Compare commits

29 Commits

Author SHA1 Message Date
6d8a8476b4 Translate phase 1 2025-09-21 17:01:12 +02:00
0808266887 Update 0.1.7 - Allow naming Syncshell 2025-09-19 23:18:09 +02:00
a2071b9c05 Update MareAPI submodule and config 2025-09-19 22:52:09 +02:00
612e7c88a2 Fix UI & Rotate Salt 2025-09-19 22:33:40 +02:00
1755b5cb54 Clean code 2025-09-14 00:21:30 +02:00
4a388dcfa9 Update 0.1.6 - UI change 2025-09-13 22:42:08 +02:00
a0957715a5 Update 0.1.6 - Fix UI settings & Delay Detection 2025-09-13 20:08:24 +02:00
04a8ee3186 Update 0.1.6 - Deploy AutoDetect, last debug and optimization 2025-09-13 13:41:00 +02:00
b79a51748f Update 0.1.4 - AutoDetect WIP Debug & Fix UI & Optimization 2025-09-11 22:37:29 +02:00
95d9f65068 Update 0.1.2 - AutoDetect Debug before release 2025-09-11 15:42:41 +02:00
a70968d30c Nearby (AutoDetect) Phase 1 — settings, window, compact section; UmbraSync theme; minor fixes 2025-09-11 11:30:09 +02:00
6ebb73040b earby (AutoDetect) Phase 1 — settings, window, compact section; UmbraSync theme; minor fixes 2025-09-11 11:28:48 +02:00
46f2443824 FINALY !!! 2025-09-05 21:34:17 +02:00
eeab8354b6 remove old ref + update gitsubmodule + update 0.1.0.0 + add NoSnapService + pimpmymod + Licence AGPLv3 2025-09-05 15:03:41 +02:00
b5d8f288f9 remove old ref + update gitsubmodule + update 0.1.0.0 + add NoSnapService + pimpmymod 2025-09-05 15:02:52 +02:00
3c2dab4d21 add submodules (MareAPI, Penumbra.Api, Glamourer.Api) 2025-09-05 13:39:35 +02:00
edb49f710a remove UmbraCrypt gitlink 2025-09-05 13:35:47 +02:00
9ff21dc341 purge UmbraCrypt submodule 2025-09-05 13:34:29 +02:00
17962a37b3 Uodate 0.0.6 - Change reference and clean 2025-09-04 22:54:55 +02:00
4495177f02 Update 0.0.5 - Change repo & services 2025-09-04 21:22:39 +02:00
14bb5c14f7 Update 0.0.5 - Change repo & services 2025-09-04 21:22:24 +02:00
6101686a33 Add PenumbraAPI & GlamourerAPI + Update API & Connector 2025-08-31 18:19:04 +02:00
bc6cde48de PimpMyMod 2025-08-31 12:27:50 +02:00
9ce213f949 OupsiDoupsi 2025-08-31 11:22:23 +02:00
cd5bf3f06f Change build version for beta 2025-08-31 10:44:42 +02:00
52eaa0cf95 Initial commit 2025-08-31 00:52:40 +02:00
6edb90f4d7 Initial commit 2025-08-31 00:52:32 +02:00
4688043f6a Initial commit 2025-08-31 00:21:08 +02:00
5da6047759 Initial commit 2025-08-31 00:21:01 +02:00
220 changed files with 37652 additions and 1 deletions

273
.editorconfig Normal file
View File

@@ -0,0 +1,273 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
# New line preferences
end_of_line = crlf
insert_final_newline = false
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = 0
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = true
dotnet_style_allow_statement_immediately_after_block_experimental = true
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = false
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_local_function = true:suggestion
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async
csharp_style_prefer_readonly_struct = true:suggestion
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = block_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.private_or_internal_field_should_be_fieldstyle.severity = suggestion
dotnet_naming_rule.private_or_internal_field_should_be_fieldstyle.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_fieldstyle.style = fieldstyle
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_field.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.fieldstyle.required_prefix = _
dotnet_naming_style.fieldstyle.required_suffix =
dotnet_naming_style.fieldstyle.word_separator =
dotnet_naming_style.fieldstyle.capitalization = camel_case
dotnet_diagnostic.MA0016.severity = silent
dotnet_diagnostic.MA0026.severity = warning
dotnet_diagnostic.MA0046.severity = suggestion
dotnet_diagnostic.MA0051.severity = suggestion
dotnet_diagnostic.MA0011.severity = suggestion
[*.{cs,vb}]
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_style_readonly_field = true:suggestion
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
dotnet_style_allow_statement_immediately_after_block_experimental = true:silent

354
.gitignore vendored Normal file
View File

@@ -0,0 +1,354 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.idea
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
.DS_Store
MareSynchronos/.DS_Store
*.zip
UmbraServer_extracted/
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/

12
.gitmodules vendored Normal file
View File

@@ -0,0 +1,12 @@
[submodule "MareAPI"]
path = MareAPI
url = ssh://git@git.umbra-sync.net:1222/Keda/UmbraAPI.git
branch = main
[submodule "Penumbra.Api"]
path = Penumbra.Api
url = https://github.com/Ottermandias/Penumbra.Api.git
branch = main
[submodule "Glamourer.Api"]
path = Glamourer.Api
url = https://github.com/Ottermandias/Glamourer.Api.git
branch = main

1
Glamourer.Api Submodule

Submodule Glamourer.Api added at 54c1944dc7

21
LICENSE_MIT Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Penumbra-Sync
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
MareAPI Submodule

Submodule MareAPI added at fa9b7bce43

46
MareSynchronos.sln Normal file
View File

@@ -0,0 +1,46 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.32328.378
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MareSynchronos", "MareSynchronos\MareSynchronos.csproj", "{13C812E9-0D42-4B95-8646-40EEBF30636F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MareSynchronos.API", "MareAPI\MareSynchronosAPI\MareSynchronos.API.csproj", "{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|Any CPU.ActiveCfg = Debug|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|Any CPU.Build.0 = Debug|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|x64.ActiveCfg = Debug|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|x64.Build.0 = Debug|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|Any CPU.ActiveCfg = Release|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|Any CPU.Build.0 = Release|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|x64.ActiveCfg = Release|x64
{13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|x64.Build.0 = Release|x64
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Debug|x64.ActiveCfg = Debug|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Debug|x64.Build.0 = Debug|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Release|Any CPU.Build.0 = Release|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Release|x64.ActiveCfg = Release|Any CPU
{5A0B7434-8D89-4E90-B55C-B4A7AE1A6ADE}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,120 @@
[*.{cs,vb}]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:silent
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_namespace_match_folder = true:suggestion
[*.cs]
csharp_indent_labels = one_less_than_current
csharp_using_directive_placement = outside_namespace:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_prefer_braces = true:silent
csharp_style_namespace_declarations = block_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = true:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = false:silent
dotnet_diagnostic.MA0076.severity = silent
dotnet_diagnostic.MA0051.severity = silent
csharp_style_throw_expression = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
dotnet_diagnostic.S1075.severity = silent
dotnet_diagnostic.SS3358.severity = suggestion
dotnet_diagnostic.MA0007.severity = silent
dotnet_diagnostic.MA0075.severity = silent
# S3358: Ternary operators should not be nested
dotnet_diagnostic.S3358.severity = suggestion
# S6678: Use PascalCase for named placeholders
dotnet_diagnostic.S6678.severity = none
# S6605: Collection-specific "Exists" method should be used instead of the "Any" extension
dotnet_diagnostic.S6605.severity = none
# S6667: Logging in a catch clause should pass the caught exception as a parameter.
dotnet_diagnostic.S6667.severity = suggestion
# IDE0290: Use primary constructor
csharp_style_prefer_primary_constructors = false
# S3267: Loops should be simplified with "LINQ" expressions
dotnet_diagnostic.S3267.severity = silent
# MA0048: File name must match type name
dotnet_diagnostic.MA0048.severity = silent

View File

@@ -0,0 +1,859 @@
using MareSynchronos.Interop.Ipc;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
namespace MareSynchronos.FileCache;
public sealed class CacheMonitor : DisposableMediatorSubscriberBase
{
private readonly MareConfigService _configService;
private readonly DalamudUtilService _dalamudUtil;
private readonly FileCompactor _fileCompactor;
private readonly FileCacheManager _fileDbManager;
private readonly IpcManager _ipcManager;
private readonly PerformanceCollectorService _performanceCollector;
private long _currentFileProgress = 0;
private CancellationTokenSource _scanCancellationTokenSource = new();
private readonly CancellationTokenSource _periodicCalculationTokenSource = new();
public static readonly IImmutableList<string> AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk"];
public CacheMonitor(ILogger<CacheMonitor> logger, IpcManager ipcManager, MareConfigService configService,
FileCacheManager fileDbManager, MareMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil,
FileCompactor fileCompactor) : base(logger, mediator)
{
_ipcManager = ipcManager;
_configService = configService;
_fileDbManager = fileDbManager;
_performanceCollector = performanceCollector;
_dalamudUtil = dalamudUtil;
_fileCompactor = fileCompactor;
Mediator.Subscribe<PenumbraInitializedMessage>(this, (_) =>
{
StartPenumbraWatcher(_ipcManager.Penumbra.ModDirectory);
StartMareWatcher(configService.Current.CacheFolder);
StartSubstWatcher(_fileDbManager.SubstFolder);
InvokeScan();
});
Mediator.Subscribe<HaltScanMessage>(this, (msg) => HaltScan(msg.Source));
Mediator.Subscribe<ResumeScanMessage>(this, (msg) => ResumeScan(msg.Source));
Mediator.Subscribe<DalamudLoginMessage>(this, (_) =>
{
StartMareWatcher(configService.Current.CacheFolder);
StartSubstWatcher(_fileDbManager.SubstFolder);
StartPenumbraWatcher(_ipcManager.Penumbra.ModDirectory);
InvokeScan();
});
Mediator.Subscribe<PenumbraDirectoryChangedMessage>(this, (msg) =>
{
StartPenumbraWatcher(msg.ModDirectory);
InvokeScan();
});
if (_ipcManager.Penumbra.APIAvailable && !string.IsNullOrEmpty(_ipcManager.Penumbra.ModDirectory))
{
StartPenumbraWatcher(_ipcManager.Penumbra.ModDirectory);
}
if (configService.Current.HasValidSetup())
{
StartMareWatcher(configService.Current.CacheFolder);
StartSubstWatcher(_fileDbManager.SubstFolder);
InvokeScan();
}
var token = _periodicCalculationTokenSource.Token;
_ = Task.Run(async () =>
{
Logger.LogInformation("Starting Periodic Storage Directory Calculation Task");
var token = _periodicCalculationTokenSource.Token;
while (!token.IsCancellationRequested)
{
try
{
while (_dalamudUtil.IsOnFrameworkThread && !token.IsCancellationRequested)
{
await Task.Delay(1).ConfigureAwait(false);
}
RecalculateFileCacheSize(token);
}
catch
{
// ignore
}
await Task.Delay(TimeSpan.FromMinutes(1), token).ConfigureAwait(false);
}
}, token);
}
public long CurrentFileProgress => _currentFileProgress;
public long FileCacheSize { get; set; }
public long FileCacheDriveFree { get; set; }
public ConcurrentDictionary<string, StrongBox<int>> HaltScanLocks { get; set; } = new(StringComparer.Ordinal);
public bool IsScanRunning => CurrentFileProgress > 0 || TotalFiles > 0;
public long TotalFiles { get; private set; }
public long TotalFilesStorage { get; private set; }
public void HaltScan(string source)
{
HaltScanLocks.TryAdd(source, new(0));
Interlocked.Increment(ref HaltScanLocks[source].Value);
}
record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null);
private readonly Dictionary<string, WatcherChange> _watcherChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _mareChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WatcherChange> _substChanges = new Dictionary<string, WatcherChange>(StringComparer.OrdinalIgnoreCase);
public void StopMonitoring()
{
Logger.LogInformation("Stopping monitoring of Penumbra and Mare storage folders");
MareWatcher?.Dispose();
SubstWatcher?.Dispose();
PenumbraWatcher?.Dispose();
MareWatcher = null;
SubstWatcher = null;
PenumbraWatcher = null;
}
public bool StorageisNTFS { get; private set; } = false;
public void StartMareWatcher(string? snowPath)
{
MareWatcher?.Dispose();
if (string.IsNullOrEmpty(snowPath) || !Directory.Exists(snowPath))
{
MareWatcher = null;
Logger.LogWarning("Umbra file path is not set, cannot start the FSW for Umbra.");
return;
}
DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName);
StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase);
Logger.LogInformation("Umbra Storage is on NTFS drive: {isNtfs}", StorageisNTFS);
Logger.LogDebug("Initializing Mare FSW on {path}", snowPath);
MareWatcher = new()
{
Path = snowPath,
InternalBufferSize = 8388608,
NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.LastWrite
| NotifyFilters.FileName
| NotifyFilters.DirectoryName
| NotifyFilters.Size,
Filter = "*.*",
IncludeSubdirectories = false,
};
MareWatcher.Deleted += MareWatcher_FileChanged;
MareWatcher.Created += MareWatcher_FileChanged;
MareWatcher.EnableRaisingEvents = true;
}
public void StartSubstWatcher(string? substPath)
{
SubstWatcher?.Dispose();
if (string.IsNullOrEmpty(substPath))
{
SubstWatcher = null;
Logger.LogWarning("Umbra file path is not set, cannot start the FSW for Umbra.");
return;
}
try
{
if (!Directory.Exists(substPath))
Directory.CreateDirectory(substPath);
}
catch
{
Logger.LogWarning("Could not create subst directory at {path}.", substPath);
return;
}
Logger.LogDebug("Initializing Subst FSW on {path}", substPath);
SubstWatcher = new()
{
Path = substPath,
InternalBufferSize = 8388608,
NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.LastWrite
| NotifyFilters.FileName
| NotifyFilters.DirectoryName
| NotifyFilters.Size,
Filter = "*.*",
IncludeSubdirectories = false,
};
SubstWatcher.Deleted += SubstWatcher_FileChanged;
SubstWatcher.Created += SubstWatcher_FileChanged;
SubstWatcher.EnableRaisingEvents = true;
}
private void MareWatcher_FileChanged(object sender, FileSystemEventArgs e)
{
Logger.LogTrace("Umbra FSW: FileChanged: {change} => {path}", e.ChangeType, e.FullPath);
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
lock (_mareChanges)
{
_mareChanges[e.FullPath] = new(e.ChangeType);
}
_ = MareWatcherExecution();
}
private void SubstWatcher_FileChanged(object sender, FileSystemEventArgs e)
{
Logger.LogTrace("Subst FSW: FileChanged: {change} => {path}", e.ChangeType, e.FullPath);
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
lock (_substChanges)
{
_substChanges[e.FullPath] = new(e.ChangeType);
}
_ = SubstWatcherExecution();
}
public void StartPenumbraWatcher(string? penumbraPath)
{
PenumbraWatcher?.Dispose();
if (string.IsNullOrEmpty(penumbraPath))
{
PenumbraWatcher = null;
Logger.LogWarning("Penumbra is not connected or the path is not set, cannot start FSW for Penumbra.");
return;
}
Logger.LogDebug("Initializing Penumbra FSW on {path}", penumbraPath);
PenumbraWatcher = new()
{
Path = penumbraPath,
InternalBufferSize = 8388608,
NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.LastWrite
| NotifyFilters.FileName
| NotifyFilters.DirectoryName
| NotifyFilters.Size,
Filter = "*.*",
IncludeSubdirectories = true
};
PenumbraWatcher.Deleted += Fs_Changed;
PenumbraWatcher.Created += Fs_Changed;
PenumbraWatcher.Changed += Fs_Changed;
PenumbraWatcher.Renamed += Fs_Renamed;
PenumbraWatcher.EnableRaisingEvents = true;
}
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 (e.ChangeType is not (WatcherChangeTypes.Changed or WatcherChangeTypes.Deleted or WatcherChangeTypes.Created))
return;
lock (_watcherChanges)
{
_watcherChanges[e.FullPath] = new(e.ChangeType);
}
Logger.LogTrace("FSW {event}: {path}", e.ChangeType, e.FullPath);
_ = PenumbraWatcherExecution();
}
private void Fs_Renamed(object sender, RenamedEventArgs e)
{
if (Directory.Exists(e.FullPath))
{
var directoryFiles = Directory.GetFiles(e.FullPath, "*.*", SearchOption.AllDirectories);
lock (_watcherChanges)
{
foreach (var file in directoryFiles)
{
if (!AllowedFileExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) continue;
var oldPath = file.Replace(e.FullPath, e.OldFullPath, StringComparison.OrdinalIgnoreCase);
_watcherChanges.Remove(oldPath);
_watcherChanges[file] = new(WatcherChangeTypes.Renamed, oldPath);
Logger.LogTrace("FSW Renamed: {path} -> {new}", oldPath, file);
}
}
}
else
{
if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return;
lock (_watcherChanges)
{
_watcherChanges.Remove(e.OldFullPath);
_watcherChanges[e.FullPath] = new(WatcherChangeTypes.Renamed, e.OldFullPath);
}
Logger.LogTrace("FSW Renamed: {path} -> {new}", e.OldFullPath, e.FullPath);
}
_ = PenumbraWatcherExecution();
}
private CancellationTokenSource _penumbraFswCts = new();
private CancellationTokenSource _mareFswCts = new();
private CancellationTokenSource _substFswCts = new();
public FileSystemWatcher? PenumbraWatcher { get; private set; }
public FileSystemWatcher? MareWatcher { get; private set; }
public FileSystemWatcher? SubstWatcher { get; private set; }
private async Task MareWatcherExecution()
{
_mareFswCts = _mareFswCts.CancelRecreate();
var token = _mareFswCts.Token;
var delay = TimeSpan.FromSeconds(5);
Dictionary<string, WatcherChange> changes;
lock (_mareChanges)
changes = _mareChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
try
{
do
{
await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value.Value > 0));
}
catch (TaskCanceledException)
{
return;
}
lock (_mareChanges)
{
foreach (var key in changes.Keys)
{
_mareChanges.Remove(key);
}
}
HandleChanges(changes);
}
private async Task SubstWatcherExecution()
{
_substFswCts = _substFswCts.CancelRecreate();
var token = _substFswCts.Token;
var delay = TimeSpan.FromSeconds(5);
Dictionary<string, WatcherChange> changes;
lock (_substChanges)
changes = _substChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
try
{
do
{
await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value.Value > 0));
}
catch (TaskCanceledException)
{
return;
}
lock (_substChanges)
{
foreach (var key in changes.Keys)
{
_substChanges.Remove(key);
}
}
HandleChanges(changes);
}
public void ClearSubstStorage()
{
var substDir = _fileDbManager.SubstFolder;
var allSubstFiles = Directory.GetFiles(substDir, "*.*", SearchOption.TopDirectoryOnly)
.Where(f =>
{
var val = f.Split('\\')[^1];
return val.Length == 40 || (val.Split('.').FirstOrDefault()?.Length ?? 0) == 40
|| val.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase);
});
if (SubstWatcher != null)
SubstWatcher.EnableRaisingEvents = false;
Dictionary<string, WatcherChange> changes = _substChanges.ToDictionary(t => t.Key, t => new WatcherChange(WatcherChangeTypes.Deleted, t.Key), StringComparer.Ordinal);
foreach (var file in allSubstFiles)
{
try
{
File.Delete(file);
}
catch { }
}
HandleChanges(changes);
if (SubstWatcher != null)
SubstWatcher.EnableRaisingEvents = true;
}
public void DeleteSubstOriginals()
{
var cacheDir = _configService.Current.CacheFolder;
var substDir = _fileDbManager.SubstFolder;
var allSubstFiles = Directory.GetFiles(substDir, "*.*", SearchOption.TopDirectoryOnly)
.Where(f =>
{
var val = f.Split('\\')[^1];
return val.Length == 40 || (val.Split('.').FirstOrDefault()?.Length ?? 0) == 40
|| val.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase);
});
foreach (var substFile in allSubstFiles)
{
var cacheFile = Path.Join(cacheDir, Path.GetFileName(substFile));
try
{
if (File.Exists(cacheFile))
File.Delete(cacheFile);
}
catch { }
}
}
private void HandleChanges(Dictionary<string, WatcherChange> changes)
{
lock (_fileDbManager)
{
var deletedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Deleted).Select(c => c.Key);
var renamedEntries = changes.Where(c => c.Value.ChangeType == WatcherChangeTypes.Renamed);
var remainingEntries = changes.Where(c => c.Value.ChangeType != WatcherChangeTypes.Deleted).Select(c => c.Key);
foreach (var entry in deletedEntries)
{
Logger.LogDebug("FSW Change: Deletion - {val}", entry);
}
foreach (var entry in renamedEntries)
{
Logger.LogDebug("FSW Change: Renamed - {oldVal} => {val}", entry.Value.OldPath, entry.Key);
}
foreach (var entry in remainingEntries)
{
Logger.LogDebug("FSW Change: Creation or Change - {val}", entry);
}
var allChanges = deletedEntries
.Concat(renamedEntries.Select(c => c.Value.OldPath!))
.Concat(renamedEntries.Select(c => c.Key))
.Concat(remainingEntries)
.ToArray();
_ = _fileDbManager.GetFileCachesByPaths(allChanges);
_fileDbManager.WriteOutFullCsv();
}
}
private async Task PenumbraWatcherExecution()
{
_penumbraFswCts = _penumbraFswCts.CancelRecreate();
var token = _penumbraFswCts.Token;
Dictionary<string, WatcherChange> changes;
lock (_watcherChanges)
changes = _watcherChanges.ToDictionary(t => t.Key, t => t.Value, StringComparer.Ordinal);
var delay = TimeSpan.FromSeconds(10);
try
{
do
{
await Task.Delay(delay, token).ConfigureAwait(false);
} while (HaltScanLocks.Any(f => f.Value.Value > 0));
}
catch (TaskCanceledException)
{
return;
}
lock (_watcherChanges)
{
foreach (var key in changes.Keys)
{
_watcherChanges.Remove(key);
}
}
HandleChanges(changes);
}
public void InvokeScan()
{
TotalFiles = 0;
_currentFileProgress = 0;
_scanCancellationTokenSource = _scanCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource();
var token = _scanCancellationTokenSource.Token;
_ = Task.Run(async () =>
{
Logger.LogDebug("Starting Full File Scan");
TotalFiles = 0;
_currentFileProgress = 0;
while (_dalamudUtil.IsOnFrameworkThread)
{
Logger.LogWarning("Scanner is on framework, waiting for leaving thread before continuing");
await Task.Delay(250, token).ConfigureAwait(false);
}
Thread scanThread = new(() =>
{
try
{
_performanceCollector.LogPerformance(this, $"FullFileScan", () => FullFileScan(token));
}
catch (Exception ex)
{
Logger.LogError(ex, "Error during Full File Scan");
}
})
{
Priority = ThreadPriority.Lowest,
IsBackground = true
};
scanThread.Start();
while (scanThread.IsAlive)
{
await Task.Delay(250).ConfigureAwait(false);
}
TotalFiles = 0;
_currentFileProgress = 0;
}, token);
}
public void RecalculateFileCacheSize(CancellationToken token)
{
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);
try
{
FileCacheDriveFree = di.AvailableFreeSpace;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder);
}
var files = Directory.EnumerateFiles(_configService.Current.CacheFolder)
.Concat(Directory.EnumerateFiles(_fileDbManager.SubstFolder))
.Select(f => new FileInfo(f))
.OrderBy(f => f.LastAccessTime).ToList();
FileCacheSize = files
.Sum(f =>
{
token.ThrowIfCancellationRequested();
try
{
return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS);
}
catch
{
return 0;
}
});
var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d);
if (FileCacheSize < maxCacheInBytes) return;
var substDir = _fileDbManager.SubstFolder;
var maxCacheBuffer = maxCacheInBytes * 0.05d;
while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer)
{
var oldestFile = files[0];
FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile);
File.Delete(oldestFile.FullName);
files.Remove(oldestFile);
}
}
public void ResetLocks()
{
HaltScanLocks.Clear();
}
public void ResumeScan(string source)
{
HaltScanLocks.TryAdd(source, new(0));
Interlocked.Decrement(ref HaltScanLocks[source].Value);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_scanCancellationTokenSource?.Cancel();
PenumbraWatcher?.Dispose();
MareWatcher?.Dispose();
SubstWatcher?.Dispose();
_penumbraFswCts?.CancelDispose();
_mareFswCts?.CancelDispose();
_substFswCts?.CancelDispose();
_periodicCalculationTokenSource?.CancelDispose();
}
private void FullFileScan(CancellationToken ct)
{
TotalFiles = 1;
var penumbraDir = _ipcManager.Penumbra.ModDirectory;
bool penDirExists = true;
bool cacheDirExists = true;
var substDir = _fileDbManager.SubstFolder;
if (string.IsNullOrEmpty(penumbraDir) || !Directory.Exists(penumbraDir))
{
penDirExists = false;
Logger.LogWarning("Penumbra directory is not set or does not exist.");
}
if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder))
{
cacheDirExists = false;
Logger.LogWarning("Umbra Cache directory is not set or does not exist.");
}
if (!penDirExists || !cacheDirExists)
{
return;
}
try
{
if (!Directory.Exists(substDir))
Directory.CreateDirectory(substDir);
}
catch
{
Logger.LogWarning("Could not create subst directory at {path}.", substDir);
}
var previousThreadPriority = Thread.CurrentThread.Priority;
Thread.CurrentThread.Priority = ThreadPriority.Lowest;
Logger.LogDebug("Getting files from {penumbra} and {storage}", penumbraDir, _configService.Current.CacheFolder);
Dictionary<string, string[]> penumbraFiles = new(StringComparer.Ordinal);
foreach (var folder in Directory.EnumerateDirectories(penumbraDir!))
{
try
{
penumbraFiles[folder] =
[
.. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories)
.AsParallel()
.Where(f => AllowedFileExtensions.Any(e => f.EndsWith(e, StringComparison.OrdinalIgnoreCase))
&& !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase)
&& !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)),
];
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Could not enumerate path {path}", folder);
}
Thread.Sleep(50);
if (ct.IsCancellationRequested) return;
}
var allCacheFiles = Directory.GetFiles(_configService.Current.CacheFolder, "*.*", SearchOption.TopDirectoryOnly)
.Concat(Directory.GetFiles(substDir, "*.*", SearchOption.TopDirectoryOnly))
.AsParallel()
.Where(f =>
{
var val = f.Split('\\')[^1];
return val.Length == 40 || (val.Split('.').FirstOrDefault()?.Length ?? 0) == 40;
});
if (ct.IsCancellationRequested) return;
var allScannedFiles = (penumbraFiles.SelectMany(k => k.Value))
.Concat(allCacheFiles)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToDictionary(t => t.ToLowerInvariant(), t => false, StringComparer.OrdinalIgnoreCase);
TotalFiles = allScannedFiles.Count;
Thread.CurrentThread.Priority = previousThreadPriority;
Thread.Sleep(TimeSpan.FromSeconds(2));
if (ct.IsCancellationRequested) return;
// scan files from database
var threadCount = Math.Clamp((int)(Environment.ProcessorCount / 2.0f), 2, 8);
List<FileCacheEntity> entitiesToRemove = [];
List<FileCacheEntity> entitiesToUpdate = [];
Lock sync = new();
Thread[] workerThreads = new Thread[threadCount];
ConcurrentQueue<FileCacheEntity> fileCaches = new(_fileDbManager.GetAllFileCaches());
TotalFilesStorage = fileCaches.Count;
for (int i = 0; i < threadCount; i++)
{
Logger.LogTrace("Creating Thread {i}", i);
workerThreads[i] = new((tcounter) =>
{
var threadNr = (int)tcounter!;
Logger.LogTrace("Spawning Worker Thread {i}", threadNr);
while (!ct.IsCancellationRequested && fileCaches.TryDequeue(out var workload))
{
try
{
if (ct.IsCancellationRequested) return;
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
var validatedCacheResult = _fileDbManager.ValidateFileCacheEntity(workload);
if (validatedCacheResult.State != FileState.RequireDeletion)
{
lock (sync) { allScannedFiles[validatedCacheResult.FileCache.ResolvedFilepath] = true; }
}
if (validatedCacheResult.State == FileState.RequireUpdate)
{
Logger.LogTrace("To update: {path}", validatedCacheResult.FileCache.ResolvedFilepath);
lock (sync) { entitiesToUpdate.Add(validatedCacheResult.FileCache); }
}
else if (validatedCacheResult.State == FileState.RequireDeletion)
{
Logger.LogTrace("To delete: {path}", validatedCacheResult.FileCache.ResolvedFilepath);
lock (sync) { entitiesToRemove.Add(validatedCacheResult.FileCache); }
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed validating {path}", workload.ResolvedFilepath);
}
Interlocked.Increment(ref _currentFileProgress);
}
Logger.LogTrace("Ending Worker Thread {i}", threadNr);
})
{
Priority = ThreadPriority.Lowest,
IsBackground = true
};
workerThreads[i].Start(i);
}
while (!ct.IsCancellationRequested && workerThreads.Any(u => u.IsAlive))
{
Thread.Sleep(1000);
}
if (ct.IsCancellationRequested) return;
Logger.LogTrace("Threads exited");
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
if (entitiesToUpdate.Any() || entitiesToRemove.Any())
{
foreach (var entity in entitiesToUpdate)
{
_fileDbManager.UpdateHashedFile(entity);
}
foreach (var entity in entitiesToRemove)
{
_fileDbManager.RemoveHashedFile(entity.Hash, entity.PrefixedFilePath);
}
_fileDbManager.WriteOutFullCsv();
}
Logger.LogTrace("Scanner validated existing db files");
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
if (ct.IsCancellationRequested) return;
// scan new files
if (allScannedFiles.Any(c => !c.Value))
{
Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key),
new ParallelOptions()
{
MaxDegreeOfParallelism = threadCount,
CancellationToken = ct
}, (cachePath) =>
{
if (ct.IsCancellationRequested) return;
if (!_ipcManager.Penumbra.APIAvailable)
{
Logger.LogWarning("Penumbra not available");
return;
}
try
{
var entry = _fileDbManager.CreateFileEntry(cachePath);
if (entry == null)
{
if (cachePath.StartsWith(substDir, StringComparison.Ordinal))
_ = _fileDbManager.CreateSubstEntry(cachePath);
else
_ = _fileDbManager.CreateCacheEntry(cachePath);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed adding {file}", cachePath);
}
Interlocked.Increment(ref _currentFileProgress);
});
Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value));
}
Logger.LogDebug("Scan complete");
TotalFiles = 0;
_currentFileProgress = 0;
entitiesToRemove.Clear();
allScannedFiles.Clear();
if (!_configService.Current.InitialScanComplete)
{
_configService.Current.InitialScanComplete = true;
_configService.Save();
StartMareWatcher(_configService.Current.CacheFolder);
StartSubstWatcher(_fileDbManager.SubstFolder);
StartPenumbraWatcher(penumbraDir);
}
}
}

View File

@@ -0,0 +1,30 @@
#nullable disable
namespace MareSynchronos.FileCache;
public class FileCacheEntity
{
public FileCacheEntity(string hash, string path, string lastModifiedDateTicks, long? size = null, long? compressedSize = null)
{
Size = size;
CompressedSize = compressedSize;
Hash = hash;
PrefixedFilePath = path;
LastModifiedDateTicks = lastModifiedDateTicks;
}
public long? CompressedSize { get; set; }
public string CsvEntry => $"{Hash}{FileCacheManager.CsvSplit}{PrefixedFilePath}{FileCacheManager.CsvSplit}{LastModifiedDateTicks}|{Size ?? -1}|{CompressedSize ?? -1}";
public string Hash { get; set; }
public bool IsCacheEntry => PrefixedFilePath.StartsWith(FileCacheManager.CachePrefix, StringComparison.OrdinalIgnoreCase);
public bool IsSubstEntry => PrefixedFilePath.StartsWith(FileCacheManager.SubstPrefix, StringComparison.OrdinalIgnoreCase);
public string LastModifiedDateTicks { get; set; }
public string PrefixedFilePath { get; init; }
public string ResolvedFilepath { get; private set; } = string.Empty;
public long? Size { get; set; }
public void SetResolvedFilePath(string filePath)
{
ResolvedFilepath = filePath.ToLowerInvariant().Replace("\\\\", "\\", StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,553 @@
using Dalamud.Utility;
using K4os.Compression.LZ4.Streams;
using MareSynchronos.Interop.Ipc;
using MareSynchronos.MareConfiguration;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
namespace MareSynchronos.FileCache;
public sealed class FileCacheManager : IHostedService
{
public const string CachePrefix = "{cache}";
public const string CsvSplit = "|";
public const string PenumbraPrefix = "{penumbra}";
public const string SubstPrefix = "{subst}";
public const string SubstPath = "subst";
public string CacheFolder => _configService.Current.CacheFolder;
public string SubstFolder => CacheFolder.IsNullOrEmpty() ? string.Empty : CacheFolder.ToLowerInvariant().TrimEnd('\\') + "\\" + SubstPath;
private readonly MareConfigService _configService;
private readonly MareMediator _mareMediator;
private readonly string _csvPath;
private readonly ConcurrentDictionary<string, List<FileCacheEntity>> _fileCaches = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _getCachesByPathsSemaphore = new(1, 1);
private readonly Lock _fileWriteLock = new();
private readonly IpcManager _ipcManager;
private readonly ILogger<FileCacheManager> _logger;
public FileCacheManager(ILogger<FileCacheManager> logger, IpcManager ipcManager, MareConfigService configService, MareMediator mareMediator)
{
_logger = logger;
_ipcManager = ipcManager;
_configService = configService;
_mareMediator = mareMediator;
_csvPath = Path.Combine(configService.ConfigurationDirectory, "FileCache.csv");
}
private string CsvBakPath => _csvPath + ".bak";
public FileCacheEntity? CreateCacheEntry(string path, string? hash = null)
{
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating cache entry for {path}", path);
var fullName = fi.FullName.ToLowerInvariant();
if (!fullName.Contains(_configService.Current.CacheFolder.ToLowerInvariant(), StringComparison.Ordinal)) return null;
string prefixedPath = fullName.Replace(_configService.Current.CacheFolder.ToLowerInvariant(), CachePrefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
if (hash != null)
return CreateFileCacheEntity(fi, prefixedPath, hash);
else
return CreateFileCacheEntity(fi, prefixedPath);
}
public FileCacheEntity? CreateSubstEntry(string path)
{
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating substitute entry for {path}", path);
var fullName = fi.FullName.ToLowerInvariant();
if (!fullName.Contains(SubstFolder, StringComparison.Ordinal)) return null;
string prefixedPath = fullName.Replace(SubstFolder, SubstPrefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
var fakeHash = Path.GetFileNameWithoutExtension(fi.FullName).ToUpperInvariant();
var result = CreateFileCacheEntity(fi, prefixedPath, fakeHash);
return result;
}
public FileCacheEntity? CreateFileEntry(string path)
{
FileInfo fi = new(path);
if (!fi.Exists) return null;
_logger.LogTrace("Creating file entry for {path}", path);
var fullName = fi.FullName.ToLowerInvariant();
if (!fullName.Contains(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), StringComparison.Ordinal)) return null;
string prefixedPath = fullName.Replace(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), PenumbraPrefix + "\\", StringComparison.Ordinal).Replace("\\\\", "\\", StringComparison.Ordinal);
return CreateFileCacheEntity(fi, prefixedPath);
}
public List<FileCacheEntity> GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v).ToList();
public List<FileCacheEntity> GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true)
{
List<FileCacheEntity> output = [];
if (_fileCaches.TryGetValue(hash, out var fileCacheEntities))
{
foreach (var fileCache in fileCacheEntities.Where(c => ignoreCacheEntries ? (!c.IsCacheEntry && !c.IsSubstEntry) : true).ToList())
{
if (!validate) output.Add(fileCache);
else
{
var validated = GetValidatedFileCache(fileCache);
if (validated != null) output.Add(validated);
}
}
}
return output;
}
public Task<List<FileCacheEntity>> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken)
{
_mareMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity)));
_logger.LogInformation("Validating local storage");
var cacheEntries = _fileCaches.SelectMany(v => v.Value).Where(v => v.IsCacheEntry).ToList();
List<FileCacheEntity> brokenEntities = [];
int i = 0;
foreach (var fileCache in cacheEntries)
{
if (cancellationToken.IsCancellationRequested) break;
if (fileCache.IsSubstEntry) continue;
_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);
if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal))
{
_logger.LogInformation("Failed to validate {file}, got hash {hash}, expected hash {expectedHash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash);
brokenEntities.Add(fileCache);
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath);
brokenEntities.Add(fileCache);
}
}
foreach (var brokenEntity in brokenEntities)
{
RemoveHashedFile(brokenEntity.Hash, brokenEntity.PrefixedFilePath);
try
{
File.Delete(brokenEntity.ResolvedFilepath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath);
}
}
_mareMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity)));
return Task.FromResult(brokenEntities);
}
public string GetCacheFilePath(string hash, string extension)
{
return Path.Combine(_configService.Current.CacheFolder, hash + "." + extension);
}
public string GetSubstFilePath(string hash, string extension)
{
return Path.Combine(SubstFolder, hash + "." + extension);
}
public async Task<(string, byte[])> GetCompressedFileData(string fileHash, CancellationToken uploadToken)
{
var fileCache = GetFileCacheByHash(fileHash)!;
using var fs = File.OpenRead(fileCache.ResolvedFilepath);
var ms = new MemoryStream(64 * 1024);
using var encstream = LZ4Stream.Encode(ms, new LZ4EncoderSettings(){CompressionLevel=K4os.Compression.LZ4.LZ4Level.L09_HC});
await fs.CopyToAsync(encstream, uploadToken).ConfigureAwait(false);
encstream.Close();
fileCache.CompressedSize = encstream.Length;
return (fileHash, ms.ToArray());
}
public FileCacheEntity? GetFileCacheByHash(string hash, bool preferSubst = false)
{
var caches = GetFileCachesByHash(hash);
if (preferSubst && caches.Subst != null)
return caches.Subst;
return caches.Penumbra ?? caches.Cache;
}
public (FileCacheEntity? Penumbra, FileCacheEntity? Cache, FileCacheEntity? Subst) GetFileCachesByHash(string hash)
{
(FileCacheEntity? Penumbra, FileCacheEntity? Cache, FileCacheEntity? Subst) result = (null, null, null);
if (_fileCaches.TryGetValue(hash, out var hashes))
{
result.Penumbra = hashes.Where(p => p.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.Ordinal)).Select(GetValidatedFileCache).FirstOrDefault();
result.Cache = hashes.Where(p => p.PrefixedFilePath.StartsWith(CachePrefix, StringComparison.Ordinal)).Select(GetValidatedFileCache).FirstOrDefault();
result.Subst = hashes.Where(p => p.PrefixedFilePath.StartsWith(SubstPrefix, StringComparison.Ordinal)).Select(GetValidatedFileCache).FirstOrDefault();
}
return result;
}
private FileCacheEntity? GetFileCacheByPath(string path)
{
var cleanedPath = path.Replace("/", "\\", StringComparison.OrdinalIgnoreCase).ToLowerInvariant()
.Replace(_ipcManager.Penumbra.ModDirectory!.ToLowerInvariant(), "", StringComparison.OrdinalIgnoreCase);
var entry = _fileCaches.SelectMany(v => v.Value).FirstOrDefault(f => f.ResolvedFilepath.EndsWith(cleanedPath, StringComparison.OrdinalIgnoreCase));
if (entry == null)
{
_logger.LogDebug("Found no entries for {path}", cleanedPath);
return CreateFileEntry(path);
}
var validatedCacheEntry = GetValidatedFileCache(entry);
return validatedCacheEntry;
}
public Dictionary<string, FileCacheEntity?> GetFileCachesByPaths(string[] paths)
{
_getCachesByPathsSemaphore.Wait();
try
{
var cleanedPaths = paths.Distinct(StringComparer.OrdinalIgnoreCase).ToDictionary(p => p,
p => p.Replace("/", "\\", StringComparison.OrdinalIgnoreCase)
.Replace(_ipcManager.Penumbra.ModDirectory!, _ipcManager.Penumbra.ModDirectory!.EndsWith('\\') ? PenumbraPrefix + '\\' : PenumbraPrefix, StringComparison.OrdinalIgnoreCase)
.Replace(SubstFolder, SubstPrefix, StringComparison.OrdinalIgnoreCase)
.Replace(_configService.Current.CacheFolder, _configService.Current.CacheFolder.EndsWith('\\') ? CachePrefix + '\\' : CachePrefix, StringComparison.OrdinalIgnoreCase)
.Replace("\\\\", "\\", StringComparison.Ordinal),
StringComparer.OrdinalIgnoreCase);
Dictionary<string, FileCacheEntity?> result = new(StringComparer.OrdinalIgnoreCase);
var dict = _fileCaches.SelectMany(f => f.Value)
.ToDictionary(d => d.PrefixedFilePath, d => d, StringComparer.OrdinalIgnoreCase);
foreach (var entry in cleanedPaths)
{
if (dict.TryGetValue(entry.Value, out var entity))
{
var validatedCache = GetValidatedFileCache(entity);
result.Add(entry.Key, validatedCache);
}
else
{
if (entry.Value.StartsWith(PenumbraPrefix, StringComparison.Ordinal))
result.Add(entry.Key, CreateFileEntry(entry.Key));
else if (entry.Value.StartsWith(SubstPrefix, StringComparison.Ordinal))
result.Add(entry.Key, CreateSubstEntry(entry.Key));
else if (entry.Value.StartsWith(CachePrefix, StringComparison.Ordinal))
result.Add(entry.Key, CreateCacheEntry(entry.Key));
}
}
return result;
}
finally
{
_getCachesByPathsSemaphore.Release();
}
}
public void RemoveHashedFile(string hash, string prefixedFilePath)
{
if (_fileCaches.TryGetValue(hash, out var caches))
{
var removedCount = caches?.RemoveAll(c => string.Equals(c.PrefixedFilePath, prefixedFilePath, StringComparison.Ordinal));
_logger.LogTrace("Removed from DB: {count} file(s) with hash {hash} and file cache {path}", removedCount, hash, prefixedFilePath);
if (caches?.Count == 0)
{
_fileCaches.Remove(hash, out var _);
}
}
}
public void UpdateHashedFile(FileCacheEntity fileCache, bool computeProperties = true)
{
_logger.LogTrace("Updating hash for {path}", fileCache.ResolvedFilepath);
var oldHash = fileCache.Hash;
var prefixedPath = fileCache.PrefixedFilePath;
if (computeProperties)
{
var fi = new FileInfo(fileCache.ResolvedFilepath);
fileCache.Size = fi.Length;
fileCache.CompressedSize = null;
fileCache.Hash = Crypto.GetFileHash(fileCache.ResolvedFilepath);
fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
}
RemoveHashedFile(oldHash, prefixedPath);
AddHashedFile(fileCache);
}
public (FileState State, FileCacheEntity FileCache) ValidateFileCacheEntity(FileCacheEntity fileCache)
{
fileCache = ReplacePathPrefixes(fileCache);
FileInfo fi = new(fileCache.ResolvedFilepath);
if (!fi.Exists)
{
return (FileState.RequireDeletion, fileCache);
}
if (!string.Equals(fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal))
{
return (FileState.RequireUpdate, fileCache);
}
return (FileState.Valid, fileCache);
}
public void WriteOutFullCsv()
{
lock (_fileWriteLock)
{
StringBuilder sb = new();
foreach (var entry in _fileCaches.SelectMany(k => k.Value).OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase))
{
sb.AppendLine(entry.CsvEntry);
}
if (File.Exists(_csvPath))
{
File.Copy(_csvPath, CsvBakPath, overwrite: true);
}
try
{
File.WriteAllText(_csvPath, sb.ToString());
File.Delete(CsvBakPath);
}
catch
{
File.WriteAllText(CsvBakPath, sb.ToString());
}
}
}
internal FileCacheEntity MigrateFileHashToExtension(FileCacheEntity fileCache, string ext)
{
try
{
RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath);
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));
newHashedEntity.SetResolvedFilePath(extensionPath);
AddHashedFile(newHashedEntity);
_logger.LogTrace("Migrated from {oldPath} to {newPath}", fileCache.ResolvedFilepath, newHashedEntity.ResolvedFilepath);
return newHashedEntity;
}
catch (Exception ex)
{
AddHashedFile(fileCache);
_logger.LogWarning(ex, "Failed to migrate entity {entity}", fileCache.PrefixedFilePath);
return fileCache;
}
}
private void AddHashedFile(FileCacheEntity fileCache)
{
if (!_fileCaches.TryGetValue(fileCache.Hash, out var entries) || entries is null)
{
_fileCaches[fileCache.Hash] = entries = [];
}
if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase)))
{
entries.Add(fileCache);
}
}
private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null)
{
hash ??= Crypto.GetFileHash(fileInfo.FullName);
var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length);
entity = ReplacePathPrefixes(entity);
AddHashedFile(entity);
lock (_fileWriteLock)
{
File.AppendAllLines(_csvPath, new[] { entity.CsvEntry });
}
var result = GetFileCacheByPath(fileInfo.FullName);
_logger.LogTrace("Creating cache entity for {name} success: {success}", fileInfo.FullName, (result != null));
return result;
}
private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache)
{
var resultingFileCache = ReplacePathPrefixes(fileCache);
resultingFileCache = Validate(resultingFileCache);
return resultingFileCache;
}
private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache)
{
if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase))
{
fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(PenumbraPrefix, _ipcManager.Penumbra.ModDirectory, StringComparison.Ordinal));
}
else if (fileCache.PrefixedFilePath.StartsWith(SubstPrefix, StringComparison.OrdinalIgnoreCase))
{
fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(SubstPrefix, SubstFolder, StringComparison.Ordinal));
}
else if (fileCache.PrefixedFilePath.StartsWith(CachePrefix, StringComparison.OrdinalIgnoreCase))
{
fileCache.SetResolvedFilePath(fileCache.PrefixedFilePath.Replace(CachePrefix, _configService.Current.CacheFolder, StringComparison.Ordinal));
}
return fileCache;
}
private FileCacheEntity? Validate(FileCacheEntity fileCache)
{
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;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting FileCacheManager");
lock (_fileWriteLock)
{
try
{
_logger.LogInformation("Checking for {bakPath}", CsvBakPath);
if (File.Exists(CsvBakPath))
{
_logger.LogInformation("{bakPath} found, moving to {csvPath}", CsvBakPath, _csvPath);
File.Move(CsvBakPath, _csvPath, overwrite: true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to move BAK to ORG, deleting BAK");
try
{
if (File.Exists(CsvBakPath))
File.Delete(CsvBakPath);
}
catch (Exception ex1)
{
_logger.LogWarning(ex1, "Could not delete bak file");
}
}
}
if (File.Exists(_csvPath))
{
if (!_ipcManager.Penumbra.APIAvailable || string.IsNullOrEmpty(_ipcManager.Penumbra.ModDirectory))
{
_mareMediator.Publish(new NotificationMessage("Penumbra not connected",
"Could not load local file cache data. Penumbra is not connected or not properly set up. Please enable and/or configure Penumbra properly to use Umbra. After, reload Umbra in the Plugin installer.",
MareConfiguration.Models.NotificationType.Error));
}
_logger.LogInformation("{csvPath} found, parsing", _csvPath);
bool success = false;
string[] entries = [];
int attempts = 0;
while (!success && attempts < 10)
{
try
{
_logger.LogInformation("Attempting to read {csvPath}", _csvPath);
entries = File.ReadAllLines(_csvPath);
success = true;
}
catch (Exception ex)
{
attempts++;
_logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath);
Thread.Sleep(100);
}
}
if (!entries.Any())
{
_logger.LogWarning("Could not load entries from {path}, continuing with empty file cache", _csvPath);
}
_logger.LogInformation("Found {amount} files in {path}", entries.Length, _csvPath);
Dictionary<string, bool> processedFiles = new(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries)
{
var splittedEntry = entry.Split(CsvSplit, StringSplitOptions.None);
try
{
var hash = splittedEntry[0];
if (hash.Length != 40) throw new InvalidOperationException("Expected Hash length of 40, received " + hash.Length);
var path = splittedEntry[1];
var time = splittedEntry[2];
if (processedFiles.ContainsKey(path))
{
_logger.LogWarning("Already processed {file}, ignoring", path);
continue;
}
processedFiles.Add(path, value: true);
long size = -1;
long compressed = -1;
if (splittedEntry.Length > 3)
{
if (long.TryParse(splittedEntry[3], CultureInfo.InvariantCulture, out long result))
{
size = result;
}
if (long.TryParse(splittedEntry[4], CultureInfo.InvariantCulture, out long resultCompressed))
{
compressed = resultCompressed;
}
}
AddHashedFile(ReplacePathPrefixes(new FileCacheEntity(hash, path, time, size, compressed)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to initialize entry {entry}, ignoring", entry);
}
}
if (processedFiles.Count != entries.Length)
{
WriteOutFullCsv();
}
}
_logger.LogInformation("Started FileCacheManager");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
WriteOutFullCsv();
return Task.CompletedTask;
}
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace MareSynchronos.FileCache;
public enum FileState
{
Valid,
RequireUpdate,
RequireDeletion,
}

View File

@@ -0,0 +1,313 @@
using MareSynchronos.API.Data.Enum;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Data;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace MareSynchronos.FileCache;
public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{
private readonly Lock _cacheAdditionLock = new();
private readonly HashSet<string> _cachedHandledPaths = new(StringComparer.Ordinal);
private readonly TransientConfigService _configurationService;
private readonly DalamudUtilService _dalamudUtil;
private readonly string[] _fileTypesToHandle = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk"];
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private ConcurrentDictionary<IntPtr, ObjectKind> _cachedFrameAddresses = [];
public TransientResourceManager(ILogger<TransientResourceManager> logger, TransientConfigService configurationService,
DalamudUtilService dalamudUtil, MareMediator mediator) : base(logger, mediator)
{
_configurationService = configurationService;
_dalamudUtil = dalamudUtil;
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<PenumbraModSettingChangedMessage>(this, (_) => Manager_PenumbraModSettingChanged());
Mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, (_) => DalamudUtil_FrameworkUpdate());
Mediator.Subscribe<ClassJobChangedMessage>(this, (msg) =>
{
if (_playerRelatedPointers.Contains(msg.GameObjectHandler))
{
DalamudUtil_ClassJobChanged();
}
});
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
_playerRelatedPointers.Add(msg.GameObjectHandler);
});
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
_playerRelatedPointers.Remove(msg.GameObjectHandler);
});
}
private string PlayerPersistentDataKey => _dalamudUtil.GetPlayerNameAsync().GetAwaiter().GetResult() + "_" + _dalamudUtil.GetHomeWorldIdAsync().GetAwaiter().GetResult();
private ConcurrentDictionary<ObjectKind, HashSet<string>>? _semiTransientResources = null;
private ConcurrentDictionary<ObjectKind, HashSet<string>> SemiTransientResources
{
get
{
if (_semiTransientResources == null)
{
_semiTransientResources = new();
_semiTransientResources.TryAdd(ObjectKind.Player, new HashSet<string>(StringComparer.Ordinal));
if (_configurationService.Current.PlayerPersistentTransientCache.TryGetValue(PlayerPersistentDataKey, out var gamePaths))
{
int restored = 0;
foreach (var gamePath in gamePaths)
{
if (string.IsNullOrEmpty(gamePath)) continue;
try
{
Logger.LogDebug("Loaded persistent transient resource {path}", gamePath);
SemiTransientResources[ObjectKind.Player].Add(gamePath);
restored++;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during loading persistent transient resource {path}", gamePath);
}
}
Logger.LogDebug("Restored {restored}/{total} semi persistent resources", restored, gamePaths.Count);
}
}
return _semiTransientResources;
}
}
private ConcurrentDictionary<IntPtr, HashSet<string>> TransientResources { get; } = new();
public void CleanUpSemiTransientResources(ObjectKind objectKind, List<FileReplacement>? fileReplacement = null)
{
if (SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? value))
{
if (fileReplacement == null)
{
value.Clear();
return;
}
foreach (var replacement in fileReplacement.Where(p => !p.HasFileReplacement).SelectMany(p => p.GamePaths).ToList())
{
value.RemoveWhere(p => string.Equals(p, replacement, StringComparison.OrdinalIgnoreCase));
}
}
}
public HashSet<string> GetSemiTransientResources(ObjectKind objectKind)
{
if (SemiTransientResources.TryGetValue(objectKind, out var result))
{
return result ?? new HashSet<string>(StringComparer.Ordinal);
}
return new HashSet<string>(StringComparer.Ordinal);
}
public List<string> GetTransientResources(IntPtr gameObject)
{
if (TransientResources.TryGetValue(gameObject, out var result))
{
return [.. result];
}
return [];
}
public void PersistTransientResources(IntPtr gameObject, ObjectKind objectKind)
{
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? value))
{
value = new HashSet<string>(StringComparer.Ordinal);
SemiTransientResources[objectKind] = value;
}
if (!TransientResources.TryGetValue(gameObject, out var resources))
{
return;
}
var transientResources = resources.ToList();
Logger.LogDebug("Persisting {count} transient resources", transientResources.Count);
foreach (var gamePath in transientResources)
{
value.Add(gamePath);
}
if (objectKind == ObjectKind.Player && SemiTransientResources.TryGetValue(ObjectKind.Player, out var fileReplacements))
{
_configurationService.Current.PlayerPersistentTransientCache[PlayerPersistentDataKey] = fileReplacements.Where(f => !string.IsNullOrEmpty(f)).ToHashSet(StringComparer.Ordinal);
_configurationService.Save();
}
TransientResources[gameObject].Clear();
}
internal void AddSemiTransientResource(ObjectKind objectKind, string item)
{
if (!SemiTransientResources.TryGetValue(objectKind, out HashSet<string>? value))
{
value = new HashSet<string>(StringComparer.Ordinal);
SemiTransientResources[objectKind] = value;
}
value.Add(item.ToLowerInvariant());
}
internal void ClearTransientPaths(IntPtr ptr, List<string> list)
{
if (TransientResources.TryGetValue(ptr, out var set))
{
foreach (var file in set.Where(p => list.Contains(p, StringComparer.OrdinalIgnoreCase)))
{
Logger.LogTrace("Removing From Transient: {file}", file);
}
int removed = set.RemoveWhere(p => list.Contains(p, StringComparer.OrdinalIgnoreCase));
Logger.LogInformation("Removed {removed} previously existing transient paths", removed);
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
try
{
TransientResources.Clear();
SemiTransientResources.Clear();
if (SemiTransientResources.TryGetValue(ObjectKind.Player, out HashSet<string>? value))
{
_configurationService.Current.PlayerPersistentTransientCache[PlayerPersistentDataKey] = value;
_configurationService.Save();
}
}
catch { }
}
private void DalamudUtil_ClassJobChanged()
{
if (SemiTransientResources.TryGetValue(ObjectKind.Pet, out HashSet<string>? value))
{
value?.Clear();
}
}
private void DalamudUtil_FrameworkUpdate()
{
_cachedFrameAddresses = _cachedFrameAddresses = new ConcurrentDictionary<nint, ObjectKind>(_playerRelatedPointers.Where(k => k.Address != nint.Zero).ToDictionary(c => c.CurrentAddress(), c => c.ObjectKind));
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Clear();
}
foreach (var item in TransientResources.Where(item => !_dalamudUtil.IsGameObjectPresent(item.Key)).Select(i => i.Key).ToList())
{
Logger.LogDebug("Object not present anymore: {addr}", item.ToString("X"));
TransientResources.TryRemove(item, out _);
}
}
private void Manager_PenumbraModSettingChanged()
{
_ = Task.Run(() =>
{
Logger.LogDebug("Penumbra Mod Settings changed, verifying SemiTransientResources");
foreach (var item in _playerRelatedPointers)
{
Mediator.Publish(new TransientResourceChangedMessage(item.Address));
}
});
}
private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{
var gamePath = msg.GamePath.ToLowerInvariant();
var gameObject = msg.GameObject;
var filePath = msg.FilePath;
// ignore files already processed this frame
if (_cachedHandledPaths.Contains(gamePath)) return;
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
// 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)) return;
// ignore files to not handle
if (!_fileTypesToHandle.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
{
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return;
}
// ignore files not belonging to anything player related
if (!_cachedFrameAddresses.TryGetValue(gameObject, out var objectKind))
{
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return;
}
if (!TransientResources.TryGetValue(gameObject, out HashSet<string>? value))
{
value = new(StringComparer.OrdinalIgnoreCase);
TransientResources[gameObject] = value;
}
if (value.Contains(replacedGamePath) ||
SemiTransientResources.SelectMany(k => k.Value).Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase)))
{
Logger.LogTrace("Not adding {replacedPath} : {filePath}", replacedGamePath, filePath);
}
else
{
var thing = _playerRelatedPointers.FirstOrDefault(f => f.Address == gameObject);
value.Add(replacedGamePath);
Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, thing?.ToString() ?? gameObject.ToString("X"), filePath);
_ = Task.Run(async () =>
{
_sendTransientCts?.Cancel();
_sendTransientCts?.Dispose();
_sendTransientCts = new();
var token = _sendTransientCts.Token;
await Task.Delay(TimeSpan.FromSeconds(2), token).ConfigureAwait(false);
Mediator.Publish(new TransientResourceChangedMessage(gameObject));
});
}
}
internal void RemoveTransientResource(ObjectKind objectKind, string path)
{
if (SemiTransientResources.TryGetValue(objectKind, out var resources))
{
resources.RemoveWhere(f => string.Equals(path, f, StringComparison.OrdinalIgnoreCase));
_configurationService.Current.PlayerPersistentTransientCache[PlayerPersistentDataKey] = resources;
_configurationService.Save();
}
}
private CancellationTokenSource _sendTransientCts = new();
}

View File

@@ -0,0 +1,8 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "<Pending>", Scope = "member", Target = "~M:MareSynchronos.Services.CharaDataManager.AttachPoseData(MareSynchronos.API.Dto.CharaData.PoseEntry,MareSynchronos.Services.CharaData.Models.CharaDataExtendedUpdateDto)")]

View File

@@ -0,0 +1,42 @@
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.UI.Info;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop;
public unsafe class BlockedCharacterHandler
{
private sealed record CharaData(ulong AccId, ulong ContentId);
private readonly Dictionary<CharaData, bool> _blockedCharacterCache = new();
private readonly ILogger<BlockedCharacterHandler> _logger;
public BlockedCharacterHandler(ILogger<BlockedCharacterHandler> logger, IGameInteropProvider gameInteropProvider)
{
gameInteropProvider.InitializeFromAttributes(this);
_logger = logger;
}
private static CharaData GetIdsFromPlayerPointer(nint ptr)
{
if (ptr == nint.Zero) return new(0, 0);
var castChar = ((BattleChara*)ptr);
return new(castChar->Character.AccountId, castChar->Character.ContentId);
}
public bool IsCharacterBlocked(nint ptr, out bool firstTime)
{
firstTime = false;
var combined = GetIdsFromPlayerPointer(ptr);
if (_blockedCharacterCache.TryGetValue(combined, out var isBlocked))
return isBlocked;
firstTime = true;
var blockStatus = InfoProxyBlacklist.Instance()->GetBlockResultType(combined.AccId, combined.ContentId);
_logger.LogTrace("CharaPtr {ptr} is BlockStatus: {status}", ptr, blockStatus);
if ((int)blockStatus == 0)
return false;
return _blockedCharacterCache[combined] = blockStatus != InfoProxyBlacklist.BlockResultType.NotBlocked;
}
}

View File

@@ -0,0 +1,55 @@
using Dalamud.Plugin.Services;
using MareSynchronos.MareConfiguration;
using Microsoft.Extensions.Logging;
using System.Text;
namespace MareSynchronos.Interop;
internal sealed class DalamudLogger : ILogger
{
private readonly MareConfigService _mareConfigService;
private readonly string _name;
private readonly IPluginLog _pluginLog;
public DalamudLogger(string name, MareConfigService mareConfigService, IPluginLog pluginLog)
{
_name = name;
_mareConfigService = mareConfigService;
_pluginLog = pluginLog;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull => default!;
public bool IsEnabled(LogLevel logLevel)
{
return (int)_mareConfigService.Current.LogLevel <= (int)logLevel;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
if ((int)logLevel <= (int)LogLevel.Information)
_pluginLog.Information($"[{_name}]{{{(int)logLevel}}} {state}");
else
{
StringBuilder sb = new();
sb.Append($"[{_name}]{{{(int)logLevel}}} {state}: {exception?.Message}");
if (!string.IsNullOrWhiteSpace(exception?.StackTrace))
sb.AppendLine(exception?.StackTrace);
var innerException = exception?.InnerException;
while (innerException != null)
{
sb.AppendLine($"InnerException {innerException}: {innerException.Message}");
sb.AppendLine(innerException.StackTrace);
innerException = innerException.InnerException;
}
if (logLevel == LogLevel.Warning)
_pluginLog.Warning(sb.ToString());
else if (logLevel == LogLevel.Error)
_pluginLog.Error(sb.ToString());
else
_pluginLog.Fatal(sb.ToString());
}
}
}

View File

@@ -0,0 +1,44 @@
using Dalamud.Plugin.Services;
using MareSynchronos.MareConfiguration;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace MareSynchronos.Interop;
[ProviderAlias("Dalamud")]
public sealed class DalamudLoggingProvider : ILoggerProvider
{
private readonly ConcurrentDictionary<string, DalamudLogger> _loggers =
new(StringComparer.OrdinalIgnoreCase);
private readonly MareConfigService _mareConfigService;
private readonly IPluginLog _pluginLog;
public DalamudLoggingProvider(MareConfigService mareConfigService, IPluginLog pluginLog)
{
_mareConfigService = mareConfigService;
_pluginLog = pluginLog;
}
public ILogger CreateLogger(string categoryName)
{
string catName = categoryName.Split(".", StringSplitOptions.RemoveEmptyEntries).Last();
if (catName.Length > 15)
{
catName = string.Join("", catName.Take(6)) + "..." + string.Join("", catName.TakeLast(6));
}
else
{
catName = string.Join("", Enumerable.Range(0, 15 - catName.Length).Select(_ => " ")) + catName;
}
return _loggers.GetOrAdd(catName, name => new DalamudLogger(name, _mareConfigService, _pluginLog));
}
public void Dispose()
{
_loggers.Clear();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,20 @@
using Dalamud.Plugin.Services;
using MareSynchronos.MareConfiguration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop;
public static class DalamudLoggingProviderExtensions
{
public static ILoggingBuilder AddDalamudLogging(this ILoggingBuilder builder, IPluginLog pluginLog)
{
builder.ClearProviders();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, DalamudLoggingProvider>
(b => new DalamudLoggingProvider(b.GetRequiredService<MareConfigService>(), pluginLog)));
return builder;
}
}

View File

@@ -0,0 +1,333 @@
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Hooking;
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
using FFXIVClientStructs.FFXIV.Component.Shell;
using MareSynchronos.Services;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop;
public record ChatChannelOverride
{
public string ChannelName = string.Empty;
public Action<byte[]>? ChatMessageHandler;
}
public unsafe sealed class GameChatHooks : IDisposable
{
// Based on https://git.anna.lgbt/anna/ExtraChat/src/branch/main/client/ExtraChat/GameFunctions.cs
private readonly ILogger<GameChatHooks> _logger;
private readonly Action<int, byte[]> _ssCommandHandler;
#region signatures
#pragma warning disable CS0649
// I do not know what kind of black magic this function performs
// Client::UI::Misc::PronounModule::???
[Signature("E8 ?? ?? ?? ?? 44 88 74 24 ?? 4C 8D 45")]
private readonly delegate* unmanaged<PronounModule*, Utf8String*, byte, Utf8String*> _processStringStep2;
// Component::Shell::ShellCommandModule::ExecuteCommandInner
private delegate void SendMessageDelegate(ShellCommandModule* module, Utf8String* message, UIModule* uiModule);
[Signature(
"E8 ?? ?? ?? ?? FE 87 ?? ?? ?? ?? C7 87",
DetourName = nameof(SendMessageDetour)
)]
private Hook<SendMessageDelegate>? SendMessageHook { get; init; }
// Client::UI::Shell::RaptureShellModule::SetChatChannel
private delegate void SetChatChannelDelegate(RaptureShellModule* module, uint channel);
[Signature(
"E8 ?? ?? ?? ?? 33 C0 EB ?? 85 D2",
DetourName = nameof(SetChatChannelDetour)
)]
private Hook<SetChatChannelDelegate>? SetChatChannelHook { get; init; }
// Component::Shell::ShellCommandModule::ChangeChannelName
private delegate byte* ChangeChannelNameDelegate(AgentChatLog* agent);
[Signature(
"E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6",
DetourName = nameof(ChangeChannelNameDetour)
)]
private Hook<ChangeChannelNameDelegate>? ChangeChannelNameHook { get; init; }
// Client::UI::Agent::AgentChatLog::???
private delegate byte ShouldDoNameLookupDelegate(AgentChatLog* agent);
[Signature(
"48 89 5C 24 ?? 57 48 83 EC ?? 48 8B D9 40 32 FF 48 8B 49 ?? ?? ?? ?? FF 50",
DetourName = nameof(ShouldDoNameLookupDetour)
)]
private Hook<ShouldDoNameLookupDelegate>? ShouldDoNameLookupHook { get; init; }
// Temporary chat channel change (via hotkey)
// Client::UI::Shell::RaptureShellModule::???
private delegate ulong TempChatChannelDelegate(RaptureShellModule* module, uint x, uint y, ulong z);
[Signature(
"48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 83 B9 ?? ?? ?? ?? ?? 49 8B F9 41 8B F0",
DetourName = nameof(TempChatChannelDetour)
)]
private Hook<TempChatChannelDelegate>? TempChatChannelHook { get; init; }
// Temporary tell target change (via hotkey)
// Client::UI::Shell::RaptureShellModule::SetContextTellTargetInForay
private delegate ulong TempTellTargetDelegate(RaptureShellModule* module, ulong a, ulong b, ulong c, ushort d, ulong e, ulong f, ushort g);
[Signature(
"48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 57 48 83 EC ?? 83 B9 ?? ?? ?? ?? ?? 41 0F B7 F9",
DetourName = nameof(TempTellTargetDetour)
)]
private Hook<TempTellTargetDelegate>? TempTellTargetHook { get; init; }
// Called every frame while the chat bar is not focused
private delegate void UnfocusTickDelegate(RaptureShellModule* module);
[Signature(
"40 53 48 83 EC ?? 83 B9 ?? ?? ?? ?? ?? 48 8B D9 0F 84 ?? ?? ?? ?? 48 8D 91",
DetourName = nameof(UnfocusTickDetour)
)]
private Hook<UnfocusTickDelegate>? UnfocusTickHook { get; init; }
#pragma warning restore CS0649
#endregion
private ChatChannelOverride? _chatChannelOverride;
private ChatChannelOverride? _chatChannelOverrideTempBuffer;
private bool _shouldForceNameLookup = false;
private DateTime _nextMessageIsReply = DateTime.UnixEpoch;
public ChatChannelOverride? ChatChannelOverride
{
get => _chatChannelOverride;
set {
_chatChannelOverride = value;
_shouldForceNameLookup = true;
}
}
private void StashChatChannel()
{
if (_chatChannelOverride != null)
{
_logger.LogTrace("Stashing chat channel");
_chatChannelOverrideTempBuffer = _chatChannelOverride;
ChatChannelOverride = null;
}
}
private void UnstashChatChannel()
{
if (_chatChannelOverrideTempBuffer != null)
{
_logger.LogTrace("Unstashing chat channel");
ChatChannelOverride = _chatChannelOverrideTempBuffer;
_chatChannelOverrideTempBuffer = null;
}
}
public GameChatHooks(ILogger<GameChatHooks> logger, IGameInteropProvider gameInteropProvider, Action<int, byte[]> ssCommandHandler)
{
_logger = logger;
_ssCommandHandler = ssCommandHandler;
logger.LogInformation("Initializing GameChatHooks");
gameInteropProvider.InitializeFromAttributes(this);
SendMessageHook?.Enable();
SetChatChannelHook?.Enable();
ChangeChannelNameHook?.Enable();
ShouldDoNameLookupHook?.Enable();
TempChatChannelHook?.Enable();
TempTellTargetHook?.Enable();
UnfocusTickHook?.Enable();
}
public void Dispose()
{
SendMessageHook?.Dispose();
SetChatChannelHook?.Dispose();
ChangeChannelNameHook?.Dispose();
ShouldDoNameLookupHook?.Dispose();
TempChatChannelHook?.Dispose();
TempTellTargetHook?.Dispose();
UnfocusTickHook?.Dispose();
}
private byte[] ProcessChatMessage(Utf8String* message)
{
var pronounModule = UIModule.Instance()->GetPronounModule();
var chatString1 = pronounModule->ProcessString(message, true);
var chatString2 = _processStringStep2(pronounModule, chatString1, 1);
return MemoryHelper.ReadRaw((nint)chatString2->StringPtr.Value, chatString2->Length);
}
private void SendMessageDetour(ShellCommandModule* thisPtr, Utf8String* message, UIModule* uiModule)
{
try
{
var messageLength = message->Length;
var messageSpan = message->AsSpan();
bool isCommand = false;
bool isReply = false;
var utcNow = DateTime.UtcNow;
// Check if chat input begins with a command (or auto-translated command)
// Or if we think we're being called to send text via the /r command
if (_nextMessageIsReply >= utcNow)
{
isCommand = true;
}
else if (messageLength == 0 || messageSpan[0] == (byte)'/' || !messageSpan.ContainsAnyExcept((byte)' '))
{
isCommand = true;
if (messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes("/r ")) || messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes("/reply ")))
isReply = true;
}
else if (messageSpan[0] == (byte)0x02) /* Payload.START_BYTE */
{
var payload = Payload.Decode(new BinaryReader(new UnmanagedMemoryStream(message->StringPtr, message->BufSize))) as AutoTranslatePayload;
// Auto-translate text begins with /
if (payload != null && payload.Text.Length > 2 && payload.Text[2] == '/')
{
isCommand = true;
if (payload.Text[2..].StartsWith("/r ", StringComparison.Ordinal) || payload.Text[2..].StartsWith("/reply ", StringComparison.Ordinal))
isReply = true;
}
}
// When using /r the game will set a flag and then call this function a second time
// The next call to this function will be raw text intended for the IM recipient
// This flag's validity is time-limited as a fail-safe
if (isReply)
_nextMessageIsReply = utcNow + TimeSpan.FromMilliseconds(100);
// If it is a command, check if it begins with /ss first so we can handle the message directly
// Letting Dalamud handle the commands causes all of the special payloads to be dropped
if (isCommand && messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes("/ss")))
{
for (int i = 1; i <= ChatService.CommandMaxNumber; ++i)
{
var cmdString = $"/ss{i} ";
if (messageSpan.StartsWith(System.Text.Encoding.ASCII.GetBytes(cmdString)))
{
var ssChatBytes = ProcessChatMessage(message);
ssChatBytes = ssChatBytes.Skip(cmdString.Length).ToArray();
_ssCommandHandler?.Invoke(i, ssChatBytes);
return;
}
}
}
// If not a command, or no override is set, then call the original chat handler
if (isCommand || _chatChannelOverride == null)
{
SendMessageHook!.OriginalDisposeSafe(thisPtr, message, uiModule);
return;
}
// Otherwise, the text is to be sent to the emulated chat channel handler
// The chat input string is rendered in to a payload for display first
var chatBytes = ProcessChatMessage(message);
if (chatBytes.Length > 0)
_chatChannelOverride.ChatMessageHandler?.Invoke(chatBytes);
}
catch (Exception e)
{
_logger.LogError(e, "Exception thrown during SendMessageDetour");
}
}
private void SetChatChannelDetour(RaptureShellModule* module, uint channel)
{
try
{
if (_chatChannelOverride != null)
{
_chatChannelOverride = null;
_shouldForceNameLookup = true;
}
}
catch (Exception e)
{
_logger.LogError(e, "Exception thrown during SetChatChannelDetour");
}
SetChatChannelHook!.OriginalDisposeSafe(module, channel);
}
private ulong TempChatChannelDetour(RaptureShellModule* module, uint x, uint y, ulong z)
{
var result = TempChatChannelHook!.OriginalDisposeSafe(module, x, y, z);
if (result != 0)
StashChatChannel();
return result;
}
private ulong TempTellTargetDetour(RaptureShellModule* module, ulong a, ulong b, ulong c, ushort d, ulong e, ulong f, ushort g)
{
var result = TempTellTargetHook!.OriginalDisposeSafe(module, a, b, c, d, e, f, g);
if (result != 0)
StashChatChannel();
return result;
}
private void UnfocusTickDetour(RaptureShellModule* module)
{
UnfocusTickHook!.OriginalDisposeSafe(module);
UnstashChatChannel();
}
private byte* ChangeChannelNameDetour(AgentChatLog* agent)
{
var originalResult = ChangeChannelNameHook!.OriginalDisposeSafe(agent);
try
{
// Replace the chat channel name on the UI if active
if (_chatChannelOverride != null)
{
agent->ChannelLabel.SetString(_chatChannelOverride.ChannelName);
}
}
catch (Exception e)
{
_logger.LogError(e, "Exception thrown during ChangeChannelNameDetour");
}
return originalResult;
}
private byte ShouldDoNameLookupDetour(AgentChatLog* agent)
{
var originalResult = ShouldDoNameLookupHook!.OriginalDisposeSafe(agent);
try
{
// Force the chat channel name to update when required
if (_shouldForceNameLookup)
{
_shouldForceNameLookup = false;
return 1;
}
}
catch (Exception e)
{
_logger.LogError(e, "Exception thrown during ShouldDoNameLookupDetour");
}
return originalResult;
}
}

View File

@@ -0,0 +1,259 @@
using Lumina.Data;
using Lumina.Extensions;
using System.Runtime.InteropServices;
using System.Text;
using static Lumina.Data.Parsing.MdlStructs;
namespace MareSynchronos.Interop.GameModel;
#pragma warning disable S1104 // Fields should not have public accessibility
// This code is completely and shamelessly borrowed from Penumbra to load V5 and V6 model files.
// Original Source: https://github.com/Ottermandias/Penumbra.GameData/blob/main/Files/MdlFile.cs
public class MdlFile
{
public const int V5 = 0x01000005;
public const int V6 = 0x01000006;
public const uint NumVertices = 17;
public const uint FileHeaderSize = 0x44;
// Raw data to write back.
public uint Version = 0x01000005;
public float Radius;
public float ModelClipOutDistance;
public float ShadowClipOutDistance;
public byte BgChangeMaterialIndex;
public byte BgCrestChangeMaterialIndex;
public ushort CullingGridCount;
public byte Flags3;
public byte Unknown6;
public ushort Unknown8;
public ushort Unknown9;
// Offsets are stored relative to RuntimeSize instead of file start.
public uint[] VertexOffset = [0, 0, 0];
public uint[] IndexOffset = [0, 0, 0];
public uint[] VertexBufferSize = [0, 0, 0];
public uint[] IndexBufferSize = [0, 0, 0];
public byte LodCount;
public bool EnableIndexBufferStreaming;
public bool EnableEdgeGeometry;
public ModelFlags1 Flags1;
public ModelFlags2 Flags2;
public VertexDeclarationStruct[] VertexDeclarations = [];
public ElementIdStruct[] ElementIds = [];
public MeshStruct[] Meshes = [];
public BoundingBoxStruct[] BoneBoundingBoxes = [];
public LodStruct[] Lods = [];
public ExtraLodStruct[] ExtraLods = [];
public MdlFile(string filePath)
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
using var r = new LuminaBinaryReader(stream);
var header = LoadModelFileHeader(r);
LodCount = header.LodCount;
VertexBufferSize = header.VertexBufferSize;
IndexBufferSize = header.IndexBufferSize;
VertexOffset = header.VertexOffset;
IndexOffset = header.IndexOffset;
var dataOffset = FileHeaderSize + header.RuntimeSize + header.StackSize;
for (var i = 0; i < LodCount; ++i)
{
VertexOffset[i] -= dataOffset;
IndexOffset[i] -= dataOffset;
}
VertexDeclarations = new VertexDeclarationStruct[header.VertexDeclarationCount];
for (var i = 0; i < header.VertexDeclarationCount; ++i)
VertexDeclarations[i] = VertexDeclarationStruct.Read(r);
_ = LoadStrings(r);
var modelHeader = LoadModelHeader(r);
ElementIds = new ElementIdStruct[modelHeader.ElementIdCount];
for (var i = 0; i < modelHeader.ElementIdCount; i++)
ElementIds[i] = ElementIdStruct.Read(r);
Lods = new LodStruct[3];
for (var i = 0; i < 3; i++)
{
var lod = r.ReadStructure<LodStruct>();
if (i < LodCount)
{
lod.VertexDataOffset -= dataOffset;
lod.IndexDataOffset -= dataOffset;
}
Lods[i] = lod;
}
ExtraLods = modelHeader.Flags2.HasFlag(ModelFlags2.ExtraLodEnabled)
? r.ReadStructuresAsArray<ExtraLodStruct>(3)
: [];
Meshes = new MeshStruct[modelHeader.MeshCount];
for (var i = 0; i < modelHeader.MeshCount; i++)
Meshes[i] = MeshStruct.Read(r);
}
private ModelFileHeader LoadModelFileHeader(LuminaBinaryReader r)
{
var header = ModelFileHeader.Read(r);
Version = header.Version;
EnableIndexBufferStreaming = header.EnableIndexBufferStreaming;
EnableEdgeGeometry = header.EnableEdgeGeometry;
return header;
}
private ModelHeader LoadModelHeader(BinaryReader r)
{
var modelHeader = r.ReadStructure<ModelHeader>();
Radius = modelHeader.Radius;
Flags1 = modelHeader.Flags1;
Flags2 = modelHeader.Flags2;
ModelClipOutDistance = modelHeader.ModelClipOutDistance;
ShadowClipOutDistance = modelHeader.ShadowClipOutDistance;
CullingGridCount = modelHeader.CullingGridCount;
Flags3 = modelHeader.Flags3;
Unknown6 = modelHeader.Unknown6;
Unknown8 = modelHeader.Unknown8;
Unknown9 = modelHeader.Unknown9;
BgChangeMaterialIndex = modelHeader.BGChangeMaterialIndex;
BgCrestChangeMaterialIndex = modelHeader.BGCrestChangeMaterialIndex;
return modelHeader;
}
private static (uint[], string[]) LoadStrings(BinaryReader r)
{
var stringCount = r.ReadUInt16();
r.ReadUInt16();
var stringSize = (int)r.ReadUInt32();
var stringData = r.ReadBytes(stringSize);
var start = 0;
var strings = new string[stringCount];
var offsets = new uint[stringCount];
for (var i = 0; i < stringCount; ++i)
{
var span = stringData.AsSpan(start);
var idx = span.IndexOf((byte)'\0');
strings[i] = Encoding.UTF8.GetString(span[..idx]);
offsets[i] = (uint)start;
start = start + idx + 1;
}
return (offsets, strings);
}
[StructLayout(LayoutKind.Sequential)]
public unsafe struct ModelHeader
{
// MeshHeader
public float Radius;
public ushort MeshCount;
public ushort AttributeCount;
public ushort SubmeshCount;
public ushort MaterialCount;
public ushort BoneCount;
public ushort BoneTableCount;
public ushort ShapeCount;
public ushort ShapeMeshCount;
public ushort ShapeValueCount;
public byte LodCount;
public ModelFlags1 Flags1;
public ushort ElementIdCount;
public byte TerrainShadowMeshCount;
public ModelFlags2 Flags2;
public float ModelClipOutDistance;
public float ShadowClipOutDistance;
public ushort CullingGridCount;
public ushort TerrainShadowSubmeshCount;
public byte Flags3;
public byte BGChangeMaterialIndex;
public byte BGCrestChangeMaterialIndex;
public byte Unknown6;
public ushort BoneTableArrayCountTotal;
public ushort Unknown8;
public ushort Unknown9;
private fixed byte _padding[6];
}
public struct ShapeStruct
{
public uint StringOffset;
public ushort[] ShapeMeshStartIndex;
public ushort[] ShapeMeshCount;
public static ShapeStruct Read(LuminaBinaryReader br)
{
ShapeStruct ret = new ShapeStruct();
ret.StringOffset = br.ReadUInt32();
ret.ShapeMeshStartIndex = br.ReadUInt16Array(3);
ret.ShapeMeshCount = br.ReadUInt16Array(3);
return ret;
}
}
[Flags]
public enum ModelFlags1 : byte
{
DustOcclusionEnabled = 0x80,
SnowOcclusionEnabled = 0x40,
RainOcclusionEnabled = 0x20,
Unknown1 = 0x10,
LightingReflectionEnabled = 0x08,
WavingAnimationDisabled = 0x04,
LightShadowDisabled = 0x02,
ShadowDisabled = 0x01,
}
[Flags]
public enum ModelFlags2 : byte
{
Unknown2 = 0x80,
BgUvScrollEnabled = 0x40,
EnableForceNonResident = 0x20,
ExtraLodEnabled = 0x10,
ShadowMaskEnabled = 0x08,
ForceLodRangeEnabled = 0x04,
EdgeGeometryEnabled = 0x02,
Unknown3 = 0x01
}
public struct VertexDeclarationStruct
{
// There are always 17, but stop when stream = -1
public VertexElement[] VertexElements;
public static VertexDeclarationStruct Read(LuminaBinaryReader br)
{
VertexDeclarationStruct ret = new VertexDeclarationStruct();
var elems = new List<VertexElement>();
// Read the vertex elements that we need
var thisElem = br.ReadStructure<VertexElement>();
do
{
elems.Add(thisElem);
thisElem = br.ReadStructure<VertexElement>();
} while (thisElem.Stream != 255);
// Skip the number of bytes that we don't need to read
// We skip elems.Count * 9 because we had to read the invalid element
int toSeek = 17 * 8 - (elems.Count + 1) * 8;
br.Seek(br.BaseStream.Position + toSeek);
ret.VertexElements = elems.ToArray();
return ret;
}
}
}
#pragma warning restore S1104 // Fields should not have public accessibility

View File

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

View File

@@ -0,0 +1,145 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.API.Dto.CharaData;
using MareSynchronos.Services;
using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Text.Json.Nodes;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerBrio : IIpcCaller
{
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;
public bool APIAvailable { get; private set; }
public IpcCallerBrio(ILogger<IpcCallerBrio> logger, IDalamudPluginInterface dalamudPluginInterface,
DalamudUtilService dalamudUtilService)
{
_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");
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);
}
public async Task<bool> DespawnActorAsync(nint address)
{
if (!APIAvailable) return false;
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);
}
public async Task<bool> ApplyTransformAsync(nint address, WorldData data)
{
if (!APIAvailable) return false;
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return false;
_logger.LogDebug("Applying Transform to Actor {actor}", gameObject.Name.TextValue);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetModelTransform.InvokeFunc(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);
}
public async Task<WorldData> GetTransformAsync(nint address)
{
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);
if (data.Item1 == null || data.Item2 == null || data.Item3 == null) return default;
return new WorldData()
{
PositionX = data.Item1.Value.X,
PositionY = data.Item1.Value.Y,
PositionZ = data.Item1.Value.Z,
RotationX = data.Item2.Value.X,
RotationY = data.Item2.Value.Y,
RotationZ = data.Item2.Value.Z,
RotationW = data.Item2.Value.W,
ScaleX = data.Item3.Value.X,
ScaleY = data.Item3.Value.Y,
ScaleZ = data.Item3.Value.Z
};
}
public async Task<string?> GetPoseAsync(nint address)
{
if (!APIAvailable) return null;
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return null;
_logger.LogDebug("Getting Pose from Actor {actor}", gameObject.Name.TextValue);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false);
}
public async Task<bool> SetPoseAsync(nint address, string pose)
{
if (!APIAvailable) return false;
var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false);
if (gameObject == null) return false;
_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);
applicablePose["ModelDifference"] = JsonNode.Parse(JsonNode.Parse(currentPose)!["ModelDifference"]!.ToJsonString());
await _dalamudUtilService.RunOnFrameworkThread(() =>
{
_brioFreezeActor.InvokeFunc(gameObject);
_brioFreezePhysics.InvokeFunc();
}).ConfigureAwait(false);
return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetPoseFromJson.InvokeFunc(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false);
}
public void Dispose()
{
}
}

View File

@@ -0,0 +1,139 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Utility;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Text;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerCustomize : IIpcCaller
{
private readonly ICallGateSubscriber<(int, int)> _customizePlusApiVersion;
private readonly ICallGateSubscriber<ushort, (int, Guid?)> _customizePlusGetActiveProfile;
private readonly ICallGateSubscriber<Guid, (int, string?)> _customizePlusGetProfileById;
private readonly ICallGateSubscriber<ushort, Guid, object> _customizePlusOnScaleUpdate;
private readonly ICallGateSubscriber<ushort, int> _customizePlusRevertCharacter;
private readonly ICallGateSubscriber<ushort, string, (int, Guid?)> _customizePlusSetBodyScaleToCharacter;
private readonly ICallGateSubscriber<Guid, int> _customizePlusDeleteByUniqueId;
private readonly ILogger<IpcCallerCustomize> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly MareMediator _mareMediator;
public IpcCallerCustomize(ILogger<IpcCallerCustomize> logger, IDalamudPluginInterface dalamudPluginInterface,
DalamudUtilService dalamudUtil, MareMediator mareMediator)
{
_customizePlusApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("CustomizePlus.General.GetApiVersion");
_customizePlusGetActiveProfile = dalamudPluginInterface.GetIpcSubscriber<ushort, (int, Guid?)>("CustomizePlus.Profile.GetActiveProfileIdOnCharacter");
_customizePlusGetProfileById = dalamudPluginInterface.GetIpcSubscriber<Guid, (int, string?)>("CustomizePlus.Profile.GetByUniqueId");
_customizePlusRevertCharacter = dalamudPluginInterface.GetIpcSubscriber<ushort, int>("CustomizePlus.Profile.DeleteTemporaryProfileOnCharacter");
_customizePlusSetBodyScaleToCharacter = dalamudPluginInterface.GetIpcSubscriber<ushort, string, (int, Guid?)>("CustomizePlus.Profile.SetTemporaryProfileOnCharacter");
_customizePlusOnScaleUpdate = dalamudPluginInterface.GetIpcSubscriber<ushort, Guid, object>("CustomizePlus.Profile.OnUpdate");
_customizePlusDeleteByUniqueId = dalamudPluginInterface.GetIpcSubscriber<Guid, int>("CustomizePlus.Profile.DeleteTemporaryProfileByUniqueId");
_customizePlusOnScaleUpdate.Subscribe(OnCustomizePlusScaleChange);
_logger = logger;
_dalamudUtil = dalamudUtil;
_mareMediator = mareMediator;
CheckAPI();
}
public bool APIAvailable { get; private set; } = false;
public async Task RevertAsync(nint character)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is ICharacter c)
{
_logger.LogTrace("CustomizePlus reverting for {chara}", c.Address.ToString("X"));
_customizePlusRevertCharacter!.InvokeFunc(c.ObjectIndex);
}
}).ConfigureAwait(false);
}
public async Task<Guid?> SetBodyScaleAsync(nint character, string scale)
{
if (!APIAvailable) return null;
return await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is ICharacter c)
{
string decodedScale = Encoding.UTF8.GetString(Convert.FromBase64String(scale));
_logger.LogTrace("CustomizePlus applying for {chara}", c.Address.ToString("X"));
if (scale.IsNullOrEmpty())
{
_customizePlusRevertCharacter!.InvokeFunc(c.ObjectIndex);
return null;
}
else
{
var result = _customizePlusSetBodyScaleToCharacter!.InvokeFunc(c.ObjectIndex, decodedScale);
return result.Item2;
}
}
return null;
}).ConfigureAwait(false);
}
public async Task RevertByIdAsync(Guid? profileId)
{
if (!APIAvailable || profileId == null) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
_ = _customizePlusDeleteByUniqueId.InvokeFunc(profileId.Value);
}).ConfigureAwait(false);
}
public async Task<string?> GetScaleAsync(nint character)
{
if (!APIAvailable) return null;
var scale = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is ICharacter c)
{
var res = _customizePlusGetActiveProfile.InvokeFunc(c.ObjectIndex);
_logger.LogTrace("CustomizePlus GetActiveProfile returned {err}", res.Item1);
if (res.Item1 != 0 || res.Item2 == null) return string.Empty;
return _customizePlusGetProfileById.InvokeFunc(res.Item2.Value).Item2;
}
return string.Empty;
}).ConfigureAwait(false);
if (string.IsNullOrEmpty(scale)) return string.Empty;
return Convert.ToBase64String(Encoding.UTF8.GetBytes(scale));
}
public void CheckAPI()
{
try
{
var version = _customizePlusApiVersion.InvokeFunc();
APIAvailable = (version.Item1 == 6 && version.Item2 >= 0);
}
catch
{
APIAvailable = false;
}
}
private void OnCustomizePlusScaleChange(ushort c, Guid g)
{
var obj = _dalamudUtil.GetCharacterFromObjectTableByIndex(c);
_mareMediator.Publish(new CustomizePlusMessage(obj?.Address ?? null));
}
public void Dispose()
{
_customizePlusOnScaleUpdate.Unsubscribe(OnCustomizePlusScaleChange);
}
}

View File

@@ -0,0 +1,253 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Glamourer.Api.Helpers;
using Glamourer.Api.IpcSubscribers;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcCaller
{
private readonly ILogger<IpcCallerGlamourer> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly MareMediator _mareMediator;
private readonly RedrawManager _redrawManager;
private readonly ApiVersion _glamourerApiVersions;
private readonly ApplyState? _glamourerApplyAll;
private readonly GetStateBase64? _glamourerGetAllCustomization;
private readonly RevertState _glamourerRevert;
private readonly RevertStateName _glamourerRevertByName;
private readonly UnlockState _glamourerUnlock;
private readonly UnlockStateName _glamourerUnlockByName;
private readonly EventSubscriber<nint>? _glamourerStateChanged;
private bool _pluginLoaded;
private Version _pluginVersion;
private bool _shownGlamourerUnavailable = false;
private readonly uint LockCode = 0x626E7579;
public IpcCallerGlamourer(ILogger<IpcCallerGlamourer> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, MareMediator mareMediator,
RedrawManager redrawManager) : base(logger, mareMediator)
{
_glamourerApiVersions = new ApiVersion(pi);
_glamourerGetAllCustomization = new GetStateBase64(pi);
_glamourerApplyAll = new ApplyState(pi);
_glamourerRevert = new RevertState(pi);
_glamourerRevertByName = new RevertStateName(pi);
_glamourerUnlock = new UnlockState(pi);
_glamourerUnlockByName = new UnlockStateName(pi);
_logger = logger;
_dalamudUtil = dalamudUtil;
_mareMediator = mareMediator;
_redrawManager = redrawManager;
var plugin = PluginWatcherService.GetInitialPluginState(pi, "Glamourer");
_pluginLoaded = plugin?.IsLoaded ?? false;
_pluginVersion = plugin?.Version ?? new(0, 0, 0, 0);
Mediator.SubscribeKeyed<PluginChangeMessage>(this, "Glamourer", (msg) =>
{
_pluginLoaded = msg.IsLoaded;
_pluginVersion = msg.Version;
CheckAPI();
});
CheckAPI();
_glamourerStateChanged = StateChanged.Subscriber(pi, GlamourerChanged);
_glamourerStateChanged.Enable();
Mediator.Subscribe<DalamudLoginMessage>(this, s => _shownGlamourerUnavailable = false);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_redrawManager.Cancel();
_glamourerStateChanged?.Dispose();
}
public bool APIAvailable { get; private set; }
public void CheckAPI()
{
bool apiAvailable = false;
try
{
bool versionValid = _pluginLoaded && _pluginVersion >= new Version(1, 0, 6, 1);
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;
_mareMediator.Publish(new NotificationMessage("Glamourer inactive", "Your Glamourer installation is not active or out of date. Update Glamourer to continue to use Umbra. 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 allowImmediate = false)
{
if (!APIAvailable || string.IsNullOrEmpty(customization) || _dalamudUtil.IsZoning) return;
// Call immediately if possible
if (allowImmediate && _dalamudUtil.IsOnFrameworkThread && !await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
{
var gameObj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false);
if (gameObj is ICharacter chara)
{
logger.LogDebug("[{appid}] Calling on IPC: GlamourerApplyAll", applicationId);
_glamourerApplyAll!.Invoke(customization, chara.ObjectIndex, LockCode);
return;
}
}
await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false);
try
{
await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, (chara) =>
{
try
{
logger.LogDebug("[{appid}] Calling on IPC: GlamourerApplyAll", applicationId);
_glamourerApplyAll!.Invoke(customization, chara.ObjectIndex, LockCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[{appid}] Failed to apply Glamourer data", applicationId);
}
}, token).ConfigureAwait(false);
}
finally
{
_redrawManager.RedrawSemaphore.Release();
}
}
public async Task<string> GetCharacterCustomizationAsync(IntPtr character)
{
if (!APIAvailable) return string.Empty;
try
{
return await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is ICharacter c)
{
return _glamourerGetAllCustomization!.Invoke(c.ObjectIndex).Item2 ?? string.Empty;
}
return string.Empty;
}).ConfigureAwait(false);
}
catch
{
return string.Empty;
}
}
public async Task RevertAsync(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) =>
{
try
{
logger.LogDebug("[{appid}] Calling On IPC: GlamourerUnlock", applicationId);
_glamourerUnlock.Invoke(chara.ObjectIndex, LockCode);
logger.LogDebug("[{appid}] Calling On IPC: GlamourerRevert", applicationId);
_glamourerRevert.Invoke(chara.ObjectIndex, LockCode);
logger.LogDebug("[{appid}] Calling On IPC: PenumbraRedraw", applicationId);
_mareMediator.Publish(new PenumbraRedrawCharacterMessage(chara));
}
catch (Exception ex)
{
logger.LogWarning(ex, "[{appid}] Error during GlamourerRevert", applicationId);
}
}, token).ConfigureAwait(false);
}
finally
{
_redrawManager.RedrawSemaphore.Release();
}
}
public void RevertNow(ILogger logger, Guid applicationId, int objectIndex)
{
if ((!APIAvailable) || _dalamudUtil.IsZoning) return;
logger.LogTrace("[{applicationId}] Immediately reverting object index {objId}", applicationId, objectIndex);
_glamourerRevert.Invoke(objectIndex, LockCode);
}
public void RevertByNameNow(ILogger logger, Guid applicationId, string name)
{
if ((!APIAvailable) || _dalamudUtil.IsZoning) return;
logger.LogTrace("[{applicationId}] Immediately reverting {name}", applicationId, name);
_glamourerRevertByName.Invoke(name, LockCode);
}
public async Task RevertByNameAsync(ILogger logger, string name, Guid applicationId)
{
if ((!APIAvailable) || _dalamudUtil.IsZoning) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
RevertByName(logger, name, applicationId);
}).ConfigureAwait(false);
}
public void RevertByName(ILogger logger, string name, Guid applicationId)
{
if ((!APIAvailable) || _dalamudUtil.IsZoning) return;
try
{
logger.LogDebug("[{appid}] Calling On IPC: GlamourerRevertByName", applicationId);
_glamourerRevertByName.Invoke(name, LockCode);
logger.LogDebug("[{appid}] Calling On IPC: GlamourerUnlockName", applicationId);
_glamourerUnlockByName.Invoke(name, LockCode);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during Glamourer RevertByName");
}
}
private void GlamourerChanged(nint address)
{
_mareMediator.Publish(new GlamourerChangedMessage(address));
}
}

View File

@@ -0,0 +1,93 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerHeels : IIpcCaller
{
private readonly ILogger<IpcCallerHeels> _logger;
private readonly MareMediator _mareMediator;
private readonly DalamudUtilService _dalamudUtil;
private readonly ICallGateSubscriber<(int, int)> _heelsGetApiVersion;
private readonly ICallGateSubscriber<string> _heelsGetOffset;
private readonly ICallGateSubscriber<string, object?> _heelsOffsetUpdate;
private readonly ICallGateSubscriber<int, string, object?> _heelsRegisterPlayer;
private readonly ICallGateSubscriber<int, object?> _heelsUnregisterPlayer;
public IpcCallerHeels(ILogger<IpcCallerHeels> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, MareMediator mareMediator)
{
_logger = logger;
_mareMediator = mareMediator;
_dalamudUtil = dalamudUtil;
_heelsGetApiVersion = pi.GetIpcSubscriber<(int, int)>("SimpleHeels.ApiVersion");
_heelsGetOffset = pi.GetIpcSubscriber<string>("SimpleHeels.GetLocalPlayer");
_heelsRegisterPlayer = pi.GetIpcSubscriber<int, string, object?>("SimpleHeels.RegisterPlayer");
_heelsUnregisterPlayer = pi.GetIpcSubscriber<int, object?>("SimpleHeels.UnregisterPlayer");
_heelsOffsetUpdate = pi.GetIpcSubscriber<string, object?>("SimpleHeels.LocalChanged");
_heelsOffsetUpdate.Subscribe(HeelsOffsetChange);
CheckAPI();
}
public bool APIAvailable { get; private set; } = false;
private void HeelsOffsetChange(string offset)
{
_mareMediator.Publish(new HeelsOffsetMessage());
}
public async Task<string> GetOffsetAsync()
{
if (!APIAvailable) return string.Empty;
return await _dalamudUtil.RunOnFrameworkThread(_heelsGetOffset.InvokeFunc).ConfigureAwait(false);
}
public async Task RestoreOffsetForPlayerAsync(IntPtr character)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj != null)
{
_logger.LogTrace("Restoring Heels data to {chara}", character.ToString("X"));
_heelsUnregisterPlayer.InvokeAction(gameObj.ObjectIndex);
}
}).ConfigureAwait(false);
}
public async Task SetOffsetForPlayerAsync(IntPtr character, string data)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj != null)
{
_logger.LogTrace("Applying Heels data to {chara}", character.ToString("X"));
_heelsRegisterPlayer.InvokeAction(gameObj.ObjectIndex, data);
}
}).ConfigureAwait(false);
}
public void CheckAPI()
{
try
{
APIAvailable = _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 0 };
}
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_heelsOffsetUpdate.Unsubscribe(HeelsOffsetChange);
}
}

View File

@@ -0,0 +1,135 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Text;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerHonorific : IIpcCaller
{
private readonly ICallGateSubscriber<(uint major, uint minor)> _honorificApiVersion;
private readonly ICallGateSubscriber<int, object> _honorificClearCharacterTitle;
private readonly ICallGateSubscriber<object> _honorificDisposing;
private readonly ICallGateSubscriber<string> _honorificGetLocalCharacterTitle;
private readonly ICallGateSubscriber<string, object> _honorificLocalCharacterTitleChanged;
private readonly ICallGateSubscriber<object> _honorificReady;
private readonly ICallGateSubscriber<int, string, object> _honorificSetCharacterTitle;
private readonly ILogger<IpcCallerHonorific> _logger;
private readonly MareMediator _mareMediator;
private readonly DalamudUtilService _dalamudUtil;
public IpcCallerHonorific(ILogger<IpcCallerHonorific> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
MareMediator mareMediator)
{
_logger = logger;
_mareMediator = mareMediator;
_dalamudUtil = dalamudUtil;
_honorificApiVersion = pi.GetIpcSubscriber<(uint, uint)>("Honorific.ApiVersion");
_honorificGetLocalCharacterTitle = pi.GetIpcSubscriber<string>("Honorific.GetLocalCharacterTitle");
_honorificClearCharacterTitle = pi.GetIpcSubscriber<int, object>("Honorific.ClearCharacterTitle");
_honorificSetCharacterTitle = pi.GetIpcSubscriber<int, string, object>("Honorific.SetCharacterTitle");
_honorificLocalCharacterTitleChanged = pi.GetIpcSubscriber<string, object>("Honorific.LocalCharacterTitleChanged");
_honorificDisposing = pi.GetIpcSubscriber<object>("Honorific.Disposing");
_honorificReady = pi.GetIpcSubscriber<object>("Honorific.Ready");
_honorificLocalCharacterTitleChanged.Subscribe(OnHonorificLocalCharacterTitleChanged);
_honorificDisposing.Subscribe(OnHonorificDisposing);
_honorificReady.Subscribe(OnHonorificReady);
CheckAPI();
}
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
{
try
{
APIAvailable = _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 0 };
}
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_honorificLocalCharacterTitleChanged.Unsubscribe(OnHonorificLocalCharacterTitleChanged);
_honorificDisposing.Unsubscribe(OnHonorificDisposing);
_honorificReady.Unsubscribe(OnHonorificReady);
}
public async Task ClearTitleAsync(nint character)
{
if (!APIAvailable) return;
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is IPlayerCharacter c)
{
_logger.LogTrace("Honorific removing for {addr}", c.Address.ToString("X"));
_honorificClearCharacterTitle!.InvokeAction(c.ObjectIndex);
}
}).ConfigureAwait(false);
}
public async Task<string> GetTitle()
{
if (!APIAvailable) return string.Empty;
return await _dalamudUtil.RunOnFrameworkThread(() =>
{
string title = _honorificGetLocalCharacterTitle.InvokeFunc();
return string.IsNullOrEmpty(title) ? string.Empty : Convert.ToBase64String(Encoding.UTF8.GetBytes(title));
}).ConfigureAwait(false);
}
public async Task SetTitleAsync(IntPtr character, string honorificDataB64)
{
if (!APIAvailable) return;
_logger.LogTrace("Applying Honorific data to {chara}", character.ToString("X"));
try
{
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is IPlayerCharacter pc)
{
string honorificData = string.IsNullOrEmpty(honorificDataB64) ? string.Empty : Encoding.UTF8.GetString(Convert.FromBase64String(honorificDataB64));
if (string.IsNullOrEmpty(honorificData))
{
_honorificClearCharacterTitle!.InvokeAction(pc.ObjectIndex);
}
else
{
_honorificSetCharacterTitle!.InvokeAction(pc.ObjectIndex, honorificData);
}
}
}).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not apply Honorific data");
}
}
private void OnHonorificDisposing()
{
_mareMediator.Publish(new HonorificMessage(string.Empty));
}
private void OnHonorificLocalCharacterTitleChanged(string titleJson)
{
string titleData = string.IsNullOrEmpty(titleJson) ? string.Empty : Convert.ToBase64String(Encoding.UTF8.GetBytes(titleJson));
_mareMediator.Publish(new HonorificMessage(titleData));
}
private void OnHonorificReady()
{
CheckAPI();
_mareMediator.Publish(new HonorificReadyMessage());
}
}

View File

@@ -0,0 +1,44 @@
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerMare : DisposableMediatorSubscriberBase
{
private readonly ICallGateSubscriber<List<nint>> _mareHandledGameAddresses;
private readonly List<nint> _emptyList = [];
private bool _pluginLoaded;
public IpcCallerMare(ILogger<IpcCallerMare> logger, IDalamudPluginInterface pi, MareMediator mediator) : base(logger, mediator)
{
_mareHandledGameAddresses = pi.GetIpcSubscriber<List<nint>>("MareSynchronos.GetHandledAddresses");
_pluginLoaded = PluginWatcherService.GetInitialPluginState(pi, "MareSynchronos")?.IsLoaded ?? false;
Mediator.SubscribeKeyed<PluginChangeMessage>(this, "MareSynchronos", (msg) =>
{
_pluginLoaded = msg.IsLoaded;
});
}
public bool APIAvailable { get; private set; } = false;
// Must be called on framework thread
public IReadOnlyList<nint> GetHandledGameAddresses()
{
if (!_pluginLoaded) return _emptyList;
try
{
return _mareHandledGameAddresses.InvokeFunc();
}
catch
{
return _emptyList;
}
}
}

View File

@@ -0,0 +1,104 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerMoodles : IIpcCaller
{
private readonly ICallGateSubscriber<int> _moodlesApiVersion;
private readonly ICallGateSubscriber<IPlayerCharacter, object> _moodlesOnChange;
private readonly ICallGateSubscriber<nint, string> _moodlesGetStatus;
private readonly ICallGateSubscriber<nint, string, object> _moodlesSetStatus;
private readonly ICallGateSubscriber<nint, object> _moodlesRevertStatus;
private readonly ILogger<IpcCallerMoodles> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly MareMediator _mareMediator;
public IpcCallerMoodles(ILogger<IpcCallerMoodles> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
MareMediator mareMediator)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
_mareMediator = mareMediator;
_moodlesApiVersion = pi.GetIpcSubscriber<int>("Moodles.Version");
_moodlesOnChange = pi.GetIpcSubscriber<IPlayerCharacter, 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");
_moodlesOnChange.Subscribe(OnMoodlesChange);
CheckAPI();
}
private void OnMoodlesChange(IPlayerCharacter character)
{
_mareMediator.Publish(new MoodlesMessage(character.Address));
}
public bool APIAvailable { get; private set; } = false;
public void CheckAPI()
{
try
{
APIAvailable = _moodlesApiVersion.InvokeFunc() == 3;
}
catch
{
APIAvailable = false;
}
}
public void Dispose()
{
_moodlesOnChange.Unsubscribe(OnMoodlesChange);
}
public async Task<string?> GetStatusAsync(nint address)
{
if (!APIAvailable) return null;
try
{
return await _dalamudUtil.RunOnFrameworkThread(() => _moodlesGetStatus.InvokeFunc(address)).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not Get Moodles Status");
return null;
}
}
public async Task SetStatusAsync(nint pointer, string status)
{
if (!APIAvailable) return;
try
{
await _dalamudUtil.RunOnFrameworkThread(() => _moodlesSetStatus.InvokeAction(pointer, status)).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not Set Moodles Status");
}
}
public async Task RevertStatusAsync(nint pointer)
{
if (!APIAvailable) return;
try
{
await _dalamudUtil.RunOnFrameworkThread(() => _moodlesRevertStatus.InvokeAction(pointer)).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not Set Moodles Status");
}
}
}

View File

@@ -0,0 +1,366 @@
using Dalamud.Plugin;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
using System.Collections.Concurrent;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCaller
{
private readonly DalamudUtilService _dalamudUtil;
private readonly MareMediator _mareMediator;
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;
_mareMediator.Publish(new PenumbraDirectoryChangedMessage(_penumbraModDirectory));
}
}
}
private readonly ConcurrentDictionary<IntPtr, bool> _penumbraRedrawRequests = new();
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 bool _pluginLoaded;
private Version _pluginVersion;
public IpcCallerPenumbra(ILogger<IpcCallerPenumbra> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
MareMediator mareMediator, RedrawManager redrawManager) : base(logger, mareMediator)
{
_dalamudUtil = dalamudUtil;
_mareMediator = mareMediator;
_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)
_mareMediator.Publish(new PenumbraModSettingChangedMessage());
});
_penumbraConvertTextureFile = new ConvertTextureFile(pi);
_penumbraResourcePaths = new GetGameObjectResourcePaths(pi);
_penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded);
var plugin = PluginWatcherService.GetInitialPluginState(pi, "Penumbra");
_pluginLoaded = plugin?.IsLoaded ?? false;
_pluginVersion = plugin?.Version ?? new(0, 0, 0, 0);
Mediator.SubscribeKeyed<PluginChangeMessage>(this, "Penumbra", (msg) =>
{
_pluginLoaded = msg.IsLoaded;
_pluginVersion = msg.Version;
CheckAPI();
});
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()
{
bool penumbraAvailable = false;
try
{
penumbraAvailable = _pluginLoaded && _pluginVersion >= new Version(1, 5, 1, 0);
try
{
penumbraAvailable &= _penumbraEnabled.Invoke();
}
catch
{
penumbraAvailable = false;
}
_shownPenumbraUnavailable = _shownPenumbraUnavailable && !penumbraAvailable;
APIAvailable = penumbraAvailable;
}
catch
{
APIAvailable = penumbraAvailable;
}
finally
{
if (!penumbraAvailable && !_shownPenumbraUnavailable)
{
_shownPenumbraUnavailable = true;
_mareMediator.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 Umbra. If you just updated Penumbra, ignore this message.",
NotificationType.Error));
}
}
}
public void CheckModDirectory()
{
if (!APIAvailable)
{
ModDirectory = string.Empty;
}
else
{
ModDirectory = _penumbraResolveModDir!.Invoke().ToLowerInvariant();
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_redrawManager.Cancel();
_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;
_mareMediator.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);
}
}
}
}
_mareMediator.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(() =>
{
Guid collId;
var collName = "ElfSync_" + uid;
PenumbraApiEc penEC = _penumbraCreateNamedTemporaryCollection.Invoke(uid, collName, out collId);
logger.LogTrace("Creating Temp Collection {collName}, GUID: {collId}", collName, collId);
if (penEC != PenumbraApiEc.Success)
{
logger.LogError("Failed to create temporary collection for {collName} with error code {penEC}. Please include this line in any error reports", collName, penEC);
return Guid.Empty;
}
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 void RedrawNow(ILogger logger, Guid applicationId, int objectIndex)
{
if (!APIAvailable || _dalamudUtil.IsZoning) return;
logger.LogTrace("[{applicationId}] Immediately redrawing object index {objId}", applicationId, objectIndex);
_penumbraRedraw.Invoke(objectIndex);
}
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("MareChara_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("MareChara_Files", collId, 0);
logger.LogTrace("[{applicationId}] Removing temp files mod for {collId}, Success: {ret}", applicationId, collId, retRemove);
var retAdd = _penumbraAddTemporaryMod.Invoke("MareChara_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
{
_mareMediator.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)
{
_mareMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2));
}
}
private void PenumbraDispose()
{
_redrawManager.Cancel();
_mareMediator.Publish(new PenumbraDisposedMessage());
}
private void PenumbraInit()
{
APIAvailable = true;
ModDirectory = _penumbraResolveModDir.Invoke();
_mareMediator.Publish(new PenumbraInitializedMessage());
_penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw);
}
}

View File

@@ -0,0 +1,158 @@
using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public sealed class IpcCallerPetNames : IIpcCaller
{
private readonly ILogger<IpcCallerPetNames> _logger;
private readonly DalamudUtilService _dalamudUtil;
private readonly MareMediator _mareMediator;
private readonly ICallGateSubscriber<object> _petnamesReady;
private readonly ICallGateSubscriber<object> _petnamesDisposing;
private readonly ICallGateSubscriber<(uint, uint)> _apiVersion;
private readonly ICallGateSubscriber<bool> _enabled;
private readonly ICallGateSubscriber<string, object> _playerDataChanged;
private readonly ICallGateSubscriber<string> _getPlayerData;
private readonly ICallGateSubscriber<string, object> _setPlayerData;
private readonly ICallGateSubscriber<ushort, object> _clearPlayerData;
public IpcCallerPetNames(ILogger<IpcCallerPetNames> logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil,
MareMediator mareMediator)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
_mareMediator = mareMediator;
_petnamesReady = pi.GetIpcSubscriber<object>("PetRenamer.OnReady");
_petnamesDisposing = pi.GetIpcSubscriber<object>("PetRenamer.OnDisposing");
_apiVersion = pi.GetIpcSubscriber<(uint, uint)>("PetRenamer.ApiVersion");
_enabled = pi.GetIpcSubscriber<bool>("PetRenamer.IsEnabled");
_playerDataChanged = pi.GetIpcSubscriber<string, object>("PetRenamer.OnPlayerDataChanged");
_getPlayerData = pi.GetIpcSubscriber<string>("PetRenamer.GetPlayerData");
_setPlayerData = pi.GetIpcSubscriber<string, object>("PetRenamer.SetPlayerData");
_clearPlayerData = pi.GetIpcSubscriber<ushort, object>("PetRenamer.ClearPlayerData");
_petnamesReady.Subscribe(OnPetNicknamesReady);
_petnamesDisposing.Subscribe(OnPetNicknamesDispose);
_playerDataChanged.Subscribe(OnLocalPetNicknamesDataChange);
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();
_mareMediator.Publish(new PetNamesReadyMessage());
}
private void OnPetNicknamesDispose()
{
_mareMediator.Publish(new PetNamesMessage(string.Empty));
}
public string GetLocalNames()
{
if (!APIAvailable) return string.Empty;
try
{
string localNameData = _getPlayerData.InvokeFunc();
return string.IsNullOrEmpty(localNameData) ? string.Empty : localNameData;
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not obtain Pet Nicknames data");
}
return string.Empty;
}
public async Task SetPlayerData(nint character, string playerData)
{
if (!APIAvailable) return;
_logger.LogTrace("Applying Pet Nicknames data to {chara}", character.ToString("X"));
try
{
await _dalamudUtil.RunOnFrameworkThread(() =>
{
if (string.IsNullOrEmpty(playerData))
{
var gameObj = _dalamudUtil.CreateGameObject(character);
if (gameObj is IPlayerCharacter pc)
{
_clearPlayerData.InvokeAction(pc.ObjectIndex);
}
}
else
{
_setPlayerData.InvokeAction(playerData);
}
}).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not apply Pet Nicknames data");
}
}
public async Task ClearPlayerData(nint characterPointer)
{
if (!APIAvailable) return;
try
{
await _dalamudUtil.RunOnFrameworkThread(() =>
{
var gameObj = _dalamudUtil.CreateGameObject(characterPointer);
if (gameObj is IPlayerCharacter pc)
{
_logger.LogTrace("Pet Nicknames removing for {addr}", pc.Address.ToString("X"));
_clearPlayerData.InvokeAction(pc.ObjectIndex);
}
}).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not clear Pet Nicknames data");
}
}
private void OnLocalPetNicknamesDataChange(string data)
{
_mareMediator.Publish(new PetNamesMessage(data));
}
public void Dispose()
{
_petnamesReady.Unsubscribe(OnPetNicknamesReady);
_petnamesDisposing.Unsubscribe(OnPetNicknamesDispose);
_playerDataChanged.Unsubscribe(OnLocalPetNicknamesDataChange);
}
}

View File

@@ -0,0 +1,68 @@
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public sealed partial class IpcManager : DisposableMediatorSubscriberBase
{
public IpcManager(ILogger<IpcManager> logger, MareMediator mediator,
IpcCallerPenumbra penumbraIpc, IpcCallerGlamourer glamourerIpc, IpcCallerCustomize customizeIpc, IpcCallerHeels heelsIpc,
IpcCallerHonorific honorificIpc, IpcCallerMoodles moodlesIpc, IpcCallerPetNames ipcCallerPetNames, IpcCallerBrio ipcCallerBrio) : base(logger, mediator)
{
CustomizePlus = customizeIpc;
Heels = heelsIpc;
Glamourer = glamourerIpc;
Penumbra = penumbraIpc;
Honorific = honorificIpc;
Moodles = moodlesIpc;
PetNames = ipcCallerPetNames;
Brio = ipcCallerBrio;
if (Initialized)
{
Mediator.Publish(new PenumbraInitializedMessage());
}
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => PeriodicApiStateCheck());
try
{
PeriodicApiStateCheck();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to check for some IPC, plugin not installed?");
}
}
public bool Initialized => Penumbra.APIAvailable && Glamourer.APIAvailable;
public IpcCallerCustomize CustomizePlus { get; init; }
public IpcCallerHonorific Honorific { get; init; }
public IpcCallerHeels Heels { get; init; }
public IpcCallerGlamourer Glamourer { get; }
public IpcCallerPenumbra Penumbra { get; }
public IpcCallerMoodles Moodles { get; }
public IpcCallerPetNames PetNames { get; }
public IpcCallerBrio Brio { get; }
private int _stateCheckCounter = -1;
private void PeriodicApiStateCheck()
{
// Stagger API checks
if (++_stateCheckCounter > 8)
_stateCheckCounter = 0;
int i = _stateCheckCounter;
if (i == 0) Penumbra.CheckAPI();
if (i == 1) Penumbra.CheckModDirectory();
if (i == 2) Glamourer.CheckAPI();
if (i == 3) Heels.CheckAPI();
if (i == 4) CustomizePlus.CheckAPI();
if (i == 5) Honorific.CheckAPI();
if (i == 6) Moodles.CheckAPI();
if (i == 7) PetNames.CheckAPI();
if (i == 8) Brio.CheckAPI();
}
}

View File

@@ -0,0 +1,196 @@
using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Interop.Ipc;
public class IpcProvider : IHostedService, IMediatorSubscriber
{
private readonly ILogger<IpcProvider> _logger;
private readonly IDalamudPluginInterface _pi;
private readonly MareConfigService _mareConfig;
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<GameObjectHandler> _activeGameObjectHandlers = [];
private ICallGateProvider<string, IGameObject, bool>? _loadFileProviderMare;
private ICallGateProvider<string, IGameObject, Task<bool>>? _loadFileAsyncProviderMare;
private ICallGateProvider<List<nint>>? _handledGameAddressesMare;
private bool _marePluginEnabled = false;
private bool _impersonating = false;
private DateTime _unregisterTime = DateTime.UtcNow;
private CancellationTokenSource _registerDelayCts = new();
public bool MarePluginEnabled => _marePluginEnabled;
public bool ImpersonationActive => _impersonating;
public MareMediator Mediator { get; init; }
public IpcProvider(ILogger<IpcProvider> logger, IDalamudPluginInterface pi, MareConfigService mareConfig,
CharaDataManager charaDataManager, MareMediator mareMediator)
{
_logger = logger;
_pi = pi;
_mareConfig = mareConfig;
_charaDataManager = charaDataManager;
Mediator = mareMediator;
Mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (msg.OwnedObject) return;
_activeGameObjectHandlers.Add(msg.GameObjectHandler);
});
Mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (msg.OwnedObject) return;
_activeGameObjectHandlers.Remove(msg.GameObjectHandler);
});
_marePluginEnabled = PluginWatcherService.GetInitialPluginState(pi, "MareSynchronos")?.IsLoaded ?? false;
Mediator.SubscribeKeyed<PluginChangeMessage>(this, "MareSynchronos", p => {
_marePluginEnabled = p.IsLoaded;
HandleMareImpersonation(automatic: true);
});
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Starting IpcProvider Service");
_loadFileProvider = _pi.GetIpcProvider<string, IGameObject, bool>("ElfSync.LoadMcdf");
_loadFileProvider.RegisterFunc(LoadMcdf);
_loadFileAsyncProvider = _pi.GetIpcProvider<string, IGameObject, Task<bool>>("UmbraSync.LoadMcdfAsync");
_loadFileAsyncProvider.RegisterFunc(LoadMcdfAsync);
_handledGameAddresses = _pi.GetIpcProvider<List<nint>>("UmbraSync.GetHandledAddresses");
_handledGameAddresses.RegisterFunc(GetHandledAddresses);
_loadFileProviderMare = _pi.GetIpcProvider<string, IGameObject, bool>("MareSynchronos.LoadMcdf");
_loadFileAsyncProviderMare = _pi.GetIpcProvider<string, IGameObject, Task<bool>>("MareSynchronos.LoadMcdfAsync");
_handledGameAddressesMare = _pi.GetIpcProvider<List<nint>>("MareSynchronos.GetHandledAddresses");
HandleMareImpersonation(automatic: true);
_logger.LogInformation("Started IpcProviderService");
return Task.CompletedTask;
}
public void HandleMareImpersonation(bool automatic = false)
{
if (_marePluginEnabled)
{
if (_impersonating)
{
_loadFileProviderMare?.UnregisterFunc();
_loadFileAsyncProviderMare?.UnregisterFunc();
_handledGameAddressesMare?.UnregisterFunc();
_impersonating = false;
_unregisterTime = DateTime.UtcNow;
_logger.LogDebug("Unregistered MareSynchronos API");
}
}
else
{
if (_mareConfig.Current.MareAPI)
{
var cancelToken = _registerDelayCts.Token;
Task.Run(async () =>
{
// Wait before registering to reduce the chance of a race condition
if (automatic)
await Task.Delay(5000);
if (cancelToken.IsCancellationRequested)
return;
if (_marePluginEnabled)
{
_logger.LogDebug("Not registering MareSynchronos API: Mare plugin is loaded");
return;
}
_loadFileProviderMare?.RegisterFunc(LoadMcdf);
_loadFileAsyncProviderMare?.RegisterFunc(LoadMcdfAsync);
_handledGameAddressesMare?.RegisterFunc(GetHandledAddresses);
_impersonating = true;
_logger.LogDebug("Registered MareSynchronos API");
}, cancelToken);
}
else
{
_registerDelayCts = _registerDelayCts.CancelRecreate();
if (_impersonating)
{
_loadFileProviderMare?.UnregisterFunc();
_loadFileAsyncProviderMare?.UnregisterFunc();
_handledGameAddressesMare?.UnregisterFunc();
_impersonating = false;
_unregisterTime = DateTime.UtcNow;
_logger.LogDebug("Unregistered MareSynchronos API");
}
}
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogDebug("Stopping IpcProvider Service");
_loadFileProvider?.UnregisterFunc();
_loadFileAsyncProvider?.UnregisterFunc();
_handledGameAddresses?.UnregisterFunc();
_registerDelayCts.Cancel();
if (_impersonating)
{
_loadFileProviderMare?.UnregisterFunc();
_loadFileAsyncProviderMare?.UnregisterFunc();
_handledGameAddressesMare?.UnregisterFunc();
}
Mediator.UnsubscribeAll(this);
return Task.CompletedTask;
}
private async Task<bool> LoadMcdfAsync(string path, IGameObject target)
{
await ApplyFileAsync(path, target).ConfigureAwait(false);
return true;
}
private bool LoadMcdf(string path, IGameObject target)
{
_ = Task.Run(async () => await ApplyFileAsync(path, target).ConfigureAwait(false)).ConfigureAwait(false);
return true;
}
private async Task ApplyFileAsync(string path, IGameObject target)
{
_charaDataManager.LoadMcdf(path);
await (_charaDataManager.LoadedMcdfHeader ?? Task.CompletedTask).ConfigureAwait(false);
_charaDataManager.McdfApplyToTarget(target.Name.TextValue);
}
private List<nint> GetHandledAddresses()
{
if (!_impersonating)
{
if ((DateTime.UtcNow - _unregisterTime).TotalSeconds >= 1.0)
{
_logger.LogWarning("GetHandledAddresses called when it should not be registered");
_handledGameAddressesMare?.UnregisterFunc();
}
return [];
}
return _activeGameObjectHandlers.Where(g => g.Address != nint.Zero).Select(g => g.Address).Distinct().ToList();
}
}

View File

@@ -0,0 +1,54 @@
using Dalamud.Game.ClientState.Objects.Types;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace MareSynchronos.Interop.Ipc;
public class RedrawManager
{
private readonly MareMediator _mareMediator;
private readonly DalamudUtilService _dalamudUtil;
private readonly ConcurrentDictionary<nint, bool> _penumbraRedrawRequests = [];
private CancellationTokenSource _disposalCts = new();
public SemaphoreSlim RedrawSemaphore { get; init; } = new(2, 2);
public RedrawManager(MareMediator mareMediator, DalamudUtilService dalamudUtil)
{
_mareMediator = mareMediator;
_dalamudUtil = dalamudUtil;
}
public async Task PenumbraRedrawInternalAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, Action<ICharacter> action, CancellationToken token)
{
_mareMediator.Publish(new PenumbraStartRedrawMessage(handler.Address));
_penumbraRedrawRequests[handler.Address] = true;
try
{
using CancellationTokenSource cancelToken = new CancellationTokenSource();
using CancellationTokenSource combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, token, _disposalCts.Token);
var combinedToken = combinedCts.Token;
cancelToken.CancelAfter(TimeSpan.FromSeconds(15));
await handler.ActOnFrameworkAfterEnsureNoDrawAsync(action, combinedToken).ConfigureAwait(false);
if (!_disposalCts.Token.IsCancellationRequested)
await _dalamudUtil.WaitWhileCharacterIsDrawing(logger, handler, applicationId, 30000, combinedToken).ConfigureAwait(false);
}
finally
{
_penumbraRedrawRequests[handler.Address] = false;
_mareMediator.Publish(new PenumbraEndRedrawMessage(handler.Address));
}
}
internal void Cancel()
{
_disposalCts = _disposalCts.CancelRecreate();
}
}

View File

@@ -0,0 +1,203 @@
using Dalamud.Memory;
using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
namespace MareSynchronos.Interop;
/// <summary>
/// Code for spawning mostly taken from https://git.anna.lgbt/anna/OrangeGuidanceTomestone/src/branch/main/client/Vfx.cs
/// </summary>
public unsafe class VfxSpawnManager : DisposableMediatorSubscriberBase
{
private static readonly byte[] _pool = "Client.System.Scheduler.Instance.VfxObject\0"u8.ToArray();
#region signatures
#pragma warning disable CS0649
[Signature("E8 ?? ?? ?? ?? F3 0F 10 35 ?? ?? ?? ?? 48 89 43 08")]
private readonly delegate* unmanaged<byte*, byte*, VfxStruct*> _staticVfxCreate;
[Signature("E8 ?? ?? ?? ?? ?? ?? ?? 8B 4A ?? 85 C9")]
private readonly delegate* unmanaged<VfxStruct*, float, int, ulong> _staticVfxRun;
[Signature("40 53 48 83 EC 20 48 8B D9 48 8B 89 ?? ?? ?? ?? 48 85 C9 74 28 33 D2 E8 ?? ?? ?? ?? 48 8B 8B ?? ?? ?? ?? 48 85 C9")]
private readonly delegate* unmanaged<VfxStruct*, nint> _staticVfxRemove;
#pragma warning restore CS0649
#endregion
public VfxSpawnManager(ILogger<VfxSpawnManager> logger, IGameInteropProvider gameInteropProvider, MareMediator mareMediator)
: base(logger, mareMediator)
{
gameInteropProvider.InitializeFromAttributes(this);
mareMediator.Subscribe<GposeStartMessage>(this, (msg) =>
{
ChangeSpawnVisibility(0f);
});
mareMediator.Subscribe<GposeEndMessage>(this, (msg) =>
{
RestoreSpawnVisiblity();
});
mareMediator.Subscribe<CutsceneStartMessage>(this, (msg) =>
{
ChangeSpawnVisibility(0f);
});
mareMediator.Subscribe<CutsceneEndMessage>(this, (msg) =>
{
RestoreSpawnVisiblity();
});
}
private unsafe void RestoreSpawnVisiblity()
{
foreach (var vfx in _spawnedObjects)
{
((VfxStruct*)vfx.Value.Address)->Alpha = vfx.Value.Visibility;
}
}
private unsafe void ChangeSpawnVisibility(float visibility)
{
foreach (var vfx in _spawnedObjects)
{
((VfxStruct*)vfx.Value.Address)->Alpha = visibility;
}
}
private readonly Dictionary<Guid, (nint Address, float Visibility)> _spawnedObjects = [];
private VfxStruct* SpawnStatic(string path, Vector3 pos, Quaternion rotation, float r, float g, float b, float a, Vector3 scale)
{
VfxStruct* vfx;
fixed (byte* terminatedPath = Encoding.UTF8.GetBytes(path).NullTerminate())
{
fixed (byte* pool = _pool)
{
vfx = _staticVfxCreate(terminatedPath, pool);
}
}
if (vfx == null)
{
return null;
}
vfx->Position = new Vector3(pos.X, pos.Y + 1, pos.Z);
vfx->Rotation = new Quaternion(rotation.X, rotation.Y, rotation.Z, rotation.W);
vfx->SomeFlags &= 0xF7;
vfx->Flags |= 2;
vfx->Red = r;
vfx->Green = g;
vfx->Blue = b;
vfx->Scale = scale;
vfx->Alpha = a;
_staticVfxRun(vfx, 0.0f, -1);
return vfx;
}
public Guid? SpawnObject(Vector3 position, Quaternion rotation, Vector3 scale, float r = 1f, float g = 1f, float b = 1f, float a = 0.5f)
{
Logger.LogDebug("Trying to Spawn orb VFX at {pos}, {rot}", position, rotation);
var vfx = SpawnStatic("bgcommon/world/common/vfx_for_event/eff/b0150_eext_y.avfx", position, rotation, r, g, b, a, scale);
if (vfx == null || (nint)vfx == nint.Zero)
{
Logger.LogDebug("Failed to Spawn VFX at {pos}, {rot}", position, rotation);
return null;
}
Guid guid = Guid.NewGuid();
Logger.LogDebug("Spawned VFX at {pos}, {rot}: 0x{ptr:X}", position, rotation, (nint)vfx);
_spawnedObjects[guid] = ((nint)vfx, a);
return guid;
}
public unsafe void MoveObject(Guid id, Vector3 newPosition)
{
if (_spawnedObjects.TryGetValue(id, out var vfxValue))
{
if (vfxValue.Address == nint.Zero) return;
var vfx = (VfxStruct*)vfxValue.Address;
vfx->Position = newPosition with { Y = newPosition.Y + 1 };
vfx->Flags |= 2;
}
}
public void DespawnObject(Guid? id)
{
if (id == null) return;
if (_spawnedObjects.Remove(id.Value, out var value))
{
Logger.LogDebug("Despawning {obj:X}", value.Address);
_staticVfxRemove((VfxStruct*)value.Address);
}
}
private void RemoveAllVfx()
{
foreach (var obj in _spawnedObjects.Values)
{
Logger.LogDebug("Despawning {obj:X}", obj);
_staticVfxRemove((VfxStruct*)obj.Address);
}
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
RemoveAllVfx();
}
}
[StructLayout(LayoutKind.Explicit)]
internal struct VfxStruct
{
[FieldOffset(0x38)]
public byte Flags;
[FieldOffset(0x50)]
public Vector3 Position;
[FieldOffset(0x60)]
public Quaternion Rotation;
[FieldOffset(0x70)]
public Vector3 Scale;
[FieldOffset(0x128)]
public int ActorCaster;
[FieldOffset(0x130)]
public int ActorTarget;
[FieldOffset(0x1B8)]
public int StaticCaster;
[FieldOffset(0x1C0)]
public int StaticTarget;
[FieldOffset(0x248)]
public byte SomeFlags;
[FieldOffset(0x260)]
public float Red;
[FieldOffset(0x264)]
public float Green;
[FieldOffset(0x268)]
public float Blue;
[FieldOffset(0x26C)]
public float Alpha;
}
}

View File

@@ -0,0 +1,48 @@
using System.Globalization;
namespace MareSynchronos.Localization;
public static class LocalizationExtensions
{
public static string Loc(this string fallbackEnglish, params object[] formatArgs)
{
var service = LocalizationService.Instance;
if (service == null) return FormatFallback(fallbackEnglish, formatArgs);
return service.GetString(fallbackEnglish, formatArgs);
}
public static string LocKey(this string key, string fallbackEnglish, params object[] formatArgs)
{
var service = LocalizationService.Instance;
if (service == null) return FormatFallback(fallbackEnglish, formatArgs);
return service.GetString(key, fallbackEnglish, formatArgs);
}
public static string LocLabel(this string labelWithId, params object[] formatArgs)
{
if (string.IsNullOrEmpty(labelWithId)) return labelWithId;
var separatorIndex = labelWithId.IndexOf("##", StringComparison.Ordinal);
if (separatorIndex < 0)
{
return labelWithId.Loc(formatArgs);
}
var label = labelWithId[..separatorIndex];
var id = labelWithId[separatorIndex..];
return string.Concat(label.Loc(formatArgs), id);
}
private static string FormatFallback(string fallback, params object[] args)
{
if (args == null || args.Length == 0) return fallback;
try
{
return string.Format(CultureInfo.CurrentCulture, fallback, args);
}
catch
{
return fallback;
}
}
}

View File

@@ -0,0 +1,231 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using Dalamud.Plugin;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Models;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.Localization;
public class LocalizationService
{
private readonly ILogger<LocalizationService> _logger;
private readonly IDalamudPluginInterface _pluginInterface;
private readonly MareConfigService _configService;
private readonly Dictionary<LocalizationLanguage, Dictionary<string, string>> _translations = new();
private readonly HashSet<string> _missingLocalizationsLogged = new(StringComparer.Ordinal);
private readonly Lock _missingLocalizationsLock = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
AllowTrailingCommas = true
};
public static LocalizationService? Instance { get; private set; }
public LocalizationService(ILogger<LocalizationService> logger, IDalamudPluginInterface pluginInterface, MareConfigService configService)
{
_logger = logger;
_pluginInterface = pluginInterface;
_configService = configService;
Instance = this;
foreach (var language in Enum.GetValues<LocalizationLanguage>())
{
_translations[language] = LoadLanguage(language);
}
}
public LocalizationLanguage CurrentLanguage => _configService.Current.Language;
public static IEnumerable<LocalizationLanguage> SupportedLanguages => Enum.GetValues<LocalizationLanguage>();
public string GetString(string fallbackEnglish, params object[] formatArgs)
{
return GetStringInternal(null, fallbackEnglish, formatArgs);
}
public string GetString(string key, string fallbackEnglish, params object[] formatArgs)
{
return GetStringInternal(key, fallbackEnglish, formatArgs);
}
public string GetLanguageDisplayName(LocalizationLanguage language)
{
var fallback = language switch
{
LocalizationLanguage.French => "Français",
LocalizationLanguage.English => "English",
_ => language.ToString()
};
return GetRawString($"Language.DisplayName.{language}", fallback);
}
public void ReloadLanguage(LocalizationLanguage language)
{
_translations[language] = LoadLanguage(language);
}
public void ReloadAll()
{
foreach (var language in Enum.GetValues<LocalizationLanguage>())
{
ReloadLanguage(language);
}
}
private string GetStringInternal(string? key, string fallbackEnglish, params object[] formatArgs)
{
var usedKey = string.IsNullOrWhiteSpace(key) ? fallbackEnglish : key;
var text = GetRawString(usedKey, fallbackEnglish);
if (formatArgs != null && formatArgs.Length > 0)
{
try
{
return string.Format(CultureInfo.CurrentCulture, text, formatArgs);
}
catch (FormatException ex)
{
_logger.LogWarning(ex, "Localization format mismatch for key {Key} with text '{Text}'", usedKey, text);
try
{
return string.Format(CultureInfo.CurrentCulture, fallbackEnglish, formatArgs);
}
catch
{
return fallbackEnglish;
}
}
}
return text;
}
private string GetRawString(string key, string fallbackEnglish)
{
if (TryGetString(CurrentLanguage, key, out var localized))
{
return localized;
}
LogMissingLocalization(CurrentLanguage, key, fallbackEnglish);
if (TryGetString(LocalizationLanguage.English, key, out var english))
{
return english;
}
if (CurrentLanguage != LocalizationLanguage.English)
{
LogMissingLocalization(LocalizationLanguage.English, key, fallbackEnglish);
}
return fallbackEnglish;
}
private bool TryGetString(LocalizationLanguage language, string key, out string value)
{
var dictionary = GetDictionary(language);
if (dictionary.TryGetValue(key, out var text) && !string.IsNullOrWhiteSpace(text))
{
value = text;
return true;
}
value = string.Empty;
return false;
}
private Dictionary<string, string> GetDictionary(LocalizationLanguage language)
{
if (!_translations.TryGetValue(language, out var dictionary))
{
dictionary = LoadLanguage(language);
_translations[language] = dictionary;
}
return dictionary;
}
private Dictionary<string, string> LoadLanguage(LocalizationLanguage language)
{
var baseDirectory = _pluginInterface.AssemblyLocation.DirectoryName ?? string.Empty;
var localizationDirectory = Path.Combine(baseDirectory, "Localization");
var languageCode = GetLanguageCode(language);
var filePath = Path.Combine(localizationDirectory, $"{languageCode}.json");
Dictionary<string, string>? translations = null;
if (File.Exists(filePath))
{
try
{
using var stream = File.OpenRead(filePath);
translations = DeserializeTranslations(stream);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load localization data from {FilePath}", filePath);
}
}
if (translations == null)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"{assembly.GetName().Name}.Localization.{languageCode}.json";
using var resourceStream = assembly.GetManifestResourceStream(resourceName);
if (resourceStream != null)
{
try
{
translations = DeserializeTranslations(resourceStream);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load embedded localization resource {Resource}", resourceName);
}
}
}
if (translations == null)
{
_logger.LogDebug("Localization data for {Language} not found on disk or embedded, using empty dictionary.", language);
translations = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
return translations;
}
private void LogMissingLocalization(LocalizationLanguage language, string key, string fallback)
{
var marker = $"{language}:{key}";
using var scope = _missingLocalizationsLock.EnterScope();
if (_missingLocalizationsLogged.Contains(marker)) return;
_missingLocalizationsLogged.Add(marker);
_logger.LogDebug("Missing localization for {Language} ({Key}). Using fallback '{Fallback}'.", language, key, fallback);
}
private static string GetLanguageCode(LocalizationLanguage language) => language switch
{
LocalizationLanguage.French => "fr",
LocalizationLanguage.English => "en",
_ => language.ToString().ToLowerInvariant(),
};
private static Dictionary<string, string>? DeserializeTranslations(Stream stream)
{
var translations = JsonSerializer.Deserialize<Dictionary<string, string>>(stream, JsonOptions);
return translations != null
? new Dictionary<string, string>(translations, StringComparer.OrdinalIgnoreCase)
: null;
}
}

View File

@@ -0,0 +1,579 @@
{
"Language.DisplayName.French": "French",
"Language.DisplayName.English": "English",
"Settings.Plugins.MandatoryHeading": "Mandatory Plugins",
"Settings.Plugins.MandatoryLabel": "Mandatory Plugins:",
"Settings.Plugins.OptionalHeading": "Optional Addons",
"Settings.Plugins.OptionalLabel": "Optional Addons:",
"Settings.Plugins.OptionalDescription": "These addons are not required for basic operation, but without them you may not see others as intended.",
"Settings.Plugins.Tooltip.Available": "{0} is available and up to date.",
"Settings.Plugins.Tooltip.Unavailable": "{0} is unavailable or not up to date.",
"Settings.Plugins.WarningMandatoryMissing": "You need to install both Penumbra and Glamourer and keep them up to date to use Umbra.",
"Settings.General.LocalizationHeading": "Localization",
"Settings.General.Language": "Language",
"Settings.General.Language.Description": "Select the plugin language. Any missing translations will be shown in English.",
"Settings.General.NotesHeading": "Notes",
"Settings.General.Notes.Export": "Export all your user notes to clipboard",
"Settings.General.Notes.Import": "Import notes from clipboard",
"Settings.General.Notes.Overwrite": "Overwrite existing notes",
"Settings.General.Notes.Overwrite.Description": "If this option is selected all already existing notes for UIDs will be overwritten by the imported notes.",
"Settings.General.Notes.Import.Success": "User Notes successfully imported",
"Settings.General.Notes.Import.Failure": "Attempt to import notes from clipboard failed. Check formatting and try again",
"Settings.General.Notes.OpenPopup": "Open Notes Popup on user addition",
"Settings.General.Notes.OpenPopup.Description": "This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs.",
"Settings.Transfers.Blocked.Description": "Files that you attempted to upload or download that were forbidden to be transferred by their creators will appear here. If you see file paths from your drive here, then those files were not allowed to be uploaded. If you see hashes, those files were not allowed to be downloaded. Ask your paired friend to send you the mod in question through other means or acquire the mod yourself.",
"Settings.Transfers.Blocked.Column.Hash": "Hash/Filename",
"Settings.Transfers.Blocked.Column.ForbiddenBy": "Forbidden by",
"Settings.Transfers.Blocked.Tab": "Blocked Transfers",
"Settings.Transfers.Heading": "Transfer Settings",
"Settings.Transfers.GlobalLimit.Label": "Global Download Speed Limit",
"Settings.Transfers.GlobalLimit.Unit.BytePerSec": "Byte/s",
"Settings.Transfers.GlobalLimit.Unit.KiloBytePerSec": "KB/s",
"Settings.Transfers.GlobalLimit.Unit.MegaBytePerSec": "MB/s",
"Settings.Transfers.GlobalLimit.Hint": "0 = No limit/infinite",
"Settings.Transfers.MaxParallelDownloads": "Maximum Parallel Downloads",
"Settings.Transfers.AutoDetect.Heading": "AutoDetect",
"Settings.Transfers.AutoDetect.EnableNearby": "Enable Nearby detection (beta)",
"Settings.Transfers.AutoDetect.AllowRequests": "Allow pair requests",
"Settings.Transfers.AutoDetect.Notification.Title": "Nearby Detection",
"Settings.Transfers.AutoDetect.Notification.Enabled": "Pair requests enabled: others can invite you.",
"Settings.Transfers.AutoDetect.Notification.Disabled": "Pair requests disabled: others cannot invite you.",
"Settings.Transfers.AutoDetect.MaxDistance": "Max distance (meters)",
"Settings.Transfers.UI.Heading": "Transfer UI",
"Settings.Transfers.UI.ShowWindow": "Show separate transfer window",
"Settings.Transfers.UI.ShowWindow.Description": "The download window will show the current progress of outstanding downloads.\n\nWhat do W/Q/P/D stand for?\nW = Waiting for Slot (see Maximum Parallel Downloads)\nQ = Queued on Server, waiting for queue ready signal\nP = Processing download (aka downloading)\nD = Decompressing download",
"Settings.Transfers.UI.EditWindowPosition": "Edit Transfer Window position",
"Settings.Transfers.UI.ShowTransferBars": "Show transfer bars rendered below players",
"Settings.Transfers.UI.ShowTransferBars.Description": "This will render a progress bar during the download at the feet of the player you are downloading from.",
"Settings.Transfers.UI.ShowDownloadText": "Show Download Text",
"Settings.Transfers.UI.ShowDownloadText.Description": "Shows download text (amount of MiB downloaded) in the transfer bars",
"Settings.Transfers.UI.BarWidth": "Transfer Bar Width",
"Settings.Transfers.UI.BarWidth.Description": "Width of the displayed transfer bars (will never be less wide than the displayed text)",
"Settings.Transfers.UI.BarHeight": "Transfer Bar Height",
"Settings.Transfers.UI.BarHeight.Description": "Height of the displayed transfer bars (will never be less tall than the displayed text)",
"Settings.Transfers.UI.ShowUploading": "Show 'Uploading' text below players that are currently uploading",
"Settings.Transfers.UI.ShowUploading.Description": "This will render an 'Uploading' text at the feet of the player that is in progress of uploading data.",
"Settings.Transfers.UI.ShowUploadingBigText": "Large font for 'Uploading' text",
"Settings.Transfers.UI.ShowUploadingBigText.Description": "This will render an 'Uploading' text in a larger font.",
"Settings.Transfers.Current.Heading": "Current Transfers",
"Settings.Transfers.Current.Tab": "Transfers",
"Settings.Transfers.Current.Uploads": "Uploads",
"Settings.Transfers.Current.Uploads.Column.File": "File",
"Settings.Transfers.Current.Uploads.Column.Uploaded": "Uploaded",
"Settings.Transfers.Current.Uploads.Column.Size": "Size",
"Settings.Transfers.Current.Downloads": "Downloads",
"Settings.Transfers.Current.Downloads.Column.User": "User",
"Settings.Transfers.Current.Downloads.Column.Server": "Server",
"Settings.Transfers.Current.Downloads.Column.Files": "Files",
"Settings.Transfers.Current.Downloads.Column.Download": "Download",
"Settings.Storage.Heading": "Storage",
"Settings.Storage.Description": "Umbra stores downloaded files from paired people permanently. This is to improve loading performance and requiring less downloads. The storage governs itself by clearing data beyond the set storage size. Please set the storage size accordingly. It is not necessary to manually clear the storage.",
"Settings.Service.ActionsHeading": "Service Actions",
"Settings.Service.Actions.DeleteAccount": "Delete account",
"Settings.Service.Actions.DeleteAccountPopup": "Delete your account?",
"Settings.Service.Actions.DeleteAccount.Description": "Completely deletes your currently connected account.",
"Settings.Service.Actions.DeleteAccount.Popup.Body1": "Your account and all associated files and data on the service will be deleted.",
"Settings.Service.Actions.DeleteAccount.Popup.Body2": "Your UID will be removed from all pairing lists.",
"Settings.Service.Actions.DeleteAccount.Popup.Confirm": "Are you sure you want to continue?",
"Settings.Service.Actions.DeleteAccount.Popup.Cancel": "Cancel",
"Settings.Service.SettingsHeading": "Service & Character Settings",
"Settings.Service.ReconnectWarning": "For any changes to be applied to the current service you need to reconnect to the service.",
"Settings.Service.Tabs.CharacterAssignments": "Character Assignments",
"Settings.Service.Tabs.SecretKey": "Secret Key Management",
"Settings.Service.Tabs.ServiceSettings": "Service Settings",
"Settings.Service.Character.Assignments.Description": "Characters listed here will connect with the specified secret key.",
"Settings.Service.Character.Assignments.TooltipCurrent": "Current character",
"Settings.Service.Character.Assignments.DeleteTooltip": "Delete character assignment",
"Settings.Service.Character.Assignments.AddCurrent": "Add current character",
"Settings.Service.Character.Assignments.NoKeys": "You need to add a Secret Key first before adding Characters.",
"Settings.Service.SecretKey.DisplayName": "Secret Key Display Name",
"Settings.Service.SecretKey.Value": "Secret Key",
"Settings.Service.SecretKey.AssignCurrent": "Assign current character",
"Settings.Service.SecretKey.AssignTooltip": "Use this secret key for {0} @ {1}",
"Settings.Service.SecretKey.Delete": "Delete Secret Key",
"Settings.Service.SecretKey.DeleteTooltip": "Hold CTRL to delete this secret key entry",
"Settings.Service.SecretKey.InUse": "This key is currently assigned to a character and cannot be edited or deleted.",
"Settings.Service.SecretKey.Add": "Add new Secret Key",
"Settings.Service.SecretKey.NewFriendlyName": "New Secret Key",
"Settings.Service.SecretKey.RegisterAccount": "Register a new Umbra account",
"Settings.Service.SecretKey.RegisterFailed": "An unknown error occured. Please try again later.",
"Settings.Service.SecretKey.RegisterSuccess": "New account registered.\nPlease keep a copy of your secret key in case you need to reset your plugins, or to use it on another PC.",
"Settings.Service.SecretKey.RegisteredFriendlyName": "{0} (registered {1})",
"Settings.Service.SecretKey.Registering": "Sending request...",
"Settings.Service.ServiceTab.Uri": "Service URI",
"Settings.Service.ServiceTab.UriReadOnlyHint": "You cannot edit the URI of the main service.",
"Settings.Service.ServiceTab.Name": "Service Name",
"Settings.Service.ServiceTab.NameReadOnlyHint": "You cannot edit the name of the main service.",
"Settings.Service.ServiceTab.Delete": "Delete Service",
"Settings.Service.ServiceTab.DeleteHint": "Hold CTRL to delete this service",
"Settings.Advanced.Heading": "Advanced",
"Settings.Advanced.Tab": "Advanced",
"Settings.Advanced.Api.Enable": "Enable Umbra Sync API",
"Settings.Advanced.Api.Description": "Enables handling of the Umbra Sync API. This currently includes:\n\n - MCDF loading support for other plugins\n - Blocking Moodles applications to paired users\n\nIf the Umbra Sync plugin is loaded while this option is enabled, control of its API will be relinquished.",
"Settings.Advanced.Api.Status.Active": "Umbra API active!",
"Settings.Advanced.Api.Status.Disabled": "Umbra API inactive: Option is disabled",
"Settings.Advanced.Api.Status.PluginLoaded": "Umbra API inactive: Umbra plugin is loaded",
"Settings.Advanced.Api.Status.Unknown": "Umbra API inactive: Unknown reason",
"Settings.Advanced.EventViewer.LogToDisk": "Log Event Viewer data to disk",
"Settings.Advanced.EventViewer.Open": "Open Event Viewer",
"Settings.Advanced.HoldCombat": "Hold application during combat",
"Settings.Advanced.SerializedApplications": "Serialized player applications",
"Settings.Advanced.SerializedApplications.Description": "Experimental - May reduce issues in crowded areas",
"Settings.Advanced.DebugHeading": "Debug",
"Settings.Advanced.Debug.LastCreatedTree": "Last created character data",
"Settings.Advanced.Debug.CopyButton": "[DEBUG] Copy Last created Character Data to clipboard",
"Settings.Advanced.Debug.CopyError": "ERROR: No created character data, cannot copy.",
"Settings.Advanced.Debug.CopyTooltip": "Use this when reporting mods being rejected from the server.",
"Settings.Advanced.LogLevel": "Log Level",
"Settings.Advanced.Performance.LogCounters": "Log Performance Counters",
"Settings.Advanced.Performance.LogCounters.Description": "Enabling this can incur a (slight) performance impact. Enabling this for extended periods of time is not recommended.",
"Settings.Advanced.Performance.PrintStats": "Print Performance Stats to /xllog",
"Settings.Advanced.Performance.PrintStatsRecent": "Print Performance Stats (last 60s) to /xllog",
"Settings.Advanced.ActiveBlocks": "Active Character Blocks",
"Settings.UI.Heading": "UI",
"Settings.UI.EnableRightClick": "Enable Game Right Click Menu Entries",
"Settings.UI.EnableRightClick.Description": "This will add Umbra related right click menu entries in the game UI on paired players.",
"Settings.UI.EnableDtrEntry": "Display status and visible pair count in Server Info Bar",
"Settings.UI.EnableDtrEntry.Description": "This will add Umbra connection status and visible pair count in the Server Info Bar.\nYou can further configure this through your Dalamud Settings.",
"Settings.UI.Dtr.ShowUid": "Show visible character's UID in tooltip",
"Settings.UI.Dtr.PreferNotes": "Prefer notes over player names in tooltip",
"Settings.UI.Dtr.UseColors": "Color-code the Server Info Bar entry according to status",
"Settings.UI.Dtr.ColorDefault": "Default",
"Settings.UI.Dtr.ColorNotConnected": "Not Connected",
"Settings.UI.Dtr.ColorPairsInRange": "Pairs in Range",
"Settings.UI.NameColors.Enable": "Color nameplates of paired players",
"Settings.UI.NameColors.Character": "Character Name Color",
"Settings.UI.NameColors.Blocked": "Blocked Character Color",
"Settings.UI.VisibleGroup": "Show separate Visible group",
"Settings.UI.VisibleGroup.Description": "This will show all currently visible users in a special 'Visible' group in the main UI.",
"Settings.UI.OfflineGroup": "Show separate Offline group",
"Settings.UI.OfflineGroup.Description": "This will show all currently offline users in a special 'Offline' group in the main UI.",
"Settings.UI.ShowPlayerNames": "Show player names",
"Settings.UI.ShowPlayerNames.Description": "This will show character names instead of UIDs when possible",
"Settings.UI.Profiles.Show": "Show Profiles on Hover",
"Settings.UI.Profiles.Show.Description": "This will show the configured user profile after a set delay",
"Settings.UI.Profiles.PopoutRight": "Popout profiles on the right",
"Settings.UI.Profiles.PopoutRight.Description": "Will show profiles on the right side of the main UI",
"Settings.UI.Profiles.HoverDelay": "Hover Delay",
"Settings.UI.Profiles.HoverDelay.Description": "Delay until the profile should be displayed",
"Settings.UI.Profiles.ShowNsfw": "Show profiles marked as NSFW",
"Settings.UI.Profiles.ShowNsfw.Description": "Will show profiles that have the NSFW tag enabled",
"Settings.Notifications.Heading": "Notifications",
"Settings.Notifications.InfoDisplay": "Info Notification Display",
"Settings.Notifications.InfoDisplay.Description": "The location where \"Info\" notifications will display.\n'Nowhere' will not show any Info notifications\n'Chat' will print Info notifications in chat\n'Toast' will show Warning toast notifications in the bottom right corner\n'Both' will show chat as well as the toast notification",
"Settings.Notifications.WarningDisplay": "Warning Notification Display",
"Settings.Notifications.WarningDisplay.Description": "The location where \"Warning\" notifications will display.\n'Nowhere' will not show any Warning notifications\n'Chat' will print Warning notifications in chat\n'Toast' will show Warning toast notifications in the bottom right corner\n'Both' will show chat as well as the toast notification",
"Settings.Notifications.ErrorDisplay": "Error Notification Display",
"Settings.Notifications.ErrorDisplay.Description": "The location where \"Error\" notifications will display.\n'Nowhere' will not show any Error notifications\n'Chat' will print Error notifications in chat\n'Toast' will show Error toast notifications in the bottom right corner\n'Both' will show chat as well as the toast notification",
"Settings.Notifications.Location.Nowhere": "Nowhere",
"Settings.Notifications.Location.Chat": "Chat",
"Settings.Notifications.Location.Toast": "Toast",
"Settings.Notifications.Location.Both": "Both",
"Settings.Notifications.DisableOptionalWarnings": "Disable optional plugin warnings",
"Settings.Notifications.DisableOptionalWarnings.Description": "Enabling this will not show any \"Warning\" labeled messages for missing optional plugins.",
"Settings.Notifications.EnableOnlineNotifications": "Enable online notifications",
"Settings.Notifications.EnableOnlineNotifications.Description": "Enabling this will show a small notification (type: Info) in the bottom right corner when pairs go online.",
"Settings.Notifications.IndividualPairsOnly": "Notify only for individual pairs",
"Settings.Notifications.IndividualPairsOnly.Description": "Enabling this will only show online notifications (type: Info) for individual pairs.",
"Settings.Notifications.NamedPairsOnly": "Notify only for named pairs",
"Settings.Notifications.NamedPairsOnly.Description": "Enabling this will only show online notifications (type: Info) for pairs where you have set an individual note.",
"Compact.Version.UnsupportedTitle": "UNSUPPORTED VERSION",
"Compact.Version.Outdated": "Your UmbraSync installation is out of date, the current version is {0}.{1}.{2}. It is highly recommended to keep UmbraSync up to date. Open /xlplugins and update the plugin.",
"Compact.Toggle.IndividualPairs": "Individual pairs",
"Compact.Toggle.Syncshells": "Syncshells",
"Compact.AddUser.ModalTitle": "Set Notes for New User",
"Compact.AddUser.Description": "You have successfully added {0}. Set a local note for the user in the field below:",
"Compact.AddUser.NoteHint": "Note for {0}",
"Compact.AddUser.Save": "Save Note",
"Compact.AddCharacter.Button": "Add current character with secret key",
"Compact.AddCharacter.SecretKeyLabel": "Secret Key",
"Compact.AddCharacter.NoKeys": "No secret keys are configured for the current server.",
"Compact.AddPair.Hint": "Other player's UID/Alias",
"Compact.AddPair.Tooltip": "Pair with {0}",
"Compact.AddPair.Tooltip.DefaultUser": "other user",
"Compact.Filter.Hint": "Filter for UID/notes",
"Compact.Filter.ToggleTooltip": "Hold Control to {0} pairing with {1} out of {2} displayed users.",
"Compact.Filter.ToggleTooltip.Resume": "resume",
"Compact.Filter.ToggleTooltip.Pause": "pause",
"Compact.Filter.CooldownTooltip": "Next execution is available at {0} seconds",
"Compact.Nearby.Title": "Nearby ({0})",
"Compact.Nearby.Button": "Nearby",
"Compact.Nearby.None": "No nearby players detected.",
"Compact.Nearby.Tooltip.AlreadyPaired": "Already paired on Umbra",
"Compact.Nearby.Tooltip.RequestsDisabled": "Pair requests are disabled for this player",
"Compact.Nearby.Tooltip.SendInvite": "Send Umbra invitation",
"Compact.Nearby.Tooltip.CannotInvite": "Unable to invite this player",
"Compact.Nearby.Incoming": "Incoming requests",
"Compact.Nearby.Incoming.Entry": "{0} [{1}]",
"Compact.Nearby.Incoming.Accept": "Accept and add as pair",
"Compact.Nearby.Incoming.Dismiss": "Dismiss request",
"Compact.Header.SettingsTooltip": "Open the UmbraSync settings",
"Compact.Header.CopyUid": "Copy your UID to clipboard",
"Compact.ServerStatus.UsersOnline": "Users Online",
"Compact.ServerStatus.Shard": "Shard: {0}",
"Compact.ServerStatus.NotConnected": "Not connected to any server",
"Compact.ServerStatus.EditProfile": "Edit your Profile",
"Compact.ServerStatus.Disconnect": "Disconnect from {0}",
"Compact.ServerStatus.Connect": "Connect to {0}",
"Compact.ServerError.Connecting": "Attempting to connect to the server.",
"Compact.ServerError.Reconnecting": "Connection to server interrupted, attempting to reconnect to the server.",
"Compact.ServerError.Disconnected": "You are currently disconnected from the sync server.",
"Compact.ServerError.Disconnecting": "Disconnecting from the server",
"Compact.ServerError.Unauthorized": "Server Response: {0}",
"Compact.ServerError.Offline": "Your selected sync server is currently offline.",
"Compact.ServerError.VersionMismatch": "Your plugin or the server you are connecting to is out of date. Please update your plugin now. If you already did so, contact the server provider to update their server to the latest version.",
"Compact.ServerError.RateLimited": "You are rate limited for (re)connecting too often. Disconnect, wait 10 minutes and try again.",
"Compact.ServerError.NoSecretKey": "You have no secret key set for this current character. Use the button below or open the settings and set a secret key for the current character. You can reuse the same secret key for multiple characters.",
"Compact.ServerError.MultiChara": "Your Character Configuration has multiple characters configured with same name and world. You will not be able to connect until you fix this issue. Remove the duplicates from the configuration in Settings -> Service Settings -> Character Management and reconnect manually after.",
"Compact.Transfers.CharacterAnalysis": "Character Analysis",
"Compact.Transfers.CharacterDataHub": "Character Data Hub",
"Compact.UidText.Reconnecting": "Reconnecting",
"Compact.UidText.Connecting": "Connecting",
"Compact.UidText.Disconnected": "Disconnected",
"Compact.UidText.Disconnecting": "Disconnecting",
"Compact.UidText.Unauthorized": "Unauthorized",
"Compact.UidText.VersionMismatch": "Version mismatch",
"Compact.UidText.Offline": "Unavailable",
"Compact.UidText.RateLimited": "Rate Limited",
"Compact.UidText.NoSecretKey": "No Secret Key",
"Compact.UidText.MultiChara": "Duplicate Characters",
"UserPair.Status.Online": "User is online",
"UserPair.Status.Offline": "User is offline",
"UserPair.Tooltip.NotAddedBack": "{0} has not added you back",
"UserPair.Tooltip.Paused": "Pairing with {0} is paused",
"UserPair.Tooltip.Visible": "{0} is visible: {1}\nClick to target this player",
"UserPair.Tooltip.Visible.LastPrefix": "(Last) ",
"UserPair.Tooltip.Visible.ModsInfo": "Mods Info",
"UserPair.Tooltip.Visible.FilesSize": "Files Size: {0}",
"UserPair.Tooltip.Visible.Vram": "Approx. VRAM Usage: {0}",
"UserPair.Tooltip.Visible.Tris": "Triangle Count (excl. Vanilla): {0}",
"UserPair.Tooltip.Pause": "Pause pairing with {0}",
"UserPair.Tooltip.Resume": "Resume pairing with {0}",
"UserPair.Tooltip.Permission.Header": "Individual user permissions",
"UserPair.Tooltip.Permission.Sound": "Sound sync disabled with {0}",
"UserPair.Tooltip.Permission.Animation": "Animation sync disabled with {0}",
"UserPair.Tooltip.Permission.Vfx": "VFX sync disabled with {0}",
"UserPair.Tooltip.Permission.Status": "You: {0}, They: {1}",
"UserPair.Tooltip.Permission.State.Disabled": "Disabled",
"UserPair.Tooltip.Permission.State.Enabled": "Enabled",
"UserPair.Tooltip.SharedData": "This user has shared {0} Character Data Sets with you.",
"UserPair.Tooltip.SharedData.OpenHub": "Click to open the Character Data Hub and show the entries.",
"UserPair.Menu.Target": "Target player",
"UserPair.Menu.OpenProfile": "Open Profile",
"UserPair.Menu.OpenProfile.Tooltip": "Opens the profile for this user in a new window",
"UserPair.Menu.OpenAnalysis": "Open Analysis",
"UserPair.Menu.ReloadData": "Reload last data",
"UserPair.Menu.ReloadData.Tooltip": "This reapplies the last received character data to this character",
"UserPair.Menu.CyclePause": "Cycle pause state",
"UserPair.Menu.PairGroups": "Pair Groups",
"UserPair.Menu.PairGroups.Tooltip": "Choose pair groups for {0}",
"UserPair.Menu.EnableSoundSync": "Enable sound sync",
"UserPair.Menu.DisableSoundSync": "Disable sound sync",
"UserPair.Menu.EnableAnimationSync": "Enable animation sync",
"UserPair.Menu.DisableAnimationSync": "Disable animation sync",
"UserPair.Menu.EnableVfxSync": "Enable VFX sync",
"UserPair.Menu.DisableVfxSync": "Disable VFX sync",
"UserPair.Menu.Unpair": "Unpair Permanently",
"UserPair.Menu.Unpair.Tooltip": "Hold CTRL and click to unpair permanently from {0}",
"Popup.Generic.Close": "Close",
"Popup.BanUser.Description": "User {0} will be banned and removed from this Syncshell.",
"Popup.BanUser.ReasonHint": "Ban Reason",
"Popup.BanUser.Button": "Ban User",
"Popup.BanUser.ReasonNote": "The reason will be displayed in the banlist. The current server-side alias if present (Vanity ID) will automatically be attached to the reason.",
"Popup.Report.Title": "Report {0} Profile",
"Popup.Report.Note": "Note: Sending a report will disable the offending profile globally.\nThe report will be sent to the team of your currently connected server.\nDepending on the severity of the offense the users profile or account can be permanently disabled or banned.",
"Popup.Report.Warning": "Report spam and wrong reports will not be tolerated and can lead to permanent account suspension.",
"Popup.Report.Scope": "This is not for reporting misbehavior but solely for the actual profile. Reports that are not solely for the profile will be ignored.",
"Popup.Report.Button": "Send Report",
"PairGroups.Popup.Title": "Choose Groups for {0}",
"PairGroups.Popup.SelectPrompt": "Select the groups you want {0} to be in.",
"PairGroups.Popup.CreatePrompt": "Create a new group for {0}.",
"PairGroups.Popup.NewGroupHint": "New Group",
"PairGroups.SelectPairs.Title": "Choose Users for Group {0}",
"PairGroups.SelectPairs.SelectPrompt": "Select users for group {0}",
"PairGroups.SelectPairs.FilterHint": "Filter",
"UidDisplay.Tooltip": "Left click to switch between UID display and nick\nRight click to change nick for {0}\nMiddle Mouse Button to open their profile in a separate window",
"UidDisplay.EditNotes.Hint": "Nick/Notes",
"DataAnalysis.WindowTitle": "Character Data Analysis",
"DataAnalysis.Bc7.ModalTitle": "BC7 Conversion in Progress",
"DataAnalysis.Bc7.Status": "BC7 Conversion in progress: {0}/{1}",
"DataAnalysis.Bc7.CurrentFile": "Current file: {0}",
"DataAnalysis.Bc7.Cancel": "Cancel conversion",
"DataAnalysis.Description": "This window shows you all files and their sizes that are currently in use through your character and associated entities",
"DataAnalysis.Analyzing": "Analyzing {0}/{1}",
"DataAnalysis.Button.CancelAnalysis": "Cancel analysis",
"DataAnalysis.Analyze.MissingNotice": "Some entries in the analysis have file size not determined yet, press the button below to analyze your current data",
"DataAnalysis.Button.StartMissing": "Start analysis (missing entries)",
"DataAnalysis.Button.StartAll": "Start analysis (recalculate all entries)",
"DataAnalysis.TotalFiles": "Total files:",
"DataAnalysis.Tooltip.FileSummary": "{0}: {1} files, size: {2}, compressed: {3}",
"DataAnalysis.TotalSizeActual": "Total size (actual):",
"DataAnalysis.TotalSizeDownload": "Total size (download size):",
"DataAnalysis.Tooltip.CalculateDownloadSize": "Click \"Start analysis\" to calculate download size",
"DataAnalysis.TotalTriangles": "Total modded model triangles: {0}",
"DataAnalysis.FilesFor": "Files for {0}",
"DataAnalysis.Object.SizeActual": "{0} size (actual):",
"DataAnalysis.Object.SizeDownload": "{0} size (download size):",
"DataAnalysis.Object.Vram": "{0} VRAM usage:",
"DataAnalysis.Object.Triangles": "{0} modded model triangles: {1}",
"DataAnalysis.FileGroup.Count": "{0} files",
"DataAnalysis.FileGroup.SizeActual": "{0} files size (actual):",
"DataAnalysis.FileGroup.SizeDownload": "{0} files size (download size):",
"DataAnalysis.Bc7.EnableMode": "Enable BC7 Conversion Mode",
"DataAnalysis.Bc7.WarningTitle": "WARNING BC7 CONVERSION:",
"DataAnalysis.Bc7.WarningIrreversible": "Converting textures to BC7 is irreversible!",
"DataAnalysis.Bc7.WarningDetails": "- Converting textures to BC7 will reduce their size (compressed and uncompressed) drastically. It is recommended to be used for large (4k+) textures.\n- Some textures, especially ones utilizing colorsets, might not be suited for BC7 conversion and might produce visual artifacts.\n- Before converting textures, make sure to have the original files of the mod you are converting so you can reimport it in case of issues.\n- Conversion will convert all found texture duplicates (entries with more than 1 file path) automatically.\n- Converting textures to BC7 is a very expensive operation and, depending on the amount of textures to convert, will take a while to complete.",
"DataAnalysis.Bc7.StartConversion": "Start conversion of {0} texture(s)",
"DataAnalysis.Table.Hash": "Hash",
"DataAnalysis.Table.Filepaths": "Filepaths",
"DataAnalysis.Table.Gamepaths": "Gamepaths",
"DataAnalysis.Table.FileSize": "File Size",
"DataAnalysis.Table.DownloadSize": "Download Size",
"DataAnalysis.Table.Format": "Format",
"DataAnalysis.Table.ConvertToBc7": "Convert to BC7",
"DataAnalysis.Table.Triangles": "Triangles",
"DataAnalysis.SelectedFile": "Selected file:",
"DataAnalysis.LocalFilePath": "Local file path:",
"DataAnalysis.MoreCount": "(and {0} more)",
"DataAnalysis.GamePath": "Used by game path:",
"DownloadUi.WindowTitle": "Umbra Downloads",
"DownloadUi.UploadStatus": "Compressing+Uploading {0}/{1}",
"DownloadUi.DownloadStatus": "{0} [W:{1}/Q:{2}/P:{3}/D:{4}]",
"DownloadUi.UploadingLabel": "Uploading",
"EventViewer.WindowTitle": "Event Viewer",
"EventViewer.Button.Unfreeze": "Unfreeze View",
"EventViewer.Button.Freeze": "Freeze View",
"EventViewer.Tooltip.NewEvents": "New events are available. Click to resume updating.",
"EventViewer.FilterLabel": "Filter lines",
"EventViewer.Button.OpenLog": "Open EventLog folder",
"EventViewer.Column.Time": "Time",
"EventViewer.Column.Source": "Source",
"EventViewer.Column.Uid": "UID",
"EventViewer.Column.Character": "Character",
"EventViewer.Column.Event": "Event",
"EventViewer.Severity.Informational": "Informational",
"EventViewer.Severity.Warning": "Warning",
"EventViewer.Severity.Error": "Error",
"EventViewer.NoValue": "--",
"DtrEntry.EntryName": "Umbra",
"DtrEntry.Tooltip.Connected": "Umbra: Connected",
"DtrEntry.Tooltip.Disconnected": "Umbra: Not Connected",
"PermissionWindow.Title": "Permissions for {0}",
"PermissionWindow.Pause.Label": "Pause Sync",
"PermissionWindow.Pause.HelpMain": "Pausing will completely cease any sync with this user.",
"PermissionWindow.Pause.HelpNote": "Note: this is bidirectional, either user pausing will cease sync completely.",
"PermissionWindow.OtherPaused.True": "{0} has paused you",
"PermissionWindow.OtherPaused.False": "{0} has not paused you",
"PermissionWindow.Sounds.Label": "Disable Sounds",
"PermissionWindow.Sounds.HelpMain": "Disabling sounds will remove all sounds synced with this user on both sides.",
"PermissionWindow.Sounds.HelpNote": "Note: this is bidirectional, either user disabling sound sync will stop sound sync on both sides.",
"PermissionWindow.OtherSoundDisabled.True": "{0} has disabled sound sync with you",
"PermissionWindow.OtherSoundDisabled.False": "{0} has not disabled sound sync with you",
"PermissionWindow.Animations.Label": "Disable Animations",
"PermissionWindow.Animations.HelpMain": "Disabling sounds will remove all animations synced with this user on both sides.",
"PermissionWindow.Animations.HelpNote": "Note: this is bidirectional, either user disabling animation sync will stop animation sync on both sides.",
"PermissionWindow.OtherAnimationDisabled.True": "{0} has disabled animation sync with you",
"PermissionWindow.OtherAnimationDisabled.False": "{0} has not disabled animation sync with you",
"PermissionWindow.Vfx.Label": "Disable VFX",
"PermissionWindow.Vfx.HelpMain": "Disabling sounds will remove all VFX synced with this user on both sides.",
"PermissionWindow.Vfx.HelpNote": "Note: this is bidirectional, either user disabling VFX sync will stop VFX sync on both sides.",
"PermissionWindow.OtherVfxDisabled.True": "{0} has disabled VFX sync with you",
"PermissionWindow.OtherVfxDisabled.False": "{0} has not disabled VFX sync with you",
"PermissionWindow.Button.Save": "Save",
"PermissionWindow.Tooltip.Save": "Save and apply all changes",
"PermissionWindow.Button.Revert": "Revert",
"PermissionWindow.Tooltip.Revert": "Revert all changes",
"PermissionWindow.Button.Reset": "Reset to Default",
"PermissionWindow.Tooltip.Reset": "This will set all permissions to their default setting",
"EditProfile.WindowTitle": "Umbra Edit Profile",
"EditProfile.CurrentProfile": "Current Profile (as saved on server)",
"EditProfile.Button.UploadPicture": "Upload new profile picture",
"EditProfile.Dialog.PictureTitle": "Select new Profile picture",
"EditProfile.Tooltip.UploadPicture": "Select and upload a new profile picture",
"EditProfile.Button.ClearPicture": "Clear uploaded profile picture",
"EditProfile.Tooltip.ClearPicture": "Clear your currently uploaded profile picture",
"EditProfile.Error.PictureTooLarge": "The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size",
"EditProfile.Checkbox.Nsfw": "Profile is NSFW",
"EditProfile.Help.Nsfw": "If your profile description or image can be considered NSFW, toggle this to ON",
"EditProfile.DescriptionCounter": "Description {0}/1500",
"EditProfile.PreviewLabel": "Preview (approximate)",
"EditProfile.Button.SaveDescription": "Save Description",
"EditProfile.Tooltip.SaveDescription": "Sets your profile description text",
"EditProfile.Button.ClearDescription": "Clear Description",
"EditProfile.Tooltip.ClearDescription": "Clears your profile description text",
"Intro.Welcome.Title": "Welcome to Umbra",
"Intro.Welcome.Paragraph1": "Umbra is a plugin that will replicate your full current character state including all Penumbra mods to other paired users. Note that you will have to have Penumbra as well as Glamourer installed to use this plugin.",
"Intro.Welcome.Paragraph2": "We will have to setup a few things first before you can start using this plugin. Click on next to continue.",
"Intro.Welcome.Note": "Note: Any modifications you have applied through anything but Penumbra cannot be shared and your character state on other clients might look broken because of this or others players mods might not apply on your end altogether. If you want to use this plugin you will have to move your mods to Penumbra.",
"Intro.Welcome.Next": "Next",
"Intro.Agreement.Title": "Agreement of Usage of Service",
"Intro.Agreement.Callout": "READ THIS CAREFULLY",
"Intro.Agreement.Timeout": "'I agree' button will be available in {0}s",
"Intro.Agreement.Paragraph1": "To use Umbra, you must be over the age of 18, or 21 in some jurisdictions.",
"Intro.Agreement.Paragraph2": "All of the mod files currently active on your character as well as your current character state will be uploaded to the service you registered yourself at automatically. The plugin will exclusively upload the necessary mod files and not the whole mod.",
"Intro.Agreement.Paragraph3": "If you are on a data capped internet connection, higher fees due to data usage depending on the amount of downloaded and uploaded mod files might occur. Mod files will be compressed on up- and download to save on bandwidth usage. Due to varying up- and download speeds, changes in characters might not be visible immediately. Files present on the service that already represent your active mod files will not be uploaded again.",
"Intro.Agreement.Paragraph4": "The mod files you are uploading are confidential and will not be distributed to parties other than the ones who are requesting the exact same mod files. Please think about who you are going to pair since it is unavoidable that they will receive and locally cache the necessary mod files that you have currently in use. Locally cached mod files will have arbitrary file names to discourage attempts at replicating the original mod.",
"Intro.Agreement.Paragraph5": "The plugin creator tried their best to keep you secure. However, there is no guarantee for 100% security. Do not blindly pair your client with everyone.",
"Intro.Agreement.Paragraph6": "Mod files that are saved on the service will remain on the service as long as there are requests for the files from clients. After a period of not being used, the mod files will be automatically deleted.",
"Intro.Agreement.Paragraph7": "Accounts that are inactive for ninety (90) days will be deleted for privacy reasons.",
"Intro.Agreement.Paragraph8": "Umbra is operated from servers located in the European Union. You agree not to upload any content to the service that violates EU law; and more specifically, German law.",
"Intro.Agreement.Paragraph9": "You may delete your account at any time from within the Settings panel of the plugin. Any mods unique to you will then be removed from the server within 14 days.",
"Intro.Agreement.Paragraph10": "This service is provided as-is.",
"Intro.Agreement.Accept": "I agree",
"Intro.Storage.Title": "File Storage Setup",
"Intro.Storage.Description": "To not unnecessarily download files already present on your computer, Umbra will have to scan your Penumbra mod directory. Additionally, a local storage folder must be set where Umbra will download other character files to. Once the storage folder is set and the scan complete, this page will automatically forward to registration at a service.",
"Intro.Storage.ScanNote": "Note: The initial scan, depending on the amount of mods you have, might take a while. Please wait until it is completed.",
"Intro.Storage.Warning.FileCache": "Warning: once past this step you should not delete the FileCache.csv of Umbra in the Plugin Configurations folder of Dalamud. Otherwise on the next launch a full re-scan of the file cache database will be initiated.",
"Intro.Storage.Warning.ScanHang": "Warning: if the scan is hanging and does nothing for a long time, chances are high your Penumbra folder is not set up properly.",
"Intro.Storage.NoPenumbra": "You do not have a valid Penumbra path set. Open Penumbra and set up a valid path for the mod directory.",
"Intro.Storage.StartScan": "Start Scan",
"Intro.Storage.UseCompactor": "Use File Compactor",
"Intro.Storage.CompactorDescription": "The File Compactor can save a tremendous amount of space on the hard disk for downloads through Umbra. It will incur a minor CPU penalty on download but can speed up loading of other characters. It is recommended to keep it enabled. You can change this setting later anytime in the Umbra settings.",
"Intro.Registration.Title": "Service Registration",
"Intro.Registration.Description": "To be able to use Umbra you will have to register an account.",
"Intro.Registration.Support": "Refer to the instructions at the location you obtained this plugin for more information or support.",
"Intro.Registration.NewAccountInfo": "If you have not used Umbra before, click below to register a new account.",
"Intro.Registration.RegisterButton": "Register a new Umbra account",
"Intro.Registration.SendingRequest": "Sending request...",
"Intro.Registration.Success": "New account registered.\nPlease keep a copy of your secret key in case you need to reset your plugins, or to use it on another PC.",
"Intro.Registration.UnknownError": "An unknown error occured. Please try again later.",
"Intro.Registration.SecretKeyLabel": "Enter Secret Key",
"Intro.Registration.SecretKeyLabelRegistered": "Secret Key",
"Intro.Registration.SecretKeyInstructions": "If you already have a registered account, you can enter its secret key below to use it instead.",
"Intro.Registration.SecretKeyLength": "Your secret key must be exactly 64 characters long.",
"Intro.Registration.SecretKeyCharacters": "Your secret key can only contain ABCDEF and the numbers 0-9.",
"Intro.Registration.SaveAndConnect": "Save and Connect",
"Intro.Registration.SavedKeyRegistered": "(registered {0})",
"Intro.Registration.SavedKeySetup": "Secret Key added on Setup ({0})",
"Intro.ConnectionStatus.Connected": "Connected",
"AutoDetect.Disabled": "Nearby detection is disabled. Enable it in Settings to start detecting nearby Umbra users.",
"AutoDetect.MaxDistance": "Max distance (m)",
"AutoDetect.Table.Name": "Name",
"AutoDetect.Table.World": "World",
"AutoDetect.Table.Distance": "Distance",
"AutoDetect.Table.Status": "Status",
"AutoDetect.Table.Action": "Action",
"AutoDetect.World.Unknown": "-",
"AutoDetect.Distance.Unknown": "-",
"AutoDetect.Distance.Format": "{0:0.0} m",
"AutoDetect.Status.Paired": "Paired",
"AutoDetect.Status.RequestsDisabled": "Requests disabled",
"AutoDetect.Status.OnUmbra": "On Umbra",
"AutoDetect.Action.AlreadySynced": "Already sync",
"AutoDetect.Action.RequestsDisabled": "Requests disabled",
"AutoDetect.Action.SendRequest": "Send request",
"PairGroups.ResumeAll": "Resume pairing with all pairs in {0}",
"PairGroups.PauseAll": "Pause pairing with all pairs in {0}",
"PairGroups.Menu.Title": "Group Flyout Menu",
"PairGroups.Menu.AddPeople": "Add people to {0}",
"PairGroups.Menu.AddPeople.Tooltip": "Add more users to Group {0}",
"PairGroups.Menu.Delete": "Delete {0}",
"PairGroups.Menu.Delete.Tooltip": "Delete Group {0} (Will not delete the pairs)\nHold CTRL to delete",
"PairGroups.Tag.Unpaired": "Unpaired",
"PairGroups.Tag.Offline": "Offline",
"PairGroups.Tag.Online": "Online",
"PairGroups.Tag.Contacts": "Contacts",
"PairGroups.Tag.Visible": "Visible",
"PairGroups.Header.WithCounts": "{0} ({1}/{2}/{3} Pairs)",
"PairGroups.Header.Special": "{0} ({1} Pairs)",
"PairGroups.Tooltip.Title": "Group {0}",
"PairGroups.Tooltip.Visible": "{0} Pairs visible",
"PairGroups.Tooltip.Online": "{0} Pairs online/paused",
"PairGroups.Tooltip.Total": "{0} Pairs total",
"GroupPanel.Join.InputHint": "Syncshell GID/Alias (leave empty to create)",
"GroupPanel.Join.PasswordPopup": "Enter Syncshell Password",
"GroupPanel.Create.PopupTitle": "Create Syncshell",
"GroupPanel.Create.Tooltip": "Create Syncshell",
"GroupPanel.Create.TooMany": "You cannot create more than {0} Syncshells",
"GroupPanel.Join.Tooltip": "Join Syncshell {0}",
"GroupPanel.Join.TooMany": "You cannot join more than {0} Syncshells",
"GroupPanel.Join.Warning": "Before joining any Syncshells please be aware that you will be automatically paired with everyone in the Syncshell.",
"GroupPanel.Join.EnterPassword": "Enter the password for Syncshell {0}:",
"GroupPanel.Join.PasswordHint": "{0} Password",
"GroupPanel.Join.Error": "An error occured during joining of this Syncshell: you either have joined the maximum amount of Syncshells ({0}), it does not exist, the password you entered is wrong, you already joined the Syncshell, the Syncshell is full ({1} users) or the Syncshell has closed invites.",
"GroupPanel.Join.Button": "Join {0}",
"GroupPanel.Create.ChooseType": "Choisissez le type de Syncshell \u00e0 cr\u00e9er.",
"GroupPanel.Create.Permanent": "Permanente",
"GroupPanel.Create.Temporary": "Temporaire",
"GroupPanel.Create.AliasPrompt": "Donnez un nom \u00e0 votre Syncshell (optionnel) puis cr\u00e9ez-la.",
"GroupPanel.Create.AliasHint": "Nom du Syncshell",
"GroupPanel.Create.TempMaxDuration": "Dur\u00e9e maximale d'une Syncshell temporaire : 7 jours.",
"GroupPanel.Create.TempExpires": "Expiration le {0:g} (heure locale).",
"GroupPanel.Create.Instruction": "Appuyez sur le bouton ci-dessous pour cr\u00e9er une nouvelle Syncshell.",
"GroupPanel.Create.Button": "Create Syncshell",
"GroupPanel.Create.Error.NameInUse": "Le nom de la Syncshell est d\u00e9j\u00e0 utilis\u00e9.",
"GroupPanel.Create.Result.Name": "Syncshell Name: {0}",
"GroupPanel.Create.Result.Id": "Syncshell ID: {0}",
"GroupPanel.Create.Result.Password": "Syncshell Password: {0}",
"GroupPanel.Create.Result.ChangeLater": "You can change the Syncshell password later at any time.",
"GroupPanel.Create.Result.TempExpires": "Cette Syncshell expirera le {0:g} (heure locale).",
"GroupPanel.Create.Error.Generic": "You are already owner of the maximum amount of Syncshells (3) or joined the maximum amount of Syncshells (6). Relinquish ownership of your own Syncshells to someone else or leave existing Syncshells.",
"GroupPanel.CommentHint": "Comment/Notes",
"GroupPanel.CommentTooltip": "Hit ENTER to save\\nRight click to cancel",
"GroupPanel.Banlist.Title": "Manage Banlist for {0}",
"GroupPanel.Banlist.Refresh": "Refresh Banlist from Server",
"GroupPanel.Banlist.Column.Uid": "UID",
"GroupPanel.Banlist.Column.Alias": "Alias",
"GroupPanel.Banlist.Column.By": "By",
"GroupPanel.Banlist.Column.Date": "Date",
"GroupPanel.Banlist.Column.Reason": "Reason",
"GroupPanel.Banlist.Column.Actions": "Actions",
"GroupPanel.Banlist.Unban": "Unban",
"GroupPanel.Password.Title": "Change Syncshell Password",
"GroupPanel.Password.Description": "Enter the new Syncshell password for Syncshell {0} here.",
"GroupPanel.Password.Warning": "This action is irreversible",
"GroupPanel.Password.Hint": "New password for {0}",
"GroupPanel.Password.Button": "Change password",
"GroupPanel.Password.Error.TooShort": "The selected password is too short. It must be at least 10 characters.",
"GroupPanel.Invites.Title": "Create Bulk One-Time Invites",
"GroupPanel.Invites.Description": "This allows you to create up to 100 one-time invites at once for the Syncshell {0}.\\nThe invites are valid for 24h after creation and will automatically expire.",
"GroupPanel.Invites.CreateButton": "Create invites",
"GroupPanel.Invites.Result": "A total of {0} invites have been created.",
"GroupPanel.Invites.Copy": "Copy invites to clipboard",
"GroupPanel.List.Visible": "Visible",
"GroupPanel.List.Online": "Online",
"GroupPanel.List.Offline": "Offline/Unknown",
"GroupPanel.List.OfflineOmitted": "{0} offline users omitted from display.",
"GroupPanel.Permissions.Header": "Syncshell permissions",
"GroupPanel.Permissions.InvitesDisabled": "Syncshell is closed for joining",
"GroupPanel.Permissions.SoundDisabledOwner": "Sound sync disabled through owner",
"GroupPanel.Permissions.AnimationDisabledOwner": "Animation sync disabled through owner",
"GroupPanel.Permissions.VfxDisabledOwner": "VFX sync disabled through owner",
"GroupPanel.Permissions.OwnHeader": "Your permissions",
"GroupPanel.Permissions.SoundDisabledSelf": "Sound sync disabled through you",
"GroupPanel.Permissions.AnimationDisabledSelf": "Animation sync disabled through you",
"GroupPanel.Permissions.VfxDisabledSelf": "VFX sync disabled through you",
"GroupPanel.Permissions.NotePriority": "Note that syncshell permissions for disabling take precedence over your own set permissions",
"GroupPanel.PauseToggle.Tooltip": "{0} pairing with all users in this Syncshell",
"GroupPanel.PauseToggle.Resume": "Resume",
"GroupPanel.PauseToggle.Pause": "Pause",
"GroupPanel.Popup.Leave": "Leave Syncshell",
"GroupPanel.Popup.LeaveTooltip": "Hold CTRL and click to leave this Syncshell{0}",
"GroupPanel.Popup.LeaveWarning": "WARNING: This action is irreversible\\nLeaving an owned Syncshell will transfer the ownership to a random person in the Syncshell.",
"GroupPanel.Popup.CopyId": "Copy ID",
"GroupPanel.Popup.CopyIdTooltip": "Copy Syncshell ID to Clipboard",
"GroupPanel.Popup.CopyNotes": "Copy Notes",
"GroupPanel.Popup.CopyNotesTooltip": "Copies all your notes for all users in this Syncshell to the clipboard.\\nThey can be imported via Settings -> General -> Notes -> Import notes from clipboard",
"GroupPanel.Popup.EnableSound": "Enable sound sync",
"GroupPanel.Popup.DisableSound": "Disable sound sync",
"GroupPanel.Popup.SoundTooltip": "Sets your allowance for sound synchronization for users of this syncshell.\\nDisabling the synchronization will stop applying sound modifications for users of this syncshell.\\nNote: this setting can be forcefully overridden to 'disabled' through the syncshell owner.\\nNote: this setting does not apply to individual pairs that are also in the syncshell.",
"GroupPanel.Popup.EnableAnimations": "Enable animations sync",
"GroupPanel.Popup.DisableAnimations": "Disable animations sync",
"GroupPanel.Popup.AnimTooltip": "Sets your allowance for animations synchronization for users of this syncshell.\\nDisabling the synchronization will stop applying animations modifications for users of this syncshell.\\nNote: this setting might also affect sound synchronization\\nNote: this setting can be forcefully overridden to 'disabled' through the syncshell owner.\\nNote: this setting does not apply to individual pairs that are also in the syncshell.",
"GroupPanel.Popup.EnableVfx": "Enable VFX sync",
"GroupPanel.Popup.DisableVfx": "Disable VFX sync",
"GroupPanel.Popup.VfxTooltip": "Sets your allowance for VFX synchronization for users of this syncshell.\\nDisabling the synchronization will stop applying VFX modifications for users of this syncshell.\\nNote: this setting might also affect animation synchronization to some degree\\nNote: this setting can be forcefully overridden to 'disabled' through the syncshell owner.\\nNote: this setting does not apply to individual pairs that are also in the syncshell.",
"GroupPanel.Syncshell.OwnerTooltip": "You are the owner of Syncshell {0}",
"GroupPanel.Syncshell.ModeratorTooltip": "You are a moderator of Syncshell {0}",
"GroupPanel.Syncshell.MemberCount": "{0}/{1}",
"GroupPanel.Syncshell.MemberCountTooltip": "Membres connect\u00e9s / membres totaux\\nCapacit\u00e9 maximale : {0}\\nSyncshell ID: {1}",
"GroupPanel.Syncshell.NameTooltip": "Left click to switch between GID display and comment\\nRight click to change comment for {0}\\n\\nUsers: {1}, Owner: {2}",
"GroupPanel.Syncshell.TempTag": "(Temp)",
"GroupPanel.Syncshell.TempExpires": "Expire le {0:g}",
"GroupPanel.Syncshell.TempTooltip": "Syncshell temporaire",
"GroupPanel.Create.Duration.SingleDay": "24h",
"GroupPanel.Create.Duration.Days": "{0}j",
"GroupPanel.Create.Duration.Hours": "{0}h",
"GroupPanel.Invites.AmountLabel": "Amount",
"GroupPanel.Popup.OpenAdmin": "Open Admin Panel"
}

View File

@@ -0,0 +1,579 @@
{
"Language.DisplayName.French": "Français",
"Language.DisplayName.English": "Anglais",
"Settings.Plugins.MandatoryHeading": "",
"Settings.Plugins.MandatoryLabel": "",
"Settings.Plugins.OptionalHeading": "",
"Settings.Plugins.OptionalLabel": "",
"Settings.Plugins.OptionalDescription": "",
"Settings.Plugins.Tooltip.Available": "",
"Settings.Plugins.Tooltip.Unavailable": "",
"Settings.Plugins.WarningMandatoryMissing": "",
"Settings.General.LocalizationHeading": "Langue du plugin",
"Settings.General.Language": "Langue",
"Settings.General.Language.Description": "Sélectionnez la langue du plugin. Les traductions manquantes seront affichées en anglais.",
"Settings.General.NotesHeading": "",
"Settings.General.Notes.Export": "",
"Settings.General.Notes.Import": "",
"Settings.General.Notes.Overwrite": "",
"Settings.General.Notes.Overwrite.Description": "",
"Settings.General.Notes.Import.Success": "",
"Settings.General.Notes.Import.Failure": "",
"Settings.General.Notes.OpenPopup": "",
"Settings.General.Notes.OpenPopup.Description": "",
"Settings.Transfers.Blocked.Description": "",
"Settings.Transfers.Blocked.Column.Hash": "",
"Settings.Transfers.Blocked.Column.ForbiddenBy": "",
"Settings.Transfers.Blocked.Tab": "",
"Settings.Transfers.Heading": "",
"Settings.Transfers.GlobalLimit.Label": "",
"Settings.Transfers.GlobalLimit.Unit.BytePerSec": "",
"Settings.Transfers.GlobalLimit.Unit.KiloBytePerSec": "",
"Settings.Transfers.GlobalLimit.Unit.MegaBytePerSec": "",
"Settings.Transfers.GlobalLimit.Hint": "",
"Settings.Transfers.MaxParallelDownloads": "",
"Settings.Transfers.AutoDetect.Heading": "",
"Settings.Transfers.AutoDetect.EnableNearby": "",
"Settings.Transfers.AutoDetect.AllowRequests": "",
"Settings.Transfers.AutoDetect.Notification.Title": "",
"Settings.Transfers.AutoDetect.Notification.Enabled": "",
"Settings.Transfers.AutoDetect.Notification.Disabled": "",
"Settings.Transfers.AutoDetect.MaxDistance": "",
"Settings.Transfers.UI.Heading": "",
"Settings.Transfers.UI.ShowWindow": "",
"Settings.Transfers.UI.ShowWindow.Description": "",
"Settings.Transfers.UI.EditWindowPosition": "",
"Settings.Transfers.UI.ShowTransferBars": "",
"Settings.Transfers.UI.ShowTransferBars.Description": "",
"Settings.Transfers.UI.ShowDownloadText": "",
"Settings.Transfers.UI.ShowDownloadText.Description": "",
"Settings.Transfers.UI.BarWidth": "",
"Settings.Transfers.UI.BarWidth.Description": "",
"Settings.Transfers.UI.BarHeight": "",
"Settings.Transfers.UI.BarHeight.Description": "",
"Settings.Transfers.UI.ShowUploading": "",
"Settings.Transfers.UI.ShowUploading.Description": "",
"Settings.Transfers.UI.ShowUploadingBigText": "",
"Settings.Transfers.UI.ShowUploadingBigText.Description": "",
"Settings.Transfers.Current.Heading": "",
"Settings.Transfers.Current.Tab": "",
"Settings.Transfers.Current.Uploads": "",
"Settings.Transfers.Current.Uploads.Column.File": "",
"Settings.Transfers.Current.Uploads.Column.Uploaded": "",
"Settings.Transfers.Current.Uploads.Column.Size": "",
"Settings.Transfers.Current.Downloads": "",
"Settings.Transfers.Current.Downloads.Column.User": "",
"Settings.Transfers.Current.Downloads.Column.Server": "",
"Settings.Transfers.Current.Downloads.Column.Files": "",
"Settings.Transfers.Current.Downloads.Column.Download": "",
"Settings.Storage.Heading": "",
"Settings.Storage.Description": "",
"Settings.Service.ActionsHeading": "",
"Settings.Service.Actions.DeleteAccount": "",
"Settings.Service.Actions.DeleteAccountPopup": "",
"Settings.Service.Actions.DeleteAccount.Description": "",
"Settings.Service.Actions.DeleteAccount.Popup.Body1": "",
"Settings.Service.Actions.DeleteAccount.Popup.Body2": "",
"Settings.Service.Actions.DeleteAccount.Popup.Confirm": "",
"Settings.Service.Actions.DeleteAccount.Popup.Cancel": "",
"Settings.Service.SettingsHeading": "",
"Settings.Service.ReconnectWarning": "",
"Settings.Service.Tabs.CharacterAssignments": "",
"Settings.Service.Tabs.SecretKey": "",
"Settings.Service.Tabs.ServiceSettings": "",
"Settings.Service.Character.Assignments.Description": "",
"Settings.Service.Character.Assignments.TooltipCurrent": "",
"Settings.Service.Character.Assignments.DeleteTooltip": "",
"Settings.Service.Character.Assignments.AddCurrent": "",
"Settings.Service.Character.Assignments.NoKeys": "",
"Settings.Service.SecretKey.DisplayName": "",
"Settings.Service.SecretKey.Value": "",
"Settings.Service.SecretKey.AssignCurrent": "",
"Settings.Service.SecretKey.AssignTooltip": "",
"Settings.Service.SecretKey.Delete": "",
"Settings.Service.SecretKey.DeleteTooltip": "",
"Settings.Service.SecretKey.InUse": "",
"Settings.Service.SecretKey.Add": "",
"Settings.Service.SecretKey.NewFriendlyName": "",
"Settings.Service.SecretKey.RegisterAccount": "",
"Settings.Service.SecretKey.RegisterFailed": "",
"Settings.Service.SecretKey.RegisterSuccess": "",
"Settings.Service.SecretKey.RegisteredFriendlyName": "",
"Settings.Service.SecretKey.Registering": "",
"Settings.Service.ServiceTab.Uri": "",
"Settings.Service.ServiceTab.UriReadOnlyHint": "",
"Settings.Service.ServiceTab.Name": "",
"Settings.Service.ServiceTab.NameReadOnlyHint": "",
"Settings.Service.ServiceTab.Delete": "",
"Settings.Service.ServiceTab.DeleteHint": "",
"Settings.Advanced.Heading": "",
"Settings.Advanced.Tab": "",
"Settings.Advanced.Api.Enable": "",
"Settings.Advanced.Api.Description": "",
"Settings.Advanced.Api.Status.Active": "",
"Settings.Advanced.Api.Status.Disabled": "",
"Settings.Advanced.Api.Status.PluginLoaded": "",
"Settings.Advanced.Api.Status.Unknown": "",
"Settings.Advanced.EventViewer.LogToDisk": "",
"Settings.Advanced.EventViewer.Open": "",
"Settings.Advanced.HoldCombat": "",
"Settings.Advanced.SerializedApplications": "",
"Settings.Advanced.SerializedApplications.Description": "",
"Settings.Advanced.DebugHeading": "",
"Settings.Advanced.Debug.LastCreatedTree": "",
"Settings.Advanced.Debug.CopyButton": "",
"Settings.Advanced.Debug.CopyError": "",
"Settings.Advanced.Debug.CopyTooltip": "",
"Settings.Advanced.LogLevel": "",
"Settings.Advanced.Performance.LogCounters": "",
"Settings.Advanced.Performance.LogCounters.Description": "",
"Settings.Advanced.Performance.PrintStats": "",
"Settings.Advanced.Performance.PrintStatsRecent": "",
"Settings.Advanced.ActiveBlocks": "",
"Settings.UI.Heading": "",
"Settings.UI.EnableRightClick": "",
"Settings.UI.EnableRightClick.Description": "",
"Settings.UI.EnableDtrEntry": "",
"Settings.UI.EnableDtrEntry.Description": "",
"Settings.UI.Dtr.ShowUid": "",
"Settings.UI.Dtr.PreferNotes": "",
"Settings.UI.Dtr.UseColors": "",
"Settings.UI.Dtr.ColorDefault": "",
"Settings.UI.Dtr.ColorNotConnected": "",
"Settings.UI.Dtr.ColorPairsInRange": "",
"Settings.UI.NameColors.Enable": "",
"Settings.UI.NameColors.Character": "",
"Settings.UI.NameColors.Blocked": "",
"Settings.UI.VisibleGroup": "",
"Settings.UI.VisibleGroup.Description": "",
"Settings.UI.OfflineGroup": "",
"Settings.UI.OfflineGroup.Description": "",
"Settings.UI.ShowPlayerNames": "",
"Settings.UI.ShowPlayerNames.Description": "",
"Settings.UI.Profiles.Show": "",
"Settings.UI.Profiles.Show.Description": "",
"Settings.UI.Profiles.PopoutRight": "",
"Settings.UI.Profiles.PopoutRight.Description": "",
"Settings.UI.Profiles.HoverDelay": "",
"Settings.UI.Profiles.HoverDelay.Description": "",
"Settings.UI.Profiles.ShowNsfw": "",
"Settings.UI.Profiles.ShowNsfw.Description": "",
"Settings.Notifications.Heading": "",
"Settings.Notifications.InfoDisplay": "",
"Settings.Notifications.InfoDisplay.Description": "",
"Settings.Notifications.WarningDisplay": "",
"Settings.Notifications.WarningDisplay.Description": "",
"Settings.Notifications.ErrorDisplay": "",
"Settings.Notifications.ErrorDisplay.Description": "",
"Settings.Notifications.Location.Nowhere": "",
"Settings.Notifications.Location.Chat": "",
"Settings.Notifications.Location.Toast": "",
"Settings.Notifications.Location.Both": "",
"Settings.Notifications.DisableOptionalWarnings": "",
"Settings.Notifications.DisableOptionalWarnings.Description": "",
"Settings.Notifications.EnableOnlineNotifications": "",
"Settings.Notifications.EnableOnlineNotifications.Description": "",
"Settings.Notifications.IndividualPairsOnly": "",
"Settings.Notifications.IndividualPairsOnly.Description": "",
"Settings.Notifications.NamedPairsOnly": "",
"Settings.Notifications.NamedPairsOnly.Description": "",
"Compact.Version.UnsupportedTitle": "",
"Compact.Version.Outdated": "",
"Compact.Toggle.IndividualPairs": "",
"Compact.Toggle.Syncshells": "",
"Compact.AddUser.ModalTitle": "",
"Compact.AddUser.Description": "",
"Compact.AddUser.NoteHint": "",
"Compact.AddUser.Save": "",
"Compact.AddCharacter.Button": "",
"Compact.AddCharacter.SecretKeyLabel": "",
"Compact.AddCharacter.NoKeys": "",
"Compact.AddPair.Hint": "",
"Compact.AddPair.Tooltip": "",
"Compact.AddPair.Tooltip.DefaultUser": "",
"Compact.Filter.Hint": "",
"Compact.Filter.ToggleTooltip": "",
"Compact.Filter.ToggleTooltip.Resume": "",
"Compact.Filter.ToggleTooltip.Pause": "",
"Compact.Filter.CooldownTooltip": "",
"Compact.Nearby.Title": "",
"Compact.Nearby.Button": "",
"Compact.Nearby.None": "",
"Compact.Nearby.Tooltip.AlreadyPaired": "",
"Compact.Nearby.Tooltip.RequestsDisabled": "",
"Compact.Nearby.Tooltip.SendInvite": "",
"Compact.Nearby.Tooltip.CannotInvite": "",
"Compact.Nearby.Incoming": "",
"Compact.Nearby.Incoming.Entry": "",
"Compact.Nearby.Incoming.Accept": "",
"Compact.Nearby.Incoming.Dismiss": "",
"Compact.Header.SettingsTooltip": "",
"Compact.Header.CopyUid": "",
"Compact.ServerStatus.UsersOnline": "",
"Compact.ServerStatus.Shard": "",
"Compact.ServerStatus.NotConnected": "",
"Compact.ServerStatus.EditProfile": "",
"Compact.ServerStatus.Disconnect": "",
"Compact.ServerStatus.Connect": "",
"Compact.ServerError.Connecting": "",
"Compact.ServerError.Reconnecting": "",
"Compact.ServerError.Disconnected": "",
"Compact.ServerError.Disconnecting": "",
"Compact.ServerError.Unauthorized": "",
"Compact.ServerError.Offline": "",
"Compact.ServerError.VersionMismatch": "",
"Compact.ServerError.RateLimited": "",
"Compact.ServerError.NoSecretKey": "",
"Compact.ServerError.MultiChara": "",
"Compact.Transfers.CharacterAnalysis": "",
"Compact.Transfers.CharacterDataHub": "",
"Compact.UidText.Reconnecting": "",
"Compact.UidText.Connecting": "",
"Compact.UidText.Disconnected": "",
"Compact.UidText.Disconnecting": "",
"Compact.UidText.Unauthorized": "",
"Compact.UidText.VersionMismatch": "",
"Compact.UidText.Offline": "",
"Compact.UidText.RateLimited": "",
"Compact.UidText.NoSecretKey": "",
"Compact.UidText.MultiChara": "",
"UserPair.Status.Online": "",
"UserPair.Status.Offline": "",
"UserPair.Tooltip.NotAddedBack": "",
"UserPair.Tooltip.Paused": "",
"UserPair.Tooltip.Visible": "",
"UserPair.Tooltip.Visible.LastPrefix": "",
"UserPair.Tooltip.Visible.ModsInfo": "",
"UserPair.Tooltip.Visible.FilesSize": "",
"UserPair.Tooltip.Visible.Vram": "",
"UserPair.Tooltip.Visible.Tris": "",
"UserPair.Tooltip.Pause": "",
"UserPair.Tooltip.Resume": "",
"UserPair.Tooltip.Permission.Header": "",
"UserPair.Tooltip.Permission.Sound": "",
"UserPair.Tooltip.Permission.Animation": "",
"UserPair.Tooltip.Permission.Vfx": "",
"UserPair.Tooltip.Permission.Status": "",
"UserPair.Tooltip.Permission.State.Disabled": "",
"UserPair.Tooltip.Permission.State.Enabled": "",
"UserPair.Tooltip.SharedData": "",
"UserPair.Tooltip.SharedData.OpenHub": "",
"UserPair.Menu.Target": "",
"UserPair.Menu.OpenProfile": "",
"UserPair.Menu.OpenProfile.Tooltip": "",
"UserPair.Menu.OpenAnalysis": "",
"UserPair.Menu.ReloadData": "",
"UserPair.Menu.ReloadData.Tooltip": "",
"UserPair.Menu.CyclePause": "",
"UserPair.Menu.PairGroups": "",
"UserPair.Menu.PairGroups.Tooltip": "",
"UserPair.Menu.EnableSoundSync": "",
"UserPair.Menu.DisableSoundSync": "",
"UserPair.Menu.EnableAnimationSync": "",
"UserPair.Menu.DisableAnimationSync": "",
"UserPair.Menu.EnableVfxSync": "",
"UserPair.Menu.DisableVfxSync": "",
"UserPair.Menu.Unpair": "",
"UserPair.Menu.Unpair.Tooltip": "",
"Popup.Generic.Close": "",
"Popup.BanUser.Description": "",
"Popup.BanUser.ReasonHint": "",
"Popup.BanUser.Button": "",
"Popup.BanUser.ReasonNote": "",
"Popup.Report.Title": "",
"Popup.Report.Note": "",
"Popup.Report.Warning": "",
"Popup.Report.Scope": "",
"Popup.Report.Button": "",
"PairGroups.Popup.Title": "",
"PairGroups.Popup.SelectPrompt": "",
"PairGroups.Popup.CreatePrompt": "",
"PairGroups.Popup.NewGroupHint": "",
"PairGroups.SelectPairs.Title": "",
"PairGroups.SelectPairs.SelectPrompt": "",
"PairGroups.SelectPairs.FilterHint": "",
"UidDisplay.Tooltip": "",
"UidDisplay.EditNotes.Hint": "",
"DataAnalysis.WindowTitle": "",
"DataAnalysis.Bc7.ModalTitle": "",
"DataAnalysis.Bc7.Status": "",
"DataAnalysis.Bc7.CurrentFile": "",
"DataAnalysis.Bc7.Cancel": "",
"DataAnalysis.Description": "",
"DataAnalysis.Analyzing": "",
"DataAnalysis.Button.CancelAnalysis": "",
"DataAnalysis.Analyze.MissingNotice": "",
"DataAnalysis.Button.StartMissing": "",
"DataAnalysis.Button.StartAll": "",
"DataAnalysis.TotalFiles": "",
"DataAnalysis.Tooltip.FileSummary": "",
"DataAnalysis.TotalSizeActual": "",
"DataAnalysis.TotalSizeDownload": "",
"DataAnalysis.Tooltip.CalculateDownloadSize": "",
"DataAnalysis.TotalTriangles": "",
"DataAnalysis.FilesFor": "",
"DataAnalysis.Object.SizeActual": "",
"DataAnalysis.Object.SizeDownload": "",
"DataAnalysis.Object.Vram": "",
"DataAnalysis.Object.Triangles": "",
"DataAnalysis.FileGroup.Count": "",
"DataAnalysis.FileGroup.SizeActual": "",
"DataAnalysis.FileGroup.SizeDownload": "",
"DataAnalysis.Bc7.EnableMode": "",
"DataAnalysis.Bc7.WarningTitle": "",
"DataAnalysis.Bc7.WarningIrreversible": "",
"DataAnalysis.Bc7.WarningDetails": "",
"DataAnalysis.Bc7.StartConversion": "",
"DataAnalysis.Table.Hash": "",
"DataAnalysis.Table.Filepaths": "",
"DataAnalysis.Table.Gamepaths": "",
"DataAnalysis.Table.FileSize": "",
"DataAnalysis.Table.DownloadSize": "",
"DataAnalysis.Table.Format": "",
"DataAnalysis.Table.ConvertToBc7": "",
"DataAnalysis.Table.Triangles": "",
"DataAnalysis.SelectedFile": "",
"DataAnalysis.LocalFilePath": "",
"DataAnalysis.MoreCount": "",
"DataAnalysis.GamePath": "",
"DownloadUi.WindowTitle": "",
"DownloadUi.UploadStatus": "",
"DownloadUi.DownloadStatus": "",
"DownloadUi.UploadingLabel": "",
"EventViewer.WindowTitle": "",
"EventViewer.Button.Unfreeze": "",
"EventViewer.Button.Freeze": "",
"EventViewer.Tooltip.NewEvents": "",
"EventViewer.FilterLabel": "",
"EventViewer.Button.OpenLog": "",
"EventViewer.Column.Time": "",
"EventViewer.Column.Source": "",
"EventViewer.Column.Uid": "",
"EventViewer.Column.Character": "",
"EventViewer.Column.Event": "",
"EventViewer.Severity.Informational": "",
"EventViewer.Severity.Warning": "",
"EventViewer.Severity.Error": "",
"EventViewer.NoValue": "",
"DtrEntry.EntryName": "",
"DtrEntry.Tooltip.Connected": "",
"DtrEntry.Tooltip.Disconnected": "",
"PermissionWindow.Title": "",
"PermissionWindow.Pause.Label": "",
"PermissionWindow.Pause.HelpMain": "",
"PermissionWindow.Pause.HelpNote": "",
"PermissionWindow.OtherPaused.True": "",
"PermissionWindow.OtherPaused.False": "",
"PermissionWindow.Sounds.Label": "",
"PermissionWindow.Sounds.HelpMain": "",
"PermissionWindow.Sounds.HelpNote": "",
"PermissionWindow.OtherSoundDisabled.True": "",
"PermissionWindow.OtherSoundDisabled.False": "",
"PermissionWindow.Animations.Label": "",
"PermissionWindow.Animations.HelpMain": "",
"PermissionWindow.Animations.HelpNote": "",
"PermissionWindow.OtherAnimationDisabled.True": "",
"PermissionWindow.OtherAnimationDisabled.False": "",
"PermissionWindow.Vfx.Label": "",
"PermissionWindow.Vfx.HelpMain": "",
"PermissionWindow.Vfx.HelpNote": "",
"PermissionWindow.OtherVfxDisabled.True": "",
"PermissionWindow.OtherVfxDisabled.False": "",
"PermissionWindow.Button.Save": "",
"PermissionWindow.Tooltip.Save": "",
"PermissionWindow.Button.Revert": "",
"PermissionWindow.Tooltip.Revert": "",
"PermissionWindow.Button.Reset": "",
"PermissionWindow.Tooltip.Reset": "",
"EditProfile.WindowTitle": "",
"EditProfile.CurrentProfile": "",
"EditProfile.Button.UploadPicture": "",
"EditProfile.Dialog.PictureTitle": "",
"EditProfile.Tooltip.UploadPicture": "",
"EditProfile.Button.ClearPicture": "",
"EditProfile.Tooltip.ClearPicture": "",
"EditProfile.Error.PictureTooLarge": "",
"EditProfile.Checkbox.Nsfw": "",
"EditProfile.Help.Nsfw": "",
"EditProfile.DescriptionCounter": "",
"EditProfile.PreviewLabel": "",
"EditProfile.Button.SaveDescription": "",
"EditProfile.Tooltip.SaveDescription": "",
"EditProfile.Button.ClearDescription": "",
"EditProfile.Tooltip.ClearDescription": "",
"Intro.Welcome.Title": "",
"Intro.Welcome.Paragraph1": "",
"Intro.Welcome.Paragraph2": "",
"Intro.Welcome.Note": "",
"Intro.Welcome.Next": "",
"Intro.Agreement.Title": "",
"Intro.Agreement.Callout": "",
"Intro.Agreement.Timeout": "",
"Intro.Agreement.Paragraph1": "",
"Intro.Agreement.Paragraph2": "",
"Intro.Agreement.Paragraph3": "",
"Intro.Agreement.Paragraph4": "",
"Intro.Agreement.Paragraph5": "",
"Intro.Agreement.Paragraph6": "",
"Intro.Agreement.Paragraph7": "",
"Intro.Agreement.Paragraph8": "",
"Intro.Agreement.Paragraph9": "",
"Intro.Agreement.Paragraph10": "",
"Intro.Agreement.Accept": "",
"Intro.Storage.Title": "",
"Intro.Storage.Description": "",
"Intro.Storage.ScanNote": "",
"Intro.Storage.Warning.FileCache": "",
"Intro.Storage.Warning.ScanHang": "",
"Intro.Storage.NoPenumbra": "",
"Intro.Storage.StartScan": "",
"Intro.Storage.UseCompactor": "",
"Intro.Storage.CompactorDescription": "",
"Intro.Registration.Title": "",
"Intro.Registration.Description": "",
"Intro.Registration.Support": "",
"Intro.Registration.NewAccountInfo": "",
"Intro.Registration.RegisterButton": "",
"Intro.Registration.SendingRequest": "",
"Intro.Registration.Success": "",
"Intro.Registration.UnknownError": "",
"Intro.Registration.SecretKeyLabel": "",
"Intro.Registration.SecretKeyLabelRegistered": "",
"Intro.Registration.SecretKeyInstructions": "",
"Intro.Registration.SecretKeyLength": "",
"Intro.Registration.SecretKeyCharacters": "",
"Intro.Registration.SaveAndConnect": "",
"Intro.Registration.SavedKeyRegistered": "",
"Intro.Registration.SavedKeySetup": "",
"Intro.ConnectionStatus.Connected": "",
"AutoDetect.Disabled": "",
"AutoDetect.MaxDistance": "",
"AutoDetect.Table.Name": "",
"AutoDetect.Table.World": "",
"AutoDetect.Table.Distance": "",
"AutoDetect.Table.Status": "",
"AutoDetect.Table.Action": "",
"AutoDetect.World.Unknown": "",
"AutoDetect.Distance.Unknown": "",
"AutoDetect.Distance.Format": "",
"AutoDetect.Status.Paired": "",
"AutoDetect.Status.RequestsDisabled": "",
"AutoDetect.Status.OnUmbra": "",
"AutoDetect.Action.AlreadySynced": "",
"AutoDetect.Action.RequestsDisabled": "",
"AutoDetect.Action.SendRequest": "",
"PairGroups.ResumeAll": "",
"PairGroups.PauseAll": "",
"PairGroups.Menu.Title": "",
"PairGroups.Menu.AddPeople": "",
"PairGroups.Menu.AddPeople.Tooltip": "",
"PairGroups.Menu.Delete": "",
"PairGroups.Menu.Delete.Tooltip": "",
"PairGroups.Tag.Unpaired": "",
"PairGroups.Tag.Offline": "",
"PairGroups.Tag.Online": "",
"PairGroups.Tag.Contacts": "",
"PairGroups.Tag.Visible": "",
"PairGroups.Header.WithCounts": "",
"PairGroups.Header.Special": "",
"PairGroups.Tooltip.Title": "",
"PairGroups.Tooltip.Visible": "",
"PairGroups.Tooltip.Online": "",
"PairGroups.Tooltip.Total": "",
"GroupPanel.Join.InputHint": "",
"GroupPanel.Join.PasswordPopup": "",
"GroupPanel.Create.PopupTitle": "",
"GroupPanel.Create.Tooltip": "",
"GroupPanel.Create.TooMany": "",
"GroupPanel.Join.Tooltip": "",
"GroupPanel.Join.TooMany": "",
"GroupPanel.Join.Warning": "",
"GroupPanel.Join.EnterPassword": "",
"GroupPanel.Join.PasswordHint": "",
"GroupPanel.Join.Error": "",
"GroupPanel.Join.Button": "",
"GroupPanel.Create.ChooseType": "",
"GroupPanel.Create.Permanent": "",
"GroupPanel.Create.Temporary": "",
"GroupPanel.Create.AliasPrompt": "",
"GroupPanel.Create.AliasHint": "",
"GroupPanel.Create.TempMaxDuration": "",
"GroupPanel.Create.TempExpires": "",
"GroupPanel.Create.Instruction": "",
"GroupPanel.Create.Button": "",
"GroupPanel.Create.Error.NameInUse": "",
"GroupPanel.Create.Result.Name": "",
"GroupPanel.Create.Result.Id": "",
"GroupPanel.Create.Result.Password": "",
"GroupPanel.Create.Result.ChangeLater": "",
"GroupPanel.Create.Result.TempExpires": "",
"GroupPanel.Create.Error.Generic": "",
"GroupPanel.CommentHint": "",
"GroupPanel.CommentTooltip": "",
"GroupPanel.Banlist.Title": "",
"GroupPanel.Banlist.Refresh": "",
"GroupPanel.Banlist.Column.Uid": "",
"GroupPanel.Banlist.Column.Alias": "",
"GroupPanel.Banlist.Column.By": "",
"GroupPanel.Banlist.Column.Date": "",
"GroupPanel.Banlist.Column.Reason": "",
"GroupPanel.Banlist.Column.Actions": "",
"GroupPanel.Banlist.Unban": "",
"GroupPanel.Password.Title": "",
"GroupPanel.Password.Description": "",
"GroupPanel.Password.Warning": "",
"GroupPanel.Password.Hint": "",
"GroupPanel.Password.Button": "",
"GroupPanel.Password.Error.TooShort": "",
"GroupPanel.Invites.Title": "",
"GroupPanel.Invites.Description": "",
"GroupPanel.Invites.CreateButton": "",
"GroupPanel.Invites.Result": "",
"GroupPanel.Invites.Copy": "",
"GroupPanel.List.Visible": "",
"GroupPanel.List.Online": "",
"GroupPanel.List.Offline": "",
"GroupPanel.List.OfflineOmitted": "",
"GroupPanel.Permissions.Header": "",
"GroupPanel.Permissions.InvitesDisabled": "",
"GroupPanel.Permissions.SoundDisabledOwner": "",
"GroupPanel.Permissions.AnimationDisabledOwner": "",
"GroupPanel.Permissions.VfxDisabledOwner": "",
"GroupPanel.Permissions.OwnHeader": "",
"GroupPanel.Permissions.SoundDisabledSelf": "",
"GroupPanel.Permissions.AnimationDisabledSelf": "",
"GroupPanel.Permissions.VfxDisabledSelf": "",
"GroupPanel.Permissions.NotePriority": "",
"GroupPanel.PauseToggle.Tooltip": "",
"GroupPanel.PauseToggle.Resume": "",
"GroupPanel.PauseToggle.Pause": "",
"GroupPanel.Popup.Leave": "",
"GroupPanel.Popup.LeaveTooltip": "",
"GroupPanel.Popup.LeaveWarning": "",
"GroupPanel.Popup.CopyId": "",
"GroupPanel.Popup.CopyIdTooltip": "",
"GroupPanel.Popup.CopyNotes": "",
"GroupPanel.Popup.CopyNotesTooltip": "",
"GroupPanel.Popup.EnableSound": "",
"GroupPanel.Popup.DisableSound": "",
"GroupPanel.Popup.SoundTooltip": "",
"GroupPanel.Popup.EnableAnimations": "",
"GroupPanel.Popup.DisableAnimations": "",
"GroupPanel.Popup.AnimTooltip": "",
"GroupPanel.Popup.EnableVfx": "",
"GroupPanel.Popup.DisableVfx": "",
"GroupPanel.Popup.VfxTooltip": "",
"GroupPanel.Syncshell.OwnerTooltip": "",
"GroupPanel.Syncshell.ModeratorTooltip": "",
"GroupPanel.Syncshell.MemberCount": "",
"GroupPanel.Syncshell.MemberCountTooltip": "",
"GroupPanel.Syncshell.NameTooltip": "",
"GroupPanel.Syncshell.TempTag": "",
"GroupPanel.Syncshell.TempExpires": "",
"GroupPanel.Syncshell.TempTooltip": "",
"GroupPanel.Create.Duration.SingleDay": "",
"GroupPanel.Create.Duration.Days": "",
"GroupPanel.Create.Duration.Hours": "",
"GroupPanel.Invites.AmountLabel": "",
"GroupPanel.Popup.OpenAdmin": ""
}

View File

@@ -0,0 +1,11 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class CharaDataConfigService : ConfigurationServiceBase<CharaDataConfig>
{
public const string ConfigName = "charadata.json";
public CharaDataConfigService(string configDir) : base(configDir) { }
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,13 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public static class ConfigurationExtensions
{
public static bool HasValidSetup(this MareConfig configuration)
{
return configuration.AcceptedAgreement && configuration.InitialScanComplete
&& !string.IsNullOrEmpty(configuration.CacheFolder)
&& Directory.Exists(configuration.CacheFolder);
}
}

View File

@@ -0,0 +1,82 @@
using MareSynchronos.WebAPI;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.MareConfiguration;
public class ConfigurationMigrator(ILogger<ConfigurationMigrator> logger, MareConfigService mareConfig) : IHostedService
{
private readonly ILogger<ConfigurationMigrator> _logger = logger;
private readonly MareConfigService _mareConfig = mareConfig;
public void Migrate()
{
try
{
var path = _mareConfig.ConfigurationPath;
if (!File.Exists(path)) return;
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var root = doc.RootElement;
bool changed = false;
if (root.TryGetProperty("EnableAutoSyncDiscovery", out var enableAutoSync))
{
var val = enableAutoSync.GetBoolean();
if (_mareConfig.Current.EnableAutoDetectDiscovery != val)
{
_mareConfig.Current.EnableAutoDetectDiscovery = val;
changed = true;
}
}
if (root.TryGetProperty("AllowAutoSyncPairRequests", out var allowAutoSync))
{
var val = allowAutoSync.GetBoolean();
if (_mareConfig.Current.AllowAutoDetectPairRequests != val)
{
_mareConfig.Current.AllowAutoDetectPairRequests = val;
changed = true;
}
}
if (root.TryGetProperty("AutoSyncMaxDistanceMeters", out var maxDistSync) && maxDistSync.TryGetInt32(out var md))
{
if (_mareConfig.Current.AutoDetectMaxDistanceMeters != md)
{
_mareConfig.Current.AutoDetectMaxDistanceMeters = md;
changed = true;
}
}
if (root.TryGetProperty("AutoSyncMuteMinutes", out var muteSync) && muteSync.TryGetInt32(out var mm))
{
if (_mareConfig.Current.AutoDetectMuteMinutes != mm)
{
_mareConfig.Current.AutoDetectMuteMinutes = mm;
changed = true;
}
}
if (changed)
{
_logger.LogInformation("Migrated config: AutoSync -> AutoDetect fields");
_mareConfig.Save();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Configuration migration failed");
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
Migrate();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,137 @@
using MareSynchronos.MareConfiguration.Configurations;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Reflection;
using System.Text.Json;
namespace MareSynchronos.MareConfiguration;
public class ConfigurationSaveService : IHostedService
{
private readonly HashSet<object> _configsToSave = [];
private readonly ILogger<ConfigurationSaveService> _logger;
private readonly SemaphoreSlim _configSaveSemaphore = new(1, 1);
private readonly CancellationTokenSource _configSaveCheckCts = new();
public const string BackupFolder = "config_backup";
private readonly MethodInfo _saveMethod;
public ConfigurationSaveService(ILogger<ConfigurationSaveService> logger, IEnumerable<IConfigService<IMareConfiguration>> configs)
{
foreach (var config in configs)
{
config.ConfigSave += OnConfigurationSave;
}
_logger = logger;
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
_saveMethod = GetType().GetMethod(nameof(SaveConfig), BindingFlags.Instance | BindingFlags.NonPublic)!;
#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
}
private void OnConfigurationSave(object? sender, EventArgs e)
{
_configSaveSemaphore.Wait();
_configsToSave.Add(sender!);
_configSaveSemaphore.Release();
}
private async Task PeriodicSaveCheck(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await SaveConfigs().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during SaveConfigs");
}
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
}
}
private async Task SaveConfigs()
{
if (_configsToSave.Count == 0) return;
await _configSaveSemaphore.WaitAsync().ConfigureAwait(false);
var configList = _configsToSave.ToList();
_configsToSave.Clear();
_configSaveSemaphore.Release();
foreach (var config in configList)
{
var expectedType = config.GetType().BaseType!.GetGenericArguments()[0];
var save = _saveMethod.MakeGenericMethod(expectedType);
await ((Task)save.Invoke(this, [config])!).ConfigureAwait(false);
}
}
private async Task SaveConfig<T>(IConfigService<T> config) where T : IMareConfiguration
{
_logger.LogTrace("Saving {configName}", config.ConfigurationName);
var configDir = config.ConfigurationPath.Replace(config.ConfigurationName, string.Empty);
try
{
var configBackupFolder = Path.Join(configDir, BackupFolder);
if (!Directory.Exists(configBackupFolder))
Directory.CreateDirectory(configBackupFolder);
var configNameSplit = config.ConfigurationName.Split(".");
var existingConfigs = Directory.EnumerateFiles(
configBackupFolder,
configNameSplit[0] + "*")
.Select(c => new FileInfo(c))
.OrderByDescending(c => c.LastWriteTime).ToList();
if (existingConfigs.Skip(10).Any())
{
foreach (var oldBak in existingConfigs.Skip(10).ToList())
{
oldBak.Delete();
}
}
string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]);
_logger.LogTrace("Backing up current config to {backupPath}", backupPath);
File.Copy(config.ConfigurationPath, backupPath, overwrite: true);
FileInfo fi = new(backupPath);
fi.LastWriteTimeUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
// ignore if file cannot be backupped
_logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath);
}
var temp = config.ConfigurationPath + ".tmp";
try
{
await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(config.Current, typeof(T), new JsonSerializerOptions()
{
WriteIndented = true
})).ConfigureAwait(false);
File.Move(temp, config.ConfigurationPath, true);
config.UpdateLastWriteTime();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during config save of {config}", config.ConfigurationName);
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
_ = Task.Run(() => PeriodicSaveCheck(_configSaveCheckCts.Token));
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await _configSaveCheckCts.CancelAsync().ConfigureAwait(false);
_configSaveCheckCts.Dispose();
await SaveConfigs().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,141 @@
using MareSynchronos.MareConfiguration.Configurations;
using System.Text.Json;
namespace MareSynchronos.MareConfiguration;
public abstract class ConfigurationServiceBase<T> : IConfigService<T> where T : IMareConfiguration
{
private readonly CancellationTokenSource _periodicCheckCts = new();
private DateTime _configLastWriteTime;
private Lazy<T> _currentConfigInternal;
private bool _disposed = false;
public event EventHandler? ConfigSave;
protected ConfigurationServiceBase(string configDirectory)
{
ConfigurationDirectory = configDirectory;
_ = Task.Run(CheckForConfigUpdatesInternal, _periodicCheckCts.Token);
_currentConfigInternal = LazyConfig();
}
public string ConfigurationDirectory { get; init; }
public T Current => _currentConfigInternal.Value;
public abstract string ConfigurationName { get; }
public string ConfigurationPath => Path.Combine(ConfigurationDirectory, ConfigurationName);
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
public void Save()
{
ConfigSave?.Invoke(this, EventArgs.Empty);
}
public void UpdateLastWriteTime()
{
_configLastWriteTime = GetConfigLastWriteTime();
}
protected virtual void Dispose(bool disposing)
{
if (!disposing || _disposed) return;
_disposed = true;
_periodicCheckCts.Cancel();
_periodicCheckCts.Dispose();
}
protected T LoadConfig()
{
T? config;
if (!File.Exists(ConfigurationPath))
{
config = AttemptToLoadBackup();
}
else
{
try
{
config = JsonSerializer.Deserialize<T>(File.ReadAllText(ConfigurationPath));
}
catch
{
// config failed to load for some reason
config = AttemptToLoadBackup();
}
}
if (config == null || Equals(config, default(T)))
{
config = Activator.CreateInstance<T>();
Save();
}
_configLastWriteTime = GetConfigLastWriteTime();
return config;
}
private T? AttemptToLoadBackup()
{
var configBackupFolder = Path.Join(ConfigurationDirectory, ConfigurationSaveService.BackupFolder);
var configNameSplit = ConfigurationName.Split(".");
if (!Directory.Exists(configBackupFolder))
return default;
var existingBackups = Directory.EnumerateFiles(configBackupFolder, configNameSplit[0] + "*").OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc);
foreach (var file in existingBackups)
{
try
{
var config = JsonSerializer.Deserialize<T>(File.ReadAllText(file));
if (Equals(config, default(T)))
{
File.Delete(file);
}
File.Copy(file, ConfigurationPath, true);
return config;
}
catch
{
// couldn't load backup, might as well delete it
File.Delete(file);
}
}
return default;
}
private async Task CheckForConfigUpdatesInternal()
{
while (!_periodicCheckCts.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(5), _periodicCheckCts.Token).ConfigureAwait(false);
var lastWriteTime = GetConfigLastWriteTime();
if (lastWriteTime != _configLastWriteTime)
{
_currentConfigInternal = LazyConfig();
}
}
}
private DateTime GetConfigLastWriteTime()
{
try { return new FileInfo(ConfigurationPath).LastWriteTimeUtc; }
catch { return DateTime.MinValue; }
}
private Lazy<T> LazyConfig()
{
_configLastWriteTime = GetConfigLastWriteTime();
return new Lazy<T>(LoadConfig);
}
}

View File

@@ -0,0 +1,19 @@
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
public class CharaDataConfig : IMareConfiguration
{
public bool OpenMareHubOnGposeStart { get; set; } = false;
public string LastSavedCharaDataLocation { get; set; } = string.Empty;
public Dictionary<string, CharaDataFavorite> FavoriteCodes { get; set; } = [];
public bool DownloadMcdDataOnConnection { get; set; } = true;
public int Version { get; set; } = 0;
public bool NearbyOwnServerOnly { get; set; } = false;
public bool NearbyIgnoreHousingLimitations { get; set; } = false;
public bool NearbyDrawWisps { get; set; } = true;
public int NearbyDistanceFilter { get; set; } = 100;
public bool NearbyShowOwnData { get; set; } = false;
public bool ShowHelpTexts { get; set; } = true;
public bool NearbyShowAlways { get; set; } = false;
}

View File

@@ -0,0 +1,6 @@
namespace MareSynchronos.MareConfiguration.Configurations;
public interface IMareConfiguration
{
int Version { get; set; }
}

View File

@@ -0,0 +1,82 @@
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.UI;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class MareConfig : IMareConfiguration
{
public int ExpectedTOSVersion = 2;
public int AcceptedTOSVersion { get; set; } = 0;
public bool AcceptedAgreement { get; set; } = false;
public string CacheFolder { get; set; } = string.Empty;
public bool DisableOptionalPluginWarnings { get; set; } = false;
public bool EnableDtrEntry { get; set; } = true;
public int DtrStyle { get; set; } = 0;
public bool ShowUidInDtrTooltip { get; set; } = true;
public bool PreferNoteInDtrTooltip { get; set; } = false;
public bool UseColorsInDtr { get; set; } = true;
public DtrEntry.Colors DtrColorsDefault { get; set; } = default;
public DtrEntry.Colors DtrColorsNotConnected { get; set; } = new(Glow: 0x0428FFu);
public DtrEntry.Colors DtrColorsPairsInRange { get; set; } = new(Glow: 0xFFBA47u);
public bool UseNameColors { get; set; } = false;
public DtrEntry.Colors NameColors { get; set; } = new(Foreground: 0x67EBF5u, Glow: 0x00303Cu);
public DtrEntry.Colors BlockedNameColors { get; set; } = new(Foreground: 0x8AADC7, Glow: 0x000080u);
public bool EnableRightClickMenus { get; set; } = true;
public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both;
public string ExportFolder { get; set; } = string.Empty;
public bool FileScanPaused { get; set; } = false;
public NotificationLocation InfoNotification { get; set; } = NotificationLocation.Toast;
public bool InitialScanComplete { get; set; } = false;
public LogLevel LogLevel { get; set; } = LogLevel.Information;
public bool LogPerformance { get; set; } = false;
public bool LogEvents { get; set; } = true;
public bool HoldCombatApplication { get; set; } = false;
public double MaxLocalCacheInGiB { get; set; } = 100;
public bool OpenGposeImportOnGposeStart { get; set; } = false;
public bool OpenPopupOnAdd { get; set; } = true;
public int ParallelDownloads { get; set; } = 10;
public int DownloadSpeedLimitInBytes { get; set; } = 0;
public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps;
[Obsolete] public bool PreferNotesOverNamesForVisible { get; set; } = false;
public float ProfileDelay { get; set; } = 1.5f;
public bool ProfilePopoutRight { get; set; } = false;
public bool ProfilesAllowNsfw { get; set; } = false;
public bool ProfilesShow { get; set; } = false;
public bool ShowSyncshellUsersInVisible { get; set; } = true;
[Obsolete] public bool ShowCharacterNameInsteadOfNotesForVisible { get; set; } = false;
public bool ShowCharacterNames { get; set; } = true;
public bool ShowOfflineUsersSeparately { get; set; } = true;
public bool ShowSyncshellOfflineUsersSeparately { get; set; } = true;
public bool GroupUpSyncshells { get; set; } = true;
public bool SerialApplication { get; set; } = false;
public bool ShowOnlineNotifications { get; set; } = false;
public bool ShowOnlineNotificationsOnlyForIndividualPairs { get; set; } = true;
public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false;
public bool ShowTransferBars { get; set; } = true;
public bool ShowTransferWindow { get; set; } = false;
public bool ShowUploading { get; set; } = true;
public bool ShowUploadingBigText { get; set; } = true;
public bool ShowVisibleUsersSeparately { get; set; } = true;
public bool EnableAutoDetectDiscovery { get; set; } = false;
public bool AllowAutoDetectPairRequests { get; set; } = false;
public int AutoDetectMaxDistanceMeters { get; set; } = 40;
public int AutoDetectMuteMinutes { get; set; } = 5;
public int TimeSpanBetweenScansInSeconds { get; set; } = 30;
public int TransferBarsHeight { get; set; } = 12;
public bool TransferBarsShowText { get; set; } = true;
public int TransferBarsWidth { get; set; } = 250;
public bool UseAlternativeFileUpload { get; set; } = false;
public bool UseCompactor { get; set; } = false;
public int Version { get; set; } = 1;
public NotificationLocation WarningNotification { get; set; } = NotificationLocation.Both;
public bool DisableSyncshellChat { get; set; } = false;
public int ChatColor { get; set; } = 0; // 0 means "use plugin default"
public int ChatLogKind { get; set; } = 1; // XivChatType.Debug
public bool ExtraChatAPI { get; set; } = false;
public bool ExtraChatTags { get; set; } = false;
public bool MareAPI { get; set; } = true;
}

View File

@@ -0,0 +1,16 @@
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
public class PlayerPerformanceConfig : IMareConfiguration
{
public int Version { get; set; } = 1;
public bool AutoPausePlayersExceedingThresholds { get; set; } = true;
public bool NotifyAutoPauseDirectPairs { get; set; } = true;
public bool NotifyAutoPauseGroupPairs { get; set; } = true;
public int VRAMSizeAutoPauseThresholdMiB { get; set; } = 500;
public int TrisAutoPauseThresholdThousands { get; set; } = 400;
public bool IgnoreDirectPairs { get; set; } = true;
public TextureShrinkMode TextureShrinkMode { get; set; } = TextureShrinkMode.Default;
public bool TextureShrinkDeleteOriginal { get; set; } = false;
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Nodes;
namespace MareSynchronos.MareConfiguration.Configurations;
public class RemoteConfigCache : IMareConfiguration
{
public int Version { get; set; } = 0;
public ulong Timestamp { get; set; } = 0;
public string Origin { get; set; } = string.Empty;
public DateTimeOffset? LastModified { get; set; } = null;
public string ETag { get; set; } = string.Empty;
public JsonObject Configuration { get; set; } = new();
}

View File

@@ -0,0 +1,10 @@
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class ServerBlockConfig : IMareConfiguration
{
public Dictionary<string, ServerBlockStorage> ServerBlocks { get; set; } = new(StringComparer.Ordinal);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,17 @@
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.WebAPI;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class ServerConfig : IMareConfiguration
{
public int CurrentServer { get; set; } = 0;
public List<ServerStorage> ServerStorage { get; set; } = new()
{
{ new ServerStorage() { ServerName = ApiController.UmbraServer, ServerUri = ApiController.UmbraServiceUri } },
};
public int Version { get; set; } = 1;
}

View File

@@ -0,0 +1,10 @@
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class ServerTagConfig : IMareConfiguration
{
public Dictionary<string, ServerTagStorage> ServerTagStorage { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,10 @@
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class SyncshellConfig : IMareConfiguration
{
public Dictionary<string, ServerShellStorage> ServerShellStorage { get; set; } = new(StringComparer.Ordinal);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,7 @@
namespace MareSynchronos.MareConfiguration.Configurations;
public class TransientConfig : IMareConfiguration
{
public Dictionary<string, HashSet<string>> PlayerPersistentTransientCache { get; set; } = new(StringComparer.Ordinal);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,10 @@
using MareSynchronos.MareConfiguration.Models;
namespace MareSynchronos.MareConfiguration.Configurations;
[Serializable]
public class UidNotesConfig : IMareConfiguration
{
public Dictionary<string, ServerNotesStorage> ServerNotes { get; set; } = new(StringComparer.Ordinal);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Concurrent;
namespace MareSynchronos.MareConfiguration.Configurations;
public class XivDataStorageConfig : IMareConfiguration
{
public ConcurrentDictionary<string, long> TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, (uint Mip0Size, int MipCount, ushort Width, ushort Height)> TexDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Dictionary<string, List<ushort>>> BonesDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int Version { get; set; } = 0;
}

View File

@@ -0,0 +1,12 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public interface IConfigService<out T> : IDisposable where T : IMareConfiguration
{
T Current { get; }
string ConfigurationName { get; }
string ConfigurationPath { get; }
public event EventHandler? ConfigSave;
void UpdateLastWriteTime();
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class MareConfigService : ConfigurationServiceBase<MareConfig>
{
public const string ConfigName = "config.json";
public MareConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,9 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public record Authentication
{
public string CharacterName { get; set; } = string.Empty;
public uint WorldId { get; set; } = 0;
public int SecretKeyIdx { get; set; } = -1;
}

View File

@@ -0,0 +1,8 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class CharaDataFavorite
{
public DateTime LastDownloaded { get; set; } = DateTime.MaxValue;
public string CustomDescription { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace MareSynchronos.MareConfiguration.Models;
public enum DownloadSpeeds
{
Bps,
KBps,
MBps
}

View File

@@ -0,0 +1,16 @@
namespace MareSynchronos.MareConfiguration.Models;
public enum NotificationLocation
{
Nowhere,
Chat,
Toast,
Both
}
public enum NotificationType
{
Info,
Warning,
Error
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class SecretKey
{
public string FriendlyName { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class ServerBlockStorage
{
public List<string> Whitelist { get; set; } = new();
public List<string> Blacklist { get; set; } = new();
}

View File

@@ -0,0 +1,9 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class ServerNotesStorage
{
public Dictionary<string, string> GidServerComments { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, string> UidServerComments { get; set; } = new(StringComparer.Ordinal);
public Dictionary<string, string> UidLastSeenNames { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,7 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class ServerShellStorage
{
public Dictionary<string, ShellConfig> GidShellConfig { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,11 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class ServerStorage
{
public List<Authentication> Authentications { get; set; } = [];
public bool FullPause { get; set; } = false;
public Dictionary<int, SecretKey> SecretKeys { get; set; } = [];
public string ServerName { get; set; } = string.Empty;
public string ServerUri { get; set; } = string.Empty;
}

View File

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

View File

@@ -0,0 +1,10 @@
namespace MareSynchronos.MareConfiguration.Models;
[Serializable]
public class ShellConfig
{
public bool Enabled { get; set; } = true;
public int ShellNumber { get; set; }
public int Color { get; set; } = 0; // 0 means "default to the global setting"
public int LogKind { get; set; } = 0; // 0 means "default to the global setting"
}

View File

@@ -0,0 +1,10 @@
namespace MareSynchronos.MareConfiguration.Models;
public enum TextureShrinkMode
{
Never,
Default,
DefaultHiRes,
Always,
AlwaysHiRes
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class NotesConfigService : ConfigurationServiceBase<UidNotesConfig>
{
public const string ConfigName = "notes.json";
public NotesConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,11 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class PlayerPerformanceConfigService : ConfigurationServiceBase<PlayerPerformanceConfig>
{
public const string ConfigName = "playerperformance.json";
public PlayerPerformanceConfigService(string configDir) : base(configDir) { }
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,11 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class RemoteConfigCacheService : ConfigurationServiceBase<RemoteConfigCache>
{
public const string ConfigName = "remotecache.json";
public RemoteConfigCacheService(string configDir) : base(configDir) { }
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class ServerBlockConfigService : ConfigurationServiceBase<ServerBlockConfig>
{
public const string ConfigName = "blocks.json";
public ServerBlockConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class ServerConfigService : ConfigurationServiceBase<ServerConfig>
{
public const string ConfigName = "server.json";
public ServerConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

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

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class SyncshellConfigService : ConfigurationServiceBase<SyncshellConfig>
{
public const string ConfigName = "syncshells.json";
public SyncshellConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,14 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class TransientConfigService : ConfigurationServiceBase<TransientConfig>
{
public const string ConfigName = "transient.json";
public TransientConfigService(string configDir) : base(configDir)
{
}
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,12 @@
using MareSynchronos.MareConfiguration.Configurations;
namespace MareSynchronos.MareConfiguration;
public class XivDataStorageService : ConfigurationServiceBase<XivDataStorageConfig>
{
public const string ConfigName = "xivdatastorage.json";
public XivDataStorageService(string configDir) : base(configDir) { }
public override string ConfigurationName => ConfigName;
}

View File

@@ -0,0 +1,170 @@
using MareSynchronos.FileCache;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.PlayerData.Services;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Reflection;
namespace MareSynchronos;
#pragma warning disable S125 // Sections of code should not be commented out
/*
(..,,...,,,,,+/, ,,.....,,+
..,,+++/((###%%%&&%%#(+,,.,,,+++,,,,//,,#&@@@@%+.
...+//////////(/,,,,++,.,(###((//////////,.. .,#@@%/./
,..+/////////+///,.,. ,&@@@@,,/////////////+,.. ,(##+,.
,,.+//////////++++++.. ./#%#,+/////////////+,....,/((,..,
+..////////////+++++++... .../##(,,////////////////++,,,+/(((+,
+,.+//////////////+++++++,.,,,/(((+.,////////////////////////((((#/,,
/+.+//////////++++/++++++++++,,...,++///////////////////////////((((##,
/,.////////+++++++++++++++++++++////////+++//////++/+++++//////////((((#(+,
/+.+////////+++++++++++++++++++++++++++++++++++++++++++++++++++++/////((((##+
+,.///////////////+++++++++++++++++++++++++++++++++++++++++++++++++++///((((%/
/.,/////////////////+++++++++++++++++++++++++++++++++++++++++++++++++++///+/(#+
+,./////////////////+++++++++++++++++++++++++++++++++++++++++++++++,,+++++///((,
...////////++/++++++++++++++++++++++++,,++++++++++++++++++++++++++++++++++++//(,,
..//+,+///++++++++++++++++++,,,,+++,,,,,,,,,,,,++++++++,,+++++++++++++++++++//,,+
..,++,.++++++++++++++++++++++,,,,,,,,,,,,,,,,,,,++++++++,,,,,,,,,,++++++++++...
..+++,.+++++++++++++++++++,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,++,..,.
..,++++,,+++++++++++,+,,,,,,,,,,..,+++++++++,,,,,,.....................,//+,+
....,+++++,.,+++++++++++,,,,,,,,.+///(((((((((((((///////////////////////(((+,,,
.....,++++++++++..,+++++++++++,,.,,,.////////(((((((((((((((////////////////////+,,/
.....,++++++++++++,..,,+++++++++,,.,../////////////////((((((((((//////////////////,,+
...,,+++++++++++++,.,,.,,,+++++++++,.,/////////////////(((//++++++++++++++//+++++++++/,,
....,++++++++++++++,.,++.,++++++++++++.,+////////////////////+++++++++++++++++++++++++///,,..
...,++++++++++++++++..+++..+++++++++++++.,//////////////////////////++++++++++++///////++++......
...++++++++++++++++++..++++.,++,++++++++++.+///////////////////////////////////////////++++++..,,,..
...+++++++++++++++++++..+++++..,+,,+++++++++.+//////////////////////////////////////////+++++++...,,,,..
..++++++++++++++++++++..++++++..,+,,+++++++++.+//////////////////////////////////////++++++++++,....,,,,..
...+++//(//////+++++++++..++++++,.,+++++++++++++,..,....,,,+++///////////////////////++++++++++++..,,,,,,,,...
..,++/(((((//////+++++++,.,++++++,,.,,,+++++++++++++++++++++++,.++////////////////////+++++++++++.....,,,,,,,...
..,//#(((((///////+++++++..++++++++++,...,++,++++++++++++++++,...+++/////////////////////+,,,+++... ....,,,,,,...
...+//(((((//////////++++++..+++++++++++++++,......,,,,++++++,,,..+++////////////////////////+,.... ...,,,,,,,...
..,//((((////////////++++++..++++++/+++++++++++++,,...,,........,+/+//////////////////////((((/+,.. ....,.,,,,..
...+/////////////////////+++..++++++/+///+++++++++++++++++++++///+/+////////////////////////(((((/+... .......,,...
..++////+++//////////////++++.+++++++++///////++++++++////////////////////////////////////+++/(((((/+.. .....,,...
.,++++++++///////////////++++..++++//////////////////////////////////////////////////////++++++/((((++.. ........
.+++++++++////////////////++++,.+++/////////////////////////////////////////////////////+++++++++/((/++..
.,++++++++//////////////////++++,.+++//////////////////////////////////////////////////+++++++++++++//+++..
.++++++++//////////////////////+/,.,+++////((((////////////////////////////////////////++++++++++++++++++...
.++++++++///////////////////////+++..++++//((((((((///////////////////////////////////++++++++++++++++++++ .
.++++++///////////////////////////++,.,+++++/(((((((((/////////////////////////////+++++++++++++++++++++++,..
.++++++////////////////////////////+++,.,+++++++/((((((((//////////////////////////++++++++++++++++++++++++..
.+++++++///////////////////++////////++++,.,+++++++++///////////+////////////////+++++++++++++++++++++++++,..
..++++++++++//////////////////////+++++++..+...,+++++++++++++++/++++++++++++++++++++++++++++++++++++++++++,...
..++++++++++++///////////////+++++++,...,,,,,.,....,,,,+++++++++++++++++++++++++++++++++++++++++++++++,,,,...
...++++++++++++++++++++++++++,,,,...,,,,,,,,,..,,++,,,.,,,,,,,,,,,,,,,,,,+++++++++++++++++++++++++,,,,,,,,..
...+++++++++++++++,,,,,,,,....,,,,,,,,,,,,,,,..,,++++++,,,,,,,,,,,,,,,,+++++++++++++++++++++++++,,,,,,,,,..
...++++++++++++,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,...,++++++++++++++++++++++++++++++++++++++++++++,,,,,,,,,,...
,....,++++++++++++++,,,+++++++,,,,,,,,,,,,,,,,,.,++++++++++++++++++++++++++++++++++++++++++++,,,,,,,,..
*/
#pragma warning restore S125 // Sections of code should not be commented out
public class MarePlugin : MediatorSubscriberBase, IHostedService
{
private readonly DalamudUtilService _dalamudUtil;
private readonly MareConfigService _mareConfigService;
private readonly ServerConfigurationManager _serverConfigurationManager;
private readonly IServiceScopeFactory _serviceScopeFactory;
private IServiceScope? _runtimeServiceScope;
private Task? _launchTask = null;
public MarePlugin(ILogger<MarePlugin> logger, MareConfigService mareConfigService,
ServerConfigurationManager serverConfigurationManager,
DalamudUtilService dalamudUtil,
IServiceScopeFactory serviceScopeFactory, MareMediator mediator) : base(logger, mediator)
{
_mareConfigService = mareConfigService;
_serverConfigurationManager = serverConfigurationManager;
_dalamudUtil = dalamudUtil;
_serviceScopeFactory = serviceScopeFactory;
}
public Task StartAsync(CancellationToken cancellationToken)
{
var version = Assembly.GetExecutingAssembly().GetName().Version!;
Logger.LogInformation("Launching {name} {major}.{minor}.{build}.{rev}", "Umbra Sync", version.Major, version.Minor, version.Build, version.Revision);
Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(MarePlugin), Services.Events.EventSeverity.Informational,
$"Starting Umbra Sync {version.Major}.{version.Minor}.{version.Build}.{version.Revision}")));
Mediator.Subscribe<SwitchToMainUiMessage>(this, (msg) => { if (_launchTask == null || _launchTask.IsCompleted) _launchTask = Task.Run(WaitForPlayerAndLaunchCharacterManager); });
Mediator.Subscribe<DalamudLoginMessage>(this, (_) => DalamudUtilOnLogIn());
Mediator.Subscribe<DalamudLogoutMessage>(this, (_) => DalamudUtilOnLogOut());
Mediator.StartQueueProcessing();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
UnsubscribeAll();
DalamudUtilOnLogOut();
Logger.LogDebug("Halting MarePlugin");
return Task.CompletedTask;
}
private void DalamudUtilOnLogIn()
{
Logger?.LogDebug("Client login");
if (_launchTask == null || _launchTask.IsCompleted) _launchTask = Task.Run(WaitForPlayerAndLaunchCharacterManager);
}
private void DalamudUtilOnLogOut()
{
Logger?.LogDebug("Client logout");
_runtimeServiceScope?.Dispose();
}
private async Task WaitForPlayerAndLaunchCharacterManager()
{
while (!await _dalamudUtil.GetIsPlayerPresentAsync().ConfigureAwait(false))
{
await Task.Delay(100).ConfigureAwait(false);
}
try
{
Logger?.LogDebug("Launching Managers");
_runtimeServiceScope?.Dispose();
_runtimeServiceScope = _serviceScopeFactory.CreateScope();
_runtimeServiceScope.ServiceProvider.GetRequiredService<UiService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<CommandManagerService>();
if (!_mareConfigService.Current.HasValidSetup() || !_serverConfigurationManager.HasValidConfig())
{
Mediator.Publish(new SwitchToIntroUiMessage());
return;
}
_runtimeServiceScope.ServiceProvider.GetRequiredService<CacheCreationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<TransientResourceManager>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<OnlinePlayerManager>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<NotificationService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<ChatService>();
_runtimeServiceScope.ServiceProvider.GetRequiredService<GuiHookService>();
#if !DEBUG
if (_mareConfigService.Current.LogLevel != LogLevel.Information)
{
Mediator.Publish(new NotificationMessage("Abnormal Log Level",
$"Your log level is set to '{_mareConfigService.Current.LogLevel}' which is not recommended for normal usage. Set it to '{LogLevel.Information}' in \"Umbra Settings -> Debug\" unless instructed otherwise.",
MareConfiguration.Models.NotificationType.Error, TimeSpan.FromSeconds(15000)));
}
#endif
}
catch (Exception ex)
{
Logger?.LogCritical(ex, "Error during launch of managers");
}
}
}

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Dalamud.NET.Sdk/13.0.0">
<PropertyGroup>
<AssemblyName>UmbraSync</AssemblyName>
<RootNamespace>UmbraSync</RootNamespace>
<Version>0.1.8.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="PlayerData\Export\**" />
<EmbeddedResource Remove="PlayerData\Export\**" />
<None Remove="PlayerData\Export\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Chaos.NaCl.Standard" Version="1.0.0" />
<PackageReference Include="Downloader" Version="3.3.4" />
<PackageReference Include="K4os.Compression.LZ4.Legacy" Version="1.3.8" />
<PackageReference Include="K4os.Compression.LZ4.Streams" Version="1.3.8" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.212">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.8" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.15.0.120848">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<ItemGroup Condition="Exists('.\Penumbra.Api\Penumbra.Api.csproj')">
<ProjectReference Include=".\Penumbra.Api\Penumbra.Api.csproj" />
</ItemGroup>
<ItemGroup Condition="!Exists('.\Penumbra.Api\Penumbra.Api.csproj')">
<PackageReference Include="Penumbra.Api" Version="5.12.0" />
</ItemGroup>
<ItemGroup Condition="Exists('.\Glamourer.Api\Glamourer.Api.csproj')">
<ProjectReference Include=".\Glamourer.Api\Glamourer.Api.csproj" />
</ItemGroup>
<ItemGroup Condition="!Exists('.\Glamourer.Api\Glamourer.Api.csproj')">
<PackageReference Include="Glamourer.Api" Version="2.6.0" />
</ItemGroup>
<PropertyGroup>
<SourceRevisionId>build$([System.DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss:fffZ"))</SourceRevisionId>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MareAPI\MareSynchronosAPI\MareSynchronos.API.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\.editorconfig" Link=".editorconfig" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Localization\\*.json" />
</ItemGroup>
<ItemGroup>
<Content Include="Localization\\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Enum;
namespace MareSynchronos.PlayerData.Data;
public class CharacterData
{
public Dictionary<ObjectKind, string> CustomizePlusScale { get; set; } = [];
public Dictionary<ObjectKind, HashSet<FileReplacement>> FileReplacements { get; set; } = [];
public Dictionary<ObjectKind, string> GlamourerString { get; set; } = [];
public string HeelsData { get; set; } = string.Empty;
public string HonorificData { get; set; } = string.Empty;
public string ManipulationString { get; set; } = string.Empty;
public string PetNamesData { get; set; } = string.Empty;
public string MoodlesData { get; set; } = string.Empty;
public API.Data.CharacterData ToAPI()
{
Dictionary<ObjectKind, List<FileReplacementData>> fileReplacements =
FileReplacements.ToDictionary(k => k.Key, k => k.Value.Where(f => f.HasFileReplacement && !f.IsFileSwap)
.GroupBy(f => f.Hash, StringComparer.OrdinalIgnoreCase)
.Select(g =>
{
return new FileReplacementData()
{
GamePaths = g.SelectMany(f => f.GamePaths).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
Hash = g.First().Hash,
};
}).ToList());
foreach (var item in FileReplacements)
{
var fileSwapsToAdd = item.Value.Where(f => f.IsFileSwap).Select(f => f.ToFileReplacementDto());
fileReplacements[item.Key].AddRange(fileSwapsToAdd);
}
return new API.Data.CharacterData()
{
FileReplacements = fileReplacements,
GlamourerData = GlamourerString.ToDictionary(d => d.Key, d => d.Value),
ManipulationData = ManipulationString,
HeelsData = HeelsData,
CustomizePlusData = CustomizePlusScale.ToDictionary(d => d.Key, d => d.Value),
HonorificData = HonorificData,
PetNamesData = PetNamesData,
MoodlesData = MoodlesData
};
}
}

View File

@@ -0,0 +1,42 @@
using MareSynchronos.API.Data;
using System.Text.RegularExpressions;
namespace MareSynchronos.PlayerData.Data;
public partial class FileReplacement
{
public FileReplacement(string[] gamePaths, string filePath)
{
GamePaths = gamePaths.Select(g => g.Replace('\\', '/').ToLowerInvariant()).ToHashSet(StringComparer.Ordinal);
ResolvedPath = filePath.Replace('\\', '/');
}
public HashSet<string> GamePaths { get; init; }
public bool HasFileReplacement => GamePaths.Count >= 1 && GamePaths.Any(p => !string.Equals(p, ResolvedPath, StringComparison.Ordinal));
public string Hash { get; set; } = string.Empty;
public bool IsFileSwap => !LocalPathRegex().IsMatch(ResolvedPath) && GamePaths.All(p => !LocalPathRegex().IsMatch(p));
public string ResolvedPath { get; init; }
public FileReplacementData ToFileReplacementDto()
{
return new FileReplacementData
{
GamePaths = [.. GamePaths],
Hash = Hash,
FileSwapPath = IsFileSwap ? ResolvedPath : string.Empty,
};
}
public override string ToString()
{
return $"HasReplacement:{HasFileReplacement},IsFileSwap:{IsFileSwap} - {string.Join(",", GamePaths)} => {ResolvedPath}";
}
#pragma warning disable MA0009
[GeneratedRegex(@"^[a-zA-Z]:(/|\\)", RegexOptions.ECMAScript)]
private static partial Regex LocalPathRegex();
#pragma warning restore MA0009
}

View File

@@ -0,0 +1,47 @@
namespace MareSynchronos.PlayerData.Data;
public class FileReplacementComparer : IEqualityComparer<FileReplacement>
{
private static readonly FileReplacementComparer _instance = new();
private FileReplacementComparer()
{ }
public static FileReplacementComparer Instance => _instance;
public bool Equals(FileReplacement? x, FileReplacement? y)
{
if (x == null || y == null) return false;
return x.ResolvedPath.Equals(y.ResolvedPath) && CompareLists(x.GamePaths, y.GamePaths);
}
public int GetHashCode(FileReplacement obj)
{
return HashCode.Combine(obj.ResolvedPath.GetHashCode(StringComparison.OrdinalIgnoreCase), GetOrderIndependentHashCode(obj.GamePaths));
}
private static bool CompareLists(HashSet<string> list1, HashSet<string> list2)
{
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;
}
private static int GetOrderIndependentHashCode<T>(IEnumerable<T> source) where T : notnull
{
int hash = 0;
foreach (T element in source)
{
hash = unchecked(hash +
EqualityComparer<T>.Default.GetHashCode(element));
}
return hash;
}
}

View File

@@ -0,0 +1,49 @@
using MareSynchronos.API.Data;
namespace MareSynchronos.PlayerData.Data;
public class FileReplacementDataComparer : IEqualityComparer<FileReplacementData>
{
private static readonly FileReplacementDataComparer _instance = new();
private FileReplacementDataComparer()
{ }
public static FileReplacementDataComparer Instance => _instance;
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);
}
public int GetHashCode(FileReplacementData obj)
{
return HashCode.Combine(obj.Hash.GetHashCode(StringComparison.OrdinalIgnoreCase), GetOrderIndependentHashCode(obj.GamePaths), StringComparer.Ordinal.GetHashCode(obj.FileSwapPath));
}
private static bool CompareHashSets(HashSet<string> list1, HashSet<string> list2)
{
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;
}
private static int GetOrderIndependentHashCode<T>(IEnumerable<T> source) where T : notnull
{
int hash = 0;
foreach (T element in source)
{
hash = unchecked(hash +
EqualityComparer<T>.Default.GetHashCode(element));
}
return hash;
}
}

View File

@@ -0,0 +1,14 @@
namespace MareSynchronos.PlayerData.Pairs;
public enum PlayerChanges
{
ModFiles = 1,
ModManip = 2,
Glamourer = 3,
Customize = 4,
Heels = 5,
Honorific = 7,
ForcedRedraw = 8,
Moodles = 9,
PetNames = 10,
}

View File

@@ -0,0 +1,30 @@
using MareSynchronos.FileCache;
using MareSynchronos.Services.Mediator;
using MareSynchronos.WebAPI.Files;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Factories;
public class FileDownloadManagerFactory
{
private readonly FileCacheManager _fileCacheManager;
private readonly FileCompactor _fileCompactor;
private readonly FileTransferOrchestrator _fileTransferOrchestrator;
private readonly ILoggerFactory _loggerFactory;
private readonly MareMediator _mareMediator;
public FileDownloadManagerFactory(ILoggerFactory loggerFactory, MareMediator mareMediator, FileTransferOrchestrator fileTransferOrchestrator,
FileCacheManager fileCacheManager, FileCompactor fileCompactor)
{
_loggerFactory = loggerFactory;
_mareMediator = mareMediator;
_fileTransferOrchestrator = fileTransferOrchestrator;
_fileCacheManager = fileCacheManager;
_fileCompactor = fileCompactor;
}
public FileDownloadManager Create()
{
return new FileDownloadManager(_loggerFactory.CreateLogger<FileDownloadManager>(), _mareMediator, _fileTransferOrchestrator, _fileCacheManager, _fileCompactor);
}
}

View File

@@ -0,0 +1,30 @@
using MareSynchronos.API.Data.Enum;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Factories;
public class GameObjectHandlerFactory
{
private readonly DalamudUtilService _dalamudUtilService;
private readonly ILoggerFactory _loggerFactory;
private readonly MareMediator _mareMediator;
private readonly PerformanceCollectorService _performanceCollectorService;
public GameObjectHandlerFactory(ILoggerFactory loggerFactory, PerformanceCollectorService performanceCollectorService, MareMediator mareMediator,
DalamudUtilService dalamudUtilService)
{
_loggerFactory = loggerFactory;
_performanceCollectorService = performanceCollectorService;
_mareMediator = mareMediator;
_dalamudUtilService = dalamudUtilService;
}
public async Task<GameObjectHandler> Create(ObjectKind objectKind, Func<nint> getAddressFunc, bool isWatched = false)
{
return await _dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(_loggerFactory.CreateLogger<GameObjectHandler>(),
_performanceCollectorService, _mareMediator, _dalamudUtilService, objectKind, getAddressFunc, isWatched)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,30 @@
using MareSynchronos.FileCache;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Factories;
public class PairAnalyzerFactory
{
private readonly ILoggerFactory _loggerFactory;
private readonly MareMediator _mareMediator;
private readonly FileCacheManager _fileCacheManager;
private readonly XivDataAnalyzer _modelAnalyzer;
public PairAnalyzerFactory(ILoggerFactory loggerFactory, MareMediator mareMediator,
FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer)
{
_loggerFactory = loggerFactory;
_fileCacheManager = fileCacheManager;
_mareMediator = mareMediator;
_modelAnalyzer = modelAnalyzer;
}
public PairAnalyzer Create(Pair pair)
{
return new PairAnalyzer(_loggerFactory.CreateLogger<PairAnalyzer>(), pair, _mareMediator,
_fileCacheManager, _modelAnalyzer);
}
}

View File

@@ -0,0 +1,33 @@
using MareSynchronos.API.Data;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Factories;
public class PairFactory
{
private readonly PairHandlerFactory _cachedPlayerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly MareMediator _mareMediator;
private readonly MareConfigService _mareConfig;
private readonly ServerConfigurationManager _serverConfigurationManager;
public PairFactory(ILoggerFactory loggerFactory, PairHandlerFactory cachedPlayerFactory,
MareMediator mareMediator, MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager)
{
_loggerFactory = loggerFactory;
_cachedPlayerFactory = cachedPlayerFactory;
_mareMediator = mareMediator;
_mareConfig = mareConfig;
_serverConfigurationManager = serverConfigurationManager;
}
public Pair Create(UserData userData)
{
return new Pair(_loggerFactory.CreateLogger<Pair>(), userData, _cachedPlayerFactory, _mareMediator, _mareConfig, _serverConfigurationManager);
}
}

View File

@@ -0,0 +1,60 @@
using MareSynchronos.FileCache;
using MareSynchronos.Interop.Ipc;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Factories;
public class PairHandlerFactory
{
private readonly MareConfigService _configService;
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 MareMediator _mareMediator;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private readonly PairAnalyzerFactory _pairAnalyzerFactory;
private readonly VisibilityService _visibilityService;
public PairHandlerFactory(ILoggerFactory loggerFactory, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager,
FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService,
PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime,
FileCacheManager fileCacheManager, MareMediator mareMediator, PlayerPerformanceService playerPerformanceService,
ServerConfigurationManager serverConfigManager, PairAnalyzerFactory pairAnalyzerFactory,
MareConfigService configService, VisibilityService visibilityService)
{
_loggerFactory = loggerFactory;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_fileDownloadManagerFactory = fileDownloadManagerFactory;
_dalamudUtilService = dalamudUtilService;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_hostApplicationLifetime = hostApplicationLifetime;
_fileCacheManager = fileCacheManager;
_mareMediator = mareMediator;
_playerPerformanceService = playerPerformanceService;
_serverConfigManager = serverConfigManager;
_pairAnalyzerFactory = pairAnalyzerFactory;
_configService = configService;
_visibilityService = visibilityService;
}
public PairHandler Create(Pair pair)
{
return new PairHandler(_loggerFactory.CreateLogger<PairHandler>(), pair, _pairAnalyzerFactory.Create(pair), _gameObjectHandlerFactory,
_ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime,
_fileCacheManager, _mareMediator, _playerPerformanceService, _serverConfigManager, _configService, _visibilityService);
}
}

View File

@@ -0,0 +1,365 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.FileCache;
using MareSynchronos.Interop.Ipc;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Data;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using CharacterData = MareSynchronos.PlayerData.Data.CharacterData;
namespace MareSynchronos.PlayerData.Factories;
public class PlayerDataFactory
{
private static readonly string[] _allowedExtensionsForGamePaths = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk"];
private readonly DalamudUtilService _dalamudUtil;
private readonly FileCacheManager _fileCacheManager;
private readonly IpcManager _ipcManager;
private readonly ILogger<PlayerDataFactory> _logger;
private readonly PerformanceCollectorService _performanceCollector;
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly MareMediator _mareMediator;
private readonly TransientResourceManager _transientResourceManager;
public PlayerDataFactory(ILogger<PlayerDataFactory> logger, DalamudUtilService dalamudUtil, IpcManager ipcManager,
TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory,
PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, MareMediator mareMediator)
{
_logger = logger;
_dalamudUtil = dalamudUtil;
_ipcManager = ipcManager;
_transientResourceManager = transientResourceManager;
_fileCacheManager = fileReplacementFactory;
_performanceCollector = performanceCollector;
_modelAnalyzer = modelAnalyzer;
_mareMediator = mareMediator;
_logger.LogTrace("Creating {this}", nameof(PlayerDataFactory));
}
public async Task BuildCharacterData(CharacterData previousData, GameObjectHandler playerRelatedObject, CancellationToken token)
{
if (!_ipcManager.Initialized)
{
throw new InvalidOperationException("Penumbra or Glamourer is not connected");
}
if (playerRelatedObject == null) return;
bool pointerIsZero = true;
try
{
pointerIsZero = playerRelatedObject.Address == IntPtr.Zero;
try
{
pointerIsZero = await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false);
}
catch
{
pointerIsZero = true;
_logger.LogDebug("NullRef for {object}", playerRelatedObject);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create data for {object}", playerRelatedObject);
}
if (pointerIsZero)
{
_logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind);
previousData.FileReplacements.Remove(playerRelatedObject.ObjectKind);
previousData.GlamourerString.Remove(playerRelatedObject.ObjectKind);
previousData.CustomizePlusScale.Remove(playerRelatedObject.ObjectKind);
return;
}
var previousFileReplacements = previousData.FileReplacements.ToDictionary(d => d.Key, d => d.Value);
var previousGlamourerData = previousData.GlamourerString.ToDictionary(d => d.Key, d => d.Value);
var previousCustomize = previousData.CustomizePlusScale.ToDictionary(d => d.Key, d => d.Value);
try
{
await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () =>
{
await CreateCharacterData(previousData, playerRelatedObject, token).ConfigureAwait(false);
}).ConfigureAwait(true);
return;
}
catch (OperationCanceledException)
{
_logger.LogDebug("Cancelled creating Character data for {object}", playerRelatedObject);
throw;
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to create {object} data", playerRelatedObject);
}
previousData.FileReplacements = previousFileReplacements;
previousData.GlamourerString = previousGlamourerData;
previousData.CustomizePlusScale = previousCustomize;
}
private async Task<bool> CheckForNullDrawObject(IntPtr playerPointer)
{
return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false);
}
private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer)
{
return ((Character*)playerPointer)->GameObject.DrawObject == null;
}
private async Task<CharacterData> CreateCharacterData(CharacterData previousData, GameObjectHandler playerRelatedObject, CancellationToken token)
{
var objectKind = playerRelatedObject.ObjectKind;
_logger.LogDebug("Building character data for {obj}", playerRelatedObject);
if (!previousData.FileReplacements.TryGetValue(objectKind, out HashSet<FileReplacement>? value))
{
previousData.FileReplacements[objectKind] = new(FileReplacementComparer.Instance);
}
else
{
value.Clear();
}
previousData.CustomizePlusScale.Remove(objectKind);
// wait until chara is not drawing and present so nothing spontaneously explodes
await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: token).ConfigureAwait(false);
int totalWaitTime = 10000;
while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0)
{
_logger.LogTrace("Character is null but it shouldn't be, waiting");
await Task.Delay(50, token).ConfigureAwait(false);
totalWaitTime -= 50;
}
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");
previousData.FileReplacements[objectKind] =
new HashSet<FileReplacement>(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance)
.Where(p => p.HasFileReplacement).ToHashSet();
previousData.FileReplacements[objectKind].RemoveWhere(c => c.GamePaths.Any(g => !_allowedExtensionsForGamePaths.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))));
_logger.LogDebug("== Static Replacements ==");
foreach (var replacement in previousData.FileReplacements[objectKind].Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase))
{
_logger.LogDebug("=> {repl}", replacement);
}
// 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 previousData.FileReplacements[objectKind].Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths))
{
_logger.LogDebug("Persisting {item}", item);
_transientResourceManager.AddSemiTransientResource(objectKind, item);
}
}
_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(playerRelatedObject.Address, previousData.FileReplacements[objectKind].SelectMany(c => c.GamePaths).ToList());
// get all remaining paths and resolve them
var transientPaths = ManageSemiTransientData(objectKind, playerRelatedObject.Address);
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);
previousData.FileReplacements[objectKind].Add(replacement);
}
// clean up all semi transient resources that don't have any file replacement (aka null resolve)
_transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. previousData.FileReplacements[objectKind]]);
// make sure we only return data that actually has file replacements
foreach (var item in previousData.FileReplacements)
{
previousData.FileReplacements[item.Key] = new HashSet<FileReplacement>(item.Value.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance);
}
// gather up data from ipc
previousData.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations();
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();
previousData.GlamourerString[playerRelatedObject.ObjectKind] = await getGlamourerData.ConfigureAwait(false);
_logger.LogDebug("Glamourer is now: {data}", previousData.GlamourerString[playerRelatedObject.ObjectKind]);
var customizeScale = await getCustomizeData.ConfigureAwait(false);
previousData.CustomizePlusScale[playerRelatedObject.ObjectKind] = customizeScale ?? string.Empty;
_logger.LogDebug("Customize is now: {data}", previousData.CustomizePlusScale[playerRelatedObject.ObjectKind]);
previousData.HonorificData = await getHonorificTitle.ConfigureAwait(false);
_logger.LogDebug("Honorific is now: {data}", previousData.HonorificData);
previousData.HeelsData = await getHeelsOffset.ConfigureAwait(false);
_logger.LogDebug("Heels is now: {heels}", previousData.HeelsData);
if (objectKind == ObjectKind.Player)
{
previousData.PetNamesData = _ipcManager.PetNames.GetLocalNames();
_logger.LogDebug("Pet Nicknames is now: {petnames}", previousData.PetNamesData);
previousData.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty;
}
if (previousData.FileReplacements.TryGetValue(objectKind, out HashSet<FileReplacement>? fileReplacements))
{
var toCompute = 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)
{
file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty;
}
var removed = fileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash));
if (removed > 0)
{
_logger.LogDebug("Removed {amount} of invalid files", removed);
}
}
if (objectKind == ObjectKind.Player)
{
try
{
await VerifyPlayerAnimationBones(boneIndices, previousData, objectKind).ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to verify player animations, continuing without further verification");
}
}
_logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds);
return previousData;
}
private async Task VerifyPlayerAnimationBones(Dictionary<string, List<ushort>>? boneIndices, CharacterData previousData, ObjectKind objectKind)
{
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 previousData.FileReplacements[objectKind].Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList())
{
var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false);
bool validationFailed = false;
if (skeletonIndices != null)
{
// 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;
}
_logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count);
foreach (var boneCount in skeletonIndices.Select(k => k).ToList())
{
if (boneCount.Value.Max() > boneIndices.SelectMany(b => b.Value).Max())
{
_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;
}
}
}
if (validationFailed)
{
noValidationFailed++;
_logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath);
previousData.FileReplacements[objectKind].Remove(file);
foreach (var gamePath in file.GamePaths)
{
_transientResourceManager.RemoveTransientResource(objectKind, gamePath);
}
}
}
if (noValidationFailed > 0)
{
_mareMediator.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)));
}
}
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(HashSet<string> forwardResolve, HashSet<string> reverseResolve)
{
var forwardPaths = forwardResolve.ToArray();
var reversePaths = reverseResolve.ToArray();
Dictionary<string, List<string>> resolvedPaths = new(StringComparer.Ordinal);
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();
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());
}
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
private HashSet<string> ManageSemiTransientData(ObjectKind objectKind, IntPtr charaPointer)
{
_transientResourceManager.PersistTransientResources(charaPointer, objectKind);
HashSet<string> pathsToResolve = new(StringComparer.Ordinal);
foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path)))
{
pathsToResolve.Add(path);
}
return pathsToResolve;
}
}

View File

@@ -0,0 +1,487 @@
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using Microsoft.Extensions.Logging;
using System.Runtime.CompilerServices;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer;
using ObjectKind = MareSynchronos.API.Data.Enum.ObjectKind;
namespace MareSynchronos.PlayerData.Handlers;
public sealed class GameObjectHandler : DisposableMediatorSubscriberBase
{
private readonly DalamudUtilService _dalamudUtil;
private readonly Func<IntPtr> _getAddress;
private readonly bool _isOwnedObject;
private readonly PerformanceCollectorService _performanceCollector;
private CancellationTokenSource? _clearCts = new();
private Task? _delayedZoningTask;
private bool _haltProcessing = false;
private bool _ignoreSendAfterRedraw = false;
private int _ptrNullCounter = 0;
private byte _classJob = 0;
private CancellationTokenSource _zoningCts = new();
public GameObjectHandler(ILogger<GameObjectHandler> logger, PerformanceCollectorService performanceCollector,
MareMediator mediator, DalamudUtilService dalamudUtil, ObjectKind objectKind, Func<IntPtr> getAddress, bool ownedObject = true) : base(logger, mediator)
{
_performanceCollector = performanceCollector;
ObjectKind = objectKind;
_dalamudUtil = dalamudUtil;
_getAddress = () =>
{
_dalamudUtil.EnsureIsOnFramework();
return getAddress.Invoke();
};
_isOwnedObject = ownedObject;
Name = string.Empty;
if (ownedObject)
{
Mediator.Subscribe<TransientResourceChangedMessage>(this, (msg) =>
{
if (_delayedZoningTask?.IsCompleted ?? true)
{
if (msg.Address != Address) return;
Mediator.Publish(new CreateCacheForObjectMessage(this));
}
});
}
Mediator.Subscribe<FrameworkUpdateMessage>(this, (_) => FrameworkUpdate());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (_) => ZoneSwitchEnd());
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) => ZoneSwitchStart());
Mediator.Subscribe<CutsceneStartMessage>(this, (_) =>
{
_haltProcessing = true;
});
Mediator.Subscribe<CutsceneEndMessage>(this, (_) =>
{
_haltProcessing = false;
ZoneSwitchEnd();
});
Mediator.Subscribe<PenumbraStartRedrawMessage>(this, (msg) =>
{
if (msg.Address == Address)
{
_haltProcessing = true;
}
});
Mediator.Subscribe<PenumbraEndRedrawMessage>(this, (msg) =>
{
if (msg.Address == Address)
{
_haltProcessing = false;
_ = Task.Run(async () =>
{
_ignoreSendAfterRedraw = true;
await Task.Delay(500).ConfigureAwait(false);
_ignoreSendAfterRedraw = false;
});
}
});
Mediator.Publish(new GameObjectHandlerCreatedMessage(this, _isOwnedObject));
_dalamudUtil.RunOnFrameworkThread(CheckAndUpdateObject).GetAwaiter().GetResult();
}
private enum DrawCondition
{
None,
DrawObjectZero,
RenderFlags,
ModelInSlotLoaded,
ModelFilesInSlotLoaded
}
public byte RaceId { get; private set; }
public byte Gender { get; private set; }
public byte TribeId { get; private set; }
public IntPtr Address { get; private set; }
public string Name { get; private set; }
public ObjectKind ObjectKind { get; }
private byte[] CustomizeData { get; set; } = new byte[26];
private IntPtr DrawObjectAddress { get; set; }
private byte[] EquipSlotData { get; set; } = new byte[40];
private ushort[] MainHandData { get; set; } = new ushort[3];
private ushort[] OffHandData { get; set; } = new ushort[3];
public async Task ActOnFrameworkAfterEnsureNoDrawAsync(Action<Dalamud.Game.ClientState.Objects.Types.ICharacter> act, CancellationToken token)
{
while (await _dalamudUtil.RunOnFrameworkThread(() =>
{
if (IsBeingDrawn()) return true;
var gameObj = _dalamudUtil.CreateGameObject(Address);
if (gameObj is Dalamud.Game.ClientState.Objects.Types.ICharacter chara)
{
act.Invoke(chara);
}
return false;
}).ConfigureAwait(false))
{
await Task.Delay(250, token).ConfigureAwait(false);
}
}
public void CompareNameAndThrow(string name)
{
if (!string.Equals(Name, name, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Player name not equal to requested name, pointer invalid");
}
if (Address == IntPtr.Zero)
{
throw new InvalidOperationException("Player pointer is zero, pointer invalid");
}
}
public IntPtr CurrentAddress()
{
_dalamudUtil.EnsureIsOnFramework();
return _getAddress.Invoke();
}
public Dalamud.Game.ClientState.Objects.Types.IGameObject? GetGameObject()
{
return _dalamudUtil.CreateGameObject(Address);
}
public void Invalidate()
{
Address = IntPtr.Zero;
DrawObjectAddress = IntPtr.Zero;
_haltProcessing = false;
}
public async Task<bool> IsBeingDrawnRunOnFrameworkAsync()
{
return await _dalamudUtil.RunOnFrameworkThread(IsBeingDrawn).ConfigureAwait(false);
}
public override string ToString()
{
var owned = _isOwnedObject ? "Self" : "Other";
return $"{owned}/{ObjectKind}:{Name} ({Address:X},{DrawObjectAddress:X})";
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Mediator.Publish(new GameObjectHandlerDestroyedMessage(this, _isOwnedObject));
}
private unsafe void CheckAndUpdateObject()
{
var prevAddr = Address;
var prevDrawObj = DrawObjectAddress;
Address = _getAddress();
if (Address != IntPtr.Zero)
{
_ptrNullCounter = 0;
var drawObjAddr = (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->DrawObject;
DrawObjectAddress = drawObjAddr;
}
else
{
DrawObjectAddress = IntPtr.Zero;
}
if (_haltProcessing) return;
bool drawObjDiff = DrawObjectAddress != prevDrawObj;
bool addrDiff = Address != prevAddr;
if (Address != IntPtr.Zero && DrawObjectAddress != IntPtr.Zero)
{
if (_clearCts != null)
{
Logger.LogDebug("[{this}] Cancelling Clear Task", this);
_clearCts.CancelDispose();
_clearCts = null;
}
var chara = (Character*)Address;
var name = chara->GameObject.NameString;
bool nameChange = !string.Equals(name, Name, StringComparison.Ordinal);
if (nameChange)
{
Name = name;
}
bool equipDiff = false;
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
{
var classJob = chara->CharacterData.ClassJob;
if (classJob != _classJob)
{
Logger.LogTrace("[{this}] classjob changed from {old} to {new}", this, _classJob, classJob);
_classJob = classJob;
Mediator.Publish(new ClassJobChangedMessage(this));
}
equipDiff = CompareAndUpdateEquipByteData((byte*)&((Human*)DrawObjectAddress)->Head);
ref var mh = ref chara->DrawData.Weapon(WeaponSlot.MainHand);
ref var oh = ref chara->DrawData.Weapon(WeaponSlot.OffHand);
equipDiff |= CompareAndUpdateMainHand((Weapon*)mh.DrawObject);
equipDiff |= CompareAndUpdateOffHand((Weapon*)oh.DrawObject);
if (equipDiff)
Logger.LogTrace("Checking [{this}] equip data as human from draw obj, result: {diff}", this, equipDiff);
}
else
{
equipDiff = CompareAndUpdateEquipByteData((byte*)Unsafe.AsPointer(ref chara->DrawData.EquipmentModelIds[0]));
if (equipDiff)
Logger.LogTrace("Checking [{this}] equip data from game obj, result: {diff}", this, equipDiff);
}
if (equipDiff && !_isOwnedObject && !_ignoreSendAfterRedraw) // send the message out immediately and cancel out, no reason to continue if not self
{
Logger.LogTrace("[{this}] Changed", this);
Mediator.Publish(new CharacterChangedMessage(this));
return;
}
bool customizeDiff = false;
if (((DrawObject*)DrawObjectAddress)->Object.GetObjectType() == ObjectType.CharacterBase
&& ((CharacterBase*)DrawObjectAddress)->GetModelType() == CharacterBase.ModelType.Human)
{
var gender = ((Human*)DrawObjectAddress)->Customize.Sex;
var raceId = ((Human*)DrawObjectAddress)->Customize.Race;
var tribeId = ((Human*)DrawObjectAddress)->Customize.Tribe;
if (_isOwnedObject && ObjectKind == ObjectKind.Player
&& (gender != Gender || raceId != RaceId || tribeId != TribeId))
{
Mediator.Publish(new CensusUpdateMessage(gender, raceId, tribeId));
Gender = gender;
RaceId = raceId;
TribeId = tribeId;
}
customizeDiff = CompareAndUpdateCustomizeData(((Human*)DrawObjectAddress)->Customize.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data as human from draw obj, result: {diff}", this, customizeDiff);
}
else
{
customizeDiff = CompareAndUpdateCustomizeData(chara->DrawData.CustomizeData.Data);
if (customizeDiff)
Logger.LogTrace("Checking [{this}] customize data from game obj, result: {diff}", this, equipDiff);
}
if ((addrDiff || drawObjDiff || equipDiff || customizeDiff || nameChange) && _isOwnedObject)
{
Logger.LogDebug("[{this}] Changed, Sending CreateCacheObjectMessage", this);
Mediator.Publish(new CreateCacheForObjectMessage(this));
}
}
else if (addrDiff || drawObjDiff)
{
Logger.LogTrace("[{this}] Changed", this);
if (_isOwnedObject && ObjectKind != ObjectKind.Player)
{
_clearCts?.CancelDispose();
_clearCts = new();
var token = _clearCts.Token;
_ = Task.Run(() => ClearAsync(token), token);
}
}
}
private async Task ClearAsync(CancellationToken token)
{
Logger.LogDebug("[{this}] Running Clear Task", this);
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
Logger.LogDebug("[{this}] Sending ClearCachedForObjectMessage", this);
Mediator.Publish(new ClearCacheForObjectMessage(this));
_clearCts = null;
}
private unsafe bool CompareAndUpdateCustomizeData(Span<byte> customizeData)
{
bool hasChanges = false;
for (int i = 0; i < customizeData.Length; i++)
{
var data = customizeData[i];
if (CustomizeData[i] != data)
{
CustomizeData[i] = data;
hasChanges = true;
}
}
return hasChanges;
}
private unsafe bool CompareAndUpdateEquipByteData(byte* equipSlotData)
{
bool hasChanges = false;
for (int i = 0; i < EquipSlotData.Length; i++)
{
var data = equipSlotData[i];
if (EquipSlotData[i] != data)
{
EquipSlotData[i] = data;
hasChanges = true;
}
}
return hasChanges;
}
private unsafe bool CompareAndUpdateMainHand(Weapon* weapon)
{
if ((nint)weapon == nint.Zero) return false;
bool hasChanges = false;
hasChanges |= weapon->ModelSetId != MainHandData[0];
MainHandData[0] = weapon->ModelSetId;
hasChanges |= weapon->Variant != MainHandData[1];
MainHandData[1] = weapon->Variant;
hasChanges |= weapon->SecondaryId != MainHandData[2];
MainHandData[2] = weapon->SecondaryId;
return hasChanges;
}
private unsafe bool CompareAndUpdateOffHand(Weapon* weapon)
{
if ((nint)weapon == nint.Zero) return false;
bool hasChanges = false;
hasChanges |= weapon->ModelSetId != OffHandData[0];
OffHandData[0] = weapon->ModelSetId;
hasChanges |= weapon->Variant != OffHandData[1];
OffHandData[1] = weapon->Variant;
hasChanges |= weapon->SecondaryId != OffHandData[2];
OffHandData[2] = weapon->SecondaryId;
return hasChanges;
}
private void FrameworkUpdate()
{
if (!_delayedZoningTask?.IsCompleted ?? false) return;
try
{
_performanceCollector.LogPerformance(this, $"CheckAndUpdateObject>{(_isOwnedObject ? "Self" : "Other")}+{ObjectKind}/{(string.IsNullOrEmpty(Name) ? "Unk" : Name)}"
+ $"+{Address.ToString("X")}", CheckAndUpdateObject);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error during FrameworkUpdate of {this}", this);
}
}
private unsafe IntPtr GetDrawObjUnsafe(nint curPtr)
{
return (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)curPtr)->DrawObject;
}
private bool IsBeingDrawn()
{
var curPtr = _getAddress();
Logger.LogTrace("[{this}] IsBeingDrawn, CurPtr: {ptr}", this, curPtr.ToString("X"));
if (curPtr == IntPtr.Zero && _ptrNullCounter < 2)
{
Logger.LogTrace("[{this}] IsBeingDrawn, CurPtr is ZERO, counter is {cnt}", this, _ptrNullCounter);
_ptrNullCounter++;
return true;
}
if (curPtr == IntPtr.Zero)
{
Logger.LogTrace("[{this}] IsBeingDrawn, CurPtr is ZERO, returning", this);
Address = IntPtr.Zero;
DrawObjectAddress = IntPtr.Zero;
throw new ArgumentNullException($"CurPtr for {this} turned ZERO");
}
if (_dalamudUtil.IsAnythingDrawing)
{
Logger.LogTrace("[{this}] IsBeingDrawn, Global draw block", this);
return true;
}
var drawObj = GetDrawObjUnsafe(curPtr);
Logger.LogTrace("[{this}] IsBeingDrawn, DrawObjPtr: {ptr}", this, drawObj.ToString("X"));
var isDrawn = IsBeingDrawnUnsafe(drawObj, curPtr);
Logger.LogTrace("[{this}] IsBeingDrawn, Condition: {cond}", this, isDrawn);
return isDrawn != DrawCondition.None;
}
private unsafe DrawCondition IsBeingDrawnUnsafe(IntPtr drawObj, IntPtr curPtr)
{
var drawObjZero = drawObj == IntPtr.Zero;
if (drawObjZero) return DrawCondition.DrawObjectZero;
var renderFlags = (((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)curPtr)->RenderFlags) != 0x0;
if (renderFlags) return DrawCondition.RenderFlags;
if (ObjectKind == ObjectKind.Player)
{
var modelInSlotLoaded = (((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0);
if (modelInSlotLoaded) return DrawCondition.ModelInSlotLoaded;
var modelFilesInSlotLoaded = (((CharacterBase*)drawObj)->HasModelFilesInSlotLoaded != 0);
if (modelFilesInSlotLoaded) return DrawCondition.ModelFilesInSlotLoaded;
return DrawCondition.None;
}
return DrawCondition.None;
}
private void ZoneSwitchEnd()
{
if (!_isOwnedObject || _haltProcessing) return;
_clearCts?.Cancel();
_clearCts?.Dispose();
_clearCts = null;
try
{
_zoningCts?.CancelAfter(2500);
}
catch (ObjectDisposedException)
{
// ignore
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Zoning CTS cancel issue");
}
}
private void ZoneSwitchStart()
{
if (!_isOwnedObject || _haltProcessing) return;
_zoningCts = new();
Logger.LogDebug("[{obj}] Starting Delay After Zoning", this);
_delayedZoningTask = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromSeconds(120), _zoningCts.Token).ConfigureAwait(false);
}
catch
{
// ignore cancelled
}
finally
{
Logger.LogDebug("[{this}] Delay after zoning complete", this);
_zoningCts.Dispose();
}
});
}
}

View File

@@ -0,0 +1,890 @@
using MareSynchronos.API.Data;
using MareSynchronos.FileCache;
using MareSynchronos.Interop.Ipc;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Factories;
using MareSynchronos.PlayerData.Pairs;
using MareSynchronos.Services;
using MareSynchronos.Services.Events;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Utils;
using MareSynchronos.WebAPI.Files;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Diagnostics;
using ObjectKind = MareSynchronos.API.Data.Enum.ObjectKind;
namespace MareSynchronos.PlayerData.Handlers;
public sealed class PairHandler : DisposableMediatorSubscriberBase
{
private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced);
private readonly MareConfigService _configService;
private readonly DalamudUtilService _dalamudUtil;
private readonly FileDownloadManager _downloadManager;
private readonly FileCacheManager _fileDbManager;
private readonly GameObjectHandlerFactory _gameObjectHandlerFactory;
private readonly IpcManager _ipcManager;
private readonly PlayerPerformanceService _playerPerformanceService;
private readonly ServerConfigurationManager _serverConfigManager;
private readonly PluginWarningNotificationService _pluginWarningNotificationManager;
private readonly VisibilityService _visibilityService;
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 _deferred = Guid.Empty;
private Guid _penumbraCollection = Guid.Empty;
private bool _redrawOnNextApplication = false;
public PairHandler(ILogger<PairHandler> logger, Pair pair, PairAnalyzer pairAnalyzer,
GameObjectHandlerFactory gameObjectHandlerFactory,
IpcManager ipcManager, FileDownloadManager transferManager,
PluginWarningNotificationService pluginWarningNotificationManager,
DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime,
FileCacheManager fileDbManager, MareMediator mediator,
PlayerPerformanceService playerPerformanceService,
ServerConfigurationManager serverConfigManager,
MareConfigService configService, VisibilityService visibilityService) : base(logger, mediator)
{
Pair = pair;
PairAnalyzer = pairAnalyzer;
_gameObjectHandlerFactory = gameObjectHandlerFactory;
_ipcManager = ipcManager;
_downloadManager = transferManager;
_pluginWarningNotificationManager = pluginWarningNotificationManager;
_dalamudUtil = dalamudUtil;
_fileDbManager = fileDbManager;
_playerPerformanceService = playerPerformanceService;
_serverConfigManager = serverConfigManager;
_configService = configService;
_visibilityService = visibilityService;
_visibilityService.StartTracking(Pair.Ident);
Mediator.SubscribeKeyed<PlayerVisibilityMessage>(this, Pair.Ident, (msg) => UpdateVisibility(msg.IsVisible, msg.Invalidate));
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (_) =>
{
_downloadCancellationTokenSource?.CancelDispose();
_charaHandler?.Invalidate();
IsVisible = false;
});
Mediator.Subscribe<PenumbraInitializedMessage>(this, (_) =>
{
_penumbraCollection = Guid.Empty;
if (!IsVisible && _charaHandler != null)
{
PlayerName = string.Empty;
_charaHandler.Dispose();
_charaHandler = null;
}
});
Mediator.Subscribe<ClassJobChangedMessage>(this, (msg) =>
{
if (msg.GameObjectHandler == _charaHandler)
{
_redrawOnNextApplication = true;
}
});
Mediator.Subscribe<CombatOrPerformanceEndMessage>(this, (msg) =>
{
if (IsVisible && _dataReceivedInDowntime != null)
{
ApplyCharacterData(_dataReceivedInDowntime.ApplicationId,
_dataReceivedInDowntime.CharacterData, _dataReceivedInDowntime.Forced);
_dataReceivedInDowntime = null;
}
});
Mediator.Subscribe<CombatOrPerformanceStartMessage>(this, _ =>
{
if (_configService.Current.HoldCombatApplication)
{
_dataReceivedInDowntime = null;
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
}
});
Mediator.Subscribe<RecalculatePerformanceMessage>(this, (msg) =>
{
if (msg.UID != null && !msg.UID.Equals(Pair.UserData.UID, StringComparison.Ordinal)) return;
Logger.LogDebug("Recalculating performance for {uid}", Pair.UserData.UID);
pair.ApplyLastReceivedData(forced: true);
});
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)));
}
}
}
public long LastAppliedDataBytes { get; private set; }
public Pair Pair { get; private init; }
public PairAnalyzer PairAnalyzer { get; private init; }
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 (_configService.Current.HoldCombatApplication && _dalamudUtil.IsInCombatOrPerforming)
{
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
"Cannot apply character data: you are in combat or performing music, deferring application")));
Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat or performing", applicationBase);
_dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization);
SetUploading(isUploading: false);
return;
}
if (_charaHandler == null || (PlayerCharacter == IntPtr.Zero))
{
if (_deferred != Guid.Empty)
{
_isVisible = false;
_visibilityService.StopTracking(Pair.Ident);
_visibilityService.StartTracking(Pair.Ident);
}
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;
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, characterData));
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
// Ensure that this deferred application actually occurs by forcing visibiltiy to re-proc
// Set _deferred as a silencing flag to avoid spamming logs once per frame with failed applications
_isVisible = false;
_deferred = applicationBase;
_visibilityService.StopTracking(Pair.Ident);
_visibilityService.StartTracking(Pair.Ident);
return;
}
_deferred = Guid.Empty;
SetUploading(isUploading: false);
if (Pair.IsDownloadBlocked)
{
var reasons = string.Join(", ", Pair.HoldDownloadReasons);
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
$"Not applying character data: {reasons}")));
Logger.LogDebug("[BASE-{appBase}] Not applying due to hold: {reasons}", applicationBase, reasons);
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;
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, characterData));
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
return;
}
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);
if (!disposing) return;
_visibilityService.StopTracking(Pair.Ident);
SetUploading(isUploading: false);
var name = PlayerName;
Logger.LogDebug("Disposing {name} ({user})", name, Pair);
try
{
Guid applicationId = Guid.NewGuid();
if (!string.IsNullOrEmpty(name))
{
Mediator.Publish(new EventMessage(new Event(name, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Disposing User")));
}
UndoApplicationAsync(applicationId).GetAwaiter().GetResult();
_applicationCancellationTokenSource?.Dispose();
_applicationCancellationTokenSource = null;
_downloadCancellationTokenSource?.Dispose();
_downloadCancellationTokenSource = null;
_charaHandler?.Dispose();
_charaHandler = null;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error on disposal of {name}", name);
}
finally
{
PlayerName = null;
_cachedData = null;
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, null));
Logger.LogDebug("Disposing {name} complete", name);
}
}
public void UndoApplication(Guid applicationId = default)
{
_ = Task.Run(async () => {
await UndoApplicationAsync(applicationId).ConfigureAwait(false);
});
}
private async Task UndoApplicationAsync(Guid applicationId = default)
{
Logger.LogDebug($"Undoing application of {Pair.UserPair}");
var name = PlayerName;
try
{
if (applicationId == default)
applicationId = Guid.NewGuid();
_applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate();
_downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate();
Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, Pair.UserPair);
if (_penumbraCollection != Guid.Empty)
{
await _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, _penumbraCollection).ConfigureAwait(false);
_penumbraCollection = Guid.Empty;
}
if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name))
{
Logger.LogTrace("[{applicationId}] Restoring state for {name} ({OnlineUser})", applicationId, name, Pair.UserPair);
if (!IsVisible)
{
Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, Pair.UserPair);
await _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).ConfigureAwait(false);
}
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
{
await RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
Logger.LogWarning(ex, "Failed disposing player (not present anymore?)");
break;
}
}
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Error on undoing application of {name}", 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.GetCompanion(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMount(ptr), isWatched: false).ConfigureAwait(false),
ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPet(ptr), isWatched: false).ConfigureAwait(false),
_ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key)
};
async Task processApplication(IEnumerable<PlayerChanges> changeList)
{
foreach (var change in changeList)
{
Logger.LogDebug("[{applicationId}{ft}] Processing {change} for {handler}", applicationId, _dalamudUtil.IsOnFrameworkThread ? "*" : "", 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, allowImmediate: true).ConfigureAwait(false);
}
break;
case PlayerChanges.PetNames:
await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false);
break;
case PlayerChanges.Moodles:
await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false);
break;
case PlayerChanges.ForcedRedraw:
await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false);
break;
default:
break;
}
token.ThrowIfCancellationRequested();
}
}
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();
if (_configService.Current.SerialApplication)
{
var serialChangeList = changes.Value.Where(p => p <= PlayerChanges.ForcedRedraw).OrderBy(p => (int)p);
var asyncChangeList = changes.Value.Where(p => p > PlayerChanges.ForcedRedraw).OrderBy(p => (int)p);
await _dalamudUtil.RunOnFrameworkThread(async () => await processApplication(serialChangeList).ConfigureAwait(false)).ConfigureAwait(false);
await Task.Run(async () => await processApplication(asyncChangeList).ConfigureAwait(false), CancellationToken.None).ConfigureAwait(false);
}
else
{
_ = processApplication(changes.Value.OrderBy(p => (int)p));
}
}
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;
_ = Task.Run(async () => {
await 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)
{
Logger.LogTrace("[BASE-{appBase}] DownloadAndApplyCharacterAsync", applicationBase);
Dictionary<(string GamePath, string? Hash), string> moddedPaths = [];
if (updateModdedPaths)
{
Logger.LogTrace("[BASE-{appBase}] DownloadAndApplyCharacterAsync > updateModdedPaths", applicationBase);
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))
{
Pair.HoldApplication("IndividualPerformanceThreshold", maxValue: 1);
_downloadManager.ClearDownload();
return;
}
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false), downloadToken);
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);
}
try
{
Mediator.Publish(new HaltScanMessage(nameof(PlayerPerformanceService.ShrinkTextures)));
if (await _playerPerformanceService.ShrinkTextures(this, charaData, downloadToken).ConfigureAwait(false))
_ = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken);
}
finally
{
Mediator.Publish(new ResumeScanMessage(nameof(PlayerPerformanceService.ShrinkTextures)));
}
bool exceedsThreshold = !await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false);
if (exceedsThreshold)
Pair.HoldApplication("IndividualPerformanceThreshold", maxValue: 1);
else
Pair.UnholdApplication("IndividualPerformanceThreshold");
if (exceedsThreshold)
{
Logger.LogTrace("[BASE-{appBase}] Not applying due to performance thresholds", applicationBase);
return;
}
}
if (Pair.IsApplicationBlocked)
{
var reasons = string.Join(", ", Pair.HoldApplicationReasons);
Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning,
$"Not applying character data: {reasons}")));
Logger.LogTrace("[BASE-{appBase}] Not applying due to hold: {reasons}", applicationBase, reasons);
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)
{
ushort objIndex = ushort.MaxValue;
try
{
_applicationId = Guid.NewGuid();
Logger.LogDebug("[BASE-{applicationId}] Starting application task for {this}: {appId}", applicationBase, this, _applicationId);
if (_penumbraCollection == Guid.Empty)
{
if (objIndex == ushort.MaxValue)
objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false);
_penumbraCollection = await _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, Pair.UserData.UID).ConfigureAwait(false);
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false);
}
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
if (objIndex == ushort.MaxValue)
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;
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, 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;
Mediator.Publish(new PairDataAppliedMessage(Pair.UserData.UID, charaData));
Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId);
}
else
{
Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId);
}
}
}
private void UpdateVisibility(bool nowVisible, bool invalidate = false)
{
if (string.IsNullOrEmpty(PlayerName))
{
var pc = _dalamudUtil.FindPlayerByNameHash(Pair.Ident);
if (pc.ObjectId == 0) 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}")));
}
// This was triggered by the character becoming handled by Mare, so unapply everything
// There seems to be a good chance that this races Mare and then crashes
if (!nowVisible && invalidate)
{
bool wasVisible = IsVisible;
IsVisible = false;
_charaHandler?.Invalidate();
_downloadCancellationTokenSource?.CancelDispose();
_downloadCancellationTokenSource = null;
if (wasVisible)
Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible);
Logger.LogDebug("Invalidating {this}", this);
UndoApplication();
return;
}
if (!IsVisible && nowVisible)
{
// This is deferred application attempt, avoid any log output
if (_deferred != Guid.Empty)
{
_isVisible = true;
_ = Task.Run(() =>
{
ApplyCharacterData(_deferred, _cachedData!, forceApplyCustomization: true);
});
}
IsVisible = true;
Mediator.Publish(new PairHandlerVisibleMessage(this));
if (_cachedData != null)
{
Guid appData = Guid.NewGuid();
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 (IsVisible && !nowVisible)
{
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();
Mediator.Subscribe<HonorificReadyMessage>(this, msg =>
{
if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return;
Logger.LogTrace("Reapplying Honorific data for {this}", this);
_ = Task.Run(async () => await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false), CancellationToken.None);
});
Mediator.Subscribe<PetNamesReadyMessage>(this, msg =>
{
if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return;
Logger.LogTrace("Reapplying Pet Names data for {this}", this);
_ = Task.Run(async () => await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false), CancellationToken.None);
});
}
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 Pet Nicknames for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false);
Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name);
await _ipcManager.Moodles.RevertStatusAsync(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, preferSubst: true);
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)
{
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];
}
}

View File

@@ -0,0 +1,75 @@
using MareSynchronos.API.Data;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Utils;
using MareSynchronos.WebAPI;
using MareSynchronos.WebAPI.Files;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Pairs;
public class OnlinePlayerManager : DisposableMediatorSubscriberBase
{
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtil;
private readonly FileUploadManager _fileTransferManager;
private readonly HashSet<PairHandler> _newVisiblePlayers = [];
private readonly PairManager _pairManager;
private CharacterData? _lastSentData;
public OnlinePlayerManager(ILogger<OnlinePlayerManager> logger, ApiController apiController, DalamudUtilService dalamudUtil,
PairManager pairManager, MareMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator)
{
_apiController = apiController;
_dalamudUtil = dalamudUtil;
_pairManager = pairManager;
_fileTransferManager = fileTransferManager;
Mediator.Subscribe<PlayerChangedMessage>(this, (_) => PlayerManagerOnPlayerHasChanged());
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (_) => FrameworkOnUpdate());
Mediator.Subscribe<CharacterDataCreatedMessage>(this, (msg) =>
{
var newData = msg.CharacterData;
if (_lastSentData == null || (!string.Equals(newData.DataHash.Value, _lastSentData.DataHash.Value, StringComparison.Ordinal)))
{
Logger.LogDebug("Pushing data for visible players");
_lastSentData = newData;
PushCharacterData(_pairManager.GetVisibleUsers());
}
else
{
Logger.LogDebug("Not sending data for {hash}", newData.DataHash.Value);
}
});
Mediator.Subscribe<PairHandlerVisibleMessage>(this, (msg) => _newVisiblePlayers.Add(msg.Player));
Mediator.Subscribe<ConnectedMessage>(this, (_) => PushCharacterData(_pairManager.GetVisibleUsers()));
}
private void FrameworkOnUpdate()
{
if (!_dalamudUtil.GetIsPlayerPresent() || !_apiController.IsConnected) return;
if (!_newVisiblePlayers.Any()) return;
var newVisiblePlayers = _newVisiblePlayers.ToList();
_newVisiblePlayers.Clear();
Logger.LogTrace("Has new visible players, pushing character data");
PushCharacterData(newVisiblePlayers.Select(c => c.Pair.UserData).ToList());
}
private void PlayerManagerOnPlayerHasChanged()
{
PushCharacterData(_pairManager.GetVisibleUsers());
}
private void PushCharacterData(List<UserData> visiblePlayers)
{
if (visiblePlayers.Any() && _lastSentData != null)
{
_ = Task.Run(async () =>
{
var dataToSend = await _fileTransferManager.UploadFiles(_lastSentData.DeepClone(), visiblePlayers).ConfigureAwait(false);
await _apiController.PushCharacterData(dataToSend, visiblePlayers).ConfigureAwait(false);
});
}
}
}

View File

@@ -0,0 +1,10 @@
namespace MareSynchronos.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 ShowPetNicknamesWarning { get; set; } = false;
public bool ShownMoodlesWarning { get; set; } = false;
}

View File

@@ -0,0 +1,371 @@
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text.SeStringHandling;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Comparer;
using MareSynchronos.API.Data.Enum;
using MareSynchronos.API.Data.Extensions;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.API.Dto.User;
using MareSynchronos.MareConfiguration;
using MareSynchronos.PlayerData.Factories;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using MareSynchronos.Services.ServerConfiguration;
using MareSynchronos.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace MareSynchronos.PlayerData.Pairs;
public class Pair : DisposableMediatorSubscriberBase
{
private readonly PairHandlerFactory _cachedPlayerFactory;
private readonly SemaphoreSlim _creationSemaphore = new(1);
private readonly ILogger<Pair> _logger;
private readonly MareConfigService _mareConfig;
private readonly ServerConfigurationManager _serverConfigurationManager;
private CancellationTokenSource _applicationCts = new();
private OnlineUserIdentDto? _onlineUserIdentDto = null;
public Pair(ILogger<Pair> logger, UserData userData, PairHandlerFactory cachedPlayerFactory,
MareMediator mediator, MareConfigService mareConfig, ServerConfigurationManager serverConfigurationManager)
: base(logger, mediator)
{
_logger = logger;
_cachedPlayerFactory = cachedPlayerFactory;
_mareConfig = mareConfig;
_serverConfigurationManager = serverConfigurationManager;
UserData = userData;
Mediator.SubscribeKeyed<HoldPairApplicationMessage>(this, UserData.UID, (msg) => HoldApplication(msg.Source));
Mediator.SubscribeKeyed<UnholdPairApplicationMessage>(this, UserData.UID, (msg) => UnholdApplication(msg.Source));
}
public Dictionary<GroupFullInfoDto, GroupPairFullInfoDto> GroupPair { get; set; } = new(GroupDtoComparer.Instance);
public bool HasCachedPlayer => CachedPlayer != null && !string.IsNullOrEmpty(CachedPlayer.PlayerName) && _onlineUserIdentDto != null;
public bool IsOnline => CachedPlayer != null;
public bool IsPaused => UserPair != null && UserPair.OtherPermissions.IsPaired() ? UserPair.OtherPermissions.IsPaused() || UserPair.OwnPermissions.IsPaused()
: GroupPair.All(p => p.Key.GroupUserPermissions.IsPaused() || p.Value.GroupUserPermissions.IsPaused());
// Download locks apply earlier in the process than Application locks
private ConcurrentDictionary<string, int> HoldDownloadLocks { get; set; } = new(StringComparer.Ordinal);
private ConcurrentDictionary<string, int> HoldApplicationLocks { get; set; } = new(StringComparer.Ordinal);
public bool IsDownloadBlocked => HoldDownloadLocks.Any(f => f.Value > 0);
public bool IsApplicationBlocked => HoldApplicationLocks.Any(f => f.Value > 0) || IsDownloadBlocked;
public IEnumerable<string> HoldDownloadReasons => HoldDownloadLocks.Keys;
public IEnumerable<string> HoldApplicationReasons => Enumerable.Concat(HoldDownloadLocks.Keys, HoldApplicationLocks.Keys);
public bool IsVisible => CachedPlayer?.IsVisible ?? false;
public CharacterData? LastReceivedCharacterData { get; set; }
public string? PlayerName => GetPlayerName();
public uint PlayerCharacterId => GetPlayerCharacterId();
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 PairAnalyzer? PairAnalyzer => CachedPlayer?.PairAnalyzer;
public UserData UserData { get; init; }
public UserPairDto? 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;
void Add(string name, Action<IMenuItemClickedArgs>? action)
{
args.AddMenuItem(new MenuItem()
{
Name = name,
OnClicked = action,
PrefixColor = 526,
PrefixChar = 'S'
});
}
bool isBlocked = IsApplicationBlocked;
bool isBlacklisted = _serverConfigurationManager.IsUidBlacklisted(UserData.UID);
bool isWhitelisted = _serverConfigurationManager.IsUidWhitelisted(UserData.UID);
Add("Open Profile", _ => Mediator.Publish(new ProfileOpenStandaloneMessage(this)));
if (!isBlocked && !isBlacklisted)
Add("Always Block Modded Appearance", _ => {
_serverConfigurationManager.AddBlacklistUid(UserData.UID);
HoldApplication("Blacklist", maxValue: 1);
ApplyLastReceivedData(forced: true);
});
else if (isBlocked && !isWhitelisted)
Add("Always Allow Modded Appearance", _ => {
_serverConfigurationManager.AddWhitelistUid(UserData.UID);
UnholdApplication("Blacklist", skipApplication: true);
ApplyLastReceivedData(forced: true);
});
if (isWhitelisted)
Add("Remove from Whitelist", _ => {
_serverConfigurationManager.RemoveWhitelistUid(UserData.UID);
ApplyLastReceivedData(forced: true);
});
else if (isBlacklisted)
Add("Remove from Blacklist", _ => {
_serverConfigurationManager.RemoveBlacklistUid(UserData.UID);
UnholdApplication("Blacklist", skipApplication: true);
ApplyLastReceivedData(forced: true);
});
Add("Reapply last data", _ => ApplyLastReceivedData(forced: true));
if (UserPair != null)
{
Add("Change Permissions", _ => Mediator.Publish(new OpenPermissionWindow(this)));
Add("Cycle pause state", _ => Mediator.Publish(new CyclePauseMessage(UserData)));
}
}
public void ApplyData(OnlineUserCharaDataDto data)
{
_applicationCts = _applicationCts.CancelRecreate();
LastReceivedCharacterData = data.CharaData;
if (CachedPlayer == null)
{
_logger.LogDebug("Received Data for {uid} but CachedPlayer does not exist, waiting", data.User.UID);
_ = Task.Run(async () =>
{
using var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(TimeSpan.FromSeconds(120));
var appToken = _applicationCts.Token;
using var combined = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, appToken);
while (CachedPlayer == null && !combined.Token.IsCancellationRequested)
{
await Task.Delay(250, combined.Token).ConfigureAwait(false);
}
if (!combined.IsCancellationRequested)
{
_logger.LogDebug("Applying delayed data for {uid}", data.User.UID);
ApplyLastReceivedData();
}
});
return;
}
ApplyLastReceivedData();
}
public void ApplyLastReceivedData(bool forced = false)
{
if (CachedPlayer == null) return;
if (LastReceivedCharacterData == null) return;
if (IsDownloadBlocked) return;
if (_serverConfigurationManager.IsUidBlacklisted(UserData.UID))
HoldApplication("Blacklist", maxValue: 1);
CachedPlayer.ApplyCharacterData(Guid.NewGuid(), RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone())!, forced);
}
public void CreateCachedPlayer(OnlineUserIdentDto? dto = null)
{
try
{
_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);
}
finally
{
_creationSemaphore.Release();
}
}
public string? GetNote()
{
return _serverConfigurationManager.GetNoteForUid(UserData.UID);
}
public string? GetPlayerName()
{
if (CachedPlayer != null && CachedPlayer.PlayerName != null)
return CachedPlayer.PlayerName;
else
return _serverConfigurationManager.GetNameForUid(UserData.UID);
}
public uint GetPlayerCharacterId()
{
if (CachedPlayer != null)
return CachedPlayer.PlayerCharacterId;
return uint.MaxValue;
}
public string? GetNoteOrName()
{
string? note = GetNote();
if (_mareConfig.Current.ShowCharacterNames || IsVisible)
return note ?? GetPlayerName();
else
return note;
}
public string GetPairSortKey()
{
string? noteOrName = GetNoteOrName();
if (noteOrName != null)
return $"0{noteOrName}";
else
return $"9{UserData.AliasOrUID}";
}
public string GetPlayerNameHash()
{
return CachedPlayer?.PlayerNameHash ?? string.Empty;
}
public bool HasAnyConnection()
{
return UserPair != null || GroupPair.Any();
}
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();
}
}
public void SetNote(string note)
{
_serverConfigurationManager.SetNoteForUid(UserData.UID, note);
}
internal void SetIsUploading()
{
CachedPlayer?.SetUploading();
}
public void HoldApplication(string source, int maxValue = int.MaxValue)
{
_logger.LogDebug($"Holding {UserData.UID} for reason: {source}");
bool wasHeld = IsApplicationBlocked;
HoldApplicationLocks.AddOrUpdate(source, 1, (k, v) => Math.Min(maxValue, v + 1));
if (!wasHeld)
CachedPlayer?.UndoApplication();
}
public void UnholdApplication(string source, bool skipApplication = false)
{
_logger.LogDebug($"Un-holding {UserData.UID} for reason: {source}");
bool wasHeld = IsApplicationBlocked;
HoldApplicationLocks.AddOrUpdate(source, 0, (k, v) => Math.Max(0, v - 1));
HoldApplicationLocks.TryRemove(new(source, 0));
if (!skipApplication && wasHeld && !IsApplicationBlocked)
ApplyLastReceivedData(forced: true);
}
public void HoldDownloads(string source, int maxValue = int.MaxValue)
{
_logger.LogDebug($"Holding {UserData.UID} for reason: {source}");
bool wasHeld = IsApplicationBlocked;
HoldDownloadLocks.AddOrUpdate(source, 1, (k, v) => Math.Min(maxValue, v + 1));
if (!wasHeld)
CachedPlayer?.UndoApplication();
}
public void UnholdDownloads(string source, bool skipApplication = false)
{
_logger.LogDebug($"Un-holding {UserData.UID} for reason: {source}");
bool wasHeld = IsApplicationBlocked;
HoldDownloadLocks.AddOrUpdate(source, 0, (k, v) => Math.Max(0, v - 1));
HoldDownloadLocks.TryRemove(new(source, 0));
if (!skipApplication && wasHeld && !IsApplicationBlocked)
ApplyLastReceivedData(forced: true);
}
private CharacterData? RemoveNotSyncedFiles(CharacterData? data)
{
_logger.LogTrace("Removing not synced files");
if (data == null)
{
_logger.LogTrace("Nothing to remove");
return data;
}
var ActiveGroupPairs = GroupPair.Where(p => !p.Value.GroupUserPermissions.IsPaused() && !p.Key.GroupUserPermissions.IsPaused()).ToList();
bool disableIndividualAnimations = UserPair != null && (UserPair.OtherPermissions.IsDisableAnimations() || UserPair.OwnPermissions.IsDisableAnimations());
bool disableIndividualVFX = UserPair != null && (UserPair.OtherPermissions.IsDisableVFX() || UserPair.OwnPermissions.IsDisableVFX());
bool disableGroupAnimations = ActiveGroupPairs.All(pair => pair.Value.GroupUserPermissions.IsDisableAnimations() || pair.Key.GroupPermissions.IsDisableAnimations() || pair.Key.GroupUserPermissions.IsDisableAnimations());
bool disableAnimations = (UserPair != null && disableIndividualAnimations) || (UserPair == null && disableGroupAnimations);
bool disableIndividualSounds = UserPair != null && (UserPair.OtherPermissions.IsDisableSounds() || UserPair.OwnPermissions.IsDisableSounds());
bool disableGroupSounds = ActiveGroupPairs.All(pair => pair.Value.GroupUserPermissions.IsDisableSounds() || pair.Key.GroupPermissions.IsDisableSounds() || pair.Key.GroupUserPermissions.IsDisableSounds());
bool disableGroupVFX = ActiveGroupPairs.All(pair => pair.Value.GroupUserPermissions.IsDisableVFX() || pair.Key.GroupPermissions.IsDisableVFX() || pair.Key.GroupUserPermissions.IsDisableVFX());
bool disableSounds = (UserPair != null && disableIndividualSounds) || (UserPair == null && disableGroupSounds);
bool disableVFX = (UserPair != null && disableIndividualVFX) || (UserPair == null && disableGroupVFX);
_logger.LogTrace("Disable: Sounds: {disableSounds}, Anims: {disableAnimations}, VFX: {disableVFX}",
disableSounds, disableAnimations, disableVFX);
if (disableAnimations || disableSounds || disableVFX)
{
_logger.LogTrace("Data cleaned up: Animations disabled: {disableAnimations}, Sounds disabled: {disableSounds}, VFX disabled: {disableVFX}",
disableAnimations, disableSounds, disableVFX);
foreach (var objectKind in data.FileReplacements.Select(k => k.Key))
{
if (disableSounds)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase)))
.ToList();
if (disableAnimations)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) || p.EndsWith("pap", StringComparison.OrdinalIgnoreCase)))
.ToList();
if (disableVFX)
data.FileReplacements[objectKind] = data.FileReplacements[objectKind]
.Where(f => !f.GamePaths.Any(p => p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) || p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase)))
.ToList();
}
}
return data;
}
}

View File

@@ -0,0 +1,403 @@
using Dalamud.Plugin.Services;
using MareSynchronos.API.Data;
using MareSynchronos.API.Data.Comparer;
using MareSynchronos.API.Data.Extensions;
using MareSynchronos.API.Dto.Group;
using MareSynchronos.API.Dto.User;
using MareSynchronos.MareConfiguration;
using MareSynchronos.MareConfiguration.Models;
using MareSynchronos.PlayerData.Factories;
using MareSynchronos.Services.Events;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace MareSynchronos.PlayerData.Pairs;
public sealed class PairManager : DisposableMediatorSubscriberBase
{
private readonly ConcurrentDictionary<UserData, Pair> _allClientPairs = new(UserDataComparer.Instance);
private readonly ConcurrentDictionary<GroupData, GroupFullInfoDto> _allGroups = new(GroupDataComparer.Instance);
private readonly MareConfigService _configurationService;
private readonly IContextMenu _dalamudContextMenu;
private readonly PairFactory _pairFactory;
private Lazy<List<Pair>> _directPairsInternal;
private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> _groupPairsInternal;
public PairManager(ILogger<PairManager> logger, PairFactory pairFactory,
MareConfigService configurationService, MareMediator mediator,
IContextMenu dalamudContextMenu) : base(logger, mediator)
{
_pairFactory = pairFactory;
_configurationService = configurationService;
_dalamudContextMenu = dalamudContextMenu;
Mediator.Subscribe<DisconnectedMessage>(this, (_) => ClearPairs());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => ReapplyPairData());
_directPairsInternal = DirectPairsLazy();
_groupPairsInternal = GroupPairsLazy();
_dalamudContextMenu.OnMenuOpened += DalamudContextMenuOnOnOpenGameObjectContextMenu;
}
public List<Pair> DirectPairs => _directPairsInternal.Value;
public Dictionary<GroupFullInfoDto, List<Pair>> GroupPairs => _groupPairsInternal.Value;
public Dictionary<GroupData, GroupFullInfoDto> Groups => _allGroups.ToDictionary(k => k.Key, k => k.Value);
public Pair? LastAddedUser { get; internal set; }
public void AddGroup(GroupFullInfoDto dto)
{
_allGroups[dto.Group] = dto;
RecreateLazy();
}
public void AddGroupPair(GroupPairFullInfoDto dto)
{
if (!_allClientPairs.ContainsKey(dto.User))
_allClientPairs[dto.User] = _pairFactory.Create(dto.User);
var group = _allGroups[dto.Group];
_allClientPairs[dto.User].GroupPair[group] = dto;
RecreateLazy();
}
public Pair? GetPairByUID(string uid)
{
var existingPair = _allClientPairs.FirstOrDefault(f => uid.Equals(f.Key.UID, StringComparison.Ordinal));
if (!Equals(existingPair, default(KeyValuePair<UserData, Pair>)))
{
return existingPair.Value;
}
return null;
}
public void AddUserPair(UserPairDto dto, bool addToLastAddedUser = true)
{
if (!_allClientPairs.ContainsKey(dto.User))
{
_allClientPairs[dto.User] = _pairFactory.Create(dto.User);
}
else
{
addToLastAddedUser = false;
}
_allClientPairs[dto.User].UserPair = dto;
if (addToLastAddedUser)
LastAddedUser = _allClientPairs[dto.User];
_allClientPairs[dto.User].ApplyLastReceivedData();
RecreateLazy();
}
public void ClearPairs()
{
Logger.LogDebug("Clearing all Pairs");
DisposePairs();
_allClientPairs.Clear();
_allGroups.Clear();
RecreateLazy();
}
public List<Pair> GetOnlineUserPairs() => _allClientPairs.Where(p => !string.IsNullOrEmpty(p.Value.GetPlayerNameHash())).Select(p => p.Value).ToList();
public int GetVisibleUserCount() => _allClientPairs.Count(p => p.Value.IsVisible);
public List<UserData> GetVisibleUsers() => _allClientPairs.Where(p => p.Value.IsVisible).Select(p => p.Key).ToList();
public void MarkPairOffline(UserData user)
{
if (_allClientPairs.TryGetValue(user, out var pair))
{
Mediator.Publish(new ClearProfileDataMessage(pair.UserData));
pair.MarkOffline();
}
RecreateLazy();
}
public void MarkPairOnline(OnlineUserIdentDto dto, bool sendNotif = true)
{
if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto);
Mediator.Publish(new ClearProfileDataMessage(dto.User));
var pair = _allClientPairs[dto.User];
if (pair.HasCachedPlayer)
{
RecreateLazy();
return;
}
if (sendNotif && _configurationService.Current.ShowOnlineNotifications
&& (_configurationService.Current.ShowOnlineNotificationsOnlyForIndividualPairs && pair.UserPair != null
|| !_configurationService.Current.ShowOnlineNotificationsOnlyForIndividualPairs)
&& (_configurationService.Current.ShowOnlineNotificationsOnlyForNamedPairs && !string.IsNullOrEmpty(pair.GetNote())
|| !_configurationService.Current.ShowOnlineNotificationsOnlyForNamedPairs))
{
string? note = pair.GetNoteOrName();
var msg = !string.IsNullOrEmpty(note)
? $"{note} ({pair.UserData.AliasOrUID}) is now online"
: $"{pair.UserData.AliasOrUID} is now online";
Mediator.Publish(new NotificationMessage("User online", msg, NotificationType.Info, TimeSpan.FromSeconds(5)));
}
pair.CreateCachedPlayer(dto);
RecreateLazy();
}
public void ReceiveCharaData(OnlineUserCharaDataDto dto)
{
if (!_allClientPairs.TryGetValue(dto.User, out var pair)) throw new InvalidOperationException("No user found for " + dto.User);
Mediator.Publish(new EventMessage(new Event(pair.UserData, nameof(PairManager), EventSeverity.Informational, "Received Character Data")));
_allClientPairs[dto.User].ApplyData(dto);
}
public void RemoveGroup(GroupData data)
{
_allGroups.TryRemove(data, out _);
foreach (var item in _allClientPairs.ToList())
{
foreach (var grpPair in item.Value.GroupPair.Select(k => k.Key).Where(grpPair => GroupDataComparer.Instance.Equals(grpPair.Group, data)).ToList())
{
_allClientPairs[item.Key].GroupPair.Remove(grpPair);
}
if (!_allClientPairs[item.Key].HasAnyConnection() && _allClientPairs.TryRemove(item.Key, out var pair))
{
pair.MarkOffline();
}
}
RecreateLazy();
}
public void RemoveGroupPair(GroupPairDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var pair))
{
var group = _allGroups[dto.Group];
pair.GroupPair.Remove(group);
if (!pair.HasAnyConnection())
{
pair.MarkOffline();
_allClientPairs.TryRemove(dto.User, out _);
}
}
RecreateLazy();
}
public void RemoveUserPair(UserDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var pair))
{
pair.UserPair = null;
if (!pair.HasAnyConnection())
{
pair.MarkOffline();
_allClientPairs.TryRemove(dto.User, out _);
}
}
RecreateLazy();
}
public void SetGroupInfo(GroupInfoDto dto)
{
_allGroups[dto.Group].Group = dto.Group;
_allGroups[dto.Group].Owner = dto.Owner;
_allGroups[dto.Group].GroupPermissions = dto.GroupPermissions;
RecreateLazy();
}
public void UpdatePairPermissions(UserPermissionsDto dto)
{
if (!_allClientPairs.TryGetValue(dto.User, out var pair))
{
throw new InvalidOperationException("No such pair for " + dto);
}
if (pair.UserPair == null) throw new InvalidOperationException("No direct pair for " + dto);
if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused()
|| pair.UserPair.OtherPermissions.IsPaired() != dto.Permissions.IsPaired())
{
Mediator.Publish(new ClearProfileDataMessage(dto.User));
}
pair.UserPair.OtherPermissions = dto.Permissions;
Logger.LogTrace("Paused: {paused}, Anims: {anims}, Sounds: {sounds}, VFX: {vfx}",
pair.UserPair.OtherPermissions.IsPaused(),
pair.UserPair.OtherPermissions.IsDisableAnimations(),
pair.UserPair.OtherPermissions.IsDisableSounds(),
pair.UserPair.OtherPermissions.IsDisableVFX());
if (!pair.IsPaused)
pair.ApplyLastReceivedData();
RecreateLazy();
}
public void UpdateSelfPairPermissions(UserPermissionsDto dto)
{
if (!_allClientPairs.TryGetValue(dto.User, out var pair))
{
throw new InvalidOperationException("No such pair for " + dto);
}
if (pair.UserPair == null) throw new InvalidOperationException("No direct pair for " + dto);
if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused()
|| pair.UserPair.OwnPermissions.IsPaired() != dto.Permissions.IsPaired())
{
Mediator.Publish(new ClearProfileDataMessage(dto.User));
}
pair.UserPair.OwnPermissions = dto.Permissions;
Logger.LogTrace("Paused: {paused}, Anims: {anims}, Sounds: {sounds}, VFX: {vfx}",
pair.UserPair.OwnPermissions.IsPaused(),
pair.UserPair.OwnPermissions.IsDisableAnimations(),
pair.UserPair.OwnPermissions.IsDisableSounds(),
pair.UserPair.OwnPermissions.IsDisableVFX());
if (!pair.IsPaused)
pair.ApplyLastReceivedData();
RecreateLazy();
}
internal void ReceiveUploadStatus(UserDto dto)
{
if (_allClientPairs.TryGetValue(dto.User, out var existingPair) && existingPair.IsVisible)
{
existingPair.SetIsUploading();
}
}
internal void SetGroupPairStatusInfo(GroupPairUserInfoDto dto)
{
var group = _allGroups[dto.Group];
_allClientPairs[dto.User].GroupPair[group].GroupPairStatusInfo = dto.GroupUserInfo;
RecreateLazy();
}
internal void SetGroupPairUserPermissions(GroupPairUserPermissionDto dto)
{
var group = _allGroups[dto.Group];
var prevPermissions = _allClientPairs[dto.User].GroupPair[group].GroupUserPermissions;
_allClientPairs[dto.User].GroupPair[group].GroupUserPermissions = dto.GroupPairPermissions;
if (prevPermissions.IsDisableAnimations() != dto.GroupPairPermissions.IsDisableAnimations()
|| prevPermissions.IsDisableSounds() != dto.GroupPairPermissions.IsDisableSounds()
|| prevPermissions.IsDisableVFX() != dto.GroupPairPermissions.IsDisableVFX())
{
_allClientPairs[dto.User].ApplyLastReceivedData();
}
RecreateLazy();
}
internal void SetGroupPermissions(GroupPermissionDto dto)
{
var prevPermissions = _allGroups[dto.Group].GroupPermissions;
_allGroups[dto.Group].GroupPermissions = dto.Permissions;
if (prevPermissions.IsDisableAnimations() != dto.Permissions.IsDisableAnimations()
|| prevPermissions.IsDisableSounds() != dto.Permissions.IsDisableSounds()
|| prevPermissions.IsDisableVFX() != dto.Permissions.IsDisableVFX())
{
RecreateLazy();
var group = _allGroups[dto.Group];
GroupPairs[group].ForEach(p => p.ApplyLastReceivedData());
}
RecreateLazy();
}
internal void SetGroupStatusInfo(GroupPairUserInfoDto dto)
{
_allGroups[dto.Group].GroupUserInfo = dto.GroupUserInfo;
RecreateLazy();
}
internal void SetGroupUserPermissions(GroupPairUserPermissionDto dto)
{
var prevPermissions = _allGroups[dto.Group].GroupUserPermissions;
_allGroups[dto.Group].GroupUserPermissions = dto.GroupPairPermissions;
if (prevPermissions.IsDisableAnimations() != dto.GroupPairPermissions.IsDisableAnimations()
|| prevPermissions.IsDisableSounds() != dto.GroupPairPermissions.IsDisableSounds()
|| prevPermissions.IsDisableVFX() != dto.GroupPairPermissions.IsDisableVFX())
{
RecreateLazy();
var group = _allGroups[dto.Group];
GroupPairs[group].ForEach(p => p.ApplyLastReceivedData());
}
RecreateLazy();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_dalamudContextMenu.OnMenuOpened -= DalamudContextMenuOnOnOpenGameObjectContextMenu;
DisposePairs();
}
private void DalamudContextMenuOnOnOpenGameObjectContextMenu(Dalamud.Game.Gui.ContextMenu.IMenuOpenedArgs args)
{
if (args.MenuType == Dalamud.Game.Gui.ContextMenu.ContextMenuType.Inventory) return;
if (!_configurationService.Current.EnableRightClickMenus) return;
foreach (var pair in _allClientPairs.Where((p => p.Value.IsVisible)))
{
pair.Value.AddContextMenu(args);
}
}
private Lazy<List<Pair>> DirectPairsLazy() => new(() => _allClientPairs.Select(k => k.Value)
.Where(k => k.UserPair != null).ToList());
private void DisposePairs()
{
Logger.LogDebug("Disposing all Pairs");
Parallel.ForEach(_allClientPairs, item =>
{
item.Value.MarkOffline(wait: false);
});
RecreateLazy();
}
private Lazy<Dictionary<GroupFullInfoDto, List<Pair>>> GroupPairsLazy()
{
return new Lazy<Dictionary<GroupFullInfoDto, List<Pair>>>(() =>
{
Dictionary<GroupFullInfoDto, List<Pair>> outDict = new();
foreach (var group in _allGroups)
{
outDict[group.Value] = _allClientPairs.Select(p => p.Value).Where(p => p.GroupPair.Any(g => GroupDataComparer.Instance.Equals(group.Key, g.Key.Group))).ToList();
}
return outDict;
});
}
private void ReapplyPairData()
{
foreach (var pair in _allClientPairs.Select(k => k.Value))
{
pair.ApplyLastReceivedData(forced: true);
}
}
private void RecreateLazy()
{
_directPairsInternal = DirectPairsLazy();
_groupPairsInternal = GroupPairsLazy();
}
}

View File

@@ -0,0 +1,263 @@
using MareSynchronos.API.Data.Enum;
using MareSynchronos.PlayerData.Data;
using MareSynchronos.PlayerData.Factories;
using MareSynchronos.PlayerData.Handlers;
using MareSynchronos.Services;
using MareSynchronos.Services.Mediator;
using Microsoft.Extensions.Logging;
namespace MareSynchronos.PlayerData.Services;
#pragma warning disable MA0040
public sealed class CacheCreationService : DisposableMediatorSubscriberBase
{
private readonly SemaphoreSlim _cacheCreateLock = new(1);
private readonly Dictionary<ObjectKind, GameObjectHandler> _cachesToCreate = [];
private readonly PlayerDataFactory _characterDataFactory;
private readonly CancellationTokenSource _cts = new();
private readonly CharacterData _playerData = new();
private readonly Dictionary<ObjectKind, GameObjectHandler> _playerRelatedObjects = [];
private Task? _cacheCreationTask;
private CancellationTokenSource _honorificCts = new();
private CancellationTokenSource _petNicknamesCts = new();
private CancellationTokenSource _moodlesCts = new();
private bool _isZoning = false;
private bool _haltCharaDataCreation;
private readonly Dictionary<ObjectKind, CancellationTokenSource> _glamourerCts = new();
public CacheCreationService(ILogger<CacheCreationService> logger, MareMediator mediator, GameObjectHandlerFactory gameObjectHandlerFactory,
PlayerDataFactory characterDataFactory, DalamudUtilService dalamudUtil) : base(logger, mediator)
{
_characterDataFactory = characterDataFactory;
Mediator.Subscribe<CreateCacheForObjectMessage>(this, (msg) =>
{
Logger.LogDebug("Received CreateCacheForObject for {handler}, updating", msg.ObjectToCreateFor);
_cacheCreateLock.Wait();
_cachesToCreate[msg.ObjectToCreateFor.ObjectKind] = msg.ObjectToCreateFor;
_cacheCreateLock.Release();
});
Mediator.Subscribe<ZoneSwitchStartMessage>(this, (msg) => _isZoning = true);
Mediator.Subscribe<ZoneSwitchEndMessage>(this, (msg) => _isZoning = false);
Mediator.Subscribe<HaltCharaDataCreation>(this, (msg) =>
{
_haltCharaDataCreation = !msg.Resume;
});
_playerRelatedObjects[ObjectKind.Player] = gameObjectHandlerFactory.Create(ObjectKind.Player, dalamudUtil.GetPlayerPointer, isWatched: true)
.GetAwaiter().GetResult();
_playerRelatedObjects[ObjectKind.MinionOrMount] = gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => dalamudUtil.GetMinionOrMount(), isWatched: true)
.GetAwaiter().GetResult();
_playerRelatedObjects[ObjectKind.Pet] = gameObjectHandlerFactory.Create(ObjectKind.Pet, () => dalamudUtil.GetPet(), isWatched: true)
.GetAwaiter().GetResult();
_playerRelatedObjects[ObjectKind.Companion] = gameObjectHandlerFactory.Create(ObjectKind.Companion, () => dalamudUtil.GetCompanion(), isWatched: true)
.GetAwaiter().GetResult();
Mediator.Subscribe<ClassJobChangedMessage>(this, (msg) =>
{
if (msg.GameObjectHandler != _playerRelatedObjects[ObjectKind.Player]) return;
Logger.LogTrace("Removing pet data for {obj}", msg.GameObjectHandler);
_playerData.FileReplacements.Remove(ObjectKind.Pet);
_playerData.GlamourerString.Remove(ObjectKind.Pet);
_playerData.CustomizePlusScale.Remove(ObjectKind.Pet);
Mediator.Publish(new CharacterDataCreatedMessage(_playerData.ToAPI()));
});
Mediator.Subscribe<ClearCacheForObjectMessage>(this, (msg) =>
{
// ignore pets
if (msg.ObjectToCreateFor == _playerRelatedObjects[ObjectKind.Pet]) return;
_ = Task.Run(() =>
{
Logger.LogTrace("Clearing cache for {obj}", msg.ObjectToCreateFor);
_playerData.FileReplacements.Remove(msg.ObjectToCreateFor.ObjectKind);
_playerData.GlamourerString.Remove(msg.ObjectToCreateFor.ObjectKind);
_playerData.CustomizePlusScale.Remove(msg.ObjectToCreateFor.ObjectKind);
Mediator.Publish(new CharacterDataCreatedMessage(_playerData.ToAPI()));
});
});
Mediator.Subscribe<CustomizePlusMessage>(this, (msg) =>
{
if (_isZoning) return;
_ = Task.Run(async () =>
{
foreach (var item in _playerRelatedObjects
.Where(item => msg.Address == null
|| item.Value.Address == msg.Address).Select(k => k.Key))
{
Logger.LogDebug("Received CustomizePlus change, updating {obj}", item);
await AddPlayerCacheToCreate(item).ConfigureAwait(false);
}
});
});
Mediator.Subscribe<HeelsOffsetMessage>(this, (msg) =>
{
if (_isZoning) return;
Logger.LogDebug("Received Heels Offset change, updating player");
_ = AddPlayerCacheToCreate();
});
Mediator.Subscribe<GlamourerChangedMessage>(this, (msg) =>
{
if (_isZoning) return;
var changedType = _playerRelatedObjects.FirstOrDefault(f => f.Value.Address == msg.Address);
if (changedType.Key != default || changedType.Value != default)
{
GlamourerChanged(changedType.Key);
}
});
Mediator.Subscribe<HonorificMessage>(this, (msg) =>
{
if (_isZoning) return;
if (!string.Equals(msg.NewHonorificTitle, _playerData.HonorificData, StringComparison.Ordinal))
{
Logger.LogDebug("Received Honorific change, updating player");
HonorificChanged();
}
});
Mediator.Subscribe<PetNamesMessage>(this, (msg) =>
{
if (_isZoning) return;
if (!string.Equals(msg.PetNicknamesData, _playerData.PetNamesData, StringComparison.Ordinal))
{
Logger.LogDebug("Received Pet Nicknames change, updating player");
PetNicknamesChanged();
}
});
Mediator.Subscribe<MoodlesMessage>(this, (msg) =>
{
if (_isZoning) return;
var changedType = _playerRelatedObjects.FirstOrDefault(f => f.Value.Address == msg.Address);
if (changedType.Key == ObjectKind.Player && changedType.Value != default)
{
Logger.LogDebug("Received Moodles change, updating player");
MoodlesChanged();
}
});
Mediator.Subscribe<PenumbraModSettingChangedMessage>(this, (msg) =>
{
Logger.LogDebug("Received Penumbra Mod settings change, updating player");
AddPlayerCacheToCreate().GetAwaiter().GetResult();
});
Mediator.Subscribe<DelayedFrameworkUpdateMessage>(this, (msg) => ProcessCacheCreation());
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_playerRelatedObjects.Values.ToList().ForEach(p => p.Dispose());
_cts.Dispose();
}
private async Task AddPlayerCacheToCreate(ObjectKind kind = ObjectKind.Player)
{
await _cacheCreateLock.WaitAsync().ConfigureAwait(false);
_cachesToCreate[kind] = _playerRelatedObjects[kind];
_cacheCreateLock.Release();
}
private void GlamourerChanged(ObjectKind kind)
{
if (_glamourerCts.TryGetValue(kind, out var cts))
{
_glamourerCts[kind]?.Cancel();
_glamourerCts[kind]?.Dispose();
}
_glamourerCts[kind] = new();
var token = _glamourerCts[kind].Token;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMilliseconds(500), token).ConfigureAwait(false);
await AddPlayerCacheToCreate(kind).ConfigureAwait(false);
});
}
private void HonorificChanged()
{
_honorificCts?.Cancel();
_honorificCts?.Dispose();
_honorificCts = new();
var token = _honorificCts.Token;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
await AddPlayerCacheToCreate().ConfigureAwait(false);
}, token);
}
private void PetNicknamesChanged()
{
_petNicknamesCts?.Cancel();
_petNicknamesCts?.Dispose();
_petNicknamesCts = new();
var token = _petNicknamesCts.Token;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
await AddPlayerCacheToCreate().ConfigureAwait(false);
}, token);
}
private void MoodlesChanged()
{
_moodlesCts?.Cancel();
_moodlesCts?.Dispose();
_moodlesCts = new();
var token = _moodlesCts.Token;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(1), token).ConfigureAwait(false);
await AddPlayerCacheToCreate().ConfigureAwait(false);
}, token);
}
private void ProcessCacheCreation()
{
if (_isZoning || _haltCharaDataCreation) return;
if (_cachesToCreate.Any() && (_cacheCreationTask?.IsCompleted ?? true))
{
_cacheCreateLock.Wait();
var toCreate = _cachesToCreate.ToList();
_cachesToCreate.Clear();
_cacheCreateLock.Release();
_cacheCreationTask = Task.Run(async () =>
{
try
{
foreach (var obj in toCreate)
{
await _characterDataFactory.BuildCharacterData(_playerData, obj.Value, _cts.Token).ConfigureAwait(false);
}
Mediator.Publish(new CharacterDataCreatedMessage(_playerData.ToAPI()));
}
catch (Exception ex)
{
Logger.LogCritical(ex, "Error during Cache Creation Processing");
}
finally
{
Logger.LogDebug("Cache Creation complete");
}
}, _cts.Token);
}
else if (_cachesToCreate.Any())
{
Logger.LogDebug("Cache Creation stored until previous creation finished");
}
}
}
#pragma warning restore MA0040

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