Compare commits

...

331 Commits
1.12.1 ... dev

Author SHA1 Message Date
defnotken
e8f598e695 bumpity bump
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m7s
2026-01-19 12:36:24 -06:00
defnotken
861a337029 Merge branch '2.0.3' into dev
Some checks failed
Tag and Release Lightless / tag-and-release (push) Has been cancelled
2026-01-19 12:35:29 -06:00
06f89955d3 Merge pull request 'clr-fix-attempt' (#141) from clr-fix-attempt into 2.0.3
Reviewed-on: #141
2026-01-19 18:33:55 +00:00
cake
367af2c3d0 Remove blankspace 2026-01-19 19:26:00 +01:00
cake
19a238c808 Removal of unsafe and check if PTR is correctly aligning, checking in virtual query instead of memory 2026-01-19 19:24:44 +01:00
defnotken
c7a2b679f2 bumpity bump
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m2s
2026-01-19 11:23:49 -06:00
defnotken
bec69074a5 Merge branch '2.0.3' into dev 2026-01-19 11:23:23 -06:00
7d86b41cee Merge pull request 'update-decimation-filters' (#140) from update-decimation-filters into 2.0.3
Reviewed-on: #140
2026-01-19 17:22:27 +00:00
0185e6b534 Merge pull request 'Implement Lifestream With Location Sharing.' (#139) from lifestream-location-share into 2.0.3
Reviewed-on: #139
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
2026-01-19 17:02:13 +00:00
defnotken
90bf84f8eb Implement Lifestream With Location Sharing. 2026-01-19 10:58:37 -06:00
f27db300ec fix temp collection config and migrate it 2026-01-19 23:04:54 +09:00
828be6eb5b fix body materials 2026-01-19 21:20:54 +09:00
d039d2fd90 add emperors oops 2026-01-19 14:19:08 +09:00
e75a371475 improve filtering 2026-01-19 14:14:14 +09:00
defnotken
ac711d9a43 Bump plugin testing
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m19s
2026-01-18 20:53:14 -06:00
defnotken
b875e0c3a1 Merge branch '2.0.3' into dev 2026-01-18 20:51:58 -06:00
cake
d6437998ac Added imgui flag for notifcation 2026-01-19 03:51:08 +01:00
cake
4fa9876c1c Fix build 2026-01-19 03:35:36 +01:00
defnotken
46e76bbfe6 Merge branch '2.0.3' into dev 2026-01-18 20:29:53 -06:00
9dd8e19fb7 Merge pull request 'meow decimation' (#138) from meow into 2.0.3
Reviewed-on: #138
2026-01-19 02:29:02 +00:00
5167465d28 up to date with 2.0.3 2026-01-19 10:06:21 +09:00
e8c7539770 fix log level 2026-01-19 09:57:50 +09:00
54d6a0a1a4 reworked mesh decimation yes 2026-01-19 09:50:54 +09:00
b57d54d69c fix some existing issues 2026-01-17 08:00:58 +09:00
8be0811b4a fix pair offline state 2026-01-17 03:06:46 +09:00
7c281926a5 :sludge: 2026-01-16 19:29:24 +09:00
6c7e4e6303 fix task register 2026-01-16 19:18:11 +09:00
e2d663cae9 removal of *temporary* collections 2026-01-16 18:19:12 +09:00
96123d00a2 sigma update 2026-01-16 11:00:58 +09:00
Tsubasahane
4502cadaeb Fix lumina offset for WorldSheet 2026-01-12 13:58:23 +08:00
7f33b6a4ce Fixed missing symbol
"@" was missing for the LightlessClient.zip
2026-01-09 06:11:08 +00:00
61f584f059 Merge pull request 'ci-cd-rework' (#137) from ci-cd-rework into 2.0.3
Reviewed-on: #137
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2026-01-09 05:58:21 +00:00
95d286f990 Yeet 2026-01-08 20:35:15 +00:00
choco
42d6a19db1 syncshell list cleanup 2026-01-07 01:08:28 +01:00
choco
05f7d256d7 syncshell join modal to overlay, which doesnt prevent game interaction 2026-01-07 00:54:50 +01:00
cake
058ba504cb Updated text of options 2026-01-06 15:10:15 +01:00
cake
19966f3828 Default to unsafe 2026-01-06 15:06:21 +01:00
cake
3654365f2a bump version
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m6s
2026-01-06 14:45:23 +01:00
cake
9b256dd185 Merge branch '2.0.3' into dev 2026-01-06 14:45:02 +01:00
cake
d8b9e9cf19 Splitting havok tasks. 2026-01-06 14:27:01 +01:00
cake
ad34d88336 Merged I18N and latest 2.0.3 changes 2026-01-06 13:54:04 +01:00
9167bb1afd i18n init (#135)
shouldnt break anything?

Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Reviewed-on: #135
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2026-01-06 12:51:29 +00:00
cake
5161c6bad3 Attempt fix on crash. 2026-01-06 13:50:58 +01:00
59ed03a825 replace with log in/out flags 2026-01-06 13:27:24 +09:00
ae76efedf8 Merge remote-tracking branch 'origin/2.0.3' into meow 2026-01-06 11:57:37 +09:00
0e24da75d5 MEOW MEOW MEOW 2026-01-06 11:57:01 +09:00
defnotken
223ade39cb another push
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m13s
2026-01-05 20:48:24 -06:00
defnotken
5aca9e70b2 Merge branch '2.0.3' into dev 2026-01-05 20:47:38 -06:00
defnotken
ce28799db3 More checks for animations and bones. 2026-01-05 20:46:14 -06:00
defnotken
92772cf334 dev push
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m12s
2026-01-05 20:21:26 -06:00
defnotken
0395e81a9f Merge branch '2.0.3' into dev 2026-01-05 20:17:12 -06:00
defnotken
9b9010ab8e Defenses? 2026-01-05 18:57:18 -06:00
defnotken
7734a7bf7e dev build
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m8s
2026-01-05 17:42:21 -06:00
defnotken
db2d19bb1e Merge branch '2.0.3' into dev 2026-01-05 17:41:48 -06:00
cake
032201ed9e Changed logging, last change of gameobject 2026-01-06 00:31:08 +01:00
cake
775b128cf3 Removal of parameter 2026-01-06 00:23:24 +01:00
cake
4bb8db8c03 Game object handler changes. 2026-01-06 00:22:22 +01:00
defnotken
f307c65c66 check nulls remove redundant catches. 2026-01-05 17:19:31 -06:00
defnotken
ab305a249c more checks
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m7s
2026-01-05 15:48:54 -06:00
defnotken
9d104a9dd8 Merge branch '2.0.3' into dev 2026-01-05 15:42:15 -06:00
defnotken
4eec363cd2 yeet some comments 2026-01-05 15:40:32 -06:00
defnotken
d00df84ed6 even more violation checks.... 2026-01-05 15:39:18 -06:00
defnotken
bcd3bd5ca2 add more checks
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m8s
2026-01-05 15:08:26 -06:00
defnotken
9048b3bd87 more checks on drawing 2026-01-05 15:07:48 -06:00
defnotken
c1829a9837 Merge branch '2.0.3' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m14s
2026-01-05 14:48:47 -06:00
defnotken
a2ed9f8d2b Adding memory violations catches and null checks to NameString and GameObj 2026-01-05 14:48:14 -06:00
8e08da7471 Chat changes for 2.0.3 (#134)
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #134
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2026-01-05 19:58:10 +00:00
defnotken
cca23f6e05 Building Dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2026-01-05 10:50:25 -06:00
defnotken
3205e6e0c3 Adding AccessViolationException catch to return true for NullDrawObject 2026-01-05 10:40:31 -06:00
cake
d16e46200d Added clear of block of pap files. 2026-01-05 16:41:30 +01:00
cake
5fc13647ae Fixed name getting, cast fix on compact ui 2026-01-05 14:24:07 +01:00
cake
39d5d9d7c1 Another few fixes. 2026-01-05 01:54:19 +01:00
cake
c19db58ead Fix build error from conflict 2026-01-05 01:49:00 +01:00
30717ba200 Merged Cake and Abel branched into 2.0.3 (#131)
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #131
2026-01-05 00:45:14 +00:00
e0b8070aa8 Merge pull request 'Lifestream IPC witrh Debug Example' (#124) from lifestream-ipc into 2.0.3
Reviewed-on: #124
2026-01-04 14:50:00 +00:00
3241b9222b Merge pull request 'Changes of admin ui for banning users.' (#128) from ban-admin-changes into 2.0.3
Reviewed-on: #128
2026-01-04 14:49:47 +00:00
80b082240f Merge branch '2.0.3' into ban-admin-changes 2026-01-04 14:49:38 +00:00
b8c8f3dffd Merge pull request 'Lightless Lightfinder redesign + stuff' (#127) from 2.0.0-crashing-bugfixes into 2.0.3
Reviewed-on: #127
2026-01-04 14:21:27 +00:00
543ea6c865 Merge branch '2.0.3' into 2.0.0-crashing-bugfixes 2026-01-04 14:19:23 +00:00
defnotken
de9c9955ef add more functionality for future features. 2026-01-04 00:54:40 -06:00
cake
2eb0c463e3 Fixed refreshing of ban list 2026-01-04 05:40:34 +01:00
cake
cd510f93af Changed banning into syncshell 2026-01-04 05:08:08 +01:00
cake
3bbda69699 Revert "Added another try on fetching download status"
This reverts commit deb7f67e59.
2026-01-03 23:22:18 +01:00
cake
deb7f67e59 Added another try on fetching download status 2026-01-03 23:12:18 +01:00
choco
9ba45670c5 top menu cleanup, removed duplicate old code 2026-01-03 02:08:28 +01:00
cake
f7bb73bcd1 Updated api 2026-01-02 18:34:07 +01:00
choco
4c07162ee3 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessAPI
2026-01-02 09:26:21 +01:00
choco
a4d62af73d lightfinder user text 2026-01-02 09:23:23 +01:00
choco
5fba3c01e7 lightfinder nearby badge alignment 2026-01-02 09:19:39 +01:00
defnotken
df33a0f0a2 Move buttons to debug 2026-01-01 17:27:12 -06:00
c439d1c822 Merge branch '2.0.3' into lifestream-ipc 2026-01-01 23:21:46 +00:00
choco
906dda3885 lightfinder nearby badge icon 2026-01-01 22:32:45 +01:00
choco
f812b6d09e own syncshell sometimes not showing in list bug 2026-01-01 22:32:34 +01:00
7e61954541 Location Sharing 2.0 (#125)
Need: Lightless-Sync/LightlessServer#49
Authored-by: Tsubasahane <wozaiha@gmail.com>
Reviewed-on: #125
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2025-12-31 17:31:31 +00:00
choco
89f59a98f5 Merge remote-tracking branch 'origin/2.0.3' into 2.0.0-crashing-bugfixes 2025-12-31 09:02:55 +01:00
defnotken
fb58d8657d Lifestream IPC witrh Debug Example 2025-12-30 23:43:22 -06:00
bbb3375661 2.0.3 staaato 2025-12-31 02:44:31 +00:00
ed7932ab83 2.0.2
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m3s
Reviewed-on: #106
2025-12-31 02:29:36 +00:00
choco
e95a2c3352 Merge remote-tracking branch 'refs/remotes/origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-30 19:32:42 +01:00
4eaaaf694c Merge pull request 'Complete Decompression after try.' (#122) from decompression-bullshit into 2.0.2
Reviewed-on: #122
2025-12-30 15:25:55 +00:00
defnotken
c32c89d1a8 Complete Decompression after try. 2025-12-30 08:52:59 -06:00
a8b58d05d6 Merge pull request 'pair-adapter-debug' (#121) from pair-adapter-debug into 2.0.2
Reviewed-on: #121
2025-12-30 14:29:53 +00:00
9ea0571e82 Lower Time out 2025-12-30 14:29:38 +00:00
choco
a8340c3279 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
2025-12-30 14:55:42 +01:00
Tsubasahane
e25979e089 fix Icon direction 2025-12-30 18:04:54 +08:00
Tsubasahane
ca7375b9c3 dont check location when target is offline 2025-12-30 14:42:02 +08:00
Tsubasahane
f8752fcb4d changed kanmoji to show correctly 2025-12-30 14:37:13 +08:00
Tsubasahane
d1c955c74f Reuse WorldData and make context menu work for non-Global uses 2025-12-30 14:23:37 +08:00
Tsubasahane
91e60694ad triggers update when map changes 2025-12-30 11:20:12 +08:00
cake
308c220735 Fixed auto prune options locked 2025-12-30 02:08:54 +01:00
defnotken
27d4da4615 thought a variable was unused. 2025-12-29 08:47:51 -06:00
defnotken
6b49c92ef9 Add a timeout to prevent deadlock of application data 2025-12-29 08:41:32 -06:00
Tsubasahane
f37fdefddd show icon correctly 2025-12-29 16:43:12 +08:00
Tsubasahane
18fa0a47b1 Locationshare fix 2025-12-29 15:42:55 +08:00
Tsubasahane
9f5cc9e0d1 Merge branch '2.0.2' into 2.0.2-Location 2025-12-29 14:48:07 +08:00
cake
6d20995dbf Added decompression gate to decompress files 2025-12-29 02:50:49 +01:00
choco
b02db4c1e1 Merge remote-tracking branch 'origin/2.0.0-crashing-bugfixes' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/Services/DalamudUtilService.cs
#	LightlessSync/UI/DtrEntry.cs
2025-12-28 16:56:06 +01:00
cake
d6b31ed5b9 Fixed finder again. 2025-12-28 16:55:01 +01:00
cake
9e600bfae0 Fixed merge conflicts. 2025-12-28 16:48:51 +01:00
cake
1a73d5a4d9 2.0.2 merged again 2025-12-28 16:40:47 +01:00
cake
cf495dc826 Merge branch '2.0.2' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2 2025-12-28 16:28:37 +01:00
cake
08050614da Own profiles are shown as online now. 2025-12-28 16:28:27 +01:00
94f520d0e7 Add Serious Warning about nameplates (#118)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #118
Co-authored-by: defnotken <defnotken@noreply.git.lightless-sync.org>
Co-committed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-28 15:13:40 +00:00
Tsubasahane
a933330418 Share location 2025-12-28 23:07:45 +08:00
Tsubasahane
ea34b18f40 Merge branch '2.0.2' into 2.0.2-Location 2025-12-28 13:10:17 +08:00
474fd5ef11 Merge pull request 'Fix Async hiccup in chat.' (#117) from quick-chat-fix into 2.0.2
Reviewed-on: #117
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
2025-12-28 03:58:35 +00:00
defnotken
759066731e Fix Async hiccup in chat. 2025-12-27 21:57:01 -06:00
defnotken
ff88e5f856 Merge branch '2.0.2' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2 2025-12-27 21:38:44 -06:00
defnotken
61bac0d39d Changelog update 2025-12-27 21:38:11 -06:00
5b3d00b90a API14 Updates - Migrate to IPlayerState (#113)
- use IPlayerState for DalamudUtilService and make things less async
- make LocationInfo work with ContentFinderData

Co-authored-by: Tsubasahane <wozaiha@gmail.com>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #113
Reviewed-by: cake <cake@noreply.git.lightless-sync.org>
Co-authored-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
Co-committed-by: Tsubasa <tsubasa@noreply.git.lightless-sync.org>
2025-12-28 03:26:07 +00:00
defnotken
67dc215e83 Merge branch '2.0.2-Location' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 2.0.2-Location 2025-12-27 21:17:32 -06:00
defnotken
baf3869cec Merge conf 2025-12-27 21:17:26 -06:00
Tsubasahane
eeda5aeb66 Revert "Location Sharing"
This reverts commit 70745613e1.
2025-12-28 10:54:01 +08:00
e14d50674d Update World Data/Job Data to client lang (#115)
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #115
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-12-28 02:46:47 +00:00
129cf14151 Merge pull request 'Fixed chat input not clearing after sending.' (#116) from fix-chat-send into 2.0.2
Reviewed-on: #116
2025-12-28 02:46:21 +00:00
cake
dba04d740b Fixed chat input not clearing after sending. 2025-12-28 03:45:02 +01:00
04d7a66317 Merge pull request 'Fixed some occlusion checking on invincible elements, added debug mode imgui lightfinder, added caching file cache.' (#114) from debug-mode-lightfinder-imgui into 2.0.2
Reviewed-on: #114
2025-12-28 02:24:50 +00:00
cake
2abc92fc61 Fixed warnings 2025-12-28 03:17:27 +01:00
cake
a3ea48c6e1 Fixed some comments 2025-12-28 03:15:15 +01:00
cake
deb99628f6 Added debug mode for lightfinder IMGUI, added caching of file cache entries to reduce load of loading all entries again. 2025-12-28 03:01:02 +01:00
f69effb8a3 fix syncing.. 2025-12-28 10:48:40 +09:00
choco
754df95071 Merge remote-tracking branch 'origin/2.0.2-Location' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/UI/DtrEntry.cs
2025-12-27 23:13:20 +01:00
choco
24fca31606 join syncshell draw modal 2025-12-27 23:09:29 +01:00
choco
a99c1c01b0 Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes 2025-12-27 23:08:03 +01:00
8f32b375dd boom 2025-12-28 05:24:12 +09:00
choco
85999fab8f Merge remote-tracking branch 'origin/2.0.2' into 2.0.0-crashing-bugfixes
# Conflicts:
#	LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs
#	LightlessSync/UI/SyncshellFinderUI.cs
#	LightlessSync/UI/TopTabMenu.cs
#	LightlessSync/WebAPI/Files/FileDownloadManager.cs
2025-12-27 20:49:20 +01:00
Tsubasahane
70745613e1 Location Sharing 2025-12-27 19:57:21 +08:00
Tsubasahane
5c8e239a7b implement playerState
- use IPlayerState for DalamudUtilService and make things less asynced
- make LocationInfo work with ContentFinderData
2025-12-27 17:04:39 +08:00
choco
5eed65149a nearby lightfinder users window, wiht pair func 2025-12-27 02:38:56 +01:00
1632258c4f Merge pull request 'mcdf-background-creation' (#112) from mcdf-background-creation into 2.0.2
Reviewed-on: #112
2025-12-27 01:33:31 +00:00
cake
1ab4e2f94b Added color options for header 2025-12-26 22:26:29 +01:00
a5786e1d5b Merge branch '2.0.2' into mcdf-background-creation 2025-12-26 21:20:09 +00:00
0b32639f99 Added chat notification pair request send (#111)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #111
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:43:19 +00:00
65dea18f5f Added count to lightfinder label (#110)
[[https://lightless.media/u/3J6Um2OI.png](url)](https://lightless.media/u/3J6Um2OI.png)

Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #110
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:43:09 +00:00
6546a658f3 Added temporary storage of guids of collections to be wiped on bootup when crash/reload (#109)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #109
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:43:01 +00:00
8a41baa88b Fix context menu option from settings. (#108)
Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #108
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:42:51 +00:00
88cb778791 Refactored most of file download, redone it so correct usage of slots and better thread management. (#107)
Before: https://lightless.media/u/n5DhLTPR.mp4

After: https://lightless.media/u/sqvDR0Ho.mp4

Usage of the locks is way more optimized.

Co-authored-by: cake <admin@cakeandbanana.nl>
Reviewed-on: #107
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-26 20:42:43 +00:00
choco
f792bc1954 compact ui design refactor with lightfinder redesign 2025-12-26 00:00:13 +01:00
choco
ced72ab9eb icon centering changes 2025-12-24 16:59:46 +01:00
defnotken
6892d81041 Add notifications 2025-12-24 01:34:37 -06:00
defnotken
a47ca4452a oops 2025-12-24 01:06:49 -06:00
defnotken
32df21bf4a Make mcdf safe in the background. WIP: Progress notif 2025-12-24 01:05:21 -06:00
defnotken
1a2885fd74 ver bump 2025-12-23 20:12:39 -06:00
e470222fe6 Merge pull request '2.0.1' (#99) from 2.0.1 into master
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m3s
Reviewed-on: #99
2025-12-24 02:11:07 +00:00
defnotken
eb83ca90cb Changelog 2025-12-23 20:06:39 -06:00
defnotken
35f0f6da5e removing unused call
Some checks failed
Tag and Release Lightless / tag-and-release (push) Has been cancelled
2025-12-23 17:46:13 -06:00
7d151dac2b hopefully it's fine now? 2025-12-24 07:43:23 +09:00
defnotken
2eba5a1f30 Bumping API 2025-12-23 11:20:52 -06:00
choco
6c1cc77aaa settings animated header 2025-12-23 17:36:36 +01:00
choco
5b81caf5a8 compact menu redesign with new animated particle header, enable particles toggle added in UI settings 2025-12-23 17:16:51 +01:00
choco
4e03b381dc animated header main menu redesign test 2025-12-23 00:48:47 +01:00
choco
3222133aa0 Merge branch '2.0.1' into 2.0.0-crashing-bugfixes 2025-12-23 00:36:56 +01:00
0a6cb05883 Merge pull request 'Fix bug with GPose Actors Page not showing up' (#105) from brio-actor-page-fix into 2.0.1
Reviewed-on: #105
2025-12-22 16:01:57 +00:00
Minmoose
838495810e Update DalamudUtilService.cs 2025-12-22 09:59:35 -06:00
a207c8994b Merge pull request 'Added documentation/comments, Added gpose detection.' (#104) from documentation-gpose-lightfinder-plate into 2.0.1
Reviewed-on: #104
2025-12-22 15:00:12 +00:00
cake
9b4e48ad3e Added documentation/comments, Added gpose detection. 2025-12-22 15:40:31 +01:00
fb4810980e Merge pull request 'Fixed many issues with lightfinder, added back icon support' (#102) from lightfinder-changes-picto into 2.0.1
Reviewed-on: #102
2025-12-22 03:34:27 +00:00
51e107d30a Merge pull request 'Added null check on GetCid if it is empty, would return zero' (#103) from fix-cid-settings into 2.0.1
Reviewed-on: #103
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-12-22 03:09:39 +00:00
cake
cc011743af Added null check on GetCid if it is empty, would return zero 2025-12-22 03:36:06 +01:00
cake
f47fbda0d9 Fixed many issues with lightfinder, added back icon support 2025-12-22 03:06:07 +01:00
fd3b42eff1 Merge pull request 'Null reference exception fix for setting' (#101) from null-reference-validation into 2.0.1
Reviewed-on: #101
2025-12-21 22:53:44 +00:00
3262664d1c Merge branch '2.0.1' into null-reference-validation 2025-12-21 22:42:50 +00:00
afa0d9f101 Merge pull request 'imgui push/pop' (#100) from updatenotes-changes into 2.0.1
Reviewed-on: #100
2025-12-21 22:41:50 +00:00
defnotken
a66a9407f5 Null reference exception fix for setting 2025-12-21 16:36:23 -06:00
choco
0ec423e65c potential resolve disposal crashes and race conditions 2025-12-21 22:34:39 +01:00
choco
34bbc34b5b imgui push/pop 2025-12-21 20:56:15 +01:00
defnotken
be068ed6d1 Imgui Assertion fix 2025-12-21 12:50:51 -06:00
defnotken
3c3c8fd90b ver bump 2025-12-21 12:34:21 -06:00
835a0a637d 2.0.0 (#92)
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 2m27s
2.0.0 Changes:

- Reworked shell finder UI with compact or list view with profile tags showing with the listing, allowing moderators to broadcast the syncshell as well to have it be used more.
- Reworked user list in syncshell admin screen to have filter visible and moved away from table to its own thing, allowing to copy uid/note/alias when clicking on the name.
- Reworked download bars and download box to make it look more modern, removed the jitter around, so it shouldn't vibrate around much.
- Chat has been added to the top menu, working in Zone or in Syncshells to be used there.
- Paired system has been revamped to make pausing and unpausing faster, and loading people should be faster as well.
- Moved to the internal object table to have faster load times for users; people should load in faster
- Compactor is running on a multi-threaded level instead of single-threaded; this should increase the speed of compacting files
- Nameplate Service has been reworked so it wouldn't use the nameplate handler anymore.
- Files can be resized when downloading to reduce load on users if they aren't compressed. (can be toggled to resize all).
- Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many syncshells in your list.
- Lightfinder plates have been moved away from using Nameplates, but will use an overlay.
- Main UI has been changed a bit with a gradient, and on hover will glow up now.
- Reworked Profile UI for Syncshell and Users to be more user-facing with more customizable items.
- Reworked Settings UI to look more modern.
- Performance should be better due to new systems that would dispose of the collections and better caching of items.

Co-authored-by: defnotken <itsdefnotken@gmail.com>
Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: choco <choco@patat.nl>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: Minmoose <KennethBohr@outlook.com>
Reviewed-on: #92
2025-12-21 17:19:34 +00: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
5abc297a94 1.12.3 - LightSpeed
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 51s
Reviewed-on: #60
Reviewed-by: celine <celine@noreply.git.lightless-sync.org>
2025-10-24 16:22:21 +02:00
choco
8aad714918 removed wrong ondisconnect notification 2025-10-23 00:40:54 +02:00
b5bdededae Merge pull request 'Client API changes' (#72) from banner-api-changes into 1.12.3
Reviewed-on: #72
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-21 22:51:29 +02:00
CakeAndBanana
487156e4f9 Submodule update 2025-10-21 22:48:26 +02:00
90d8f691d2 Merge branch '1.12.3' into banner-api-changes 2025-10-21 22:41:29 +02:00
CakeAndBanana
764bb8bae2 API changes 2025-10-21 22:38:12 +02:00
5d2c58bf3e Merge pull request 'some caching stuff and bug fixes' (#71) from some-garbage-optimization into 1.12.3
Reviewed-on: #71
2025-10-21 20:46:15 +02:00
azyges
6bb00c50d8 improve logging fallback 2025-10-22 03:33:51 +09:00
azyges
1a89c2caee some caching stuff and bug fixes 2025-10-22 03:20:13 +09:00
defnotken
1e97f27cb8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-21 11:22:31 -05:00
defnotken
7aadbcec10 Wording changes 2025-10-21 11:22:19 -05:00
choco
a32ac02c6d download notification stuck fix 2025-10-21 09:59:30 +02:00
f48698373b Merge pull request 'notification-cleanup' (#70) from notification-cleanup into 1.12.3
Reviewed-on: #70
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-20 21:28:15 +02:00
ee20b6fa5f version 1.12.3 2025-10-20 21:25:28 +02:00
f11741225b Merge branch 'dev' into notification-cleanup 2025-10-20 21:16:47 +02:00
choco
147baa4c1b api cleanup, decline message on notification decline 2025-10-20 21:16:30 +02:00
choco
4f5ef8ff4b type cleanup 2025-10-20 14:51:10 +02:00
choco
fae6d31792 Merge remote-tracking branch 'origin/notification-cleanup' into notification-cleanup 2025-10-20 14:32:29 +02:00
choco
b4dd0ee0e1 type cleanup 2025-10-20 14:32:21 +02:00
f5458c7f97 Delete CONTRIBUTING.md 2025-10-20 14:05:51 +02:00
923f118a47 Delete DEVELOPMENT.md 2025-10-20 14:05:45 +02:00
choco
0cb71e5444 service cleanups, containing logic directly now 2025-10-20 14:00:54 +02:00
defnotken
268fd471fe welcome screen fix bump
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 34s
2025-10-19 15:50:02 -05:00
defnotken
d517a21f5d Merge branch '1.12.3' into dev 2025-10-19 15:49:16 -05:00
cac94374d9 Merge pull request 'patch-notes' (#69) from patch-notes into 1.12.3
Reviewed-on: #69
2025-10-19 22:48:44 +02:00
choco
b513e0555b temp menu removal 2025-10-19 22:46:36 +02:00
choco
de8c9cf035 Merge remote-tracking branch 'origin/patch-notes' into patch-notes 2025-10-19 22:43:58 +02:00
choco
7f8872cbe0 added open changelog button to settings title menu 2025-10-19 22:43:45 +02:00
defnotken
5626a34755 Merge branch '1.12.3' into dev
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 33s
2025-10-19 15:16:02 -05:00
defnotken
a98afdda01 Updating changelog. 2025-10-19 15:15:30 -05:00
4373092d44 Merge pull request 'patch-notes' (#66) from patch-notes into 1.12.3
Reviewed-on: #66
2025-10-19 22:01:15 +02:00
7b5c61371e Merge branch '1.12.3' into patch-notes 2025-10-19 22:00:58 +02:00
9bd997f699 Merge pull request 'Added profile editor on syncshell admin.' (#68) from syncshell-profiles into 1.12.3
Reviewed-on: #68
2025-10-19 22:00:51 +02:00
e2511a5c1f Merge branch '1.12.3' into syncshell-profiles 2025-10-19 22:00:16 +02:00
CakeAndBanana
e8760a8937 Fixed nsfwf 2025-10-19 21:59:04 +02:00
CakeAndBanana
7d4e097be8 Added nsfw on syncshell profile. 2025-10-19 21:58:12 +02:00
CakeAndBanana
2cba1ccfe0 reverted join, added apicontroller stuff 2025-10-19 21:56:03 +02:00
06921e1dd1 Merge branch '1.12.3' into patch-notes 2025-10-19 21:49:18 +02:00
defnotken
68ba5f4b06 dev build
Some checks failed
Tag and Release Lightless / tag-and-release (push) Failing after 27s
2025-10-19 14:47:30 -05:00
defnotken
217c160ec7 update submodule 2025-10-19 14:43:40 -05:00
defnotken
ff010fa1f8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-19 14:43:01 -05:00
f7145339b3 Merge pull request 'init' (#67) from lightspeed into 1.12.3
Reviewed-on: #67
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-19 21:41:16 +02:00
azyges
aa2b828386 init 2025-10-20 04:20:11 +09:00
CakeAndBanana
547db3a76b Added tag calls for new api changes 2025-10-19 21:10:07 +02:00
defnotken
4ac8b24524 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-19 12:33:40 -05:00
CakeAndBanana
d72cc207e1 Made tags an array of integers instead of strings 2025-10-19 18:53:31 +02:00
CakeAndBanana
477f5aa6e7 Fixed some stuff 2025-10-19 18:41:02 +02:00
CakeAndBanana
edb7232b17 Added nsfw in group profile editor. 2025-10-19 16:55:42 +02:00
choco
44177ab7bd broadcast bypass toggle gone 2025-10-17 20:07:14 +02:00
choco
47b7ecd521 forced current changelog version to be opened on default 2025-10-17 15:36:50 +02:00
choco
8a3902ec2b init change :) 2025-10-16 23:44:15 +02:00
choco
ea8f8e3895 last null check removal 2025-10-16 23:34:10 +02:00
choco
f1af6601cc removed null check 2025-10-16 23:30:06 +02:00
choco
2d094404df proper version checker on plugin laoding 2025-10-16 23:21:14 +02:00
choco
8fdff1eb18 SHOWING changelog everytime till the got it button is pressed, should reappear on version updates according to the current settings 2025-10-16 23:03:32 +02:00
choco
9170b5205c removed temp changelog on loading 2025-10-16 22:54:56 +02:00
choco
dccd2cdc36 changelog cleanup, credits tab 2025-10-16 22:52:46 +02:00
choco
6d01d47c2f broadcast notifications, action button for reconnecting once expired 2025-10-16 22:13:40 +02:00
CakeAndBanana
280c80d89f Changed the layout a bit, added nsfw 2025-10-16 15:19:01 +02:00
choco
92b8d4a1cd credits integration into the yaml and ui 2025-10-16 14:35:30 +02:00
choco
f77d109d00 added changelog model and update notes UI with particle effects 2025-10-16 10:37:00 +02:00
choco
823dd39a9b improved particle animations, took some inspiration from brio and character + kinda pogged at their change log 2025-10-16 01:29:38 +02:00
choco
d5c12e81c3 banner with particles 2025-10-15 22:59:08 +02:00
choco
04c00af92e check if completed intro setup process 2025-10-15 16:37:56 +02:00
choco
a66a43dda8 patch notes setup, added some of the older patchnotes into a changelog.yaml 2025-10-15 13:29:32 +02:00
77ff8ae372 Merge pull request 'notification-changes' (#65) from notification-changes into 1.12.3
Reviewed-on: #65
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-14 22:43:10 +02:00
CakeAndBanana
011cf7951b Added more documentation, fixed some small issues with cache 2025-10-14 20:45:05 +02:00
defnotken
7d480b9e2c Defensive handling and NRE removal. 2025-10-14 10:33:44 -05:00
choco
cf27a67296 optional action button toggle for pair request (default on) 2025-10-14 15:07:46 +02:00
choco
d6a4595bb8 old debug line removals, better alignment performance notifs 2025-10-14 14:54:30 +02:00
choco
f202818b55 performance notifcation addition, with some regular bugfixes regarding the flexing of the notifications 2025-10-14 11:46:14 +02:00
choco
3f2e4d6640 small service cleanup 2025-10-14 09:36:10 +02:00
choco
90b483e4ea Merge remote-tracking branch 'origin/1.12.3' into notification-changes 2025-10-13 22:24:50 +02:00
434c7d5f4a Merge pull request 'add stuff' (#63) from syncshell-profiles-2 into syncshell-profiles
Reviewed-on: #63
2025-10-13 06:12:24 +02:00
defnotken
a4eb840589 add stuff 2025-10-12 22:59:50 -05:00
CakeAndBanana
e80806ef9d Changes some calls 2025-10-13 01:09:05 +02:00
CakeAndBanana
c447c33b7a Fixed callbacks for cleaning up profiles. 2025-10-13 00:39:30 +02:00
choco
bcb524df52 text auto flexing on dismiss removed 2025-10-13 00:13:21 +02:00
b64bb66119 Merge pull request 'notification-changes' (#62) from notification-changes into 1.12.3
Reviewed-on: #62
2025-10-12 23:33:42 +02:00
choco
118edb9dea notif overlay flex with 1 sec delay removal 2025-10-12 23:11:18 +02:00
CakeAndBanana
b43ceb9f7e Changed syncshell profiles a bit. 2025-10-12 22:49:50 +02:00
choco
6c0d00dc39 update intervall prevent spam better performance 2025-10-12 21:07:32 +02:00
CakeAndBanana
be847c16b8 Added profile settings for Syncshells. 2025-10-12 20:04:09 +02:00
02a680f8cc Merge pull request 'Merge master to profiles' (#61) from master into syncshell-profiles
Reviewed-on: #61
2025-10-12 19:19:37 +02:00
defnotken
dae8127ac8 Merge branch '1.12.3' of https://git.lightless-sync.org/Lightless-Sync/LightlessClient into 1.12.3 2025-10-12 12:11:18 -05:00
defnotken
0635caab65 Safety checks for NullDrawObject 2025-10-12 12:09:06 -05:00
1530ac3911 Merge pull request 'notification-changes' (#59) from notification-changes into 1.12.3
Reviewed-on: #59
2025-10-12 18:08:52 +02:00
3f80467180 Merge branch '1.12.3' into notification-changes 2025-10-12 18:08:43 +02:00
defnotken
4b4e587a89 1.12.3 initial 2025-10-12 11:07:24 -05:00
choco
02c3846031 column rename for better UX c: 2025-10-12 18:02:53 +02:00
choco
a8a01b3034 added more customization to the notifs, settings improvemnts, left and right notifs, animations for sliding in and out 2025-10-12 18:00:49 +02:00
50a5046c96 1.12.2
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 51s
Reviewed-on: #52
2025-10-12 15:33:02 +02:00
azyges
c0b8e15380 wrapped the entire plugin draw pass in a single theme scope, prevent leaks to dalamud ui 2025-10-12 21:48:51 +09:00
e6735be594 Merge pull request 'pair-notifs-ui' (#58) from pair-notifs-ui into 1.12.2
Reviewed-on: #58
2025-10-12 00:05:53 +02:00
choco
fe419336d7 math clamping sliders for notifcation settings, pair notifs disappear now when accepted with other methods 2025-10-12 00:04:10 +02:00
defnotken
ffbeeba929 more null checks for nameplates. 2025-10-11 16:39:37 -05:00
choco
a7475a7007 sounds to default off removed old notifcations panel with unused pair panel 2025-10-11 23:08:29 +02:00
choco
3936cbd439 can only be run on Framework fix 2025-10-11 22:54:04 +02:00
choco
ba16963b66 forgot dependency injection error fix 2025-10-11 22:25:54 +02:00
6467a3e73b Merge pull request 'pair-notifs-ui' (#57) from pair-notifs-ui into 1.12.2
Reviewed-on: #57
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-11 22:19:21 +02:00
choco
bb779904f7 removed temp accept/decline logic from the api layer 2025-10-11 21:52:14 +02:00
choco
59d0e8ee37 Merge branch '1.12.2' into pair-notifs-ui
# Conflicts:
#	LightlessSync/UI/SettingsUi.cs
2025-10-11 21:42:14 +02:00
choco
a441bbfcc8 notifcation refactor for better readability 2025-10-11 21:24:39 +02:00
choco
c545ccea52 X offset 2025-10-11 20:19:24 +02:00
azyges
c32d9cadff fix leak 2025-10-11 10:15:51 +09:00
azyges
d7c9df54cb update cache 2025-10-11 08:20:50 +09:00
37ec0961d9 Merge pull request 'lightfinder-partial-rework' (#56) from lightfinder-partial-rework into 1.12.2
Reviewed-on: #56
2025-10-11 01:04:27 +02:00
9736c5090d Merge branch '1.12.2' into lightfinder-partial-rework 2025-10-11 01:04:12 +02:00
defnotken
4f3ab604db update lightlessapi pointer 2025-10-10 18:03:57 -05:00
azyges
6a0f8c507c add seperate colors for labels and update color inputs 2025-10-11 07:52:52 +09:00
choco
e13fde3d43 improved settings with sounds bug fix 2025-10-11 00:46:18 +02:00
7b806ab660 Merge pull request 'Nameplate Fix + Text Handling' (#55) from nameplate-fix into 1.12.2
Reviewed-on: #55
Reviewed-by: Essie <azyges@noreply.git.lightless-sync.org>
2025-10-10 23:35:27 +02:00
defnotken
387e5ad515 oop 2025-10-10 16:27:40 -05:00
defnotken
70c296a16b Nameplate Fix + Text Handling 2025-10-10 16:26:36 -05:00
azyges
2a9b5812ed add theme override customizations 2025-10-11 03:29:44 +09:00
azyges
9b04976aa6 add info options for server bar, direct settings button in lightfinder window and fix color swaps 2025-10-11 00:48:19 +09:00
CakeAndBanana
144ac166fb Changed Discord URL 2025-10-10 15:50:22 +02:00
CakeAndBanana
98c3a2c7f8 Added syncshell profile related items. 2025-10-10 06:42:59 +02:00
defnotken
b06ffb3341 update submodule 2025-10-09 18:06:31 -05:00
e9461efe11 Merge pull request 'Added option to show hidden plates' (#54) from hidden-plates-finder into 1.12.2
Reviewed-on: #54
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-10 01:05:34 +02:00
1f1afdec24 Merge branch '1.12.2' into hidden-plates-finder 2025-10-10 01:05:26 +02:00
d428a436e7 Merge pull request 'Changed /light lightfinder to /light finder' (#53) from command-change-finder into 1.12.2
Reviewed-on: #53
Reviewed-by: defnotken <defnotken@noreply.git.lightless-sync.org>
2025-10-10 01:05:18 +02:00
CakeAndBanana
ad29fa7b69 Fixed label 2025-10-09 23:31:45 +02:00
CakeAndBanana
23c56505ac Added option to show 2025-10-09 23:31:35 +02:00
CakeAndBanana
58850f4530 Changed /light lightfinder to /light finder 2025-10-09 23:01:50 +02:00
choco
f5339dc1d2 notif offset placement, default slider yoinked from abel 2025-10-09 22:53:01 +02:00
choco
85ecea6391 settings styling and sound disabled not working bugfix 2025-10-09 20:21:01 +02:00
choco
cd817487e4 scoped service crash fix 2025-10-09 19:18:27 +02:00
choco
f50b622f0a service cleanup 2025-10-09 15:50:59 +02:00
choco
d295f3e22d pair/downloads notif changes + more settings options 2025-10-09 13:56:40 +02:00
choco
0dfa667ed3 removed fallback logic in NotificationService and some settings cleanup 2025-10-09 11:31:35 +02:00
choco
2b118df892 notifications refactor with duplication bugfix 2025-10-09 11:13:47 +02:00
azyges
f01229a97f rework lightfinder for new api 2025-10-09 07:33:49 +09:00
choco
3fdc9dd958 Merge remote-tracking branch 'origin/1.12.2' into pair-notifs-ui 2025-10-08 23:49:07 +02:00
CakeAndBanana
86107acf12 version bumped 2025-10-08 23:39:07 +02:00
choco
27e7fb7ed9 more to notification system with new settings tab 2025-10-08 23:20:58 +02:00
choco
17f4ddad89 Merge tag '1.12.1' into pair-notifs-ui 2025-10-08 20:31:33 +02:00
a29e155cec 1.12.1: QoL Lightfinder
All checks were successful
Tag and Release Lightless / tag-and-release (push) Successful in 45s
Reviewed-on: #43
2025-10-08 19:57:23 +02:00
choco
9b6d00570e implemened game sound effects for notifs 2025-10-06 21:55:45 +02:00
choco
83e4555e4b notifications improvement, working pairs incoming request feature and working user logging in notif 2025-10-06 20:25:47 +02:00
choco
090b81c989 added notification system for file downloads and pair requests 2025-10-06 16:14:34 +02:00
304 changed files with 76338 additions and 11005 deletions

View File

@@ -6,7 +6,10 @@ on:
env:
PLUGIN_NAME: LightlessSync
DOTNET_VERSION: 9.x
DOTNET_VERSION: |
10.x.x
9.x.x
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
tag-and-release:
@@ -16,28 +19,28 @@ jobs:
steps:
- name: Checkout Lightless
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
submodules: true
submodules: recursive
- name: Setup .NET 9 SDK
uses: actions/setup-dotnet@v4
- name: Setup .NET 10 SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 9.x
dotnet-version: |
10.x.x
9.x.x
- name: Download Dalamud
run: |
cd /
mkdir -p root/.xlcore/dalamud/Hooks/dev
mkdir -p ~/.xlcore/dalamud/Hooks/dev
curl -O https://goatcorp.github.io/dalamud-distrib/stg/latest.zip
unzip latest.zip -d /root/.xlcore/dalamud/Hooks/dev
unzip latest.zip -d ~/.xlcore/dalamud/Hooks/dev
- name: Lets Build Lightless!
run: |
dotnet restore
dotnet build --configuration Release --no-restore
dotnet publish --configuration Release --no-build
dotnet publish --configuration Release
mv LightlessSync/bin/x64/Release/LightlessSync/latest.zip LightlessClient.zip
- name: Get version
id: package_version
@@ -49,19 +52,6 @@ jobs:
run: |
echo "Version: ${{ steps.package_version.outputs.version }}"
- name: Prepare Lightless Client
run: |
PUBLISH_PATH="/workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/publish/"
if [ -d "$PUBLISH_PATH" ]; then
rm -rf "$PUBLISH_PATH"
echo "Removed $PUBLISH_PATH"
else
echo "$PUBLISH_PATH does not exist, nothing to remove."
fi
mkdir -p output
(cd /workspace/Lightless-Sync/LightlessClient/LightlessSync/bin/x64/Release/ && zip -r $OLDPWD/output/LightlessClient.zip *)
- name: Create Git tag if not exists (master)
if: github.ref == 'refs/heads/master'
run: |
@@ -159,13 +149,6 @@ jobs:
echo "release_id=$release_id" >> $GITHUB_OUTPUT || echo "::set-output name=release_id::$release_id"
echo "RELEASE_ID=$release_id" >> $GITHUB_ENV
- name: Check asset exists
run: |
if [ ! -f output/LightlessClient.zip ]; then
echo "output/LightlessClient.zip does not exist!"
exit 1
fi
- name: Upload Assets to release
env:
RELEASE_ID: ${{ env.RELEASE_ID }}
@@ -173,7 +156,7 @@ jobs:
echo "Uploading to release ID: $RELEASE_ID"
curl --fail-with-body -s -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@output/LightlessClient.zip" \
-F "attachment=@LightlessClient.zip" \
"https://git.lightless-sync.org/api/v1/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets"
- name: Clone plugin hosting repo
@@ -278,8 +261,8 @@ jobs:
- name: Commit and push to LightlessSync
run: |
cd LightlessSyncRepo/LightlessSync
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git config user.name "Gitea-Automation"
git config user.email "aaa@aaaaaaa.aaa"
git add .
git diff-index --quiet HEAD || git commit -m "Update ${{ env.PLUGIN_NAME }} to ${{ steps.package_version.outputs.version }}"
git push https://x-access-token:${{ secrets.AUTOMATION_TOKEN }}@git.lightless-sync.org/${{ gitea.repository_owner }}/LightlessSync.git HEAD:main

3
.gitignore vendored
View File

@@ -348,3 +348,6 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# idea
/.idea

18
.gitmodules vendored
View File

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

View File

@@ -0,0 +1,18 @@
namespace LightlessSync.FileCache;
public interface ICompactorContext
{
bool UseCompactor { get; }
string CacheFolder { get; }
bool IsWine { get; }
}
public interface ICompactionExecutor
{
bool TryCompact(string filePath);
}
public sealed class NoopCompactionExecutor : ICompactionExecutor
{
public bool TryCompact(string filePath) => false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,251 @@
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace LightlessSync.Utils
{
public static class FileSystemHelper
{
public enum FilesystemType
{
Unknown = 0,
NTFS, // Compressable on file level
Btrfs, // Compressable on file level
Ext4, // Uncompressable
Xfs, // Uncompressable
Apfs, // Compressable on OS
HfsPlus, // Compressable on OS
Fat, // Uncompressable
Exfat, // Uncompressable
Zfs // Compressable, not on file level
}
private const string _mountPath = "/proc/mounts";
private const int _defaultBlockSize = 4096;
private static readonly Dictionary<string, int> _blockSizeCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, FilesystemType> _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase);
public static FilesystemType GetFilesystemType(string filePath, bool isWine = false)
{
try
{
string rootPath;
if (OperatingSystem.IsWindows() && (!IsProbablyWine() || !isWine))
{
var info = new FileInfo(filePath);
var dir = info.Directory ?? new DirectoryInfo(filePath);
rootPath = dir.Root.FullName;
}
else
{
rootPath = GetMountPoint(filePath);
if (string.IsNullOrEmpty(rootPath))
rootPath = "/";
}
if (_filesystemTypeCache.TryGetValue(rootPath, out var cachedType))
return cachedType;
FilesystemType detected;
if (OperatingSystem.IsWindows() && (!IsProbablyWine() || !isWine))
{
var root = new DriveInfo(rootPath);
var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty;
detected = format switch
{
"NTFS" => FilesystemType.NTFS,
"FAT32" => FilesystemType.Fat,
"EXFAT" => FilesystemType.Exfat,
_ => FilesystemType.Unknown
};
}
else
{
detected = GetLinuxFilesystemType(filePath);
}
if (isWine || IsProbablyWine())
{
switch (detected)
{
case FilesystemType.NTFS:
case FilesystemType.Unknown:
{
var linuxDetected = GetLinuxFilesystemType(filePath);
if (linuxDetected != FilesystemType.Unknown)
{
detected = linuxDetected;
}
break;
}
}
}
_filesystemTypeCache[rootPath] = detected;
return detected;
}
catch
{
return FilesystemType.Unknown;
}
}
private static string GetMountPoint(string filePath)
{
try
{
var path = Path.GetFullPath(filePath);
if (!File.Exists(_mountPath)) return "/";
var mounts = File.ReadAllLines(_mountPath);
string bestMount = "/";
foreach (var line in mounts)
{
var parts = line.Split(' ');
if (parts.Length < 3) continue;
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
string normalizedMount;
try { normalizedMount = Path.GetFullPath(mountPoint); }
catch { normalizedMount = mountPoint; }
if (path.StartsWith(normalizedMount, StringComparison.Ordinal) &&
normalizedMount.Length > bestMount.Length)
{
bestMount = normalizedMount;
}
}
return bestMount;
}
catch
{
return "/";
}
}
public static string GetMountOptionsForPath(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
var mounts = File.ReadAllLines("/proc/mounts");
string bestMount = string.Empty;
string mountOptions = string.Empty;
foreach (var line in mounts)
{
var parts = line.Split(' ');
if (parts.Length < 4) continue;
var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
string normalized;
try { normalized = Path.GetFullPath(mountPoint); }
catch { normalized = mountPoint; }
if (fullPath.StartsWith(normalized, StringComparison.Ordinal) &&
normalized.Length > bestMount.Length)
{
bestMount = normalized;
mountOptions = parts[3];
}
}
return mountOptions;
}
catch (Exception)
{
return string.Empty;
}
}
private static FilesystemType GetLinuxFilesystemType(string filePath)
{
try
{
var mountPoint = GetMountPoint(filePath);
var mounts = File.ReadAllLines(_mountPath);
foreach (var line in mounts)
{
var parts = line.Split(' ');
if (parts.Length < 3) continue;
var mount = parts[1].Replace("\\040", " ", StringComparison.Ordinal);
if (string.Equals(mount, mountPoint, StringComparison.Ordinal))
{
var fstype = parts[2].ToLowerInvariant();
return fstype switch
{
"btrfs" => FilesystemType.Btrfs,
"ext4" => FilesystemType.Ext4,
"xfs" => FilesystemType.Xfs,
"zfs" => FilesystemType.Zfs,
"apfs" => FilesystemType.Apfs,
"hfsplus" => FilesystemType.HfsPlus,
_ => FilesystemType.Unknown
};
}
}
return FilesystemType.Unknown;
}
catch
{
return FilesystemType.Unknown;
}
}
public static int GetBlockSizeForPath(string path, ILogger? logger = null, bool isWine = false)
{
try
{
if (string.IsNullOrWhiteSpace(path))
return _defaultBlockSize;
var fi = new FileInfo(path);
if (!fi.Exists)
return _defaultBlockSize;
var root = fi.Directory?.Root.FullName.ToLowerInvariant() ?? "/";
if (_blockSizeCache.TryGetValue(root, out int cached))
return cached;
if (OperatingSystem.IsWindows() && !isWine)
{
int result = GetDiskFreeSpaceW(root,
out uint sectorsPerCluster,
out uint bytesPerSector,
out _,
out _);
if (result == 0)
{
logger?.LogWarning("Failed to determine block size for {root}", root);
return _defaultBlockSize;
}
int clusterSize = (int)(sectorsPerCluster * bytesPerSector);
_blockSizeCache[root] = clusterSize;
logger?.LogTrace("NTFS cluster size for {root}: {cluster}", root, clusterSize);
return clusterSize;
}
return _defaultBlockSize;
}
catch (Exception ex)
{
logger?.LogTrace(ex, "Error determining block size for {path}", path);
return _defaultBlockSize;
}
}
[DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)]
private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters);
//Extra check on
public static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts");
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LightlessCompactor\LightlessCompactor.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,270 @@
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text.Json;
internal sealed class WorkerCompactorContext : ICompactorContext
{
public WorkerCompactorContext(string cacheFolder, bool isWine)
{
CacheFolder = cacheFolder;
IsWine = isWine;
}
public bool UseCompactor => true;
public string CacheFolder { get; }
public bool IsWine { get; }
}
internal sealed class WorkerOptions
{
public string? FilePath { get; init; }
public bool IsWine { get; init; }
public string CacheFolder { get; init; } = string.Empty;
public LogLevel LogLevel { get; init; } = LogLevel.Information;
public string PipeName { get; init; } = "LightlessCompactor";
public int? ParentProcessId { get; init; }
}
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var options = ParseOptions(args, out var error);
if (options is null)
{
Console.Error.WriteLine(error ?? "Invalid arguments.");
Console.Error.WriteLine("Usage: LightlessCompactorWorker --file <path> [--wine] [--cache-folder <path>] [--verbose]");
Console.Error.WriteLine(" or: LightlessCompactorWorker --pipe <name> [--wine] [--parent <pid>] [--verbose]");
return 2;
}
TrySetLowPriority();
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(options.LogLevel);
builder.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.TimestampFormat = "HH:mm:ss.fff ";
});
});
var logger = loggerFactory.CreateLogger<FileCompactor>();
var context = new WorkerCompactorContext(options.CacheFolder, options.IsWine);
using var compactor = new FileCompactor(logger, context, new NoopCompactionExecutor());
if (!string.IsNullOrWhiteSpace(options.FilePath))
{
var success = compactor.TryCompactFile(options.FilePath!);
return success ? 0 : 1;
}
var serverLogger = loggerFactory.CreateLogger("CompactorWorker");
return await RunServerAsync(compactor, options, serverLogger).ConfigureAwait(false);
}
private static async Task<int> RunServerAsync(FileCompactor compactor, WorkerOptions options, ILogger serverLogger)
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
if (options.ParentProcessId.HasValue)
{
_ = Task.Run(() => MonitorParent(options.ParentProcessId.Value, cts));
}
serverLogger.LogInformation("Compactor worker listening on pipe {pipe}", options.PipeName);
try
{
while (!token.IsCancellationRequested)
{
var server = new NamedPipeServerStream(
options.PipeName,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
try
{
await server.WaitForConnectionAsync(token).ConfigureAwait(false);
}
catch
{
server.Dispose();
throw;
}
_ = Task.Run(() => HandleClientAsync(server, compactor, cts));
}
}
catch (OperationCanceledException)
{
// shutdown requested
}
catch (Exception ex)
{
serverLogger.LogWarning(ex, "Compactor worker terminated unexpectedly.");
return 1;
}
return 0;
}
private static async Task HandleClientAsync(NamedPipeServerStream pipe, FileCompactor compactor, CancellationTokenSource shutdownCts)
{
await using var _ = pipe;
using var reader = new StreamReader(pipe);
using var writer = new StreamWriter(pipe) { AutoFlush = true };
var line = await reader.ReadLineAsync().ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(line))
return;
CompactorRequest? request = null;
try
{
request = JsonSerializer.Deserialize<CompactorRequest>(line);
}
catch
{
// ignore
}
CompactorResponse response;
if (request is null)
{
response = new CompactorResponse { Success = false, Error = "Invalid request." };
}
else if (string.Equals(request.Type, "shutdown", StringComparison.OrdinalIgnoreCase))
{
shutdownCts.Cancel();
response = new CompactorResponse { Success = true };
}
else if (string.Equals(request.Type, "compact", StringComparison.OrdinalIgnoreCase))
{
var success = compactor.TryCompactFile(request.Path ?? string.Empty);
response = new CompactorResponse { Success = success };
}
else
{
response = new CompactorResponse { Success = false, Error = "Unknown request type." };
}
await writer.WriteLineAsync(JsonSerializer.Serialize(response)).ConfigureAwait(false);
}
private static void MonitorParent(int parentPid, CancellationTokenSource shutdownCts)
{
try
{
var parent = Process.GetProcessById(parentPid);
parent.WaitForExit();
}
catch
{
// parent missing
}
finally
{
shutdownCts.Cancel();
}
}
private static WorkerOptions? ParseOptions(string[] args, out string? error)
{
string? filePath = null;
bool isWine = false;
string cacheFolder = string.Empty;
var logLevel = LogLevel.Information;
string pipeName = "LightlessCompactor";
int? parentPid = null;
for (int i = 0; i < args.Length; i++)
{
var arg = args[i];
switch (arg)
{
case "--file":
if (i + 1 >= args.Length)
{
error = "Missing value for --file.";
return null;
}
filePath = args[++i];
break;
case "--cache-folder":
if (i + 1 >= args.Length)
{
error = "Missing value for --cache-folder.";
return null;
}
cacheFolder = args[++i];
break;
case "--pipe":
if (i + 1 >= args.Length)
{
error = "Missing value for --pipe.";
return null;
}
pipeName = args[++i];
break;
case "--parent":
if (i + 1 >= args.Length || !int.TryParse(args[++i], out var pid))
{
error = "Invalid value for --parent.";
return null;
}
parentPid = pid;
break;
case "--wine":
isWine = true;
break;
case "--verbose":
logLevel = LogLevel.Trace;
break;
}
}
error = null;
return new WorkerOptions
{
FilePath = filePath,
IsWine = isWine,
CacheFolder = cacheFolder,
LogLevel = logLevel,
PipeName = pipeName,
ParentProcessId = parentPid
};
}
private static void TrySetLowPriority()
{
try
{
if (OperatingSystem.IsWindows())
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.BelowNormal;
}
catch
{
// ignore
}
}
private sealed class CompactorRequest
{
public string Type { get; init; } = "compact";
public string? Path { get; init; }
}
private sealed class CompactorResponse
{
public bool Success { get; init; }
public string? Error { get; init; }
}
}

View File

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

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

View File

@@ -0,0 +1,340 @@
tagline: "Lightless Sync v2.0.1"
subline: "LIGHTLESS IS EVOLVING!!"
changelog:
- name: "v2.0.2"
tagline: "Last update of 2025!... ... ... If Nothing breaks"
date: "December 28 2025"
# be sure to set this every new version
isCurrent: true
versions:
- number: "Chat"
icon: ""
items:
- "Added a 7TV emote picker to chat. Youll now see a new button next to Send that opens an emote selector."
- "Pin User, Remove User, and Ban User (including Syncshell) have been added when you right click a user in chat."
- "Chatters now show status icons/labels in the Syncshell (e.g., Owner, Moderator, and Pinned when applicable)."
- "The Rules page no longer blocks input for other open Lightless UI windows."
- number: "LightFinder"
icon: ""
items:
- "If the ImGui Lightfinder icons arent working correctly, you can switch back to the Nameplate signature hook. Important warning - USE AT YOUR OWN RISK: The native nameplate hook can crash the game if multiple plugins hook the nameplate function at the same time. We will not provide support about Nameplate crashes, nor will the Dalamud team, **DO NOT BOTHER THEM.**"
- "The LightFinder label in the menu has a counter next to it showing the number of broadcasting users."
- "There is less interference of hidden UI elements for the imGui renderer of LightFinder."
- number: "Miscellaneous fixes"
icon: ""
items:
- "Overhauled transient resources in an attempt to mitigate mount and minion problems."
- "Some file cache entries will now be cached to reduce load on your game."
- "Downloading and decompressing have been redone to fix the locking issues."
- "Disabling the context menu will now hide the context menu on right clicks again. (Thanks @infiniti)"
- "Temporary collections that were not cleared before will now be cleared when the plugin starts."
- "Pair requests will now appear in chat if notifications are not enabled or on chat mode."
- "Fixed an instance were an object may be null in the Download UI."
- "API 14 - Migrate to IPlayerState service"
- name: "v2.0.1"
tagline: "Some Fixes"
date: "December 23 2025"
versions:
- number: "Chat"
icon: ""
items:
- "You can turn off the syncshell chat as Owner by going to the Syncshell Admin panel -> Owner -> Enable/Disable Chat."
- "Fixed an issue where you can't chat due to regions being in a different language."
- number: "LightFinder"
icon: ""
items:
- "The icon/Lightfinder Text will be hidden when Game UI is hidden and behind game elements/UI"
- "Able to select an icon for the selected list or a custom glyph if you know the code."
- "Smoothing and reducing jitter on the icon/Lightfinder Text."
- "Fixed so higher scaled UI options (100/150/200% UI scale) wouldn't break the element."
- "Detects if GPose is active, wouldn't render the elements"
- number: "Miscellaneous fixes"
icon: ""
items:
- "Fixed the null error given on GetCID when transferring between zones/housing."
- "Added push/pop on certain ImGUI elements to remove them after being used. "
- "Having all tabs open in the Main UI wouldn't lag out the game anymore."
- "Cycle pause has been adjusted to the old function. There is a separate button to pause normally, now called 'Toggle (Un)Pause State'."
- "Changes have been made to the character redraw to address the issues with the building character data constantly being redrawn and the redrawn behavior with Honorific titles."
- "GPose characters should appear again in the actor screen"
- "Lightspeed download console messages are no longer shown as warnings."
- number: "Server Updates"
icon: ""
items:
- "Changes have been made to the disabling of your profile. It should save again."
- "Ability added to toggle chats from syncshell to be disabled."
- "Files are continuously being deleted due to high volumes in storage, potentially causing MCDOs to have missing files. We have increased the limit of the storage in our configurations to see if that helps."
- name: "v2.0.0"
tagline: "Thank you for 4 months!"
date: "December 2025"
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

@@ -6,6 +6,7 @@ using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.IO;
namespace LightlessSync.FileCache;
@@ -21,6 +22,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private CancellationTokenSource _scanCancellationTokenSource = new();
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"];
private static readonly HashSet<string> AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase);
public CacheMonitor(ILogger<CacheMonitor> logger, IpcManager ipcManager, LightlessConfigService configService,
FileCacheManager fileDbManager, LightlessMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil,
@@ -72,7 +74,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
while (_dalamudUtil.IsOnFrameworkThread && !token.IsCancellationRequested)
{
await Task.Delay(1).ConfigureAwait(false);
await Task.Delay(1, token).ConfigureAwait(false);
}
RecalculateFileCacheSize(token);
@@ -101,8 +103,9 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
}
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
private readonly record struct CacheEvictionCandidate(string FullPath, long Size, DateTime LastAccessTime);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _lightlessChanges = new(StringComparer.OrdinalIgnoreCase);
public void StopMonitoring()
{
@@ -115,6 +118,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public bool StorageisNTFS { get; private set; } = false;
public bool StorageIsBtrfs { get ; private set; } = false;
public void StartLightlessWatcher(string? lightlessPath)
{
LightlessWatcher?.Dispose();
@@ -124,10 +129,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless.");
return;
}
var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine);
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase);
if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtil.IsWine)
{
StorageisNTFS = true;
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
}
if (fsType == FileSystemHelper.FilesystemType.Btrfs)
{
StorageIsBtrfs = true;
Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs);
}
Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
LightlessWatcher = new()
@@ -152,7 +166,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
Logger.LogTrace("Lightless FSW: FileChanged: {change} => {path}", e.ChangeType, e.FullPath);
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
if (!HasAllowedExtension(e.FullPath)) return;
lock (_watcherChanges)
{
@@ -196,7 +210,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private void Fs_Changed(object sender, FileSystemEventArgs e)
{
if (Directory.Exists(e.FullPath)) return;
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
if (!HasAllowedExtension(e.FullPath)) return;
if (e.ChangeType is not (WatcherChangeTypes.Changed or WatcherChangeTypes.Deleted or WatcherChangeTypes.Created))
return;
@@ -220,7 +234,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
foreach (var file in directoryFiles)
{
if (!AllowedFileExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) continue;
if (!HasAllowedExtension(file)) continue;
var oldPath = file.Replace(e.FullPath, e.OldFullPath, StringComparison.OrdinalIgnoreCase);
_watcherChanges.Remove(oldPath);
@@ -232,7 +246,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
}
else
{
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
if (!HasAllowedExtension(e.FullPath)) return;
lock (_watcherChanges)
{
@@ -248,9 +262,21 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
private CancellationTokenSource _penumbraFswCts = new();
private CancellationTokenSource _lightlessFswCts = new();
public FileSystemWatcher? PenumbraWatcher { get; private set; }
public FileSystemWatcher? LightlessWatcher { get; private set; }
private static bool HasAllowedExtension(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
var extension = Path.GetExtension(path);
return !string.IsNullOrEmpty(extension) && AllowedFileExtensionSet.Contains(extension);
}
private async Task LightlessWatcherExecution()
{
_lightlessFswCts = _lightlessFswCts.CancelRecreate();
@@ -392,51 +418,64 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
public void RecalculateFileCacheSize(CancellationToken token)
{
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) ||
!Directory.Exists(_configService.Current.CacheFolder))
{
FileCacheSize = 0;
return;
}
FileCacheSize = -1;
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
bool isWine = _dalamudUtil?.IsWine ?? false;
try
{
FileCacheDriveFree = di.AvailableFreeSpace;
var drive = DriveInfo.GetDrives()
.FirstOrDefault(d => _configService.Current.CacheFolder
.StartsWith(d.Name, StringComparison.OrdinalIgnoreCase));
if (drive != null)
FileCacheDriveFree = drive.AvailableFreeSpace;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder);
Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder);
}
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder).Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime).ToList();
FileCacheSize = files
.Sum(f =>
var cacheFolder = _configService.Current.CacheFolder;
var candidates = new List<CacheEvictionCandidate>();
long totalSize = 0;
totalSize += AddFolderCandidates(cacheFolder, candidates, token, isWine);
totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "downscaled"), candidates, token, isWine);
totalSize += AddFolderCandidates(Path.Combine(cacheFolder, "decimated"), candidates, token, isWine);
FileCacheSize = totalSize;
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes)
return;
var maxCacheBuffer = maxCacheInBytes * 0.05d;
candidates.Sort(static (a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime));
var evictionTarget = maxCacheInBytes - (long)maxCacheBuffer;
var index = 0;
while (FileCacheSize > evictionTarget && index < candidates.Count)
{
token.ThrowIfCancellationRequested();
var oldestFile = candidates[index];
try
{
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
EvictCacheCandidate(oldestFile, cacheFolder);
FileCacheSize -= oldestFile.Size;
}
catch
catch (Exception ex)
{
return 0;
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullPath);
}
});
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes) return;
var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
{
var oldestFile = files[0];
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
File.Delete(oldestFile.FullName);
files.Remove(oldestFile);
index++;
}
}
@@ -445,6 +484,114 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
HaltScanLocks.Clear();
}
private long AddFolderCandidates(string directory, List<CacheEvictionCandidate> candidates, CancellationToken token, bool isWine)
{
if (!Directory.Exists(directory))
{
return 0;
}
long totalSize = 0;
foreach (var path in Directory.EnumerateFiles(directory))
{
token.ThrowIfCancellationRequested();
try
{
var file = new FileInfo(path);
var size = GetFileSizeOnDisk(file, isWine);
totalSize += size;
candidates.Add(new CacheEvictionCandidate(file.FullName, size, file.LastAccessTime));
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Error getting size for {file}", path);
}
}
return totalSize;
}
private long GetFileSizeOnDisk(FileInfo file, bool isWine)
{
if (isWine)
{
return file.Length;
}
try
{
return _fileCompactor.GetFileSizeOnDisk(file);
}
catch (Exception ex)
{
Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", file.FullName);
return file.Length;
}
}
private void EvictCacheCandidate(CacheEvictionCandidate candidate, string cacheFolder)
{
if (TryGetCacheHashAndPrefixedPath(candidate.FullPath, cacheFolder, out var hash, out var prefixedPath))
{
_fileDbManager.RemoveHashedFile(hash, prefixedPath);
}
try
{
if (File.Exists(candidate.FullPath))
{
File.Delete(candidate.FullPath);
}
}
catch (Exception ex)
{
Logger.LogTrace(ex, "Failed to delete old file {file}", candidate.FullPath);
}
}
private static bool TryGetCacheHashAndPrefixedPath(string filePath, string cacheFolder, out string hash, out string prefixedPath)
{
hash = string.Empty;
prefixedPath = string.Empty;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
var fileName = Path.GetFileNameWithoutExtension(filePath);
if (string.IsNullOrEmpty(fileName) || !IsSha1Hash(fileName))
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, filePath)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
prefixedPath = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
hash = fileName;
return true;
}
private static bool IsSha1Hash(string value)
{
if (value.Length != 40)
{
return false;
}
foreach (var ch in value)
{
if (!Uri.IsHexDigit(ch))
{
return false;
}
}
return true;
}
public void ResumeScan(string source)
{
if (!HaltScanLocks.ContainsKey(source)) HaltScanLocks[source] = 0;
@@ -456,12 +603,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_scanCancellationTokenSource?.Cancel();
// Disposing of file system watchers
PenumbraWatcher?.Dispose();
LightlessWatcher?.Dispose();
// Disposing of cancellation token sources
_scanCancellationTokenSource?.CancelDispose();
_scanCancellationTokenSource?.Dispose();
_penumbraFswCts?.CancelDispose();
_penumbraFswCts?.Dispose();
_lightlessFswCts?.CancelDispose();
_lightlessFswCts?.Dispose();
_periodicCalculationTokenSource?.CancelDispose();
_periodicCalculationTokenSource?.Dispose();
}
private void FullFileScan(CancellationToken ct)
@@ -498,7 +652,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
[
.. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
.AsParallel()
.Where(f => AllowedFileExtensions.Any(e => f.EndsWith(e, StringComparison.OrdinalIgnoreCase))
.Where(f => HasAllowedExtension(f)
&& !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),
@@ -539,7 +693,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
List<FileCacheEntity> entitiesToRemove = [];
List<FileCacheEntity> entitiesToUpdate = [];
object sync = new();
Lock sync = new();
Thread[] workerThreads = new Thread[threadCount];
ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches());
@@ -644,24 +798,25 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
if (ct.IsCancellationRequested) return;
// scan new files
if (allScannedFiles.Any(c => !c.Value))
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
foreach (var cachePath in newFiles)
{
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
new ParallelOptions()
{
MaxDegreeOfParallelism = threadCount,
CancellationToken = ct
}, (cachePath) =>
if (ct.IsCancellationRequested) break;
ProcessOne(cachePath);
Interlocked.Increment(ref _currentFileProgress);
}
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
void ProcessOne(string? cachePath)
{
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
{
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
return;
}
if (ct.IsCancellationRequested) return;
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
@@ -673,15 +828,14 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
}
catch (IOException ioex)
{
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
Interlocked.Increment(ref _currentFileProgress);
});
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
}
Logger.LogDebug("Scan complete");

View File

@@ -0,0 +1,241 @@
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text.Json;
namespace LightlessSync.FileCache;
internal sealed class ExternalCompactionExecutor : ICompactionExecutor, IDisposable
{
private readonly ILogger<ExternalCompactionExecutor> _logger;
private readonly ICompactorContext _context;
private readonly TimeSpan _timeout = TimeSpan.FromMinutes(5);
private readonly string _pipeName;
private Process? _workerProcess;
private bool _disposed;
private readonly object _sync = new();
public ExternalCompactionExecutor(ILogger<ExternalCompactionExecutor> logger, ICompactorContext context)
{
_logger = logger;
_context = context;
_pipeName = $"LightlessCompactor-{Environment.ProcessId}";
}
public bool TryCompact(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
return false;
if (!EnsureWorkerRunning())
return false;
try
{
var request = new CompactorRequest
{
Type = "compact",
Path = filePath
};
return SendRequest(request, out var response) && response?.Success == true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "External compactor failed for {file}", filePath);
return false;
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
SendRequest(new CompactorRequest { Type = "shutdown" }, out _);
}
catch
{
// ignore
}
lock (_sync)
{
if (_workerProcess is null)
return;
TryKill(_workerProcess);
_workerProcess.Dispose();
_workerProcess = null;
}
}
private bool EnsureWorkerRunning()
{
lock (_sync)
{
if (_workerProcess is { HasExited: false })
return true;
_workerProcess?.Dispose();
_workerProcess = null;
var workerPath = ResolveWorkerPath();
if (string.IsNullOrEmpty(workerPath))
return false;
var args = BuildArguments();
var startInfo = new ProcessStartInfo
{
FileName = workerPath,
Arguments = args,
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
var process = new Process { StartInfo = startInfo };
if (!process.Start())
return false;
TrySetLowPriority(process);
_ = DrainAsync(process.StandardOutput, "stdout");
_ = DrainAsync(process.StandardError, "stderr");
_workerProcess = process;
return true;
}
}
private bool SendRequest(CompactorRequest request, out CompactorResponse? response)
{
response = null;
using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
try
{
pipe.Connect((int)_timeout.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Compactor pipe connection failed.");
return false;
}
using var writer = new StreamWriter(pipe) { AutoFlush = true };
using var reader = new StreamReader(pipe);
var payload = JsonSerializer.Serialize(request);
writer.WriteLine(payload);
var readTask = reader.ReadLineAsync();
if (!readTask.Wait(_timeout))
{
_logger.LogWarning("Compactor pipe timed out waiting for response.");
return false;
}
var line = readTask.Result;
if (string.IsNullOrWhiteSpace(line))
return false;
try
{
response = JsonSerializer.Deserialize<CompactorResponse>(line);
return response is not null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse compactor response.");
return false;
}
}
private string? ResolveWorkerPath()
{
var baseDir = AppContext.BaseDirectory;
var exeName = OperatingSystem.IsWindows() || _context.IsWine
? "LightlessCompactorWorker.exe"
: "LightlessCompactorWorker";
var path = Path.Combine(baseDir, exeName);
return File.Exists(path) ? path : null;
}
private string BuildArguments()
{
var args = new List<string> { "--pipe", Quote(_pipeName), "--parent", Environment.ProcessId.ToString() };
if (_context.IsWine)
args.Add("--wine");
return string.Join(' ', args);
}
private static string Quote(string value)
{
if (string.IsNullOrEmpty(value))
return "\"\"";
if (!value.Contains('"', StringComparison.Ordinal))
return "\"" + value + "\"";
return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
}
private static void TrySetLowPriority(Process process)
{
try
{
if (OperatingSystem.IsWindows())
process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
catch
{
// ignore
}
}
private async Task DrainAsync(StreamReader reader, string label)
{
try
{
string? line;
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
if (_logger.IsEnabled(LogLevel.Trace))
_logger.LogTrace("Compactor {label}: {line}", label, line);
}
}
catch
{
// ignore
}
}
private static void TryKill(Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
// ignore
}
}
private sealed class CompactorRequest
{
public string Type { get; init; } = "compact";
public string? Path { get; init; }
}
private sealed class CompactorResponse
{
public bool Success { get; init; }
public string? Error { get; init; }
}
}

View File

@@ -1,15 +1,23 @@
#nullable disable
using System.Text.Json.Serialization;
namespace LightlessSync.FileCache;
public class FileCacheEntity
{
public FileCacheEntity(string hash, string path, string lastModifiedDateTicks, long? size = null, long? compressedSize = null)
[JsonConstructor]
public FileCacheEntity(
string hash,
string prefixedFilePath,
string lastModifiedDateTicks,
long? size = null,
long? compressedSize = null)
{
Size = size;
CompressedSize = compressedSize;
Hash = hash;
PrefixedFilePath = path;
PrefixedFilePath = prefixedFilePath;
LastModifiedDateTicks = lastModifiedDateTicks;
}
@@ -23,7 +31,5 @@ public class FileCacheEntity
public long? Size { get; set; }
public void SetResolvedFilePath(string filePath)
{
ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
}
=> ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,229 +0,0 @@
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
using Microsoft.Extensions.Logging;
using System.Runtime.InteropServices;
namespace LightlessSync.FileCache;
public sealed class FileCompactor
{
public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U;
public const ulong WOF_PROVIDER_FILE = 2UL;
private readonly Dictionary<string, int> _clusterSizes;
private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo;
private readonly ILogger<FileCompactor> _logger;
private readonly LightlessConfigService _lightlessConfigService;
private readonly DalamudUtilService _dalamudUtilService;
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService)
{
_clusterSizes = new(StringComparer.Ordinal);
_logger = logger;
_lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService;
_efInfo = new WOF_FILE_COMPRESSION_INFO_V1
{
Algorithm = CompressionAlgorithm.XPRESS8K,
Flags = 0
};
}
private enum CompressionAlgorithm
{
NO_COMPRESSION = -2,
LZNT1 = -1,
XPRESS4K = 0,
LZX = 1,
XPRESS8K = 2,
XPRESS16K = 3
}
public bool MassCompactRunning { get; private set; } = false;
public string Progress { get; private set; } = string.Empty;
public void CompactStorage(bool compress)
{
MassCompactRunning = true;
int currentFile = 1;
var allFiles = Directory.EnumerateFiles(_lightlessConfigService.Current.CacheFolder).ToList();
int allFilesCount = allFiles.Count;
foreach (var file in allFiles)
{
Progress = $"{currentFile}/{allFilesCount}";
if (compress)
CompactFile(file);
else
DecompressFile(file);
currentFile++;
}
MassCompactRunning = false;
}
public long GetFileSizeOnDisk(FileInfo fileInfo, bool? isNTFS = null)
{
bool ntfs = isNTFS ?? string.Equals(new DriveInfo(fileInfo.Directory!.Root.FullName).DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase);
if (_dalamudUtilService.IsWine || !ntfs) return fileInfo.Length;
var clusterSize = GetClusterSize(fileInfo);
if (clusterSize == -1) return fileInfo.Length;
var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize);
var size = (long)hosize << 32 | losize;
return ((size + clusterSize - 1) / clusterSize) * clusterSize;
}
public async Task WriteAllBytesAsync(string filePath, byte[] decompressedFile, CancellationToken token)
{
await File.WriteAllBytesAsync(filePath, decompressedFile, token).ConfigureAwait(false);
if (_dalamudUtilService.IsWine || !_lightlessConfigService.Current.UseCompactor)
{
return;
}
CompactFile(filePath);
}
[DllImport("kernel32.dll")]
private static extern int DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out IntPtr lpBytesReturned, out IntPtr lpOverlapped);
[DllImport("kernel32.dll")]
private static extern uint GetCompressedFileSizeW([In, MarshalAs(UnmanagedType.LPWStr)] string lpFileName,
[Out, MarshalAs(UnmanagedType.U4)] out uint lpFileSizeHigh);
[DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)]
private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName,
out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters,
out uint lpTotalNumberOfClusters);
[DllImport("WoFUtil.dll")]
private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength);
[DllImport("WofUtil.dll")]
private static extern int WofSetFileDataLocation(IntPtr FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length);
private void CompactFile(string filePath)
{
var fs = new DriveInfo(new FileInfo(filePath).Directory!.Root.FullName);
bool isNTFS = string.Equals(fs.DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase);
if (!isNTFS)
{
_logger.LogWarning("Drive for file {file} is not NTFS", filePath);
return;
}
var fi = new FileInfo(filePath);
var oldSize = fi.Length;
var clusterSize = GetClusterSize(fi);
if (oldSize < Math.Max(clusterSize, 8 * 1024))
{
_logger.LogDebug("File {file} is smaller than cluster size ({size}), ignoring", filePath, clusterSize);
return;
}
if (!IsCompactedFile(filePath))
{
_logger.LogDebug("Compacting file to XPRESS8K: {file}", filePath);
WOFCompressFile(filePath);
var newSize = GetFileSizeOnDisk(fi);
_logger.LogDebug("Compressed {file} from {orig}b to {comp}b", filePath, oldSize, newSize);
}
else
{
_logger.LogDebug("File {file} already compressed", filePath);
}
}
private void DecompressFile(string path)
{
_logger.LogDebug("Removing compression from {file}", path);
try
{
using (var fs = new FileStream(path, FileMode.Open))
{
#pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called
var hDevice = fs.SafeFileHandle.DangerousGetHandle();
#pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called
_ = DeviceIoControl(hDevice, FSCTL_DELETE_EXTERNAL_BACKING, nint.Zero, 0, nint.Zero, 0, out _, out _);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error decompressing file {path}", path);
}
}
private int GetClusterSize(FileInfo fi)
{
if (!fi.Exists) return -1;
var root = fi.Directory?.Root.FullName.ToLower() ?? string.Empty;
if (string.IsNullOrEmpty(root)) return -1;
if (_clusterSizes.TryGetValue(root, out int value)) return value;
_logger.LogDebug("Getting Cluster Size for {path}, root {root}", fi.FullName, root);
int result = GetDiskFreeSpaceW(root, out uint sectorsPerCluster, out uint bytesPerSector, out _, out _);
if (result == 0) return -1;
_clusterSizes[root] = (int)(sectorsPerCluster * bytesPerSector);
_logger.LogDebug("Determined Cluster Size for root {root}: {cluster}", root, _clusterSizes[root]);
return _clusterSizes[root];
}
private static bool IsCompactedFile(string filePath)
{
uint buf = 8;
_ = WofIsExternalFile(filePath, out int isExtFile, out uint _, out var info, ref buf);
if (isExtFile == 0) return false;
return info.Algorithm == CompressionAlgorithm.XPRESS8K;
}
private void WOFCompressFile(string path)
{
var efInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf(_efInfo));
Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: true);
ulong length = (ulong)Marshal.SizeOf(_efInfo);
try
{
using (var fs = new FileStream(path, FileMode.Open))
{
#pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called
var hFile = fs.SafeFileHandle.DangerousGetHandle();
#pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called
if (fs.SafeFileHandle.IsInvalid)
{
_logger.LogWarning("Invalid file handle to {file}", path);
}
else
{
var ret = WofSetFileDataLocation(hFile, WOF_PROVIDER_FILE, efInfoPtr, length);
if (!(ret == 0 || ret == unchecked((int)0x80070158)))
{
_logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X"));
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error compacting file {path}", path);
}
finally
{
Marshal.FreeHGlobal(efInfoPtr);
}
}
private struct WOF_FILE_COMPRESSION_INFO_V1
{
public CompressionAlgorithm Algorithm;
public ulong Flags;
}
}

View File

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

View File

@@ -0,0 +1,20 @@
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services;
namespace LightlessSync.FileCache;
internal sealed class PluginCompactorContext : ICompactorContext
{
private readonly LightlessConfigService _configService;
private readonly DalamudUtilService _dalamudUtilService;
public PluginCompactorContext(LightlessConfigService configService, DalamudUtilService dalamudUtilService)
{
_configService = configService;
_dalamudUtilService = dalamudUtilService;
}
public bool UseCompactor => _configService.Current.UseCompactor;
public string CacheFolder => _configService.Current.CacheFolder;
public bool IsWine => _dalamudUtilService.IsWine;
}

View File

@@ -3,11 +3,14 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
namespace LightlessSync.FileCache;
@@ -17,33 +20,53 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
private readonly TransientConfigService _configurationService;
private readonly DalamudUtilService _dalamudUtil;
private readonly ActorObjectService _actorObjectService;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"];
private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private ConcurrentDictionary<IntPtr, ObjectKind> _cachedFrameAddresses = [];
private readonly object _playerRelatedLock = new();
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
private readonly Dictionary<nint, GameObjectHandler> _ownedHandlers = new();
private ConcurrentDictionary<nint, ObjectKind> _cachedFrameAddresses = new();
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
private uint _lastClassJobId = uint.MaxValue;
public bool IsTransientRecording { get; private set; } = false;
public TransientResourceManager(ILogger<TransientResourceManager> logger, TransientConfigService configurationService,
DalamudUtilService dalamudUtil, LightlessMediator mediator) : base(logger, mediator)
DalamudUtilService dalamudUtil, LightlessMediator mediator, ActorObjectService actorObjectService, GameObjectHandlerFactory gameObjectHandlerFactory) : base(logger, mediator)
{
_configurationService = configurationService;
_dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(msg.Descriptor));
Mediator.Subscribe<PenumbraModSettingChangedMessage>(this, (_) => Manager_PenumbraModSettingChanged());
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate());
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedLock)
{
_playerRelatedPointers.Add(msg.GameObjectHandler);
}
});
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedLock)
{
_playerRelatedPointers.Remove(msg.GameObjectHandler);
}
});
foreach (var descriptor in _actorObjectService.ObjectDescriptors)
{
HandleActorTracked(descriptor);
}
}
private TransientConfig.TransientPlayerConfig PlayerConfig
@@ -59,7 +82,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
}
}
private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult() + "_" + _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerName() + "_" + _dalamudUtil.GetHomeWorldId();
private ConcurrentDictionary<ObjectKind, HashSet<string>> SemiTransientResources
{
get
@@ -68,9 +91,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{
_semiTransientResources = new();
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
_semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.Ordinal);
_semiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
_semiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
_semiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
}
return _semiTransientResources;
@@ -108,14 +134,14 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{
SemiTransientResources.TryGetValue(objectKind, out var result);
return result ?? new HashSet<string>(StringComparer.Ordinal);
return result ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
public void PersistTransientResources(ObjectKind objectKind)
{
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? semiTransientResources))
{
SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.Ordinal);
SemiTransientResources[objectKind] = semiTransientResources = new(StringComparer.OrdinalIgnoreCase);
}
if (!TransientResources.TryGetValue(objectKind, out var resources))
@@ -123,13 +149,22 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
return;
}
var transientResources = resources.ToList();
List<string> transientResources;
lock (resources)
{
transientResources = resources.ToList();
}
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
List<string> newlyAddedGamePaths = resources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
List<string> newlyAddedGamePaths;
lock (semiTransientResources)
{
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList();
foreach (var gamePath in transientResources)
{
semiTransientResources.Add(gamePath);
}
}
bool saveConfig = false;
if (objectKind == ObjectKind.Player && newlyAddedGamePaths.Count != 0)
@@ -161,17 +196,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_configurationService.Save();
}
TransientResources[objectKind].Clear();
lock (resources)
{
resources.Clear();
}
}
public void RemoveTransientResource(ObjectKind objectKind, string path)
{
var normalizedPath = NormalizeGamePath(path);
if (SemiTransientResources.TryGetValue(objectKind, out var resources))
{
resources.RemoveWhere(f => string.Equals(path, f, StringComparison.Ordinal));
resources.Remove(normalizedPath);
if (objectKind == ObjectKind.Player)
{
PlayerConfig.RemovePath(path, objectKind);
PlayerConfig.RemovePath(normalizedPath, objectKind);
Logger.LogTrace("Saving transient.json from {method}", nameof(RemoveTransientResource));
_configurationService.Save();
}
@@ -180,16 +219,17 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
internal bool AddTransientResource(ObjectKind objectKind, string item)
{
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(item))
var normalizedItem = NormalizeGamePath(item);
if (SemiTransientResources.TryGetValue(objectKind, out var semiTransient) && semiTransient != null && semiTransient.Contains(normalizedItem))
return false;
if (!TransientResources.TryGetValue(objectKind, out HashSet<string>? transientResource))
{
transientResource = new HashSet<string>(StringComparer.Ordinal);
transientResource = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
TransientResources[objectKind] = transientResource;
}
return transientResource.Add(item.ToLowerInvariant());
return transientResource.Add(normalizedItem);
}
internal void ClearTransientPaths(ObjectKind objectKind, List<string> list)
@@ -241,17 +281,66 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
TransientResources.Clear();
SemiTransientResources.Clear();
lock (_ownedHandlerLock)
{
foreach (var handler in _ownedHandlers.Values)
{
handler.Dispose();
}
_ownedHandlers.Clear();
}
}
private void DalamudUtil_FrameworkUpdate()
{
_cachedFrameAddresses = new(_playerRelatedPointers.Where(k => k.Address != nint.Zero).ToDictionary(c => c.Address, c => c.ObjectKind));
_ = Task.Run(() => RefreshPlayerRelatedAddressMap());
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Clear();
}
if (_lastClassJobId != _dalamudUtil.ClassJobId)
{
UpdateClassJobCache();
}
CleanupAbsentObjects();
}
private void RefreshPlayerRelatedAddressMap()
{
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
lock (_playerRelatedLock)
{
foreach (var handler in _playerRelatedPointers)
{
var address = (nint)handler.Address;
if (address != nint.Zero)
{
tempMap[address] = handler;
updatedFrameAddresses[address] = handler.ObjectKind;
}
}
}
_playerRelatedByAddress.Clear();
foreach (var kvp in tempMap)
{
_playerRelatedByAddress[kvp.Key] = kvp.Value;
}
_cachedFrameAddresses.Clear();
foreach (var kvp in updatedFrameAddresses)
{
_cachedFrameAddresses[kvp.Key] = kvp.Value;
}
}
private void UpdateClassJobCache()
{
_lastClassJobId = _dalamudUtil.ClassJobId;
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
@@ -259,16 +348,22 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
value?.Clear();
}
// reload config for current new classjob
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
.Concat(jobSpecificData ?? [])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
petSpecificData ?? [],
StringComparer.OrdinalIgnoreCase);
}
foreach (var kind in Enum.GetValues(typeof(ObjectKind)))
private void CleanupAbsentObjects()
{
if (!_cachedFrameAddresses.Any(k => k.Value == (ObjectKind)kind) && TransientResources.Remove((ObjectKind)kind, out _))
foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast<ObjectKind>())
{
if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _))
{
Logger.LogDebug("Object not present anymore: {kind}", kind.ToString());
}
@@ -280,10 +375,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_ = Task.Run(() =>
{
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
lock (_playerRelatedLock)
{
foreach (var item in _playerRelatedPointers)
{
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
}
}
});
}
@@ -292,6 +390,134 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_semiTransientResources = null;
}
private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor)
{
if (descriptor.IsInGpose)
return;
if (descriptor.OwnedKind is not ObjectKind ownedKind)
return;
if (Logger.IsEnabled(LogLevel.Debug))
{
Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", ownedKind, descriptor.Address, descriptor.Name);
}
_cachedFrameAddresses[descriptor.Address] = ownedKind;
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 static string NormalizeGamePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant();
}
private static string NormalizeFilePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
if (path.StartsWith("|", StringComparison.Ordinal))
{
var lastPipe = path.LastIndexOf('|');
if (lastPipe >= 0 && lastPipe + 1 < path.Length)
{
path = path[(lastPipe + 1)..];
}
}
return NormalizeGamePath(path);
}
private static bool HasHandledFileType(string gamePath, string[] handledTypes)
{
for (var i = 0; i < handledTypes.Length; i++)
{
if (gamePath.EndsWith(handledTypes[i], StringComparison.Ordinal))
return true;
}
return false;
}
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{
var gamePath = msg.GamePath.ToLowerInvariant();
@@ -350,11 +576,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
TransientResources[objectKind] = transientResources;
}
var owner = _playerRelatedPointers.FirstOrDefault(f => f.Address == gameObjectAddress);
_playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
bool alreadyTransient = false;
bool transientContains = transientResources.Contains(replacedGamePath);
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value).Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value)
.Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
if (transientContains || semiTransientContains)
{
if (!IsTransientRecording)
@@ -383,21 +610,30 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private void SendTransients(nint gameObject, ObjectKind objectKind)
{
_ = Task.Run(async () =>
{
_sendTransientCts?.Cancel();
_sendTransientCts?.Dispose();
_sendTransientCts.Cancel();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
foreach (var kvp in TransientResources)
_ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
if (TransientResources.TryGetValue(objectKind, out var values) && values.Any())
{
Logger.LogTrace("Sending Transients for {kind}", objectKind);
Mediator.Publish(new TransientResourceChangedMessage(gameObject));
}
}
catch (TaskCanceledException)
{
}
catch (System.OperationCanceledException)
{
}
});
}
@@ -440,7 +676,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (!item.AddTransient || item.AlreadyTransient) continue;
if (!TransientResources.TryGetValue(item.Owner.ObjectKind, out var transient))
{
TransientResources[item.Owner.ObjectKind] = transient = [];
TransientResources[item.Owner.ObjectKind] = transient = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
Logger.LogTrace("Adding recorded: {gamePath} => {filePath}", item.GamePath, item.FilePath);

View File

@@ -1,4 +1,5 @@
using Dalamud.Plugin.Services;
using Dalamud.Game.ClientState.Objects.SubKinds;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Microsoft.Extensions.Logging;
@@ -11,24 +12,35 @@ public unsafe class BlockedCharacterHandler
private readonly Dictionary<CharaData, bool> _blockedCharacterCache = new();
private readonly ILogger<BlockedCharacterHandler> _logger;
private readonly IObjectTable _objectTable;
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider)
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider, IObjectTable objectTable)
{
gameInteropProvider.InitializeFromAttributes(this);
_logger = logger;
_objectTable = objectTable;
}
private static CharaData GetIdsFromPlayerPointer(nint ptr)
private CharaData? TryGetIdsFromPlayerPointer(nint ptr, ushort objectIndex)
{
if (ptr == nint.Zero) return new(0, 0);
var castChar = ((BattleChara*)ptr);
if (ptr == nint.Zero || objectIndex >= 200)
return null;
var obj = _objectTable[objectIndex];
if (obj is not IPlayerCharacter player || player.Address != ptr)
return null;
var castChar = (BattleChara*)player.Address;
return new(castChar->Character.AccountId, castChar->Character.ContentId);
}
public bool IsCharacterBlocked(nint ptr, out bool firstTime)
public bool IsCharacterBlocked(nint ptr, ushort objectIndex, out bool firstTime)
{
firstTime = false;
var combined = GetIdsFromPlayerPointer(ptr);
var combined = TryGetIdsFromPlayerPointer(ptr, objectIndex);
if (combined == null)
return false;
if (_blockedCharacterCache.TryGetValue(combined, out var isBlocked))
return isBlocked;

View File

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

View File

@@ -0,0 +1,11 @@
namespace Lifestream.Enums;
public enum ResidentialAetheryteKind
{
None = -1,
Uldah = 9,
Gridania = 2,
Limsa = 8,
Foundation = 70,
Kugane = 111,
}

View File

@@ -0,0 +1,51 @@
namespace Lifestream.Enums;
public enum TerritoryTypeIdHousing
{
None = -1,
// Mist (Limsa Lominsa)
Mist = 339,
MistSmall = 282,
MistMedium = 283,
MistLarge = 284,
MistFCRoom = 384,
MistFCWorkshop = 423,
MistApartment = 608,
// Lavender Beds (Gridania)
Lavender = 340,
LavenderSmall = 342,
LavenderMedium = 343,
LavenderLarge = 344,
LavenderFCRoom = 385,
LavenderFCWorkshop = 425,
LavenderApartment = 609,
// Goblet (Ul'dah)
Goblet = 341,
GobletSmall = 345,
GobletMedium = 346,
GobletLarge = 347,
GobletFCRoom = 386,
GobletFCWorkshop = 424,
GobletApartment = 610,
// Shirogane (Kugane)
Shirogane = 641,
ShiroganeSmall = 649,
ShiroganeMedium = 650,
ShiroganeLarge = 651,
ShiroganeFCRoom = 652,
ShiroganeFCWorkshop = 653,
ShiroganeApartment = 655,
// Empyreum (Ishgard)
Empyream = 979,
EmpyreamSmall = 980,
EmpyreamMedium = 981,
EmpyreamLarge = 982,
EmpyreamFCRoom = 983,
EmpyreamFCWorkshop = 984,
EmpyreamApartment = 999,
}

View File

@@ -0,0 +1 @@
global using AddressBookEntryTuple = (string Name, int World, int City, int Ward, int PropertyType, int Plot, int Apartment, bool ApartmentSubdivision, bool AliasEnabled, string Alias);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Lifestream.Enums;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace LightlessSync.Interop.Ipc;
public sealed class IpcCallerLifestream : IpcServiceBase
{
private static readonly IpcServiceDescriptor LifestreamDescriptor = new("Lifestream", "Lifestream", new Version(0, 0, 0, 0));
private readonly ICallGateSubscriber<string, object> _executeLifestreamCommand;
private readonly ICallGateSubscriber<AddressBookEntryTuple, bool> _isHere;
private readonly ICallGateSubscriber<AddressBookEntryTuple, object> _goToHousingAddress;
private readonly ICallGateSubscriber<bool> _isBusy;
private readonly ICallGateSubscriber<object> _abort;
private readonly ICallGateSubscriber<string, bool> _changeWorld;
private readonly ICallGateSubscriber<uint, bool> _changeWorldById;
private readonly ICallGateSubscriber<string, bool> _aetheryteTeleport;
private readonly ICallGateSubscriber<uint, bool> _aetheryteTeleportById;
private readonly ICallGateSubscriber<bool> _canChangeInstance;
private readonly ICallGateSubscriber<int> _getCurrentInstance;
private readonly ICallGateSubscriber<int> _getNumberOfInstances;
private readonly ICallGateSubscriber<int, object> _changeInstance;
private readonly ICallGateSubscriber<(ResidentialAetheryteKind, int, int)> _getCurrentPlotInfo;
public IpcCallerLifestream(IDalamudPluginInterface pi, LightlessMediator lightlessMediator, ILogger<IpcCallerLifestream> logger)
: base(logger, lightlessMediator, pi, LifestreamDescriptor)
{
_executeLifestreamCommand = pi.GetIpcSubscriber<string, object>("Lifestream.ExecuteCommand");
_isHere = pi.GetIpcSubscriber<AddressBookEntryTuple, bool>("Lifestream.IsHere");
_goToHousingAddress = pi.GetIpcSubscriber<AddressBookEntryTuple, object>("Lifestream.GoToHousingAddress");
_isBusy = pi.GetIpcSubscriber<bool>("Lifestream.IsBusy");
_abort = pi.GetIpcSubscriber<object>("Lifestream.Abort");
_changeWorld = pi.GetIpcSubscriber<string, bool>("Lifestream.ChangeWorld");
_changeWorldById = pi.GetIpcSubscriber<uint, bool>("Lifestream.ChangeWorldById");
_aetheryteTeleport = pi.GetIpcSubscriber<string, bool>("Lifestream.AetheryteTeleport");
_aetheryteTeleportById = pi.GetIpcSubscriber<uint, bool>("Lifestream.AetheryteTeleportById");
_canChangeInstance = pi.GetIpcSubscriber<bool>("Lifestream.CanChangeInstance");
_getCurrentInstance = pi.GetIpcSubscriber<int>("Lifestream.GetCurrentInstance");
_getNumberOfInstances = pi.GetIpcSubscriber<int>("Lifestream.GetNumberOfInstances");
_changeInstance = pi.GetIpcSubscriber<int, object>("Lifestream.ChangeInstance");
_getCurrentPlotInfo = pi.GetIpcSubscriber<(ResidentialAetheryteKind, int, int)>("Lifestream.GetCurrentPlotInfo");
CheckAPI();
}
public void ExecuteLifestreamCommand(string command)
{
if (!APIAvailable) return;
_executeLifestreamCommand.InvokeAction(command);
}
public bool IsHere(AddressBookEntryTuple entry)
{
if (!APIAvailable) return false;
return _isHere.InvokeFunc(entry);
}
public void GoToHousingAddress(AddressBookEntryTuple entry)
{
if (!APIAvailable) return;
_goToHousingAddress.InvokeAction(entry);
}
public bool IsBusy()
{
if (!APIAvailable) return false;
return _isBusy.InvokeFunc();
}
public void Abort()
{
if (!APIAvailable) return;
_abort.InvokeAction();
}
public bool ChangeWorld(string worldName)
{
if (!APIAvailable) return false;
return _changeWorld.InvokeFunc(worldName);
}
public bool AetheryteTeleport(string aetheryteName)
{
if (!APIAvailable) return false;
return _aetheryteTeleport.InvokeFunc(aetheryteName);
}
public bool ChangeWorldById(uint worldId)
{
if (!APIAvailable) return false;
return _changeWorldById.InvokeFunc(worldId);
}
public bool AetheryteTeleportById(uint aetheryteId)
{
if (!APIAvailable) return false;
return _aetheryteTeleportById.InvokeFunc(aetheryteId);
}
public bool CanChangeInstance()
{
if (!APIAvailable) return false;
return _canChangeInstance.InvokeFunc();
}
public int GetCurrentInstance()
{
if (!APIAvailable) return -1;
return _getCurrentInstance.InvokeFunc();
}
public int GetNumberOfInstances()
{
if (!APIAvailable) return -1;
return _getNumberOfInstances.InvokeFunc();
}
public void ChangeInstance(int instanceNumber)
{
if (!APIAvailable) return;
_changeInstance.InvokeAction(instanceNumber);
}
public (ResidentialAetheryteKind, int, int)? GetCurrentPlotInfo()
{
if (!APIAvailable) return (ResidentialAetheryteKind.None, -1, -1);
return _getCurrentPlotInfo.InvokeFunc();
}
}

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,12 @@ namespace LightlessSync.Interop.Ipc;
public sealed partial class IpcManager : DisposableMediatorSubscriberBase
{
private bool _wasInitialized;
public IpcManager(ILogger<IpcManager> logger, LightlessMediator mediator,
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator)
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio,
IpcCallerLifestream ipcCallerLifestream) : base(logger, mediator)
{
CustomizePlus = customizeIpc;
Heels = heelsIpc;
@@ -17,8 +20,10 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Moodles = moodlesIpc;
PetNames = ipcCallerPetNames;
Brio = ipcCallerBrio;
Lifestream = ipcCallerLifestream;
if (Initialized)
_wasInitialized = Initialized;
if (_wasInitialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
@@ -44,8 +49,8 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
public IpcCallerPenumbra Penumbra { get; }
public IpcCallerMoodles Moodles { get; }
public IpcCallerPetNames PetNames { get; }
public IpcCallerBrio Brio { get; }
public IpcCallerLifestream Lifestream { get; }
private void PeriodicApiStateCheck()
{
@@ -58,5 +63,14 @@ public sealed partial class IpcManager : DisposableMediatorSubscriberBase
Moodles.CheckAPI();
PetNames.CheckAPI();
Brio.CheckAPI();
var initialized = Initialized;
if (initialized && !_wasInitialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
_wasInitialized = initialized;
Lifestream.CheckAPI();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
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;
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);
}
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}";
var createResult = _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId);
logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}, Result: {Result}", name, tempCollectionId, createResult);
return (tempCollectionId, name);
}).ConfigureAwait(false);
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);
}
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<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, 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)
{
}
}

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,111 @@
using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Globalization;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraResource : PenumbraBase
{
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
private readonly ResolveGameObjectPath _resolveGameObjectPath;
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
private readonly ResolvePlayerPathsAsync _resolvePlayerPaths;
private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations;
private readonly EventSubscriber<nint, string, string> _gameObjectResourcePathResolved;
public PenumbraResource(
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator)
{
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
_resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface);
_getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface);
_gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded);
}
public override string Name => "Penumbra.Resources";
public async Task<Dictionary<string, HashSet<string>>?> GetCharacterDataAsync(ILogger logger, GameObjectHandler handler)
{
if (!IsAvailable)
{
return null;
}
var requestId = Guid.NewGuid();
var totalTimer = Stopwatch.StartNew();
logger.LogTrace("[{requestId}] Requesting Penumbra.GetGameObjectResourcePaths for {handler}", requestId, handler);
var result = await DalamudUtil.RunOnFrameworkThread(() =>
{
var idx = handler.GetGameObject()?.ObjectIndex;
if (idx == null)
{
logger.LogTrace("[{requestId}] GetGameObjectResourcePaths aborted (missing object index) for {handler}", requestId, handler);
return null;
}
logger.LogTrace("[{requestId}] Invoking Penumbra.GetGameObjectResourcePaths for index {index}", requestId, idx.Value);
var invokeTimer = Stopwatch.StartNew();
var data = _gameObjectResourcePaths.Invoke(idx.Value)[0];
invokeTimer.Stop();
logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths returned {count} entries in {elapsedMs}ms",
requestId, data?.Count ?? 0, invokeTimer.ElapsedMilliseconds);
return data;
}).ConfigureAwait(false);
totalTimer.Stop();
logger.LogTrace("[{requestId}] Penumbra.GetGameObjectResourcePaths finished in {elapsedMs}ms (null: {isNull})",
requestId, totalTimer.ElapsedMilliseconds, result is null);
return result;
}
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 string ResolveGameObjectPath(string gamePath, int gameObjectIndex)
=> IsAvailable ? _resolveGameObjectPath.Invoke(gamePath, gameObjectIndex) : gamePath;
public string[] ReverseResolveGameObjectPath(string moddedPath, int gameObjectIndex)
=> IsAvailable ? _reverseResolveGameObjectPath.Invoke(moddedPath, gameObjectIndex) : Array.Empty<string>();
private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath)
{
if (ptr != nint.Zero && string.Compare(gamePath, resolvedPath, ignoreCase: true, CultureInfo.InvariantCulture) != 0)
{
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
}
}
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{
}
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, bool requestRedraw)
{
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 (requestRedraw && 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

@@ -1,11 +1,17 @@
using LightlessSync.WebAPI;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using LightlessSync.LightlessConfiguration.Configurations;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.WebAPI;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LightlessSync.LightlessConfiguration;
public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, TransientConfigService transientConfigService,
ServerConfigService serverConfigService) : IHostedService
ServerConfigService serverConfigService, TempCollectionConfigService tempCollectionConfigService,
LightlessConfigService lightlessConfigService) : IHostedService
{
private readonly ILogger<ConfigurationMigrator> _logger = logger;
@@ -19,6 +25,27 @@ public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, Transi
transientConfigService.Save();
}
if (transientConfigService.Current.Version == 1)
{
_logger.LogInformation("Migrating Transient Config V1 => V2");
var totalRemoved = 0;
var configCount = 0;
var changedCount = 0;
foreach (var config in transientConfigService.Current.TransientConfigs.Values)
{
if (config.NormalizePaths(out var removed))
changedCount++;
totalRemoved += removed;
configCount++;
}
_logger.LogInformation("Transient config normalization: processed {count} entries, updated {updated}, removed {removed} paths", configCount, changedCount, totalRemoved);
transientConfigService.Current.Version = 2;
transientConfigService.Save();
}
if (serverConfigService.Current.Version == 1)
{
_logger.LogInformation("Migrating Server Config V1 => V2");
@@ -30,6 +57,8 @@ public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, Transi
serverConfigService.Current.Version = 2;
serverConfigService.Save();
}
MigrateTempCollectionConfig(tempCollectionConfigService, lightlessConfigService);
}
public Task StartAsync(CancellationToken cancellationToken)
@@ -42,4 +71,273 @@ public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, Transi
{
return Task.CompletedTask;
}
private void MigrateTempCollectionConfig(TempCollectionConfigService tempCollectionConfigService, LightlessConfigService lightlessConfigService)
{
var now = DateTime.UtcNow;
TempCollectionConfig tempConfig = tempCollectionConfigService.Current;
var tempChanged = false;
var tempNeedsSave = false;
if (TryReadTempCollectionData(lightlessConfigService.ConfigurationPath, out var root, out var ids, out var entries))
{
tempChanged |= MergeTempCollectionData(tempConfig, ids, entries, now);
var removed = root.Remove("OrphanableTempCollections");
removed |= root.Remove("OrphanableTempCollectionEntries");
if (removed)
{
try
{
string updatedJson = root.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(lightlessConfigService.ConfigurationPath, updatedJson);
lightlessConfigService.UpdateLastWriteTime();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to rewrite {config} after temp collection migration", lightlessConfigService.ConfigurationPath);
return;
}
}
if (ids.Count > 0 || entries.Count > 0)
{
_logger.LogInformation("Migrated {ids} temp collection ids and {entries} entries to {configName}",
ids.Count, entries.Count, tempCollectionConfigService.ConfigurationName);
}
}
if (TryReadTempCollectionData(tempCollectionConfigService.ConfigurationPath, out var tempRoot, out var tempIds, out var tempEntries))
{
tempChanged |= MergeTempCollectionData(tempConfig, tempIds, tempEntries, now);
if (tempRoot.Remove("OrphanableTempCollections"))
{
tempNeedsSave = true;
}
}
if (tempChanged || tempNeedsSave)
{
tempCollectionConfigService.Save();
}
}
private bool TryReadTempCollectionData(string configPath, out JsonObject root, out HashSet<Guid> ids, out List<OrphanableTempCollectionEntry> entries)
{
root = new JsonObject();
ids = [];
entries = [];
if (!File.Exists(configPath))
{
return false;
}
try
{
root = JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? new JsonObject();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read temp collection data from {config}", configPath);
return false;
}
root.TryGetPropertyValue("OrphanableTempCollections", out JsonNode? idsNode);
root.TryGetPropertyValue("OrphanableTempCollectionEntries", out JsonNode? entriesNode);
if (idsNode == null && entriesNode == null)
{
return false;
}
ids = ParseGuidSet(idsNode);
entries = ParseEntries(entriesNode);
return true;
}
private static HashSet<Guid> ParseGuidSet(JsonNode? node)
{
HashSet<Guid> ids = [];
if (node is not JsonArray array)
{
return ids;
}
foreach (JsonNode? item in array)
{
Guid id = ParseGuid(item);
if (id != Guid.Empty)
{
ids.Add(id);
}
}
return ids;
}
private static List<OrphanableTempCollectionEntry> ParseEntries(JsonNode? node)
{
List<OrphanableTempCollectionEntry> entries = [];
if (node is not JsonArray array)
{
return entries;
}
foreach (JsonNode? item in array)
{
if (item is not JsonObject obj)
{
continue;
}
Guid id = ParseGuid(obj["Id"]);
if (id == Guid.Empty)
{
continue;
}
DateTime registeredAtUtc = DateTime.MinValue;
if (TryParseDateTime(obj["RegisteredAtUtc"], out DateTime parsed))
{
registeredAtUtc = parsed;
}
entries.Add(new OrphanableTempCollectionEntry
{
Id = id,
RegisteredAtUtc = registeredAtUtc
});
}
return entries;
}
private static Guid ParseGuid(JsonNode? node)
{
if (node is JsonValue value)
{
if (value.TryGetValue<string>(out string? stringValue) && Guid.TryParse(stringValue, out Guid parsed))
{
return parsed;
}
}
return Guid.Empty;
}
private static bool TryParseDateTime(JsonNode? node, out DateTime value)
{
value = DateTime.MinValue;
if (node is not JsonValue val)
{
return false;
}
if (val.TryGetValue<DateTime>(out DateTime direct))
{
value = direct;
return true;
}
if (val.TryGetValue<string>(out string? stringValue)
&& DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsed))
{
value = parsed;
return true;
}
return false;
}
private static bool MergeTempCollectionData(TempCollectionConfig config, HashSet<Guid> ids, List<OrphanableTempCollectionEntry> entries, DateTime now)
{
bool changed = false;
Dictionary<Guid, OrphanableTempCollectionEntry> entryLookup = new();
for (var i = config.OrphanableTempCollectionEntries.Count - 1; i >= 0; i--)
{
var entry = config.OrphanableTempCollectionEntries[i];
if (entry.Id == Guid.Empty)
{
config.OrphanableTempCollectionEntries.RemoveAt(i);
changed = true;
continue;
}
if (entryLookup.TryGetValue(entry.Id, out var existing))
{
if (entry.RegisteredAtUtc != DateTime.MinValue
&& (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc))
{
existing.RegisteredAtUtc = entry.RegisteredAtUtc;
changed = true;
}
config.OrphanableTempCollectionEntries.RemoveAt(i);
changed = true;
continue;
}
entryLookup[entry.Id] = entry;
}
foreach (OrphanableTempCollectionEntry entry in entries)
{
if (entry.Id == Guid.Empty)
{
continue;
}
if (!entryLookup.TryGetValue(entry.Id, out OrphanableTempCollectionEntry? existing))
{
var added = new OrphanableTempCollectionEntry
{
Id = entry.Id,
RegisteredAtUtc = entry.RegisteredAtUtc
};
config.OrphanableTempCollectionEntries.Add(added);
entryLookup[entry.Id] = added;
changed = true;
continue;
}
if (entry.RegisteredAtUtc != DateTime.MinValue
&& (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc))
{
existing.RegisteredAtUtc = entry.RegisteredAtUtc;
changed = true;
}
}
foreach (Guid id in ids)
{
if (id == Guid.Empty)
{
continue;
}
if (!entryLookup.TryGetValue(id, out OrphanableTempCollectionEntry? existing))
{
var added = new OrphanableTempCollectionEntry
{
Id = id,
RegisteredAtUtc = now
};
config.OrphanableTempCollectionEntries.Add(added);
entryLookup[id] = added;
changed = true;
continue;
}
if (existing.RegisteredAtUtc == DateTime.MinValue)
{
existing.RegisteredAtUtc = now;
changed = true;
}
}
return changed;
}
}

View File

@@ -72,7 +72,10 @@ public class ConfigurationSaveService : IHostedService
{
_logger.LogTrace("Saving {configName}", config.ConfigurationName);
var configDir = config.ConfigurationPath.Replace(config.ConfigurationName, string.Empty);
var isTempCollections = string.Equals(config.ConfigurationName, TempCollectionConfigService.ConfigName, StringComparison.OrdinalIgnoreCase);
if (!isTempCollections)
{
try
{
var configBackupFolder = Path.Join(configDir, BackupFolder);
@@ -104,13 +107,14 @@ public class ConfigurationSaveService : IHostedService
// ignore if file cannot be backupped
_logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath);
}
}
var temp = config.ConfigurationPath + ".tmp";
try
{
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(config.Current, typeof(T), new JsonSerializerOptions()
{
WriteIndented = true
WriteIndented = !isTempCollections
})).ConfigureAwait(false);
File.Move(temp, config.ConfigurationPath, true);
config.UpdateLastWriteTime();

View File

@@ -0,0 +1,34 @@
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 bool ShowNotesInSyncshellChat { get; set; } = true;
public bool EnableAnimatedEmotes { get; set; } = true;
public float EmoteScale { get; set; } = 1.5f;
public bool EnableMentionNotifications { get; set; } = true;
public bool AutoOpenChatOnNewMessage { get; set; } = false;
public float ChatWindowOpacity { get; set; } = .97f;
public bool FadeWhenUnfocused { get; set; } = false;
public float UnfocusedWindowOpacity { get; set; } = 0.6f;
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 bool PersistSyncshellHistory { get; set; } = false;
public List<string> ChannelOrder { get; set; } = new();
public Dictionary<string, bool> HiddenChannels { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, string> SyncshellChannelHistory { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, bool> PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal);
}

View File

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

View File

@@ -2,7 +2,9 @@ using Dalamud.Game.Text;
using LightlessSync.UtilsEnum.Enum;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.UI;
using LightlessSync.UI.Models;
using Microsoft.Extensions.Logging;
using LightlessSync.PlayerData.Factories;
namespace LightlessSync.LightlessConfiguration.Configurations;
@@ -22,7 +24,16 @@ public class LightlessConfig : ILightlessConfiguration
public DtrEntry.Colors DtrColorsDefault { get; set; } = default;
public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu);
public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u);
public bool ShowLightfinderInDtr { get; set; } = false;
public bool UseLightfinderColorsInDtr { get; set; } = true;
public DtrEntry.Colors DtrColorsLightfinderEnabled { get; set; } = new(Foreground: 0xB590FFu, Glow: 0x4F406Eu);
public DtrEntry.Colors DtrColorsLightfinderDisabled { get; set; } = new(Foreground: 0xD44444u, Glow: 0x642222u);
public DtrEntry.Colors DtrColorsLightfinderCooldown { get; set; } = new(Foreground: 0xFFE97Au, Glow: 0x766C3Au);
public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u);
public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests;
public bool UseLightlessRedesign { get; set; } = true;
public bool ShowUiWhenUiHidden { get; set; } = true;
public bool ShowUiInGpose { get; set; } = true;
public bool EnableRightClickMenus { get; set; } = true;
public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both;
public string ExportFolder { get; set; } = string.Empty;
@@ -41,6 +52,9 @@ public class LightlessConfig : ILightlessConfiguration
public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
public bool PreferNotesOverNamesForVisible { get; set; } = false;
public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical;
public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical;
public TextureFormatSortMode TextureFormatSortMode { get; set; } = TextureFormatSortMode.None;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;
@@ -54,11 +68,16 @@ public class LightlessConfig : ILightlessConfiguration
public bool ShowOnlineNotifications { get; set; } = false;
public bool ShowOnlineNotificationsOnlyForIndividualPairs { get; set; } = true;
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
public bool ShowVisiblePairsGreenEye { get; set; } = false;
public bool ShowTransferBars { get; set; } = true;
public bool ShowTransferWindow { get; set; } = false;
public bool ShowPlayerLinesTransferWindow { get; set; } = true;
public bool ShowPlayerSpeedBarsTransferWindow { get; set; } = true;
public bool UseNotificationsForDownloads { get; set; } = true;
public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableDirectDownloads { get; set; } = true;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true;
@@ -69,17 +88,69 @@ public class LightlessConfig : ILightlessConfiguration
public bool AutoPopulateEmptyNotesFromCharaName { get; set; } = false;
public int Version { get; set; } = 1;
public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both;
// Lightless Notification Configuration
public bool UseLightlessNotifications { get; set; } = true;
public bool ShowNotificationProgress { get; set; } = true;
public NotificationLocation LightlessInfoNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessWarningNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessErrorNotification { get; set; } = NotificationLocation.ChatAndLightlessUi;
public NotificationLocation LightlessPairRequestNotification { get; set; } = NotificationLocation.LightlessUi;
public NotificationLocation LightlessDownloadNotification { get; set; } = NotificationLocation.TextOverlay;
public NotificationLocation LightlessPerformanceNotification { get; set; } = NotificationLocation.LightlessUi;
// Basic Settings
public float NotificationOpacity { get; set; } = 0.95f;
public int MaxSimultaneousNotifications { get; set; } = 5;
public bool AutoDismissOnAction { get; set; } = true;
public bool DismissNotificationOnClick { get; set; } = false;
public bool ShowNotificationTimestamp { get; set; } = false;
// Position & Layout
public NotificationCorner NotificationCorner { get; set; } = NotificationCorner.Right;
public int NotificationOffsetY { get; set; } = 50;
public int NotificationOffsetX { get; set; } = 0;
public float NotificationWidth { get; set; } = 350f;
public float NotificationSpacing { get; set; } = 8f;
// Animation & Effects
public float NotificationAnimationSpeed { get; set; } = 10f;
public float NotificationSlideSpeed { get; set; } = 10f;
public float NotificationAccentBarWidth { get; set; } = 3f;
// Duration per Type
public int InfoNotificationDurationSeconds { get; set; } = 10;
public int WarningNotificationDurationSeconds { get; set; } = 15;
public int ErrorNotificationDurationSeconds { get; set; } = 20;
public int PairRequestDurationSeconds { get; set; } = 180;
public int DownloadNotificationDurationSeconds { get; set; } = 30;
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
public uint CustomInfoSoundId { get; set; } = 2; // Se2
public uint CustomWarningSoundId { get; set; } = 16; // Se15
public uint CustomErrorSoundId { get; set; } = 16; // Se15
public uint PairRequestSoundId { get; set; } = 5; // Se5
public uint PerformanceSoundId { get; set; } = 16; // Se15
public bool DisableInfoSound { get; set; } = true;
public bool DisableWarningSound { get; set; } = true;
public bool DisableErrorSound { get; set; } = true;
public bool DisablePairRequestSound { get; set; } = true;
public bool DisablePerformanceSound { get; set; } = true;
public bool ShowPerformanceNotificationActions { get; set; } = true;
public bool ShowPairRequestNotificationActions { get; set; } = true;
public bool UseFocusTarget { get; set; } = false;
public bool overrideFriendColor { get; set; } = false;
public bool overridePartyColor { get; set; } = false;
public bool overrideFcTagColor { get; set; } = false;
public bool useColoredUIDs { get; set; } = true;
public bool BroadcastEnabled { get; set; } = false;
public bool LightfinderAutoEnableOnConnect { get; set; } = false;
public LightfinderLabelRenderer LightfinderLabelRenderer { get; set; } = LightfinderLabelRenderer.Pictomancy;
public short LightfinderLabelOffsetX { get; set; } = 0;
public short LightfinderLabelOffsetY { get; set; } = 0;
public bool LightfinderLabelUseIcon { get; set; } = false;
public bool LightfinderLabelShowOwn { get; set; } = true;
public bool LightfinderLabelShowPaired { get; set; } = true;
public bool LightfinderLabelShowHidden { get; set; } = false;
public string LightfinderLabelIconGlyph { get; set; } = SeIconCharExtensions.ToIconString(SeIconChar.Hyadelyn);
public float LightfinderLabelScale { get; set; } = 1.0f;
public bool LightfinderAutoAlign { get; set; } = true;
@@ -87,4 +158,9 @@ public class LightlessConfig : ILightlessConfiguration
public DateTime BroadcastTtl { get; set; } = DateTime.MinValue;
public bool SyncshellFinderEnabled { get; set; } = false;
public string? SelectedFinderSyncshell { get; set; } = null;
public string LastSeenVersion { get; set; } = string.Empty;
public bool EnableParticleEffects { get; set; } = true;
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe;
public bool AnimationAllowOneBasedShift { get; set; } = false;
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
}

View File

@@ -0,0 +1,156 @@
namespace LightlessSync.LightlessConfiguration.Configurations;
public static class ModelDecimationDefaults
{
public const bool EnableAutoDecimation = false;
public const int TriangleThreshold = 15_000;
public const double TargetRatio = 0.8;
public const bool NormalizeTangents = true;
public const bool AvoidBodyIntersection = true;
/// <summary>Default triangle threshold for batch decimation (0 = no threshold).</summary>
public const int BatchTriangleThreshold = 0;
/// <summary>Default target triangle ratio for batch decimation.</summary>
public const double BatchTargetRatio = 0.8;
/// <summary>Default tangent normalization toggle for batch decimation.</summary>
public const bool BatchNormalizeTangents = true;
/// <summary>Default body collision guard toggle for batch decimation.</summary>
public const bool BatchAvoidBodyIntersection = true;
/// <summary>Default display for the batch decimation warning overlay.</summary>
public const bool ShowBatchDecimationWarning = true;
public const bool KeepOriginalModelFiles = true;
public const bool SkipPreferredPairs = true;
public const bool AllowBody = false;
public const bool AllowFaceHead = false;
public const bool AllowTail = false;
public const bool AllowClothing = true;
public const bool AllowAccessories = true;
}
public sealed class ModelDecimationAdvancedSettings
{
/// <summary>Minimum triangles per connected component before skipping decimation.</summary>
public const int DefaultMinComponentTriangles = 6;
/// <summary>Average-edge multiplier used to cap collapses.</summary>
public const float DefaultMaxCollapseEdgeLengthFactor = 1.25f;
/// <summary>Maximum normal deviation (degrees) allowed for a collapse.</summary>
public const float DefaultNormalSimilarityThresholdDegrees = 60f;
/// <summary>Minimum bone-weight overlap required to allow a collapse.</summary>
public const float DefaultBoneWeightSimilarityThreshold = 0.85f;
/// <summary>UV similarity threshold to protect seams.</summary>
public const float DefaultUvSimilarityThreshold = 0.02f;
/// <summary>UV seam cosine threshold for blocking seam collapses.</summary>
public const float DefaultUvSeamAngleCos = 0.99f;
/// <summary>Whether to block UV seam vertices from collapsing.</summary>
public const bool DefaultBlockUvSeamVertices = true;
/// <summary>Whether to allow collapses on boundary edges.</summary>
public const bool DefaultAllowBoundaryCollapses = false;
/// <summary>Body collision distance factor for the primary pass.</summary>
public const float DefaultBodyCollisionDistanceFactor = 0.75f;
/// <summary>Body collision distance factor for the relaxed fallback pass.</summary>
public const float DefaultBodyCollisionNoOpDistanceFactor = 0.25f;
/// <summary>Relax multiplier applied when the mesh is close to the body.</summary>
public const float DefaultBodyCollisionAdaptiveRelaxFactor = 1.0f;
/// <summary>Ratio of near-body vertices required to trigger relaxation.</summary>
public const float DefaultBodyCollisionAdaptiveNearRatio = 0.4f;
/// <summary>UV threshold for relaxed body-collision mode.</summary>
public const float DefaultBodyCollisionAdaptiveUvThreshold = 0.08f;
/// <summary>UV seam cosine threshold for relaxed body-collision mode.</summary>
public const float DefaultBodyCollisionNoOpUvSeamAngleCos = 0.98f;
/// <summary>Expansion factor for protected vertices near the body.</summary>
public const float DefaultBodyCollisionProtectionFactor = 1.5f;
/// <summary>Minimum ratio used when decimating the body proxy.</summary>
public const float DefaultBodyProxyTargetRatioMin = 0.85f;
/// <summary>Inflation applied to body collision distances.</summary>
public const float DefaultBodyCollisionProxyInflate = 0.0005f;
/// <summary>Body collision penetration factor used during collapse checks.</summary>
public const float DefaultBodyCollisionPenetrationFactor = 0.75f;
/// <summary>Minimum body collision distance threshold.</summary>
public const float DefaultMinBodyCollisionDistance = 0.0001f;
/// <summary>Minimum cell size for body collision spatial hashing.</summary>
public const float DefaultMinBodyCollisionCellSize = 0.0001f;
/// <summary>Minimum triangles per connected component before skipping decimation.</summary>
public int MinComponentTriangles { get; set; } = DefaultMinComponentTriangles;
/// <summary>Average-edge multiplier used to cap collapses.</summary>
public float MaxCollapseEdgeLengthFactor { get; set; } = DefaultMaxCollapseEdgeLengthFactor;
/// <summary>Maximum normal deviation (degrees) allowed for a collapse.</summary>
public float NormalSimilarityThresholdDegrees { get; set; } = DefaultNormalSimilarityThresholdDegrees;
/// <summary>Minimum bone-weight overlap required to allow a collapse.</summary>
public float BoneWeightSimilarityThreshold { get; set; } = DefaultBoneWeightSimilarityThreshold;
/// <summary>UV similarity threshold to protect seams.</summary>
public float UvSimilarityThreshold { get; set; } = DefaultUvSimilarityThreshold;
/// <summary>UV seam cosine threshold for blocking seam collapses.</summary>
public float UvSeamAngleCos { get; set; } = DefaultUvSeamAngleCos;
/// <summary>Whether to block UV seam vertices from collapsing.</summary>
public bool BlockUvSeamVertices { get; set; } = DefaultBlockUvSeamVertices;
/// <summary>Whether to allow collapses on boundary edges.</summary>
public bool AllowBoundaryCollapses { get; set; } = DefaultAllowBoundaryCollapses;
/// <summary>Body collision distance factor for the primary pass.</summary>
public float BodyCollisionDistanceFactor { get; set; } = DefaultBodyCollisionDistanceFactor;
/// <summary>Body collision distance factor for the relaxed fallback pass.</summary>
public float BodyCollisionNoOpDistanceFactor { get; set; } = DefaultBodyCollisionNoOpDistanceFactor;
/// <summary>Relax multiplier applied when the mesh is close to the body.</summary>
public float BodyCollisionAdaptiveRelaxFactor { get; set; } = DefaultBodyCollisionAdaptiveRelaxFactor;
/// <summary>Ratio of near-body vertices required to trigger relaxation.</summary>
public float BodyCollisionAdaptiveNearRatio { get; set; } = DefaultBodyCollisionAdaptiveNearRatio;
/// <summary>UV threshold for relaxed body-collision mode.</summary>
public float BodyCollisionAdaptiveUvThreshold { get; set; } = DefaultBodyCollisionAdaptiveUvThreshold;
/// <summary>UV seam cosine threshold for relaxed body-collision mode.</summary>
public float BodyCollisionNoOpUvSeamAngleCos { get; set; } = DefaultBodyCollisionNoOpUvSeamAngleCos;
/// <summary>Expansion factor for protected vertices near the body.</summary>
public float BodyCollisionProtectionFactor { get; set; } = DefaultBodyCollisionProtectionFactor;
/// <summary>Minimum ratio used when decimating the body proxy.</summary>
public float BodyProxyTargetRatioMin { get; set; } = DefaultBodyProxyTargetRatioMin;
/// <summary>Inflation applied to body collision distances.</summary>
public float BodyCollisionProxyInflate { get; set; } = DefaultBodyCollisionProxyInflate;
/// <summary>Body collision penetration factor used during collapse checks.</summary>
public float BodyCollisionPenetrationFactor { get; set; } = DefaultBodyCollisionPenetrationFactor;
/// <summary>Minimum body collision distance threshold.</summary>
public float MinBodyCollisionDistance { get; set; } = DefaultMinBodyCollisionDistance;
/// <summary>Minimum cell size for body collision spatial hashing.</summary>
public float MinBodyCollisionCellSize { get; set; } = DefaultMinBodyCollisionCellSize;
}

View File

@@ -4,6 +4,7 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
{
public int Version { get; set; } = 1;
public bool ShowPerformanceIndicator { get; set; } = true;
public bool ShowPerformanceUsageNextToName { get; set; } = false;
public bool WarnOnExceedingThresholds { get; set; } = true;
public bool WarnOnPreferredPermissionsExceedingThresholds { get; set; } = false;
public int VRAMSizeWarningThresholdMiB { get; set; } = 375;
@@ -16,4 +17,30 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public bool PauseInInstanceDuty { get; set; } = false;
public bool PauseWhilePerforming { get; set; } = true;
public bool PauseInCombat { get; set; } = true;
public bool EnableNonIndexTextureMipTrim { get; set; } = false;
public bool EnableIndexTextureDownscale { get; set; } = false;
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool EnableUncompressedTextureCompression { get; set; } = false;
public bool SkipUncompressedTextureCompressionMipMaps { get; set; } = false;
public bool KeepOriginalTextureFiles { get; set; } = false;
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
public bool EnableModelDecimation { get; set; } = ModelDecimationDefaults.EnableAutoDecimation;
public int ModelDecimationTriangleThreshold { get; set; } = ModelDecimationDefaults.TriangleThreshold;
public double ModelDecimationTargetRatio { get; set; } = ModelDecimationDefaults.TargetRatio;
public bool ModelDecimationNormalizeTangents { get; set; } = ModelDecimationDefaults.NormalizeTangents;
public bool ModelDecimationAvoidBodyIntersection { get; set; } = ModelDecimationDefaults.AvoidBodyIntersection;
public ModelDecimationAdvancedSettings ModelDecimationAdvanced { get; set; } = new();
public int BatchModelDecimationTriangleThreshold { get; set; } = ModelDecimationDefaults.BatchTriangleThreshold;
public double BatchModelDecimationTargetRatio { get; set; } = ModelDecimationDefaults.BatchTargetRatio;
public bool BatchModelDecimationNormalizeTangents { get; set; } = ModelDecimationDefaults.BatchNormalizeTangents;
public bool BatchModelDecimationAvoidBodyIntersection { get; set; } = ModelDecimationDefaults.BatchAvoidBodyIntersection;
public bool ShowBatchModelDecimationWarning { get; set; } = ModelDecimationDefaults.ShowBatchDecimationWarning;
public bool KeepOriginalModelFiles { get; set; } = ModelDecimationDefaults.KeepOriginalModelFiles;
public bool SkipModelDecimationForPreferredPairs { get; set; } = ModelDecimationDefaults.SkipPreferredPairs;
public bool ModelDecimationAllowBody { get; set; } = ModelDecimationDefaults.AllowBody;
public bool ModelDecimationAllowFaceHead { get; set; } = ModelDecimationDefaults.AllowFaceHead;
public bool ModelDecimationAllowTail { get; set; } = ModelDecimationDefaults.AllowTail;
public bool ModelDecimationAllowClothing { get; set; } = ModelDecimationDefaults.AllowClothing;
public bool ModelDecimationAllowAccessories { get; set; } = ModelDecimationDefaults.AllowAccessories;
}

View File

@@ -0,0 +1,10 @@
using LightlessSync.LightlessConfiguration.Models;
namespace LightlessSync.LightlessConfiguration.Configurations;
[Serializable]
public sealed class TempCollectionConfig : ILightlessConfiguration
{
public int Version { get; set; } = 1;
public List<OrphanableTempCollectionEntry> OrphanableTempCollectionEntries { get; set; } = [];
}

View File

@@ -5,7 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
public class TransientConfig : ILightlessConfiguration
{
public Dictionary<string, TransientPlayerConfig> TransientConfigs { get; set; } = [];
public int Version { get; set; } = 1;
public int Version { get; set; } = 2;
public class TransientPlayerConfig
{
@@ -13,6 +13,8 @@ public class TransientConfig : ILightlessConfiguration
public Dictionary<uint, List<string>> JobSpecificCache { get; set; } = [];
public Dictionary<uint, List<string>> JobSpecificPetCache { get; set; } = [];
private readonly object _cacheLock = new();
public TransientPlayerConfig()
{
@@ -38,6 +40,8 @@ public class TransientConfig : ILightlessConfiguration
}
public int RemovePath(string gamePath, ObjectKind objectKind)
{
lock (_cacheLock)
{
int removedEntries = 0;
if (objectKind == ObjectKind.Player)
@@ -57,8 +61,11 @@ public class TransientConfig : ILightlessConfiguration
}
return removedEntries;
}
}
public void AddOrElevate(uint jobId, string gamePath)
{
lock (_cacheLock)
{
// check if it's in the global cache, if yes, do nothing
if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal))
@@ -81,4 +88,70 @@ public class TransientConfig : ILightlessConfiguration
}
}
}
public bool NormalizePaths(out int removedEntries)
{
bool changed = false;
removedEntries = 0;
GlobalPersistentCache = NormalizeList(GlobalPersistentCache, ref changed, ref removedEntries);
foreach (var jobId in JobSpecificCache.Keys.ToList())
{
JobSpecificCache[jobId] = NormalizeList(JobSpecificCache[jobId], ref changed, ref removedEntries);
}
foreach (var jobId in JobSpecificPetCache.Keys.ToList())
{
JobSpecificPetCache[jobId] = NormalizeList(JobSpecificPetCache[jobId], ref changed, ref removedEntries);
}
return changed;
}
private static List<string> NormalizeList(List<string> entries, ref bool changed, ref int removedEntries)
{
if (entries.Count == 0)
return entries;
var result = new List<string>(entries.Count);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
var normalized = NormalizePath(entry);
if (string.IsNullOrEmpty(normalized))
{
changed = true;
continue;
}
if (!string.Equals(entry, normalized, StringComparison.Ordinal))
{
changed = true;
}
if (seen.Add(normalized))
{
result.Add(normalized);
}
else
{
changed = true;
}
}
removedEntries += entries.Count - result.Count;
return result;
}
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
return string.Empty;
return path.Replace("\\", "/", StringComparison.Ordinal).ToLowerInvariant();
}
}
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ namespace LightlessSync.LightlessConfiguration.Configurations;
public class XivDataStorageConfig : ILightlessConfiguration
{
public ConcurrentDictionary<string, long> TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, long> EffectiveTriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Dictionary<string, List<ushort>>> BonesDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace LightlessSync.LightlessConfiguration.Models;
public sealed class OrphanableTempCollectionEntry
{
public Guid Id { get; set; }
public DateTime RegisteredAtUtc { get; set; } = DateTime.MinValue;
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ using LightlessSync.UI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using System.Reflection;
namespace LightlessSync;
@@ -73,6 +74,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
private readonly DalamudUtilService _dalamudUtil;
private readonly LightlessConfigService _lightlessConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly PairHandlerRegistry _pairHandlerRegistry;
private readonly IServiceScopeFactory _serviceScopeFactory;
private IServiceScope? _runtimeServiceScope;
private Task? _launchTask = null;
@@ -80,11 +82,13 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
public LightlessPlugin(ILogger<LightlessPlugin> logger, LightlessConfigService lightlessConfigService,
ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtil,
PairHandlerRegistry pairHandlerRegistry,
IServiceScopeFactory serviceScopeFactory, LightlessMediator mediator) : base(logger, mediator)
{
_lightlessConfigService = lightlessConfigService;
_serverConfigurationManager = serverConfigurationManager;
_dalamudUtil = dalamudUtil;
_pairHandlerRegistry = pairHandlerRegistry;
_serviceScopeFactory = serviceScopeFactory;
}
@@ -107,15 +111,41 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
Logger.LogDebug("Halting LightlessPlugin");
try
{
_pairHandlerRegistry.ResetAllHandlers();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to reset pair handlers on shutdown");
}
UnsubscribeAll();
DalamudUtilOnLogOut();
Logger.LogDebug("Halting LightlessPlugin");
return Task.CompletedTask;
}
private void CheckVersion()
{
var ver = Assembly.GetExecutingAssembly().GetName().Version;
var currentVersion = ver == null ? string.Empty : $"{ver.Major}.{ver.Minor}.{ver.Build}";
var lastSeen = _lightlessConfigService.Current.LastSeenVersion ?? string.Empty;
Logger.LogInformation("Last seen version: {lastSeen}, current version: {currentVersion}", lastSeen, currentVersion);
Logger.LogInformation("User has valid setup: {hasValidSetup}", _lightlessConfigService.Current.HasValidSetup());
Logger.LogInformation("Server has valid config: {hasValidConfig}", _serverConfigurationManager.HasValidConfig());
// Show update notes if version has changed and user has valid setup
if (!string.Equals(lastSeen, currentVersion, StringComparison.Ordinal) &&
_lightlessConfigService.Current.HasValidSetup() &&
_serverConfigurationManager.HasValidConfig())
{
Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)));
}
}
private void DalamudUtilOnLogIn()
{
Logger?.LogDebug("Client login");
@@ -154,6 +184,7 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
_runtimeServiceScope.ServiceProvider.GetRequiredService<VisibleUserDataDistributor>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NameplateService>();
CheckVersion();
#if !DEBUG
if (_lightlessConfigService.Current.LogLevel != LogLevel.Information)

View File

@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/13.1.0">
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/14.0.1">
<PropertyGroup>
<Authors></Authors>
<Company></Company>
<Version>1.12.1</Version>
<Version>2.0.2.80</Version>
<Description></Description>
<Copyright></Copyright>
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net9.0-windows7.0</TargetFramework>
<TargetFramework>net10.0-windows7.0</TargetFramework>
<Platforms>x64</Platforms>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
@@ -24,28 +24,39 @@
<Compile Remove="PlayerData\Export\**" />
<EmbeddedResource Remove="PlayerData\Export\**" />
<None Remove="PlayerData\Export\**" />
<EmbeddedResource Update="Resources\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<Compile Update="Resources\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="13.0.0" />
<PackageReference Include="Blake3" Version="2.0.0" />
<PackageReference Include="Brio.API" Version="3.0.1" />
<PackageReference Include="Downloader" Version="4.0.3" />
<PackageReference Include="K4os.Compression.LZ4.Legacy" Version="1.3.8" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.212">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.264">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.3" />
<PackageReference Include="Glamourer.Api" Version="2.6.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.2" />
<PackageReference Include="Penumbra.String" Version="1.0.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.7.0.110445">
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Glamourer.Api" Version="2.8.0" />
<PackageReference Include="NReco.Logging.File" Version="1.3.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.17.0.131074">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<PropertyGroup>
@@ -64,8 +75,8 @@
<None Update="images\icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="Localization\de.json" />
<EmbeddedResource Include="Localization\fr.json" />
<EmbeddedResource Include="Changelog\changelog.yaml" />
<EmbeddedResource Include="Changelog\credits.yaml" />
</ItemGroup>
<ItemGroup>
@@ -74,7 +85,38 @@
<ItemGroup>
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
<ProjectReference Include="..\PenumbraAPI\Penumbra.Api.csproj" />
<ProjectReference Include="..\LightlessCompactor\LightlessCompactor.csproj" />
<ProjectReference Include="..\LightlessCompactorWorker\LightlessCompactorWorker.csproj" ReferenceOutputAssembly="false" />
<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>
<CompactorWorkerFiles Include="..\LightlessCompactorWorker\bin\$(Configuration)\net10.0\*.*" />
</ItemGroup>
<Target Name="CopyCompactorWorker" AfterTargets="Build">
<Copy SourceFiles="@(CompactorWorkerFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeEditing/Localization/Localizable/@EntryValue">Yes</s:String>
<s:String x:Key="/Default/CodeEditing/Localization/LocalizableInspector/@EntryValue">Pessimistic</s:String></wpf:ResourceDictionary>

View File

@@ -1,44 +0,0 @@
using CheapLoc;
namespace LightlessSync.Localization;
public static class Strings
{
public static ToSStrings ToS { get; set; } = new();
public class ToSStrings
{
public readonly string AgreeLabel = Loc.Localize("AgreeLabel", "I agree");
public readonly string AgreementLabel = Loc.Localize("AgreementLabel", "Agreement of Usage of Service");
public readonly string ButtonWillBeAvailableIn = Loc.Localize("ButtonWillBeAvailableIn", "'I agree' button will be available in");
public readonly string LanguageLabel = Loc.Localize("LanguageLabel", "Language");
public readonly string Paragraph1 = Loc.Localize("Paragraph1",
"All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. " +
"The plugin will exclusively upload the necessary mod files and not the whole mod.");
public readonly string Paragraph2 = Loc.Localize("Paragraph2",
"If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. " +
"Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. " +
"Files present on the service that already represent your active mod files will not be uploaded again.");
public readonly string Paragraph3 = Loc.Localize("Paragraph3",
"The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. " +
"Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. " +
"Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.");
public readonly string Paragraph4 = Loc.Localize("Paragraph4",
"The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.");
public readonly string Paragraph5 = Loc.Localize("Paragraph5",
"Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. " +
"After a period of not being used, the mod files will be automatically deleted. " +
"You will also be able to wipe all the files you have personally uploaded on request. " +
"The service holds no information about which mod files belong to which mod.");
public readonly string Paragraph6 = Loc.Localize("Paragraph6",
"This service is provided as-is. In case of abuse join the Lightless Sync Discord.");
public readonly string ReadLabel = Loc.Localize("ReadLabel", "READ THIS CAREFULLY");
}
}

View File

@@ -1,46 +0,0 @@
{
"LanguageLabel": {
"message": "Language",
"description": "ToSStrings..ctor"
},
"AgreementLabel": {
"message": "Nutzungsbedingungen",
"description": "ToSStrings..ctor"
},
"ReadLabel": {
"message": "BITTE LIES DIES SORGFÄLTIG",
"description": "ToSStrings..ctor"
},
"Paragraph1": {
"message": "Alle Moddateien, die aktuell auf deinem Charakter aktiv sind und dein Charakterzustand werden automatisch zu dem Service, an dem du dich registriert hast, hochgeladen. Das Plugin wird ausschließlich die nötigen Moddateien hochladen und nicht die gesamte Modifikation.",
"description": "ToSStrings..ctor"
},
"Paragraph2": {
"message": "Falls du mit einer getakteten Internetverbindung verbunden bist, können durch den Datentransfer von Hoch- und Runtergeladenen Moddateien höhere Kosten entstehen. Moddateien werden beim Hoch- und Runterladen komprimiert um Bandbreite zu sparen. Durch unterschiedliche Hoch- und Runterladgeschwindigkeiten ist es möglich, dass Änderungen an Charakteren nicht sofort sichtbar sind. Dateien die bereits auf dem Service existieren, werden nicht nochmals hochgeladen.",
"description": "ToSStrings..ctor"
},
"Paragraph3": {
"message": "Die Moddateien die du hochlädst sind vertraulich und werden nicht mit anderen Nutzern geteilt, die nicht die exakt selben Dateien anfordern. Bitte überlege dir sorgfältig mit wem du deinen Identifikationscode teilst, da es unvermeidlich ist, dass die andere Person deine Moddateien erhält und lokal zwischenspeichert. Lokal zwischengespeicherte Dateien haben willkürrliche Namen um vor Versuchen abzuschrecken die originalen Moddateien aus diesen wiederherzustellen.",
"description": "ToSStrings..ctor"
},
"Paragraph4": {
"message": "Der Ersteller des Plugins hat sein Bestes getan, um deine Sicherheit zu gewährleisten. Es gibt jedoch keine Garantie für 100%ige Sicherheit. Teile deinen Identifikationscode nicht blind mit jedem.",
"description": "ToSStrings..ctor"
},
"Paragraph5": {
"message": "Moddateien, die auf dem Service gespeichert sind, verbleiben auf dem Service, solange es Anforderungen für diese Dateien gibt. Nach einer Zeitspanne in der die Dateien nicht verwendet wurden, werden diese automatisch gelöscht. Du hast auch die Möglichkeit manuell alle Dateien auf dem Service zu löschen. Der Service hat keine Informationen welche Moddateien zu welcher Modifikation gehören.",
"description": "ToSStrings..ctor"
},
"Paragraph6": {
"message": "Dieser Dienst wird ohne Gewähr angeboten. Im Falle eines Missbrauchs tretet dem Lightless Sync Discord bei.",
"description": "ToSStrings..ctor"
},
"AgreeLabel": {
"message": "Ich Stimme zu",
"description": "ToSStrings..ctor"
},
"ButtonWillBeAvailableIn": {
"message": "\"Ich stimme zu\" Knopf verfügbar in",
"description": "ToSStrings..ctor"
}
}

View File

@@ -1,46 +0,0 @@
{
"LanguageLabel": {
"message": "Language",
"description": "ToSStrings..ctor"
},
"AgreementLabel": {
"message": "Conditions d'Utilisation",
"description": "ToSStrings..ctor"
},
"ReadLabel": {
"message": "LISEZ CES INFORMATIONS ATTENTIVEMENT",
"description": "ToSStrings..ctor"
},
"Paragraph1": {
"message": "Tous les fichiers moddés actuellement en cours d'utilisation ainsi que le statut actuel de votre personnage vont être mix en ligne via le service sur lequel vous vous êtes automatiquement enregistré. Seuls les fichiers nécessaires seront téléversés par le plugin et non pas le mod en entier.",
"description": "ToSStrings..ctor"
},
"Paragraph2": {
"message": "Si le débit de votre connexion internet est limité, le téléchargement et téléversement d'un grand nombre de fichiers peut entraîner des coûts supplémentaires. Les fichiers seront compressés au chargement et versement pour réduire l'impact sur votre bande passants. Selon la rapidité de vos téléchargements et téléversements, les changements ne seront peut-être pas visibles instantanément sur les personnages. Les fichiers déja présents sur le service qui correspondent à ceux de vos mods en cours d'utilisation ne seront pas remis en ligne.",
"description": "ToSStrings..ctor"
},
"Paragraph3": {
"message": "Les fichiers que vous allez partager sont confidentiels et ne seront envoyés qu'aux utilisateurs qui feront une requête exacte de ceux-çi. Nous vous demandons de (re)considérer qui sera synchronisé avec vous, puisqu'ils recevront et stockeront inévitablement en local les fichiers nécéssaires utilisés à cet instant. Les noms des fichiers stockés localement sont changés de manière arbitraire afin de décourager toute tentative de réplication des originaux.",
"description": "ToSStrings..ctor"
},
"Paragraph4": {
"message": "Le créateur de ce plugin a tenté de sécuriser l'application du mieux possible. Cependant, il ne peut pas garantir une protection 100% infaillible. Pour votre sécurité, ne vous synchronisez pas aveuglément et avec n'importe qui.",
"description": "ToSStrings..ctor"
},
"Paragraph5": {
"message": "Les fichiers sauvegardés sur le service resteront en ligne tant que des utilisateurs en feront usage. Ils seront effacés automatiquement après une certaine période d'inactivité. Vous pouvez également demander l'effacement de tous les fichiers que vous avez mis en ligne vous-même. Le service en soi ne contient aucune information pouvant identifier quel fichier appartient à quel mod.",
"description": "ToSStrings..ctor"
},
"Paragraph6": {
"message": "Ce service et ses composants vous sont fournis en l'état. En cas d'abus rejoindre le serveur Discord Lightless Sync.",
"description": "ToSStrings..ctor"
},
"AgreeLabel": {
"message": "J'accept",
"description": "ToSStrings..ctor"
},
"ButtonWillBeAvailableIn": {
"message": "Bouton \"J'accept\" disposible dans",
"description": "ToSStrings..ctor"
}
}

View File

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

View File

@@ -0,0 +1,9 @@
namespace LightlessSync.PlayerData.Factories
{
public enum AnimationValidationMode
{
Unsafe = 0,
Safe = 1,
Safest = 2,
}
}

View File

@@ -1,5 +1,8 @@
using LightlessSync.FileCache;
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.WebAPI.Files;
using Microsoft.Extensions.Logging;
@@ -7,24 +10,53 @@ namespace LightlessSync.PlayerData.Factories;
public class FileDownloadManagerFactory
{
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly ILoggerFactory _loggerFactory;
private readonly LightlessMediator _lightlessMediator;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly LightlessConfigService _configService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureMetadataHelper _textureMetadataHelper;
private readonly FileDownloadDeduplicator _downloadDeduplicator;
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
public FileDownloadManagerFactory(
ILoggerFactory loggerFactory,
LightlessMediator lightlessMediator,
FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager,
FileCompactor fileCompactor,
LightlessConfigService configService,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
TextureMetadataHelper textureMetadataHelper,
FileDownloadDeduplicator downloadDeduplicator)
{
_loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator;
_fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor;
_configService = configService;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_textureMetadataHelper = textureMetadataHelper;
_downloadDeduplicator = downloadDeduplicator;
}
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,
_modelDecimationService,
_textureMetadataHelper,
_downloadDeduplicator);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,19 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using LightlessSync.API.Data.Enum;
using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Data;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.Mediator;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Factories;
@@ -18,13 +24,34 @@ public class PlayerDataFactory
private readonly IpcManager _ipcManager;
private readonly ILogger<PlayerDataFactory> _logger;
private readonly PerformanceCollectorService _performanceCollector;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly LightlessMediator _lightlessMediator;
private readonly TransientResourceManager _transientResourceManager;
private static readonly SemaphoreSlim _papParseLimiter = new(1, 1);
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator)
// Transient resolved entries threshold
private const int _maxTransientResolvedEntries = 1000;
// Character build caches
private readonly TaskRegistry<nint> _characterBuildInflight = new();
private readonly ConcurrentDictionary<nint, CacheEntry> _characterBuildCache = new();
// Time out thresholds
private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30);
public PlayerDataFactory(
ILogger<PlayerDataFactory> logger,
DalamudUtilService dalamudUtil,
IpcManager ipcManager,
TransientResourceManager transientResourceManager,
FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector,
XivDataAnalyzer modelAnalyzer,
LightlessMediator lightlessMediator,
LightlessConfigService configService)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
@@ -34,15 +61,15 @@ public class PlayerDataFactory
_performanceCollector = performanceCollector;
_modelAnalyzer = modelAnalyzer;
_lightlessMediator = lightlessMediator;
_configService = configService;
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
}
private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc);
public async Task<CharacterDataFragment?> BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token)
{
if (!_ipcManager.Initialized)
{
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
}
if (playerRelatedObject == null) return null;
@@ -67,16 +94,17 @@ public class PlayerDataFactory
if (pointerIsZero)
{
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
_logger.LogTrace("Pointer was zero for {objectKind}; couldn't build character", playerRelatedObject.ObjectKind);
return null;
}
try
{
return await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
{
return await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false);
}).ConfigureAwait(true);
return await _performanceCollector.LogPerformance(
this,
$"CreateCharacterData>{playerRelatedObject.ObjectKind}",
async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false)
).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -91,160 +119,330 @@ public class PlayerDataFactory
return null;
}
private static readonly int _drawObjectOffset =
(int)Marshal.OffsetOf<GameObject>(nameof(GameObject.DrawObject));
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
=> await _dalamudUtil.RunOnFrameworkThread(() =>
{
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
nint basePtr = playerPointer;
if (!LooksLikeUserPtr(basePtr))
return true;
nint drawObjAddr = basePtr + _drawObjectOffset;
if (!TryReadIntPtr(drawObjAddr, out var drawObj))
return true;
return drawObj == 0;
}).ConfigureAwait(false);
private static bool LooksLikeUserPtr(nint p)
{
if (p == 0) return false;
ulong u = (ulong)p;
if (u < 0x0000_0001_0000UL) return false;
if (u > 0x0000_7FFF_FFFF_FFFFUL) return false;
if ((u & 0x7UL) != 0) return false;
return true;
}
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
private static bool TryReadIntPtr(nint addr, out nint value)
{
return ((Character*)playerPointer)->GameObject.DrawObject == null;
value = 0;
if (!VirtualReadable(addr))
return false;
try
{
value = Marshal.ReadIntPtr(addr);
return true;
}
catch
{
return false;
}
}
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
private static bool VirtualReadable(nint addr)
{
if (VirtualQuery(addr, out var mbi, (nuint)Marshal.SizeOf<MEMORY_BASIC_INFORMATION>()) == 0)
return false;
const uint MEM_COMMIT = 0x1000;
const uint PAGE_NOACCESS = 0x01;
const uint PAGE_GUARD = 0x100;
if (mbi.State != MEM_COMMIT) return false;
if ((mbi.Protect & PAGE_GUARD) != 0) return false;
if (mbi.Protect == PAGE_NOACCESS) return false;
return true;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern nuint VirtualQuery(nint lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, nuint dwLength);
[StructLayout(LayoutKind.Sequential)]
private struct MEMORY_BASIC_INFORMATION
{
public nint BaseAddress;
public nint AllocationBase;
public uint AllocationProtect;
public nuint RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
private static bool IsCacheFresh(CacheEntry entry)
=> (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl;
private Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
=> CreateCharacterDataCoalesced(playerRelatedObject, ct);
private async Task<CharacterDataFragment> CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct)
{
var key = obj.Address;
if (_characterBuildCache.TryGetValue(key, out CacheEntry cached) && IsCacheFresh(cached) && !_characterBuildInflight.TryGetExisting(key, out _))
return cached.Fragment;
Task<CharacterDataFragment> buildTask = _characterBuildInflight.GetOrStart(key, () => BuildAndCacheAsync(obj, key));
if (_characterBuildCache.TryGetValue(key, out cached))
{
var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false);
if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5))
{
return cached.Fragment;
}
}
return await WithCancellation(buildTask, ct).ConfigureAwait(false);
}
private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
{
using var cts = new CancellationTokenSource(_hardBuildTimeout);
CharacterDataFragment fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
PruneCharacterCacheIfNeeded();
return fragment;
}
private void PruneCharacterCacheIfNeeded()
{
if (_characterBuildCache.Count < 2048) return;
var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10);
foreach (var kv in _characterBuildCache)
{
if (kv.Value.CreatedUtc < cutoff)
_characterBuildCache.TryRemove(kv.Key, out _);
}
}
private static async Task<T> WithCancellation<T>(Task<T> task, CancellationToken ct)
=> await task.WaitAsync(ct).ConfigureAwait(false);
private async Task<CharacterDataFragment> CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct)
{
var objectKind = playerRelatedObject.ObjectKind;
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
var logDebug = _logger.IsEnabled(LogLevel.Debug);
var sw = Stopwatch.StartNew();
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
// wait until chara is not drawing and present so nothing spontaneously explodes
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false);
int totalWaitTime = 10000;
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
{
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
totalWaitTime -= 50;
}
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices =
objectKind != ObjectKind.Player
? null
: await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
DateTime start = DateTime.UtcNow;
// penumbra call, it's currently broken
Dictionary<string, HashSet<string>>? resolvedPaths;
resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false));
if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data");
ct.ThrowIfCancellationRequested();
fragment.FileReplacements =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet();
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
ct.ThrowIfCancellationRequested();
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false);
// if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times
// or we get into redraw city for every change and nothing works properly
if (objectKind == ObjectKind.Pet)
{
foreach (var item in fragment.FileReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
if (_transientResourceManager.AddTransientResource(objectKind, item))
{
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
}
_logger.LogTrace("Clearing {count} Static Replacements for Pet", fragment.FileReplacements.Count);
fragment.FileReplacements.Clear();
}
ct.ThrowIfCancellationRequested();
_logger.LogDebug("Handling transient update for {obj}", playerRelatedObject);
// remove all potentially gathered paths from the transient resource manager that are resolved through static resolving
_transientResourceManager.ClearTransientPaths(objectKind, fragment.FileReplacements.SelectMany(c => c.GamePaths).ToList());
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct)
.ConfigureAwait(false);
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind);
var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
}
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
ct.ThrowIfCancellationRequested();
// make sure we only return data that actually has file replacements
fragment.FileReplacements = new HashSet<FileReplacement>(fragment.FileReplacements.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false))
throw new InvalidOperationException("DrawObject became null during build (actor despawned)");
// gather up data from ipc
Task<string> getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
Task<string> getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address);
Task<string?> getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address);
Task<string> getHonorificTitle = _ipcManager.Honorific.GetTitle();
Task<string?>? getMoodlesData = null;
Task<string>? getHeelsOffset = null;
Task<string>? getHonorificTitle = null;
if (objectKind == ObjectKind.Player)
{
getHeelsOffset = _ipcManager.Heels.GetOffsetAsync();
getHonorificTitle = _ipcManager.Honorific.GetTitle();
getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address);
}
Guid penumbraRequestId = Guid.Empty;
Stopwatch? penumbraSw = null;
if (logDebug)
{
penumbraRequestId = Guid.NewGuid();
penumbraSw = Stopwatch.StartNew();
_logger.LogDebug("Penumbra GetCharacterData start {id} for {obj}", penumbraRequestId, playerRelatedObject);
}
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false);
if (logDebug)
{
penumbraSw!.Stop();
_logger.LogDebug("Penumbra GetCharacterData done {id} in {elapsedMs}ms (count={count})",
penumbraRequestId,
penumbraSw.ElapsedMilliseconds,
resolvedPaths?.Count ?? -1);
}
if (resolvedPaths == null)
throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character");
ct.ThrowIfCancellationRequested();
var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct);
fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false);
if (logDebug)
{
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in fragment.FileReplacements
.Where(i => i.HasFileReplacement)
.OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
ct.ThrowIfCancellationRequested();
}
}
var staticReplacements = new HashSet<FileReplacement>(fragment.FileReplacements, FileReplacementComparer.Instance);
var transientTask = ResolveTransientReplacementsAsync(
playerRelatedObject,
objectKind,
staticReplacements,
waitRecordingTask,
ct);
fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
fragment.CustomizePlusScale = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale);
if (objectKind == ObjectKind.Player)
{
var playerFragment = (fragment as CharacterDataFragmentPlayer)!;
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant");
playerFragment!.HonorificData = await getHonorificTitle.ConfigureAwait(false);
playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData);
playerFragment!.HeelsData = await getHeelsOffset.ConfigureAwait(false);
playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData);
playerFragment!.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty;
_logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData);
playerFragment!.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData);
}
ct.ThrowIfCancellationRequested();
var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false);
if (clearedForPet != null)
fragment.FileReplacements.Clear();
if (logDebug)
{
_logger.LogDebug("== Transient Replacements ==");
foreach (var replacement in resolvedTransientPaths
.Select(c => new FileReplacement([.. c.Value], c.Key))
.OrderBy(f => f.ResolvedPath, StringComparer.Ordinal))
{
_logger.LogDebug("=> {repl}", replacement);
fragment.FileReplacements.Add(replacement);
}
}
else
{
foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)))
fragment.FileReplacements.Add(replacement);
}
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]);
fragment.FileReplacements = new HashSet<FileReplacement>(
fragment.FileReplacements
.Where(v => v.HasFileReplacement)
.OrderBy(v => v.ResolvedPath, StringComparer.Ordinal),
FileReplacementComparer.Instance);
ct.ThrowIfCancellationRequested();
var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray();
_logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length);
var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray());
await Task.Run(() =>
{
var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]);
foreach (var file in toCompute)
{
ct.ThrowIfCancellationRequested();
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
}, ct).ConfigureAwait(false);
var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
if (removed > 0)
{
_logger.LogDebug("Removed {amount} of invalid files", removed);
}
ct.ThrowIfCancellationRequested();
Dictionary<string, List<ushort>>? boneIndices = null;
var hasPapFiles = false;
if (objectKind == ObjectKind.Player)
{
hasPapFiles = fragment.FileReplacements.Any(f =>
!f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
if (hasPapFiles)
{
boneIndices = await _dalamudUtil
.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject))
.ConfigureAwait(false);
}
try
{
await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false);
#if DEBUG
if (hasPapFiles && boneIndices != null)
_modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject);
#endif
if (hasPapFiles)
{
await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct)
.ConfigureAwait(false);
}
}
catch (OperationCanceledException e)
{
@@ -257,104 +455,320 @@ public class PlayerDataFactory
}
}
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
_logger.LogInformation("Building character data for {obj} took {time}ms",
objectKind, sw.Elapsed.TotalMilliseconds);
return fragment;
}
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct)
private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct)
{
if (boneIndices == null) return;
foreach (var kvp in boneIndices)
{
_logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value));
}
if (boneIndices.All(u => u.Value.Count == 0)) return;
int noValidationFailed = 0;
foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
var remaining = 10000;
while (remaining > 0)
{
ct.ThrowIfCancellationRequested();
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
bool validationFailed = false;
if (skeletonIndices != null)
var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false))
return;
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, ct).ConfigureAwait(false);
remaining -= 50;
}
}
private static HashSet<FileReplacement> BuildStaticReplacements(Dictionary<string, HashSet<string>> resolvedPaths)
{
// 105 is the maximum vanilla skellington spoopy bone index
if (skeletonIndices.All(k => k.Value.Max() <= 105))
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
foreach (var kvp in resolvedPaths)
{
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
var fr = new FileReplacement([.. kvp.Value], kvp.Key);
if (!fr.HasFileReplacement) continue;
var allAllowed = fr.GamePaths.All(g =>
CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)));
if (!allAllowed) continue;
set.Add(fr);
}
return set;
}
private async Task<(IReadOnlyDictionary<string, string[]> ResolvedPaths, HashSet<FileReplacement>? ClearedReplacements)>
ResolveTransientReplacementsAsync(
GameObjectHandler obj,
ObjectKind objectKind,
HashSet<FileReplacement> staticReplacements,
Task waitRecordingTask,
CancellationToken ct)
{
await waitRecordingTask.ConfigureAwait(false);
HashSet<FileReplacement>? clearedReplacements = null;
if (objectKind == ObjectKind.Pet)
{
foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
if (_transientResourceManager.AddTransientResource(objectKind, item))
_logger.LogDebug("Marking static {item} for Pet as transient", item);
}
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
clearedReplacements = staticReplacements;
}
ct.ThrowIfCancellationRequested();
_transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]);
var transientPaths = ManageSemiTransientData(objectKind);
if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal))
.ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
{
_logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load",
resolved.Count,
_maxTransientResolvedEntries);
}
return (resolved, clearedReplacements);
}
private async Task VerifyPlayerAnimationBones(
Dictionary<string, List<ushort>>? playerBoneIndices,
CharacterDataFragmentPlayer fragment,
CancellationToken ct)
{
var mode = _configService.Current.AnimationValidationMode;
var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift;
var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance;
if (mode == AnimationValidationMode.Unsafe)
return;
if (playerBoneIndices == null || playerBoneIndices.Count == 0)
return;
var localBoneSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
foreach (var (rawLocalKey, indices) in playerBoneIndices)
{
if (indices is not { Count: > 0 })
continue;
var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey);
if (string.IsNullOrEmpty(key))
continue;
if (!localBoneSets.TryGetValue(key, out var set))
localBoneSets[key] = set = [];
foreach (var idx in indices)
set.Add(idx);
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
if (localBoneSets.Count == 0)
return;
foreach (var boneCount in skeletonIndices.Select(k => k).ToList())
if (_logger.IsEnabled(LogLevel.Debug))
{
if (boneCount.Value.Max() > boneIndices.SelectMany(b => b.Value).Max())
_logger.LogDebug("SEND local buckets: {b}",
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
_logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})",
file.ResolvedPath, boneCount.Key, boneCount.Value.Max(), boneIndices.SelectMany(b => b.Value).Max());
validationFailed = true;
break;
}
var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0;
var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0;
_logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}",
kvp.Key, kvp.Value.Count, min, max);
}
}
if (validationFailed)
var papGroups = fragment.FileReplacements
.Where(f => !f.IsFileSwap
&& !string.IsNullOrEmpty(f.Hash)
&& f.GamePaths is { Count: > 0 }
&& f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase)
.ToList();
int noValidationFailed = 0;
foreach (var g in papGroups)
{
ct.ThrowIfCancellationRequested();
var hash = g.Key;
var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase);
var papPathSummary = string.Join(", ", resolvedPath);
if (papPathSummary.IsNullOrEmpty())
papPathSummary = "<unknown pap path>";
Dictionary<string, List<ushort>>? papIndices = null;
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
try
{
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
var papPath = cacheEntity?.ResolvedFilepath;
if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath))
{
var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct)
.ConfigureAwait(false);
if (havokBytes is { Length: > 8 })
{
papIndices = await _dalamudUtil.RunOnFrameworkThread(
() => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false))
.ConfigureAwait(false);
}
}
}
finally
{
_papParseLimiter.Release();
}
if (papIndices == null || papIndices.Count == 0)
continue;
if (_logger.IsEnabled(LogLevel.Debug))
{
try
{
var papBuckets = papIndices
.Where(kvp => kvp.Value is { Count: > 0 })
.Select(kvp => new
{
Raw = kvp.Key,
Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key),
Indices = kvp.Value
})
.Where(x => x.Indices is { Count: > 0 })
.GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase)
.Select(grp =>
{
var all = grp.SelectMany(v => v.Indices).ToList();
var min = all.Count > 0 ? all.Min() : 0;
var max = all.Count > 0 ? all.Max() : 0;
var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase));
return $"{grp.Key}(min={min},max={max},raw=[{raws}])";
})
.ToList();
_logger.LogDebug("SEND pap buckets for hash={hash}: {b}",
hash,
string.Join(" | ", papBuckets));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash);
}
}
bool isCompatible = false;
string reason = string.Empty;
try
{
isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary);
reason = $"Exception during compatibility check: {ex.Message}";
isCompatible = false;
}
if (isCompatible)
continue;
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
fragment.FileReplacements.Remove(file);
foreach (var gamePath in file.GamePaths)
{
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gamePath);
}
}
_logger.LogWarning(
"Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}",
papPathSummary,
reason);
var removedGamePaths = fragment.FileReplacements
.Where(fr => !fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
fragment.FileReplacements.RemoveWhere(fr =>
!fr.IsFileSwap
&& string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase)
&& fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase)));
foreach (var gp in removedGamePaths)
_transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp);
}
if (noValidationFailed > 0)
{
_lightlessMediator.Publish(new NotificationMessage("Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " +
$"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).",
NotificationType.Warning, TimeSpan.FromSeconds(10)));
_lightlessMediator.Publish(new NotificationMessage(
"Invalid Skeleton Setup",
$"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " +
"Please adjust your skeleton/mods or change the validation mode if this is unexpected. " +
"Those animation files have been removed from your sent (player) data. (Check /xllog for details).",
NotificationType.Warning,
TimeSpan.FromSeconds(10)));
}
}
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(HashSet<string> forwardResolve, HashSet<string> reverseResolve)
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
HashSet<string> forwardResolve,
HashSet<string> reverseResolve)
{
var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray();
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
if (forwardPaths.Length == 0 && reversePaths.Length == 0)
{
return new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++)
{
var filePath = forward[i].ToLowerInvariant();
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.Add(forwardPaths[i].ToLowerInvariant());
}
else
{
resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()];
}
}
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePaths[i].ToLowerInvariant();
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverse[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
{
list.AddRange(reverse[i].Select(c => c.ToLowerInvariant()));
}
else
{
resolvedPaths[filePath] = new List<string>(reverse[i].Select(c => c.ToLowerInvariant()).ToList());
}
resolvedPaths[filePath] = [.. reverse[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
@@ -365,11 +779,29 @@ public class PlayerDataFactory
_transientResourceManager.PersistTransientResources(objectKind);
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
int scanned = 0, skippedEmpty = 0, skippedVfx = 0;
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind))
{
scanned++;
if (string.IsNullOrEmpty(path))
{
skippedEmpty++;
continue;
}
pathsToResolve.Add(path);
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
"ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}",
objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx);
}
return pathsToResolve;
}
}

View File

@@ -5,6 +5,7 @@ using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags;
using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.PlayerData.Handlers;
@@ -15,6 +16,8 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
private readonly Func<IntPtr> _getAddress;
private readonly bool _isOwnedObject;
private readonly PerformanceCollectorService _performanceCollector;
private readonly object _frameworkUpdateGate = new();
private bool _frameworkUpdateSubscribed;
private byte _classJob = 0;
private Task? _delayedZoningTask;
private bool _haltProcessing = false;
@@ -46,7 +49,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
});
}
Mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => FrameworkUpdate());
if (_isOwnedObject)
{
EnableFrameworkUpdates();
}
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
@@ -94,6 +100,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None;
public byte Gender { get; private set; }
public string Name { get; private set; }
public uint EntityId { get; private set; } = uint.MaxValue;
public ObjectKind ObjectKind { get; }
public byte RaceId { get; private set; }
public byte TribeId { get; private set; }
@@ -107,7 +114,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
{
while (await _dalamudUtil.RunOnFrameworkThread(() =>
{
if (_haltProcessing) CheckAndUpdateObject();
EnsureLatestObjectState();
if (CurrentDrawCondition != DrawCondition.None) return true;
var gameObj = _dalamudUtil.CreateGameObject(Address);
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
@@ -142,9 +149,15 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
{
Address = IntPtr.Zero;
DrawObjectAddress = IntPtr.Zero;
EntityId = uint.MaxValue;
_haltProcessing = false;
}
public void Refresh()
{
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
}
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
{
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
@@ -156,34 +169,36 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
private void CheckAndUpdateObject() => CheckAndUpdateObject(allowPublish: true);
Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject));
}
private unsafe void CheckAndUpdateObject()
private unsafe void CheckAndUpdateObject(bool allowPublish)
{
var prevAddr = Address;
var prevDrawObj = DrawObjectAddress;
string? nameString = null;
Address = _getAddress();
if (Address != IntPtr.Zero)
{
var drawObjAddr = (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->DrawObject;
DrawObjectAddress = drawObjAddr;
CurrentDrawCondition = DrawCondition.None;
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
DrawObjectAddress = (IntPtr)gameObject->DrawObject;
EntityId = gameObject->EntityId;
var chara = (Character*)Address;
nameString = chara->GameObject.NameString;
if (!string.IsNullOrEmpty(nameString) && !string.Equals(nameString, Name, StringComparison.Ordinal))
Name = nameString;
}
else
{
DrawObjectAddress = IntPtr.Zero;
CurrentDrawCondition = DrawCondition.DrawObjectZero;
EntityId = uint.MaxValue;
}
CurrentDrawCondition = IsBeingDrawnUnsafe();
if (_haltProcessing) return;
if (_haltProcessing || !allowPublish) return;
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr;
@@ -191,16 +206,18 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
{
var chara = (Character*)Address;
var name = chara->GameObject.NameString;
bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal);
if (nameChange)
{
Name = name;
}
var drawObj = (DrawObject*)DrawObjectAddress;
var objType = drawObj->Object.GetObjectType();
var isHuman = objType == ObjectType.CharacterBase
&& ((CharacterBase*)drawObj)->GetModelType() == CharacterBase.ModelType.Human;
nameString ??= ((Character*)Address)->GameObject.NameString;
var nameChange = !string.Equals(nameString, Name, StringComparison.Ordinal);
if (nameChange) Name = nameString;
bool equipDiff = false;
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
if (isHuman)
{
var classJob = chara->CharacterData.ClassJob;
if (classJob != _classJob)
@@ -210,7 +227,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Mediator.Publish(new ClassJobChangedMessage(this));
}
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)DrawObjectAddress)->Head);
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)drawObj)->Head);
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
@@ -235,12 +252,11 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
bool customizeDiff = false;
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
if (isHuman)
{
var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
var gender = ((Human*)drawObj)->Customize.Sex;
var raceId = ((Human*)drawObj)->Customize.Race;
var tribeId = ((Human*)drawObj)->Customize.Tribe;
if (_isOwnedObject && ObjectKind == ObjectKind.Player
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
@@ -251,7 +267,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
TribeId = tribeId;
}
customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
customizeDiff = CompareAndUpdateCustomizeData(((Human*)drawObj)->Customize.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
}
@@ -340,12 +356,10 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
private void FrameworkUpdate()
{
if (!_delayedZoningTask?.IsCompleted ?? false) return;
try
{
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}"
+ $"+{Address.ToString("X")}", CheckAndUpdateObject);
var zoningDelayActive = !(_delayedZoningTask?.IsCompleted ?? true);
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}", () => CheckAndUpdateObject(allowPublish: !zoningDelayActive));
}
catch (Exception ex)
{
@@ -355,7 +369,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
private bool IsBeingDrawn()
{
if (_haltProcessing) CheckAndUpdateObject();
EnsureLatestObjectState();
if (_dalamudUtil.IsAnythingDrawing)
{
@@ -367,12 +381,34 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
return CurrentDrawCondition != DrawCondition.None;
}
private void EnsureLatestObjectState()
{
if (_haltProcessing || !_frameworkUpdateSubscribed)
{
CheckAndUpdateObject();
}
}
private void EnableFrameworkUpdates()
{
lock (_frameworkUpdateGate)
{
if (_frameworkUpdateSubscribed)
{
return;
}
Mediator.Subscribe<FrameworkUpdateMessage>(this, _ => FrameworkUpdate());
_frameworkUpdateSubscribed = true;
}
}
private unsafe DrawCondition IsBeingDrawnUnsafe()
{
if (Address == IntPtr.Zero) return DrawCondition.ObjectZero;
if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero;
var renderFlags = (((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags) != 0x0;
if (renderFlags) return DrawCondition.RenderFlags;
var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags;
if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags;
if (ObjectKind == ObjectKind.Player)
{
@@ -424,6 +460,6 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
Logger.LogDebug("[{this}] Delay after zoning complete", this);
_zoningCts.Dispose();
}
});
}, _zoningCts.Token);
}
}

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

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,303 @@
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);
}
}
_mediator.Publish(new PairOnlineMessage(new PairUniqueIdentifier(dto.User.UID)));
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, forceDisposal: true);
}
_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,42 @@
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,
bool PendingModReapply,
bool ModApplyDeferred,
int MissingCriticalMods,
int MissingNonCriticalMods,
int MissingForbiddenMods)
{
public static PairDebugInfo Empty { get; } = new(
false,
false,
false,
false,
null,
null,
null,
null,
Array.Empty<string>(),
false,
false,
0,
0,
false,
false,
0,
0,
0);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
using LightlessSync.FileCache;
using LightlessSync.Interop.Ipc;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using Dalamud.Plugin.Services;
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 PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService;
private readonly PairStateCache _pairStateCache;
private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly IFramework _framework;
public PairHandlerAdapterFactory(
ILoggerFactory loggerFactory,
LightlessMediator mediator,
PairManager pairManager,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager,
FileDownloadManagerFactory fileDownloadManagerFactory,
PluginWarningNotificationService pluginWarningNotificationManager,
IServiceProvider serviceProvider,
IFramework framework,
IHostApplicationLifetime lifetime,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService playerPerformanceConfigService,
PlayerPerformanceService playerPerformanceService,
PairProcessingLimiter pairProcessingLimiter,
ServerConfigurationManager serverConfigManager,
TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService,
PairStateCache pairStateCache,
PairPerformanceMetricsCache pairPerformanceMetricsCache,
PenumbraTempCollectionJanitor tempCollectionJanitor,
XivDataAnalyzer modelAnalyzer,
LightlessConfigService configService)
{
_loggerFactory = loggerFactory;
_mediator = mediator;
_pairManager = pairManager;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_serviceProvider = serviceProvider;
_framework = framework;
_lifetime = lifetime;
_fileCacheManager = fileCacheManager;
_playerPerformanceConfigService = playerPerformanceConfigService;
_playerPerformanceService = playerPerformanceService;
_pairProcessingLimiter = pairProcessingLimiter;
_serverConfigManager = serverConfigManager;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
_pairStateCache = pairStateCache;
_pairPerformanceMetricsCache = pairPerformanceMetricsCache;
_tempCollectionJanitor = tempCollectionJanitor;
_modelAnalyzer = modelAnalyzer;
_configService = configService;
}
public IPairHandlerAdapter Create(string ident)
{
var downloadManager = _fileDownloadManagerFactory.Create();
var dalamudUtilService = _serviceProvider.GetRequiredService<DalamudUtilService>();
var actorObjectService = _serviceProvider.GetRequiredService<ActorObjectService>();
return new PairHandlerAdapter(
_loggerFactory.CreateLogger<PairHandlerAdapter>(),
_mediator,
_pairManager,
ident,
_gameObjectHandlerFactory,
_ipcManager,
downloadManager,
_pluginWarningNotificationManager,
dalamudUtilService,
_framework,
actorObjectService,
_lifetime,
_fileCacheManager,
_playerPerformanceConfigService,
_playerPerformanceService,
_pairProcessingLimiter,
_serverConfigManager,
_textureDownscaleService,
_modelDecimationService,
_pairStateCache,
_pairPerformanceMetricsCache,
_tempCollectionJanitor,
_modelAnalyzer,
_configService);
}
}

View File

@@ -0,0 +1,525 @@
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 object _visibilityGate = new();
private readonly Dictionary<string, PairHandlerEntry> _entriesByIdent = new(StringComparer.Ordinal);
private readonly Dictionary<string, CancellationTokenSource> _pendingInvisibleEvictions = 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.LastAppliedApproximateEffectiveTris < 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();
_pairStateCache.Clear(registration.CharacterIdent);
}
}
else if (shouldScheduleRemoval && handler is not null)
{
_ = RemoveAfterGracePeriodAsync(handler);
}
return PairOperationResult<PairUniqueIdentifier>.Ok(registration.PairIdent);
}
private PairOperationResult CancelAllInvisibleEvictions()
{
List<CancellationTokenSource> snapshot;
lock (_visibilityGate)
{
snapshot = [.. _pendingInvisibleEvictions.Values];
_pendingInvisibleEvictions.Clear();
}
List<string>? errors = null;
foreach (var cts in snapshot)
{
try { cts.Cancel(); }
catch (Exception ex)
{
(errors ??= new List<string>()).Add($"Cancel: {ex.Message}");
}
try { cts.Dispose(); }
catch (Exception ex)
{
(errors ??= new List<string>()).Add($"Dispose: {ex.Message}");
}
}
return errors is null
? PairOperationResult.Ok()
: PairOperationResult.Fail($"CancelAllInvisibleEvictions had error(s): {string.Join(" | ", errors)}");
}
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();
CancelAllInvisibleEvictions();
_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);
_pairStateCache.Clear(handler.Ident);
}
}
}
public void Dispose()
{
List<IPairHandlerAdapter> handlers;
lock (_gate)
{
handlers = _entriesByHandler.Keys.ToList();
CancelAllInvisibleEvictions();
_entriesByIdent.Clear();
_entriesByHandler.Clear();
}
CancelAllPendingCharacterData();
foreach (var handler in handlers)
{
handler.Dispose();
_pairPerformanceMetricsCache.Clear(handler.Ident);
_pairStateCache.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();
_pairStateCache.Clear(handler.Ident);
}
}
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,308 @@
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
&& handler.LastAppliedApproximateEffectiveTris >= 0)
{
continue;
}
if (handler.FetchPerformanceMetricsFromCache())
{
continue;
}
try
{
_ = Task.Run(async () =>
{
try
{
await handler.EnsurePerformanceMetricsAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident);
}
}
});
}
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,221 @@
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;
Ident = null;
}
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,66 @@
using System.Collections.Concurrent;
namespace LightlessSync.PlayerData.Pairs;
public readonly record struct PairPerformanceMetrics(
long TriangleCount,
long ApproximateVramBytes,
long ApproximateEffectiveVramBytes,
long ApproximateEffectiveTris);
/// <summary>
/// caches performance metrics keyed by pair ident
/// </summary>
public sealed class PairPerformanceMetricsCache
{
private sealed record CacheEntry(string DataHash, PairPerformanceMetrics Metrics);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new(StringComparer.Ordinal);
public bool TryGetMetrics(string ident, string dataHash, out PairPerformanceMetrics metrics)
{
metrics = default;
if (string.IsNullOrEmpty(ident) || string.IsNullOrEmpty(dataHash))
{
return false;
}
if (!_cache.TryGetValue(ident, out var entry))
{
return false;
}
if (!string.Equals(entry.DataHash, dataHash, StringComparison.Ordinal))
{
return false;
}
metrics = entry.Metrics;
return true;
}
public void StoreMetrics(string ident, string dataHash, PairPerformanceMetrics metrics)
{
if (string.IsNullOrEmpty(ident) || string.IsNullOrEmpty(dataHash))
{
return;
}
_cache[ident] = new CacheEntry(dataHash, metrics);
}
public void Clear(string ident)
{
if (string.IsNullOrEmpty(ident))
{
return;
}
_cache.TryRemove(ident, out _);
}
public void ClearAll()
{
_cache.Clear();
}
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
using Dalamud.Game;
using Dalamud.Game.ClientState.Objects;
using Dalamud.Interface;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin;
@@ -13,14 +14,20 @@ using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.PlayerData.Services;
using LightlessSync.Services;
using LightlessSync.Services.Chat;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.CharaData;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.Rendering;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.UI.Components;
using LightlessSync.UI.Components.Popup;
using LightlessSync.UI.Handlers;
using LightlessSync.UI.Tags;
using LightlessSync.UI.Services;
using LightlessSync.WebAPI;
using LightlessSync.WebAPI.Files;
using LightlessSync.WebAPI.SignalR;
@@ -30,6 +37,11 @@ using Microsoft.Extensions.Logging;
using NReco.Logging.File;
using System.Net.Http.Headers;
using System.Reflection;
using OtterTex;
using LightlessSync.Services.LightFinder;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.UI.Models;
namespace LightlessSync;
@@ -41,8 +53,9 @@ public sealed class Plugin : IDalamudPlugin
IFramework framework, IObjectTable objectTable, IClientState clientState, ICondition condition, IChatGui chatGui,
IGameGui gameGui, IDtrBar dtrBar, IPluginLog pluginLog, ITargetManager targetManager, INotificationManager notificationManager,
ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig,
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle)
ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle, IPlayerState playerState)
{
NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName);
if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName))
Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName);
var traceDir = Path.Join(pluginInterface.ConfigDirectory.FullName, "tracelog");
@@ -85,198 +98,495 @@ public sealed class Plugin : IDalamudPlugin
});
lb.SetMinimumLevel(LogLevel.Trace);
})
.ConfigureServices(collection =>
.ConfigureServices(services =>
{
collection.AddSingleton(new WindowSystem("LightlessSync"));
collection.AddSingleton<FileDialogManager>();
collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true));
collection.AddSingleton(gameGui);
var configDir = pluginInterface.ConfigDirectory.FullName;
// add lightless related singletons
collection.AddSingleton<LightlessMediator>();
collection.AddSingleton<FileCacheManager>();
collection.AddSingleton<ServerConfigurationManager>();
collection.AddSingleton<ApiController>();
collection.AddSingleton<PerformanceCollectorService>();
collection.AddSingleton<HubFactory>();
collection.AddSingleton<FileUploadManager>();
collection.AddSingleton<FileTransferOrchestrator>();
collection.AddSingleton<LightlessPlugin>();
collection.AddSingleton<LightlessProfileManager>();
collection.AddSingleton<GameObjectHandlerFactory>();
collection.AddSingleton<FileDownloadManagerFactory>();
collection.AddSingleton<PairHandlerFactory>();
collection.AddSingleton<PairProcessingLimiter>();
collection.AddSingleton<PairFactory>();
collection.AddSingleton<XivDataAnalyzer>();
collection.AddSingleton<CharacterAnalyzer>();
collection.AddSingleton<TokenProvider>();
collection.AddSingleton<PluginWarningNotificationService>();
collection.AddSingleton<FileCompactor>();
collection.AddSingleton<TagHandler>();
collection.AddSingleton<PairRequestService>();
collection.AddSingleton<IdDisplayHandler>();
collection.AddSingleton<PlayerPerformanceService>();
collection.AddSingleton<TransientResourceManager>();
// Core infrastructure
services.AddSingleton(new WindowSystem("LightlessSync"));
services.AddSingleton<FileDialogManager>();
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
services.AddSingleton(framework);
services.AddSingleton(gameGui);
services.AddSingleton(gameInteropProvider);
services.AddSingleton(addonLifecycle);
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
collection.AddSingleton<CharaDataManager>();
collection.AddSingleton<CharaDataFileHandler>();
collection.AddSingleton<CharaDataCharacterHandler>();
collection.AddSingleton<CharaDataNearbyManager>();
collection.AddSingleton<CharaDataGposeTogetherManager>();
// Core singletons
services.AddSingleton<LightlessMediator>();
services.AddSingleton<FileCacheManager>();
services.AddSingleton<ServerConfigurationManager>();
services.AddSingleton<ProfileTagService>();
services.AddSingleton<ApiController>();
services.AddSingleton<PerformanceCollectorService>();
services.AddSingleton<NameplateUpdateHookService>();
services.AddSingleton<HubFactory>();
services.AddSingleton<FileUploadManager>();
services.AddSingleton<FileTransferOrchestrator>();
services.AddSingleton<LightlessPlugin>();
services.AddSingleton<LightlessProfileManager>();
services.AddSingleton<TextureCompressionService>();
services.AddSingleton<TextureDownscaleService>();
services.AddSingleton<ModelDecimationService>();
services.AddSingleton<GameObjectHandlerFactory>();
services.AddSingleton<FileDownloadDeduplicator>();
services.AddSingleton<FileDownloadManagerFactory>();
services.AddSingleton<PairProcessingLimiter>();
services.AddSingleton<XivDataAnalyzer>();
services.AddSingleton<CharacterAnalyzer>();
services.AddSingleton<TokenProvider>();
services.AddSingleton<PluginWarningNotificationService>();
services.AddSingleton<ICompactorContext, PluginCompactorContext>();
services.AddSingleton<ICompactionExecutor, ExternalCompactionExecutor>();
services.AddSingleton<FileCompactor>();
services.AddSingleton<TagHandler>();
services.AddSingleton<PairRequestService>();
services.AddSingleton<ZoneChatService>();
services.AddSingleton<ChatEmoteService>();
services.AddSingleton<IdDisplayHandler>();
services.AddSingleton<PlayerPerformanceService>();
services.AddSingleton<PenumbraTempCollectionJanitor>();
services.AddSingleton<LocationShareService>();
collection.AddSingleton(s => new VfxSpawnManager(s.GetRequiredService<ILogger<VfxSpawnManager>>(),
gameInteropProvider, s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new BlockedCharacterHandler(s.GetRequiredService<ILogger<BlockedCharacterHandler>>(), gameInteropProvider));
collection.AddSingleton((s) => new IpcProvider(s.GetRequiredService<ILogger<IpcProvider>>(),
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,
objectTable));
services.AddSingleton(sp => new IpcProvider(
sp.GetRequiredService<ILogger<IpcProvider>>(),
pluginInterface,
s.GetRequiredService<CharaDataManager>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton<SelectPairForTagUi>();
collection.AddSingleton<RenamePairTagUi>();
collection.AddSingleton<SelectSyncshellForTagUi>();
collection.AddSingleton<RenameSyncshellTagUi>();
collection.AddSingleton((s) => new EventAggregator(pluginInterface.ConfigDirectory.FullName,
s.GetRequiredService<ILogger<EventAggregator>>(), s.GetRequiredService<LightlessMediator>()));
collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService<ILogger<DalamudUtilService>>(),
clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig,
s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>()));
collection.AddSingleton((s) => new DtrEntry(s.GetRequiredService<ILogger<DtrEntry>>(), dtrBar, s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<ServerConfigurationManager>()));
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, 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<LightlessMediator>(), s.GetRequiredService<DalamudUtilService>(),
notificationManager, chatGui, s.GetRequiredService<LightlessConfigService>()));
collection.AddSingleton((s) =>
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,
condition,
sp.GetRequiredService<LightlessMediator>()));
services.AddSingleton(sp => new DalamudUtilService(
sp.GetRequiredService<ILogger<DalamudUtilService>>(),
clientState,
objectTable,
framework,
gameGui,
condition,
gameData,
targetManager,
gameConfig,
playerState,
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,
clientState,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(),
objectTable,
sp.GetRequiredService<PairUiService>(),
pluginInterface,
sp.GetRequiredService<PictomancyService>()));
services.AddSingleton(sp => new LightFinderNativePlateHandler(
sp.GetRequiredService<ILogger<LightFinderNativePlateHandler>>(),
clientState,
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<LightlessMediator>(),
objectTable,
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<NameplateUpdateHookService>()));
services.AddSingleton(sp => new LightFinderScannerService(
sp.GetRequiredService<ILogger<LightFinderScannerService>>(),
framework,
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<LightFinderPlateHandler>(),
sp.GetRequiredService<LightFinderNativePlateHandler>(),
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>(),
chatGui,
sp.GetRequiredService<NotificationService>())
);
// IPC callers / manager
services.AddSingleton(sp => new IpcCallerPenumbra(
sp.GetRequiredService<ILogger<IpcCallerPenumbra>>(),
pluginInterface,
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<RedrawManager>()));
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 IpcCallerLifestream(
pluginInterface,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ILogger<IpcCallerLifestream>>()));
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>(),
sp.GetRequiredService<IpcCallerLifestream>()
));
// 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 =>
{
var httpClient = new HttpClient();
var ver = Assembly.GetExecutingAssembly().GetName().Version;
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
httpClient.DefaultRequestHeaders.UserAgent.Add(
new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}"));
return httpClient;
});
collection.AddSingleton((s) =>
// Lightless Config services
services.AddSingleton(sp => new UiThemeConfigService(configDir));
services.AddSingleton(sp => new ChatConfigService(configDir));
services.AddSingleton(sp =>
{
var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName);
LightlessSync.UI.Style.MainStyle.Init(cfg);
var cfg = new LightlessConfigService(configDir);
var theme = sp.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<ServerConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PairTagConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<SyncshellTagConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<TransientConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<XivDataStorageService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PlayerPerformanceConfigService>());
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
collection.AddSingleton<ConfigurationMigrator>();
collection.AddSingleton<ConfigurationSaveService>();
collection.AddSingleton<HubFactory>();
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
services.AddSingleton(sp => new TempCollectionConfigService(configDir));
services.AddSingleton(sp => new ServerConfigService(configDir));
services.AddSingleton(sp => new NotesConfigService(configDir));
services.AddSingleton(sp => new PairTagConfigService(configDir));
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
services.AddSingleton(sp => new TransientConfigService(configDir));
services.AddSingleton(sp => new XivDataStorageService(configDir));
services.AddSingleton(sp => new PlayerPerformanceConfigService(configDir));
services.AddSingleton(sp => new CharaDataConfigService(configDir));
// Config adapters
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<LightlessConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<TempCollectionConfigService>());
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>());
// add scoped services
collection.AddScoped<DrawEntityFactory>();
collection.AddScoped<CacheMonitor>();
collection.AddScoped<UiFactory>();
collection.AddScoped<SelectTagForPairUi>();
collection.AddScoped<SelectTagForSyncshellUi>();
collection.AddScoped<WindowMediatorSubscriberBase, SettingsUi>();
collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
collection.AddScoped<WindowMediatorSubscriberBase, JoinSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
services.AddSingleton<ConfigurationMigrator>();
services.AddSingleton<ConfigurationSaveService>();
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<PerformanceCollectorService>()));
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
collection.AddScoped<CacheCreationService>();
collection.AddScoped<PlayerDataFactory>();
collection.AddScoped<VisibleUserDataDistributor>();
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
s.GetRequiredService<UiFactory>(),
s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
collection.AddScoped((s) => new UiSharedService(s.GetRequiredService<ILogger<UiSharedService>>(), s.GetRequiredService<IpcManager>(), s.GetRequiredService<ApiController>(),
s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<DalamudUtilService>(),
pluginInterface, textureProvider, s.GetRequiredService<Dalamud.Localization>(), s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<TokenProvider>(),
s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
// 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>();
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>());
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>());
collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>());
collection.AddHostedService(p => p.GetRequiredService<PerformanceCollectorService>());
collection.AddHostedService(p => p.GetRequiredService<DtrEntry>());
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
collection.AddHostedService(p => p.GetRequiredService<LightlessPlugin>());
collection.AddHostedService(p => p.GetRequiredService<ContextMenuService>());
collection.AddHostedService(p => p.GetRequiredService<BroadcastService>());
})
.Build();
services.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>(sp => new EditProfileUi(
sp.GetRequiredService<ILogger<EditProfileUi>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<FileDialogManager>(),
sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<ProfileTagService>(),
sp.GetRequiredService<PerformanceCollectorService>()));
services.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
services.AddScoped<WindowMediatorSubscriberBase, LightFinderUI>(sp => new LightFinderUI(
sp.GetRequiredService<ILogger<LightFinderUI>>(),
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PerformanceCollectorService>(),
sp.GetRequiredService<LightFinderService>(),
sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<UiSharedService>(),
sp.GetRequiredService<ApiController>(),
sp.GetRequiredService<LightFinderScannerService>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessProfileManager>(),
sp.GetRequiredService<ActorObjectService>(),
sp.GetRequiredService<LightFinderPlateHandler>()));
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<DalamudUtilService>(),
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,
sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<PairUiService>(),
sp.GetRequiredService<NameplateUpdateHookService>()));
// 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>());
services.AddHostedService(sp => sp.GetRequiredService<LightFinderNativePlateHandler>());
}).Build();
_ = _host.StartAsync();
}
public void Dispose()
{
_host.StopAsync().GetAwaiter().GetResult();
_host.Dispose();
_host.StopAsync().ContinueWith(_ => _host.Dispose()).Wait(TimeSpan.FromSeconds(5));
}
}

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