Compare commits

..

267 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
CakeAndBanana
98c3a2c7f8 Added syncshell profile related items. 2025-10-10 06:42:59 +02:00
195 changed files with 36246 additions and 8505 deletions

View File

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

18
.gitmodules vendored
View File

@@ -1,6 +1,18 @@
[submodule "LightlessAPI"] [submodule "LightlessAPI"]
path = LightlessAPI path = LightlessAPI
url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git
[submodule "PenumbraAPI"] [submodule "Penumbra.GameData"]
path = PenumbraAPI path = Penumbra.GameData
url = https://github.com/Ottermandias/Penumbra.Api.git 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 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.1.32328.378 VisualStudioVersion = 18.0.11217.181
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
@@ -12,40 +12,110 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync", "LightlessS
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}"
EndProject 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64 Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64 Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Release|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.ActiveCfg = Debug|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.Build.0 = 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.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|x64
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.Build.0 = 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 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.Build.0 = 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 {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.ActiveCfg = Release|Any CPU
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.Build.0 = Debug|x64 {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.Build.0 = Release|Any CPU
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.ActiveCfg = Debug|x64 {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.Build.0 = Debug|x64 {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.Build.0 = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.ActiveCfg = Release|x64 {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.Build.0 = Release|x64 {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.Build.0 = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.ActiveCfg = Release|x64 {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.ActiveCfg = Debug|x64
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.Build.0 = Release|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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE 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

@@ -72,7 +72,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{ {
while (_dalamudUtil.IsOnFrameworkThread && !token.IsCancellationRequested) while (_dalamudUtil.IsOnFrameworkThread && !token.IsCancellationRequested)
{ {
await Task.Delay(1).ConfigureAwait(false); await Task.Delay(1, token).ConfigureAwait(false);
} }
RecalculateFileCacheSize(token); RecalculateFileCacheSize(token);
@@ -101,8 +101,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
} }
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null); record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
public void StopMonitoring() public void StopMonitoring()
{ {
@@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public bool StorageisNTFS { get; private set; } = false; public bool StorageisNTFS { get; private set; } = false;
public bool StorageIsBtrfs { get ; private set; } = false;
public void StartLightlessWatcher(string? lightlessPath) public void StartLightlessWatcher(string? lightlessPath)
{ {
LightlessWatcher?.Dispose(); 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."); Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
return; return;
} }
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName); if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtil.IsWine)
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase); {
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS); 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); Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
LightlessWatcher = new() LightlessWatcher = new()
@@ -248,6 +259,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private CancellationTokenSource _penumbraFswCts = new(); private CancellationTokenSource _penumbraFswCts = new();
private CancellationTokenSource _lightlessFswCts = new(); private CancellationTokenSource _lightlessFswCts = new();
public FileSystemWatcher? PenumbraWatcher { get; private set; } public FileSystemWatcher? PenumbraWatcher { get; private set; }
public FileSystemWatcher? LightlessWatcher { get; private set; } public FileSystemWatcher? LightlessWatcher { get; private set; }
@@ -392,51 +404,140 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void RecalculateFileCacheSize(CancellationToken token) 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; FileCacheSize = 0;
return; return;
} }
FileCacheSize = -1; FileCacheSize = -1;
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName); bool isWine = _dalamudUtil?.IsWine ?? false;
try 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) 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)) var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
.OrderBy(f => f.LastAccessTime).ToList(); .Select(f => new FileInfo(f))
FileCacheSize = files .OrderBy(f => f.LastAccessTime)
.Sum(f => .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(); token.ThrowIfCancellationRequested();
try 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); var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes)
if (FileCacheSize < maxCacheInBytes) return; return;
var maxCacheBuffer = maxCacheInBytes * 0.05d; var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0)
{ {
var oldestFile = files[0]; var oldestFile = files[0];
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
File.Delete(oldestFile.FullName); try
files.Remove(oldestFile); {
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) protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); base.Dispose(disposing);
_scanCancellationTokenSource?.Cancel(); // Disposing of file system watchers
PenumbraWatcher?.Dispose(); PenumbraWatcher?.Dispose();
LightlessWatcher?.Dispose(); LightlessWatcher?.Dispose();
// Disposing of cancellation token sources
_scanCancellationTokenSource?.CancelDispose();
_scanCancellationTokenSource?.Dispose();
_penumbraFswCts?.CancelDispose(); _penumbraFswCts?.CancelDispose();
_penumbraFswCts?.Dispose();
_lightlessFswCts?.CancelDispose(); _lightlessFswCts?.CancelDispose();
_lightlessFswCts?.Dispose();
_periodicCalculationTokenSource?.CancelDispose(); _periodicCalculationTokenSource?.CancelDispose();
_periodicCalculationTokenSource?.Dispose();
} }
private void FullFileScan(CancellationToken ct) private void FullFileScan(CancellationToken ct)
@@ -539,7 +647,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
List<FileCacheEntity> entitiesToRemove = []; List<FileCacheEntity> entitiesToRemove = [];
List<FileCacheEntity> entitiesToUpdate = []; List<FileCacheEntity> entitiesToUpdate = [];
object sync = new(); Lock sync = new();
Thread[] workerThreads = new Thread[threadCount]; Thread[] workerThreads = new Thread[threadCount];
ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches()); ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches());
@@ -644,44 +752,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (ct.IsCancellationRequested) return; if (ct.IsCancellationRequested) return;
// scan new files var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
if (allScannedFiles.Any(c => !c.Value)) foreach (var cachePath in newFiles)
{ {
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key), if (ct.IsCancellationRequested) break;
new ParallelOptions() ProcessOne(cachePath);
{ Interlocked.Increment(ref _currentFileProgress);
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) return; Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
if (!_ipcManager.Penumbra.APIAvailable) void ProcessOne(string? cachePath)
{ {
Logger.LogWarning("Penumbra not available"); if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
return; {
} Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
try if (!_ipcManager.Penumbra.APIAvailable)
{ {
var entry = _fileDbManager.CreateFileEntry(cachePath); Logger.LogWarning("Penumbra not available");
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath); return;
} }
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
Interlocked.Increment(ref _currentFileProgress); try
}); {
var entry = _fileDbManager.CreateFileEntry(cachePath);
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value)); 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"); Logger.LogDebug("Scan complete");

View File

@@ -18,6 +18,7 @@ public sealed class FileCacheManager : IHostedService
public const string PenumbraPrefix = "{penumbra}"; public const string PenumbraPrefix = "{penumbra}";
private const int FileCacheVersion = 1; private const int FileCacheVersion = 1;
private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:"; private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:";
private readonly SemaphoreSlim _fileWriteSemaphore = new(1, 1);
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly string _csvPath; private readonly string _csvPath;
@@ -27,6 +28,7 @@ public sealed class FileCacheManager : IHostedService
private readonly Lock _fileWriteLock = new(); private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager; private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger; private readonly ILogger<FileCacheManager> _logger;
private bool _csvHeaderEnsured;
public string CacheFolder => _configService.Current.CacheFolder; public string CacheFolder => _configService.Current.CacheFolder;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator) public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
@@ -40,11 +42,8 @@ public sealed class FileCacheManager : IHostedService
private string CsvBakPath => _csvPath + ".bak"; private string CsvBakPath => _csvPath + ".bak";
private static string NormalizeSeparators(string path) private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
{
return path.Replace("/", "\\", StringComparison.Ordinal)
.Replace("\\\\", "\\", StringComparison.Ordinal); .Replace("\\\\", "\\", StringComparison.Ordinal);
}
private static string NormalizePrefixedPathKey(string prefixedPath) private static string NormalizePrefixedPathKey(string prefixedPath)
{ {
@@ -133,13 +132,9 @@ public sealed class FileCacheManager : IHostedService
chosenLength = penumbraMatch; chosenLength = penumbraMatch;
} }
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch)) if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch) && cacheMatch > chosenLength)
{ {
if (cacheMatch > chosenLength) chosenPrefixed = cachePrefixed;
{
chosenPrefixed = cachePrefixed;
chosenLength = cacheMatch;
}
} }
return NormalizePrefixedPathKey(chosenPrefixed ?? normalized); return NormalizePrefixedPathKey(chosenPrefixed ?? normalized);
@@ -175,69 +170,126 @@ public sealed class FileCacheManager : IHostedService
return CreateFileCacheEntity(fi, prefixedPath); return CreateFileCacheEntity(fi, prefixedPath);
} }
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).ToList(); public List<FileCacheEntity> GetAllFileCaches() => [.. _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null))];
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true) public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
{ {
List<FileCacheEntity> output = []; var output = new List<FileCacheEntity>();
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities))
return output;
foreach (var fileCache in fileCacheEntities.Values
.Where(c => !ignoreCacheEntries || !c.IsCacheEntry))
{ {
foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList()) if (!validate)
{ {
if (!validate) output.Add(fileCache);
{ continue;
output.Add(fileCache); }
}
else var validated = GetValidatedFileCache(fileCache);
{ if (validated != null)
var validated = GetValidatedFileCache(fileCache); output.Add(validated);
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; 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))); _lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage"); _logger.LogInformation("Validating local storage");
var cacheEntries = _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).Where(v => v.IsCacheEntry).ToList();
List<FileCacheEntity> brokenEntities = []; var cacheEntries = _fileCaches.Values
int i = 0; .SelectMany(v => v.Values)
foreach (var fileCache in cacheEntries) .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 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)) 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); 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); brokenEntities.Add(fileCache);
} }
} }).ConfigureAwait(false);
foreach (var brokenEntity in brokenEntities) foreach (var brokenEntity in brokenEntities)
{ {
@@ -249,12 +301,14 @@ public sealed class FileCacheManager : IHostedService
} }
catch (Exception ex) 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))); _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) public string GetCacheFilePath(string hash, string extension)
@@ -401,7 +455,7 @@ public sealed class FileCacheManager : IHostedService
var fi = new FileInfo(fileCache.ResolvedFilepath); var fi = new FileInfo(fileCache.ResolvedFilepath);
fileCache.Size = fi.Length; fileCache.Size = fi.Length;
fileCache.CompressedSize = null; 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); fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
} }
RemoveHashedFile(oldHash, prefixedPath); RemoveHashedFile(oldHash, prefixedPath);
@@ -452,6 +506,44 @@ public sealed class FileCacheManager : IHostedService
} }
} }
public async Task WriteOutFullCsvAsync(CancellationToken cancellationToken = default)
{
await _fileWriteSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var sb = new StringBuilder();
sb.AppendLine(BuildVersionHeader());
foreach (var entry in _fileCaches.Values
.SelectMany(k => k.Values)
.OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
{
sb.AppendLine(entry.CsvEntry);
}
if (File.Exists(_csvPath))
{
File.Copy(_csvPath, CsvBakPath, overwrite: true);
}
try
{
await File.WriteAllTextAsync(_csvPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
File.Delete(CsvBakPath);
}
catch
{
await File.WriteAllTextAsync(CsvBakPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
}
}
finally
{
_fileWriteSemaphore.Release();
}
}
private void EnsureCsvHeaderLocked() private void EnsureCsvHeaderLocked()
{ {
if (!File.Exists(_csvPath)) if (!File.Exists(_csvPath))
@@ -462,6 +554,7 @@ public sealed class FileCacheManager : IHostedService
string[] existingLines = File.ReadAllLines(_csvPath); string[] existingLines = File.ReadAllLines(_csvPath);
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion) if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
{ {
_csvHeaderEnsured = true;
return; return;
} }
@@ -481,6 +574,18 @@ public sealed class FileCacheManager : IHostedService
} }
File.WriteAllText(_csvPath, rebuilt.ToString()); File.WriteAllText(_csvPath, rebuilt.ToString());
_csvHeaderEnsured = true;
}
private void EnsureCsvHeaderLockedCached()
{
if (_csvHeaderEnsured)
{
return;
}
EnsureCsvHeaderLocked();
_csvHeaderEnsured = true;
} }
private void BackupUnsupportedCache(string suffix) private void BackupUnsupportedCache(string suffix)
@@ -531,7 +636,7 @@ public sealed class FileCacheManager : IHostedService
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null) 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); var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length);
entity = ReplacePathPrefixes(entity); entity = ReplacePathPrefixes(entity);
AddHashedFile(entity); AddHashedFile(entity);
@@ -539,12 +644,13 @@ public sealed class FileCacheManager : IHostedService
{ {
if (!File.Exists(_csvPath)) if (!File.Exists(_csvPath))
{ {
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry }); File.WriteAllLines(_csvPath, [BuildVersionHeader(), entity.CsvEntry]);
_csvHeaderEnsured = true;
} }
else else
{ {
EnsureCsvHeaderLocked(); EnsureCsvHeaderLockedCached();
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); File.AppendAllLines(_csvPath, [entity.CsvEntry]);
} }
} }
var result = GetFileCacheByPath(fileInfo.FullName); var result = GetFileCacheByPath(fileInfo.FullName);
@@ -555,11 +661,17 @@ public sealed class FileCacheManager : IHostedService
private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache) private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache)
{ {
var resultingFileCache = ReplacePathPrefixes(fileCache); var resultingFileCache = ReplacePathPrefixes(fileCache);
//_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath);
resultingFileCache = Validate(resultingFileCache); resultingFileCache = Validate(resultingFileCache);
return 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) private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache)
{ {
if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase)) if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
@@ -582,6 +694,7 @@ public sealed class FileCacheManager : IHostedService
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
return null; return null;
} }
var file = new FileInfo(fileCache.ResolvedFilepath); var file = new FileInfo(fileCache.ResolvedFilepath);
if (!file.Exists) if (!file.Exists)
{ {
@@ -589,7 +702,8 @@ public sealed class FileCacheManager : IHostedService
return null; 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); UpdateHashedFile(fileCache);
} }
@@ -597,7 +711,34 @@ public sealed class FileCacheManager : IHostedService
return fileCache; 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"); _logger.LogInformation("Starting FileCacheManager");
@@ -648,14 +789,14 @@ public sealed class FileCacheManager : IHostedService
try try
{ {
_logger.LogInformation("Attempting to read {csvPath}", _csvPath); _logger.LogInformation("Attempting to read {csvPath}", _csvPath);
entries = File.ReadAllLines(_csvPath); entries = await File.ReadAllLinesAsync(_csvPath, cancellationToken).ConfigureAwait(false);
success = true; success = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
attempts++; attempts++;
_logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath); _logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath);
Task.Delay(100, cancellationToken); await Task.Delay(100, cancellationToken).ConfigureAwait(false);
} }
} }
@@ -682,7 +823,7 @@ public sealed class FileCacheManager : IHostedService
BackupUnsupportedCache("invalid-version"); BackupUnsupportedCache("invalid-version");
parseEntries = false; parseEntries = false;
rewriteRequired = true; rewriteRequired = true;
entries = Array.Empty<string>(); entries = [];
} }
else if (parsedVersion != FileCacheVersion) else if (parsedVersion != FileCacheVersion)
{ {
@@ -690,7 +831,7 @@ public sealed class FileCacheManager : IHostedService
BackupUnsupportedCache($"v{parsedVersion}"); BackupUnsupportedCache($"v{parsedVersion}");
parseEntries = false; parseEntries = false;
rewriteRequired = true; rewriteRequired = true;
entries = Array.Empty<string>(); entries = [];
} }
else else
{ {
@@ -770,18 +911,18 @@ public sealed class FileCacheManager : IHostedService
if (rewriteRequired) if (rewriteRequired)
{ {
WriteOutFullCsv(); await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
} }
} }
_logger.LogInformation("Started FileCacheManager"); _logger.LogInformation("Started FileCacheManager");
_lightlessMediator.Publish(new FileCacheInitializedMessage());
return Task.CompletedTask; await Task.CompletedTask.ConfigureAwait(false);
} }
public Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
WriteOutFullCsv(); await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
return Task.CompletedTask; 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, Valid,
RequireUpdate, RequireUpdate,
RequireDeletion, RequireDeletion,
RequireRehash
} }

View File

@@ -3,11 +3,17 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Data; using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Utils; using LightlessSync.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; 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; namespace LightlessSync.FileCache;
@@ -17,21 +23,29 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal); private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
private readonly TransientConfigService _configurationService; private readonly TransientConfigService _configurationService;
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"]; private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"]; private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = []; 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 ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
private uint _lastClassJobId = uint.MaxValue; private uint _lastClassJobId = uint.MaxValue;
public bool IsTransientRecording { get; private set; } = false; public bool IsTransientRecording { get; private set; } = false;
public TransientResourceManager(ILogger<TransientResourceManager> logger, TransientConfigService configurationService, 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; _configurationService = configurationService;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent); 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<PenumbraModSettingChangedMessage>(this, (_) => Manager_PenumbraModSettingChanged());
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate()); Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate());
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) => Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
@@ -44,6 +58,11 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (!msg.OwnedObject) return; if (!msg.OwnedObject) return;
_playerRelatedPointers.Remove(msg.GameObjectHandler); _playerRelatedPointers.Remove(msg.GameObjectHandler);
}); });
foreach (var descriptor in _actorObjectService.PlayerDescriptors)
{
HandleActorTracked(descriptor);
}
} }
private TransientConfig.TransientPlayerConfig PlayerConfig private TransientConfig.TransientPlayerConfig PlayerConfig
@@ -123,12 +142,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
return; return;
} }
var transientResources = resources.ToList(); List<string> transientResources;
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count); lock (resources)
List<string> newlyAddedGamePaths = resources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
foreach (var gamePath in transientResources)
{ {
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; bool saveConfig = false;
@@ -161,7 +189,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_configurationService.Save(); _configurationService.Save();
} }
TransientResources[objectKind].Clear(); lock (resources)
{
resources.Clear();
}
} }
public void RemoveTransientResource(ObjectKind objectKind, string path) public void RemoveTransientResource(ObjectKind objectKind, string path)
@@ -241,16 +272,46 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
TransientResources.Clear(); TransientResources.Clear();
SemiTransientResources.Clear(); SemiTransientResources.Clear();
lock (_ownedHandlerLock)
{
foreach (var handler in _ownedHandlers.Values)
{
handler.Dispose();
}
_ownedHandlers.Clear();
}
} }
private void DalamudUtil_FrameworkUpdate() private void DalamudUtil_FrameworkUpdate()
{ {
_cachedFrameAddresses = new(_playerRelatedPointers.Where(k => k.Address != nint.Zero).ToDictionary(c => c.Address, c => c.ObjectKind));
lock (_cacheAdditionLock) lock (_cacheAdditionLock)
{ {
_cachedHandledPaths.Clear(); _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) if (_lastClassJobId != _dalamudUtil.ClassJobId)
{ {
_lastClassJobId = _dalamudUtil.ClassJobId; _lastClassJobId = _dalamudUtil.ClassJobId;
@@ -259,16 +320,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
value?.Clear(); value?.Clear();
} }
// reload config for current new classjob
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = [.. 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()); Logger.LogDebug("Object not present anymore: {kind}", kind.ToString());
} }
@@ -292,6 +352,116 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null; _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) private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{ {
var gamePath = msg.GamePath.ToLowerInvariant(); var gamePath = msg.GamePath.ToLowerInvariant();
@@ -383,21 +553,30 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void SendTransients(nint gameObject, ObjectKind objectKind) private void SendTransients(nint gameObject, ObjectKind objectKind)
{ {
_sendTransientCts.Cancel();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
_sendTransientCts?.Cancel(); try
_sendTransientCts?.Dispose();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
foreach (var kvp in TransientResources)
{ {
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
if (TransientResources.TryGetValue(objectKind, out var values) && values.Any()) if (TransientResources.TryGetValue(objectKind, out var values) && values.Any())
{ {
Logger.LogTrace("Sending Transients for {kind}", objectKind); Logger.LogTrace("Sending Transients for {kind}", objectKind);
Mediator.Publish(new TransientResourceChangedMessage(gameObject)); Mediator.Publish(new TransientResourceChangedMessage(gameObject));
} }
} }
catch (TaskCanceledException)
{
}
catch (System.OperationCanceledException)
{
}
}); });
} }

View File

@@ -20,7 +20,10 @@ internal sealed class DalamudLogger : ILogger
_hasModifiedGameFiles = hasModifiedGameFiles; _hasModifiedGameFiles = hasModifiedGameFiles;
} }
public IDisposable BeginScope<TState>(TState state) => default!; IDisposable? ILogger.BeginScope<TState>(TState state)
{
return default!;
}
public bool IsEnabled(LogLevel logLevel) 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;
using Dalamud.Plugin.Ipc;
using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.CharaData;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Numerics; using System.Numerics;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace LightlessSync.Interop.Ipc; 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 ILogger<IpcCallerBrio> _logger;
private readonly DalamudUtilService _dalamudUtilService; private readonly DalamudUtilService _dalamudUtilService;
private readonly ICallGateSubscriber<(int, int)> _brioApiVersion;
private readonly ICallGateSubscriber<bool, bool, bool, Task<IGameObject>> _brioSpawnActorAsync; private readonly ApiVersion _apiVersion;
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 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, public IpcCallerBrio(ILogger<IpcCallerBrio> logger, IDalamudPluginInterface dalamudPluginInterface,
DalamudUtilService dalamudUtilService) DalamudUtilService dalamudUtilService, LightlessMediator mediator) : base(logger, mediator, dalamudPluginInterface, BrioDescriptor)
{ {
_logger = logger; _logger = logger;
_dalamudUtilService = dalamudUtilService; _dalamudUtilService = dalamudUtilService;
_brioApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("Brio.ApiVersion"); _apiVersion = new ApiVersion(dalamudPluginInterface);
_brioSpawnActorAsync = dalamudPluginInterface.GetIpcSubscriber<bool, bool, bool, Task<IGameObject>>("Brio.Actor.SpawnExAsync"); _spawnActor = new SpawnActor(dalamudPluginInterface);
_brioDespawnActor = dalamudPluginInterface.GetIpcSubscriber<IGameObject, bool>("Brio.Actor.Despawn"); _despawnActor = new DespawnActor(dalamudPluginInterface);
_brioSetModelTransform = dalamudPluginInterface.GetIpcSubscriber<IGameObject, Vector3?, Quaternion?, Vector3?, bool, bool>("Brio.Actor.SetModelTransform");
_brioGetModelTransform = dalamudPluginInterface.GetIpcSubscriber<IGameObject, (Vector3?, Quaternion?, Vector3?)>("Brio.Actor.GetModelTransform"); _setModelTransform = new SetModelTransform(dalamudPluginInterface);
_brioGetPoseAsJson = dalamudPluginInterface.GetIpcSubscriber<IGameObject, string>("Brio.Actor.Pose.GetPoseAsJson"); _getModelTransform = new GetModelTransform(dalamudPluginInterface);
_brioSetPoseFromJson = dalamudPluginInterface.GetIpcSubscriber<IGameObject, string, bool, bool>("Brio.Actor.Pose.LoadFromJson");
_brioFreezeActor = dalamudPluginInterface.GetIpcSubscriber<IGameObject, bool>("Brio.Actor.Freeze"); _getPoseAsJson = new GetPoseAsJson(dalamudPluginInterface);
_brioFreezePhysics = dalamudPluginInterface.GetIpcSubscriber<bool>("Brio.FreezePhysics"); _setPoseFromJson = new LoadPoseFromJson(dalamudPluginInterface);
_freezeActor = new FreezeActor(dalamudPluginInterface);
_freezePhysics = new FreezePhysics(dalamudPluginInterface);
CheckAPI(); CheckAPI();
} }
public void CheckAPI()
{
try
{
var version = _brioApiVersion.InvokeFunc();
APIAvailable = (version.Item1 == 2 && version.Item2 >= 0);
}
catch
{
APIAvailable = false;
}
}
public async Task<IGameObject?> SpawnActorAsync() public async Task<IGameObject?> SpawnActorAsync()
{ {
if (!APIAvailable) return null; if (!APIAvailable) return null;
_logger.LogDebug("Spawning Brio Actor"); _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) public async Task<bool> DespawnActorAsync(nint address)
@@ -72,7 +66,7 @@ public sealed class IpcCallerBrio : IIpcCaller
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return false; if (gameObject == null) return false;
_logger.LogDebug("Despawning Brio Actor {actor}", gameObject.Name.TextValue); _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) public async Task<bool> ApplyTransformAsync(nint address, WorldData data)
@@ -82,7 +76,7 @@ public sealed class IpcCallerBrio : IIpcCaller
if (gameObject == null) return false; if (gameObject == null) return false;
_logger.LogDebug("Applying Transform to Actor {actor}", gameObject.Name.TextValue); _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 Vector3(data.PositionX, data.PositionY, data.PositionZ),
new Quaternion(data.RotationX, data.RotationY, data.RotationZ, data.RotationW), new Quaternion(data.RotationX, data.RotationY, data.RotationZ, data.RotationW),
new Vector3(data.ScaleX, data.ScaleY, data.ScaleZ), false)).ConfigureAwait(false); new Vector3(data.ScaleX, data.ScaleY, data.ScaleZ), false)).ConfigureAwait(false);
@@ -93,8 +87,7 @@ public sealed class IpcCallerBrio : IIpcCaller
if (!APIAvailable) return default; if (!APIAvailable) return default;
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return default; if (gameObject == null) return default;
var data = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetModelTransform.InvokeFunc(gameObject)).ConfigureAwait(false); var data = await _dalamudUtilService.RunOnFrameworkThread(() => _getModelTransform.Invoke(gameObject)).ConfigureAwait(false);
//_logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue);
return new WorldData() return new WorldData()
{ {
@@ -118,7 +111,7 @@ public sealed class IpcCallerBrio : IIpcCaller
if (gameObject == null) return null; if (gameObject == null) return null;
_logger.LogDebug("Getting Pose from Actor {actor}", gameObject.Name.TextValue); _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) 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); _logger.LogDebug("Setting Pose to Actor {actor}", gameObject.Name.TextValue);
var applicablePose = JsonNode.Parse(pose)!; 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()); applicablePose["ModelDifference"] = JsonNode.Parse(JsonNode.Parse(currentPose)!["ModelDifference"]!.ToJsonString());
await _dalamudUtilService.RunOnFrameworkThread(() => await _dalamudUtilService.RunOnFrameworkThread(() =>
{ {
_brioFreezeActor.InvokeFunc(gameObject); _freezeActor.Invoke(gameObject);
_brioFreezePhysics.InvokeFunc(); _freezePhysics.Invoke();
}).ConfigureAwait(false); }).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;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using Dalamud.Utility; using Dalamud.Utility;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -9,8 +10,10 @@ using System.Text;
namespace LightlessSync.Interop.Ipc; 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<(int, int)> _customizePlusApiVersion;
private readonly ICallGateSubscriber<ushort, (int, Guid?)> _customizePlusGetActiveProfile; private readonly ICallGateSubscriber<ushort, (int, Guid?)> _customizePlusGetActiveProfile;
private readonly ICallGateSubscriber<Guid, (int, string?)> _customizePlusGetProfileById; private readonly ICallGateSubscriber<Guid, (int, string?)> _customizePlusGetProfileById;
@@ -23,7 +26,7 @@ public sealed class IpcCallerCustomize : IIpcCaller
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
public IpcCallerCustomize(ILogger<IpcCallerCustomize> logger, IDalamudPluginInterface dalamudPluginInterface, 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"); _customizePlusApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("CustomizePlus.General.GetApiVersion");
_customizePlusGetActiveProfile = dalamudPluginInterface.GetIpcSubscriber<ushort, (int, Guid?)>("CustomizePlus.Profile.GetActiveProfileIdOnCharacter"); _customizePlusGetActiveProfile = dalamudPluginInterface.GetIpcSubscriber<ushort, (int, Guid?)>("CustomizePlus.Profile.GetActiveProfileIdOnCharacter");
@@ -41,8 +44,6 @@ public sealed class IpcCallerCustomize : IIpcCaller
CheckAPI(); CheckAPI();
} }
public bool APIAvailable { get; private set; } = false;
public async Task RevertAsync(nint character) public async Task RevertAsync(nint character)
{ {
if (!APIAvailable) return; if (!APIAvailable) return;
@@ -113,16 +114,25 @@ public sealed class IpcCallerCustomize : IIpcCaller
return Convert.ToBase64String(Encoding.UTF8.GetBytes(scale)); 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 try
{ {
var version = _customizePlusApiVersion.InvokeFunc(); 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)); _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); _customizePlusOnScaleUpdate.Unsubscribe(OnCustomizePlusScaleChange);
} }
} }

View File

@@ -2,6 +2,7 @@
using Dalamud.Plugin; using Dalamud.Plugin;
using Glamourer.Api.Helpers; using Glamourer.Api.Helpers;
using Glamourer.Api.IpcSubscribers; using Glamourer.Api.IpcSubscribers;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
@@ -10,8 +11,9 @@ using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc; 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 ILogger<IpcCallerGlamourer> _logger;
private readonly IDalamudPluginInterface _pi; private readonly IDalamudPluginInterface _pi;
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
@@ -31,7 +33,7 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC
private readonly uint LockCode = 0x6D617265; private readonly uint LockCode = 0x6D617265;
public IpcCallerGlamourer(ILogger<IpcCallerGlamourer> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator, 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); _glamourerApiVersions = new ApiVersion(pi);
_glamourerGetAllCustomization = new GetStateBase64(pi); _glamourerGetAllCustomization = new GetStateBase64(pi);
@@ -62,47 +64,6 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC
_glamourerStateChanged?.Dispose(); _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) 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; 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) private void GlamourerChanged(nint address)
{ {
_lightlessMediator.Publish(new GlamourerChangedMessage(address)); _lightlessMediator.Publish(new GlamourerChangedMessage(address));

View File

@@ -1,13 +1,16 @@
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc; 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 ILogger<IpcCallerHeels> _logger;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
@@ -18,6 +21,7 @@ public sealed class IpcCallerHeels : IIpcCaller
private readonly ICallGateSubscriber<int, object?> _heelsUnregisterPlayer; private readonly ICallGateSubscriber<int, object?> _heelsUnregisterPlayer;
public IpcCallerHeels(ILogger<IpcCallerHeels> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator) public IpcCallerHeels(ILogger<IpcCallerHeels> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator)
: base(logger, lightlessMediator, pi, HeelsDescriptor)
{ {
_logger = logger; _logger = logger;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
@@ -32,8 +36,26 @@ public sealed class IpcCallerHeels : IIpcCaller
CheckAPI(); 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) private void HeelsOffsetChange(string offset)
{ {
@@ -74,20 +96,14 @@ public sealed class IpcCallerHeels : IIpcCaller
}).ConfigureAwait(false); }).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); _heelsOffsetUpdate.Unsubscribe(HeelsOffsetChange);
} }
} }

View File

@@ -1,6 +1,7 @@
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -8,8 +9,10 @@ using System.Text;
namespace LightlessSync.Interop.Ipc; 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<(uint major, uint minor)> _honorificApiVersion;
private readonly ICallGateSubscriber<int, object> _honorificClearCharacterTitle; private readonly ICallGateSubscriber<int, object> _honorificClearCharacterTitle;
private readonly ICallGateSubscriber<object> _honorificDisposing; private readonly ICallGateSubscriber<object> _honorificDisposing;
@@ -22,7 +25,7 @@ public sealed class IpcCallerHonorific : IIpcCaller
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
public IpcCallerHonorific(ILogger<IpcCallerHonorific> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, public IpcCallerHonorific(ILogger<IpcCallerHonorific> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator) LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, HonorificDescriptor)
{ {
_logger = logger; _logger = logger;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
@@ -41,23 +44,14 @@ public sealed class IpcCallerHonorific : IIpcCaller
CheckAPI(); CheckAPI();
} }
protected override void Dispose(bool disposing)
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
{ {
try base.Dispose(disposing);
if (!disposing)
{ {
APIAvailable = _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 }; return;
} }
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_honorificLocalCharacterTitleChanged.Unsubscribe(OnHonorificLocalCharacterTitleChanged); _honorificLocalCharacterTitleChanged.Unsubscribe(OnHonorificLocalCharacterTitleChanged);
_honorificDisposing.Unsubscribe(OnHonorificDisposing); _honorificDisposing.Unsubscribe(OnHonorificDisposing);
_honorificReady.Unsubscribe(OnHonorificReady); _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() private void OnHonorificDisposing()
{ {
_lightlessMediator.Publish(new HonorificMessage(string.Empty)); _lightlessMediator.Publish(new HonorificMessage(string.Empty));

View File

@@ -1,14 +1,17 @@
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc; 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<int> _moodlesApiVersion;
private readonly ICallGateSubscriber<IPlayerCharacter, object> _moodlesOnChange; private readonly ICallGateSubscriber<IPlayerCharacter, object> _moodlesOnChange;
private readonly ICallGateSubscriber<nint, string> _moodlesGetStatus; private readonly ICallGateSubscriber<nint, string> _moodlesGetStatus;
@@ -19,7 +22,7 @@ public sealed class IpcCallerMoodles : IIpcCaller
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
public IpcCallerMoodles(ILogger<IpcCallerMoodles> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, public IpcCallerMoodles(ILogger<IpcCallerMoodles> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator) LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, MoodlesDescriptor)
{ {
_logger = logger; _logger = logger;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
@@ -41,22 +44,14 @@ public sealed class IpcCallerMoodles : IIpcCaller
_lightlessMediator.Publish(new MoodlesMessage(character.Address)); _lightlessMediator.Publish(new MoodlesMessage(character.Address));
} }
public bool APIAvailable { get; private set; } = false; protected override void Dispose(bool disposing)
public void CheckAPI()
{ {
try base.Dispose(disposing);
if (!disposing)
{ {
APIAvailable = _moodlesApiVersion.InvokeFunc() == 3; return;
} }
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_moodlesOnChange.Unsubscribe(OnMoodlesChange); _moodlesOnChange.Unsubscribe(OnMoodlesChange);
} }
@@ -101,4 +96,25 @@ public sealed class IpcCallerMoodles : IIpcCaller
_logger.LogWarning(e, "Could not Set Moodles Status"); _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.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Api.Helpers; using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers; using Penumbra.Api.IpcSubscribers;
using System.Collections.Concurrent;
namespace LightlessSync.Interop.Ipc; namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCaller public sealed class IpcCallerPenumbra : IpcServiceBase
{ {
private readonly IDalamudPluginInterface _pi; private static readonly IpcServiceDescriptor PenumbraDescriptor = new("Penumbra", "Penumbra", new Version(1, 2, 0, 22));
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 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 GetEnabledState _penumbraEnabled;
private readonly GetPlayerMetaManipulations _penumbraGetMetaManipulations; private readonly GetModDirectory _penumbraGetModDirectory;
private readonly RedrawObject _penumbraRedraw; private readonly EventSubscriber _penumbraInit;
private readonly DeleteTemporaryCollection _penumbraRemoveTemporaryCollection; private readonly EventSubscriber _penumbraDispose;
private readonly RemoveTemporaryMod _penumbraRemoveTemporaryMod; private readonly EventSubscriber<ModSettingChange, Guid, string, bool> _penumbraModSettingChanged;
private readonly GetModDirectory _penumbraResolveModDir;
private readonly ResolvePlayerPathsAsync _penumbraResolvePaths;
private readonly GetGameObjectResourcePaths _penumbraResourcePaths;
public IpcCallerPenumbra(ILogger<IpcCallerPenumbra> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, private bool _shownPenumbraUnavailable;
LightlessMediator lightlessMediator, RedrawManager redrawManager) : base(logger, lightlessMediator) 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; _penumbraEnabled = new GetEnabledState(pluginInterface);
_dalamudUtil = dalamudUtil; _penumbraGetModDirectory = new GetModDirectory(pluginInterface);
_lightlessMediator = lightlessMediator; _penumbraInit = Initialized.Subscriber(pluginInterface, HandlePenumbraInitialized);
_redrawManager = redrawManager; _penumbraDispose = Disposed.Subscriber(pluginInterface, HandlePenumbraDisposed);
_penumbraInit = Initialized.Subscriber(pi, PenumbraInit); _penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged);
_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);
_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(); CheckAPI();
CheckModDirectory(); 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 string? ModDirectory
public void CheckAPI()
{ {
bool penumbraAvailable = false; get => _modDirectory;
try private set
{ {
var penumbraVersion = (_pi.InstalledPlugins if (string.Equals(_modDirectory, value, StringComparison.Ordinal))
.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
{ {
penumbraAvailable &= _penumbraEnabled.Invoke(); return;
}
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));
} }
_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() public void CheckModDirectory()
{ {
if (!APIAvailable) if (!APIAvailable)
{ {
ModDirectory = string.Empty; 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); base.Dispose(disposing);
_redrawManager.Cancel(); if (!disposing)
{
return;
}
_penumbraModSettingChanged.Dispose(); _penumbraModSettingChanged.Dispose();
_penumbraGameObjectResourcePathResolved.Dispose();
_penumbraDispose.Dispose(); _penumbraDispose.Dispose();
_penumbraInit.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.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc; 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 ILogger<IpcCallerPetNames> _logger;
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
@@ -24,7 +27,7 @@ public sealed class IpcCallerPetNames : IIpcCaller
private readonly ICallGateSubscriber<ushort, object> _clearPlayerData; private readonly ICallGateSubscriber<ushort, object> _clearPlayerData;
public IpcCallerPetNames(ILogger<IpcCallerPetNames> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, public IpcCallerPetNames(ILogger<IpcCallerPetNames> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
LightlessMediator lightlessMediator) LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, PetRenamerDescriptor)
{ {
_logger = logger; _logger = logger;
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
@@ -46,25 +49,6 @@ public sealed class IpcCallerPetNames : IIpcCaller
CheckAPI(); 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() private void OnPetNicknamesReady()
{ {
CheckAPI(); CheckAPI();
@@ -76,6 +60,34 @@ public sealed class IpcCallerPetNames : IIpcCaller
_lightlessMediator.Publish(new PetNamesMessage(string.Empty)); _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() public string GetLocalNames()
{ {
if (!APIAvailable) return string.Empty; if (!APIAvailable) return string.Empty;
@@ -149,8 +161,14 @@ public sealed class IpcCallerPetNames : IIpcCaller
_lightlessMediator.Publish(new PetNamesMessage(data)); _lightlessMediator.Publish(new PetNamesMessage(data));
} }
public void Dispose() protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing);
if (!disposing)
{
return;
}
_petnamesReady.Unsubscribe(OnPetNicknamesReady); _petnamesReady.Unsubscribe(OnPetNicknamesReady);
_petnamesDisposing.Unsubscribe(OnPetNicknamesDispose); _petnamesDisposing.Unsubscribe(OnPetNicknamesDispose);
_playerDataChanged.Unsubscribe(OnLocalPetNicknamesDataChange); _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;
using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Ipc;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
@@ -14,9 +15,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
private readonly ILogger<IpcProvider> _logger; private readonly ILogger<IpcProvider> _logger;
private readonly IDalamudPluginInterface _pi; private readonly IDalamudPluginInterface _pi;
private readonly CharaDataManager _charaDataManager; private readonly CharaDataManager _charaDataManager;
private ICallGateProvider<string, IGameObject, bool>? _loadFileProvider; private readonly List<IpcRegister> _ipcRegisters = [];
private ICallGateProvider<string, IGameObject, Task<bool>>? _loadFileAsyncProvider;
private ICallGateProvider<List<nint>>? _handledGameAddresses;
private readonly List<GameObjectHandler> _activeGameObjectHandlers = []; private readonly List<GameObjectHandler> _activeGameObjectHandlers = [];
public LightlessMediator Mediator { get; init; } public LightlessMediator Mediator { get; init; }
@@ -44,12 +43,9 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
_logger.LogInformation("Starting IpcProviderService"); _logger.LogInformation("Starting IpcProviderService");
_loadFileProvider = _pi.GetIpcProvider<string, IGameObject, bool>("LightlessSync.LoadMcdf"); _ipcRegisters.Add(RegisterFunc<string, IGameObject, bool>("LightlessSync.LoadMcdf", LoadMcdf));
_loadFileProvider.RegisterFunc(LoadMcdf); _ipcRegisters.Add(RegisterFunc<string, IGameObject, Task<bool>>("LightlessSync.LoadMcdfAsync", LoadMcdfAsync));
_loadFileAsyncProvider = _pi.GetIpcProvider<string, IGameObject, Task<bool>>("LightlessSync.LoadMcdfAsync"); _ipcRegisters.Add(RegisterFunc("LightlessSync.GetHandledAddresses", GetHandledAddresses));
_loadFileAsyncProvider.RegisterFunc(LoadMcdfAsync);
_handledGameAddresses = _pi.GetIpcProvider<List<nint>>("LightlessSync.GetHandledAddresses");
_handledGameAddresses.RegisterFunc(GetHandledAddresses);
_logger.LogInformation("Started IpcProviderService"); _logger.LogInformation("Started IpcProviderService");
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -57,9 +53,11 @@ public class IpcProvider : IHostedService, IMediatorSubscriber
public Task StopAsync(CancellationToken cancellationToken) public Task StopAsync(CancellationToken cancellationToken)
{ {
_logger.LogDebug("Stopping IpcProvider Service"); _logger.LogDebug("Stopping IpcProvider Service");
_loadFileProvider?.UnregisterFunc(); foreach (var register in _ipcRegisters)
_loadFileAsyncProvider?.UnregisterFunc(); {
_handledGameAddresses?.UnregisterFunc(); register.Dispose();
}
_ipcRegisters.Clear();
Mediator.UnsubscribeAll(this); Mediator.UnsubscribeAll(this);
return Task.CompletedTask; 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(); 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

@@ -2,6 +2,7 @@ using Dalamud.Game.Text;
using LightlessSync.UtilsEnum.Enum; using LightlessSync.UtilsEnum.Enum;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI; using LightlessSync.UI;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.LightlessConfiguration.Configurations; namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -48,6 +49,7 @@ public class LightlessConfig : ILightlessConfiguration
public int DownloadSpeedLimitInBytes { get; set; } = 0; public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps; public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
public bool PreferNotesOverNamesForVisible { get; set; } = false; public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default;
public float ProfileDelay { get; set; } = 1.5f; public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false; public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false; public bool ProfilesAllowNsfw { get; set; } = false;
@@ -61,12 +63,16 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowOnlineNotifications { get; set; } = false; public bool ShowOnlineNotifications { get; set; } = false;
public bool ShowOnlineNotificationsOnlyForIndividualPairs { get; set; } = true; public bool ShowOnlineNotificationsOnlyForIndividualPairs { get; set; } = true;
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false; public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
public bool ShowVisiblePairsGreenEye { get; set; } = false;
public bool ShowTransferBars { get; set; } = true; public bool ShowTransferBars { get; set; } = true;
public bool ShowTransferWindow { get; set; } = false; 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 UseNotificationsForDownloads { get; set; } = true;
public bool ShowUploading { get; set; } = true; public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true; public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableDirectDownloads { get; set; } = true;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30; public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12; public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true; public bool TransferBarsShowText { get; set; } = true;
@@ -86,6 +92,7 @@ public class LightlessConfig : ILightlessConfiguration
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi; public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi; public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay; public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
// Basic Settings // Basic Settings
public float NotificationOpacity { get; set; } = 0.95f; public float NotificationOpacity { get; set; } = 0.95f;
@@ -95,6 +102,7 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowNotificationTimestamp { get; set; } = false; public bool ShowNotificationTimestamp { get; set; } = false;
// Position & Layout // Position & Layout
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
public int NotificationOffsetY { get; set; } = 50; public int NotificationOffsetY { get; set; } = 50;
public int NotificationOffsetX { get; set; } = 0; public int NotificationOffsetX { get; set; } = 0;
public float NotificationWidth { get; set; } = 350f; public float NotificationWidth { get; set; } = 350f;
@@ -102,6 +110,7 @@ public class LightlessConfig : ILightlessConfiguration
// Animation & Effects // Animation & Effects
public float NotificationAnimationSpeed { get; set; } = 10f; public float NotificationAnimationSpeed { get; set; } = 10f;
public float NotificationSlideSpeed { get; set; } = 10f;
public float NotificationAccentBarWidth { get; set; } = 3f; public float NotificationAccentBarWidth { get; set; } = 3f;
// Duration per Type // Duration per Type
@@ -109,17 +118,20 @@ public class LightlessConfig : ILightlessConfiguration
public int WarningNotificationDurationSeconds { get; set; } = 15; public int WarningNotificationDurationSeconds { get; set; } = 15;
public int ErrorNotificationDurationSeconds { get; set; } = 20; public int ErrorNotificationDurationSeconds { get; set; } = 20;
public int PairRequestDurationSeconds { get; set; } = 180; public int PairRequestDurationSeconds { get; set; } = 180;
public int DownloadNotificationDurationSeconds { get; set; } = 300; public int DownloadNotificationDurationSeconds { get; set; } = 30;
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
public uint CustomInfoSoundId { get; set; } = 2; // Se2 public uint CustomInfoSoundId { get; set; } = 2; // Se2
public uint CustomWarningSoundId { get; set; } = 16; // Se15 public uint CustomWarningSoundId { get; set; } = 16; // Se15
public uint CustomErrorSoundId { get; set; } = 16; // Se15 public uint CustomErrorSoundId { get; set; } = 16; // Se15
public uint PairRequestSoundId { get; set; } = 5; // Se5 public uint PairRequestSoundId { get; set; } = 5; // Se5
public uint DownloadSoundId { get; set; } = 15; // Se14 public uint PerformanceSoundId { get; set; } = 16; // Se15
public bool DisableInfoSound { get; set; } = true; public bool DisableInfoSound { get; set; } = true;
public bool DisableWarningSound { get; set; } = true; public bool DisableWarningSound { get; set; } = true;
public bool DisableErrorSound { get; set; } = true; public bool DisableErrorSound { get; set; } = true;
public bool DisablePairRequestSound { get; set; } = true; public bool DisablePairRequestSound { get; set; } = true;
public bool DisableDownloadSound { 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 UseFocusTarget { get; set; } = false;
public bool overrideFriendColor { get; set; } = false; public bool overrideFriendColor { get; set; } = false;
public bool overridePartyColor { get; set; } = false; public bool overridePartyColor { get; set; } = false;
@@ -140,4 +152,5 @@ public class LightlessConfig : ILightlessConfiguration
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue; public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
public bool SyncshellFinderEnabled { get; set; } = false; public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null; 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 int Version { get; set; } = 1;
public bool ShowPerformanceIndicator { get; set; } = true; public bool ShowPerformanceIndicator { get; set; } = true;
public bool ShowPerformanceUsageNextToName { get; set; } = false;
public bool WarnOnExceedingThresholds { get; set; } = true; public bool WarnOnExceedingThresholds { get; set; } = true;
public bool WarnOnPreferredPermissionsExceedingThresholds { get; set; } = false; public bool WarnOnPreferredPermissionsExceedingThresholds { get; set; } = false;
public int VRAMSizeWarningThresholdMiB { get; set; } = 375; public int VRAMSizeWarningThresholdMiB { get; set; } = 375;
@@ -16,4 +17,9 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public bool PauseInInstanceDuty { get; set; } = false; public bool PauseInInstanceDuty { get; set; } = false;
public bool PauseWhilePerforming { get; set; } = true; public bool PauseWhilePerforming { get; set; } = true;
public bool PauseInCombat { get; set; } = true; public bool PauseInCombat { get; set; } = true;
public bool EnableNonIndexTextureMipTrim { get; set; } = false;
public bool EnableIndexTextureDownscale { get; set; } = false;
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool KeepOriginalTextureFiles { get; set; } = false;
} }

View File

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

View File

@@ -17,5 +17,12 @@ public enum NotificationType
Warning, Warning,
Error, Error,
PairRequest, PairRequest,
Download 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 bool UseOAuth2 { get; set; } = false;
public string? OAuthToken { get; set; } = null; public string? OAuthToken { get; set; } = null;
public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets; public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets;
public bool ForceWebSockets { get; set; } = false;
} }

View File

@@ -9,6 +9,7 @@ using LightlessSync.UI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog;
using System.Reflection; using System.Reflection;
namespace LightlessSync; namespace LightlessSync;
@@ -116,6 +117,24 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
return Task.CompletedTask; 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() private void DalamudUtilOnLogIn()
{ {
Logger?.LogDebug("Client login"); Logger?.LogDebug("Client login");
@@ -154,6 +173,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>(); _runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>(); _runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>(); _runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
CheckVersion();
#if !DEBUG #if !DEBUG
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information) if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)

View File

@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/13.1.0"> <Project Sdk="Dalamud.NET.Sdk/14.0.1">
<PropertyGroup> <PropertyGroup>
<Authors></Authors> <Authors></Authors>
<Company></Company> <Company></Company>
<Version>1.12.2</Version> <Version>1.42.0.72</Version>
<Description></Description> <Description></Description>
<Copyright></Copyright> <Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl> <PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0-windows7.0</TargetFramework> <TargetFramework>net10.0-windows7.0</TargetFramework>
<Platforms>x64</Platforms> <Platforms>x64</Platforms>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@@ -27,25 +27,26 @@
</ItemGroup> </ItemGroup>
<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="Downloader" Version="4.0.3" />
<PackageReference Include="K4os.Compression.LZ4.Legacy" Version="1.3.8" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Glamourer.Api" Version="2.6.0" /> <PackageReference Include="Glamourer.Api" Version="2.8.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.2" /> <PackageReference Include="NReco.Logging.File" Version="1.3.1" />
<PackageReference Include="Penumbra.String" Version="1.0.5" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" /> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.17.0.131074">
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </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> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -64,6 +65,8 @@
<None Update="images\icon.png"> <None Update="images\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<EmbeddedResource Include="Changelog\changelog.yaml" />
<EmbeddedResource Include="Changelog\credits.yaml" />
<EmbeddedResource Include="Localization\de.json" /> <EmbeddedResource Include="Localization\de.json" />
<EmbeddedResource Include="Localization\fr.json" /> <EmbeddedResource Include="Localization\fr.json" />
</ItemGroup> </ItemGroup>
@@ -74,7 +77,28 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" /> <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> </ItemGroup>
</Project> </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; namespace LightlessSync.PlayerData.Data;
@@ -13,37 +16,42 @@ public class FileReplacementDataComparer : IEqualityComparer<FileReplacementData
public bool Equals(FileReplacementData? x, FileReplacementData? y) public bool Equals(FileReplacementData? x, FileReplacementData? y)
{ {
if (x == null || y == null) return false; if (ReferenceEquals(x, y))
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); 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) 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) var left = new HashSet<string>(first ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
return false; var right = new HashSet<string>(second ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
return left.SetEquals(right);
for (int i = 0; i < list1.Count; i++)
{
if (!string.Equals(list1.ElementAt(i), list2.ElementAt(i), StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
} }
private static int GetOrderIndependentHashCode<T>(IEnumerable<T> source) where T : notnull private static int GetSetHashCode(IEnumerable<string> paths)
{ {
int hash = 0; int hash = 0;
foreach (T element in source) foreach (var element in paths ?? Enumerable.Empty<string>())
{ {
hash = unchecked(hash + hash = unchecked(hash + StringComparer.OrdinalIgnoreCase.GetHashCode(element));
EqualityComparer<T>.Default.GetHashCode(element));
} }
return hash; return hash;
} }
} }

View File

@@ -1,5 +1,7 @@
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -7,24 +9,45 @@ namespace LightlessSync.PlayerData.Factories;
public class FileDownloadManagerFactory public class FileDownloadManagerFactory
{ {
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator; 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, public FileDownloadManagerFactory(
FileCacheManager fileCacheManager, FileCompactor fileCompactor) ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
TextureMetadataHelper textureMetadataHelper)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator; _fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager; _fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_textureMetadataHelper = textureMetadataHelper;
} }
public FileDownloadManager Create() 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.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Factories; namespace LightlessSync.PlayerData.Factories;
public class GameObjectHandlerFactory public class GameObjectHandlerFactory
{ {
private readonly DalamudUtilService _dalamudUtilService; private readonly IServiceProvider _serviceProvider;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly PerformanceCollectorService _performanceCollectorService; private readonly PerformanceCollectorService _performanceCollectorService;
public GameObjectHandlerFactory(ILoggerFactory loggerFactory, PerformanceCollectorService performanceCollectorService, LightlessMediator lightlessMediator, public GameObjectHandlerFactory(
DalamudUtilService dalamudUtilService) ILoggerFactory loggerFactory,
PerformanceCollectorService performanceCollectorService,
LightlessMediator lightlessMediator,
IServiceProvider serviceProvider)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_performanceCollectorService = performanceCollectorService; _performanceCollectorService = performanceCollectorService;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_dalamudUtilService = dalamudUtilService; _serviceProvider = serviceProvider;
} }
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false) public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
{ {
return await _dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(_loggerFactory.CreateLogger<GameObjectHandler>(), var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>();
_performanceCollectorService, _lightlessMediator, _dalamudUtilService, objectKind, getAddressFunc, isWatched)).ConfigureAwait(false); 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.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LightlessSync.WebAPI;
namespace LightlessSync.PlayerData.Factories; namespace LightlessSync.PlayerData.Factories;
public class PairFactory public class PairFactory
{ {
private readonly PairHandlerFactory _cachedPlayerFactory; private readonly PairLedger _pairLedger;
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator; private readonly LightlessMediator _lightlessMediator;
private readonly ServerConfigurationManager _serverConfigurationManager; private readonly Lazy<ServerConfigurationManager> _serverConfigurationManager;
private readonly Lazy<ApiController> _apiController;
public PairFactory(ILoggerFactory loggerFactory, PairHandlerFactory cachedPlayerFactory, public PairFactory(
LightlessMediator lightlessMediator, ServerConfigurationManager serverConfigurationManager) ILoggerFactory loggerFactory,
PairLedger pairLedger,
LightlessMediator lightlessMediator,
Lazy<ServerConfigurationManager> serverConfigurationManager,
Lazy<ApiController> apiController)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_cachedPlayerFactory = cachedPlayerFactory; _pairLedger = pairLedger;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
_serverConfigurationManager = serverConfigurationManager; _serverConfigurationManager = serverConfigurationManager;
_apiController = apiController;
} }
public Pair Create(UserFullPairDto userPairDto) public Pair Create(UserFullPairDto userPairDto)
{ {
return new Pair(_loggerFactory.CreateLogger<Pair>(), userPairDto, _cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager); return CreateInternal(userPairDto);
} }
public Pair Create(UserPairDto userPairDto) public Pair Create(UserPairDto userPairDto)
{ {
return new Pair(_loggerFactory.CreateLogger<Pair>(), new(userPairDto.User, userPairDto.IndividualPairStatus, [], userPairDto.OwnPermissions, userPairDto.OtherPermissions), var full = new UserFullPairDto(
_cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager); userPairDto.User,
userPairDto.IndividualPairStatus,
new List<string>(),
userPairDto.OwnPermissions,
userPairDto.OtherPermissions);
return CreateInternal(full);
}
public Pair? Create(PairDisplayEntry entry)
{
var dto = new UserFullPairDto(
entry.User,
entry.PairStatus ?? IndividualPairStatus.None,
entry.Groups.Select(g => g.Group.GID).Distinct(StringComparer.Ordinal).ToList(),
entry.SelfPermissions,
entry.OtherPermissions);
return CreateInternal(dto);
}
public Pair? Create(PairUniqueIdentifier ident)
{
if (!_pairLedger.TryGetEntry(ident, out var entry) || entry is null)
{
return null;
}
return Create(entry);
}
private Pair CreateInternal(UserFullPairDto dto)
{
return new Pair(
_loggerFactory.CreateLogger<Pair>(),
dto,
_pairLedger,
_lightlessMediator,
_serverConfigurationManager.Value,
_apiController);
} }
} }

View File

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

View File

@@ -98,7 +98,19 @@ public class PlayerDataFactory
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) 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) private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,173 +1,156 @@
using Dalamud.Game.Gui.ContextMenu; using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using LightlessSync.API.Data; using LightlessSync.API.Data;
using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Enum;
using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Extensions;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Utils; using LightlessSync.UI;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.PlayerData.Pairs; namespace LightlessSync.PlayerData.Pairs;
/// <summary>
/// ui wrapper around a pair connection
/// </summary>
public class Pair public class Pair
{ {
private readonly PairHandlerFactory _cachedPlayerFactory; private readonly PairLedger _pairLedger;
private readonly SemaphoreSlim _creationSemaphore = new(1);
private readonly ILogger<Pair> _logger; private readonly ILogger<Pair> _logger;
private readonly LightlessMediator _mediator; private readonly LightlessMediator _mediator;
private readonly ServerConfigurationManager _serverConfigurationManager; private readonly ServerConfigurationManager _serverConfigurationManager;
private CancellationTokenSource _applicationCts = new(); private readonly Lazy<ApiController> _apiController;
private OnlineUserIdentDto? _onlineUserIdentDto = null;
public Pair(ILogger<Pair> logger, UserFullPairDto userPair, PairHandlerFactory cachedPlayerFactory, private const int _lightlessPrefixColor = 708;
LightlessMediator mediator, ServerConfigurationManager serverConfigurationManager)
public Pair(
ILogger<Pair> logger,
UserFullPairDto userPair,
PairLedger pairLedger,
LightlessMediator mediator,
ServerConfigurationManager serverConfigurationManager,
Lazy<ApiController> apiController)
{ {
_logger = logger; _logger = logger;
UserPair = userPair; UserPair = userPair;
_cachedPlayerFactory = cachedPlayerFactory; _pairLedger = pairLedger;
_mediator = mediator; _mediator = mediator;
_serverConfigurationManager = serverConfigurationManager; _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 IndividualPairStatus IndividualPairStatus => UserPair.IndividualPairStatus;
public bool IsDirectlyPaired => IndividualPairStatus != IndividualPairStatus.None; public bool IsDirectlyPaired => IndividualPairStatus != IndividualPairStatus.None;
public bool IsOneSidedPair => IndividualPairStatus == IndividualPairStatus.OneSided; 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 IsPaired => IndividualPairStatus == IndividualPairStatus.Bidirectional || UserPair.Groups.Any();
public bool IsPaused => UserPair.OwnPermissions.IsPaused(); public bool IsPaused => UserPair.OwnPermissions.IsPaused();
public bool IsVisible => CachedPlayer?.IsVisible ?? false; public bool IsVisible => _pairLedger.IsPairVisible(PairIdent);
public CharacterData? LastReceivedCharacterData { get; set; } public CharacterData? LastReceivedCharacterData => TryGetHandler()?.LastReceivedCharacterData;
public string? PlayerName => CachedPlayer?.PlayerName ?? string.Empty; public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID;
public long LastAppliedDataBytes => CachedPlayer?.LastAppliedDataBytes ?? -1; public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1;
public long LastAppliedDataTris { get; set; } = -1; public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1;
public long LastAppliedApproximateVRAMBytes { get; set; } = -1; public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1;
public string Ident => _onlineUserIdentDto?.Ident ?? string.Empty; public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1;
public uint PlayerCharacterId => CachedPlayer?.PlayerCharacterId ?? uint.MaxValue; 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 UserData UserData => UserPair.User;
public UserFullPairDto UserPair { get; set; } public UserFullPairDto UserPair { get; set; }
private PairHandler? CachedPlayer { get; set; }
public void AddContextMenu(IMenuOpenedArgs args) public void AddContextMenu(IMenuOpenedArgs args)
{ {
if (CachedPlayer == null || (args.Target is not MenuTargetDefault target) || target.TargetObjectId != CachedPlayer.PlayerCharacterId || IsPaused) return; var handler = TryGetHandler();
if (handler is null)
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()
{ {
Name = openProfileSeString, return;
OnClicked = (a) => _mediator.Publish(new ProfileOpenStandaloneMessage(this)), }
UseDefaultPrefix = false,
PrefixChar = 'L', if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused)
PrefixColor = 708 {
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, ApplyLastReceivedData(forced: true);
OnClicked = (a) => ApplyLastReceivedData(forced: true), return Task.CompletedTask;
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
}); });
args.AddMenuItem(new MenuItem() UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{ {
Name = changePermissions, _mediator.Publish(new OpenPermissionWindow(this));
OnClicked = (a) => _mediator.Publish(new OpenPermissionWindow(this)), return Task.CompletedTask;
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
}); });
args.AddMenuItem(new MenuItem() UiSharedService.AddContextMenuItem(args, name: "Cycle pause state", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
{ {
Name = cyclePauseState, TriggerCyclePause();
OnClicked = (a) => _mediator.Publish(new CyclePauseMessage(UserData)), return Task.CompletedTask;
UseDefaultPrefix = false,
PrefixChar = 'L',
PrefixColor = 708
}); });
} }
public void ApplyData(OnlineUserCharaDataDto data) public void ApplyData(OnlineUserCharaDataDto data)
{ {
_applicationCts = _applicationCts.CancelRecreate(); _logger.LogTrace("Character data received for {Uid}; handler will process via registry.", UserData.UID);
LastReceivedCharacterData = data.CharaData; }
if (CachedPlayer == null) private void TriggerCyclePause()
{ {
_logger.LogDebug("Received Data for {uid} but CachedPlayer does not exist, waiting", data.User.UID); _ = _apiController.Value.CyclePauseAsync(this);
_ = 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();
} }
public void ApplyLastReceivedData(bool forced = false) public void ApplyLastReceivedData(bool forced = false)
{ {
if (CachedPlayer == null) return; var handler = TryGetHandler();
if (LastReceivedCharacterData == null) return; 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) public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
{ {
try var handler = TryGetHandler();
if (handler is null)
{ {
_creationSemaphore.Wait(); _logger.LogTrace("CreateCachedPlayer skipped for {Uid}: handler unavailable.", UserData.UID);
return;
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);
} }
finally
if (!handler.Initialized)
{ {
_creationSemaphore.Release(); handler.Initialize();
} }
} }
@@ -178,7 +161,7 @@ public class Pair
public string GetPlayerNameHash() public string GetPlayerNameHash()
{ {
return CachedPlayer?.PlayerNameHash ?? string.Empty; return TryGetHandler()?.PlayerNameHash ?? string.Empty;
} }
public bool HasAnyConnection() public bool HasAnyConnection()
@@ -188,21 +171,7 @@ public class Pair
public void MarkOffline(bool wait = true) public void MarkOffline(bool wait = true)
{ {
try _logger.LogTrace("MarkOffline invoked for {Uid} (wait: {Wait}). New registry handles handler disposal.", UserData.UID, wait);
{
if (wait)
_creationSemaphore.Wait();
LastReceivedCharacterData = null;
var player = CachedPlayer;
CachedPlayer = null;
player?.Dispose();
_onlineUserIdentDto = null;
}
finally
{
if (wait)
_creationSemaphore.Release();
}
} }
public void SetNote(string note) public void SetNote(string note)
@@ -212,47 +181,36 @@ public class Pair
internal void SetIsUploading() 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"); var handler = TryGetHandler();
if (data == null) if (handler is null)
{ {
_logger.LogTrace("Nothing to remove"); return PairDebugInfo.Empty;
return data;
} }
bool disableIndividualAnimations = (UserPair.OtherPermissions.IsDisableAnimations() || UserPair.OwnPermissions.IsDisableAnimations()); return new PairDebugInfo(
bool disableIndividualVFX = (UserPair.OtherPermissions.IsDisableVFX() || UserPair.OwnPermissions.IsDisableVFX()); true,
bool disableIndividualSounds = (UserPair.OtherPermissions.IsDisableSounds() || UserPair.OwnPermissions.IsDisableSounds()); handler.Initialized,
handler.IsVisible,
_logger.LogTrace("Disable: Sounds: {disableIndividualSounds}, Anims: {disableIndividualAnims}; " + handler.ScheduledForDeletion,
"VFX: {disableGroupSounds}", handler.LastDataReceivedAt,
disableIndividualSounds, disableIndividualAnimations, disableIndividualVFX); handler.LastApplyAttemptAt,
handler.LastSuccessfulApplyAt,
if (disableIndividualAnimations || disableIndividualSounds || disableIndividualVFX) handler.LastFailureReason,
{ handler.LastBlockingConditions,
_logger.LogTrace("Data cleaned up: Animations disabled: {disableAnimations}, Sounds disabled: {disableSounds}, VFX disabled: {disableVFX}", handler.IsApplying,
disableIndividualAnimations, disableIndividualSounds, disableIndividualVFX); handler.IsDownloading,
foreach (var objectKind in data.FileReplacements.Select(k => k.Key)) handler.PendingDownloadCount,
{ handler.ForbiddenDownloadCount);
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;
} }
} }

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
using Dalamud.Game; using Dalamud.Game;
using Dalamud.Game.ClientState.Objects; using Dalamud.Game.ClientState.Objects;
using Dalamud.Interface;
using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing; using Dalamud.Interface.Windowing;
using Dalamud.Plugin; using Dalamud.Plugin;
@@ -13,14 +14,20 @@ using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Services; using LightlessSync.PlayerData.Services;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.Chat;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.CharaData; using LightlessSync.Services.CharaData;
using LightlessSync.Services.Events; using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.Rendering;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI; using LightlessSync.UI;
using LightlessSync.UI.Components; using LightlessSync.UI.Components;
using LightlessSync.UI.Components.Popup; using LightlessSync.UI.Components.Popup;
using LightlessSync.UI.Handlers; using LightlessSync.UI.Handlers;
using LightlessSync.UI.Tags;
using LightlessSync.UI.Services;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.SignalR; using LightlessSync.WebAPI.SignalR;
@@ -30,6 +37,9 @@ using Microsoft.Extensions.Logging;
using NReco.Logging.File; using NReco.Logging.File;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Reflection; using System.Reflection;
using OtterTex;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.PairProcessing;
namespace LightlessSync; namespace LightlessSync;
@@ -43,6 +53,7 @@ public sealed class Plugin : IDalamudPlugin
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig, ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle) ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle)
{ {
NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName);
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName)) if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName); Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName);
var traceDir = Path.Join(pluginInterface.ConfigDirectory.FullName, "tracelog"); var traceDir = Path.Join(pluginInterface.ConfigDirectory.FullName, "tracelog");
@@ -85,218 +96,459 @@ public sealed class Plugin : IDalamudPlugin
}); });
lb.SetMinimumLevel(LogLevel.Trace); 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")); var httpClient = new HttpClient();
collection.AddSingleton<FileDialogManager>(); var ver = Assembly.GetExecutingAssembly().GetName().Version;
collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true)); httpClient.DefaultRequestHeaders.UserAgent.Add(
collection.AddSingleton(gameGui); new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}"));
return httpClient;
});
// add lightless related singletons // Lightless Config services
collection.AddSingleton<LightlessMediator>(); services.AddSingleton(sp => new UiThemeConfigService(configDir));
collection.AddSingleton<FileCacheManager>(); services.AddSingleton(sp => new ChatConfigService(configDir));
collection.AddSingleton<ServerConfigurationManager>(); services.AddSingleton(sp =>
collection.AddSingleton<ApiController>(); {
collection.AddSingleton<PerformanceCollectorService>(); var cfg = new LightlessConfigService(configDir);
collection.AddSingleton<HubFactory>(); var theme = sp.GetRequiredService<UiThemeConfigService>();
collection.AddSingleton<FileUploadManager>(); LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
collection.AddSingleton<FileTransferOrchestrator>(); return cfg;
collection.AddSingleton<LightlessPlugin>(); });
collection.AddSingleton<LightlessProfileManager>(); services.AddSingleton(sp => new ServerConfigService(configDir));
collection.AddSingleton<GameObjectHandlerFactory>(); services.AddSingleton(sp => new NotesConfigService(configDir));
collection.AddSingleton<FileDownloadManagerFactory>(); services.AddSingleton(sp => new PairTagConfigService(configDir));
collection.AddSingleton<PairHandlerFactory>(); services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
collection.AddSingleton<PairProcessingLimiter>(); services.AddSingleton(sp => new TransientConfigService(configDir));
collection.AddSingleton<PairFactory>(); services.AddSingleton(sp => new XivDataStorageService(configDir));
collection.AddSingleton<XivDataAnalyzer>(); services.AddSingleton(sp => new PlayerPerformanceConfigService(configDir));
collection.AddSingleton<CharacterAnalyzer>(); services.AddSingleton(sp => new CharaDataConfigService(configDir));
collection.AddSingleton<TokenProvider>();
collection.AddSingleton<PluginWarningNotificationService>();
collection.AddSingleton<FileCompactor>();
collection.AddSingleton<TagHandler>();
collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
collection.AddSingleton<PairRequestService>();
collection.AddSingleton<IdDisplayHandler>();
collection.AddSingleton<PlayerPerformanceService>();
collection.AddSingleton<TransientResourceManager>();
collection.AddSingleton<CharaDataManager>(); // Config adapters
collection.AddSingleton<CharaDataFileHandler>(); services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<LightlessConfigService>());
collection.AddSingleton<CharaDataCharacterHandler>(); services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
collection.AddSingleton<CharaDataNearbyManager>(); services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
collection.AddSingleton<CharaDataGposeTogetherManager>(); 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>>(), services.AddSingleton<ConfigurationMigrator>();
gameInteropProvider, s.GetRequiredService<LightlessMediator>())); services.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton((s) => new BlockedCharacterHandler(s.GetRequiredService<ILogger<BlockedCharacterHandler>>(), gameInteropProvider));
collection.AddSingleton((s) => new IpcProvider(s.GetRequiredService<ILogger<IpcProvider>>(),
pluginInterface,
s.GetRequiredService<CharaDataManager>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton<SelectPairForTagUi>();
collection.AddSingleton<RenamePairTagUi>();
collection.AddSingleton<SelectSyncshellForTagUi>();
collection.AddSingleton<RenameSyncshellTagUi>();
collection.AddSingleton((s) => new EventAggregator(pluginInterface.ConfigDirectory.FullName,
s.GetRequiredService<ILogger<EventAggregator>>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService<ILogger<DalamudUtilService>>(),
clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig,
s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>()));
collection.AddSingleton((s) => new DtrEntry(
s.GetRequiredService<ILogger<DtrEntry>>(),
dtrBar,
s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairManager>(),
s.GetRequiredService<PairRequestService>(),
s.GetRequiredService<ApiController>(),
s.GetRequiredService<ServerConfigurationManager>(),
s.GetRequiredService<BroadcastService>(),
s.GetRequiredService<BroadcastScannerService>(),
s.GetRequiredService<DalamudUtilService>()));
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, s.GetRequiredService<PairProcessingLimiter>()));
collection.AddSingleton<RedrawManager>();
collection.AddSingleton<BroadcastService>();
collection.AddSingleton(addonLifecycle);
collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData,
p.GetRequiredService<ILogger<ContextMenuService>>(), p.GetRequiredService<DalamudUtilService>(), p.GetRequiredService<ApiController>(), objectTable,
p.GetRequiredService<LightlessConfigService>(), p.GetRequiredService<PairRequestService>(), p.GetRequiredService<PairManager>(), clientState));
collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService<ILogger<IpcCallerPenumbra>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService<ILogger<IpcCallerGlamourer>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
collection.AddSingleton((s) => new IpcCallerCustomize(s.GetRequiredService<ILogger<IpcCallerCustomize>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerHeels(s.GetRequiredService<ILogger<IpcCallerHeels>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerHonorific(s.GetRequiredService<ILogger<IpcCallerHonorific>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerMoodles(s.GetRequiredService<ILogger<IpcCallerMoodles>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerPetNames(s.GetRequiredService<ILogger<IpcCallerPetNames>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new IpcCallerBrio(s.GetRequiredService<ILogger<IpcCallerBrio>>(), pluginInterface,
s.GetRequiredService<DalamudUtilService>()));
collection.AddSingleton((s) => new IpcManager(s.GetRequiredService<ILogger<IpcManager>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<IpcCallerPenumbra>(), s.GetRequiredService<IpcCallerGlamourer>(),
s.GetRequiredService<IpcCallerCustomize>(), s.GetRequiredService<IpcCallerHeels>(), s.GetRequiredService<IpcCallerHonorific>(),
s.GetRequiredService<IpcCallerMoodles>(), s.GetRequiredService<IpcCallerPetNames>(), s.GetRequiredService<IpcCallerBrio>()));
collection.AddSingleton((s) => new NotificationService(
s.GetRequiredService<ILogger<NotificationService>>(),
s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<DalamudUtilService>(),
notificationManager,
chatGui,
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PairRequestService>()));
collection.AddSingleton((s) =>
{
var httpClient = new HttpClient();
var ver = Assembly.GetExecutingAssembly().GetName().Version;
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
return httpClient;
});
collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) =>
{
var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName);
var theme = s.GetRequiredService<UiThemeConfigService>();
LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
return cfg;
});
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new PairTagConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new SyncshellTagConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new TransientConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new XivDataStorageService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<LightlessConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<UiThemeConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PairTagConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<SyncshellTagConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<TransientConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<XivDataStorageService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PlayerPerformanceConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton<HubFactory>();
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
// 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 services.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>(sp => new EditProfileUi(
collection.AddScoped<DrawEntityFactory>(); sp.GetRequiredService<ILogger<EditProfileUi>>(),
collection.AddScoped<CacheMonitor>(); sp.GetRequiredService<LightlessMediator>(),
collection.AddScoped<UiFactory>(); sp.GetRequiredService<ApiController>(),
collection.AddScoped<SelectTagForPairUi>(); sp.GetRequiredService<UiSharedService>(),
collection.AddScoped<SelectTagForSyncshellUi>(); sp.GetRequiredService<FileDialogManager>(),
collection.AddScoped<WindowMediatorSubscriberBase, SettingsUi>(); sp.GetRequiredService<LightlessProfileManager>(),
collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>(); sp.GetRequiredService<ProfileTagService>(),
collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>(); sp.GetRequiredService<PerformanceCollectorService>()));
collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
collection.AddScoped<WindowMediatorSubscriberBase, JoinSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(), services.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<PerformanceCollectorService>()));
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUI>((s) =>
new LightlessNotificationUI(
s.GetRequiredService<ILogger<LightlessNotificationUI>>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>()));
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
collection.AddScoped<CacheCreationService>();
collection.AddScoped<PlayerDataFactory>();
collection.AddScoped<VisibleUserDataDistributor>();
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
s.GetRequiredService<UiFactory>(),
s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessMediator>(),
s.GetRequiredService<NotificationService>()));
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
collection.AddScoped((s) => new UiSharedService(s.GetRequiredService<ILogger<UiSharedService>>(), s.GetRequiredService<IpcManager>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<DalamudUtilService>(),
pluginInterface, textureProvider, s.GetRequiredService<Dalamud.Localization>(), s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<TokenProvider>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>()); services.AddScoped<WindowMediatorSubscriberBase, LightFinderUI>(sp => new LightFinderUI(
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>()); sp.GetRequiredService<ILogger<LightFinderUI>>(),
collection.AddHostedService(p => p.GetRequiredService<NotificationService>()); sp.GetRequiredService<LightlessMediator>(),
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>()); sp.GetRequiredService<PerformanceCollectorService>(),
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>()); sp.GetRequiredService<LightFinderService>(),
collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>()); sp.GetRequiredService<LightlessConfigService>(),
collection.AddHostedService(p => p.GetRequiredService<PerformanceCollectorService>()); sp.GetRequiredService<UiSharedService>(),
collection.AddHostedService(p => p.GetRequiredService<DtrEntry>()); sp.GetRequiredService<ApiController>(),
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>()); sp.GetRequiredService<LightFinderScannerService>()));
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<LightlessPlugin>()); services.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>(sp => new SyncshellFinderUI(
collection.AddHostedService(p => p.GetRequiredService<ContextMenuService>()); sp.GetRequiredService<ILogger<SyncshellFinderUI>>(),
collection.AddHostedService(p => p.GetRequiredService<BroadcastService>()); sp.GetRequiredService<LightlessMediator>(),
}) sp.GetRequiredService<PerformanceCollectorService>(),
.Build(); 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(); _ = _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.LightlessConfiguration;
using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.CharaData.Models; using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -28,7 +28,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
private readonly List<CharaDataMetaInfoExtendedDto> _nearbyData = []; private readonly List<CharaDataMetaInfoExtendedDto> _nearbyData = [];
private readonly CharaDataNearbyManager _nearbyManager; private readonly CharaDataNearbyManager _nearbyManager;
private readonly CharaDataCharacterHandler _characterHandler; private readonly CharaDataCharacterHandler _characterHandler;
private readonly PairManager _pairManager; private readonly PairUiService _pairUiService;
private readonly Dictionary<string, CharaDataFullExtendedDto> _ownCharaData = []; private readonly Dictionary<string, CharaDataFullExtendedDto> _ownCharaData = [];
private readonly Dictionary<string, Task> _sharedMetaInfoTimeoutTasks = []; private readonly Dictionary<string, Task> _sharedMetaInfoTimeoutTasks = [];
private readonly Dictionary<UserData, List<CharaDataMetaInfoExtendedDto>> _sharedWithYouData = []; private readonly Dictionary<UserData, List<CharaDataMetaInfoExtendedDto>> _sharedWithYouData = [];
@@ -45,7 +45,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
LightlessMediator lightlessMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService, LightlessMediator lightlessMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService,
FileDownloadManagerFactory fileDownloadManagerFactory, FileDownloadManagerFactory fileDownloadManagerFactory,
CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager,
CharaDataCharacterHandler charaDataCharacterHandler, PairManager pairManager) : base(logger, lightlessMediator) CharaDataCharacterHandler charaDataCharacterHandler, PairUiService pairUiService) : base(logger, lightlessMediator)
{ {
_apiController = apiController; _apiController = apiController;
_fileHandler = charaDataFileHandler; _fileHandler = charaDataFileHandler;
@@ -54,7 +54,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
_configService = charaDataConfigService; _configService = charaDataConfigService;
_nearbyManager = charaDataNearbyManager; _nearbyManager = charaDataNearbyManager;
_characterHandler = charaDataCharacterHandler; _characterHandler = charaDataCharacterHandler;
_pairManager = pairManager; _pairUiService = pairUiService;
lightlessMediator.Subscribe<ConnectedMessage>(this, (msg) => lightlessMediator.Subscribe<ConnectedMessage>(this, (msg) =>
{ {
_connectCts?.Cancel(); _connectCts?.Cancel();
@@ -421,9 +421,10 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
}); });
var result = await GetSharedWithYouTask.ConfigureAwait(false); var result = await GetSharedWithYouTask.ConfigureAwait(false);
var snapshot = _pairUiService.GetSnapshot();
foreach (var grouping in result.GroupBy(r => r.Uploader)) 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; if (pair?.IsPaused ?? false) continue;
List<CharaDataMetaInfoExtendedDto> newList = new(); List<CharaDataMetaInfoExtendedDto> newList = new();
foreach (var item in grouping) 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.API.Data.Enum;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.CharaData.Models;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI; using LightlessSync.UI;
using LightlessSync.Utils; using LightlessSync.Utils;
using Lumina.Data.Files; using Lumina.Data.Files;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Immutable;
namespace LightlessSync.Services; namespace LightlessSync.Services;
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
@@ -16,6 +18,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
private CancellationTokenSource? _analysisCts; private CancellationTokenSource? _analysisCts;
private CancellationTokenSource _baseAnalysisCts = new(); private CancellationTokenSource _baseAnalysisCts = new();
private string _lastDataHash = string.Empty; private string _lastDataHash = string.Empty;
private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty;
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer) public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
: base(logger, mediator) : base(logger, mediator)
@@ -34,71 +37,98 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
public bool IsAnalysisRunning => _analysisCts != null; public bool IsAnalysisRunning => _analysisCts != null;
public int TotalFiles { get; internal set; } public int TotalFiles { get; internal set; }
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = []; internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
public CharacterAnalysisSummary LatestSummary => _latestSummary;
public void CancelAnalyze() public void CancelAnalyze()
{ {
_analysisCts?.CancelDispose(); _analysisCts?.CancelDispose();
_analysisCts = null; _analysisCts = null;
} }
public async Task ComputeAnalysis(bool print = true, bool recalculate = false) public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
{ {
Logger.LogDebug("=== Calculating Character Analysis ==="); Logger.LogDebug("=== Calculating Character Analysis ===");
_analysisCts = _analysisCts?.CancelRecreate() ?? new(); _analysisCts = _analysisCts?.CancelRecreate() ?? new();
var cancelToken = _analysisCts.Token; var cancelToken = _analysisCts.Token;
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList(); 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(); foreach (var file in remaining)
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) cancelToken.ThrowIfCancellationRequested();
{
Logger.LogDebug("Computing file {file}", file.FilePaths[0]);
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
CurrentFile++;
}
_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)
{ await _fileCacheManager.WriteOutFullCsvAsync(cancelToken).ConfigureAwait(false);
Logger.LogWarning(ex, "Failed to analyze files"); }
} catch (OperationCanceledException)
finally {
{ Logger.LogInformation("File analysis cancelled");
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer))); throw;
} }
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to analyze files");
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
} }
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage()); Mediator.Publish(new CharacterDataAnalyzedMessage());
_analysisCts.CancelDispose(); _analysisCts.CancelDispose();
_analysisCts = null; _analysisCts = null;
if (print) PrintAnalysis(); if (print) PrintAnalysis();
} }
public void Dispose() public void Dispose()
{ {
_analysisCts.CancelDispose(); _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) private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
{ {
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return; if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
LastAnalysis.Clear(); LastAnalysis.Clear();
foreach (var obj in charaData.FileReplacements) foreach (var obj in charaData.FileReplacements)
{ {
Dictionary<string, FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase); Dictionary<string, FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase);
@@ -106,9 +136,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{ {
token.ThrowIfCancellationRequested(); 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; if (fileCacheEntries.Count == 0) continue;
var filePath = fileCacheEntries[0].ResolvedFilepath; var filePath = fileCacheEntries[0].ResolvedFilepath;
FileInfo fi = new(filePath); FileInfo fi = new(filePath);
string ext = "unk?"; string ext = "unk?";
@@ -120,28 +149,49 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{ {
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath); Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
} }
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false); var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
foreach (var entry in fileCacheEntries) foreach (var entry in fileCacheEntries)
{ {
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext, data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext,
[.. fileEntry.GamePaths], [.. 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.Size > 0 ? entry.Size.Value : 0,
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0, entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
tris); tris);
} }
} }
LastAnalysis[obj.Key] = data; LastAnalysis[obj.Key] = data;
} }
RecalculateSummary();
Mediator.Publish(new CharacterDataAnalyzedMessage()); Mediator.Publish(new CharacterDataAnalyzedMessage());
_lastDataHash = charaData.DataHash.Value; _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() private void PrintAnalysis()
{ {
if (LastAnalysis.Count == 0) return; if (LastAnalysis.Count == 0) return;
@@ -150,7 +200,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
int fileCounter = 1; int fileCounter = 1;
int totalFiles = kvp.Value.Count; int totalFiles = kvp.Value.Count;
Logger.LogInformation("=== Analysis for {obj} ===", kvp.Key); 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)) 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); 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, 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))); 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 summary for all currently present objects ===");
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}",
LastAnalysis.Values.Sum(v => v.Values.Count), 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)))); 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."); 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) 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; 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 compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false);
var normalSize = new FileInfo(FilePaths[0]).Length; 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) foreach (var entry in entries)
{ {
entry.Size = normalSize; entry.Size = normalSize;
@@ -203,33 +250,40 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
} }
OriginalSize = normalSize; OriginalSize = normalSize;
CompressedSize = compressedsize.Item2.LongLength; CompressedSize = compressedsize.Item2.LongLength;
RefreshFormat();
} }
public long OriginalSize { get; private set; } = OriginalSize; public long OriginalSize { get; private set; } = OriginalSize;
public long CompressedSize { get; private set; } = CompressedSize; public long CompressedSize { get; private set; } = CompressedSize;
public long Triangles { get; private set; } = Triangles; 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": if (!string.Equals(FileType, "tex", StringComparison.Ordinal))
{ {
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:
return string.Empty; 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 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 ApiController _apiController;
private readonly ICommandManager _commandManager; private readonly ICommandManager _commandManager;
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
_apiController = apiController; _apiController = apiController;
_mediator = mediator; _mediator = mediator;
_lightlessConfigService = lightlessConfigService; _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 + HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
"Additionally possible commands:" + Environment.NewLine + "Additionally possible commands:" + Environment.NewLine +
@@ -43,13 +48,15 @@ public sealed class CommandManagerService : IDisposable
"\t /light gpose - Opens the Lightless Character Data Hub window" + 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 analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
"\t /light settings - Opens the Lightless Settings window" + Environment.NewLine + "\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
"\t /light finder - Opens the Lightfinder window" "\t /light finder - Opens the Lightfinder window" + Environment.NewLine +
"\t /light finder - Opens the Lightless Chat window"
}); });
} }
public void Dispose() public void Dispose()
{ {
_commandManager.RemoveHandler(_commandName); _commandManager.RemoveHandler(_longName);
_commandManager.RemoveHandler(_shortName);
} }
private void OnCommand(string command, string args) private void OnCommand(string command, string args)
@@ -125,7 +132,11 @@ public sealed class CommandManagerService : IDisposable
} }
else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase))
{ {
_mediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); _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

@@ -1,14 +1,18 @@
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.Gui.ContextMenu; using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LightlessSync.UI;
using LightlessSync.Services.LightFinder;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -20,11 +24,17 @@ internal class ContextMenuService : IHostedService
private readonly ILogger<ContextMenuService> _logger; private readonly ILogger<ContextMenuService> _logger;
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly IClientState _clientState; private readonly IClientState _clientState;
private readonly PairManager _pairManager; private readonly PairUiService _pairUiService;
private readonly PairRequestService _pairRequestService; private readonly PairRequestService _pairRequestService;
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly IObjectTable _objectTable; private readonly IObjectTable _objectTable;
private readonly LightlessConfigService _configService; 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( public ContextMenuService(
IContextMenu contextMenu, IContextMenu contextMenu,
@@ -36,8 +46,12 @@ internal class ContextMenuService : IHostedService
IObjectTable objectTable, IObjectTable objectTable,
LightlessConfigService configService, LightlessConfigService configService,
PairRequestService pairRequestService, PairRequestService pairRequestService,
PairManager pairManager, PairUiService pairUiService,
IClientState clientState) IClientState clientState,
LightFinderScannerService broadcastScannerService,
LightFinderService broadcastService,
LightlessProfileManager lightlessProfileManager,
LightlessMediator mediator)
{ {
_contextMenu = contextMenu; _contextMenu = contextMenu;
_pluginInterface = pluginInterface; _pluginInterface = pluginInterface;
@@ -47,9 +61,13 @@ internal class ContextMenuService : IHostedService
_apiController = apiController; _apiController = apiController;
_objectTable = objectTable; _objectTable = objectTable;
_configService = configService; _configService = configService;
_pairManager = pairManager; _pairUiService = pairUiService;
_pairRequestService = pairRequestService; _pairRequestService = pairRequestService;
_clientState = clientState; _clientState = clientState;
_broadcastScannerService = broadcastScannerService;
_broadcastService = broadcastService;
_lightlessProfileManager = lightlessProfileManager;
_mediator = mediator;
} }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
@@ -78,52 +96,109 @@ internal class ContextMenuService : IHostedService
private void OnMenuOpened(IMenuOpenedArgs args) private void OnMenuOpened(IMenuOpenedArgs args)
{ {
if (!_pluginInterface.UiBuilder.ShouldModifyUi) if (!_pluginInterface.UiBuilder.ShouldModifyUi)
return; return;
if (args.AddonName != null) if (args.AddonName != null)
{
var addonName = args.AddonName;
_logger.LogTrace("Context menu addon name: {AddonName}", addonName);
return; return;
}
//Check if target is not menutargetdefault.
if (args.Target is not MenuTargetDefault target) if (args.Target is not MenuTargetDefault target)
{
_logger.LogTrace("Context menu target is not MenuTargetDefault.");
return; return;
}
_logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null");
//Check if name or target id isnt null/zero
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0) 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; return;
}
//Check if it is a real target.
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
if (targetData == null || targetData.Address == nint.Zero) if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null)
{
_logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId);
return; 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);
}
//Check if user is paired or is own.
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
return; 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;
}
//Check if in PVP or GPose
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing) 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; return;
}
//Check for valid world.
var world = GetWorld(target.TargetHomeWorld.RowId); var world = GetWorld(target.TargetHomeWorld.RowId);
if (!IsWorldValid(world)) if (!IsWorldValid(world))
return;
if (!_configService.Current.EnableRightClickMenus)
return;
args.AddMenuItem(new MenuItem
{ {
Name = "Send Pair Request", _logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
PrefixChar = 'L', return;
UseDefaultPrefix = false, }
PrefixColor = 708,
OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false) 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) private async Task HandleSelection(IMenuArgs args)
{ {
if (args.Target is not MenuTargetDefault target) if (args.Target is not MenuTargetDefault target)
@@ -159,9 +234,48 @@ internal class ContextMenuService : IHostedService
} }
} }
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() private async Task HandleLightfinderProfileSelection(string hashedCid)
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) {
.Select(u => (ulong)u.PlayerCharacterId)]; 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) private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target)
{ {
@@ -200,8 +314,6 @@ internal class ContextMenuService : IHostedService
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF; private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId));
public static bool IsWorldValid(World world) public static bool IsWorldValid(World world)
{ {
var name = world.Name.ToString(); var name = world.Name.ToString();

View File

@@ -12,7 +12,10 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.CharaData;
using LightlessSync.Interop; using LightlessSync.Interop;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Utils; using LightlessSync.Utils;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
@@ -21,7 +24,9 @@ using Microsoft.Extensions.Logging;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -37,23 +42,27 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private readonly IGameGui _gameGui; private readonly IGameGui _gameGui;
private readonly ILogger<DalamudUtilService> _logger; private readonly ILogger<DalamudUtilService> _logger;
private readonly IObjectTable _objectTable; private readonly IObjectTable _objectTable;
private readonly ActorObjectService _actorObjectService;
private readonly ITargetManager _targetManager;
private readonly PerformanceCollectorService _performanceCollector; private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly Lazy<PairFactory> _pairFactory;
private PairUniqueIdentifier? _FocusPairIdent;
private IGameObject? _FocusOriginalTarget;
private uint? _classJobId = 0; private uint? _classJobId = 0;
private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow; private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow;
private string _lastGlobalBlockPlayer = string.Empty; private string _lastGlobalBlockPlayer = string.Empty;
private string _lastGlobalBlockReason = string.Empty; private string _lastGlobalBlockReason = string.Empty;
private ushort _lastZone = 0; private ushort _lastZone = 0;
private readonly Dictionary<string, (string Name, nint Address)> _playerCharas = new(StringComparer.Ordinal); private ushort _lastWorldId = 0;
private readonly List<string> _notUpdatedCharas = [];
private bool _sentBetweenAreas = false; private bool _sentBetweenAreas = false;
private Lazy<ulong> _cid; private Lazy<ulong> _cid;
public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework, public DalamudUtilService(ILogger<DalamudUtilService> logger, IClientState clientState, IObjectTable objectTable, IFramework framework,
IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig, IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig,
BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector, ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector,
LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService) LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy<PairFactory> pairFactory)
{ {
_logger = logger; _logger = logger;
_clientState = clientState; _clientState = clientState;
@@ -63,11 +72,14 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_condition = condition; _condition = condition;
_gameData = gameData; _gameData = gameData;
_gameConfig = gameConfig; _gameConfig = gameConfig;
_actorObjectService = actorObjectService;
_targetManager = targetManager;
_blockedCharacterHandler = blockedCharacterHandler; _blockedCharacterHandler = blockedCharacterHandler;
Mediator = mediator; Mediator = mediator;
_performanceCollector = performanceCollector; _performanceCollector = performanceCollector;
_configService = configService; _configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService; _playerPerformanceConfigService = playerPerformanceConfigService;
_pairFactory = pairFactory;
WorldData = new(() => WorldData = new(() =>
{ {
return gameData.GetExcelSheet<Lumina.Excel.Sheets.World>(Dalamud.Game.ClientLanguage.English)! 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) => mediator.Subscribe<TargetPairMessage>(this, (msg) =>
{ {
if (clientState.IsPvP) return; if (clientState.IsPvP) return;
var name = msg.Pair.PlayerName; if (!ResolvePairAddress(msg.Pair, out var pair, out var addr)) return;
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;
var useFocusTarget = _configService.Current.UseFocusTarget; var useFocusTarget = _configService.Current.UseFocusTarget;
_ = RunOnFrameworkThread(() => _ = RunOnFrameworkThread(() =>
{ {
var gameObject = CreateGameObject(addr);
if (gameObject is null) return;
if (useFocusTarget) if (useFocusTarget)
targetManager.FocusTarget = CreateGameObject(addr); {
_targetManager.FocusTarget = gameObject;
if (_FocusPairIdent.HasValue && _FocusPairIdent.Value.Equals(pair.UniqueIdent))
{
_FocusOriginalTarget = _targetManager.FocusTarget;
}
}
else else
targetManager.Target = CreateGameObject(addr); {
_targetManager.Target = gameObject;
}
}).ConfigureAwait(false); }).ConfigureAwait(false);
}); });
IsWine = Util.IsWine(); IsWine = Util.IsWine();
@@ -139,6 +158,61 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private Lazy<ulong> RebuildCID() => new(GetCID); private Lazy<ulong> RebuildCID() => new(GetCID);
public bool IsWine { get; init; } 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 public unsafe GameObject* GposeTarget
{ {
@@ -165,6 +239,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public bool IsInCombat { get; private set; } = false; public bool IsInCombat { get; private set; } = false;
public bool IsPerforming { get; private set; } = false; public bool IsPerforming { get; private set; } = false;
public bool IsInInstance { get; private set; } = false; public bool IsInInstance { get; private set; } = false;
public bool IsInDuty => _condition[ConditionFlag.BoundByDuty];
public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles; public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles;
public uint ClassJobId => _classJobId!.Value; public uint ClassJobId => _classJobId!.Value;
public Lazy<Dictionary<uint, string>> JobData { get; private set; } 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 bool IsLodEnabled { get; private set; }
public LightlessMediator Mediator { get; } 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) public IGameObject? CreateGameObject(IntPtr reference)
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
@@ -194,7 +295,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
var objTableObj = _objectTable[index]; 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; return (ICharacter)objTableObj;
} }
@@ -226,13 +327,19 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IEnumerable<ICharacter?> GetGposeCharactersFromObjectTable() 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() public bool GetIsPlayerPresent()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _clientState.LocalPlayer != null && _clientState.LocalPlayer.IsValid(); return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid();
} }
public async Task<bool> GetIsPlayerPresentAsync() public async Task<bool> GetIsPlayerPresentAsync()
@@ -245,7 +352,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
EnsureIsOnFramework(); EnsureIsOnFramework();
playerPointer ??= GetPlayerPtr(); playerPointer ??= GetPlayerPtr();
if (playerPointer == IntPtr.Zero) return IntPtr.Zero; 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) public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
@@ -268,6 +396,62 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false); 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() public async Task<IPlayerCharacter> GetPlayerCharacterAsync()
{ {
return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false); return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false);
@@ -276,19 +460,20 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IPlayerCharacter GetPlayerCharacter() public IPlayerCharacter GetPlayerCharacter()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _clientState.LocalPlayer!; return _objectTable.LocalPlayer!;
} }
public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName) 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; return IntPtr.Zero;
} }
public string GetPlayerName() public string GetPlayerName()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _clientState.LocalPlayer?.Name.ToString() ?? "--"; return _objectTable.LocalPlayer?.Name.ToString() ?? "--";
} }
public async Task<string> GetPlayerNameAsync() public async Task<string> GetPlayerNameAsync()
@@ -313,6 +498,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false); return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false);
} }
public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid)
{
hashedCid = string.Empty;
if (playerCharacter == null)
return false;
var address = playerCharacter.Address;
if (address == nint.Zero)
return false;
var cid = ((BattleChara*)address)->Character.ContentId;
if (cid == 0)
return false;
hashedCid = cid.ToString().GetHash256();
return true;
}
public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr) public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr)
{ {
return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256(); return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256();
@@ -321,7 +524,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public IntPtr GetPlayerPtr() public IntPtr GetPlayerPtr()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _clientState.LocalPlayer?.Address ?? IntPtr.Zero; return _objectTable.LocalPlayer?.Address ?? IntPtr.Zero;
} }
public async Task<IntPtr> GetPlayerPointerAsync() public async Task<IntPtr> GetPlayerPointerAsync()
@@ -332,13 +535,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
public uint GetHomeWorldId() public uint GetHomeWorldId()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _clientState.LocalPlayer?.HomeWorld.RowId ?? 0; return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0;
} }
public uint GetWorldId() public uint GetWorldId()
{ {
EnsureIsOnFramework(); EnsureIsOnFramework();
return _clientState.LocalPlayer!.CurrentWorld.RowId; return _objectTable.LocalPlayer!.CurrentWorld.RowId;
} }
public unsafe LocationInfo GetMapData() public unsafe LocationInfo GetMapData()
@@ -347,8 +550,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var agentMap = AgentMap.Instance(); var agentMap = AgentMap.Instance();
var houseMan = HousingManager.Instance(); var houseMan = HousingManager.Instance();
uint serverId = 0; uint serverId = 0;
if (_clientState.LocalPlayer == null) serverId = 0; if (_objectTable.LocalPlayer == null) serverId = 0;
else serverId = _clientState.LocalPlayer.CurrentWorld.RowId; else serverId = _objectTable.LocalPlayer.CurrentWorld.RowId;
uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId; uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId;
uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId; uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId;
uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision()); uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision());
@@ -436,17 +639,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var fileName = Path.GetFileNameWithoutExtension(callerFilePath); var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
await _performanceCollector.LogPerformance(this, $"RunOnFramework:Act/{fileName}>{callerMember}:{callerLineNumber}", async () => 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(); act();
return;
}
await _framework.RunOnFrameworkThread(act).ConfigureAwait(false);
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
@@ -455,18 +654,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
var fileName = Path.GetFileNameWithoutExtension(callerFilePath); var fileName = Path.GetFileNameWithoutExtension(callerFilePath);
return await _performanceCollector.LogPerformance(this, $"RunOnFramework:Func<{typeof(T)}>/{fileName}>{callerMember}:{callerLineNumber}", async () => 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); return func.Invoke();
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 await _framework.RunOnFrameworkThread(func).ConfigureAwait(false);
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
@@ -476,7 +669,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_framework.Update += FrameworkOnUpdate; _framework.Update += FrameworkOnUpdate;
if (IsLoggedIn) if (IsLoggedIn)
{ {
_classJobId = _clientState.LocalPlayer!.ClassJob.RowId; _classJobId = _objectTable.LocalPlayer!.ClassJob.RowId;
} }
_logger.LogInformation("Started DalamudUtilService"); _logger.LogInformation("Started DalamudUtilService");
@@ -489,6 +682,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.UnsubscribeAll(this); Mediator.UnsubscribeAll(this);
_framework.Update -= FrameworkOnUpdate; _framework.Update -= FrameworkOnUpdate;
if (_FocusPairIdent.HasValue)
{
if (_framework.IsInFrameworkUpdateThread)
{
ReleaseFocusUnsafe();
}
else
{
_ = RunOnFrameworkThread(ReleaseFocusUnsafe);
}
}
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -513,15 +717,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{ {
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler); logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick; 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); 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) catch (AccessViolationException ex)
{ {
logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler); logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler);
@@ -535,7 +735,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
const int tick = 250; const int tick = 250;
int curWaitTime = 0; int curWaitTime = 0;
_logger.LogTrace("RenderFlags: {flags}", obj->RenderFlags.ToString("X")); _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"); _logger.LogTrace($"Waiting for gpose actor to finish drawing");
curWaitTime += tick; curWaitTime += tick;
@@ -552,8 +752,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
internal (string Name, nint Address) FindPlayerByNameHash(string ident) internal (string Name, nint Address) FindPlayerByNameHash(string ident)
{ {
_playerCharas.TryGetValue(ident, out var result); if (_actorObjectService.TryGetValidatedActorByHash(ident, out var descriptor))
return result; {
return (descriptor.Name, descriptor.Address);
}
return default;
} }
public string? GetWorldNameFromPlayerAddress(nint address) public string? GetWorldNameFromPlayerAddress(nint address)
@@ -576,7 +780,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
bool isDrawingChanged = false; bool isDrawingChanged = false;
if ((nint)drawObj != IntPtr.Zero) if ((nint)drawObj != IntPtr.Zero)
{ {
isDrawing = gameObj->RenderFlags == 0b100000000000; isDrawing = (gameObj->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None;
if (!isDrawing) if (!isDrawing)
{ {
isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0;
@@ -629,7 +833,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
private unsafe void FrameworkOnUpdateInternal() private unsafe void FrameworkOnUpdateInternal()
{ {
if ((_clientState.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty])
{ {
return; return;
} }
@@ -639,37 +843,43 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () => _performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () =>
{ {
IsAnythingDrawing = false; 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]; var actor = playerDescriptors[i];
if (chara == null || chara.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player)
var playerAddress = actor.Address;
if (playerAddress == nint.Zero)
continue; 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; continue;
} }
var charaName = ((GameObject*)chara.Address)->NameString;
var hash = GetHashedCIDFromPlayerPointer(chara.Address);
if (!IsAnythingDrawing) if (!IsAnythingDrawing)
CheckCharacterForDrawing(chara.Address, charaName); {
_notUpdatedCharas.Remove(hash); var gameObj = (GameObject*)playerAddress;
_playerCharas[hash] = (charaName, chara.Address); 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)) if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer))
@@ -679,76 +889,75 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_lastGlobalBlockReason = string.Empty; _lastGlobalBlockReason = string.Empty;
} }
if (_clientState.IsGPosing && !IsInGpose) // Checks on conditions
{ var shouldBeInGpose = _clientState.IsGPosing;
_logger.LogDebug("Gpose start"); var shouldBeInCombat = _condition[ConditionFlag.InCombat] && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat;
IsInGpose = true; var shouldBePerforming = _condition[ConditionFlag.Performing] && _playerPerformanceConfigService.Current.PauseWhilePerforming;
Mediator.Publish(new GposeStartMessage()); var shouldBeInInstance = _condition[ConditionFlag.BoundByDuty] && _playerPerformanceConfigService.Current.PauseInInstanceDuty;
} var shouldBeInCutscene = _condition[ConditionFlag.WatchingCutscene];
else if (!_clientState.IsGPosing && IsInGpose)
{
_logger.LogDebug("Gpose end");
IsInGpose = false;
Mediator.Publish(new GposeEndMessage());
}
if ((_condition[ConditionFlag.InCombat]) && !IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat) // Gpose
{ HandleStateTransition(() => IsInGpose, v => IsInGpose = v, shouldBeInGpose, "Gpose",
_logger.LogDebug("Combat start"); onEnter: () =>
IsInCombat = true; {
Mediator.Publish(new CombatStartMessage()); Mediator.Publish(new GposeStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCombat))); },
} onExit: () =>
else if ((!_condition[ConditionFlag.InCombat]) && IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat) {
{ Mediator.Publish(new GposeEndMessage());
_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)));
}
if (_condition[ConditionFlag.WatchingCutscene] && !IsInCutscene) // Combat
{ HandleStateTransition(() => IsInCombat, v => IsInCombat = v, shouldBeInCombat, "Combat",
_logger.LogDebug("Cutscene start"); onEnter: () =>
IsInCutscene = true; {
Mediator.Publish(new CutsceneStartMessage()); Mediator.Publish(new CombatStartMessage());
Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene))); Mediator.Publish(new HaltScanMessage(nameof(IsInCombat)));
} },
else if (!_condition[ConditionFlag.WatchingCutscene] && IsInCutscene) onExit: () =>
{ {
_logger.LogDebug("Cutscene end"); Mediator.Publish(new CombatEndMessage());
IsInCutscene = false; Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat)));
Mediator.Publish(new CutsceneEndMessage()); });
Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene)));
} // 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) if (IsInCutscene)
{ {
@@ -782,10 +991,22 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas))); Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas)));
} }
var localPlayer = _clientState.LocalPlayer; var localPlayer = _objectTable.LocalPlayer;
if (localPlayer != null) if (localPlayer != null)
{ {
_classJobId = localPlayer.ClassJob.RowId; _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) if (!IsInCombat || !IsPerforming || !IsInInstance)
@@ -801,6 +1022,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_logger.LogDebug("Logged in"); _logger.LogDebug("Logged in");
IsLoggedIn = true; IsLoggedIn = true;
_lastZone = _clientState.TerritoryType; _lastZone = _clientState.TerritoryType;
_lastWorldId = (ushort)localPlayer.CurrentWorld.RowId;
_cid = RebuildCID(); _cid = RebuildCID();
Mediator.Publish(new DalamudLoginMessage()); Mediator.Publish(new DalamudLoginMessage());
} }
@@ -808,6 +1030,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
{ {
_logger.LogDebug("Logged out"); _logger.LogDebug("Logged out");
IsLoggedIn = false; IsLoggedIn = false;
_lastWorldId = 0;
Mediator.Publish(new DalamudLogoutMessage()); Mediator.Publish(new DalamudLogoutMessage());
} }
@@ -825,4 +1048,31 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
_delayedFrameworkUpdateCheck = DateTime.UtcNow; _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 DateTime EventTime { get; }
public string UID { get; } public string UID { get; }
public string AliasOrUid { get; }
public string UserId { get; }
public string Character { get; } public string Character { get; }
public string EventSource { get; } public string EventSource { get; }
public EventSeverity EventSeverity { get; } public EventSeverity EventSeverity { get; }
@@ -14,7 +16,9 @@ public record Event
public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message) public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message)
{ {
EventTime = DateTime.Now; 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.Character = Character ?? string.Empty;
this.EventSource = EventSource; this.EventSource = EventSource;
this.EventSeverity = EventSeverity; this.EventSeverity = EventSeverity;
@@ -37,7 +41,7 @@ public record Event
else else
{ {
if (string.IsNullOrEmpty(Character)) 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 else
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}\\{Character}> {Message}"; 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

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

View File

@@ -1,18 +1,21 @@
using LightlessSync.API.Dto.Group; using Dalamud.Interface;
using LightlessSync.API.Dto.Group;
using LightlessSync.API.Dto.User; using LightlessSync.API.Dto.User;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using LightlessSync.Utils; using LightlessSync.Utils;
using LightlessSync.WebAPI; using LightlessSync.WebAPI;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Threading;
namespace LightlessSync.Services; namespace LightlessSync.Services.LightFinder;
public class BroadcastService : IHostedService, IMediatorSubscriber public class LightFinderService : IHostedService, IMediatorSubscriber
{ {
private readonly ILogger<BroadcastService> _logger; private readonly ILogger<LightFinderService> _logger;
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly LightlessMediator _mediator; private readonly LightlessMediator _mediator;
private readonly LightlessConfigService _config; private readonly LightlessConfigService _config;
@@ -41,7 +44,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
} }
} }
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController) public LightFinderService(ILogger<LightFinderService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
{ {
_logger = logger; _logger = logger;
_mediator = mediator; _mediator = mediator;
@@ -54,7 +57,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{ {
if (!_apiController.IsConnected) if (!_apiController.IsConnected)
{ {
_logger.LogDebug(context + " skipped, not connected"); _logger.LogDebug("{context} skipped, not connected", context);
return; return;
} }
await action().ConfigureAwait(false); await action().ConfigureAwait(false);
@@ -140,6 +143,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
IsLightFinderAvailable = false; IsLightFinderAvailable = false;
ApplyBroadcastDisabled(forcePublish: true); ApplyBroadcastDisabled(forcePublish: true);
_logger.LogDebug("Cleared Lightfinder state due to disconnect."); _logger.LogDebug("Cleared Lightfinder state due to disconnect.");
} }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
@@ -236,6 +240,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{ {
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect."); _logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true)); _mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
_mediator.Publish(new NotificationMessage(
"Broadcast Auto-Enabled",
"Your Lightfinder broadcast has been automatically enabled.",
NotificationType.Info));
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -272,7 +281,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (!msg.Enabled) if (!msg.Enabled)
{ {
ApplyBroadcastDisabled(forcePublish: true); ApplyBroadcastDisabled(forcePublish: true);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}"))); Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
return; return;
} }
@@ -285,7 +294,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (TryApplyBroadcastEnabled(ttl, "client request")) if (TryApplyBroadcastEnabled(ttl, "client request"))
{ {
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl); _logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}"))); Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
} }
else else
{ {
@@ -362,7 +371,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids) public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
{ {
Dictionary<string, BroadcastStatusInfoDto?> result = new(); Dictionary<string, BroadcastStatusInfoDto?> result = new(StringComparer.Ordinal);
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () => await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
{ {
@@ -387,13 +396,16 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
return result; return result;
} }
public async void ToggleBroadcast() public async void ToggleBroadcast()
{ {
if (!IsLightFinderAvailable) if (!IsLightFinderAvailable)
{ {
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available."); _logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
_mediator.Publish(new NotificationMessage(
"Broadcast Unavailable",
"Lightfinder is not available on this server.",
NotificationType.Error));
return; return;
} }
@@ -403,6 +415,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero) if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
{ {
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds); _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; return;
} }
@@ -427,10 +443,19 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus); _logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
_mediator.Publish(new EnableBroadcastMessage(hashedCid, 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to determine current broadcast status for toggle"); _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); }).ConfigureAwait(false);
} }
@@ -493,6 +518,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
{ {
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally."); _logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
ApplyBroadcastDisabled(forcePublish: true); ApplyBroadcastDisabled(forcePublish: true);
ShowBroadcastExpiredNotification();
} }
} }
else else
@@ -501,4 +527,49 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
} }
}).ConfigureAwait(false); }).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

@@ -6,9 +6,12 @@ using LightlessSync.API.Dto.Group;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Chat;
using LightlessSync.Services.Events; using LightlessSync.Services.Events;
using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.Files.Models;
using System.Numerics; using System.Numerics;
using LightlessSync.UI.Models;
namespace LightlessSync.Services.Mediator; namespace LightlessSync.Services.Mediator;
@@ -20,12 +23,15 @@ public record OpenSettingsUiMessage : MessageBase;
public record OpenLightfinderSettingsMessage : MessageBase; public record OpenLightfinderSettingsMessage : MessageBase;
public record DalamudLoginMessage : MessageBase; public record DalamudLoginMessage : MessageBase;
public record DalamudLogoutMessage : 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 PriorityFrameworkUpdateMessage : SameThreadMessage;
public record FrameworkUpdateMessage : SameThreadMessage; public record FrameworkUpdateMessage : SameThreadMessage;
public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase; public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase;
public record DelayedFrameworkUpdateMessage : SameThreadMessage; public record DelayedFrameworkUpdateMessage : SameThreadMessage;
public record ZoneSwitchStartMessage : MessageBase; public record ZoneSwitchStartMessage : MessageBase;
public record ZoneSwitchEndMessage : MessageBase; public record ZoneSwitchEndMessage : MessageBase;
public record WorldChangedMessage(ushort PreviousWorldId, ushort CurrentWorldId) : MessageBase;
public record CutsceneStartMessage : MessageBase; public record CutsceneStartMessage : MessageBase;
public record GposeStartMessage : SameThreadMessage; public record GposeStartMessage : SameThreadMessage;
public record GposeEndMessage : MessageBase; public record GposeEndMessage : MessageBase;
@@ -48,31 +54,43 @@ public record PetNamesMessage(string PetNicknamesData) : MessageBase;
public record HonorificReadyMessage : MessageBase; public record HonorificReadyMessage : MessageBase;
public record TransientResourceChangedMessage(IntPtr Address) : MessageBase; public record TransientResourceChangedMessage(IntPtr Address) : MessageBase;
public record HaltScanMessage(string Source) : MessageBase; public record HaltScanMessage(string Source) : MessageBase;
public record ResumeScanMessage(string Source) : MessageBase;
public record NotificationMessage public record NotificationMessage
(string Title, string Message, NotificationType Type, TimeSpan? TimeShownOnScreen = null) : MessageBase; (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 CreateCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage; public record ClearCacheForObjectMessage(GameObjectHandler ObjectToCreateFor) : SameThreadMessage;
public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage; public record CharacterDataCreatedMessage(CharacterData CharacterData) : SameThreadMessage;
public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase; public record LightlessNotificationMessage(LightlessSync.UI.Models.LightlessNotification Notification) : MessageBase;
public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase; public record LightlessNotificationDismissMessage(string NotificationId) : MessageBase;
public record ClearAllNotificationsMessage : MessageBase;
public record CharacterDataAnalyzedMessage : MessageBase; public record CharacterDataAnalyzedMessage : MessageBase;
public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase; public record PenumbraStartRedrawMessage(IntPtr Address) : MessageBase;
public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase; public record PenumbraEndRedrawMessage(IntPtr Address) : MessageBase;
public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage; public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage;
public record HubReconnectedMessage(string? Arg) : SameThreadMessage; public record HubReconnectedMessage(string? Arg) : SameThreadMessage;
public record HubClosedMessage(Exception? Exception) : 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 DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase; public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase; public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase; public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase; public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
public record ClearProfileDataMessage(UserData? UserData = null) : MessageBase; public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase;
public record CyclePauseMessage(UserData UserData) : MessageBase; public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase;
public record CyclePauseMessage(Pair Pair) : MessageBase;
public record PauseMessage(UserData UserData) : MessageBase; public record PauseMessage(UserData UserData) : MessageBase;
public record ProfilePopoutToggle(Pair? Pair) : MessageBase; public record ProfilePopoutToggle(Pair? Pair) : MessageBase;
public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase; public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase;
public record ProfileOpenStandaloneMessage(Pair Pair) : 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 RemoveWindowMessage(WindowMediatorSubscriberBase Window) : MessageBase;
public record RefreshUiMessage : MessageBase; public record RefreshUiMessage : MessageBase;
public record OpenBanUserPopupMessage(Pair PairToBan, GroupFullInfoDto GroupFullInfoDto) : MessageBase; public record OpenBanUserPopupMessage(Pair PairToBan, GroupFullInfoDto GroupFullInfoDto) : MessageBase;
@@ -81,8 +99,11 @@ public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase;
public record OpenPermissionWindow(Pair Pair) : MessageBase; public record OpenPermissionWindow(Pair Pair) : MessageBase;
public record DownloadLimitChangedMessage() : SameThreadMessage; public record DownloadLimitChangedMessage() : SameThreadMessage;
public record PairProcessingLimitChangedMessage : 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 CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase; public record TargetPairMessage(Pair Pair) : MessageBase;
public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
public record CombatStartMessage : MessageBase; public record CombatStartMessage : MessageBase;
public record CombatEndMessage : MessageBase; public record CombatEndMessage : MessageBase;
public record PerformanceStartMessage : MessageBase; public record PerformanceStartMessage : MessageBase;
@@ -103,8 +124,16 @@ public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData)
public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase; public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase;
public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase; public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase;
public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : 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 SyncshellBroadcastsUpdatedMessage : MessageBase;
public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase;
public record PairRequestsUpdatedMessage : 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 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 S2094
#pragma warning restore MA0048 // File name must match type name #pragma warning restore MA0048 // File name must match type name

View File

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

View File

@@ -1,114 +1,254 @@
using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Game.Gui.NamePlate; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Game.NativeWrapper;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Hooking;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility; 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.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI; using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Numerics;
using static LightlessSync.UI.DtrEntry;
using LSeStringBuilder = Lumina.Text.SeStringBuilder;
namespace LightlessSync.Services; 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 ILogger<NameplateService> _logger;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly IClientState _clientState; private readonly IClientState _clientState;
private readonly INamePlateGui _namePlateGui; private readonly IGameGui _gameGui;
private readonly PairManager _pairManager; private readonly IObjectTable _objectTable;
private readonly PairUiService _pairUiService;
public NameplateService(ILogger<NameplateService> logger, public NameplateService(ILogger<NameplateService> logger,
LightlessConfigService configService, LightlessConfigService configService,
INamePlateGui namePlateGui,
IClientState clientState, IClientState clientState,
PairManager pairManager, IGameGui gameGui,
LightlessMediator lightlessMediator) : base(logger, lightlessMediator) IObjectTable objectTable,
IGameInteropProvider interop,
LightlessMediator lightlessMediator,
PairUiService pairUiService) : base(logger, lightlessMediator)
{ {
_logger = logger; _logger = logger;
_configService = configService; _configService = configService;
_namePlateGui = namePlateGui;
_clientState = clientState; _clientState = clientState;
_pairManager = pairManager; _gameGui = gameGui;
_objectTable = objectTable;
_pairUiService = pairUiService;
_namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; interop.InitializeFromAttributes(this);
_namePlateGui.RequestRedraw(); _nameplateHook?.Enable();
Mediator.Subscribe<VisibilityChange>(this, (_) => _namePlateGui.RequestRedraw()); Refresh();
Mediator.Subscribe<VisibilityChange>(this, (_) => Refresh());
} }
private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList<INamePlateUpdateHandler> handlers) /// <summary>
/// Detour for the game's internal nameplate update function.
/// This will be called whenever the client updates any nameplate.
///
/// We hook into it to apply our own nameplate coloring logic via <see cref="SetNameplate"/>,
/// </summary>
private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
{ {
if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) try
{
SetNameplate(namePlateInfo, battleChara);
}
catch (Exception e)
{
_logger.LogError(e, "Error in NameplateService UpdateNameplateDetour");
}
return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex);
}
/// <summary>
/// Determine if the player should be colored based on conditions (isFriend, IsInParty)
/// </summary>
/// <param name="playerCharacter">Player character that will be checked</param>
/// <param name="visibleUserIds">All visible users in the current object table</param>
/// <returns>PLayer should or shouldnt be colored based on the result. True means colored</returns>
private bool ShouldColorPlayer(IPlayerCharacter playerCharacter, HashSet<ulong> visibleUserIds)
{
if (!visibleUserIds.Contains(playerCharacter.GameObjectId))
return false;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember);
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = _configService.Current.overridePartyColor && isInParty;
bool friendColorAllowed = _configService.Current.overrideFriendColor && isFriend;
if ((isInParty && !partyColorAllowed) || (isFriend && !friendColorAllowed))
return false;
return true;
}
/// <summary>
/// Setting up the nameplate of the user to be colored
/// </summary>
/// <param name="namePlateInfo">Information given from the Signature to be updated</param>
/// <param name="battleChara">Character from FF</param>
private void SetNameplate(RaptureAtkModule.NamePlateInfo* namePlateInfo, BattleChara* battleChara)
{
if (!_configService.Current.IsNameplateColorsEnabled || _clientState.IsPvPExcludingDen)
return;
if (namePlateInfo == null || battleChara == null)
return; return;
var visibleUsersIds = _pairManager.GetOnlineUserPairs() var obj = _objectTable.FirstOrDefault(o => o.Address == (nint)battleChara);
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) if (obj is not IPlayerCharacter player)
.Select(u => (ulong)u.PlayerCharacterId) return;
.ToHashSet();
var now = DateTime.UtcNow; var snapshot = _pairUiService.GetSnapshot();
var colors = _configService.Current.NameplateColors; var visibleUsersIds = snapshot.PairsByUid.Values
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
.Select(u => (ulong)u.PlayerCharacterId)
.ToHashSet();
foreach (var handler in handlers) //Check if player should be colored
{ if (!ShouldColorPlayer(player, visibleUsersIds))
var playerCharacter = handler.PlayerCharacter; return;
if (playerCharacter == null)
continue;
var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember); var originalName = player.Name.ToString();
var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend);
bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty);
bool friendColorAllowed = (_configService.Current.overrideFriendColor && isFriend);
if (visibleUsersIds.Contains(handler.GameObjectId) && //Check if not null of the name
!( if (string.IsNullOrEmpty(originalName))
(isInParty && !partyColorAllowed) || return;
(isFriend && !friendColorAllowed)
))
{
handler.NameParts.TextWrap = CreateTextWrap(colors);
if (_configService.Current.overrideFcTagColor) //Check if any characters/symbols are forbidden
{ if (HasForbiddenSeStringChars(originalName))
bool hasActualFcTag = playerCharacter.CompanyTag.TextValue.Length > 0; return;
bool isFromDifferentRealm = playerCharacter.HomeWorld.RowId != playerCharacter.CurrentWorld.RowId;
bool shouldColorFcArea = hasActualFcTag || (!hasActualFcTag && isFromDifferentRealm);
if (shouldColorFcArea) //Swap color channels as we store them in BGR format as FF loves that
{ var cfgColors = SwapColorChannels(_configService.Current.NameplateColors);
handler.FreeCompanyTagParts.OuterWrap = CreateTextWrap(colors); var coloredName = WrapStringInColor(originalName, cfgColors.Glow, cfgColors.Foreground);
handler.FreeCompanyTagParts.TextWrap = CreateTextWrap(colors);
} //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() 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(); AtkUnitBasePtr namePlateAddon = _gameGui.GetAddonByName("NamePlate");
var right = new Lumina.Text.SeStringBuilder();
left.PushColorRgba(color.Foreground); if (namePlateAddon.IsNull)
right.PopColor(); {
_logger.LogInformation("NamePlate addon is null, cannot refresh nameplates.");
return;
}
left.PushEdgeColorRgba(color.Glow); var addonNamePlate = (AddonNamePlate*)namePlateAddon.Address;
right.PopEdgeColor();
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) protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); if (disposing)
{
_nameplateHook?.Dispose();
}
_namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate; base.Dispose(disposing);
_namePlateGui.RequestRedraw();
} }
} }

View File

@@ -4,15 +4,22 @@ using Dalamud.Interface.ImGuiNotification;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI; using LightlessSync.UI;
using LightlessSync.UI.Models; using LightlessSync.UI.Models;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI;
using LightlessSync.API.Data;
using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType; using NotificationType = LightlessSync.LightlessConfiguration.Models.NotificationType;
namespace LightlessSync.Services; namespace LightlessSync.Services;
public class NotificationService : DisposableMediatorSubscriberBase, IHostedService public class NotificationService : DisposableMediatorSubscriberBase, IHostedService
{ {
private readonly ILogger<NotificationService> _logger; private readonly ILogger<NotificationService> _logger;
@@ -21,7 +28,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
private readonly INotificationManager _notificationManager; private readonly INotificationManager _notificationManager;
private readonly IChatGui _chatGui; private readonly IChatGui _chatGui;
private readonly PairRequestService _pairRequestService; private readonly PairRequestService _pairRequestService;
private readonly HashSet<string> _shownPairRequestNotifications = new(); private readonly HashSet<string> _shownPairRequestNotifications = [];
private readonly PairUiService _pairUiService;
private readonly PairFactory _pairFactory;
public NotificationService( public NotificationService(
ILogger<NotificationService> logger, ILogger<NotificationService> logger,
@@ -30,7 +39,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
INotificationManager notificationManager, INotificationManager notificationManager,
IChatGui chatGui, IChatGui chatGui,
LightlessMediator mediator, LightlessMediator mediator,
PairRequestService pairRequestService) : base(logger, mediator) PairRequestService pairRequestService,
PairUiService pairUiService,
PairFactory pairFactory) : base(logger, mediator)
{ {
_logger = logger; _logger = logger;
_configService = configService; _configService = configService;
@@ -38,12 +49,17 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_notificationManager = notificationManager; _notificationManager = notificationManager;
_chatGui = chatGui; _chatGui = chatGui;
_pairRequestService = pairRequestService; _pairRequestService = pairRequestService;
_pairUiService = pairUiService;
_pairFactory = pairFactory;
} }
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage); Mediator.Subscribe<NotificationMessage>(this, HandleNotificationMessage);
Mediator.Subscribe<PairRequestReceivedMessage>(this, HandlePairRequestReceived);
Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated); Mediator.Subscribe<PairRequestsUpdatedMessage>(this, HandlePairRequestsUpdated);
Mediator.Subscribe<PairDownloadStatusMessage>(this, HandlePairDownloadStatus);
Mediator.Subscribe<PerformanceNotificationMessage>(this, HandlePerformanceNotification);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -54,7 +70,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
{ {
var notification = CreateNotification(title, message, type, duration, actions, soundEffectId); var notification = CreateNotification(title, message, type, duration, actions, soundEffectId);
if (_configService.Current.AutoDismissOnAction && notification.Actions.Any()) if (_configService.Current.AutoDismissOnAction && notification.Actions.Count != 0)
{ {
WrapActionsWithAutoDismiss(notification); WrapActionsWithAutoDismiss(notification);
} }
@@ -99,7 +115,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
} }
} }
private void DismissNotification(LightlessNotification notification) private static void DismissNotification(LightlessNotification notification)
{ {
notification.IsDismissed = true; notification.IsDismissed = true;
notification.IsAnimatingOut = true; notification.IsAnimatingOut = true;
@@ -107,23 +123,42 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline) public void ShowPairRequestNotification(string senderName, string senderId, Action onAccept, Action onDecline)
{ {
var notification = new LightlessNotification var location = GetNotificationLocation(NotificationType.PairRequest);
{
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) // Show in chat if configured
if (location == NotificationLocation.Chat || location == NotificationLocation.ChatAndLightlessUi)
{ {
PlayNotificationSound(notification.SoundEffectId.Value); ShowChat(new NotificationMessage("Pair Request Received", $"{senderName} wants to directly pair with you.", NotificationType.PairRequest));
} }
Mediator.Publish(new LightlessNotificationMessage(notification)); // 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() => private uint? GetPairRequestSoundId() =>
@@ -184,10 +219,12 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationMessage(notification)); Mediator.Publish(new LightlessNotificationMessage(notification));
} }
private string FormatDownloadCompleteMessage(string fileName, int fileCount) => private static string FormatDownloadCompleteMessage(string fileName, int fileCount)
fileCount > 1 {
return fileCount > 1
? $"Downloaded {fileCount} files successfully." ? $"Downloaded {fileCount} files successfully."
: $"Downloaded {fileName} successfully."; : $"Downloaded {fileName} successfully.";
}
private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder) private List<LightlessNotificationAction> CreateDownloadCompleteActions(Action? onOpenFolder)
{ {
@@ -233,8 +270,10 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationMessage(notification)); Mediator.Publish(new LightlessNotificationMessage(notification));
} }
private string FormatErrorMessage(string message, Exception? exception) => private static string FormatErrorMessage(string message, Exception? exception)
exception != null ? $"{message}\n\nError: {exception.Message}" : message; {
return exception != null ? $"{message}\n\nError: {exception.Message}" : message;
}
private List<LightlessNotificationAction> CreateErrorActions(Action? onRetry, Action? onViewLog) private List<LightlessNotificationAction> CreateErrorActions(Action? onRetry, Action? onViewLog)
{ {
@@ -271,33 +310,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return actions; return actions;
} }
public void ShowPairDownloadNotification(List<(string playerName, float progress, string status)> downloadStatus,
int queueWaiting = 0)
{
var userDownloads = downloadStatus.Where(x => x.playerName != "Pair Queue").ToList();
var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.progress) : 0f;
var message = BuildPairDownloadMessage(userDownloads, queueWaiting);
var notification = new LightlessNotification private string BuildPairDownloadMessage(List<(string PlayerName, float Progress, string Status)> userDownloads,
{
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));
if (AreAllDownloadsCompleted(userDownloads))
{
DismissPairDownloadNotification();
}
}
private string BuildPairDownloadMessage(List<(string playerName, float progress, string status)> userDownloads,
int queueWaiting) int queueWaiting)
{ {
var messageParts = new List<string>(); var messageParts = new List<string>();
@@ -309,7 +323,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
if (userDownloads.Count > 0) if (userDownloads.Count > 0)
{ {
var completedCount = userDownloads.Count(x => x.progress >= 1.0f); var completedCount = userDownloads.Count(x => x.Progress >= 1.0f);
messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed"); messageParts.Add($"Progress: {completedCount}/{userDownloads.Count} completed");
} }
@@ -322,32 +336,28 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
return string.Join("\n", messageParts); return string.Join("\n", messageParts);
} }
private string BuildActiveDownloadLines(List<(string playerName, float progress, string status)> userDownloads) private string BuildActiveDownloadLines(List<(string PlayerName, float Progress, string Status)> userDownloads)
{ {
var activeDownloads = userDownloads var activeDownloads = userDownloads
.Where(x => x.progress < 1.0f) .Where(x => x.Progress < 1.0f)
.Take(_configService.Current.MaxConcurrentPairApplications); .Take(_configService.Current.MaxConcurrentPairApplications);
if (!activeDownloads.Any()) return string.Empty; if (!activeDownloads.Any()) return string.Empty;
return string.Join("\n", activeDownloads.Select(x => $"• {x.playerName}: {FormatDownloadStatus(x)}")); return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}"));
} }
private string FormatDownloadStatus((string playerName, float progress, string status) download) => private static string FormatDownloadStatus((string PlayerName, float Progress, string Status) download)
download.status switch {
return download.Status switch
{ {
"downloading" => $"{download.progress:P0}", "downloading" => $"{download.Progress:P0}",
"decompressing" => "decompressing", "decompressing" => "decompressing",
"queued" => "queued", "queued" => "queued",
"waiting" => "waiting for slot", "waiting" => "waiting for slot",
_ => download.status _ => download.Status
}; };
}
private bool AreAllDownloadsCompleted(List<(string playerName, float progress, string status)> userDownloads) =>
userDownloads.Any() && userDownloads.All(x => x.progress >= 1.0f);
public void DismissPairDownloadNotification() =>
Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress"));
private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch
{ {
@@ -356,6 +366,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds), NotificationType.Error => TimeSpan.FromSeconds(_configService.Current.ErrorNotificationDurationSeconds),
NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds), NotificationType.PairRequest => TimeSpan.FromSeconds(_configService.Current.PairRequestDurationSeconds),
NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds), NotificationType.Download => TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
NotificationType.Performance => TimeSpan.FromSeconds(_configService.Current.PerformanceNotificationDurationSeconds),
_ => TimeSpan.FromSeconds(10) _ => TimeSpan.FromSeconds(10)
}; };
@@ -371,7 +382,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Info => _configService.Current.DisableInfoSound, NotificationType.Info => _configService.Current.DisableInfoSound,
NotificationType.Warning => _configService.Current.DisableWarningSound, NotificationType.Warning => _configService.Current.DisableWarningSound,
NotificationType.Error => _configService.Current.DisableErrorSound, NotificationType.Error => _configService.Current.DisableErrorSound,
NotificationType.Download => _configService.Current.DisableDownloadSound, NotificationType.Performance => _configService.Current.DisablePerformanceSound,
NotificationType.Download => true, // Download sounds always disabled
_ => false _ => false
}; };
@@ -380,7 +392,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Info => _configService.Current.CustomInfoSoundId, NotificationType.Info => _configService.Current.CustomInfoSoundId,
NotificationType.Warning => _configService.Current.CustomWarningSoundId, NotificationType.Warning => _configService.Current.CustomWarningSoundId,
NotificationType.Error => _configService.Current.CustomErrorSoundId, NotificationType.Error => _configService.Current.CustomErrorSoundId,
NotificationType.Download => _configService.Current.DownloadSoundId, NotificationType.Performance => _configService.Current.PerformanceSoundId,
_ => NotificationSounds.GetDefaultSound(type) _ => NotificationSounds.GetDefaultSound(type)
}; };
@@ -396,6 +408,17 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId); _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) private void HandleNotificationMessage(NotificationMessage msg)
{ {
@@ -418,6 +441,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
NotificationType.Error => _configService.Current.LightlessErrorNotification, NotificationType.Error => _configService.Current.LightlessErrorNotification,
NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification, NotificationType.PairRequest => _configService.Current.LightlessPairRequestNotification,
NotificationType.Download => _configService.Current.LightlessDownloadNotification, NotificationType.Download => _configService.Current.LightlessDownloadNotification,
NotificationType.Performance => _configService.Current.LightlessPerformanceNotification,
_ => NotificationLocation.LightlessUi _ => NotificationLocation.LightlessUi
}; };
@@ -482,13 +506,16 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
}); });
} }
private Dalamud.Interface.ImGuiNotification.NotificationType private static Dalamud.Interface.ImGuiNotification.NotificationType
ConvertToDalamudNotificationType(NotificationType type) => type switch ConvertToDalamudNotificationType(NotificationType type)
{ {
NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, return type switch
NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, {
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error,
}; NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
_ => Dalamud.Interface.ImGuiNotification.NotificationType.Info
};
}
private void ShowChat(NotificationMessage msg) private void ShowChat(NotificationMessage msg)
{ {
@@ -505,6 +532,18 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
case NotificationType.Error: case NotificationType.Error:
PrintErrorChat(msg.Message); PrintErrorChat(msg.Message);
break; 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;
} }
} }
@@ -528,12 +567,41 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
_chatGui.Print(se.BuiltString); _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 _) private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _)
{ {
var activeRequests = _pairRequestService.GetActiveRequests(); var activeRequests = _pairRequestService.GetActiveRequests();
var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(); var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(StringComparer.Ordinal);
// Dismiss notifications for requests that are no longer active // Dismiss notifications for requests that are no longer active (expired)
var notificationsToRemove = _shownPairRequestNotifications var notificationsToRemove = _shownPairRequestNotifications
.Where(hashedCid => !activeRequestIds.Contains(hashedCid)) .Where(hashedCid => !activeRequestIds.Contains(hashedCid))
.ToList(); .ToList();
@@ -544,18 +612,172 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ
Mediator.Publish(new LightlessNotificationDismissMessage(notificationId)); Mediator.Publish(new LightlessNotificationDismissMessage(notificationId));
_shownPairRequestNotifications.Remove(hashedCid); _shownPairRequestNotifications.Remove(hashedCid);
} }
}
// Show/update notifications for all active requests private void HandlePairDownloadStatus(PairDownloadStatusMessage msg)
foreach (var request in activeRequests) {
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
{ {
_shownPairRequestNotifications.Add(request.HashedCid); Id = "pair_download_progress",
ShowPairRequestNotification( Title = "Downloading Pair Data",
request.DisplayName, Message = message,
request.HashedCid, Type = NotificationType.Download,
() => _pairRequestService.AcceptPairRequest(request.HashedCid, request.DisplayName), Duration = TimeSpan.FromSeconds(_configService.Current.DownloadNotificationDurationSeconds),
() => _pairRequestService.DeclinePairRequest(request.HashedCid) 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

@@ -1,20 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.Services; namespace LightlessSync.Services.PairProcessing;
public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{ {
private const int HardLimit = 32; private const int _hardLimit = 32;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly object _limitLock = new(); private readonly object _limitLock = new();
private readonly SemaphoreSlim _semaphore; private readonly SemaphoreSlim _semaphore;
private int _currentLimit; private int _currentLimit;
private int _pendingReductions; private int _pendingReductions;
private int _pendingIncrements;
private int _waiting; private int _waiting;
private int _inFlight; private int _inFlight;
@@ -23,8 +21,8 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{ {
_configService = configService; _configService = configService;
_currentLimit = CalculateLimit(); _currentLimit = CalculateLimit();
var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : HardLimit; var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : _hardLimit;
_semaphore = new SemaphoreSlim(initialCount, HardLimit); _semaphore = new SemaphoreSlim(initialCount, _hardLimit);
Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit()); Mediator.Subscribe<PairProcessingLimitChangedMessage>(this, _ => UpdateSemaphoreLimit());
} }
@@ -70,7 +68,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!IsEnabled) if (!IsEnabled)
{ {
_semaphore.Release(); TryReleaseSemaphore();
return NoopReleaser.Instance; return NoopReleaser.Instance;
} }
@@ -87,21 +85,15 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (!enabled) if (!enabled)
{ {
var releaseAmount = HardLimit - _semaphore.CurrentCount; var releaseAmount = _hardLimit - _semaphore.CurrentCount;
if (releaseAmount > 0) if (releaseAmount > 0)
{ {
try TryReleaseSemaphore(releaseAmount);
{
_semaphore.Release(releaseAmount);
}
catch (SemaphoreFullException)
{
// ignore, already at max
}
} }
_currentLimit = desiredLimit; _currentLimit = desiredLimit;
_pendingReductions = 0; _pendingReductions = 0;
_pendingIncrements = 0;
return; return;
} }
@@ -113,10 +105,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
if (desiredLimit > _currentLimit) if (desiredLimit > _currentLimit)
{ {
var increment = desiredLimit - _currentLimit; var increment = desiredLimit - _currentLimit;
var allowed = Math.Min(increment, HardLimit - _semaphore.CurrentCount); _pendingIncrements += increment;
if (allowed > 0)
var available = _hardLimit - _semaphore.CurrentCount;
var toRelease = Math.Min(_pendingIncrements, available);
if (toRelease > 0 && TryReleaseSemaphore(toRelease))
{ {
_semaphore.Release(allowed); _pendingIncrements -= toRelease;
} }
} }
else else
@@ -133,6 +128,13 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
{ {
_pendingReductions += remaining; _pendingReductions += remaining;
} }
if (_pendingIncrements > 0)
{
var offset = Math.Min(_pendingIncrements, _pendingReductions);
_pendingIncrements -= offset;
_pendingReductions -= offset;
}
} }
_currentLimit = desiredLimit; _currentLimit = desiredLimit;
@@ -143,7 +145,26 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
private int CalculateLimit() private int CalculateLimit()
{ {
var configured = _configService.Current.MaxConcurrentPairApplications; var configured = _configService.Current.MaxConcurrentPairApplications;
return Math.Clamp(configured, 1, HardLimit); 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() private void ReleaseOne()
@@ -166,9 +187,20 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
_pendingReductions--; _pendingReductions--;
return; return;
} }
if (_pendingIncrements > 0)
{
if (!TryReleaseSemaphore())
{
return;
}
_pendingIncrements--;
return;
}
} }
_semaphore.Release(); TryReleaseSemaphore();
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
@@ -213,8 +245,3 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase
} }
} }
} }
public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting)
{
public int Remaining => Math.Max(0, Limit - InFlight);
}

View File

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

View File

@@ -1,10 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.UI.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace LightlessSync.Services; namespace LightlessSync.Services;
@@ -12,18 +8,24 @@ namespace LightlessSync.Services;
public sealed class PairRequestService : DisposableMediatorSubscriberBase public sealed class PairRequestService : DisposableMediatorSubscriberBase
{ {
private readonly DalamudUtilService _dalamudUtil; private readonly DalamudUtilService _dalamudUtil;
private readonly PairManager _pairManager; private readonly PairUiService _pairUiService;
private readonly Lazy<WebAPI.ApiController> _apiController; private readonly Lazy<WebAPI.ApiController> _apiController;
private readonly object _syncRoot = new(); private readonly Lock _syncRoot = new();
private readonly List<PairRequestEntry> _requests = []; private readonly List<PairRequestEntry> _requests = [];
private readonly Dictionary<string, string> _displayNameCache = new(StringComparer.Ordinal);
private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5); private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5);
public PairRequestService(ILogger<PairRequestService> logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, PairManager pairManager, Lazy<WebAPI.ApiController> apiController) public PairRequestService(
ILogger<PairRequestService> logger,
LightlessMediator mediator,
DalamudUtilService dalamudUtil,
PairUiService pairUiService,
Lazy<WebAPI.ApiController> apiController)
: base(logger, mediator) : base(logger, mediator)
{ {
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_pairManager = pairManager; _pairUiService = pairUiService;
_apiController = apiController; _apiController = apiController;
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ => Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, _ =>
@@ -95,6 +97,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
lock (_syncRoot) lock (_syncRoot)
{ {
removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0; removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0;
if (removed)
{
_displayNameCache.Remove(hashedCid);
}
} }
if (removed) if (removed)
@@ -128,6 +134,23 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
return string.Empty; 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); var (name, address) = _dalamudUtil.FindPlayerByNameHash(hashedCid);
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
{ {
@@ -137,8 +160,9 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
: name; : name;
} }
var pair = _pairManager var snapshot = _pairUiService.GetSnapshot();
.GetOnlineUserPairs() var pair = snapshot.PairsByUid.Values
.Where(p => !string.IsNullOrEmpty(p.GetPlayerNameHash()))
.FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal)); .FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal));
if (pair != null) if (pair != null)
@@ -184,7 +208,21 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0; var removedAny = false;
for (var i = _requests.Count - 1; i >= 0; i--)
{
var entry = _requests[i];
if (now - entry.ReceivedAt <= _expiration)
{
continue;
}
_displayNameCache.Remove(entry.HashedCid);
_requests.RemoveAt(i);
removedAny = true;
}
return removedAny;
} }
public void AcceptPairRequest(string hashedCid, string displayName) public void AcceptPairRequest(string hashedCid, string displayName)
@@ -215,13 +253,45 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase
}); });
} }
public void DeclinePairRequest(string hashedCid) public void DeclinePairRequest(string hashedCid, string displayName)
{ {
RemoveRequest(hashedCid); 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); Logger.LogDebug("Declined pair request from {HashedCid}", hashedCid);
} }
private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt); private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt);
public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, 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(); 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)) if (!PerformanceCounters.TryGetValue(cn, out var list))
{
list = PerformanceCounters[cn] = new(maxEntries); list = PerformanceCounters[cn] = new(maxEntries);
}
var dt = DateTime.UtcNow.Ticks; var dt = DateTime.UtcNow.Ticks;
try try
@@ -53,12 +53,12 @@ public sealed class PerformanceCollectorService : IHostedService
{ {
if (!_lightlessConfigService.Current.LogPerformance) { act.Invoke(); return; } 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)) if (!PerformanceCounters.TryGetValue(cn, out var list))
{
list = PerformanceCounters[cn] = new(maxEntries); list = PerformanceCounters[cn] = new(maxEntries);
}
var dt = DateTime.UtcNow.Ticks; var dt = DateTime.UtcNow.Ticks;
try try
@@ -72,7 +72,7 @@ public sealed class PerformanceCollectorService : IHostedService
if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10)) if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10))
_logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed)); _logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed));
#endif #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.Append("-Counter Name".PadRight(longestCounterName, '-'));
sb.AppendLine(); sb.AppendLine();
var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList(); 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) 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)) if (!string.Equals(previousCaller, newCaller, StringComparison.Ordinal))
{ {
DrawSeparator(sb, longestCounterName); DrawSeparator(sb, longestCounterName);
@@ -135,13 +135,13 @@ public sealed class PerformanceCollectorService : IHostedService
if (pastEntries.Any()) 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('|');
sb.Append((" " + TimeSpan.FromTicks(pastEntries.Max(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15)); sb.Append((" " + TimeSpan.FromTicks(pastEntries.Max(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|'); sb.Append('|');
sb.Append((" " + TimeSpan.FromTicks((long)pastEntries.Average(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15)); sb.Append((" " + TimeSpan.FromTicks((long)pastEntries.Average(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15));
sb.Append('|'); 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('|');
sb.Append((" " + pastEntries.Count).PadRight(10)); sb.Append((" " + pastEntries.Count).PadRight(10));
sb.Append('|'); sb.Append('|');
@@ -157,6 +157,12 @@ public sealed class PerformanceCollectorService : IHostedService
_logger.LogInformation("{perf}", sb.ToString()); _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) private static void DrawSeparator(StringBuilder sb, int longestCounterName)
{ {
sb.Append("".PadRight(15, '-')); sb.Append("".PadRight(15, '-'));
@@ -183,7 +189,7 @@ public sealed class PerformanceCollectorService : IHostedService
{ {
try 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 _)) if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _))
{ {
_logger.LogDebug("Could not remove performance counter {counter}", entries.Key); _logger.LogDebug("Could not remove performance counter {counter}", entries.Key);

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

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