Compare commits

...

164 Commits

Author SHA1 Message Date
defnotken
af607e4380 fixes to plugin.
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m26s
2025-12-19 15:36:54 -06:00
defnotken
29e6555480 Merge branch '2.0.0' into dev 2025-12-19 15:31:20 -06:00
defnotken
9402731b2b Merge branch '2.0.0' into dev 2025-12-19 15:30:44 -06:00
cake
4d0bf2d57e Updated Brio SDK 2025-12-19 22:29:37 +01:00
7f74f88302 Merge branch '2.0.0' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.0 2025-12-20 04:01:20 +09:00
cake
934cdfbcf0 updated nuget packages 2025-12-19 19:57:56 +01:00
cake
d2a68e6533 Disabled sort on payload on group submit 2025-12-19 19:49:30 +01:00
20008f904d fix send button and improve input focus 2025-12-20 03:39:28 +09:00
cake
54b50886c0 Fixed UID scaling on fontsize 2025-12-19 19:38:43 +01:00
cake
234fe5d360 Fixed font size issue on player names. 2025-12-19 19:20:41 +01:00
defnotken
05770d9a5b update workflow 2025-12-19 10:25:29 -06:00
defnotken
f43fb28257 Hello version?
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m22s
2025-12-19 10:17:35 -06:00
defnotken
465da1bdf2 pre 2.0 test
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m17s
2025-12-19 10:06:00 -06:00
defnotken
0a592c87dd Maybe?
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m20s
2025-12-19 09:57:44 -06:00
defnotken
321a9c8b55 lets try this checkout
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 1m7s
2025-12-19 09:54:54 -06:00
defnotken
4aa09ce05e Merge branch '2.0.0' into dev
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 1m4s
2025-12-19 09:43:26 -06:00
defnotken
68dc8aef2f sdk update 2025-12-19 09:42:17 -06:00
defnotken
56143c5f3d bump api + sdk 2025-12-19 09:32:01 -06:00
defnotken
fea633b6f6 Lets test build dev.
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 1m51s
2025-12-19 09:18:37 -06:00
91739536bf Merge pull request 'patch-notes-fixes' (#98) from notif-style-rework into 2.0.0
Reviewed-on: #98
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-19 15:09:10 +00:00
choco
ae9df103f3 updated bullet points to wrap to the next line when it reaches the edge of the content area. 2025-12-19 15:55:20 +01:00
choco
bade5ab6f5 Merge branch 'refs/heads/2.0.0' into notif-style-rework 2025-12-19 15:48:14 +01:00
5e22f3bff0 Merge pull request 'Update refs and workflow' (#97) from update-refs-and-workflow into 2.0.0
Reviewed-on: #97
2025-12-19 14:14:48 +00:00
defnotken
ed099f322d Update refs and workflow 2025-12-19 08:12:18 -06:00
cake
116e65b220 Updated submodule. updated sdk to 14.0.1 2025-12-19 07:22:03 +01:00
cake
ee175efe41 Fixed some warnings, bumped api 2025-12-19 00:15:42 +01:00
Minmoose
6ca491ac30 Update for Brio API v3 2025-12-18 16:03:35 -06:00
4ffc2247b2 adjust visibility flags, improve chat functionality, bump submodules 2025-12-18 20:49:38 +09:00
7b4e42c487 Merge pull request 'Fixing Sestrings' (#96) from Sestrings-fix-2 into 2.0.0
Reviewed-on: #96
2025-12-18 05:21:27 +00:00
defnotken
5e2afc8bfe Fixed Sestring font + offset 2025-12-17 23:19:18 -06:00
6d57813ef2 Merge pull request 'sestring-font-fix' (#95) from sestring-font-fix into 2.0.0
Reviewed-on: #95
2025-12-18 05:14:02 +00:00
defnotken
8b75063b9d packagejson shenanigans 2025-12-17 23:07:28 -06:00
defnotken
99b49762bb why 2025-12-17 23:06:07 -06:00
defnotken
35e35591f5 Fix sestrings 2025-12-17 23:03:49 -06:00
defnotken
e3c04e31e7 clean up 2025-12-17 20:40:24 -06:00
defnotken
f7fb609c71 clean up 2025-12-17 20:38:15 -06:00
cake
d766c2c42e Added offset, font and fontsize as needed for 14 2025-12-18 01:19:26 +01:00
1d212437f5 API14 2025-12-18 04:46:44 +09:00
choco
9d1d6783ce Merge branch '2.0.0' into notif-style-rework 2025-12-17 09:46:57 +01:00
f47df8fac2 Merge branch '2.0.0' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.0 2025-12-17 17:19:46 +09:00
ecc1e7107f bump submodule 2025-12-17 17:18:53 +09:00
1cc8339307 Merge pull request 'Update changelog.' (#94) from clean-up-and-patch-notes-2.0.0 into 2.0.0
Reviewed-on: #94
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
2025-12-16 18:22:47 +00:00
defnotken
6522b586d5 Update changelog. 2025-12-16 11:18:54 -06:00
choco
8b9e35283d reworked the animated star banner into a reusable component for reusability 2025-12-16 11:49:56 +01:00
choco
755bae1294 matching the notifications to the new styling 2025-12-16 11:35:52 +01:00
cake
a41f419076 Reduced message size and reason length of report 2025-12-16 07:02:35 +01:00
cake
dec6c4900b bumped version in project 2025-12-16 02:01:30 +01:00
cake
5dabd23d93 Fixed null exception on CID 2025-12-16 01:57:07 +01:00
cake
0dd520d926 Fixed height issue of download box 2025-12-16 01:41:02 +01:00
cake
4e4d19ad00 Removed own broadcast from list, count fixed as well 2025-12-16 00:04:57 +01:00
cake
d5c11cd22f Added tags in the shell finder, button is red when not joinable. Fixed some null errors. 2025-12-15 23:52:06 +01:00
4444a88746 watafak 2025-12-16 06:31:29 +09:00
cake
bdfcf254a8 Added copy text in tooltip of uid 2025-12-15 20:22:31 +01:00
cake
eb11ff0b4c Fix some issues in syncshell admin panel, removed flag row and added it in user name. updated banned list to remove from imgui table. 2025-12-15 20:15:54 +01:00
cake
ee1fcb5661 Fixed certain scenario that could break the event viewer 2025-12-15 19:37:22 +01:00
cake
44e91bef8f Cleaning of registry, fixed typo in object kind as it should be companion in the companion kind. 2025-12-14 04:37:33 +01:00
cake
6891424b0d Fixed that notifcations prevents click around it. 2025-12-14 00:49:39 +01:00
cake
6395b1eb52 Removal of use notifcation for downloads as its not used at all 2025-12-12 06:06:50 +01:00
0671c46e5d more tags meow 2025-12-11 16:31:44 +09:00
cake
09b78e1896 Fixed push color in main tab as well. 2025-12-11 05:41:53 +01:00
cake
1b2db4c698 Fixed pushes to imgui styles so its contained to only admin panel. 2025-12-11 05:38:35 +01:00
6cf0e3daed various 'improvements' 2025-12-11 12:59:32 +09:00
cake
2e14fc2f8f Cleaned up services context 2025-12-09 07:30:52 +01:00
cake
675918624d Redone syncshell admin ui, fixed some bugs on edit profile. 2025-12-09 05:45:19 +01:00
cake
25f0d41581 Fixed spelling 2025-12-06 05:41:14 +01:00
cake
1cb326070b Added option to show green eye in pair list. 2025-12-06 05:35:27 +01:00
cake
b444782b76 Fixed plugin, added 0 zero (15 minutes) option for pruning. 2025-12-05 13:36:12 +01:00
cake
feec5e8ff3 Pushed Imgui plate handler for lightfinder. need to redo options of it. 2025-12-05 04:48:55 +01:00
cake
cc1f381687 Merge branch '2.0.0' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.0 2025-12-05 04:44:10 +01:00
cake
69b504c42f Added the old system of WQPD back. added more options of it. 2025-12-05 04:43:30 +01:00
541d17132d performance cache + queued character data application 2025-12-05 10:49:30 +09:00
1c36db97dc show focus target on visibility hover 2025-12-03 13:59:30 +09:00
cake
c335489cee Fixed null errors 2025-12-02 18:41:10 +01:00
cake
6734021b89 GroupFullinfo be nullable 2025-12-02 18:20:59 +01:00
cake
46a8fc72cb Changed profile opening to use GroupData instead of full info, Added opening of syncshell profile from finder. 2025-12-02 18:19:15 +01:00
cake
962567fbfe Merge branch '2.0.0' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.0 2025-12-02 07:03:48 +01:00
cake
0e076f6290 Forced another sha1 2025-12-02 07:01:25 +01:00
cake
72cd5006db Force SHA1 hashing on updated hash files 2025-12-02 06:33:08 +01:00
023ca2013e biggest fix ever 2025-12-02 13:39:08 +09:00
a77261a096 mid settings improvement attempt 2025-12-02 08:44:34 +09:00
febc47442a goober fix tooltips 2025-12-01 03:12:00 +09:00
cake
481bc99dcd Merge branch '2.0.0' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.0 2025-11-30 15:55:57 +01:00
cake
9d6a0a1257 Fixed colors so it uses dalamud or lightless, added notifications system back 2025-11-30 15:55:32 +01:00
8076d63ce2 add chat message scaling 2025-11-30 21:35:17 +09:00
ba5c8b588e meow timestamp meow 2025-11-30 20:32:22 +09:00
91393bf4a1 added chat report functionality and some other random stuff 2025-11-30 19:59:37 +09:00
cake
e0e2304253 Removed usings 2025-11-30 08:10:15 +01:00
cake
a9181d2592 Removed obselete functions, changed download bars a bit. renamed files correctly 2025-11-30 08:09:58 +01:00
cake
cab13874d8 Allow moderators to use shell broadcasting, distinct shell finder to remove duplicate shells 2025-11-30 01:26:18 +01:00
cake
04cd09cbb9 Fixed seperator 2025-11-30 00:18:36 +01:00
cake
0b36c1bdc2 Changed syncshell admin user list, added filter and copy. show creation date while hoving over text. 2025-11-30 00:00:39 +01:00
cake
1e88fe0cf3 Fixed context menu items, made static function for it to be used 2025-11-29 22:42:55 +01:00
740b58afc4 Initialize migration. (#88)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #88
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Co-committed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-29 18:02:39 +01:00
cake
9e12725f89 Merge branch '2.0.0' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.0 2025-11-29 04:52:56 +01:00
cake
aa04ab05ab added downscaled as file scanning if exist. made gib to gb for calculations. changed windows detection. 2025-11-29 04:51:53 +01:00
28967d6e17 rebuild the temp collection if cached files don't persist 2025-11-29 09:17:28 +09:00
d995afcf48 work done on the ipc 2025-11-28 00:33:46 +09:00
5ab67c70d6 Redone nameplate service (#93)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #93
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-27 00:17:03 +01:00
8cc83bce79 big clean up in progress 1 2025-11-27 00:29:56 +09:00
1cdc0a90f9 basic summaries for all pair classes, create partial classes and condense models into a single file 2025-11-26 17:56:01 +09:00
defnotken
e350e8007a Fixing submodules 2025-11-25 22:22:35 -06:00
defnotken
7a9ade95c3 Remove PenumbraAPI submodule completely 2025-11-25 22:08:14 -06:00
defnotken
01607c275a Remove broken Penumbra and OtterGui submodules 2025-11-25 22:02:07 -06:00
defnotken
1e6109d1e6 Remove PenumbraAPI submodule 2025-11-25 21:59:14 -06:00
defnotken
961092ab87 Re-add Penumbra and OtterGui submodules 2025-11-25 21:58:14 -06:00
defnotken
36166f1399 Merge branch 'master' into 2.0.0 2025-11-25 19:29:22 -06:00
d057c638ab temp fix 2025-11-25 11:22:53 +09:00
28d9110cb0 Restore logic 2025-11-25 09:42:34 +09:00
ef592032b3 init 2 2025-11-25 07:14:59 +09:00
defnotken
9c794137c1 changelog added 2025-11-11 12:43:09 -06:00
defnotken
4a256f7807 update penumbra api 2025-11-11 12:03:18 -06:00
defnotken
25756561b9 updating lightlessapi 2025-11-11 12:02:37 -06:00
defnotken
e8c546c128 update lightless API pt 1 2025-11-11 11:54:21 -06:00
d4ba1cf437 Merge pull request 'Turned off btrfs compression for now as not completed' (#85) from linux-improvements into 1.12.4
Reviewed-on: #85
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-11 18:41:05 +01:00
e0d1f98c70 Merge branch '1.12.4' into linux-improvements 2025-11-11 17:12:51 +01:00
cake
1862689b1b Changed some commands in file getting, redone compression check commands and turned off btrfs compactor for 1.12.4 2025-11-11 17:09:50 +01:00
325dc8947d Merge pull request 'download notification stuck fix, more x and y offset positions' (#84) from notification-reworks into 1.12.4
Reviewed-on: #84
2025-11-10 22:00:58 +01:00
choco
95e7f2daa7 download notification progress and download bar, chat only option for notifications (idk why you would bother even enabling the lightless nofis then) 2025-11-10 21:59:20 +01:00
choco
41a303dc91 auto dismiss notifs if no updates 2025-11-10 21:29:55 +01:00
choco
25b03aea15 download dismiss message if downloads are complete 2025-11-10 21:22:28 +01:00
choco
b6564156f0 Merge remote-tracking branch 'origin/notification-reworks' into notification-reworks 2025-11-10 21:05:48 +01:00
choco
f89ce900c7 more offset changes accepting minus for notifications till -2500 2025-11-10 21:05:39 +01:00
299abc21ee Merge branch '1.12.4' into notification-reworks 2025-11-10 21:00:01 +01:00
choco
c02a8ed2ee notification clickthrougable, update notes centered in the middle of the screen unmovable 2025-11-10 20:57:43 +01:00
choco
8692e877cf download notification stuck fix, more x and y offset positions 2025-11-10 10:59:42 +01:00
cake
7de72471bb Refactored 2025-11-10 06:25:35 +01:00
cake
d7182e9d57 Hopefully fixes all issues with linux based path finding 2025-11-10 03:52:37 +01:00
2b02de731a Merge pull request 'Some changes on the file compression for linux and windows regards threading.' (#83) from linux-improvements into 1.12.4
Reviewed-on: #83
2025-11-09 06:11:34 +01:00
cake
e9082ab8d0 forget semicolomn.. 2025-11-07 06:07:34 +01:00
2a06a11cbc Merge branch '1.12.4' into linux-improvements 2025-11-07 05:29:34 +01:00
cake
557121a9b7 Added batching for the File Frag command for the iscompressed calls. 2025-11-07 05:27:58 +01:00
b22140a8d4 Merge pull request 'Added more checks in nameplate handler' (#82) from more-checks-nameplate into 1.12.4
Reviewed-on: #82
2025-11-06 18:14:45 +01:00
4db468a480 Merge pull request 'Btrfs Compactor work, defaulted linux on websockets.' (#77) from linux-improvements into 1.12.4
Reviewed-on: #77
2025-11-06 18:13:52 +01:00
8d8f8d20cd Merge pull request 'Syncshell grouped folders pausing function' (#76) from grouped-folder-pause into 1.12.4
Reviewed-on: #76
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Reviewed-by: choco <choco@noreply.git.lightless-sync.org>
2025-11-06 18:13:41 +01:00
3722b79615 Merge pull request 'Fixed amount of lightfinder users, added user names in the server info list for lightfinder. Added /lightless command' (#75) from lightfinder-dtr-changes into 1.12.4
Reviewed-on: #75
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Reviewed-by: choco <choco@noreply.git.lightless-sync.org>
2025-11-06 18:13:33 +01:00
cf97e7e800 Merge pull request 'pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on' (#74) from direct-pair-button into 1.12.4
Reviewed-on: #74
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-11-06 18:13:22 +01:00
azyges
1d672d2552 improve checks and add logging 2025-11-06 23:40:58 +09:00
cake
35636f27f6 Cleanup 2025-11-03 21:47:15 +01:00
cake
1b686e45dc Added more null checks and redid active broadcasting cache. 2025-11-03 20:19:02 +01:00
cake
b6aa2bebb1 Added more checks. 2025-11-03 19:59:12 +01:00
cake
cfc9f60176 Added safe checks on enqueue. 2025-11-03 19:27:47 +01:00
cake
d4dca455ba Clean-up, added extra checks on linux in cache monitor, documentation added 2025-11-03 18:54:35 +01:00
76c2777f00 Merge pull request 'Changed warning text for Brio #78' (#81) from character-hub-brio-change into 1.12.4
Reviewed-on: #81
2025-11-01 00:12:49 +01:00
cake
0af2a6134b Changed warning text for Brio 2025-11-01 00:09:54 +01:00
cake
6e3c60f627 Changes in file compression for windows, redone linux side because wine issues. 2025-10-31 23:47:41 +01:00
cake
5feb74c1c0 Added another wine check in parralel with dalamud 2025-10-30 03:46:55 +01:00
cake
c1770528f3 Added wine checks, path fixing on wine -> linux 2025-10-30 03:34:56 +01:00
cake
bf139c128b Added fail safes in compact of WOF incase 2025-10-30 03:11:38 +01:00
cake
b3cc41382f Refactored a bit, added comments on the file systems. 2025-10-30 03:05:53 +01:00
cake
7c4d0fd5e9 Added comments, clean-up 2025-10-29 22:54:50 +01:00
cake
c37e3badf1 Check if wine is used. 2025-10-29 06:09:44 +01:00
f4478f653a Merge branch '1.12.4' into linux-improvements 2025-10-29 04:53:36 +01:00
cake
3f85852618 Added string comparisons 2025-10-29 04:52:17 +01:00
cake
3e626c5e47 Cleanup some code, removed ntfs usage on cache monitor 2025-10-29 04:49:46 +01:00
cake
9a846a37d4 Redone handling of windows compactor handling. 2025-10-29 04:43:18 +01:00
cake
177534d78b Implemented compactor to work on BTRFS, redid cache a bit for better function on linux. Removed error for websockets, it will be forced on wine again. 2025-10-29 04:37:24 +01:00
cake
de75b90703 Added spacing on function 2025-10-28 18:23:01 +01:00
cake
c16891021c Added pause button for all syncshells and grouped syncshells. 2025-10-28 18:20:57 +01:00
d19d1c0a3a Merge branch '1.12.4' into lightfinder-dtr-changes 2025-10-28 15:47:42 +01:00
choco
cabc4ec0fe removed lightfinder on check 2025-10-28 00:57:49 +01:00
cake
8bccdc5ef1 Added lightless command. 2025-10-27 22:26:03 +01:00
cake
ce5f8a43a2 Added list of users names in the dtr entry whenever lightfinder is active 2025-10-27 16:16:40 +01:00
choco
437731749f pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on 2025-10-26 17:34:17 +01:00
defnotken
55e78e088a Initialize .4 2025-10-24 09:45:24 -05:00
189 changed files with 31485 additions and 8807 deletions

View File

@@ -6,7 +6,9 @@ on:
env:
PLUGIN_NAME: LightlessSync
DOTNET_VERSION: 9.x
DOTNET_VERSION: |
10.x.x
9.x.x
jobs:
tag-and-release:
@@ -16,15 +18,17 @@ jobs:
steps:
- name: Checkout Lightless
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
submodules: true
submodules: recursive
- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4
- name: Setup .NET 10 SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 9.x
dotnet-version: |
10.x.x
9.x.x
- name: Download Dalamud
run: |

18
.gitmodules vendored
View File

@@ -1,6 +1,18 @@
[submodule "LightlessAPI"]
path = LightlessAPI
url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git
[submodule "PenumbraAPI"]
path = PenumbraAPI
url = https://github.com/Ottermandias/Penumbra.Api.git
[submodule "Penumbra.GameData"]
path = Penumbra.GameData
url = https://github.com/Ottermandias/Penumbra.GameData
[submodule "Penumbra.Api"]
path = Penumbra.Api
url = https://github.com/Ottermandias/Penumbra.Api
[submodule "Penumbra.String"]
path = Penumbra.String
url = https://github.com/Ottermandias/Penumbra.String
[submodule "OtterGui"]
path = OtterGui
url = https://github.com/Ottermandias/OtterGui
[submodule "ffxiv_pictomancy"]
path = ffxiv_pictomancy
url = https://github.com/sourpuh/ffxiv_pictomancy

View File

@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32328.378
# Visual Studio Version 18
VisualStudioVersion = 18.0.11217.181
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}"
ProjectSection(SolutionItems) = preProject
@@ -12,40 +12,110 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync", "LightlessS
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "PenumbraAPI\Penumbra.Api.csproj", "{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{22AE06C8-5139-45D2-A5F9-E76C019050D9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{3C016B19-2A2C-4068-9378-B9B805605EFB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{C77A2833-3FE4-405B-811D-439B1FF859D9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.ActiveCfg = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.Build.0 = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x86.ActiveCfg = Debug|Any CPU
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x86.Build.0 = Debug|Any CPU
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.Build.0 = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.Build.0 = Release|x64
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Release|Any CPU
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.ActiveCfg = Release|Any CPU
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.Build.0 = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.Build.0 = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x86.Build.0 = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.Build.0 = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.Build.0 = Release|Any CPU
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.Build.0 = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.Build.0 = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.ActiveCfg = Release|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.Build.0 = Release|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.ActiveCfg = Release|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.Build.0 = Release|x64
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.Build.0 = Release|Any CPU
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.ActiveCfg = Debug|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.Build.0 = Debug|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.ActiveCfg = Debug|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.Build.0 = Debug|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.ActiveCfg = Debug|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.Build.0 = Debug|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|Any CPU.ActiveCfg = Release|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|Any CPU.Build.0 = Release|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x64.ActiveCfg = Release|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x64.Build.0 = Release|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x86.ActiveCfg = Release|x64
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x86.Build.0 = Release|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|Any CPU.ActiveCfg = Debug|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|Any CPU.Build.0 = Debug|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x64.ActiveCfg = Debug|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x64.Build.0 = Debug|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x86.ActiveCfg = Debug|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x86.Build.0 = Debug|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|Any CPU.ActiveCfg = Release|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|Any CPU.Build.0 = Release|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x64.ActiveCfg = Release|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x64.Build.0 = Release|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x86.ActiveCfg = Release|x64
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x86.Build.0 = Release|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|Any CPU.ActiveCfg = Debug|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|Any CPU.Build.0 = Debug|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x64.ActiveCfg = Debug|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x64.Build.0 = Debug|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x86.ActiveCfg = Debug|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x86.Build.0 = Debug|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|Any CPU.ActiveCfg = Release|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|Any CPU.Build.0 = Release|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x64.ActiveCfg = Release|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x64.Build.0 = Release|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x86.ActiveCfg = Release|x64
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x86.Build.0 = Release|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|Any CPU.ActiveCfg = Debug|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|Any CPU.Build.0 = Debug|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x64.ActiveCfg = Debug|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x64.Build.0 = Debug|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x86.ActiveCfg = Debug|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x86.Build.0 = Debug|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|Any CPU.ActiveCfg = Release|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|Any CPU.Build.0 = Release|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.ActiveCfg = Release|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.Build.0 = Release|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.ActiveCfg = Release|x64
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.Build.0 = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|Any CPU.ActiveCfg = Debug|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|Any CPU.Build.0 = Debug|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x64.ActiveCfg = Debug|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x64.Build.0 = Debug|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x86.ActiveCfg = Debug|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x86.Build.0 = Debug|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|Any CPU.ActiveCfg = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|Any CPU.Build.0 = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.ActiveCfg = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.Build.0 = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -1,11 +1,80 @@
tagline: "Lightless Sync v1.12.4"
subline: "Bugfixes and various improvements across Lightless"
tagline: "Lightless Sync v2.0.0"
subline: "LIGHTLESS IS EVOLVING!!"
changelog:
- name: "v2.0.0"
tagline: "Thank you for 4 months!"
date: "December 2025"
# be sure to set this every new version
isCurrent: true
versions:
- number: "Lightless Chat"
icon: ""
items:
- "Chat has been added to the top of the main UI. It will work in certain Zones or in Syncshells!"
- "You will only be able to use the chat feature after enabling it and accepting the rules. If you're not interested, don't use it!"
- "Breaking the rules may result in a mute or ban from chat. Serious offenses may result in a ban from the Lightless service altogether."
- "You can right click the offender in the chat and report them within the chat, reports will be reviewed asap."
- "Syncshells can enforce their own chat rules and moderate their own chat. This however does not apply to serious offenses."
- "Your name in chat will not be shown unless you are paired with the person OR you are in the same syncshell. Otherwise, you will be anonymous."
- "Refer to #release-notes in the Discord for more information. Feel free to ask questions in the Discord as well."
- number: "Changes to LightFinder"
icon: ""
items:
- "We have recieve quite a bit of reports of users crashing due to how Nameplates are handled across various plugins. As a result, we have moved the LightFinder icon and text to Imgui."
- "This should resolve the crashing issues, however, it may not look as nice as before. We are looking into ways to improve the Imgui experience in the future."
- "We will always prioritize stability and safety over visuals."
- "Refer to #release-notes in the Discord for an example of the error."
- number: "User Profiles, ShellFinder, Syncshells, Syncshell Profiles"
icon: ""
items:
- "Both User Profiles and Syncshell Profiles have been revamped for 2.0.0."
- "We have added profile tags to both Users and Syncshells that will show when a profile is being viewed"
- "Syncshell Admin Panel has been reworked to make it a friendlier experience"
- "Syncshell Moderators can now also broadcast on ShellFinder"
- "ShellFinder has been revamped to be more visually friends and also show more information (Tags) about the Syncshell"
- "Syncshells has an auto-prune feature now that will remove inactive members after a set amount of time, options available are 1, 3, 7, and 14 days that runs in 1 hour intervals"
- "IF YOUR SYNCSHELL IS NSFW, PLEASE MARK IT AS NSFW!"
- "Refer to #release-notes in the Discord for pretty pictures or try it yourself!."
- number: "Texture Optimization"
icon: ""
items:
- "In 2.0.0, we've added the option for Texture Optimization to improve the performance of scenarios such as overwhelmingly big "
- "NOTE: ALL OF THESE ARE OPTIONAL AND DISABLED BY DEFAULT"
- "Within Texture Optimization, you will be able to safely downscale all textures of new downloads around you."
- "This downscale DOES NOT APPLY to DIRECT PAIRS or those who've updated their preferred settings to not be downscaled"
- "The first time this is enabled, you may experience some lag or frame drops, but in the long run, it will help performance."
- "This can be found in Lightless Settings > Performance > Texture Optimization"
- "Like a broken record, please refer to #release-notes in the Discord for more information."
- number: "Character Analysis - The big scary UI no one knew about"
icon: ""
items:
- "We have made the Character Analysis UI more user friendly. This includes a revamp of the look and functionality"
- "You can now see more information about your character and how it affects performance"
- "It will show you the Textures tab by default with an option for \"Other file types\""
- "You can now choose if you want to BC7/BC5/BC4/BC3/BC1 compress a certain texture."
- "The UI will give you a recommendation on what BC compression to use based on the file."
- "Shows a small preview of what the texture looks like with some general info about it."
- "Shows you how much VRAM you would take up."
- "This can be found in Lightless Settings > Performance > Character Analysis"
- number: "Performance"
icon: ""
items:
- "Moved to the internal object table to have improved overall plugin performance."
- "Compactor is now running on a multi-threaded level instead of single-threaded; This should increase the speed of compacting files."
- "Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many Syncshells in your list."
- "Pairing system has been revamped to make pausing and unpausing faster, and loading people should be faster as well."
- number: "Miscellaneous Changes and Bugfixes"
icon: ""
items:
- "UI has been updated to look more modern"
- "We have started on file compression for Linux with the option for BTRFS or ZFS but it's not very great yet and will release later."
- "Nameplate colours now use sigs to client structs as an alternative to the Nameplate Handler, also preventing crashes on that from our end."
- "Notifications now work with the \"Enable multi-monitor windows\" settings of Dalamud."
- "Fixed a bug where nothing above the notifications was clickable in certain cases."
- "Added a check that prevents small messages from going below 0 resulting in an ArgumentOutOfRangeException."
- name: "v1.12.4"
tagline: "Preparation for future features"
date: "November 11th 2025"
# be sure to set this every new version
isCurrent: true
versions:
- number: "Syncshells"
icon: ""

View File

@@ -72,7 +72,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
while (_dalamudUtil.IsOnFrameworkThread && !token.IsCancellationRequested)
{
await Task.Delay(1).ConfigureAwait(false);
await Task.Delay(1, token).ConfigureAwait(false);
}
RecalculateFileCacheSize(token);
@@ -101,8 +101,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
}
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
public void StopMonitoring()
{
@@ -128,7 +128,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
}
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
if (fsType == FileSystemHelper.FilesystemType.NTFS)
if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtil.IsWine)
{
StorageisNTFS = true;
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
@@ -259,6 +259,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private CancellationTokenSource _penumbraFswCts = new();
private CancellationTokenSource _lightlessFswCts = new();
public FileSystemWatcher? PenumbraWatcher { get; private set; }
public FileSystemWatcher? LightlessWatcher { get; private set; }
@@ -469,6 +470,52 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
FileCacheSize = totalSize;
if (Directory.Exists(_configService.Current.CacheFolder + "/downscaled"))
{
var filesDownscaled = Directory.EnumerateFiles(_configService.Current.CacheFolder + "/downscaled").Select(f => new FileInfo(f)).OrderBy(f => f.LastAccessTime).ToList();
long totalSizeDownscaled = 0;
foreach (var f in filesDownscaled)
{
token.ThrowIfCancellationRequested();
try
{
long size = 0;
if (!isWine)
{
try
{
size = _fileCompactor.GetFileSizeOnDisk(f);
}
catch (Exception ex)
{
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName);
size = f.Length;
}
}
else
{
size = f.Length;
}
totalSizeDownscaled += size;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
}
}
FileCacheSize = (totalSize + totalSizeDownscaled);
}
else
{
FileCacheSize = totalSize;
}
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes)
return;
@@ -510,12 +557,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_scanCancellationTokenSource?.Cancel();
// Disposing of file system watchers
PenumbraWatcher?.Dispose();
LightlessWatcher?.Dispose();
// Disposing of cancellation token sources
_scanCancellationTokenSource?.CancelDispose();
_scanCancellationTokenSource?.Dispose();
_penumbraFswCts?.CancelDispose();
_penumbraFswCts?.Dispose();
_lightlessFswCts?.CancelDispose();
_lightlessFswCts?.Dispose();
_periodicCalculationTokenSource?.CancelDispose();
_periodicCalculationTokenSource?.Dispose();
}
private void FullFileScan(CancellationToken ct)
@@ -593,7 +647,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
List<FileCacheEntity> entitiesToRemove = [];
List<FileCacheEntity> entitiesToUpdate = [];
object sync = new();
Lock sync = new();
Thread[] workerThreads = new Thread[threadCount];
ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches());

View File

@@ -18,6 +18,7 @@ public sealed class FileCacheManager : IHostedService
public const string PenumbraPrefix = "{penumbra}";
private const int FileCacheVersion = 1;
private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:";
private readonly SemaphoreSlim _fileWriteSemaphore = new(1, 1);
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator;
private readonly string _csvPath;
@@ -41,11 +42,8 @@ public sealed class FileCacheManager : IHostedService
private string CsvBakPath => _csvPath + ".bak";
private static string NormalizeSeparators(string path)
{
return path.Replace("/", "\\", StringComparison.Ordinal)
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal);
}
private static string NormalizePrefixedPathKey(string prefixedPath)
{
@@ -134,13 +132,9 @@ public sealed class FileCacheManager : IHostedService
chosenLength = penumbraMatch;
}
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch))
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch) && cacheMatch > chosenLength)
{
if (cacheMatch > chosenLength)
{
chosenPrefixed = cachePrefixed;
chosenLength = cacheMatch;
}
chosenPrefixed = cachePrefixed;
}
return NormalizePrefixedPathKey(chosenPrefixed ?? normalized);
@@ -176,27 +170,53 @@ public sealed class FileCacheManager : IHostedService
return CreateFileCacheEntity(fi, prefixedPath);
}
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).ToList();
public List<FileCacheEntity> GetAllFileCaches() => [.. _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null))];
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
{
List<FileCacheEntity> output = [];
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
var output = new List<FileCacheEntity>();
if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities))
return output;
foreach (var fileCache in fileCacheEntities.Values
.Where(c => !ignoreCacheEntries || !c.IsCacheEntry))
{
foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList())
if (!validate)
{
if (!validate)
{
output.Add(fileCache);
}
else
{
var validated = GetValidatedFileCache(fileCache);
if (validated != null)
{
output.Add(validated);
}
}
output.Add(fileCache);
continue;
}
var validated = GetValidatedFileCache(fileCache);
if (validated != null)
output.Add(validated);
}
return output;
}
public async Task<List<FileCacheEntity>> GetAllFileCachesByHashAsync(string hash, bool ignoreCacheEntries = false, bool validate = true,CancellationToken token = default)
{
var output = new List<FileCacheEntity>();
if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities))
return output;
foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry))
{
token.ThrowIfCancellationRequested();
if (!validate)
{
output.Add(fileCache);
}
else
{
var validated = await GetValidatedFileCacheAsync(fileCache, token).ConfigureAwait(false);
if (validated != null)
output.Add(validated);
}
}
@@ -238,10 +258,11 @@ public sealed class FileCacheManager : IHostedService
return;
}
var algo = Crypto.DetectAlgo(fileCache.Hash);
string computedHash;
try
{
computedHash = await Crypto.GetFileHashAsync(fileCache.ResolvedFilepath, token).ConfigureAwait(false);
computedHash = await Crypto.ComputeFileHashAsync(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1, token).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -253,8 +274,8 @@ public sealed class FileCacheManager : IHostedService
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
{
_logger.LogInformation(
"Hash mismatch: {file} (got {computedHash}, expected {expected})",
fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
"Hash mismatch: {file} (got {computedHash}, expected {expected} : hash {hash})",
fileCache.ResolvedFilepath, computedHash, fileCache.Hash, algo);
brokenEntities.Add(fileCache);
}
@@ -434,7 +455,7 @@ public sealed class FileCacheManager : IHostedService
var fi = new FileInfo(fileCache.ResolvedFilepath);
fileCache.Size = fi.Length;
fileCache.CompressedSize = null;
fileCache.Hash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1);
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
}
RemoveHashedFile(oldHash, prefixedPath);
@@ -485,6 +506,44 @@ public sealed class FileCacheManager : IHostedService
}
}
public async Task WriteOutFullCsvAsync(CancellationToken cancellationToken = default)
{
await _fileWriteSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var sb = new StringBuilder();
sb.AppendLine(BuildVersionHeader());
foreach (var entry in _fileCaches.Values
.SelectMany(k => k.Values)
.OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
{
sb.AppendLine(entry.CsvEntry);
}
if (File.Exists(_csvPath))
{
File.Copy(_csvPath, CsvBakPath, overwrite: true);
}
try
{
await File.WriteAllTextAsync(_csvPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
File.Delete(CsvBakPath);
}
catch
{
await File.WriteAllTextAsync(CsvBakPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
}
finally
{
_fileWriteSemaphore.Release();
}
}
private void EnsureCsvHeaderLocked()
{
if (!File.Exists(_csvPath))
@@ -577,7 +636,7 @@ public sealed class FileCacheManager : IHostedService
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null)
{
hash ??= Crypto.GetFileHash(fileInfo.FullName);
hash ??= Crypto.ComputeFileHash(fileInfo.FullName, Crypto.HashAlgo.Sha1);
var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length);
entity = ReplacePathPrefixes(entity);
AddHashedFile(entity);
@@ -585,13 +644,13 @@ public sealed class FileCacheManager : IHostedService
{
if (!File.Exists(_csvPath))
{
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
File.WriteAllLines(_csvPath, [BuildVersionHeader(), entity.CsvEntry]);
_csvHeaderEnsured = true;
}
else
{
EnsureCsvHeaderLockedCached();
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
File.AppendAllLines(_csvPath, [entity.CsvEntry]);
}
}
var result = GetFileCacheByPath(fileInfo.FullName);
@@ -602,11 +661,17 @@ public sealed class FileCacheManager : IHostedService
private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache)
{
var resultingFileCache = ReplacePathPrefixes(fileCache);
//_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath);
resultingFileCache = Validate(resultingFileCache);
return resultingFileCache;
}
private async Task<FileCacheEntity?> GetValidatedFileCacheAsync(FileCacheEntity fileCache, CancellationToken token = default)
{
var resultingFileCache = ReplacePathPrefixes(fileCache);
resultingFileCache = await ValidateAsync(resultingFileCache, token).ConfigureAwait(false);
return resultingFileCache;
}
private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache)
{
if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
@@ -629,6 +694,7 @@ public sealed class FileCacheManager : IHostedService
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
return null;
}
var file = new FileInfo(fileCache.ResolvedFilepath);
if (!file.Exists)
{
@@ -636,7 +702,8 @@ public sealed class FileCacheManager : IHostedService
return null;
}
if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
var lastWriteTicks = file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
if (!string.Equals(lastWriteTicks, fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
{
UpdateHashedFile(fileCache);
}
@@ -644,7 +711,34 @@ public sealed class FileCacheManager : IHostedService
return fileCache;
}
public Task StartAsync(CancellationToken cancellationToken)
private async Task<FileCacheEntity?> ValidateAsync(FileCacheEntity fileCache, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(fileCache.ResolvedFilepath))
{
_logger.LogWarning("FileCacheEntity has empty ResolvedFilepath for hash {hash}, prefixed path {prefixed}", fileCache.Hash, fileCache.PrefixedFilePath);
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
return null;
}
return await Task.Run(() =>
{
var file = new FileInfo(fileCache.ResolvedFilepath);
if (!file.Exists)
{
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
return null;
}
if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
{
UpdateHashedFile(fileCache);
}
return fileCache;
}, token).ConfigureAwait(false);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting FileCacheManager");
@@ -695,14 +789,14 @@ public sealed class FileCacheManager : IHostedService
try
{
_logger.LogInformation("Attempting to read {csvPath}", _csvPath);
entries = File.ReadAllLines(_csvPath);
entries = await File.ReadAllLinesAsync(_csvPath, cancellationToken).ConfigureAwait(false);
success = true;
}
catch (Exception ex)
{
attempts++;
_logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath);
Task.Delay(100, cancellationToken);
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
@@ -729,7 +823,7 @@ public sealed class FileCacheManager : IHostedService
BackupUnsupportedCache("invalid-version");
parseEntries = false;
rewriteRequired = true;
entries = Array.Empty<string>();
entries = [];
}
else if (parsedVersion != FileCacheVersion)
{
@@ -737,7 +831,7 @@ public sealed class FileCacheManager : IHostedService
BackupUnsupportedCache($"v{parsedVersion}");
parseEntries = false;
rewriteRequired = true;
entries = Array.Empty<string>();
entries = [];
}
else
{
@@ -817,18 +911,18 @@ public sealed class FileCacheManager : IHostedService
if (rewriteRequired)
{
WriteOutFullCsv();
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
}
}
_logger.LogInformation("Started FileCacheManager");
return Task.CompletedTask;
_lightlessMediator.Publish(new FileCacheInitializedMessage());
await Task.CompletedTask.ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken)
public async Task StopAsync(CancellationToken cancellationToken)
{
WriteOutFullCsv();
return Task.CompletedTask;
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
await Task.CompletedTask.ConfigureAwait(false);
}
}

View File

@@ -4,6 +4,7 @@ using LightlessSync.Services.Compactor;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Channels;
@@ -11,7 +12,7 @@ using static LightlessSync.Utils.FileSystemHelper;
namespace LightlessSync.FileCache;
public sealed class FileCompactor : IDisposable
public sealed partial class FileCompactor : IDisposable
{
public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U;
public const ulong WOF_PROVIDER_FILE = 2UL;
@@ -29,23 +30,26 @@ public sealed class FileCompactor : IDisposable
private readonly SemaphoreSlim _globalGate;
//Limit btrfs gate on half of threads given to compactor.
private static readonly SemaphoreSlim _btrfsGate = new(4, 4);
private readonly SemaphoreSlim _btrfsGate;
private readonly BatchFilefragService _fragBatch;
private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo = new()
private readonly bool _isWindows;
private readonly int _workerCount;
private readonly WofFileCompressionInfoV1 _efInfo = new()
{
Algorithm = (int)CompressionAlgorithm.XPRESS8K,
Flags = 0
};
[StructLayout(LayoutKind.Sequential, Pack = 1)]
private struct WOF_FILE_COMPRESSION_INFO_V1
private struct WofFileCompressionInfoV1
{
public int Algorithm;
public ulong Flags;
}
private enum CompressionAlgorithm
private enum CompressionAlgorithm
{
NO_COMPRESSION = -2,
LZNT1 = -1,
@@ -61,6 +65,7 @@ public sealed class FileCompactor : IDisposable
_logger = logger;
_lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService;
_isWindows = OperatingSystem.IsWindows();
_compactionQueue = Channel.CreateUnbounded<string>(new UnboundedChannelOptions
{
@@ -68,29 +73,36 @@ public sealed class FileCompactor : IDisposable
SingleWriter = false
});
//Amount of threads given for the compactor
int workers = Math.Clamp(Math.Min(Environment.ProcessorCount / 2, 4), 1, 8);
//Setup gates for the threads and setup worker count
_globalGate = new SemaphoreSlim(workers, workers);
int workerCount = Math.Max(workers * 2, workers);
_btrfsGate = new SemaphoreSlim(workers / 2, workers / 2);
_workerCount = Math.Max(workers * 2, workers);
for (int i = 0; i < workerCount; i++)
//Setup workers on the queue
for (int i = 0; i < _workerCount; i++)
{
int workerId = i;
_workers.Add(Task.Factory.StartNew(
() => ProcessQueueWorkerAsync(_compactionCts.Token),
() => ProcessQueueWorkerAsync(workerId, _compactionCts.Token),
_compactionCts.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default).Unwrap());
}
//Uses an batching service for the filefrag command on Linux
_fragBatch = new BatchFilefragService(
useShell: _dalamudUtilService.IsWine,
log: _logger,
batchSize: 64,
flushMs: 25,
flushMs: 25,
runDirect: RunProcessDirect,
runShell: RunProcessShell
);
_logger.LogInformation("FileCompactor started with {workers} workers", workerCount);
_logger.LogInformation("FileCompactor started with {workers} workers", _workerCount);
}
public bool MassCompactRunning { get; private set; }
@@ -100,37 +112,91 @@ public sealed class FileCompactor : IDisposable
/// Compact the storage of the Cache Folder
/// </summary>
/// <param name="compress">Used to check if files needs to be compressed</param>
public void CompactStorage(bool compress)
public void CompactStorage(bool compress, int? maxDegree = null)
{
MassCompactRunning = true;
try
{
var allFiles = Directory.EnumerateFiles(_lightlessConfigService.Current.CacheFolder).ToList();
int total = allFiles.Count;
int current = 0;
foreach (var file in allFiles)
var folder = _lightlessConfigService.Current.CacheFolder;
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{
current++;
Progress = $"{current}/{total}";
if (_logger.IsEnabled(LogLevel.Warning))
_logger.LogWarning("Filecompacator couldnt find your Cache folder: {folder}", folder);
Progress = "0/0";
return;
}
var files = Directory.EnumerateFiles(folder).ToArray();
var total = files.Length;
Progress = $"0/{total}";
if (total == 0) return;
var degree = maxDegree ?? Math.Clamp(Environment.ProcessorCount / 2, 1, 8);
var done = 0;
int workerCounter = -1;
var po = new ParallelOptions
{
MaxDegreeOfParallelism = degree,
CancellationToken = _compactionCts.Token
};
Parallel.ForEach(files, po, localInit: () => Interlocked.Increment(ref workerCounter), body: (file, state, workerId) =>
{
_globalGate.WaitAsync(po.CancellationToken).GetAwaiter().GetResult();
if (!_pendingCompactions.TryAdd(file, 0))
return -1;
try
{
// Compress or decompress files
if (compress)
CompactFile(file);
else
DecompressFile(file);
try
{
if (compress)
{
if (_lightlessConfigService.Current.UseCompactor)
CompactFile(file, workerId);
}
else
{
DecompressFile(file, workerId);
}
}
catch (IOException ioEx)
{
_logger.LogDebug(ioEx, "[W{worker}] File being read/written, skipping file: {file}", workerId, file);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[W{worker}] Error processing file: {file}", workerId, file);
}
finally
{
var n = Interlocked.Increment(ref done);
Progress = $"{n}/{total}";
}
}
catch (IOException ioEx)
finally
{
_logger.LogDebug(ioEx, "File {file} locked or busy, skipping", file);
_pendingCompactions.TryRemove(file, out _);
_globalGate.Release();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting/decompressing file {file}", file);
}
}
return workerId;
},
localFinally: _ =>
{
//Ignore local finally for now
});
}
catch (OperationCanceledException ex)
{
_logger.LogDebug(ex, "Mass compaction call got cancelled, shutting off compactor.");
}
finally
{
@@ -139,6 +205,7 @@ public sealed class FileCompactor : IDisposable
}
}
/// <summary>
/// Write all bytes into a directory async
/// </summary>
@@ -197,24 +264,20 @@ public sealed class FileCompactor : IDisposable
{
try
{
bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var (_, linuxPath) = ResolvePathsForBtrfs(fileInfo.FullName);
var (ok, output, err, code) =
isWindowsProc
_isWindows
? RunProcessShell($"stat -c='%b' {QuoteSingle(linuxPath)}", workingDir: null, 10000)
: RunProcessDirect("stat", ["-c='%b'", linuxPath], workingDir: null, 10000);
if (ok && long.TryParse(output.Trim(), out long blocks))
return (false, blocks * 512L); // st_blocks are always 512B units
_logger.LogDebug("Btrfs size probe failed for {linux} (stat {code}, err {err}). Falling back to Length.", linuxPath, code, err);
return (false, fileInfo.Length);
return (flowControl: false, value: fileInfo.Length);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed Btrfs size probe for {file}, using Length", fileInfo.FullName);
return (false, fileInfo.Length);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug(ex, "Failed Btrfs size probe for {file}, using Length", fileInfo.FullName);
return (flowControl: true, value: fileInfo.Length);
}
}
@@ -228,34 +291,48 @@ public sealed class FileCompactor : IDisposable
try
{
var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine);
var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize);
var size = (long)hosize << 32 | losize;
return (flowControl: false, value: ((size + blockSize - 1) / blockSize) * blockSize);
if (blockSize <= 0)
throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}");
uint lo = GetCompressedFileSizeW(fileInfo.FullName, out uint hi);
if (lo == 0xFFFFFFFF)
{
int err = Marshal.GetLastWin32Error();
if (err != 0)
throw new Win32Exception(err);
}
long size = ((long)hi << 32) | lo;
long rounded = ((size + blockSize - 1) / blockSize) * blockSize;
return (flowControl: false, value: rounded);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName);
return (flowControl: true, value: default);
}
return (flowControl: true, value: default);
}
/// <summary>
/// Compressing the given path with BTRFS or NTFS file system.
/// </summary>
/// <param name="path">Path of the decompressed/normal file</param>
private void CompactFile(string filePath)
/// <param name="filePath">Path of the decompressed/normal file</param>
/// <param name="workerId">Worker/Process Id</param>
private void CompactFile(string filePath, int workerId)
{
var fi = new FileInfo(filePath);
if (!fi.Exists)
{
_logger.LogTrace("Skip compaction: missing {file}", filePath);
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("[W{worker}] Skip compaction: missing {file}", workerId, filePath);
return;
}
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine);
var oldSize = fi.Length;
int blockSize = GetBlockSizeForPath(fi.FullName, _logger, _dalamudUtilService.IsWine);
int blockSize = (int)(GetFileSizeOnDisk(fi) / 512);
// We skipping small files (128KiB) as they slow down the system a lot for BTRFS. as BTRFS has a different blocksize it requires an different calculation.
long minSizeBytes = fsType == FilesystemType.Btrfs
@@ -264,7 +341,8 @@ public sealed class FileCompactor : IDisposable
if (oldSize < minSizeBytes)
{
_logger.LogTrace("Skip compaction: {file} ({size} B) < threshold ({th} B)", filePath, oldSize, minSizeBytes);
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("[W{worker}] Skip compaction: {file} ({size} B) < threshold ({th} B)", workerId, filePath, oldSize, minSizeBytes);
return;
}
@@ -272,20 +350,20 @@ public sealed class FileCompactor : IDisposable
{
if (!IsWOFCompactedFile(filePath))
{
_logger.LogDebug("NTFS compaction XPRESS8K: {file}", filePath);
if (WOFCompressFile(filePath))
{
var newSize = GetFileSizeOnDisk(fi);
_logger.LogDebug("NTFS compressed {file} {old} -> {new}", filePath, oldSize, newSize);
_logger.LogDebug("[W{worker}] NTFS compressed XPRESS8K {file} {old} -> {new}", workerId, filePath, oldSize, newSize);
}
else
{
_logger.LogWarning("NTFS compression failed or unavailable for {file}", filePath);
_logger.LogWarning("[W{worker}] NTFS compression failed or unavailable for {file}", workerId, filePath);
}
}
else
{
_logger.LogTrace("Already NTFS-compressed: {file}", filePath);
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("[W{worker}] Already NTFS-compressed with XPRESS8K: {file}", workerId, filePath);
}
return;
}
@@ -294,41 +372,43 @@ public sealed class FileCompactor : IDisposable
{
if (!IsBtrfsCompressedFile(filePath))
{
_logger.LogDebug("Btrfs compression zstd: {file}", filePath);
if (BtrfsCompressFile(filePath))
{
var newSize = GetFileSizeOnDisk(fi);
_logger.LogDebug("Btrfs compressed {file} {old} -> {new}", filePath, oldSize, newSize);
_logger.LogDebug("[W{worker}] Btrfs compressed clzo {file} {old} -> {new}", workerId, filePath, oldSize, newSize);
}
else
{
_logger.LogWarning("Btrfs compression failed or unavailable for {file}", filePath);
_logger.LogWarning("[W{worker}] Btrfs compression failed or unavailable for {file}", workerId, filePath);
}
}
else
{
_logger.LogTrace("Already Btrfs-compressed: {file}", filePath);
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("[W{worker}] Already Btrfs-compressed with clzo: {file}", workerId, filePath);
}
return;
}
_logger.LogTrace("Skip compact: unsupported FS for {file}", filePath);
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("[W{worker}] Skip compact: unsupported FS for {file}", workerId, filePath);
}
/// <summary>
/// Decompressing the given path with BTRFS file system or NTFS file system.
/// </summary>
/// <param name="path">Path of the compressed file</param>
private void DecompressFile(string path)
/// <param name="filePath">Path of the decompressed/normal file</param>
/// <param name="workerId">Worker/Process Id</param>
private void DecompressFile(string filePath, int workerId)
{
_logger.LogDebug("Decompress request: {file}", path);
var fsType = GetFilesystemType(path, _dalamudUtilService.IsWine);
_logger.LogDebug("[W{worker}] Decompress request: {file}", workerId, filePath);
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine);
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine)
{
try
{
bool flowControl = DecompressWOFFile(path);
bool flowControl = DecompressWOFFile(filePath, workerId);
if (!flowControl)
{
return;
@@ -336,7 +416,7 @@ public sealed class FileCompactor : IDisposable
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NTFS decompress error {file}", path);
_logger.LogWarning(ex, "[W{worker}] NTFS decompress error {file}", workerId, filePath);
}
}
@@ -344,7 +424,7 @@ public sealed class FileCompactor : IDisposable
{
try
{
bool flowControl = DecompressBtrfsFile(path);
bool flowControl = DecompressBtrfsFile(filePath);
if (!flowControl)
{
return;
@@ -352,7 +432,7 @@ public sealed class FileCompactor : IDisposable
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Btrfs decompress error {file}", path);
_logger.LogWarning(ex, "[W{worker}] Btrfs decompress error {file}", workerId, filePath);
}
}
}
@@ -372,51 +452,48 @@ public sealed class FileCompactor : IDisposable
string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path;
var opts = GetMountOptionsForPath(linuxPath);
bool hasCompress = opts.Contains("compress", StringComparison.OrdinalIgnoreCase);
bool hasCompressForce = opts.Contains("compress-force", StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrEmpty(opts))
_logger.LogTrace("Mount opts for {file}: {opts}", linuxPath, opts);
if (hasCompressForce)
var probe = RunProcessShell("command -v btrfs || which btrfs", timeoutMs: 5000);
var _btrfsAvailable = probe.ok && !string.IsNullOrWhiteSpace(probe.stdout);
if (!_btrfsAvailable)
_logger.LogWarning("btrfs cli not found in path. Compression will be skipped.");
var prop = isWine
? RunProcessShell($"btrfs property set -- {QuoteSingle(linuxPath)} compression none", timeoutMs: 15000)
: RunProcessDirect("btrfs", ["property", "set", "--", linuxPath, "compression", "none"], "/", 15000);
if (prop.ok) _logger.LogTrace("Set per-file 'compression none' on {file}", linuxPath);
else _logger.LogTrace("btrfs property set failed for {file} (exit {code}): {err}", linuxPath, prop.exitCode, prop.stderr);
var defrag = isWine
? RunProcessShell($"btrfs filesystem defragment -f -- {QuoteSingle(linuxPath)}", timeoutMs: 60000)
: RunProcessDirect("btrfs", ["filesystem", "defragment", "-f", "--", linuxPath], "/", 60000);
if (!defrag.ok)
{
_logger.LogWarning("Cannot safely decompress {file}: mount options contains compress-force ({opts}).", linuxPath, opts);
_logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit {code}): {err}",
linuxPath, defrag.exitCode, defrag.stderr);
return false;
}
if (hasCompress)
{
var setCmd = $"btrfs property set -- {QuoteDouble(linuxPath)} compression none";
var (okSet, _, errSet, codeSet) = isWine
? RunProcessShell(setCmd)
: RunProcessDirect("btrfs", ["property", "set", "--", linuxPath, "compression", "none"]);
if (!okSet)
{
_logger.LogWarning("Failed to set 'compression none' on {file}, please check drive options (exit code is: {code}): {err}", linuxPath, codeSet, errSet);
return false;
}
_logger.LogTrace("Set per-file 'compression none' on {file}", linuxPath);
}
if (!IsBtrfsCompressedFile(linuxPath))
{
_logger.LogTrace("{file} is not compressed, skipping decompression completely", linuxPath);
return true;
}
var (ok, stdout, stderr, code) = isWine
? RunProcessShell($"btrfs filesystem defragment -- {QuoteDouble(linuxPath)}")
: RunProcessDirect("btrfs", ["filesystem", "defragment", "--", linuxPath]);
if (!ok)
{
_logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit code is: {code}): {stderr}",
linuxPath, code, stderr);
return false;
}
if (!string.IsNullOrWhiteSpace(stdout))
_logger.LogTrace("btrfs defragment output for {file}: {out}", linuxPath, stdout.Trim());
if (!string.IsNullOrWhiteSpace(defrag.stdout))
_logger.LogTrace("btrfs defragment output for {file}: {out}", linuxPath, defrag.stdout.Trim());
_logger.LogInformation("Decompressed (rewritten uncompressed) Btrfs file: {file}", linuxPath);
try
{
if (_fragBatch != null)
{
var compressed = _fragBatch.IsCompressedAsync(linuxPath, _compactionCts.Token).GetAwaiter().GetResult();
if (compressed)
_logger.LogTrace("Post-check: {file} still shows 'compressed' flag (may be stale).", linuxPath);
}
}
catch { /* ignore verification noisy */ }
return true;
}
catch (Exception ex)
@@ -432,18 +509,18 @@ public sealed class FileCompactor : IDisposable
/// </summary>
/// <param name="path">Path of the compressed file</param>
/// <returns>Decompressing state</returns>
private bool DecompressWOFFile(string path)
private bool DecompressWOFFile(string path, int workerID)
{
//Check if its already been compressed
if (TryIsWofExternal(path, out bool isExternal, out int algo))
{
if (!isExternal)
{
_logger.LogTrace("Already decompressed file: {file}", path);
_logger.LogTrace("[W{worker}] Already decompressed file: {file}", workerID, path);
return true;
}
var compressString = ((CompressionAlgorithm)algo).ToString();
_logger.LogTrace("WOF compression (algo={algo}) detected for {file}", compressString, path);
_logger.LogTrace("[W{worker}] WOF compression (algo={algo}) detected for {file}", workerID, compressString, path);
}
//This will attempt to start WOF thread.
@@ -457,15 +534,15 @@ public sealed class FileCompactor : IDisposable
// 342 error code means its been decompressed after the control, we handle it as it succesfully been decompressed.
if (err == 342)
{
_logger.LogTrace("Successfully decompressed NTFS file {file}", path);
_logger.LogTrace("[W{worker}] Successfully decompressed NTFS file {file}", workerID, path);
return true;
}
_logger.LogWarning("DeviceIoControl failed for {file} with Win32 error {err}", path, err);
_logger.LogWarning("[W{worker}] DeviceIoControl failed for {file} with Win32 error {err}", workerID, path, err);
return false;
}
_logger.LogTrace("Successfully decompressed NTFS file {file}", path);
_logger.LogTrace("[W{worker}] Successfully decompressed NTFS file {file}", workerID, path);
return true;
});
}
@@ -478,6 +555,7 @@ public sealed class FileCompactor : IDisposable
/// <returns>Converted path to be used in Linux</returns>
private string ToLinuxPathIfWine(string path, bool isWine, bool preferShell = true)
{
//Return if not wine
if (!isWine || !IsProbablyWine())
return path;
@@ -539,7 +617,7 @@ public sealed class FileCompactor : IDisposable
/// <returns>Compessing state</returns>
private bool WOFCompressFile(string path)
{
int size = Marshal.SizeOf<WOF_FILE_COMPRESSION_INFO_V1>();
int size = Marshal.SizeOf<WofFileCompressionInfoV1>();
IntPtr efInfoPtr = Marshal.AllocHGlobal(size);
try
@@ -592,7 +670,7 @@ public sealed class FileCompactor : IDisposable
{
try
{
uint buf = (uint)Marshal.SizeOf<WOF_FILE_COMPRESSION_INFO_V1>();
uint buf = (uint)Marshal.SizeOf<WofFileCompressionInfoV1>();
int result = WofIsExternalFile(filePath, out int isExternal, out _, out var info, ref buf);
if (result != 0 || isExternal == 0)
return false;
@@ -621,7 +699,7 @@ public sealed class FileCompactor : IDisposable
algorithm = 0;
try
{
uint buf = (uint)Marshal.SizeOf<WOF_FILE_COMPRESSION_INFO_V1>();
uint buf = (uint)Marshal.SizeOf<WofFileCompressionInfoV1>();
int hr = WofIsExternalFile(path, out int ext, out _, out var info, ref buf);
if (hr == 0 && ext != 0)
{
@@ -630,13 +708,13 @@ public sealed class FileCompactor : IDisposable
}
return true;
}
catch (DllNotFoundException)
catch (DllNotFoundException)
{
return false;
return false;
}
catch (EntryPointNotFoundException)
{
return false;
catch (EntryPointNotFoundException)
{
return false;
}
}
@@ -651,8 +729,7 @@ public sealed class FileCompactor : IDisposable
{
try
{
bool windowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
string linuxPath = windowsProc ? ResolveLinuxPathForWine(path) : path;
string linuxPath = _isWindows ? ResolveLinuxPathForWine(path) : path;
var task = _fragBatch.IsCompressedAsync(linuxPath, _compactionCts.Token);
@@ -685,7 +762,6 @@ public sealed class FileCompactor : IDisposable
try
{
var (winPath, linuxPath) = ResolvePathsForBtrfs(path);
bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
if (IsBtrfsCompressedFile(linuxPath))
{
@@ -699,8 +775,13 @@ public sealed class FileCompactor : IDisposable
return false;
}
var probe = RunProcessShell("command -v btrfs || which btrfs", timeoutMs: 5000);
var _btrfsAvailable = probe.ok && !string.IsNullOrWhiteSpace(probe.stdout);
if (!_btrfsAvailable)
_logger.LogWarning("btrfs cli not found in path. Compression will be skipped.");
(bool ok, string stdout, string stderr, int code) =
isWindowsProc
_isWindows
? RunProcessShell($"btrfs filesystem defragment -clzo -- {QuoteSingle(linuxPath)}")
: RunProcessDirect("btrfs", ["filesystem", "defragment", "-clzo", "--", linuxPath]);
@@ -783,9 +864,10 @@ public sealed class FileCompactor : IDisposable
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
CreateNoWindow = true,
WorkingDirectory = workingDir ?? "/",
};
if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir;
foreach (var a in args) psi.ArgumentList.Add(a);
EnsureUnixPathEnv(psi);
@@ -799,8 +881,18 @@ public sealed class FileCompactor : IDisposable
}
int code;
try { code = proc.ExitCode; } catch { code = -1; }
return (code == 0, so2, se2, code);
try { code = proc.ExitCode; }
catch { code = -1; }
bool ok = code == 0;
if (!ok && code == -1 &&
string.IsNullOrWhiteSpace(se2) && !string.IsNullOrWhiteSpace(so2))
{
ok = true;
}
return (ok, so2, se2, code);
}
/// <summary>
@@ -811,15 +903,14 @@ public sealed class FileCompactor : IDisposable
/// <returns>State of the process, output of the process and error with exit code</returns>
private (bool ok, string stdout, string stderr, int exitCode) RunProcessShell(string command, string? workingDir = null, int timeoutMs = 60000)
{
var psi = new ProcessStartInfo("/bin/bash")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
CreateNoWindow = true,
WorkingDirectory = workingDir ?? "/",
};
if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir;
// Use a Login shell so PATH includes /usr/sbin etc. AKA -lc for login shell
psi.ArgumentList.Add("-lc");
@@ -836,65 +927,72 @@ public sealed class FileCompactor : IDisposable
}
int code;
try { code = proc.ExitCode; } catch { code = -1; }
return (code == 0, so2, se2, code);
try { code = proc.ExitCode; }
catch { code = -1; }
bool ok = code == 0;
if (!ok && code == -1 && string.IsNullOrWhiteSpace(se2) && !string.IsNullOrWhiteSpace(so2))
{
ok = true;
}
return (ok, so2, se2, code);
}
/// <summary>
/// Checking the process result for shell or direct processes
/// </summary>
/// <param name="proc">Process</param>
/// <param name="timeoutMs">How long when timeout is gotten</param>
/// <param name="timeoutMs">How long when timeout goes over threshold</param>
/// <param name="token">Cancellation Token</param>
/// <returns>Multiple variables</returns>
private (bool success, string testy, string testi) CheckProcessResult(Process proc, int timeoutMs, CancellationToken token)
private (bool success, string output, string errorCode) CheckProcessResult(Process proc, int timeoutMs, CancellationToken token)
{
var outTask = proc.StandardOutput.ReadToEndAsync(token);
var errTask = proc.StandardError.ReadToEndAsync(token);
var bothTasks = Task.WhenAll(outTask, errTask);
//On wine, we dont wanna use waitforexit as it will be always broken and giving an error.
if (_dalamudUtilService.IsWine)
{
var finished = Task.WhenAny(bothTasks, Task.Delay(timeoutMs, token)).GetAwaiter().GetResult();
if (finished != bothTasks)
{
try
{
proc.Kill(entireProcessTree: true);
Task.WaitAll([outTask, errTask], 1000, token);
}
catch
{
// ignore this
}
var so = outTask.IsCompleted ? outTask.Result : "";
var se = errTask.IsCompleted ? errTask.Result : "timeout";
return (false, so, se);
}
var finished = Task.WhenAny(bothTasks, Task.Delay(timeoutMs, token)).GetAwaiter().GetResult();
var stderr = errTask.Result;
var ok = string.IsNullOrWhiteSpace(stderr);
return (ok, outTask.Result, stderr);
if (token.IsCancellationRequested)
return KillProcess(proc, outTask, errTask, token);
if (finished != bothTasks)
return KillProcess(proc, outTask, errTask, token);
bool isWine = _dalamudUtilService?.IsWine ?? false;
if (!isWine)
{
try { proc.WaitForExit(); } catch { /* ignore quirks */ }
}
else
{
var sw = Stopwatch.StartNew();
while (!proc.HasExited && sw.ElapsedMilliseconds < 75)
Thread.Sleep(5);
}
// On linux, we can use it as we please
if (!proc.WaitForExit(timeoutMs))
{
try
{
proc.Kill(entireProcessTree: true);
Task.WaitAll([outTask, errTask], 1000, token);
}
catch
{
// ignore this
}
return (false, outTask.IsCompleted ? outTask.Result : "", "timeout");
}
var stdout = outTask.Status == TaskStatus.RanToCompletion ? outTask.Result : "";
var stderr = errTask.Status == TaskStatus.RanToCompletion ? errTask.Result : "";
Task.WaitAll(outTask, errTask);
return (true, outTask.Result, errTask.Result);
int code = -1;
try { if (proc.HasExited) code = proc.ExitCode; } catch { /* Wine may still throw */ }
bool ok = code == 0 || (isWine && string.IsNullOrWhiteSpace(stderr));
return (ok, stdout, stderr);
static (bool success, string output, string errorCode) KillProcess(
Process proc, Task<string> outTask, Task<string> errTask, CancellationToken token)
{
try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ }
try { Task.WaitAll([outTask, errTask], 1000, token); } catch { /* ignore */ }
var so = outTask.IsCompleted ? outTask.Result : "";
var se = errTask.IsCompleted ? errTask.Result : "canceled/timeout";
return (false, so, se);
}
}
/// <summary>
@@ -954,10 +1052,10 @@ public sealed class FileCompactor : IDisposable
}
/// <summary>
/// Process the queue with, meant for a worker/thread
/// Process the queue, meant for a worker/thread
/// </summary>
/// <param name="token">Cancellation token for the worker whenever it needs to be stopped</param>
private async Task ProcessQueueWorkerAsync(CancellationToken token)
private async Task ProcessQueueWorkerAsync(int workerId, CancellationToken token)
{
try
{
@@ -973,7 +1071,7 @@ public sealed class FileCompactor : IDisposable
try
{
if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath))
CompactFile(filePath);
CompactFile(filePath, workerId);
}
finally
{
@@ -992,8 +1090,8 @@ public sealed class FileCompactor : IDisposable
}
}
}
catch (OperationCanceledException)
{
catch (OperationCanceledException)
{
// Shutting down worker, this exception is expected
}
}
@@ -1005,7 +1103,7 @@ public sealed class FileCompactor : IDisposable
/// <returns>Linux path to be used in Linux</returns>
private string ResolveLinuxPathForWine(string windowsPath)
{
var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(windowsPath)}", null, 5000);
var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(windowsPath)}", workingDir: null, 5000);
if (ok && !string.IsNullOrWhiteSpace(outp)) return outp.Trim();
return ToLinuxPathIfWine(windowsPath, isWine: true);
}
@@ -1029,9 +1127,7 @@ public sealed class FileCompactor : IDisposable
/// <returns></returns>
private (string windowsPath, string linuxPath) ResolvePathsForBtrfs(string path)
{
bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
if (!isWindowsProc)
if (!_isWindows)
return (path, path);
var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(path)}", workingDir: null, 5000);
@@ -1050,7 +1146,7 @@ public sealed class FileCompactor : IDisposable
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (_isWindows)
{
using var _ = new FileStream(winePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
}
@@ -1060,7 +1156,11 @@ public sealed class FileCompactor : IDisposable
}
return true;
}
catch { return false; }
catch (Exception ex)
{
_logger.LogTrace(ex, "Probe open failed for {file} (linux={linux})", winePath, linuxPath);
return false;
}
}
/// <summary>
@@ -1085,17 +1185,18 @@ public sealed class FileCompactor : IDisposable
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped);
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial uint GetCompressedFileSizeW([MarshalAs(UnmanagedType.LPWStr)] string lpFileName, out uint lpFileSizeHigh);
[DllImport("kernel32.dll")]
private static extern uint GetCompressedFileSizeW([In, MarshalAs(UnmanagedType.LPWStr)] string lpFileName, [Out, MarshalAs(UnmanagedType.U4)] out uint lpFileSizeHigh);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped);
[DllImport("WofUtil.dll")]
private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength);
[LibraryImport("WofUtil.dll")]
private static partial int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WofFileCompressionInfoV1 Info, ref uint BufferLength);
[DllImport("WofUtil.dll", SetLastError = true)]
private static extern int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length);
[LibraryImport("WofUtil.dll")]
private static partial int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length);
private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
@@ -1103,7 +1204,11 @@ public sealed class FileCompactor : IDisposable
public void Dispose()
{
//Cleanup of gates and frag service
_fragBatch?.Dispose();
_btrfsGate?.Dispose();
_globalGate?.Dispose();
_compactionQueue.Writer.TryComplete();
_compactionCts.Cancel();
@@ -1111,8 +1216,8 @@ public sealed class FileCompactor : IDisposable
{
Task.WaitAll([.. _workers.Where(t => t != null)], TimeSpan.FromSeconds(5));
}
catch
{
catch
{
// Ignore this catch on the dispose
}
finally

View File

@@ -5,4 +5,5 @@ public enum FileState
Valid,
RequireUpdate,
RequireDeletion,
RequireRehash
}

View File

@@ -3,11 +3,17 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
namespace LightlessSync.FileCache;
@@ -17,21 +23,29 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
private readonly TransientConfigService _configurationService;
private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private ConcurrentDictionary<IntPtr, ObjectKind> _cachedFrameAddresses = [];
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
private uint _lastClassJobId = uint.MaxValue;
public bool IsTransientRecording { get; private set; } = false;
public TransientResourceManager(ILogger<TransientResourceManager> logger, TransientConfigService configurationService,
DalamudUtilService dalamudUtil, LightlessMediator mediator) : base(logger, mediator)
DalamudUtilService dalamudUtil, LightlessMediator mediator, ActorObjectService actorObjectService, GameObjectHandlerFactory gameObjectHandlerFactory) : base(logger, mediator)
{
_configurationService = configurationService;
_dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe<PenumbraModSettingChangedMessage>(this, (_) => Manager_PenumbraModSettingChanged());
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate());
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
@@ -44,6 +58,11 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (!msg.OwnedObject) return;
_playerRelatedPointers.Remove(msg.GameObjectHandler);
});
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
HandleActorTracked(descriptor);
}
}
private TransientConfig.TransientPlayerConfig PlayerConfig
@@ -123,12 +142,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
return;
}
var transientResources = resources.ToList();
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
List<string> newlyAddedGamePaths = resources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
foreach (var gamePath in transientResources)
List<string> transientResources;
lock (resources)
{
semiTransientResources.Add(gamePath);
transientResources = resources.ToList();
}
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
List<string> newlyAddedGamePaths;
lock (semiTransientResources)
{
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
foreach (var gamePath in transientResources)
{
semiTransientResources.Add(gamePath);
}
}
bool saveConfig = false;
@@ -161,7 +189,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_configurationService.Save();
}
TransientResources[objectKind].Clear();
lock (resources)
{
resources.Clear();
}
}
public void RemoveTransientResource(ObjectKind objectKind, string path)
@@ -241,16 +272,46 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
TransientResources.Clear();
SemiTransientResources.Clear();
lock (_ownedHandlerLock)
{
foreach (var handler in _ownedHandlers.Values)
{
handler.Dispose();
}
_ownedHandlers.Clear();
}
}
private void DalamudUtil_FrameworkUpdate()
{
_cachedFrameAddresses = new(_playerRelatedPointers.Where(k => k.Address != nint.Zero).ToDictionary(c => c.Address, c => c.ObjectKind));
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Clear();
}
var activeDescriptors = new Dictionary<nint, ObjectKind>();
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
if (TryResolveObjectKind(descriptor, out var resolvedKind))
{
activeDescriptors[descriptor.Address] = resolvedKind;
}
}
foreach (var address in _cachedFrameAddresses.Keys.ToList())
{
if (!activeDescriptors.ContainsKey(address))
{
_cachedFrameAddresses.TryRemove(address, out _);
}
}
foreach (var descriptor in activeDescriptors)
{
_cachedFrameAddresses[descriptor.Key] = descriptor.Value;
}
if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
_lastClassJobId = _dalamudUtil.ClassJobId;
@@ -259,16 +320,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
value?.Clear();
}
// reload config for current new classjob
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
}
foreach (var kind in Enum.GetValues(typeof(ObjectKind)))
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
{
if (!_cachedFrameAddresses.Any(k => k.Value == (ObjectKind)kind) && TransientResources.Remove((ObjectKind)kind, out _))
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
{
Logger.LogDebug("Object not present anymore: {kind}", kind.ToString());
}
@@ -292,6 +352,116 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null;
}
private static bool TryResolveObjectKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind resolvedKind)
{
if (descriptor.OwnedKind is ObjectKind ownedKind)
{
resolvedKind = ownedKind;
return true;
}
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
resolvedKind = ObjectKind.Player;
return true;
}
resolvedKind = default;
return false;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (!TryResolveObjectKind(descriptor, out var resolvedKind))
return;
if (Logger.IsEnabled(LogLevel.Debug))
{
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", resolvedKind, descriptor.Address, descriptor.Name);
}
_cachedFrameAddresses[descriptor.Address] = resolvedKind;
if (descriptor.OwnedKind is not ObjectKind ownedKind)
return;
lock (_ownedHandlerLock)
{
if (_ownedHandlers.ContainsKey(descriptor.Address))
return;
_ = CreateOwnedHandlerAsync(descriptor, ownedKind);
}
}
private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor)
{
if (Logger.IsEnabled(LogLevel.Debug))
{
var kindLabel = descriptor.OwnedKind?.ToString()
?? (descriptor.ObjectKind == DalamudObjectKind.Player ? ObjectKind.Player.ToString() : "<none>");
Logger.LogDebug("ActorObject untracked: addr={address:X} name={name} kind={kind}", descriptor.Address, descriptor.Name, kindLabel);
}
_cachedFrameAddresses.TryRemove(descriptor.Address, out _);
if (descriptor.OwnedKind is not ObjectKind)
return;
lock (_ownedHandlerLock)
{
if (_ownedHandlers.Remove(descriptor.Address, out var handler))
{
handler.Dispose();
}
}
}
private async Task CreateOwnedHandlerAsync(ActorObjectService.ActorDescriptor descriptor, ObjectKind kind)
{
try
{
var handler = await _gameObjectHandlerFactory.Create(
kind,
() =>
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId) &&
_actorObjectService.TryGetValidatedActorByHash(descriptor.HashedContentId, out var current) &&
current.OwnedKind == kind)
{
return current.Address;
}
return descriptor.Address;
},
true).ConfigureAwait(false);
if (handler.Address == IntPtr.Zero)
{
handler.Dispose();
return;
}
lock (_ownedHandlerLock)
{
if (!_cachedFrameAddresses.ContainsKey(descriptor.Address))
{
Logger.LogDebug("ActorObject handler discarded (stale): addr={address:X}", descriptor.Address);
handler.Dispose();
return;
}
_ownedHandlers[descriptor.Address] = handler;
}
Logger.LogDebug("ActorObject handler created: {kind} addr={address:X}", kind, descriptor.Address);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to create owned handler for {kind} at {address:X}", kind, descriptor.Address);
}
}
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{
var gamePath = msg.GamePath.ToLowerInvariant();
@@ -383,21 +553,30 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void SendTransients(nint gameObject, ObjectKind objectKind)
{
_sendTransientCts.Cancel();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
_ = Task.Run(async () =>
{
_sendTransientCts?.Cancel();
_sendTransientCts?.Dispose();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
foreach (var kvp in TransientResources)
try
{
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
if (TransientResources.TryGetValue(objectKind, out var values) && values.Any())
{
Logger.LogTrace("Sending Transients for {kind}", objectKind);
Mediator.Publish(new TransientResourceChangedMessage(gameObject));
}
}
catch (TaskCanceledException)
{
}
catch (System.OperationCanceledException)
{
}
});
}

View File

@@ -20,7 +20,10 @@ internal sealed class DalamudLogger : ILogger
_hasModifiedGameFiles = hasModifiedGameFiles;
}
public IDisposable BeginScope<TState>(TState state) => default!;
IDisposable? ILogger.BeginScope<TState>(TState state)
{
return default!;
}
public bool IsEnabled(LogLevel logLevel)
{

View File

@@ -0,0 +1,196 @@
using Dalamud.Plugin;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Linq;
namespace LightlessSync.Interop.Ipc.Framework;
public enum IpcConnectionState
{
Unknown = 0,
MissingPlugin = 1,
VersionMismatch = 2,
PluginDisabled = 3,
NotReady = 4,
Available = 5,
Error = 6,
}
public sealed record IpcServiceDescriptor(string InternalName, string DisplayName, Version MinimumVersion)
{
public override string ToString()
=> $"{DisplayName} (>= {MinimumVersion})";
}
public interface IIpcService : IDisposable
{
IpcServiceDescriptor Descriptor { get; }
IpcConnectionState State { get; }
IDalamudPluginInterface PluginInterface { get; }
bool APIAvailable { get; }
void CheckAPI();
}
public interface IIpcInterop : IDisposable
{
string Name { get; }
void OnConnectionStateChanged(IpcConnectionState state);
}
public abstract class IpcInteropBase : IIpcInterop
{
protected IpcInteropBase(ILogger logger)
{
Logger = logger;
}
protected ILogger Logger { get; }
protected IpcConnectionState State { get; private set; } = IpcConnectionState.Unknown;
protected bool IsAvailable => State == IpcConnectionState.Available;
public abstract string Name { get; }
public void OnConnectionStateChanged(IpcConnectionState state)
{
if (State == state)
{
return;
}
var previous = State;
State = state;
HandleStateChange(previous, state);
}
protected abstract void HandleStateChange(IpcConnectionState previous, IpcConnectionState current);
public virtual void Dispose()
{
}
}
public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcService
{
private readonly List<IIpcInterop> _interops = new();
protected IpcServiceBase(
ILogger logger,
LightlessMediator mediator,
IDalamudPluginInterface pluginInterface,
IpcServiceDescriptor descriptor) : base(logger, mediator)
{
PluginInterface = pluginInterface;
Descriptor = descriptor;
}
protected IDalamudPluginInterface PluginInterface { get; }
IDalamudPluginInterface IIpcService.PluginInterface => PluginInterface;
protected IpcServiceDescriptor Descriptor { get; }
IpcServiceDescriptor IIpcService.Descriptor => Descriptor;
public IpcConnectionState State { get; private set; } = IpcConnectionState.Unknown;
public bool APIAvailable => State == IpcConnectionState.Available;
public virtual void CheckAPI()
{
var newState = EvaluateState();
UpdateState(newState);
}
protected virtual IpcConnectionState EvaluateState()
{
try
{
var plugin = PluginInterface.InstalledPlugins
.Where(p => string.Equals(p.InternalName, Descriptor.InternalName, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(p => p.IsLoaded)
.FirstOrDefault();
if (plugin == null)
{
return IpcConnectionState.MissingPlugin;
}
if (plugin.Version < Descriptor.MinimumVersion)
{
return IpcConnectionState.VersionMismatch;
}
if (!IsPluginEnabled(plugin))
{
return IpcConnectionState.PluginDisabled;
}
if (!IsPluginReady())
{
return IpcConnectionState.NotReady;
}
return IpcConnectionState.Available;
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Failed to evaluate IPC state for {Service}", Descriptor.DisplayName);
return IpcConnectionState.Error;
}
}
protected virtual bool IsPluginEnabled(IExposedPlugin plugin)
=> plugin.IsLoaded;
protected virtual bool IsPluginReady()
=> true;
protected TInterop RegisterInterop<TInterop>(TInterop interop)
where TInterop : IIpcInterop
{
_interops.Add(interop);
interop.OnConnectionStateChanged(State);
return interop;
}
private void UpdateState(IpcConnectionState newState)
{
if (State == newState)
{
return;
}
var previous = State;
State = newState;
OnConnectionStateChanged(previous, newState);
foreach (var interop in _interops)
{
interop.OnConnectionStateChanged(newState);
}
}
protected virtual void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current)
{
Logger.LogTrace("{Service} IPC state transitioned from {Previous} to {Current}", Descriptor.DisplayName, previous, current);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
{
return;
}
for (var i = _interops.Count - 1; i >= 0; --i)
{
_interops[i].Dispose();
}
_interops.Clear();
}
}

View File

@@ -1,7 +0,0 @@
namespace LightlessSync.Interop.Ipc;
public interface IIpcCaller : IDisposable
{
bool APIAvailable { get; }
void CheckAPI();
}

View File

@@ -1,69 +1,63 @@
using Dalamud.Game.ClientState.Objects.Types;
using Brio.API;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Text.Json.Nodes;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerBrio : IIpcCaller
public sealed class IpcCallerBrio : IpcServiceBase
{
private static readonly IpcServiceDescriptor BrioDescriptor = new("Brio", "Brio", new Version(3, 0, 0, 0));
private readonly ILogger<IpcCallerBrio> _logger;
private readonly DalamudUtilService _dalamudUtilService;
private readonly ICallGateSubscriber<(int, int)> _brioApiVersion;
private readonly ICallGateSubscriber<bool, bool, bool, Task<IGameObject>> _brioSpawnActorAsync;
private readonly ICallGateSubscriber<IGameObject, bool> _brioDespawnActor;
private readonly ICallGateSubscriber<IGameObject, Vector3?, Quaternion?, Vector3?, bool, bool> _brioSetModelTransform;
private readonly ICallGateSubscriber<IGameObject, (Vector3?, Quaternion?, Vector3?)> _brioGetModelTransform;
private readonly ICallGateSubscriber<IGameObject, string> _brioGetPoseAsJson;
private readonly ICallGateSubscriber<IGameObject, string, bool, bool> _brioSetPoseFromJson;
private readonly ICallGateSubscriber<IGameObject, bool> _brioFreezeActor;
private readonly ICallGateSubscriber<bool> _brioFreezePhysics;
private readonly ApiVersion _apiVersion;
private readonly SpawnActor _spawnActor;
private readonly DespawnActor _despawnActor;
private readonly SetModelTransform _setModelTransform;
private readonly GetModelTransform _getModelTransform;
public bool APIAvailable { get; private set; }
private readonly GetPoseAsJson _getPoseAsJson;
private readonly LoadPoseFromJson _setPoseFromJson;
private readonly FreezeActor _freezeActor;
private readonly FreezePhysics _freezePhysics;
public IpcCallerBrio(ILogger<IpcCallerBrio> logger, IDalamudPluginInterface dalamudPluginInterface,
DalamudUtilService dalamudUtilService)
DalamudUtilService dalamudUtilService, LightlessMediator mediator) : base(logger, mediator, dalamudPluginInterface, BrioDescriptor)
{
_logger = logger;
_dalamudUtilService = dalamudUtilService;
_brioApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("Brio.ApiVersion");
_brioSpawnActorAsync = dalamudPluginInterface.GetIpcSubscriber<bool, bool, bool, Task<IGameObject>>("Brio.Actor.SpawnExAsync");
_brioDespawnActor = dalamudPluginInterface.GetIpcSubscriber<IGameObject, bool>("Brio.Actor.Despawn");
_brioSetModelTransform = dalamudPluginInterface.GetIpcSubscriber<IGameObject, Vector3?, Quaternion?, Vector3?, bool, bool>("Brio.Actor.SetModelTransform");
_brioGetModelTransform = dalamudPluginInterface.GetIpcSubscriber<IGameObject, (Vector3?, Quaternion?, Vector3?)>("Brio.Actor.GetModelTransform");
_brioGetPoseAsJson = dalamudPluginInterface.GetIpcSubscriber<IGameObject, string>("Brio.Actor.Pose.GetPoseAsJson");
_brioSetPoseFromJson = dalamudPluginInterface.GetIpcSubscriber<IGameObject, string, bool, bool>("Brio.Actor.Pose.LoadFromJson");
_brioFreezeActor = dalamudPluginInterface.GetIpcSubscriber<IGameObject, bool>("Brio.Actor.Freeze");
_brioFreezePhysics = dalamudPluginInterface.GetIpcSubscriber<bool>("Brio.FreezePhysics");
_apiVersion = new ApiVersion(dalamudPluginInterface);
_spawnActor = new SpawnActor(dalamudPluginInterface);
_despawnActor = new DespawnActor(dalamudPluginInterface);
_setModelTransform = new SetModelTransform(dalamudPluginInterface);
_getModelTransform = new GetModelTransform(dalamudPluginInterface);
_getPoseAsJson = new GetPoseAsJson(dalamudPluginInterface);
_setPoseFromJson = new LoadPoseFromJson(dalamudPluginInterface);
_freezeActor = new FreezeActor(dalamudPluginInterface);
_freezePhysics = new FreezePhysics(dalamudPluginInterface);
CheckAPI();
}
public void CheckAPI()
{
try
{
var version = _brioApiVersion.InvokeFunc();
APIAvailable = (version.Item1 == 2 && version.Item2 >= 0);
}
catch
{
APIAvailable = false;
}
}
public async Task<IGameObject?> SpawnActorAsync()
{
if (!APIAvailable) return null;
_logger.LogDebug("Spawning Brio Actor");
return await _brioSpawnActorAsync.InvokeFunc(false, false, true).ConfigureAwait(false);
return await _dalamudUtilService.RunOnFrameworkThread(() => _spawnActor.Invoke(Brio.API.Enums.SpawnFlags.Default, true)).ConfigureAwait(false);
}
public async Task<bool> DespawnActorAsync(nint address)
@@ -72,7 +66,7 @@ public sealed class IpcCallerBrio : IIpcCaller
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return false;
_logger.LogDebug("Despawning Brio Actor {actor}", gameObject.Name.TextValue);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioDespawnActor.InvokeFunc(gameObject)).ConfigureAwait(false);
return await _dalamudUtilService.RunOnFrameworkThread(() => _despawnActor.Invoke(gameObject)).ConfigureAwait(false);
}
public async Task<bool> ApplyTransformAsync(nint address, WorldData data)
@@ -82,7 +76,7 @@ public sealed class IpcCallerBrio : IIpcCaller
if (gameObject == null) return false;
_logger.LogDebug("Applying Transform to Actor {actor}", gameObject.Name.TextValue);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetModelTransform.InvokeFunc(gameObject,
return await _dalamudUtilService.RunOnFrameworkThread(() => _setModelTransform.Invoke(gameObject,
new Vector3(data.PositionX, data.PositionY, data.PositionZ),
new Quaternion(data.RotationX, data.RotationY, data.RotationZ, data.RotationW),
new Vector3(data.ScaleX, data.ScaleY, data.ScaleZ), false)).ConfigureAwait(false);
@@ -93,8 +87,7 @@ public sealed class IpcCallerBrio : IIpcCaller
if (!APIAvailable) return default;
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return default;
var data = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetModelTransform.InvokeFunc(gameObject)).ConfigureAwait(false);
//_logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue);
var data = await _dalamudUtilService.RunOnFrameworkThread(() => _getModelTransform.Invoke(gameObject)).ConfigureAwait(false);
return new WorldData()
{
@@ -118,7 +111,7 @@ public sealed class IpcCallerBrio : IIpcCaller
if (gameObject == null) return null;
_logger.LogDebug("Getting Pose from Actor {actor}", gameObject.Name.TextValue);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false);
return await _dalamudUtilService.RunOnFrameworkThread(() => _getPoseAsJson.Invoke(gameObject)).ConfigureAwait(false);
}
public async Task<bool> SetPoseAsync(nint address, string pose)
@@ -129,18 +122,41 @@ public sealed class IpcCallerBrio : IIpcCaller
_logger.LogDebug("Setting Pose to Actor {actor}", gameObject.Name.TextValue);
var applicablePose = JsonNode.Parse(pose)!;
var currentPose = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false);
var currentPose = await _dalamudUtilService.RunOnFrameworkThread(() => _getPoseAsJson.Invoke(gameObject)).ConfigureAwait(false);
applicablePose["ModelDifference"] = JsonNode.Parse(JsonNode.Parse(currentPose)!["ModelDifference"]!.ToJsonString());
await _dalamudUtilService.RunOnFrameworkThread(() =>
{
_brioFreezeActor.InvokeFunc(gameObject);
_brioFreezePhysics.InvokeFunc();
_freezeActor.Invoke(gameObject);
_freezePhysics.Invoke();
}).ConfigureAwait(false);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetPoseFromJson.InvokeFunc(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false);
return await _dalamudUtilService.RunOnFrameworkThread(() => _setPoseFromJson.Invoke(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false);
}
public void Dispose()
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
try
{
var version = _apiVersion.Invoke();
return version.Item1 == 3 && version.Item2 >= 0
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query Brio IPC version");
return IpcConnectionState.Error;
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
}
}

View File

@@ -2,6 +2,7 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Utility;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
@@ -9,8 +10,10 @@ using System.Text;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerCustomize : IIpcCaller
public sealed class IpcCallerCustomize : IpcServiceBase
{
private static readonly IpcServiceDescriptor CustomizeDescriptor = new("CustomizePlus", "Customize+", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<(int, int)> _customizePlusApiVersion;
private readonly ICallGateSubscriber<ushort, (int, Guid?)> _customizePlusGetActiveProfile;
private readonly ICallGateSubscriber<Guid, (int, string?)> _customizePlusGetProfileById;
@@ -23,7 +26,7 @@ public sealed class IpcCallerCustomize : IIpcCaller
private readonly LightlessMediator _lightlessMediator;
public IpcCallerCustomize(ILogger<IpcCallerCustomize> logger, IDalamudPluginInterface dalamudPluginInterface,
DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator)
DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator) : base(logger, lightlessMediator, dalamudPluginInterface, CustomizeDescriptor)
{
_customizePlusApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("CustomizePlus.General.GetApiVersion");
_customizePlusGetActiveProfile = dalamudPluginInterface.GetIpcSubscriber<ushort, (int, Guid?)>("CustomizePlus.Profile.GetActiveProfileIdOnCharacter");
@@ -41,8 +44,6 @@ public sealed class IpcCallerCustomize : IIpcCaller
CheckAPI();
}
public bool APIAvailable { get; private set; } = false;
public async Task RevertAsync(nint character)
{
if (!APIAvailable) return;
@@ -113,16 +114,25 @@ public sealed class IpcCallerCustomize : IIpcCaller
return Convert.ToBase64String(Encoding.UTF8.GetBytes(scale));
}
public void CheckAPI()
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
try
{
var version = _customizePlusApiVersion.InvokeFunc();
APIAvailable = (version.Item1 == 6 && version.Item2 >= 0);
return version.Item1 == 6 && version.Item2 >= 0
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch
catch (Exception ex)
{
APIAvailable = false;
Logger.LogDebug(ex, "Failed to query Customize+ API version");
return IpcConnectionState.Error;
}
}
@@ -132,8 +142,14 @@ public sealed class IpcCallerCustomize : IIpcCaller
_lightlessMediator.Publish(new CustomizePlusMessage(obj?.Address ?? null));
}
public void Dispose()
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
{
return;
}
_customizePlusOnScaleUpdate.Unsubscribe(OnCustomizePlusScaleChange);
}
}

View File

@@ -2,6 +2,7 @@
using Dalamud.Plugin;
using Glamourer.Api.Helpers;
using Glamourer.Api.IpcSubscribers;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
@@ -10,8 +11,9 @@ using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcCaller
public sealed class IpcCallerGlamourer : IpcServiceBase
{
private static readonly IpcServiceDescriptor GlamourerDescriptor = new("Glamourer", "Glamourer", new Version(1, 3, 0, 10));
private readonly ILogger<IpcCallerGlamourer> _logger;
private readonly IDalamudPluginInterface _pi;
private readonly DalamudUtilService _dalamudUtil;
@@ -31,7 +33,7 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC
private readonly uint LockCode = 0x6D617265;
public IpcCallerGlamourer(ILogger<IpcCallerGlamourer> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator,
RedrawManager redrawManager) : base(logger, lightlessMediator)
RedrawManager redrawManager) : base(logger, lightlessMediator, pi, GlamourerDescriptor)
{
_glamourerApiVersions = new ApiVersion(pi);
_glamourerGetAllCustomization = new GetStateBase64(pi);
@@ -62,47 +64,6 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC
_glamourerStateChanged?.Dispose();
}
public bool APIAvailable { get; private set; }
public void CheckAPI()
{
bool apiAvailable = false;
try
{
bool versionValid = (_pi.InstalledPlugins
.FirstOrDefault(p => string.Equals(p.InternalName, "Glamourer", StringComparison.OrdinalIgnoreCase))
?.Version ?? new Version(0, 0, 0, 0)) >= new Version(1, 3, 0, 10);
try
{
var version = _glamourerApiVersions.Invoke();
if (version is { Major: 1, Minor: >= 1 } && versionValid)
{
apiAvailable = true;
}
}
catch
{
// ignore
}
_shownGlamourerUnavailable = _shownGlamourerUnavailable && !apiAvailable;
APIAvailable = apiAvailable;
}
catch
{
APIAvailable = apiAvailable;
}
finally
{
if (!apiAvailable && !_shownGlamourerUnavailable)
{
_shownGlamourerUnavailable = true;
_lightlessMediator.Publish(new NotificationMessage("Glamourer inactive", "Your Glamourer installation is not active or out of date. Update Glamourer to continue to use Lightless. If you just updated Glamourer, ignore this message.",
NotificationType.Error));
}
}
}
public async Task ApplyAllAsync(ILogger logger, GameObjectHandler handler, string? customization, Guid applicationId, CancellationToken token, bool fireAndForget = false)
{
if (!APIAvailable || string.IsNullOrEmpty(customization) || _dalamudUtil.IsZoning) return;
@@ -210,6 +171,49 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC
}
}
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
try
{
var version = _glamourerApiVersions.Invoke();
return version is { Major: 1, Minor: >= 1 }
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query Glamourer API version");
return IpcConnectionState.Error;
}
}
protected override void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current)
{
base.OnConnectionStateChanged(previous, current);
if (current == IpcConnectionState.Available)
{
_shownGlamourerUnavailable = false;
return;
}
if (_shownGlamourerUnavailable || current == IpcConnectionState.Unknown)
{
return;
}
_shownGlamourerUnavailable = true;
_lightlessMediator.Publish(new NotificationMessage("Glamourer inactive",
"Your Glamourer installation is not active or out of date. Update Glamourer to continue to use Lightless. If you just updated Glamourer, ignore this message.",
NotificationType.Error));
}
private void GlamourerChanged(nint address)
{
_lightlessMediator.Publish(new GlamourerChangedMessage(address));

View File

@@ -1,13 +1,16 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerHeels : IIpcCaller
public sealed class IpcCallerHeels : IpcServiceBase
{
private static readonly IpcServiceDescriptor HeelsDescriptor = new("SimpleHeels", "Simple Heels", new Version(0, 0, 0, 0));
private readonly ILogger<IpcCallerHeels> _logger;
private readonly LightlessMediator _lightlessMediator;
private readonly DalamudUtilService _dalamudUtil;
@@ -18,6 +21,7 @@ public sealed class IpcCallerHeels : IIpcCaller
private readonly ICallGateSubscriber<int, object?> _heelsUnregisterPlayer;
public IpcCallerHeels(ILogger<IpcCallerHeels> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator)
: base(logger, lightlessMediator, pi, HeelsDescriptor)
{
_logger = logger;
_lightlessMediator = lightlessMediator;
@@ -32,8 +36,26 @@ public sealed class IpcCallerHeels : IIpcCaller
CheckAPI();
}
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
public bool APIAvailable { get; private set; } = false;
try
{
return _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 1 }
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query SimpleHeels API version");
return IpcConnectionState.Error;
}
}
private void HeelsOffsetChange(string offset)
{
@@ -74,20 +96,14 @@ public sealed class IpcCallerHeels : IIpcCaller
}).ConfigureAwait(false);
}
public void CheckAPI()
protected override void Dispose(bool disposing)
{
try
base.Dispose(disposing);
if (!disposing)
{
APIAvailable = _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 1 };
return;
}
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_heelsOffsetUpdate.Unsubscribe(HeelsOffsetChange);
}
}

View File

@@ -1,6 +1,7 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
@@ -8,8 +9,10 @@ using System.Text;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerHonorific : IIpcCaller
public sealed class IpcCallerHonorific : IpcServiceBase
{
private static readonly IpcServiceDescriptor HonorificDescriptor = new("Honorific", "Honorific", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<(uint major, uint minor)> _honorificApiVersion;
private readonly ICallGateSubscriber<int, object> _honorificClearCharacterTitle;
private readonly ICallGateSubscriber<object> _honorificDisposing;
@@ -22,7 +25,7 @@ public sealed class IpcCallerHonorific : IIpcCaller
private readonly DalamudUtilService _dalamudUtil;
public IpcCallerHonorific(ILogger<IpcCallerHonorific> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator)
LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, HonorificDescriptor)
{
_logger = logger;
_lightlessMediator = lightlessMediator;
@@ -41,23 +44,14 @@ public sealed class IpcCallerHonorific : IIpcCaller
CheckAPI();
}
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
protected override void Dispose(bool disposing)
{
try
base.Dispose(disposing);
if (!disposing)
{
APIAvailable = _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 };
return;
}
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_honorificLocalCharacterTitleChanged.Unsubscribe(OnHonorificLocalCharacterTitleChanged);
_honorificDisposing.Unsubscribe(OnHonorificDisposing);
_honorificReady.Unsubscribe(OnHonorificReady);
@@ -113,6 +107,27 @@ public sealed class IpcCallerHonorific : IIpcCaller
}
}
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
try
{
return _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 }
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query Honorific API version");
return IpcConnectionState.Error;
}
}
private void OnHonorificDisposing()
{
_lightlessMediator.Publish(new HonorificMessage(string.Empty));

View File

@@ -1,14 +1,17 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerMoodles : IIpcCaller
public sealed class IpcCallerMoodles : IpcServiceBase
{
private static readonly IpcServiceDescriptor MoodlesDescriptor = new("Moodles", "Moodles", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<int> _moodlesApiVersion;
private readonly ICallGateSubscriber<IPlayerCharacter, object> _moodlesOnChange;
private readonly ICallGateSubscriber<nint, string> _moodlesGetStatus;
@@ -19,7 +22,7 @@ public sealed class IpcCallerMoodles : IIpcCaller
private readonly LightlessMediator _lightlessMediator;
public IpcCallerMoodles(ILogger<IpcCallerMoodles> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator)
LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, MoodlesDescriptor)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
@@ -41,22 +44,14 @@ public sealed class IpcCallerMoodles : IIpcCaller
_lightlessMediator.Publish(new MoodlesMessage(character.Address));
}
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
protected override void Dispose(bool disposing)
{
try
base.Dispose(disposing);
if (!disposing)
{
APIAvailable = _moodlesApiVersion.InvokeFunc() == 3;
return;
}
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_moodlesOnChange.Unsubscribe(OnMoodlesChange);
}
@@ -101,4 +96,25 @@ public sealed class IpcCallerMoodles : IIpcCaller
_logger.LogWarning(e, "Could not Set Moodles Status");
}
}
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
try
{
return _moodlesApiVersion.InvokeFunc() == 3
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query Moodles API version");
return IpcConnectionState.Error;
}
}
}

View File

@@ -1,146 +1,205 @@
using Dalamud.Plugin;
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Interop.Ipc.Penumbra;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
using System.Collections.Concurrent;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCaller
public sealed class IpcCallerPenumbra : IpcServiceBase
{
private readonly IDalamudPluginInterface _pi;
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessMediator _lightlessMediator;
private readonly RedrawManager _redrawManager;
private bool _shownPenumbraUnavailable = false;
private string? _penumbraModDirectory;
public string? ModDirectory
{
get => _penumbraModDirectory;
private set
{
if (!string.Equals(_penumbraModDirectory, value, StringComparison.Ordinal))
{
_penumbraModDirectory = value;
_lightlessMediator.Publish(new PenumbraDirectoryChangedMessage(_penumbraModDirectory));
}
}
}
private static readonly IpcServiceDescriptor PenumbraDescriptor = new("Penumbra", "Penumbra", new Version(1, 2, 0, 22));
private readonly ConcurrentDictionary<IntPtr, bool> _penumbraRedrawRequests = new();
private readonly PenumbraCollections _collections;
private readonly PenumbraResource _resources;
private readonly PenumbraRedraw _redraw;
private readonly PenumbraTexture _textures;
private readonly EventSubscriber _penumbraDispose;
private readonly EventSubscriber<nint, string, string> _penumbraGameObjectResourcePathResolved;
private readonly EventSubscriber _penumbraInit;
private readonly EventSubscriber<ModSettingChange, Guid, string, bool> _penumbraModSettingChanged;
private readonly EventSubscriber<nint, int> _penumbraObjectIsRedrawn;
private readonly AddTemporaryMod _penumbraAddTemporaryMod;
private readonly AssignTemporaryCollection _penumbraAssignTemporaryCollection;
private readonly ConvertTextureFile _penumbraConvertTextureFile;
private readonly CreateTemporaryCollection _penumbraCreateNamedTemporaryCollection;
private readonly GetEnabledState _penumbraEnabled;
private readonly GetPlayerMetaManipulations _penumbraGetMetaManipulations;
private readonly RedrawObject _penumbraRedraw;
private readonly DeleteTemporaryCollection _penumbraRemoveTemporaryCollection;
private readonly RemoveTemporaryMod _penumbraRemoveTemporaryMod;
private readonly GetModDirectory _penumbraResolveModDir;
private readonly ResolvePlayerPathsAsync _penumbraResolvePaths;
private readonly GetGameObjectResourcePaths _penumbraResourcePaths;
private readonly GetModDirectory _penumbraGetModDirectory;
private readonly EventSubscriber _penumbraInit;
private readonly EventSubscriber _penumbraDispose;
private readonly EventSubscriber<ModSettingChange, Guid, string, bool> _penumbraModSettingChanged;
public IpcCallerPenumbra(ILogger<IpcCallerPenumbra> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator, RedrawManager redrawManager) : base(logger, lightlessMediator)
private bool _shownPenumbraUnavailable;
private string? _modDirectory;
public IpcCallerPenumbra(
ILogger<IpcCallerPenumbra> logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator,
RedrawManager redrawManager,
ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor)
{
_pi = pi;
_dalamudUtil = dalamudUtil;
_lightlessMediator = lightlessMediator;
_redrawManager = redrawManager;
_penumbraInit = Initialized.Subscriber(pi, PenumbraInit);
_penumbraDispose = Disposed.Subscriber(pi, PenumbraDispose);
_penumbraResolveModDir = new GetModDirectory(pi);
_penumbraRedraw = new RedrawObject(pi);
_penumbraObjectIsRedrawn = GameObjectRedrawn.Subscriber(pi, RedrawEvent);
_penumbraGetMetaManipulations = new GetPlayerMetaManipulations(pi);
_penumbraRemoveTemporaryMod = new RemoveTemporaryMod(pi);
_penumbraAddTemporaryMod = new AddTemporaryMod(pi);
_penumbraCreateNamedTemporaryCollection = new CreateTemporaryCollection(pi);
_penumbraRemoveTemporaryCollection = new DeleteTemporaryCollection(pi);
_penumbraAssignTemporaryCollection = new AssignTemporaryCollection(pi);
_penumbraResolvePaths = new ResolvePlayerPathsAsync(pi);
_penumbraEnabled = new GetEnabledState(pi);
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pi, (change, arg1, arg, b) =>
{
if (change == ModSettingChange.EnableState)
_lightlessMediator.Publish(new PenumbraModSettingChangedMessage());
});
_penumbraConvertTextureFile = new ConvertTextureFile(pi);
_penumbraResourcePaths = new GetGameObjectResourcePaths(pi);
_penumbraEnabled = new GetEnabledState(pluginInterface);
_penumbraGetModDirectory = new GetModDirectory(pluginInterface);
_penumbraInit = Initialized.Subscriber(pluginInterface, HandlePenumbraInitialized);
_penumbraDispose = Disposed.Subscriber(pluginInterface, HandlePenumbraDisposed);
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged);
_penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded);
_collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator));
_resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator, actorObjectService));
_redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager));
_textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw));
SubscribeMediatorEvents();
CheckAPI();
CheckModDirectory();
Mediator.Subscribe<PenumbraRedrawCharacterMessage>(this, (msg) =>
{
_penumbraRedraw.Invoke(msg.Character.ObjectIndex, RedrawType.AfterGPose);
});
Mediator.Subscribe<DalamudLoginMessage>(this, (msg) => _shownPenumbraUnavailable = false);
}
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
public string? ModDirectory
{
bool penumbraAvailable = false;
try
get => _modDirectory;
private set
{
var penumbraVersion = (_pi.InstalledPlugins
.FirstOrDefault(p => string.Equals(p.InternalName, "Penumbra", StringComparison.OrdinalIgnoreCase))
?.Version ?? new Version(0, 0, 0, 0));
penumbraAvailable = penumbraVersion >= new Version(1, 2, 0, 22);
try
if (string.Equals(_modDirectory, value, StringComparison.Ordinal))
{
penumbraAvailable &= _penumbraEnabled.Invoke();
}
catch
{
penumbraAvailable = false;
}
_shownPenumbraUnavailable = _shownPenumbraUnavailable && !penumbraAvailable;
APIAvailable = penumbraAvailable;
}
catch
{
APIAvailable = penumbraAvailable;
}
finally
{
if (!penumbraAvailable && !_shownPenumbraUnavailable)
{
_shownPenumbraUnavailable = true;
_lightlessMediator.Publish(new NotificationMessage("Penumbra inactive",
"Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.",
NotificationType.Error));
return;
}
_modDirectory = value;
Mediator.Publish(new PenumbraDirectoryChangedMessage(_modDirectory));
}
}
public Task AssignTemporaryCollectionAsync(ILogger logger, Guid collectionId, int objectIndex)
=> _collections.AssignTemporaryCollectionAsync(logger, collectionId, objectIndex);
public Task<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
=> _collections.CreateTemporaryCollectionAsync(logger, uid);
public Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collectionId)
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths);
public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);
public Task<Dictionary<string, HashSet<string>>?> GetCharacterData(ILogger logger, GameObjectHandler handler)
=> _resources.GetCharacterDataAsync(logger, handler);
public string GetMetaManipulations()
=> _resources.GetMetaManipulations();
public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
=> _resources.ResolvePathsAsync(forward, reverse);
public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
=> _redraw.RedrawAsync(logger, handler, applicationId, token);
public Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token);
public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
=> _textures.ConvertTextureFileDirectAsync(job, token);
public void CheckModDirectory()
{
if (!APIAvailable)
{
ModDirectory = string.Empty;
return;
}
else
try
{
ModDirectory = _penumbraResolveModDir!.Invoke().ToLowerInvariant();
ModDirectory = _penumbraGetModDirectory.Invoke().ToLowerInvariant();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to resolve Penumbra mod directory");
}
}
protected override bool IsPluginEnabled(IExposedPlugin plugin)
{
try
{
return _penumbraEnabled.Invoke();
}
catch
{
return false;
}
}
protected override void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current)
{
base.OnConnectionStateChanged(previous, current);
if (current == IpcConnectionState.Available)
{
_shownPenumbraUnavailable = false;
if (string.IsNullOrEmpty(ModDirectory))
{
CheckModDirectory();
}
return;
}
ModDirectory = string.Empty;
_redraw.CancelPendingRedraws();
if (_shownPenumbraUnavailable || current == IpcConnectionState.Unknown)
{
return;
}
_shownPenumbraUnavailable = true;
Mediator.Publish(new NotificationMessage(
"Penumbra inactive",
"Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.",
NotificationType.Error));
}
private void SubscribeMediatorEvents()
{
Mediator.Subscribe<PenumbraRedrawCharacterMessage>(this, msg =>
{
_redraw.RequestImmediateRedraw(msg.Character.ObjectIndex, RedrawType.AfterGPose);
});
Mediator.Subscribe<DalamudLoginMessage>(this, _ => _shownPenumbraUnavailable = false);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => _resources.TrackActor(msg.Descriptor.Address));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => _resources.UntrackActor(msg.Descriptor.Address));
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, msg => _resources.TrackActor(msg.GameObjectHandler.Address));
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, msg => _resources.UntrackActor(msg.GameObjectHandler.Address));
}
private void HandlePenumbraInitialized()
{
Mediator.Publish(new PenumbraInitializedMessage());
CheckModDirectory();
_redraw.RequestImmediateRedraw(0, RedrawType.Redraw);
CheckAPI();
}
private void HandlePenumbraDisposed()
{
_redraw.CancelPendingRedraws();
ModDirectory = string.Empty;
Mediator.Publish(new PenumbraDisposedMessage());
CheckAPI();
}
private void HandlePenumbraModSettingChanged(ModSettingChange change, Guid _, string __, bool ___)
{
if (change == ModSettingChange.EnableState)
{
Mediator.Publish(new PenumbraModSettingChangedMessage());
CheckAPI();
}
}
@@ -148,196 +207,13 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa
{
base.Dispose(disposing);
_redrawManager.Cancel();
if (!disposing)
{
return;
}
_penumbraModSettingChanged.Dispose();
_penumbraGameObjectResourcePathResolved.Dispose();
_penumbraDispose.Dispose();
_penumbraInit.Dispose();
_penumbraObjectIsRedrawn.Dispose();
}
public async Task AssignTemporaryCollectionAsync(ILogger logger, Guid collName, int idx)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var retAssign = _penumbraAssignTemporaryCollection.Invoke(collName, idx, forceAssignment: true);
logger.LogTrace("Assigning Temp Collection {collName} to index {idx}, Success: {ret}", collName, idx, retAssign);
return collName;
}).ConfigureAwait(false);
}
public async Task ConvertTextureFiles(ILogger logger, Dictionary<string, string[]> textures, IProgress<(string, int)> progress, CancellationToken token)
{
if (!APIAvailable) return;
_lightlessMediator.Publish(new HaltScanMessage(nameof(ConvertTextureFiles)));
int currentTexture = 0;
foreach (var texture in textures)
{
if (token.IsCancellationRequested) break;
progress.Report((texture.Key, ++currentTexture));
logger.LogInformation("Converting Texture {path} to {type}", texture.Key, TextureType.Bc7Tex);
var convertTask = _penumbraConvertTextureFile.Invoke(texture.Key, texture.Key, TextureType.Bc7Tex, mipMaps: true);
await convertTask.ConfigureAwait(false);
if (convertTask.IsCompletedSuccessfully && texture.Value.Any())
{
foreach (var duplicatedTexture in texture.Value)
{
logger.LogInformation("Migrating duplicate {dup}", duplicatedTexture);
try
{
File.Copy(texture.Key, duplicatedTexture, overwrite: true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to copy duplicate {dup}", duplicatedTexture);
}
}
}
}
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles)));
await _dalamudUtil.RunOnFrameworkThread(async () =>
{
var gameObject = await _dalamudUtil.CreateGameObjectAsync(await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false)).ConfigureAwait(false);
_penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw);
}).ConfigureAwait(false);
}
public async Task<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
{
if (!APIAvailable) return Guid.Empty;
return await _dalamudUtil.RunOnFrameworkThread(() =>
{
var collName = "Lightless_" + uid;
_penumbraCreateNamedTemporaryCollection.Invoke(collName, collName, out var collId);
logger.LogTrace("Creating Temp Collection {collName}, GUID: {collId}", collName, collId);
return collId;
}).ConfigureAwait(false);
}
public async Task<Dictionary<string, HashSet<string>>?> GetCharacterData(ILogger logger, GameObjectHandler handler)
{
if (!APIAvailable) return null;
return await _dalamudUtil.RunOnFrameworkThread(() =>
{
logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths");
var idx = handler.GetGameObject()?.ObjectIndex;
if (idx == null) return null;
return _penumbraResourcePaths.Invoke(idx.Value)[0];
}).ConfigureAwait(false);
}
public string GetMetaManipulations()
{
if (!APIAvailable) return string.Empty;
return _penumbraGetMetaManipulations.Invoke();
}
public async Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
{
if (!APIAvailable || _dalamudUtil.IsZoning) return;
try
{
await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false);
await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, (chara) =>
{
logger.LogDebug("[{appid}] Calling on IPC: PenumbraRedraw", applicationId);
_penumbraRedraw!.Invoke(chara.ObjectIndex, setting: RedrawType.Redraw);
}, token).ConfigureAwait(false);
}
finally
{
_redrawManager.RedrawSemaphore.Release();
}
}
public async Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collId)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
logger.LogTrace("[{applicationId}] Removing temp collection for {collId}", applicationId, collId);
var ret2 = _penumbraRemoveTemporaryCollection.Invoke(collId);
logger.LogTrace("[{applicationId}] RemoveTemporaryCollection: {ret2}", applicationId, ret2);
}).ConfigureAwait(false);
}
public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
{
return await _penumbraResolvePaths.Invoke(forward, reverse).ConfigureAwait(false);
}
public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collId, string manipulationData)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
logger.LogTrace("[{applicationId}] Manip: {data}", applicationId, manipulationData);
var retAdd = _penumbraAddTemporaryMod.Invoke("LightlessChara_Meta", collId, [], manipulationData, 0);
logger.LogTrace("[{applicationId}] Setting temp meta mod for {collId}, Success: {ret}", applicationId, collId, retAdd);
}).ConfigureAwait(false);
}
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collId, Dictionary<string, string> modPaths)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
foreach (var mod in modPaths)
{
logger.LogTrace("[{applicationId}] Change: {from} => {to}", applicationId, mod.Key, mod.Value);
}
var retRemove = _penumbraRemoveTemporaryMod.Invoke("LightlessChara_Files", collId, 0);
logger.LogTrace("[{applicationId}] Removing temp files mod for {collId}, Success: {ret}", applicationId, collId, retRemove);
var retAdd = _penumbraAddTemporaryMod.Invoke("LightlessChara_Files", collId, modPaths, string.Empty, 0);
logger.LogTrace("[{applicationId}] Setting temp files mod for {collId}, Success: {ret}", applicationId, collId, retAdd);
}).ConfigureAwait(false);
}
private void RedrawEvent(IntPtr objectAddress, int objectTableIndex)
{
bool wasRequested = false;
if (_penumbraRedrawRequests.TryGetValue(objectAddress, out var redrawRequest) && redrawRequest)
{
_penumbraRedrawRequests[objectAddress] = false;
}
else
{
_lightlessMediator.Publish(new PenumbraRedrawMessage(objectAddress, objectTableIndex, wasRequested));
}
}
private void ResourceLoaded(IntPtr ptr, string arg1, string arg2)
{
if (ptr != IntPtr.Zero && string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) != 0)
{
_lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2));
}
}
private void PenumbraDispose()
{
_redrawManager.Cancel();
_lightlessMediator.Publish(new PenumbraDisposedMessage());
}
private void PenumbraInit()
{
APIAvailable = true;
ModDirectory = _penumbraResolveModDir.Invoke();
_lightlessMediator.Publish(new PenumbraInitializedMessage());
_penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw);
}
}

View File

@@ -1,14 +1,17 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerPetNames : IIpcCaller
public sealed class IpcCallerPetNames : IpcServiceBase
{
private static readonly IpcServiceDescriptor PetRenamerDescriptor = new("PetRenamer", "Pet Renamer", new Version(0, 0, 0, 0));
private readonly ILogger<IpcCallerPetNames> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessMediator _lightlessMediator;
@@ -24,7 +27,7 @@ public sealed class IpcCallerPetNames : IIpcCaller
private readonly ICallGateSubscriber<ushort, object> _clearPlayerData;
public IpcCallerPetNames(ILogger<IpcCallerPetNames> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator)
LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, PetRenamerDescriptor)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
@@ -46,25 +49,6 @@ public sealed class IpcCallerPetNames : IIpcCaller
CheckAPI();
}
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
{
try
{
APIAvailable = _enabled?.InvokeFunc() ?? false;
if (APIAvailable)
{
APIAvailable = _apiVersion?.InvokeFunc() is { Item1: 4, Item2: >= 0 };
}
}
catch
{
APIAvailable = false;
}
}
private void OnPetNicknamesReady()
{
CheckAPI();
@@ -76,6 +60,34 @@ public sealed class IpcCallerPetNames : IIpcCaller
_lightlessMediator.Publish(new PetNamesMessage(string.Empty));
}
protected override IpcConnectionState EvaluateState()
{
var state = base.EvaluateState();
if (state != IpcConnectionState.Available)
{
return state;
}
try
{
var enabled = _enabled?.InvokeFunc() ?? false;
if (!enabled)
{
return IpcConnectionState.PluginDisabled;
}
var version = _apiVersion?.InvokeFunc() ?? (0u, 0u);
return version.Item1 == 4 && version.Item2 >= 0
? IpcConnectionState.Available
: IpcConnectionState.VersionMismatch;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to query Pet Renamer API version");
return IpcConnectionState.Error;
}
}
public string GetLocalNames()
{
if (!APIAvailable) return string.Empty;
@@ -149,8 +161,14 @@ public sealed class IpcCallerPetNames : IIpcCaller
_lightlessMediator.Publish(new PetNamesMessage(data));
}
public void Dispose()
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
{
return;
}
_petnamesReady.Unsubscribe(OnPetNicknamesReady);
_petnamesDisposing.Unsubscribe(OnPetNicknamesDispose);
_playerDataChanged.Unsubscribe(OnLocalPetNicknamesDataChange);

View File

@@ -1,4 +1,5 @@
using Dalamud.Game.ClientState.Objects.Types;
using System;
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using LightlessSync.PlayerData.Handlers;
@@ -14,9 +15,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
private readonly ILogger<IpcProvider> _logger;
private readonly IDalamudPluginInterface _pi;
private readonly CharaDataManager _charaDataManager;
private ICallGateProvider<string, IGameObject, bool>? _loadFileProvider;
private ICallGateProvider<string, IGameObject, Task<bool>>? _loadFileAsyncProvider;
private ICallGateProvider<List<nint>>? _handledGameAddresses;
private readonly List<IpcRegister> _ipcRegisters = [];
private readonly List<GameObjectHandler> _activeGameObjectHandlers = [];
public LightlessMediator Mediator { get; init; }
@@ -44,12 +43,9 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting IpcProviderService");
_loadFileProvider = _pi.GetIpcProvider<string, IGameObject, bool>("LightlessSync.LoadMcdf");
_loadFileProvider.RegisterFunc(LoadMcdf);
_loadFileAsyncProvider = _pi.GetIpcProvider<string, IGameObject, Task<bool>>("LightlessSync.LoadMcdfAsync");
_loadFileAsyncProvider.RegisterFunc(LoadMcdfAsync);
_handledGameAddresses = _pi.GetIpcProvider<List<nint>>("LightlessSync.GetHandledAddresses");
_handledGameAddresses.RegisterFunc(GetHandledAddresses);
_ipcRegisters.Add(RegisterFunc<string, IGameObject, bool>("LightlessSync.LoadMcdf", LoadMcdf));
_ipcRegisters.Add(RegisterFunc<string, IGameObject, Task<bool>>("LightlessSync.LoadMcdfAsync", LoadMcdfAsync));
_ipcRegisters.Add(RegisterFunc("LightlessSync.GetHandledAddresses", GetHandledAddresses));
_logger.LogInformation("Started IpcProviderService");
return Task.CompletedTask;
}
@@ -57,9 +53,11 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Stopping IpcProvider Service");
_loadFileProvider?.UnregisterFunc();
_loadFileAsyncProvider?.UnregisterFunc();
_handledGameAddresses?.UnregisterFunc();
foreach (var register in _ipcRegisters)
{
register.Dispose();
}
_ipcRegisters.Clear();
Mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
@@ -89,4 +87,40 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
{
return _activeGameObjectHandlers.Where(g => g.Address != nint.Zero).Select(g => g.Address).Distinct().ToList();
}
private IpcRegister RegisterFunc(string label, Func<List<nint>> handler)
{
var provider = _pi.GetIpcProvider<List<nint>>(label);
provider.RegisterFunc(handler);
return new IpcRegister(provider.UnregisterFunc);
}
private IpcRegister RegisterFunc<T1, T2, TRet>(string label, Func<T1, T2, TRet> handler)
{
var provider = _pi.GetIpcProvider<T1, T2, TRet>(label);
provider.RegisterFunc(handler);
return new IpcRegister(provider.UnregisterFunc);
}
private sealed class IpcRegister : IDisposable
{
private readonly Action _unregister;
private bool _disposed;
public IpcRegister(Action unregister)
{
_unregister = unregister;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_unregister();
_disposed = true;
}
}
}

View File

@@ -0,0 +1,27 @@
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc.Penumbra;
public abstract class PenumbraBase : IpcInteropBase
{
protected PenumbraBase(
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator) : base(logger)
{
PluginInterface = pluginInterface;
DalamudUtil = dalamudUtil;
Mediator = mediator;
}
protected IDalamudPluginInterface PluginInterface { get; }
protected DalamudUtilService DalamudUtil { get; }
protected LightlessMediator Mediator { get; }
}

View File

@@ -0,0 +1,197 @@
using System.Collections.Concurrent;
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.Api.IpcSubscribers;
namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraCollections : PenumbraBase
{
private readonly CreateTemporaryCollection _createNamedTemporaryCollection;
private readonly AssignTemporaryCollection _assignTemporaryCollection;
private readonly DeleteTemporaryCollection _removeTemporaryCollection;
private readonly AddTemporaryMod _addTemporaryMod;
private readonly RemoveTemporaryMod _removeTemporaryMod;
private readonly GetCollections _getCollections;
private readonly ConcurrentDictionary<Guid, string> _activeTemporaryCollections = new();
private int _cleanupScheduled;
public PenumbraCollections(
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator)
{
_createNamedTemporaryCollection = new CreateTemporaryCollection(pluginInterface);
_assignTemporaryCollection = new AssignTemporaryCollection(pluginInterface);
_removeTemporaryCollection = new DeleteTemporaryCollection(pluginInterface);
_addTemporaryMod = new AddTemporaryMod(pluginInterface);
_removeTemporaryMod = new RemoveTemporaryMod(pluginInterface);
_getCollections = new GetCollections(pluginInterface);
}
public override string Name => "Penumbra.Collections";
public async Task AssignTemporaryCollectionAsync(ILogger logger, Guid collectionId, int objectIndex)
{
if (!IsAvailable || collectionId == Guid.Empty)
{
return;
}
await DalamudUtil.RunOnFrameworkThread(() =>
{
var result = _assignTemporaryCollection.Invoke(collectionId, objectIndex, forceAssignment: true);
logger.LogTrace("Assigning Temp Collection {CollectionId} to index {ObjectIndex}, Success: {Result}", collectionId, objectIndex, result);
return result;
}).ConfigureAwait(false);
}
public async Task<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
{
if (!IsAvailable)
{
return Guid.Empty;
}
var (collectionId, collectionName) = await DalamudUtil.RunOnFrameworkThread(() =>
{
var name = $"Lightless_{uid}";
_createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId);
logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}", name, tempCollectionId);
return (tempCollectionId, name);
}).ConfigureAwait(false);
if (collectionId != Guid.Empty)
{
_activeTemporaryCollections[collectionId] = collectionName;
}
return collectionId;
}
public async Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collectionId)
{
if (!IsAvailable || collectionId == Guid.Empty)
{
return;
}
await DalamudUtil.RunOnFrameworkThread(() =>
{
logger.LogTrace("[{ApplicationId}] Removing temp collection for {CollectionId}", applicationId, collectionId);
var result = _removeTemporaryCollection.Invoke(collectionId);
logger.LogTrace("[{ApplicationId}] RemoveTemporaryCollection: {Result}", applicationId, result);
}).ConfigureAwait(false);
_activeTemporaryCollections.TryRemove(collectionId, out _);
}
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, IReadOnlyDictionary<string, string> modPaths)
{
if (!IsAvailable || collectionId == Guid.Empty)
{
return;
}
await DalamudUtil.RunOnFrameworkThread(() =>
{
foreach (var mod in modPaths)
{
logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value);
}
var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0);
logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult);
var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, new Dictionary<string, string>(modPaths), string.Empty, 0);
logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult);
}).ConfigureAwait(false);
}
public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
{
if (!IsAvailable || collectionId == Guid.Empty)
{
return;
}
await DalamudUtil.RunOnFrameworkThread(() =>
{
logger.LogTrace("[{ApplicationId}] Manip: {Data}", applicationId, manipulationData);
var result = _addTemporaryMod.Invoke("LightlessChara_Meta", collectionId, [], manipulationData, 0);
logger.LogTrace("[{ApplicationId}] Setting temp meta mod for {CollectionId}, Success: {Result}", applicationId, collectionId, result);
}).ConfigureAwait(false);
}
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{
if (current == IpcConnectionState.Available)
{
ScheduleCleanup();
}
else if (previous == IpcConnectionState.Available && current != IpcConnectionState.Available)
{
Interlocked.Exchange(ref _cleanupScheduled, 0);
}
}
private void ScheduleCleanup()
{
if (Interlocked.Exchange(ref _cleanupScheduled, 1) != 0)
{
return;
}
_ = Task.Run(CleanupTemporaryCollectionsAsync);
}
private async Task CleanupTemporaryCollectionsAsync()
{
if (!IsAvailable)
{
return;
}
try
{
var collections = await DalamudUtil.RunOnFrameworkThread(() => _getCollections.Invoke()).ConfigureAwait(false);
foreach (var (collectionId, name) in collections)
{
if (!IsLightlessCollectionName(name) || _activeTemporaryCollections.ContainsKey(collectionId))
{
continue;
}
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
{
var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId);
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
return result;
}).ConfigureAwait(false);
if (deleteResult == PenumbraApiEc.Success)
{
_activeTemporaryCollections.TryRemove(collectionId, out _);
}
else
{
Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult);
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections");
}
}
private static bool IsLightlessCollectionName(string? name)
=> !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,89 @@
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraRedraw : PenumbraBase
{
private readonly RedrawManager _redrawManager;
private readonly RedrawObject _penumbraRedraw;
private readonly EventSubscriber<nint, int> _penumbraObjectIsRedrawn;
public PenumbraRedraw(
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator,
RedrawManager redrawManager) : base(logger, pluginInterface, dalamudUtil, mediator)
{
_redrawManager = redrawManager;
_penumbraRedraw = new RedrawObject(pluginInterface);
_penumbraObjectIsRedrawn = GameObjectRedrawn.Subscriber(pluginInterface, HandlePenumbraRedrawEvent);
}
public override string Name => "Penumbra.Redraw";
public void CancelPendingRedraws()
=> _redrawManager.Cancel();
public void RequestImmediateRedraw(int objectIndex, RedrawType redrawType)
{
if (!IsAvailable)
{
return;
}
_penumbraRedraw.Invoke(objectIndex, redrawType);
}
public async Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
{
if (!IsAvailable || DalamudUtil.IsZoning)
{
return;
}
var redrawSemaphore = _redrawManager.RedrawSemaphore;
var semaphoreAcquired = false;
try
{
await redrawSemaphore.WaitAsync(token).ConfigureAwait(false);
semaphoreAcquired = true;
await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, chara =>
{
logger.LogDebug("[{ApplicationId}] Calling on IPC: PenumbraRedraw", applicationId);
_penumbraRedraw.Invoke(chara.ObjectIndex, RedrawType.Redraw);
}, token).ConfigureAwait(false);
}
finally
{
if (semaphoreAcquired)
{
redrawSemaphore.Release();
}
}
}
private void HandlePenumbraRedrawEvent(IntPtr objectAddress, int objectTableIndex)
=> Mediator.Publish(new PenumbraRedrawMessage(objectAddress, objectTableIndex, false));
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{
}
public override void Dispose()
{
base.Dispose();
_penumbraObjectIsRedrawn.Dispose();
}
}

View File

@@ -0,0 +1,141 @@
using System.Collections.Concurrent;
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraResource : PenumbraBase
{
private readonly ActorObjectService _actorObjectService;
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
private readonly ResolvePlayerPathsAsync _resolvePlayerPaths;
private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations;
private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved;
private readonly ConcurrentDictionary<IntPtr, byte> _trackedActors = new();
public PenumbraResource(
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator,
ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator)
{
_actorObjectService = actorObjectService;
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
_resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface);
_getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface);
_gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded);
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
TrackActor(descriptor.Address);
}
}
public override string Name => "Penumbra.Resources";
public async Task<Dictionary<string, HashSet<string>>?> GetCharacterDataAsync(ILogger logger, GameObjectHandler handler)
{
if (!IsAvailable)
{
return null;
}
return await DalamudUtil.RunOnFrameworkThread(() =>
{
logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths");
var idx = handler.GetGameObject()?.ObjectIndex;
if (idx == null)
{
return null;
}
return _gameObjectResourcePaths.Invoke(idx.Value)[0];
}).ConfigureAwait(false);
}
public string GetMetaManipulations()
=> IsAvailable ? _getPlayerMetaManipulations.Invoke() : string.Empty;
public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forwardPaths, string[] reversePaths)
{
if (!IsAvailable)
{
return (Array.Empty<string>(), Array.Empty<string[]>());
}
return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false);
}
public void TrackActor(nint address)
{
if (address != nint.Zero)
{
_trackedActors[(IntPtr)address] = 0;
}
}
public void UntrackActor(nint address)
{
if (address != nint.Zero)
{
_trackedActors.TryRemove((IntPtr)address, out _);
}
}
private void HandleResourceLoaded(nint ptr, string resolvedPath, string gamePath)
{
if (ptr == nint.Zero)
{
return;
}
if (!_trackedActors.ContainsKey(ptr))
{
var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr);
if (descriptor.Address != nint.Zero)
{
_trackedActors[ptr] = 0;
}
else
{
return;
}
}
if (string.Compare(resolvedPath, gamePath, StringComparison.OrdinalIgnoreCase) == 0)
{
return;
}
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, resolvedPath, gamePath));
}
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{
if (current != IpcConnectionState.Available)
{
_trackedActors.Clear();
}
else
{
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
TrackActor(descriptor.Address);
}
}
}
public override void Dispose()
{
base.Dispose();
_gameObjectResourcePathResolved.Dispose();
}
}

View File

@@ -0,0 +1,121 @@
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.Api.IpcSubscribers;
namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraTexture : PenumbraBase
{
private readonly PenumbraRedraw _redrawFeature;
private readonly ConvertTextureFile _convertTextureFile;
public PenumbraTexture(
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator,
PenumbraRedraw redrawFeature) : base(logger, pluginInterface, dalamudUtil, mediator)
{
_redrawFeature = redrawFeature;
_convertTextureFile = new ConvertTextureFile(pluginInterface);
}
public override string Name => "Penumbra.Textures";
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
{
if (!IsAvailable || jobs.Count == 0)
{
return;
}
Mediator.Publish(new HaltScanMessage(nameof(ConvertTextureFilesAsync)));
var totalJobs = jobs.Count;
var completedJobs = 0;
try
{
foreach (var job in jobs)
{
if (token.IsCancellationRequested)
{
break;
}
progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job));
await ConvertSingleJobAsync(logger, job, token).ConfigureAwait(false);
completedJobs++;
}
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync)));
}
if (completedJobs > 0 && !token.IsCancellationRequested)
{
await DalamudUtil.RunOnFrameworkThread(async () =>
{
var player = await DalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false);
if (player == null)
{
return;
}
var gameObject = await DalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false);
if (gameObject == null)
{
return;
}
_redrawFeature.RequestImmediateRedraw(gameObject.ObjectIndex, RedrawType.Redraw);
}).ConfigureAwait(false);
}
}
public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
{
if (!IsAvailable)
{
return;
}
await ConvertSingleJobAsync(Logger, job, token).ConfigureAwait(false);
}
private async Task ConvertSingleJobAsync(ILogger logger, TextureConversionJob job, CancellationToken token)
{
token.ThrowIfCancellationRequested();
logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType);
var convertTask = _convertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps);
await convertTask.ConfigureAwait(false);
if (!convertTask.IsCompletedSuccessfully || job.DuplicateTargets is not { Count: > 0 })
{
return;
}
foreach (var duplicate in job.DuplicateTargets)
{
try
{
logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate);
File.Copy(job.OutputFile, duplicate, overwrite: true);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate);
}
}
}
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{
}
}

View File

@@ -0,0 +1,21 @@
using Penumbra.Api.Enums;
namespace LightlessSync.Interop.Ipc;
/// <summary>
/// Represents a single texture conversion request, including optional duplicate targets.
/// </summary>
public sealed record TextureConversionJob(
string InputFile,
string OutputFile,
TextureType TargetType,
bool IncludeMipMaps = true,
IReadOnlyList<string>? DuplicateTargets = null);
/// <summary>
/// Progress payload for a texture conversion batch.
/// </summary>
/// <param name="Completed">Number of completed conversions.</param>
/// <param name="Total">Total number of conversions scheduled.</param>
/// <param name="CurrentJob">The job currently being processed.</param>
public sealed record TextureConversionProgress(int Completed, int Total, TextureConversionJob CurrentJob);

View File

@@ -0,0 +1,14 @@
using LightlessSync.LightlessConfiguration.Configurations;
namespace LightlessSync.LightlessConfiguration;
public sealed class ChatConfigService : ConfigurationServiceBase<ChatConfig>
{
public const string ConfigName = "chatconfig.json";
public ChatConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,16 @@
using System;
namespace LightlessSync.LightlessConfiguration.Configurations;
[Serializable]
public sealed class ChatConfig : ILightlessConfiguration
{
public int Version { get; set; } = 1;
public bool AutoEnableChatOnLogin { get; set; } = false;
public bool ShowRulesOverlayOnOpen { get; set; } = true;
public bool ShowMessageTimestamps { get; set; } = true;
public float ChatWindowOpacity { get; set; } = .97f;
public bool IsWindowPinned { get; set; } = false;
public bool AutoOpenChatOnPluginLoad { get; set; } = false;
public float ChatFontScale { get; set; } = 1.0f;
}

View File

@@ -2,6 +2,7 @@ using Dalamud.Game.Text;
using LightlessSync.UtilsEnum.Enum;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -48,6 +49,7 @@ public class LightlessConfig : ILightlessConfiguration
public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;
@@ -61,8 +63,11 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowOnlineNotifications { get; set; } = false;
public bool ShowOnlineNotificationsOnlyForIndividualPairs { get; set; } = true;
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
public bool ShowVisiblePairsGreenEye { get; set; } = false;
public bool ShowTransferBars { get; set; } = true;
public bool ShowTransferWindow { get; set; } = false;
public bool ShowPlayerLinesTransferWindow { get; set; } = true;
public bool ShowPlayerSpeedBarsTransferWindow { get; set; } = true;
public bool UseNotificationsForDownloads { get; set; } = true;
public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true;

View File

@@ -4,6 +4,7 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
{
public int Version { get; set; } = 1;
public bool ShowPerformanceIndicator { get; set; } = true;
public bool ShowPerformanceUsageNextToName { get; set; } = false;
public bool WarnOnExceedingThresholds { get; set; } = true;
public bool WarnOnPreferredPermissionsExceedingThresholds { get; set; } = false;
public int VRAMSizeWarningThresholdMiB { get; set; } = 375;
@@ -16,4 +17,9 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public bool PauseInInstanceDuty { get; set; } = false;
public bool PauseWhilePerforming { get; set; } = true;
public bool PauseInCombat { get; set; } = true;
public bool EnableNonIndexTextureMipTrim { get; set; } = false;
public bool EnableIndexTextureDownscale { get; set; } = false;
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool KeepOriginalTextureFiles { get; set; } = false;
}

View File

@@ -13,6 +13,8 @@ public class TransientConfig : ILightlessConfiguration
public Dictionary<uint, List<string>> JobSpecificCache { get; set; } = [];
public Dictionary<uint, List<string>> JobSpecificPetCache { get; set; } = [];
private readonly object _cacheLock = new();
public TransientPlayerConfig()
{
@@ -39,45 +41,51 @@ public class TransientConfig : ILightlessConfiguration
public int RemovePath(string gamePath, ObjectKind objectKind)
{
int removedEntries = 0;
if (objectKind == ObjectKind.Player)
lock (_cacheLock)
{
if (GlobalPersistentCache.Remove(gamePath)) removedEntries++;
foreach (var kvp in JobSpecificCache)
int removedEntries = 0;
if (objectKind == ObjectKind.Player)
{
if (kvp.Value.Remove(gamePath)) removedEntries++;
if (GlobalPersistentCache.Remove(gamePath)) removedEntries++;
foreach (var kvp in JobSpecificCache)
{
if (kvp.Value.Remove(gamePath)) removedEntries++;
}
}
}
if (objectKind == ObjectKind.Pet)
{
foreach (var kvp in JobSpecificPetCache)
if (objectKind == ObjectKind.Pet)
{
if (kvp.Value.Remove(gamePath)) removedEntries++;
foreach (var kvp in JobSpecificPetCache)
{
if (kvp.Value.Remove(gamePath)) removedEntries++;
}
}
return removedEntries;
}
return removedEntries;
}
public void AddOrElevate(uint jobId, string gamePath)
{
// check if it's in the global cache, if yes, do nothing
if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal))
lock (_cacheLock)
{
return;
}
// check if it's in the global cache, if yes, do nothing
if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal))
{
return;
}
if (ElevateIfNeeded(jobId, gamePath)) return;
if (ElevateIfNeeded(jobId, gamePath)) return;
// check if the jobid is already in the cache to start
if (!JobSpecificCache.TryGetValue(jobId, out var jobCache))
{
JobSpecificCache[jobId] = jobCache = new();
}
// check if the jobid is already in the cache to start
if (!JobSpecificCache.TryGetValue(jobId, out var jobCache))
{
JobSpecificCache[jobId] = jobCache = new();
}
// check if the path is already in the job specific cache
if (!jobCache.Contains(gamePath, StringComparer.Ordinal))
{
jobCache.Add(gamePath);
// check if the path is already in the job specific cache
if (!jobCache.Contains(gamePath, StringComparer.Ordinal))
{
jobCache.Add(gamePath);
}
}
}
}

View File

@@ -1,29 +0,0 @@
namespace LightlessSync.LightlessConfiguration.Models.Obsolete;
[Serializable]
[Obsolete("Deprecated, use ServerStorage")]
public class ServerStorageV0
{
public List<Authentication> Authentications { get; set; } = [];
public bool FullPause { get; set; } = false;
public Dictionary<string, string> GidServerComments { get; set; } = new(StringComparer.Ordinal);
public HashSet<string> OpenPairTags { get; set; } = new(StringComparer.Ordinal);
public Dictionary<int, SecretKey> SecretKeys { get; set; } = [];
public HashSet<string> ServerAvailablePairTags { get; set; } = new(StringComparer.Ordinal);
public string ServerName { get; set; } = string.Empty;
public string ServerUri { get; set; } = string.Empty;
public Dictionary<string, string> UidServerComments { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, List<string>> UidServerPairedUserTags { get; set; } = new(StringComparer.Ordinal);
public ServerStorage ToV1()
{
return new ServerStorage()
{
ServerUri = ServerUri,
ServerName = ServerName,
Authentications = [.. Authentications],
FullPause = FullPause,
SecretKeys = SecretKeys.ToDictionary(p => p.Key, p => p.Value)
};
}
}

View File

@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>1.12.4</Version>
<Version>1.42.0.70</Version>
<Description></Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net9.0-windows7.0</TargetFramework>
<TargetFramework>net10.0-windows7.0</TargetFramework>
<Platforms>x64</Platforms>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
@@ -27,25 +27,25 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="13.0.0" />
<PackageReference Include="Blake3" Version="2.0.0" />
<PackageReference Include="Brio.API" Version="3.0.1" />
<PackageReference Include="Downloader" Version="4.0.3" />
<PackageReference Include="K4os.Compression.LZ4.Legacy" Version="1.3.8" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.212">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.264">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
<PackageReference Include="Glamourer.Api" Version="2.6.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
<PackageReference Include="Penumbra.String" Version="1.0.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.17.0.131074">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
@@ -77,7 +77,28 @@
<ItemGroup>
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
<ProjectReference Include="..\PenumbraAPI\Penumbra.Api.csproj" />
<ProjectReference Include="..\Penumbra.Api\Penumbra.Api.csproj" />
<ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" />
<ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" />
<ProjectReference Include="..\ffxiv_pictomancy\Pictomancy\Pictomancy.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="OtterTex">
<HintPath>lib\OtterTex.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<None Include="lib\DirectXTexC.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>DirectXTexC.dll</TargetPath>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Update="DalamudPackager" Version="14.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,7 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LightlessSync.PlayerData.Data;
@@ -13,37 +16,42 @@ public class FileReplacementDataComparer : IEqualityComparer<FileReplacementData
public bool Equals(FileReplacementData? x, FileReplacementData? y)
{
if (x == null || y == null) return false;
return x.Hash.Equals(y.Hash) && CompareHashSets(x.GamePaths.ToHashSet(StringComparer.Ordinal), y.GamePaths.ToHashSet(StringComparer.Ordinal)) && string.Equals(x.FileSwapPath, y.FileSwapPath, StringComparison.Ordinal);
if (ReferenceEquals(x, y))
return true;
if (x is null || y is null)
return false;
return string.Equals(x.Hash, y.Hash, StringComparison.OrdinalIgnoreCase)
&& ComparePathSets(x.GamePaths, y.GamePaths)
&& string.Equals(x.FileSwapPath ?? string.Empty, y.FileSwapPath ?? string.Empty, StringComparison.Ordinal);
}
public int GetHashCode(FileReplacementData obj)
{
return HashCode.Combine(obj.Hash.GetHashCode(StringComparison.OrdinalIgnoreCase), GetOrderIndependentHashCode(obj.GamePaths), StringComparer.Ordinal.GetHashCode(obj.FileSwapPath));
if (obj is null)
return 0;
var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Hash ?? string.Empty);
hash = HashCode.Combine(hash, GetSetHashCode(obj.GamePaths));
hash = HashCode.Combine(hash, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FileSwapPath ?? string.Empty));
return hash;
}
private static bool CompareHashSets(HashSet<string> list1, HashSet<string> list2)
private static bool ComparePathSets(IEnumerable<string> first, IEnumerable<string> second)
{
if (list1.Count != list2.Count)
return false;
for (int i = 0; i < list1.Count; i++)
{
if (!string.Equals(list1.ElementAt(i), list2.ElementAt(i), StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
var left = new HashSet<string>(first ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
var right = new HashSet<string>(second ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
return left.SetEquals(right);
}
private static int GetOrderIndependentHashCode<T>(IEnumerable<T> source) where T : notnull
private static int GetSetHashCode(IEnumerable<string> paths)
{
int hash = 0;
foreach (T element in source)
foreach (var element in paths ?? Enumerable.Empty<string>())
{
hash = unchecked(hash +
EqualityComparer<T>.Default.GetHashCode(element));
hash = unchecked(hash + StringComparer.OrdinalIgnoreCase.GetHashCode(element));
}
return hash;
}
}

View File

@@ -1,7 +1,7 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -9,13 +9,14 @@ namespace LightlessSync.PlayerData.Factories;
public class FileDownloadManagerFactory
{
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly TextureMetadataHelper _textureMetadataHelper;
public FileDownloadManagerFactory(
ILoggerFactory loggerFactory,
@@ -23,16 +24,18 @@ public class FileDownloadManagerFactory
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
PairProcessingLimiter pairProcessingLimiter,
LightlessConfigService configService)
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
TextureMetadataHelper textureMetadataHelper)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor;
_pairProcessingLimiter = pairProcessingLimiter;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_textureMetadataHelper = textureMetadataHelper;
}
public FileDownloadManager Create()
@@ -43,7 +46,8 @@ public class FileDownloadManagerFactory
_fileTransferOrchestrator,
_fileCacheManager,
_fileCompactor,
_pairProcessingLimiter,
_configService);
_configService,
_textureDownscaleService,
_textureMetadataHelper);
}
}

View File

@@ -2,29 +2,40 @@
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Factories;
public class GameObjectHandlerFactory
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly IServiceProvider _serviceProvider;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly PerformanceCollectorService _performanceCollectorService;
public GameObjectHandlerFactory(ILoggerFactory loggerFactory, PerformanceCollectorService performanceCollectorService, LightlessMediator lightlessMediator,
DalamudUtilService dalamudUtilService)
public GameObjectHandlerFactory(
ILoggerFactory loggerFactory,
PerformanceCollectorService performanceCollectorService,
LightlessMediator lightlessMediator,
IServiceProvider serviceProvider)
{
_loggerFactory = loggerFactory;
_performanceCollectorService = performanceCollectorService;
_lightlessMediator = lightlessMediator;
_dalamudUtilService = dalamudUtilService;
_serviceProvider = serviceProvider;
}
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
{
return await _dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(_loggerFactory.CreateLogger<GameObjectHandler>(),
_performanceCollectorService, _lightlessMediator, _dalamudUtilService, objectKind, getAddressFunc, isWatched)).ConfigureAwait(false);
var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>();
return await dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(
_loggerFactory.CreateLogger<GameObjectHandler>(),
_performanceCollectorService,
_lightlessMediator,
dalamudUtilService,
objectKind,
getAddressFunc,
isWatched)).ConfigureAwait(false);
}
}

View File

@@ -1,35 +1,83 @@
using LightlessSync.API.Dto.User;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
using LightlessSync.WebAPI;
namespace LightlessSync.PlayerData.Factories;
public class PairFactory
{
private readonly PairHandlerFactory _cachedPlayerFactory;
private readonly PairLedger _pairLedger;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly Lazy<ServerConfigurationManager> _serverConfigurationManager;
private readonly Lazy<ApiController> _apiController;
public PairFactory(ILoggerFactory loggerFactory, PairHandlerFactory cachedPlayerFactory,
LightlessMediator lightlessMediator, ServerConfigurationManager serverConfigurationManager)
public PairFactory(
ILoggerFactory loggerFactory,
PairLedger pairLedger,
LightlessMediator lightlessMediator,
Lazy<ServerConfigurationManager> serverConfigurationManager,
Lazy<ApiController> apiController)
{
_loggerFactory = loggerFactory;
_cachedPlayerFactory = cachedPlayerFactory;
_pairLedger = pairLedger;
_lightlessMediator = lightlessMediator;
_serverConfigurationManager = serverConfigurationManager;
_apiController = apiController;
}
public Pair Create(UserFullPairDto userPairDto)
{
return new Pair(_loggerFactory.CreateLogger<Pair>(), userPairDto, _cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager);
return CreateInternal(userPairDto);
}
public Pair Create(UserPairDto userPairDto)
{
return new Pair(_loggerFactory.CreateLogger<Pair>(), new(userPairDto.User, userPairDto.IndividualPairStatus, [], userPairDto.OwnPermissions, userPairDto.OtherPermissions),
_cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager);
var full = new UserFullPairDto(
userPairDto.User,
userPairDto.IndividualPairStatus,
new List<string>(),
userPairDto.OwnPermissions,
userPairDto.OtherPermissions);
return CreateInternal(full);
}
}
public Pair? Create(PairDisplayEntry entry)
{
var dto = new UserFullPairDto(
entry.User,
entry.PairStatus ?? IndividualPairStatus.None,
entry.Groups.Select(g => g.Group.GID).Distinct(StringComparer.Ordinal).ToList(),
entry.SelfPermissions,
entry.OtherPermissions);
return CreateInternal(dto);
}
public Pair? Create(PairUniqueIdentifier ident)
{
if (!_pairLedger.TryGetEntry(ident, out var entry) || entry is null)
{
return null;
}
return Create(entry);
}
private Pair CreateInternal(UserFullPairDto dto)
{
return new Pair(
_loggerFactory.CreateLogger<Pair>(),
dto,
_pairLedger,
_lightlessMediator,
_serverConfigurationManager.Value,
_apiController);
}
}

View File

@@ -1,55 +0,0 @@
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Factories;
public class PairHandlerFactory
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly FileCacheManager _fileCacheManager;
private readonly FileDownloadManagerFactory _fileDownloadManagerFactory;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly IpcManager _ipcManager;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
public PairHandlerFactory(ILoggerFactory loggerFactory, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager,
FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService,
PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime,
FileCacheManager fileCacheManager, LightlessMediator lightlessMediator, PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager)
{
_loggerFactory = loggerFactory;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_dalamudUtilService = dalamudUtilService;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_hostApplicationLifetime = hostApplicationLifetime;
_fileCacheManager = fileCacheManager;
_lightlessMediator = lightlessMediator;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
}
public PairHandler Create(Pair pair)
{
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _gameObjectHandlerFactory,
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
_fileCacheManager, _lightlessMediator, _playerPerformanceService, _pairProcessingLimiter, _serverConfigManager);
}
}

View File

@@ -5,6 +5,7 @@ using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
@@ -94,6 +95,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
public byte Gender { get; private set; }
public string Name { get; private set; }
public uint EntityId { get; private set; } = uint.MaxValue;
public ObjectKind ObjectKind { get; }
public byte RaceId { get; private set; }
public byte TribeId { get; private set; }
@@ -142,6 +144,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
{
Address = IntPtr.Zero;
DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue;
_haltProcessing = false;
}
@@ -171,13 +174,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Address = _getAddress();
if (Address != IntPtr.Zero)
{
var drawObjAddr = (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->DrawObject;
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
var drawObjAddr = (IntPtr)gameObject->DrawObject;
DrawObjectAddress = drawObjAddr;
EntityId = gameObject->EntityId;
CurrentDrawCondition = DrawCondition.None;
}
else
{
DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue;
CurrentDrawCondition = DrawCondition.DrawObjectZero;
}
@@ -371,8 +377,8 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
{
if (Address == IntPtr.Zero) return DrawCondition.ObjectZero;
if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero;
var renderFlags = (((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags) != 0x0;
if (renderFlags) return DrawCondition.RenderFlags;
var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags;
if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags;
if (ObjectKind == ObjectKind.Player)
{

View File

@@ -1,775 +0,0 @@
using LightlessSync.API.Data;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
public sealed class PairHandler : DisposableMediatorSubscriberBase
{
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
private readonly DalamudUtilService _dalamudUtil;
private readonly FileDownloadManager _downloadManager;
private readonly FileCacheManager _fileDbManager;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IpcManager _ipcManager;
private readonly IHostApplicationLifetime _lifetime;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private CancellationTokenSource? _applicationCancellationTokenSource = new();
private Guid _applicationId;
private Task? _applicationTask;
private CharacterData? _cachedData = null;
private GameObjectHandler? _charaHandler;
private readonly Dictionary<ObjectKind, Guid?> _customizeIds = [];
private CombatData? _dataReceivedInDowntime;
private CancellationTokenSource? _downloadCancellationTokenSource = new();
private bool _forceApplyMods = false;
private bool _isVisible;
private Guid _penumbraCollection;
private bool _redrawOnNextApplication = false;
public PairHandler(ILogger<PairHandler> logger, Pair pair,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager, FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager, LightlessMediator mediator,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager) : base(logger, mediator)
{
Pair = pair;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil;
_lifetime = lifetime;
_fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult();
Mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => FrameworkUpdate());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) =>
{
_downloadCancellationTokenSource?.CancelDispose();
_charaHandler?.Invalidate();
IsVisible = false;
});
Mediator.Subscribe<PenumbraInitializedMessage>(this, (_) =>
{
_penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult();
if (!IsVisible && _charaHandler != null)
{
PlayerName = string.Empty;
_charaHandler.Dispose();
_charaHandler = null;
}
});
Mediator.Subscribe<ClassJobChangedMessage>(this, (msg) =>
{
if (msg.GameObjectHandler == _charaHandler)
{
_redrawOnNextApplication = true;
}
});
Mediator.Subscribe<CombatEndMessage>(this, (msg) =>
{
EnableSync();
});
Mediator.Subscribe<CombatStartMessage>(this, _ =>
{
DisableSync();
});
Mediator.Subscribe<PerformanceEndMessage>(this, (msg) =>
{
EnableSync();
});
Mediator.Subscribe<PerformanceStartMessage>(this, _ =>
{
DisableSync();
});
Mediator.Subscribe<InstanceOrDutyStartMessage>(this, _ =>
{
DisableSync();
});
Mediator.Subscribe<InstanceOrDutyEndMessage>(this, (msg) =>
{
EnableSync();
});
LastAppliedDataBytes = -1;
}
public bool IsVisible
{
get => _isVisible;
private set
{
if (_isVisible != value)
{
_isVisible = value;
string text = "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible");
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler),
EventSeverity.Informational, text)));
Mediator.Publish(new RefreshUiMessage());
Mediator.Publish(new VisibilityChange());
}
}
}
public long LastAppliedDataBytes { get; private set; }
public Pair Pair { get; private set; }
public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero;
public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero
? uint.MaxValue
: ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId;
public string? PlayerName { get; private set; }
public string PlayerNameHash => Pair.Ident;
public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false)
{
if (_dalamudUtil.IsInCombat)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in combat, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_dalamudUtil.IsPerforming)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are performing music, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_dalamudUtil.IsInInstance)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in an instance, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_charaHandler == null || (PlayerCharacter == IntPtr.Zero))
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: Receiving Player is in an invalid state, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}",
applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero);
var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger,
this, forceApplyCustomization, forceApplyMods: false)
.Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles));
_forceApplyMods = hasDiffMods || _forceApplyMods || (PlayerCharacter == IntPtr.Zero && _cachedData == null);
_cachedData = characterData;
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
return;
}
SetUploading(isUploading: false);
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, this, forceApplyCustomization, _forceApplyMods);
Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA");
if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) && !forceApplyCustomization) return;
if (_dalamudUtil.IsInCutscene || _dalamudUtil.IsInGpose || !_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in GPose, a Cutscene or Penumbra/Glamourer is not available")));
Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while in cutscene/gpose or Penumbra/Glamourer unavailable, returning", applicationBase, this);
return;
}
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
"Applying Character Data")));
_forceApplyMods |= forceApplyCustomization;
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods);
if (_charaHandler != null && _forceApplyMods)
{
_forceApplyMods = false;
}
if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player))
{
player.Add(PlayerChanges.ForcedRedraw);
_redrawOnNextApplication = false;
}
if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges))
{
_pluginWarningNotificationManager.NotifyForMissingPlugins(Pair.UserData, PlayerName!, playerChanges);
}
Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, this);
DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate);
}
public override string ToString()
{
return Pair == null
? base.ToString() ?? string.Empty
: Pair.UserData.AliasOrUID + ":" + PlayerName + ":" + (PlayerCharacter != nint.Zero ? "HasChar" : "NoChar");
}
internal void SetUploading(bool isUploading = true)
{
Logger.LogTrace("Setting {this} uploading {uploading}", this, isUploading);
if (_charaHandler != null)
{
Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading));
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
SetUploading(isUploading: false);
var name = PlayerName;
Logger.LogDebug("Disposing {name} ({user})", name, Pair);
try
{
Guid applicationId = Guid.NewGuid();
_applicationCancellationTokenSource?.CancelDispose();
_applicationCancellationTokenSource = null;
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
_downloadManager.Dispose();
_charaHandler?.Dispose();
_charaHandler = null;
if (!string.IsNullOrEmpty(name))
{
Mediator.Publish(new EventMessage(new Event(name, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Disposing User")));
}
if (_lifetime.ApplicationStopping.IsCancellationRequested) return;
if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name))
{
Logger.LogTrace("[{applicationId}] Restoring state for {name} ({OnlineUser})", applicationId, name, Pair.UserPair);
Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, Pair.UserPair);
_ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, _penumbraCollection).GetAwaiter().GetResult();
if (!IsVisible)
{
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, Pair.UserPair);
_ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult();
}
else
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(60));
Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false);
foreach (KeyValuePair<ObjectKind, List<FileReplacementData>> item in _cachedData?.FileReplacements ?? [])
{
try
{
RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
break;
}
}
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error on disposal of {name}", name);
}
finally
{
PlayerName = null;
_cachedData = null;
Logger.LogDebug("Disposing {name} complete", name);
}
}
private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair<ObjectKind, HashSet<PlayerChanges>> changes, CharacterData charaData, CancellationToken token)
{
if (PlayerCharacter == nint.Zero) return;
var ptr = PlayerCharacter;
var handler = changes.Key switch
{
ObjectKind.Player => _charaHandler!,
ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false),
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
};
try
{
if (handler.Address == nint.Zero)
{
return;
}
Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
foreach (var change in changes.Value.OrderBy(p => (int)p))
{
Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler);
switch (change)
{
case PlayerChanges.Customize:
if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData))
{
_customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false);
}
else if (_customizeIds.TryGetValue(changes.Key, out var customizeId))
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
_customizeIds.Remove(changes.Key);
}
break;
case PlayerChanges.Heels:
await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false);
break;
case PlayerChanges.Honorific:
await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false);
break;
case PlayerChanges.Glamourer:
if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData))
{
await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false);
}
break;
case PlayerChanges.Moodles:
await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false);
break;
case PlayerChanges.PetNames:
await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false);
break;
case PlayerChanges.ForcedRedraw:
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
break;
default:
break;
}
token.ThrowIfCancellationRequested();
}
}
finally
{
if (handler != _charaHandler) handler.Dispose();
}
}
private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData)
{
if (!updatedData.Any())
{
Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, this);
return;
}
var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles));
var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip));
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var downloadToken = _downloadCancellationTokenSource.Token;
_ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false);
}
private Task? _pairDownloadTask;
private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData,
bool updateModdedPaths, bool updateManip, CancellationToken downloadToken)
{
await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false);
Dictionary<(string GamePath, string? Hash), string> moddedPaths = [];
if (updateModdedPaths)
{
int attempts = 0;
List<FileReplacementData> toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested)
{
if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted)
{
Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData);
await _pairDownloadTask.ConfigureAwait(false);
}
Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData);
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
$"Starting download for {toDownloadReplacements.Count} files")));
var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false);
if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles))
{
_downloadManager.ClearDownload();
return;
}
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false));
await _pairDownloadTask.ConfigureAwait(false);
if (downloadToken.IsCancellationRequested)
{
Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase);
return;
}
toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal))))
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false);
}
if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false))
return;
}
downloadToken.ThrowIfCancellationRequested();
var appToken = _applicationCancellationTokenSource?.Token;
while ((!_applicationTask?.IsCompleted ?? false)
&& !downloadToken.IsCancellationRequested
&& (!appToken?.IsCancellationRequested ?? false))
{
// block until current application is done
Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName);
await Task.Delay(250).ConfigureAwait(false);
}
if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) return;
_applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource();
var token = _applicationCancellationTokenSource.Token;
_applicationTask = ApplyCharacterDataAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token);
}
private async Task ApplyCharacterDataAsync(Guid applicationBase, CharacterData charaData, Dictionary<ObjectKind, HashSet<PlayerChanges>> updatedData, bool updateModdedPaths, bool updateManip,
Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token)
{
try
{
_applicationId = Guid.NewGuid();
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {this}: {appId}", applicationBase, this, _applicationId);
Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, _charaHandler);
await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, _charaHandler!, _applicationId, 30000, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
if (updateModdedPaths)
{
// ensure collection is set
var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false);
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false);
await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection,
moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false);
LastAppliedDataBytes = -1;
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
{
if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0;
LastAppliedDataBytes += path.Length;
}
}
if (updateManip)
{
await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, _penumbraCollection, charaData.ManipulationData).ConfigureAwait(false);
}
token.ThrowIfCancellationRequested();
foreach (var kind in updatedData)
{
await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false);
token.ThrowIfCancellationRequested();
}
_cachedData = charaData;
Logger.LogDebug("[{applicationId}] Application finished", _applicationId);
}
catch (Exception ex)
{
if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException))
{
IsVisible = false;
_forceApplyMods = true;
_cachedData = charaData;
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
}
else
{
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
}
}
}
private void FrameworkUpdate()
{
if (string.IsNullOrEmpty(PlayerName))
{
var pc = _dalamudUtil.FindPlayerByNameHash(Pair.Ident);
if (pc == default((string, nint))) return;
Logger.LogDebug("One-Time Initializing {this}", this);
Initialize(pc.Name);
Logger.LogDebug("One-Time Initialized {this}", this);
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational,
$"Initializing User For Character {pc.Name}")));
}
if (_charaHandler?.Address != nint.Zero && !IsVisible)
{
Guid appData = Guid.NewGuid();
IsVisible = true;
if (_cachedData != null)
{
Logger.LogTrace("[BASE-{appBase}] {this} visibility changed, now: {visi}, cached data exists", appData, this, IsVisible);
_ = Task.Run(() =>
{
ApplyCharacterData(appData, _cachedData!, forceApplyCustomization: true);
});
}
else
{
Logger.LogTrace("{this} visibility changed, now: {visi}, no cached data exists", this, IsVisible);
}
}
else if (_charaHandler?.Address == nint.Zero && IsVisible)
{
IsVisible = false;
_charaHandler.Invalidate();
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible);
}
}
private void Initialize(string name)
{
PlayerName = name;
_charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident), isWatched: false).GetAwaiter().GetResult();
_serverConfigManager.AutoPopulateNoteForUid(Pair.UserData.UID, name);
Mediator.Subscribe<HonorificReadyMessage>(this, async (_) =>
{
if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return;
Logger.LogTrace("Reapplying Honorific data for {this}", this);
await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false);
});
Mediator.Subscribe<PetNamesReadyMessage>(this, async (_) =>
{
if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return;
Logger.LogTrace("Reapplying Pet Names data for {this}", this);
await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false);
});
_ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, _charaHandler.GetGameObject()!.ObjectIndex).GetAwaiter().GetResult();
}
private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken)
{
nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident);
if (address == nint.Zero) return;
Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, Pair.UserData.AliasOrUID, name, objectKind);
if (_customizeIds.TryGetValue(objectKind, out var customizeId))
{
_customizeIds.Remove(objectKind);
}
if (objectKind == ObjectKind.Player)
{
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
tempHandler.CompareNameAndThrow(name);
Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
}
else if (objectKind == ObjectKind.MinionOrMount)
{
var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false);
if (minionOrMount != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
else if (objectKind == ObjectKind.Pet)
{
var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false);
if (pet != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
else if (objectKind == ObjectKind.Companion)
{
var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false);
if (companion != nint.Zero)
{
await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false);
using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => companion, isWatched: false).ConfigureAwait(false);
await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false);
}
}
}
private List<FileReplacementData> TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token)
{
Stopwatch st = Stopwatch.StartNew();
ConcurrentBag<FileReplacementData> missingFiles = [];
moddedDictionary = [];
ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new();
bool hasMigrationChanges = false;
try
{
var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList();
Parallel.ForEach(replacementList, new ParallelOptions()
{
CancellationToken = token,
MaxDegreeOfParallelism = 4
},
(item) =>
{
token.ThrowIfCancellationRequested();
var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash);
if (fileCache != null)
{
if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension))
{
hasMigrationChanges = true;
fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]);
}
foreach (var gamePath in item.GamePaths)
{
outputDict[(gamePath, item.Hash)] = fileCache.ResolvedFilepath;
}
}
else
{
Logger.LogTrace("Missing file: {hash}", item.Hash);
missingFiles.Add(item);
}
});
moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value);
foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList())
{
foreach (var gamePath in item.GamePaths)
{
Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath);
moddedDictionary[(gamePath, null)] = item.FileSwapPath;
}
}
}
catch (OperationCanceledException)
{
Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase);
throw;
}
catch (Exception ex)
{
Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase);
}
if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv();
st.Stop();
Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count);
return [.. missingFiles];
}
private void DisableSync()
{
_dataReceivedInDowntime = null;
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
}
private void EnableSync()
{
if (IsVisible && _dataReceivedInDowntime != null)
{
ApplyCharacterData(_dataReceivedInDowntime.ApplicationId,
_dataReceivedInDowntime.CharacterData, _dataReceivedInDowntime.Forced);
_dataReceivedInDowntime = null;
}
}
}

View File

@@ -0,0 +1,36 @@
using LightlessSync.API.Data;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// orchestrates the lifecycle of a paired character
/// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{
new string Ident { get; }
bool Initialized { get; }
bool IsVisible { get; }
bool ScheduledForDeletion { get; set; }
CharacterData? LastReceivedCharacterData { get; }
long LastAppliedDataBytes { get; }
new string? PlayerName { get; }
string PlayerNameHash { get; }
uint PlayerCharacterId { get; }
DateTime? LastDataReceivedAt { get; }
DateTime? LastApplyAttemptAt { get; }
DateTime? LastSuccessfulApplyAt { get; }
string? LastFailureReason { get; }
IReadOnlyList<string> LastBlockingConditions { get; }
bool IsApplying { get; }
bool IsDownloading { get; }
int PendingDownloadCount { get; }
int ForbiddenDownloadCount { get; }
void Initialize();
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);
void SetPaused(bool paused);
}

View File

@@ -0,0 +1,6 @@
namespace LightlessSync.PlayerData.Pairs;
public interface IPairHandlerAdapterFactory
{
IPairHandlerAdapter Create(string ident);
}

View File

@@ -0,0 +1,19 @@
using LightlessSync.API.Data;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// performance metrics for each pair handler
/// </summary>
public interface IPairPerformanceSubject
{
string Ident { get; }
string PlayerName { get; }
UserData UserData { get; }
bool IsPaused { get; }
bool IsDirectlyPaired { get; }
bool HasStickyPermissions { get; }
long LastAppliedApproximateVRAMBytes { get; set; }
long LastAppliedApproximateEffectiveVRAMBytes { get; set; }
long LastAppliedDataTris { get; set; }
}

View File

@@ -1,10 +0,0 @@
namespace LightlessSync.PlayerData.Pairs;
public record OptionalPluginWarning
{
public bool ShownHeelsWarning { get; set; } = false;
public bool ShownCustomizePlusWarning { get; set; } = false;
public bool ShownHonorificWarning { get; set; } = false;
public bool ShownMoodlesWarning { get; set; } = false;
public bool ShowPetNicknamesWarning { get; set; } = false;
}

View File

@@ -1,173 +1,156 @@
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text.SeStringHandling;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils;
using LightlessSync.UI;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// ui wrapper around a pair connection
/// </summary>
public class Pair
{
private readonly PairHandlerFactory _cachedPlayerFactory;
private readonly SemaphoreSlim _creationSemaphore = new(1);
private readonly PairLedger _pairLedger;
private readonly ILogger<Pair> _logger;
private readonly LightlessMediator _mediator;
private readonly ServerConfigurationManager _serverConfigurationManager;
private CancellationTokenSource _applicationCts = new();
private OnlineUserIdentDto? _onlineUserIdentDto = null;
private readonly Lazy<ApiController> _apiController;
public Pair(ILogger<Pair> logger, UserFullPairDto userPair, PairHandlerFactory cachedPlayerFactory,
LightlessMediator mediator, ServerConfigurationManager serverConfigurationManager)
private const int _lightlessPrefixColor = 708;
public Pair(
ILogger<Pair> logger,
UserFullPairDto userPair,
PairLedger pairLedger,
LightlessMediator mediator,
ServerConfigurationManager serverConfigurationManager,
Lazy<ApiController> apiController)
{
_logger = logger;
UserPair = userPair;
_cachedPlayerFactory = cachedPlayerFactory;
_pairLedger = pairLedger;
_mediator = mediator;
_serverConfigurationManager = serverConfigurationManager;
_apiController = apiController;
}
public bool HasCachedPlayer => CachedPlayer != null && !string.IsNullOrEmpty(CachedPlayer.PlayerName) && _onlineUserIdentDto != null;
private PairUniqueIdentifier PairIdent => UniqueIdent;
private IPairHandlerAdapter? TryGetHandler()
{
return _pairLedger.GetHandler(PairIdent);
}
private PairConnection? TryGetConnection()
{
return _pairLedger.TryGetEntry(PairIdent, out var entry) && entry is not null
? entry.Connection
: null;
}
public bool HasCachedPlayer => TryGetHandler() is not null;
public IndividualPairStatus IndividualPairStatus => UserPair.IndividualPairStatus;
public bool IsDirectlyPaired => IndividualPairStatus != IndividualPairStatus.None;
public bool IsOneSidedPair => IndividualPairStatus == IndividualPairStatus.OneSided;
public bool IsOnline => CachedPlayer != null;
public bool IsOnline => TryGetConnection()?.IsOnline ?? false;
public bool IsPaired => IndividualPairStatus == IndividualPairStatus.Bidirectional || UserPair.Groups.Any();
public bool IsPaused => UserPair.OwnPermissions.IsPaused();
public bool IsVisible => CachedPlayer?.IsVisible ?? false;
public CharacterData? LastReceivedCharacterData { get; set; }
public string? PlayerName => CachedPlayer?.PlayerName ?? string.Empty;
public long LastAppliedDataBytes => CachedPlayer?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris { get; set; } = -1;
public long LastAppliedApproximateVRAMBytes { get; set; } = -1;
public string Ident => _onlineUserIdentDto?.Ident ?? string.Empty;
public uint PlayerCharacterId => CachedPlayer?.PlayerCharacterId ?? uint.MaxValue;
public bool IsVisible => _pairLedger.IsPairVisible(PairIdent);
public CharacterData? LastReceivedCharacterData => TryGetHandler()?.LastReceivedCharacterData;
public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID;
public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1;
public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1;
public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1;
public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty;
public uint PlayerCharacterId => TryGetHandler()?.PlayerCharacterId ?? uint.MaxValue;
public PairUniqueIdentifier UniqueIdent => new(UserData.UID);
public UserData UserData => UserPair.User;
public UserFullPairDto UserPair { get; set; }
private PairHandler? CachedPlayer { get; set; }
public void AddContextMenu(IMenuOpenedArgs args)
{
if (CachedPlayer == null || (args.Target is not MenuTargetDefault target) || target.TargetObjectId != CachedPlayer.PlayerCharacterId || IsPaused) return;
SeStringBuilder seStringBuilder = new();
SeStringBuilder seStringBuilder2 = new();
SeStringBuilder seStringBuilder3 = new();
SeStringBuilder seStringBuilder4 = new();
var openProfileSeString = seStringBuilder.AddText("Open Profile").Build();
var reapplyDataSeString = seStringBuilder2.AddText("Reapply last data").Build();
var cyclePauseState = seStringBuilder3.AddText("Cycle pause state").Build();
var changePermissions = seStringBuilder4.AddText("Change Permissions").Build();
args.AddMenuItem(new MenuItem()
var handler = TryGetHandler();
if (handler is null)
{
Name = openProfileSeString,
OnClicked = (a) => _mediator.Publish(new ProfileOpenStandaloneMessage(this)),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
return;
}
if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused)
{
return;
}
UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
_mediator.Publish(new ProfileOpenStandaloneMessage(this));
return Task.CompletedTask;
});
args.AddMenuItem(new MenuItem()
UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
Name = reapplyDataSeString,
OnClicked = (a) => ApplyLastReceivedData(forced: true),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
ApplyLastReceivedData(forced: true);
return Task.CompletedTask;
});
args.AddMenuItem(new MenuItem()
UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
Name = changePermissions,
OnClicked = (a) => _mediator.Publish(new OpenPermissionWindow(this)),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
_mediator.Publish(new OpenPermissionWindow(this));
return Task.CompletedTask;
});
args.AddMenuItem(new MenuItem()
UiSharedService.AddContextMenuItem(args, name: "Cycle pause state", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{
Name = cyclePauseState,
OnClicked = (a) => _mediator.Publish(new CyclePauseMessage(UserData)),
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
TriggerCyclePause();
return Task.CompletedTask;
});
}
public void ApplyData(OnlineUserCharaDataDto data)
{
_applicationCts = _applicationCts.CancelRecreate();
LastReceivedCharacterData = data.CharaData;
_logger.LogTrace("Character data received for {Uid}; handler will process via registry.", UserData.UID);
}
if (CachedPlayer == null)
{
_logger.LogDebug("Received Data for {uid} but CachedPlayer does not exist, waiting", data.User.UID);
_ = Task.Run(async () =>
{
using var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(TimeSpan.FromSeconds(120));
var appToken = _applicationCts.Token;
using var combined = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, appToken);
while (CachedPlayer == null && !combined.Token.IsCancellationRequested)
{
await Task.Delay(250, combined.Token).ConfigureAwait(false);
}
if (!combined.IsCancellationRequested)
{
_logger.LogDebug("Applying delayed data for {uid}", data.User.UID);
ApplyLastReceivedData();
}
});
return;
}
ApplyLastReceivedData();
private void TriggerCyclePause()
{
_ = _apiController.Value.CyclePauseAsync(this);
}
public void ApplyLastReceivedData(bool forced = false)
{
if (CachedPlayer == null) return;
if (LastReceivedCharacterData == null) return;
var handler = TryGetHandler();
if (handler is null)
{
_logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID);
return;
}
CachedPlayer.ApplyCharacterData(Guid.NewGuid(), RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone())!, forced);
handler.ApplyLastReceivedData(forced);
}
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
{
try
var handler = TryGetHandler();
if (handler is null)
{
_creationSemaphore.Wait();
if (CachedPlayer != null) return;
if (dto == null && _onlineUserIdentDto == null)
{
CachedPlayer?.Dispose();
CachedPlayer = null;
return;
}
if (dto != null)
{
_onlineUserIdentDto = dto;
}
CachedPlayer?.Dispose();
CachedPlayer = _cachedPlayerFactory.Create(this);
_logger.LogTrace("CreateCachedPlayer skipped for {Uid}: handler unavailable.", UserData.UID);
return;
}
finally
if (!handler.Initialized)
{
_creationSemaphore.Release();
handler.Initialize();
}
}
@@ -178,7 +161,7 @@ public class Pair
public string GetPlayerNameHash()
{
return CachedPlayer?.PlayerNameHash ?? string.Empty;
return TryGetHandler()?.PlayerNameHash ?? string.Empty;
}
public bool HasAnyConnection()
@@ -188,21 +171,7 @@ public class Pair
public void MarkOffline(bool wait = true)
{
try
{
if (wait)
_creationSemaphore.Wait();
LastReceivedCharacterData = null;
var player = CachedPlayer;
CachedPlayer = null;
player?.Dispose();
_onlineUserIdentDto = null;
}
finally
{
if (wait)
_creationSemaphore.Release();
}
_logger.LogTrace("MarkOffline invoked for {Uid} (wait: {Wait}). New registry handles handler disposal.", UserData.UID, wait);
}
public void SetNote(string note)
@@ -212,47 +181,36 @@ public class Pair
internal void SetIsUploading()
{
CachedPlayer?.SetUploading();
var handler = TryGetHandler();
if (handler is null)
{
return;
}
handler.SetUploading(true);
}
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
public PairDebugInfo GetDebugInfo()
{
_logger.LogTrace("Removing not synced files");
if (data == null)
var handler = TryGetHandler();
if (handler is null)
{
_logger.LogTrace("Nothing to remove");
return data;
return PairDebugInfo.Empty;
}
bool disableIndividualAnimations = (UserPair.OtherPermissions.IsDisableAnimations() || UserPair.OwnPermissions.IsDisableAnimations());
bool disableIndividualVFX = (UserPair.OtherPermissions.IsDisableVFX() || UserPair.OwnPermissions.IsDisableVFX());
bool disableIndividualSounds = (UserPair.OtherPermissions.IsDisableSounds() || UserPair.OwnPermissions.IsDisableSounds());
_logger.LogTrace("Disable: Sounds: {disableIndividualSounds}, Anims: {disableIndividualAnims}; " +
"VFX: {disableGroupSounds}",
disableIndividualSounds, disableIndividualAnimations, disableIndividualVFX);
if (disableIndividualAnimations || disableIndividualSounds || disableIndividualVFX)
{
_logger.LogTrace("Data cleaned up: Animations disabled: {disableAnimations}, Sounds disabled: {disableSounds}, VFX disabled: {disableVFX}",
disableIndividualAnimations, disableIndividualSounds, disableIndividualVFX);
foreach (var objectKind in data.FileReplacements.Select(k => k.Key))
{
if (disableIndividualSounds)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase)))
.ToList();
if (disableIndividualAnimations)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) || p.EndsWith("pap", StringComparison.OrdinalIgnoreCase)))
.ToList();
if (disableIndividualVFX)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) || p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
}
return data;
return new PairDebugInfo(
true,
handler.Initialized,
handler.IsVisible,
handler.ScheduledForDeletion,
handler.LastDataReceivedAt,
handler.LastApplyAttemptAt,
handler.LastSuccessfulApplyAt,
handler.LastFailureReason,
handler.LastBlockingConditions,
handler.IsApplying,
handler.IsDownloading,
handler.PendingDownloadCount,
handler.ForbiddenDownloadCount);
}
}
}

View File

@@ -0,0 +1,136 @@
using LightlessSync.API.Dto.Group;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// handles group related pair events
/// </summary>
public sealed partial class PairCoordinator
{
public void HandleGroupChangePermissions(GroupPermissionDto dto)
{
var result = _pairManager.UpdateGroupPermissions(dto);
if (!result.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update permissions for group {GroupId}: {Error}", dto.Group.GID, result.Error);
}
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupFullInfo(GroupFullInfoDto dto)
{
var result = _pairManager.AddGroup(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add group {GroupId}: {Error}", dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairJoined(GroupPairFullInfoDto dto)
{
var result = _pairManager.AddOrUpdateGroupPair(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add group pair {Uid}/{Group}: {Error}", dto.User.UID, dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairLeft(GroupPairDto dto)
{
var deregistration = _pairManager.RemoveGroupPair(dto);
if (deregistration.Success && deregistration.Value is { } registration && registration.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true);
}
else if (!deregistration.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("RemoveGroupPair failed for {Uid}: {Error}", dto.User.UID, deregistration.Error);
}
if (deregistration.Success)
{
PublishPairDataChanged(groupChanged: true);
}
}
public void HandleGroupRemoved(GroupDto dto)
{
var removalResult = _pairManager.RemoveGroup(dto.Group.GID);
if (removalResult.Success)
{
foreach (var registration in removalResult.Value)
{
if (registration.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true);
}
}
}
else if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to remove group {Group}: {Error}", dto.Group.GID, removalResult.Error);
}
if (removalResult.Success)
{
PublishPairDataChanged(groupChanged: true);
}
}
public void HandleGroupInfoUpdate(GroupInfoDto dto)
{
var result = _pairManager.UpdateGroupInfo(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update group info for {Group}: {Error}", dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairPermissions(GroupPairUserPermissionDto dto)
{
var result = _pairManager.UpdateGroupPairPermissions(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update group pair permissions for {Group}: {Error}", dto.Group.GID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
public void HandleGroupPairStatus(GroupPairUserInfoDto dto, bool isSelf)
{
PairOperationResult result;
if (isSelf)
{
result = _pairManager.UpdateGroupStatus(dto);
}
else
{
result = _pairManager.UpdateGroupPairStatus(dto);
}
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update group status for {Group}:{Uid}: {Error}", dto.GID, dto.UID, result.Error);
return;
}
PublishPairDataChanged(groupChanged: true);
}
}

View File

@@ -0,0 +1,302 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// handles user pair events
/// </summary>
public sealed partial class PairCoordinator
{
public void HandleUserAddPair(UserPairDto dto, bool addToLastAddedUser = true)
{
var result = _pairManager.AddOrUpdateIndividual(dto, addToLastAddedUser);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add/update pair {Uid}: {Error}", dto.User.UID, result.Error);
return;
}
PublishPairDataChanged();
}
public void HandleUserAddPair(UserFullPairDto dto)
{
var result = _pairManager.AddOrUpdateIndividual(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to add/update full pair {Uid}: {Error}", dto.User.UID, result.Error);
return;
}
PublishPairDataChanged();
}
public void HandleUserRemovePair(UserDto dto)
{
var removal = _pairManager.RemoveIndividual(dto);
if (removal.Success && removal.Value is { } registration && registration.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true);
}
else if (!removal.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("RemoveIndividual failed for {Uid}: {Error}", dto.User.UID, removal.Error);
}
if (removal.Success)
{
_pendingCharacterData.TryRemove(dto.User.UID, out _);
PublishPairDataChanged();
}
}
public void HandleUserStatus(UserIndividualPairStatusDto dto)
{
var result = _pairManager.SetIndividualStatus(dto);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update individual pair status for {Uid}: {Error}", dto.User.UID, result.Error);
return;
}
PublishPairDataChanged();
}
public void HandleUserOnline(OnlineUserIdentDto dto, bool sendNotification)
{
var wasOnline = false;
PairConnection? previousConnection = null;
if (_pairManager.TryGetPair(dto.User.UID, out var existingConnection))
{
previousConnection = existingConnection;
wasOnline = existingConnection.IsOnline;
}
var registrationResult = _pairManager.MarkOnline(dto);
if (!registrationResult.Success)
{
_logger.LogDebug("MarkOnline failed for {Uid}: {Error}", dto.User.UID, registrationResult.Error);
return;
}
var registration = registrationResult.Value;
if (registration.CharacterIdent is null)
{
_logger.LogDebug("Online registration for {Uid} missing ident.", dto.User.UID);
}
else
{
var handlerResult = _handlerRegistry.RegisterOnlinePair(registration);
if (!handlerResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("RegisterOnlinePair failed for {Uid}: {Error}", dto.User.UID, handlerResult.Error);
}
}
var connectionResult = _pairManager.GetPair(dto.User.UID);
var connection = connectionResult.Success ? connectionResult.Value : previousConnection;
if (connection is not null)
{
_mediator.Publish(new ClearProfileUserDataMessage(connection.User));
}
else
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
}
if (!wasOnline)
{
NotifyUserOnline(connection, sendNotification);
}
if (registration.CharacterIdent is not null &&
_pendingCharacterData.TryRemove(dto.User.UID, out var pendingData))
{
var pendingRegistration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), registration.CharacterIdent);
var pendingApply = _handlerRegistry.ApplyCharacterData(pendingRegistration, pendingData);
if (!pendingApply.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Applying pending character data for {Uid} failed: {Error}", dto.User.UID, pendingApply.Error);
}
}
PublishPairDataChanged();
}
public void HandleUserOffline(UserData user)
{
var registrationResult = _pairManager.MarkOffline(user);
if (registrationResult.Success)
{
_pendingCharacterData.TryRemove(user.UID, out _);
if (registrationResult.Value.CharacterIdent is not null)
{
_ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value);
}
_mediator.Publish(new ClearProfileUserDataMessage(user));
PublishPairDataChanged();
}
else if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("MarkOffline failed for {Uid}: {Error}", user.UID, registrationResult.Error);
}
}
public void HandleUserPermissions(UserPermissionsDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Permission update received for unknown pair {Uid}", dto.User.UID);
}
return;
}
var connection = pairResult.Value;
var previous = connection.OtherToSelfPermissions;
var updateResult = _pairManager.UpdateOtherPermissions(dto);
if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error);
return;
}
PublishPairDataChanged();
if (previous.IsPaused() != dto.Permissions.IsPaused())
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
if (connection.Ident is not null)
{
var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused());
if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error);
}
}
}
if (!connection.IsPaused && connection.Ident is not null)
{
ReapplyLastKnownData(dto.User.UID, connection.Ident);
}
}
public void HandleSelfPermissions(UserPermissionsDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Self permission update received for unknown pair {Uid}", dto.User.UID);
}
return;
}
var connection = pairResult.Value;
var previous = connection.SelfToOtherPermissions;
var updateResult = _pairManager.UpdateSelfPermissions(dto);
if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update self permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error);
return;
}
PublishPairDataChanged();
if (previous.IsPaused() != dto.Permissions.IsPaused())
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
if (connection.Ident is not null)
{
var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused());
if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error);
}
}
}
if (!connection.IsPaused && connection.Ident is not null)
{
ReapplyLastKnownData(dto.User.UID, connection.Ident);
}
}
public void HandleUploadStatus(UserDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Upload status received for unknown pair {Uid}", dto.User.UID);
}
return;
}
var connection = pairResult.Value;
if (connection.Ident is null)
{
return;
}
var setResult = _handlerRegistry.SetUploading(new PairUniqueIdentifier(dto.User.UID), connection.Ident, true);
if (!setResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to set uploading for {Uid}: {Error}", dto.User.UID, setResult.Error);
}
}
public void HandleCharacterData(OnlineUserCharaDataDto dto)
{
var pairResult = _pairManager.GetPair(dto.User.UID);
if (!pairResult.Success)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Character data received for unknown pair {Uid}, queued for later.", dto.User.UID);
}
_pendingCharacterData[dto.User.UID] = dto;
return;
}
var connection = pairResult.Value;
_mediator.Publish(new EventMessage(new Event(connection.User, nameof(PairCoordinator), EventSeverity.Informational, "Received Character Data")));
if (connection.Ident is null)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Character data received for {Uid} without ident, queued for later.", dto.User.UID);
}
_pendingCharacterData[dto.User.UID] = dto;
return;
}
_pendingCharacterData.TryRemove(dto.User.UID, out _);
var registration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident);
var applyResult = _handlerRegistry.ApplyCharacterData(registration, dto);
if (!applyResult.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("ApplyCharacterData queued for {Uid}: {Error}", dto.User.UID, applyResult.Error);
}
}
public void HandleProfile(UserDto dto)
{
_mediator.Publish(new ClearProfileUserDataMessage(dto.User));
}
}

View File

@@ -0,0 +1,139 @@
using System.Collections.Concurrent;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// wires mediator events into the pair system
/// </summary>
public sealed partial class PairCoordinator : MediatorSubscriberBase
{
private readonly ILogger<PairCoordinator> _logger;
private readonly LightlessConfigService _configService;
private readonly LightlessMediator _mediator;
private readonly PairHandlerRegistry _handlerRegistry;
private readonly PairManager _pairManager;
private readonly PairLedger _pairLedger;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly PairPerformanceMetricsCache _metricsCache;
private readonly ConcurrentDictionary<string, OnlineUserCharaDataDto> _pendingCharacterData = new(StringComparer.Ordinal);
public PairCoordinator(
ILogger<PairCoordinator> logger,
LightlessConfigService configService,
LightlessMediator mediator,
PairHandlerRegistry handlerRegistry,
PairManager pairManager,
PairLedger pairLedger,
ServerConfigurationManager serverConfigurationManager,
PairPerformanceMetricsCache metricsCache)
: base(logger, mediator)
{
_logger = logger;
_configService = configService;
_mediator = mediator;
_handlerRegistry = handlerRegistry;
_pairManager = pairManager;
_pairLedger = pairLedger;
_serverConfigurationManager = serverConfigurationManager;
_metricsCache = metricsCache;
mediator.Subscribe<ActiveServerChangedMessage>(this, msg => HandleActiveServerChange(msg.ServerUrl));
mediator.Subscribe<DisconnectedMessage>(this, _ => HandleDisconnected());
}
internal PairLedger Ledger => _pairLedger;
private void PublishPairDataChanged(bool groupChanged = false)
{
_mediator.Publish(new RefreshUiMessage());
_mediator.Publish(new PairDataChangedMessage());
if (groupChanged)
{
_mediator.Publish(new GroupCollectionChangedMessage());
}
}
private void NotifyUserOnline(PairConnection? connection, bool sendNotification)
{
if (connection is null)
{
return;
}
var config = _configService.Current;
if (config.ShowOnlineNotifications && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Pair {Uid} marked online", connection.User.UID);
}
if (!sendNotification || !config.ShowOnlineNotifications)
{
return;
}
if (config.ShowOnlineNotificationsOnlyForIndividualPairs &&
(!connection.IsDirectlyPaired || connection.IsOneSided))
{
return;
}
var note = _serverConfigurationManager.GetNoteForUid(connection.User.UID);
if (config.ShowOnlineNotificationsOnlyForNamedPairs &&
string.IsNullOrEmpty(note))
{
return;
}
var message = !string.IsNullOrEmpty(note)
? $"{note} ({connection.User.AliasOrUID}) is now online"
: $"{connection.User.AliasOrUID} is now online";
_mediator.Publish(new NotificationMessage("User online", message, NotificationType.Info, TimeSpan.FromSeconds(5)));
}
private void ReapplyLastKnownData(string userId, string ident, bool forced = false)
{
var result = _handlerRegistry.ApplyLastReceivedData(new PairUniqueIdentifier(userId), ident, forced);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to reapply cached data for {Uid}: {Error}", userId, result.Error);
}
}
private void HandleActiveServerChange(string serverUrl)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Active server changed to {Server}", serverUrl);
}
ResetPairState();
}
private void HandleDisconnected()
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Lightless disconnected, clearing pair state");
}
ResetPairState();
}
private void ResetPairState()
{
_handlerRegistry.ResetAllHandlers();
_pairManager.ClearAll();
_pendingCharacterData.Clear();
_metricsCache.ClearAll();
_mediator.Publish(new ClearProfileUserDataMessage());
_mediator.Publish(new ClearProfileGroupDataMessage());
PublishPairDataChanged(groupChanged: true);
}
}

View File

@@ -0,0 +1,32 @@
namespace LightlessSync.PlayerData.Pairs;
public sealed record PairDebugInfo(
bool HasHandler,
bool HandlerInitialized,
bool HandlerVisible,
bool HandlerScheduledForDeletion,
DateTime? LastDataReceivedAt,
DateTime? LastApplyAttemptAt,
DateTime? LastSuccessfulApplyAt,
string? LastFailureReason,
IReadOnlyList<string> BlockingConditions,
bool IsApplying,
bool IsDownloading,
int PendingDownloadCount,
int ForbiddenDownloadCount)
{
public static PairDebugInfo Empty { get; } = new(
false,
false,
false,
false,
null,
null,
null,
null,
Array.Empty<string>(),
false,
false,
0,
0);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory
{
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _mediator;
private readonly PairManager _pairManager;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IpcManager _ipcManager;
private readonly FileDownloadManagerFactory _fileDownloadManagerFactory;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _lifetime;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
public PairHandlerAdapterFactory(
ILoggerFactory loggerFactory,
LightlessMediator mediator,
PairManager pairManager,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager,
FileDownloadManagerFactory fileDownloadManagerFactory,
PluginWarningNotificationService pluginWarningNotificationManager,
IServiceProvider serviceProvider,
IHostApplicationLifetime lifetime,
FileCacheManager fileCacheManager,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService,
PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache)
{
_loggerFactory = loggerFactory;
_mediator = mediator;
_pairManager = pairManager;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_serviceProvider = serviceProvider;
_lifetime = lifetime;
_fileCacheManager = fileCacheManager;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_textureDownscaleService = textureDownscaleService;
_pairStateCache = pairStateCache;
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
}
public IPairHandlerAdapter Create(string ident)
{
var downloadManager = _fileDownloadManagerFactory.Create();
var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>();
return new PairHandlerAdapter(
_loggerFactory.CreateLogger<PairHandlerAdapter>(),
_mediator,
_pairManager,
ident,
_gameObjectHandlerFactory,
_ipcManager,
downloadManager,
_pluginWarningNotificationManager,
dalamudUtilService,
_lifetime,
_fileCacheManager,
_playerPerformanceService,
_pairProcessingLimiter,
_serverConfigManager,
_textureDownscaleService,
_pairStateCache,
_pairPerformanceMetricsCache);
}
}

View File

@@ -0,0 +1,486 @@
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// creates, tracks, and removes pair handlers
/// </summary>
public sealed class PairHandlerRegistry : IDisposable
{
private readonly object _gate = new();
private readonly object _pendingGate = new();
private readonly Dictionary<string, PairHandlerEntry> _entriesByIdent = new(StringComparer.Ordinal);
private readonly Dictionary<IPairHandlerAdapter, PairHandlerEntry> _entriesByHandler = new(ReferenceEqualityComparer.Instance);
private readonly IPairHandlerAdapterFactory _handlerFactory;
private readonly PairManager _pairManager;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
private readonly ILogger<PairHandlerRegistry> _logger;
private readonly TimeSpan _deletionGracePeriod = TimeSpan.FromMinutes(5);
private static readonly TimeSpan _handlerReadyTimeout = TimeSpan.FromMinutes(3);
private const int _handlerReadyPollDelayMs = 500;
private readonly Dictionary<string, CancellationTokenSource> _pendingCharacterData = new(StringComparer.Ordinal);
public PairHandlerRegistry(
IPairHandlerAdapterFactory handlerFactory,
PairManager pairManager,
PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache,
ILogger<PairHandlerRegistry> logger)
{
_handlerFactory = handlerFactory;
_pairManager = pairManager;
_pairStateCache = pairStateCache;
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
_logger = logger;
}
public int GetVisibleUsersCount()
{
lock (_gate)
{
return _entriesByHandler.Keys.Count(handler => handler.IsVisible);
}
}
public bool IsIdentVisible(string ident)
{
lock (_gate)
{
return _entriesByIdent.TryGetValue(ident, out var entry) && entry.Handler.IsVisible;
}
}
public PairOperationResult<PairUniqueIdentifier> RegisterOnlinePair(PairRegistration registration)
{
if (registration.CharacterIdent is null)
{
return PairOperationResult<PairUniqueIdentifier>.Fail($"Registration for {registration.PairIdent.UserId} missing ident.");
}
IPairHandlerAdapter handler;
lock (_gate)
{
var entry = GetOrCreateEntry(registration.CharacterIdent);
handler = entry.Handler;
handler.ScheduledForDeletion = false;
entry.AddPair(registration.PairIdent);
if (!handler.Initialized)
{
handler.Initialize();
}
}
ApplyPauseStateForHandler(handler);
if (handler.LastReceivedCharacterData is null)
{
var cachedData = _pairStateCache.TryLoad(registration.CharacterIdent);
if (cachedData is not null)
{
handler.LoadCachedCharacterData(cachedData);
}
}
if (handler.LastReceivedCharacterData is not null &&
(handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0))
{
handler.ApplyLastReceivedData(forced: true);
}
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
}
public PairOperationResult<PairUniqueIdentifier> DeregisterOfflinePair(PairRegistration registration, bool forceDisposal = false)
{
if (registration.CharacterIdent is null)
{
return PairOperationResult<PairUniqueIdentifier>.Fail($"Deregister for {registration.PairIdent.UserId} missing ident.");
}
IPairHandlerAdapter? handler = null;
bool shouldScheduleRemoval = false;
bool shouldDisposeImmediately = false;
lock (_gate)
{
if (!_entriesByIdent.TryGetValue(registration.CharacterIdent, out var entry))
{
return PairOperationResult<PairUniqueIdentifier>.Fail($"Ident {registration.CharacterIdent} not registered.");
}
handler = entry.Handler;
entry.RemovePair(registration.PairIdent);
if (entry.PairCount == 0)
{
if (forceDisposal)
{
shouldDisposeImmediately = true;
}
else
{
shouldScheduleRemoval = true;
handler.ScheduledForDeletion = true;
}
}
}
if (shouldDisposeImmediately && handler is not null)
{
if (TryFinalizeHandlerRemoval(handler))
{
handler.Dispose();
}
}
else if (shouldScheduleRemoval && handler is not null)
{
_ = RemoveAfterGracePeriodAsync(handler);
}
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
}
public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
{
if (registration.CharacterIdent is null)
{
return PairOperationResult.Fail($"Character data received without ident for {registration.PairIdent.UserId}.");
}
if (!TryGetHandler(registration.CharacterIdent, out var handler) || handler is null)
{
var registerResult = RegisterOnlinePair(registration);
if (!registerResult.Success)
{
return PairOperationResult.Fail(registerResult.Error);
}
if (!TryGetHandler(registration.CharacterIdent, out handler) || handler is null)
{
QueuePendingCharacterData(registration, dto);
return PairOperationResult.Ok();
}
}
if (!handler.Initialized)
{
handler.Initialize();
QueuePendingCharacterData(registration, dto);
return PairOperationResult.Ok();
}
handler.ApplyData(dto.CharaData);
return PairOperationResult.Ok();
}
public PairOperationResult ApplyLastReceivedData(PairUniqueIdentifier pairIdent, string ident, bool forced = false)
{
if (!TryGetHandler(ident, out var handler) || handler is null)
{
return PairOperationResult.Fail($"Cannot reapply data: handler for {pairIdent.UserId} not found.");
}
handler.ApplyLastReceivedData(forced);
return PairOperationResult.Ok();
}
public PairOperationResult SetUploading(PairUniqueIdentifier pairIdent, string ident, bool uploading)
{
if (!TryGetHandler(ident, out var handler) || handler is null)
{
return PairOperationResult.Fail($"Cannot set uploading for {pairIdent.UserId}: handler not found.");
}
handler.SetUploading(uploading);
return PairOperationResult.Ok();
}
public PairOperationResult SetPausedState(PairUniqueIdentifier pairIdent, string ident, bool paused)
{
if (!TryGetHandler(ident, out var handler) || handler is null)
{
return PairOperationResult.Fail($"Cannot update pause state for {pairIdent.UserId}: handler not found.");
}
_ = paused; // value reflected in pair manager already
ApplyPauseStateForHandler(handler);
return PairOperationResult.Ok();
}
public PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>> GetPairConnections(string ident)
{
PairHandlerEntry? entry;
lock (_gate)
{
_entriesByIdent.TryGetValue(ident, out entry);
}
if (entry is null)
{
return PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>>.Fail($"No handler registered for {ident}.");
}
var list = new List<(PairUniqueIdentifier, PairConnection)>();
foreach (var pairIdent in entry.SnapshotPairs())
{
var result = _pairManager.GetPair(pairIdent.UserId);
if (result.Success)
{
list.Add((pairIdent, result.Value));
}
}
return PairOperationResult<IReadOnlyList<(PairUniqueIdentifier Ident, PairConnection Pair)>>.Ok(list);
}
private void ApplyPauseStateForHandler(IPairHandlerAdapter handler)
{
var pairs = _pairManager.GetPairsByIdent(handler.Ident);
bool paused = pairs.Any(p => p.SelfToOtherPermissions.IsPaused() || p.OtherToSelfPermissions.IsPaused());
handler.SetPaused(paused);
}
internal bool TryGetHandler(string ident, out IPairHandlerAdapter? handler)
{
lock (_gate)
{
var success = _entriesByIdent.TryGetValue(ident, out var entry);
handler = entry?.Handler;
return success;
}
}
internal IReadOnlyList<IPairHandlerAdapter> GetHandlerSnapshot()
{
lock (_gate)
{
return _entriesByHandler.Keys.ToList();
}
}
internal IReadOnlyCollection<PairUniqueIdentifier> GetRegisteredPairs(IPairHandlerAdapter handler)
{
lock (_gate)
{
if (_entriesByHandler.TryGetValue(handler, out var entry))
{
return entry.SnapshotPairs();
}
}
return Array.Empty<PairUniqueIdentifier>();
}
internal void ReapplyAll(bool forced = false)
{
var handlers = GetHandlerSnapshot();
foreach (var handler in handlers)
{
try
{
handler.ApplyLastReceivedData(forced);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to reapply cached data for {Ident}", handler.Ident);
}
}
}
}
internal void ResetAllHandlers()
{
List<IPairHandlerAdapter> handlers;
lock (_gate)
{
handlers = _entriesByHandler.Keys.ToList();
_entriesByIdent.Clear();
_entriesByHandler.Clear();
}
CancelAllPendingCharacterData();
foreach (var handler in handlers)
{
try
{
handler.Dispose();
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to dispose handler for {Ident}", handler.Ident);
}
}
finally
{
_pairPerformanceMetricsCache.Clear(handler.Ident);
}
}
}
public void Dispose()
{
List<IPairHandlerAdapter> handlers;
lock (_gate)
{
handlers = _entriesByHandler.Keys.ToList();
_entriesByIdent.Clear();
_entriesByHandler.Clear();
}
CancelAllPendingCharacterData();
foreach (var handler in handlers)
{
handler.Dispose();
_pairPerformanceMetricsCache.Clear(handler.Ident);
}
}
private PairHandlerEntry GetOrCreateEntry(string ident)
{
if (_entriesByIdent.TryGetValue(ident, out var entry))
{
return entry;
}
var handler = _handlerFactory.Create(ident);
entry = new PairHandlerEntry(ident, handler);
_entriesByIdent[ident] = entry;
_entriesByHandler[handler] = entry;
return entry;
}
private async Task RemoveAfterGracePeriodAsync(IPairHandlerAdapter handler)
{
await Task.Delay(_deletionGracePeriod).ConfigureAwait(false);
if (TryFinalizeHandlerRemoval(handler))
{
handler.Dispose();
}
}
private bool TryFinalizeHandlerRemoval(IPairHandlerAdapter handler)
{
string? ident = null;
lock (_gate)
{
if (!_entriesByHandler.TryGetValue(handler, out var entry) || entry.HasPairs)
{
handler.ScheduledForDeletion = false;
return false;
}
ident = entry.Ident;
_entriesByHandler.Remove(handler);
_entriesByIdent.Remove(entry.Ident);
}
if (ident is not null)
{
_pairPerformanceMetricsCache.Clear(ident);
CancelPendingCharacterData(ident);
}
return true;
}
private void QueuePendingCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
{
if (registration.CharacterIdent is null) return;
CancellationTokenSource? previous;
CancellationTokenSource cts;
lock (_pendingGate)
{
_pendingCharacterData.TryGetValue(registration.CharacterIdent, out previous);
previous?.Cancel();
cts = new CancellationTokenSource();
_pendingCharacterData[registration.CharacterIdent] = cts;
}
cts.CancelAfter(_handlerReadyTimeout);
_ = Task.Run(() => WaitThenApplyPendingCharacterDataAsync(registration, dto, cts.Token, cts));
}
private void CancelPendingCharacterData(string ident)
{
CancellationTokenSource? cts = null;
lock (_pendingGate)
{
if (_pendingCharacterData.TryGetValue(ident, out cts))
_pendingCharacterData.Remove(ident);
}
cts?.Cancel();
}
private void CancelAllPendingCharacterData()
{
List<CancellationTokenSource>? snapshot = null;
lock (_pendingGate)
{
if (_pendingCharacterData.Count > 0)
{
snapshot = [.. _pendingCharacterData.Values];
_pendingCharacterData.Clear();
}
}
if (snapshot is null) return;
foreach (var cts in snapshot) cts.Cancel();
}
private async Task WaitThenApplyPendingCharacterDataAsync(
PairRegistration registration,
OnlineUserCharaDataDto dto,
CancellationToken token,
CancellationTokenSource source)
{
if (registration.CharacterIdent is null)
{
return;
}
try
{
while (!token.IsCancellationRequested)
{
if (TryGetHandler(registration.CharacterIdent, out var handler) && handler is not null && handler.Initialized)
{
handler.ApplyData(dto.CharaData);
break;
}
await Task.Delay(_handlerReadyPollDelayMs, token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// expected
}
finally
{
lock (_pendingGate)
{
if (_pendingCharacterData.TryGetValue(registration.CharacterIdent, out var current) && ReferenceEquals(current, source))
{
_pendingCharacterData.Remove(registration.CharacterIdent);
}
}
source.Dispose();
}
}
}

View File

@@ -0,0 +1,294 @@
using LightlessSync.API.Dto.Group;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// keeps pair info for ui and reapplication
/// </summary>
public sealed class PairLedger : DisposableMediatorSubscriberBase
{
private readonly PairManager _pairManager;
private readonly PairHandlerRegistry _registry;
private readonly ILogger<PairLedger> _logger;
private readonly object _metricsGate = new();
private CancellationTokenSource? _ensureMetricsCts;
public PairLedger(
ILogger<PairLedger> logger,
LightlessMediator mediator,
PairManager pairManager,
PairHandlerRegistry registry) : base(logger, mediator)
{
_pairManager = pairManager;
_registry = registry;
_logger = logger;
Mediator.Subscribe<CutsceneEndMessage>(this, _ => ReapplyAll(forced: true));
Mediator.Subscribe<GposeEndMessage>(this, _ => ReapplyAll());
Mediator.Subscribe<PenumbraInitializedMessage>(this, _ => ReapplyAll(forced: true));
Mediator.Subscribe<FileCacheInitializedMessage>(this, _ => ReapplyAll(forced: true));
Mediator.Subscribe<DisconnectedMessage>(this, _ => Reset());
Mediator.Subscribe<ConnectedMessage>(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2)));
Mediator.Subscribe<HubReconnectedMessage>(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2)));
Mediator.Subscribe<DalamudLoginMessage>(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2)));
Mediator.Subscribe<VisibilityChange>(this, _ => EnsureMetricsForVisiblePairs());
}
public bool IsPairVisible(PairUniqueIdentifier pairIdent)
{
var connectionResult = _pairManager.GetPair(pairIdent.UserId);
if (!connectionResult.Success)
{
return false;
}
var connection = connectionResult.Value;
if (connection.Ident is null)
{
return false;
}
return _registry.IsIdentVisible(connection.Ident);
}
public IPairHandlerAdapter? GetHandler(PairUniqueIdentifier pairIdent)
{
var connectionResult = _pairManager.GetPair(pairIdent.UserId);
if (!connectionResult.Success)
{
return null;
}
var connection = connectionResult.Value;
if (connection.Ident is null)
{
return null;
}
return _registry.TryGetHandler(connection.Ident, out var handler) ? handler : null;
}
public IReadOnlyList<PairConnection> GetVisiblePairs()
{
return _pairManager.GetAllPairs()
.Select(kv => kv.Value)
.Where(connection => connection.Ident is not null && _registry.IsIdentVisible(connection.Ident))
.ToList();
}
public IReadOnlyList<GroupFullInfoDto> GetAllGroupInfos()
{
return _pairManager.GetAllGroups()
.Select(kv => kv.Value.GroupFullInfo)
.ToList();
}
public IReadOnlyDictionary<string, Syncshell> GetAllSyncshells()
{
return _pairManager.GetAllGroups();
}
public void ReapplyAll(bool forced = false)
{
if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace("Reapplying cached data for all handlers (forced: {Forced})", forced);
}
_registry.ReapplyAll(forced);
}
public void ReapplyPair(PairUniqueIdentifier pairIdent, bool forced = false)
{
var connectionResult = _pairManager.GetPair(pairIdent.UserId);
if (!connectionResult.Success)
{
return;
}
var connection = connectionResult.Value;
if (connection.Ident is null)
{
return;
}
var result = _registry.ApplyLastReceivedData(pairIdent, connection.Ident, forced);
if (!result.Success && _logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Failed to reapply data for {UserId}: {Error}", pairIdent.UserId, result.Error);
}
}
private void Reset()
{
if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace("Resetting pair handlers after disconnect.");
}
CancelScheduledMetrics();
}
public IReadOnlyList<PairDisplayEntry> GetAllEntries()
{
var groups = _pairManager.GetAllGroups();
var list = new List<PairDisplayEntry>();
foreach (var (userId, connection) in _pairManager.GetAllPairs())
{
var ident = new PairUniqueIdentifier(userId);
IPairHandlerAdapter? handler = null;
if (connection.Ident is not null)
{
_registry.TryGetHandler(connection.Ident, out handler);
}
var groupInfos = connection.Groups.Keys
.Select(gid =>
{
if (groups.TryGetValue(gid, out var shell))
{
return shell.GroupFullInfo;
}
return null;
})
.Where(dto => dto is not null)
.Cast<GroupFullInfoDto>()
.ToList();
list.Add(new PairDisplayEntry(ident, connection, groupInfos, handler));
}
return list;
}
public bool TryGetEntry(PairUniqueIdentifier ident, out PairDisplayEntry? entry)
{
entry = null;
var connectionResult = _pairManager.GetPair(ident.UserId);
if (!connectionResult.Success)
{
return false;
}
var connection = connectionResult.Value;
var groups = connection.Groups.Keys
.Select(gid =>
{
var groupResult = _pairManager.GetGroup(gid);
return groupResult.Success ? groupResult.Value.GroupFullInfo : null;
})
.Where(dto => dto is not null)
.Cast<GroupFullInfoDto>()
.ToList();
IPairHandlerAdapter? handler = null;
if (connection.Ident is not null)
{
_registry.TryGetHandler(connection.Ident, out handler);
}
entry = new PairDisplayEntry(ident, connection, groups, handler);
return true;
}
private void ScheduleEnsureMetrics(TimeSpan? delay = null)
{
lock (_metricsGate)
{
_ensureMetricsCts?.Cancel();
var cts = new CancellationTokenSource();
_ensureMetricsCts = cts;
_ = Task.Run(async () =>
{
try
{
if (delay is { } d && d > TimeSpan.Zero)
{
await Task.Delay(d, cts.Token).ConfigureAwait(false);
}
EnsureMetricsForVisiblePairs();
}
catch (OperationCanceledException)
{
// ignored
}
finally
{
lock (_metricsGate)
{
if (_ensureMetricsCts == cts)
{
_ensureMetricsCts = null;
}
}
cts.Dispose();
}
});
}
}
private void CancelScheduledMetrics()
{
lock (_metricsGate)
{
_ensureMetricsCts?.Cancel();
_ensureMetricsCts = null;
}
}
private void EnsureMetricsForVisiblePairs()
{
var handlers = _registry.GetHandlerSnapshot();
foreach (var handler in handlers)
{
if (!handler.IsVisible)
{
continue;
}
if (handler.LastReceivedCharacterData is null)
{
continue;
}
if (handler.LastAppliedApproximateVRAMBytes >= 0
&& handler.LastAppliedDataTris >= 0
&& handler.LastAppliedApproximateEffectiveVRAMBytes >= 0)
{
continue;
}
if (handler.FetchPerformanceMetricsFromCache())
{
continue;
}
try
{
handler.ApplyLastReceivedData(forced: true);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident);
}
}
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
CancelScheduledMetrics();
}
base.Dispose(disposing);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.Group;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// core models for the pair system
/// </summary>
public sealed class PairState
{
public CharacterData? CharacterData { get; set; }
public Guid? TemporaryCollectionId { get; set; }
public bool IsEmpty => CharacterData is null && (TemporaryCollectionId is null || TemporaryCollectionId == Guid.Empty);
}
public readonly record struct PairUniqueIdentifier(string UserId);
/// <summary>
/// link between a pair id and character ident
/// </summary>
public sealed record PairRegistration(PairUniqueIdentifier PairIdent, string? CharacterIdent);
/// <summary>
/// per group membership info for a pair
/// </summary>
public sealed class GroupPairRelationship
{
public GroupPairRelationship(string groupId, GroupPairUserInfo? info)
{
GroupId = groupId;
UserInfo = info;
}
public string GroupId { get; }
public GroupPairUserInfo? UserInfo { get; private set; }
public void SetUserInfo(GroupPairUserInfo? info)
{
UserInfo = info;
}
}
/// <summary>
/// runtime view of a single pair connection
/// </summary>
public sealed class PairConnection
{
public PairConnection(UserData user)
{
User = user;
Groups = new Dictionary<string, GroupPairRelationship>(StringComparer.Ordinal);
}
public UserData User { get; }
public bool IsOnline { get; private set; }
public string? Ident { get; private set; }
public UserPermissions SelfToOtherPermissions { get; private set; } = UserPermissions.NoneSet;
public UserPermissions OtherToSelfPermissions { get; private set; } = UserPermissions.NoneSet;
public IndividualPairStatus? IndividualPairStatus { get; private set; }
public Dictionary<string, GroupPairRelationship> Groups { get; }
public bool IsPaused => SelfToOtherPermissions.IsPaused();
public bool IsDirectlyPaired => IndividualPairStatus is not null && IndividualPairStatus != API.Data.Enum.IndividualPairStatus.None;
public bool IsOneSided => IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided;
public bool HasAnyConnection => IsDirectlyPaired || Groups.Count > 0;
public void SetOnline(string? ident)
{
IsOnline = true;
Ident = ident;
}
public void SetOffline()
{
IsOnline = false;
}
public void UpdatePermissions(UserPermissions own, UserPermissions other)
{
SelfToOtherPermissions = own;
OtherToSelfPermissions = other;
}
public void UpdateStatus(IndividualPairStatus? status)
{
IndividualPairStatus = status;
}
public void EnsureGroupRelationship(string groupId, GroupPairUserInfo? info)
{
if (Groups.TryGetValue(groupId, out var relationship))
{
relationship.SetUserInfo(info);
}
else
{
Groups[groupId] = new GroupPairRelationship(groupId, info);
}
}
public void RemoveGroupRelationship(string groupId)
{
Groups.Remove(groupId);
}
}
/// <summary>
/// syncshell metadata plus member connections
/// </summary>
public sealed class Syncshell
{
public Syncshell(GroupFullInfoDto dto)
{
GroupFullInfo = dto;
Users = new Dictionary<string, PairConnection>(StringComparer.Ordinal);
}
public GroupFullInfoDto GroupFullInfo { get; private set; }
public Dictionary<string, PairConnection> Users { get; }
public void Update(GroupFullInfoDto dto)
{
GroupFullInfo = dto;
}
}
/// <summary>
/// simple success/failure result
/// </summary>
public readonly struct PairOperationResult
{
private PairOperationResult(bool success, string? error)
{
Success = success;
Error = error;
}
public bool Success { get; }
public string? Error { get; }
public static PairOperationResult Ok() => new(true, null);
public static PairOperationResult Fail(string error) => new(false, error);
}
/// <summary>
/// typed success/failure result
/// </summary>
public readonly struct PairOperationResult<T>
{
private PairOperationResult(bool success, T value, string? error)
{
Success = success;
Value = value;
Error = error;
}
public bool Success { get; }
public T Value { get; }
public string? Error { get; }
public static PairOperationResult<T> Ok(T value) => new(true, value, null);
public static PairOperationResult<T> Fail(string error) => new(false, default!, error);
}
/// <summary>
/// state of which optional plugin warnings were shown
/// </summary>
public record OptionalPluginWarning
{
public bool ShownHeelsWarning { get; set; } = false;
public bool ShownCustomizePlusWarning { get; set; } = false;
public bool ShownHonorificWarning { get; set; } = false;
public bool ShownMoodlesWarning { get; set; } = false;
public bool ShowPetNicknamesWarning { get; set; } = false;
}
/// <summary>
/// tracks the handler registered pairs for an ident
/// </summary>
internal sealed class PairHandlerEntry
{
private readonly HashSet<PairUniqueIdentifier> _pairs = new();
public PairHandlerEntry(string ident, IPairHandlerAdapter handler)
{
Ident = ident;
Handler = handler;
}
public string Ident { get; }
public IPairHandlerAdapter Handler { get; }
public bool HasPairs => _pairs.Count > 0;
public int PairCount => _pairs.Count;
public void AddPair(PairUniqueIdentifier pair)
{
_pairs.Add(pair);
}
public bool RemovePair(PairUniqueIdentifier pair)
{
return _pairs.Remove(pair);
}
public IReadOnlyCollection<PairUniqueIdentifier> SnapshotPairs()
{
if (_pairs.Count == 0)
{
return Array.Empty<PairUniqueIdentifier>();
}
return _pairs.ToArray();
}
}

View File

@@ -0,0 +1,65 @@
using System.Collections.Concurrent;
namespace LightlessSync.PlayerData.Pairs;
public readonly record struct PairPerformanceMetrics(
long TriangleCount,
long ApproximateVramBytes,
long ApproximateEffectiveVramBytes);
/// <summary>
/// caches performance metrics keyed by pair ident
/// </summary>
public sealed class PairPerformanceMetricsCache
{
private sealed record CacheEntry(string DataHash, PairPerformanceMetrics Metrics);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
public bool TryGetMetrics(string ident, string dataHash, out PairPerformanceMetrics metrics)
{
metrics = default;
if (string.IsNullOrEmpty(ident) || string.IsNullOrEmpty(dataHash))
{
return false;
}
if (!_cache.TryGetValue(ident, out var entry))
{
return false;
}
if (!string.Equals(entry.DataHash, dataHash, StringComparison.Ordinal))
{
return false;
}
metrics = entry.Metrics;
return true;
}
public void StoreMetrics(string ident, string dataHash, PairPerformanceMetrics metrics)
{
if (string.IsNullOrEmpty(ident) || string.IsNullOrEmpty(dataHash))
{
return;
}
_cache[ident] = new CacheEntry(dataHash, metrics);
}
public void Clear(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return;
}
_cache.TryRemove(ident, out _);
}
public void ClearAll()
{
_cache.Clear();
}
}

View File

@@ -0,0 +1,119 @@
using System.Collections.Concurrent;
using LightlessSync.API.Data;
using LightlessSync.Utils;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// cache for character/pair data and penumbra collections
/// </summary>
public sealed class PairStateCache
{
private readonly ConcurrentDictionary<string, PairState> _cache = new(StringComparer.Ordinal);
public void Store(string ident, CharacterData data)
{
if (string.IsNullOrEmpty(ident) || data is null)
{
return;
}
var state = _cache.GetOrAdd(ident, _ => new PairState());
state.CharacterData = data.DeepClone();
}
public CharacterData? TryLoad(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return null;
}
if (_cache.TryGetValue(ident, out var state) && state.CharacterData is not null)
{
return state.CharacterData.DeepClone();
}
return null;
}
public Guid? TryGetTemporaryCollection(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return null;
}
if (_cache.TryGetValue(ident, out var state))
{
return state.TemporaryCollectionId;
}
return null;
}
public Guid? StoreTemporaryCollection(string ident, Guid collection)
{
if (string.IsNullOrEmpty(ident) || collection == Guid.Empty)
{
return null;
}
var state = _cache.GetOrAdd(ident, _ => new PairState());
state.TemporaryCollectionId = collection;
return collection;
}
public Guid? ClearTemporaryCollection(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return null;
}
if (_cache.TryGetValue(ident, out var state))
{
var existing = state.TemporaryCollectionId;
state.TemporaryCollectionId = null;
TryRemoveIfEmpty(ident, state);
return existing;
}
return null;
}
public IReadOnlyList<Guid> ClearAllTemporaryCollections()
{
var removed = new List<Guid>();
foreach (var (ident, state) in _cache)
{
if (state.TemporaryCollectionId is { } guid && guid != Guid.Empty)
{
removed.Add(guid);
state.TemporaryCollectionId = null;
}
TryRemoveIfEmpty(ident, state);
}
return removed;
}
public void Clear(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return;
}
_cache.TryRemove(ident, out _);
}
private void TryRemoveIfEmpty(string ident, PairState state)
{
if (state.IsEmpty)
{
_cache.TryRemove(ident, out _);
}
}
}

View File

@@ -1,4 +1,5 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data.Comparer;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
@@ -8,27 +9,29 @@ using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// pushes character data to visible pairs
/// </summary>
public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
{
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtil;
private readonly FileUploadManager _fileTransferManager;
private readonly PairManager _pairManager;
private readonly PairLedger _pairLedger;
private CharacterData? _lastCreatedData;
private CharacterData? _uploadingCharacterData = null;
private readonly List<UserData> _previouslyVisiblePlayers = [];
private Task<CharacterData>? _fileUploadTask = null;
private readonly HashSet<UserData> _usersToPushDataTo = [];
private readonly SemaphoreSlim _pushDataSemaphore = new(1, 1);
private readonly HashSet<UserData> _usersToPushDataTo = new(UserDataComparer.Instance);
private readonly SemaphoreSlim _pushLock = new(1, 1);
private readonly CancellationTokenSource _runtimeCts = new();
public VisibleUserDataDistributor(ILogger<VisibleUserDataDistributor> logger, ApiController apiController, DalamudUtilService dalamudUtil,
PairManager pairManager, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator)
PairLedger pairLedger, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator)
{
_apiController = apiController;
_dalamudUtil = dalamudUtil;
_pairManager = pairManager;
_pairLedger = pairLedger;
_fileTransferManager = fileTransferManager;
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => FrameworkOnUpdate());
Mediator.Subscribe<CharacterDataCreatedMessage>(this, (msg) =>
@@ -47,7 +50,14 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
});
Mediator.Subscribe<ConnectedMessage>(this, (_) => PushToAllVisibleUsers());
Mediator.Subscribe<DisconnectedMessage>(this, (_) => _previouslyVisiblePlayers.Clear());
Mediator.Subscribe<DisconnectedMessage>(this, (_) =>
{
_fileTransferManager.CancelUpload();
_previouslyVisiblePlayers.Clear();
_usersToPushDataTo.Clear();
_uploadingCharacterData = null;
_fileUploadTask = null;
});
}
protected override void Dispose(bool disposing)
@@ -63,7 +73,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
private void PushToAllVisibleUsers(bool forced = false)
{
foreach (var user in _pairManager.GetVisibleUsers())
foreach (var user in GetVisibleUsers())
{
_usersToPushDataTo.Add(user);
}
@@ -79,8 +89,8 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
{
if (!_dalamudUtil.GetIsPlayerPresent() || !_apiController.IsConnected) return;
var allVisibleUsers = _pairManager.GetVisibleUsers();
var newVisibleUsers = allVisibleUsers.Except(_previouslyVisiblePlayers).ToList();
var allVisibleUsers = GetVisibleUsers();
var newVisibleUsers = allVisibleUsers.Except(_previouslyVisiblePlayers, UserDataComparer.Instance).ToList();
_previouslyVisiblePlayers.Clear();
_previouslyVisiblePlayers.AddRange(allVisibleUsers);
if (newVisibleUsers.Count == 0) return;
@@ -98,46 +108,49 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase
private void PushCharacterData(bool forced = false)
{
if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return;
_ = PushCharacterDataAsync(forced);
}
_ = Task.Run(async () =>
private async Task PushCharacterDataAsync(bool forced = false)
{
await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
try
{
try
{
forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash;
if (_lastCreatedData == null || _usersToPushDataTo.Count == 0)
return;
if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced)
var hashChanged = _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash;
forced |= hashChanged;
if (_fileUploadTask == null || _fileUploadTask.IsCompleted || forced)
{
_uploadingCharacterData = _lastCreatedData.DeepClone();
var uploadTargets = _usersToPushDataTo.ToList();
Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}",
_lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forced);
_fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]);
_lastCreatedData.DataHash,
_fileUploadTask == null,
_fileUploadTask?.IsCompleted ?? false,
forced);
_fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, uploadTargets);
}
if (_fileUploadTask != null)
{
var dataToSend = await _fileUploadTask.ConfigureAwait(false);
await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
try
{
if (_usersToPushDataTo.Count == 0) return;
Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", _usersToPushDataTo.Select(k => k.AliasOrUID)));
await _apiController.PushCharacterData(dataToSend, [.. _usersToPushDataTo]).ConfigureAwait(false);
_usersToPushDataTo.Clear();
}
finally
{
_pushDataSemaphore.Release();
}
}
var dataToSend = await _fileUploadTask.ConfigureAwait(false);
var users = _usersToPushDataTo.ToList();
if (users.Count == 0)
return;
Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", users.Select(k => k.AliasOrUID)));
await _apiController.PushCharacterData(dataToSend, users).ConfigureAwait(false);
_usersToPushDataTo.Clear();
}
catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested)
finally
{
Logger.LogDebug("PushCharacterData cancelled");
_pushLock.Release();
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to push character data");
}
});
}
}
private List<UserData> GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)];
}

View File

@@ -20,6 +20,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
private readonly CancellationTokenSource _runtimeCts = new();
private CancellationTokenSource _creationCts = new();
private CancellationTokenSource _debounceCts = new();
private string? _lastPublishedHash;
private bool _haltCharaDataCreation;
private bool _isZoning = false;
@@ -183,7 +184,18 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
{
if (_isZoning || _haltCharaDataCreation) return;
if (_cachesToCreate.Count == 0) return;
bool hasCaches;
_cacheCreateLock.Wait();
try
{
hasCaches = _cachesToCreate.Count > 0;
}
finally
{
_cacheCreateLock.Release();
}
if (!hasCaches) return;
if (_playerRelatedObjects.Any(p => p.Value.CurrentDrawCondition is
not (GameObjectHandler.DrawCondition.None or GameObjectHandler.DrawCondition.DrawObjectZero or GameObjectHandler.DrawCondition.ObjectZero)))
@@ -197,6 +209,11 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
_creationCts = new();
_cacheCreateLock.Wait(_creationCts.Token);
var objectKindsToCreate = _cachesToCreate.ToList();
if (objectKindsToCreate.Count == 0)
{
_cacheCreateLock.Release();
return;
}
foreach (var creationObj in objectKindsToCreate)
{
_currentlyCreating.Add(creationObj);
@@ -225,8 +242,17 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
_playerData.SetFragment(kvp.Key, kvp.Value);
}
Mediator.Publish(new CharacterDataCreatedMessage(_playerData.ToAPI()));
_currentlyCreating.Clear();
var apiData = _playerData.ToAPI();
var currentHash = apiData.DataHash.Value;
if (string.Equals(_lastPublishedHash, currentHash, StringComparison.Ordinal))
{
Logger.LogTrace("Cache creation produced identical character data ({hash}), skipping publish.", currentHash);
}
else
{
_lastPublishedHash = currentHash;
Mediator.Publish(new CharacterDataCreatedMessage(apiData));
}
}
catch (OperationCanceledException)
{
@@ -238,6 +264,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase
}
finally
{
_currentlyCreating.Clear();
Logger.LogDebug("Cache Creation complete");
}
});

View File

@@ -13,14 +13,20 @@ using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Services;
using LightlessSync.Services;
using LightlessSync.Services.Chat;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.Rendering;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.UI.Components;
using LightlessSync.UI.Components.Popup;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Tags;
using LightlessSync.UI.Services;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.SignalR;
@@ -30,6 +36,9 @@ using Microsoft.Extensions.Logging;
using NReco.Logging.File;
using System.Net.Http.Headers;
using System.Reflection;
using OtterTex;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.PairProcessing;
namespace LightlessSync;
@@ -43,6 +52,7 @@ public sealed class Plugin : IDalamudPlugin
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle)
{
NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName);
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName);
var traceDir = Path.Join(pluginInterface.ConfigDirectory.FullName, "tracelog");
@@ -85,218 +95,458 @@ public sealed class Plugin : IDalamudPlugin
});
lb.SetMinimumLevel(LogLevel.Trace);
})
.ConfigureServices(collection =>
.ConfigureServices(services =>
{
var configDir = pluginInterface.ConfigDirectory.FullName;
// Core infrastructure
services.AddSingleton(new WindowSystem("LightlessSync"));
services.AddSingleton<FileDialogManager>();
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
services.AddSingleton(gameGui);
services.AddSingleton(addonLifecycle);
// Core singletons
services.AddSingleton<LightlessMediator>();
services.AddSingleton<FileCacheManager>();
services.AddSingleton<ServerConfigurationManager>();
services.AddSingleton<ProfileTagService>();
services.AddSingleton<ApiController>();
services.AddSingleton<PerformanceCollectorService>();
services.AddSingleton<HubFactory>();
services.AddSingleton<FileUploadManager>();
services.AddSingleton<FileTransferOrchestrator>();
services.AddSingleton<LightlessPlugin>();
services.AddSingleton<LightlessProfileManager>();
services.AddSingleton<TextureCompressionService>();
services.AddSingleton<TextureDownscaleService>();
services.AddSingleton<GameObjectHandlerFactory>();
services.AddSingleton<FileDownloadManagerFactory>();
services.AddSingleton<PairProcessingLimiter>();
services.AddSingleton<XivDataAnalyzer>();
services.AddSingleton<CharacterAnalyzer>();
services.AddSingleton<TokenProvider>();
services.AddSingleton<PluginWarningNotificationService>();
services.AddSingleton<FileCompactor>();
services.AddSingleton<TagHandler>();
services.AddSingleton<PairRequestService>();
services.AddSingleton<ZoneChatService>();
services.AddSingleton<IdDisplayHandler>();
services.AddSingleton<PlayerPerformanceService>();
services.AddSingleton<TextureMetadataHelper>(sp =>
new TextureMetadataHelper(sp.GetRequiredService<ILogger<TextureMetadataHelper>>(), gameData));
services.AddSingleton(sp => new Lazy<ApiController>(() => sp.GetRequiredService<ApiController>()));
services.AddSingleton(sp => new PairFactory(
sp.GetRequiredService<ILoggerFactory>(),
sp.GetRequiredService<PairLedger>(),
sp.GetRequiredService<LightlessMediator>(),
new Lazy<ServerConfigurationManager>(() => sp.GetRequiredService<ServerConfigurationManager>()),
sp.GetRequiredService<Lazy<ApiController>>()));
services.AddSingleton(sp => new TransientResourceManager(
sp.GetRequiredService<ILogger<TransientResourceManager>>(),
sp.GetRequiredService<TransientConfigService>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<GameObjectHandlerFactory>()));
// Lightless Chara data
services.AddSingleton<CharaDataManager>();
services.AddSingleton<CharaDataFileHandler>();
services.AddSingleton<CharaDataCharacterHandler>();
services.AddSingleton<CharaDataNearbyManager>();
services.AddSingleton<CharaDataGposeTogetherManager>();
// Game / VFX / IPC
services.AddSingleton(sp => new VfxSpawnManager(
sp.GetRequiredService<ILogger<VfxSpawnManager>>(),
gameInteropProvider,
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new BlockedCharacterHandler(
sp.GetRequiredService<ILogger<BlockedCharacterHandler>>(),
gameInteropProvider));
services.AddSingleton(sp => new IpcProvider(
sp.GetRequiredService<ILogger<IpcProvider>>(),
pluginInterface,
sp.GetRequiredService<CharaDataManager>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new PictomancyService(
sp.GetRequiredService<ILogger<PictomancyService>>(),
pluginInterface));
// Tag (Groups) UIs
services.AddSingleton<SelectPairForTagUi>();
services.AddSingleton<RenamePairTagUi>();
services.AddSingleton<SelectSyncshellForTagUi>();
services.AddSingleton<RenameSyncshellTagUi>();
// Eventing / utilities
services.AddSingleton(sp => new EventAggregator(
configDir,
sp.GetRequiredService<ILogger<EventAggregator>>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new ActorObjectService(
sp.GetRequiredService<ILogger<ActorObjectService>>(),
framework,
gameInteropProvider,
objectTable,
clientState,
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new DalamudUtilService(
sp.GetRequiredService<ILogger<DalamudUtilService>>(),
clientState,
objectTable,
framework,
gameGui,
condition,
gameData,
targetManager,
gameConfig,
sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<BlockedCharacterHandler>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<PlayerPerformanceConfigService>(),
new Lazy<PairFactory>(() => sp.GetRequiredService<PairFactory>())));
// Pairing and Dtr integration
services.AddSingleton<PairManager>();
services.AddSingleton<PairStateCache>();
services.AddSingleton<PairPerformanceMetricsCache>();
services.AddSingleton<PairLedger>();
services.AddSingleton<PairUiService>();
services.AddSingleton<IPairHandlerAdapterFactory, PairHandlerAdapterFactory>();
services.AddSingleton(sp => new PairHandlerRegistry(
sp.GetRequiredService<IPairHandlerAdapterFactory>(),
sp.GetRequiredService<PairManager>(),
sp.GetRequiredService<PairStateCache>(),
sp.GetRequiredService<PairPerformanceMetricsCache>(),
sp.GetRequiredService<ILogger<PairHandlerRegistry>>()));
services.AddSingleton(sp => new DtrEntry(
sp.GetRequiredService<ILogger<DtrEntry>>(),
dtrBar,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<PairRequestService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<ServerConfigurationManager>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<DalamudUtilService>()));
services.AddSingleton(sp => new PairCoordinator(
sp.GetRequiredService<ILogger<PairCoordinator>>(),
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairHandlerRegistry>(),
sp.GetRequiredService<PairManager>(),
sp.GetRequiredService<PairLedger>(),
sp.GetRequiredService<ServerConfigurationManager>(),
sp.GetRequiredService<PairPerformanceMetricsCache>()));
// Light finder / redraw / context menu
services.AddSingleton<RedrawManager>();
services.AddSingleton<LightFinderService>();
services.AddSingleton(sp => new LightFinderPlateHandler(
sp.GetRequiredService<ILogger<LightFinderPlateHandler>>(),
addonLifecycle,
gameGui,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(),
objectTable,
sp.GetRequiredService<PairUiService>(),
pluginInterface,
sp.GetRequiredService<PictomancyService>()));
services.AddSingleton(sp => new LightFinderScannerService(
sp.GetRequiredService<ILogger<LightFinderScannerService>>(),
framework,
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<LightFinderPlateHandler>(),
sp.GetRequiredService<ActorObjectService>()));
services.AddSingleton(sp => new ContextMenuService(
contextMenu,
pluginInterface,
gameData,
sp.GetRequiredService<ILogger<ContextMenuService>>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<ApiController>(),
objectTable,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<PairRequestService>(),
sp.GetRequiredService<PairUiService>(),
clientState,
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<LightlessMediator>()));
// IPC callers / manager
services.AddSingleton(sp => new IpcCallerPenumbra(
sp.GetRequiredService<ILogger<IpcCallerPenumbra>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<RedrawManager>(),
sp.GetRequiredService<ActorObjectService>()));
services.AddSingleton(sp => new IpcCallerGlamourer(
sp.GetRequiredService<ILogger<IpcCallerGlamourer>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<RedrawManager>()));
services.AddSingleton(sp => new IpcCallerCustomize(
sp.GetRequiredService<ILogger<IpcCallerCustomize>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerHeels(
sp.GetRequiredService<ILogger<IpcCallerHeels>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerHonorific(
sp.GetRequiredService<ILogger<IpcCallerHonorific>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerMoodles(
sp.GetRequiredService<ILogger<IpcCallerMoodles>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerPetNames(
sp.GetRequiredService<ILogger<IpcCallerPetNames>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcCallerBrio(
sp.GetRequiredService<ILogger<IpcCallerBrio>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new IpcManager(
sp.GetRequiredService<ILogger<IpcManager>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<IpcCallerPenumbra>(),
sp.GetRequiredService<IpcCallerGlamourer>(),
sp.GetRequiredService<IpcCallerCustomize>(),
sp.GetRequiredService<IpcCallerHeels>(),
sp.GetRequiredService<IpcCallerHonorific>(),
sp.GetRequiredService<IpcCallerMoodles>(),
sp.GetRequiredService<IpcCallerPetNames>(),
sp.GetRequiredService<IpcCallerBrio>()));
// Notifications / HTTP
services.AddSingleton(sp => new NotificationService(
sp.GetRequiredService<ILogger<NotificationService>>(),
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<DalamudUtilService>(),
notificationManager,
chatGui,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairRequestService>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<PairFactory>()));
services.AddSingleton(sp =>
{
collection.AddSingleton(new WindowSystem("LightlessSync"));
collection.AddSingleton<FileDialogManager>();
collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true));
collection.AddSingleton(gameGui);
var httpClient = new HttpClient();
var ver = Assembly.GetExecutingAssembly().GetName().Version;
httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}"));
return httpClient;
});
// add lightless related singletons
collection.AddSingleton<LightlessMediator>();
collection.AddSingleton<FileCacheManager>();
collection.AddSingleton<ServerConfigurationManager>();
collection.AddSingleton<ApiController>();
collection.AddSingleton<PerformanceCollectorService>();
collection.AddSingleton<HubFactory>();
collection.AddSingleton<FileUploadManager>();
collection.AddSingleton<FileTransferOrchestrator>();
collection.AddSingleton<LightlessPlugin>();
collection.AddSingleton<LightlessProfileManager>();
collection.AddSingleton<GameObjectHandlerFactory>();
collection.AddSingleton<FileDownloadManagerFactory>();
collection.AddSingleton<PairHandlerFactory>();
collection.AddSingleton<PairProcessingLimiter>();
collection.AddSingleton<PairFactory>();
collection.AddSingleton<XivDataAnalyzer>();
collection.AddSingleton<CharacterAnalyzer>();
collection.AddSingleton<TokenProvider>();
collection.AddSingleton<PluginWarningNotificationService>();
collection.AddSingleton<FileCompactor>();
collection.AddSingleton<TagHandler>();
collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
collection.AddSingleton<PairRequestService>();
collection.AddSingleton<IdDisplayHandler>();
collection.AddSingleton<PlayerPerformanceService>();
collection.AddSingleton<TransientResourceManager>();
// Lightless Config services
services.AddSingleton(sp => new UiThemeConfigService(configDir));
services.AddSingleton(sp => new ChatConfigService(configDir));
services.AddSingleton(sp =>
{
var cfg = new LightlessConfigService(configDir);
var theme = sp.GetRequiredService<UiThemeConfigService>();
LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
return cfg;
});
services.AddSingleton(sp => new ServerConfigService(configDir));
services.AddSingleton(sp => new NotesConfigService(configDir));
services.AddSingleton(sp => new PairTagConfigService(configDir));
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
services.AddSingleton(sp => new TransientConfigService(configDir));
services.AddSingleton(sp => new XivDataStorageService(configDir));
services.AddSingleton(sp => new PlayerPerformanceConfigService(configDir));
services.AddSingleton(sp => new CharaDataConfigService(configDir));
collection.AddSingleton<CharaDataManager>();
collection.AddSingleton<CharaDataFileHandler>();
collection.AddSingleton<CharaDataCharacterHandler>();
collection.AddSingleton<CharaDataNearbyManager>();
collection.AddSingleton<CharaDataGposeTogetherManager>();
// Config adapters
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<LightlessConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<TransientConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<XivDataStorageService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PlayerPerformanceConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton(s => new VfxSpawnManager(s.GetRequiredService<ILogger<VfxSpawnManager>>(),
gameInteropProvider, s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new BlockedCharacterHandler(s.GetRequiredService<ILogger<BlockedCharacterHandler>>(), gameInteropProvider));
collection.AddSingleton((s) => new IpcProvider(s.GetRequiredService<ILogger<IpcProvider>>(),
pluginInterface,
s.GetRequiredService<CharaDataManager>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton<SelectPairForTagUi>();
collection.AddSingleton<RenamePairTagUi>();
collection.AddSingleton<SelectSyncshellForTagUi>();
collection.AddSingleton<RenameSyncshellTagUi>();
collection.AddSingleton((s) => new EventAggregator(pluginInterface.ConfigDirectory.FullName,
s.GetRequiredService<ILogger<EventAggregator>>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService<ILogger<DalamudUtilService>>(),
clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig,
s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>()));
collection.AddSingleton((s) => new DtrEntry(
s.GetRequiredService<ILogger<DtrEntry>>(),
dtrBar,
s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairManager>(),
s.GetRequiredService<PairRequestService>(),
s.GetRequiredService<ApiController>(),
s.GetRequiredService<ServerConfigurationManager>(),
s.GetRequiredService<BroadcastService>(),
s.GetRequiredService<BroadcastScannerService>(),
s.GetRequiredService<DalamudUtilService>()));
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, s.GetRequiredService<PairProcessingLimiter>()));
collection.AddSingleton<RedrawManager>();
collection.AddSingleton<BroadcastService>();
collection.AddSingleton(addonLifecycle);
collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData,
p.GetRequiredService<ILogger<ContextMenuService>>(), p.GetRequiredService<DalamudUtilService>(), p.GetRequiredService<ApiController>(), objectTable,
p.GetRequiredService<LightlessConfigService>(), p.GetRequiredService<PairRequestService>(), p.GetRequiredService<PairManager>(), clientState));
collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService<ILogger<IpcCallerPenumbra>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService<ILogger<IpcCallerGlamourer>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
collection.AddSingleton((s) => new IpcCallerCustomize(s.GetRequiredService<ILogger<IpcCallerCustomize>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerHeels(s.GetRequiredService<ILogger<IpcCallerHeels>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerHonorific(s.GetRequiredService<ILogger<IpcCallerHonorific>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerMoodles(s.GetRequiredService<ILogger<IpcCallerMoodles>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerPetNames(s.GetRequiredService<ILogger<IpcCallerPetNames>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerBrio(s.GetRequiredService<ILogger<IpcCallerBrio>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>()));
collection.AddSingleton((s) => new IpcManager(s.GetRequiredService<ILogger<IpcManager>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<IpcCallerPenumbra>(), s.GetRequiredService<IpcCallerGlamourer>(),
s.GetRequiredService<IpcCallerCustomize>(), s.GetRequiredService<IpcCallerHeels>(), s.GetRequiredService<IpcCallerHonorific>(),
s.GetRequiredService<IpcCallerMoodles>(), s.GetRequiredService<IpcCallerPetNames>(), s.GetRequiredService<IpcCallerBrio>()));
collection.AddSingleton((s) => new NotificationService(
s.GetRequiredService<ILogger<NotificationService>>(),
s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<DalamudUtilService>(),
notificationManager,
chatGui,
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairRequestService>()));
collection.AddSingleton((s) =>
{
var httpClient = new HttpClient();
var ver = Assembly.GetExecutingAssembly().GetName().Version;
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
return httpClient;
});
collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) =>
{
var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName);
var theme = s.GetRequiredService<UiThemeConfigService>();
LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
return cfg;
});
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new PairTagConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new SyncshellTagConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new TransientConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new XivDataStorageService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<LightlessConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<UiThemeConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PairTagConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<SyncshellTagConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<TransientConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<XivDataStorageService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PlayerPerformanceConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton<HubFactory>();
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
services.AddSingleton<ConfigurationMigrator>();
services.AddSingleton<ConfigurationSaveService>();
// Scoped factories / UI
services.AddScoped<DrawEntityFactory>();
services.AddScoped<CacheMonitor>();
services.AddScoped<UiFactory>();
services.AddScoped<SelectTagForPairUi>();
services.AddScoped<SelectTagForSyncshellUi>();
services.AddScoped<WindowMediatorSubscriberBase, SettingsUi>();
services.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
services.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
services.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
services.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
services.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
services.AddScoped<WindowMediatorSubscriberBase, JoinSyncshellUI>();
services.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
services.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
services.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
services.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
services.AddScoped<WindowMediatorSubscriberBase, ZoneChatUi>();
// add scoped services
collection.AddScoped<DrawEntityFactory>();
collection.AddScoped<CacheMonitor>();
collection.AddScoped<UiFactory>();
collection.AddScoped<SelectTagForPairUi>();
collection.AddScoped<SelectTagForSyncshellUi>();
collection.AddScoped<WindowMediatorSubscriberBase, SettingsUi>();
collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
collection.AddScoped<WindowMediatorSubscriberBase, JoinSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
collection.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
services.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>(sp => new EditProfileUi(
sp.GetRequiredService<ILogger<EditProfileUi>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<FileDialogManager>(),
sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<ProfileTagService>(),
sp.GetRequiredService<PerformanceCollectorService>()));
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<PerformanceCollectorService>()));
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUi>((s) =>
new LightlessNotificationUi(
s.GetRequiredService<ILogger<LightlessNotificationUi>>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>()));
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
collection.AddScoped<CacheCreationService>();
collection.AddScoped<PlayerDataFactory>();
collection.AddScoped<VisibleUserDataDistributor>();
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
s.GetRequiredService<UiFactory>(),
s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
collection.AddScoped((s) => new UiSharedService(s.GetRequiredService<ILogger<UiSharedService>>(), s.GetRequiredService<IpcManager>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<DalamudUtilService>(),
pluginInterface, textureProvider, s.GetRequiredService<Dalamud.Localization>(), s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<TokenProvider>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
services.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>());
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>());
collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>());
collection.AddHostedService(p => p.GetRequiredService<PerformanceCollectorService>());
collection.AddHostedService(p => p.GetRequiredService<DtrEntry>());
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<LightlessPlugin>());
collection.AddHostedService(p => p.GetRequiredService<ContextMenuService>());
collection.AddHostedService(p => p.GetRequiredService<BroadcastService>());
})
.Build();
services.AddScoped<WindowMediatorSubscriberBase, LightFinderUI>(sp => new LightFinderUI(
sp.GetRequiredService<ILogger<LightFinderUI>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>()));
services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessProfileManager>()));
services.AddScoped<IPopupHandler, BanUserPopupHandler>();
services.AddScoped<IPopupHandler, CensusPopupHandler>();
services.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUi>(sp =>
new LightlessNotificationUi(
sp.GetRequiredService<ILogger<LightlessNotificationUi>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightlessConfigService>()));
services.AddScoped<CacheCreationService>();
services.AddScoped<PlayerDataFactory>();
services.AddScoped<VisibleUserDataDistributor>();
services.AddScoped(sp => new UiService(
sp.GetRequiredService<ILogger<UiService>>(),
pluginInterface.UiBuilder,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<WindowSystem>(),
sp.GetServices<WindowMediatorSubscriberBase>(),
sp.GetRequiredService<UiFactory>(),
sp.GetRequiredService<FileDialogManager>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairFactory>()));
services.AddScoped(sp => new CommandManagerService(
commandManager,
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<ServerConfigurationManager>(),
sp.GetRequiredService<CacheMonitor>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<LightlessConfigService>()));
services.AddScoped(sp => new UiSharedService(
sp.GetRequiredService<ILogger<UiSharedService>>(),
sp.GetRequiredService<IpcManager>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<CacheMonitor>(),
sp.GetRequiredService<FileDialogManager>(),
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<DalamudUtilService>(),
pluginInterface,
textureProvider,
sp.GetRequiredService<Dalamud.Localization>(),
sp.GetRequiredService<ServerConfigurationManager>(),
sp.GetRequiredService<TokenProvider>(),
sp.GetRequiredService<LightlessMediator>()));
services.AddScoped(sp => new NameplateService(
sp.GetRequiredService<ILogger<NameplateService>>(),
sp.GetRequiredService<LightlessConfigService>(),
clientState,
gameGui,
objectTable,
gameInteropProvider,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairUiService>()));
// Hosted services
services.AddHostedService(sp => sp.GetRequiredService<ConfigurationSaveService>());
services.AddHostedService(sp => sp.GetRequiredService<ActorObjectService>());
services.AddHostedService(sp => sp.GetRequiredService<LightlessMediator>());
services.AddHostedService(sp => sp.GetRequiredService<ZoneChatService>());
services.AddHostedService(sp => sp.GetRequiredService<NotificationService>());
services.AddHostedService(sp => sp.GetRequiredService<FileCacheManager>());
services.AddHostedService(sp => sp.GetRequiredService<ConfigurationMigrator>());
services.AddHostedService(sp => sp.GetRequiredService<DalamudUtilService>());
services.AddHostedService(sp => sp.GetRequiredService<PerformanceCollectorService>());
services.AddHostedService(sp => sp.GetRequiredService<DtrEntry>());
services.AddHostedService(sp => sp.GetRequiredService<EventAggregator>());
services.AddHostedService(sp => sp.GetRequiredService<IpcProvider>());
services.AddHostedService(sp => sp.GetRequiredService<LightlessPlugin>());
services.AddHostedService(sp => sp.GetRequiredService<ContextMenuService>());
services.AddHostedService(sp => sp.GetRequiredService<LightFinderService>());
services.AddHostedService(sp => sp.GetRequiredService<LightFinderPlateHandler>());
}).Build();
_ = _host.StartAsync();
}

View File

@@ -0,0 +1,938 @@
using System.Collections.Concurrent;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.Interop;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.Services.ActorTracking;
public sealed class ActorObjectService : IHostedService, IDisposable
{
public readonly record struct ActorDescriptor(
string Name,
string HashedContentId,
nint Address,
ushort ObjectIndex,
bool IsLocalPlayer,
bool IsInGpose,
DalamudObjectKind ObjectKind,
LightlessObjectKind? OwnedKind,
uint OwnerEntityId);
private readonly ILogger<ActorObjectService> _logger;
private readonly IFramework _framework;
private readonly IGameInteropProvider _interop;
private readonly IObjectTable _objectTable;
private readonly LightlessMediator _mediator;
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
private readonly ConcurrentDictionary<string, ActorDescriptor> _actorsByHash = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<nint, ActorDescriptor>> _actorsByName = new(StringComparer.Ordinal);
private readonly OwnedObjectTracker _ownedTracker = new();
private ActorSnapshot _snapshot = ActorSnapshot.Empty;
private Hook<Character.Delegates.OnInitialize>? _onInitializeHook;
private Hook<Character.Delegates.Terminate>? _onTerminateHook;
private Hook<Character.Delegates.Dtor>? _onDestructorHook;
private Hook<Companion.Delegates.OnInitialize>? _onCompanionInitializeHook;
private Hook<Companion.Delegates.Terminate>? _onCompanionTerminateHook;
private bool _hooksActive;
private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1);
private DateTime _nextRefreshAllowed = DateTime.MinValue;
public ActorObjectService(
ILogger<ActorObjectService> logger,
IFramework framework,
IGameInteropProvider interop,
IObjectTable objectTable,
IClientState clientState,
LightlessMediator mediator)
{
_logger = logger;
_framework = framework;
_interop = interop;
_objectTable = objectTable;
_mediator = mediator;
}
private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot);
public IReadOnlyList<nint> PlayerAddresses => Snapshot.PlayerAddresses;
public IEnumerable<ActorDescriptor> PlayerDescriptors => _activePlayers.Values;
public IReadOnlyList<ActorDescriptor> PlayerCharacterDescriptors => Snapshot.PlayerDescriptors;
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
{
descriptor = default;
if (!_actorsByHash.TryGetValue(hash, out var candidate))
return false;
if (!ValidateDescriptorThreadSafe(candidate))
return false;
descriptor = candidate;
return true;
}
public bool TryGetPlayerByName(string name, out ActorDescriptor descriptor)
{
descriptor = default;
if (!_actorsByName.TryGetValue(name, out var entries) || entries.IsEmpty)
return false;
ActorDescriptor? best = null;
foreach (var candidate in entries.Values)
{
if (!ValidateDescriptorThreadSafe(candidate))
continue;
if (best is null || IsBetterNameMatch(candidate, best.Value))
{
best = candidate;
}
}
if (best is { } selected)
{
descriptor = selected;
return true;
}
return false;
}
public bool HooksActive => _hooksActive;
public IReadOnlyList<nint> RenderedPlayerAddresses => Snapshot.OwnedObjects.RenderedPlayers;
public IReadOnlyList<nint> RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions;
public IReadOnlyList<nint> OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses;
public IReadOnlyDictionary<nint, LightlessObjectKind> OwnedObjects => Snapshot.OwnedObjects.Map;
public nint LocalPlayerAddress => Snapshot.OwnedObjects.LocalPlayer;
public nint LocalPetAddress => Snapshot.OwnedObjects.LocalPet;
public nint LocalMinionOrMountAddress => Snapshot.OwnedObjects.LocalMinionOrMount;
public nint LocalCompanionAddress => Snapshot.OwnedObjects.LocalCompanion;
public bool TryGetOwnedKind(nint address, out LightlessObjectKind kind)
=> OwnedObjects.TryGetValue(address, out kind);
public bool TryGetOwnedActor(LightlessObjectKind kind, out ActorDescriptor descriptor)
{
descriptor = default;
if (!TryGetOwnedObject(kind, out var address))
return false;
return TryGetDescriptor(address, out descriptor);
}
public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind)
{
ownedKind = default;
var ownedSnapshot = OwnedObjects;
foreach (var (address, kind) in ownedSnapshot)
{
if (!TryGetDescriptor(address, out var descriptor))
continue;
if (descriptor.ObjectIndex == objectIndex)
{
ownedKind = kind;
return true;
}
}
return false;
}
public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address)
{
var ownedSnapshot = Snapshot.OwnedObjects;
address = kind switch
{
LightlessObjectKind.Player => ownedSnapshot.LocalPlayer,
LightlessObjectKind.Pet => ownedSnapshot.LocalPet,
LightlessObjectKind.MinionOrMount => ownedSnapshot.LocalMinionOrMount,
LightlessObjectKind.Companion => ownedSnapshot.LocalCompanion,
_ => nint.Zero
};
return address != nint.Zero;
}
public bool TryGetOwnedActor(uint ownerEntityId, LightlessObjectKind? kindFilter, out ActorDescriptor descriptor)
{
descriptor = default;
foreach (var candidate in _activePlayers.Values)
{
if (candidate.OwnerEntityId != ownerEntityId)
continue;
if (kindFilter.HasValue && candidate.OwnedKind != kindFilter)
continue;
descriptor = candidate;
return true;
}
return false;
}
public bool TryGetPlayerAddressByHash(string hash, out nint address)
{
if (TryGetValidatedActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero)
{
address = descriptor.Address;
return true;
}
address = nint.Zero;
return false;
}
public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default)
{
if (address == nint.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
if (isLoaded)
return;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
private bool ValidateDescriptorThreadSafe(ActorDescriptor descriptor)
{
if (_framework.IsInFrameworkUpdateThread)
return ValidateDescriptorInternal(descriptor);
return _framework.RunOnFrameworkThread(() => ValidateDescriptorInternal(descriptor)).GetAwaiter().GetResult();
}
private bool ValidateDescriptorInternal(ActorDescriptor descriptor)
{
if (descriptor.Address == nint.Zero)
return false;
if (descriptor.ObjectKind == DalamudObjectKind.Player &&
!string.IsNullOrEmpty(descriptor.HashedContentId))
{
if (!TryGetLivePlayerHash(descriptor, out var liveHash))
{
UntrackGameObject(descriptor.Address);
return false;
}
if (!string.Equals(liveHash, descriptor.HashedContentId, StringComparison.Ordinal))
{
UntrackGameObject(descriptor.Address);
return false;
}
}
return true;
}
private bool TryGetLivePlayerHash(ActorDescriptor descriptor, out string liveHash)
{
liveHash = string.Empty;
if (_objectTable.CreateObjectReference(descriptor.Address) is not IPlayerCharacter playerCharacter)
return false;
return DalamudUtilService.TryGetHashedCID(playerCharacter, out liveHash);
}
public void RefreshTrackedActors(bool force = false)
{
var now = DateTime.UtcNow;
if (!force && _hooksActive)
{
if (now < _nextRefreshAllowed)
return;
_nextRefreshAllowed = now + SnapshotRefreshInterval;
}
if (_framework.IsInFrameworkUpdateThread)
{
RefreshTrackedActorsInternal();
}
else
{
_ = _framework.RunOnFrameworkThread(RefreshTrackedActorsInternal);
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
InitializeHooks();
var warmupTask = WarmupExistingActors();
return warmupTask;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize ActorObjectService hooks, falling back to empty cache.");
DisposeHooks();
return Task.CompletedTask;
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
DisposeHooks();
_activePlayers.Clear();
_actorsByHash.Clear();
_actorsByName.Clear();
_ownedTracker.Reset();
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
return Task.CompletedTask;
}
private unsafe void InitializeHooks()
{
if (_hooksActive)
return;
_onInitializeHook = _interop.HookFromAddress<Character.Delegates.OnInitialize>(
(nint)Character.StaticVirtualTablePointer->OnInitialize,
OnCharacterInitialized);
_onTerminateHook = _interop.HookFromAddress<Character.Delegates.Terminate>(
(nint)Character.StaticVirtualTablePointer->Terminate,
OnCharacterTerminated);
_onDestructorHook = _interop.HookFromAddress<Character.Delegates.Dtor>(
(nint)Character.StaticVirtualTablePointer->Dtor,
OnCharacterDisposed);
_onCompanionInitializeHook = _interop.HookFromAddress<Companion.Delegates.OnInitialize>(
(nint)Companion.StaticVirtualTablePointer->OnInitialize,
OnCompanionInitialized);
_onCompanionTerminateHook = _interop.HookFromAddress<Companion.Delegates.Terminate>(
(nint)Companion.StaticVirtualTablePointer->Terminate,
OnCompanionTerminated);
_onInitializeHook.Enable();
_onTerminateHook.Enable();
_onDestructorHook.Enable();
_onCompanionInitializeHook.Enable();
_onCompanionTerminateHook.Enable();
_hooksActive = true;
_logger.LogDebug("ActorObjectService hooks enabled.");
}
private Task WarmupExistingActors()
{
return _framework.RunOnFrameworkThread(() =>
{
RefreshTrackedActorsInternal();
_nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval;
});
}
private unsafe void OnCharacterInitialized(Character* chara)
{
try
{
_onInitializeHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara));
}
private unsafe void OnCharacterTerminated(Character* chara)
{
var address = (nint)chara;
QueueFrameworkUpdate(() => UntrackGameObject(address));
try
{
_onTerminateHook!.Original(chara);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character terminate.");
}
}
private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory)
{
var address = (nint)chara;
QueueFrameworkUpdate(() => UntrackGameObject(address));
try
{
return _onDestructorHook!.Original(chara, freeMemory);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original character destructor.");
return null;
}
}
private unsafe void TrackGameObject(GameObject* gameObject)
{
if (gameObject == null)
return;
var objectKind = (DalamudObjectKind)gameObject->ObjectKind;
if (!IsSupportedObjectKind(objectKind))
return;
if (BuildDescriptor(gameObject, objectKind) is not { } descriptor)
return;
if (descriptor.ObjectKind != DalamudObjectKind.Player && descriptor.OwnedKind is null)
return;
if (_activePlayers.TryGetValue(descriptor.Address, out var existing))
{
RemoveDescriptor(existing);
}
AddDescriptor(descriptor);
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}",
descriptor.Name,
descriptor.Address,
descriptor.ObjectIndex,
descriptor.OwnedKind?.ToString() ?? "<none>",
descriptor.IsLocalPlayer,
descriptor.IsInGpose);
}
_mediator.Publish(new ActorTrackedMessage(descriptor));
}
private unsafe ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind)
{
if (gameObject == null)
return null;
var address = (nint)gameObject;
string name = string.Empty;
ushort objectIndex = gameObject->ObjectIndex;
bool isInGpose = objectIndex >= 200;
bool isLocal = _objectTable.LocalPlayer?.Address == address;
string hashedCid = string.Empty;
IPlayerCharacter? resolvedPlayer = null;
if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter)
{
resolvedPlayer = playerCharacter;
name = playerCharacter.Name.TextValue ?? string.Empty;
objectIndex = playerCharacter.ObjectIndex;
isInGpose = objectIndex >= 200;
isLocal = playerCharacter.Address == _objectTable.LocalPlayer?.Address;
}
else
{
name = gameObject->NameString ?? string.Empty;
}
if (objectKind == DalamudObjectKind.Player)
{
if (resolvedPlayer == null || !DalamudUtilService.TryGetHashedCID(resolvedPlayer, out hashedCid))
{
hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
}
}
var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal);
return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId);
}
private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer)
{
if (gameObject == null)
return (null, 0);
if (objectKind == DalamudObjectKind.Player)
{
var entityId = ((Character*)gameObject)->EntityId;
return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId);
}
if (isLocalPlayer)
{
var entityId = ((Character*)gameObject)->EntityId;
return (LightlessObjectKind.Player, entityId);
}
if (_objectTable.LocalPlayer is not { } localPlayer)
return (null, 0);
var ownerId = gameObject->OwnerId;
if (ownerId == 0)
{
var character = (Character*)gameObject;
if (character != null)
{
ownerId = character->CompanionOwnerId;
if (ownerId == 0)
{
var parent = character->GetParentCharacter();
if (parent != null)
{
ownerId = parent->EntityId;
}
}
}
}
if (ownerId == 0 || ownerId != localPlayer.EntityId)
return (null, ownerId);
var ownedKind = objectKind switch
{
DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount,
DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount,
DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch
{
BattleNpcSubKind.Buddy => LightlessObjectKind.Companion,
BattleNpcSubKind.Pet => LightlessObjectKind.Pet,
_ => (LightlessObjectKind?)null,
},
_ => (LightlessObjectKind?)null,
};
return (ownedKind, ownerId);
}
private void UntrackGameObject(nint address)
{
if (address == nint.Zero)
return;
if (_activePlayers.TryRemove(address, out var descriptor))
{
RemoveDescriptor(descriptor);
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}",
descriptor.Name,
descriptor.Address,
descriptor.ObjectIndex,
descriptor.OwnedKind?.ToString() ?? "<none>");
}
_mediator.Publish(new ActorUntrackedMessage(descriptor));
}
}
private unsafe void RefreshTrackedActorsInternal()
{
var addresses = EnumerateActiveCharacterAddresses();
HashSet<nint> seen = new(addresses.Count);
foreach (var address in addresses)
{
if (address == nint.Zero)
continue;
if (!seen.Add(address))
continue;
if (_activePlayers.ContainsKey(address))
continue;
TrackGameObject((GameObject*)address);
}
var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList();
foreach (var staleAddress in stale)
{
UntrackGameObject(staleAddress);
}
if (_hooksActive)
{
_nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval;
}
}
private void IndexDescriptor(ActorDescriptor descriptor)
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId))
{
_actorsByHash[descriptor.HashedContentId] = descriptor;
}
if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name))
{
var bucket = _actorsByName.GetOrAdd(descriptor.Name, _ => new ConcurrentDictionary<nint, ActorDescriptor>());
bucket[descriptor.Address] = descriptor;
}
}
private static bool IsBetterNameMatch(ActorDescriptor candidate, ActorDescriptor current)
{
if (!candidate.IsInGpose && current.IsInGpose)
return true;
if (candidate.IsInGpose && !current.IsInGpose)
return false;
return candidate.ObjectIndex < current.ObjectIndex;
}
private bool TryGetDescriptor(nint address, out ActorDescriptor descriptor)
=> _activePlayers.TryGetValue(address, out descriptor);
private unsafe void OnCompanionInitialized(Companion* companion)
{
try
{
_onCompanionInitializeHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion initialize.");
}
QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion));
}
private unsafe void OnCompanionTerminated(Companion* companion)
{
var address = (nint)companion;
QueueFrameworkUpdate(() => UntrackGameObject(address));
try
{
_onCompanionTerminateHook!.Original(companion);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error invoking original companion terminate.");
}
}
private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor)
{
if (!string.IsNullOrEmpty(descriptor.HashedContentId))
{
_actorsByHash.TryRemove(descriptor.HashedContentId, out _);
}
if (descriptor.ObjectKind == DalamudObjectKind.Player
&& !string.IsNullOrEmpty(descriptor.Name)
&& _actorsByName.TryGetValue(descriptor.Name, out var bucket))
{
bucket.TryRemove(descriptor.Address, out _);
if (bucket.IsEmpty)
{
_actorsByName.TryRemove(descriptor.Name, out _);
}
}
}
private void AddDescriptor(ActorDescriptor descriptor)
{
_activePlayers[descriptor.Address] = descriptor;
IndexDescriptor(descriptor);
_ownedTracker.OnDescriptorAdded(descriptor);
PublishSnapshot();
}
private void RemoveDescriptor(ActorDescriptor descriptor)
{
RemoveDescriptorFromIndexes(descriptor);
_ownedTracker.OnDescriptorRemoved(descriptor);
PublishSnapshot();
}
private void PublishSnapshot()
{
var playerDescriptors = _activePlayers.Values
.Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player)
.ToArray();
var playerAddresses = new nint[playerDescriptors.Length];
for (var i = 0; i < playerDescriptors.Length; i++)
playerAddresses[i] = playerDescriptors[i].Address;
var ownedSnapshot = _ownedTracker.CreateSnapshot();
var nextGeneration = Snapshot.Generation + 1;
var snapshot = new ActorSnapshot(playerDescriptors, playerAddresses, ownedSnapshot, nextGeneration);
Volatile.Write(ref _snapshot, snapshot);
}
private void QueueFrameworkUpdate(Action action)
{
if (action == null)
return;
if (_framework.IsInFrameworkUpdateThread)
{
action();
return;
}
_ = _framework.RunOnFrameworkThread(action);
}
private void DisposeHooks()
{
var hadHooks = _hooksActive
|| _onInitializeHook is not null
|| _onTerminateHook is not null
|| _onDestructorHook is not null
|| _onCompanionInitializeHook is not null
|| _onCompanionTerminateHook is not null;
_onInitializeHook?.Disable();
_onTerminateHook?.Disable();
_onDestructorHook?.Disable();
_onCompanionInitializeHook?.Disable();
_onCompanionTerminateHook?.Disable();
_onInitializeHook?.Dispose();
_onTerminateHook?.Dispose();
_onDestructorHook?.Dispose();
_onCompanionInitializeHook?.Dispose();
_onCompanionTerminateHook?.Dispose();
_onInitializeHook = null;
_onTerminateHook = null;
_onDestructorHook = null;
_onCompanionInitializeHook = null;
_onCompanionTerminateHook = null;
_hooksActive = false;
if (hadHooks)
{
_logger.LogDebug("ActorObjectService hooks disabled.");
}
}
public void Dispose()
{
DisposeHooks();
GC.SuppressFinalize(this);
}
private static bool IsSupportedObjectKind(DalamudObjectKind objectKind) =>
objectKind is DalamudObjectKind.Player
or DalamudObjectKind.BattleNpc
or DalamudObjectKind.Companion
or DalamudObjectKind.MountType;
private static unsafe List<nint> EnumerateActiveCharacterAddresses()
{
var results = new List<nint>(64);
var manager = GameObjectManager.Instance();
if (manager == null)
return results;
const int objectLimit = 200;
unsafe
{
for (var i = 0; i < objectLimit; i++)
{
Pointer<GameObject> objPtr = manager->Objects.IndexSorted[i];
var obj = objPtr.Value;
if (obj == null)
continue;
var objectKind = (DalamudObjectKind)obj->ObjectKind;
if (!IsSupportedObjectKind(objectKind))
continue;
results.Add((nint)obj);
}
}
return results;
}
private static unsafe bool IsObjectFullyLoaded(nint address)
{
if (address == nint.Zero)
return false;
var gameObject = (GameObject*)address;
if (gameObject == null)
return false;
var drawObject = gameObject->DrawObject;
if (drawObject == null)
return false;
if ((gameObject->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None)
return false;
var characterBase = (CharacterBase*)drawObject;
if (characterBase == null)
return false;
if (characterBase->HasModelInSlotLoaded != 0)
return false;
if (characterBase->HasModelFilesInSlotLoaded != 0)
return false;
return true;
}
private sealed class OwnedObjectTracker
{
private readonly HashSet<nint> _renderedPlayers = new();
private readonly HashSet<nint> _renderedCompanions = new();
private readonly Dictionary<nint, LightlessObjectKind> _ownedObjects = new();
private nint _localPlayerAddress = nint.Zero;
private nint _localPetAddress = nint.Zero;
private nint _localMinionMountAddress = nint.Zero;
private nint _localCompanionAddress = nint.Zero;
public void OnDescriptorAdded(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Add(descriptor.Address);
if (descriptor.IsLocalPlayer)
_localPlayerAddress = descriptor.Address;
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Add(descriptor.Address);
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects[descriptor.Address] = ownedKind;
switch (ownedKind)
{
case LightlessObjectKind.Player:
_localPlayerAddress = descriptor.Address;
break;
case LightlessObjectKind.Pet:
_localPetAddress = descriptor.Address;
break;
case LightlessObjectKind.MinionOrMount:
_localMinionMountAddress = descriptor.Address;
break;
case LightlessObjectKind.Companion:
_localCompanionAddress = descriptor.Address;
break;
}
}
}
public void OnDescriptorRemoved(ActorDescriptor descriptor)
{
if (descriptor.ObjectKind == DalamudObjectKind.Player)
{
_renderedPlayers.Remove(descriptor.Address);
if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address)
_localPlayerAddress = nint.Zero;
}
else if (descriptor.ObjectKind == DalamudObjectKind.Companion)
{
_renderedCompanions.Remove(descriptor.Address);
if (_localCompanionAddress == descriptor.Address)
_localCompanionAddress = nint.Zero;
}
if (descriptor.OwnedKind is { } ownedKind)
{
_ownedObjects.Remove(descriptor.Address);
switch (ownedKind)
{
case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address:
_localPlayerAddress = nint.Zero;
break;
case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address:
_localPetAddress = nint.Zero;
break;
case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address:
_localMinionMountAddress = nint.Zero;
break;
case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address:
_localCompanionAddress = nint.Zero;
break;
}
}
}
public OwnedObjectSnapshot CreateSnapshot()
=> new(
_renderedPlayers.ToArray(),
_renderedCompanions.ToArray(),
_ownedObjects.Keys.ToArray(),
new Dictionary<nint, LightlessObjectKind>(_ownedObjects),
_localPlayerAddress,
_localPetAddress,
_localMinionMountAddress,
_localCompanionAddress);
public void Reset()
{
_renderedPlayers.Clear();
_renderedCompanions.Clear();
_ownedObjects.Clear();
_localPlayerAddress = nint.Zero;
_localPetAddress = nint.Zero;
_localMinionMountAddress = nint.Zero;
_localCompanionAddress = nint.Zero;
}
}
private sealed record OwnedObjectSnapshot(
IReadOnlyList<nint> RenderedPlayers,
IReadOnlyList<nint> RenderedCompanions,
IReadOnlyList<nint> OwnedAddresses,
IReadOnlyDictionary<nint, LightlessObjectKind> Map,
nint LocalPlayer,
nint LocalPet,
nint LocalMinionOrMount,
nint LocalCompanion)
{
public static OwnedObjectSnapshot Empty { get; } = new(
Array.Empty<nint>(),
Array.Empty<nint>(),
Array.Empty<nint>(),
new Dictionary<nint, LightlessObjectKind>(),
nint.Zero,
nint.Zero,
nint.Zero,
nint.Zero);
}
private sealed record ActorSnapshot(
IReadOnlyList<ActorDescriptor> PlayerDescriptors,
IReadOnlyList<nint> PlayerAddresses,
OwnedObjectSnapshot OwnedObjects,
int Generation)
{
public static ActorSnapshot Empty { get; } = new(
Array.Empty<ActorDescriptor>(),
Array.Empty<nint>(),
OwnedObjectSnapshot.Empty,
0);
}
}

View File

@@ -6,9 +6,9 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging;
@@ -28,7 +28,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
private readonly List<CharaDataMetaInfoExtendedDto> _nearbyData = [];
private readonly CharaDataNearbyManager _nearbyManager;
private readonly CharaDataCharacterHandler _characterHandler;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly Dictionary<string, CharaDataFullExtendedDto> _ownCharaData = [];
private readonly Dictionary<string, Task> _sharedMetaInfoTimeoutTasks = [];
private readonly Dictionary<UserData, List<CharaDataMetaInfoExtendedDto>> _sharedWithYouData = [];
@@ -45,7 +45,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
LightlessMediator lightlessMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService,
FileDownloadManagerFactory fileDownloadManagerFactory,
CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager,
CharaDataCharacterHandler charaDataCharacterHandler, PairManager pairManager) : base(logger, lightlessMediator)
CharaDataCharacterHandler charaDataCharacterHandler, PairUiService pairUiService) : base(logger, lightlessMediator)
{
_apiController = apiController;
_fileHandler = charaDataFileHandler;
@@ -54,7 +54,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
_configService = charaDataConfigService;
_nearbyManager = charaDataNearbyManager;
_characterHandler = charaDataCharacterHandler;
_pairManager = pairManager;
_pairUiService = pairUiService;
lightlessMediator.Subscribe<ConnectedMessage>(this, (msg) =>
{
_connectCts?.Cancel();
@@ -421,9 +421,10 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
});
var result = await GetSharedWithYouTask.ConfigureAwait(false);
var snapshot = _pairUiService.GetSnapshot();
foreach (var grouping in result.GroupBy(r => r.Uploader))
{
var pair = _pairManager.GetPairByUID(grouping.Key.UID);
snapshot.PairsByUid.TryGetValue(grouping.Key.UID, out var pair);
if (pair?.IsPaused ?? false) continue;
List<CharaDataMetaInfoExtendedDto> newList = new();
foreach (var item in grouping)

View File

@@ -0,0 +1,19 @@
using LightlessSync.API.Data.Enum;
using LightlessSync.Services.CharaData.Models;
using System.Collections.Immutable;
namespace LightlessSync.Services.CharaData;
public sealed class CharacterAnalysisSummary
{
public static CharacterAnalysisSummary Empty { get; } =
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
{
Objects = objects;
}
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
}

View File

@@ -0,0 +1,8 @@
using System.Runtime.InteropServices;
namespace LightlessSync.Services.CharaData.Models;
[StructLayout(LayoutKind.Auto)]
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
{
public bool HasEntries => EntryCount > 0;
}

View File

@@ -1,16 +1,14 @@
using LightlessSync.API.Data;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum;
using LightlessSync.FileCache;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.Utils;
using Lumina.Data.Files;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LightlessSync.Services;
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
@@ -40,73 +38,97 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
public int TotalFiles { get; internal set; }
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
public CharacterAnalysisSummary LatestSummary => _latestSummary;
public void CancelAnalyze()
{
_analysisCts?.CancelDispose();
_analysisCts = null;
}
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
{
Logger.LogDebug("=== Calculating Character Analysis ===");
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
var cancelToken = _analysisCts.Token;
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
if (allFiles.Exists(c => !c.IsComputed || recalculate))
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
if (remaining.Count == 0)
return;
TotalFiles = remaining.Count;
CurrentFile = 0;
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
try
{
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
TotalFiles = remaining.Count;
CurrentFile = 1;
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
try
foreach (var file in remaining)
{
foreach (var file in remaining)
{
Logger.LogDebug("Computing file {file}", file.FilePaths[0]);
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
CurrentFile++;
}
cancelToken.ThrowIfCancellationRequested();
_fileCacheManager.WriteOutFullCsv();
var path = file.FilePaths.FirstOrDefault() ?? "<unknown>";
Logger.LogDebug("Computing file {file}", path);
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
CurrentFile++;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to analyze files");
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
}
await _fileCacheManager.WriteOutFullCsvAsync(cancelToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Logger.LogInformation("File analysis cancelled");
throw;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to analyze files");
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
}
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage());
_analysisCts.CancelDispose();
_analysisCts = null;
if (print) PrintAnalysis();
}
public void Dispose()
{
_analysisCts.CancelDispose();
_baseAnalysisCts.Dispose();
}
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
{
var normalized = new HashSet<string>(
filePaths.Where(path => !string.IsNullOrWhiteSpace(path)),
StringComparer.OrdinalIgnoreCase);
if (normalized.Count == 0)
{
return;
}
foreach (var objectEntries in LastAnalysis.Values)
{
foreach (var entry in objectEntries.Values)
{
if (!entry.FilePaths.Exists(path => normalized.Contains(path)))
{
continue;
}
token.ThrowIfCancellationRequested();
await entry.ComputeSizes(_fileCacheManager, token).ConfigureAwait(false);
}
}
}
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
{
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
LastAnalysis.Clear();
foreach (var obj in charaData.FileReplacements)
{
Dictionary<string, FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase);
@@ -114,9 +136,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
token.ThrowIfCancellationRequested();
var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList();
var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList();
if (fileCacheEntries.Count == 0) continue;
var filePath = fileCacheEntries[0].ResolvedFilepath;
FileInfo fi = new(filePath);
string ext = "unk?";
@@ -128,30 +149,24 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
}
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
foreach (var entry in fileCacheEntries)
{
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext,
[.. fileEntry.GamePaths],
fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct().ToList(),
[.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)],
entry.Size > 0 ? entry.Size.Value : 0,
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
tris);
}
}
LastAnalysis[obj.Key] = data;
}
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage());
_lastDataHash = charaData.DataHash.Value;
}
private void RecalculateSummary()
{
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
@@ -177,7 +192,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
}
private void PrintAnalysis()
{
if (LastAnalysis.Count == 0) return;
@@ -186,7 +200,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
int fileCounter = 1;
int totalFiles = kvp.Value.Count;
Logger.LogInformation("=== Analysis for {obj} ===", kvp.Key);
foreach (var entry in kvp.Value.OrderBy(b => b.Value.GamePaths.OrderBy(p => p, StringComparer.Ordinal).First(), StringComparer.Ordinal))
{
Logger.LogInformation("File {x}/{y}: {hash}", fileCounter++, totalFiles, entry.Key);
@@ -215,7 +228,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", kvp.Value.Count,
UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.OriginalSize)), UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.CompressedSize)));
}
Logger.LogInformation("=== Total summary for all currently present objects ===");
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}",
LastAnalysis.Values.Sum(v => v.Values.Count),
@@ -223,7 +235,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize))));
Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
}
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
{
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
@@ -231,7 +242,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false);
var normalSize = new FileInfo(FilePaths[0]).Length;
var entries = fileCacheManager.GetAllFileCachesByHash(Hash, ignoreCacheEntries: true, validate: false);
var entries = await fileCacheManager.GetAllFileCachesByHashAsync(Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false);
foreach (var entry in entries)
{
entry.Size = normalSize;
@@ -239,53 +250,40 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
}
OriginalSize = normalSize;
CompressedSize = compressedsize.Item2.LongLength;
RefreshFormat();
}
public long OriginalSize { get; private set; } = OriginalSize;
public long CompressedSize { get; private set; } = CompressedSize;
public long Triangles { get; private set; } = Triangles;
public Lazy<string> Format => _format ??= CreateFormatValue();
public Lazy<string> Format = new(() =>
private Lazy<string>? _format;
public void RefreshFormat()
{
switch (FileType)
_format = CreateFormatValue();
}
private Lazy<string> CreateFormatValue()
=> new(() =>
{
case "tex":
{
try
{
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
reader.BaseStream.Position = 4;
var format = (TexFile.TextureFormat)reader.ReadInt32();
return format.ToString();
}
catch
{
return "Unknown";
}
}
default:
if (!string.Equals(FileType, "tex", StringComparison.Ordinal))
{
return string.Empty;
}
});
}
try
{
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(stream);
reader.BaseStream.Position = 4;
var format = (TexFile.TextureFormat)reader.ReadInt32();
return format.ToString();
}
catch
{
return "Unknown";
}
});
}
}
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
{
public bool HasEntries => EntryCount > 0;
}
public sealed class CharacterAnalysisSummary
{
public static CharacterAnalysisSummary Empty { get; } =
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
{
Objects = objects;
}
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
}

View File

@@ -0,0 +1,34 @@
using LightlessSync.API.Dto.Chat;
namespace LightlessSync.Services.Chat;
public sealed record ChatMessageEntry(
ChatMessageDto? Payload,
string DisplayName,
bool FromSelf,
DateTime ReceivedAtUtc,
ChatSystemEntry? SystemMessage = null)
{
public bool IsSystem => SystemMessage is not null;
}
public enum ChatSystemEntryType
{
ZoneSeparator
}
public sealed record ChatSystemEntry(ChatSystemEntryType Type, string? ZoneName);
public readonly record struct ChatChannelSnapshot(
string Key,
ChatChannelDescriptor Descriptor,
string DisplayName,
ChatChannelType Type,
bool IsConnected,
bool IsAvailable,
string? StatusText,
bool HasUnread,
int UnreadCount,
IReadOnlyList<ChatMessageEntry> Messages);
public readonly record struct ChatReportResult(bool Success, string? ErrorMessage);

File diff suppressed because it is too large Load Diff

View File

@@ -131,7 +131,7 @@ public sealed class CommandManagerService : IDisposable
}
else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase))
{
_mediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
_mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
}
}

View File

@@ -92,13 +92,13 @@ namespace LightlessSync.Services.Compactor
}
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
}
catch
{
break;
catch
{
break;
}
}
@@ -124,8 +124,8 @@ namespace LightlessSync.Services.Compactor
}
}
}
catch (OperationCanceledException)
{
catch (OperationCanceledException)
{
//Shutting down worker, exception called
}
}
@@ -145,17 +145,13 @@ namespace LightlessSync.Services.Compactor
if (_useShell)
{
var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle));
var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle));
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
}
else
{
var args = new List<string> { "-v" };
foreach (var path in list)
{
args.Add(' ' + path);
}
var args = new List<string> { "-v", "--" };
args.AddRange(list);
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
}
@@ -200,7 +196,7 @@ namespace LightlessSync.Services.Compactor
/// Regex of the File Size return on the Linux/Wine systems, giving back the amount
/// </summary>
/// <returns>Regex of the File Size</returns>
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)]
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
private static partial Regex SizeRegex();
/// <summary>

View File

@@ -1,14 +1,18 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Lumina.Excel.Sheets;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using LightlessSync.UI;
using LightlessSync.Services.LightFinder;
namespace LightlessSync.Services;
@@ -20,11 +24,17 @@ internal class ContextMenuService : IHostedService
private readonly ILogger<ContextMenuService> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly IClientState _clientState;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly PairRequestService _pairRequestService;
private readonly ApiController _apiController;
private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
private readonly LightFinderScannerService _broadcastScannerService;
private readonly LightFinderService _broadcastService;
private readonly LightlessProfileManager _lightlessProfileManager;
private readonly LightlessMediator _mediator;
private const int _lightlessPrefixColor = 708;
public ContextMenuService(
IContextMenu contextMenu,
@@ -33,11 +43,15 @@ internal class ContextMenuService : IHostedService
ILogger<ContextMenuService> logger,
DalamudUtilService dalamudUtil,
ApiController apiController,
IObjectTable objectTable,
IObjectTable objectTable,
LightlessConfigService configService,
PairRequestService pairRequestService,
PairManager pairManager,
IClientState clientState)
PairUiService pairUiService,
IClientState clientState,
LightFinderScannerService broadcastScannerService,
LightFinderService broadcastService,
LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator)
{
_contextMenu = contextMenu;
_pluginInterface = pluginInterface;
@@ -47,9 +61,13 @@ internal class ContextMenuService : IHostedService
_apiController = apiController;
_objectTable = objectTable;
_configService = configService;
_pairManager = pairManager;
_pairUiService = pairUiService;
_pairRequestService = pairRequestService;
_clientState = clientState;
_broadcastScannerService = broadcastScannerService;
_broadcastService = broadcastService;
_lightlessProfileManager = lightlessProfileManager;
_mediator = mediator;
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -78,52 +96,109 @@ internal class ContextMenuService : IHostedService
private void OnMenuOpened(IMenuOpenedArgs args)
{
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
return;
if (args.AddonName != null)
{
var addonName = args.AddonName;
_logger.LogTrace("Context menu addon name: {AddonName}", addonName);
return;
//Check if target is not menutargetdefault.
}
if (args.Target is not MenuTargetDefault target)
{
_logger.LogTrace("Context menu target is not MenuTargetDefault.");
return;
}
_logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null");
//Check if name or target id isnt null/zero
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
{
_logger.LogTrace("Context menu target has invalid data: Name='{TargetName}', ObjectId={TargetObjectId}, HomeWorldId={TargetHomeWorldId}", target.TargetName, target.TargetObjectId, target.TargetHomeWorld.RowId);
return;
}
//Check if it is a real target.
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero)
if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null)
{
_logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values.FirstOrDefault(p =>
p.IsVisible &&
p.PlayerCharacterId != uint.MaxValue &&
p.PlayerCharacterId == target.TargetObjectId);
if (pair is not null)
{
_logger.LogTrace("Target player {TargetName}@{World} is already paired, adding existing pair context menu.", target.TargetName, target.TargetHomeWorld.RowId);
pair.AddContextMenu(args);
if (!pair.IsDirectlyPaired)
{
_logger.LogTrace("Target player {TargetName}@{World} is not directly paired, add direct pair menu item", target.TargetName, target.TargetHomeWorld.RowId);
AddDirectPairMenuItem(args);
}
return;
}
_logger.LogTrace("Target player {TargetName}@{World} is not paired, adding direct pair request context menu.", target.TargetName, target.TargetHomeWorld.RowId);
//Check if user is directly paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _objectTable.LocalPlayer?.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus)
{
_logger.LogTrace("Target player {TargetName}@{World} is already paired or is self, or right-click menus are disabled.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
//Check if in PVP or GPose
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
{
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
//Check for valid world.
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
return;
if (!_configService.Current.EnableRightClickMenus)
return;
args.AddMenuItem(new MenuItem
{
Name = "Send Direct Pair Request",
PrefixChar = 'L',
UseDefaultPrefix = false,
PrefixColor = 708,
OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false)
});
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
return;
}
string? targetHashedCid = null;
if (_broadcastService.IsBroadcasting)
{
targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
}
if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid))
{
var hashedCid = targetHashedCid;
UiSharedService.AddContextMenuItem(args, name: "Open Lightless Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleLightfinderProfileSelection(hashedCid));
}
AddDirectPairMenuItem(args);
}
private void AddDirectPairMenuItem(IMenuOpenedArgs args)
{
UiSharedService.AddContextMenuItem(
args,
name: "Send Direct Pair Request",
prefixChar: 'L',
colorMenuItem: _lightlessPrefixColor,
onClick: () => HandleSelection(args));
}
private HashSet<ulong> VisibleUserIds =>
[.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
.Select(p => (ulong)p.PlayerCharacterId)];
private async Task HandleSelection(IMenuArgs args)
{
if (args.Target is not MenuTargetDefault target)
@@ -159,9 +234,48 @@ internal class ContextMenuService : IHostedService
}
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.DirectPairs
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
private async Task HandleLightfinderProfileSelection(string hashedCid)
{
if (string.IsNullOrWhiteSpace(hashedCid))
return;
if (!_broadcastService.IsBroadcasting)
{
Notify("Lightfinder inactive", "Enable Lightfinder to open broadcaster profiles.", NotificationType.Warning, 6);
return;
}
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry) || !entry.IsBroadcasting || entry.ExpiryTime <= DateTime.UtcNow)
{
Notify("Broadcaster unavailable", "That player is not currently using Lightfinder.", NotificationType.Info, 5);
return;
}
var result = await _lightlessProfileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false);
if (result == null)
{
Notify("Profile unavailable", "Unable to load Lightless profile for that player.", NotificationType.Error, 6);
return;
}
_mediator.Publish(new OpenLightfinderProfileMessage(result.Value.User, result.Value.ProfileData, hashedCid));
}
private void Notify(string title, string message, NotificationType type, double durationSeconds)
{
_mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds)));
}
private bool CanOpenLightfinderProfile(string hashedCid)
{
if (!_broadcastService.IsBroadcasting)
return false;
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry))
return false;
return entry.IsBroadcasting && entry.ExpiryTime > DateTime.UtcNow;
}
private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target)
{
@@ -200,8 +314,6 @@ internal class ContextMenuService : IHostedService
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId));
public static bool IsWorldValid(World world)
{
var name = world.Name.ToString();

View File

@@ -12,7 +12,10 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using LightlessSync.API.Dto.CharaData;
using LightlessSync.Interop;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Lumina.Excel.Sheets;
@@ -21,7 +24,9 @@ using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
namespace LightlessSync.Services;
@@ -37,23 +42,27 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private readonly IGameGui _gameGui;
private readonly ILogger<DalamudUtilService> _logger;
private readonly IObjectTable _objectTable;
private readonly ActorObjectService _actorObjectService;
private readonly ITargetManager _targetManager;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly Lazy<PairFactory> _pairFactory;
private PairUniqueIdentifier? _FocusPairIdent;
private IGameObject? _FocusOriginalTarget;
private uint? _classJobId = 0;
private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow;
private string _lastGlobalBlockPlayer = string.Empty;
private string _lastGlobalBlockReason = string.Empty;
private ushort _lastZone = 0;
private readonly Dictionary<string, (string Name, nint Address)> _playerCharas = new(StringComparer.Ordinal);
private readonly List<string> _notUpdatedCharas = [];
private ushort _lastWorldId = 0;
private bool _sentBetweenAreas = false;
private Lazy<ulong> _cid;
public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework,
IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig,
BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService)
ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy<PairFactory> pairFactory)
{
_logger = logger;
_clientState = clientState;
@@ -63,11 +72,14 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_condition = condition;
_gameData = gameData;
_gameConfig = gameConfig;
_actorObjectService = actorObjectService;
_targetManager = targetManager;
_blockedCharacterHandler = blockedCharacterHandler;
Mediator = mediator;
_performanceCollector = performanceCollector;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_pairFactory = pairFactory;
WorldData = new(() =>
{
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(Dalamud.Game.ClientLanguage.English)!
@@ -119,17 +131,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
mediator.Subscribe<TargetPairMessage>(this, (msg) =>
{
if (clientState.IsPvP) return;
var name = msg.Pair.PlayerName;
if (string.IsNullOrEmpty(name)) return;
var addr = _playerCharas.FirstOrDefault(f => string.Equals(f.Value.Name, name, StringComparison.Ordinal)).Value.Address;
if (addr == nint.Zero) return;
if (!ResolvePairAddress(msg.Pair, out var pair, out var addr)) return;
var useFocusTarget = _configService.Current.UseFocusTarget;
_ = RunOnFrameworkThread(() =>
{
var gameObject = CreateGameObject(addr);
if (gameObject is null) return;
if (useFocusTarget)
targetManager.FocusTarget = CreateGameObject(addr);
{
_targetManager.FocusTarget = gameObject;
if (_FocusPairIdent.HasValue && _FocusPairIdent.Value.Equals(pair.UniqueIdent))
{
_FocusOriginalTarget = _targetManager.FocusTarget;
}
}
else
targetManager.Target = CreateGameObject(addr);
{
_targetManager.Target = gameObject;
}
}).ConfigureAwait(false);
});
IsWine = Util.IsWine();
@@ -139,6 +158,61 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private Lazy<ulong> RebuildCID() => new(GetCID);
public bool IsWine { get; init; }
private bool ResolvePairAddress(Pair pair, out Pair resolvedPair, out nint address)
{
resolvedPair = _pairFactory.Value.Create(pair.UniqueIdent) ?? pair;
address = nint.Zero;
var name = resolvedPair.PlayerName;
if (string.IsNullOrEmpty(name)) return false;
if (!_actorObjectService.TryGetPlayerByName(name, out var descriptor))
return false;
address = descriptor.Address;
return address != nint.Zero;
}
public void FocusVisiblePair(Pair pair)
{
if (_clientState.IsPvP) return;
if (!ResolvePairAddress(pair, out var resolvedPair, out var address)) return;
_ = RunOnFrameworkThread(() => FocusPairUnsafe(address, resolvedPair.UniqueIdent));
}
public void ReleaseVisiblePairFocus()
{
_ = RunOnFrameworkThread(ReleaseFocusUnsafe);
}
private void FocusPairUnsafe(nint address, PairUniqueIdentifier pairIdent)
{
var target = CreateGameObject(address);
if (target is null) return;
if (!_FocusPairIdent.HasValue)
{
_FocusOriginalTarget = _targetManager.FocusTarget;
}
_targetManager.FocusTarget = target;
_FocusPairIdent = pairIdent;
}
private void ReleaseFocusUnsafe()
{
if (!_FocusPairIdent.HasValue)
{
return;
}
var previous = _FocusOriginalTarget;
if (previous != null && !IsObjectPresent(previous))
{
previous = null;
}
_targetManager.FocusTarget = previous;
_FocusPairIdent = null;
_FocusOriginalTarget = null;
}
public unsafe GameObject* GposeTarget
{
@@ -194,7 +268,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
EnsureIsOnFramework();
var objTableObj = _objectTable[index];
if (objTableObj!.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) return null;
if (objTableObj!.ObjectKind != DalamudObjectKind.Player) return null;
return (ICharacter)objTableObj;
}
@@ -226,13 +300,19 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IEnumerable<ICharacter?> GetGposeCharactersFromObjectTable()
{
return _objectTable.Where(o => o.ObjectIndex > 200 && o.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player).Cast<ICharacter>();
foreach (var actor in _actorObjectService.PlayerDescriptors
.Where(a => a.ObjectKind == DalamudObjectKind.Player && a.ObjectIndex > 200))
{
var character = _objectTable.CreateObjectReference(actor.Address) as ICharacter;
if (character != null)
yield return character;
}
}
public bool GetIsPlayerPresent()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer != null && _clientState.LocalPlayer.IsValid();
return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid();
}
public async Task<bool> GetIsPlayerPresentAsync()
@@ -245,7 +325,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
EnsureIsOnFramework();
playerPointer ??= GetPlayerPtr();
if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
return _objectTable.GetObjectAddress(((GameObject*)playerPointer)->ObjectIndex + 1);
var playerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)playerAddress)->EntityId;
if (ownerEntityId == 0) return IntPtr.Zero;
if (playerAddress == _actorObjectService.LocalPlayerAddress)
{
var localOwned = _actorObjectService.LocalMinionOrMountAddress;
if (localOwned != nint.Zero)
{
return localOwned;
}
}
var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind =>
kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion);
if (ownedObject != nint.Zero)
{
return ownedObject;
}
return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
}
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
@@ -268,6 +369,62 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false);
}
private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func<DalamudObjectKind, bool> matchesKind)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (!matchesKind(obj.ObjectKind))
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private static unsafe uint ResolveOwnerId(GameObject* gameObject)
{
if (gameObject == null)
{
return 0;
}
if (gameObject->OwnerId != 0)
{
return gameObject->OwnerId;
}
var character = (Character*)gameObject;
if (character == null)
{
return 0;
}
if (character->CompanionOwnerId != 0)
{
return character->CompanionOwnerId;
}
var parent = character->GetParentCharacter();
return parent != null ? parent->EntityId : 0;
}
public async Task<IPlayerCharacter> GetPlayerCharacterAsync()
{
return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false);
@@ -276,19 +433,20 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IPlayerCharacter GetPlayerCharacter()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer!;
return _objectTable.LocalPlayer!;
}
public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName)
{
if (_playerCharas.TryGetValue(characterName, out var pchar)) return pchar.Address;
if (_actorObjectService.TryGetValidatedActorByHash(characterName, out var actor))
return actor.Address;
return IntPtr.Zero;
}
public string GetPlayerName()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer?.Name.ToString() ?? "--";
return _objectTable.LocalPlayer?.Name.ToString() ?? "--";
}
public async Task<string> GetPlayerNameAsync()
@@ -313,6 +471,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
}
public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid)
{
hashedCid = string.Empty;
if (playerCharacter == null)
return false;
var address = playerCharacter.Address;
if (address == nint.Zero)
return false;
var cid = ((BattleChara*)address)->Character.ContentId;
if (cid == 0)
return false;
hashedCid = cid.ToString().GetHash256();
return true;
}
public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
{
return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256();
@@ -321,7 +497,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IntPtr GetPlayerPtr()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer?.Address ?? IntPtr.Zero;
return _objectTable.LocalPlayer?.Address ?? IntPtr.Zero;
}
public async Task<IntPtr> GetPlayerPointerAsync()
@@ -332,13 +508,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public uint GetHomeWorldId()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer?.HomeWorld.RowId ?? 0;
return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0;
}
public uint GetWorldId()
{
EnsureIsOnFramework();
return _clientState.LocalPlayer!.CurrentWorld.RowId;
return _objectTable.LocalPlayer!.CurrentWorld.RowId;
}
public unsafe LocationInfo GetMapData()
@@ -347,8 +523,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var agentMap = AgentMap.Instance();
var houseMan = HousingManager.Instance();
uint serverId = 0;
if (_clientState.LocalPlayer == null) serverId = 0;
else serverId = _clientState.LocalPlayer.CurrentWorld.RowId;
if (_objectTable.LocalPlayer == null) serverId = 0;
else serverId = _objectTable.LocalPlayer.CurrentWorld.RowId;
uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId;
uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId;
uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision());
@@ -436,17 +612,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
await _performanceCollector.LogPerformance(this, $"RunOnFramework:Act/{fileName}>{callerMember}:{callerLineNumber}", async () =>
{
if (!_framework.IsInFrameworkUpdateThread)
if (_framework.IsInFrameworkUpdateThread)
{
await _framework.RunOnFrameworkThread(act).ContinueWith((_) => Task.CompletedTask).ConfigureAwait(false);
while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered
{
_logger.LogTrace("Still on framework");
await Task.Delay(1).ConfigureAwait(false);
}
}
else
act();
return;
}
await _framework.RunOnFrameworkThread(act).ConfigureAwait(false);
}).ConfigureAwait(false);
}
@@ -455,18 +627,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
return await _performanceCollector.LogPerformance(this, $"RunOnFramework:Func<{typeof(T)}>/{fileName}>{callerMember}:{callerLineNumber}", async () =>
{
if (!_framework.IsInFrameworkUpdateThread)
if (_framework.IsInFrameworkUpdateThread)
{
var result = await _framework.RunOnFrameworkThread(func).ContinueWith((task) => task.Result).ConfigureAwait(false);
while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered
{
_logger.LogTrace("Still on framework");
await Task.Delay(1).ConfigureAwait(false);
}
return result;
return func.Invoke();
}
return func.Invoke();
return await _framework.RunOnFrameworkThread(func).ConfigureAwait(false);
}).ConfigureAwait(false);
}
@@ -476,7 +642,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_framework.Update += FrameworkOnUpdate;
if (IsLoggedIn)
{
_classJobId = _clientState.LocalPlayer!.ClassJob.RowId;
_classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
}
_logger.LogInformation("Started DalamudUtilService");
@@ -489,6 +655,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.UnsubscribeAll(this);
_framework.Update -= FrameworkOnUpdate;
if (_FocusPairIdent.HasValue)
{
if (_framework.IsInFrameworkUpdateThread)
{
ReleaseFocusUnsafe();
}
else
{
_ = RunOnFrameworkThread(ReleaseFocusUnsafe);
}
}
return Task.CompletedTask;
}
@@ -513,15 +690,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick;
await Task.Delay(tick).ConfigureAwait(true);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
}
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
}
catch (NullReferenceException ex)
{
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
}
catch (AccessViolationException ex)
{
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
@@ -535,7 +708,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
const int tick = 250;
int curWaitTime = 0;
_logger.LogTrace("RenderFlags: {flags}", obj->RenderFlags.ToString("X"));
while (obj->RenderFlags != 0x00 && curWaitTime < timeOut)
while (obj->RenderFlags != VisibilityFlags.None && curWaitTime < timeOut)
{
_logger.LogTrace($"Waiting for gpose actor to finish drawing");
curWaitTime += tick;
@@ -552,8 +725,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
internal (string Name, nint Address) FindPlayerByNameHash(string ident)
{
_playerCharas.TryGetValue(ident, out var result);
return result;
if (_actorObjectService.TryGetValidatedActorByHash(ident, out var descriptor))
{
return (descriptor.Name, descriptor.Address);
}
return default;
}
public string? GetWorldNameFromPlayerAddress(nint address)
@@ -576,7 +753,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
bool isDrawingChanged = false;
if ((nint)drawObj != IntPtr.Zero)
{
isDrawing = gameObj->RenderFlags == 0b100000000000;
isDrawing = (gameObj->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None;
if (!isDrawing)
{
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
@@ -629,7 +806,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private unsafe void FrameworkOnUpdateInternal()
{
if ((_clientState.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
{
return;
}
@@ -639,37 +816,43 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () =>
{
IsAnythingDrawing = false;
_performanceCollector.LogPerformance(this, $"ObjTableToCharas",
_performanceCollector.LogPerformance(this, $"TrackedActorsToState",
() =>
{
_notUpdatedCharas.AddRange(_playerCharas.Keys);
_actorObjectService.RefreshTrackedActors();
for (int i = 0; i < 200; i += 2)
var playerDescriptors = _actorObjectService.PlayerCharacterDescriptors;
for (var i = 0; i < playerDescriptors.Count; i++)
{
var chara = _objectTable[i];
if (chara == null || chara.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player)
var actor = playerDescriptors[i];
var playerAddress = actor.Address;
if (playerAddress == nint.Zero)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(chara.Address, out bool firstTime) && firstTime)
if (actor.ObjectIndex >= 200)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime)
{
_logger.LogTrace("Skipping character {addr}, blocked/muted", chara.Address.ToString("X"));
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
continue;
}
var charaName = ((GameObject*)chara.Address)->NameString;
var hash = GetHashedCIDFromPlayerPointer(chara.Address);
if (!IsAnythingDrawing)
CheckCharacterForDrawing(chara.Address, charaName);
_notUpdatedCharas.Remove(hash);
_playerCharas[hash] = (charaName, chara.Address);
{
var gameObj = (GameObject*)playerAddress;
var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty;
var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName;
CheckCharacterForDrawing(playerAddress, charaName);
if (IsAnythingDrawing)
break;
}
else
{
break;
}
}
foreach (var notUpdatedChara in _notUpdatedCharas)
{
_playerCharas.Remove(notUpdatedChara);
}
_notUpdatedCharas.Clear();
});
if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer))
@@ -679,76 +862,75 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_lastGlobalBlockReason = string.Empty;
}
if (_clientState.IsGPosing && !IsInGpose)
{
_logger.LogDebug("Gpose start");
IsInGpose = true;
Mediator.Publish(new GposeStartMessage());
}
else if (!_clientState.IsGPosing && IsInGpose)
{
_logger.LogDebug("Gpose end");
IsInGpose = false;
Mediator.Publish(new GposeEndMessage());
}
// Checks on conditions
var shouldBeInGpose = _clientState.IsGPosing;
var shouldBeInCombat = _condition[ConditionFlag.InCombat] && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat;
var shouldBePerforming = _condition[ConditionFlag.Performing] && _playerPerformanceConfigService.Current.PauseWhilePerforming;
var shouldBeInInstance = _condition[ConditionFlag.BoundByDuty] && _playerPerformanceConfigService.Current.PauseInInstanceDuty;
var shouldBeInCutscene = _condition[ConditionFlag.WatchingCutscene];
if ((_condition[ConditionFlag.InCombat]) && !IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat)
{
_logger.LogDebug("Combat start");
IsInCombat = true;
Mediator.Publish(new CombatStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCombat)));
}
else if ((!_condition[ConditionFlag.InCombat]) && IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat)
{
_logger.LogDebug("Combat end");
IsInCombat = false;
Mediator.Publish(new CombatEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat)));
}
if (_condition[ConditionFlag.Performing] && !IsPerforming && _playerPerformanceConfigService.Current.PauseWhilePerforming)
{
_logger.LogDebug("Performance start");
IsInCombat = true;
Mediator.Publish(new PerformanceStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsPerforming)));
}
else if (!_condition[ConditionFlag.Performing] && IsPerforming && _playerPerformanceConfigService.Current.PauseWhilePerforming)
{
_logger.LogDebug("Performance end");
IsInCombat = false;
Mediator.Publish(new PerformanceEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsPerforming)));
}
if ((_condition[ConditionFlag.BoundByDuty]) && !IsInInstance && _playerPerformanceConfigService.Current.PauseInInstanceDuty)
{
_logger.LogDebug("Instance start");
IsInInstance = true;
Mediator.Publish(new InstanceOrDutyStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInInstance)));
}
else if (((!_condition[ConditionFlag.BoundByDuty]) && IsInInstance && _playerPerformanceConfigService.Current.PauseInInstanceDuty) || ((_condition[ConditionFlag.BoundByDuty]) && IsInInstance && !_playerPerformanceConfigService.Current.PauseInInstanceDuty))
{
_logger.LogDebug("Instance end");
IsInInstance = false;
Mediator.Publish(new InstanceOrDutyEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInInstance)));
}
// Gpose
HandleStateTransition(() => IsInGpose, v => IsInGpose = v, shouldBeInGpose, "Gpose",
onEnter: () =>
{
Mediator.Publish(new GposeStartMessage());
},
onExit: () =>
{
Mediator.Publish(new GposeEndMessage());
});
if (_condition[ConditionFlag.WatchingCutscene] && !IsInCutscene)
{
_logger.LogDebug("Cutscene start");
IsInCutscene = true;
Mediator.Publish(new CutsceneStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene)));
}
else if (!_condition[ConditionFlag.WatchingCutscene] && IsInCutscene)
{
_logger.LogDebug("Cutscene end");
IsInCutscene = false;
Mediator.Publish(new CutsceneEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene)));
}
// Combat
HandleStateTransition(() => IsInCombat, v => IsInCombat = v, shouldBeInCombat, "Combat",
onEnter: () =>
{
Mediator.Publish(new CombatStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCombat)));
},
onExit: () =>
{
Mediator.Publish(new CombatEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat)));
});
// Performance
HandleStateTransition(() => IsPerforming, v => IsPerforming = v, shouldBePerforming, "Performance",
onEnter: () =>
{
Mediator.Publish(new PerformanceStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsPerforming)));
},
onExit: () =>
{
Mediator.Publish(new PerformanceEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsPerforming)));
});
// Instance / Duty
HandleStateTransition(() => IsInInstance, v => IsInInstance = v, shouldBeInInstance, "Instance",
onEnter: () =>
{
Mediator.Publish(new InstanceOrDutyStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInInstance)));
},
onExit: () =>
{
Mediator.Publish(new InstanceOrDutyEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInInstance)));
});
// Cutscene
HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene",
onEnter: () =>
{
Mediator.Publish(new CutsceneStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene)));
},
onExit: () =>
{
Mediator.Publish(new CutsceneEndMessage());
Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene)));
});
if (IsInCutscene)
{
@@ -782,10 +964,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
}
var localPlayer = _clientState.LocalPlayer;
var localPlayer = _objectTable.LocalPlayer;
if (localPlayer != null)
{
_classJobId = localPlayer.ClassJob.RowId;
var currentWorldId = (ushort)localPlayer.CurrentWorld.RowId;
if (currentWorldId != _lastWorldId)
{
var previousWorldId = _lastWorldId;
_lastWorldId = currentWorldId;
Mediator.Publish(new WorldChangedMessage(previousWorldId, currentWorldId));
}
}
else if (_lastWorldId != 0)
{
_lastWorldId = 0;
}
if (!IsInCombat || !IsPerforming || !IsInInstance)
@@ -801,6 +995,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_logger.LogDebug("Logged in");
IsLoggedIn = true;
_lastZone = _clientState.TerritoryType;
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage());
}
@@ -808,6 +1003,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
_logger.LogDebug("Logged out");
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
}
@@ -825,4 +1021,31 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_delayedFrameworkUpdateCheck = DateTime.UtcNow;
});
}
}
/// <summary>
/// Handler for the transition of different states of game
/// </summary>
/// <param name="getState">Get state of condition</param>
/// <param name="setState">Set state of condition</param>
/// <param name="shouldBeActive">Correction of the state of the condition</param>
/// <param name="stateName">Condition name</param>
/// <param name="onEnter">Function for on entering the state</param>
/// <param name="onExit">Function for on leaving the state</param>
private void HandleStateTransition(Func<bool> getState, Action<bool> setState, bool shouldBeActive, string stateName, System.Action onEnter, System.Action onExit)
{
var isActive = getState();
if (shouldBeActive && !isActive)
{
_logger.LogDebug("{stateName} start", stateName);
setState(true);
onEnter();
}
else if (!shouldBeActive && isActive)
{
_logger.LogDebug("{stateName} end", stateName);
setState(false);
onExit();
}
}
}

View File

@@ -6,6 +6,8 @@ public record Event
{
public DateTime EventTime { get; }
public string UID { get; }
public string AliasOrUid { get; }
public string UserId { get; }
public string Character { get; }
public string EventSource { get; }
public EventSeverity EventSeverity { get; }
@@ -14,7 +16,9 @@ public record Event
public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message)
{
EventTime = DateTime.Now;
this.UID = UserData.AliasOrUID;
this.UserId = UserData.UID;
this.AliasOrUid = UserData.AliasOrUID;
this.UID = UserData.UID;
this.Character = Character ?? string.Empty;
this.EventSource = EventSource;
this.EventSeverity = EventSeverity;
@@ -37,7 +41,7 @@ public record Event
else
{
if (string.IsNullOrEmpty(Character))
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}> {Message}";
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{AliasOrUid}> {Message}";
else
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}\\{Character}> {Message}";
}

View File

@@ -0,0 +1,692 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text;
using Dalamud.Interface;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.Rendering;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Pictomancy;
using System.Collections.Immutable;
using System.Globalization;
using System.Numerics;
using Task = System.Threading.Tasks.Task;
namespace LightlessSync.Services.LightFinder;
public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscriber
{
private readonly ILogger<LightFinderPlateHandler> _logger;
private readonly IAddonLifecycle _addonLifecycle;
private readonly IGameGui _gameGui;
private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService;
private readonly PairUiService _pairUiService;
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
private readonly IUiBuilder _uiBuilder;
private bool _mEnabled;
private bool _needsLabelRefresh;
private bool _drawSubscribed;
private AddonNamePlate* _mpNameplateAddon;
private readonly object _labelLock = new();
private readonly NameplateBuffers _buffers = new();
private int _labelRenderCount;
private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private static readonly Vector2 DefaultPivot = new(0.5f, 1f);
private ImmutableHashSet<string> _activeBroadcastingCids = [];
public LightFinderPlateHandler(
ILogger<LightFinderPlateHandler> logger,
IAddonLifecycle addonLifecycle,
IGameGui gameGui,
LightlessConfigService configService,
LightlessMediator mediator,
IObjectTable objectTable,
PairUiService pairUiService,
IDalamudPluginInterface pluginInterface,
PictomancyService pictomancyService)
{
_logger = logger;
_addonLifecycle = addonLifecycle;
_gameGui = gameGui;
_configService = configService;
_mediator = mediator;
_objectTable = objectTable;
_pairUiService = pairUiService;
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
_ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService));
}
internal void Init()
{
if (!_drawSubscribed)
{
_uiBuilder.Draw += OnUiBuilderDraw;
_drawSubscribed = true;
}
EnableNameplate();
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
}
internal void Uninit()
{
DisableNameplate();
if (_drawSubscribed)
{
_uiBuilder.Draw -= OnUiBuilderDraw;
_drawSubscribed = false;
}
ClearLabelBuffer();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
_mpNameplateAddon = null;
}
internal void EnableNameplate()
{
if (!_mEnabled)
{
try
{
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
_mEnabled = true;
}
catch (Exception e)
{
_logger.LogError(e, "Unknown error while trying to enable nameplate.");
DisableNameplate();
}
}
}
internal void DisableNameplate()
{
if (_mEnabled)
{
try
{
_addonLifecycle.UnregisterListener(NameplateDrawDetour);
}
catch (Exception e)
{
_logger.LogError(e, "Unknown error while unregistering nameplate listener.");
}
_mEnabled = false;
ClearNameplateCaches();
}
}
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
{
if (args.Addon.Address == nint.Zero)
{
if (_logger.IsEnabled(LogLevel.Warning))
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
return;
}
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
if (_mpNameplateAddon != pNameplateAddon)
{
ClearNameplateCaches();
_mpNameplateAddon = pNameplateAddon;
}
UpdateNameplateNodes();
}
private void UpdateNameplateNodes()
{
var currentHandle = _gameGui.GetAddonByName("NamePlate");
if (currentHandle.Address == nint.Zero)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
ClearLabelBuffer();
return;
}
var currentAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
{
if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
{
ClearLabelBuffer();
return;
}
var visibleUserIdsSnapshot = VisibleUserIds;
var safeCount = System.Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
var currentConfig = _configService.Current;
var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge");
var scratchCount = 0;
for (int i = 0; i < safeCount; ++i)
{
var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;
var nameplateIndex = objectInfo->NamePlateIndex;
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue;
var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
continue;
// CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
continue;
var local = _objectTable.LocalPlayer;
if (!currentConfig.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
continue;
var hidePaired = !currentConfig.LightfinderLabelShowPaired;
var goId = gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
continue;
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (root == null || root->Component == null || nameContainer == null || nameText == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
continue;
}
root->Component->UldManager.UpdateDrawNodeList();
bool isVisible =
(marker != null && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
currentConfig.LightfinderLabelShowHidden;
if (!isVisible)
continue;
var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f;
var effectiveScale = baseScale * scaleMultiplier;
var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f;
var targetFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
var labelContent = currentConfig.LightfinderLabelUseIcon
? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph)
: DefaultLabelText;
if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
labelContent = DefaultLabelText;
var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
AlignmentType alignment;
var textScaleY = nameText->AtkResNode.ScaleY;
if (textScaleY <= 0f)
textScaleY = 1f;
var blockHeight = ResolveCache(
_buffers.TextHeights,
nameplateIndex,
System.Math.Abs((int)nameplateObject.TextH),
() => GetScaledTextHeight(nameText),
nodeHeight);
var containerHeight = ResolveCache(
_buffers.ContainerHeights,
nameplateIndex,
(int)nameContainer->Height,
() =>
{
var computed = blockHeight + (int)System.Math.Round(8 * textScaleY);
return computed <= blockHeight ? blockHeight + 1 : computed;
},
blockHeight + 1);
var blockTop = containerHeight - blockHeight;
if (blockTop < 0)
blockTop = 0;
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
var positionY = blockTop - verticalPadding;
var rawTextWidth = (int)nameplateObject.TextW;
var textWidth = ResolveCache(
_buffers.TextWidths,
nameplateIndex,
System.Math.Abs(rawTextWidth),
() => GetScaledTextWidth(nameText),
nodeWidth);
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset);
if (nameContainer == null)
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Nameplate {Index} container became unavailable during update, skipping.", nameplateIndex);
continue;
}
float finalX;
if (currentConfig.LightfinderAutoAlign)
{
var measuredWidth = System.Math.Max(1, textWidth > 0 ? textWidth : nodeWidth);
var measuredWidthF = (float)measuredWidth;
var alignmentType = currentConfig.LabelAlignment;
var containerScale = nameContainer->ScaleX;
if (containerScale <= 0f)
containerScale = 1f;
var containerWidthRaw = (float)nameContainer->Width;
if (containerWidthRaw <= 0f)
containerWidthRaw = measuredWidthF;
var containerWidth = containerWidthRaw * containerScale;
if (containerWidth <= 0f)
containerWidth = measuredWidthF;
var containerLeft = nameContainer->ScreenX;
var containerRight = containerLeft + containerWidth;
var containerCenter = containerLeft + (containerWidth * 0.5f);
var iconMargin = currentConfig.LightfinderLabelUseIcon
? System.Math.Min(containerWidth * 0.1f, 14f * containerScale)
: 0f;
switch (alignmentType)
{
case LabelAlignment.Left:
finalX = containerLeft + iconMargin;
alignment = AlignmentType.BottomLeft;
break;
case LabelAlignment.Right:
finalX = containerRight - iconMargin;
alignment = AlignmentType.BottomRight;
break;
default:
finalX = containerCenter;
alignment = AlignmentType.Bottom;
break;
}
finalX += currentConfig.LightfinderLabelOffsetX;
}
else
{
var cachedTextOffset = _buffers.TextOffsets[nameplateIndex];
var hasCachedOffset = cachedTextOffset != int.MinValue;
var baseOffsetX = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset) ? cachedTextOffset : 0;
finalX = nameContainer->ScreenX + baseOffsetX + 58 + currentConfig.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += currentConfig.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
var finalPosition = new Vector2(finalX, nameContainer->ScreenY + positionY);
var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon)
? AlignmentToPivot(alignment)
: DefaultPivot;
var textColorPacked = PackColor(labelColor);
var edgeColorPacked = PackColor(edgeColor);
_buffers.LabelScratch[scratchCount++] = new NameplateLabelInfo(
finalPosition,
labelContent,
textColorPacked,
edgeColorPacked,
targetFontSize,
pivot,
currentConfig.LightfinderLabelUseIcon);
}
lock (_labelLock)
{
if (scratchCount == 0)
{
_labelRenderCount = 0;
}
else
{
Array.Copy(_buffers.LabelScratch, _buffers.LabelRender, scratchCount);
_labelRenderCount = scratchCount;
}
}
}
private void OnUiBuilderDraw()
{
if (!_mEnabled)
return;
int copyCount;
lock (_labelLock)
{
copyCount = _labelRenderCount;
if (copyCount == 0)
return;
Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount);
}
using var drawList = PictoService.Draw();
if (drawList == null)
return;
for (int i = 0; i < copyCount; ++i)
{
ref var info = ref _buffers.LabelCopy[i];
var font = default(ImFontPtr);
if (info.UseIcon)
{
var ioFonts = ImGui.GetIO().Fonts;
font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont();
}
drawList.AddScreenText(info.ScreenPosition, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font);
}
}
private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch
{
AlignmentType.BottomLeft => new Vector2(0f, 1f),
AlignmentType.BottomRight => new Vector2(1f, 1f),
AlignmentType.TopLeft => new Vector2(0f, 0f),
AlignmentType.TopRight => new Vector2(1f, 0f),
AlignmentType.Top => new Vector2(0.5f, 0f),
AlignmentType.Left => new Vector2(0f, 0.5f),
AlignmentType.Right => new Vector2(1f, 0.5f),
_ => DefaultPivot
};
private static uint PackColor(Vector4 color)
{
var r = (byte)System.Math.Clamp(color.X * 255f, 0f, 255f);
var g = (byte)System.Math.Clamp(color.Y * 255f, 0f, 255f);
var b = (byte)System.Math.Clamp(color.Z * 255f, 0f, 255f);
var a = (byte)System.Math.Clamp(color.W * 255f, 0f, 255f);
return (uint)((a << 24) | (b << 16) | (g << 8) | r);
}
private void ClearLabelBuffer()
{
lock (_labelLock)
{
_labelRenderCount = 0;
}
}
private static unsafe int GetScaledTextHeight(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawHeight = (int)resNode->GetHeight();
if (rawHeight <= 0 && node->LineSpacing > 0)
rawHeight = node->LineSpacing;
if (rawHeight <= 0)
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
var scale = resNode->ScaleY;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawHeight * scale);
return System.Math.Max(1, computed);
}
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawWidth = (int)resNode->GetWidth();
if (rawWidth <= 0)
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
var scale = resNode->ScaleX;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawWidth * scale);
return System.Math.Max(1, computed);
}
private static int ResolveCache(
int[] cache,
int index,
int rawValue,
Func<int> fallback,
int fallbackWhenZero)
{
if (rawValue > 0)
{
cache[index] = rawValue;
return rawValue;
}
var cachedValue = cache[index];
if (cachedValue > 0)
return cachedValue;
var computed = fallback();
if (computed <= 0)
computed = fallbackWhenZero;
cache[index] = computed;
return computed;
}
private bool TryCacheTextOffset(int nameplateIndex, int measuredTextWidth, int textOffset)
{
if (System.Math.Abs(measuredTextWidth) > 0 || textOffset != 0)
{
_buffers.TextOffsets[nameplateIndex] = textOffset;
return true;
}
return false;
}
internal static string NormalizeIconGlyph(string? rawInput)
{
if (string.IsNullOrWhiteSpace(rawInput))
return DefaultIconGlyph;
var trimmed = rawInput.Trim();
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
return SeIconCharExtensions.ToIconString(iconEnum);
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? trimmed[2..]
: trimmed;
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
return char.ConvertFromUtf32(hexValue);
var enumerator = trimmed.EnumerateRunes();
if (enumerator.MoveNext())
return enumerator.Current.ToString();
return DefaultIconGlyph;
}
internal static string ToIconEditorString(string? rawInput)
{
var normalized = NormalizeIconGlyph(rawInput);
var runeEnumerator = normalized.EnumerateRunes();
return runeEnumerator.MoveNext()
? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture)
: DefaultIconGlyph;
}
private readonly struct NameplateLabelInfo
{
public NameplateLabelInfo(
Vector2 screenPosition,
string text,
uint textColor,
uint edgeColor,
float fontSize,
Vector2 pivot,
bool useIcon)
{
ScreenPosition = screenPosition;
Text = text;
TextColor = textColor;
EdgeColor = edgeColor;
FontSize = fontSize;
Pivot = pivot;
UseIcon = useIcon;
}
public Vector2 ScreenPosition { get; }
public string Text { get; }
public uint TextColor { get; }
public uint EdgeColor { get; }
public float FontSize { get; }
public Vector2 Pivot { get; }
public bool UseIcon { get; }
}
private HashSet<ulong> VisibleUserIds
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh()
{
_needsLabelRefresh = true;
}
public void OnTick(PriorityFrameworkUpdateMessage _)
{
if (_needsLabelRefresh)
{
UpdateNameplateNodes();
_needsLabelRefresh = false;
}
}
public void UpdateBroadcastingCids(IEnumerable<string> cids)
{
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
return;
_activeBroadcastingCids = newSet;
if (_logger.IsEnabled(LogLevel.Information))
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
FlagRefresh();
}
public void ClearNameplateCaches()
{
_buffers.Clear();
ClearLabelBuffer();
}
private sealed class NameplateBuffers
{
public NameplateBuffers()
{
TextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
System.Array.Fill(TextOffsets, int.MinValue);
}
public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects];
public int[] TextHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects];
public int[] ContainerHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects];
public int[] TextOffsets { get; }
public NameplateLabelInfo[] LabelScratch { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
public NameplateLabelInfo[] LabelRender { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
public void Clear()
{
System.Array.Clear(TextWidths, 0, TextWidths.Length);
System.Array.Clear(TextHeights, 0, TextHeights.Length);
System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
System.Array.Fill(TextOffsets, int.MinValue);
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
Init();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Uninit();
return Task.CompletedTask;
}
}

View File

@@ -1,67 +1,62 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin.Services;
using Dalamud.Plugin.Services;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace LightlessSync.Services;
namespace LightlessSync.Services.LightFinder;
public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable
public class LightFinderScannerService : DisposableMediatorSubscriberBase
{
private readonly ILogger<BroadcastScannerService> _logger;
private readonly IObjectTable _objectTable;
private readonly ILogger<LightFinderScannerService> _logger;
private readonly ActorObjectService _actorTracker;
private readonly IFramework _framework;
private readonly BroadcastService _broadcastService;
private readonly NameplateHandler _nameplateHandler;
private readonly LightFinderService _broadcastService;
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new();
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
private readonly Queue<string> _lookupQueue = new();
private readonly HashSet<string> _lookupQueuedCids = new();
private readonly HashSet<string> _syncshellCids = new();
private readonly HashSet<string> _lookupQueuedCids = [];
private readonly HashSet<string> _syncshellCids = [];
private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
private readonly CancellationTokenSource _cleanupCts = new();
private Task? _cleanupTask;
private readonly Task? _cleanupTask;
private readonly int _checkEveryFrames = 20;
private int _frameCounter = 0;
private int _lookupsThisFrame = 0;
private const int MaxLookupsPerFrame = 30;
private const int MaxQueueSize = 100;
private const int _maxLookupsPerFrame = 30;
private const int _maxQueueSize = 100;
private volatile bool _batchRunning = false;
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
public BroadcastScannerService(ILogger<BroadcastScannerService> logger,
IClientState clientState,
IObjectTable objectTable,
public LightFinderScannerService(ILogger<LightFinderScannerService> logger,
IFramework framework,
BroadcastService broadcastService,
LightFinderService broadcastService,
LightlessMediator mediator,
NameplateHandler nameplateHandler,
DalamudUtilService dalamudUtil,
LightlessConfigService configService) : base(logger, mediator)
LightFinderPlateHandler lightFinderPlateHandler,
ActorObjectService actorTracker) : base(logger, mediator)
{
_logger = logger;
_objectTable = objectTable;
_actorTracker = actorTracker;
_broadcastService = broadcastService;
_nameplateHandler = nameplateHandler;
_lightFinderPlateHandler = lightFinderPlateHandler;
_logger = logger;
_framework = framework;
_framework.Update += OnFrameworkUpdate;
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop);
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token);
_nameplateHandler.Init();
_actorTracker = actorTracker;
}
private void OnFrameworkUpdate(IFramework framework) => Update();
@@ -69,34 +64,34 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
public void Update()
{
_frameCounter++;
_lookupsThisFrame = 0;
var lookupsThisFrame = 0;
if (!_broadcastService.IsBroadcasting)
return;
var now = DateTime.UtcNow;
foreach (var obj in _objectTable)
foreach (var address in _actorTracker.PlayerAddresses)
{
if (obj is not IPlayerCharacter player || player.Address == IntPtr.Zero)
if (address == nint.Zero)
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address);
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize)
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
_lookupQueue.Enqueue(cid);
}
if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0)
{
var cidsToLookup = new List<string>();
while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame)
while (_lookupQueue.Count > 0 && lookupsThisFrame < _maxLookupsPerFrame)
{
var cid = _lookupQueue.Dequeue();
_lookupQueuedCids.Remove(cid);
cidsToLookup.Add(cid);
_lookupsThisFrame++;
lookupsThisFrame++;
}
if (cidsToLookup.Count > 0 && !_batchRunning)
@@ -118,8 +113,8 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
continue;
var ttl = info.IsBroadcasting && info.TTL.HasValue
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks))
: RetryDelay;
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, _maxAllowedTtl.Ticks))
: _retryDelay;
var expiry = now + ttl;
@@ -133,7 +128,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
.Select(e => e.Key)
.ToList();
_nameplateHandler.UpdateBroadcastingCids(activeCids);
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts();
}
@@ -146,7 +141,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
_lookupQueuedCids.Clear();
_syncshellCids.Clear();
_nameplateHandler.UpdateBroadcastingCids(Enumerable.Empty<string>());
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
}
}
@@ -156,7 +151,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
var newSet = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Select(e => e.Key)
.ToHashSet();
.ToHashSet(StringComparer.Ordinal);
if (!_syncshellCids.SetEquals(newSet))
{
@@ -172,7 +167,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
{
var now = DateTime.UtcNow;
return _broadcastCache
return [.. _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Select(e => new BroadcastStatusInfoDto
{
@@ -180,8 +175,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
IsBroadcasting = true,
TTL = e.Value.ExpiryTime - now,
GID = e.Value.GID
})
.ToList();
})];
}
private async Task ExpiredBroadcastCleanupLoop()
@@ -192,7 +186,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(10), token);
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
var now = DateTime.UtcNow;
foreach (var (cid, entry) in _broadcastCache.ToArray())
@@ -202,7 +196,10 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
}
}
}
catch (OperationCanceledException) { }
catch (OperationCanceledException)
{
// No action needed when cancelled
}
catch (Exception ex)
{
_logger.LogError(ex, "Broadcast cleanup loop crashed");
@@ -235,8 +232,15 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos
{
base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate;
if (_cleanupTask != null)
{
_cleanupTask?.Wait(100, _cleanupCts.Token);
}
_cleanupCts.Cancel();
_cleanupCts.Dispose();
_cleanupTask?.Wait(100);
_nameplateHandler.Uninit();
_cleanupCts.Dispose();
}
}

View File

@@ -1,22 +1,21 @@
using Dalamud.Interface;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.Utils;
using LightlessSync.WebAPI;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Threading;
namespace LightlessSync.Services;
public class BroadcastService : IHostedService, IMediatorSubscriber
namespace LightlessSync.Services.LightFinder;
public class LightFinderService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<BroadcastService> _logger;
private readonly ILogger<LightFinderService> _logger;
private readonly ApiController _apiController;
private readonly LightlessMediator _mediator;
private readonly LightlessConfigService _config;
@@ -45,7 +44,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
}
}
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
public LightFinderService(ILogger<LightFinderService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
{
_logger = logger;
_mediator = mediator;
@@ -58,7 +57,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{
if (!_apiController.IsConnected)
{
_logger.LogDebug(context + " skipped, not connected");
_logger.LogDebug("{context} skipped, not connected", context);
return;
}
await action().ConfigureAwait(false);
@@ -282,7 +281,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (!msg.Enabled)
{
ApplyBroadcastDisabled(forcePublish: true);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
return;
}
@@ -295,7 +294,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (TryApplyBroadcastEnabled(ttl, "client request"))
{
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
}
else
{
@@ -372,7 +371,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
{
Dictionary<string, BroadcastStatusInfoDto?> result = new();
Dictionary<string, BroadcastStatusInfoDto?> result = new(StringComparer.Ordinal);
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
{
@@ -397,8 +396,6 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
return result;
}
public async void ToggleBroadcast()
{

View File

@@ -1,6 +0,0 @@
namespace LightlessSync.Services;
public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
}

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
namespace LightlessSync.Services;
public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
{
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));
}

View File

@@ -6,9 +6,12 @@ using LightlessSync.API.Dto.Group;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Chat;
using LightlessSync.Services.Events;
using LightlessSync.WebAPI.Files.Models;
using System.Numerics;
using LightlessSync.UI.Models;
namespace LightlessSync.Services.Mediator;
@@ -20,12 +23,15 @@ public record OpenSettingsUiMessage : MessageBase;
public record OpenLightfinderSettingsMessage : MessageBase;
public record DalamudLoginMessage : MessageBase;
public record DalamudLogoutMessage : MessageBase;
public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
public record ActorUntrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;
public record PriorityFrameworkUpdateMessage : SameThreadMessage;
public record FrameworkUpdateMessage : SameThreadMessage;
public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase;
public record DelayedFrameworkUpdateMessage : SameThreadMessage;
public record ZoneSwitchStartMessage : MessageBase;
public record ZoneSwitchEndMessage : MessageBase;
public record WorldChangedMessage(ushort PreviousWorldId, ushort CurrentWorldId) : MessageBase;
public record CutsceneStartMessage : MessageBase;
public record GposeStartMessage : SameThreadMessage;
public record GposeEndMessage : MessageBase;
@@ -65,6 +71,7 @@ public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record FileCacheInitializedMessage : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
@@ -72,11 +79,18 @@ public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase;
public record CyclePauseMessage(Pair Pair) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase;
public record ProfileOpenStandaloneMessage(Pair Pair) : MessageBase;
public record GroupProfileOpenStandaloneMessage(GroupData Group) : MessageBase;
public record OpenGroupProfileEditorMessage(GroupFullInfoDto Group) : MessageBase;
public record CloseGroupProfilePreviewMessage(GroupFullInfoDto Group) : MessageBase;
public record ActiveServerChangedMessage(string ServerUrl) : MessageBase;
public record OpenSelfProfilePreviewMessage(UserData User) : MessageBase;
public record CloseSelfProfilePreviewMessage(UserData User) : MessageBase;
public record OpenLightfinderProfileMessage(UserData User, LightlessProfileData ProfileData, string HashedCid) : MessageBase;
public record RemoveWindowMessage(WindowMediatorSubscriberBase Window) : MessageBase;
public record RefreshUiMessage : MessageBase;
public record OpenBanUserPopupMessage(Pair PairToBan, GroupFullInfoDto GroupFullInfoDto) : MessageBase;
@@ -85,8 +99,11 @@ public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase;
public record OpenPermissionWindow(Pair Pair) : MessageBase;
public record DownloadLimitChangedMessage() : SameThreadMessage;
public record PairProcessingLimitChangedMessage : SameThreadMessage;
public record PairDataChangedMessage : MessageBase;
public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase;
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase;
public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
public record CombatStartMessage : MessageBase;
public record CombatEndMessage : MessageBase;
public record PerformanceStartMessage : MessageBase;
@@ -107,10 +124,16 @@ public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData)
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase;
public record UserLeftSyncshell(string gid) : MessageBase;
public record UserJoinedSyncshell(string gid) : MessageBase;
public record SyncshellBroadcastsUpdatedMessage : MessageBase;
public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase;
public record PairRequestsUpdatedMessage : MessageBase;
public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase;
public record VisibilityChange : MessageBase;
public record ChatChannelsUpdated : MessageBase;
public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase;
public record GroupCollectionChangedMessage : MessageBase;
public record OpenUserProfileMessage(UserData User) : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name

View File

@@ -1,694 +0,0 @@
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Text;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.Utils;
using LightlessSync.UtilsEnum.Enum;
// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you!
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
namespace LightlessSync.Services;
public unsafe class NameplateHandler : IMediatorSubscriber
{
private readonly ILogger<NameplateHandler> _logger;
private readonly IAddonLifecycle _addonLifecycle;
private readonly IGameGui _gameGui;
private readonly IClientState _clientState;
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _configService;
private readonly PairManager _pairManager;
private readonly LightlessMediator _mediator;
public LightlessMediator Mediator => _mediator;
private bool _mEnabled = false;
private bool _needsLabelRefresh = false;
private AddonNamePlate* _mpNameplateAddon = null;
private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
internal const uint mNameplateNodeIDBase = 0x7D99D500;
private const string DefaultLabelText = "LightFinder";
private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn;
private const int _containerOffsetX = 50;
private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon);
private ImmutableHashSet<string> _activeBroadcastingCids = [];
public NameplateHandler(ILogger<NameplateHandler> logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager)
{
_logger = logger;
_addonLifecycle = addonLifecycle;
_gameGui = gameGui;
_dalamudUtil = dalamudUtil;
_configService = configService;
_mediator = mediator;
_clientState = clientState;
_pairManager = pairManager;
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
internal void Init()
{
EnableNameplate();
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
}
internal void Uninit()
{
DisableNameplate();
DestroyNameplateNodes();
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
_mpNameplateAddon = null;
}
internal void EnableNameplate()
{
if (!_mEnabled)
{
try
{
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
_mEnabled = true;
}
catch (Exception e)
{
_logger.LogError($"Unknown error while trying to enable nameplate distances:\n{e}");
DisableNameplate();
}
}
}
internal void DisableNameplate()
{
if (_mEnabled)
{
try
{
_addonLifecycle.UnregisterListener(NameplateDrawDetour);
}
catch (Exception e)
{
_logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}");
}
_mEnabled = false;
HideAllNameplateNodes();
}
}
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
{
if (args.Addon.Address == nint.Zero)
{
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
return;
}
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
if (_mpNameplateAddon != pNameplateAddon)
{
for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null;
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
_mpNameplateAddon = pNameplateAddon;
if (_mpNameplateAddon != null) CreateNameplateNodes();
}
UpdateNameplateNodes();
}
private void CreateNameplateNodes()
{
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
var nameplateObject = GetNameplateObject(i);
if (nameplateObject == null)
continue;
var rootNode = nameplateObject.Value.RootComponentNode;
if (rootNode == null || rootNode->Component == null)
continue;
var pNameplateResNode = nameplateObject.Value.NameContainer;
if (pNameplateResNode == null)
continue;
if (pNameplateResNode->ChildNode == null)
continue;
var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare);
if (pNewNode != null)
{
var pLastChild = pNameplateResNode->ChildNode;
while (pLastChild->PrevSiblingNode != null) pLastChild = pLastChild->PrevSiblingNode;
pNewNode->AtkResNode.NextSiblingNode = pLastChild;
pNewNode->AtkResNode.ParentNode = pNameplateResNode;
pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode;
rootNode->Component->UldManager.UpdateDrawNodeList();
pNewNode->AtkResNode.SetUseDepthBasedPriority(true);
_mTextNodes[i] = pNewNode;
}
}
}
private void DestroyNameplateNodes()
{
var currentHandle = _gameGui.GetAddonByName("NamePlate", 1);
if (currentHandle.Address == nint.Zero)
{
_logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
return;
}
var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null)
return;
if (_mpNameplateAddon != pCurrentNameplateAddon)
{
_logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon);
return;
}
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
{
var pTextNode = _mTextNodes[i];
var pNameplateNode = GetNameplateComponentNode(i);
if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null))
{
_logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i);
continue;
}
if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null)
{
try
{
if (pTextNode->AtkResNode.PrevSiblingNode != null)
pTextNode->AtkResNode.PrevSiblingNode->NextSiblingNode = pTextNode->AtkResNode.NextSiblingNode;
if (pTextNode->AtkResNode.NextSiblingNode != null)
pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode;
pNameplateNode->Component->UldManager.UpdateDrawNodeList();
pTextNode->AtkResNode.Destroy(true);
_mTextNodes[i] = null;
}
catch (Exception e)
{
_logger.LogError($"Unknown error while removing text node 0x{(IntPtr)pTextNode:X} for nameplate {i} on component node 0x{(IntPtr)pNameplateNode:X}:\n{e}");
}
}
}
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
private void HideAllNameplateNodes()
{
for (int i = 0; i < _mTextNodes.Length; ++i)
{
HideNameplateTextNode(i);
}
}
private void UpdateNameplateNodes()
{
var currentHandle = _gameGui.GetAddonByName("NamePlate");
if (currentHandle.Address == nint.Zero)
{
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
return;
}
var currentAddon = (AddonNamePlate*)currentHandle.Address;
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
{
if (_mpNameplateAddon != null)
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
return;
}
var framework = Framework.Instance();
if (framework == null)
{
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
return;
}
var uiModule = framework->GetUIModule();
if (uiModule == null)
{
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
return;
}
var ui3DModule = uiModule->GetUI3DModule();
if (ui3DModule == null)
{
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
return;
}
var vec = ui3DModule->NamePlateObjectInfoPointers;
if (vec.IsEmpty)
return;
var visibleUserIdsSnapshot = VisibleUserIds;
var safeCount = System.Math.Min(
ui3DModule->NamePlateObjectInfoCount,
vec.Length
);
for (int i = 0; i < safeCount; ++i)
{
var config = _configService.Current;
var objectInfoPtr = vec[i];
if (objectInfoPtr == null)
continue;
var objectInfo = objectInfoPtr.Value;
if (objectInfo == null || objectInfo->GameObject == null)
continue;
var nameplateIndex = objectInfo->NamePlateIndex;
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
continue;
var pNode = _mTextNodes[nameplateIndex];
if (pNode == null)
continue;
var gameObject = objectInfo->GameObject;
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
// CID gating
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
if (cid == null || !_activeBroadcastingCids.Contains(cid))
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var local = _clientState.LocalPlayer;
if (!config.LightfinderLabelShowOwn && local != null &&
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var hidePaired = !config.LightfinderLabelShowPaired;
var goId = (ulong)gameObject->GetGameObjectId();
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
{
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
var root = nameplateObject.RootComponentNode;
var nameContainer = nameplateObject.NameContainer;
var nameText = nameplateObject.NameText;
var marker = nameplateObject.MarkerIcon;
if (root == null || root->Component == null || nameContainer == null || nameText == null)
{
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
pNode->AtkResNode.ToggleVisibility(enable: false);
continue;
}
root->Component->UldManager.UpdateDrawNodeList();
bool isVisible =
((marker != null) && marker->AtkResNode.IsVisible()) ||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
config.LightfinderLabelShowHidden;
pNode->AtkResNode.ToggleVisibility(isVisible);
if (!isVisible)
continue;
var labelColor = UIColors.Get("Lightfinder");
var edgeColor = UIColors.Get("LightfinderEdge");
var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
var effectiveScale = baseScale * scaleMultiplier;
var labelContent = config.LightfinderLabelUseIcon
? NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
: DefaultLabelText;
pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier);
pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255);
AlignmentType alignment;
var textScaleY = nameText->AtkResNode.ScaleY;
if (textScaleY <= 0f)
textScaleY = 1f;
var blockHeight = System.Math.Abs((int)nameplateObject.TextH);
if (blockHeight > 0)
{
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
else
{
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
}
if (blockHeight <= 0)
{
blockHeight = GetScaledTextHeight(nameText);
if (blockHeight <= 0)
blockHeight = nodeHeight;
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
}
var containerHeight = (int)nameContainer->Height;
if (containerHeight > 0)
{
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
else
{
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
}
if (containerHeight <= 0)
{
containerHeight = blockHeight + (int)System.Math.Round(8 * textScaleY);
if (containerHeight <= blockHeight)
containerHeight = blockHeight + 1;
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
}
var blockTop = containerHeight - blockHeight;
if (blockTop < 0)
blockTop = 0;
var verticalPadding = (int)System.Math.Round(4 * effectiveScale);
var positionY = blockTop - verticalPadding - nodeHeight;
var textWidth = System.Math.Abs((int)nameplateObject.TextW);
if (textWidth <= 0)
{
textWidth = GetScaledTextWidth(nameText);
if (textWidth <= 0)
textWidth = nodeWidth;
}
if (textWidth > 0)
{
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
}
var textOffset = (int)System.Math.Round(nameText->AtkResNode.X);
var hasValidOffset = true;
if (System.Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
{
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
}
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
{
textOffset = _cachedNameplateTextOffsets[nameplateIndex];
}
else
{
hasValidOffset = false;
}
int positionX;
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
labelContent = DefaultLabelText;
pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
pNode->SetText(labelContent);
if (!config.LightfinderLabelUseIcon)
{
pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize;
pNode->AtkResNode.Width = 0;
nodeWidth = (int)pNode->AtkResNode.GetWidth();
if (nodeWidth <= 0)
nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
pNode->AtkResNode.Width = (ushort)nodeWidth;
}
else
{
pNode->TextFlags |= TextFlags.AutoAdjustNodeSize;
pNode->AtkResNode.Width = 0;
nodeWidth = pNode->AtkResNode.GetWidth();
}
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
{
var nameplateWidth = (int)nameContainer->Width;
int leftPos = nameplateWidth / 8;
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
int centrePos = (nameplateWidth - nodeWidth) / 2;
int staticMargin = 24;
int calcMargin = (int)(nameplateWidth * 0.08f);
switch (config.LabelAlignment)
{
case LabelAlignment.Left:
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
alignment = AlignmentType.BottomLeft;
break;
case LabelAlignment.Right:
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
alignment = AlignmentType.BottomRight;
break;
default:
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
alignment = AlignmentType.Bottom;
break;
}
}
else
{
positionX = 58 + config.LightfinderLabelOffsetX;
alignment = AlignmentType.Bottom;
}
positionY += config.LightfinderLabelOffsetY;
alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8);
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
pNode->AtkResNode.Color.A = 255;
pNode->TextColor.R = (byte)(labelColor.X * 255);
pNode->TextColor.G = (byte)(labelColor.Y * 255);
pNode->TextColor.B = (byte)(labelColor.Z * 255);
pNode->TextColor.A = (byte)(labelColor.W * 255);
pNode->EdgeColor.R = (byte)(edgeColor.X * 255);
pNode->EdgeColor.G = (byte)(edgeColor.Y * 255);
pNode->EdgeColor.B = (byte)(edgeColor.Z * 255);
pNode->EdgeColor.A = (byte)(edgeColor.W * 255);
if(!config.LightfinderLabelUseIcon)
{
pNode->AlignmentType = AlignmentType.Bottom;
}
else
{
pNode->AlignmentType = alignment;
}
pNode->AtkResNode.SetPositionShort(
(short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue),
(short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue)
);
var computedLineSpacing = (int)System.Math.Round(24 * scaleMultiplier);
pNode->LineSpacing = (byte)System.Math.Clamp(computedLineSpacing, 0, byte.MaxValue);
pNode->CharSpacing = 1;
pNode->TextFlags = config.LightfinderLabelUseIcon
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
: TextFlags.Edge | TextFlags.Glare;
}
}
private static unsafe int GetScaledTextHeight(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawHeight = (int)resNode->GetHeight();
if (rawHeight <= 0 && node->LineSpacing > 0)
rawHeight = node->LineSpacing;
if (rawHeight <= 0)
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
var scale = resNode->ScaleY;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawHeight * scale);
return System.Math.Max(1, computed);
}
private static unsafe int GetScaledTextWidth(AtkTextNode* node)
{
if (node == null)
return 0;
var resNode = &node->AtkResNode;
var rawWidth = (int)resNode->GetWidth();
if (rawWidth <= 0)
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
var scale = resNode->ScaleX;
if (scale <= 0f)
scale = 1f;
var computed = (int)System.Math.Round(rawWidth * scale);
return System.Math.Max(1, computed);
}
internal static string NormalizeIconGlyph(string? rawInput)
{
if (string.IsNullOrWhiteSpace(rawInput))
return DefaultIconGlyph;
var trimmed = rawInput.Trim();
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
return SeIconCharExtensions.ToIconString(iconEnum);
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? trimmed[2..]
: trimmed;
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
return char.ConvertFromUtf32(hexValue);
var enumerator = trimmed.EnumerateRunes();
if (enumerator.MoveNext())
return enumerator.Current.ToString();
return DefaultIconGlyph;
}
internal static string ToIconEditorString(string? rawInput)
{
var normalized = NormalizeIconGlyph(rawInput);
var runeEnumerator = normalized.EnumerateRunes();
return runeEnumerator.MoveNext()
? runeEnumerator.Current.Value.ToString("X4", CultureInfo.InvariantCulture)
: DefaultIconGlyph;
}
private void HideNameplateTextNode(int i)
{
var pNode = _mTextNodes[i];
if (pNode != null)
{
pNode->AtkResNode.ToggleVisibility(false);
}
}
private AddonNamePlate.NamePlateObject? GetNameplateObject(int i)
{
if (i < AddonNamePlate.NumNamePlateObjects &&
_mpNameplateAddon != null &&
_mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null)
{
return _mpNameplateAddon->NamePlateObjectArray[i];
}
else
{
return null;
}
}
private AtkComponentNode* GetNameplateComponentNode(int i)
{
var nameplateObject = GetNameplateObject(i);
return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null;
}
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)];
public void FlagRefresh()
{
_needsLabelRefresh = true;
}
public void OnTick(PriorityFrameworkUpdateMessage _)
{
if (_needsLabelRefresh)
{
UpdateNameplateNodes();
_needsLabelRefresh = false;
}
}
public void UpdateBroadcastingCids(IEnumerable<string> cids)
{
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
return;
_activeBroadcastingCids = newSet;
_logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids));
FlagRefresh();
}
public void ClearNameplateCaches()
{
System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
}
}

View File

@@ -1,114 +1,254 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Gui.NamePlate;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.NativeWrapper;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Component.GUI;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
using System.Numerics;
using static LightlessSync.UI.DtrEntry;
using LSeStringBuilder = Lumina.Text.SeStringBuilder;
namespace LightlessSync.Services;
public class NameplateService : DisposableMediatorSubscriberBase
/// <summary>
/// NameplateService is used for coloring our nameplates based on the settings of the user.
/// </summary>
public unsafe class NameplateService : DisposableMediatorSubscriberBase
{
private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex);
// Glyceri, Thanks :bow:
[Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))]
private readonly Hook<UpdateNameplateDelegate>? _nameplateHook = null;
private readonly ILogger<NameplateService> _logger;
private readonly LightlessConfigService _configService;
private readonly IClientState _clientState;
private readonly INamePlateGui _namePlateGui;
private readonly PairManager _pairManager;
private readonly IGameGui _gameGui;
private readonly IObjectTable _objectTable;
private readonly PairUiService _pairUiService;
public NameplateService(ILogger<NameplateService> logger,
LightlessConfigService configService,
INamePlateGui namePlateGui,
IClientState clientState,
PairManager pairManager,
LightlessMediator lightlessMediator) : base(logger, lightlessMediator)
IGameGui gameGui,
IObjectTable objectTable,
IGameInteropProvider interop,
LightlessMediator lightlessMediator,
PairUiService pairUiService) : base(logger, lightlessMediator)
{
_logger = logger;
_configService = configService;
_namePlateGui = namePlateGui;
_clientState = clientState;
_pairManager = pairManager;
_gameGui = gameGui;
_objectTable = objectTable;
_pairUiService = pairUiService;
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw());
interop.InitializeFromAttributes(this);
_nameplateHook?.Enable();
Refresh();
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
}
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers)
/// <summary>
/// Detour for the game's internal nameplate update function.
/// This will be called whenever the client updates any nameplate.
///
/// We hook into it to apply our own nameplate coloring logic via <see cref="SetNameplate"/>,
/// </summary>
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
{
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen))
try
{
SetNameplate(namePlateInfo, battleChara);
}
catch (Exception e)
{
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour");
}
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
}
/// <summary>
/// Determine if the player should be colored based on conditions (isFriend, IsInParty)
/// </summary>
/// <param name="playerCharacter">Player character that will be checked</param>
/// <param name="visibleUserIds">All visible users in the current object table</param>
/// <returns>PLayer should or shouldnt be colored based on the result. True means colored</returns>
private bool ShouldColorPlayer(IPlayerCharacter playerCharacter, HashSet<ulong> visibleUserIds)
{
if (!visibleUserIds.Contains(playerCharacter.GameObjectId))
return false;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = _configService.Current.overridePartyColor && isInParty;
bool friendColorAllowed = _configService.Current.overrideFriendColor && isFriend;
if ((isInParty && !partyColorAllowed) || (isFriend && !friendColorAllowed))
return false;
return true;
}
/// <summary>
/// Setting up the nameplate of the user to be colored
/// </summary>
/// <param name="namePlateInfo">Information given from the Signature to be updated</param>
/// <param name="battleChara">Character from FF</param>
private void SetNameplate(RaptureAtkModule.NamePlateInfo* namePlateInfo, BattleChara* battleChara)
{
if (!_configService.Current.IsNameplateColorsEnabled || _clientState.IsPvPExcludingDen)
return;
if (namePlateInfo == null || battleChara == null)
return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs()
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
var obj = _objectTable.FirstOrDefault(o => o.Address == (nint)battleChara);
if (obj is not IPlayerCharacter player)
return;
var now = DateTime.UtcNow;
var colors = _configService.Current.NameplateColors;
var snapshot = _pairUiService.GetSnapshot();
var visibleUsersIds = snapshot.PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
foreach (var handler in handlers)
{
var playerCharacter = handler.PlayerCharacter;
if (playerCharacter == null)
continue;
//Check if player should be colored
if (!ShouldColorPlayer(player, visibleUsersIds))
return;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty);
bool friendColorAllowed = (_configService.Current.overrideFriendColor && isFriend);
var originalName = player.Name.ToString();
if (visibleUsersIds.Contains(handler.GameObjectId) &&
!(
(isInParty && !partyColorAllowed) ||
(isFriend && !friendColorAllowed)
))
{
handler.NameParts.TextWrap = CreateTextWrap(colors);
//Check if not null of the name
if (string.IsNullOrEmpty(originalName))
return;
if (_configService.Current.overrideFcTagColor)
{
bool hasActualFcTag = playerCharacter.CompanyTag.TextValue.Length > 0;
bool isFromDifferentRealm = playerCharacter.HomeWorld.RowId != playerCharacter.CurrentWorld.RowId;
bool shouldColorFcArea = hasActualFcTag || (!hasActualFcTag && isFromDifferentRealm);
if (shouldColorFcArea)
{
handler.FreeCompanyTagParts.OuterWrap = CreateTextWrap(colors);
handler.FreeCompanyTagParts.TextWrap = CreateTextWrap(colors);
}
}
}
}
//Check if any characters/symbols are forbidden
if (HasForbiddenSeStringChars(originalName))
return;
//Swap color channels as we store them in BGR format as FF loves that
var cfgColors = SwapColorChannels(_configService.Current.NameplateColors);
var coloredName = WrapStringInColor(originalName, cfgColors.Glow, cfgColors.Foreground);
//Replace string of nameplate with our colored one
namePlateInfo->Name.SetString(coloredName.EncodeWithNullTerminator());
}
/// <summary>
/// Converts Uint code to Vector4 as we store Colors in Uint in our config, needed for lumina
/// </summary>
/// <param name="rgb">Color code</param>
/// <returns>Vector4 Color</returns>
private static Vector4 RgbUintToVector4(uint rgb)
{
float r = ((rgb >> 16) & 0xFF) / 255f;
float g = ((rgb >> 8) & 0xFF) / 255f;
float b = (rgb & 0xFF) / 255f;
return new Vector4(r, g, b, 1f);
}
/// <summary>
/// Checks if the string has any forbidden characters/symbols as the string builder wouldnt append.
/// </summary>
/// <param name="s">String that has to be checked</param>
/// <returns>Contains forbidden characters/symbols or not</returns>
private static bool HasForbiddenSeStringChars(string s)
{
if (string.IsNullOrEmpty(s))
return false;
foreach (var ch in s)
{
if (ch == '\0' || ch == '\u0002')
return true;
}
return false;
}
/// <summary>
/// Wraps the given string with the given edge and text color.
/// </summary>
/// <param name="text">String that has to be wrapped</param>
/// <param name="edgeColor">Edge(border) color</param>
/// <param name="textColor">Text color</param>
/// <returns>Color wrapped SeString</returns>
public static SeString WrapStringInColor(string text, uint? edgeColor = null, uint? textColor = null)
{
if (string.IsNullOrEmpty(text))
return SeString.Empty;
var builder = new LSeStringBuilder();
if (textColor is uint tc)
builder.PushColorRgba(RgbUintToVector4(tc));
if (edgeColor is uint ec)
builder.PushEdgeColorRgba(RgbUintToVector4(ec));
builder.Append(text);
if (edgeColor != null)
builder.PopEdgeColor();
if (textColor != null)
builder.PopColor();
return builder.ToReadOnlySeString().ToDalamudString();
}
/// <summary>
/// Request redraw of nameplates
/// </summary>
public void RequestRedraw()
{
_namePlateGui.RequestRedraw();
Refresh();
}
private static (SeString, SeString) CreateTextWrap(DtrEntry.Colors color)
/// <summary>
/// Toggles the refresh of the Nameplate addon
/// </summary>
protected void Refresh()
{
var left = new Lumina.Text.SeStringBuilder();
var right = new Lumina.Text.SeStringBuilder();
AtkUnitBasePtr namePlateAddon = _gameGui.GetAddonByName("NamePlate");
left.PushColorRgba(color.Foreground);
right.PopColor();
if (namePlateAddon.IsNull)
{
_logger.LogInformation("NamePlate addon is null, cannot refresh nameplates.");
return;
}
left.PushEdgeColorRgba(color.Glow);
right.PopEdgeColor();
var addonNamePlate = (AddonNamePlate*)namePlateAddon.Address;
return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString());
if (addonNamePlate == null)
{
_logger.LogInformation("addonNamePlate addon is null, cannot refresh nameplates.");
return;
}
addonNamePlate->DoFullUpdate = 1;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
_nameplateHook?.Dispose();
}
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
base.Dispose(disposing);
}
}

View File

@@ -4,9 +4,14 @@ using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using FFXIVClientStructs.FFXIV.Client.UI;
@@ -23,7 +28,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui;
private readonly PairRequestService _pairRequestService;
private readonly HashSet<string> _shownPairRequestNotifications = new();
private readonly HashSet<string> _shownPairRequestNotifications = [];
private readonly PairUiService _pairUiService;
private readonly PairFactory _pairFactory;
public NotificationService(
ILogger<NotificationService> logger,
@@ -32,7 +39,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
INotificationManager notificationManager,
IChatGui chatGui,
LightlessMediator mediator,
PairRequestService pairRequestService) : base(logger, mediator)
PairRequestService pairRequestService,
PairUiService pairUiService,
PairFactory pairFactory) : base(logger, mediator)
{
_logger = logger;
_configService = configService;
@@ -40,6 +49,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_notificationManager = notificationManager;
_chatGui = chatGui;
_pairRequestService = pairRequestService;
_pairUiService = pairUiService;
_pairFactory = pairFactory;
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -59,7 +70,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
{
var notification = CreateNotification(title, message, type, duration, actions, soundEffectId);
if (_configService.Current.AutoDismissOnAction && notification.Actions.Any())
if (_configService.Current.AutoDismissOnAction && notification.Actions.Count != 0)
{
WrapActionsWithAutoDismiss(notification);
}
@@ -104,7 +115,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
}
}
private void DismissNotification(LightlessNotification notification)
private static void DismissNotification(LightlessNotification notification)
{
notification.IsDismissed = true;
notification.IsAnimatingOut = true;
@@ -208,10 +219,12 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private string FormatDownloadCompleteMessage(string fileName, int fileCount) =>
fileCount > 1
private static string FormatDownloadCompleteMessage(string fileName, int fileCount)
{
return fileCount > 1
? $"Downloaded {fileCount} files successfully."
: $"Downloaded {fileName} successfully.";
}
private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder)
{
@@ -257,8 +270,10 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private string FormatErrorMessage(string message, Exception? exception) =>
exception != null ? $"{message}\n\nError: {exception.Message}" : message;
private static string FormatErrorMessage(string message, Exception? exception)
{
return exception != null ? $"{message}\n\nError: {exception.Message}" : message;
}
private List<LightlessNotificationAction> CreateErrorActions(Action? onRetry, Action? onViewLog)
{
@@ -332,8 +347,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}"));
}
private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) =>
download.Status switch
private static string FormatDownloadStatus((string PlayerName, float Progress, string Status) download)
{
return download.Status switch
{
"downloading" => $"{download.Progress:P0}",
"decompressing" => "decompressing",
@@ -341,6 +357,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
"waiting" => "waiting for slot",
_ => download.Status
};
}
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
{
@@ -391,6 +408,17 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId);
}
}
private Pair? ResolvePair(UserData userData)
{
var snapshot = _pairUiService.GetSnapshot();
if (snapshot.PairsByUid.TryGetValue(userData.UID, out var pair))
{
return pair;
}
var ident = new PairUniqueIdentifier(userData.UID);
return _pairFactory.Create(ident);
}
private void HandleNotificationMessage(NotificationMessage msg)
{
@@ -478,13 +506,16 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
});
}
private Dalamud.Interface.ImGuiNotification.NotificationType
ConvertToDalamudNotificationType(NotificationType type) => type switch
private static Dalamud.Interface.ImGuiNotification.NotificationType
ConvertToDalamudNotificationType(NotificationType type)
{
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
};
return type switch
{
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
};
}
private void ShowChat(NotificationMessage msg)
{
@@ -568,7 +599,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
{
var activeRequests = _pairRequestService.GetActiveRequests();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(StringComparer.Ordinal);
// Dismiss notifications for requests that are no longer active (expired)
var notificationsToRemove = _shownPairRequestNotifications
@@ -585,7 +616,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
{
var userDownloads = msg.DownloadStatus.Where(x => x.PlayerName != "Pair Queue").ToList();
var userDownloads = msg.DownloadStatus.Where(x => !string.Equals(x.PlayerName, "Pair Queue", StringComparison.Ordinal)).ToList();
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f;
var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting);
@@ -659,7 +690,14 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
{
try
{
Mediator.Publish(new CyclePauseMessage(userData));
var pair = ResolvePair(userData);
if (pair == null)
{
_logger.LogWarning("Cannot cycle pause {uid} because pair is missing", userData.UID);
throw new InvalidOperationException("Pair not available");
}
Mediator.Publish(new CyclePauseMessage(pair));
DismissNotification(notification);
var displayName = GetUserDisplayName(userData, playerName);
@@ -734,7 +772,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return actions;
}
private string GetUserDisplayName(UserData userData, string playerName)
private static string GetUserDisplayName(UserData userData, string playerName)
{
if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal))
{

View File

@@ -1,15 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
namespace LightlessSync.Services.PairProcessing;
public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
private const int HardLimit = 32;
private const int _hardLimit = 32;
private readonly LightlessConfigService _configService;
private readonly object _limitLock = new();
private readonly SemaphoreSlim _semaphore;
@@ -24,8 +21,8 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
_configService = configService;
_currentLimit = CalculateLimit();
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : HardLimit;
_semaphore = new SemaphoreSlim(initialCount, HardLimit);
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : _hardLimit;
_semaphore = new SemaphoreSlim(initialCount, _hardLimit);
Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit());
}
@@ -88,7 +85,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!enabled)
{
var releaseAmount = HardLimit - _semaphore.CurrentCount;
var releaseAmount = _hardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0)
{
TryReleaseSemaphore(releaseAmount);
@@ -110,7 +107,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
var increment = desiredLimit - _currentLimit;
_pendingIncrements += increment;
var available = HardLimit - _semaphore.CurrentCount;
var available = _hardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{
@@ -148,7 +145,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
private int CalculateLimit()
{
var configured = _configService.Current.MaxConcurrentPairApplications;
return Math.Clamp(configured, 1, HardLimit);
return Math.Clamp(configured, 1, _hardLimit);
}
private bool TryReleaseSemaphore(int count = 1)
@@ -248,8 +245,3 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
}
}
}
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
{
public int Remaining => Math.Max(0, Limit - InFlight);
}

View File

@@ -0,0 +1,9 @@
using System.Runtime.InteropServices;
namespace LightlessSync.Services.PairProcessing;
[StructLayout(LayoutKind.Auto)]
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
{
public int Remaining => Math.Max(0, Limit - InFlight);
}

View File

@@ -1,6 +1,6 @@
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
@@ -8,10 +8,11 @@ namespace LightlessSync.Services;
public sealed class PairRequestService : DisposableMediatorSubscriberBase
{
private readonly DalamudUtilService _dalamudUtil;
private readonly PairManager _pairManager;
private readonly PairUiService _pairUiService;
private readonly Lazy<WebAPI.ApiController> _apiController;
private readonly Lock _syncRoot = new();
private readonly List<PairRequestEntry> _requests = [];
private readonly Dictionary<string, string> _displayNameCache = new(StringComparer.Ordinal);
private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
@@ -19,12 +20,12 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
ILogger<PairRequestService> logger,
LightlessMediator mediator,
DalamudUtilService dalamudUtil,
PairManager pairManager,
PairUiService pairUiService,
Lazy<WebAPI.ApiController> apiController)
: base(logger, mediator)
{
_dalamudUtil = dalamudUtil;
_pairManager = pairManager;
_pairUiService = pairUiService;
_apiController = apiController;
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
@@ -96,6 +97,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
lock (_syncRoot)
{
removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0;
if (removed)
{
_displayNameCache.Remove(hashedCid);
}
}
if (removed)
@@ -129,6 +134,23 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
return string.Empty;
}
if (TryGetCachedDisplayName(hashedCid, out var cached))
{
return cached;
}
var resolved = ResolveDisplayNameInternal(hashedCid);
if (!string.IsNullOrWhiteSpace(resolved))
{
CacheDisplayName(hashedCid, resolved);
return resolved;
}
return string.Empty;
}
private string ResolveDisplayNameInternal(string hashedCid)
{
var (name, address) = _dalamudUtil.FindPlayerByNameHash(hashedCid);
if (!string.IsNullOrWhiteSpace(name))
{
@@ -138,8 +160,9 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
: name;
}
var pair = _pairManager
.GetOnlineUserPairs()
var snapshot = _pairUiService.GetSnapshot();
var pair = snapshot.PairsByUid.Values
.Where(p => !string.IsNullOrEmpty(p.GetPlayerNameHash()))
.FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal));
if (pair != null)
@@ -185,7 +208,21 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
}
var now = DateTime.UtcNow;
return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0;
var removedAny = false;
for (var i = _requests.Count - 1; i >= 0; i--)
{
var entry = _requests[i];
if (now - entry.ReceivedAt <= _expiration)
{
continue;
}
_displayNameCache.Remove(entry.HashedCid);
_requests.RemoveAt(i);
removedAny = true;
}
return removedAny;
}
public void AcceptPairRequest(string hashedCid, string displayName)
@@ -229,4 +266,32 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt);
private bool TryGetCachedDisplayName(string hashedCid, out string displayName)
{
lock (_syncRoot)
{
if (!string.IsNullOrWhiteSpace(hashedCid) && _displayNameCache.TryGetValue(hashedCid, out var cached))
{
displayName = cached;
return true;
}
}
displayName = string.Empty;
return false;
}
private void CacheDisplayName(string hashedCid, string displayName)
{
if (string.IsNullOrWhiteSpace(hashedCid) || string.IsNullOrWhiteSpace(displayName) || string.Equals(hashedCid, displayName, StringComparison.Ordinal))
{
return;
}
lock (_syncRoot)
{
_displayNameCache[hashedCid] = displayName;
}
}
}

View File

@@ -26,12 +26,12 @@ public sealed class PerformanceCollectorService : IHostedService
{
if (!_lightlessConfigService.Current.LogPerformance) return func.Invoke();
string cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage();
var owner = sender.GetType().Name;
var counter = counterName.BuildMessage();
var cn = string.Concat(owner, _counterSplit, counter);
if (!PerformanceCounters.TryGetValue(cn, out var list))
{
list = PerformanceCounters[cn] = new(maxEntries);
}
var dt = DateTime.UtcNow.Ticks;
try
@@ -53,12 +53,12 @@ public sealed class PerformanceCollectorService : IHostedService
{
if (!_lightlessConfigService.Current.LogPerformance) { act.Invoke(); return; }
var cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage();
var owner = sender.GetType().Name;
var counter = counterName.BuildMessage();
var cn = string.Concat(owner, _counterSplit, counter);
if (!PerformanceCounters.TryGetValue(cn, out var list))
{
list = PerformanceCounters[cn] = new(maxEntries);
}
var dt = DateTime.UtcNow.Ticks;
try
@@ -72,7 +72,7 @@ public sealed class PerformanceCollectorService : IHostedService
if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10))
_logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed));
#endif
list.Add(new(TimeOnly.FromDateTime(DateTime.Now), elapsed));
list.Add((TimeOnly.FromDateTime(DateTime.Now), elapsed));
}
}
@@ -121,11 +121,11 @@ public sealed class PerformanceCollectorService : IHostedService
sb.Append('|');
sb.Append("-Counter Name".PadRight(longestCounterName, '-'));
sb.AppendLine();
var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
var previousCaller = orderedData[0].Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0];
var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList();
var previousCaller = SplitCounterKey(orderedData[0].Key).Owner;
foreach (var entry in orderedData)
{
var newCaller = entry.Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0];
var newCaller = SplitCounterKey(entry.Key).Owner;
if (!string.Equals(previousCaller, newCaller, StringComparison.Ordinal))
{
DrawSeparator(sb, longestCounterName);
@@ -135,13 +135,13 @@ public sealed class PerformanceCollectorService : IHostedService
if (pastEntries.Any())
{
sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries.Last().Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries[^1].Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|');
sb.Append((" " + TimeSpan.FromTicks(pastEntries.Max(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|');
sb.Append((" " + TimeSpan.FromTicks((long)pastEntries.Average(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|');
sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries.Last().Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' '));
sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries[^1].Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' '));
sb.Append('|');
sb.Append((" " + pastEntries.Count).PadRight(10));
sb.Append('|');
@@ -157,6 +157,12 @@ public sealed class PerformanceCollectorService : IHostedService
_logger.LogInformation("{perf}", sb.ToString());
}
private static (string Owner, string Counter) SplitCounterKey(string cn)
{
var parts = cn.Split(_counterSplit, 2, StringSplitOptions.None);
return (parts[0], parts.Length > 1 ? parts[1] : string.Empty);
}
private static void DrawSeparator(StringBuilder sb, int longestCounterName)
{
sb.Append("".PadRight(15, '-'));
@@ -183,7 +189,7 @@ public sealed class PerformanceCollectorService : IHostedService
{
try
{
var last = entries.Value.ToList().Last();
var last = entries.Value.ToList()[^1];
if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _))
{
_logger.LogDebug("Could not remove performance counter {counter}", entries.Key);

View File

@@ -1,9 +1,10 @@
using LightlessSync.API.Data;
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.WebAPI.Files.Models;
using Microsoft.Extensions.Logging;
@@ -17,20 +18,22 @@ public class PlayerPerformanceService
private readonly ILogger<PlayerPerformanceService> _logger;
private readonly LightlessMediator _mediator;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, LightlessMediator mediator,
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
XivDataAnalyzer xivDataAnalyzer)
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService)
{
_logger = logger;
_mediator = mediator;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = xivDataAnalyzer;
_textureDownscaleService = textureDownscaleService;
}
public async Task<bool> CheckBothThresholds(PairHandler pairHandler, CharacterData charaData)
public async Task<bool> CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
{
var config = _playerPerformanceConfigService.Current;
bool notPausedAfterVram = ComputeAndAutoPauseOnVRAMUsageThresholds(pairHandler, charaData, []);
@@ -39,37 +42,37 @@ public class PlayerPerformanceService
if (!notPausedAfterTris) return false;
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pairHandler.Pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.Pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
var vramUsage = pairHandler.Pair.LastAppliedApproximateVRAMBytes;
var triUsage = pairHandler.Pair.LastAppliedDataTris;
var vramUsage = pairHandler.LastAppliedApproximateVRAMBytes;
var triUsage = pairHandler.LastAppliedDataTris;
bool isPrefPerm = pairHandler.Pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000,
bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000L,
triUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm);
bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024 * 1024,
bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024L * 1024L,
vramUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm);
if (_warnedForPlayers.TryGetValue(pairHandler.Pair.UserData.UID, out bool hadWarning) && hadWarning)
if (_warnedForPlayers.TryGetValue(pairHandler.UserData.UID, out bool hadWarning) && hadWarning)
{
_warnedForPlayers[pairHandler.Pair.UserData.UID] = exceedsTris || exceedsVram;
_warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram;
return true;
}
_warnedForPlayers[pairHandler.Pair.UserData.UID] = exceedsTris || exceedsVram;
_warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram;
if (exceedsVram)
{
_mediator.Publish(new EventMessage(new Event(pairHandler.Pair.PlayerName, pairHandler.Pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds VRAM threshold: ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeWarningThresholdMiB} MiB)")));
}
if (exceedsTris)
{
_mediator.Publish(new EventMessage(new Event(pairHandler.Pair.PlayerName, pairHandler.Pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds triangle threshold: ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
}
@@ -78,41 +81,40 @@ public class PlayerPerformanceService
string warningText = string.Empty;
if (exceedsTris && !exceedsVram)
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" +
$"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
}
else if (!exceedsTris)
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB";
}
else
{
warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" +
$"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles";
}
_mediator.Publish(new PerformanceNotificationMessage(
$"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds performance threshold(s)",
warningText,
pairHandler.Pair.UserData,
pairHandler.Pair.IsPaused,
pairHandler.Pair.PlayerName));
pairHandler.UserData,
pairHandler.IsPaused,
pairHandler.PlayerName));
}
return true;
}
public async Task<bool> CheckTriangleUsageThresholds(PairHandler pairHandler, CharacterData charaData)
public async Task<bool> CheckTriangleUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
{
var config = _playerPerformanceConfigService.Current;
var pair = pairHandler.Pair;
long triUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pair.LastAppliedDataTris = 0;
pairHandler.LastAppliedDataTris = 0;
return true;
}
@@ -126,35 +128,35 @@ public class PlayerPerformanceService
triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
}
pair.LastAppliedDataTris = triUsage;
pairHandler.LastAppliedDataTris = triUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
// now check auto pause
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000,
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000L,
triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" +
$"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
pairHandler.UserData,
true,
pair.PlayerName));
pairHandler.PlayerName));
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)")));
_mediator.Publish(new PauseMessage(pair.UserData));
_mediator.Publish(new PauseMessage(pairHandler.UserData));
return false;
}
@@ -162,16 +164,18 @@ public class PlayerPerformanceService
return true;
}
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(PairHandler pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
{
var config = _playerPerformanceConfigService.Current;
var pair = pairHandler.Pair;
bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
long vramUsage = 0;
long effectiveVramUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pair.LastAppliedApproximateVRAMBytes = 0;
pairHandler.LastAppliedApproximateVRAMBytes = 0;
pairHandler.LastAppliedApproximateEffectiveVRAMBytes = 0;
return true;
}
@@ -183,11 +187,13 @@ public class PlayerPerformanceService
foreach (var hash in moddedTextureHashes)
{
long fileSize = 0;
long effectiveSize = 0;
var download = toDownloadFiles.Find(f => string.Equals(hash, f.Hash, StringComparison.OrdinalIgnoreCase));
if (download != null)
{
fileSize = download.TotalRaw;
effectiveSize = fileSize;
}
else
{
@@ -201,39 +207,63 @@ public class PlayerPerformanceService
}
fileSize = fileEntry.Size.Value;
effectiveSize = fileSize;
if (!skipDownscale)
{
var preferredPath = _textureDownscaleService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
if (!string.IsNullOrEmpty(preferredPath) && File.Exists(preferredPath))
{
try
{
effectiveSize = new FileInfo(preferredPath).Length;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to read size for preferred texture path {Path}", preferredPath);
effectiveSize = fileSize;
}
}
else
{
effectiveSize = fileSize;
}
}
}
vramUsage += fileSize;
effectiveVramUsage += effectiveSize;
}
pair.LastAppliedApproximateVRAMBytes = vramUsage;
pairHandler.LastAppliedApproximateVRAMBytes = vramUsage;
pairHandler.LastAppliedApproximateEffectiveVRAMBytes = effectiveVramUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
.Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal)))
.Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal)))
return true;
bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky);
bool isPrefPerm = pairHandler.HasStickyPermissions;
// now check auto pause
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024,
if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024L * 1024L,
vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm))
{
var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" +
$"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB";
_mediator.Publish(new PerformanceNotificationMessage(
$"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused",
$"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused",
message,
pair.UserData,
pairHandler.UserData,
true,
pair.PlayerName));
pairHandler.PlayerName));
_mediator.Publish(new PauseMessage(pair.UserData));
_mediator.Publish(new PauseMessage(pairHandler.UserData));
_mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
_mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning,
$"Exceeds VRAM threshold: automatically paused ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB} MiB)")));
return false;

View File

@@ -0,0 +1,17 @@
namespace LightlessSync.Services.Profiles;
public record LightlessGroupProfileData(
bool IsDisabled,
bool IsNsfw,
string Base64ProfilePicture,
string Base64BannerPicture,
string Description,
IReadOnlyList<int> Tags)
{
public Lazy<byte[]> ProfileImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value)
? Array.Empty<byte>()
: Convert.FromBase64String(value);
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace LightlessSync.Services;
public record LightlessProfileData(
bool IsFlagged,
bool IsNSFW,
string Base64ProfilePicture,
string Base64SupporterPicture,
string Base64BannerPicture,
string Description,
IReadOnlyList<int> Tags)
{
public Lazy<byte[]> ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture));
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty<byte>() : Convert.FromBase64String(value);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
namespace LightlessSync.Services.Profiles;
public record LightlessUserProfileData(
bool IsFlagged,
bool IsNSFW,
string Base64ProfilePicture,
string Base64SupporterPicture,
string Base64BannerPicture,
string Description,
IReadOnlyList<int> Tags)
{
public Lazy<byte[]> ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture));
public Lazy<byte[]> SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture));
public Lazy<byte[]> BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture));
private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty<byte>() : Convert.FromBase64String(value);
}

View File

@@ -0,0 +1,82 @@
using System.Numerics;
using System.Reflection;
using Dalamud.Bindings.ImGui;
using Pictomancy;
namespace LightlessSync.Services.Rendering;
internal static class PctDrawListExtensions
{
private static readonly FieldInfo? DrawListField = typeof(PctDrawList).GetField("_drawList", BindingFlags.Instance | BindingFlags.NonPublic);
private static bool TryGetImDrawList(PctDrawList drawList, out ImDrawListPtr ptr)
{
ptr = default;
if (DrawListField == null)
return false;
if (DrawListField.GetValue(drawList) is ImDrawListPtr list)
{
ptr = list;
return true;
}
return false;
}
public static void AddScreenText(this PctDrawList drawList, Vector2 screenPosition, string text, uint color, float fontSize, Vector2? pivot = null, uint? outlineColor = null, ImFontPtr fontOverride = default)
{
if (drawList == null || string.IsNullOrEmpty(text))
return;
if (!TryGetImDrawList(drawList, out var imDrawList))
return;
var font = fontOverride.IsNull ? ImGui.GetFont() : fontOverride;
if (font.IsNull)
return;
var size = MathF.Max(1f, fontSize);
var pivotValue = pivot ?? new Vector2(0.5f, 0.5f);
Vector2 measured;
float calcFontSize;
if (!fontOverride.IsNull)
{
ImGui.PushFont(font);
measured = ImGui.CalcTextSize(text);
calcFontSize = ImGui.GetFontSize();
ImGui.PopFont();
}
else
{
measured = ImGui.CalcTextSize(text);
calcFontSize = ImGui.GetFontSize();
}
if (calcFontSize > 0f && MathF.Abs(size - calcFontSize) > 0.001f)
{
measured *= size / calcFontSize;
}
var drawPos = screenPosition - measured * pivotValue;
if (outlineColor.HasValue)
{
var thickness = MathF.Max(1f, size / 24f);
Span<Vector2> offsets = stackalloc Vector2[4]
{
new Vector2(1f, 0f),
new Vector2(-1f, 0f),
new Vector2(0f, 1f),
new Vector2(0f, -1f)
};
foreach (var offset in offsets)
{
imDrawList.AddText(font, size, drawPos + offset * thickness, outlineColor.Value, text);
}
}
imDrawList.AddText(font, size, drawPos, color, text);
}
}

View File

@@ -0,0 +1,47 @@
using Dalamud.Plugin;
using Microsoft.Extensions.Logging;
using Pictomancy;
namespace LightlessSync.Services.Rendering;
public sealed class PictomancyService : IDisposable
{
private readonly ILogger<PictomancyService> _logger;
private bool _initialized;
public PictomancyService(ILogger<PictomancyService> logger, IDalamudPluginInterface pluginInterface)
{
_logger = logger;
try
{
PictoService.Initialize(pluginInterface);
_initialized = true;
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Pictomancy initialized");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize Pictomancy");
}
}
public void Dispose()
{
if (!_initialized)
return;
try
{
PictoService.Dispose();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dispose Pictomancy");
}
finally
{
_initialized = false;
}
}
}

View File

@@ -252,9 +252,16 @@ public class ServerConfigurationManager
public void SelectServer(int idx)
{
var previousIndex = _configService.Current.CurrentServer;
_configService.Current.CurrentServer = idx;
CurrentServer!.FullPause = false;
Save();
if (previousIndex != idx)
{
var serverUrl = CurrentServer.ServerUri;
_lightlessMediator.Publish(new ActiveServerChangedMessage(serverUrl));
}
}
internal void AddCurrentCharacterToServer(int serverSelectionIndex = -1)

View File

@@ -0,0 +1,312 @@
using System.Numerics;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
/*
* Index upscaler code (converted/reversed for downscaling purposes) provided by Ny
* thank you!!
*/
namespace LightlessSync.Services.TextureCompression;
internal static class IndexDownscaler
{
private static readonly Vector2[] SampleOffsets =
{
new(0.25f, 0.25f),
new(0.75f, 0.25f),
new(0.25f, 0.75f),
new(0.75f, 0.75f),
};
public static Image<Rgba32> Downscale(Image<Rgba32> source, int targetWidth, int targetHeight, int blockMultiple)
{
var current = source.Clone();
while (current.Width > targetWidth || current.Height > targetHeight)
{
var nextWidth = Math.Max(targetWidth, Math.Max(blockMultiple, current.Width / 2));
var nextHeight = Math.Max(targetHeight, Math.Max(blockMultiple, current.Height / 2));
var next = new Image<Rgba32>(nextWidth, nextHeight);
for (var y = 0; y < nextHeight; y++)
{
var srcY = Math.Min(current.Height - 1, y * 2);
for (var x = 0; x < nextWidth; x++)
{
var srcX = Math.Min(current.Width - 1, x * 2);
var topLeft = current[srcX, srcY];
var topRight = current[Math.Min(current.Width - 1, srcX + 1), srcY];
var bottomLeft = current[srcX, Math.Min(current.Height - 1, srcY + 1)];
var bottomRight = current[Math.Min(current.Width - 1, srcX + 1), Math.Min(current.Height - 1, srcY + 1)];
next[x, y] = DownscaleIndexBlock(topLeft, topRight, bottomLeft, bottomRight);
}
}
current.Dispose();
current = next;
}
return current;
}
private static Rgba32 DownscaleIndexBlock(in Rgba32 topLeft, in Rgba32 topRight, in Rgba32 bottomLeft, in Rgba32 bottomRight)
{
Span<Rgba32> ordered = stackalloc Rgba32[4]
{
bottomLeft,
bottomRight,
topRight,
topLeft
};
Span<float> weights = stackalloc float[4];
var hasContribution = false;
foreach (var sample in SampleOffsets)
{
if (TryAccumulateSampleWeights(ordered, sample, weights))
{
hasContribution = true;
}
}
if (hasContribution)
{
var bestIndex = IndexOfMax(weights);
if (bestIndex >= 0 && weights[bestIndex] > 0f)
{
return ordered[bestIndex];
}
}
Span<Rgba32> fallback = stackalloc Rgba32[4] { topLeft, topRight, bottomLeft, bottomRight };
return PickMajorityColor(fallback);
}
private static bool TryAccumulateSampleWeights(ReadOnlySpan<Rgba32> colors, in Vector2 sampleUv, Span<float> weights)
{
var red = new Vector4(
colors[0].R / 255f,
colors[1].R / 255f,
colors[2].R / 255f,
colors[3].R / 255f);
var symbols = QuantizeSymbols(red);
var cellUv = ComputeShiftedUv(sampleUv);
Span<int> order = stackalloc int[4];
order[0] = 0;
order[1] = 1;
order[2] = 2;
order[3] = 3;
ApplySymmetry(ref symbols, ref cellUv, order);
var equality = BuildEquality(symbols, symbols.W);
var selector = BuildSelector(equality, symbols, cellUv);
const uint lut = 0x00000C07u;
if (((lut >> (int)selector) & 1u) != 0u)
{
weights[order[3]] += 1f;
return true;
}
if (selector == 3u)
{
equality = BuildEquality(symbols, symbols.Z);
}
var weight = ComputeWeight(equality, cellUv);
if (weight <= 1e-6f)
{
return false;
}
var factor = 1f / weight;
var wW = equality.W * (1f - cellUv.X) * (1f - cellUv.Y) * factor;
var wX = equality.X * (1f - cellUv.X) * cellUv.Y * factor;
var wZ = equality.Z * cellUv.X * (1f - cellUv.Y) * factor;
var wY = equality.Y * cellUv.X * cellUv.Y * factor;
var contributed = false;
if (wW > 0f)
{
weights[order[3]] += wW;
contributed = true;
}
if (wX > 0f)
{
weights[order[0]] += wX;
contributed = true;
}
if (wZ > 0f)
{
weights[order[2]] += wZ;
contributed = true;
}
if (wY > 0f)
{
weights[order[1]] += wY;
contributed = true;
}
return contributed;
}
private static Vector4 QuantizeSymbols(in Vector4 channel)
=> new(
Quantize(channel.X),
Quantize(channel.Y),
Quantize(channel.Z),
Quantize(channel.W));
private static float Quantize(float value)
{
var clamped = Math.Clamp(value, 0f, 1f);
return (MathF.Round(clamped * 16f) + 0.5f) / 16f;
}
private static void ApplySymmetry(ref Vector4 symbols, ref Vector2 cellUv, Span<int> order)
{
if (cellUv.X >= 0.5f)
{
symbols = SwapYxwz(symbols, order);
cellUv.X = 1f - cellUv.X;
}
if (cellUv.Y >= 0.5f)
{
symbols = SwapWzyx(symbols, order);
cellUv.Y = 1f - cellUv.Y;
}
}
private static Vector4 BuildEquality(in Vector4 symbols, float reference)
=> new(
AreEqual(symbols.X, reference) ? 1f : 0f,
AreEqual(symbols.Y, reference) ? 1f : 0f,
AreEqual(symbols.Z, reference) ? 1f : 0f,
AreEqual(symbols.W, reference) ? 1f : 0f);
private static uint BuildSelector(in Vector4 equality, in Vector4 symbols, in Vector2 cellUv)
{
uint selector = 0;
if (equality.X > 0.5f) selector |= 4u;
if (equality.Y > 0.5f) selector |= 8u;
if (equality.Z > 0.5f) selector |= 16u;
if (AreEqual(symbols.X, symbols.Z)) selector |= 2u;
if (cellUv.X + cellUv.Y >= 0.5f) selector |= 1u;
return selector;
}
private static float ComputeWeight(in Vector4 equality, in Vector2 cellUv)
=> equality.W * (1f - cellUv.X) * (1f - cellUv.Y)
+ equality.X * (1f - cellUv.X) * cellUv.Y
+ equality.Z * cellUv.X * (1f - cellUv.Y)
+ equality.Y * cellUv.X * cellUv.Y;
private static Vector2 ComputeShiftedUv(in Vector2 uv)
{
var shifted = new Vector2(
uv.X - MathF.Floor(uv.X),
uv.Y - MathF.Floor(uv.Y));
shifted.X -= 0.5f;
if (shifted.X < 0f)
{
shifted.X += 1f;
}
shifted.Y -= 0.5f;
if (shifted.Y < 0f)
{
shifted.Y += 1f;
}
return shifted;
}
private static Vector4 SwapYxwz(in Vector4 v, Span<int> order)
{
var o0 = order[0];
var o1 = order[1];
var o2 = order[2];
var o3 = order[3];
order[0] = o1;
order[1] = o0;
order[2] = o3;
order[3] = o2;
return new Vector4(v.Y, v.X, v.W, v.Z);
}
private static Vector4 SwapWzyx(in Vector4 v, Span<int> order)
{
var o0 = order[0];
var o1 = order[1];
var o2 = order[2];
var o3 = order[3];
order[0] = o3;
order[1] = o2;
order[2] = o1;
order[3] = o0;
return new Vector4(v.W, v.Z, v.Y, v.X);
}
private static int IndexOfMax(ReadOnlySpan<float> values)
{
var bestIndex = -1;
var bestValue = 0f;
for (var i = 0; i < values.Length; i++)
{
if (values[i] > bestValue)
{
bestValue = values[i];
bestIndex = i;
}
}
return bestIndex;
}
private static bool AreEqual(float a, float b) => MathF.Abs(a - b) <= 1e-5f;
private static Rgba32 PickMajorityColor(ReadOnlySpan<Rgba32> colors)
{
var counts = new Dictionary<Rgba32, int>(colors.Length);
foreach (var color in colors)
{
if (counts.TryGetValue(color, out var count))
{
counts[color] = count + 1;
}
else
{
counts[color] = 1;
}
}
return counts
.OrderByDescending(kvp => kvp.Value)
.ThenByDescending(kvp => kvp.Key.A)
.ThenByDescending(kvp => kvp.Key.R)
.ThenByDescending(kvp => kvp.Key.G)
.ThenByDescending(kvp => kvp.Key.B)
.First().Key;
}
}

View File

@@ -0,0 +1,280 @@
using System.Runtime.InteropServices;
using Lumina.Data.Files;
using OtterTex;
namespace LightlessSync.Services.TextureCompression;
// base taken from penumbra mostly
internal static class TexFileHelper
{
private const int HeaderSize = 80;
private const int MaxMipLevels = 13;
public static ScratchImage Load(string path)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return Load(stream);
}
public static ScratchImage Load(Stream stream)
{
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true);
var header = ReadHeader(reader);
var meta = CreateMeta(header);
meta.MipLevels = ComputeMipCount(stream.Length, header, meta);
if (meta.MipLevels == 0)
{
throw new InvalidOperationException("TEX file does not contain a valid mip chain.");
}
var scratch = ScratchImage.Initialize(meta);
ReadPixelData(reader, scratch);
return scratch;
}
public static void Save(string path, ScratchImage image)
{
var header = BuildHeader(image);
if (header.Format == TexFile.TextureFormat.Unknown)
{
throw new InvalidOperationException($"Unable to export TEX file with unsupported format {image.Meta.Format}.");
}
var mode = File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew;
using var stream = new FileStream(path, mode, FileAccess.Write, FileShare.Read);
using var writer = new BinaryWriter(stream);
WriteHeader(writer, header);
writer.Write(image.Pixels);
GC.KeepAlive(image);
}
private static TexFile.TexHeader ReadHeader(BinaryReader reader)
{
Span<byte> buffer = stackalloc byte[HeaderSize];
var read = reader.Read(buffer);
if (read != HeaderSize)
{
throw new EndOfStreamException($"Incomplete TEX header: expected {HeaderSize} bytes, read {read} bytes.");
}
return MemoryMarshal.Read<TexFile.TexHeader>(buffer);
}
private static TexMeta CreateMeta(in TexFile.TexHeader header)
{
var meta = new TexMeta
{
Width = header.Width,
Height = header.Height,
Depth = Math.Max(header.Depth, (ushort)1),
ArraySize = 1,
MipLevels = header.MipCount,
Format = header.Format.ToDxgi(),
Dimension = header.Type.ToDimension(),
MiscFlags = header.Type.HasFlag(TexFile.Attribute.TextureTypeCube) ? D3DResourceMiscFlags.TextureCube : 0,
MiscFlags2 = 0,
};
if (meta.Format == DXGIFormat.Unknown)
{
throw new InvalidOperationException($"TEX format {header.Format} cannot be mapped to DXGI.");
}
if (meta.Dimension == TexDimension.Unknown)
{
throw new InvalidOperationException($"Unrecognised TEX dimension attribute {header.Type}.");
}
return meta;
}
private static unsafe int ComputeMipCount(long totalLength, in TexFile.TexHeader header, in TexMeta meta)
{
var width = Math.Max(meta.Width, 1);
var height = Math.Max(meta.Height, 1);
var minSide = meta.Format.IsCompressed() ? 4 : 1;
var bitsPerPixel = meta.Format.BitsPerPixel();
var expectedOffset = HeaderSize;
var remaining = totalLength - HeaderSize;
for (var level = 0; level < MaxMipLevels; level++)
{
var declaredOffset = header.OffsetToSurface[level];
if (declaredOffset == 0)
{
return level;
}
if (declaredOffset != expectedOffset || remaining <= 0)
{
return level;
}
var mipSize = (int)((long)width * height * bitsPerPixel / 8);
if (mipSize > remaining)
{
return level;
}
expectedOffset += mipSize;
remaining -= mipSize;
if (width <= minSide && height <= minSide)
{
return level + 1;
}
width = Math.Max(width / 2, minSide);
height = Math.Max(height / 2, minSide);
}
return MaxMipLevels;
}
private static unsafe void ReadPixelData(BinaryReader reader, ScratchImage image)
{
fixed (byte* destination = image.Pixels)
{
var span = new Span<byte>(destination, image.Pixels.Length);
var read = reader.Read(span);
if (read < span.Length)
{
throw new InvalidDataException($"TEX pixel buffer is truncated (read {read} of {span.Length} bytes).");
}
}
}
private static TexFile.TexHeader BuildHeader(ScratchImage image)
{
var meta = image.Meta;
var header = new TexFile.TexHeader
{
Width = (ushort)meta.Width,
Height = (ushort)meta.Height,
Depth = (ushort)Math.Max(meta.Depth, 1),
MipCount = (byte)Math.Min(meta.MipLevels, MaxMipLevels),
Format = meta.Format.ToTex(),
Type = meta.Dimension switch
{
_ when meta.IsCubeMap => TexFile.Attribute.TextureTypeCube,
TexDimension.Tex1D => TexFile.Attribute.TextureType1D,
TexDimension.Tex2D => TexFile.Attribute.TextureType2D,
TexDimension.Tex3D => TexFile.Attribute.TextureType3D,
_ => 0,
},
};
PopulateOffsets(ref header, image);
return header;
}
private static unsafe void PopulateOffsets(ref TexFile.TexHeader header, ScratchImage image)
{
var index = 0;
fixed (byte* basePtr = image.Pixels)
{
foreach (var mip in image.Images)
{
if (index >= MaxMipLevels)
{
break;
}
var byteOffset = (byte*)mip.Pixels - basePtr;
header.OffsetToSurface[index++] = HeaderSize + (uint)byteOffset;
}
}
while (index < MaxMipLevels)
{
header.OffsetToSurface[index++] = 0;
}
header.LodOffset[0] = 0;
header.LodOffset[1] = (byte)Math.Min(header.MipCount - 1, 1);
header.LodOffset[2] = (byte)Math.Min(header.MipCount - 1, 2);
}
private static unsafe void WriteHeader(BinaryWriter writer, in TexFile.TexHeader header)
{
writer.Write((uint)header.Type);
writer.Write((uint)header.Format);
writer.Write(header.Width);
writer.Write(header.Height);
writer.Write(header.Depth);
writer.Write((byte)(header.MipCount | (header.MipUnknownFlag ? 0x80 : 0)));
writer.Write(header.ArraySize);
writer.Write(header.LodOffset[0]);
writer.Write(header.LodOffset[1]);
writer.Write(header.LodOffset[2]);
for (var i = 0; i < MaxMipLevels; i++)
{
writer.Write(header.OffsetToSurface[i]);
}
}
private static TexDimension ToDimension(this TexFile.Attribute attribute)
=> (attribute & TexFile.Attribute.TextureTypeMask) switch
{
TexFile.Attribute.TextureType1D => TexDimension.Tex1D,
TexFile.Attribute.TextureType2D => TexDimension.Tex2D,
TexFile.Attribute.TextureType3D => TexDimension.Tex3D,
_ => TexDimension.Unknown,
};
private static DXGIFormat ToDxgi(this TexFile.TextureFormat format)
=> format switch
{
TexFile.TextureFormat.L8 => DXGIFormat.R8UNorm,
TexFile.TextureFormat.A8 => DXGIFormat.A8UNorm,
TexFile.TextureFormat.B4G4R4A4 => DXGIFormat.B4G4R4A4UNorm,
TexFile.TextureFormat.B5G5R5A1 => DXGIFormat.B5G5R5A1UNorm,
TexFile.TextureFormat.B8G8R8A8 => DXGIFormat.B8G8R8A8UNorm,
TexFile.TextureFormat.B8G8R8X8 => DXGIFormat.B8G8R8X8UNorm,
TexFile.TextureFormat.R32F => DXGIFormat.R32Float,
TexFile.TextureFormat.R16G16F => DXGIFormat.R16G16Float,
TexFile.TextureFormat.R32G32F => DXGIFormat.R32G32Float,
TexFile.TextureFormat.R16G16B16A16F => DXGIFormat.R16G16B16A16Float,
TexFile.TextureFormat.R32G32B32A32F => DXGIFormat.R32G32B32A32Float,
TexFile.TextureFormat.BC1 => DXGIFormat.BC1UNorm,
TexFile.TextureFormat.BC2 => DXGIFormat.BC2UNorm,
TexFile.TextureFormat.BC3 => DXGIFormat.BC3UNorm,
(TexFile.TextureFormat)0x6120 => DXGIFormat.BC4UNorm,
TexFile.TextureFormat.BC5 => DXGIFormat.BC5UNorm,
(TexFile.TextureFormat)0x6330 => DXGIFormat.BC6HSF16,
TexFile.TextureFormat.BC7 => DXGIFormat.BC7UNorm,
TexFile.TextureFormat.D16 => DXGIFormat.R16G16B16A16Typeless,
TexFile.TextureFormat.D24S8 => DXGIFormat.R24G8Typeless,
TexFile.TextureFormat.Shadow16 => DXGIFormat.R16Typeless,
TexFile.TextureFormat.Shadow24 => DXGIFormat.R24G8Typeless,
_ => DXGIFormat.Unknown,
};
private static TexFile.TextureFormat ToTex(this DXGIFormat format)
=> format switch
{
DXGIFormat.R8UNorm => TexFile.TextureFormat.L8,
DXGIFormat.A8UNorm => TexFile.TextureFormat.A8,
DXGIFormat.B4G4R4A4UNorm => TexFile.TextureFormat.B4G4R4A4,
DXGIFormat.B5G5R5A1UNorm => TexFile.TextureFormat.B5G5R5A1,
DXGIFormat.B8G8R8A8UNorm => TexFile.TextureFormat.B8G8R8A8,
DXGIFormat.B8G8R8X8UNorm => TexFile.TextureFormat.B8G8R8X8,
DXGIFormat.R32Float => TexFile.TextureFormat.R32F,
DXGIFormat.R16G16Float => TexFile.TextureFormat.R16G16F,
DXGIFormat.R32G32Float => TexFile.TextureFormat.R32G32F,
DXGIFormat.R16G16B16A16Float => TexFile.TextureFormat.R16G16B16A16F,
DXGIFormat.R32G32B32A32Float => TexFile.TextureFormat.R32G32B32A32F,
DXGIFormat.BC1UNorm => TexFile.TextureFormat.BC1,
DXGIFormat.BC2UNorm => TexFile.TextureFormat.BC2,
DXGIFormat.BC3UNorm => TexFile.TextureFormat.BC3,
DXGIFormat.BC4UNorm => (TexFile.TextureFormat)0x6120,
DXGIFormat.BC5UNorm => TexFile.TextureFormat.BC5,
DXGIFormat.BC6HSF16 => (TexFile.TextureFormat)0x6330,
DXGIFormat.BC7UNorm => TexFile.TextureFormat.BC7,
DXGIFormat.R16G16B16A16Typeless => TexFile.TextureFormat.D16,
DXGIFormat.R24G8Typeless => TexFile.TextureFormat.D24S8,
DXGIFormat.R16Typeless => TexFile.TextureFormat.Shadow16,
_ => TexFile.TextureFormat.Unknown,
};
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Immutable;
using Penumbra.Api.Enums;
namespace LightlessSync.Services.TextureCompression;
internal static class TextureCompressionCapabilities
{
private static readonly ImmutableDictionary<TextureCompressionTarget, TextureType> TexTargets =
new Dictionary<TextureCompressionTarget, TextureType>
{
[TextureCompressionTarget.BC1] = TextureType.Bc1Tex,
[TextureCompressionTarget.BC3] = TextureType.Bc3Tex,
[TextureCompressionTarget.BC4] = TextureType.Bc4Tex,
[TextureCompressionTarget.BC5] = TextureType.Bc5Tex,
[TextureCompressionTarget.BC7] = TextureType.Bc7Tex,
}.ToImmutableDictionary();
private static readonly ImmutableDictionary<TextureCompressionTarget, TextureType> DdsTargets =
new Dictionary<TextureCompressionTarget, TextureType>
{
[TextureCompressionTarget.BC1] = TextureType.Bc1Dds,
[TextureCompressionTarget.BC3] = TextureType.Bc3Dds,
[TextureCompressionTarget.BC4] = TextureType.Bc4Dds,
[TextureCompressionTarget.BC5] = TextureType.Bc5Dds,
[TextureCompressionTarget.BC7] = TextureType.Bc7Dds,
}.ToImmutableDictionary();
private static readonly TextureCompressionTarget[] SelectableTargetsCache = TexTargets
.Select(kvp => kvp.Key)
.OrderBy(t => t)
.ToArray();
private static readonly HashSet<TextureCompressionTarget> SelectableTargetSet = SelectableTargetsCache.ToHashSet();
public static IReadOnlyList<TextureCompressionTarget> SelectableTargets => SelectableTargetsCache;
public static TextureCompressionTarget DefaultTarget => TextureCompressionTarget.BC7;
public static bool IsSelectable(TextureCompressionTarget target) => SelectableTargetSet.Contains(target);
public static TextureCompressionTarget Normalize(TextureCompressionTarget? desired)
{
if (desired.HasValue && IsSelectable(desired.Value))
{
return desired.Value;
}
return DefaultTarget;
}
public static bool TryGetPenumbraTarget(TextureCompressionTarget target, string? outputPath, out TextureType textureType)
{
if (!string.IsNullOrWhiteSpace(outputPath) &&
string.Equals(Path.GetExtension(outputPath), ".dds", StringComparison.OrdinalIgnoreCase))
{
return DdsTargets.TryGetValue(target, out textureType);
}
return TexTargets.TryGetValue(target, out textureType);
}
}

View File

@@ -0,0 +1,7 @@
namespace LightlessSync.Services.TextureCompression;
public sealed record TextureCompressionRequest(
string PrimaryFilePath,
IReadOnlyList<string> DuplicateFilePaths,
TextureCompressionTarget Target);

Some files were not shown because too many files have changed in this diff Show More