Compare commits

..

357 Commits

Author SHA1 Message Date
defnotken
6db9925693 Dev be building
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2025-12-20 13:45:34 -06:00
defnotken
0ba324bfe5 Merge branch '2.0.0' into dev 2025-12-20 13:43:35 -06:00
03105e0755 fix log level 2025-12-21 04:28:36 +09:00
b99f68a891 collapsible texture details 2025-12-21 02:23:18 +09:00
7c7a98f770 This looks better 2025-12-21 01:19:17 +09:00
ab369d008e can drag chat tabs around as much as u want
syncshell tabs can use notes instead by rightclicking and prefering it
added some visibility settings (hide in combat, etc)
and cleaned up some of the ui
2025-12-21 01:17:00 +09:00
defnotken
f4665a0909 Brio fix
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m20s
2025-12-20 00:41:10 -06:00
defnotken
cb4bcec5e9 Merge branch '2.0.0' into dev 2025-12-20 00:40:31 -06:00
Minmoose
e5fa477eee Fix Brio IPC 2025-12-19 19:06:34 -06:00
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
cake
ac8270e4ad Added chat command in handler 2025-12-19 22:34:04 +01: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
906f401940 1.12.4
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 35s
Co-authored-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: azyges <229218900+azyges@users.noreply.github.com>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: choco <choco@noreply.git.lightless-sync.org>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #73
2025-11-12 21:10:40 +01: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
5abc297a94 1.12.3 - LightSpeed
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 51s
Reviewed-on: #60
Reviewed-by: celine <celine@noreply.git.lightless-sync.org>
2025-10-24 16:22:21 +02:00
choco
8aad714918 removed wrong ondisconnect notification 2025-10-23 00:40:54 +02:00
b5bdededae Merge pull request 'Client API changes' (#72) from banner-api-changes into 1.12.3
Reviewed-on: #72
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 22:51:29 +02:00
CakeAndBanana
487156e4f9 Submodule update 2025-10-21 22:48:26 +02:00
90d8f691d2 Merge branch '1.12.3' into banner-api-changes 2025-10-21 22:41:29 +02:00
CakeAndBanana
764bb8bae2 API changes 2025-10-21 22:38:12 +02:00
5d2c58bf3e Merge pull request 'some caching stuff and bug fixes' (#71) from some-garbage-optimization into 1.12.3
Reviewed-on: #71
2025-10-21 20:46:15 +02:00
azyges
6bb00c50d8 improve logging fallback 2025-10-22 03:33:51 +09:00
azyges
1a89c2caee some caching stuff and bug fixes 2025-10-22 03:20:13 +09:00
defnotken
1e97f27cb8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-21 11:22:31 -05:00
defnotken
7aadbcec10 Wording changes 2025-10-21 11:22:19 -05:00
choco
a32ac02c6d download notification stuck fix 2025-10-21 09:59:30 +02:00
f48698373b Merge pull request 'notification-cleanup' (#70) from notification-cleanup into 1.12.3
Reviewed-on: #70
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-20 21:28:15 +02:00
ee20b6fa5f version 1.12.3 2025-10-20 21:25:28 +02:00
f11741225b Merge branch 'dev' into notification-cleanup 2025-10-20 21:16:47 +02:00
choco
147baa4c1b api cleanup, decline message on notification decline 2025-10-20 21:16:30 +02:00
choco
4f5ef8ff4b type cleanup 2025-10-20 14:51:10 +02:00
choco
fae6d31792 Merge remote-tracking branch 'origin/notification-cleanup' into notification-cleanup 2025-10-20 14:32:29 +02:00
choco
b4dd0ee0e1 type cleanup 2025-10-20 14:32:21 +02:00
f5458c7f97 Delete CONTRIBUTING.md 2025-10-20 14:05:51 +02:00
923f118a47 Delete DEVELOPMENT.md 2025-10-20 14:05:45 +02:00
choco
0cb71e5444 service cleanups, containing logic directly now 2025-10-20 14:00:54 +02:00
defnotken
268fd471fe welcome screen fix bump
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 34s
2025-10-19 15:50:02 -05:00
defnotken
d517a21f5d Merge branch '1.12.3' into dev 2025-10-19 15:49:16 -05:00
cac94374d9 Merge pull request 'patch-notes' (#69) from patch-notes into 1.12.3
Reviewed-on: #69
2025-10-19 22:48:44 +02:00
choco
b513e0555b temp menu removal 2025-10-19 22:46:36 +02:00
choco
de8c9cf035 Merge remote-tracking branch 'origin/patch-notes' into patch-notes 2025-10-19 22:43:58 +02:00
choco
7f8872cbe0 added open changelog button to settings title menu 2025-10-19 22:43:45 +02:00
defnotken
5626a34755 Merge branch '1.12.3' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 33s
2025-10-19 15:16:02 -05:00
defnotken
a98afdda01 Updating changelog. 2025-10-19 15:15:30 -05:00
4373092d44 Merge pull request 'patch-notes' (#66) from patch-notes into 1.12.3
Reviewed-on: #66
2025-10-19 22:01:15 +02:00
7b5c61371e Merge branch '1.12.3' into patch-notes 2025-10-19 22:00:58 +02:00
9bd997f699 Merge pull request 'Added profile editor on syncshell admin.' (#68) from syncshell-profiles into 1.12.3
Reviewed-on: #68
2025-10-19 22:00:51 +02:00
e2511a5c1f Merge branch '1.12.3' into syncshell-profiles 2025-10-19 22:00:16 +02:00
CakeAndBanana
e8760a8937 Fixed nsfwf 2025-10-19 21:59:04 +02:00
CakeAndBanana
7d4e097be8 Added nsfw on syncshell profile. 2025-10-19 21:58:12 +02:00
CakeAndBanana
2cba1ccfe0 reverted join, added apicontroller stuff 2025-10-19 21:56:03 +02:00
06921e1dd1 Merge branch '1.12.3' into patch-notes 2025-10-19 21:49:18 +02:00
defnotken
68ba5f4b06 dev build
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 27s
2025-10-19 14:47:30 -05:00
defnotken
217c160ec7 update submodule 2025-10-19 14:43:40 -05:00
defnotken
ff010fa1f8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-19 14:43:01 -05:00
f7145339b3 Merge pull request 'init' (#67) from lightspeed into 1.12.3
Reviewed-on: #67
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-19 21:41:16 +02:00
azyges
aa2b828386 init 2025-10-20 04:20:11 +09:00
CakeAndBanana
547db3a76b Added tag calls for new api changes 2025-10-19 21:10:07 +02:00
defnotken
4ac8b24524 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-19 12:33:40 -05:00
CakeAndBanana
d72cc207e1 Made tags an array of integers instead of strings 2025-10-19 18:53:31 +02:00
CakeAndBanana
477f5aa6e7 Fixed some stuff 2025-10-19 18:41:02 +02:00
CakeAndBanana
edb7232b17 Added nsfw in group profile editor. 2025-10-19 16:55:42 +02:00
choco
44177ab7bd broadcast bypass toggle gone 2025-10-17 20:07:14 +02:00
choco
47b7ecd521 forced current changelog version to be opened on default 2025-10-17 15:36:50 +02:00
choco
8a3902ec2b init change :) 2025-10-16 23:44:15 +02:00
choco
ea8f8e3895 last null check removal 2025-10-16 23:34:10 +02:00
choco
f1af6601cc removed null check 2025-10-16 23:30:06 +02:00
choco
2d094404df proper version checker on plugin laoding 2025-10-16 23:21:14 +02:00
choco
8fdff1eb18 SHOWING changelog everytime till the got it button is pressed, should reappear on version updates according to the current settings 2025-10-16 23:03:32 +02:00
choco
9170b5205c removed temp changelog on loading 2025-10-16 22:54:56 +02:00
choco
dccd2cdc36 changelog cleanup, credits tab 2025-10-16 22:52:46 +02:00
choco
6d01d47c2f broadcast notifications, action button for reconnecting once expired 2025-10-16 22:13:40 +02:00
CakeAndBanana
280c80d89f Changed the layout a bit, added nsfw 2025-10-16 15:19:01 +02:00
choco
92b8d4a1cd credits integration into the yaml and ui 2025-10-16 14:35:30 +02:00
choco
f77d109d00 added changelog model and update notes UI with particle effects 2025-10-16 10:37:00 +02:00
choco
823dd39a9b improved particle animations, took some inspiration from brio and character + kinda pogged at their change log 2025-10-16 01:29:38 +02:00
choco
d5c12e81c3 banner with particles 2025-10-15 22:59:08 +02:00
choco
04c00af92e check if completed intro setup process 2025-10-15 16:37:56 +02:00
choco
a66a43dda8 patch notes setup, added some of the older patchnotes into a changelog.yaml 2025-10-15 13:29:32 +02:00
77ff8ae372 Merge pull request 'notification-changes' (#65) from notification-changes into 1.12.3
Reviewed-on: #65
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-14 22:43:10 +02:00
CakeAndBanana
011cf7951b Added more documentation, fixed some small issues with cache 2025-10-14 20:45:05 +02:00
defnotken
7d480b9e2c Defensive handling and NRE removal. 2025-10-14 10:33:44 -05:00
choco
cf27a67296 optional action button toggle for pair request (default on) 2025-10-14 15:07:46 +02:00
choco
d6a4595bb8 old debug line removals, better alignment performance notifs 2025-10-14 14:54:30 +02:00
choco
f202818b55 performance notifcation addition, with some regular bugfixes regarding the flexing of the notifications 2025-10-14 11:46:14 +02:00
choco
3f2e4d6640 small service cleanup 2025-10-14 09:36:10 +02:00
choco
90b483e4ea Merge remote-tracking branch 'origin/1.12.3' into notification-changes 2025-10-13 22:24:50 +02:00
434c7d5f4a Merge pull request 'add stuff' (#63) from syncshell-profiles-2 into syncshell-profiles
Reviewed-on: #63
2025-10-13 06:12:24 +02:00
defnotken
a4eb840589 add stuff 2025-10-12 22:59:50 -05:00
CakeAndBanana
e80806ef9d Changes some calls 2025-10-13 01:09:05 +02:00
CakeAndBanana
c447c33b7a Fixed callbacks for cleaning up profiles. 2025-10-13 00:39:30 +02:00
choco
bcb524df52 text auto flexing on dismiss removed 2025-10-13 00:13:21 +02:00
b64bb66119 Merge pull request 'notification-changes' (#62) from notification-changes into 1.12.3
Reviewed-on: #62
2025-10-12 23:33:42 +02:00
choco
118edb9dea notif overlay flex with 1 sec delay removal 2025-10-12 23:11:18 +02:00
CakeAndBanana
b43ceb9f7e Changed syncshell profiles a bit. 2025-10-12 22:49:50 +02:00
choco
6c0d00dc39 update intervall prevent spam better performance 2025-10-12 21:07:32 +02:00
CakeAndBanana
be847c16b8 Added profile settings for Syncshells. 2025-10-12 20:04:09 +02:00
02a680f8cc Merge pull request 'Merge master to profiles' (#61) from master into syncshell-profiles
Reviewed-on: #61
2025-10-12 19:19:37 +02:00
defnotken
dae8127ac8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-12 12:11:18 -05:00
defnotken
0635caab65 Safety checks for NullDrawObject 2025-10-12 12:09:06 -05:00
1530ac3911 Merge pull request 'notification-changes' (#59) from notification-changes into 1.12.3
Reviewed-on: #59
2025-10-12 18:08:52 +02:00
3f80467180 Merge branch '1.12.3' into notification-changes 2025-10-12 18:08:43 +02:00
defnotken
4b4e587a89 1.12.3 initial 2025-10-12 11:07:24 -05:00
choco
02c3846031 column rename for better UX c: 2025-10-12 18:02:53 +02:00
choco
a8a01b3034 added more customization to the notifs, settings improvemnts, left and right notifs, animations for sliding in and out 2025-10-12 18:00:49 +02:00
50a5046c96 1.12.2
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 51s
Reviewed-on: #52
2025-10-12 15:33:02 +02:00
azyges
c0b8e15380 wrapped the entire plugin draw pass in a single theme scope, prevent leaks to dalamud ui 2025-10-12 21:48:51 +09:00
e6735be594 Merge pull request 'pair-notifs-ui' (#58) from pair-notifs-ui into 1.12.2
Reviewed-on: #58
2025-10-12 00:05:53 +02:00
choco
fe419336d7 math clamping sliders for notifcation settings, pair notifs disappear now when accepted with other methods 2025-10-12 00:04:10 +02:00
defnotken
ffbeeba929 more null checks for nameplates. 2025-10-11 16:39:37 -05:00
choco
a7475a7007 sounds to default off removed old notifcations panel with unused pair panel 2025-10-11 23:08:29 +02:00
choco
3936cbd439 can only be run on Framework fix 2025-10-11 22:54:04 +02:00
choco
ba16963b66 forgot dependency injection error fix 2025-10-11 22:25:54 +02:00
6467a3e73b Merge pull request 'pair-notifs-ui' (#57) from pair-notifs-ui into 1.12.2
Reviewed-on: #57
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-11 22:19:21 +02:00
choco
bb779904f7 removed temp accept/decline logic from the api layer 2025-10-11 21:52:14 +02:00
choco
59d0e8ee37 Merge branch '1.12.2' into pair-notifs-ui
# Conflicts:
#	LightlessSync/UI/SettingsUi.cs
2025-10-11 21:42:14 +02:00
choco
a441bbfcc8 notifcation refactor for better readability 2025-10-11 21:24:39 +02:00
choco
c545ccea52 X offset 2025-10-11 20:19:24 +02:00
azyges
c32d9cadff fix leak 2025-10-11 10:15:51 +09:00
azyges
d7c9df54cb update cache 2025-10-11 08:20:50 +09:00
37ec0961d9 Merge pull request 'lightfinder-partial-rework' (#56) from lightfinder-partial-rework into 1.12.2
Reviewed-on: #56
2025-10-11 01:04:27 +02:00
9736c5090d Merge branch '1.12.2' into lightfinder-partial-rework 2025-10-11 01:04:12 +02:00
defnotken
4f3ab604db update lightlessapi pointer 2025-10-10 18:03:57 -05:00
azyges
6a0f8c507c add seperate colors for labels and update color inputs 2025-10-11 07:52:52 +09:00
choco
e13fde3d43 improved settings with sounds bug fix 2025-10-11 00:46:18 +02:00
7b806ab660 Merge pull request 'Nameplate Fix + Text Handling' (#55) from nameplate-fix into 1.12.2
Reviewed-on: #55
Reviewed-by: Essie <azyges@noreply.git.lightless-sync.org>
2025-10-10 23:35:27 +02:00
defnotken
387e5ad515 oop 2025-10-10 16:27:40 -05:00
defnotken
70c296a16b Nameplate Fix + Text Handling 2025-10-10 16:26:36 -05:00
azyges
2a9b5812ed add theme override customizations 2025-10-11 03:29:44 +09:00
azyges
9b04976aa6 add info options for server bar, direct settings button in lightfinder window and fix color swaps 2025-10-11 00:48:19 +09:00
CakeAndBanana
144ac166fb Changed Discord URL 2025-10-10 15:50:22 +02:00
CakeAndBanana
98c3a2c7f8 Added syncshell profile related items. 2025-10-10 06:42:59 +02:00
defnotken
b06ffb3341 update submodule 2025-10-09 18:06:31 -05:00
e9461efe11 Merge pull request 'Added option to show hidden plates' (#54) from hidden-plates-finder into 1.12.2
Reviewed-on: #54
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-10 01:05:34 +02:00
1f1afdec24 Merge branch '1.12.2' into hidden-plates-finder 2025-10-10 01:05:26 +02:00
d428a436e7 Merge pull request 'Changed /light lightfinder to /light finder' (#53) from command-change-finder into 1.12.2
Reviewed-on: #53
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-10 01:05:18 +02:00
CakeAndBanana
ad29fa7b69 Fixed label 2025-10-09 23:31:45 +02:00
CakeAndBanana
23c56505ac Added option to show 2025-10-09 23:31:35 +02:00
CakeAndBanana
58850f4530 Changed /light lightfinder to /light finder 2025-10-09 23:01:50 +02:00
choco
f5339dc1d2 notif offset placement, default slider yoinked from abel 2025-10-09 22:53:01 +02:00
choco
85ecea6391 settings styling and sound disabled not working bugfix 2025-10-09 20:21:01 +02:00
choco
cd817487e4 scoped service crash fix 2025-10-09 19:18:27 +02:00
choco
f50b622f0a service cleanup 2025-10-09 15:50:59 +02:00
choco
d295f3e22d pair/downloads notif changes + more settings options 2025-10-09 13:56:40 +02:00
choco
0dfa667ed3 removed fallback logic in NotificationService and some settings cleanup 2025-10-09 11:31:35 +02:00
choco
2b118df892 notifications refactor with duplication bugfix 2025-10-09 11:13:47 +02:00
azyges
f01229a97f rework lightfinder for new api 2025-10-09 07:33:49 +09:00
choco
3fdc9dd958 Merge remote-tracking branch 'origin/1.12.2' into pair-notifs-ui 2025-10-08 23:49:07 +02:00
CakeAndBanana
86107acf12 version bumped 2025-10-08 23:39:07 +02:00
choco
27e7fb7ed9 more to notification system with new settings tab 2025-10-08 23:20:58 +02:00
choco
17f4ddad89 Merge tag '1.12.1' into pair-notifs-ui 2025-10-08 20:31:33 +02:00
a29e155cec 1.12.1: QoL Lightfinder
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 45s
Reviewed-on: #43
2025-10-08 19:57:23 +02:00
defnotken
1488704db4 update tag and release yaml 2025-10-08 12:44:10 -05:00
CakeAndBanana
46db5c87e0 Fixed renaming of syncshell tags 2025-10-07 23:10:41 +02:00
a772ee4705 improve lightfinder settings 2025-10-08 01:47:04 +09:00
defnotken
1d88c04235 Fixed Nameplate. 2025-10-06 22:48:50 -05:00
defnotken
a7378652c4 Cleaned up and made both Text and Icon align. 2025-10-06 21:59:18 -05:00
61267d1b03 fix nameplates alignment? 2025-10-07 09:16:58 +09:00
choco
9b6d00570e implemened game sound effects for notifs 2025-10-06 21:55:45 +02:00
choco
83e4555e4b notifications improvement, working pairs incoming request feature and working user logging in notif 2025-10-06 20:25:47 +02:00
choco
090b81c989 added notification system for file downloads and pair requests 2025-10-06 16:14:34 +02:00
CakeAndBanana
ca70c622bc Added new options for visibilties on nameplate. 2025-10-06 05:29:54 +02:00
defnotken
49e5fb9d8d Fixing workflow for dev 2025-10-05 18:39:13 -05:00
a2bb1d7336 fix merge + add catch
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 38s
2025-10-06 08:31:23 +09:00
4f50028517 Merge pull request 'changes' (#49) from file-system-improvements into 1.12.1
Reviewed-on: #49
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-06 00:13:19 +02:00
9f87a6a8fc change default 2025-10-06 07:08:53 +09:00
CakeAndBanana
4a391f2392 Fixed merge conflict 2025-10-05 21:21:18 +02:00
a7c4b8f356 clean 2025-10-06 04:02:01 +09:00
c35650438c changes 2025-10-06 03:47:24 +09:00
b87185bc33 Merge pull request 'Change admin to developer for pairs' (#46) from change-admin-developer into 1.12.1
Reviewed-on: #46
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-05 20:14:05 +02:00
67da22fe9f Merge pull request 'Fix for the visibility on the lightless text' (#45) from visibility-fix-nameplate into 1.12.1
Reviewed-on: #45
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-05 20:13:59 +02:00
CakeAndBanana
15798e6753 Removed commented logging 2025-10-05 20:13:36 +02:00
ca68e63c7d Merge pull request 'Added command for lightfinder' (#47) from add-lightfinder-command into 1.12.1
Reviewed-on: #47
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-05 20:12:23 +02:00
eb10a27c6e Merge pull request 'Added right click menu option so it works for request pair' (#48) from fix_rightclick_pair into 1.12.1
Reviewed-on: #48
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-05 20:12:15 +02:00
39784a1fea Merge pull request 'optimized syncshell join UI with recently joined tracking and removing unknown broadcasters from the table' (#44) from shellfinder-refresh into 1.12.1
Reviewed-on: #44
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
2025-10-05 17:14:28 +02:00
CakeAndBanana
fec2e4d380 Fixed description 2025-10-05 16:33:17 +02:00
CakeAndBanana
afc3b4534c Fixed so the option to use the right click menu will disable pair button if disabled 2025-10-05 16:31:55 +02:00
CakeAndBanana
98b9cc7fe7 Fixed text from settings to lightfinder 2025-10-05 16:24:55 +02:00
CakeAndBanana
fd26d776a5 Added command for lightfinder. 2025-10-05 16:22:01 +02:00
CakeAndBanana
fdfd5722c7 Changed admin to developer for pairs 2025-10-05 15:52:02 +02:00
CakeAndBanana
19e42d34ff Removal of blank space, me eepy 2025-10-05 15:45:32 +02:00
CakeAndBanana
173e0aa7ae Removed double variable. 2025-10-05 15:43:48 +02:00
CakeAndBanana
55d979b7c0 Added a check if nameplate is visible to show the lightless text. 2025-10-05 15:41:52 +02:00
choco
f31a139a3e optimized syncshell join UI with recently joined tracking and removing unknown broadcasters from the table 2025-10-05 15:29:21 +02:00
c82c633513 Merge pull request 'Fixed UID not being copied, Removed UIRefreshcall on BroadcastUI and replaced with internal calls while loading or selecting.' (#42) from fix-syncshell-uid into 1.12.1
Reviewed-on: #42
2025-10-05 13:15:09 +02:00
CakeAndBanana
f6ea1eddc0 version bump 2025-10-05 13:12:31 +02:00
CakeAndBanana
4ca3b6da48 Fixed UID not being copied, Removed UIRefreshcall on BroadcastUI and replaced with internal calls while loading or selecting. 2025-10-05 12:57:10 +02:00
e75dd86fdb Update .gitea/workflows/lightless-tag-and-release.yml
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 31s
2025-10-05 08:18:33 +02:00
716d7a54d9 Update .gitea/workflows/lightless-tag-and-release.yml
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 33s
2025-10-05 08:10:21 +02:00
701ccaffe4 Update .gitea/workflows/lightless-tag-and-release.yml
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 32s
2025-10-05 08:06:59 +02:00
ee8d05ca7a Update .gitea/workflows/lightless-tag-and-release.yml
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 31s
2025-10-05 08:04:34 +02:00
fd5522b90a Update .gitea/workflows/lightless-tag-and-release.yml
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 34s
2025-10-05 07:59:01 +02:00
7672f147f5 1.12.0 - LightFinder + Bugfixes + Other fixes from 1.11.X (#39)
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 32s
![image.png](/attachments/c984e826-254e-4ce3-af92-4e795ea717ab)

Lightless 1.12.0 is HERE! In this major update, we are introducing something we've been working on and testing for the last couple of weeks. In this update we are introducing a new (**OPTIONAL**) feature called **LightFinder**! We took inspiration from FFXIV's very own Party Finder and decided to implement something that allows users to not only look for fellow Lightless users, but also put up their Syncshell for others looking to join a sync community!

When you enable LightFinder, you will be visible to other LightFinder users for **3 hours** or when you want to disabled it. When the 3 hours are up, you can either leave it disabled or enable it again for another 3 hours. The tag shown above will show above your nameplate, and you will be able to receive pair requests in your UI from other users with LightFinder enabled without having to input their uid!

![image.png](/attachments/91a7ce60-7771-49d7-bae8-6d7a67e46fa3)

Are you at a Venue? In Limsa? Partying in the streets of Uldah? If you're looking for fellow Lightless users you can now enable LightFinder and you will be shown to others who also have LightFinder enabled!

Looking for a Syncshell to join? Enable LightFinder to see what SyncShells are available to join!

Want to advertise your Syncshell? Choose the syncshell you want to put up in LightFinder and enable LightFinder.

**IMPORTANT: We want to stress the fact that, if you just want to sync with just your friends and no one else, this does not take that away. No one will know you use Lightless unless you choose to tell them, or use this **OPTIONAL** feature. Your privacy is still maintained if you don't want to use the feature.**

# Major Changes

## LightFinder - **OPTIONAL FEATURE** - **DOES NOT AFFECT YOU IF YOU DON'T WANT TO USE IT**

![image.png](/attachments/9a1e7455-f9b3-4e3a-9ec5-5bc74ed43c26)

* New **OPTIONAL** syncing feature where one can enable something like a Party Finder so that other Lightless users in the area can see you are looking for people to sync with. Enable it by clicking the compass button and then the `Enable LightFinder` button.

![image.png](/attachments/7be5c1fc-439f-43fc-bb1e-8ae32ab5e52e)

* [L] Send Pair Request has been added to player context menus. You should still be able to send a request without Lightfinder on BUT you will need to know the other player is using Lightless and have them send a pair request back.

![image.png](/attachments/75e0d600-baf9-43d5-af37-e86b6a1e209b)

* When in LightFinder mode, for X mins you will be visible to all Lightless Users WHO ALSO HAVE LIGHTFINDER ON and will receive notifications of people wanting to pair with you. If you are the person using LightFinder, you understand the risks of pairing with someone you don't know. If you are the person sending a request to someone with LightFinder on, you also understand the risks of pairing with someone you don't know. **AGAIN, THIS IS OPTIONAL.**

* When in LightFinder mode, you can also put up a Syncshell you own on the Syncshell Finder so that others can easily find it and join. This has to be done prior to enabling LightFinder.

![image.png](/attachments/1929d8e0-778d-4ced-9bf0-f350887980db)

* Syncshell Finder allows you to join Syncshells that are indexed by LightFinder

![image.png](/attachments/fef7b786-b1a0-4967-a4a9-886cdddc1a61)

# Minor Changes

* Vanity addition to our supporters: On top of vanity ids you can find a fun addition under Your User Settings -> Edit Lightless Profile -> Vanity Settings to change the colour of your name in the lightless ui. This will be shown to all users.
![image.png](/attachments/9b950c07-6b27-4bd1-a0d4-5346396b23e2)

* Pairing nameplate colour override can also now override FC tag (new option_

* Bunch of UI fixes, updates, and changes

* kdb is now a whitelisted filetype that can upload

Co-authored-by: CakeAndBanana <admin@cakeandbanana.nl>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <thijmenhogenkamp@gmail.com>
Co-authored-by: choco <choco@noreply.git.lightless-sync.org>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #39
2025-10-05 07:48:32 +02:00
defnotken
5dce1977c7 1.11.12 - Fix cache
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 33s
2025-09-15 23:56:36 -05:00
defnotken
e396d2cf46 1.11.11 - Disable Show groups for now
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 32s
2025-09-15 21:41:22 -05:00
c5e6c06005 1.11.10 (#31)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 34s
Co-authored-by: CakeAndBanana <admin@cakeandbanana.nl>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #31
2025-09-16 04:14:32 +02:00
206 changed files with 43169 additions and 6745 deletions

View File

@@ -2,11 +2,13 @@ name: Tag and Release Lightless
on:
push:
branches: [ master ]
branches: [ master, dev ]
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: |
@@ -41,9 +45,9 @@ jobs:
- name: Get version
id: package_version
uses: KageKirin/get-csproj-version@v0
with:
file: LightlessSync/LightlessSync.csproj
run: |
version=$(grep -oPm1 "(?<=<Version>)[^<]+" LightlessSync/LightlessSync.csproj)
echo "version=$version" >> $GITHUB_OUTPUT
- name: Display version
run: |
@@ -62,7 +66,8 @@ jobs:
mkdir -p output
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
- name: Create Git tag if not exists
- name: Create Git tag if not exists (master)
if: github.ref == 'refs/heads/master'
run: |
tag="${{ steps.package_version.outputs.version }}"
git fetch --tags
@@ -76,7 +81,23 @@ jobs:
echo "Tag $tag already exists. Skipping tag creation."
fi
- name: Create Release
- name: Create Git tag if not exists (dev)
if: github.ref == 'refs/heads/dev'
run: |
tag="${{ steps.package_version.outputs.version }}-Dev"
git fetch --tags
if ! git tag -l "$tag" | grep -q "$tag"; then
echo "Tag $tag does not exist. Creating and pushing..."
git config user.name "GitHub Action"
git config user.email "action@github.com"
git tag "$tag"
git push origin "$tag"
else
echo "Tag $tag already exists. Skipping tag creation."
fi
- name: Create Release (master)
if: github.ref == 'refs/heads/master'
id: create_release
run: |
echo "=== Searching for existing release ${{ steps.package_version.outputs.version }}==="
@@ -104,15 +125,60 @@ jobs:
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases"
)
echo "API response: $response"
release_id=$(echo "$response" | jq -r .id)
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
echo "release_id=$release_id"
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
- name: Create Release (dev)
if: github.ref == 'refs/heads/dev'
id: create_release
run: |
version="${{ steps.package_version.outputs.version }}-Dev"
echo "=== Searching for existing release $version==="
release_id=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/$version" | jq -r .id)
if [ "$release_id" != "null" ]; then
echo "=== Deleting existing release $version==="
curl -X DELETE -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$release_id"
fi
echo "=== Creating new release $version==="
response=$(
curl --fail-with-body -X POST \
-H "Content-Type: application/json" \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-d '{
"tag_name": "'"$version"'",
"name": "'"$version"'",
"draft": false,
"prerelease": false
}' \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases"
)
echo "API response: $response"
release_id=$(echo "$response" | jq -r .id)
echo "release_id=$release_id"
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
- name: Check asset exists
run: |
if [ ! -f output/LightlessClient.zip ]; then
echo "output/LightlessClient.zip does not exist!"
exit 1
fi
- name: Upload Assets to release
env:
RELEASE_ID: ${{ env.RELEASE_ID }}
run: |
echo "Uploading to release ID: $RELEASE_ID"
curl --fail-with-body -s -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@output/LightlessClient.zip" \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/${{ steps.create_release.outputs.release_id }}/assets"
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
- name: Clone plugin hosting repo
run: |
@@ -122,7 +188,8 @@ jobs:
env:
GIT_TERMINAL_PROMPT: 0
- name: Update plogonmaster.json with version
- name: Update plogonmaster.json with version (master)
if: github.ref == 'refs/heads/master'
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: |
@@ -159,7 +226,6 @@ jobs:
.DalamudApiLevel = $dalamudApiLevel
| .AssemblyVersion = $version
| .DownloadLinkInstall = $downloadUrl
| .DownloadLinkTesting = $downloadUrl
| .DownloadLinkUpdate = $downloadUrl
else
.
@@ -172,6 +238,47 @@ jobs:
# Output the content of the file
cat "$repoJsonPath"
- name: Update plogonmaster.json with version (dev)
if: github.ref == 'refs/heads/dev'
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: |
set -e
pluginJsonPath="${PLUGIN_NAME}/bin/x64/Release/${PLUGIN_NAME}.json"
repoJsonPath="LightlessSyncRepo/LightlessSync/plogonmaster.json"
assemblyVersion="${VERSION}"
version="${VERSION}-Dev"
downloadUrl="https://git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessClient/releases/download/$version/LightlessClient.zip"
pluginJson=$(cat "$pluginJsonPath")
internalName=$(jq -r '.InternalName' <<< "$pluginJson")
dalamudApiLevel=$(jq -r '.DalamudApiLevel' <<< "$pluginJson")
repoJsonRaw=$(cat "$repoJsonPath")
if echo "$repoJsonRaw" | jq 'type' | grep -q '"array"'; then
repoJson="$repoJsonRaw"
else
repoJson="[$repoJsonRaw]"
fi
updatedRepoJson=$(jq \
--arg internalName "$internalName" \
--arg dalamudApiLevel "$dalamudApiLevel" \
--arg assemblyVersion "$assemblyVersion" \
--arg version "$version" \
--arg downloadUrl "$downloadUrl" \
'
map(
if .InternalName == $internalName
then
.DalamudApiLevel = $dalamudApiLevel
| .TestingAssemblyVersion = $assemblyVersion
| .DownloadLinkTesting = $downloadUrl
else
.
end
)
' <<< "$repoJson")
echo "$updatedRepoJson" > "$repoJsonPath"
cat "$repoJsonPath"
- name: Commit and push to LightlessSync
run: |
cd LightlessSyncRepo/LightlessSync

View File

@@ -1,140 +0,0 @@
name: Tag and Release Lightless
on:
push:
branches: [ master ]
env:
PLUGIN_NAME: LightlessSync
DOTNET_VERSION: 9.x
jobs:
tag-and-release:
runs-on: windows-2022
permissions:
contents: write
steps:
- name: Checkout Lightless
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.x
- name: Download Dalamud
run: |
Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip
Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev"
- name: Lets Build Lightless!
run: |
dotnet restore
dotnet build --configuration Release --no-restore
dotnet publish --configuration Release --no-build
- name: Get version
id: package_version
uses: KageKirin/get-csproj-version@v0
with:
file: LightlessSync/LightlessSync.csproj
- name: Display version
run: |
echo "Version: ${{ steps.package_version.outputs.version }}"
- name: Prepare Lightless Client
run: |
$publishPath = "${{ env.PLUGIN_NAME }}/bin/x64/Release/publish"
if (Test-Path $publishPath) {
Remove-Item -Recurse -Force $publishPath
Write-Host "Removed $publishPath"
} else {
Write-Host "$publishPath does not exist, nothing to remove."
}
mkdir output
Compress-Archive -Path ${{ env.PLUGIN_NAME }}/bin/x64/Release/* -DestinationPath output/LightlessClient.zip
- name: Create Git tag if not exists
shell: pwsh
run: |
$tag = "${{ steps.package_version.outputs.version }}"
git fetch --tags
if (-not (git tag -l $tag)) {
Write-Host "Tag $tag does not exist. Creating and pushing..."
git config user.name "GitHub Action"
git config user.email "action@github.com"
git tag $tag
git push origin $tag
} else {
Write-Host "Tag $tag already exists. Skipping tag creation."
}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.package_version.outputs.version }}
name: ${{ steps.package_version.outputs.version }}
draft: false
prerelease: false
files: output/LightlessClient.zip
- name: Clone plugin hosting repo
run: |
mkdir LightlessSyncRepo
cd LightlessSyncRepo
git clone https://github.com/${{ github.repository_owner }}/LightlessSync.git
env:
GIT_TERMINAL_PROMPT: 0
- name: Update plogonmaster.json with version
shell: pwsh
env:
VERSION: ${{ steps.package_version.outputs.version }}
run: |
$pluginJsonPath = "${{ env.PLUGIN_NAME }}/bin/x64/Release/${{ env.PLUGIN_NAME }}.json"
$pluginJson = Get-Content $pluginJsonPath | ConvertFrom-Json
$repoJsonPath = "LightlessSyncRepo/LightlessSync/plogonmaster.json"
$repoJsonRaw = Get-Content $repoJsonPath -Raw
$repoJson = $repoJsonRaw | ConvertFrom-Json
$version = $env:VERSION
$downloadUrl = "https://github.com/${{ github.repository_owner }}/LightlessClient/releases/download/$version/LightlessClient.zip"
if (-not ($repoJson -is [System.Collections.IEnumerable])) {
$repoJson = @($repoJson)
}
foreach ($plugin in $repoJson) {
if ($plugin.InternalName -eq $pluginJson.InternalName) {
$plugin.DalamudApiLevel = $pluginJson.DalamudApiLevel
$plugin.AssemblyVersion = $version
$plugin.DownloadLinkInstall = $downloadUrl
$plugin.DownloadLinkTesting = $downloadUrl
$plugin.DownloadLinkUpdate = $downloadUrl
}
}
$repoJson | ConvertTo-Json -Depth 100 | Set-Content $repoJsonPath
# Convert to JSON and force array brackets if necessary
$repoJsonString = $repoJson | ConvertTo-Json -Depth 100
# If the output is not an array, wrap it manually
if ($repoJsonString.Trim().StartsWith('{')) {
$repoJsonString = "[$repoJsonString]"
}
$repoJsonString | Set-Content $repoJsonPath
- name: Commit and push to LightlessSync
run: |
cd LightlessSyncRepo/LightlessSync
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add .
git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
git push https://x-access-token:${{ secrets.LIGHTLESS_TOKEN }}@github.com/${{ github.repository_owner }}/LightlessSync.git HEAD:main

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

@@ -0,0 +1,279 @@
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"
versions:
- number: "Syncshells"
icon: ""
items:
- "Added a pause button for syncshells in grouped folders"
- number: "Notifications"
icon: ""
items:
- "Fixed download notifications getting stuck at times"
- "Added more offset positions for the notifications"
- number: "Lightfinder"
icon: ""
items:
- "Pair button will now show up when you're not directly paired. ie. You are technically paired in a syncshell, but you may not be directly paired'"
- "Fixed a problem where the number of LightFinder users were cached, displaying the wrong information"
- "When LightFinder is enabled, if you hover over the number of users, it will show you how many users are also using LightFinder in your area"
-
- number: "Bugfixes"
icon: ""
items:
- "Added even more checks to nameplate handler to help not only us debug, but also other plugins writing to the plate"
- number: "Miscellaneous Changes"
icon: ""
items:
- "Default Linux to Websockets"
- "Revised Brio warning"
- "Added /lightless command to open Lightless UI (you can still use /light)"
- "Initial groundwork for future features"
- name: "v1.12.3"
tagline: "LightSpeed, Welcome Screen, and More!"
date: "October 15th 2025"
versions:
- number: "LightSpeed"
icon: ""
items:
- "New way to download that will download mods directly from the file server"
- "LightSpeed is in BETA and should be faster than the batch downloading"
- number: "Welcome Screen + Additional Features"
icon: ""
items:
- "New in-game Patch Notes window."
- "Credits section to thank contributors and supporters."
- "Patch notes only show after updates, not during first-time setup."
- "Syncshell Rework stared: Profiles have been added (more features using this will come later)."
- number: "Notifications"
icon: ""
items:
- "More customizable notification options."
- "Perfomance limiter shows as notifications."
- "All notifications can be configured or disabled in Settings → Notifications."
- "Cleaning up notifications implementation"
- number: "Bugfixes"
icon: ""
items:
- "Added more safety checks to nameplates"
- "Removed a line in SyncshellUI potentially causing NullPointers"
- "Additional safety checks in PlayerData.Factory"
- name: "v1.12.2"
tagline: "LightFinder fixes, Notifications overhaul"
date: "October 12th 2025"
versions:
- number: "LightFinder"
icon: ""
items:
- "Server-side improvements for LightFinder functionality."
- "Command changed from '/light lightfinder' to '/light finder'."
- "Option to enable LightFinder on connection (opt-in, refreshes every 3 hours)."
- "LightFinder indicator can now be shown on the server info bar."
- number: "Notifications"
icon: ""
items:
- "Completely reworked notification system with new UI."
- "Pair requests now show as notifications."
- "Download progress shows as notifications."
- "Customizable notification sounds, size, position, and duration."
- "All notifications can be configured or disabled in Settings → Notifications."
- number: "Bug Fixes"
icon: ""
items:
- "Fixed nameplate alignment issues with LightFinder and icons."
- "Icons now properly apply instead of swapping on choice."
- "Updated Discord URL."
- "File cache logic improvements."
- name: "v1.12.1"
tagline: "LightFinder customization and download limiter"
date: "October 8th 2025"
versions:
- number: "New Features"
icon: ""
items:
- "LightFinder text can be modified to an icon with customizable positioning."
- "Option to hide your own indicator or paired player indicators."
- "Pair Download Limiter: Limit simultaneous downloads to 1-6 users to reduce network strain."
- "Added '/light lightfinder' command to open LightFinder UI."
- number: "Improvements"
icon: ""
items:
- "Right-click menu option for Send Pair Request can be disabled."
- "Syncshell finder improvements."
- "Download limiter settings available in Settings → Transfers."
- name: "v1.12.0"
tagline: "LightFinder - Major feature release"
date: "October 5th 2025"
versions:
- number: "Major Features"
icon: ""
items:
- "Introduced LightFinder: Optional feature inspired by FFXIV's Party Finder."
- "Find fellow Lightless users and advertise your Syncshell to others."
- "When enabled, you're visible to other LightFinder users for 3 hours."
- "LightFinder tag displays above your nameplate when active."
- "Receive pair requests directly in UI without exchanging UIDs."
- "Syncshell Finder allows joining indexed Syncshells."
- "[L] Send Pair Request added to player context menus."
- number: "Vanity Features"
icon: ""
items:
- "Supporters can now customize their name color in the Lightless UI."
- "Color changes visible to all users."
- number: "General Improvements"
icon: ""
items:
- "Pairing nameplate color override can now override FC tags."
- "Added .kdb as whitelisted filetype for uploads."
- "Various UI fixes, updates, and improvements."
- name: "v1.11.12"
tagline: "Syncshell grouping and performance options"
date: "September 16th 2025"
versions:
- number: "New Features"
icon: ""
items:
- "Ability to show grouped syncshells in main UI/all syncshells (default ON)."
- "Transfer ownership button available in Admin Panel user list."
- "Self-threshold warning now opens character analysis screen when clicked."
- number: "Performance"
icon: ""
items:
- "Auto-pause combat and auto-pause performance are now optional settings."
- "Both options are auto-enabled by default - disable at your own risk."
- number: "Bug Fixes"
icon: ""
items:
- "Reworked file caching to reduce errors for some users."
- "Fixed bug where exiting PvP could desync some users."
- name: "v1.11.9"
tagline: "File cache improvements"
date: "September 13th 2025"
versions:
- number: "Bug Fixes"
icon: ""
items:
- "Identified and fixed potential file cache problems."
- "Improved cache error handling and stability."
- name: "v1.11.8"
tagline: "Hotfix - UI and exception handling"
date: "September 12th 2025"
versions:
- number: "Bug Fixes"
icon: ""
items:
- "Attempted fix for NullReferenceException spam."
- "Fixed additional UI edge cases preventing loading for some users."
- "Fixed color bar UI issues."
- name: "v1.11.7"
tagline: "Hotfix - UI loading and warnings"
date: "September 12th 2025"
versions:
- number: "Bug Fixes"
icon: ""
items:
- "Fixed UI not loading for some users."
- "Self warnings now behind 'Warn on loading in players exceeding performance thresholds' setting."
- name: "v1.11.6"
tagline: "Admin panel rework and new features"
date: "September 11th 2025"
versions:
- number: "New Features"
icon: ""
items:
- "Reworked Syncshell Admin Page with improved styling."
- "Right-click on Server Top Bar button to disconnect from Lightless."
- "Shift+Left click on Server Top Bar button to open settings."
- "Added colors section in settings to change accent colors."
- "Ability to pause syncing while in Instance/Duty."
- "Functionality to create syncshell folders."
- "Added self-threshold warning."
- number: "Bug Fixes"
icon: ""
items:
- "Fixed owners being visible in moderator list view."
- "Removed Pin/Remove/Ban buttons on Owners when viewing as moderator."
- "Fixed nameplate bug in PvP."
- "Added 1 or 3 day options for inactive check."
- "Fixed bug where some users could not see their own syncshell folders."

View File

@@ -0,0 +1,68 @@
credits:
- category: "Development Team"
items:
- name: "Abel"
role: "Developer"
- name: "Cake"
role: "Developer"
- name: "Celine"
role: "Developer"
- name: "Choco"
role: "Developer"
- name: "Kenny"
role: "Developer"
- name: "Zura"
role: "Developer"
- name: "Additional Contributors"
role: "Community Contributors & Bug Reporters"
- category: "Moderation Team"
items:
- name: "Crow"
role: "Moderator"
- name: "Faith"
role: "Moderator"
- name: "Kiwiwiwi"
role: "Moderator"
- name: "Kruwu"
role: "Moderator"
- name: "Lexi"
role: "Moderator"
- name: "Maya"
role: "Moderator"
- name: "Metaknight"
role: "Moderator"
- name: "Minmoose"
role: "Moderator"
- name: "Nihal"
role: "Moderator"
- name: "Tani"
role: "Moderator"
- category: "Plugin Integration & IPC Support"
items:
- name: "Penumbra Team"
role: "Mod framework integration"
- name: "Glamourer Team"
role: "Customization system integration"
- name: "Customize+ Team"
role: "Body scaling integration"
- name: "Simple Heels Team"
role: "Height offset integration"
- name: "Honorific Team"
role: "Title system integration"
- name: "Glyceri"
role: "Moodles - Status effect integration"
- name: "Glyceri"
role: "PetNicknames - Pet naming integration"
- name: "Minmoose"
role: "Brio - GPose enhancement integration"
- category: "Special Thanks"
items:
- name: "Dalamud & XIVLauncher Teams"
role: "Plugin framework and infrastructure"
- name: "Community Supporters"
role: "Testing, feedback, and financial support"
- name: "Beta Testers"
role: "Early testing and bug reporting"

View File

@@ -20,7 +20,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private long _currentFileProgress = 0;
private CancellationTokenSource _scanCancellationTokenSource = new();
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk"];
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
public CacheMonitor(ILogger<CacheMonitor> logger, IpcManager ipcManager, LightlessConfigService configService,
FileCacheManager fileDbManager, LightlessMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil,
@@ -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()
{
@@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public bool StorageisNTFS { get; private set; } = false;
public bool StorageIsBtrfs { get ; private set; } = false;
public void StartLightlessWatcher(string? lightlessPath)
{
LightlessWatcher?.Dispose();
@@ -124,10 +126,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
return;
}
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase);
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtil.IsWine)
{
StorageisNTFS = true;
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
}
if (fsType == FileSystemHelper.FilesystemType.Btrfs)
{
StorageIsBtrfs = true;
Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs);
}
Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
LightlessWatcher = new()
@@ -248,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; }
@@ -392,51 +404,140 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void RecalculateFileCacheSize(CancellationToken token)
{
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
!Directory.Exists(_configService.Current.CacheFolder))
{
FileCacheSize = 0;
return;
}
FileCacheSize = -1;
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
bool isWine = _dalamudUtil?.IsWine ?? false;
try
{
FileCacheDriveFree = di.AvailableFreeSpace;
var drive = DriveInfo.GetDrives()
.FirstOrDefault(d => _configService.Current.CacheFolder
.StartsWith(d.Name, StringComparison.OrdinalIgnoreCase));
if (drive != null)
FileCacheDriveFree = drive.AvailableFreeSpace;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder);
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
}
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder).Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime).ToList();
FileCacheSize = files
.Sum(f =>
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
.Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime)
.ToList();
long totalSize = 0;
foreach (var f in files)
{
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;
}
totalSize += size;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", f.FullName);
}
}
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
{
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
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
catch (Exception ex)
{
return 0;
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;
if (FileCacheSize < maxCacheInBytes)
return;
var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
{
var oldestFile = files[0];
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
File.Delete(oldestFile.FullName);
files.Remove(oldestFile);
try
{
long fileSize = oldestFile.Length;
File.Delete(oldestFile.FullName);
FileCacheSize -= fileSize;
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName);
}
files.RemoveAt(0);
}
}
@@ -456,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)
@@ -539,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());
@@ -644,44 +752,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (ct.IsCancellationRequested) return;
// scan new files
if (allScannedFiles.Any(c => !c.Value))
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
foreach (var cachePath in newFiles)
{
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
new ParallelOptions()
{
MaxDegreeOfParallelism = threadCount,
CancellationToken = ct
}, (cachePath) =>
{
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
{
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
if (ct.IsCancellationRequested) break;
ProcessOne(cachePath);
Interlocked.Increment(ref _currentFileProgress);
}
if (ct.IsCancellationRequested) return;
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
void ProcessOne(string? cachePath)
{
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
{
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
try
{
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
Interlocked.Increment(ref _currentFileProgress);
});
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
try
{
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
catch (IOException ioex)
{
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
}
Logger.LogDebug("Scan complete");

View File

@@ -1,4 +1,4 @@
using K4os.Compression.LZ4.Legacy;
using K4os.Compression.LZ4.Legacy;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
@@ -16,14 +16,19 @@ public sealed class FileCacheManager : IHostedService
public const string CachePrefix = "{cache}";
public const string CsvSplit = "|";
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;
private readonly ConcurrentDictionary<string, List<FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger;
private bool _csvHeaderEnsured;
public string CacheFolder => _configService.Current.CacheFolder;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
@@ -37,12 +42,112 @@ public sealed class FileCacheManager : IHostedService
private string CsvBakPath => _csvPath + ".bak";
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal);
private static string NormalizePrefixedPathKey(string prefixedPath)
{
if (string.IsNullOrEmpty(prefixedPath))
{
return string.Empty;
}
return NormalizeSeparators(prefixedPath).ToLowerInvariant();
}
private static bool TryBuildPrefixedPath(string path, string? baseDirectory, string prefix, out string prefixedPath, out int matchedLength)
{
prefixedPath = string.Empty;
matchedLength = 0;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(baseDirectory))
{
return false;
}
var normalizedPath = NormalizeSeparators(path).ToLowerInvariant();
var normalizedBase = NormalizeSeparators(baseDirectory).TrimEnd('\\').ToLowerInvariant();
if (!normalizedPath.StartsWith(normalizedBase, StringComparison.Ordinal))
{
return false;
}
if (normalizedPath.Length > normalizedBase.Length)
{
if (normalizedPath[normalizedBase.Length] != '\\')
{
return false;
}
prefixedPath = prefix + normalizedPath.Substring(normalizedBase.Length);
}
else
{
prefixedPath = prefix;
}
prefixedPath = prefixedPath.Replace("\\\\", "\\", StringComparison.Ordinal);
matchedLength = normalizedBase.Length;
return true;
}
private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}";
private static bool TryParseVersionHeader(string? line, out int version)
{
version = 0;
if (string.IsNullOrWhiteSpace(line))
{
return false;
}
if (!line.StartsWith(FileCacheVersionHeaderPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var versionSpan = line.AsSpan(FileCacheVersionHeaderPrefix.Length);
return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
}
private string NormalizeToPrefixedPath(string path)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
var normalized = NormalizeSeparators(path);
if (normalized.StartsWith(CachePrefix, StringComparison.OrdinalIgnoreCase) ||
normalized.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
{
return NormalizePrefixedPathKey(normalized);
}
string? chosenPrefixed = null;
var chosenLength = -1;
if (TryBuildPrefixedPath(normalized, _ipcManager.Penumbra.ModDirectory, PenumbraPrefix, out var penumbraPrefixed, out var penumbraMatch))
{
chosenPrefixed = penumbraPrefixed;
chosenLength = penumbraMatch;
}
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch) && cacheMatch > chosenLength)
{
chosenPrefixed = cachePrefixed;
}
return NormalizePrefixedPathKey(chosenPrefixed ?? normalized);
}
public FileCacheEntity? CreateCacheEntry(string path)
{
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating cache entry for {path}", path);
return CreateFileEntity(_configService.Current.CacheFolder.ToLowerInvariant(), CachePrefix, fi);
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder)) return null;
return CreateFileEntity(cacheFolder, CachePrefix, fi);
}
public FileCacheEntity? CreateFileEntry(string path)
@@ -50,74 +155,141 @@ public sealed class FileCacheManager : IHostedService
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating file entry for {path}", path);
return CreateFileEntity(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), PenumbraPrefix, fi);
var modDirectory = _ipcManager.Penumbra.ModDirectory;
if (string.IsNullOrEmpty(modDirectory)) return null;
return CreateFileEntity(modDirectory, PenumbraPrefix, fi);
}
private FileCacheEntity? CreateFileEntity(string directory, string prefix, FileInfo fi)
{
var fullName = fi.FullName.ToLowerInvariant();
if (!fullName.Contains(_configService.Current.CacheFolder.ToLowerInvariant(), StringComparison.Ordinal)) return null;
string prefixedPath = fullName.Replace(directory, prefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
if (!TryBuildPrefixedPath(fi.FullName, directory, prefix, out var prefixedPath, out _))
{
return null;
}
return CreateFileCacheEntity(fi, prefixedPath);
}
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v).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.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);
}
}
return output;
}
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
public async Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int completed, int total, FileCacheEntity current)> progress, CancellationToken cancellationToken)
{
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage");
var cacheEntries = _fileCaches.SelectMany(v => v.Value).Where(v => v.IsCacheEntry).ToList();
List<FileCacheEntity> brokenEntities = [];
int i = 0;
foreach (var fileCache in cacheEntries)
var cacheEntries = _fileCaches.Values
.SelectMany(v => v.Values)
.Where(v => v.IsCacheEntry)
.ToList();
int total = cacheEntries.Count;
int processed = 0;
var brokenEntities = new ConcurrentBag<FileCacheEntity>();
_logger.LogInformation("Checking {count} cache entries...", total);
await Parallel.ForEachAsync(cacheEntries, new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
CancellationToken = cancellationToken
},
async (fileCache, token) =>
{
if (cancellationToken.IsCancellationRequested) break;
_logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath);
progress.Report((i, cacheEntries.Count, fileCache));
i++;
if (!File.Exists(fileCache.ResolvedFilepath))
{
brokenEntities.Add(fileCache);
continue;
}
try
{
var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
int current = Interlocked.Increment(ref processed);
if (current % 10 == 0)
progress.Report((current, total, fileCache));
if (!File.Exists(fileCache.ResolvedFilepath))
{
brokenEntities.Add(fileCache);
return;
}
var algo = Crypto.DetectAlgo(fileCache.Hash);
string computedHash;
try
{
computedHash = await Crypto.ComputeFileHashAsync(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1, token).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error hashing {file}", fileCache.ResolvedFilepath);
brokenEntities.Add(fileCache);
return;
}
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
{
_logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
_logger.LogInformation(
"Hash mismatch: {file} (got {computedHash}, expected {expected} : hash {hash})",
fileCache.ResolvedFilepath, computedHash, fileCache.Hash, algo);
brokenEntities.Add(fileCache);
}
}
catch (Exception e)
catch (OperationCanceledException)
{
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
_logger.LogError("Validation got cancelled for {file}", fileCache.ResolvedFilepath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error validating {file}", fileCache.ResolvedFilepath);
brokenEntities.Add(fileCache);
}
}
}).ConfigureAwait(false);
foreach (var brokenEntity in brokenEntities)
{
@@ -129,12 +301,14 @@ public sealed class FileCacheManager : IHostedService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
_logger.LogWarning(ex, "Failed to delete invalid cache file {file}", brokenEntity.ResolvedFilepath);
}
}
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
return Task.FromResult(brokenEntities);
_logger.LogInformation("Validation complete. Found {count} invalid entries.", brokenEntities.Count);
return [.. brokenEntities];
}
public string GetCacheFilePath(string hash, string extension)
@@ -151,29 +325,40 @@ public sealed class FileCacheManager : IHostedService
public FileCacheEntity? GetFileCacheByHash(string hash)
{
if (_fileCaches.TryGetValue(hash, out var hashes))
if (_fileCaches.TryGetValue(hash, out var entries))
{
var item = hashes.OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1).FirstOrDefault();
if (item != null) return GetValidatedFileCache(item);
var item = entries.Values
.OrderBy(p => p.PrefixedFilePath.Contains(PenumbraPrefix, StringComparison.Ordinal) ? 0 : 1)
.FirstOrDefault();
if (item != null)
{
return GetValidatedFileCache(item);
}
}
return null;
}
private FileCacheEntity? GetFileCacheByPath(string path)
{
var cleanedPath = path.Replace("/", "\\", StringComparison.OrdinalIgnoreCase).ToLowerInvariant()
.Replace(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), "", StringComparison.OrdinalIgnoreCase);
var entry = _fileCaches.SelectMany(v => v.Value).FirstOrDefault(f => f.ResolvedFilepath.EndsWith(cleanedPath, StringComparison.OrdinalIgnoreCase));
if (entry == null)
var normalizedPrefixedPath = NormalizeToPrefixedPath(path);
if (string.IsNullOrEmpty(normalizedPrefixedPath))
{
_logger.LogDebug("Found no entries for {path}", cleanedPath);
return CreateFileEntry(path);
return null;
}
var validatedCacheEntry = GetValidatedFileCache(entry);
if (_fileCachesByPrefixedPath.TryGetValue(normalizedPrefixedPath, out var entry))
{
return GetValidatedFileCache(entry);
}
return validatedCacheEntry;
_logger.LogDebug("Found no entries for {path}", normalizedPrefixedPath);
if (normalizedPrefixedPath.Contains(CachePrefix, StringComparison.Ordinal))
{
return CreateCacheEntry(path);
}
return CreateFileEntry(path) ?? CreateCacheEntry(path);
}
public Dictionary<string, FileCacheEntity?> GetFileCachesByPaths(string[] paths)
@@ -182,66 +367,55 @@ public sealed class FileCacheManager : IHostedService
try
{
var allEntities = _fileCaches.SelectMany(f => f.Value).ToArray();
var result = new Dictionary<string, FileCacheEntity?>(StringComparer.OrdinalIgnoreCase);
var seenNormalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var cacheDict = new ConcurrentDictionary<string, FileCacheEntity>(
StringComparer.OrdinalIgnoreCase);
Parallel.ForEach(allEntities, entity =>
foreach (var originalPath in paths)
{
cacheDict[entity.PrefixedFilePath] = entity;
});
var cleanedPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var seenCleaned = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
Parallel.ForEach(paths, p =>
{
var cleaned = p.Replace("/", "\\", StringComparison.OrdinalIgnoreCase)
.Replace(
_ipcManager.Penumbra.ModDirectory!,
_ipcManager.Penumbra.ModDirectory!.EndsWith('\\')
? PenumbraPrefix + '\\' : PenumbraPrefix,
StringComparison.OrdinalIgnoreCase)
.Replace(
_configService.Current.CacheFolder,
_configService.Current.CacheFolder.EndsWith('\\')
? CachePrefix + '\\' : CachePrefix,
StringComparison.OrdinalIgnoreCase)
.Replace("\\\\", "\\", StringComparison.Ordinal);
if (seenCleaned.TryAdd(cleaned, 0))
if (string.IsNullOrEmpty(originalPath))
{
_logger.LogDebug("Adding to cleanedPaths: {cleaned}", cleaned);
cleanedPaths[p] = cleaned;
result[originalPath] = null;
continue;
}
var normalized = NormalizeToPrefixedPath(originalPath);
if (seenNormalized.Add(normalized))
{
if (!string.IsNullOrEmpty(normalized))
{
_logger.LogDebug("Normalized path {cleaned}", normalized);
}
}
else if (!string.IsNullOrEmpty(normalized))
{
_logger.LogWarning("Duplicate normalized path detected: {cleaned}", normalized);
}
if (_fileCachesByPrefixedPath.TryGetValue(normalized, out var entity))
{
result[originalPath] = GetValidatedFileCache(entity);
continue;
}
FileCacheEntity? created = null;
if (normalized.Contains(CachePrefix, StringComparison.Ordinal))
{
created = CreateCacheEntry(originalPath);
}
else if (normalized.Contains(PenumbraPrefix, StringComparison.Ordinal))
{
created = CreateFileEntry(originalPath);
}
else
{
_logger.LogWarning("Duplicate found: {cleaned}", cleaned);
created = CreateFileEntry(originalPath) ?? CreateCacheEntry(originalPath);
}
});
var result = new ConcurrentDictionary<string, FileCacheEntity?>(StringComparer.OrdinalIgnoreCase);
result[originalPath] = created;
}
Parallel.ForEach(cleanedPaths, entry =>
{
_logger.LogDebug("Checking if in cache: {path}", entry.Value);
if (cacheDict.TryGetValue(entry.Value, out var entity))
{
var validatedCache = GetValidatedFileCache(entity);
result[entry.Key] = validatedCache;
}
else
{
if (!entry.Value.Contains(CachePrefix, StringComparison.Ordinal))
result[entry.Key] = CreateFileEntry(entry.Key);
else
result[entry.Key] = CreateCacheEntry(entry.Key);
}
});
return new Dictionary<string, FileCacheEntity?>(result, StringComparer.OrdinalIgnoreCase);
return result;
}
finally
{
@@ -251,16 +425,24 @@ public sealed class FileCacheManager : IHostedService
public void RemoveHashedFile(string hash, string prefixedFilePath)
{
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
if (_fileCaches.TryGetValue(hash, out var caches))
{
var removedCount = caches?.RemoveAll(c => string.Equals(c.PrefixedFilePath, prefixedFilePath, StringComparison.Ordinal));
_logger.LogTrace("Removed from DB: {count} file(s) with hash {hash} and file cache {path}", removedCount, hash, prefixedFilePath);
_logger.LogTrace("Removing from DB: {hash} => {path}", hash, prefixedFilePath);
if (caches?.Count == 0)
if (caches.TryRemove(normalizedPath, out var removedEntity))
{
_fileCaches.Remove(hash, out var entity);
_logger.LogTrace("Removed from DB: {hash} => {path}", hash, removedEntity.PrefixedFilePath);
}
if (caches.IsEmpty)
{
_fileCaches.TryRemove(hash, out _);
}
}
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
}
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
@@ -273,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);
@@ -301,7 +483,8 @@ public sealed class FileCacheManager : IHostedService
lock (_fileWriteLock)
{
StringBuilder sb = new();
foreach (var entry in _fileCaches.SelectMany(k => k.Value).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
sb.AppendLine(BuildVersionHeader());
foreach (var entry in _fileCaches.Values.SelectMany(k => k.Values).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
{
sb.AppendLine(entry.CsvEntry);
}
@@ -323,6 +506,104 @@ 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))
{
return;
}
string[] existingLines = File.ReadAllLines(_csvPath);
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
{
_csvHeaderEnsured = true;
return;
}
StringBuilder rebuilt = new();
rebuilt.AppendLine(BuildVersionHeader());
foreach (var line in existingLines)
{
if (TryParseVersionHeader(line, out _))
{
continue;
}
if (!string.IsNullOrEmpty(line))
{
rebuilt.AppendLine(line);
}
}
File.WriteAllText(_csvPath, rebuilt.ToString());
_csvHeaderEnsured = true;
}
private void EnsureCsvHeaderLockedCached()
{
if (_csvHeaderEnsured)
{
return;
}
EnsureCsvHeaderLocked();
_csvHeaderEnsured = true;
}
private void BackupUnsupportedCache(string suffix)
{
var sanitizedSuffix = string.IsNullOrWhiteSpace(suffix) ? "unsupported" : $"{suffix}.unsupported";
var backupPath = _csvPath + "." + sanitizedSuffix;
try
{
File.Move(_csvPath, backupPath, overwrite: true);
_logger.LogWarning("Backed up unsupported file cache to {path}", backupPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to back up unsupported file cache to {path}", backupPath);
}
}
internal FileCacheEntity MigrateFileHashToExtension(FileCacheEntity fileCache, string ext)
{
try
@@ -346,27 +627,31 @@ public sealed class FileCacheManager : IHostedService
private void AddHashedFile(FileCacheEntity fileCache)
{
if (!_fileCaches.TryGetValue(fileCache.Hash, out var entries) || entries is null)
{
_fileCaches[fileCache.Hash] = entries = [];
}
var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
var entries = _fileCaches.GetOrAdd(fileCache.Hash, _ => new ConcurrentDictionary<string, FileCacheEntity>(StringComparer.OrdinalIgnoreCase));
if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase)))
{
//_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath);
entries.Add(fileCache);
}
entries[normalizedPath] = fileCache;
_fileCachesByPrefixedPath[normalizedPath] = fileCache;
}
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);
lock (_fileWriteLock)
{
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
if (!File.Exists(_csvPath))
{
File.WriteAllLines(_csvPath, [BuildVersionHeader(), entity.CsvEntry]);
_csvHeaderEnsured = true;
}
else
{
EnsureCsvHeaderLockedCached();
File.AppendAllLines(_csvPath, [entity.CsvEntry]);
}
}
var result = GetFileCacheByPath(fileInfo.FullName);
_logger.LogTrace("Creating cache entity for {name} success: {success}", fileInfo.FullName, (result != null));
@@ -376,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))
@@ -397,6 +688,13 @@ public sealed class FileCacheManager : IHostedService
private FileCacheEntity? Validate(FileCacheEntity fileCache)
{
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;
}
var file = new FileInfo(fileCache.ResolvedFilepath);
if (!file.Exists)
{
@@ -404,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);
}
@@ -412,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");
@@ -463,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);
}
}
@@ -479,62 +805,124 @@ public sealed class FileCacheManager : IHostedService
_logger.LogWarning("Could not load entries from {path}, continuing with empty file cache", _csvPath);
}
_logger.LogInformation("Found {amount} files in {path}", entries.Length, _csvPath);
bool rewriteRequired = false;
bool parseEntries = entries.Length > 0;
int startIndex = 0;
Dictionary<string, bool> processedFiles = new(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
if (entries.Length > 0)
{
var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None);
try
var headerLine = entries[0];
var hasHeader = !string.IsNullOrEmpty(headerLine) &&
headerLine.StartsWith(FileCacheVersionHeaderPrefix, StringComparison.OrdinalIgnoreCase);
if (hasHeader)
{
var hash = splittedEntry[0];
if (hash.Length != 40) throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length);
var path = splittedEntry[1];
var time = splittedEntry[2];
if (processedFiles.ContainsKey(path))
if (!TryParseVersionHeader(headerLine, out var parsedVersion))
{
_logger.LogWarning("Already processed {file}, ignoring", path);
continue;
_logger.LogWarning("Failed to parse file cache version header \"{header}\". Backing up existing cache.", headerLine);
BackupUnsupportedCache("invalid-version");
parseEntries = false;
rewriteRequired = true;
entries = [];
}
processedFiles.Add(path, value: true);
long size = -1;
long compressed = -1;
if (splittedEntry.Length > 3)
else if (parsedVersion != FileCacheVersion)
{
if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result))
{
size = result;
}
if (long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed))
{
compressed = resultCompressed;
}
_logger.LogWarning("Unsupported file cache version {version} detected (expected {expected}). Backing up existing cache.", parsedVersion, FileCacheVersion);
BackupUnsupportedCache($"v{parsedVersion}");
parseEntries = false;
rewriteRequired = true;
entries = [];
}
else
{
startIndex = 1;
}
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
}
catch (Exception ex)
else if (entries.Length > 0)
{
_logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry);
_logger.LogInformation("File cache missing version header, scheduling rewrite.");
rewriteRequired = true;
}
}
if (processedFiles.Count != entries.Length)
var totalEntries = Math.Max(0, entries.Length - startIndex);
Dictionary<string, bool> processedFiles = new(StringComparer.OrdinalIgnoreCase);
if (parseEntries && totalEntries > 0)
{
WriteOutFullCsv();
_logger.LogInformation("Found {amount} files in {path}", totalEntries, _csvPath);
for (var index = startIndex; index < entries.Length; index++)
{
var entry = entries[index];
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None);
try
{
var hash = splittedEntry[0];
if (hash.Length != 40)
throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length);
var path = splittedEntry[1];
var time = splittedEntry[2];
if (processedFiles.ContainsKey(path))
{
_logger.LogWarning("Already processed {file}, ignoring", path);
continue;
}
processedFiles.Add(path, value: true);
long size = -1;
long compressed = -1;
if (splittedEntry.Length > 3)
{
if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result))
{
size = result;
}
if (splittedEntry.Length > 4 &&
long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed))
{
compressed = resultCompressed;
}
}
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry);
}
}
if (processedFiles.Count != totalEntries)
{
rewriteRequired = true;
}
}
else if (!parseEntries && entries.Length > 0)
{
_logger.LogInformation("Skipping existing file cache entries due to incompatible version.");
}
if (rewriteRequired)
{
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);
}
}

File diff suppressed because it is too large Load Diff

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 string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk"];
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(0, 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.Breaking == 3 && version.Feature >= 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,24 @@
using System;
using System.Collections.Generic;
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;
public bool HideInCombat { get; set; } = false;
public bool HideInDuty { get; set; } = false;
public bool ShowWhenUiHidden { get; set; } = true;
public bool ShowInCutscenes { get; set; } = true;
public bool ShowInGpose { get; set; } = true;
public List<string> ChannelOrder { get; set; } = new();
public Dictionary<string, bool> PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,7 @@
namespace LightlessSync.LightlessConfiguration.Configurations;
public enum LightfinderDtrDisplayMode
{
NearbyBroadcasts = 0,
PendingPairRequests = 1,
}

View File

@@ -1,5 +1,8 @@
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;
@@ -20,6 +23,14 @@ public class LightlessConfig : ILightlessConfiguration
public DtrEntry.Colors DtrColorsDefault { get; set; } = default;
public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu);
public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u);
public bool ShowLightfinderInDtr { get; set; } = false;
public bool UseLightfinderColorsInDtr { get; set; } = true;
public DtrEntry.Colors DtrColorsLightfinderEnabled { get; set; } = new(Foreground: 0xB590FFu, Glow: 0x4F406Eu);
public DtrEntry.Colors DtrColorsLightfinderDisabled { get; set; } = new(Foreground: 0xD44444u, Glow: 0x642222u);
public DtrEntry.Colors DtrColorsLightfinderCooldown { get; set; } = new(Foreground: 0xFFE97Au, Glow: 0x766C3Au);
public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u);
public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests;
public bool UseLightlessRedesign { get; set; } = true;
public bool EnableRightClickMenus { get; set; } = true;
public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both;
public string ExportFolder { get; set; } = string.Empty;
@@ -32,9 +43,13 @@ public class LightlessConfig : ILightlessConfiguration
public bool OpenGposeImportOnGposeStart { get; set; } = false;
public bool OpenPopupOnAdd { get; set; } = true;
public int ParallelDownloads { get; set; } = 10;
public int ParallelUploads { get; set; } = 8;
public bool EnablePairProcessingLimiter { get; set; } = true;
public int MaxConcurrentPairApplications { get; set; } = 3;
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;
@@ -48,11 +63,16 @@ 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;
public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableDirectDownloads { get; set; } = true;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true;
@@ -63,7 +83,74 @@ public class LightlessConfig : ILightlessConfiguration
public bool AutoPopulateEmptyNotesFromCharaName { get; set; } = false;
public int Version { get; set; } = 1;
public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both;
// Lightless Notification Configuration
public bool UseLightlessNotifications { get; set; } = true;
public bool ShowNotificationProgress { get; set; } = true;
public NotificationLocation LightlessInfoNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessWarningNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
// Basic Settings
public float NotificationOpacity { get; set; } = 0.95f;
public int MaxSimultaneousNotifications { get; set; } = 5;
public bool AutoDismissOnAction { get; set; } = true;
public bool DismissNotificationOnClick { get; set; } = false;
public bool ShowNotificationTimestamp { get; set; } = false;
// Position & Layout
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
public int NotificationOffsetY { get; set; } = 50;
public int NotificationOffsetX { get; set; } = 0;
public float NotificationWidth { get; set; } = 350f;
public float NotificationSpacing { get; set; } = 8f;
// Animation & Effects
public float NotificationAnimationSpeed { get; set; } = 10f;
public float NotificationSlideSpeed { get; set; } = 10f;
public float NotificationAccentBarWidth { get; set; } = 3f;
// Duration per Type
public int InfoNotificationDurationSeconds { get; set; } = 10;
public int WarningNotificationDurationSeconds { get; set; } = 15;
public int ErrorNotificationDurationSeconds { get; set; } = 20;
public int PairRequestDurationSeconds { get; set; } = 180;
public int DownloadNotificationDurationSeconds { get; set; } = 30;
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
public uint CustomInfoSoundId { get; set; } = 2; // Se2
public uint CustomWarningSoundId { get; set; } = 16; // Se15
public uint CustomErrorSoundId { get; set; } = 16; // Se15
public uint PairRequestSoundId { get; set; } = 5; // Se5
public uint PerformanceSoundId { get; set; } = 16; // Se15
public bool DisableInfoSound { get; set; } = true;
public bool DisableWarningSound { get; set; } = true;
public bool DisableErrorSound { get; set; } = true;
public bool DisablePairRequestSound { get; set; } = true;
public bool DisablePerformanceSound { get; set; } = true;
public bool ShowPerformanceNotificationActions { get; set; } = true;
public bool ShowPairRequestNotificationActions { get; set; } = true;
public bool UseFocusTarget { get; set; } = false;
public bool overrideFriendColor { get; set; } = false;
public bool overridePartyColor { get; set; } = false;
public bool overrideFcTagColor { get; set; } = false;
public bool useColoredUIDs { get; set; } = true;
public bool BroadcastEnabled { get; set; } = false;
public bool LightfinderAutoEnableOnConnect { get; set; } = false;
public short LightfinderLabelOffsetX { get; set; } = 0;
public short LightfinderLabelOffsetY { get; set; } = 0;
public bool LightfinderLabelUseIcon { get; set; } = false;
public bool LightfinderLabelShowOwn { get; set; } = true;
public bool LightfinderLabelShowPaired { get; set; } = true;
public bool LightfinderLabelShowHidden { get; set; } = false;
public string LightfinderLabelIconGlyph { get; set; } = SeIconCharExtensions.ToIconString(SeIconChar.Hyadelyn);
public float LightfinderLabelScale { get; set; } = 1.0f;
public bool LightfinderAutoAlign { get; set; } = true;
public LabelAlignment LabelAlignment { get; set; } = LabelAlignment.Left;
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty;
}

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

@@ -0,0 +1,9 @@
using LightlessSync.LightlessConfiguration.Models;
namespace LightlessSync.LightlessConfiguration.Configurations;
public class ServerTagConfig : ILightlessConfiguration
{
public Dictionary<string, ServerTagStorage> ServerTagStorage { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

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

@@ -0,0 +1,21 @@
using System;
using System.Numerics;
namespace LightlessSync.LightlessConfiguration.Configurations;
[Serializable]
public class UiStyleOverride
{
public uint? Color { get; set; }
public float? Float { get; set; }
public Vector2Config? Vector2 { get; set; }
public bool IsEmpty => Color is null && Float is null && Vector2 is null;
}
[Serializable]
public record struct Vector2Config(float X, float Y)
{
public static implicit operator Vector2(Vector2Config value) => new(value.X, value.Y);
public static implicit operator Vector2Config(Vector2 value) => new(value.X, value.Y);
}

View File

@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
namespace LightlessSync.LightlessConfiguration.Configurations;
[Serializable]
public class UiThemeConfig : ILightlessConfiguration
{
public Dictionary<string, UiStyleOverride> StyleOverrides { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 1;
}

View File

@@ -1,16 +1,28 @@
namespace LightlessSync.LightlessConfiguration.Models;
namespace LightlessSync.LightlessConfiguration.Models;
public enum NotificationLocation
{
Nowhere,
Chat,
Toast,
Both
Both,
LightlessUi,
ChatAndLightlessUi,
TextOverlay,
}
public enum NotificationType
{
Info,
Warning,
Error
Error,
PairRequest,
Download,
Performance
}
public enum NotificationCorner
{
Right,
Left
}

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

@@ -13,5 +13,4 @@ public class ServerStorage
public bool UseOAuth2 { get; set; } = false;
public string? OAuthToken { get; set; } = null;
public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets;
public bool ForceWebSockets { get; set; } = false;
}

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.LightlessConfiguration.Models;
[Serializable]
public class ServerTagStorage
{
public HashSet<string> OpenPairTags { get; set; } = new(StringComparer.Ordinal);
public HashSet<string> ServerAvailablePairTags { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, List<string>> UidServerPairedUserTags { get; set; } = new(StringComparer.Ordinal);
}

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ using LightlessSync.UI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using System.Reflection;
namespace LightlessSync;
@@ -116,6 +117,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
return Task.CompletedTask;
}
private void CheckVersion()
{
var ver = Assembly.GetExecutingAssembly().GetName().Version;
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
var lastSeen = _lightlessConfigService.Current.LastSeenVersion ?? string.Empty;
Logger.LogInformation("Last seen version: {lastSeen}, current version: {currentVersion}", lastSeen, currentVersion);
Logger.LogInformation("User has valid setup: {hasValidSetup}", _lightlessConfigService.Current.HasValidSetup());
Logger.LogInformation("Server has valid config: {hasValidConfig}", _serverConfigurationManager.HasValidConfig());
// Show update notes if version has changed and user has valid setup
if (!string.Equals(lastSeen, currentVersion, StringComparison.Ordinal) &&
_lightlessConfigService.Current.HasValidSetup() &&
_serverConfigurationManager.HasValidConfig())
{
Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)));
}
}
private void DalamudUtilOnLogIn()
{
Logger?.LogDebug("Client login");
@@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
CheckVersion();
#if !DEBUG
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)

View File

@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>1.11.10</Version>
<Version>1.42.0.72</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,26 @@
</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>
<PropertyGroup>
@@ -64,6 +65,8 @@
<None Update="images\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="Changelog\changelog.yaml" />
<EmbeddedResource Include="Changelog\credits.yaml" />
<EmbeddedResource Include="Localization\de.json" />
<EmbeddedResource Include="Localization\fr.json" />
</ItemGroup>
@@ -74,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,5 +1,7 @@
using LightlessSync.FileCache;
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -7,24 +9,45 @@ namespace LightlessSync.PlayerData.Factories;
public class FileDownloadManagerFactory
{
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
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, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
public FileDownloadManagerFactory(
ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
TextureMetadataHelper textureMetadataHelper)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_textureMetadataHelper = textureMetadataHelper;
}
public FileDownloadManager Create()
{
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
return new FileDownloadManager(
_loggerFactory.CreateLogger<FileDownloadManager>(),
_lightlessMediator,
_fileTransferOrchestrator,
_fileCacheManager,
_fileCompactor,
_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,52 +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 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,
ServerConfigurationManager serverConfigManager)
{
_loggerFactory = loggerFactory;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_dalamudUtilService = dalamudUtilService;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_hostApplicationLifetime = hostApplicationLifetime;
_fileCacheManager = fileCacheManager;
_lightlessMediator = lightlessMediator;
_playerPerformanceService = playerPerformanceService;
_serverConfigManager = serverConfigManager;
}
public PairHandler Create(Pair pair)
{
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _gameObjectHandlerFactory,
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
_fileCacheManager, _lightlessMediator, _playerPerformanceService, _serverConfigManager);
}
}

View File

@@ -98,7 +98,19 @@ public class PlayerDataFactory
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
{
return ((Character*)playerPointer)->GameObject.DrawObject == null;
if (playerPointer == IntPtr.Zero)
return true;
var character = (Character*)playerPointer;
if (character == null)
return true;
var gameObject = &character->GameObject;
if (gameObject == null)
return true;
return gameObject->DrawObject == null;
}
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)

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,766 +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 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,
ServerConfigurationManager serverConfigManager) : base(logger, mediator)
{
Pair = pair;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil;
_lifetime = lifetime;
_fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService;
_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)
{
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 (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);
}
}

View File

@@ -1,408 +1,577 @@
using Dalamud.Plugin.Services;
using System.Diagnostics.CodeAnalysis;
using LightlessSync.API.Data;
using LightlessSync.API.Data.Comparer;
using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Data.Enum;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace LightlessSync.PlayerData.Pairs;
public sealed class PairManager : DisposableMediatorSubscriberBase
/// <summary>
/// in memory state for pairs, groups, and syncshells
/// </summary>
public sealed class PairManager
{
private readonly ConcurrentDictionary<UserData, Pair> _allClientPairs = new(UserDataComparer.Instance);
private readonly ConcurrentDictionary<GroupData, GroupFullInfoDto> _allGroups = new(GroupDataComparer.Instance);
private readonly LightlessConfigService _configurationService;
private readonly IContextMenu _dalamudContextMenu;
private readonly PairFactory _pairFactory;
private Lazy<List<Pair>> _directPairsInternal;
private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> _groupPairsInternal;
private Lazy<Dictionary<Pair, List<GroupFullInfoDto>>> _pairsWithGroupsInternal;
private readonly object _gate = new();
private readonly Dictionary<string, PairConnection> _pairs = new(StringComparer.Ordinal);
private readonly Dictionary<string, Syncshell> _groups = new(StringComparer.Ordinal);
public PairManager(ILogger<PairManager> logger, PairFactory pairFactory,
LightlessConfigService configurationService, LightlessMediator mediator,
IContextMenu dalamudContextMenu) : base(logger, mediator)
public PairConnection? LastAddedUser { get; private set; }
public IReadOnlyDictionary<string, PairConnection> GetAllPairs()
{
_pairFactory = pairFactory;
_configurationService = configurationService;
_dalamudContextMenu = dalamudContextMenu;
Mediator.Subscribe<DisconnectedMessage>(this, (_) => ClearPairs());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => ReapplyPairData());
_directPairsInternal = DirectPairsLazy();
_groupPairsInternal = GroupPairsLazy();
_pairsWithGroupsInternal = PairsWithGroupsLazy();
_dalamudContextMenu.OnMenuOpened += DalamudContextMenuOnOnOpenGameObjectContextMenu;
}
public List<Pair> DirectPairs => _directPairsInternal.Value;
public Dictionary<GroupFullInfoDto, List<Pair>> GroupPairs => _groupPairsInternal.Value;
public Dictionary<GroupData, GroupFullInfoDto> Groups => _allGroups.ToDictionary(k => k.Key, k => k.Value);
public Pair? LastAddedUser { get; internal set; }
public Dictionary<Pair, List<GroupFullInfoDto>> PairsWithGroups => _pairsWithGroupsInternal.Value;
public void AddGroup(GroupFullInfoDto dto)
{
_allGroups[dto.Group] = dto;
RecreateLazy();
}
public void AddGroupPair(GroupPairFullInfoDto dto)
{
if (!_allClientPairs.ContainsKey(dto.User))
_allClientPairs[dto.User] = _pairFactory.Create(new UserFullPairDto(dto.User, API.Data.Enum.IndividualPairStatus.None,
[dto.Group.GID], dto.SelfToOtherPermissions, dto.OtherToSelfPermissions));
else _allClientPairs[dto.User].UserPair.Groups.Add(dto.GID);
RecreateLazy();
}
public Pair? GetPairByUID(string uid)
{
var existingPair = _allClientPairs.FirstOrDefault(f => f.Key.UID == uid);
if (!Equals(existingPair, default(KeyValuePair<UserData, Pair>)))
lock (_gate)
{
return existingPair.Value;
return new Dictionary<string, PairConnection>(_pairs);
}
return null;
}
public void AddUserPair(UserFullPairDto dto)
public IReadOnlyDictionary<string, Syncshell> GetAllGroups()
{
if (!_allClientPairs.ContainsKey(dto.User))
lock (_gate)
{
_allClientPairs[dto.User] = _pairFactory.Create(dto);
return new Dictionary<string, Syncshell>(_groups);
}
else
{
_allClientPairs[dto.User].UserPair.IndividualPairStatus = dto.IndividualPairStatus;
_allClientPairs[dto.User].ApplyLastReceivedData();
}
RecreateLazy();
}
public void AddUserPair(UserPairDto dto, bool addToLastAddedUser = true)
public PairConnection? GetLastAddedUser()
{
if (!_allClientPairs.ContainsKey(dto.User))
lock (_gate)
{
_allClientPairs[dto.User] = _pairFactory.Create(dto);
return LastAddedUser;
}
else
{
addToLastAddedUser = false;
}
_allClientPairs[dto.User].UserPair.IndividualPairStatus = dto.IndividualPairStatus;
_allClientPairs[dto.User].UserPair.OwnPermissions = dto.OwnPermissions;
_allClientPairs[dto.User].UserPair.OtherPermissions = dto.OtherPermissions;
if (addToLastAddedUser)
LastAddedUser = _allClientPairs[dto.User];
_allClientPairs[dto.User].ApplyLastReceivedData();
RecreateLazy();
}
public void ClearPairs()
public void ClearLastAddedUser()
{
Logger.LogDebug("Clearing all Pairs");
DisposePairs();
_allClientPairs.Clear();
_allGroups.Clear();
RecreateLazy();
}
public List<Pair> GetOnlineUserPairs() => _allClientPairs.Where(p => !string.IsNullOrEmpty(p.Value.GetPlayerNameHash())).Select(p => p.Value).ToList();
public int GetVisibleUserCount() => _allClientPairs.Count(p => p.Value.IsVisible);
public List<UserData> GetVisibleUsers() => [.. _allClientPairs.Where(p => p.Value.IsVisible).Select(p => p.Key)];
public void MarkPairOffline(UserData user)
{
if (_allClientPairs.TryGetValue(user, out var pair))
lock (_gate)
{
Mediator.Publish(new ClearProfileDataMessage(pair.UserData));
pair.MarkOffline();
LastAddedUser = null;
}
RecreateLazy();
}
public void MarkPairOnline(OnlineUserIdentDto dto, bool sendNotif = true)
public void ClearAll()
{
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto);
Mediator.Publish(new ClearProfileDataMessage(dto.User));
var pair = _allClientPairs[dto.User];
if (pair.HasCachedPlayer)
lock (_gate)
{
RecreateLazy();
return;
_pairs.Clear();
_groups.Clear();
LastAddedUser = null;
}
if (sendNotif && _configurationService.Current.ShowOnlineNotifications
&& (_configurationService.Current.ShowOnlineNotificationsOnlyForIndividualPairs && pair.IsDirectlyPaired && !pair.IsOneSidedPair
|| !_configurationService.Current.ShowOnlineNotificationsOnlyForIndividualPairs)
&& (_configurationService.Current.ShowOnlineNotificationsOnlyForNamedPairs && !string.IsNullOrEmpty(pair.GetNote())
|| !_configurationService.Current.ShowOnlineNotificationsOnlyForNamedPairs))
{
string? note = pair.GetNote();
var msg = !string.IsNullOrEmpty(note)
? $"{note} ({pair.UserData.AliasOrUID}) is now online"
: $"{pair.UserData.AliasOrUID} is now online";
Mediator.Publish(new NotificationMessage("User online", msg, NotificationType.Info, TimeSpan.FromSeconds(5)));
}
pair.CreateCachedPlayer(dto);
RecreateLazy();
}
public void ReceiveCharaData(OnlineUserCharaDataDto dto)
public PairOperationResult<PairConnection> GetPair(string userId)
{
if (!_allClientPairs.TryGetValue(dto.User, out var pair)) throw new InvalidOperationException("No user found for " + dto.User);
Mediator.Publish(new EventMessage(new Event(pair.UserData, nameof(PairManager), EventSeverity.Informational, "Received Character Data")));
_allClientPairs[dto.User].ApplyData(dto);
}
public void RemoveGroup(GroupData data)
{
_allGroups.TryRemove(data, out _);
foreach (var item in _allClientPairs.ToList())
lock (_gate)
{
item.Value.UserPair.Groups.Remove(data.GID);
if (!item.Value.HasAnyConnection())
if (_pairs.TryGetValue(userId, out var connection))
{
item.Value.MarkOffline();
_allClientPairs.TryRemove(item.Key, out _);
}
}
RecreateLazy();
}
public void RemoveGroupPair(GroupPairDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var pair))
{
pair.UserPair.Groups.Remove(dto.Group.GID);
if (!pair.HasAnyConnection())
{
pair.MarkOffline();
_allClientPairs.TryRemove(dto.User, out _);
}
}
RecreateLazy();
}
public void RemoveUserPair(UserDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var pair))
{
pair.UserPair.IndividualPairStatus = API.Data.Enum.IndividualPairStatus.None;
if (!pair.HasAnyConnection())
{
pair.MarkOffline();
_allClientPairs.TryRemove(dto.User, out _);
}
}
RecreateLazy();
}
public void SetGroupInfo(GroupInfoDto dto)
{
_allGroups[dto.Group].Group = dto.Group;
_allGroups[dto.Group].Owner = dto.Owner;
_allGroups[dto.Group].GroupPermissions = dto.GroupPermissions;
RecreateLazy();
}
public void UpdatePairPermissions(UserPermissionsDto dto)
{
if (!_allClientPairs.TryGetValue(dto.User, out var pair))
{
throw new InvalidOperationException("No such pair for " + dto);
}
if (pair.UserPair == null) throw new InvalidOperationException("No direct pair for " + dto);
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused())
{
Mediator.Publish(new ClearProfileDataMessage(dto.User));
}
pair.UserPair.OtherPermissions = dto.Permissions;
Logger.LogTrace("Paused: {paused}, Anims: {anims}, Sounds: {sounds}, VFX: {vfx}",
pair.UserPair.OtherPermissions.IsPaused(),
pair.UserPair.OtherPermissions.IsDisableAnimations(),
pair.UserPair.OtherPermissions.IsDisableSounds(),
pair.UserPair.OtherPermissions.IsDisableVFX());
if (!pair.IsPaused)
pair.ApplyLastReceivedData();
RecreateLazy();
}
public void UpdateSelfPairPermissions(UserPermissionsDto dto)
{
if (!_allClientPairs.TryGetValue(dto.User, out var pair))
{
throw new InvalidOperationException("No such pair for " + dto);
}
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused())
{
Mediator.Publish(new ClearProfileDataMessage(dto.User));
}
pair.UserPair.OwnPermissions = dto.Permissions;
Logger.LogTrace("Paused: {paused}, Anims: {anims}, Sounds: {sounds}, VFX: {vfx}",
pair.UserPair.OwnPermissions.IsPaused(),
pair.UserPair.OwnPermissions.IsDisableAnimations(),
pair.UserPair.OwnPermissions.IsDisableSounds(),
pair.UserPair.OwnPermissions.IsDisableVFX());
if (!pair.IsPaused)
pair.ApplyLastReceivedData();
RecreateLazy();
}
internal void ReceiveUploadStatus(UserDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var existingPair) && existingPair.IsVisible)
{
existingPair.SetIsUploading();
}
}
internal void SetGroupPairStatusInfo(GroupPairUserInfoDto dto)
{
_allGroups[dto.Group].GroupPairUserInfos[dto.UID] = dto.GroupUserInfo;
RecreateLazy();
}
internal void SetGroupPermissions(GroupPermissionDto dto)
{
_allGroups[dto.Group].GroupPermissions = dto.Permissions;
RecreateLazy();
}
internal void SetGroupStatusInfo(GroupPairUserInfoDto dto)
{
_allGroups[dto.Group].GroupUserInfo = dto.GroupUserInfo;
RecreateLazy();
}
internal void UpdateGroupPairPermissions(GroupPairUserPermissionDto dto)
{
_allGroups[dto.Group].GroupUserPermissions = dto.GroupPairPermissions;
RecreateLazy();
}
internal void UpdateIndividualPairStatus(UserIndividualPairStatusDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var pair))
{
pair.UserPair.IndividualPairStatus = dto.IndividualPairStatus;
RecreateLazy();
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_dalamudContextMenu.OnMenuOpened -= DalamudContextMenuOnOnOpenGameObjectContextMenu;
DisposePairs();
}
private void DalamudContextMenuOnOnOpenGameObjectContextMenu(Dalamud.Game.Gui.ContextMenu.IMenuOpenedArgs args)
{
if (args.MenuType == Dalamud.Game.Gui.ContextMenu.ContextMenuType.Inventory) return;
if (!_configurationService.Current.EnableRightClickMenus) return;
foreach (var pair in _allClientPairs.Where((p => p.Value.IsVisible)))
{
pair.Value.AddContextMenu(args);
}
}
private Lazy<List<Pair>> DirectPairsLazy() => new(() => _allClientPairs.Select(k => k.Value)
.Where(k => k.IndividualPairStatus != API.Data.Enum.IndividualPairStatus.None).ToList());
private void DisposePairs()
{
Logger.LogDebug("Disposing all Pairs");
Parallel.ForEach(_allClientPairs, item =>
{
item.Value.MarkOffline(wait: false);
});
RecreateLazy();
}
private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> GroupPairsLazy()
{
return new Lazy<Dictionary<GroupFullInfoDto, List<Pair>>>(() =>
{
Dictionary<GroupFullInfoDto, List<Pair>> outDict = [];
foreach (var group in _allGroups)
{
outDict[group.Value] = _allClientPairs.Select(p => p.Value).Where(p => p.UserPair.Groups.Exists(g => GroupDataComparer.Instance.Equals(group.Key, new(g)))).ToList();
}
return outDict;
});
}
private Lazy<Dictionary<Pair, List<GroupFullInfoDto>>> PairsWithGroupsLazy()
{
return new Lazy<Dictionary<Pair, List<GroupFullInfoDto>>>(() =>
{
Dictionary<Pair, List<GroupFullInfoDto>> outDict = [];
foreach (var pair in _allClientPairs.Select(k => k.Value))
{
outDict[pair] = _allGroups.Where(k => pair.UserPair.Groups.Contains(k.Key.GID, StringComparer.Ordinal)).Select(k => k.Value).ToList();
return PairOperationResult<PairConnection>.Ok(connection);
}
return outDict;
});
}
private void ReapplyPairData()
{
foreach (var pair in _allClientPairs.Select(k => k.Value))
{
pair.ApplyLastReceivedData(forced: true);
return PairOperationResult<PairConnection>.Fail($"Pair {userId} not found.");
}
}
private void RecreateLazy()
public bool TryGetPair(string userId, [NotNullWhen(true)] out PairConnection? connection)
{
_directPairsInternal = DirectPairsLazy();
_groupPairsInternal = GroupPairsLazy();
_pairsWithGroupsInternal = PairsWithGroupsLazy();
Mediator.Publish(new RefreshUiMessage());
lock (_gate)
{
return _pairs.TryGetValue(userId, out connection);
}
}
public PairOperationResult<Syncshell> GetGroup(string groupId)
{
lock (_gate)
{
if (_groups.TryGetValue(groupId, out var shell))
{
return PairOperationResult<Syncshell>.Ok(shell);
}
return PairOperationResult<Syncshell>.Fail($"Group {groupId} not found.");
}
}
public IReadOnlyList<PairConnection> GetDirectPairs()
{
lock (_gate)
{
return _pairs.Values.Where(p => p.IsDirectlyPaired).ToList();
}
}
public IReadOnlyList<PairConnection> GetPairsByIdent(string ident)
{
lock (_gate)
{
return _pairs.Values
.Where(p => p.Ident is not null && string.Equals(p.Ident, ident, StringComparison.Ordinal))
.ToList();
}
}
public IReadOnlyList<Syncshell> GetOwnedOrModeratedShells(string currentUserUid)
{
lock (_gate)
{
return _groups.Values
.Where(s =>
string.Equals(s.GroupFullInfo.Owner.UID, currentUserUid, StringComparison.OrdinalIgnoreCase)
|| s.GroupFullInfo.GroupUserInfo.HasFlag(GroupPairUserInfo.IsModerator))
.ToList();
}
}
public PairOperationResult<UserPermissions> GetPairCombinedPermissions(string userId)
{
lock (_gate)
{
if (!_pairs.TryGetValue(userId, out var connection))
{
return PairOperationResult<UserPermissions>.Fail($"Pair {userId} not found.");
}
var combined = connection.SelfToOtherPermissions | connection.OtherToSelfPermissions;
return PairOperationResult<UserPermissions>.Ok(combined);
}
}
public PairOperationResult<PairRegistration> MarkOnline(OnlineUserIdentDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
connection = GetOrCreatePair(dto.User);
}
connection.SetOnline(dto.Ident);
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), dto.Ident));
}
}
public PairOperationResult<PairRegistration> MarkOffline(UserData user)
{
lock (_gate)
{
if (!_pairs.TryGetValue(user.UID, out var connection))
{
return PairOperationResult<PairRegistration>.Fail($"Pair {user.UID} not found.");
}
connection.SetOffline();
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(user.UID), connection.Ident));
}
}
public PairOperationResult<PairRegistration> AddOrUpdateIndividual(UserPairDto dto, bool markAsLastAddedUser = true)
{
lock (_gate)
{
var connection = GetOrCreatePair(dto.User, out var created);
connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions);
connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus);
if (connection.Ident is null)
{
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), null));
}
if (created && markAsLastAddedUser)
{
LastAddedUser = connection;
}
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident));
}
}
public PairOperationResult<PairRegistration> AddOrUpdateIndividual(UserFullPairDto dto)
{
lock (_gate)
{
var connection = GetOrCreatePair(dto.User, out _);
connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions);
connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus);
var removedGroups = connection.Groups.Keys.Where(k => !dto.Groups.Contains(k, StringComparer.Ordinal)).ToList();
foreach (var groupId in removedGroups)
{
connection.RemoveGroupRelationship(groupId);
if (_groups.TryGetValue(groupId, out var shell))
{
shell.Users.Remove(dto.User.UID);
}
}
foreach (var groupId in dto.Groups)
{
connection.EnsureGroupRelationship(groupId, null);
if (_groups.TryGetValue(groupId, out var shell))
{
shell.Users[dto.User.UID] = connection;
}
}
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident));
}
}
public PairOperationResult<PairRegistration?> RemoveIndividual(UserDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
return PairOperationResult<PairRegistration?>.Fail($"Pair {dto.User.UID} not found.");
}
connection.UpdateStatus(null);
var registration = TryRemovePairIfNoConnection(connection);
return PairOperationResult<PairRegistration?>.Ok(registration);
}
}
public PairOperationResult<PairRegistration> SetPairOtherToSelfPermissions(UserPermissionsDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
return PairOperationResult<PairRegistration>.Fail($"Pair {dto.User.UID} not found.");
}
connection.UpdatePermissions(connection.SelfToOtherPermissions, dto.Permissions);
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident));
}
}
public PairOperationResult<PairRegistration> SetPairSelfToOtherPermissions(UserPermissionsDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
return PairOperationResult<PairRegistration>.Fail($"Pair {dto.User.UID} not found.");
}
connection.UpdatePermissions(dto.Permissions, connection.OtherToSelfPermissions);
return PairOperationResult<PairRegistration>.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident));
}
}
public PairOperationResult SetIndividualStatus(UserIndividualPairStatusDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
return PairOperationResult.Fail($"Pair {dto.User.UID} not found.");
}
connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus);
_ = TryRemovePairIfNoConnection(connection);
return PairOperationResult.Ok();
}
}
public PairOperationResult AddOrUpdateGroupPair(GroupPairFullInfoDto dto)
{
lock (_gate)
{
var shell = GetOrCreateShell(dto.Group);
var connection = GetOrCreatePair(dto.User);
var groupInfo = shell.GroupFullInfo.GroupPairUserInfos.GetValueOrDefault(dto.User.UID, GroupPairUserInfo.None);
connection.EnsureGroupRelationship(dto.Group.GID, groupInfo == GroupPairUserInfo.None ? null : groupInfo);
connection.UpdatePermissions(dto.SelfToOtherPermissions, dto.OtherToSelfPermissions);
shell.Users[dto.User.UID] = connection;
return PairOperationResult.Ok();
}
}
public PairOperationResult<PairRegistration?> RemoveGroupPair(GroupPairDto dto)
{
lock (_gate)
{
if (_groups.TryGetValue(dto.GID, out var shell))
{
shell.Users.Remove(dto.User.UID);
}
PairRegistration? registration = null;
if (_pairs.TryGetValue(dto.User.UID, out var connection))
{
connection.RemoveGroupRelationship(dto.GID);
registration = TryRemovePairIfNoConnection(connection);
}
return PairOperationResult<PairRegistration?>.Ok(registration);
}
}
public PairOperationResult<IReadOnlyList<PairRegistration>> RemoveGroup(string groupId)
{
lock (_gate)
{
if (!_groups.Remove(groupId, out var shell))
{
return PairOperationResult<IReadOnlyList<PairRegistration>>.Fail($"Group {groupId} not found.");
}
var removed = new List<PairRegistration>();
foreach (var connection in shell.Users.Values.ToList())
{
connection.RemoveGroupRelationship(groupId);
var registration = TryRemovePairIfNoConnection(connection);
if (registration is not null)
{
removed.Add(registration);
}
}
return PairOperationResult<IReadOnlyList<PairRegistration>>.Ok(removed);
}
}
public PairOperationResult AddGroup(GroupFullInfoDto dto)
{
lock (_gate)
{
if (!_groups.TryGetValue(dto.Group.GID, out var shell))
{
shell = new Syncshell(dto);
_groups[dto.Group.GID] = shell;
}
else
{
shell.Update(dto);
shell.Users.Clear();
}
foreach (var (userId, info) in dto.GroupPairUserInfos)
{
if (_pairs.TryGetValue(userId, out var connection))
{
connection.EnsureGroupRelationship(dto.Group.GID, info == GroupPairUserInfo.None ? null : info);
shell.Users[userId] = connection;
}
}
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateGroupInfo(GroupInfoDto dto)
{
lock (_gate)
{
if (!_groups.TryGetValue(dto.Group.GID, out var shell))
{
return PairOperationResult.Fail($"Group {dto.Group.GID} not found.");
}
var updated = new GroupFullInfoDto(
dto.Group,
dto.Owner,
dto.GroupPermissions,
shell.GroupFullInfo.GroupUserPermissions,
shell.GroupFullInfo.GroupUserInfo,
new Dictionary<string, GroupPairUserInfo>(shell.GroupFullInfo.GroupPairUserInfos, StringComparer.Ordinal),
0);
shell.Update(updated);
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateGroupPairPermissions(GroupPairUserPermissionDto dto)
{
lock (_gate)
{
if (!_groups.TryGetValue(dto.Group.GID, out var shell))
{
return PairOperationResult.Fail($"Group {dto.Group.GID} not found.");
}
var updated = shell.GroupFullInfo with { GroupUserPermissions = dto.GroupPairPermissions };
shell.Update(updated);
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateGroupPermissions(GroupPermissionDto dto)
{
lock (_gate)
{
if (!_groups.TryGetValue(dto.Group.GID, out var shell))
{
return PairOperationResult.Fail($"Group {dto.Group.GID} not found.");
}
var updated = shell.GroupFullInfo with { GroupPermissions = dto.Permissions };
shell.Update(updated);
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateGroupPairStatus(GroupPairUserInfoDto dto)
{
lock (_gate)
{
if (_pairs.TryGetValue(dto.UID, out var connection))
{
connection.EnsureGroupRelationship(dto.GID, dto.GroupUserInfo == GroupPairUserInfo.None ? null : dto.GroupUserInfo);
}
if (_groups.TryGetValue(dto.GID, out var shell))
{
var infos = new Dictionary<string, GroupPairUserInfo>(shell.GroupFullInfo.GroupPairUserInfos, StringComparer.Ordinal)
{
[dto.UID] = dto.GroupUserInfo
};
var updated = shell.GroupFullInfo with { GroupPairUserInfos = infos };
shell.Update(updated);
}
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateGroupStatus(GroupPairUserInfoDto dto)
{
lock (_gate)
{
if (!_groups.TryGetValue(dto.GID, out var shell))
{
return PairOperationResult.Fail($"Group {dto.GID} not found.");
}
var updated = shell.GroupFullInfo with { GroupUserInfo = dto.GroupUserInfo };
shell.Update(updated);
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateOtherPermissions(UserPermissionsDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
return PairOperationResult.Fail($"Pair {dto.User.UID} not found.");
}
connection.UpdatePermissions(connection.SelfToOtherPermissions, dto.Permissions);
return PairOperationResult.Ok();
}
}
public PairOperationResult UpdateSelfPermissions(UserPermissionsDto dto)
{
lock (_gate)
{
if (!_pairs.TryGetValue(dto.User.UID, out var connection))
{
return PairOperationResult.Fail($"Pair {dto.User.UID} not found.");
}
connection.UpdatePermissions(dto.Permissions, connection.OtherToSelfPermissions);
return PairOperationResult.Ok();
}
}
private PairConnection GetOrCreatePair(UserData user)
{
return GetOrCreatePair(user, out _);
}
private PairConnection GetOrCreatePair(UserData user, out bool created)
{
if (_pairs.TryGetValue(user.UID, out var connection))
{
created = false;
return connection;
}
connection = new PairConnection(user);
_pairs[user.UID] = connection;
created = true;
return connection;
}
private Syncshell GetOrCreateShell(GroupData group)
{
if (_groups.TryGetValue(group.GID, out var shell))
{
return shell;
}
var placeholder = new GroupFullInfoDto(
group,
new UserData(string.Empty),
GroupPermissions.NoneSet,
GroupUserPreferredPermissions.NoneSet,
GroupPairUserInfo.None,
new Dictionary<string, GroupPairUserInfo>(StringComparer.Ordinal),
0);
shell = new Syncshell(placeholder);
_groups[group.GID] = shell;
return shell;
}
private PairRegistration? TryRemovePairIfNoConnection(PairConnection connection)
{
if (connection.HasAnyConnection)
{
return null;
}
if (connection.IsOnline)
{
connection.SetOffline();
}
var userId = connection.User.UID;
_pairs.Remove(userId);
foreach (var shell in _groups.Values)
{
shell.Users.Remove(userId);
}
return new PairRegistration(new PairUniqueIdentifier(userId), connection.Ident);
}
public static PairConnection CreateFromFullData(UserFullPairDto dto)
{
var connection = new PairConnection(dto.User);
connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions);
connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus);
foreach (var groupId in dto.Groups)
{
connection.EnsureGroupRelationship(groupId, null);
}
return connection;
}
public static PairConnection CreateFromPartialData(UserPairDto dto)
{
var connection = new PairConnection(dto.User);
connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions);
connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus);
return connection;
}
public static GroupPairRelationship CreateGroupPairRelationshipFromFullInfo(string userUid, GroupFullInfoDto fullInfo)
{
return new GroupPairRelationship(fullInfo.Group.GID,
fullInfo.GroupPairUserInfos.TryGetValue(userUid, out var info) && info != GroupPairUserInfo.None
? info
: null);
}
}

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;
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,35 +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
{
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();
}
finally
{
_pushLock.Release();
}
}
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

@@ -1,5 +1,6 @@
using Dalamud.Game;
using Dalamud.Game;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Interface;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin;
@@ -13,14 +14,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 +37,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;
@@ -41,8 +51,9 @@ public sealed class Plugin : IDalamudPlugin
IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui,
IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager,
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
ISigScanner sigScanner, INamePlateGui namePlateGui)
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,171 +96,459 @@ 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);
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
// 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));
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<PairFactory>();
collection.AddSingleton<XivDataAnalyzer>();
collection.AddSingleton<CharacterAnalyzer>();
collection.AddSingleton<TokenProvider>();
collection.AddSingleton<PluginWarningNotificationService>();
collection.AddSingleton<FileCompactor>();
collection.AddSingleton<TagHandler>();
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<ApiController>(), s.GetRequiredService<ServerConfigurationManager>()));
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu));
collection.AddSingleton<RedrawManager>();
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<LightlessMediator>(), s.GetRequiredService<DalamudUtilService>(),
notificationManager, chatGui, s.GetRequiredService<LightlessConfigService>()));
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 LightlessConfigService(pluginInterface.ConfigDirectory.FullName));
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<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>();
services.AddSingleton<ConfigurationMigrator>();
services.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton<HubFactory>();
// 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>();
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<IPopupHandler, BanUserPopupHandler>();
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>()));
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>());
})
.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,12 +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.Immutable;
namespace LightlessSync.Services;
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
@@ -16,6 +18,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
private CancellationTokenSource? _analysisCts;
private CancellationTokenSource _baseAnalysisCts = new();
private string _lastDataHash = string.Empty;
private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty;
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
: base(logger, mediator)
@@ -34,71 +37,98 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
public bool IsAnalysisRunning => _analysisCts != null;
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);
@@ -106,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?";
@@ -120,28 +149,49 @@ 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>();
foreach (var (objectKind, entries) in LastAnalysis)
{
long totalTriangles = 0;
long texOriginalBytes = 0;
long texCompressedBytes = 0;
foreach (var entry in entries.Values)
{
totalTriangles += entry.Triangles;
if (string.Equals(entry.FileType, "tex", StringComparison.OrdinalIgnoreCase))
{
texOriginalBytes += entry.OriginalSize;
texCompressedBytes += entry.CompressedSize;
}
}
builder[objectKind] = new CharacterAnalysisObjectSummary(entries.Count, totalTriangles, texOriginalBytes, texCompressedBytes);
}
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
}
private void PrintAnalysis()
{
if (LastAnalysis.Count == 0) return;
@@ -150,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);
@@ -179,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),
@@ -187,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;
@@ -195,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;
@@ -203,33 +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";
}
});
}
}

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

@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
public sealed class CommandManagerService : IDisposable
{
private const string _commandName = "/light";
private const string _longName = "/lightless";
private const string _shortName = "/light";
private readonly ApiController _apiController;
private readonly ICommandManager _commandManager;
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
_apiController = apiController;
_mediator = mediator;
_lightlessConfigService = lightlessConfigService;
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
_commandManager.AddHandler(_longName, new CommandInfo(OnCommand)
{
HelpMessage = $"\u2191;"
});
_commandManager.AddHandler(_shortName, new CommandInfo(OnCommand)
{
HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
"Additionally possible commands:" + Environment.NewLine +
@@ -42,13 +47,16 @@ public sealed class CommandManagerService : IDisposable
"\t /light toggle on|off - Connects or disconnects to Lightless respectively" + Environment.NewLine +
"\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine +
"\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
"\t /light settings - Opens the Lightless Settings window"
"\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
"\t /light finder - Opens the Lightfinder window" + Environment.NewLine +
"\t /light finder - Opens the Lightless Chat window"
});
}
public void Dispose()
{
_commandManager.RemoveHandler(_commandName);
_commandManager.RemoveHandler(_longName);
_commandManager.RemoveHandler(_shortName);
}
private void OnCommand(string command, string args)
@@ -122,5 +130,13 @@ public sealed class CommandManagerService : IDisposable
{
_mediator.Publish(new UiToggleMessage(typeof(SettingsUi)));
}
else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase))
{
_mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
}
else if (string.Equals(splitArgs[0], "chat", StringComparison.OrdinalIgnoreCase))
{
_mediator.Publish(new UiToggleMessage(typeof(ZoneChatUi)));
}
}
}

View File

@@ -0,0 +1,224 @@
using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;
using System.Threading.Channels;
namespace LightlessSync.Services.Compactor
{
/// <summary>
/// This batch service is made for the File Frag command, because of each file needing to use this command.
/// It's better to combine into one big command in batches then doing each command on each compressed call.
/// </summary>
public sealed partial class BatchFilefragService : IDisposable
{
private readonly Channel<(string path, TaskCompletionSource<bool> tcs)> _ch;
private readonly Task _worker;
private readonly bool _useShell;
private readonly ILogger _log;
private readonly int _batchSize;
private readonly TimeSpan _flushDelay;
private readonly CancellationTokenSource _cts = new();
public delegate (bool ok, string stdout, string stderr, int exitCode) RunDirect(string fileName, IEnumerable<string> args, string? workingDir, int timeoutMs);
private readonly RunDirect _runDirect;
public delegate (bool ok, string stdout, string stderr, int exitCode) RunShell(string command, string? workingDir, int timeoutMs);
private readonly RunShell _runShell;
public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25, RunDirect? runDirect = null, RunShell? runShell = null)
{
_useShell = useShell;
_log = log;
_batchSize = Math.Max(8, batchSize);
_flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs));
_ch = Channel.CreateUnbounded<(string, TaskCompletionSource<bool>)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
// require runners to be setup, wouldnt start otherwise
if (runDirect is null || runShell is null)
throw new ArgumentNullException(nameof(runDirect), "Provide process runners from FileCompactor");
_runDirect = runDirect;
_runShell = runShell;
_worker = Task.Run(ProcessAsync, _cts.Token);
}
/// <summary>
/// Checks if the file is compressed using Btrfs using tasks
/// </summary>
/// <param name="linuxPath">Linux/Wine path given for the file.</param>
/// <param name="ct">Cancellation Token</param>
/// <returns>If it was compressed or not</returns>
public Task<bool> IsCompressedAsync(string linuxPath, CancellationToken ct = default)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_ch.Writer.TryWrite((linuxPath, tcs)))
{
tcs.TrySetResult(false);
return tcs.Task;
}
if (ct.CanBeCanceled)
{
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
_ = tcs.Task.ContinueWith(_ => reg.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
return tcs.Task;
}
/// <summary>
/// Process the pending compression tasks asynchronously
/// </summary>
/// <returns>Task</returns>
private async Task ProcessAsync()
{
var reader = _ch.Reader;
var pending = new List<(string path, TaskCompletionSource<bool> tcs)>(_batchSize);
try
{
while (await reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
{
if (!reader.TryRead(out var first)) continue;
pending.Add(first);
var flushAt = DateTime.UtcNow + _flushDelay;
while (pending.Count < _batchSize && DateTime.UtcNow < flushAt)
{
if (reader.TryRead(out var item))
{
pending.Add(item);
continue;
}
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
}
catch
{
break;
}
}
try
{
var map = RunBatch(pending.Select(p => p.path));
foreach (var (path, tcs) in pending)
{
tcs.TrySetResult(map.TryGetValue(path, out var c) && c);
}
}
catch (Exception ex)
{
_log.LogDebug(ex, "filefrag batch failed. falling back to false");
foreach (var (_, tcs) in pending)
{
tcs.TrySetResult(false);
}
}
finally
{
pending.Clear();
}
}
}
catch (OperationCanceledException)
{
//Shutting down worker, exception called
}
}
/// <summary>
/// Running the batch of each file in the queue in one file frag command.
/// </summary>
/// <param name="paths">Paths that are needed for the command building for the batch return</param>
/// <returns>Path of the file and if it went correctly</returns>
/// <exception cref="InvalidOperationException">Failing to start filefrag on the system if this exception is found</exception>
private Dictionary<string, bool> RunBatch(IEnumerable<string> paths)
{
var list = paths.Distinct(StringComparer.Ordinal).ToList();
var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal);
(bool ok, string stdout, string stderr, int code) res;
if (_useShell)
{
var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle));
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
}
else
{
var args = new List<string> { "-v", "--" };
args.AddRange(list);
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
}
if (!string.IsNullOrWhiteSpace(res.stderr))
_log.LogTrace("filefrag stderr (batch): {err}", res.stderr.Trim());
ParseFilefrag(res.stdout, result);
return result;
}
/// <summary>
/// Parsing the string given from the File Frag command into mapping
/// </summary>
/// <param name="output">Output of the process from the File Frag</param>
/// <param name="map">Mapping of the processed files</param>
private static void ParseFilefrag(string output, Dictionary<string, bool> map)
{
var reHeaderColon = ColonRegex();
var reHeaderSize = SizeRegex();
string? current = null;
using var sr = new StringReader(output);
for (string? line = sr.ReadLine(); line != null; line = sr.ReadLine())
{
var m1 = reHeaderColon.Match(line);
if (m1.Success) { current = m1.Groups[1].Value; continue; }
var m2 = reHeaderSize.Match(line);
if (m2.Success) { current = m2.Groups[1].Value; continue; }
if (current is not null && line.Contains("flags:", StringComparison.OrdinalIgnoreCase) &&
line.Contains("compressed", StringComparison.OrdinalIgnoreCase) && map.ContainsKey(current))
{
map[current] = true;
}
}
}
private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
/// <summary>
/// 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)]
private static partial Regex SizeRegex();
/// <summary>
/// Regex on colons return on the Linux/Wine systems
/// </summary>
/// <returns>Regex of the colons in the given path</returns>
[GeneratedRegex(@"^(/.+?):\s", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
private static partial Regex ColonRegex();
public void Dispose()
{
_ch.Writer.TryComplete();
_cts.Cancel();
try
{
_worker.Wait(TimeSpan.FromSeconds(2), _cts.Token);
}
catch
{
// Ignore the catch in dispose
}
_cts.Dispose();
}
}
}

View File

@@ -0,0 +1,322 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration;
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;
internal class ContextMenuService : IHostedService
{
private readonly IContextMenu _contextMenu;
private readonly IDalamudPluginInterface _pluginInterface;
private readonly IDataManager _gameData;
private readonly ILogger<ContextMenuService> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly IClientState _clientState;
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,
IDalamudPluginInterface pluginInterface,
IDataManager gameData,
ILogger<ContextMenuService> logger,
DalamudUtilService dalamudUtil,
ApiController apiController,
IObjectTable objectTable,
LightlessConfigService configService,
PairRequestService pairRequestService,
PairUiService pairUiService,
IClientState clientState,
LightFinderScannerService broadcastScannerService,
LightFinderService broadcastService,
LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator)
{
_contextMenu = contextMenu;
_pluginInterface = pluginInterface;
_gameData = gameData;
_logger = logger;
_dalamudUtil = dalamudUtil;
_apiController = apiController;
_objectTable = objectTable;
_configService = configService;
_pairUiService = pairUiService;
_pairRequestService = pairRequestService;
_clientState = clientState;
_broadcastScannerService = broadcastScannerService;
_broadcastService = broadcastService;
_lightlessProfileManager = lightlessProfileManager;
_mediator = mediator;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_contextMenu.OnMenuOpened += OnMenuOpened;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_contextMenu.OnMenuOpened -= OnMenuOpened;
return Task.CompletedTask;
}
public void Enable()
{
_contextMenu.OnMenuOpened += OnMenuOpened;
_logger.LogDebug("Context menu enabled.");
}
public void Disable()
{
_contextMenu.OnMenuOpened -= OnMenuOpened;
_logger.LogDebug("Context menu disabled.");
}
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;
}
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");
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;
}
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
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) || _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;
}
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;
}
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
{
_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)
return;
var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world))
return;
try
{
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero)
{
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name);
return;
}
var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
_logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid);
await _apiController.TryPairWithContentId(receiverCid).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(receiverCid))
{
_pairRequestService.RemoveRequest(receiverCid);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending pair request.");
}
}
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)
{
return _objectTable
.OfType<IPlayerCharacter>()
.FirstOrDefault(p =>
string.Equals(p.Name.TextValue, target.TargetName, StringComparison.OrdinalIgnoreCase) &&
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
}
private World GetWorld(uint worldId)
{
var sheet = _gameData.GetExcelSheet<World>()!;
var luminaWorlds = sheet.Where(x =>
{
var dc = x.DataCenter.ValueNullable;
var name = x.Name.ExtractText();
var internalName = x.InternalName.ExtractText();
if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText()))
return false;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName))
return false;
if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal))
return false;
return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name);
});
return luminaWorlds.FirstOrDefault(x => x.RowId == worldId);
}
private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter);
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
public static bool IsWorldValid(World world)
{
var name = world.Name.ToString();
return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]);
}
}

View File

@@ -1,4 +1,4 @@
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.ClientState.Objects.Types;
@@ -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
{
@@ -165,6 +239,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsInCombat { get; private set; } = false;
public bool IsPerforming { get; private set; } = false;
public bool IsInInstance { get; private set; } = false;
public bool IsInDuty => _condition[ConditionFlag.BoundByDuty];
public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles;
public uint ClassJobId => _classJobId!.Value;
public Lazy<Dictionary<uint, string>> JobData { get; private set; }
@@ -174,6 +249,32 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsLodEnabled { get; private set; }
public LightlessMediator Mediator { get; }
public bool IsInFieldOperation
{
get
{
if (!IsInDuty)
{
return false;
}
var territoryId = _clientState.TerritoryType;
if (territoryId == 0)
{
return false;
}
if (!TerritoryData.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name))
{
return false;
}
return name.Contains("Eureka", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Bozja", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Zadnor", StringComparison.OrdinalIgnoreCase);
}
}
public IGameObject? CreateGameObject(IntPtr reference)
{
EnsureIsOnFramework();
@@ -194,7 +295,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 +327,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 +352,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 +396,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 +460,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,7 +498,25 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
}
private unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
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 +524,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 +535,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 +550,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());
@@ -421,22 +624,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => IsObjectPresent(obj)).ConfigureAwait(false);
}
public IPlayerCharacter? GetPlayerByNameAndWorld(string name, ushort homeWorldId)
{
EnsureIsOnFramework();
return _objectTable
.OfType<IPlayerCharacter>()
.FirstOrDefault(p =>
string.Equals(p.Name.TextValue, name, StringComparison.Ordinal) &&
p.HomeWorld.RowId == homeWorldId);
}
public async Task RunOnFrameworkThread(System.Action act, [CallerMemberName] string callerMember = "", [CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = 0)
{
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);
}
@@ -445,18 +654,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);
}
@@ -466,7 +669,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");
@@ -479,6 +682,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;
}
@@ -503,15 +717,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);
@@ -525,13 +735,12 @@ 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;
Thread.Sleep(tick);
}
Thread.Sleep(tick * 2);
}
@@ -543,8 +752,24 @@ 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)
{
if (address == nint.Zero) return null;
EnsureIsOnFramework();
var playerCharacter = _objectTable.OfType<IPlayerCharacter>().FirstOrDefault(p => p.Address == address);
if (playerCharacter == null) return null;
var worldId = (ushort)playerCharacter.HomeWorld.RowId;
return WorldData.Value.TryGetValue(worldId, out var worldName) ? worldName : null;
}
private unsafe void CheckCharacterForDrawing(nint address, string characterName)
@@ -555,7 +780,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;
@@ -608,7 +833,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;
}
@@ -618,37 +843,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))
@@ -658,76 +889,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)
{
@@ -761,10 +991,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)
@@ -780,6 +1022,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());
}
@@ -787,6 +1030,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{
_logger.LogDebug("Logged out");
IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage());
}
@@ -804,4 +1048,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.Trace))
_logger.LogTrace("Active broadcast IDs: {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

@@ -0,0 +1,246 @@
using Dalamud.Plugin.Services;
using LightlessSync.API.Dto.User;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace LightlessSync.Services.LightFinder;
public class LightFinderScannerService : DisposableMediatorSubscriberBase
{
private readonly ILogger<LightFinderScannerService> _logger;
private readonly ActorObjectService _actorTracker;
private readonly IFramework _framework;
private readonly LightFinderService _broadcastService;
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
private readonly Queue<string> _lookupQueue = 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 readonly CancellationTokenSource _cleanupCts = new();
private readonly Task? _cleanupTask;
private readonly int _checkEveryFrames = 20;
private int _frameCounter = 0;
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 LightFinderScannerService(ILogger<LightFinderScannerService> logger,
IFramework framework,
LightFinderService broadcastService,
LightlessMediator mediator,
LightFinderPlateHandler lightFinderPlateHandler,
ActorObjectService actorTracker) : base(logger, mediator)
{
_logger = logger;
_actorTracker = actorTracker;
_broadcastService = broadcastService;
_lightFinderPlateHandler = lightFinderPlateHandler;
_logger = logger;
_framework = framework;
_framework.Update += OnFrameworkUpdate;
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token);
_actorTracker = actorTracker;
}
private void OnFrameworkUpdate(IFramework framework) => Update();
public void Update()
{
_frameCounter++;
var lookupsThisFrame = 0;
if (!_broadcastService.IsBroadcasting)
return;
var now = DateTime.UtcNow;
foreach (var address in _actorTracker.PlayerAddresses)
{
if (address == nint.Zero)
continue;
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address);
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
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)
{
var cid = _lookupQueue.Dequeue();
_lookupQueuedCids.Remove(cid);
cidsToLookup.Add(cid);
lookupsThisFrame++;
}
if (cidsToLookup.Count > 0 && !_batchRunning)
{
_batchRunning = true;
_ = BatchUpdateBroadcastCacheAsync(cidsToLookup).ContinueWith(_ => _batchRunning = false);
}
}
}
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
{
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
var now = DateTime.UtcNow;
foreach (var (cid, info) in results)
{
if (string.IsNullOrWhiteSpace(cid) || info == null)
continue;
var ttl = info.IsBroadcasting && info.TTL.HasValue
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, _maxAllowedTtl.Ticks))
: _retryDelay;
var expiry = now + ttl;
_broadcastCache.AddOrUpdate(cid,
new BroadcastEntry(info.IsBroadcasting, expiry, info.GID),
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
}
var activeCids = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
.Select(e => e.Key)
.ToList();
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
UpdateSyncshellBroadcasts();
}
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{
if (!msg.Enabled)
{
_broadcastCache.Clear();
_lookupQueue.Clear();
_lookupQueuedCids.Clear();
_syncshellCids.Clear();
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
}
}
private void UpdateSyncshellBroadcasts()
{
var now = DateTime.UtcNow;
var newSet = _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Select(e => e.Key)
.ToHashSet(StringComparer.Ordinal);
if (!_syncshellCids.SetEquals(newSet))
{
_syncshellCids.Clear();
foreach (var cid in newSet)
_syncshellCids.Add(cid);
Mediator.Publish(new SyncshellBroadcastsUpdatedMessage());
}
}
public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts()
{
var now = DateTime.UtcNow;
return [.. _broadcastCache
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
.Select(e => new BroadcastStatusInfoDto
{
HashedCID = e.Key,
IsBroadcasting = true,
TTL = e.Value.ExpiryTime - now,
GID = e.Value.GID
})];
}
private async Task ExpiredBroadcastCleanupLoop()
{
var token = _cleanupCts.Token;
try
{
while (!token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
var now = DateTime.UtcNow;
foreach (var (cid, entry) in _broadcastCache.ToArray())
{
if (entry.ExpiryTime <= now)
_broadcastCache.TryRemove(cid, out _);
}
}
}
catch (OperationCanceledException)
{
// No action needed when cancelled
}
catch (Exception ex)
{
_logger.LogError(ex, "Broadcast cleanup loop crashed");
}
UpdateSyncshellBroadcasts();
}
public int CountActiveBroadcasts(string? excludeHashedCid = null)
{
var now = DateTime.UtcNow;
var comparer = StringComparer.Ordinal;
return _broadcastCache.Count(entry =>
entry.Value.IsBroadcasting &&
entry.Value.ExpiryTime > now &&
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
}
public List<KeyValuePair<string, BroadcastEntry>> GetActiveBroadcasts(string? excludeHashedCid = null)
{
var now = DateTime.UtcNow;
var comparer = StringComparer.Ordinal;
return [.. _broadcastCache.Where(entry =>
entry.Value.IsBroadcasting &&
entry.Value.ExpiryTime > now &&
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))];
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_framework.Update -= OnFrameworkUpdate;
if (_cleanupTask != null)
{
_cleanupTask?.Wait(100, _cleanupCts.Token);
}
_cleanupCts.Cancel();
_cleanupCts.Dispose();
_cleanupTask?.Wait(100);
_cleanupCts.Dispose();
}
}

View File

@@ -0,0 +1,575 @@
using Dalamud.Interface;
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;
namespace LightlessSync.Services.LightFinder;
public class LightFinderService : IHostedService, IMediatorSubscriber
{
private readonly ILogger<LightFinderService> _logger;
private readonly ApiController _apiController;
private readonly LightlessMediator _mediator;
private readonly LightlessConfigService _config;
private readonly DalamudUtilService _dalamudUtil;
private CancellationTokenSource? _lightfinderCancelTokens;
private Action? _connectedHandler;
public LightlessMediator Mediator => _mediator;
public bool IsLightFinderAvailable { get; private set; } = false;
public bool IsBroadcasting => _config.Current.BroadcastEnabled;
private bool _syncedOnStartup = false;
private bool _waitingForTtlFetch = false;
private TimeSpan? _remainingTtl = null;
private DateTime _lastTtlCheck = DateTime.MinValue;
private DateTime _lastForcedDisableTime = DateTime.MinValue;
private static readonly TimeSpan _disableCooldown = TimeSpan.FromSeconds(5);
public TimeSpan? RemainingTtl => _remainingTtl;
public TimeSpan? RemainingCooldown
{
get
{
var elapsed = DateTime.UtcNow - _lastForcedDisableTime;
if (elapsed >= _disableCooldown) return null;
return _disableCooldown - elapsed;
}
}
public LightFinderService(ILogger<LightFinderService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
{
_logger = logger;
_mediator = mediator;
_config = config;
_dalamudUtil = dalamudUtil;
_apiController = apiController;
}
private async Task RequireConnectionAsync(string context, Func<Task> action)
{
if (!_apiController.IsConnected)
{
_logger.LogDebug("{context} skipped, not connected", context);
return;
}
await action().ConfigureAwait(false);
}
private async Task<string?> GetLocalHashedCidAsync(string context)
{
try
{
var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false);
return cid.ToString().GetHash256();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to resolve CID for {Context}", context);
return null;
}
}
private void ApplyBroadcastDisabled(bool forcePublish = false)
{
bool wasEnabled = _config.Current.BroadcastEnabled;
bool hadExpiry = _config.Current.BroadcastTtl != DateTime.MinValue;
bool hadRemaining = _remainingTtl.HasValue;
_config.Current.BroadcastEnabled = false;
_config.Current.BroadcastTtl = DateTime.MinValue;
if (wasEnabled || hadExpiry)
_config.Save();
_remainingTtl = null;
_waitingForTtlFetch = false;
_syncedOnStartup = false;
if (forcePublish || wasEnabled || hadRemaining)
_mediator.Publish(new BroadcastStatusChangedMessage(false, null));
}
private bool TryApplyBroadcastEnabled(TimeSpan? ttl, string context)
{
if (ttl is not { } validTtl || validTtl <= TimeSpan.Zero)
{
_logger.LogWarning("Lightfinder enable skipped ({Context}): invalid TTL ({TTL})", context, ttl);
return false;
}
bool wasEnabled = _config.Current.BroadcastEnabled;
TimeSpan? previousRemaining = _remainingTtl;
DateTime previousExpiry = _config.Current.BroadcastTtl;
var newExpiry = DateTime.UtcNow + validTtl;
_config.Current.BroadcastEnabled = true;
_config.Current.BroadcastTtl = newExpiry;
if (!wasEnabled || previousExpiry != newExpiry)
_config.Save();
_remainingTtl = validTtl;
_waitingForTtlFetch = false;
if (!wasEnabled || previousRemaining != validTtl)
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
return true;
}
private void HandleLightfinderUnavailable(string message, Exception? ex = null)
{
if (ex != null)
_logger.LogWarning(ex, message);
else
_logger.LogWarning(message);
IsLightFinderAvailable = false;
ApplyBroadcastDisabled(forcePublish: true);
}
private void OnDisconnected()
{
IsLightFinderAvailable = false;
ApplyBroadcastDisabled(forcePublish: true);
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
}
public Task StartAsync(CancellationToken cancellationToken)
{
_mediator.Subscribe<EnableBroadcastMessage>(this, OnEnableBroadcast);
_mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
_mediator.Subscribe<DisconnectedMessage>(this, _ => OnDisconnected());
IsLightFinderAvailable = false;
_lightfinderCancelTokens?.Cancel();
_lightfinderCancelTokens?.Dispose();
_lightfinderCancelTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectedHandler = () => _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token);
_apiController.OnConnected += _connectedHandler;
if (_apiController.IsConnected)
_ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_lightfinderCancelTokens?.Cancel();
_lightfinderCancelTokens?.Dispose();
_lightfinderCancelTokens = null;
if (_connectedHandler is not null)
{
_apiController.OnConnected -= _connectedHandler;
_connectedHandler = null;
}
_mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken)
{
try
{
while (!_apiController.IsConnected && !cancellationToken.IsCancellationRequested)
await Task.Delay(250, cancellationToken).ConfigureAwait(false);
if (cancellationToken.IsCancellationRequested)
return;
var hashedCid = await GetLocalHashedCidAsync("Lightfinder state check").ConfigureAwait(false);
if (string.IsNullOrEmpty(hashedCid))
return;
BroadcastStatusInfoDto? status = null;
try
{
status = await _apiController.IsUserBroadcasting(hashedCid).ConfigureAwait(false);
}
catch (HubException ex) when (ex.Message.Contains("Method does not exist", StringComparison.OrdinalIgnoreCase))
{
HandleLightfinderUnavailable("Lightfinder unavailable on server (required method missing).", ex);
}
if (!IsLightFinderAvailable)
_logger.LogInformation("Lightfinder is available.");
IsLightFinderAvailable = true;
bool isBroadcasting = status?.IsBroadcasting == true;
TimeSpan? ttl = status?.TTL;
if (isBroadcasting)
{
if (ttl is not { } remaining || remaining <= TimeSpan.Zero)
ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
if (TryApplyBroadcastEnabled(ttl, "server handshake"))
{
_syncedOnStartup = true;
}
else
{
isBroadcasting = false;
}
}
if (!isBroadcasting)
{
ApplyBroadcastDisabled(forcePublish: true);
_logger.LogInformation("Lightfinder is available but no active broadcast was found.");
}
if (_config.Current.LightfinderAutoEnableOnConnect && !isBroadcasting)
{
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
_mediator.Publish(new NotificationMessage(
"Broadcast Auto-Enabled",
"Your Lightfinder broadcast has been automatically enabled.",
NotificationType.Info));
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Lightfinder check was canceled.");
}
catch (Exception ex)
{
HandleLightfinderUnavailable("Lightfinder check failed.", ex);
}
}
private void OnEnableBroadcast(EnableBroadcastMessage msg)
{
_ = RequireConnectionAsync(nameof(OnEnableBroadcast), async () =>
{
try
{
GroupBroadcastRequestDto? groupDto = null;
if (_config.Current.SyncshellFinderEnabled && _config.Current.SelectedFinderSyncshell != null)
{
groupDto = new GroupBroadcastRequestDto
{
HashedCID = msg.HashedCid,
GID = _config.Current.SelectedFinderSyncshell,
Enabled = msg.Enabled,
};
}
await _apiController.SetBroadcastStatus(msg.Enabled, groupDto).ConfigureAwait(false);
_logger.LogDebug("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid);
if (!msg.Enabled)
{
ApplyBroadcastDisabled(forcePublish: true);
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
return;
}
_waitingForTtlFetch = true;
try
{
TimeSpan? ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false);
if (TryApplyBroadcastEnabled(ttl, "client request"))
{
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
}
else
{
ApplyBroadcastDisabled(forcePublish: true);
_logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling.");
}
}
finally
{
_waitingForTtlFetch = false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to toggle broadcast for {Cid}", msg.HashedCid);
_waitingForTtlFetch = false;
}
});
}
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
{
_config.Current.BroadcastEnabled = msg.Enabled;
_config.Save();
}
public async Task<bool> CheckIfBroadcastingAsync(string targetCid)
{
bool result = false;
await RequireConnectionAsync(nameof(CheckIfBroadcastingAsync), async () =>
{
try
{
_logger.LogDebug("[BroadcastCheck] Checking CID: {cid}", targetCid);
var info = await _apiController.IsUserBroadcasting(targetCid).ConfigureAwait(false);
result = info?.TTL > TimeSpan.Zero;
_logger.LogDebug("[BroadcastCheck] Result for {cid}: {result} (TTL: {ttl}, GID: {gid})", targetCid, result, info?.TTL, info?.GID);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check broadcast status for {cid}", targetCid);
}
}).ConfigureAwait(false);
return result;
}
public async Task<TimeSpan?> GetBroadcastTtlAsync(string? cidForLog = null)
{
TimeSpan? ttl = null;
await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => {
try
{
ttl = await _apiController.GetBroadcastTtl().ConfigureAwait(false);
}
catch (Exception ex)
{
if (cidForLog is { Length: > 0 })
{
_logger.LogWarning(ex, "Failed to fetch broadcast TTL for {Cid}", cidForLog);
}
else
{
_logger.LogWarning(ex, "Failed to fetch broadcast TTL");
}
}
}).ConfigureAwait(false);
return ttl;
}
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
{
Dictionary<string, BroadcastStatusInfoDto?> result = new(StringComparer.Ordinal);
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
{
try
{
var batch = await _apiController.AreUsersBroadcasting(hashedCids).ConfigureAwait(false);
if (batch?.Results != null)
{
foreach (var kv in batch.Results)
result[kv.Key] = kv.Value;
}
_logger.LogTrace("Batch broadcast status check complete for {Count} CIDs", hashedCids.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to batch check broadcast status");
}
}).ConfigureAwait(false);
return result;
}
public async void ToggleBroadcast()
{
if (!IsLightFinderAvailable)
{
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
_mediator.Publish(new NotificationMessage(
"Broadcast Unavailable",
"Lightfinder is not available on this server.",
NotificationType.Error));
return;
}
await RequireConnectionAsync(nameof(ToggleBroadcast), async () =>
{
var cooldown = RemainingCooldown;
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
{
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
_mediator.Publish(new NotificationMessage(
"Broadcast Cooldown",
$"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.",
NotificationType.Warning));
return;
}
var hashedCid = await GetLocalHashedCidAsync(nameof(ToggleBroadcast)).ConfigureAwait(false);
if (string.IsNullOrEmpty(hashedCid))
{
_logger.LogWarning("ToggleBroadcast - unable to resolve CID.");
return;
}
try
{
var isCurrentlyBroadcasting = await CheckIfBroadcastingAsync(hashedCid).ConfigureAwait(false);
var newStatus = !isCurrentlyBroadcasting;
if (!newStatus)
{
_lastForcedDisableTime = DateTime.UtcNow;
_logger.LogDebug("Manual disable: cooldown timer started.");
}
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
_mediator.Publish(new NotificationMessage(
newStatus ? "Broadcast Enabled" : "Broadcast Disabled",
newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.",
NotificationType.Info));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
_mediator.Publish(new NotificationMessage(
"Broadcast Toggle Failed",
$"Failed to toggle broadcast: {ex.Message}",
NotificationType.Error));
}
}).ConfigureAwait(false);
}
private async void OnTick(PriorityFrameworkUpdateMessage _)
{
if (!IsLightFinderAvailable)
return;
if (_config?.Current == null)
return;
if ((DateTime.UtcNow - _lastTtlCheck).TotalSeconds < 1)
return;
_lastTtlCheck = DateTime.UtcNow;
await RequireConnectionAsync(nameof(OnTick), async () => {
if (!_syncedOnStartup && _config.Current.BroadcastEnabled)
{
try
{
var hashedCid = await GetLocalHashedCidAsync("startup TTL refresh").ConfigureAwait(false);
if (string.IsNullOrEmpty(hashedCid))
{
_logger.LogDebug("Skipping TTL refresh; hashed CID unavailable.");
return;
}
TimeSpan? ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false);
if (TryApplyBroadcastEnabled(ttl, "startup TTL refresh"))
{
_syncedOnStartup = true;
_logger.LogDebug("Refreshed broadcast TTL from server on first OnTick: {TTL}", ttl);
}
else
{
_logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state.");
ApplyBroadcastDisabled(forcePublish: true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh TTL in OnTick");
_syncedOnStartup = false;
}
}
if (_config.Current.BroadcastEnabled)
{
if (_waitingForTtlFetch)
{
_logger.LogDebug("OnTick skipped: waiting for TTL fetch");
return;
}
DateTime expiry = _config.Current.BroadcastTtl;
TimeSpan remaining = expiry - DateTime.UtcNow;
_remainingTtl = remaining > TimeSpan.Zero ? remaining : null;
if (_remainingTtl == null)
{
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
ApplyBroadcastDisabled(forcePublish: true);
ShowBroadcastExpiredNotification();
}
}
else
{
_remainingTtl = null;
}
}).ConfigureAwait(false);
}
private void ShowBroadcastExpiredNotification()
{
var notification = new LightlessNotification
{
Id = "broadcast_expired",
Title = "Broadcast Expired",
Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?",
Type = NotificationType.PairRequest,
Duration = TimeSpan.FromSeconds(180),
Actions = new List<LightlessNotificationAction>
{
new()
{
Id = "re_enable",
Label = "Re-enable",
Icon = FontAwesomeIcon.Plus,
Color = UIColors.Get("PairBlue"),
IsPrimary = true,
OnClick = (n) =>
{
_logger.LogInformation("Re-enabling broadcast from notification");
ToggleBroadcast();
n.IsDismissed = true;
n.IsAnimatingOut = true;
}
},
new()
{
Id = "close",
Label = "Close",
Icon = FontAwesomeIcon.Times,
Color = UIColors.Get("DimRed"),
OnClick = (n) =>
{
_logger.LogInformation("Broadcast expiration notification dismissed");
n.IsDismissed = true;
n.IsAnimatingOut = true;
}
}
}
};
_mediator.Publish(new LightlessNotificationMessage(notification));
}
}

View File

@@ -1,7 +0,0 @@
namespace LightlessSync.Services;
public record LightlessProfileData(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));
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Game.ClientState.Objects.Types;
using LightlessSync.API.Data;
using LightlessSync.API.Dto;
using LightlessSync.API.Dto.CharaData;
@@ -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;
@@ -17,14 +20,18 @@ namespace LightlessSync.Services.Mediator;
public record SwitchToIntroUiMessage : MessageBase;
public record SwitchToMainUiMessage : MessageBase;
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;
@@ -47,29 +54,43 @@ public record PetNamesMessage(string PetNicknamesData) : MessageBase;
public record HonorificReadyMessage : MessageBase;
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
public record HaltScanMessage(string Source) : MessageBase;
public record ResumeScanMessage(string Source) : MessageBase;
public record NotificationMessage
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase;
public record PerformanceNotificationMessage
(string Title, string Message, UserData UserData, bool IsPaused, string PlayerName) : MessageBase;
public record CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
public record ClearAllNotificationsMessage : MessageBase;
public record CharacterDataAnalyzedMessage : MessageBase;
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
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;
public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase;
public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : 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;
@@ -77,8 +98,12 @@ public record OpenCensusPopupMessage() : MessageBase;
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;
@@ -97,7 +122,18 @@ public record GPoseLobbyReceiveCharaData(CharaDataDownloadDto CharaDataDownloadD
public record GPoseLobbyReceivePoseData(UserData UserData, PoseData PoseData) : MessageBase;
public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) : MessageBase;
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,4 +1,4 @@
using Dalamud.Interface.Windowing;
using Dalamud.Interface.Windowing;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services.Mediator;

View File

@@ -1,91 +1,254 @@
using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Gui.NamePlate;
using Dalamud.Game.ClientState.Objects.Enums;
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;
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw());
_gameGui = gameGui;
_objectTable = objectTable;
_pairUiService = pairUiService;
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)) return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs().Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue).Select(u => (ulong)u.PlayerCharacterId).ToHashSet();
var colors = _configService.Current.NameplateColors;
foreach (var handler in handlers)
try
{
var playerCharacter = handler.PlayerCharacter;
if (playerCharacter == null) { continue; }
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 (visibleUsersIds.Contains(handler.GameObjectId) &&
!(
(isInParty && !partyColorAllowed) ||
(isFriend && !friendColorAllowed)
))
{
handler.NameParts.TextWrap = CreateTextWrap(colors);
}
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 obj = _objectTable.FirstOrDefault(o => o.Address == (nint)battleChara);
if (obj is not IPlayerCharacter player)
return;
var snapshot = _pairUiService.GetSnapshot();
var visibleUsersIds = snapshot.PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
//Check if player should be colored
if (!ShouldColorPlayer(player, visibleUsersIds))
return;
var originalName = player.Name.ToString();
//Check if not null of the name
if (string.IsNullOrEmpty(originalName))
return;
//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)
{
if (disposing)
{
_nameplateHook?.Dispose();
}
base.Dispose(disposing);
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate;
_namePlateGui.RequestRedraw();
}
}

View File

@@ -1,102 +1,460 @@
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Interface;
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;
using LightlessSync.API.Data;
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
namespace LightlessSync.Services;
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
{
private readonly ILogger<NotificationService> _logger;
private readonly LightlessConfigService _configService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui;
private readonly LightlessConfigService _configurationService;
private readonly PairRequestService _pairRequestService;
private readonly HashSet<string> _shownPairRequestNotifications = [];
private readonly PairUiService _pairUiService;
private readonly PairFactory _pairFactory;
public NotificationService(ILogger<NotificationService> logger, LightlessMediator mediator,
public NotificationService(
ILogger<NotificationService> logger,
LightlessConfigService configService,
DalamudUtilService dalamudUtilService,
INotificationManager notificationManager,
IChatGui chatGui, LightlessConfigService configurationService) : base(logger, mediator)
IChatGui chatGui,
LightlessMediator mediator,
PairRequestService pairRequestService,
PairUiService pairUiService,
PairFactory pairFactory) : base(logger, mediator)
{
_logger = logger;
_configService = configService;
_dalamudUtilService = dalamudUtilService;
_notificationManager = notificationManager;
_chatGui = chatGui;
_configurationService = configurationService;
_pairRequestService = pairRequestService;
_pairUiService = pairUiService;
_pairFactory = pairFactory;
}
public Task StartAsync(CancellationToken cancellationToken)
{
Mediator.Subscribe<NotificationMessage>(this, ShowNotification);
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
Mediator.Subscribe<PairRequestReceivedMessage>(this, HandlePairRequestReceived);
Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated);
Mediator.Subscribe<PairDownloadStatusMessage>(this, HandlePairDownloadStatus);
Mediator.Subscribe<PerformanceNotificationMessage>(this, HandlePerformanceNotification);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private void PrintErrorChat(string? message)
public void ShowNotification(string title, string message, NotificationType type = NotificationType.Info,
TimeSpan? duration = null, List<LightlessNotificationAction>? actions = null, uint? soundEffectId = null)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message);
_chatGui.PrintError(se.BuiltString);
}
var notification = CreateNotification(title, message, type, duration, actions, soundEffectId);
private void PrintInfoChat(string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ").AddItalics(message ?? string.Empty);
_chatGui.Print(se.BuiltString);
}
private void PrintWarnChat(string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ").AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff();
_chatGui.Print(se.BuiltString);
}
private void ShowChat(NotificationMessage msg)
{
switch (msg.Type)
if (_configService.Current.AutoDismissOnAction && notification.Actions.Count != 0)
{
case NotificationType.Info:
PrintInfoChat(msg.Message);
break;
WrapActionsWithAutoDismiss(notification);
}
case NotificationType.Warning:
PrintWarnChat(msg.Message);
break;
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
case NotificationType.Error:
PrintErrorChat(msg.Message);
break;
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private LightlessNotification CreateNotification(string title, string message, NotificationType type,
TimeSpan? duration, List<LightlessNotificationAction>? actions, uint? soundEffectId)
{
return new LightlessNotification
{
Title = title,
Message = message,
Type = type,
Duration = duration ?? GetDefaultDurationForType(type),
Actions = actions ?? new List<LightlessNotificationAction>(),
SoundEffectId = GetSoundEffectId(type, soundEffectId),
ShowProgress = _configService.Current.ShowNotificationProgress,
CreatedAt = DateTime.UtcNow
};
}
private void WrapActionsWithAutoDismiss(LightlessNotification notification)
{
foreach (var action in notification.Actions)
{
var originalOnClick = action.OnClick;
action.OnClick = (n) =>
{
originalOnClick(n);
if (_configService.Current.AutoDismissOnAction)
{
DismissNotification(n);
}
};
}
}
private void ShowNotification(NotificationMessage msg)
private static void DismissNotification(LightlessNotification notification)
{
Logger.LogInformation("{msg}", msg.ToString());
notification.IsDismissed = true;
notification.IsAnimatingOut = true;
}
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
{
var location = GetNotificationLocation(NotificationType.PairRequest);
// Show in chat if configured
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
{
ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
}
// Show Lightless notification if configured and action buttons are enabled
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
&& _configService.Current.UseLightlessNotifications
&& _configService.Current.ShowPairRequestNotificationActions)
{
var notification = new LightlessNotification
{
Id = $"pair_request_{senderId}",
Title = "Pair Request Received",
Message = $"{senderName} wants to directly pair with you.",
Type = NotificationType.PairRequest,
Duration = TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
SoundEffectId = GetPairRequestSoundId(),
Actions = CreatePairRequestActions(onAccept, onDecline)
};
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
Mediator.Publish(new LightlessNotificationMessage(notification));
}
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
{
// Fall back to regular notification without action buttons
HandleNotificationMessage(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
}
}
private uint? GetPairRequestSoundId() =>
!_configService.Current.DisablePairRequestSound ? _configService.Current.PairRequestSoundId : null;
private List<LightlessNotificationAction> CreatePairRequestActions(Action onAccept, Action onDecline)
{
return new List<LightlessNotificationAction>
{
new()
{
Id = "accept",
Label = "Accept",
Icon = FontAwesomeIcon.Check,
Color = UIColors.Get("LightlessGreen"),
IsPrimary = true,
OnClick = (n) =>
{
_logger.LogInformation("Pair request accepted");
onAccept();
DismissNotification(n);
}
},
new()
{
Id = "decline",
Label = "Decline",
Icon = FontAwesomeIcon.Times,
Color = UIColors.Get("DimRed"),
IsDestructive = true,
OnClick = (n) =>
{
_logger.LogInformation("Pair request declined");
onDecline();
DismissNotification(n);
}
}
};
}
public void ShowDownloadCompleteNotification(string fileName, int fileCount, Action? onOpenFolder = null)
{
var notification = new LightlessNotification
{
Title = "Download Complete",
Message = FormatDownloadCompleteMessage(fileName, fileCount),
Type = NotificationType.Info,
Duration = TimeSpan.FromSeconds(8),
Actions = CreateDownloadCompleteActions(onOpenFolder),
SoundEffectId = NotificationSounds.DownloadComplete
};
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private static string FormatDownloadCompleteMessage(string fileName, int fileCount)
{
return fileCount > 1
? $"Downloaded {fileCount} files successfully."
: $"Downloaded {fileName} successfully.";
}
private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder)
{
var actions = new List<LightlessNotificationAction>();
if (onOpenFolder != null)
{
actions.Add(new LightlessNotificationAction
{
Id = "open_folder",
Label = "Open Folder",
Icon = FontAwesomeIcon.FolderOpen,
Color = UIColors.Get("LightlessBlue"),
OnClick = (n) =>
{
onOpenFolder();
DismissNotification(n);
}
});
}
return actions;
}
public void ShowErrorNotification(string title, string message, Exception? exception = null, Action? onRetry = null,
Action? onViewLog = null)
{
var notification = new LightlessNotification
{
Title = title,
Message = FormatErrorMessage(message, exception),
Type = NotificationType.Error,
Duration = TimeSpan.FromSeconds(15),
Actions = CreateErrorActions(onRetry, onViewLog),
SoundEffectId = NotificationSounds.Error
};
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
Mediator.Publish(new LightlessNotificationMessage(notification));
}
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)
{
var actions = new List<LightlessNotificationAction>();
if (onRetry != null)
{
actions.Add(new LightlessNotificationAction
{
Id = "retry",
Label = "Retry",
Icon = FontAwesomeIcon.Redo,
Color = UIColors.Get("LightlessBlue"),
OnClick = (n) =>
{
onRetry();
DismissNotification(n);
}
});
}
if (onViewLog != null)
{
actions.Add(new LightlessNotificationAction
{
Id = "view_log",
Label = "View Log",
Icon = FontAwesomeIcon.FileAlt,
Color = UIColors.Get("LightlessYellow"),
OnClick = (n) => onViewLog()
});
}
return actions;
}
private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads,
int queueWaiting)
{
var messageParts = new List<string>();
if (queueWaiting > 0)
{
messageParts.Add($"Queue: {queueWaiting} waiting");
}
if (userDownloads.Count > 0)
{
var completedCount = userDownloads.Count(x => x.Progress >= 1.0f);
messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed");
}
var activeDownloadLines = BuildActiveDownloadLines(userDownloads);
if (!string.IsNullOrEmpty(activeDownloadLines))
{
messageParts.Add(activeDownloadLines);
}
return string.Join("\n", messageParts);
}
private string BuildActiveDownloadLines(List<(string PlayerName, float Progress, string Status)> userDownloads)
{
var activeDownloads = userDownloads
.Where(x => x.Progress < 1.0f)
.Take(_configService.Current.MaxConcurrentPairApplications);
if (!activeDownloads.Any()) return string.Empty;
return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}"));
}
private static string FormatDownloadStatus((string PlayerName, float Progress, string Status) download)
{
return download.Status switch
{
"downloading" => $"{download.Progress:P0}",
"decompressing" => "decompressing",
"queued" => "queued",
"waiting" => "waiting for slot",
_ => download.Status
};
}
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
{
NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds),
NotificationType.Warning => TimeSpan.FromSeconds(_configService.Current.WarningNotificationDurationSeconds),
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
_ => TimeSpan.FromSeconds(10)
};
private uint? GetSoundEffectId(NotificationType type, uint? overrideSoundId)
{
if (overrideSoundId.HasValue) return overrideSoundId;
if (IsSoundDisabledForType(type)) return null;
return GetConfiguredSoundForType(type);
}
private bool IsSoundDisabledForType(NotificationType type) => type switch
{
NotificationType.Info => _configService.Current.DisableInfoSound,
NotificationType.Warning => _configService.Current.DisableWarningSound,
NotificationType.Error => _configService.Current.DisableErrorSound,
NotificationType.Performance => _configService.Current.DisablePerformanceSound,
NotificationType.Download => true, // Download sounds always disabled
_ => false
};
private uint GetConfiguredSoundForType(NotificationType type) => type switch
{
NotificationType.Info => _configService.Current.CustomInfoSoundId,
NotificationType.Warning => _configService.Current.CustomWarningSoundId,
NotificationType.Error => _configService.Current.CustomErrorSoundId,
NotificationType.Performance => _configService.Current.PerformanceSoundId,
_ => NotificationSounds.GetDefaultSound(type)
};
private void PlayNotificationSound(uint soundEffectId)
{
try
{
UIGlobals.PlayChatSoundEffect(soundEffectId);
_logger.LogDebug("Played notification sound effect {SoundId} via ChatGui", soundEffectId);
}
catch (Exception ex)
{
_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)
{
_logger.LogInformation("{msg}", msg.ToString());
if (!_dalamudUtilService.IsLoggedIn) return;
switch (msg.Type)
{
case NotificationType.Info:
ShowNotificationLocationBased(msg, _configurationService.Current.InfoNotification);
break;
case NotificationType.Warning:
ShowNotificationLocationBased(msg, _configurationService.Current.WarningNotification);
break;
case NotificationType.Error:
ShowNotificationLocationBased(msg, _configurationService.Current.ErrorNotification);
break;
}
var location = GetNotificationLocation(msg.Type);
ShowNotificationLocationBased(msg, location);
}
private NotificationLocation GetNotificationLocation(NotificationType type) =>
_configService.Current.UseLightlessNotifications
? GetLightlessNotificationLocation(type)
: GetClassicNotificationLocation(type);
private NotificationLocation GetLightlessNotificationLocation(NotificationType type) => type switch
{
NotificationType.Info => _configService.Current.LightlessInfoNotification,
NotificationType.Warning => _configService.Current.LightlessWarningNotification,
NotificationType.Error => _configService.Current.LightlessErrorNotification,
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
NotificationType.Download => _configService.Current.LightlessDownloadNotification,
NotificationType.Performance => _configService.Current.LightlessPerformanceNotification,
_ => NotificationLocation.LightlessUi
};
private NotificationLocation GetClassicNotificationLocation(NotificationType type) => type switch
{
NotificationType.Info => _configService.Current.InfoNotification,
NotificationType.Warning => _configService.Current.WarningNotification,
NotificationType.Error => _configService.Current.ErrorNotification,
NotificationType.PairRequest => NotificationLocation.Toast,
NotificationType.Download => NotificationLocation.Toast,
_ => NotificationLocation.Nowhere
};
private void ShowNotificationLocationBased(NotificationMessage msg, NotificationLocation location)
{
switch (location)
@@ -114,20 +472,29 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
ShowChat(msg);
break;
case NotificationLocation.LightlessUi:
ShowLightlessNotification(msg);
break;
case NotificationLocation.ChatAndLightlessUi:
ShowChat(msg);
ShowLightlessNotification(msg);
break;
case NotificationLocation.Nowhere:
break;
}
}
private void ShowLightlessNotification(NotificationMessage msg)
{
var duration = msg.TimeShownOnScreen ?? GetDefaultDurationForType(msg.Type);
ShowNotification(msg.Title ?? "Lightless Sync", msg.Message ?? string.Empty, msg.Type, duration, null, null);
}
private void ShowToast(NotificationMessage msg)
{
Dalamud.Interface.ImGuiNotification.NotificationType dalamudType = msg.Type switch
{
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
NotificationType.Info => Dalamud.Interface.ImGuiNotification.NotificationType.Info,
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
};
var dalamudType = ConvertToDalamudNotificationType(msg.Type);
_notificationManager.AddNotification(new Notification()
{
@@ -138,4 +505,279 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
InitialDuration = msg.TimeShownOnScreen ?? TimeSpan.FromSeconds(3)
});
}
private static Dalamud.Interface.ImGuiNotification.NotificationType
ConvertToDalamudNotificationType(NotificationType type)
{
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)
{
switch (msg.Type)
{
case NotificationType.Info:
PrintInfoChat(msg.Message);
break;
case NotificationType.Warning:
PrintWarnChat(msg.Message);
break;
case NotificationType.Error:
PrintErrorChat(msg.Message);
break;
case NotificationType.PairRequest:
PrintPairRequestChat(msg.Title, msg.Message);
break;
case NotificationType.Performance:
PrintPerformanceChat(msg.Title, msg.Message);
break;
// Download notifications don't support chat output, will be a giga spam otherwise
case NotificationType.Download:
break;
}
}
private void PrintErrorChat(string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Error: " + message);
_chatGui.PrintError(se.BuiltString);
}
private void PrintInfoChat(string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] Info: ")
.AddItalics(message ?? string.Empty);
_chatGui.Print(se.BuiltString);
}
private void PrintWarnChat(string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
.AddUiForeground("Warning: " + (message ?? string.Empty), 31).AddUiForegroundOff();
_chatGui.Print(se.BuiltString);
}
private void PrintPairRequestChat(string? title, string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
.AddUiForeground("Pair Request: ", 541).AddUiForegroundOff()
.AddText(title ?? message ?? string.Empty);
_chatGui.Print(se.BuiltString);
}
private void PrintPerformanceChat(string? title, string? message)
{
SeStringBuilder se = new SeStringBuilder().AddText("[Lightless Sync] ")
.AddUiForeground("Performance: ", 508).AddUiForegroundOff()
.AddText(title ?? message ?? string.Empty);
_chatGui.Print(se.BuiltString);
}
private void HandlePairRequestReceived(PairRequestReceivedMessage msg)
{
var request = _pairRequestService.RegisterIncomingRequest(msg.HashedCid, msg.Message);
var senderName = string.IsNullOrEmpty(request.DisplayName) ? "Unknown User" : request.DisplayName;
_shownPairRequestNotifications.Add(request.HashedCid);
ShowPairRequestNotification(
senderName,
request.HashedCid,
onAccept: () => _pairRequestService.AcceptPairRequest(request.HashedCid, senderName),
onDecline: () => _pairRequestService.DeclinePairRequest(request.HashedCid, senderName));
}
private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
{
var activeRequests = _pairRequestService.GetActiveRequests();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(StringComparer.Ordinal);
// Dismiss notifications for requests that are no longer active (expired)
var notificationsToRemove = _shownPairRequestNotifications
.Where(hashedCid => !activeRequestIds.Contains(hashedCid))
.ToList();
foreach (var hashedCid in notificationsToRemove)
{
var notificationId = $"pair_request_{hashedCid}";
Mediator.Publish(new LightlessNotificationDismissMessage(notificationId));
_shownPairRequestNotifications.Remove(hashedCid);
}
}
private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
{
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);
var notification = new LightlessNotification
{
Id = "pair_download_progress",
Title = "Downloading Pair Data",
Message = message,
Type = NotificationType.Download,
Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
ShowProgress = true,
Progress = totalProgress
};
Mediator.Publish(new LightlessNotificationMessage(notification));
}
private void HandlePerformanceNotification(PerformanceNotificationMessage msg)
{
var location = GetNotificationLocation(NotificationType.Performance);
// Show in chat if configured
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
{
ShowChat(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
}
// Show Lightless notification if configured and action buttons are enabled
if ((location == NotificationLocation.LightlessUi || location == NotificationLocation.ChatAndLightlessUi)
&& _configService.Current.UseLightlessNotifications
&& _configService.Current.ShowPerformanceNotificationActions)
{
var actions = CreatePerformanceActions(msg.UserData, msg.IsPaused, msg.PlayerName);
var notification = new LightlessNotification
{
Title = msg.Title,
Message = msg.Message,
Type = NotificationType.Performance,
Duration = TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
Actions = actions,
SoundEffectId = GetSoundEffectId(NotificationType.Performance, null)
};
if (notification.SoundEffectId.HasValue)
{
PlayNotificationSound(notification.SoundEffectId.Value);
}
Mediator.Publish(new LightlessNotificationMessage(notification));
}
else if (location != NotificationLocation.Nowhere && location != NotificationLocation.Chat)
{
// Fall back to regular notification without action buttons
HandleNotificationMessage(new NotificationMessage(msg.Title, msg.Message, NotificationType.Performance));
}
}
private List<LightlessNotificationAction> CreatePerformanceActions(UserData userData, bool isPaused, string playerName)
{
var actions = new List<LightlessNotificationAction>();
if (isPaused)
{
actions.Add(new LightlessNotificationAction
{
Label = "Unpause",
Icon = FontAwesomeIcon.Play,
Color = UIColors.Get("LightlessGreen"),
IsPrimary = true,
OnClick = (notification) =>
{
try
{
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);
ShowNotification(
"Player Unpaused",
$"Successfully unpaused {displayName}",
NotificationType.Info,
TimeSpan.FromSeconds(3));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to unpause player {uid}", userData.UID);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Unpause Failed",
$"Failed to unpause {displayName}",
NotificationType.Error,
TimeSpan.FromSeconds(5));
}
}
});
}
else
{
actions.Add(new LightlessNotificationAction
{
Label = "Pause",
Icon = FontAwesomeIcon.Pause,
Color = UIColors.Get("LightlessOrange"),
IsPrimary = true,
OnClick = (notification) =>
{
try
{
Mediator.Publish(new PauseMessage(userData));
DismissNotification(notification);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Player Paused",
$"Successfully paused {displayName}",
NotificationType.Info,
TimeSpan.FromSeconds(3));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pause player {uid}", userData.UID);
var displayName = GetUserDisplayName(userData, playerName);
ShowNotification(
"Pause Failed",
$"Failed to pause {displayName}",
NotificationType.Error,
TimeSpan.FromSeconds(5));
}
}
});
}
// Add dismiss button
actions.Add(new LightlessNotificationAction
{
Label = "Dismiss",
Icon = FontAwesomeIcon.Times,
Color = UIColors.Get("DimRed"),
IsPrimary = false,
OnClick = (notification) =>
{
DismissNotification(notification);
}
});
return actions;
}
private static string GetUserDisplayName(UserData userData, string playerName)
{
if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal))
{
return $"{playerName} ({userData.Alias})";
}
return $"{playerName} ({userData.UID})";
}
}

View File

@@ -0,0 +1,247 @@
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services.PairProcessing;
public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{
private const int _hardLimit = 32;
private readonly LightlessConfigService _configService;
private readonly object _limitLock = new();
private readonly SemaphoreSlim _semaphore;
private int _currentLimit;
private int _pendingReductions;
private int _pendingIncrements;
private int _waiting;
private int _inFlight;
public PairProcessingLimiter(ILogger<PairProcessingLimiter> logger, LightlessMediator mediator, LightlessConfigService configService)
: base(logger, mediator)
{
_configService = configService;
_currentLimit = CalculateLimit();
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : _hardLimit;
_semaphore = new SemaphoreSlim(initialCount, _hardLimit);
Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit());
}
public ValueTask<IAsyncDisposable> AcquireAsync(CancellationToken cancellationToken)
{
return WaitInternalAsync(cancellationToken);
}
public PairProcessingLimiterSnapshot GetSnapshot()
{
lock (_limitLock)
{
var enabled = IsEnabled;
var limit = enabled ? _currentLimit : CalculateLimit();
var waiting = Math.Max(0, Volatile.Read(ref _waiting));
var inFlight = Math.Max(0, Volatile.Read(ref _inFlight));
return new PairProcessingLimiterSnapshot(enabled, limit, inFlight, waiting);
}
}
private bool IsEnabled => _configService.Current.EnablePairProcessingLimiter;
private async ValueTask<IAsyncDisposable> WaitInternalAsync(CancellationToken token)
{
if (!IsEnabled)
{
return NoopReleaser.Instance;
}
Interlocked.Increment(ref _waiting);
try
{
await _semaphore.WaitAsync(token).ConfigureAwait(false);
}
catch
{
Interlocked.Decrement(ref _waiting);
throw;
}
Interlocked.Decrement(ref _waiting);
if (!IsEnabled)
{
TryReleaseSemaphore();
return NoopReleaser.Instance;
}
Interlocked.Increment(ref _inFlight);
return new Releaser(this);
}
private void UpdateSemaphoreLimit()
{
lock (_limitLock)
{
var enabled = IsEnabled;
var desiredLimit = CalculateLimit();
if (!enabled)
{
var releaseAmount = _hardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0)
{
TryReleaseSemaphore(releaseAmount);
}
_currentLimit = desiredLimit;
_pendingReductions = 0;
_pendingIncrements = 0;
return;
}
if (desiredLimit == _currentLimit)
{
return;
}
if (desiredLimit > _currentLimit)
{
var increment = desiredLimit - _currentLimit;
_pendingIncrements += increment;
var available = _hardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{
_pendingIncrements -= toRelease;
}
}
else
{
var decrement = _currentLimit - desiredLimit;
var removed = 0;
while (removed < decrement && _semaphore.Wait(0))
{
removed++;
}
var remaining = decrement - removed;
if (remaining > 0)
{
_pendingReductions += remaining;
}
if (_pendingIncrements > 0)
{
var offset = Math.Min(_pendingIncrements, _pendingReductions);
_pendingIncrements -= offset;
_pendingReductions -= offset;
}
}
_currentLimit = desiredLimit;
Logger.LogDebug("Pair processing concurrency updated to {limit} (pending reductions: {pending})", _currentLimit, _pendingReductions);
}
}
private int CalculateLimit()
{
var configured = _configService.Current.MaxConcurrentPairApplications;
return Math.Clamp(configured, 1, _hardLimit);
}
private bool TryReleaseSemaphore(int count = 1)
{
if (count <= 0)
{
return true;
}
try
{
_semaphore.Release(count);
return true;
}
catch (SemaphoreFullException ex)
{
Logger.LogDebug(ex, "Attempted to release {count} pair processing slots but semaphore is already at the hard limit.", count);
return false;
}
}
private void ReleaseOne()
{
var inFlight = Interlocked.Decrement(ref _inFlight);
if (inFlight < 0)
{
Interlocked.Exchange(ref _inFlight, 0);
}
if (!IsEnabled)
{
return;
}
lock (_limitLock)
{
if (_pendingReductions > 0)
{
_pendingReductions--;
return;
}
if (_pendingIncrements > 0)
{
if (!TryReleaseSemaphore())
{
return;
}
_pendingIncrements--;
return;
}
}
TryReleaseSemaphore();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing)
{
return;
}
_semaphore.Dispose();
}
private sealed class Releaser : IAsyncDisposable
{
private PairProcessingLimiter? _owner;
public Releaser(PairProcessingLimiter owner)
{
_owner = owner;
}
public ValueTask DisposeAsync()
{
var owner = Interlocked.Exchange(ref _owner, null);
owner?.ReleaseOne();
return ValueTask.CompletedTask;
}
}
private sealed class NoopReleaser : IAsyncDisposable
{
public static readonly NoopReleaser Instance = new();
private NoopReleaser()
{
}
public ValueTask DisposeAsync()
{
return ValueTask.CompletedTask;
}
}
}

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

@@ -0,0 +1,297 @@
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Services;
public sealed class PairRequestService : DisposableMediatorSubscriberBase
{
private readonly DalamudUtilService _dalamudUtil;
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);
public PairRequestService(
ILogger<PairRequestService> logger,
LightlessMediator mediator,
DalamudUtilService dalamudUtil,
PairUiService pairUiService,
Lazy<WebAPI.ApiController> apiController)
: base(logger, mediator)
{
_dalamudUtil = dalamudUtil;
_pairUiService = pairUiService;
_apiController = apiController;
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
{
bool removed;
lock (_syncRoot)
{
removed = CleanupExpiredUnsafe();
}
if (removed)
{
Mediator.Publish(new PairRequestsUpdatedMessage());
}
});
}
public PairRequestDisplay RegisterIncomingRequest(string hashedCid, string messageTemplate)
{
if (string.IsNullOrWhiteSpace(hashedCid))
{
hashedCid = string.Empty;
}
messageTemplate ??= string.Empty;
PairRequestEntry entry = new(hashedCid, messageTemplate, DateTime.UtcNow);
lock (_syncRoot)
{
CleanupExpiredUnsafe();
var index = _requests.FindIndex(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal));
if (index >= 0)
{
_requests[index] = entry;
}
else
{
_requests.Add(entry);
}
}
var display = _dalamudUtil.IsOnFrameworkThread
? ToDisplay(entry)
: _dalamudUtil.RunOnFrameworkThread(() => ToDisplay(entry)).GetAwaiter().GetResult();
Mediator.Publish(new PairRequestsUpdatedMessage());
return display;
}
public IReadOnlyList<PairRequestDisplay> GetActiveRequests()
{
List<PairRequestEntry> entries;
lock (_syncRoot)
{
CleanupExpiredUnsafe();
entries = _requests
.OrderByDescending(r => r.ReceivedAt)
.ToList();
}
return _dalamudUtil.IsOnFrameworkThread
? entries.Select(ToDisplay).ToList()
: _dalamudUtil.RunOnFrameworkThread(() => entries.Select(ToDisplay).ToList()).GetAwaiter().GetResult();
}
public bool RemoveRequest(string hashedCid)
{
bool removed;
lock (_syncRoot)
{
removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0;
if (removed)
{
_displayNameCache.Remove(hashedCid);
}
}
if (removed)
{
Mediator.Publish(new PairRequestsUpdatedMessage());
}
return removed;
}
public bool HasPendingRequests()
{
lock (_syncRoot)
{
CleanupExpiredUnsafe();
return _requests.Count > 0;
}
}
private PairRequestDisplay ToDisplay(PairRequestEntry entry)
{
var displayName = ResolveDisplayName(entry.HashedCid);
var message = FormatMessage(entry.MessageTemplate, displayName);
return new PairRequestDisplay(entry.HashedCid, displayName, message, entry.ReceivedAt);
}
private string ResolveDisplayName(string hashedCid)
{
if (string.IsNullOrWhiteSpace(hashedCid))
{
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))
{
var worldName = _dalamudUtil.GetWorldNameFromPlayerAddress(address);
return !string.IsNullOrWhiteSpace(worldName)
? $"{name} @ {worldName}"
: name;
}
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)
{
if (!string.IsNullOrWhiteSpace(pair.PlayerName))
{
return pair.PlayerName;
}
if (!string.IsNullOrWhiteSpace(pair.UserData.AliasOrUID))
{
return pair.UserData.AliasOrUID;
}
}
return string.Empty;
}
private static string FormatMessage(string template, string displayName)
{
var safeName = string.IsNullOrWhiteSpace(displayName) ? "Someone" : displayName;
template ??= string.Empty;
const string placeholder = "{DisplayName}";
if (!string.IsNullOrEmpty(template) && template.Contains(placeholder, StringComparison.Ordinal))
{
return template.Replace(placeholder, safeName, StringComparison.Ordinal);
}
if (!string.IsNullOrWhiteSpace(template))
{
return $"{safeName}: {template}";
}
return $"{safeName} sent you a pair request.";
}
private bool CleanupExpiredUnsafe()
{
if (_requests.Count == 0)
{
return false;
}
var now = DateTime.UtcNow;
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)
{
_ = Task.Run(async () =>
{
try
{
await _apiController.Value.TryPairWithContentId(hashedCid).ConfigureAwait(false);
RemoveRequest(hashedCid);
var displayText = string.IsNullOrEmpty(displayName) ? hashedCid : displayName;
Mediator.Publish(new NotificationMessage(
"Pair request accepted",
$"Sent a pair request back to {displayText}.",
NotificationType.Info,
TimeSpan.FromSeconds(3)));
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to accept pair request for {HashedCid}", hashedCid);
Mediator.Publish(new NotificationMessage(
"Failed to Accept Pair Request",
ex.Message,
NotificationType.Error,
TimeSpan.FromSeconds(5)));
}
});
}
public void DeclinePairRequest(string hashedCid, string displayName)
{
RemoveRequest(hashedCid);
Mediator.Publish(new NotificationMessage("Pair request declined",
"Declined " + displayName + "'s pending pair request.",
NotificationType.Info,
TimeSpan.FromSeconds(3)));
Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid);
}
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));
}
}
@@ -122,10 +122,10 @@ public sealed class PerformanceCollectorService : IHostedService
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 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);

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