Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d2b31eda | ||
| 17dd8a307b | |||
| 24d0c38f59 | |||
|
|
5920622b9a | ||
|
|
d357e37c65 | ||
| 4da2548e03 | |||
| e0b8070aa8 | |||
| 3241b9222b | |||
| 80b082240f | |||
| b8c8f3dffd | |||
| 543ea6c865 | |||
|
|
de9c9955ef | ||
|
|
2eb0c463e3 | ||
|
|
cd510f93af | ||
|
|
2d526bcfbf | ||
|
|
4664071eb3 | ||
|
|
11099c05ff | ||
|
|
3bbda69699 | ||
|
|
deb7f67e59 | ||
|
|
57a076ae77 | ||
|
|
6af61451dc | ||
|
|
02d091eefa | ||
|
|
e41a7149c5 | ||
| b6b9c81a57 | |||
| a824d94ffe | |||
|
|
9ba45670c5 | ||
|
|
e16ddb0a1d | ||
| 4b13dfe8d4 | |||
|
|
f7bb73bcd1 | ||
|
|
ba26edc33c | ||
|
|
14c4c1d669 | ||
|
|
e8c157d8ac | ||
|
|
2af0b5774b | ||
|
|
bb365442cf | ||
|
|
4c07162ee3 | ||
|
|
a4d62af73d | ||
|
|
5fba3c01e7 | ||
|
|
277d368f83 | ||
|
|
3487891185 | ||
|
|
96f8d33cde | ||
|
|
a033d4d4d8 | ||
|
|
7d2a914c84 | ||
|
|
d6fe09ba8e | ||
| 979443d9bb | |||
| 92cb861710 | |||
| aeed8503c2 | |||
|
|
df33a0f0a2 | ||
| c439d1c822 | |||
|
|
906dda3885 | ||
|
|
f812b6d09e | ||
| 44bb53023e | |||
| 05b91ed243 | |||
| 7e61954541 | |||
| cfc5c1e0f3 | |||
|
|
89f59a98f5 | ||
|
|
fb58d8657d | ||
| bbb3375661 | |||
| ed7932ab83 | |||
|
|
e95a2c3352 | ||
| 4eaaaf694c | |||
|
|
c32c89d1a8 | ||
| a8b58d05d6 | |||
| 9ea0571e82 | |||
|
|
a8340c3279 | ||
|
|
e25979e089 | ||
|
|
ca7375b9c3 | ||
|
|
f8752fcb4d | ||
|
|
d1c955c74f | ||
|
|
91e60694ad | ||
|
|
308c220735 | ||
|
|
27d4da4615 | ||
|
|
6b49c92ef9 | ||
|
|
f37fdefddd | ||
|
|
18fa0a47b1 | ||
|
|
9f5cc9e0d1 | ||
|
|
6d20995dbf | ||
|
|
b02db4c1e1 | ||
|
|
d6b31ed5b9 | ||
|
|
9e600bfae0 | ||
|
|
1a73d5a4d9 | ||
|
|
cf495dc826 | ||
|
|
08050614da | ||
| 94f520d0e7 | |||
|
|
a933330418 | ||
|
|
ea34b18f40 | ||
| 474fd5ef11 | |||
|
|
759066731e | ||
|
|
ff88e5f856 | ||
|
|
61bac0d39d | ||
| 5b3d00b90a | |||
|
|
67dc215e83 | ||
|
|
baf3869cec | ||
|
|
eeda5aeb66 | ||
| e14d50674d | |||
| 129cf14151 | |||
|
|
dba04d740b | ||
| 04d7a66317 | |||
|
|
2abc92fc61 | ||
|
|
a3ea48c6e1 | ||
|
|
deb99628f6 | ||
| f69effb8a3 | |||
|
|
754df95071 | ||
|
|
24fca31606 | ||
|
|
a99c1c01b0 | ||
| 8f32b375dd | |||
|
|
85999fab8f | ||
|
|
70745613e1 | ||
|
|
5c8e239a7b | ||
|
|
5eed65149a | ||
| 1632258c4f | |||
|
|
1ab4e2f94b | ||
| a5786e1d5b | |||
| 0b32639f99 | |||
| 65dea18f5f | |||
| 6546a658f3 | |||
| 8a41baa88b | |||
| 88cb778791 | |||
|
|
f792bc1954 | ||
|
|
ced72ab9eb | ||
|
|
6892d81041 | ||
|
|
a47ca4452a | ||
|
|
32df21bf4a | ||
|
|
1a2885fd74 | ||
| e470222fe6 | |||
|
|
eb83ca90cb | ||
|
|
35f0f6da5e | ||
| 7d151dac2b | |||
|
|
2eba5a1f30 | ||
|
|
6c1cc77aaa | ||
|
|
5b81caf5a8 | ||
|
|
4e03b381dc | ||
|
|
3222133aa0 | ||
| 0a6cb05883 | |||
|
|
838495810e | ||
| a207c8994b | |||
|
|
9b4e48ad3e | ||
| fb4810980e | |||
| 51e107d30a | |||
|
|
cc011743af | ||
|
|
f47fbda0d9 | ||
| fd3b42eff1 | |||
| 3262664d1c | |||
| afa0d9f101 | |||
|
|
a66a9407f5 | ||
|
|
0ec423e65c | ||
|
|
34bbc34b5b | ||
|
|
be068ed6d1 | ||
|
|
3c3c8fd90b | ||
| 835a0a637d | |||
| 906f401940 | |||
| 5abc297a94 | |||
|
|
8aad714918 | ||
| b5bdededae | |||
|
|
487156e4f9 | ||
| 90d8f691d2 | |||
|
|
764bb8bae2 | ||
| 5d2c58bf3e | |||
|
|
6bb00c50d8 | ||
|
|
1a89c2caee | ||
|
|
1e97f27cb8 | ||
|
|
7aadbcec10 | ||
|
|
a32ac02c6d | ||
| f48698373b | |||
| ee20b6fa5f | |||
| f11741225b | |||
|
|
147baa4c1b | ||
|
|
4f5ef8ff4b | ||
|
|
fae6d31792 | ||
|
|
b4dd0ee0e1 | ||
| f5458c7f97 | |||
| 923f118a47 | |||
|
|
0cb71e5444 | ||
|
|
268fd471fe | ||
|
|
d517a21f5d | ||
| cac94374d9 | |||
|
|
b513e0555b | ||
|
|
de8c9cf035 | ||
|
|
7f8872cbe0 | ||
|
|
5626a34755 | ||
|
|
a98afdda01 | ||
| 4373092d44 | |||
| 7b5c61371e | |||
| 9bd997f699 | |||
| e2511a5c1f | |||
|
|
e8760a8937 | ||
|
|
7d4e097be8 | ||
|
|
2cba1ccfe0 | ||
| 06921e1dd1 | |||
|
|
68ba5f4b06 | ||
|
|
217c160ec7 | ||
|
|
ff010fa1f8 | ||
| f7145339b3 | |||
|
|
aa2b828386 | ||
|
|
547db3a76b | ||
|
|
4ac8b24524 | ||
|
|
d72cc207e1 | ||
|
|
477f5aa6e7 | ||
|
|
edb7232b17 | ||
|
|
44177ab7bd | ||
|
|
47b7ecd521 | ||
|
|
8a3902ec2b | ||
|
|
ea8f8e3895 | ||
|
|
f1af6601cc | ||
|
|
2d094404df | ||
|
|
8fdff1eb18 | ||
|
|
9170b5205c | ||
|
|
dccd2cdc36 | ||
|
|
6d01d47c2f | ||
|
|
280c80d89f | ||
|
|
92b8d4a1cd | ||
|
|
f77d109d00 | ||
|
|
823dd39a9b | ||
|
|
d5c12e81c3 | ||
|
|
04c00af92e | ||
|
|
a66a43dda8 | ||
| 77ff8ae372 | |||
|
|
011cf7951b | ||
|
|
7d480b9e2c | ||
|
|
cf27a67296 | ||
|
|
d6a4595bb8 | ||
|
|
f202818b55 | ||
|
|
3f2e4d6640 | ||
|
|
90b483e4ea | ||
| 434c7d5f4a | |||
|
|
a4eb840589 | ||
|
|
e80806ef9d | ||
|
|
c447c33b7a | ||
|
|
bcb524df52 | ||
| b64bb66119 | |||
|
|
118edb9dea | ||
|
|
b43ceb9f7e | ||
|
|
6c0d00dc39 | ||
|
|
be847c16b8 | ||
| 02a680f8cc | |||
|
|
dae8127ac8 | ||
|
|
0635caab65 | ||
| 1530ac3911 | |||
| 3f80467180 | |||
|
|
4b4e587a89 | ||
|
|
02c3846031 | ||
|
|
a8a01b3034 | ||
| 50a5046c96 | |||
|
|
98c3a2c7f8 |
@@ -6,7 +6,9 @@ on:
|
||||
|
||||
env:
|
||||
PLUGIN_NAME: LightlessSync
|
||||
DOTNET_VERSION: 9.x
|
||||
DOTNET_VERSION: |
|
||||
10.x.x
|
||||
9.x.x
|
||||
|
||||
jobs:
|
||||
tag-and-release:
|
||||
@@ -16,15 +18,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Lightless
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup .NET 9 SDK
|
||||
uses: actions/setup-dotnet@v4
|
||||
- name: Setup .NET 10 SDK
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 9.x
|
||||
dotnet-version: |
|
||||
10.x.x
|
||||
9.x.x
|
||||
|
||||
- name: Download Dalamud
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -348,3 +348,6 @@ MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# idea
|
||||
/.idea
|
||||
|
||||
18
.gitmodules
vendored
18
.gitmodules
vendored
@@ -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
|
||||
|
||||
Submodule LightlessAPI updated: 44fbe10458...4ecd5375e6
@@ -1,7 +1,7 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.1.32328.378
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.0.11217.181
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
@@ -12,40 +12,110 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync", "LightlessS
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "PenumbraAPI\Penumbra.Api.csproj", "{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{22AE06C8-5139-45D2-A5F9-E76C019050D9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{3C016B19-2A2C-4068-9378-B9B805605EFB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{C77A2833-3FE4-405B-811D-439B1FF859D9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Release|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Release|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.Build.0 = Debug|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.Build.0 = Release|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.ActiveCfg = Release|x64
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.Build.0 = Release|x64
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.Build.0 = Debug|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.Build.0 = Release|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.ActiveCfg = Release|x64
|
||||
{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.Build.0 = Release|x64
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.Build.0 = Release|Any CPU
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.Build.0 = Debug|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.ActiveCfg = Debug|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.Build.0 = Debug|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|Any CPU.Build.0 = Release|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x64.ActiveCfg = Release|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x64.Build.0 = Release|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x86.ActiveCfg = Release|x64
|
||||
{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x86.Build.0 = Release|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x64.Build.0 = Debug|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x86.ActiveCfg = Debug|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x86.Build.0 = Debug|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|Any CPU.Build.0 = Release|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x64.ActiveCfg = Release|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x64.Build.0 = Release|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x86.ActiveCfg = Release|x64
|
||||
{22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x86.Build.0 = Release|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x64.Build.0 = Debug|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x86.ActiveCfg = Debug|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x86.Build.0 = Debug|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|Any CPU.Build.0 = Release|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x64.ActiveCfg = Release|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x64.Build.0 = Release|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x86.ActiveCfg = Release|x64
|
||||
{3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x86.Build.0 = Release|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x64.Build.0 = Debug|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x86.ActiveCfg = Debug|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x86.Build.0 = Debug|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|Any CPU.Build.0 = Release|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.ActiveCfg = Release|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.Build.0 = Release|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.ActiveCfg = Release|x64
|
||||
{C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.Build.0 = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|Any CPU.ActiveCfg = Debug|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|Any CPU.Build.0 = Debug|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x64.Build.0 = Debug|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x86.ActiveCfg = Debug|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x86.Build.0 = Debug|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|Any CPU.ActiveCfg = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|Any CPU.Build.0 = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.ActiveCfg = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.Build.0 = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64
|
||||
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
340
LightlessSync/Changelog/changelog.yaml
Normal file
340
LightlessSync/Changelog/changelog.yaml
Normal 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. You’ll 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 aren’t 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."
|
||||
68
LightlessSync/Changelog/credits.yaml
Normal file
68
LightlessSync/Changelog/credits.yaml
Normal 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"
|
||||
@@ -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);
|
||||
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
|
||||
if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtil.IsWine)
|
||||
{
|
||||
StorageisNTFS = true;
|
||||
Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
|
||||
}
|
||||
|
||||
if (fsType == FileSystemHelper.FilesystemType.Btrfs)
|
||||
{
|
||||
StorageIsBtrfs = true;
|
||||
Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs);
|
||||
}
|
||||
|
||||
Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath);
|
||||
LightlessWatcher = new()
|
||||
@@ -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 =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
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);
|
||||
|
||||
try
|
||||
{
|
||||
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
FileCacheSize = totalSize;
|
||||
|
||||
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
|
||||
|
||||
if (FileCacheSize < maxCacheInBytes) return;
|
||||
if (FileCacheSize < maxCacheInBytes)
|
||||
return;
|
||||
|
||||
var maxCacheBuffer = maxCacheInBytes * 0.05d;
|
||||
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
|
||||
|
||||
candidates.Sort(static (a, b) => a.LastAccessTime.CompareTo(b.LastAccessTime));
|
||||
|
||||
var evictionTarget = maxCacheInBytes - (long)maxCacheBuffer;
|
||||
var index = 0;
|
||||
while (FileCacheSize > evictionTarget && index < candidates.Count)
|
||||
{
|
||||
var oldestFile = files[0];
|
||||
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
|
||||
File.Delete(oldestFile.FullName);
|
||||
files.Remove(oldestFile);
|
||||
var oldestFile = candidates[index];
|
||||
|
||||
try
|
||||
{
|
||||
EvictCacheCandidate(oldestFile, cacheFolder);
|
||||
FileCacheSize -= oldestFile.Size;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullPath);
|
||||
}
|
||||
|
||||
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,44 +798,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
|
||||
// scan new files
|
||||
if (allScannedFiles.Any(c => !c.Value))
|
||||
var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList();
|
||||
foreach (var cachePath in newFiles)
|
||||
{
|
||||
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
|
||||
new ParallelOptions()
|
||||
{
|
||||
MaxDegreeOfParallelism = threadCount,
|
||||
CancellationToken = ct
|
||||
}, (cachePath) =>
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
if (ct.IsCancellationRequested) break;
|
||||
ProcessOne(cachePath);
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
}
|
||||
|
||||
if (ct.IsCancellationRequested) return;
|
||||
Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count);
|
||||
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
void ProcessOne(string? cachePath)
|
||||
{
|
||||
if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null)
|
||||
{
|
||||
Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}",
|
||||
_fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
}
|
||||
if (!_ipcManager.Penumbra.APIAvailable)
|
||||
{
|
||||
Logger.LogWarning("Penumbra not available");
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _currentFileProgress);
|
||||
});
|
||||
|
||||
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
|
||||
try
|
||||
{
|
||||
var entry = _fileDbManager.CreateFileEntry(cachePath);
|
||||
if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath);
|
||||
}
|
||||
catch (IOException ioex)
|
||||
{
|
||||
Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug("Scan complete");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace LightlessSync.FileCache;
|
||||
@@ -18,17 +20,28 @@ public sealed class FileCacheManager : IHostedService
|
||||
public const string PenumbraPrefix = "{penumbra}";
|
||||
private const int FileCacheVersion = 1;
|
||||
private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:";
|
||||
private readonly SemaphoreSlim _fileWriteSemaphore = new(1, 1);
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly string _csvPath;
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, FileCacheEntity> _fileCachesByPrefixedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
|
||||
private readonly SemaphoreSlim _evictSemaphore = new(1, 1);
|
||||
private readonly Lock _fileWriteLock = new();
|
||||
private readonly IpcManager _ipcManager;
|
||||
private readonly ILogger<FileCacheManager> _logger;
|
||||
private bool _csvHeaderEnsured;
|
||||
public string CacheFolder => _configService.Current.CacheFolder;
|
||||
|
||||
private const string _compressedCacheExtension = ".llz4";
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _compressLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, SizeInfo> _sizeCache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct SizeInfo(long Original, long Compressed);
|
||||
|
||||
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, LightlessConfigService configService, LightlessMediator lightlessMediator)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -40,11 +53,20 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
private string CsvBakPath => _csvPath + ".bak";
|
||||
|
||||
private static string NormalizeSeparators(string path)
|
||||
{
|
||||
return path.Replace("/", "\\", StringComparison.Ordinal)
|
||||
private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal)
|
||||
.Replace("\\\\", "\\", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private SemaphoreSlim GetCompressLock(string hash)
|
||||
=> _compressLocks.GetOrAdd(hash, _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public void SetSizeInfo(string hash, long original, long compressed)
|
||||
=> _sizeCache[hash] = new SizeInfo(original, compressed);
|
||||
|
||||
public bool TryGetSizeInfo(string hash, out SizeInfo info)
|
||||
=> _sizeCache.TryGetValue(hash, out info);
|
||||
|
||||
private string GetCompressedCachePath(string hash)
|
||||
=> Path.Combine(CacheFolder, hash + _compressedCacheExtension);
|
||||
|
||||
private static string NormalizePrefixedPathKey(string prefixedPath)
|
||||
{
|
||||
@@ -112,6 +134,124 @@ public sealed class FileCacheManager : IHostedService
|
||||
return int.TryParse(versionSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out version);
|
||||
}
|
||||
|
||||
public void UpdateSizeInfo(string hash, long? original = null, long? compressed = null)
|
||||
{
|
||||
_sizeCache.AddOrUpdate(
|
||||
hash,
|
||||
_ => new SizeInfo(original ?? 0, compressed ?? 0),
|
||||
(_, old) => new SizeInfo(original ?? old.Original, compressed ?? old.Compressed));
|
||||
}
|
||||
|
||||
private void UpdateEntitiesSizes(string hash, long original, long compressed)
|
||||
{
|
||||
if (_fileCaches.TryGetValue(hash, out var dict))
|
||||
{
|
||||
foreach (var e in dict.Values)
|
||||
{
|
||||
e.Size = original;
|
||||
e.CompressedSize = compressed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplySizesToEntries(IEnumerable<FileCacheEntity?> entries, long original, long compressed)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e == null) continue;
|
||||
e.Size = original;
|
||||
e.CompressedSize = compressed > 0 ? compressed : null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> GetCompressedSizeAsync(string hash, CancellationToken token)
|
||||
{
|
||||
if (_sizeCache.TryGetValue(hash, out var info) && info.Compressed > 0)
|
||||
return info.Compressed;
|
||||
|
||||
if (_fileCaches.TryGetValue(hash, out var dict))
|
||||
{
|
||||
var any = dict.Values.FirstOrDefault();
|
||||
if (any != null && any.CompressedSize > 0)
|
||||
{
|
||||
UpdateSizeInfo(hash, original: any.Size > 0 ? any.Size : null, compressed: any.CompressedSize);
|
||||
return (long)any.CompressedSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CacheFolder))
|
||||
{
|
||||
var path = GetCompressedCachePath(hash);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var len = new FileInfo(path).Length;
|
||||
UpdateSizeInfo(hash, compressed: len);
|
||||
return len;
|
||||
}
|
||||
|
||||
var bytes = await EnsureCompressedCacheBytesAsync(hash, token).ConfigureAwait(false);
|
||||
return bytes.LongLength;
|
||||
}
|
||||
|
||||
var fallback = await GetCompressedFileData(hash, token).ConfigureAwait(false);
|
||||
return fallback.Item2.LongLength;
|
||||
}
|
||||
|
||||
private async Task<byte[]> EnsureCompressedCacheBytesAsync(string hash, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder))
|
||||
throw new InvalidOperationException("CacheFolder is not set; cannot persist compressed cache.");
|
||||
|
||||
Directory.CreateDirectory(CacheFolder);
|
||||
|
||||
var compressedPath = GetCompressedCachePath(hash);
|
||||
|
||||
if (File.Exists(compressedPath))
|
||||
return await File.ReadAllBytesAsync(compressedPath, token).ConfigureAwait(false);
|
||||
|
||||
var sem = GetCompressLock(hash);
|
||||
await sem.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (File.Exists(compressedPath))
|
||||
return await File.ReadAllBytesAsync(compressedPath, token).ConfigureAwait(false);
|
||||
|
||||
var entity = GetFileCacheByHash(hash);
|
||||
if (entity == null || string.IsNullOrWhiteSpace(entity.ResolvedFilepath))
|
||||
throw new InvalidOperationException($"No local file cache found for hash {hash}.");
|
||||
|
||||
var sourcePath = entity.ResolvedFilepath;
|
||||
var originalSize = new FileInfo(sourcePath).Length;
|
||||
|
||||
var raw = await File.ReadAllBytesAsync(sourcePath, token).ConfigureAwait(false);
|
||||
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||
|
||||
var tmpPath = compressedPath + ".tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(tmpPath, compressed, token).ConfigureAwait(false);
|
||||
File.Move(tmpPath, compressedPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var compressedSize = new FileInfo(compressedPath).Length;
|
||||
SetSizeInfo(hash, originalSize, compressedSize);
|
||||
UpdateEntitiesSizes(hash, originalSize, compressedSize);
|
||||
|
||||
var maxBytes = GiBToBytes(_configService.Current.MaxLocalCacheInGiB);
|
||||
await EnforceCacheLimitAsync(maxBytes, token).ConfigureAwait(false);
|
||||
|
||||
return compressed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sem.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string NormalizeToPrefixedPath(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return string.Empty;
|
||||
@@ -133,13 +273,9 @@ public sealed class FileCacheManager : IHostedService
|
||||
chosenLength = penumbraMatch;
|
||||
}
|
||||
|
||||
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch))
|
||||
if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch) && cacheMatch > chosenLength)
|
||||
{
|
||||
if (cacheMatch > chosenLength)
|
||||
{
|
||||
chosenPrefixed = cachePrefixed;
|
||||
chosenLength = cacheMatch;
|
||||
}
|
||||
chosenPrefixed = cachePrefixed;
|
||||
}
|
||||
|
||||
return NormalizePrefixedPathKey(chosenPrefixed ?? normalized);
|
||||
@@ -155,6 +291,26 @@ public sealed class FileCacheManager : IHostedService
|
||||
return CreateFileEntity(cacheFolder, CachePrefix, fi);
|
||||
}
|
||||
|
||||
public FileCacheEntity? CreateCacheEntryWithKnownHash(string path, string hash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
return CreateCacheEntry(path);
|
||||
}
|
||||
|
||||
FileInfo fi = new(path);
|
||||
if (!fi.Exists) return null;
|
||||
_logger.LogTrace("Creating cache entry for {path} using provided hash", path);
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrEmpty(cacheFolder)) return null;
|
||||
if (!TryBuildPrefixedPath(fi.FullName, cacheFolder, CachePrefix, out var prefixedPath, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return CreateFileCacheEntity(fi, prefixedPath, hash);
|
||||
}
|
||||
|
||||
public FileCacheEntity? CreateFileEntry(string path)
|
||||
{
|
||||
FileInfo fi = new(path);
|
||||
@@ -175,69 +331,126 @@ public sealed class FileCacheManager : IHostedService
|
||||
return CreateFileCacheEntity(fi, prefixedPath);
|
||||
}
|
||||
|
||||
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).ToList();
|
||||
public List<FileCacheEntity> GetAllFileCaches() => [.. _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null))];
|
||||
|
||||
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
|
||||
{
|
||||
List<FileCacheEntity> output = [];
|
||||
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
|
||||
var output = new List<FileCacheEntity>();
|
||||
|
||||
if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities))
|
||||
return output;
|
||||
|
||||
foreach (var fileCache in fileCacheEntities.Values
|
||||
.Where(c => !ignoreCacheEntries || !c.IsCacheEntry))
|
||||
{
|
||||
foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList())
|
||||
if (!validate)
|
||||
{
|
||||
if (!validate)
|
||||
{
|
||||
output.Add(fileCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
var validated = GetValidatedFileCache(fileCache);
|
||||
if (validated != null)
|
||||
{
|
||||
output.Add(validated);
|
||||
}
|
||||
}
|
||||
output.Add(fileCache);
|
||||
continue;
|
||||
}
|
||||
|
||||
var validated = GetValidatedFileCache(fileCache);
|
||||
if (validated != null)
|
||||
output.Add(validated);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async Task<List<FileCacheEntity>> GetAllFileCachesByHashAsync(string hash, bool ignoreCacheEntries = false, bool validate = true,CancellationToken token = default)
|
||||
{
|
||||
var output = new List<FileCacheEntity>();
|
||||
|
||||
if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities))
|
||||
return output;
|
||||
|
||||
foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (!validate)
|
||||
{
|
||||
output.Add(fileCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
var validated = await GetValidatedFileCacheAsync(fileCache, token).ConfigureAwait(false);
|
||||
|
||||
if (validated != null)
|
||||
output.Add(validated);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
|
||||
public async Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int completed, int total, FileCacheEntity current)> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
_lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
|
||||
_logger.LogInformation("Validating local storage");
|
||||
var cacheEntries = _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).Where(v => v.IsCacheEntry).ToList();
|
||||
List<FileCacheEntity> brokenEntities = [];
|
||||
int i = 0;
|
||||
foreach (var fileCache in cacheEntries)
|
||||
|
||||
var cacheEntries = _fileCaches.Values
|
||||
.SelectMany(v => v.Values)
|
||||
.Where(v => v.IsCacheEntry)
|
||||
.ToList();
|
||||
|
||||
int total = cacheEntries.Count;
|
||||
int processed = 0;
|
||||
var brokenEntities = new ConcurrentBag<FileCacheEntity>();
|
||||
|
||||
_logger.LogInformation("Checking {count} cache entries...", total);
|
||||
|
||||
await Parallel.ForEachAsync(cacheEntries, new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Environment.ProcessorCount,
|
||||
CancellationToken = cancellationToken
|
||||
},
|
||||
async (fileCache, token) =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
_logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath);
|
||||
|
||||
progress.Report((i, cacheEntries.Count, fileCache));
|
||||
i++;
|
||||
if (!File.Exists(fileCache.ResolvedFilepath))
|
||||
{
|
||||
brokenEntities.Add(fileCache);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
|
||||
int current = Interlocked.Increment(ref processed);
|
||||
if (current % 10 == 0)
|
||||
progress.Report((current, total, fileCache));
|
||||
|
||||
if (!File.Exists(fileCache.ResolvedFilepath))
|
||||
{
|
||||
brokenEntities.Add(fileCache);
|
||||
return;
|
||||
}
|
||||
|
||||
var algo = Crypto.DetectAlgo(fileCache.Hash);
|
||||
string computedHash;
|
||||
try
|
||||
{
|
||||
computedHash = await Crypto.ComputeFileHashAsync(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error hashing {file}", fileCache.ResolvedFilepath);
|
||||
brokenEntities.Add(fileCache);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
|
||||
_logger.LogInformation(
|
||||
"Hash mismatch: {file} (got {computedHash}, expected {expected} : hash {hash})",
|
||||
fileCache.ResolvedFilepath, computedHash, fileCache.Hash, algo);
|
||||
|
||||
brokenEntities.Add(fileCache);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
|
||||
_logger.LogError("Validation got cancelled for {file}", fileCache.ResolvedFilepath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error validating {file}", fileCache.ResolvedFilepath);
|
||||
brokenEntities.Add(fileCache);
|
||||
}
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
foreach (var brokenEntity in brokenEntities)
|
||||
{
|
||||
@@ -249,12 +462,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
|
||||
_logger.LogWarning(ex, "Failed to delete invalid cache file {file}", brokenEntity.ResolvedFilepath);
|
||||
}
|
||||
}
|
||||
|
||||
_lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
|
||||
return Task.FromResult(brokenEntities);
|
||||
_logger.LogInformation("Validation complete. Found {count} invalid entries.", brokenEntities.Count);
|
||||
|
||||
return [.. brokenEntities];
|
||||
}
|
||||
|
||||
public string GetCacheFilePath(string hash, string extension)
|
||||
@@ -264,9 +479,18 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CacheFolder))
|
||||
{
|
||||
var bytes = await EnsureCompressedCacheBytesAsync(fileHash, uploadToken).ConfigureAwait(false);
|
||||
UpdateSizeInfo(fileHash, compressed: bytes.LongLength);
|
||||
return (fileHash, bytes);
|
||||
}
|
||||
|
||||
var fileCache = GetFileCacheByHash(fileHash)!.ResolvedFilepath;
|
||||
return (fileHash, LZ4Wrapper.WrapHC(await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false), 0,
|
||||
(int)new FileInfo(fileCache).Length));
|
||||
var raw = await File.ReadAllBytesAsync(fileCache, uploadToken).ConfigureAwait(false);
|
||||
var compressed = LZ4Wrapper.WrapHC(raw, 0, raw.Length);
|
||||
UpdateSizeInfo(fileHash, original: raw.LongLength, compressed: compressed.LongLength);
|
||||
return (fileHash, compressed);
|
||||
}
|
||||
|
||||
public FileCacheEntity? GetFileCacheByHash(string hash)
|
||||
@@ -369,9 +593,10 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveHashedFile(string hash, string prefixedFilePath)
|
||||
public void RemoveHashedFile(string hash, string prefixedFilePath, bool removeDerivedFiles = true)
|
||||
{
|
||||
var normalizedPath = NormalizePrefixedPathKey(prefixedFilePath);
|
||||
var removedHash = false;
|
||||
|
||||
if (_fileCaches.TryGetValue(hash, out var caches))
|
||||
{
|
||||
@@ -384,11 +609,16 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
if (caches.IsEmpty)
|
||||
{
|
||||
_fileCaches.TryRemove(hash, out _);
|
||||
removedHash = _fileCaches.TryRemove(hash, out _);
|
||||
}
|
||||
}
|
||||
|
||||
_fileCachesByPrefixedPath.TryRemove(normalizedPath, out _);
|
||||
|
||||
if (removeDerivedFiles && removedHash)
|
||||
{
|
||||
RemoveDerivedCacheFiles(hash);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
|
||||
@@ -401,10 +631,11 @@ public sealed class FileCacheManager : IHostedService
|
||||
var fi = new FileInfo(fileCache.ResolvedFilepath);
|
||||
fileCache.Size = fi.Length;
|
||||
fileCache.CompressedSize = null;
|
||||
fileCache.Hash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
|
||||
fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1);
|
||||
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
RemoveHashedFile(oldHash, prefixedPath);
|
||||
var removeDerivedFiles = !string.Equals(oldHash, fileCache.Hash, StringComparison.OrdinalIgnoreCase);
|
||||
RemoveHashedFile(oldHash, prefixedPath, removeDerivedFiles);
|
||||
AddHashedFile(fileCache);
|
||||
}
|
||||
|
||||
@@ -452,6 +683,44 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteOutFullCsvAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _fileWriteSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(BuildVersionHeader());
|
||||
|
||||
foreach (var entry in _fileCaches.Values
|
||||
.SelectMany(k => k.Values)
|
||||
.OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.AppendLine(entry.CsvEntry);
|
||||
}
|
||||
|
||||
if (File.Exists(_csvPath))
|
||||
{
|
||||
File.Copy(_csvPath, CsvBakPath, overwrite: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(_csvPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
File.Delete(CsvBakPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await File.WriteAllTextAsync(CsvBakPath, sb.ToString(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_fileWriteSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureCsvHeaderLocked()
|
||||
{
|
||||
if (!File.Exists(_csvPath))
|
||||
@@ -462,6 +731,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
string[] existingLines = File.ReadAllLines(_csvPath);
|
||||
if (existingLines.Length > 0 && TryParseVersionHeader(existingLines[0], out var existingVersion) && existingVersion == FileCacheVersion)
|
||||
{
|
||||
_csvHeaderEnsured = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -481,6 +751,18 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
|
||||
File.WriteAllText(_csvPath, rebuilt.ToString());
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
|
||||
private void EnsureCsvHeaderLockedCached()
|
||||
{
|
||||
if (_csvHeaderEnsured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureCsvHeaderLocked();
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
|
||||
private void BackupUnsupportedCache(string suffix)
|
||||
@@ -503,7 +785,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
{
|
||||
try
|
||||
{
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath, removeDerivedFiles: false);
|
||||
var extensionPath = fileCache.ResolvedFilepath.ToUpper(CultureInfo.InvariantCulture) + "." + ext;
|
||||
File.Move(fileCache.ResolvedFilepath, extensionPath, overwrite: true);
|
||||
var newHashedEntity = new FileCacheEntity(fileCache.Hash, fileCache.PrefixedFilePath + "." + ext, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));
|
||||
@@ -520,6 +802,33 @@ public sealed class FileCacheManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveDerivedCacheFiles(string hash)
|
||||
{
|
||||
var cacheFolder = _configService.Current.CacheFolder;
|
||||
if (string.IsNullOrWhiteSpace(cacheFolder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "downscaled", $"{hash}.tex"));
|
||||
TryDeleteDerivedCacheFile(Path.Combine(cacheFolder, "decimated", $"{hash}.mdl"));
|
||||
}
|
||||
|
||||
private void TryDeleteDerivedCacheFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogTrace(ex, "Failed to delete derived cache file {path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddHashedFile(FileCacheEntity fileCache)
|
||||
{
|
||||
var normalizedPath = NormalizePrefixedPathKey(fileCache.PrefixedFilePath);
|
||||
@@ -531,7 +840,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null)
|
||||
{
|
||||
hash ??= Crypto.GetFileHash(fileInfo.FullName);
|
||||
hash ??= Crypto.ComputeFileHash(fileInfo.FullName, Crypto.HashAlgo.Sha1);
|
||||
var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length);
|
||||
entity = ReplacePathPrefixes(entity);
|
||||
AddHashedFile(entity);
|
||||
@@ -539,12 +848,13 @@ public sealed class FileCacheManager : IHostedService
|
||||
{
|
||||
if (!File.Exists(_csvPath))
|
||||
{
|
||||
File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry });
|
||||
File.WriteAllLines(_csvPath, [BuildVersionHeader(), entity.CsvEntry]);
|
||||
_csvHeaderEnsured = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureCsvHeaderLocked();
|
||||
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
|
||||
EnsureCsvHeaderLockedCached();
|
||||
File.AppendAllLines(_csvPath, [entity.CsvEntry]);
|
||||
}
|
||||
}
|
||||
var result = GetFileCacheByPath(fileInfo.FullName);
|
||||
@@ -555,11 +865,17 @@ public sealed class FileCacheManager : IHostedService
|
||||
private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache)
|
||||
{
|
||||
var resultingFileCache = ReplacePathPrefixes(fileCache);
|
||||
//_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath);
|
||||
resultingFileCache = Validate(resultingFileCache);
|
||||
return resultingFileCache;
|
||||
}
|
||||
|
||||
private async Task<FileCacheEntity?> GetValidatedFileCacheAsync(FileCacheEntity fileCache, CancellationToken token = default)
|
||||
{
|
||||
var resultingFileCache = ReplacePathPrefixes(fileCache);
|
||||
resultingFileCache = await ValidateAsync(resultingFileCache, token).ConfigureAwait(false);
|
||||
return resultingFileCache;
|
||||
}
|
||||
|
||||
private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache)
|
||||
{
|
||||
if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -582,6 +898,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var file = new FileInfo(fileCache.ResolvedFilepath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
@@ -589,7 +906,8 @@ public sealed class FileCacheManager : IHostedService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
|
||||
var lastWriteTicks = file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.Equals(lastWriteTicks, fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
|
||||
{
|
||||
UpdateHashedFile(fileCache);
|
||||
}
|
||||
@@ -597,7 +915,111 @@ public sealed class FileCacheManager : IHostedService
|
||||
return fileCache;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
private async Task<FileCacheEntity?> ValidateAsync(FileCacheEntity fileCache, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileCache.ResolvedFilepath))
|
||||
{
|
||||
_logger.LogWarning("FileCacheEntity has empty ResolvedFilepath for hash {hash}, prefixed path {prefixed}", fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var file = new FileInfo(fileCache.ResolvedFilepath);
|
||||
if (!file.Exists)
|
||||
{
|
||||
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
|
||||
{
|
||||
UpdateHashedFile(fileCache);
|
||||
}
|
||||
|
||||
return fileCache;
|
||||
}, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnforceCacheLimitAsync(long maxBytes, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder) || maxBytes <= 0) return;
|
||||
|
||||
await _evictSemaphore.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(CacheFolder);
|
||||
|
||||
foreach (var tmp in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension + ".tmp"))
|
||||
{
|
||||
try { File.Delete(tmp); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension, SearchOption.TopDirectoryOnly)
|
||||
.Select(p => new FileInfo(p))
|
||||
.Where(fi => fi.Exists)
|
||||
.OrderBy(fi => fi.LastWriteTimeUtc)
|
||||
.ToList();
|
||||
|
||||
long total = files.Sum(f => f.Length);
|
||||
if (total <= maxBytes) return;
|
||||
|
||||
foreach (var fi in files)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (total <= maxBytes) break;
|
||||
|
||||
var hash = Path.GetFileNameWithoutExtension(fi.Name);
|
||||
|
||||
try
|
||||
{
|
||||
var len = fi.Length;
|
||||
fi.Delete();
|
||||
total -= len;
|
||||
_sizeCache.TryRemove(hash, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to evict cache file {file}", fi.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static long GiBToBytes(double gib)
|
||||
{
|
||||
if (double.IsNaN(gib) || double.IsInfinity(gib) || gib <= 0)
|
||||
return 0;
|
||||
|
||||
var bytes = gib * 1024d * 1024d * 1024d;
|
||||
|
||||
if (bytes >= long.MaxValue) return long.MaxValue;
|
||||
|
||||
return (long)Math.Round(bytes, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private void CleanupOrphanCompressedCache()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CacheFolder) || !Directory.Exists(CacheFolder))
|
||||
return;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(CacheFolder, "*" + _compressedCacheExtension))
|
||||
{
|
||||
var hash = Path.GetFileNameWithoutExtension(path);
|
||||
if (!_fileCaches.ContainsKey(hash))
|
||||
{
|
||||
try { File.Delete(path); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Failed deleting orphan {file}", path); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting FileCacheManager");
|
||||
|
||||
@@ -648,14 +1070,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Attempting to read {csvPath}", _csvPath);
|
||||
entries = File.ReadAllLines(_csvPath);
|
||||
entries = await File.ReadAllLinesAsync(_csvPath, cancellationToken).ConfigureAwait(false);
|
||||
success = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attempts++;
|
||||
_logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath);
|
||||
Task.Delay(100, cancellationToken);
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,7 +1104,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
BackupUnsupportedCache("invalid-version");
|
||||
parseEntries = false;
|
||||
rewriteRequired = true;
|
||||
entries = Array.Empty<string>();
|
||||
entries = [];
|
||||
}
|
||||
else if (parsedVersion != FileCacheVersion)
|
||||
{
|
||||
@@ -690,7 +1112,7 @@ public sealed class FileCacheManager : IHostedService
|
||||
BackupUnsupportedCache($"v{parsedVersion}");
|
||||
parseEntries = false;
|
||||
rewriteRequired = true;
|
||||
entries = Array.Empty<string>();
|
||||
entries = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -750,6 +1172,14 @@ public sealed class FileCacheManager : IHostedService
|
||||
compressed = resultCompressed;
|
||||
}
|
||||
}
|
||||
|
||||
if (size > 0 || compressed > 0)
|
||||
{
|
||||
UpdateSizeInfo(hash,
|
||||
original: size > 0 ? size : null,
|
||||
compressed: compressed > 0 ? compressed : null);
|
||||
}
|
||||
|
||||
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -770,18 +1200,20 @@ public sealed class FileCacheManager : IHostedService
|
||||
|
||||
if (rewriteRequired)
|
||||
{
|
||||
WriteOutFullCsv();
|
||||
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
CleanupOrphanCompressedCache();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Started FileCacheManager");
|
||||
|
||||
return Task.CompletedTask;
|
||||
_lightlessMediator.Publish(new FileCacheInitializedMessage());
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
WriteOutFullCsv();
|
||||
return Task.CompletedTask;
|
||||
await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false);
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,4 +5,5 @@ public enum FileState
|
||||
Valid,
|
||||
RequireUpdate,
|
||||
RequireDeletion,
|
||||
RequireRehash
|
||||
}
|
||||
@@ -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,56 @@ 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 string[] _handledFileTypesWithRecording;
|
||||
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;
|
||||
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
|
||||
|
||||
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;
|
||||
_playerRelatedPointers.Add(msg.GameObjectHandler);
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
_playerRelatedPointers.Add(msg.GameObjectHandler);
|
||||
}
|
||||
});
|
||||
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
|
||||
{
|
||||
if (!msg.OwnedObject) return;
|
||||
_playerRelatedPointers.Remove(msg.GameObjectHandler);
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
_playerRelatedPointers.Remove(msg.GameObjectHandler);
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var descriptor in _actorObjectService.ObjectDescriptors)
|
||||
{
|
||||
HandleActorTracked(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
private TransientConfig.TransientPlayerConfig PlayerConfig
|
||||
@@ -59,7 +85,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 +94,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 +137,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,12 +152,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
return;
|
||||
}
|
||||
|
||||
var transientResources = resources.ToList();
|
||||
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
|
||||
List<string> newlyAddedGamePaths = resources.Except(semiTransientResources, StringComparer.Ordinal).ToList();
|
||||
foreach (var gamePath in transientResources)
|
||||
List<string> transientResources;
|
||||
lock (resources)
|
||||
{
|
||||
semiTransientResources.Add(gamePath);
|
||||
transientResources = resources.ToList();
|
||||
}
|
||||
|
||||
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
|
||||
List<string> newlyAddedGamePaths;
|
||||
lock (semiTransientResources)
|
||||
{
|
||||
newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
foreach (var gamePath in transientResources)
|
||||
{
|
||||
semiTransientResources.Add(gamePath);
|
||||
}
|
||||
}
|
||||
|
||||
bool saveConfig = false;
|
||||
@@ -161,17 +199,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 +222,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,11 +284,21 @@ 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();
|
||||
@@ -253,22 +306,67 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (_lastClassJobId != _dalamudUtil.ClassJobId)
|
||||
{
|
||||
_lastClassJobId = _dalamudUtil.ClassJobId;
|
||||
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
|
||||
{
|
||||
value?.Clear();
|
||||
}
|
||||
|
||||
// reload config for current new classjob
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []];
|
||||
UpdateClassJobCache();
|
||||
}
|
||||
|
||||
foreach (var kind in Enum.GetValues(typeof(ObjectKind)))
|
||||
CleanupAbsentObjects();
|
||||
}
|
||||
|
||||
private void RefreshPlayerRelatedAddressMap()
|
||||
{
|
||||
var tempMap = new ConcurrentDictionary<nint, GameObjectHandler>();
|
||||
var updatedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>();
|
||||
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
if (!_cachedFrameAddresses.Any(k => k.Value == (ObjectKind)kind) && TransientResources.Remove((ObjectKind)kind, out _))
|
||||
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))
|
||||
{
|
||||
value?.Clear();
|
||||
}
|
||||
|
||||
PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData);
|
||||
SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache
|
||||
.Concat(jobSpecificData ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData);
|
||||
SemiTransientResources[ObjectKind.Pet] = new HashSet<string>(
|
||||
petSpecificData ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void CleanupAbsentObjects()
|
||||
{
|
||||
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,9 +378,12 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
|
||||
foreach (var item in _playerRelatedPointers)
|
||||
lock (_playerRelatedLock)
|
||||
{
|
||||
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
|
||||
foreach (var item in _playerRelatedPointers)
|
||||
{
|
||||
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -292,53 +393,176 @@ 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();
|
||||
var gameObjectAddress = msg.GameObject;
|
||||
var filePath = msg.FilePath;
|
||||
|
||||
// ignore files already processed this frame
|
||||
if (_cachedHandledPaths.Contains(gamePath)) return;
|
||||
|
||||
lock (_cacheAdditionLock)
|
||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind))
|
||||
{
|
||||
objectKind = ownedKind;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// replace individual mtrl stuff
|
||||
if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filePath = filePath.Split("|")[2];
|
||||
}
|
||||
// replace filepath
|
||||
filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// ignore files that are the same
|
||||
var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase))
|
||||
var gamePath = NormalizeGamePath(msg.GamePath);
|
||||
if (string.IsNullOrEmpty(gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore files already processed this frame
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
if (!_cachedHandledPaths.Add(gamePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore files to not handle
|
||||
var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes;
|
||||
if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
|
||||
var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes;
|
||||
if (!HasHandledFileType(gamePath, handledTypes))
|
||||
{
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore files not belonging to anything player related
|
||||
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
|
||||
var filePath = NormalizeFilePath(msg.FilePath);
|
||||
|
||||
// ignore files that are the same
|
||||
if (string.Equals(filePath, gamePath, StringComparison.Ordinal))
|
||||
{
|
||||
lock (_cacheAdditionLock)
|
||||
{
|
||||
_cachedHandledPaths.Add(gamePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,15 +574,15 @@ 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 transientContains = transientResources.Contains(gamePath);
|
||||
bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath));
|
||||
if (transientContains || semiTransientContains)
|
||||
{
|
||||
if (!IsTransientRecording)
|
||||
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath,
|
||||
Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath,
|
||||
transientContains, semiTransientContains);
|
||||
alreadyTransient = true;
|
||||
}
|
||||
@@ -366,10 +590,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
{
|
||||
if (!IsTransientRecording)
|
||||
{
|
||||
bool isAdded = transientResources.Add(replacedGamePath);
|
||||
bool isAdded = transientResources.Add(gamePath);
|
||||
if (isAdded)
|
||||
{
|
||||
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
|
||||
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath);
|
||||
SendTransients(gameObjectAddress, objectKind);
|
||||
}
|
||||
}
|
||||
@@ -377,27 +601,36 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
|
||||
if (owner != null && IsTransientRecording)
|
||||
{
|
||||
_recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
|
||||
_recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
|
||||
}
|
||||
}
|
||||
|
||||
private void SendTransients(nint gameObject, ObjectKind objectKind)
|
||||
{
|
||||
_sendTransientCts.Cancel();
|
||||
_sendTransientCts = new();
|
||||
var token = _sendTransientCts.Token;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
_sendTransientCts?.Cancel();
|
||||
_sendTransientCts?.Dispose();
|
||||
_sendTransientCts = new();
|
||||
var token = _sendTransientCts.Token;
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
|
||||
foreach (var kvp in TransientResources)
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false);
|
||||
|
||||
if (TransientResources.TryGetValue(objectKind, out var values) && values.Any())
|
||||
{
|
||||
Logger.LogTrace("Sending Transients for {kind}", objectKind);
|
||||
Mediator.Publish(new TransientResourceChangedMessage(gameObject));
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,7 +673,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Lifestream.Enums;
|
||||
|
||||
public enum ResidentialAetheryteKind
|
||||
{
|
||||
None = -1,
|
||||
Uldah = 9,
|
||||
Gridania = 2,
|
||||
Limsa = 8,
|
||||
Foundation = 70,
|
||||
Kugane = 111,
|
||||
}
|
||||
1
LightlessSync/Interop/InteropModel/GlobalModels.cs
Normal file
1
LightlessSync/Interop/InteropModel/GlobalModels.cs
Normal 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);
|
||||
196
LightlessSync/Interop/Ipc/Framework/IpcFramework.cs
Normal file
196
LightlessSync/Interop/Ipc/Framework/IpcFramework.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace LightlessSync.Interop.Ipc;
|
||||
|
||||
public interface IIpcCaller : IDisposable
|
||||
{
|
||||
bool APIAvailable { get; }
|
||||
void CheckAPI();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Ipc;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc;
|
||||
|
||||
public sealed class IpcCallerHeels : IIpcCaller
|
||||
public sealed class IpcCallerHeels : IpcServiceBase
|
||||
{
|
||||
private static readonly IpcServiceDescriptor HeelsDescriptor = new("SimpleHeels", "Simple Heels", new Version(0, 0, 0, 0));
|
||||
|
||||
private readonly ILogger<IpcCallerHeels> _logger;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
@@ -18,6 +21,7 @@ public sealed class IpcCallerHeels : IIpcCaller
|
||||
private readonly ICallGateSubscriber<int, object?> _heelsUnregisterPlayer;
|
||||
|
||||
public IpcCallerHeels(ILogger<IpcCallerHeels> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator)
|
||||
: base(logger, lightlessMediator, pi, HeelsDescriptor)
|
||||
{
|
||||
_logger = logger;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -32,8 +36,26 @@ public sealed class IpcCallerHeels : IIpcCaller
|
||||
|
||||
CheckAPI();
|
||||
}
|
||||
protected override IpcConnectionState EvaluateState()
|
||||
{
|
||||
var state = base.EvaluateState();
|
||||
if (state != IpcConnectionState.Available)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
public bool APIAvailable { get; private set; } = false;
|
||||
try
|
||||
{
|
||||
return _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 1 }
|
||||
? IpcConnectionState.Available
|
||||
: IpcConnectionState.VersionMismatch;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to query SimpleHeels API version");
|
||||
return IpcConnectionState.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private void HeelsOffsetChange(string offset)
|
||||
{
|
||||
@@ -74,20 +96,14 @@ public sealed class IpcCallerHeels : IIpcCaller
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void CheckAPI()
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
try
|
||||
base.Dispose(disposing);
|
||||
if (!disposing)
|
||||
{
|
||||
APIAvailable = _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 1 };
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
APIAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_heelsOffsetUpdate.Unsubscribe(HeelsOffsetChange);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Ipc;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -8,8 +9,10 @@ using System.Text;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc;
|
||||
|
||||
public sealed class IpcCallerHonorific : IIpcCaller
|
||||
public sealed class IpcCallerHonorific : IpcServiceBase
|
||||
{
|
||||
private static readonly IpcServiceDescriptor HonorificDescriptor = new("Honorific", "Honorific", new Version(0, 0, 0, 0));
|
||||
|
||||
private readonly ICallGateSubscriber<(uint major, uint minor)> _honorificApiVersion;
|
||||
private readonly ICallGateSubscriber<int, object> _honorificClearCharacterTitle;
|
||||
private readonly ICallGateSubscriber<object> _honorificDisposing;
|
||||
@@ -22,7 +25,7 @@ public sealed class IpcCallerHonorific : IIpcCaller
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
|
||||
public IpcCallerHonorific(ILogger<IpcCallerHonorific> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
|
||||
LightlessMediator lightlessMediator)
|
||||
LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, HonorificDescriptor)
|
||||
{
|
||||
_logger = logger;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
@@ -41,23 +44,14 @@ public sealed class IpcCallerHonorific : IIpcCaller
|
||||
|
||||
CheckAPI();
|
||||
}
|
||||
|
||||
public bool APIAvailable { get; private set; } = false;
|
||||
|
||||
public void CheckAPI()
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
try
|
||||
base.Dispose(disposing);
|
||||
if (!disposing)
|
||||
{
|
||||
APIAvailable = _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 };
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
APIAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_honorificLocalCharacterTitleChanged.Unsubscribe(OnHonorificLocalCharacterTitleChanged);
|
||||
_honorificDisposing.Unsubscribe(OnHonorificDisposing);
|
||||
_honorificReady.Unsubscribe(OnHonorificReady);
|
||||
@@ -113,6 +107,27 @@ public sealed class IpcCallerHonorific : IIpcCaller
|
||||
}
|
||||
}
|
||||
|
||||
protected override IpcConnectionState EvaluateState()
|
||||
{
|
||||
var state = base.EvaluateState();
|
||||
if (state != IpcConnectionState.Available)
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 }
|
||||
? IpcConnectionState.Available
|
||||
: IpcConnectionState.VersionMismatch;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to query Honorific API version");
|
||||
return IpcConnectionState.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHonorificDisposing()
|
||||
{
|
||||
_lightlessMediator.Publish(new HonorificMessage(string.Empty));
|
||||
|
||||
129
LightlessSync/Interop/Ipc/IpcCallerLifestream.cs
Normal file
129
LightlessSync/Interop/Ipc/IpcCallerLifestream.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
APIAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +1,206 @@
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Interop.Ipc.Penumbra;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
using Penumbra.Api.Helpers;
|
||||
using Penumbra.Api.IpcSubscribers;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc;
|
||||
|
||||
public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCaller
|
||||
public sealed class IpcCallerPenumbra : IpcServiceBase
|
||||
{
|
||||
private readonly IDalamudPluginInterface _pi;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly LightlessMediator _lightlessMediator;
|
||||
private readonly RedrawManager _redrawManager;
|
||||
private bool _shownPenumbraUnavailable = false;
|
||||
private string? _penumbraModDirectory;
|
||||
public string? ModDirectory
|
||||
{
|
||||
get => _penumbraModDirectory;
|
||||
private set
|
||||
{
|
||||
if (!string.Equals(_penumbraModDirectory, value, StringComparison.Ordinal))
|
||||
{
|
||||
_penumbraModDirectory = value;
|
||||
_lightlessMediator.Publish(new PenumbraDirectoryChangedMessage(_penumbraModDirectory));
|
||||
}
|
||||
}
|
||||
}
|
||||
private static readonly IpcServiceDescriptor PenumbraDescriptor = new("Penumbra", "Penumbra", new Version(1, 2, 0, 22));
|
||||
|
||||
private readonly ConcurrentDictionary<IntPtr, bool> _penumbraRedrawRequests = new();
|
||||
private readonly PenumbraCollections _collections;
|
||||
private readonly PenumbraResource _resources;
|
||||
private readonly PenumbraRedraw _redraw;
|
||||
private readonly PenumbraTexture _textures;
|
||||
|
||||
private readonly EventSubscriber _penumbraDispose;
|
||||
private readonly EventSubscriber<nint, string, string> _penumbraGameObjectResourcePathResolved;
|
||||
private readonly EventSubscriber _penumbraInit;
|
||||
private readonly EventSubscriber<ModSettingChange, Guid, string, bool> _penumbraModSettingChanged;
|
||||
private readonly EventSubscriber<nint, int> _penumbraObjectIsRedrawn;
|
||||
|
||||
private readonly AddTemporaryMod _penumbraAddTemporaryMod;
|
||||
private readonly AssignTemporaryCollection _penumbraAssignTemporaryCollection;
|
||||
private readonly ConvertTextureFile _penumbraConvertTextureFile;
|
||||
private readonly CreateTemporaryCollection _penumbraCreateNamedTemporaryCollection;
|
||||
private readonly GetEnabledState _penumbraEnabled;
|
||||
private readonly GetPlayerMetaManipulations _penumbraGetMetaManipulations;
|
||||
private readonly RedrawObject _penumbraRedraw;
|
||||
private readonly DeleteTemporaryCollection _penumbraRemoveTemporaryCollection;
|
||||
private readonly RemoveTemporaryMod _penumbraRemoveTemporaryMod;
|
||||
private readonly GetModDirectory _penumbraResolveModDir;
|
||||
private readonly ResolvePlayerPathsAsync _penumbraResolvePaths;
|
||||
private readonly GetGameObjectResourcePaths _penumbraResourcePaths;
|
||||
private readonly GetModDirectory _penumbraGetModDirectory;
|
||||
private readonly EventSubscriber _penumbraInit;
|
||||
private readonly EventSubscriber _penumbraDispose;
|
||||
private readonly EventSubscriber<ModSettingChange, Guid, string, bool> _penumbraModSettingChanged;
|
||||
|
||||
public IpcCallerPenumbra(ILogger<IpcCallerPenumbra> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
|
||||
LightlessMediator lightlessMediator, RedrawManager redrawManager) : base(logger, lightlessMediator)
|
||||
private bool _shownPenumbraUnavailable;
|
||||
private string? _modDirectory;
|
||||
|
||||
public IpcCallerPenumbra(
|
||||
ILogger<IpcCallerPenumbra> logger,
|
||||
IDalamudPluginInterface pluginInterface,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessMediator mediator,
|
||||
RedrawManager redrawManager,
|
||||
ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor)
|
||||
{
|
||||
_pi = pi;
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_redrawManager = redrawManager;
|
||||
_penumbraInit = Initialized.Subscriber(pi, PenumbraInit);
|
||||
_penumbraDispose = Disposed.Subscriber(pi, PenumbraDispose);
|
||||
_penumbraResolveModDir = new GetModDirectory(pi);
|
||||
_penumbraRedraw = new RedrawObject(pi);
|
||||
_penumbraObjectIsRedrawn = GameObjectRedrawn.Subscriber(pi, RedrawEvent);
|
||||
_penumbraGetMetaManipulations = new GetPlayerMetaManipulations(pi);
|
||||
_penumbraRemoveTemporaryMod = new RemoveTemporaryMod(pi);
|
||||
_penumbraAddTemporaryMod = new AddTemporaryMod(pi);
|
||||
_penumbraCreateNamedTemporaryCollection = new CreateTemporaryCollection(pi);
|
||||
_penumbraRemoveTemporaryCollection = new DeleteTemporaryCollection(pi);
|
||||
_penumbraAssignTemporaryCollection = new AssignTemporaryCollection(pi);
|
||||
_penumbraResolvePaths = new ResolvePlayerPathsAsync(pi);
|
||||
_penumbraEnabled = new GetEnabledState(pi);
|
||||
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pi, (change, arg1, arg, b) =>
|
||||
{
|
||||
if (change == ModSettingChange.EnableState)
|
||||
_lightlessMediator.Publish(new PenumbraModSettingChangedMessage());
|
||||
});
|
||||
_penumbraConvertTextureFile = new ConvertTextureFile(pi);
|
||||
_penumbraResourcePaths = new GetGameObjectResourcePaths(pi);
|
||||
_penumbraEnabled = new GetEnabledState(pluginInterface);
|
||||
_penumbraGetModDirectory = new GetModDirectory(pluginInterface);
|
||||
_penumbraInit = Initialized.Subscriber(pluginInterface, HandlePenumbraInitialized);
|
||||
_penumbraDispose = Disposed.Subscriber(pluginInterface, HandlePenumbraDisposed);
|
||||
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged);
|
||||
|
||||
_penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded);
|
||||
_collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator));
|
||||
_resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator, actorObjectService));
|
||||
_redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager));
|
||||
_textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw));
|
||||
|
||||
SubscribeMediatorEvents();
|
||||
|
||||
CheckAPI();
|
||||
CheckModDirectory();
|
||||
|
||||
Mediator.Subscribe<PenumbraRedrawCharacterMessage>(this, (msg) =>
|
||||
{
|
||||
_penumbraRedraw.Invoke(msg.Character.ObjectIndex, RedrawType.AfterGPose);
|
||||
});
|
||||
|
||||
Mediator.Subscribe<DalamudLoginMessage>(this, (msg) => _shownPenumbraUnavailable = false);
|
||||
}
|
||||
|
||||
public bool APIAvailable { get; private set; } = false;
|
||||
|
||||
public void CheckAPI()
|
||||
public string? ModDirectory
|
||||
{
|
||||
bool penumbraAvailable = false;
|
||||
try
|
||||
get => _modDirectory;
|
||||
private set
|
||||
{
|
||||
var penumbraVersion = (_pi.InstalledPlugins
|
||||
.FirstOrDefault(p => string.Equals(p.InternalName, "Penumbra", StringComparison.OrdinalIgnoreCase))
|
||||
?.Version ?? new Version(0, 0, 0, 0));
|
||||
penumbraAvailable = penumbraVersion >= new Version(1, 2, 0, 22);
|
||||
try
|
||||
if (string.Equals(_modDirectory, value, StringComparison.Ordinal))
|
||||
{
|
||||
penumbraAvailable &= _penumbraEnabled.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
penumbraAvailable = false;
|
||||
}
|
||||
_shownPenumbraUnavailable = _shownPenumbraUnavailable && !penumbraAvailable;
|
||||
APIAvailable = penumbraAvailable;
|
||||
}
|
||||
catch
|
||||
{
|
||||
APIAvailable = penumbraAvailable;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!penumbraAvailable && !_shownPenumbraUnavailable)
|
||||
{
|
||||
_shownPenumbraUnavailable = true;
|
||||
_lightlessMediator.Publish(new NotificationMessage("Penumbra inactive",
|
||||
"Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.",
|
||||
NotificationType.Error));
|
||||
return;
|
||||
}
|
||||
|
||||
_modDirectory = value;
|
||||
Mediator.Publish(new PenumbraDirectoryChangedMessage(_modDirectory));
|
||||
}
|
||||
}
|
||||
|
||||
public Task AssignTemporaryCollectionAsync(ILogger logger, Guid collectionId, int objectIndex)
|
||||
=> _collections.AssignTemporaryCollectionAsync(logger, collectionId, objectIndex);
|
||||
|
||||
public Task<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
|
||||
=> _collections.CreateTemporaryCollectionAsync(logger, uid);
|
||||
|
||||
public Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collectionId)
|
||||
=> _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId);
|
||||
|
||||
public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary<string, string> modPaths)
|
||||
=> _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths);
|
||||
|
||||
public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData)
|
||||
=> _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData);
|
||||
|
||||
public Task<Dictionary<string, HashSet<string>>?> GetCharacterData(ILogger logger, GameObjectHandler handler)
|
||||
=> _resources.GetCharacterDataAsync(logger, handler);
|
||||
|
||||
public string GetMetaManipulations()
|
||||
=> _resources.GetMetaManipulations();
|
||||
|
||||
public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse)
|
||||
=> _resources.ResolvePathsAsync(forward, reverse);
|
||||
|
||||
public 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 Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
|
||||
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token);
|
||||
|
||||
public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
|
||||
=> _textures.ConvertTextureFileDirectAsync(job, token);
|
||||
|
||||
public void CheckModDirectory()
|
||||
{
|
||||
if (!APIAvailable)
|
||||
{
|
||||
ModDirectory = string.Empty;
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
try
|
||||
{
|
||||
ModDirectory = _penumbraResolveModDir!.Invoke().ToLowerInvariant();
|
||||
ModDirectory = _penumbraGetModDirectory.Invoke().ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to resolve Penumbra mod directory");
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool IsPluginEnabled(IExposedPlugin plugin)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _penumbraEnabled.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current)
|
||||
{
|
||||
base.OnConnectionStateChanged(previous, current);
|
||||
|
||||
if (current == IpcConnectionState.Available)
|
||||
{
|
||||
_shownPenumbraUnavailable = false;
|
||||
if (string.IsNullOrEmpty(ModDirectory))
|
||||
{
|
||||
CheckModDirectory();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ModDirectory = string.Empty;
|
||||
_redraw.CancelPendingRedraws();
|
||||
|
||||
if (_shownPenumbraUnavailable || current == IpcConnectionState.Unknown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_shownPenumbraUnavailable = true;
|
||||
Mediator.Publish(new NotificationMessage(
|
||||
"Penumbra inactive",
|
||||
"Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.",
|
||||
NotificationType.Error));
|
||||
}
|
||||
|
||||
private void SubscribeMediatorEvents()
|
||||
{
|
||||
Mediator.Subscribe<PenumbraRedrawCharacterMessage>(this, msg =>
|
||||
{
|
||||
_redraw.RequestImmediateRedraw(msg.Character.ObjectIndex, RedrawType.AfterGPose);
|
||||
});
|
||||
|
||||
Mediator.Subscribe<DalamudLoginMessage>(this, _ => _shownPenumbraUnavailable = false);
|
||||
}
|
||||
|
||||
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 +208,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
LightlessSync/Interop/Ipc/Penumbra/PenumbraBase.cs
Normal file
27
LightlessSync/Interop/Ipc/Penumbra/PenumbraBase.cs
Normal 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; }
|
||||
}
|
||||
197
LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs
Normal file
197
LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
using Penumbra.Api.IpcSubscribers;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc.Penumbra;
|
||||
|
||||
public sealed class PenumbraCollections : PenumbraBase
|
||||
{
|
||||
private readonly CreateTemporaryCollection _createNamedTemporaryCollection;
|
||||
private readonly AssignTemporaryCollection _assignTemporaryCollection;
|
||||
private readonly DeleteTemporaryCollection _removeTemporaryCollection;
|
||||
private readonly AddTemporaryMod _addTemporaryMod;
|
||||
private readonly RemoveTemporaryMod _removeTemporaryMod;
|
||||
private readonly GetCollections _getCollections;
|
||||
private readonly ConcurrentDictionary<Guid, string> _activeTemporaryCollections = new();
|
||||
|
||||
private int _cleanupScheduled;
|
||||
|
||||
public PenumbraCollections(
|
||||
ILogger logger,
|
||||
IDalamudPluginInterface pluginInterface,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator)
|
||||
{
|
||||
_createNamedTemporaryCollection = new CreateTemporaryCollection(pluginInterface);
|
||||
_assignTemporaryCollection = new AssignTemporaryCollection(pluginInterface);
|
||||
_removeTemporaryCollection = new DeleteTemporaryCollection(pluginInterface);
|
||||
_addTemporaryMod = new AddTemporaryMod(pluginInterface);
|
||||
_removeTemporaryMod = new RemoveTemporaryMod(pluginInterface);
|
||||
_getCollections = new GetCollections(pluginInterface);
|
||||
}
|
||||
|
||||
public override string Name => "Penumbra.Collections";
|
||||
|
||||
public async Task AssignTemporaryCollectionAsync(ILogger logger, Guid collectionId, int objectIndex)
|
||||
{
|
||||
if (!IsAvailable || collectionId == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var result = _assignTemporaryCollection.Invoke(collectionId, objectIndex, forceAssignment: true);
|
||||
logger.LogTrace("Assigning Temp Collection {CollectionId} to index {ObjectIndex}, Success: {Result}", collectionId, objectIndex, result);
|
||||
return result;
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateTemporaryCollectionAsync(ILogger logger, string uid)
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
var (collectionId, collectionName) = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var name = $"Lightless_{uid}";
|
||||
_createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId);
|
||||
logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}", name, tempCollectionId);
|
||||
return (tempCollectionId, name);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (collectionId != Guid.Empty)
|
||||
{
|
||||
_activeTemporaryCollections[collectionId] = collectionName;
|
||||
}
|
||||
|
||||
return collectionId;
|
||||
}
|
||||
|
||||
public async Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collectionId)
|
||||
{
|
||||
if (!IsAvailable || collectionId == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
logger.LogTrace("[{ApplicationId}] Removing temp collection for {CollectionId}", applicationId, collectionId);
|
||||
var result = _removeTemporaryCollection.Invoke(collectionId);
|
||||
logger.LogTrace("[{ApplicationId}] RemoveTemporaryCollection: {Result}", applicationId, result);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||
}
|
||||
|
||||
public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, 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)
|
||||
{
|
||||
if (current == IpcConnectionState.Available)
|
||||
{
|
||||
ScheduleCleanup();
|
||||
}
|
||||
else if (previous == IpcConnectionState.Available && current != IpcConnectionState.Available)
|
||||
{
|
||||
Interlocked.Exchange(ref _cleanupScheduled, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleCleanup()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _cleanupScheduled, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(CleanupTemporaryCollectionsAsync);
|
||||
}
|
||||
|
||||
private async Task CleanupTemporaryCollectionsAsync()
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var collections = await DalamudUtil.RunOnFrameworkThread(() => _getCollections.Invoke()).ConfigureAwait(false);
|
||||
foreach (var (collectionId, name) in collections)
|
||||
{
|
||||
if (!IsLightlessCollectionName(name) || _activeTemporaryCollections.ContainsKey(collectionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId);
|
||||
var deleteResult = await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId);
|
||||
Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result);
|
||||
return result;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (deleteResult == PenumbraApiEc.Success)
|
||||
{
|
||||
_activeTemporaryCollections.TryRemove(collectionId, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLightlessCollectionName(string? name)
|
||||
=> !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal);
|
||||
}
|
||||
89
LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs
Normal file
89
LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
109
LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs
Normal file
109
LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Helpers;
|
||||
using Penumbra.Api.IpcSubscribers;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc.Penumbra;
|
||||
|
||||
public sealed class PenumbraResource : PenumbraBase
|
||||
{
|
||||
private readonly ActorObjectService _actorObjectService;
|
||||
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
|
||||
private readonly 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,
|
||||
ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator)
|
||||
{
|
||||
_actorObjectService = actorObjectService;
|
||||
_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;
|
||||
}
|
||||
|
||||
return await DalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths");
|
||||
var idx = handler.GetGameObject()?.ObjectIndex;
|
||||
if (idx == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _gameObjectResourcePaths.Invoke(idx.Value)[0];
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public string GetMetaManipulations()
|
||||
=> IsAvailable ? _getPlayerMetaManipulations.Invoke() : string.Empty;
|
||||
|
||||
public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forwardPaths, string[] reversePaths)
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return (Array.Empty<string>(), Array.Empty<string[]>());
|
||||
}
|
||||
|
||||
return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public 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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_actorObjectService.TryGetOwnedKind(ptr, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
|
||||
}
|
||||
|
||||
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
base.Dispose();
|
||||
_gameObjectResourcePathResolved.Dispose();
|
||||
}
|
||||
}
|
||||
121
LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs
Normal file
121
LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Dalamud.Plugin;
|
||||
using LightlessSync.Interop.Ipc.Framework;
|
||||
using LightlessSync.Services;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Penumbra.Api.Enums;
|
||||
using Penumbra.Api.IpcSubscribers;
|
||||
|
||||
namespace LightlessSync.Interop.Ipc.Penumbra;
|
||||
|
||||
public sealed class PenumbraTexture : PenumbraBase
|
||||
{
|
||||
private readonly PenumbraRedraw _redrawFeature;
|
||||
private readonly ConvertTextureFile _convertTextureFile;
|
||||
|
||||
public PenumbraTexture(
|
||||
ILogger logger,
|
||||
IDalamudPluginInterface pluginInterface,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessMediator mediator,
|
||||
PenumbraRedraw redrawFeature) : base(logger, pluginInterface, dalamudUtil, mediator)
|
||||
{
|
||||
_redrawFeature = redrawFeature;
|
||||
_convertTextureFile = new ConvertTextureFile(pluginInterface);
|
||||
}
|
||||
|
||||
public override string Name => "Penumbra.Textures";
|
||||
|
||||
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
|
||||
{
|
||||
if (!IsAvailable || jobs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Mediator.Publish(new HaltScanMessage(nameof(ConvertTextureFilesAsync)));
|
||||
|
||||
var totalJobs = jobs.Count;
|
||||
var completedJobs = 0;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job));
|
||||
await ConvertSingleJobAsync(logger, job, token).ConfigureAwait(false);
|
||||
completedJobs++;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync)));
|
||||
}
|
||||
|
||||
if (completedJobs > 0 && !token.IsCancellationRequested)
|
||||
{
|
||||
await DalamudUtil.RunOnFrameworkThread(async () =>
|
||||
{
|
||||
var player = await DalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false);
|
||||
if (player == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var gameObject = await DalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false);
|
||||
if (gameObject == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_redrawFeature.RequestImmediateRedraw(gameObject.ObjectIndex, RedrawType.Redraw);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
|
||||
{
|
||||
if (!IsAvailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ConvertSingleJobAsync(Logger, job, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ConvertSingleJobAsync(ILogger logger, TextureConversionJob job, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType);
|
||||
var convertTask = _convertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps);
|
||||
await convertTask.ConfigureAwait(false);
|
||||
|
||||
if (!convertTask.IsCompletedSuccessfully || job.DuplicateTargets is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var duplicate in job.DuplicateTargets)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate);
|
||||
File.Copy(job.OutputFile, duplicate, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
|
||||
{
|
||||
}
|
||||
}
|
||||
21
LightlessSync/Interop/Ipc/TextureConversionJob.cs
Normal file
21
LightlessSync/Interop/Ipc/TextureConversionJob.cs
Normal 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);
|
||||
14
LightlessSync/LightlessConfiguration/ChatConfigService.cs
Normal file
14
LightlessSync/LightlessConfiguration/ChatConfigService.cs
Normal 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;
|
||||
}
|
||||
@@ -19,6 +19,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");
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 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 List<string> ChannelOrder { get; set; } = new();
|
||||
public Dictionary<string, bool> PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +50,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;
|
||||
@@ -61,12 +66,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;
|
||||
@@ -86,6 +95,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
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;
|
||||
@@ -95,6 +105,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
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;
|
||||
@@ -102,6 +113,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
|
||||
// Animation & Effects
|
||||
public float NotificationAnimationSpeed { get; set; } = 10f;
|
||||
public float NotificationSlideSpeed { get; set; } = 10f;
|
||||
public float NotificationAccentBarWidth { get; set; } = 3f;
|
||||
|
||||
// Duration per Type
|
||||
@@ -109,17 +121,20 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
public int WarningNotificationDurationSeconds { get; set; } = 15;
|
||||
public int ErrorNotificationDurationSeconds { get; set; } = 20;
|
||||
public int PairRequestDurationSeconds { get; set; } = 180;
|
||||
public int DownloadNotificationDurationSeconds { get; set; } = 300;
|
||||
public int DownloadNotificationDurationSeconds { get; set; } = 30;
|
||||
public int PerformanceNotificationDurationSeconds { get; set; } = 20;
|
||||
public uint CustomInfoSoundId { get; set; } = 2; // Se2
|
||||
public uint CustomWarningSoundId { get; set; } = 16; // Se15
|
||||
public uint CustomErrorSoundId { get; set; } = 16; // Se15
|
||||
public uint PairRequestSoundId { get; set; } = 5; // Se5
|
||||
public uint DownloadSoundId { get; set; } = 15; // Se14
|
||||
public uint PerformanceSoundId { get; set; } = 16; // Se15
|
||||
public bool DisableInfoSound { get; set; } = true;
|
||||
public bool DisableWarningSound { get; set; } = true;
|
||||
public bool DisableErrorSound { get; set; } = true;
|
||||
public bool DisablePairRequestSound { get; set; } = true;
|
||||
public bool DisableDownloadSound { get; set; } = true;
|
||||
public bool DisablePerformanceSound { get; set; } = true;
|
||||
public bool ShowPerformanceNotificationActions { get; set; } = true;
|
||||
public bool ShowPairRequestNotificationActions { get; set; } = true;
|
||||
public bool UseFocusTarget { get; set; } = false;
|
||||
public bool overrideFriendColor { get; set; } = false;
|
||||
public bool overridePartyColor { get; set; } = false;
|
||||
@@ -127,6 +142,7 @@ public class LightlessConfig : ILightlessConfiguration
|
||||
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;
|
||||
@@ -140,4 +156,11 @@ 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 HashSet<Guid> OrphanableTempCollections { get; set; } = [];
|
||||
public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Safe;
|
||||
public bool AnimationAllowOneBasedShift { get; set; } = true;
|
||||
|
||||
public bool AnimationAllowNeighborIndexTolerance { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -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,20 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
|
||||
public bool PauseInInstanceDuty { get; set; } = false;
|
||||
public bool PauseWhilePerforming { get; set; } = true;
|
||||
public bool PauseInCombat { get; set; } = true;
|
||||
public bool EnableNonIndexTextureMipTrim { get; set; } = false;
|
||||
public bool EnableIndexTextureDownscale { get; set; } = false;
|
||||
public int TextureDownscaleMaxDimension { get; set; } = 2048;
|
||||
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
|
||||
public bool KeepOriginalTextureFiles { get; set; } = false;
|
||||
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
|
||||
public bool EnableModelDecimation { get; set; } = false;
|
||||
public int ModelDecimationTriangleThreshold { get; set; } = 20_000;
|
||||
public double ModelDecimationTargetRatio { get; set; } = 0.8;
|
||||
public bool KeepOriginalModelFiles { get; set; } = true;
|
||||
public bool SkipModelDecimationForPreferredPairs { get; set; } = true;
|
||||
public bool ModelDecimationAllowBody { get; set; } = false;
|
||||
public bool ModelDecimationAllowFaceHead { get; set; } = false;
|
||||
public bool ModelDecimationAllowTail { get; set; } = false;
|
||||
public bool ModelDecimationAllowClothing { get; set; } = true;
|
||||
public bool ModelDecimationAllowAccessories { get; set; } = true;
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -39,46 +41,117 @@ public class TransientConfig : ILightlessConfiguration
|
||||
|
||||
public int RemovePath(string gamePath, ObjectKind objectKind)
|
||||
{
|
||||
int removedEntries = 0;
|
||||
if (objectKind == ObjectKind.Player)
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (GlobalPersistentCache.Remove(gamePath)) removedEntries++;
|
||||
foreach (var kvp in JobSpecificCache)
|
||||
int removedEntries = 0;
|
||||
if (objectKind == ObjectKind.Player)
|
||||
{
|
||||
if (kvp.Value.Remove(gamePath)) removedEntries++;
|
||||
if (GlobalPersistentCache.Remove(gamePath)) removedEntries++;
|
||||
foreach (var kvp in JobSpecificCache)
|
||||
{
|
||||
if (kvp.Value.Remove(gamePath)) removedEntries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (objectKind == ObjectKind.Pet)
|
||||
{
|
||||
foreach (var kvp in JobSpecificPetCache)
|
||||
if (objectKind == ObjectKind.Pet)
|
||||
{
|
||||
if (kvp.Value.Remove(gamePath)) removedEntries++;
|
||||
foreach (var kvp in JobSpecificPetCache)
|
||||
{
|
||||
if (kvp.Value.Remove(gamePath)) removedEntries++;
|
||||
}
|
||||
}
|
||||
return removedEntries;
|
||||
}
|
||||
return removedEntries;
|
||||
}
|
||||
|
||||
public void AddOrElevate(uint jobId, string gamePath)
|
||||
{
|
||||
// check if it's in the global cache, if yes, do nothing
|
||||
if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal))
|
||||
lock (_cacheLock)
|
||||
{
|
||||
return;
|
||||
// check if it's in the global cache, if yes, do nothing
|
||||
if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ElevateIfNeeded(jobId, gamePath)) return;
|
||||
|
||||
// check if the jobid is already in the cache to start
|
||||
if (!JobSpecificCache.TryGetValue(jobId, out var jobCache))
|
||||
{
|
||||
JobSpecificCache[jobId] = jobCache = new();
|
||||
}
|
||||
|
||||
// check if the path is already in the job specific cache
|
||||
if (!jobCache.Contains(gamePath, StringComparer.Ordinal))
|
||||
{
|
||||
jobCache.Add(gamePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (ElevateIfNeeded(jobId, gamePath)) return;
|
||||
|
||||
// check if the jobid is already in the cache to start
|
||||
if (!JobSpecificCache.TryGetValue(jobId, out var jobCache))
|
||||
foreach (var jobId in JobSpecificPetCache.Keys.ToList())
|
||||
{
|
||||
JobSpecificCache[jobId] = jobCache = new();
|
||||
JobSpecificPetCache[jobId] = NormalizeList(JobSpecificPetCache[jobId], ref changed, ref removedEntries);
|
||||
}
|
||||
|
||||
// check if the path is already in the job specific cache
|
||||
if (!jobCache.Contains(gamePath, StringComparer.Ordinal))
|
||||
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)
|
||||
{
|
||||
jobCache.Add(gamePath);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,5 +17,12 @@ public enum NotificationType
|
||||
Warning,
|
||||
Error,
|
||||
PairRequest,
|
||||
Download
|
||||
Download,
|
||||
Performance
|
||||
}
|
||||
|
||||
public enum NotificationCorner
|
||||
{
|
||||
Right,
|
||||
Left
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -101,20 +105,46 @@ public class LightlessPlugin : MediatorSubscriberBase, IHostedService
|
||||
|
||||
UIColors.Initialize(_lightlessConfigService);
|
||||
Mediator.StartQueueProcessing();
|
||||
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -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)
|
||||
|
||||
@@ -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.2</Version>
|
||||
<Version>2.0.3</Version>
|
||||
<Description></Description>
|
||||
<Copyright></Copyright>
|
||||
<PackageProjectUrl>https://github.com/Light-Public-Syncshells/LightlessClient</PackageProjectUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0-windows7.0</TargetFramework>
|
||||
<TargetFramework>net10.0-windows7.0</TargetFramework>
|
||||
<Platforms>x64</Platforms>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
@@ -27,25 +27,27 @@
|
||||
</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,6 +66,8 @@
|
||||
<None Update="images\icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<EmbeddedResource Include="Changelog\changelog.yaml" />
|
||||
<EmbeddedResource Include="Changelog\credits.yaml" />
|
||||
<EmbeddedResource Include="Localization\de.json" />
|
||||
<EmbeddedResource Include="Localization\fr.json" />
|
||||
</ItemGroup>
|
||||
@@ -74,7 +78,28 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
|
||||
<ProjectReference Include="..\PenumbraAPI\Penumbra.Api.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.Api\Penumbra.Api.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" />
|
||||
<ProjectReference Include="..\ffxiv_pictomancy\Pictomancy\Pictomancy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="OtterTex">
|
||||
<HintPath>lib\OtterTex.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="lib\DirectXTexC.dll">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<TargetPath>DirectXTexC.dll</TargetPath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="DalamudPackager" Version="14.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LightlessSync.PlayerData.Data;
|
||||
|
||||
@@ -13,37 +16,42 @@ public class FileReplacementDataComparer : IEqualityComparer<FileReplacementData
|
||||
|
||||
public bool Equals(FileReplacementData? x, FileReplacementData? y)
|
||||
{
|
||||
if (x == null || y == null) return false;
|
||||
return x.Hash.Equals(y.Hash) && CompareHashSets(x.GamePaths.ToHashSet(StringComparer.Ordinal), y.GamePaths.ToHashSet(StringComparer.Ordinal)) && string.Equals(x.FileSwapPath, y.FileSwapPath, StringComparison.Ordinal);
|
||||
if (ReferenceEquals(x, y))
|
||||
return true;
|
||||
if (x is null || y is null)
|
||||
return false;
|
||||
|
||||
return string.Equals(x.Hash, y.Hash, StringComparison.OrdinalIgnoreCase)
|
||||
&& ComparePathSets(x.GamePaths, y.GamePaths)
|
||||
&& string.Equals(x.FileSwapPath ?? string.Empty, y.FileSwapPath ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(FileReplacementData obj)
|
||||
{
|
||||
return HashCode.Combine(obj.Hash.GetHashCode(StringComparison.OrdinalIgnoreCase), GetOrderIndependentHashCode(obj.GamePaths), StringComparer.Ordinal.GetHashCode(obj.FileSwapPath));
|
||||
if (obj is null)
|
||||
return 0;
|
||||
|
||||
var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Hash ?? string.Empty);
|
||||
hash = HashCode.Combine(hash, GetSetHashCode(obj.GamePaths));
|
||||
hash = HashCode.Combine(hash, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FileSwapPath ?? string.Empty));
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static bool CompareHashSets(HashSet<string> list1, HashSet<string> list2)
|
||||
private static bool ComparePathSets(IEnumerable<string> first, IEnumerable<string> second)
|
||||
{
|
||||
if (list1.Count != list2.Count)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < list1.Count; i++)
|
||||
{
|
||||
if (!string.Equals(list1.ElementAt(i), list2.ElementAt(i), StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
var left = new HashSet<string>(first ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
var right = new HashSet<string>(second ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
|
||||
return left.SetEquals(right);
|
||||
}
|
||||
|
||||
private static int GetOrderIndependentHashCode<T>(IEnumerable<T> source) where T : notnull
|
||||
private static int GetSetHashCode(IEnumerable<string> paths)
|
||||
{
|
||||
int hash = 0;
|
||||
foreach (T element in source)
|
||||
foreach (var element in paths ?? Enumerable.Empty<string>())
|
||||
{
|
||||
hash = unchecked(hash +
|
||||
EqualityComparer<T>.Default.GetHashCode(element));
|
||||
hash = unchecked(hash + StringComparer.OrdinalIgnoreCase.GetHashCode(element));
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LightlessSync.PlayerData.Factories
|
||||
{
|
||||
public enum AnimationValidationMode
|
||||
{
|
||||
Unsafe = 0,
|
||||
Safe = 1,
|
||||
Safest = 2,
|
||||
}
|
||||
}
|
||||
@@ -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,49 @@ 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;
|
||||
|
||||
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)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_lightlessMediator = lightlessMediator;
|
||||
_fileTransferOrchestrator = fileTransferOrchestrator;
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_fileCompactor = fileCompactor;
|
||||
_configService = configService;
|
||||
_textureDownscaleService = textureDownscaleService;
|
||||
_modelDecimationService = modelDecimationService;
|
||||
_textureMetadataHelper = textureMetadataHelper;
|
||||
}
|
||||
|
||||
public FileDownloadManager Create()
|
||||
{
|
||||
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _lightlessMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
|
||||
return new FileDownloadManager(
|
||||
_loggerFactory.CreateLogger<FileDownloadManager>(),
|
||||
_lightlessMediator,
|
||||
_fileTransferOrchestrator,
|
||||
_fileCacheManager,
|
||||
_fileCompactor,
|
||||
_configService,
|
||||
_textureDownscaleService,
|
||||
_modelDecimationService,
|
||||
_textureMetadataHelper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
using LightlessSync.API.Data.Enum;
|
||||
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 Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LightlessSync.PlayerData.Factories;
|
||||
|
||||
@@ -18,13 +21,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 ConcurrentDictionary<nint, Task<CharacterDataFragment>> _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 +58,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 +91,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)
|
||||
{
|
||||
@@ -92,159 +117,262 @@ public class PlayerDataFactory
|
||||
}
|
||||
|
||||
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
|
||||
=> await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
||||
|
||||
private unsafe static bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
{
|
||||
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
|
||||
if (playerPointer == IntPtr.Zero)
|
||||
return true;
|
||||
|
||||
var character = (Character*)playerPointer;
|
||||
if (character == null)
|
||||
return true;
|
||||
|
||||
var gameObject = &character->GameObject;
|
||||
if (gameObject == null)
|
||||
return true;
|
||||
|
||||
return gameObject->DrawObject == null;
|
||||
}
|
||||
|
||||
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
|
||||
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)
|
||||
{
|
||||
return ((Character*)playerPointer)->GameObject.DrawObject == null;
|
||||
}
|
||||
var key = obj.Address;
|
||||
|
||||
private async Task<CharacterDataFragment> CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct)
|
||||
{
|
||||
var objectKind = playerRelatedObject.ObjectKind;
|
||||
CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new();
|
||||
if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key))
|
||||
return cached.Fragment;
|
||||
|
||||
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
|
||||
var buildTask = _characterBuildInflight.GetOrAdd(key, _ => BuildAndCacheAsync(obj, key));
|
||||
|
||||
// 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)
|
||||
if (_characterBuildCache.TryGetValue(key, out cached))
|
||||
{
|
||||
_logger.LogTrace("Character is null but it shouldn't be, waiting");
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
totalWaitTime -= 50;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return await WithCancellation(buildTask, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Dictionary<string, List<ushort>>? boneIndices =
|
||||
objectKind != ObjectKind.Player
|
||||
? null
|
||||
: await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false);
|
||||
|
||||
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();
|
||||
private async Task<CharacterDataFragment> BuildAndCacheAsync(GameObjectHandler obj, nint key)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_hardBuildTimeout);
|
||||
var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false);
|
||||
|
||||
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))));
|
||||
var allowedExtensions = CacheMonitor.AllowedFileExtensions;
|
||||
fragment.FileReplacements.RemoveWhere(c => c.GamePaths.Any(g => !allowedExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
|
||||
_characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow);
|
||||
PruneCharacterCacheIfNeeded();
|
||||
|
||||
return fragment;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_characterBuildInflight.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false);
|
||||
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();
|
||||
}
|
||||
var waitRecordingTask = _transientResourceManager.WaitForRecording(ct);
|
||||
|
||||
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]);
|
||||
|
||||
var resolvedTransientPaths = transientPaths.Count == 0
|
||||
? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase).AsReadOnly()
|
||||
: await GetFileReplacementsFromPaths(playerRelatedObject, transientPaths, new HashSet<string>(StringComparer.Ordinal)).ConfigureAwait(false);
|
||||
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);
|
||||
}
|
||||
|
||||
var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? 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());
|
||||
foreach (var file in toCompute)
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
|
||||
}
|
||||
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 +385,338 @@ 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)
|
||||
{
|
||||
var set = new HashSet<FileReplacement>(FileReplacementComparer.Instance);
|
||||
|
||||
foreach (var kvp in resolvedPaths)
|
||||
{
|
||||
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))
|
||||
{
|
||||
// 105 is the maximum vanilla skellington spoopy bone index
|
||||
if (skeletonIndices.All(k => k.Value.Max() <= 105))
|
||||
{
|
||||
_logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath);
|
||||
continue;
|
||||
}
|
||||
if (_transientResourceManager.AddTransientResource(objectKind, item))
|
||||
_logger.LogDebug("Marking static {item} for Pet as transient", item);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
|
||||
_logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count);
|
||||
clearedReplacements = staticReplacements;
|
||||
}
|
||||
|
||||
foreach (var boneCount in skeletonIndices.Select(k => k).ToList())
|
||||
{
|
||||
if (boneCount.Value.Max() > boneIndices.SelectMany(b => b.Value).Max())
|
||||
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(obj, 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);
|
||||
}
|
||||
|
||||
if (localBoneSets.Count == 0)
|
||||
return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("SEND local buckets: {b}",
|
||||
string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal)));
|
||||
|
||||
foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Dictionary<string, List<ushort>>? papIndices = null;
|
||||
|
||||
await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
papIndices = await Task.Run(() => _modelAnalyzer.GetBoneIndicesFromPap(hash), ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_papParseLimiter.Release();
|
||||
}
|
||||
|
||||
if (papIndices == null || papIndices.Count == 0)
|
||||
continue;
|
||||
|
||||
if (papIndices.All(k => k.Value.DefaultIfEmpty().Max() <= 105))
|
||||
continue;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var papBuckets = papIndices
|
||||
.Select(kvp => new
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
if (validationFailed)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out var reason))
|
||||
continue;
|
||||
|
||||
noValidationFailed++;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Animation PAP hash {hash} is not compatible with local skeletons; dropping all mappings for this hash. Reason: {reason}",
|
||||
hash,
|
||||
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(
|
||||
GameObjectHandler handler,
|
||||
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 ? Array.Empty<string>() : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
|
||||
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
|
||||
if (handler.ObjectKind != ObjectKind.Player)
|
||||
{
|
||||
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
|
||||
{
|
||||
var idx = handler.GetGameObject()?.ObjectIndex;
|
||||
if (!idx.HasValue)
|
||||
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
|
||||
|
||||
var resolvedForward = new string[forwardPaths.Length];
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
|
||||
|
||||
var resolvedReverse = new string[reversePaths.Length][];
|
||||
for (int i = 0; i < reversePaths.Length; i++)
|
||||
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
|
||||
|
||||
return (idx, resolvedForward, resolvedReverse);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (objectIndex.HasValue)
|
||||
{
|
||||
for (int i = 0; i < forwardPaths.Length; i++)
|
||||
{
|
||||
var filePath = forwardResolved[i]?.ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
continue;
|
||||
|
||||
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 = reversePathsLower[i];
|
||||
var reverseResolvedLower = new string[reverseResolved[i].Length];
|
||||
for (var j = 0; j < reverseResolvedLower.Length; j++)
|
||||
{
|
||||
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
|
||||
}
|
||||
if (resolvedPaths.TryGetValue(filePath, out var list))
|
||||
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
|
||||
else
|
||||
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
|
||||
}
|
||||
|
||||
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
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 +727,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -171,13 +184,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
Address = _getAddress();
|
||||
if (Address != IntPtr.Zero)
|
||||
{
|
||||
var drawObjAddr = (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->DrawObject;
|
||||
var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address;
|
||||
var drawObjAddr = (IntPtr)gameObject->DrawObject;
|
||||
DrawObjectAddress = drawObjAddr;
|
||||
EntityId = gameObject->EntityId;
|
||||
CurrentDrawCondition = DrawCondition.None;
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawObjectAddress = IntPtr.Zero;
|
||||
EntityId = uint.MaxValue;
|
||||
CurrentDrawCondition = DrawCondition.DrawObjectZero;
|
||||
}
|
||||
|
||||
@@ -355,7 +371,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP
|
||||
|
||||
private bool IsBeingDrawn()
|
||||
{
|
||||
if (_haltProcessing) CheckAndUpdateObject();
|
||||
EnsureLatestObjectState();
|
||||
|
||||
if (_dalamudUtil.IsAnythingDrawing)
|
||||
{
|
||||
@@ -367,12 +383,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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs
Normal file
43
LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
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; }
|
||||
DateTime? InvisibleSinceUtc { get; }
|
||||
DateTime? VisibilityEvictionDueAtUtc { get; }
|
||||
|
||||
void Initialize();
|
||||
void ApplyData(CharacterData data);
|
||||
void ApplyLastReceivedData(bool forced = false);
|
||||
bool FetchPerformanceMetricsFromCache();
|
||||
void LoadCachedCharacterData(CharacterData data);
|
||||
void SetUploading(bool uploading);
|
||||
void SetPaused(bool paused);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
|
||||
public interface IPairHandlerAdapterFactory
|
||||
{
|
||||
IPairHandlerAdapter Create(string ident);
|
||||
}
|
||||
20
LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs
Normal file
20
LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
||||
{
|
||||
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 = reapplyDataSeString,
|
||||
OnClicked = (a) => ApplyLastReceivedData(forced: true),
|
||||
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;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
UiSharedService.AddContextMenuItem(args, name: "Toggle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
||||
{
|
||||
_ = _apiController.Value.PauseAsync(UserData);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
args.AddMenuItem(new MenuItem()
|
||||
UiSharedService.AddContextMenuItem(args, name: "Cycle Pause State", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () =>
|
||||
{
|
||||
Name = changePermissions,
|
||||
OnClicked = (a) => _mediator.Publish(new OpenPermissionWindow(this)),
|
||||
UseDefaultPrefix = false,
|
||||
PrefixChar = 'L',
|
||||
PrefixColor = 708
|
||||
});
|
||||
|
||||
args.AddMenuItem(new MenuItem()
|
||||
{
|
||||
Name = cyclePauseState,
|
||||
OnClicked = (a) => _mediator.Publish(new CyclePauseMessage(UserData)),
|
||||
UseDefaultPrefix = false,
|
||||
PrefixChar = 'L',
|
||||
PrefixColor = 708
|
||||
TriggerCyclePause();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
public void ApplyData(OnlineUserCharaDataDto data)
|
||||
{
|
||||
_applicationCts = _applicationCts.CancelRecreate();
|
||||
LastReceivedCharacterData = data.CharaData;
|
||||
_logger.LogTrace("Character data received for {Uid}; handler will process via registry.", UserData.UID);
|
||||
}
|
||||
|
||||
if (CachedPlayer == null)
|
||||
{
|
||||
_logger.LogDebug("Received Data for {uid} but CachedPlayer does not exist, waiting", data.User.UID);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource();
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(120));
|
||||
var appToken = _applicationCts.Token;
|
||||
using var combined = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, appToken);
|
||||
while (CachedPlayer == null && !combined.Token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(250, combined.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!combined.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Applying delayed data for {uid}", data.User.UID);
|
||||
ApplyLastReceivedData();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyLastReceivedData();
|
||||
private void TriggerCyclePause()
|
||||
{
|
||||
_ = _apiController.Value.CyclePauseAsync(this);
|
||||
}
|
||||
|
||||
public void ApplyLastReceivedData(bool forced = false)
|
||||
{
|
||||
if (CachedPlayer == null) return;
|
||||
if (LastReceivedCharacterData == null) return;
|
||||
var handler = TryGetHandler();
|
||||
if (handler is null)
|
||||
{
|
||||
_logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID);
|
||||
return;
|
||||
}
|
||||
|
||||
CachedPlayer.ApplyCharacterData(Guid.NewGuid(), RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone())!, forced);
|
||||
handler.ApplyLastReceivedData(forced);
|
||||
}
|
||||
|
||||
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
|
||||
{
|
||||
try
|
||||
var handler = TryGetHandler();
|
||||
if (handler is null)
|
||||
{
|
||||
_creationSemaphore.Wait();
|
||||
|
||||
if (CachedPlayer != null) return;
|
||||
|
||||
if (dto == null && _onlineUserIdentDto == null)
|
||||
{
|
||||
CachedPlayer?.Dispose();
|
||||
CachedPlayer = null;
|
||||
return;
|
||||
}
|
||||
if (dto != null)
|
||||
{
|
||||
_onlineUserIdentDto = dto;
|
||||
}
|
||||
|
||||
CachedPlayer?.Dispose();
|
||||
CachedPlayer = _cachedPlayerFactory.Create(this);
|
||||
_logger.LogTrace("CreateCachedPlayer skipped for {Uid}: handler unavailable.", UserData.UID);
|
||||
return;
|
||||
}
|
||||
finally
|
||||
|
||||
if (!handler.Initialized)
|
||||
{
|
||||
_creationSemaphore.Release();
|
||||
handler.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +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,48 @@ public class Pair
|
||||
|
||||
internal void SetIsUploading()
|
||||
{
|
||||
CachedPlayer?.SetUploading();
|
||||
var handler = TryGetHandler();
|
||||
if (handler is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
handler.SetUploading(true);
|
||||
}
|
||||
|
||||
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
|
||||
public PairDebugInfo GetDebugInfo()
|
||||
{
|
||||
_logger.LogTrace("Removing not synced files");
|
||||
if (data == null)
|
||||
{
|
||||
_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());
|
||||
var now = DateTime.UtcNow;
|
||||
var dueAt = handler.VisibilityEvictionDueAtUtc;
|
||||
var remainingSeconds = dueAt.HasValue
|
||||
? Math.Max(0, (dueAt.Value - now).TotalSeconds)
|
||||
: (double?)null;
|
||||
|
||||
_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.InvisibleSinceUtc,
|
||||
handler.VisibilityEvictionDueAtUtc,
|
||||
remainingSeconds,
|
||||
handler.LastFailureReason,
|
||||
handler.LastBlockingConditions,
|
||||
handler.IsApplying,
|
||||
handler.IsDownloading,
|
||||
handler.PendingDownloadCount,
|
||||
handler.ForbiddenDownloadCount,
|
||||
handler.PendingModReapply,
|
||||
handler.ModApplyDeferred,
|
||||
handler.MissingCriticalMods,
|
||||
handler.MissingNonCriticalMods,
|
||||
handler.MissingForbiddenMods);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
LightlessSync/PlayerData/Pairs/PairCoordinator.Groups.cs
Normal file
136
LightlessSync/PlayerData/Pairs/PairCoordinator.Groups.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
303
LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs
Normal file
303
LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs
Normal 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);
|
||||
}
|
||||
|
||||
_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));
|
||||
}
|
||||
}
|
||||
139
LightlessSync/PlayerData/Pairs/PairCoordinator.cs
Normal file
139
LightlessSync/PlayerData/Pairs/PairCoordinator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
48
LightlessSync/PlayerData/Pairs/PairDebugInfo.cs
Normal file
48
LightlessSync/PlayerData/Pairs/PairDebugInfo.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
|
||||
public sealed record PairDebugInfo(
|
||||
bool HasHandler,
|
||||
bool HandlerInitialized,
|
||||
bool HandlerVisible,
|
||||
bool HandlerScheduledForDeletion,
|
||||
DateTime? LastDataReceivedAt,
|
||||
DateTime? LastApplyAttemptAt,
|
||||
DateTime? LastSuccessfulApplyAt,
|
||||
DateTime? InvisibleSinceUtc,
|
||||
DateTime? VisibilityEvictionDueAtUtc,
|
||||
double? VisibilityEvictionRemainingSeconds,
|
||||
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,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
0);
|
||||
}
|
||||
2971
LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
Normal file
2971
LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs
Normal file
File diff suppressed because it is too large
Load Diff
123
LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs
Normal file
123
LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
521
LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs
Normal file
521
LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs
Normal file
@@ -0,0 +1,521 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private PairHandlerEntry GetOrCreateEntry(string ident)
|
||||
{
|
||||
if (_entriesByIdent.TryGetValue(ident, out var entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
var handler = _handlerFactory.Create(ident);
|
||||
entry = new PairHandlerEntry(ident, handler);
|
||||
_entriesByIdent[ident] = entry;
|
||||
_entriesByHandler[handler] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task RemoveAfterGracePeriodAsync(IPairHandlerAdapter handler)
|
||||
{
|
||||
await Task.Delay(_deletionGracePeriod).ConfigureAwait(false);
|
||||
|
||||
if (TryFinalizeHandlerRemoval(handler))
|
||||
{
|
||||
handler.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFinalizeHandlerRemoval(IPairHandlerAdapter handler)
|
||||
{
|
||||
string? ident = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entriesByHandler.TryGetValue(handler, out var entry) || entry.HasPairs)
|
||||
{
|
||||
handler.ScheduledForDeletion = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
ident = entry.Ident;
|
||||
_entriesByHandler.Remove(handler);
|
||||
_entriesByIdent.Remove(entry.Ident);
|
||||
}
|
||||
|
||||
if (ident is not null)
|
||||
{
|
||||
_pairPerformanceMetricsCache.Clear(ident);
|
||||
CancelPendingCharacterData(ident);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void QueuePendingCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto)
|
||||
{
|
||||
if (registration.CharacterIdent is null) return;
|
||||
|
||||
CancellationTokenSource? previous;
|
||||
CancellationTokenSource cts;
|
||||
|
||||
lock (_pendingGate)
|
||||
{
|
||||
_pendingCharacterData.TryGetValue(registration.CharacterIdent, out previous);
|
||||
previous?.Cancel();
|
||||
|
||||
cts = new CancellationTokenSource();
|
||||
_pendingCharacterData[registration.CharacterIdent] = cts;
|
||||
}
|
||||
|
||||
cts.CancelAfter(_handlerReadyTimeout);
|
||||
_ = Task.Run(() => WaitThenApplyPendingCharacterDataAsync(registration, dto, cts.Token, cts));
|
||||
}
|
||||
|
||||
private void CancelPendingCharacterData(string ident)
|
||||
{
|
||||
CancellationTokenSource? cts = null;
|
||||
lock (_pendingGate)
|
||||
{
|
||||
if (_pendingCharacterData.TryGetValue(ident, out cts))
|
||||
_pendingCharacterData.Remove(ident);
|
||||
}
|
||||
|
||||
cts?.Cancel();
|
||||
}
|
||||
|
||||
private void CancelAllPendingCharacterData()
|
||||
{
|
||||
List<CancellationTokenSource>? snapshot = null;
|
||||
lock (_pendingGate)
|
||||
{
|
||||
if (_pendingCharacterData.Count > 0)
|
||||
{
|
||||
snapshot = [.. _pendingCharacterData.Values];
|
||||
_pendingCharacterData.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot is null) return;
|
||||
foreach (var cts in snapshot) cts.Cancel();
|
||||
}
|
||||
|
||||
private async Task WaitThenApplyPendingCharacterDataAsync(
|
||||
PairRegistration registration,
|
||||
OnlineUserCharaDataDto dto,
|
||||
CancellationToken token,
|
||||
CancellationTokenSource source)
|
||||
{
|
||||
if (registration.CharacterIdent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
if (TryGetHandler(registration.CharacterIdent, out var handler) && handler is not null && handler.Initialized)
|
||||
{
|
||||
handler.ApplyData(dto.CharaData);
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(_handlerReadyPollDelayMs, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_pendingGate)
|
||||
{
|
||||
if (_pendingCharacterData.TryGetValue(registration.CharacterIdent, out var current) && ReferenceEquals(current, source))
|
||||
{
|
||||
_pendingCharacterData.Remove(registration.CharacterIdent);
|
||||
}
|
||||
}
|
||||
|
||||
source.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
295
LightlessSync/PlayerData/Pairs/PairLedger.cs
Normal file
295
LightlessSync/PlayerData/Pairs/PairLedger.cs
Normal file
@@ -0,0 +1,295 @@
|
||||
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
|
||||
{
|
||||
handler.ApplyLastReceivedData(forced: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
CancelScheduledMetrics();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
220
LightlessSync/PlayerData/Pairs/PairModels.cs
Normal file
220
LightlessSync/PlayerData/Pairs/PairModels.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Data.Extensions;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
|
||||
/// <summary>
|
||||
/// core models for the pair system
|
||||
/// </summary>
|
||||
public sealed class PairState
|
||||
{
|
||||
public CharacterData? CharacterData { get; set; }
|
||||
public Guid? TemporaryCollectionId { get; set; }
|
||||
|
||||
public bool IsEmpty => CharacterData is null && (TemporaryCollectionId is null || TemporaryCollectionId == Guid.Empty);
|
||||
}
|
||||
|
||||
public readonly record struct PairUniqueIdentifier(string UserId);
|
||||
|
||||
/// <summary>
|
||||
/// link between a pair id and character ident
|
||||
/// </summary>
|
||||
public sealed record PairRegistration(PairUniqueIdentifier PairIdent, string? CharacterIdent);
|
||||
|
||||
/// <summary>
|
||||
/// per group membership info for a pair
|
||||
/// </summary>
|
||||
public sealed class GroupPairRelationship
|
||||
{
|
||||
public GroupPairRelationship(string groupId, GroupPairUserInfo? info)
|
||||
{
|
||||
GroupId = groupId;
|
||||
UserInfo = info;
|
||||
}
|
||||
|
||||
public string GroupId { get; }
|
||||
public GroupPairUserInfo? UserInfo { get; private set; }
|
||||
|
||||
public void SetUserInfo(GroupPairUserInfo? info)
|
||||
{
|
||||
UserInfo = info;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// runtime view of a single pair connection
|
||||
/// </summary>
|
||||
public sealed class PairConnection
|
||||
{
|
||||
public PairConnection(UserData user)
|
||||
{
|
||||
User = user;
|
||||
Groups = new Dictionary<string, GroupPairRelationship>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public UserData User { get; }
|
||||
public bool IsOnline { get; private set; }
|
||||
public string? Ident { get; private set; }
|
||||
public UserPermissions SelfToOtherPermissions { get; private set; } = UserPermissions.NoneSet;
|
||||
public UserPermissions OtherToSelfPermissions { get; private set; } = UserPermissions.NoneSet;
|
||||
public IndividualPairStatus? IndividualPairStatus { get; private set; }
|
||||
public Dictionary<string, GroupPairRelationship> Groups { get; }
|
||||
|
||||
public bool IsPaused => SelfToOtherPermissions.IsPaused();
|
||||
public bool IsDirectlyPaired => IndividualPairStatus is not null && IndividualPairStatus != API.Data.Enum.IndividualPairStatus.None;
|
||||
public bool IsOneSided => IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided;
|
||||
public bool HasAnyConnection => IsDirectlyPaired || Groups.Count > 0;
|
||||
|
||||
public void SetOnline(string? ident)
|
||||
{
|
||||
IsOnline = true;
|
||||
Ident = ident;
|
||||
}
|
||||
|
||||
public void SetOffline()
|
||||
{
|
||||
IsOnline = false;
|
||||
}
|
||||
|
||||
public void UpdatePermissions(UserPermissions own, UserPermissions other)
|
||||
{
|
||||
SelfToOtherPermissions = own;
|
||||
OtherToSelfPermissions = other;
|
||||
}
|
||||
|
||||
public void UpdateStatus(IndividualPairStatus? status)
|
||||
{
|
||||
IndividualPairStatus = status;
|
||||
}
|
||||
|
||||
public void EnsureGroupRelationship(string groupId, GroupPairUserInfo? info)
|
||||
{
|
||||
if (Groups.TryGetValue(groupId, out var relationship))
|
||||
{
|
||||
relationship.SetUserInfo(info);
|
||||
}
|
||||
else
|
||||
{
|
||||
Groups[groupId] = new GroupPairRelationship(groupId, info);
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveGroupRelationship(string groupId)
|
||||
{
|
||||
Groups.Remove(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// syncshell metadata plus member connections
|
||||
/// </summary>
|
||||
public sealed class Syncshell
|
||||
{
|
||||
public Syncshell(GroupFullInfoDto dto)
|
||||
{
|
||||
GroupFullInfo = dto;
|
||||
Users = new Dictionary<string, PairConnection>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public GroupFullInfoDto GroupFullInfo { get; private set; }
|
||||
public Dictionary<string, PairConnection> Users { get; }
|
||||
|
||||
public void Update(GroupFullInfoDto dto)
|
||||
{
|
||||
GroupFullInfo = dto;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// simple success/failure result
|
||||
/// </summary>
|
||||
public readonly struct PairOperationResult
|
||||
{
|
||||
private PairOperationResult(bool success, string? error)
|
||||
{
|
||||
Success = success;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool Success { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
public static PairOperationResult Ok() => new(true, null);
|
||||
|
||||
public static PairOperationResult Fail(string error) => new(false, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// typed success/failure result
|
||||
/// </summary>
|
||||
public readonly struct PairOperationResult<T>
|
||||
{
|
||||
private PairOperationResult(bool success, T value, string? error)
|
||||
{
|
||||
Success = success;
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool Success { get; }
|
||||
public T Value { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
public static PairOperationResult<T> Ok(T value) => new(true, value, null);
|
||||
|
||||
public static PairOperationResult<T> Fail(string error) => new(false, default!, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// state of which optional plugin warnings were shown
|
||||
/// </summary>
|
||||
public record OptionalPluginWarning
|
||||
{
|
||||
public bool ShownHeelsWarning { get; set; } = false;
|
||||
public bool ShownCustomizePlusWarning { get; set; } = false;
|
||||
public bool ShownHonorificWarning { get; set; } = false;
|
||||
public bool ShownMoodlesWarning { get; set; } = false;
|
||||
public bool ShowPetNicknamesWarning { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// tracks the handler registered pairs for an ident
|
||||
/// </summary>
|
||||
internal sealed class PairHandlerEntry
|
||||
{
|
||||
private readonly HashSet<PairUniqueIdentifier> _pairs = new();
|
||||
|
||||
public PairHandlerEntry(string ident, IPairHandlerAdapter handler)
|
||||
{
|
||||
Ident = ident;
|
||||
Handler = handler;
|
||||
}
|
||||
|
||||
public string Ident { get; }
|
||||
public IPairHandlerAdapter Handler { get; }
|
||||
|
||||
public bool HasPairs => _pairs.Count > 0;
|
||||
public int PairCount => _pairs.Count;
|
||||
|
||||
public void AddPair(PairUniqueIdentifier pair)
|
||||
{
|
||||
_pairs.Add(pair);
|
||||
}
|
||||
|
||||
public bool RemovePair(PairUniqueIdentifier pair)
|
||||
{
|
||||
return _pairs.Remove(pair);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<PairUniqueIdentifier> SnapshotPairs()
|
||||
{
|
||||
if (_pairs.Count == 0)
|
||||
{
|
||||
return Array.Empty<PairUniqueIdentifier>();
|
||||
}
|
||||
|
||||
return _pairs.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
119
LightlessSync/PlayerData/Pairs/PairStateCache.cs
Normal file
119
LightlessSync/PlayerData/Pairs/PairStateCache.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
_ = PushCharacterDataAsync(forced);
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
private void HandlePairOnline(PairUniqueIdentifier pairIdent)
|
||||
{
|
||||
if (!_apiController.IsConnected || !_pairLedger.IsPairVisible(pairIdent))
|
||||
{
|
||||
try
|
||||
{
|
||||
forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced)
|
||||
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 (_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, [.. _usersToPushDataTo]);
|
||||
_lastCreatedData.DataHash,
|
||||
_fileUploadTask == null,
|
||||
_fileUploadTask?.IsCompleted ?? false,
|
||||
forced);
|
||||
|
||||
_fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, uploadTargets);
|
||||
}
|
||||
|
||||
if (_fileUploadTask != null)
|
||||
{
|
||||
var dataToSend = await _fileUploadTask.ConfigureAwait(false);
|
||||
await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_usersToPushDataTo.Count == 0) return;
|
||||
Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", _usersToPushDataTo.Select(k => k.AliasOrUID)));
|
||||
await _apiController.PushCharacterData(dataToSend, [.. _usersToPushDataTo]).ConfigureAwait(false);
|
||||
_usersToPushDataTo.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pushDataSemaphore.Release();
|
||||
}
|
||||
}
|
||||
var dataToSend = await _fileUploadTask.ConfigureAwait(false);
|
||||
|
||||
var users = _usersToPushDataTo.ToList();
|
||||
if (users.Count == 0)
|
||||
return;
|
||||
|
||||
Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", users.Select(k => k.AliasOrUID)));
|
||||
|
||||
await _apiController.PushCharacterData(dataToSend, users).ConfigureAwait(false);
|
||||
_usersToPushDataTo.Clear();
|
||||
}
|
||||
catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested)
|
||||
finally
|
||||
{
|
||||
Logger.LogDebug("PushCharacterData cancelled");
|
||||
_pushLock.Release();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to push character data");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private List<UserData> GetVisibleUsers()
|
||||
=> [.. _pairLedger.GetVisiblePairs().Where(connection => connection.IsOnline).Select(connection => connection.User)];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,225 +98,491 @@ public sealed class Plugin : IDalamudPlugin
|
||||
});
|
||||
lb.SetMinimumLevel(LogLevel.Trace);
|
||||
})
|
||||
.ConfigureServices(collection =>
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
var configDir = pluginInterface.ConfigDirectory.FullName;
|
||||
|
||||
// Core infrastructure
|
||||
services.AddSingleton(new WindowSystem("LightlessSync"));
|
||||
services.AddSingleton<FileDialogManager>();
|
||||
services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true));
|
||||
services.AddSingleton(framework);
|
||||
services.AddSingleton(gameGui);
|
||||
services.AddSingleton(gameInteropProvider);
|
||||
services.AddSingleton(addonLifecycle);
|
||||
services.AddSingleton<IUiBuilder>(pluginInterface.UiBuilder);
|
||||
|
||||
// Core singletons
|
||||
services.AddSingleton<LightlessMediator>();
|
||||
services.AddSingleton<FileCacheManager>();
|
||||
services.AddSingleton<ServerConfigurationManager>();
|
||||
services.AddSingleton<ProfileTagService>();
|
||||
services.AddSingleton<ApiController>();
|
||||
services.AddSingleton<PerformanceCollectorService>();
|
||||
services.AddSingleton<NameplateUpdateHookService>();
|
||||
services.AddSingleton<HubFactory>();
|
||||
services.AddSingleton<FileUploadManager>();
|
||||
services.AddSingleton<FileTransferOrchestrator>();
|
||||
services.AddSingleton<FileDownloadDeduplicator>();
|
||||
services.AddSingleton<LightlessPlugin>();
|
||||
services.AddSingleton<LightlessProfileManager>();
|
||||
services.AddSingleton<TextureCompressionService>();
|
||||
services.AddSingleton<TextureDownscaleService>();
|
||||
services.AddSingleton<ModelDecimationService>();
|
||||
services.AddSingleton<GameObjectHandlerFactory>();
|
||||
services.AddSingleton<FileDownloadManagerFactory>();
|
||||
services.AddSingleton<PairProcessingLimiter>();
|
||||
services.AddSingleton<XivDataAnalyzer>();
|
||||
services.AddSingleton<CharacterAnalyzer>();
|
||||
services.AddSingleton<TokenProvider>();
|
||||
services.AddSingleton<PluginWarningNotificationService>();
|
||||
services.AddSingleton<FileCompactor>();
|
||||
services.AddSingleton<TagHandler>();
|
||||
services.AddSingleton<PairRequestService>();
|
||||
services.AddSingleton<ZoneChatService>();
|
||||
services.AddSingleton<ChatEmoteService>();
|
||||
services.AddSingleton<IdDisplayHandler>();
|
||||
services.AddSingleton<PlayerPerformanceService>();
|
||||
services.AddSingleton<PenumbraTempCollectionJanitor>();
|
||||
services.AddSingleton<LocationShareService>();
|
||||
|
||||
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,
|
||||
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>(),
|
||||
sp.GetRequiredService<ActorObjectService>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerGlamourer(
|
||||
sp.GetRequiredService<ILogger<IpcCallerGlamourer>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<RedrawManager>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerCustomize(
|
||||
sp.GetRequiredService<ILogger<IpcCallerCustomize>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerHeels(
|
||||
sp.GetRequiredService<ILogger<IpcCallerHeels>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerHonorific(
|
||||
sp.GetRequiredService<ILogger<IpcCallerHonorific>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerMoodles(
|
||||
sp.GetRequiredService<ILogger<IpcCallerMoodles>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerPetNames(
|
||||
sp.GetRequiredService<ILogger<IpcCallerPetNames>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerBrio(
|
||||
sp.GetRequiredService<ILogger<IpcCallerBrio>>(),
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>()));
|
||||
|
||||
services.AddSingleton(sp => new 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 =>
|
||||
{
|
||||
collection.AddSingleton(new WindowSystem("LightlessSync"));
|
||||
collection.AddSingleton<FileDialogManager>();
|
||||
collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true));
|
||||
collection.AddSingleton(gameGui);
|
||||
var httpClient = new HttpClient();
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(
|
||||
new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}"));
|
||||
return httpClient;
|
||||
});
|
||||
|
||||
// add lightless related singletons
|
||||
collection.AddSingleton<LightlessMediator>();
|
||||
collection.AddSingleton<FileCacheManager>();
|
||||
collection.AddSingleton<ServerConfigurationManager>();
|
||||
collection.AddSingleton<ApiController>();
|
||||
collection.AddSingleton<PerformanceCollectorService>();
|
||||
collection.AddSingleton<HubFactory>();
|
||||
collection.AddSingleton<FileUploadManager>();
|
||||
collection.AddSingleton<FileTransferOrchestrator>();
|
||||
collection.AddSingleton<LightlessPlugin>();
|
||||
collection.AddSingleton<LightlessProfileManager>();
|
||||
collection.AddSingleton<GameObjectHandlerFactory>();
|
||||
collection.AddSingleton<FileDownloadManagerFactory>();
|
||||
collection.AddSingleton<PairHandlerFactory>();
|
||||
collection.AddSingleton<PairProcessingLimiter>();
|
||||
collection.AddSingleton<PairFactory>();
|
||||
collection.AddSingleton<XivDataAnalyzer>();
|
||||
collection.AddSingleton<CharacterAnalyzer>();
|
||||
collection.AddSingleton<TokenProvider>();
|
||||
collection.AddSingleton<PluginWarningNotificationService>();
|
||||
collection.AddSingleton<FileCompactor>();
|
||||
collection.AddSingleton<TagHandler>();
|
||||
collection.AddSingleton(s => new Lazy<ApiController>(() => s.GetRequiredService<ApiController>()));
|
||||
collection.AddSingleton<PairRequestService>();
|
||||
collection.AddSingleton<IdDisplayHandler>();
|
||||
collection.AddSingleton<PlayerPerformanceService>();
|
||||
collection.AddSingleton<TransientResourceManager>();
|
||||
// Lightless Config services
|
||||
services.AddSingleton(sp => new UiThemeConfigService(configDir));
|
||||
services.AddSingleton(sp => new ChatConfigService(configDir));
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var cfg = new LightlessConfigService(configDir);
|
||||
var theme = sp.GetRequiredService<UiThemeConfigService>();
|
||||
LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
|
||||
return cfg;
|
||||
});
|
||||
services.AddSingleton(sp => new ServerConfigService(configDir));
|
||||
services.AddSingleton(sp => new NotesConfigService(configDir));
|
||||
services.AddSingleton(sp => new PairTagConfigService(configDir));
|
||||
services.AddSingleton(sp => new SyncshellTagConfigService(configDir));
|
||||
services.AddSingleton(sp => new TransientConfigService(configDir));
|
||||
services.AddSingleton(sp => new XivDataStorageService(configDir));
|
||||
services.AddSingleton(sp => new PlayerPerformanceConfigService(configDir));
|
||||
services.AddSingleton(sp => new CharaDataConfigService(configDir));
|
||||
|
||||
collection.AddSingleton<CharaDataManager>();
|
||||
collection.AddSingleton<CharaDataFileHandler>();
|
||||
collection.AddSingleton<CharaDataCharacterHandler>();
|
||||
collection.AddSingleton<CharaDataNearbyManager>();
|
||||
collection.AddSingleton<CharaDataGposeTogetherManager>();
|
||||
// Config adapters
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<LightlessConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<UiThemeConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ChatConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<ServerConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<NotesConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PairTagConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<SyncshellTagConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<TransientConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<XivDataStorageService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<PlayerPerformanceConfigService>());
|
||||
services.AddSingleton<IConfigService<ILightlessConfiguration>>(sp => sp.GetRequiredService<CharaDataConfigService>());
|
||||
|
||||
collection.AddSingleton(s => new VfxSpawnManager(s.GetRequiredService<ILogger<VfxSpawnManager>>(),
|
||||
gameInteropProvider, s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new BlockedCharacterHandler(s.GetRequiredService<ILogger<BlockedCharacterHandler>>(), gameInteropProvider));
|
||||
collection.AddSingleton((s) => new IpcProvider(s.GetRequiredService<ILogger<IpcProvider>>(),
|
||||
pluginInterface,
|
||||
s.GetRequiredService<CharaDataManager>(),
|
||||
s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton<SelectPairForTagUi>();
|
||||
collection.AddSingleton<RenamePairTagUi>();
|
||||
collection.AddSingleton<SelectSyncshellForTagUi>();
|
||||
collection.AddSingleton<RenameSyncshellTagUi>();
|
||||
collection.AddSingleton((s) => new EventAggregator(pluginInterface.ConfigDirectory.FullName,
|
||||
s.GetRequiredService<ILogger<EventAggregator>>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService<ILogger<DalamudUtilService>>(),
|
||||
clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig,
|
||||
s.GetRequiredService<BlockedCharacterHandler>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<PlayerPerformanceConfigService>()));
|
||||
collection.AddSingleton((s) => new DtrEntry(
|
||||
s.GetRequiredService<ILogger<DtrEntry>>(),
|
||||
dtrBar,
|
||||
s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PairManager>(),
|
||||
s.GetRequiredService<PairRequestService>(),
|
||||
s.GetRequiredService<ApiController>(),
|
||||
s.GetRequiredService<ServerConfigurationManager>(),
|
||||
s.GetRequiredService<BroadcastService>(),
|
||||
s.GetRequiredService<BroadcastScannerService>(),
|
||||
s.GetRequiredService<DalamudUtilService>()));
|
||||
collection.AddSingleton(s => new PairManager(s.GetRequiredService<ILogger<PairManager>>(), s.GetRequiredService<PairFactory>(),
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), contextMenu, s.GetRequiredService<PairProcessingLimiter>()));
|
||||
collection.AddSingleton<RedrawManager>();
|
||||
collection.AddSingleton<BroadcastService>();
|
||||
collection.AddSingleton(addonLifecycle);
|
||||
collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData,
|
||||
p.GetRequiredService<ILogger<ContextMenuService>>(), p.GetRequiredService<DalamudUtilService>(), p.GetRequiredService<ApiController>(), objectTable,
|
||||
p.GetRequiredService<LightlessConfigService>(), p.GetRequiredService<PairRequestService>(), p.GetRequiredService<PairManager>(), clientState));
|
||||
collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService<ILogger<IpcCallerPenumbra>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
|
||||
collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService<ILogger<IpcCallerGlamourer>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<RedrawManager>()));
|
||||
collection.AddSingleton((s) => new IpcCallerCustomize(s.GetRequiredService<ILogger<IpcCallerCustomize>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new IpcCallerHeels(s.GetRequiredService<ILogger<IpcCallerHeels>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new IpcCallerHonorific(s.GetRequiredService<ILogger<IpcCallerHonorific>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new IpcCallerMoodles(s.GetRequiredService<ILogger<IpcCallerMoodles>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new IpcCallerPetNames(s.GetRequiredService<ILogger<IpcCallerPetNames>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddSingleton((s) => new IpcCallerBrio(s.GetRequiredService<ILogger<IpcCallerBrio>>(), pluginInterface,
|
||||
s.GetRequiredService<DalamudUtilService>()));
|
||||
collection.AddSingleton((s) => new IpcManager(s.GetRequiredService<ILogger<IpcManager>>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<IpcCallerPenumbra>(), s.GetRequiredService<IpcCallerGlamourer>(),
|
||||
s.GetRequiredService<IpcCallerCustomize>(), s.GetRequiredService<IpcCallerHeels>(), s.GetRequiredService<IpcCallerHonorific>(),
|
||||
s.GetRequiredService<IpcCallerMoodles>(), s.GetRequiredService<IpcCallerPetNames>(), s.GetRequiredService<IpcCallerBrio>()));
|
||||
collection.AddSingleton((s) => new NotificationService(
|
||||
s.GetRequiredService<ILogger<NotificationService>>(),
|
||||
s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<DalamudUtilService>(),
|
||||
notificationManager,
|
||||
chatGui,
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PairRequestService>()));
|
||||
collection.AddSingleton((s) =>
|
||||
{
|
||||
var httpClient = new HttpClient();
|
||||
var ver = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build));
|
||||
return httpClient;
|
||||
});
|
||||
collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) =>
|
||||
{
|
||||
var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName);
|
||||
var theme = s.GetRequiredService<UiThemeConfigService>();
|
||||
LightlessSync.UI.Style.MainStyle.Init(cfg, theme);
|
||||
return cfg;
|
||||
});
|
||||
collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new PairTagConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new SyncshellTagConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new TransientConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new XivDataStorageService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName));
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<LightlessConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<UiThemeConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<ServerConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<NotesConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PairTagConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<SyncshellTagConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<TransientConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<XivDataStorageService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<PlayerPerformanceConfigService>());
|
||||
collection.AddSingleton<IConfigService<ILightlessConfiguration>>(s => s.GetRequiredService<CharaDataConfigService>());
|
||||
collection.AddSingleton<ConfigurationMigrator>();
|
||||
collection.AddSingleton<ConfigurationSaveService>();
|
||||
collection.AddSingleton<HubFactory>();
|
||||
collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService<ILogger<BroadcastScannerService>>(), clientState, objectTable, framework, s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<NameplateHandler>(), s.GetRequiredService<DalamudUtilService>(), s.GetRequiredService<LightlessConfigService>()));
|
||||
services.AddSingleton<ConfigurationMigrator>();
|
||||
services.AddSingleton<ConfigurationSaveService>();
|
||||
|
||||
// Scoped factories / UI
|
||||
services.AddScoped<DrawEntityFactory>();
|
||||
services.AddScoped<CacheMonitor>();
|
||||
services.AddScoped<UiFactory>();
|
||||
services.AddScoped<SelectTagForPairUi>();
|
||||
services.AddScoped<SelectTagForSyncshellUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, SettingsUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, JoinSyncshellUI>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, UpdateNotesUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, ZoneChatUi>();
|
||||
|
||||
// add scoped services
|
||||
collection.AddScoped<DrawEntityFactory>();
|
||||
collection.AddScoped<CacheMonitor>();
|
||||
collection.AddScoped<UiFactory>();
|
||||
collection.AddScoped<SelectTagForPairUi>();
|
||||
collection.AddScoped<SelectTagForSyncshellUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, SettingsUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CompactUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, IntroUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, DownloadUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, PopoutProfileUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, DataAnalysisUi>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, JoinSyncshellUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CreateSyncshellUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, EventViewerUI>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, CharaDataHubUi>();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>(sp => new EditProfileUi(
|
||||
sp.GetRequiredService<ILogger<EditProfileUi>>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<FileDialogManager>(),
|
||||
sp.GetRequiredService<LightlessProfileManager>(),
|
||||
sp.GetRequiredService<ProfileTagService>(),
|
||||
sp.GetRequiredService<PerformanceCollectorService>()));
|
||||
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, EditProfileUi>((s) => new EditProfileUi(s.GetRequiredService<ILogger<EditProfileUi>>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<FileDialogManager>(),
|
||||
s.GetRequiredService<LightlessProfileManager>(), s.GetRequiredService<PerformanceCollectorService>()));
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, BroadcastUI>((s) => new BroadcastUI(s.GetRequiredService<ILogger<BroadcastUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>()));
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, SyncshellFinderUI>((s) => new SyncshellFinderUI(s.GetRequiredService<ILogger<SyncshellFinderUI>>(), s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<PerformanceCollectorService>(), s.GetRequiredService<BroadcastService>(), s.GetRequiredService<UiSharedService>(), s.GetRequiredService<ApiController>(), s.GetRequiredService<BroadcastScannerService>(), s.GetRequiredService<PairManager>(), s.GetRequiredService<DalamudUtilService>()));
|
||||
collection.AddScoped<IPopupHandler, BanUserPopupHandler>();
|
||||
collection.AddScoped<WindowMediatorSubscriberBase, LightlessNotificationUI>((s) =>
|
||||
new LightlessNotificationUI(
|
||||
s.GetRequiredService<ILogger<LightlessNotificationUI>>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<LightlessConfigService>()));
|
||||
collection.AddScoped<IPopupHandler, CensusPopupHandler>();
|
||||
collection.AddScoped<CacheCreationService>();
|
||||
collection.AddScoped<PlayerDataFactory>();
|
||||
collection.AddScoped<VisibleUserDataDistributor>();
|
||||
collection.AddScoped((s) => new UiService(s.GetRequiredService<ILogger<UiService>>(), pluginInterface.UiBuilder, s.GetRequiredService<LightlessConfigService>(),
|
||||
s.GetRequiredService<WindowSystem>(), s.GetServices<WindowMediatorSubscriberBase>(),
|
||||
s.GetRequiredService<UiFactory>(),
|
||||
s.GetRequiredService<FileDialogManager>(),
|
||||
s.GetRequiredService<LightlessMediator>(),
|
||||
s.GetRequiredService<NotificationService>()));
|
||||
collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService<PerformanceCollectorService>(),
|
||||
s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<ApiController>(),
|
||||
s.GetRequiredService<LightlessMediator>(), s.GetRequiredService<LightlessConfigService>()));
|
||||
collection.AddScoped((s) => new UiSharedService(s.GetRequiredService<ILogger<UiSharedService>>(), s.GetRequiredService<IpcManager>(), s.GetRequiredService<ApiController>(),
|
||||
s.GetRequiredService<CacheMonitor>(), s.GetRequiredService<FileDialogManager>(), s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<DalamudUtilService>(),
|
||||
pluginInterface, textureProvider, s.GetRequiredService<Dalamud.Localization>(), s.GetRequiredService<ServerConfigurationManager>(), s.GetRequiredService<TokenProvider>(),
|
||||
s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddScoped((s) => new NameplateService(s.GetRequiredService<ILogger<NameplateService>>(), s.GetRequiredService<LightlessConfigService>(), namePlateGui, clientState,
|
||||
s.GetRequiredService<PairManager>(), s.GetRequiredService<LightlessMediator>()));
|
||||
collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService<ILogger<NameplateHandler>>(), addonLifecycle, gameGui, s.GetRequiredService<DalamudUtilService>(),
|
||||
s.GetRequiredService<LightlessConfigService>(), s.GetRequiredService<LightlessMediator>(), clientState, s.GetRequiredService<PairManager>()));
|
||||
services.AddScoped<WindowMediatorSubscriberBase, PopupHandler>();
|
||||
|
||||
collection.AddHostedService(p => p.GetRequiredService<ConfigurationSaveService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<LightlessMediator>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<NotificationService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<FileCacheManager>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<ConfigurationMigrator>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<DalamudUtilService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<PerformanceCollectorService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<DtrEntry>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<EventAggregator>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<IpcProvider>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<LightlessPlugin>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<ContextMenuService>());
|
||||
collection.AddHostedService(p => p.GetRequiredService<BroadcastService>());
|
||||
})
|
||||
.Build();
|
||||
services.AddScoped<WindowMediatorSubscriberBase, LightFinderUI>(sp => new LightFinderUI(
|
||||
sp.GetRequiredService<ILogger<LightFinderUI>>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<PerformanceCollectorService>(),
|
||||
sp.GetRequiredService<LightFinderService>(),
|
||||
sp.GetRequiredService<LightlessConfigService>(),
|
||||
sp.GetRequiredService<UiSharedService>(),
|
||||
sp.GetRequiredService<ApiController>(),
|
||||
sp.GetRequiredService<LightFinderScannerService>(),
|
||||
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<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));
|
||||
}
|
||||
}
|
||||
|
||||
1294
LightlessSync/Services/ActorTracking/ActorObjectService.cs
Normal file
1294
LightlessSync/Services/ActorTracking/ActorObjectService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable
|
||||
{
|
||||
private readonly ILogger<BroadcastScannerService> _logger;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly IFramework _framework;
|
||||
|
||||
private readonly BroadcastService _broadcastService;
|
||||
private readonly NameplateHandler _nameplateHandler;
|
||||
|
||||
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new();
|
||||
private readonly Queue<string> _lookupQueue = new();
|
||||
private readonly HashSet<string> _lookupQueuedCids = new();
|
||||
private readonly HashSet<string> _syncshellCids = new();
|
||||
|
||||
private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly CancellationTokenSource _cleanupCts = new();
|
||||
private Task? _cleanupTask;
|
||||
|
||||
private int _checkEveryFrames = 20;
|
||||
private int _frameCounter = 0;
|
||||
private int _lookupsThisFrame = 0;
|
||||
private const int MaxLookupsPerFrame = 30;
|
||||
private const int MaxQueueSize = 100;
|
||||
|
||||
private volatile bool _batchRunning = false;
|
||||
|
||||
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
||||
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
||||
|
||||
public BroadcastScannerService(ILogger<BroadcastScannerService> logger,
|
||||
IClientState clientState,
|
||||
IObjectTable objectTable,
|
||||
IFramework framework,
|
||||
BroadcastService broadcastService,
|
||||
LightlessMediator mediator,
|
||||
NameplateHandler nameplateHandler,
|
||||
DalamudUtilService dalamudUtil,
|
||||
LightlessConfigService configService) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_objectTable = objectTable;
|
||||
_broadcastService = broadcastService;
|
||||
_nameplateHandler = nameplateHandler;
|
||||
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
_framework.Update += OnFrameworkUpdate;
|
||||
|
||||
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
|
||||
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop);
|
||||
|
||||
_nameplateHandler.Init();
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework framework) => Update();
|
||||
|
||||
public void Update()
|
||||
{
|
||||
_frameCounter++;
|
||||
_lookupsThisFrame = 0;
|
||||
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var obj in _objectTable)
|
||||
{
|
||||
if (obj is not IPlayerCharacter player || player.Address == IntPtr.Zero)
|
||||
continue;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address);
|
||||
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
||||
|
||||
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize)
|
||||
_lookupQueue.Enqueue(cid);
|
||||
}
|
||||
|
||||
if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0)
|
||||
{
|
||||
var cidsToLookup = new List<string>();
|
||||
while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame)
|
||||
{
|
||||
var cid = _lookupQueue.Dequeue();
|
||||
_lookupQueuedCids.Remove(cid);
|
||||
cidsToLookup.Add(cid);
|
||||
_lookupsThisFrame++;
|
||||
}
|
||||
|
||||
if (cidsToLookup.Count > 0 && !_batchRunning)
|
||||
{
|
||||
_batchRunning = true;
|
||||
_ = BatchUpdateBroadcastCacheAsync(cidsToLookup).ContinueWith(_ => _batchRunning = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
||||
{
|
||||
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var (cid, info) in results)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cid) || info == null)
|
||||
continue;
|
||||
|
||||
var ttl = info.IsBroadcasting && info.TTL.HasValue
|
||||
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks))
|
||||
: RetryDelay;
|
||||
|
||||
var expiry = now + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(cid,
|
||||
new BroadcastEntry(info.IsBroadcasting, expiry, info.GID),
|
||||
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
||||
}
|
||||
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
_nameplateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||
{
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
_broadcastCache.Clear();
|
||||
_lookupQueue.Clear();
|
||||
_lookupQueuedCids.Clear();
|
||||
_syncshellCids.Clear();
|
||||
|
||||
_nameplateHandler.UpdateBroadcastingCids(Enumerable.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var newSet = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
|
||||
.Select(e => e.Key)
|
||||
.ToHashSet();
|
||||
|
||||
if (!_syncshellCids.SetEquals(newSet))
|
||||
{
|
||||
_syncshellCids.Clear();
|
||||
foreach (var cid in newSet)
|
||||
_syncshellCids.Add(cid);
|
||||
|
||||
Mediator.Publish(new SyncshellBroadcastsUpdatedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
|
||||
.Select(e => new BroadcastStatusInfoDto
|
||||
{
|
||||
HashedCID = e.Key,
|
||||
IsBroadcasting = true,
|
||||
TTL = e.Value.ExpiryTime - now,
|
||||
GID = e.Value.GID
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task ExpiredBroadcastCleanupLoop()
|
||||
{
|
||||
var token = _cleanupCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), token);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (cid, entry) in _broadcastCache.ToArray())
|
||||
{
|
||||
if (entry.ExpiryTime <= now)
|
||||
_broadcastCache.TryRemove(cid, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Broadcast cleanup loop crashed");
|
||||
}
|
||||
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
public int CountActiveBroadcasts(string? excludeHashedCid = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var comparer = StringComparer.Ordinal;
|
||||
return _broadcastCache.Count(entry =>
|
||||
entry.Value.IsBroadcasting &&
|
||||
entry.Value.ExpiryTime > now &&
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
_framework.Update -= OnFrameworkUpdate;
|
||||
_cleanupCts.Cancel();
|
||||
_cleanupTask?.Wait(100);
|
||||
_nameplateHandler.Uninit();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using K4os.Compression.LZ4.Legacy;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.API.Dto.CharaData;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.Services.CharaData;
|
||||
using LightlessSync.Services.CharaData.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -24,10 +27,11 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
private readonly ILogger<CharaDataFileHandler> _logger;
|
||||
private readonly LightlessCharaFileDataFactory _lightlessCharaFileDataFactory;
|
||||
private readonly PlayerDataFactory _playerDataFactory;
|
||||
private readonly NotificationService _notificationService;
|
||||
private int _globalFileCounter = 0;
|
||||
|
||||
public CharaDataFileHandler(ILogger<CharaDataFileHandler> logger, FileDownloadManagerFactory fileDownloadManagerFactory, FileUploadManager fileUploadManager, FileCacheManager fileCacheManager,
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory)
|
||||
DalamudUtilService dalamudUtilService, GameObjectHandlerFactory gameObjectHandlerFactory, PlayerDataFactory playerDataFactory, NotificationService notificationService)
|
||||
{
|
||||
_fileDownloadManager = fileDownloadManagerFactory.Create();
|
||||
_logger = logger;
|
||||
@@ -36,6 +40,7 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
_dalamudUtilService = dalamudUtilService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_playerDataFactory = playerDataFactory;
|
||||
_notificationService = notificationService;
|
||||
_lightlessCharaFileDataFactory = new(fileCacheManager);
|
||||
}
|
||||
|
||||
@@ -248,54 +253,161 @@ public sealed class CharaDataFileHandler : IDisposable
|
||||
}
|
||||
|
||||
internal async Task SaveCharaFileAsync(string description, string filePath)
|
||||
{
|
||||
var createPlayerDataStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
createPlayerDataStopwatch.Stop();
|
||||
_logger.LogInformation("CreatePlayerData took {elapsed}ms", createPlayerDataStopwatch.ElapsedMilliseconds);
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
await Task.Run(async () => await SaveCharaFileAsyncInternal(description, filePath, data).ConfigureAwait(false)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SaveCharaFileAsyncInternal(string description, string filePath, CharacterData data)
|
||||
{
|
||||
var tempFilePath = filePath + ".tmp";
|
||||
var overallStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var data = await CreatePlayerData().ConfigureAwait(false);
|
||||
if (data == null) return;
|
||||
|
||||
var lightlessCharaFileData = _lightlessCharaFileDataFactory.Create(description, data);
|
||||
LightlessCharaFileHeader output = new(LightlessCharaFileHeader.CurrentVersion, lightlessCharaFileData);
|
||||
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
|
||||
using var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize: 65536, useAsync: false);
|
||||
using var lz4 = new LZ4Stream(fs, LZ4StreamMode.Compress, LZ4StreamFlags.HighCompression);
|
||||
using var writer = new BinaryWriter(lz4);
|
||||
output.WriteToStream(writer);
|
||||
|
||||
int fileIndex = 0;
|
||||
long totalBytesWritten = 0;
|
||||
long totalBytesToWrite = output.CharaFileData.Files.Sum(f => f.Length);
|
||||
var fileWriteStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
const long updateIntervalMs = 1000;
|
||||
|
||||
foreach (var item in output.CharaFileData.Files)
|
||||
{
|
||||
fileIndex++;
|
||||
var fileStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var file = _fileCacheManager.GetFileCacheByHash(item.Hash)!;
|
||||
_logger.LogDebug("Saving to MCDF: {hash}:{file}", item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("Saving to MCDF [{fileNum}/{totalFiles}]: {hash}:{file}", fileIndex, output.CharaFileData.Files.Count, item.Hash, file.ResolvedFilepath);
|
||||
_logger.LogDebug("\tAssociated GamePaths:");
|
||||
foreach (var path in item.GamePaths)
|
||||
{
|
||||
_logger.LogDebug("\t{path}", path);
|
||||
}
|
||||
|
||||
var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
await using (fsRead.ConfigureAwait(false))
|
||||
using var fsRead = File.OpenRead(file.ResolvedFilepath);
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
int bytesRead = br.Read(buffer, 0, item.Length);
|
||||
|
||||
if (bytesRead != item.Length)
|
||||
{
|
||||
using var br = new BinaryReader(fsRead);
|
||||
byte[] buffer = new byte[item.Length];
|
||||
br.Read(buffer, 0, item.Length);
|
||||
writer.Write(buffer);
|
||||
_logger.LogWarning("Expected to read {expected} bytes but got {actual} bytes from {file}", item.Length, bytesRead, file.ResolvedFilepath);
|
||||
}
|
||||
|
||||
writer.Write(buffer);
|
||||
totalBytesWritten += bytesRead;
|
||||
|
||||
fileStopwatch.Stop();
|
||||
_logger.LogDebug("Wrote file [{fileNum}/{totalFiles}] in {elapsed}ms ({sizeKb}kb)", fileIndex, output.CharaFileData.Files.Count, fileStopwatch.ElapsedMilliseconds, item.Length / 1024);
|
||||
|
||||
if (fileWriteStopwatch.ElapsedMilliseconds >= updateIntervalMs && totalBytesToWrite > 0)
|
||||
{
|
||||
float progress = (float)totalBytesWritten / totalBytesToWrite;
|
||||
var elapsed = overallStopwatch.Elapsed;
|
||||
var eta = CalculateEta(elapsed, progress);
|
||||
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "chara_file_save_progress",
|
||||
Title = "Character Data",
|
||||
Message = $"Compressing and saving character file... {(progress * 100):F0}%\nETA: {FormatTimespan(eta)}",
|
||||
Type = NotificationType.Info,
|
||||
Duration = TimeSpan.FromMinutes(5),
|
||||
ShowProgress = true,
|
||||
Progress = progress
|
||||
};
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
|
||||
fileWriteStopwatch.Restart();
|
||||
}
|
||||
}
|
||||
|
||||
var flushStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
writer.Flush();
|
||||
await lz4.FlushAsync().ConfigureAwait(false);
|
||||
await fs.FlushAsync().ConfigureAwait(false);
|
||||
lz4.Flush();
|
||||
fs.Flush();
|
||||
fs.Close();
|
||||
flushStopwatch.Stop();
|
||||
_logger.LogInformation("Flush operations took {elapsed}ms", flushStopwatch.ElapsedMilliseconds);
|
||||
|
||||
var moveStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
File.Move(tempFilePath, filePath, true);
|
||||
moveStopwatch.Stop();
|
||||
_logger.LogInformation("File move took {elapsed}ms", moveStopwatch.ElapsedMilliseconds);
|
||||
|
||||
overallStopwatch.Stop();
|
||||
_logger.LogInformation("SaveCharaFileAsync completed successfully in {elapsed}ms. Total bytes written: {totalBytes}mb", overallStopwatch.ElapsedMilliseconds, totalBytesWritten / (1024 * 1024));
|
||||
|
||||
_notificationService.ShowNotification(
|
||||
"Character Data",
|
||||
"Character file saved successfully!",
|
||||
NotificationType.Info,
|
||||
duration: TimeSpan.FromSeconds(5));
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationDismissMessage("chara_file_save_progress"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failure Saving Lightless Chara File, deleting output");
|
||||
File.Delete(tempFilePath);
|
||||
overallStopwatch.Stop();
|
||||
_logger.LogError(ex, "Failure Saving Lightless Chara File after {elapsed}ms, deleting output", overallStopwatch.ElapsedMilliseconds);
|
||||
try
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
catch (Exception deleteEx)
|
||||
{
|
||||
_logger.LogError(deleteEx, "Failed to delete temporary file {file}", tempFilePath);
|
||||
}
|
||||
|
||||
_notificationService.ShowErrorNotification(
|
||||
"Character Data Save Failed",
|
||||
"Failed to save character file",
|
||||
ex);
|
||||
|
||||
_notificationService.Mediator.Publish(new LightlessNotificationDismissMessage("chara_file_save_progress"));
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan CalculateEta(TimeSpan elapsed, float progress)
|
||||
{
|
||||
if (progress <= 0 || elapsed.TotalSeconds < 0.1)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
double totalSeconds = elapsed.TotalSeconds / progress;
|
||||
double remainingSeconds = totalSeconds - elapsed.TotalSeconds;
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Max(0, remainingSeconds));
|
||||
}
|
||||
|
||||
private static string FormatTimespan(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalSeconds < 1)
|
||||
return "< 1s";
|
||||
|
||||
if (ts.TotalSeconds < 60)
|
||||
return $"{ts.TotalSeconds:F0}s";
|
||||
|
||||
if (ts.TotalMinutes < 60)
|
||||
return $"{ts.TotalMinutes:F1}m";
|
||||
|
||||
return $"{ts.TotalHours:F1}h";
|
||||
}
|
||||
|
||||
internal async Task<List<string>> UploadFiles(List<string> fileList, ValueProgress<string> uploadProgress, CancellationToken token)
|
||||
{
|
||||
return await _fileUploadManager.UploadFiles(fileList, uploadProgress, token).ConfigureAwait(false);
|
||||
|
||||
@@ -450,7 +450,7 @@ public class CharaDataGposeTogetherManager : DisposableMediatorSubscriberBase
|
||||
};
|
||||
}
|
||||
|
||||
var loc = await _dalamudUtil.GetMapDataAsync().ConfigureAwait(false);
|
||||
var loc = _dalamudUtil.GetMapData();
|
||||
worldData.LocationInfo = loc;
|
||||
|
||||
if (_forceResendWorldData || worldData != _lastWorldData)
|
||||
|
||||
@@ -6,9 +6,9 @@ using LightlessSync.Interop.Ipc;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Factories;
|
||||
using LightlessSync.PlayerData.Handlers;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.Services.CharaData.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -28,7 +28,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
|
||||
private readonly List<CharaDataMetaInfoExtendedDto> _nearbyData = [];
|
||||
private readonly CharaDataNearbyManager _nearbyManager;
|
||||
private readonly CharaDataCharacterHandler _characterHandler;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly Dictionary<string, CharaDataFullExtendedDto> _ownCharaData = [];
|
||||
private readonly Dictionary<string, Task> _sharedMetaInfoTimeoutTasks = [];
|
||||
private readonly Dictionary<UserData, List<CharaDataMetaInfoExtendedDto>> _sharedWithYouData = [];
|
||||
@@ -45,7 +45,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
|
||||
LightlessMediator lightlessMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService,
|
||||
FileDownloadManagerFactory fileDownloadManagerFactory,
|
||||
CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager,
|
||||
CharaDataCharacterHandler charaDataCharacterHandler, PairManager pairManager) : base(logger, lightlessMediator)
|
||||
CharaDataCharacterHandler charaDataCharacterHandler, PairUiService pairUiService) : base(logger, lightlessMediator)
|
||||
{
|
||||
_apiController = apiController;
|
||||
_fileHandler = charaDataFileHandler;
|
||||
@@ -54,7 +54,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
|
||||
_configService = charaDataConfigService;
|
||||
_nearbyManager = charaDataNearbyManager;
|
||||
_characterHandler = charaDataCharacterHandler;
|
||||
_pairManager = pairManager;
|
||||
_pairUiService = pairUiService;
|
||||
lightlessMediator.Subscribe<ConnectedMessage>(this, (msg) =>
|
||||
{
|
||||
_connectCts?.Cancel();
|
||||
@@ -254,7 +254,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
|
||||
|
||||
Logger.LogTrace("Attaching World data {data}", worldData);
|
||||
|
||||
worldData.LocationInfo = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false);
|
||||
worldData.LocationInfo = _dalamudUtilService.GetMapData();
|
||||
|
||||
Logger.LogTrace("World data serialized: {data}", worldData);
|
||||
|
||||
@@ -421,9 +421,10 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase
|
||||
});
|
||||
|
||||
var result = await GetSharedWithYouTask.ConfigureAwait(false);
|
||||
var snapshot = _pairUiService.GetSnapshot();
|
||||
foreach (var grouping in result.GroupBy(r => r.Uploader))
|
||||
{
|
||||
var pair = _pairManager.GetPairByUID(grouping.Key.UID);
|
||||
snapshot.PairsByUid.TryGetValue(grouping.Key.UID, out var pair);
|
||||
if (pair?.IsPaused ?? false) continue;
|
||||
List<CharaDataMetaInfoExtendedDto> newList = new();
|
||||
foreach (var item in grouping)
|
||||
|
||||
@@ -186,8 +186,8 @@ public sealed class CharaDataNearbyManager : DisposableMediatorSubscriberBase
|
||||
var previousPoses = _nearbyData.Keys.ToList();
|
||||
_nearbyData.Clear();
|
||||
|
||||
var ownLocation = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetMapData()).ConfigureAwait(false);
|
||||
var player = await _dalamudUtilService.RunOnFrameworkThread(() => _dalamudUtilService.GetPlayerCharacter()).ConfigureAwait(false);
|
||||
var ownLocation = _dalamudUtilService.GetMapData();
|
||||
var player = await _dalamudUtilService.GetPlayerCharacterAsync().ConfigureAwait(false);
|
||||
var currentServer = player.CurrentWorld;
|
||||
var playerPos = player.Position;
|
||||
|
||||
|
||||
19
LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs
Normal file
19
LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.Services.CharaData.Models;
|
||||
using System.Collections.Immutable;
|
||||
namespace LightlessSync.Services.CharaData;
|
||||
|
||||
public sealed class CharacterAnalysisSummary
|
||||
{
|
||||
public static CharacterAnalysisSummary Empty { get; } =
|
||||
new(ImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary>.Empty);
|
||||
|
||||
internal CharacterAnalysisSummary(IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> objects)
|
||||
{
|
||||
Objects = objects;
|
||||
}
|
||||
|
||||
public IImmutableDictionary<ObjectKind, CharacterAnalysisObjectSummary> Objects { get; }
|
||||
|
||||
public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Runtime.InteropServices;
|
||||
namespace LightlessSync.Services.CharaData.Models;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes)
|
||||
{
|
||||
public bool HasEntries => EntryCount > 0;
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using LightlessSync.API.Data;
|
||||
using FFXIVClientStructs.FFXIV.Client.Graphics.Render;
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data.Enum;
|
||||
using LightlessSync.FileCache;
|
||||
using LightlessSync.Services.CharaData;
|
||||
using LightlessSync.Services.CharaData.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.Utils;
|
||||
using Lumina.Data.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
@@ -16,6 +19,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
private CancellationTokenSource? _analysisCts;
|
||||
private CancellationTokenSource _baseAnalysisCts = new();
|
||||
private string _lastDataHash = string.Empty;
|
||||
private CharacterAnalysisSummary _latestSummary = CharacterAnalysisSummary.Empty;
|
||||
|
||||
public CharacterAnalyzer(ILogger<CharacterAnalyzer> logger, LightlessMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
|
||||
: base(logger, mediator)
|
||||
@@ -24,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
|
||||
var token = _baseAnalysisCts.Token;
|
||||
_ = BaseAnalysis(msg.CharacterData, token);
|
||||
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
|
||||
});
|
||||
_fileCacheManager = fileCacheManager;
|
||||
_xivDataAnalyzer = modelAnalyzer;
|
||||
@@ -34,71 +38,101 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
public bool IsAnalysisRunning => _analysisCts != null;
|
||||
public int TotalFiles { get; internal set; }
|
||||
internal Dictionary<ObjectKind, Dictionary<string, FileDataEntry>> LastAnalysis { get; } = [];
|
||||
|
||||
public CharacterAnalysisSummary LatestSummary => _latestSummary;
|
||||
public void CancelAnalyze()
|
||||
{
|
||||
_analysisCts?.CancelDispose();
|
||||
_analysisCts = null;
|
||||
}
|
||||
|
||||
public async Task ComputeAnalysis(bool print = true, bool recalculate = false)
|
||||
{
|
||||
Logger.LogDebug("=== Calculating Character Analysis ===");
|
||||
|
||||
_analysisCts = _analysisCts?.CancelRecreate() ?? new();
|
||||
|
||||
var cancelToken = _analysisCts.Token;
|
||||
|
||||
var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList();
|
||||
if (allFiles.Exists(c => !c.IsComputed || recalculate))
|
||||
|
||||
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
|
||||
|
||||
if (remaining.Count == 0)
|
||||
return;
|
||||
|
||||
TotalFiles = remaining.Count;
|
||||
CurrentFile = 0;
|
||||
|
||||
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
|
||||
|
||||
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
|
||||
|
||||
try
|
||||
{
|
||||
var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList();
|
||||
TotalFiles = remaining.Count;
|
||||
CurrentFile = 1;
|
||||
Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count);
|
||||
|
||||
Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer)));
|
||||
try
|
||||
foreach (var file in remaining)
|
||||
{
|
||||
foreach (var file in remaining)
|
||||
{
|
||||
Logger.LogDebug("Computing file {file}", file.FilePaths[0]);
|
||||
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
|
||||
CurrentFile++;
|
||||
}
|
||||
cancelToken.ThrowIfCancellationRequested();
|
||||
|
||||
_fileCacheManager.WriteOutFullCsv();
|
||||
var path = file.FilePaths.FirstOrDefault() ?? "<unknown>";
|
||||
Logger.LogDebug("Computing file {file}", path);
|
||||
|
||||
await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false);
|
||||
|
||||
CurrentFile++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to analyze files");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
|
||||
}
|
||||
|
||||
await _fileCacheManager.WriteOutFullCsvAsync(cancelToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogInformation("File analysis cancelled");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to analyze files");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer)));
|
||||
}
|
||||
|
||||
RecalculateSummary();
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_analysisCts.CancelDispose();
|
||||
_analysisCts = null;
|
||||
|
||||
if (print) PrintAnalysis();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_analysisCts.CancelDispose();
|
||||
_baseAnalysisCts.Dispose();
|
||||
}
|
||||
|
||||
public async Task UpdateFileEntriesAsync(IEnumerable<string> filePaths, CancellationToken token)
|
||||
{
|
||||
var normalized = new HashSet<string>(
|
||||
filePaths.Where(path => !string.IsNullOrWhiteSpace(path)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (var objectEntries in LastAnalysis.Values)
|
||||
{
|
||||
foreach (var entry in objectEntries.Values)
|
||||
{
|
||||
if (!entry.FilePaths.Exists(path => normalized.Contains(path)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
await entry.ComputeSizes(_fileCacheManager, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BaseAnalysis(CharacterData charaData, CancellationToken token)
|
||||
{
|
||||
if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return;
|
||||
|
||||
LastAnalysis.Clear();
|
||||
|
||||
foreach (var obj in charaData.FileReplacements)
|
||||
{
|
||||
Dictionary<string, FileDataEntry> data = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -106,42 +140,82 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList();
|
||||
if (fileCacheEntries.Count == 0) continue;
|
||||
var fileCacheEntries = (await _fileCacheManager
|
||||
.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token)
|
||||
.ConfigureAwait(false))
|
||||
.ToList();
|
||||
|
||||
var filePath = fileCacheEntries[0].ResolvedFilepath;
|
||||
FileInfo fi = new(filePath);
|
||||
string ext = "unk?";
|
||||
try
|
||||
{
|
||||
ext = fi.Extension[1..];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Could not identify extension for {path}", filePath);
|
||||
}
|
||||
if (fileCacheEntries.Count == 0)
|
||||
continue;
|
||||
|
||||
var resolved = fileCacheEntries[0].ResolvedFilepath;
|
||||
|
||||
var extWithDot = Path.GetExtension(resolved);
|
||||
var ext = string.IsNullOrEmpty(extWithDot) ? "unk?" : extWithDot.TrimStart('.');
|
||||
|
||||
var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false);
|
||||
|
||||
foreach (var entry in fileCacheEntries)
|
||||
{
|
||||
data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext,
|
||||
[.. fileEntry.GamePaths],
|
||||
fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct().ToList(),
|
||||
entry.Size > 0 ? entry.Size.Value : 0,
|
||||
entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0,
|
||||
tris);
|
||||
}
|
||||
}
|
||||
var distinctFilePaths = fileCacheEntries
|
||||
.Select(c => c.ResolvedFilepath)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
long orig = 0, comp = 0;
|
||||
var first = fileCacheEntries[0];
|
||||
if (first.Size > 0) orig = first.Size.Value;
|
||||
if (first.CompressedSize > 0) comp = first.CompressedSize.Value;
|
||||
|
||||
if (_fileCacheManager.TryGetSizeInfo(fileEntry.Hash, out var cached))
|
||||
{
|
||||
if (orig <= 0 && cached.Original > 0) orig = cached.Original;
|
||||
if (comp <= 0 && cached.Compressed > 0) comp = cached.Compressed;
|
||||
}
|
||||
|
||||
data[fileEntry.Hash] = new FileDataEntry(
|
||||
fileEntry.Hash,
|
||||
ext,
|
||||
[.. fileEntry.GamePaths],
|
||||
distinctFilePaths,
|
||||
orig,
|
||||
comp,
|
||||
tris,
|
||||
fileCacheEntries);
|
||||
}
|
||||
LastAnalysis[obj.Key] = data;
|
||||
}
|
||||
|
||||
RecalculateSummary();
|
||||
Mediator.Publish(new CharacterDataAnalyzedMessage());
|
||||
|
||||
_lastDataHash = charaData.DataHash.Value;
|
||||
}
|
||||
|
||||
private void RecalculateSummary()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<ObjectKind, CharacterAnalysisObjectSummary>();
|
||||
|
||||
foreach (var (objectKind, entries) in LastAnalysis)
|
||||
{
|
||||
long totalTriangles = 0;
|
||||
long texOriginalBytes = 0;
|
||||
long texCompressedBytes = 0;
|
||||
|
||||
foreach (var entry in entries.Values)
|
||||
{
|
||||
totalTriangles += entry.Triangles;
|
||||
if (string.Equals(entry.FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
texOriginalBytes += entry.OriginalSize;
|
||||
texCompressedBytes += entry.CompressedSize;
|
||||
}
|
||||
}
|
||||
|
||||
builder[objectKind] = new CharacterAnalysisObjectSummary(entries.Count, totalTriangles, texOriginalBytes, texCompressedBytes);
|
||||
}
|
||||
|
||||
_latestSummary = new CharacterAnalysisSummary(builder.ToImmutable());
|
||||
}
|
||||
|
||||
private void PrintAnalysis()
|
||||
{
|
||||
if (LastAnalysis.Count == 0) return;
|
||||
@@ -150,7 +224,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
int fileCounter = 1;
|
||||
int totalFiles = kvp.Value.Count;
|
||||
Logger.LogInformation("=== Analysis for {obj} ===", kvp.Key);
|
||||
|
||||
foreach (var entry in kvp.Value.OrderBy(b => b.Value.GamePaths.OrderBy(p => p, StringComparer.Ordinal).First(), StringComparer.Ordinal))
|
||||
{
|
||||
Logger.LogInformation("File {x}/{y}: {hash}", fileCounter++, totalFiles, entry.Key);
|
||||
@@ -179,7 +252,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", kvp.Value.Count,
|
||||
UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.OriginalSize)), UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.CompressedSize)));
|
||||
}
|
||||
|
||||
Logger.LogInformation("=== Total summary for all currently present objects ===");
|
||||
Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}",
|
||||
LastAnalysis.Values.Sum(v => v.Values.Count),
|
||||
@@ -188,48 +260,91 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
|
||||
Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly.");
|
||||
}
|
||||
|
||||
internal sealed record FileDataEntry(string Hash, string FileType, List<string> GamePaths, List<string> FilePaths, long OriginalSize, long CompressedSize, long Triangles)
|
||||
internal sealed class FileDataEntry
|
||||
{
|
||||
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
|
||||
public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token)
|
||||
{
|
||||
var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false);
|
||||
var normalSize = new FileInfo(FilePaths[0]).Length;
|
||||
var entries = fileCacheManager.GetAllFileCachesByHash(Hash, ignoreCacheEntries: true, validate: false);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
entry.Size = normalSize;
|
||||
entry.CompressedSize = compressedsize.Item2.LongLength;
|
||||
}
|
||||
OriginalSize = normalSize;
|
||||
CompressedSize = compressedsize.Item2.LongLength;
|
||||
}
|
||||
public long OriginalSize { get; private set; } = OriginalSize;
|
||||
public long CompressedSize { get; private set; } = CompressedSize;
|
||||
public long Triangles { get; private set; } = Triangles;
|
||||
public string Hash { get; }
|
||||
public string FileType { get; }
|
||||
public List<string> GamePaths { get; }
|
||||
public List<string> FilePaths { get; }
|
||||
|
||||
public Lazy<string> Format = new(() =>
|
||||
public long OriginalSize { get; private set; }
|
||||
public long CompressedSize { get; private set; }
|
||||
public long Triangles { get; private set; }
|
||||
|
||||
public IReadOnlyList<FileCacheEntity> CacheEntries { get; }
|
||||
|
||||
public bool IsComputed => OriginalSize > 0 && CompressedSize > 0;
|
||||
|
||||
public FileDataEntry(
|
||||
string hash,
|
||||
string fileType,
|
||||
List<string> gamePaths,
|
||||
List<string> filePaths,
|
||||
long originalSize,
|
||||
long compressedSize,
|
||||
long triangles,
|
||||
IReadOnlyList<FileCacheEntity> cacheEntries)
|
||||
{
|
||||
switch (FileType)
|
||||
Hash = hash;
|
||||
FileType = fileType;
|
||||
GamePaths = gamePaths;
|
||||
FilePaths = filePaths;
|
||||
OriginalSize = originalSize;
|
||||
CompressedSize = compressedSize;
|
||||
Triangles = triangles;
|
||||
CacheEntries = cacheEntries;
|
||||
}
|
||||
|
||||
public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token, bool force = false)
|
||||
{
|
||||
if (!force && IsComputed)
|
||||
return;
|
||||
|
||||
if (FilePaths.Count == 0 || string.IsNullOrWhiteSpace(FilePaths[0]))
|
||||
return;
|
||||
|
||||
var path = FilePaths[0];
|
||||
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
|
||||
var original = new FileInfo(path).Length;
|
||||
|
||||
var compressedLen = await fileCacheManager.GetCompressedSizeAsync(Hash, token).ConfigureAwait(false);
|
||||
|
||||
fileCacheManager.SetSizeInfo(Hash, original, compressedLen);
|
||||
FileCacheManager.ApplySizesToEntries(CacheEntries, original, compressedLen);
|
||||
|
||||
OriginalSize = original;
|
||||
CompressedSize = compressedLen;
|
||||
|
||||
if (string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
RefreshFormat();
|
||||
}
|
||||
|
||||
public Lazy<string> Format => _format ??= CreateFormatValue();
|
||||
private Lazy<string>? _format;
|
||||
|
||||
public void RefreshFormat() => _format = CreateFormatValue();
|
||||
|
||||
private Lazy<string> CreateFormatValue()
|
||||
=> new(() =>
|
||||
{
|
||||
case "tex":
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new BinaryReader(stream);
|
||||
reader.BaseStream.Position = 4;
|
||||
var format = (TexFile.TextureFormat)reader.ReadInt32();
|
||||
return format.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
default:
|
||||
if (!string.Equals(FileType, "tex", StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var reader = new BinaryReader(stream);
|
||||
reader.BaseStream.Position = 4;
|
||||
var format = (TexFile.TextureFormat)reader.ReadInt32();
|
||||
return format.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "Unknown";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
770
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
770
LightlessSync/Services/Chat/ChatEmoteService.cs
Normal file
@@ -0,0 +1,770 @@
|
||||
using Dalamud.Interface.Textures.TextureWraps;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.UI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Gif;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Metadata;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace LightlessSync.Services.Chat;
|
||||
|
||||
public sealed class ChatEmoteService : IDisposable
|
||||
{
|
||||
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
||||
private const int DefaultFrameDelayMs = 100;
|
||||
private const int MinFrameDelayMs = 20;
|
||||
|
||||
private readonly ILogger<ChatEmoteService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly UiSharedService _uiSharedService;
|
||||
private readonly ChatConfigService _chatConfigService;
|
||||
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
||||
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
||||
|
||||
private readonly object _loadLock = new();
|
||||
private Task? _loadTask;
|
||||
|
||||
public ChatEmoteService(ILogger<ChatEmoteService> logger, HttpClient httpClient, UiSharedService uiSharedService, ChatConfigService chatConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_uiSharedService = uiSharedService;
|
||||
_chatConfigService = chatConfigService;
|
||||
}
|
||||
|
||||
public void EnsureGlobalEmotesLoaded()
|
||||
{
|
||||
lock (_loadLock)
|
||||
{
|
||||
if (_loadTask is not null && !_loadTask.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emotes.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loadTask = Task.Run(LoadGlobalEmotesAsync);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetEmoteNames()
|
||||
{
|
||||
EnsureGlobalEmotesLoaded();
|
||||
var names = _emotes.Keys.ToArray();
|
||||
Array.Sort(names, StringComparer.OrdinalIgnoreCase);
|
||||
return names;
|
||||
}
|
||||
|
||||
public bool TryGetEmote(string code, out IDalamudTextureWrap? texture)
|
||||
{
|
||||
texture = null;
|
||||
EnsureGlobalEmotesLoaded();
|
||||
|
||||
if (!_emotes.TryGetValue(code, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes;
|
||||
if (entry.TryGetTexture(allowAnimation, out texture))
|
||||
{
|
||||
if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation)
|
||||
{
|
||||
entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var entry in _emotes.Values)
|
||||
{
|
||||
entry.Dispose();
|
||||
}
|
||||
|
||||
_downloadGate.Dispose();
|
||||
}
|
||||
|
||||
private async Task LoadGlobalEmotesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("emotes", out var emotes))
|
||||
{
|
||||
_logger.LogWarning("7TV emote set response missing emotes array");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var emoteElement in emotes.EnumerateArray())
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = TryBuildEmoteSource(emoteElement);
|
||||
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load 7TV emote set");
|
||||
}
|
||||
}
|
||||
|
||||
private static EmoteSource? TryBuildEmoteSource(JsonElement emoteElement)
|
||||
{
|
||||
if (!emoteElement.TryGetProperty("data", out var dataElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dataElement.TryGetProperty("host", out var hostElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hostElement.TryGetProperty("url", out var urlElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseUrl = urlElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseUrl.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
baseUrl = "https:" + baseUrl;
|
||||
}
|
||||
|
||||
if (!hostElement.TryGetProperty("files", out var filesElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var files = ReadEmoteFiles(filesElement);
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var animatedFile = PickBestAnimatedFile(files);
|
||||
var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name);
|
||||
|
||||
var staticName = animatedFile?.StaticName;
|
||||
if (string.IsNullOrWhiteSpace(staticName))
|
||||
{
|
||||
staticName = PickBestStaticFileName(files);
|
||||
}
|
||||
|
||||
var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName);
|
||||
if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EmoteSource(staticUrl, animatedUrl);
|
||||
}
|
||||
|
||||
private static string BuildEmoteUrl(string baseUrl, string fileName)
|
||||
=> baseUrl.TrimEnd('/') + "/" + fileName;
|
||||
|
||||
private static List<EmoteFile> ReadEmoteFiles(JsonElement filesElement)
|
||||
{
|
||||
var files = new List<EmoteFile>();
|
||||
foreach (var file in filesElement.EnumerateArray())
|
||||
{
|
||||
if (!file.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? staticName = null;
|
||||
if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
staticName = staticNameElement.GetString();
|
||||
}
|
||||
|
||||
var frameCount = 1;
|
||||
if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
frameCountElement.TryGetInt32(out frameCount);
|
||||
frameCount = Math.Max(frameCount, 1);
|
||||
}
|
||||
|
||||
string? format = null;
|
||||
if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
format = formatElement.GetString();
|
||||
}
|
||||
|
||||
files.Add(new EmoteFile(name, staticName, frameCount, format));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static EmoteFile? PickBestAnimatedFile(IReadOnlyList<EmoteFile> files)
|
||||
{
|
||||
EmoteFile? webp1x = null;
|
||||
EmoteFile? gif1x = null;
|
||||
EmoteFile? webpFallback = null;
|
||||
EmoteFile? gifFallback = null;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
webp1x = file;
|
||||
}
|
||||
else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
gif1x = file;
|
||||
}
|
||||
else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
||||
{
|
||||
webpFallback = file;
|
||||
}
|
||||
else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
||||
{
|
||||
gifFallback = file;
|
||||
}
|
||||
}
|
||||
|
||||
return webp1x ?? gif1x ?? webpFallback ?? gifFallback;
|
||||
}
|
||||
|
||||
private static string? PickBestStaticFileName(IReadOnlyList<EmoteFile> files)
|
||||
{
|
||||
string? png1x = null;
|
||||
string? webp1x = null;
|
||||
string? gif1x = null;
|
||||
string? pngFallback = null;
|
||||
string? webpFallback = null;
|
||||
string? gifFallback = null;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.FrameCount > 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = file.StaticName ?? file.Name;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
png1x = name;
|
||||
}
|
||||
else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
webp1x = name;
|
||||
}
|
||||
else if (name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
gif1x = name;
|
||||
}
|
||||
else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null)
|
||||
{
|
||||
pngFallback = name;
|
||||
}
|
||||
else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
||||
{
|
||||
webpFallback = name;
|
||||
}
|
||||
else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
||||
{
|
||||
gifFallback = name;
|
||||
}
|
||||
}
|
||||
|
||||
return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback;
|
||||
}
|
||||
|
||||
private static bool IsAnimatedFormatSupported(EmoteFile file)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(file.Format))
|
||||
{
|
||||
return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase)
|
||||
|| file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
||||
|| file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl)
|
||||
{
|
||||
public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl);
|
||||
public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl);
|
||||
}
|
||||
|
||||
private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format);
|
||||
|
||||
private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (allowAnimation)
|
||||
{
|
||||
if (entry.HasAnimatedSource)
|
||||
{
|
||||
entry.MarkAnimationAttempted();
|
||||
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.HasAnimatedSource)
|
||||
{
|
||||
entry.MarkAnimationAttempted();
|
||||
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry.MarkFailed();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
|
||||
entry.MarkFailed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadGate.Release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false);
|
||||
var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
|
||||
if (!TryDecodeAnimation(data, isWebp, out var animation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.SetAnimation(animation);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryLoadStaticEmoteAsync(EmoteEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.StaticUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false);
|
||||
var texture = _uiSharedService.LoadImage(data);
|
||||
entry.SetStaticTexture(texture);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation)
|
||||
{
|
||||
animation = null;
|
||||
List<EmoteFrame>? frames = null;
|
||||
|
||||
try
|
||||
{
|
||||
Image<Rgba32> image;
|
||||
if (isWebp)
|
||||
{
|
||||
using var stream = new MemoryStream(data);
|
||||
image = WebpDecoder.Instance.Decode<Rgba32>(
|
||||
new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore },
|
||||
stream);
|
||||
}
|
||||
else
|
||||
{
|
||||
image = Image.Load<Rgba32>(data);
|
||||
}
|
||||
|
||||
using (image)
|
||||
{
|
||||
if (image.Frames.Count <= 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var composite = new Image<Rgba32>(image.Width, image.Height, Color.Transparent);
|
||||
Image<Rgba32>? restoreCanvas = null;
|
||||
GifDisposalMethod? pendingGifDisposal = null;
|
||||
WebpDisposalMethod? pendingWebpDisposal = null;
|
||||
|
||||
frames = new List<EmoteFrame>(image.Frames.Count);
|
||||
for (var i = 0; i < image.Frames.Count; i++)
|
||||
{
|
||||
var frameMetadata = image.Frames[i].Metadata;
|
||||
var delayMs = GetFrameDelayMs(frameMetadata);
|
||||
|
||||
ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal);
|
||||
|
||||
GifDisposalMethod? currentGifDisposal = null;
|
||||
WebpDisposalMethod? currentWebpDisposal = null;
|
||||
var blendMethod = WebpBlendMethod.Over;
|
||||
|
||||
if (isWebp)
|
||||
{
|
||||
if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata))
|
||||
{
|
||||
currentWebpDisposal = webpMetadata.DisposalMethod;
|
||||
blendMethod = webpMetadata.BlendMethod;
|
||||
}
|
||||
}
|
||||
else if (frameMetadata.TryGetGifMetadata(out var gifMetadata))
|
||||
{
|
||||
currentGifDisposal = gifMetadata.DisposalMethod;
|
||||
}
|
||||
|
||||
if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious)
|
||||
{
|
||||
restoreCanvas?.Dispose();
|
||||
restoreCanvas = composite.Clone();
|
||||
}
|
||||
|
||||
using var frameImage = image.Frames.CloneFrame(i);
|
||||
var alphaMode = blendMethod == WebpBlendMethod.Source
|
||||
? PixelAlphaCompositionMode.Src
|
||||
: PixelAlphaCompositionMode.SrcOver;
|
||||
composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f));
|
||||
|
||||
using var renderedFrame = composite.Clone();
|
||||
using var ms = new MemoryStream();
|
||||
renderedFrame.SaveAsPng(ms);
|
||||
|
||||
var texture = _uiSharedService.LoadImage(ms.ToArray());
|
||||
frames.Add(new EmoteFrame(texture, delayMs));
|
||||
|
||||
pendingGifDisposal = currentGifDisposal;
|
||||
pendingWebpDisposal = currentWebpDisposal;
|
||||
}
|
||||
|
||||
restoreCanvas?.Dispose();
|
||||
|
||||
animation = new EmoteAnimation(frames);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (frames is not null)
|
||||
{
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
frame.Texture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFrameDelayMs(ImageFrameMetadata metadata)
|
||||
{
|
||||
if (metadata.TryGetGifMetadata(out var gifMetadata))
|
||||
{
|
||||
var delayMs = (long)gifMetadata.FrameDelay * 10L;
|
||||
return NormalizeFrameDelayMs(delayMs);
|
||||
}
|
||||
|
||||
if (metadata.TryGetWebpFrameMetadata(out var webpMetadata))
|
||||
{
|
||||
return NormalizeFrameDelayMs(webpMetadata.FrameDelay);
|
||||
}
|
||||
|
||||
return DefaultFrameDelayMs;
|
||||
}
|
||||
|
||||
private static int NormalizeFrameDelayMs(long delayMs)
|
||||
{
|
||||
if (delayMs <= 0)
|
||||
{
|
||||
return DefaultFrameDelayMs;
|
||||
}
|
||||
|
||||
var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs;
|
||||
return Math.Max(clamped, MinFrameDelayMs);
|
||||
}
|
||||
|
||||
private static void ApplyDisposal(
|
||||
Image<Rgba32> composite,
|
||||
ref Image<Rgba32>? restoreCanvas,
|
||||
GifDisposalMethod? gifDisposal,
|
||||
WebpDisposalMethod? webpDisposal)
|
||||
{
|
||||
if (gifDisposal is not null)
|
||||
{
|
||||
switch (gifDisposal)
|
||||
{
|
||||
case GifDisposalMethod.RestoreToBackground:
|
||||
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
||||
break;
|
||||
case GifDisposalMethod.RestoreToPrevious:
|
||||
if (restoreCanvas is not null)
|
||||
{
|
||||
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
||||
var restoreSnapshot = restoreCanvas;
|
||||
composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f));
|
||||
restoreCanvas.Dispose();
|
||||
restoreCanvas = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (webpDisposal == WebpDisposalMethod.RestoreToBackground)
|
||||
{
|
||||
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EmoteAnimation : IDisposable
|
||||
{
|
||||
private readonly EmoteFrame[] _frames;
|
||||
private readonly int _durationMs;
|
||||
private readonly long _startTimestamp;
|
||||
|
||||
public EmoteAnimation(IReadOnlyList<EmoteFrame> frames)
|
||||
{
|
||||
_frames = frames.ToArray();
|
||||
_durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs));
|
||||
_startTimestamp = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
public IDalamudTextureWrap? GetCurrentFrame()
|
||||
{
|
||||
if (_frames.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_frames.Length == 1)
|
||||
{
|
||||
return _frames[0].Texture;
|
||||
}
|
||||
|
||||
var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp;
|
||||
var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency;
|
||||
var targetMs = (int)(elapsedMs % _durationMs);
|
||||
var accumulated = 0;
|
||||
|
||||
foreach (var frame in _frames)
|
||||
{
|
||||
accumulated += frame.DurationMs;
|
||||
if (targetMs < accumulated)
|
||||
{
|
||||
return frame.Texture;
|
||||
}
|
||||
}
|
||||
|
||||
return _frames[^1].Texture;
|
||||
}
|
||||
|
||||
public IDalamudTextureWrap? GetStaticFrame()
|
||||
{
|
||||
if (_frames.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _frames[0].Texture;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var frame in _frames)
|
||||
{
|
||||
frame.Texture.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs);
|
||||
|
||||
private sealed class EmoteEntry : IDisposable
|
||||
{
|
||||
private int _loadingState;
|
||||
private int _animationAttempted;
|
||||
private IDalamudTextureWrap? _staticTexture;
|
||||
private EmoteAnimation? _animation;
|
||||
|
||||
public EmoteEntry(string code, EmoteSource source)
|
||||
{
|
||||
Code = code;
|
||||
StaticUrl = source.StaticUrl;
|
||||
AnimatedUrl = source.AnimatedUrl;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
public string? StaticUrl { get; }
|
||||
public string? AnimatedUrl { get; }
|
||||
public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl);
|
||||
public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl);
|
||||
public bool HasStaticTexture => _staticTexture is not null;
|
||||
public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0;
|
||||
public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource;
|
||||
|
||||
public void MarkAnimationAttempted()
|
||||
{
|
||||
Interlocked.Exchange(ref _animationAttempted, 1);
|
||||
}
|
||||
|
||||
public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture)
|
||||
{
|
||||
if (allowAnimation && _animation is not null)
|
||||
{
|
||||
texture = _animation.GetCurrentFrame();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_staticTexture is not null)
|
||||
{
|
||||
texture = _staticTexture;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!allowAnimation && _animation is not null)
|
||||
{
|
||||
texture = _animation.GetStaticFrame();
|
||||
return true;
|
||||
}
|
||||
|
||||
texture = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void EnsureLoading(bool allowAnimation, Action<EmoteEntry, bool> queueDownload, bool allowWhenStaticLoaded = false)
|
||||
{
|
||||
if (_animation is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowWhenStaticLoaded && _staticTexture is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
queueDownload(this, allowAnimation);
|
||||
}
|
||||
|
||||
public void SetAnimation(EmoteAnimation animation)
|
||||
{
|
||||
_staticTexture?.Dispose();
|
||||
_staticTexture = null;
|
||||
_animation?.Dispose();
|
||||
_animation = animation;
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void SetStaticTexture(IDalamudTextureWrap texture)
|
||||
{
|
||||
_staticTexture?.Dispose();
|
||||
_staticTexture = texture;
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void MarkFailed()
|
||||
{
|
||||
Interlocked.Exchange(ref _loadingState, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_animation?.Dispose();
|
||||
_staticTexture?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
LightlessSync/Services/Chat/ChatModels.cs
Normal file
34
LightlessSync/Services/Chat/ChatModels.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using LightlessSync.API.Dto.Chat;
|
||||
|
||||
namespace LightlessSync.Services.Chat;
|
||||
|
||||
public sealed record ChatMessageEntry(
|
||||
ChatMessageDto? Payload,
|
||||
string DisplayName,
|
||||
bool FromSelf,
|
||||
DateTime ReceivedAtUtc,
|
||||
ChatSystemEntry? SystemMessage = null)
|
||||
{
|
||||
public bool IsSystem => SystemMessage is not null;
|
||||
}
|
||||
|
||||
public enum ChatSystemEntryType
|
||||
{
|
||||
ZoneSeparator
|
||||
}
|
||||
|
||||
public sealed record ChatSystemEntry(ChatSystemEntryType Type, string? ZoneName);
|
||||
|
||||
public readonly record struct ChatChannelSnapshot(
|
||||
string Key,
|
||||
ChatChannelDescriptor Descriptor,
|
||||
string DisplayName,
|
||||
ChatChannelType Type,
|
||||
bool IsConnected,
|
||||
bool IsAvailable,
|
||||
string? StatusText,
|
||||
bool HasUnread,
|
||||
int UnreadCount,
|
||||
IReadOnlyList<ChatMessageEntry> Messages);
|
||||
|
||||
public readonly record struct ChatReportResult(bool Success, string? ErrorMessage);
|
||||
1403
LightlessSync/Services/Chat/ZoneChatService.cs
Normal file
1403
LightlessSync/Services/Chat/ZoneChatService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ namespace LightlessSync.Services;
|
||||
|
||||
public sealed class CommandManagerService : IDisposable
|
||||
{
|
||||
private const string _commandName = "/light";
|
||||
private const string _longName = "/lightless";
|
||||
private const string _shortName = "/light";
|
||||
|
||||
private readonly ApiController _apiController;
|
||||
private readonly ICommandManager _commandManager;
|
||||
@@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable
|
||||
_apiController = apiController;
|
||||
_mediator = mediator;
|
||||
_lightlessConfigService = lightlessConfigService;
|
||||
_commandManager.AddHandler(_commandName, new CommandInfo(OnCommand)
|
||||
_commandManager.AddHandler(_longName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = $"\u2191;"
|
||||
});
|
||||
_commandManager.AddHandler(_shortName, new CommandInfo(OnCommand)
|
||||
{
|
||||
HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine +
|
||||
"Additionally possible commands:" + Environment.NewLine +
|
||||
@@ -43,13 +48,15 @@ public sealed class CommandManagerService : IDisposable
|
||||
"\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine +
|
||||
"\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine +
|
||||
"\t /light settings - Opens the Lightless Settings window" + Environment.NewLine +
|
||||
"\t /light finder - Opens the Lightfinder window"
|
||||
"\t /light finder - Opens the Lightfinder window" + Environment.NewLine +
|
||||
"\t /light chat - Opens the Lightless Chat window"
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_commandManager.RemoveHandler(_commandName);
|
||||
_commandManager.RemoveHandler(_longName);
|
||||
_commandManager.RemoveHandler(_shortName);
|
||||
}
|
||||
|
||||
private void OnCommand(string command, string args)
|
||||
@@ -125,7 +132,11 @@ public sealed class CommandManagerService : IDisposable
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_mediator.Publish(new UiToggleMessage(typeof(BroadcastUI)));
|
||||
_mediator.Publish(new UiToggleMessage(typeof(LightFinderUI)));
|
||||
}
|
||||
else if (string.Equals(splitArgs[0], "chat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_mediator.Publish(new UiToggleMessage(typeof(ZoneChatUi)));
|
||||
}
|
||||
}
|
||||
}
|
||||
224
LightlessSync/Services/Compactor/BatchFileFragService.cs
Normal file
224
LightlessSync/Services/Compactor/BatchFileFragService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.ClientState.Objects.SubKinds;
|
||||
using Dalamud.Game.Gui.ContextMenu;
|
||||
using Dalamud.Plugin;
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.PlayerData.Pairs;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services.LightFinder;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Lumina.Excel.Sheets;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -15,16 +18,24 @@ namespace LightlessSync.Services;
|
||||
internal class ContextMenuService : IHostedService
|
||||
{
|
||||
private readonly IContextMenu _contextMenu;
|
||||
private readonly IChatGui _chatGui;
|
||||
private readonly IDalamudPluginInterface _pluginInterface;
|
||||
private readonly IDataManager _gameData;
|
||||
private readonly ILogger<ContextMenuService> _logger;
|
||||
private readonly DalamudUtilService _dalamudUtil;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly PairManager _pairManager;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly PairRequestService _pairRequestService;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly NotificationService _lightlessNotification;
|
||||
private readonly LightFinderScannerService _broadcastScannerService;
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly LightlessProfileManager _lightlessProfileManager;
|
||||
private readonly LightlessMediator _mediator;
|
||||
|
||||
private const int _lightlessPrefixColor = 708;
|
||||
|
||||
public ContextMenuService(
|
||||
IContextMenu contextMenu,
|
||||
@@ -36,8 +47,14 @@ internal class ContextMenuService : IHostedService
|
||||
IObjectTable objectTable,
|
||||
LightlessConfigService configService,
|
||||
PairRequestService pairRequestService,
|
||||
PairManager pairManager,
|
||||
IClientState clientState)
|
||||
PairUiService pairUiService,
|
||||
IClientState clientState,
|
||||
LightFinderScannerService broadcastScannerService,
|
||||
LightFinderService broadcastService,
|
||||
LightlessProfileManager lightlessProfileManager,
|
||||
LightlessMediator mediator,
|
||||
IChatGui chatGui,
|
||||
NotificationService lightlessNotification)
|
||||
{
|
||||
_contextMenu = contextMenu;
|
||||
_pluginInterface = pluginInterface;
|
||||
@@ -47,9 +64,15 @@ internal class ContextMenuService : IHostedService
|
||||
_apiController = apiController;
|
||||
_objectTable = objectTable;
|
||||
_configService = configService;
|
||||
_pairManager = pairManager;
|
||||
_pairUiService = pairUiService;
|
||||
_pairRequestService = pairRequestService;
|
||||
_clientState = clientState;
|
||||
_broadcastScannerService = broadcastScannerService;
|
||||
_broadcastService = broadcastService;
|
||||
_lightlessProfileManager = lightlessProfileManager;
|
||||
_mediator = mediator;
|
||||
_chatGui = chatGui;
|
||||
_lightlessNotification = lightlessNotification;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
@@ -78,59 +101,131 @@ internal class ContextMenuService : IHostedService
|
||||
|
||||
private void OnMenuOpened(IMenuOpenedArgs args)
|
||||
{
|
||||
|
||||
if (!_pluginInterface.UiBuilder.ShouldModifyUi)
|
||||
return;
|
||||
|
||||
if (args.AddonName != null)
|
||||
return;
|
||||
|
||||
//Check if target is not menutargetdefault.
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
return;
|
||||
|
||||
//Check if name or target id isnt null/zero
|
||||
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
|
||||
return;
|
||||
|
||||
//Check if it is a real target.
|
||||
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
|
||||
if (targetData == null || targetData.Address == nint.Zero)
|
||||
return;
|
||||
|
||||
//Check if user is paired or is own.
|
||||
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId)
|
||||
return;
|
||||
|
||||
//Check if in PVP or GPose
|
||||
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
|
||||
return;
|
||||
|
||||
//Check for valid world.
|
||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
||||
if (!IsWorldValid(world))
|
||||
return;
|
||||
|
||||
if (!_configService.Current.EnableRightClickMenus)
|
||||
return;
|
||||
|
||||
args.AddMenuItem(new MenuItem
|
||||
{
|
||||
Name = "Send Pair Request",
|
||||
PrefixChar = 'L',
|
||||
UseDefaultPrefix = false,
|
||||
PrefixColor = 708,
|
||||
OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false)
|
||||
});
|
||||
_logger.LogTrace("Right-click menus are disabled in configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.AddonName != null)
|
||||
{
|
||||
var addonName = args.AddonName;
|
||||
_logger.LogTrace("Context menu addon name: {AddonName}", addonName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
{
|
||||
_logger.LogTrace("Context menu target is not MenuTargetDefault.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null");
|
||||
|
||||
if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0)
|
||||
{
|
||||
_logger.LogTrace("Context menu target has invalid data: Name='{TargetName}', ObjectId={TargetObjectId}, HomeWorldId={TargetHomeWorldId}", target.TargetName, target.TargetObjectId, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
}
|
||||
|
||||
IPlayerCharacter? targetData = GetPlayerFromObjectTable(target);
|
||||
if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null)
|
||||
{
|
||||
_logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _pairUiService.GetSnapshot();
|
||||
var pair = snapshot.PairsByUid.Values.FirstOrDefault(p =>
|
||||
p.PlayerCharacterId != uint.MaxValue &&
|
||||
p.PlayerCharacterId == target.TargetObjectId);
|
||||
|
||||
if (pair is not null)
|
||||
{
|
||||
_logger.LogTrace("Target player {TargetName}@{World} is already paired, adding existing pair context menu.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
|
||||
pair.AddContextMenu(args);
|
||||
if (!pair.IsDirectlyPaired)
|
||||
{
|
||||
_logger.LogTrace("Target player {TargetName}@{World} is not directly paired, add direct pair menu item", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
AddDirectPairMenuItem(args);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("Target player {TargetName}@{World} is not paired, adding direct pair request context menu.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
|
||||
//Check if user is directly paired or is own.
|
||||
if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _objectTable.LocalPlayer?.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus)
|
||||
{
|
||||
_logger.LogTrace("Target player {TargetName}@{World} is already paired or is self, or right-click menus are disabled.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing)
|
||||
{
|
||||
_logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsWorldValid(target.TargetHomeWorld.RowId))
|
||||
{
|
||||
_logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId);
|
||||
return;
|
||||
}
|
||||
|
||||
string? targetHashedCid = null;
|
||||
if (_broadcastService.IsBroadcasting)
|
||||
{
|
||||
targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid))
|
||||
{
|
||||
var hashedCid = targetHashedCid;
|
||||
UiSharedService.AddContextMenuItem(args, name: "Open Lightless Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleLightfinderProfileSelection(hashedCid));
|
||||
}
|
||||
|
||||
AddDirectPairMenuItem(args);
|
||||
}
|
||||
|
||||
private void AddDirectPairMenuItem(IMenuOpenedArgs args)
|
||||
{
|
||||
UiSharedService.AddContextMenuItem(
|
||||
args,
|
||||
name: "Send Direct Pair Request",
|
||||
prefixChar: 'L',
|
||||
colorMenuItem: _lightlessPrefixColor,
|
||||
onClick: () => HandleSelection(args));
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds =>
|
||||
[.. _pairUiService.GetSnapshot().PairsByUid.Values
|
||||
.Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(p => (ulong)p.PlayerCharacterId)];
|
||||
|
||||
private void NotifyInChat(string message, NotificationType type = NotificationType.Info)
|
||||
{
|
||||
if (!_configService.Current.UseLightlessNotifications || (_configService.Current.LightlessPairRequestNotification == NotificationLocation.Chat || _configService.Current.LightlessPairRequestNotification == NotificationLocation.ChatAndLightlessUi))
|
||||
{
|
||||
var chatMsg = $"[Lightless] {message}";
|
||||
if (type == NotificationType.Error)
|
||||
_chatGui.PrintError(chatMsg);
|
||||
else
|
||||
_chatGui.Print(chatMsg);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSelection(IMenuArgs args)
|
||||
{
|
||||
if (args.Target is not MenuTargetDefault target)
|
||||
return;
|
||||
|
||||
var world = GetWorld(target.TargetHomeWorld.RowId);
|
||||
if (!IsWorldValid(world))
|
||||
|
||||
if (!target.TargetHomeWorld.IsValid || !IsWorldValid(target.TargetHomeWorld.RowId))
|
||||
return;
|
||||
|
||||
try
|
||||
@@ -139,11 +234,11 @@ internal class ContextMenuService : IHostedService
|
||||
|
||||
if (targetData == null || targetData.Address == nint.Zero)
|
||||
{
|
||||
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, world.Name);
|
||||
_logger.LogWarning("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.Value.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256();
|
||||
var senderCid = _dalamudUtil.GetCID().ToString().GetHash256();
|
||||
var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address);
|
||||
|
||||
_logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid);
|
||||
@@ -152,6 +247,9 @@ internal class ContextMenuService : IHostedService
|
||||
{
|
||||
_pairRequestService.RemoveRequest(receiverCid);
|
||||
}
|
||||
|
||||
// Notify in chat when NotificationService is disabled
|
||||
NotifyInChat($"Pair request sent to {target.TargetName}@{target.TargetHomeWorld.Value.Name}.", NotificationType.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -159,9 +257,48 @@ internal class ContextMenuService : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds => [.. _pairManager.GetOnlineUserPairs()
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
private async Task HandleLightfinderProfileSelection(string hashedCid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hashedCid))
|
||||
return;
|
||||
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
{
|
||||
Notify("Lightfinder inactive", "Enable Lightfinder to open broadcaster profiles.", NotificationType.Warning, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry) || !entry.IsBroadcasting || entry.ExpiryTime <= DateTime.UtcNow)
|
||||
{
|
||||
Notify("Broadcaster unavailable", "That player is not currently using Lightfinder.", NotificationType.Info, 5);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _lightlessProfileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false);
|
||||
if (result == null)
|
||||
{
|
||||
Notify("Profile unavailable", "Unable to load Lightless profile for that player.", NotificationType.Error, 6);
|
||||
return;
|
||||
}
|
||||
|
||||
_mediator.Publish(new OpenLightfinderProfileMessage(result.Value.User, result.Value.ProfileData, hashedCid));
|
||||
}
|
||||
|
||||
private void Notify(string title, string message, NotificationType type, double durationSeconds)
|
||||
{
|
||||
_mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds)));
|
||||
}
|
||||
|
||||
private bool CanOpenLightfinderProfile(string hashedCid)
|
||||
{
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
return false;
|
||||
|
||||
if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry))
|
||||
return false;
|
||||
|
||||
return entry.IsBroadcasting && entry.ExpiryTime > DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target)
|
||||
{
|
||||
@@ -172,39 +309,8 @@ internal class ContextMenuService : IHostedService
|
||||
p.HomeWorld.RowId == target.TargetHomeWorld.RowId);
|
||||
}
|
||||
|
||||
private World GetWorld(uint worldId)
|
||||
private bool IsWorldValid(uint worldId)
|
||||
{
|
||||
var sheet = _gameData.GetExcelSheet<World>()!;
|
||||
var luminaWorlds = sheet.Where(x =>
|
||||
{
|
||||
var dc = x.DataCenter.ValueNullable;
|
||||
var name = x.Name.ExtractText();
|
||||
var internalName = x.InternalName.ExtractText();
|
||||
|
||||
if (dc == null || dc.Value.Region == 0 || string.IsNullOrWhiteSpace(dc.Value.Name.ExtractText()))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(internalName))
|
||||
return false;
|
||||
|
||||
if (name.Contains('-', StringComparison.Ordinal) || name.Contains('_', StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
return x.DataCenter.Value.Region != 5 || x.RowId > 3001 && x.RowId != 1200 && IsChineseJapaneseKoreanString(name);
|
||||
});
|
||||
|
||||
return luminaWorlds.FirstOrDefault(x => x.RowId == worldId);
|
||||
}
|
||||
|
||||
private static bool IsChineseJapaneseKoreanString(string text) => text.All(IsChineseJapaneseKoreanCharacter);
|
||||
|
||||
private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF;
|
||||
|
||||
public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId));
|
||||
|
||||
public static bool IsWorldValid(World world)
|
||||
{
|
||||
var name = world.Name.ToString();
|
||||
return !string.IsNullOrWhiteSpace(name) && char.IsUpper(name[0]);
|
||||
return _dalamudUtil.WorldData.Value.ContainsKey((ushort)worldId);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ public record Event
|
||||
{
|
||||
public DateTime EventTime { get; }
|
||||
public string UID { get; }
|
||||
public string AliasOrUid { get; }
|
||||
public string UserId { get; }
|
||||
public string Character { get; }
|
||||
public string EventSource { get; }
|
||||
public EventSeverity EventSeverity { get; }
|
||||
@@ -14,7 +16,9 @@ public record Event
|
||||
public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message)
|
||||
{
|
||||
EventTime = DateTime.Now;
|
||||
this.UID = UserData.AliasOrUID;
|
||||
this.UserId = UserData.UID;
|
||||
this.AliasOrUid = UserData.AliasOrUID;
|
||||
this.UID = UserData.UID;
|
||||
this.Character = Character ?? string.Empty;
|
||||
this.EventSource = EventSource;
|
||||
this.EventSeverity = EventSeverity;
|
||||
@@ -37,7 +41,7 @@ public record Event
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(Character))
|
||||
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}> {Message}";
|
||||
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{AliasOrUid}> {Message}";
|
||||
else
|
||||
return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}\\{Character}> {Message}";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
using Dalamud.Game.ClientState.Objects.Enums;
|
||||
using Dalamud.Plugin.Services;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Services;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.UtilsEnum.Enum;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Immutable;
|
||||
using Task = System.Threading.Tasks.Task;
|
||||
|
||||
namespace LightlessSync.Services.LightFinder;
|
||||
|
||||
/// <summary>
|
||||
/// Native nameplate handler that injects LightFinder labels via the signature hook path.
|
||||
/// </summary>
|
||||
public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService
|
||||
{
|
||||
private const uint NameplateNodeIdBase = 0x7D99D500;
|
||||
private const string DefaultLabelText = "LightFinder";
|
||||
|
||||
private readonly ILogger<LightFinderNativePlateHandler> _logger;
|
||||
private readonly IClientState _clientState;
|
||||
private readonly IObjectTable _objectTable;
|
||||
private readonly LightlessConfigService _configService;
|
||||
private readonly PairUiService _pairUiService;
|
||||
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
||||
|
||||
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
||||
private readonly string?[] _lastLabelByIndex = new string?[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||||
private LightfinderLabelRenderer _lastRenderer;
|
||||
private uint _lastSignatureUpdateFrame;
|
||||
private bool _isUpdating;
|
||||
private string _lastLabelContent = DefaultLabelText;
|
||||
|
||||
public LightFinderNativePlateHandler(
|
||||
ILogger<LightFinderNativePlateHandler> logger,
|
||||
IClientState clientState,
|
||||
LightlessConfigService configService,
|
||||
LightlessMediator mediator,
|
||||
IObjectTable objectTable,
|
||||
PairUiService pairUiService,
|
||||
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_clientState = clientState;
|
||||
_configService = configService;
|
||||
_objectTable = objectTable;
|
||||
_pairUiService = pairUiService;
|
||||
_nameplateUpdateHookService = nameplateUpdateHookService;
|
||||
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
||||
|
||||
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
}
|
||||
|
||||
private bool IsSignatureMode => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.SignatureHook;
|
||||
|
||||
/// <summary>
|
||||
/// Starts listening for nameplate updates from the hook service.
|
||||
/// </summary>
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops listening for nameplate updates and tears down any constructed nodes.
|
||||
/// </summary>
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
||||
UnsubscribeAll();
|
||||
TryDestroyNameplateNodes();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggered by the sig hook to refresh native nameplate labels.
|
||||
/// </summary>
|
||||
private void HandleNameplateUpdate(RaptureAtkModule* raptureAtkModule)
|
||||
{
|
||||
if (_isUpdating)
|
||||
return;
|
||||
|
||||
_isUpdating = true;
|
||||
try
|
||||
{
|
||||
RefreshRendererState();
|
||||
if (!IsSignatureMode)
|
||||
return;
|
||||
|
||||
if (raptureAtkModule == null)
|
||||
return;
|
||||
|
||||
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
if (_clientState.IsGPosing)
|
||||
{
|
||||
HideAllNameplateNodes(namePlateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
var fw = Framework.Instance();
|
||||
if (fw == null)
|
||||
return;
|
||||
|
||||
var frame = fw->FrameCounter;
|
||||
if (_lastSignatureUpdateFrame == frame)
|
||||
return;
|
||||
|
||||
_lastSignatureUpdateFrame = frame;
|
||||
UpdateNameplateNodes(namePlateAddon);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUpdating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hook callback from the nameplate update signature.
|
||||
/// </summary>
|
||||
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
||||
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
||||
{
|
||||
HandleNameplateUpdate(raptureAtkModule);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active broadcasting CID set and requests a nameplate redraw.
|
||||
/// </summary>
|
||||
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
||||
{
|
||||
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
|
||||
return;
|
||||
|
||||
_activeBroadcastingCids = newSet;
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
_logger.LogTrace("Active broadcast IDs (native): {Cids}", string.Join(',', _activeBroadcastingCids));
|
||||
RequestNameplateRedraw();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync renderer state with config and clear/remove native nodes if needed.
|
||||
/// </summary>
|
||||
private void RefreshRendererState()
|
||||
{
|
||||
var renderer = _configService.Current.LightfinderLabelRenderer;
|
||||
if (renderer == _lastRenderer)
|
||||
return;
|
||||
|
||||
_lastRenderer = renderer;
|
||||
|
||||
if (renderer == LightfinderLabelRenderer.SignatureHook)
|
||||
{
|
||||
ClearNameplateCaches();
|
||||
RequestNameplateRedraw();
|
||||
}
|
||||
else
|
||||
{
|
||||
TryDestroyNameplateNodes();
|
||||
ClearNameplateCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a full nameplate update through the native addon.
|
||||
/// </summary>
|
||||
private void RequestNameplateRedraw()
|
||||
{
|
||||
if (!IsSignatureMode)
|
||||
return;
|
||||
|
||||
var raptureAtkModule = GetRaptureAtkModule();
|
||||
if (raptureAtkModule == null)
|
||||
return;
|
||||
|
||||
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
namePlateAddon->DoFullUpdate = 1;
|
||||
}
|
||||
|
||||
private HashSet<ulong> VisibleUserIds
|
||||
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
|
||||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||||
|
||||
/// <summary>
|
||||
/// Creates/updates LightFinder label nodes for active broadcasts.
|
||||
/// </summary>
|
||||
private void UpdateNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||
return;
|
||||
|
||||
if (!IsSignatureMode)
|
||||
{
|
||||
HideAllNameplateNodes(namePlateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeBroadcastingCids.Count == 0)
|
||||
{
|
||||
HideAllNameplateNodes(namePlateAddon);
|
||||
return;
|
||||
}
|
||||
|
||||
var framework = Framework.Instance();
|
||||
if (framework == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var uiModule = framework->GetUIModule();
|
||||
if (uiModule == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ui3DModule = uiModule->GetUI3DModule();
|
||||
if (ui3DModule == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
var vec = ui3DModule->NamePlateObjectInfoPointers;
|
||||
if (vec.IsEmpty)
|
||||
return;
|
||||
|
||||
var config = _configService.Current;
|
||||
var visibleUserIdsSnapshot = VisibleUserIds;
|
||||
var labelColor = UIColors.Get("Lightfinder");
|
||||
var edgeColor = UIColors.Get("LightfinderEdge");
|
||||
var scaleMultiplier = Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
|
||||
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||||
var effectiveScale = baseScale * scaleMultiplier;
|
||||
var labelContent = config.LightfinderLabelUseIcon
|
||||
? LightFinderPlateHandler.NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
|
||||
: DefaultLabelText;
|
||||
|
||||
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||||
labelContent = DefaultLabelText;
|
||||
|
||||
if (!string.Equals(_lastLabelContent, labelContent, StringComparison.Ordinal))
|
||||
{
|
||||
_lastLabelContent = labelContent;
|
||||
Array.Fill(_lastLabelByIndex, null);
|
||||
}
|
||||
|
||||
var desiredFontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
|
||||
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
|
||||
var desiredFontSize = (byte)Math.Clamp((int)Math.Round(baseFontSize * scaleMultiplier), 1, 255);
|
||||
var desiredFlags = config.LightfinderLabelUseIcon
|
||||
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
|
||||
: TextFlags.Edge | TextFlags.Glare;
|
||||
var desiredLineSpacing = (byte)Math.Clamp((int)Math.Round(24 * scaleMultiplier), 0, byte.MaxValue);
|
||||
var defaultNodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||||
var defaultNodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||||
|
||||
var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
|
||||
var visibleIndices = new bool[AddonNamePlate.NumNamePlateObjects];
|
||||
|
||||
for (int i = 0; i < safeCount; ++i)
|
||||
{
|
||||
var objectInfoPtr = vec[i];
|
||||
if (objectInfoPtr == null)
|
||||
continue;
|
||||
|
||||
var objectInfo = objectInfoPtr.Value;
|
||||
if (objectInfo == null || objectInfo->GameObject == null)
|
||||
continue;
|
||||
|
||||
var nameplateIndex = objectInfo->NamePlateIndex;
|
||||
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
|
||||
continue;
|
||||
|
||||
var gameObject = objectInfo->GameObject;
|
||||
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
||||
continue;
|
||||
|
||||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||||
continue;
|
||||
|
||||
var local = _objectTable.LocalPlayer;
|
||||
if (!config.LightfinderLabelShowOwn && local != null &&
|
||||
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
||||
continue;
|
||||
|
||||
var hidePaired = !config.LightfinderLabelShowPaired;
|
||||
var goId = (ulong)gameObject->GetGameObjectId();
|
||||
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
||||
continue;
|
||||
|
||||
var nameplateObject = namePlateAddon->NamePlateObjectArray[nameplateIndex];
|
||||
var root = nameplateObject.RootComponentNode;
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
var nameText = nameplateObject.NameText;
|
||||
var marker = nameplateObject.MarkerIcon;
|
||||
|
||||
if (root == null || root->Component == null || nameContainer == null || nameText == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeId = GetNameplateNodeId(nameplateIndex);
|
||||
var pNode = EnsureNameplateTextNode(nameContainer, root, nodeId, out var nodeCreated);
|
||||
if (pNode == null)
|
||||
continue;
|
||||
|
||||
bool isVisible =
|
||||
((marker != null) && marker->AtkResNode.IsVisible()) ||
|
||||
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
|
||||
config.LightfinderLabelShowHidden;
|
||||
|
||||
if (!isVisible)
|
||||
continue;
|
||||
|
||||
if (!pNode->AtkResNode.IsVisible())
|
||||
pNode->AtkResNode.ToggleVisibility(enable: true);
|
||||
visibleIndices[nameplateIndex] = true;
|
||||
|
||||
if (nodeCreated)
|
||||
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
|
||||
|
||||
var scaleMatches = NearlyEqual(pNode->AtkResNode.ScaleX, effectiveScale) &&
|
||||
NearlyEqual(pNode->AtkResNode.ScaleY, effectiveScale);
|
||||
if (!scaleMatches)
|
||||
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
|
||||
|
||||
var fontTypeChanged = pNode->FontType != desiredFontType;
|
||||
if (fontTypeChanged)
|
||||
pNode->FontType = desiredFontType;
|
||||
|
||||
var fontSizeChanged = pNode->FontSize != desiredFontSize;
|
||||
if (fontSizeChanged)
|
||||
pNode->FontSize = desiredFontSize;
|
||||
|
||||
var needsTextUpdate = nodeCreated ||
|
||||
!string.Equals(_lastLabelByIndex[nameplateIndex], labelContent, StringComparison.Ordinal);
|
||||
if (needsTextUpdate)
|
||||
{
|
||||
pNode->SetText(labelContent);
|
||||
_lastLabelByIndex[nameplateIndex] = labelContent;
|
||||
}
|
||||
|
||||
var flagsChanged = pNode->TextFlags != desiredFlags;
|
||||
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||
if (nodeWidth <= 0)
|
||||
nodeWidth = defaultNodeWidth;
|
||||
var nodeHeight = defaultNodeHeight;
|
||||
AlignmentType alignment;
|
||||
|
||||
var textScaleY = nameText->AtkResNode.ScaleY;
|
||||
if (textScaleY <= 0f)
|
||||
textScaleY = 1f;
|
||||
|
||||
var blockHeight = Math.Abs((int)nameplateObject.TextH);
|
||||
if (blockHeight > 0)
|
||||
{
|
||||
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
|
||||
}
|
||||
|
||||
if (blockHeight <= 0)
|
||||
{
|
||||
blockHeight = GetScaledTextHeight(nameText);
|
||||
if (blockHeight <= 0)
|
||||
blockHeight = nodeHeight;
|
||||
|
||||
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
||||
}
|
||||
|
||||
var containerHeight = (int)nameContainer->Height;
|
||||
if (containerHeight > 0)
|
||||
{
|
||||
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
|
||||
}
|
||||
|
||||
if (containerHeight <= 0)
|
||||
{
|
||||
containerHeight = blockHeight + (int)Math.Round(8 * textScaleY);
|
||||
if (containerHeight <= blockHeight)
|
||||
containerHeight = blockHeight + 1;
|
||||
|
||||
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
||||
}
|
||||
|
||||
var blockTop = containerHeight - blockHeight;
|
||||
if (blockTop < 0)
|
||||
blockTop = 0;
|
||||
var verticalPadding = (int)Math.Round(4 * effectiveScale);
|
||||
|
||||
var positionY = blockTop - verticalPadding - nodeHeight;
|
||||
|
||||
var textWidth = Math.Abs((int)nameplateObject.TextW);
|
||||
if (textWidth <= 0)
|
||||
{
|
||||
textWidth = GetScaledTextWidth(nameText);
|
||||
if (textWidth <= 0)
|
||||
textWidth = nodeWidth;
|
||||
}
|
||||
|
||||
if (textWidth > 0)
|
||||
{
|
||||
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
|
||||
}
|
||||
|
||||
var textOffset = (int)Math.Round(nameText->AtkResNode.X);
|
||||
var hasValidOffset = false;
|
||||
|
||||
if (Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
|
||||
{
|
||||
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
|
||||
hasValidOffset = true;
|
||||
}
|
||||
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
|
||||
{
|
||||
hasValidOffset = true;
|
||||
}
|
||||
|
||||
int positionX;
|
||||
|
||||
if (!config.LightfinderLabelUseIcon)
|
||||
{
|
||||
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
||||
if (flagsChanged)
|
||||
pNode->TextFlags = desiredFlags;
|
||||
|
||||
if (needsWidthRefresh)
|
||||
{
|
||||
if (pNode->AtkResNode.Width != 0)
|
||||
pNode->AtkResNode.Width = 0;
|
||||
nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
||||
if (nodeWidth <= 0)
|
||||
nodeWidth = defaultNodeWidth;
|
||||
}
|
||||
|
||||
if (pNode->AtkResNode.Width != (ushort)nodeWidth)
|
||||
pNode->AtkResNode.Width = (ushort)nodeWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
||||
if (flagsChanged)
|
||||
pNode->TextFlags = desiredFlags;
|
||||
|
||||
if (needsWidthRefresh && pNode->AtkResNode.Width != 0)
|
||||
pNode->AtkResNode.Width = 0;
|
||||
nodeWidth = pNode->AtkResNode.GetWidth();
|
||||
}
|
||||
|
||||
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
|
||||
{
|
||||
var nameplateWidth = (int)nameContainer->Width;
|
||||
|
||||
int leftPos = nameplateWidth / 8;
|
||||
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
|
||||
int centrePos = (nameplateWidth - nodeWidth) / 2;
|
||||
int staticMargin = 24;
|
||||
int calcMargin = (int)(nameplateWidth * 0.08f);
|
||||
|
||||
switch (config.LabelAlignment)
|
||||
{
|
||||
case LabelAlignment.Left:
|
||||
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
|
||||
alignment = AlignmentType.BottomLeft;
|
||||
break;
|
||||
case LabelAlignment.Right:
|
||||
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
|
||||
alignment = AlignmentType.BottomRight;
|
||||
break;
|
||||
default:
|
||||
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
|
||||
alignment = AlignmentType.Bottom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
positionX = 58 + config.LightfinderLabelOffsetX;
|
||||
alignment = AlignmentType.Bottom;
|
||||
}
|
||||
|
||||
positionY += config.LightfinderLabelOffsetY;
|
||||
|
||||
alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8);
|
||||
if (pNode->AtkResNode.Color.A != 255)
|
||||
pNode->AtkResNode.Color.A = 255;
|
||||
|
||||
var textR = (byte)(labelColor.X * 255);
|
||||
var textG = (byte)(labelColor.Y * 255);
|
||||
var textB = (byte)(labelColor.Z * 255);
|
||||
var textA = (byte)(labelColor.W * 255);
|
||||
|
||||
if (pNode->TextColor.R != textR || pNode->TextColor.G != textG ||
|
||||
pNode->TextColor.B != textB || pNode->TextColor.A != textA)
|
||||
{
|
||||
pNode->TextColor.R = textR;
|
||||
pNode->TextColor.G = textG;
|
||||
pNode->TextColor.B = textB;
|
||||
pNode->TextColor.A = textA;
|
||||
}
|
||||
|
||||
var edgeR = (byte)(edgeColor.X * 255);
|
||||
var edgeG = (byte)(edgeColor.Y * 255);
|
||||
var edgeB = (byte)(edgeColor.Z * 255);
|
||||
var edgeA = (byte)(edgeColor.W * 255);
|
||||
|
||||
if (pNode->EdgeColor.R != edgeR || pNode->EdgeColor.G != edgeG ||
|
||||
pNode->EdgeColor.B != edgeB || pNode->EdgeColor.A != edgeA)
|
||||
{
|
||||
pNode->EdgeColor.R = edgeR;
|
||||
pNode->EdgeColor.G = edgeG;
|
||||
pNode->EdgeColor.B = edgeB;
|
||||
pNode->EdgeColor.A = edgeA;
|
||||
}
|
||||
|
||||
var desiredAlignment = config.LightfinderLabelUseIcon ? alignment : AlignmentType.Bottom;
|
||||
if (pNode->AlignmentType != desiredAlignment)
|
||||
pNode->AlignmentType = desiredAlignment;
|
||||
|
||||
var desiredX = (short)Math.Clamp(positionX, short.MinValue, short.MaxValue);
|
||||
var desiredY = (short)Math.Clamp(positionY, short.MinValue, short.MaxValue);
|
||||
if (!NearlyEqual(pNode->AtkResNode.X, desiredX) || !NearlyEqual(pNode->AtkResNode.Y, desiredY))
|
||||
pNode->AtkResNode.SetPositionShort(desiredX, desiredY);
|
||||
|
||||
if (pNode->LineSpacing != desiredLineSpacing)
|
||||
pNode->LineSpacing = desiredLineSpacing;
|
||||
if (pNode->CharSpacing != 1)
|
||||
pNode->CharSpacing = 1;
|
||||
}
|
||||
|
||||
HideUnmarkedNodes(namePlateAddon, visibleIndices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the current RaptureAtkModule for native UI access.
|
||||
/// </summary>
|
||||
private static RaptureAtkModule* GetRaptureAtkModule()
|
||||
{
|
||||
var framework = Framework.Instance();
|
||||
if (framework == null)
|
||||
return null;
|
||||
|
||||
var uiModule = framework->GetUIModule();
|
||||
if (uiModule == null)
|
||||
return null;
|
||||
|
||||
return uiModule->GetRaptureAtkModule();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the NamePlate addon from the given RaptureAtkModule.
|
||||
/// </summary>
|
||||
private static AddonNamePlate* GetNamePlateAddon(RaptureAtkModule* raptureAtkModule)
|
||||
{
|
||||
if (raptureAtkModule == null)
|
||||
return null;
|
||||
|
||||
var addon = raptureAtkModule->RaptureAtkUnitManager.GetAddonByName("NamePlate");
|
||||
return addon != null ? (AddonNamePlate*)addon : null;
|
||||
}
|
||||
|
||||
private static uint GetNameplateNodeId(int index)
|
||||
=> NameplateNodeIdBase + (uint)index;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the NamePlate addon is visible and safe to touch.
|
||||
/// </summary>
|
||||
private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return false;
|
||||
|
||||
var root = namePlateAddon->AtkUnitBase.RootNode;
|
||||
return root != null && root->IsVisible();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a LightFinder text node by ID in the name container.
|
||||
/// </summary>
|
||||
private static AtkTextNode* FindNameplateTextNode(AtkResNode* nameContainer, uint nodeId)
|
||||
{
|
||||
if (nameContainer == null)
|
||||
return null;
|
||||
|
||||
var child = nameContainer->ChildNode;
|
||||
while (child != null)
|
||||
{
|
||||
if (child->NodeId == nodeId &&
|
||||
child->Type == NodeType.Text &&
|
||||
child->ParentNode == nameContainer)
|
||||
return (AtkTextNode*)child;
|
||||
|
||||
child = child->PrevSiblingNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a LightFinder text node exists for the given nameplate index.
|
||||
/// </summary>
|
||||
private static AtkTextNode* EnsureNameplateTextNode(AtkResNode* nameContainer, AtkComponentNode* root, uint nodeId, out bool created)
|
||||
{
|
||||
created = false;
|
||||
if (nameContainer == null || root == null || root->Component == null)
|
||||
return null;
|
||||
|
||||
var existing = FindNameplateTextNode(nameContainer, nodeId);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
if (nameContainer->ChildNode == null)
|
||||
return null;
|
||||
|
||||
var newNode = AtkNodeHelpers.CreateOrphanTextNode(nodeId, TextFlags.Edge | TextFlags.Glare);
|
||||
if (newNode == null)
|
||||
return null;
|
||||
|
||||
var lastChild = nameContainer->ChildNode;
|
||||
while (lastChild->PrevSiblingNode != null)
|
||||
lastChild = lastChild->PrevSiblingNode;
|
||||
|
||||
newNode->AtkResNode.NextSiblingNode = lastChild;
|
||||
newNode->AtkResNode.ParentNode = nameContainer;
|
||||
lastChild->PrevSiblingNode = (AtkResNode*)newNode;
|
||||
root->Component->UldManager.UpdateDrawNodeList();
|
||||
newNode->AtkResNode.SetUseDepthBasedPriority(true);
|
||||
|
||||
created = true;
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides all native LightFinder nodes on the nameplate addon.
|
||||
/// </summary>
|
||||
private static void HideAllNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||
return;
|
||||
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides all LightFinder nodes not marked as visible this frame.
|
||||
/// </summary>
|
||||
private static void HideUnmarkedNodes(AddonNamePlate* namePlateAddon, bool[] visibleIndices)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
if (!IsNameplateAddonVisible(namePlateAddon))
|
||||
return;
|
||||
|
||||
var visibleLength = visibleIndices.Length;
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
if (i < visibleLength && visibleIndices[i])
|
||||
continue;
|
||||
|
||||
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides the LightFinder text node for a single nameplate object.
|
||||
/// </summary>
|
||||
private static void HideNameplateTextNode(AddonNamePlate.NamePlateObject nameplateObject, uint nodeId)
|
||||
{
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
if (nameContainer == null)
|
||||
return;
|
||||
|
||||
var node = FindNameplateTextNode(nameContainer, nodeId);
|
||||
if (!IsValidNameplateTextNode(node, nameContainer))
|
||||
return;
|
||||
|
||||
node->AtkResNode.ToggleVisibility(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to destroy all constructed LightFinder nodes safely.
|
||||
/// </summary>
|
||||
private void TryDestroyNameplateNodes()
|
||||
{
|
||||
var raptureAtkModule = GetRaptureAtkModule();
|
||||
if (raptureAtkModule == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Unable to destroy nameplate nodes because the RaptureAtkModule is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
||||
if (namePlateAddon == null)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
_logger.LogDebug("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
DestroyNameplateNodes(namePlateAddon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all constructed LightFinder nodes from the given nameplate addon.
|
||||
/// </summary>
|
||||
private void DestroyNameplateNodes(AddonNamePlate* namePlateAddon)
|
||||
{
|
||||
if (namePlateAddon == null)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
||||
{
|
||||
var nameplateObject = namePlateAddon->NamePlateObjectArray[i];
|
||||
var root = nameplateObject.RootComponentNode;
|
||||
var nameContainer = nameplateObject.NameContainer;
|
||||
if (root == null || root->Component == null || nameContainer == null)
|
||||
continue;
|
||||
|
||||
var nodeId = GetNameplateNodeId(i);
|
||||
var textNode = FindNameplateTextNode(nameContainer, nodeId);
|
||||
if (!IsValidNameplateTextNode(textNode, nameContainer))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var resNode = &textNode->AtkResNode;
|
||||
|
||||
if (resNode->PrevSiblingNode != null)
|
||||
resNode->PrevSiblingNode->NextSiblingNode = resNode->NextSiblingNode;
|
||||
if (resNode->NextSiblingNode != null)
|
||||
resNode->NextSiblingNode->PrevSiblingNode = resNode->PrevSiblingNode;
|
||||
|
||||
root->Component->UldManager.UpdateDrawNodeList();
|
||||
resNode->Destroy(true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Unknown error while removing text node 0x{Node:X} for nameplate {Index} on component node 0x{Component:X}", (IntPtr)textNode, i, (IntPtr)root);
|
||||
}
|
||||
}
|
||||
|
||||
ClearNameplateCaches();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a node is a LightFinder text node owned by the container.
|
||||
/// </summary>
|
||||
private static bool IsValidNameplateTextNode(AtkTextNode* node, AtkResNode* nameContainer)
|
||||
{
|
||||
if (node == null || nameContainer == null)
|
||||
return false;
|
||||
|
||||
var resNode = &node->AtkResNode;
|
||||
return resNode->Type == NodeType.Text && resNode->ParentNode == nameContainer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Float comparison helper for UI values.
|
||||
/// </summary>
|
||||
private static bool NearlyEqual(float a, float b, float epsilon = 0.001f)
|
||||
=> Math.Abs(a - b) <= epsilon;
|
||||
|
||||
private static int GetScaledTextHeight(AtkTextNode* node)
|
||||
{
|
||||
if (node == null)
|
||||
return 0;
|
||||
|
||||
var resNode = &node->AtkResNode;
|
||||
var rawHeight = (int)resNode->GetHeight();
|
||||
if (rawHeight <= 0 && node->LineSpacing > 0)
|
||||
rawHeight = node->LineSpacing;
|
||||
if (rawHeight <= 0)
|
||||
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
|
||||
|
||||
var scale = resNode->ScaleY;
|
||||
if (scale <= 0f)
|
||||
scale = 1f;
|
||||
|
||||
var computed = (int)Math.Round(rawHeight * scale);
|
||||
return Math.Max(1, computed);
|
||||
}
|
||||
|
||||
private static int GetScaledTextWidth(AtkTextNode* node)
|
||||
{
|
||||
if (node == null)
|
||||
return 0;
|
||||
|
||||
var resNode = &node->AtkResNode;
|
||||
var rawWidth = (int)resNode->GetWidth();
|
||||
if (rawWidth <= 0)
|
||||
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
|
||||
|
||||
var scale = resNode->ScaleX;
|
||||
if (scale <= 0f)
|
||||
scale = 1f;
|
||||
|
||||
var computed = (int)Math.Round(rawWidth * scale);
|
||||
return Math.Max(1, computed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears cached text sizing and label state for nameplates.
|
||||
/// </summary>
|
||||
public void ClearNameplateCaches()
|
||||
{
|
||||
Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
||||
Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
||||
Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
||||
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
||||
Array.Fill(_lastLabelByIndex, null);
|
||||
}
|
||||
|
||||
}
|
||||
1371
LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs
Normal file
1371
LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs
Normal file
File diff suppressed because it is too large
Load Diff
382
LightlessSync/Services/LightFinder/LightFinderScannerService.cs
Normal file
382
LightlessSync/Services/LightFinder/LightFinderScannerService.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using Dalamud.Plugin.Services;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.Services.ActorTracking;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LightlessSync.Services.LightFinder;
|
||||
|
||||
public class LightFinderScannerService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly ILogger<LightFinderScannerService> _logger;
|
||||
private readonly ActorObjectService _actorTracker;
|
||||
private readonly IFramework _framework;
|
||||
|
||||
private readonly LightFinderService _broadcastService;
|
||||
private readonly LightFinderPlateHandler _lightFinderPlateHandler;
|
||||
private readonly LightFinderNativePlateHandler _lightFinderNativePlateHandler;
|
||||
|
||||
private readonly ConcurrentDictionary<string, BroadcastEntry> _broadcastCache = new(StringComparer.Ordinal);
|
||||
private readonly Queue<string> _lookupQueue = new();
|
||||
private readonly HashSet<string> _lookupQueuedCids = [];
|
||||
private readonly HashSet<string> _syncshellCids = [];
|
||||
private volatile bool _pendingLocalBroadcast;
|
||||
private TimeSpan? _pendingLocalTtl;
|
||||
private string? _pendingLocalGid;
|
||||
|
||||
private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly CancellationTokenSource _cleanupCts = new();
|
||||
private readonly Task? _cleanupTask;
|
||||
|
||||
private readonly int _checkEveryFrames = 20;
|
||||
private int _frameCounter = 0;
|
||||
private const int _maxLookupsPerFrame = 30;
|
||||
private const int _maxQueueSize = 100;
|
||||
|
||||
private volatile bool _batchRunning = false;
|
||||
private volatile bool _disposed = false;
|
||||
|
||||
public IReadOnlyDictionary<string, BroadcastEntry> BroadcastCache => _broadcastCache;
|
||||
public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID);
|
||||
|
||||
public LightFinderScannerService(ILogger<LightFinderScannerService> logger,
|
||||
IFramework framework,
|
||||
LightFinderService broadcastService,
|
||||
LightlessMediator mediator,
|
||||
LightFinderPlateHandler lightFinderPlateHandler,
|
||||
LightFinderNativePlateHandler lightFinderNativePlateHandler,
|
||||
ActorObjectService actorTracker) : base(logger, mediator)
|
||||
{
|
||||
_logger = logger;
|
||||
_actorTracker = actorTracker;
|
||||
_broadcastService = broadcastService;
|
||||
_lightFinderPlateHandler = lightFinderPlateHandler;
|
||||
_lightFinderNativePlateHandler = lightFinderNativePlateHandler;
|
||||
|
||||
_logger = logger;
|
||||
_framework = framework;
|
||||
_framework.Update += OnFrameworkUpdate;
|
||||
|
||||
Mediator.Subscribe<BroadcastStatusChangedMessage>(this, OnBroadcastStatusChanged);
|
||||
_cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token);
|
||||
|
||||
_actorTracker = actorTracker;
|
||||
}
|
||||
|
||||
private void OnFrameworkUpdate(IFramework framework) => Update();
|
||||
|
||||
public void Update()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_frameCounter++;
|
||||
var lookupsThisFrame = 0;
|
||||
|
||||
if (!_broadcastService.IsBroadcasting)
|
||||
return;
|
||||
|
||||
TryPrimeLocalBroadcastCache();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var descriptor in _actorTracker.PlayerDescriptors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(descriptor.HashedContentId))
|
||||
continue;
|
||||
|
||||
var cid = descriptor.HashedContentId;
|
||||
var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now;
|
||||
|
||||
if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize)
|
||||
_lookupQueue.Enqueue(cid);
|
||||
}
|
||||
|
||||
if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0)
|
||||
{
|
||||
var cidsToLookup = new List<string>();
|
||||
while (_lookupQueue.Count > 0 && lookupsThisFrame < _maxLookupsPerFrame)
|
||||
{
|
||||
var cid = _lookupQueue.Dequeue();
|
||||
_lookupQueuedCids.Remove(cid);
|
||||
cidsToLookup.Add(cid);
|
||||
lookupsThisFrame++;
|
||||
}
|
||||
|
||||
if (cidsToLookup.Count > 0 && !_batchRunning)
|
||||
{
|
||||
_batchRunning = true;
|
||||
_ = BatchUpdateBroadcastCacheAsync(cidsToLookup).ContinueWith(_ => _batchRunning = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BatchUpdateBroadcastCacheAsync(List<string> cids)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var results = await _broadcastService.AreUsersBroadcastingAsync(cids).ConfigureAwait(false);
|
||||
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var (cid, info) in results)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cid) || info == null)
|
||||
continue;
|
||||
|
||||
var ttl = info.IsBroadcasting && info.TTL.HasValue
|
||||
? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, _maxAllowedTtl.Ticks))
|
||||
: _retryDelay;
|
||||
|
||||
var expiry = now + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(cid,
|
||||
new BroadcastEntry(info.IsBroadcasting, expiry, info.GID),
|
||||
(_, old) => new BroadcastEntry(info.IsBroadcasting, expiry, info.GID));
|
||||
}
|
||||
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
_broadcastCache.Clear();
|
||||
_lookupQueue.Clear();
|
||||
_lookupQueuedCids.Clear();
|
||||
_syncshellCids.Clear();
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids([]);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids([]);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingLocalBroadcast = true;
|
||||
_pendingLocalTtl = msg.Ttl;
|
||||
_pendingLocalGid = msg.Gid;
|
||||
TryPrimeLocalBroadcastCache();
|
||||
}
|
||||
|
||||
private void TryPrimeLocalBroadcastCache()
|
||||
{
|
||||
if (!_pendingLocalBroadcast)
|
||||
return;
|
||||
|
||||
if (!TryGetLocalHashedCid(out var localCid))
|
||||
return;
|
||||
|
||||
var ttl = _pendingLocalTtl ?? _maxAllowedTtl;
|
||||
var expiry = DateTime.UtcNow + ttl;
|
||||
|
||||
_broadcastCache.AddOrUpdate(localCid,
|
||||
new BroadcastEntry(true, expiry, _pendingLocalGid),
|
||||
(_, old) => new BroadcastEntry(true, expiry, _pendingLocalGid ?? old.GID));
|
||||
|
||||
_pendingLocalBroadcast = false;
|
||||
_pendingLocalTtl = null;
|
||||
_pendingLocalGid = null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var activeCids = _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now)
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
_lightFinderPlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
_lightFinderNativePlateHandler.UpdateBroadcastingCids(activeCids);
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
private void UpdateSyncshellBroadcasts()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nearbyCids = GetNearbyHashedCids(out _);
|
||||
var newSet = nearbyCids.Count == 0
|
||||
? new HashSet<string>(StringComparer.Ordinal)
|
||||
: _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
|
||||
.Where(e => nearbyCids.Contains(e.Key))
|
||||
.Select(e => e.Key)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (!_syncshellCids.SetEquals(newSet))
|
||||
{
|
||||
_syncshellCids.Clear();
|
||||
foreach (var cid in newSet)
|
||||
_syncshellCids.Add(cid);
|
||||
|
||||
Mediator.Publish(new SyncshellBroadcastsUpdatedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<BroadcastStatusInfoDto> GetActiveSyncshellBroadcasts(bool excludeLocal = false)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var nearbyCids = GetNearbyHashedCids(out var localCid);
|
||||
if (nearbyCids.Count == 0)
|
||||
return [];
|
||||
|
||||
return [.. _broadcastCache
|
||||
.Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID))
|
||||
.Where(e => nearbyCids.Contains(e.Key))
|
||||
.Where(e => !excludeLocal || !string.Equals(e.Key, localCid, StringComparison.Ordinal))
|
||||
.Select(e => new BroadcastStatusInfoDto
|
||||
{
|
||||
HashedCID = e.Key,
|
||||
IsBroadcasting = true,
|
||||
TTL = e.Value.ExpiryTime - now,
|
||||
GID = e.Value.GID
|
||||
})];
|
||||
}
|
||||
|
||||
public bool TryGetLocalHashedCid(out string hashedCid)
|
||||
{
|
||||
hashedCid = string.Empty;
|
||||
var descriptors = _actorTracker.PlayerDescriptors;
|
||||
if (descriptors.Count == 0)
|
||||
return false;
|
||||
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
if (!descriptor.IsLocalPlayer || string.IsNullOrWhiteSpace(descriptor.HashedContentId))
|
||||
continue;
|
||||
|
||||
hashedCid = descriptor.HashedContentId;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private HashSet<string> GetNearbyHashedCids(out string? localCid)
|
||||
{
|
||||
localCid = null;
|
||||
var descriptors = _actorTracker.PlayerDescriptors;
|
||||
if (descriptors.Count == 0)
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(descriptor.HashedContentId))
|
||||
continue;
|
||||
|
||||
if (descriptor.IsLocalPlayer)
|
||||
localCid = descriptor.HashedContentId;
|
||||
|
||||
set.Add(descriptor.HashedContentId);
|
||||
}
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
private async Task ExpiredBroadcastCleanupLoop()
|
||||
{
|
||||
var token = _cleanupCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (cid, entry) in _broadcastCache.ToArray())
|
||||
{
|
||||
if (entry.ExpiryTime <= now)
|
||||
_broadcastCache.TryRemove(cid, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// No action needed when cancelled
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Broadcast cleanup loop crashed");
|
||||
}
|
||||
|
||||
UpdateSyncshellBroadcasts();
|
||||
}
|
||||
|
||||
public int CountActiveBroadcasts(string? excludeHashedCid = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var comparer = StringComparer.Ordinal;
|
||||
return _broadcastCache.Count(entry =>
|
||||
entry.Value.IsBroadcasting &&
|
||||
entry.Value.ExpiryTime > now &&
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)));
|
||||
}
|
||||
|
||||
public List<KeyValuePair<string, BroadcastEntry>> GetActiveBroadcasts(string? excludeHashedCid = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var comparer = StringComparer.Ordinal;
|
||||
return [.. _broadcastCache.Where(entry =>
|
||||
entry.Value.IsBroadcasting &&
|
||||
entry.Value.ExpiryTime > now &&
|
||||
(excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))];
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
_disposed = true;
|
||||
base.Dispose(disposing);
|
||||
_framework.Update -= OnFrameworkUpdate;
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupCts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, can be ignored :)
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupTask?.Wait(100);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Task may have already completed or been cancelled?
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cleanupCts.Dispose();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using Dalamud.Interface;
|
||||
using LightlessSync.API.Dto.Group;
|
||||
using LightlessSync.API.Dto.User;
|
||||
using LightlessSync.LightlessConfiguration;
|
||||
using LightlessSync.LightlessConfiguration.Models;
|
||||
using LightlessSync.Services.Mediator;
|
||||
using LightlessSync.UI;
|
||||
using LightlessSync.UI.Models;
|
||||
using LightlessSync.Utils;
|
||||
using LightlessSync.WebAPI;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading;
|
||||
|
||||
namespace LightlessSync.Services;
|
||||
public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
namespace LightlessSync.Services.LightFinder;
|
||||
public class LightFinderService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
private readonly ILogger<BroadcastService> _logger;
|
||||
private readonly ILogger<LightFinderService> _logger;
|
||||
private readonly ApiController _apiController;
|
||||
private readonly LightlessMediator _mediator;
|
||||
private readonly LightlessConfigService _config;
|
||||
@@ -41,7 +44,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
public BroadcastService(ILogger<BroadcastService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
|
||||
public LightFinderService(ILogger<LightFinderService> logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediator = mediator;
|
||||
@@ -54,7 +57,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
if (!_apiController.IsConnected)
|
||||
{
|
||||
_logger.LogDebug(context + " skipped, not connected");
|
||||
_logger.LogDebug("{context} skipped, not connected", context);
|
||||
return;
|
||||
}
|
||||
await action().ConfigureAwait(false);
|
||||
@@ -64,7 +67,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
try
|
||||
{
|
||||
var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false);
|
||||
var cid = _dalamudUtil.GetCID();
|
||||
return cid.ToString().GetHash256();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -118,7 +121,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
_waitingForTtlFetch = false;
|
||||
|
||||
if (!wasEnabled || previousRemaining != validTtl)
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl));
|
||||
{
|
||||
var gid = _config.Current.SyncshellFinderEnabled ? _config.Current.SelectedFinderSyncshell : null;
|
||||
_mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl, gid));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl);
|
||||
return true;
|
||||
@@ -140,6 +146,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
IsLightFinderAvailable = false;
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
_logger.LogDebug("Cleared Lightfinder state due to disconnect.");
|
||||
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
@@ -236,6 +243,11 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
_logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect.");
|
||||
_mediator.Publish(new EnableBroadcastMessage(hashedCid, true));
|
||||
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Auto-Enabled",
|
||||
"Your Lightfinder broadcast has been automatically enabled.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -272,7 +284,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
if (!msg.Enabled)
|
||||
{
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
|
||||
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}")));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -285,7 +297,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
if (TryApplyBroadcastEnabled(ttl, "client request"))
|
||||
{
|
||||
_logger.LogDebug("Fetched TTL from server: {TTL}", ttl);
|
||||
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
|
||||
Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}")));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -362,7 +374,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
|
||||
public async Task<Dictionary<string, BroadcastStatusInfoDto?>> AreUsersBroadcastingAsync(List<string> hashedCids)
|
||||
{
|
||||
Dictionary<string, BroadcastStatusInfoDto?> result = new();
|
||||
Dictionary<string, BroadcastStatusInfoDto?> result = new(StringComparer.Ordinal);
|
||||
|
||||
await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () =>
|
||||
{
|
||||
@@ -387,13 +399,16 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async void ToggleBroadcast()
|
||||
{
|
||||
|
||||
if (!IsLightFinderAvailable)
|
||||
{
|
||||
_logger.LogWarning("ToggleBroadcast - Lightfinder is not available.");
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Unavailable",
|
||||
"Lightfinder is not available on this server.",
|
||||
NotificationType.Error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -403,6 +418,10 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero)
|
||||
{
|
||||
_logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds);
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Cooldown",
|
||||
$"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.",
|
||||
NotificationType.Warning));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,10 +446,19 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
_logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus);
|
||||
|
||||
_mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus));
|
||||
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
newStatus ? "Broadcast Enabled" : "Broadcast Disabled",
|
||||
newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.",
|
||||
NotificationType.Info));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to determine current broadcast status for toggle");
|
||||
_mediator.Publish(new NotificationMessage(
|
||||
"Broadcast Toggle Failed",
|
||||
$"Failed to toggle broadcast: {ex.Message}",
|
||||
NotificationType.Error));
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
@@ -493,6 +521,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
{
|
||||
_logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally.");
|
||||
ApplyBroadcastDisabled(forcePublish: true);
|
||||
ShowBroadcastExpiredNotification();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -501,4 +530,49 @@ public class BroadcastService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ShowBroadcastExpiredNotification()
|
||||
{
|
||||
var notification = new LightlessNotification
|
||||
{
|
||||
Id = "broadcast_expired",
|
||||
Title = "Broadcast Expired",
|
||||
Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?",
|
||||
Type = NotificationType.PairRequest,
|
||||
Duration = TimeSpan.FromSeconds(180),
|
||||
Actions = new List<LightlessNotificationAction>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "re_enable",
|
||||
Label = "Re-enable",
|
||||
Icon = FontAwesomeIcon.Plus,
|
||||
Color = UIColors.Get("PairBlue"),
|
||||
IsPrimary = true,
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Re-enabling broadcast from notification");
|
||||
ToggleBroadcast();
|
||||
n.IsDismissed = true;
|
||||
n.IsAnimatingOut = true;
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "close",
|
||||
Label = "Close",
|
||||
Icon = FontAwesomeIcon.Times,
|
||||
Color = UIColors.Get("DimRed"),
|
||||
OnClick = (n) =>
|
||||
{
|
||||
_logger.LogInformation("Broadcast expiration notification dismissed");
|
||||
n.IsDismissed = true;
|
||||
n.IsAnimatingOut = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_mediator.Publish(new LightlessNotificationMessage(notification));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace LightlessSync.Services;
|
||||
|
||||
public record LightlessProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description)
|
||||
{
|
||||
public Lazy<byte[]> ImageData { get; } = new Lazy<byte[]>(Convert.FromBase64String(Base64ProfilePicture));
|
||||
public Lazy<byte[]> SupporterImageData { get; } = new Lazy<byte[]>(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user