diff --git a/.editorconfig b/.editorconfig index c645b573..f0328fd7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3576,6 +3576,18 @@ resharper_xaml_xaml_xamarin_forms_data_type_and_binding_context_type_mismatched_ resharper_xaml_x_key_attribute_disallowed_highlighting=error resharper_xml_doc_comment_syntax_problem_highlighting=warning resharper_xunit_xunit_test_with_console_output_highlighting=warning +csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion +csharp_style_expression_bodied_methods = true:silent +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_expression_bodied_properties = true:silent [*.{cshtml,htm,html,proto,razor}] indent_style=tab diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1783c9a4..7901a653 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: '8.x.x' + dotnet-version: '9.x.x' - name: Restore dependencies run: dotnet restore - name: Download Dalamud diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4799cbed..377919b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,12 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: '8.x.x' + dotnet-version: '9.x.x' - name: Restore dependencies run: dotnet restore - name: Download Dalamud run: | - Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/latest.zip -OutFile latest.zip + Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev" - name: Build run: | diff --git a/.github/workflows/test_release.yml b/.github/workflows/test_release.yml index 549c967a..2bece720 100644 --- a/.github/workflows/test_release.yml +++ b/.github/workflows/test_release.yml @@ -15,7 +15,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: '8.x.x' + dotnet-version: '9.x.x' - name: Restore dependencies run: dotnet restore - name: Download Dalamud diff --git a/OtterGui b/OtterGui index 215e0172..a63f6735 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 215e01722a319c70b271dd23a40d99edc3fc197e +Subproject commit a63f6735cf4bed4f7502a022a10378607082b770 diff --git a/Penumbra.Api b/Penumbra.Api index 97e9f427..3d6cee1a 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 97e9f427406f82a59ddef764b44ecea654a51623 +Subproject commit 3d6cee1a11922ccd426f36060fd026bc1a698adf diff --git a/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs b/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs index 11dc52db..292be2ff 100644 --- a/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs +++ b/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Nodes; +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; namespace Penumbra.CrashHandler.Buffers; diff --git a/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs b/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs index a48fe846..89fea29d 100644 --- a/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs +++ b/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Nodes; +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; namespace Penumbra.CrashHandler.Buffers; diff --git a/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs b/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs index a1b3de52..e2ffcebe 100644 --- a/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs +++ b/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs @@ -1,5 +1,8 @@ +using System; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.IO.MemoryMappedFiles; +using System.Linq; using System.Numerics; using System.Text; diff --git a/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs b/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs index ac507e7f..e4ee66d0 100644 --- a/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs +++ b/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Nodes; +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; namespace Penumbra.CrashHandler.Buffers; diff --git a/Penumbra.CrashHandler/CrashData.cs b/Penumbra.CrashHandler/CrashData.cs index dd75f46e..55460548 100644 --- a/Penumbra.CrashHandler/CrashData.cs +++ b/Penumbra.CrashHandler/CrashData.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using Penumbra.CrashHandler.Buffers; namespace Penumbra.CrashHandler; diff --git a/Penumbra.CrashHandler/GameEventLogReader.cs b/Penumbra.CrashHandler/GameEventLogReader.cs index 1813a671..8a7f53f8 100644 --- a/Penumbra.CrashHandler/GameEventLogReader.cs +++ b/Penumbra.CrashHandler/GameEventLogReader.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Nodes; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; using Penumbra.CrashHandler.Buffers; namespace Penumbra.CrashHandler; diff --git a/Penumbra.CrashHandler/GameEventLogWriter.cs b/Penumbra.CrashHandler/GameEventLogWriter.cs index e2c461f4..915c59a2 100644 --- a/Penumbra.CrashHandler/GameEventLogWriter.cs +++ b/Penumbra.CrashHandler/GameEventLogWriter.cs @@ -1,4 +1,5 @@ -using Penumbra.CrashHandler.Buffers; +using System; +using Penumbra.CrashHandler.Buffers; namespace Penumbra.CrashHandler; diff --git a/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj index c9f97fde..1b1f0a28 100644 --- a/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj +++ b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj @@ -1,20 +1,6 @@ - - + Exe - net8.0-windows - preview - enable - x64 - enable - true - false - - - - $(appdata)\XIVLauncher\addon\Hooks\dev\ - $(HOME)/.xlcore/dalamud/Hooks/dev/ - $(DALAMUD_HOME)/ @@ -25,4 +11,8 @@ embedded + + false + + diff --git a/Penumbra.CrashHandler/Program.cs b/Penumbra.CrashHandler/Program.cs index 3bc461f7..38c176a6 100644 --- a/Penumbra.CrashHandler/Program.cs +++ b/Penumbra.CrashHandler/Program.cs @@ -1,4 +1,6 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; +using System.IO; using System.Text.Json; namespace Penumbra.CrashHandler; diff --git a/Penumbra.CrashHandler/packages.lock.json b/Penumbra.CrashHandler/packages.lock.json new file mode 100644 index 00000000..1d395083 --- /dev/null +++ b/Penumbra.CrashHandler/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.25, )", + "resolved": "1.2.25", + "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" + } + } + } +} \ No newline at end of file diff --git a/Penumbra.GameData b/Penumbra.GameData index 2b0c7f3b..d889f9ef 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 2b0c7f3bee0bc2eb466540d2fac265804354493d +Subproject commit d889f9ef918514a46049725052d378b441915b00 diff --git a/Penumbra.String b/Penumbra.String index dd83f972..c8611a0c 160000 --- a/Penumbra.String +++ b/Penumbra.String @@ -1 +1 @@ -Subproject commit dd83f97299ac33cfacb1064bde4f4d1f6a260936 +Subproject commit c8611a0c546b6b2ec29214ab319fc2c38fe74793 diff --git a/Penumbra.sln b/Penumbra.sln index 94a04ef3..fbcd6080 100644 --- a/Penumbra.sln +++ b/Penumbra.sln @@ -9,6 +9,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .github\workflows\build.yml = .github\workflows\build.yml + Penumbra\Penumbra.json = Penumbra\Penumbra.json .github\workflows\release.yml = .github\workflows\release.yml repo.json = repo.json .github\workflows\test_release.yml = .github\workflows\test_release.yml @@ -24,40 +25,74 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.String", "Penumbra EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.CrashHandler", "Penumbra.CrashHandler\Penumbra.CrashHandler.csproj", "{EE834491-A98F-4395-BE0D-6861AE5AD953}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Schemas", "Schemas", "{BFEA7504-1210-4F79-A7FE-BF03B6567E33}" + ProjectSection(SolutionItems) = preProject + schemas\default_mod.json = schemas\default_mod.json + schemas\group.json = schemas\group.json + schemas\local_mod_data-v3.json = schemas\local_mod_data-v3.json + schemas\mod_meta-v3.json = schemas\mod_meta-v3.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "structs", "structs", "{B03F276A-0572-4F62-AF86-EF62F6B80463}" + ProjectSection(SolutionItems) = preProject + schemas\structs\container.json = schemas\structs\container.json + schemas\structs\group_combining.json = schemas\structs\group_combining.json + schemas\structs\group_imc.json = schemas\structs\group_imc.json + schemas\structs\group_multi.json = schemas\structs\group_multi.json + schemas\structs\group_single.json = schemas\structs\group_single.json + schemas\structs\manipulation.json = schemas\structs\manipulation.json + schemas\structs\meta_atch.json = schemas\structs\meta_atch.json + schemas\structs\meta_atr.json = schemas\structs\meta_atr.json + schemas\structs\meta_enums.json = schemas\structs\meta_enums.json + schemas\structs\meta_eqdp.json = schemas\structs\meta_eqdp.json + schemas\structs\meta_eqp.json = schemas\structs\meta_eqp.json + schemas\structs\meta_est.json = schemas\structs\meta_est.json + schemas\structs\meta_geqp.json = schemas\structs\meta_geqp.json + schemas\structs\meta_gmp.json = schemas\structs\meta_gmp.json + schemas\structs\meta_imc.json = schemas\structs\meta_imc.json + schemas\structs\meta_rsp.json = schemas\structs\meta_rsp.json + schemas\structs\meta_shp.json = schemas\structs\meta_shp.json + schemas\structs\option.json = schemas\structs\option.json + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|Any CPU.Build.0 = Release|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|Any CPU.Build.0 = Release|Any CPU - {87750518-1A20-40B4-9FC1-22F906EFB290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {87750518-1A20-40B4-9FC1-22F906EFB290}.Debug|Any CPU.Build.0 = Debug|Any CPU - {87750518-1A20-40B4-9FC1-22F906EFB290}.Release|Any CPU.ActiveCfg = Release|Any CPU - {87750518-1A20-40B4-9FC1-22F906EFB290}.Release|Any CPU.Build.0 = Release|Any CPU - {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Release|Any CPU.Build.0 = Release|Any CPU - {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|Any CPU.Build.0 = Release|Any CPU - {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|Any CPU.Build.0 = Release|Any CPU + {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|x64.ActiveCfg = Release|x64 + {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|x64.Build.0 = Release|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|x64.ActiveCfg = Debug|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|x64.Build.0 = Debug|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|x64.ActiveCfg = Release|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|x64.Build.0 = Release|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Debug|x64.ActiveCfg = Debug|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Debug|x64.Build.0 = Debug|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Release|x64.ActiveCfg = Release|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Release|x64.Build.0 = Release|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Debug|x64.ActiveCfg = Debug|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Debug|x64.Build.0 = Debug|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Release|x64.ActiveCfg = Release|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Release|x64.Build.0 = Release|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|x64.ActiveCfg = Debug|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|x64.Build.0 = Debug|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|x64.ActiveCfg = Release|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|x64.Build.0 = Release|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|x64.ActiveCfg = Debug|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|x64.Build.0 = Debug|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|x64.ActiveCfg = Release|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BFEA7504-1210-4F79-A7FE-BF03B6567E33} = {F89C9EAE-25C8-43BE-8108-5921E5A93502} + {B03F276A-0572-4F62-AF86-EF62F6B80463} = {BFEA7504-1210-4F79-A7FE-BF03B6567E33} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF} EndGlobalSection diff --git a/Penumbra/Api/Api/CollectionApi.cs b/Penumbra/Api/Api/CollectionApi.cs index 04299187..c40feb12 100644 --- a/Penumbra/Api/Api/CollectionApi.cs +++ b/Penumbra/Api/Api/CollectionApi.cs @@ -2,13 +2,14 @@ using OtterGui.Services; using Penumbra.Api.Enums; using Penumbra.Collections; using Penumbra.Collections.Manager; +using Penumbra.Mods; namespace Penumbra.Api.Api; public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : IPenumbraApiCollection, IApiService { public Dictionary GetCollections() - => collections.Storage.ToDictionary(c => c.Id, c => c.Name); + => collections.Storage.ToDictionary(c => c.Identity.Id, c => c.Identity.Name); public List<(Guid Id, string Name)> GetCollectionsByIdentifier(string identifier) { @@ -17,17 +18,33 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : var list = new List<(Guid Id, string Name)>(4); if (Guid.TryParse(identifier, out var guid) && collections.Storage.ById(guid, out var collection) && collection != ModCollection.Empty) - list.Add((collection.Id, collection.Name)); + list.Add((collection.Identity.Id, collection.Identity.Name)); else if (identifier.Length >= 8) - list.AddRange(collections.Storage.Where(c => c.Identifier.StartsWith(identifier, StringComparison.OrdinalIgnoreCase)) - .Select(c => (c.Id, c.Name))); + list.AddRange(collections.Storage.Where(c => c.Identity.Identifier.StartsWith(identifier, StringComparison.OrdinalIgnoreCase)) + .Select(c => (c.Identity.Id, c.Identity.Name))); list.AddRange(collections.Storage - .Where(c => string.Equals(c.Name, identifier, StringComparison.OrdinalIgnoreCase) && !list.Contains((c.Id, c.Name))) - .Select(c => (c.Id, c.Name))); + .Where(c => string.Equals(c.Identity.Name, identifier, StringComparison.OrdinalIgnoreCase) + && !list.Contains((c.Identity.Id, c.Identity.Name))) + .Select(c => (c.Identity.Id, c.Identity.Name))); return list; } + public Func CheckCurrentChangedItemFunc() + { + var weakRef = new WeakReference(collections); + return s => + { + if (!weakRef.TryGetTarget(out var c)) + throw new ObjectDisposedException("The underlying collection storage of this IPC container was disposed."); + + if (!c.Active.Current.ChangedItems.TryGetValue(s, out var d)) + return []; + + return d.Item1.Select(m => (m is Mod mod ? mod.Identifier : string.Empty, m.Name.Text)).ToArray(); + }; + } + public Dictionary GetChangedItemsForCollection(Guid collectionId) { try @@ -54,7 +71,7 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : return null; var collection = collections.Active.ByType((CollectionType)type); - return collection == null ? null : (collection.Id, collection.Name); + return collection == null ? null : (collection.Identity.Id, collection.Identity.Name); } internal (Guid Id, string Name)? GetCollection(byte type) @@ -64,17 +81,18 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : { var id = helpers.AssociatedIdentifier(gameObjectIdx); if (!id.IsValid) - return (false, false, (collections.Active.Default.Id, collections.Active.Default.Name)); + return (false, false, (collections.Active.Default.Identity.Id, collections.Active.Default.Identity.Name)); if (collections.Active.Individuals.TryGetValue(id, out var collection)) - return (true, true, (collection.Id, collection.Name)); + return (true, true, (collection.Identity.Id, collection.Identity.Name)); helpers.AssociatedCollection(gameObjectIdx, out collection); - return (true, false, (collection.Id, collection.Name)); + return (true, false, (collection.Identity.Id, collection.Identity.Name)); } public Guid[] GetCollectionByName(string name) - => collections.Storage.Where(c => string.Equals(name, c.Name, StringComparison.OrdinalIgnoreCase)).Select(c => c.Id).ToArray(); + => collections.Storage.Where(c => string.Equals(name, c.Identity.Name, StringComparison.OrdinalIgnoreCase)).Select(c => c.Identity.Id) + .ToArray(); public (PenumbraApiEc, (Guid Id, string Name)? OldCollection) SetCollection(ApiCollectionType type, Guid? collectionId, bool allowCreateNew, bool allowDelete) @@ -83,7 +101,7 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : return (PenumbraApiEc.InvalidArgument, null); var oldCollection = collections.Active.ByType((CollectionType)type); - var old = oldCollection != null ? (oldCollection.Id, oldCollection.Name) : new ValueTuple?(); + var old = oldCollection != null ? (oldCollection.Identity.Id, oldCollection.Identity.Name) : new ValueTuple?(); if (collectionId == null) { if (old == null) @@ -106,7 +124,7 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : collections.Active.CreateSpecialCollection((CollectionType)type); } - else if (old.Value.Item1 == collection.Id) + else if (old.Value.Item1 == collection.Identity.Id) { return (PenumbraApiEc.NothingChanged, old); } @@ -120,10 +138,10 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : { var id = helpers.AssociatedIdentifier(gameObjectIdx); if (!id.IsValid) - return (PenumbraApiEc.InvalidIdentifier, (collections.Active.Default.Id, collections.Active.Default.Name)); + return (PenumbraApiEc.InvalidIdentifier, (collections.Active.Default.Identity.Id, collections.Active.Default.Identity.Name)); var oldCollection = collections.Active.Individuals.TryGetValue(id, out var c) ? c : null; - var old = oldCollection != null ? (oldCollection.Id, oldCollection.Name) : new ValueTuple?(); + var old = oldCollection != null ? (oldCollection.Identity.Id, oldCollection.Identity.Name) : new ValueTuple?(); if (collectionId == null) { if (old == null) @@ -148,7 +166,7 @@ public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : var ids = collections.Active.Individuals.GetGroup(id); collections.Active.CreateIndividualCollection(ids); } - else if (old.Value.Item1 == collection.Id) + else if (old.Value.Item1 == collection.Identity.Id) { return (PenumbraApiEc.NothingChanged, old); } diff --git a/Penumbra/Api/Api/GameStateApi.cs b/Penumbra/Api/Api/GameStateApi.cs index c2cae32b..74cde3a0 100644 --- a/Penumbra/Api/Api/GameStateApi.cs +++ b/Penumbra/Api/Api/GameStateApi.cs @@ -14,16 +14,18 @@ public class GameStateApi : IPenumbraApiGameState, IApiService, IDisposable { private readonly CommunicatorService _communicator; private readonly CollectionResolver _collectionResolver; + private readonly DrawObjectState _drawObjectState; private readonly CutsceneService _cutsceneService; private readonly ResourceLoader _resourceLoader; public unsafe GameStateApi(CommunicatorService communicator, CollectionResolver collectionResolver, CutsceneService cutsceneService, - ResourceLoader resourceLoader) + ResourceLoader resourceLoader, DrawObjectState drawObjectState) { _communicator = communicator; _collectionResolver = collectionResolver; _cutsceneService = cutsceneService; _resourceLoader = resourceLoader; + _drawObjectState = drawObjectState; _resourceLoader.ResourceLoaded += OnResourceLoaded; _resourceLoader.PapRequested += OnPapRequested; _communicator.CreatedCharacterBase.Subscribe(OnCreatedCharacterBase, Communication.CreatedCharacterBase.Priority.Api); @@ -61,12 +63,36 @@ public class GameStateApi : IPenumbraApiGameState, IApiService, IDisposable public unsafe (nint GameObject, (Guid Id, string Name) Collection) GetDrawObjectInfo(nint drawObject) { var data = _collectionResolver.IdentifyCollection((DrawObject*)drawObject, true); - return (data.AssociatedGameObject, (data.ModCollection.Id, data.ModCollection.Name)); + return (data.AssociatedGameObject, (Id: data.ModCollection.Identity.Id, Name: data.ModCollection.Identity.Name)); } public int GetCutsceneParentIndex(int actorIdx) => _cutsceneService.GetParentIndex(actorIdx); + public Func GetCutsceneParentIndexFunc() + { + var weakRef = new WeakReference(_cutsceneService); + return idx => + { + if (!weakRef.TryGetTarget(out var c)) + throw new ObjectDisposedException("The underlying cutscene state storage of this IPC container was disposed."); + + return c.GetParentIndex(idx); + }; + } + + public Func GetGameObjectFromDrawObjectFunc() + { + var weakRef = new WeakReference(_drawObjectState); + return model => + { + if (!weakRef.TryGetTarget(out var c)) + throw new ObjectDisposedException("The underlying draw object state storage of this IPC container was disposed."); + + return c.TryGetValue(model, out var data) ? data.Item1.Address : nint.Zero; + }; + } + public PenumbraApiEc SetCutsceneParentIndex(int copyIdx, int newParentIdx) => _cutsceneService.SetParentIndex(copyIdx, newParentIdx) ? PenumbraApiEc.Success @@ -93,5 +119,5 @@ public class GameStateApi : IPenumbraApiGameState, IApiService, IDisposable } private void OnCreatedCharacterBase(nint gameObject, ModCollection collection, nint drawObject) - => CreatedCharacterBase?.Invoke(gameObject, collection.Id, drawObject); + => CreatedCharacterBase?.Invoke(gameObject, collection.Identity.Id, drawObject); } diff --git a/Penumbra/Api/Api/IdentityChecker.cs b/Penumbra/Api/Api/IdentityChecker.cs new file mode 100644 index 00000000..e090053e --- /dev/null +++ b/Penumbra/Api/Api/IdentityChecker.cs @@ -0,0 +1,7 @@ +namespace Penumbra.Api.Api; + +public static class IdentityChecker +{ + public static bool Check(string identity) + => true; +} diff --git a/Penumbra/Api/Api/MetaApi.cs b/Penumbra/Api/Api/MetaApi.cs index 217cb1e3..5cffc811 100644 --- a/Penumbra/Api/Api/MetaApi.cs +++ b/Penumbra/Api/Api/MetaApi.cs @@ -16,8 +16,6 @@ namespace Penumbra.Api.Api; public class MetaApi(IFramework framework, CollectionResolver collectionResolver, ApiHelpers helpers) : IPenumbraApiMeta, IApiService { - public const int CurrentVersion = 1; - public string GetPlayerMetaManipulations() { var collection = collectionResolver.PlayerCollection(); @@ -53,7 +51,7 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver } internal static string CompressMetaManipulations(ModCollection collection) - => CompressMetaManipulationsV0(collection); + => CompressMetaManipulationsV1(collection); private static string CompressMetaManipulationsV0(ModCollection collection) { @@ -68,6 +66,8 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver MetaDictionary.SerializeTo(array, cache.Rsp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); MetaDictionary.SerializeTo(array, cache.Gmp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); MetaDictionary.SerializeTo(array, cache.Atch.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Shp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Atr.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); } return Functions.ToCompressedBase64(array, 0); @@ -99,7 +99,6 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver WriteCache(zipStream, cache.Est); WriteCache(zipStream, cache.Rsp); WriteCache(zipStream, cache.Gmp); - WriteCache(zipStream, cache.Atch); cache.GlobalEqp.EnterReadLock(); try @@ -112,6 +111,10 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver { cache.GlobalEqp.ExitReadLock(); } + + WriteCache(zipStream, cache.Atch); + WriteCache(zipStream, cache.Shp); + WriteCache(zipStream, cache.Atr); } } @@ -141,16 +144,97 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver } } + public const uint ImcKey = ((uint)'I' << 24) | ((uint)'M' << 16) | ((uint)'C' << 8); + public const uint EqpKey = ((uint)'E' << 24) | ((uint)'Q' << 16) | ((uint)'P' << 8); + public const uint EqdpKey = ((uint)'E' << 24) | ((uint)'Q' << 16) | ((uint)'D' << 8) | 'P'; + public const uint EstKey = ((uint)'E' << 24) | ((uint)'S' << 16) | ((uint)'T' << 8); + public const uint RspKey = ((uint)'R' << 24) | ((uint)'S' << 16) | ((uint)'P' << 8); + public const uint GmpKey = ((uint)'G' << 24) | ((uint)'M' << 16) | ((uint)'P' << 8); + public const uint GeqpKey = ((uint)'G' << 24) | ((uint)'E' << 16) | ((uint)'Q' << 8) | 'P'; + public const uint AtchKey = ((uint)'A' << 24) | ((uint)'T' << 16) | ((uint)'C' << 8) | 'H'; + public const uint ShpKey = ((uint)'S' << 24) | ((uint)'H' << 16) | ((uint)'P' << 8); + public const uint AtrKey = ((uint)'A' << 24) | ((uint)'T' << 16) | ((uint)'R' << 8); + + private static unsafe string CompressMetaManipulationsV2(ModCollection? collection) + { + using var ms = new MemoryStream(); + ms.Capacity = 1024; + using (var zipStream = new GZipStream(ms, CompressionMode.Compress, true)) + { + zipStream.Write((byte)2); + zipStream.Write("META0002"u8); + if (collection?.MetaCache is { } cache) + { + WriteCache(zipStream, cache.Imc, ImcKey); + WriteCache(zipStream, cache.Eqp, EqpKey); + WriteCache(zipStream, cache.Eqdp, EqdpKey); + WriteCache(zipStream, cache.Est, EstKey); + WriteCache(zipStream, cache.Rsp, RspKey); + WriteCache(zipStream, cache.Gmp, GmpKey); + cache.GlobalEqp.EnterReadLock(); + + try + { + if (cache.GlobalEqp.Count > 0) + { + zipStream.Write(GeqpKey); + zipStream.Write(cache.GlobalEqp.Count); + foreach (var (globalEqp, _) in cache.GlobalEqp) + zipStream.Write(new ReadOnlySpan(&globalEqp, sizeof(GlobalEqpManipulation))); + } + } + finally + { + cache.GlobalEqp.ExitReadLock(); + } + + WriteCache(zipStream, cache.Atch, AtchKey); + WriteCache(zipStream, cache.Shp, ShpKey); + WriteCache(zipStream, cache.Atr, AtrKey); + } + } + + ms.Flush(); + ms.Position = 0; + var data = ms.GetBuffer().AsSpan(0, (int)ms.Length); + return Convert.ToBase64String(data); + + void WriteCache(Stream stream, MetaCacheBase metaCache, uint label) + where TKey : unmanaged, IMetaIdentifier + where TValue : unmanaged + { + metaCache.EnterReadLock(); + try + { + if (metaCache.Count <= 0) + return; + + stream.Write(label); + stream.Write(metaCache.Count); + foreach (var (identifier, (_, value)) in metaCache) + { + stream.Write(identifier); + stream.Write(value); + } + } + finally + { + metaCache.ExitReadLock(); + } + } + } + /// /// Convert manipulations from a transmitted base64 string to actual manipulations. /// The empty string is treated as an empty set. /// Only returns true if all conversions are successful and distinct. /// - internal static bool ConvertManips(string manipString, [NotNullWhen(true)] out MetaDictionary? manips) + internal static bool ConvertManips(string manipString, [NotNullWhen(true)] out MetaDictionary? manips, out byte version) { if (manipString.Length == 0) { - manips = new MetaDictionary(); + manips = new MetaDictionary(); + version = byte.MaxValue; return true; } @@ -163,13 +247,14 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver zipStream.CopyTo(resultStream); resultStream.Flush(); resultStream.Position = 0; - var data = resultStream.GetBuffer().AsSpan(0, (int)resultStream.Length); - var version = data[0]; - data = data[1..]; + var data = resultStream.GetBuffer().AsSpan(0, (int)resultStream.Length); + version = data[0]; + data = data[1..]; switch (version) { case 0: return ConvertManipsV0(data, out manips); case 1: return ConvertManipsV1(data, out manips); + case 2: return ConvertManipsV2(data, out manips); default: Penumbra.Log.Debug($"Invalid version for manipulations: {version}."); manips = null; @@ -179,9 +264,135 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver catch (Exception ex) { Penumbra.Log.Debug($"Error decompressing manipulations:\n{ex}"); + manips = null; + version = byte.MaxValue; + return false; + } + } + + private static bool ConvertManipsV2(ReadOnlySpan data, [NotNullWhen(true)] out MetaDictionary? manips) + { + if (!data.StartsWith("META0002"u8)) + { + Penumbra.Log.Debug("Invalid manipulations of version 2, does not start with valid prefix."); manips = null; return false; } + + manips = new MetaDictionary(); + var r = new SpanBinaryReader(data[8..]); + while (r.Remaining > 4) + { + var prefix = r.ReadUInt32(); + var count = r.Remaining > 4 ? r.ReadInt32() : 0; + if (count is 0) + continue; + + switch (prefix) + { + case ImcKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case EqpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case EqdpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case EstKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case RspKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case GmpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case GeqpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier)) + return false; + } + + break; + case AtchKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case ShpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case AtrKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + } + } + + return true; } private static bool ConvertManipsV1(ReadOnlySpan data, [NotNullWhen(true)] out MetaDictionary? manips) @@ -249,15 +460,6 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver return false; } - var atchCount = r.ReadInt32(); - for (var i = 0; i < atchCount; ++i) - { - var identifier = r.Read(); - var value = r.Read(); - if (!identifier.Validate() || !manips.TryAdd(identifier, value)) - return false; - } - var globalEqpCount = r.ReadInt32(); for (var i = 0; i < globalEqpCount; ++i) { @@ -266,6 +468,41 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver return false; } + // Atch was added after there were already some V1 around, so check for size here. + if (r.Position < r.Count) + { + var atchCount = r.ReadInt32(); + for (var i = 0; i < atchCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + // Shp and Atr was added later + if (r.Position < r.Count) + { + var shpCount = r.ReadInt32(); + for (var i = 0; i < shpCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var atrCount = r.ReadInt32(); + for (var i = 0; i < atrCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + } + } + return true; } @@ -274,7 +511,7 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver var json = Encoding.UTF8.GetString(data); manips = JsonConvert.DeserializeObject(json); return manips != null; - } + } internal void TestMetaManipulations() { @@ -291,11 +528,11 @@ public class MetaApi(IFramework framework, CollectionResolver collectionResolver var v1Time = watch.ElapsedMilliseconds; watch.Restart(); - var v1Success = ConvertManips(v1, out var v1Roundtrip); + var v1Success = ConvertManips(v1, out var v1Roundtrip, out _); var v1RoundtripTime = watch.ElapsedMilliseconds; watch.Restart(); - var v0Success = ConvertManips(v0, out var v0Roundtrip); + var v0Success = ConvertManips(v0, out var v0Roundtrip, out _); var v0RoundtripTime = watch.ElapsedMilliseconds; Penumbra.Log.Information($"Version | Count | Time | Length | Success | ReCount | ReTime | Equal"); diff --git a/Penumbra/Api/Api/ModSettingsApi.cs b/Penumbra/Api/Api/ModSettingsApi.cs index e046ce30..3ba17cf4 100644 --- a/Penumbra/Api/Api/ModSettingsApi.cs +++ b/Penumbra/Api/Api/ModSettingsApi.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Api.Enums; using Penumbra.Api.Helpers; @@ -63,13 +63,18 @@ public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable return new AvailableModSettings(dict); } - public Dictionary? GetAvailableModSettingsBase(string modDirectory, string modName) - => _modManager.TryGetMod(modDirectory, modName, out var mod) - ? mod.Groups.ToDictionary(g => g.Name, g => (g.Options.Select(o => o.Name).ToArray(), (int)g.Type)) - : null; - public (PenumbraApiEc, (bool, int, Dictionary>, bool)?) GetCurrentModSettings(Guid collectionId, string modDirectory, string modName, bool ignoreInheritance) + { + var ret = GetCurrentModSettingsWithTemp(collectionId, modDirectory, modName, ignoreInheritance, true, 0); + if (ret.Item2 is null) + return (ret.Item1, null); + + return (ret.Item1, (ret.Item2.Value.Item1, ret.Item2.Value.Item2, ret.Item2.Value.Item3, ret.Item2.Value.Item4)); + } + + public (PenumbraApiEc, (bool, int, Dictionary>, bool, bool)?) GetCurrentModSettingsWithTemp(Guid collectionId, + string modDirectory, string modName, bool ignoreInheritance, bool ignoreTemporary, int key) { if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) return (PenumbraApiEc.ModMissing, null); @@ -77,17 +82,32 @@ public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable if (!_collectionManager.Storage.ById(collectionId, out var collection)) return (PenumbraApiEc.CollectionMissing, null); - var settings = collection.Id == Guid.Empty - ? null - : ignoreInheritance - ? collection.Settings[mod.Index] - : collection[mod.Index].Settings; - if (settings == null) + if (collection.Identity.Id == Guid.Empty) return (PenumbraApiEc.Success, null); - var (enabled, priority, dict) = settings.ConvertToShareable(mod); - return (PenumbraApiEc.Success, - (enabled, priority.Value, dict, collection.Settings[mod.Index] == null)); + if (GetCurrentSettings(collection, mod, ignoreInheritance, ignoreTemporary, key) is { } settings) + return (PenumbraApiEc.Success, settings); + + return (PenumbraApiEc.Success, null); + } + + public (PenumbraApiEc, Dictionary>, bool, bool)>?) GetAllModSettings(Guid collectionId, + bool ignoreInheritance, bool ignoreTemporary, int key) + { + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return (PenumbraApiEc.CollectionMissing, null); + + if (collection.Identity.Id == Guid.Empty) + return (PenumbraApiEc.Success, []); + + var ret = new Dictionary>, bool, bool)>(_modManager.Count); + foreach (var mod in _modManager) + { + if (GetCurrentSettings(collection, mod, ignoreInheritance, ignoreTemporary, key) is { } settings) + ret[mod.Identifier] = settings; + } + + return (PenumbraApiEc.Success, ret); } public PenumbraApiEc TryInheritMod(Guid collectionId, string modDirectory, string modName, bool inherit) @@ -184,36 +204,9 @@ public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); - var groupIdx = mod.Groups.IndexOf(g => g.Name == optionGroupName); - if (groupIdx < 0) - return ApiHelpers.Return(PenumbraApiEc.OptionGroupMissing, args); - - var setting = Setting.Zero; - switch (mod.Groups[groupIdx]) - { - case { Behaviour: GroupDrawBehaviour.SingleSelection } single: - { - var optionIdx = optionNames.Count == 0 ? -1 : single.Options.IndexOf(o => o.Name == optionNames[^1]); - if (optionIdx < 0) - return ApiHelpers.Return(PenumbraApiEc.OptionMissing, args); - - setting = Setting.Single(optionIdx); - break; - } - case { Behaviour: GroupDrawBehaviour.MultiSelection } multi: - { - foreach (var name in optionNames) - { - var optionIdx = multi.Options.IndexOf(o => o.Name == name); - if (optionIdx < 0) - return ApiHelpers.Return(PenumbraApiEc.OptionMissing, args); - - setting |= Setting.Multi(optionIdx); - } - - break; - } - } + var settingSuccess = ConvertModSetting(mod, optionGroupName, optionNames, out var groupIdx, out var setting); + if (settingSuccess is not PenumbraApiEc.Success) + return ApiHelpers.Return(settingSuccess, args); var ret = _collectionEditor.SetModSetting(collection, mod, groupIdx, setting) ? PenumbraApiEc.Success @@ -238,13 +231,38 @@ public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable return ApiHelpers.Return(PenumbraApiEc.Success, args); } + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private (bool, int, Dictionary>, bool, bool)? GetCurrentSettings(ModCollection collection, Mod mod, + bool ignoreInheritance, bool ignoreTemporary, int key) + { + var settings = collection.Settings.Settings[mod.Index]; + if (!ignoreTemporary && settings.TempSettings is { } tempSettings && (tempSettings.Lock <= 0 || tempSettings.Lock == key)) + { + if (!tempSettings.ForceInherit) + return (tempSettings.Enabled, tempSettings.Priority.Value, tempSettings.ConvertToShareable(mod).Settings, + false, true); + if (!ignoreInheritance && collection.GetActualSettings(mod.Index).Settings is { } actualSettingsTemp) + return (actualSettingsTemp.Enabled, actualSettingsTemp.Priority.Value, + actualSettingsTemp.ConvertToShareable(mod).Settings, true, true); + } + + if (settings.Settings is { } ownSettings) + return (ownSettings.Enabled, ownSettings.Priority.Value, ownSettings.ConvertToShareable(mod).Settings, false, + false); + if (!ignoreInheritance && collection.GetInheritedSettings(mod.Index).Settings is { } actualSettings) + return (actualSettings.Enabled, actualSettings.Priority.Value, + actualSettings.ConvertToShareable(mod).Settings, true, false); + + return null; + } + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] private void TriggerSettingEdited(Mod mod) { var collection = _collectionResolver.PlayerCollection(); - var (settings, parent) = collection[mod.Index]; + var (settings, parent) = collection.GetActualSettings(mod.Index); if (settings is { Enabled: true }) - ModSettingChanged?.Invoke(ModSettingChange.Edited, collection.Id, mod.Identifier, parent != collection); + ModSettingChanged?.Invoke(ModSettingChange.Edited, collection.Identity.Id, mod.Identifier, parent != collection); } private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2) @@ -254,7 +272,7 @@ public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable } private void OnModSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting _1, int _2, bool inherited) - => ModSettingChanged?.Invoke(type, collection.Id, mod?.ModPath.Name ?? string.Empty, inherited); + => ModSettingChanged?.Invoke(type, collection.Identity.Id, mod?.ModPath.Name ?? string.Empty, inherited); private void OnModOptionEdited(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, int moveIndex) @@ -283,4 +301,41 @@ public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable TriggerSettingEdited(mod); } + + public static PenumbraApiEc ConvertModSetting(Mod mod, string groupName, IReadOnlyList optionNames, out int groupIndex, + out Setting setting) + { + groupIndex = mod.Groups.IndexOf(g => g.Name.Equals(groupName, StringComparison.OrdinalIgnoreCase)); + setting = Setting.Zero; + if (groupIndex < 0) + return PenumbraApiEc.OptionGroupMissing; + + switch (mod.Groups[groupIndex]) + { + case { Behaviour: GroupDrawBehaviour.SingleSelection } single: + { + var optionIdx = optionNames.Count == 0 ? -1 : single.Options.IndexOf(o => o.Name == optionNames[^1]); + if (optionIdx < 0) + return PenumbraApiEc.OptionMissing; + + setting = Setting.Single(optionIdx); + break; + } + case { Behaviour: GroupDrawBehaviour.MultiSelection } multi: + { + foreach (var name in optionNames) + { + var optionIdx = multi.Options.IndexOf(o => o.Name == name); + if (optionIdx < 0) + return PenumbraApiEc.OptionMissing; + + setting |= Setting.Multi(optionIdx); + } + + break; + } + } + + return PenumbraApiEc.Success; + } } diff --git a/Penumbra/Api/Api/ModsApi.cs b/Penumbra/Api/Api/ModsApi.cs index 64e201be..1f4f1cf4 100644 --- a/Penumbra/Api/Api/ModsApi.cs +++ b/Penumbra/Api/Api/ModsApi.cs @@ -1,3 +1,4 @@ +using Newtonsoft.Json.Linq; using OtterGui.Compression; using OtterGui.Services; using Penumbra.Api.Enums; @@ -33,12 +34,8 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable { switch (type) { - case ModPathChangeType.Deleted when oldDirectory != null: - ModDeleted?.Invoke(oldDirectory.Name); - break; - case ModPathChangeType.Added when newDirectory != null: - ModAdded?.Invoke(newDirectory.Name); - break; + case ModPathChangeType.Deleted when oldDirectory != null: ModDeleted?.Invoke(oldDirectory.Name); break; + case ModPathChangeType.Added when newDirectory != null: ModAdded?.Invoke(newDirectory.Name); break; case ModPathChangeType.Moved when newDirectory != null && oldDirectory != null: ModMoved?.Invoke(oldDirectory.Name, newDirectory.Name); break; @@ -46,7 +43,9 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable } public void Dispose() - => _communicator.ModPathChanged.Unsubscribe(OnModPathChanged); + { + _communicator.ModPathChanged.Unsubscribe(OnModPathChanged); + } public Dictionary GetModList() => _modManager.ToDictionary(m => m.ModPath.Name, m => m.Name.Text); @@ -109,10 +108,22 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable public event Action? ModAdded; public event Action? ModMoved; + public event Action? CreatingPcp + { + add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi); + remove => _communicator.PcpCreation.Unsubscribe(value!); + } + + public event Action? ParsingPcp + { + add => _communicator.PcpParsing.Subscribe(value!, PcpParsing.Priority.ModsApi); + remove => _communicator.PcpParsing.Unsubscribe(value!); + } + public (PenumbraApiEc, string, bool, bool) GetModPath(string modDirectory, string modName) { if (!_modManager.TryGetMod(modDirectory, modName, out var mod) - || !_modFileSystem.FindLeaf(mod, out var leaf)) + || !_modFileSystem.TryGetValue(mod, out var leaf)) return (PenumbraApiEc.ModMissing, string.Empty, false, false); var fullPath = leaf.FullName(); @@ -127,7 +138,7 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable return PenumbraApiEc.InvalidArgument; if (!_modManager.TryGetMod(modDirectory, modName, out var mod) - || !_modFileSystem.FindLeaf(mod, out var leaf)) + || !_modFileSystem.TryGetValue(mod, out var leaf)) return PenumbraApiEc.ModMissing; try @@ -145,4 +156,10 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable => _modManager.TryGetMod(modDirectory, modName, out var mod) ? mod.ChangedItems.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToInternalObject()) : []; + + public IReadOnlyDictionary> GetChangedItemAdapterDictionary() + => new ModChangedItemAdapter(new WeakReference(_modManager)); + + public IReadOnlyList<(string ModDirectory, IReadOnlyDictionary ChangedItems)> GetChangedItemAdapterList() + => new ModChangedItemAdapter(new WeakReference(_modManager)); } diff --git a/Penumbra/Api/Api/PenumbraApi.cs b/Penumbra/Api/Api/PenumbraApi.cs index eaaf9f38..c4026c72 100644 --- a/Penumbra/Api/Api/PenumbraApi.cs +++ b/Penumbra/Api/Api/PenumbraApi.cs @@ -16,13 +16,16 @@ public class PenumbraApi( TemporaryApi temporary, UiApi ui) : IDisposable, IApiService, IPenumbraApi { + public const int BreakingVersion = 5; + public const int FeatureVersion = 13; + public void Dispose() { Valid = false; } public (int Breaking, int Feature) ApiVersion - => (5, 3); + => (BreakingVersion, FeatureVersion); public bool Valid { get; private set; } = true; public IPenumbraApiCollection Collection { get; } = collection; diff --git a/Penumbra/Api/Api/PluginStateApi.cs b/Penumbra/Api/Api/PluginStateApi.cs index d69df448..f74553d1 100644 --- a/Penumbra/Api/Api/PluginStateApi.cs +++ b/Penumbra/Api/Api/PluginStateApi.cs @@ -1,39 +1,38 @@ +using System.Collections.Frozen; using Newtonsoft.Json; using OtterGui.Services; using Penumbra.Communication; +using Penumbra.Mods; using Penumbra.Services; namespace Penumbra.Api.Api; -public class PluginStateApi : IPenumbraApiPluginState, IApiService +public class PluginStateApi(Configuration config, CommunicatorService communicator) : IPenumbraApiPluginState, IApiService { - private readonly Configuration _config; - private readonly CommunicatorService _communicator; - - public PluginStateApi(Configuration config, CommunicatorService communicator) - { - _config = config; - _communicator = communicator; - } - public string GetModDirectory() - => _config.ModDirectory; + => config.ModDirectory; public string GetConfiguration() - => JsonConvert.SerializeObject(_config, Formatting.Indented); + => JsonConvert.SerializeObject(config, Formatting.Indented); public event Action? ModDirectoryChanged { - add => _communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api); - remove => _communicator.ModDirectoryChanged.Unsubscribe(value!); + add => communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api); + remove => communicator.ModDirectoryChanged.Unsubscribe(value!); } public bool GetEnabledState() - => _config.EnableMods; + => config.EnableMods; public event Action? EnabledChange { - add => _communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api); - remove => _communicator.EnabledChanged.Unsubscribe(value!); + add => communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api); + remove => communicator.EnabledChanged.Unsubscribe(value!); } + + public FrozenSet SupportedFeatures + => FeatureChecker.SupportedFeatures.ToFrozenSet(); + + public string[] CheckSupportedFeatures(IEnumerable requiredFeatures) + => requiredFeatures.Where(f => !FeatureChecker.Supported(f)).ToArray(); } diff --git a/Penumbra/Api/Api/RedrawApi.cs b/Penumbra/Api/Api/RedrawApi.cs index 82d14f7b..08f1f9df 100644 --- a/Penumbra/Api/Api/RedrawApi.cs +++ b/Penumbra/Api/Api/RedrawApi.cs @@ -1,27 +1,57 @@ using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; using OtterGui.Services; using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Interop; using Penumbra.Interop.Services; -namespace Penumbra.Api.Api; - -public class RedrawApi(RedrawService redrawService) : IPenumbraApiRedraw, IApiService +namespace Penumbra.Api.Api; + +public class RedrawApi(RedrawService redrawService, IFramework framework, CollectionManager collections, ObjectManager objects, ApiHelpers helpers) : IPenumbraApiRedraw, IApiService { public void RedrawObject(int gameObjectIndex, RedrawType setting) - => redrawService.RedrawObject(gameObjectIndex, setting); + { + framework.RunOnFrameworkThread(() => redrawService.RedrawObject(gameObjectIndex, setting)); + } public void RedrawObject(string name, RedrawType setting) - => redrawService.RedrawObject(name, setting); + { + framework.RunOnFrameworkThread(() => redrawService.RedrawObject(name, setting)); + } public void RedrawObject(IGameObject? gameObject, RedrawType setting) - => redrawService.RedrawObject(gameObject, setting); + { + framework.RunOnFrameworkThread(() => redrawService.RedrawObject(gameObject, setting)); + } public void RedrawAll(RedrawType setting) - => redrawService.RedrawAll(setting); + { + framework.RunOnFrameworkThread(() => redrawService.RedrawAll(setting)); + } + + public void RedrawCollectionMembers(Guid collectionId, RedrawType setting) + { + + if (!collections.Storage.ById(collectionId, out var collection)) + collection = ModCollection.Empty; + framework.RunOnFrameworkThread(() => + { + foreach (var actor in objects.Objects) + { + helpers.AssociatedCollection(actor.ObjectIndex, out var modCollection); + if (collection == modCollection) + { + redrawService.RedrawObject(actor.ObjectIndex, setting); + } + } + }); + } public event GameObjectRedrawnDelegate? GameObjectRedrawn { add => redrawService.GameObjectRedrawn += value; remove => redrawService.GameObjectRedrawn -= value; } -} +} diff --git a/Penumbra/Api/Api/TemporaryApi.cs b/Penumbra/Api/Api/TemporaryApi.cs index 516b4347..7567acd3 100644 --- a/Penumbra/Api/Api/TemporaryApi.cs +++ b/Penumbra/Api/Api/TemporaryApi.cs @@ -1,8 +1,11 @@ +using OtterGui.Log; using OtterGui.Services; using Penumbra.Api.Enums; +using Penumbra.Collections; using Penumbra.Collections.Manager; using Penumbra.GameData.Actors; using Penumbra.GameData.Interop; +using Penumbra.Mods.Manager; using Penumbra.Mods.Settings; using Penumbra.String.Classes; @@ -13,10 +16,20 @@ public class TemporaryApi( ObjectManager objects, ActorManager actors, CollectionManager collectionManager, - TempModManager tempMods) : IPenumbraApiTemporary, IApiService + TempModManager tempMods, + ApiHelpers apiHelpers, + ModManager modManager) : IPenumbraApiTemporary, IApiService { - public Guid CreateTemporaryCollection(string name) - => tempCollections.CreateTemporaryCollection(name); + public (PenumbraApiEc, Guid) CreateTemporaryCollection(string identity, string name) + { + if (!IdentityChecker.Check(identity)) + return (PenumbraApiEc.InvalidCredentials, Guid.Empty); + + var collection = tempCollections.CreateTemporaryCollection(name); + if (collection == Guid.Empty) + return (PenumbraApiEc.UnknownError, collection); + return (PenumbraApiEc.Success, collection); + } public PenumbraApiEc DeleteTemporaryCollection(Guid collectionId) => tempCollections.RemoveTemporaryCollection(collectionId) @@ -60,7 +73,7 @@ public class TemporaryApi( if (!ConvertPaths(paths, out var p)) return ApiHelpers.Return(PenumbraApiEc.InvalidGamePath, args); - if (!MetaApi.ConvertManips(manipString, out var m)) + if (!MetaApi.ConvertManips(manipString, out var m, out _)) return ApiHelpers.Return(PenumbraApiEc.InvalidManipulation, args); var ret = tempMods.Register(tag, null, p, m, new ModPriority(priority)) switch @@ -86,7 +99,7 @@ public class TemporaryApi( if (!ConvertPaths(paths, out var p)) return ApiHelpers.Return(PenumbraApiEc.InvalidGamePath, args); - if (!MetaApi.ConvertManips(manipString, out var m)) + if (!MetaApi.ConvertManips(manipString, out var m, out _)) return ApiHelpers.Return(PenumbraApiEc.InvalidManipulation, args); var ret = tempMods.Register(tag, collection, p, m, new ModPriority(priority)) switch @@ -125,6 +138,177 @@ public class TemporaryApi( return ApiHelpers.Return(ret, args); } + public (PenumbraApiEc, (bool, bool, int, Dictionary>)?, string) QueryTemporaryModSettings(Guid collectionId, + string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return (ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args), null, string.Empty); + + return QueryTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + public (PenumbraApiEc ErrorCode, (bool, bool, int, Dictionary>)? Settings, string Source) + QueryTemporaryModSettingsPlayer(int objectIndex, + string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "ModDirectory", modDirectory, "ModName", modName); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return (ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args), null, string.Empty); + + return QueryTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + private (PenumbraApiEc ErrorCode, (bool, bool, int, Dictionary>)? Settings, string Source) QueryTemporaryModSettings( + in LazyString args, ModCollection collection, string modDirectory, string modName, int key) + { + if (!modManager.TryGetMod(modDirectory, modName, out var mod)) + return (ApiHelpers.Return(PenumbraApiEc.ModMissing, args), null, string.Empty); + + if (collection.Identity.Index <= 0) + return (ApiHelpers.Return(PenumbraApiEc.Success, args), null, string.Empty); + + var settings = collection.GetTempSettings(mod.Index); + if (settings == null) + return (ApiHelpers.Return(PenumbraApiEc.Success, args), null, string.Empty); + + if (settings.Lock > 0 && settings.Lock != key) + return (ApiHelpers.Return(PenumbraApiEc.TemporarySettingDisallowed, args), null, settings.Source); + + return (ApiHelpers.Return(PenumbraApiEc.Success, args), + (settings.ForceInherit, settings.Enabled, settings.Priority.Value, settings.ConvertToShareable(mod).Settings), settings.Source); + } + + + public PenumbraApiEc SetTemporaryModSettings(Guid collectionId, string modDirectory, string modName, bool inherit, bool enabled, + int priority, + IReadOnlyDictionary> options, string source, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Inherit", inherit, + "Enabled", enabled, + "Priority", priority, "Options", options, "Source", source, "Key", key); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return SetTemporaryModSettings(args, collection, modDirectory, modName, inherit, enabled, priority, options, source, key); + } + + public PenumbraApiEc SetTemporaryModSettingsPlayer(int objectIndex, string modDirectory, string modName, bool inherit, bool enabled, + int priority, + IReadOnlyDictionary> options, string source, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "ModDirectory", modDirectory, "ModName", modName, "Inherit", inherit, "Enabled", + enabled, + "Priority", priority, "Options", options, "Source", source, "Key", key); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + return SetTemporaryModSettings(args, collection, modDirectory, modName, inherit, enabled, priority, options, source, key); + } + + private PenumbraApiEc SetTemporaryModSettings(in LazyString args, ModCollection collection, string modDirectory, string modName, + bool inherit, bool enabled, int priority, IReadOnlyDictionary> options, string source, int key) + { + if (collection.Identity.Index <= 0) + return ApiHelpers.Return(PenumbraApiEc.TemporarySettingImpossible, args); + + if (!modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + if (!collectionManager.Editor.CanSetTemporarySettings(collection, mod, key)) + if (collection.GetTempSettings(mod.Index) is { Lock: > 0 } oldSettings && oldSettings.Lock != key) + return ApiHelpers.Return(PenumbraApiEc.TemporarySettingDisallowed, args); + + var newSettings = new TemporaryModSettings() + { + ForceInherit = inherit, + Enabled = enabled, + Priority = new ModPriority(priority), + Lock = key, + Source = source, + Settings = SettingList.Default(mod), + }; + + + foreach (var (groupName, optionNames) in options) + { + var ec = ModSettingsApi.ConvertModSetting(mod, groupName, optionNames, out var groupIdx, out var setting); + if (ec != PenumbraApiEc.Success) + return ApiHelpers.Return(ec, args); + + newSettings.Settings[groupIdx] = setting; + } + + if (collectionManager.Editor.SetTemporarySettings(collection, mod, newSettings, key)) + return ApiHelpers.Return(PenumbraApiEc.Success, args); + + // This should not happen since all error cases had been checked before. + return ApiHelpers.Return(PenumbraApiEc.UnknownError, args); + } + + public PenumbraApiEc RemoveTemporaryModSettings(Guid collectionId, string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Key", key); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return RemoveTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + public PenumbraApiEc RemoveTemporaryModSettingsPlayer(int objectIndex, string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "ModDirectory", modDirectory, "ModName", modName, "Key", key); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + return RemoveTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + private PenumbraApiEc RemoveTemporaryModSettings(in LazyString args, ModCollection collection, string modDirectory, string modName, int key) + { + if (collection.Identity.Index <= 0) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, args); + + if (!modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + if (collection.GetTempSettings(mod.Index) is null) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, args); + + if (!collectionManager.Editor.SetTemporarySettings(collection, mod, null, key)) + return ApiHelpers.Return(PenumbraApiEc.TemporarySettingDisallowed, args); + + return ApiHelpers.Return(PenumbraApiEc.Success, args); + } + + public PenumbraApiEc RemoveAllTemporaryModSettings(Guid collectionId, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "Key", key); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return RemoveAllTemporaryModSettings(args, collection, key); + } + + public PenumbraApiEc RemoveAllTemporaryModSettingsPlayer(int objectIndex, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "Key", key); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + return RemoveAllTemporaryModSettings(args, collection, key); + } + + private PenumbraApiEc RemoveAllTemporaryModSettings(in LazyString args, ModCollection collection, int key) + { + if (collection.Identity.Index <= 0) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, args); + + var numRemoved = collectionManager.Editor.ClearTemporarySettings(collection, key); + return ApiHelpers.Return(numRemoved > 0 ? PenumbraApiEc.Success : PenumbraApiEc.NothingChanged, args); + } + + /// /// Convert a dictionary of strings to a dictionary of game paths to full paths. /// Only returns true if all paths can successfully be converted and added. diff --git a/Penumbra/Api/Api/UiApi.cs b/Penumbra/Api/Api/UiApi.cs index 515874c0..b14f67ae 100644 --- a/Penumbra/Api/Api/UiApi.cs +++ b/Penumbra/Api/Api/UiApi.cs @@ -81,21 +81,21 @@ public class UiApi : IPenumbraApiUi, IApiService, IDisposable public void CloseMainWindow() => _configWindow.IsOpen = false; - private void OnChangedItemClick(MouseButton button, IIdentifiedObjectData? data) + private void OnChangedItemClick(MouseButton button, IIdentifiedObjectData data) { if (ChangedItemClicked == null) return; - var (type, id) = data?.ToApiObject() ?? (ChangedItemType.None, 0); + var (type, id) = data.ToApiObject(); ChangedItemClicked.Invoke(button, type, id); } - private void OnChangedItemHover(IIdentifiedObjectData? data) + private void OnChangedItemHover(IIdentifiedObjectData data) { if (ChangedItemTooltip == null) return; - var (type, id) = data?.ToApiObject() ?? (ChangedItemType.None, 0); + var (type, id) = data.ToApiObject(); ChangedItemTooltip.Invoke(type, id); } } diff --git a/Penumbra/Api/HttpApi.cs b/Penumbra/Api/HttpApi.cs index 859c46b4..79348a88 100644 --- a/Penumbra/Api/HttpApi.cs +++ b/Penumbra/Api/HttpApi.cs @@ -1,9 +1,11 @@ +using Dalamud.Plugin.Services; using EmbedIO; using EmbedIO.Routing; using EmbedIO.WebApi; using OtterGui.Services; using Penumbra.Api.Api; using Penumbra.Api.Enums; +using Penumbra.Mods.Settings; namespace Penumbra.Api; @@ -12,23 +14,28 @@ public class HttpApi : IDisposable, IApiService private partial class Controller : WebApiController { // @formatter:off - [Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods(); - [Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw(); - [Route( HttpVerbs.Post, "/redrawAll" )] public partial void RedrawAll(); - [Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod(); - [Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod(); - [Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow(); + [Route( HttpVerbs.Get, "/moddirectory" )] public partial string GetModDirectory(); + [Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods(); + [Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw(); + [Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll(); + [Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod(); + [Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod(); + [Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow(); + [Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod(); + [Route( HttpVerbs.Post, "/setmodsettings")] public partial Task SetModSettings(); // @formatter:on } public const string Prefix = "http://localhost:42069/"; private readonly IPenumbraApi _api; + private readonly IFramework _framework; private WebServer? _server; - public HttpApi(Configuration config, IPenumbraApi api) + public HttpApi(Configuration config, IPenumbraApi api, IFramework framework) { - _api = api; + _api = api; + _framework = framework; if (config.EnableHttpApi) CreateWebServer(); } @@ -44,7 +51,7 @@ public class HttpApi : IDisposable, IApiService .WithUrlPrefix(Prefix) .WithMode(HttpListenerMode.EmbedIO)) .WithCors(Prefix) - .WithWebApi("/api", m => m.WithController(() => new Controller(_api))); + .WithWebApi("/api", m => m.WithController(() => new Controller(_api, _framework))); _server.StateChanged += (_, e) => Penumbra.Log.Information($"WebServer New State - {e.NewState}"); _server.RunAsync(); @@ -59,60 +66,96 @@ public class HttpApi : IDisposable, IApiService public void Dispose() => ShutdownWebServer(); - private partial class Controller + private partial class Controller(IPenumbraApi api, IFramework framework) { - private readonly IPenumbraApi _api; - - public Controller(IPenumbraApi api) - => _api = api; + public partial string GetModDirectory() + { + Penumbra.Log.Debug($"[HTTP] {nameof(GetModDirectory)} triggered."); + return api.PluginState.GetModDirectory(); + } public partial object? GetMods() { Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered."); - return _api.Mods.GetModList(); + return api.Mods.GetModList(); } public async partial Task Redraw() { - var data = await HttpContext.GetRequestDataAsync(); - Penumbra.Log.Debug($"[HTTP] {nameof(Redraw)} triggered with {data}."); - if (data.ObjectTableIndex >= 0) - _api.Redraw.RedrawObject(data.ObjectTableIndex, data.Type); - else - _api.Redraw.RedrawAll(data.Type); + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] [{Environment.CurrentManagedThreadId}] {nameof(Redraw)} triggered with {data}."); + await framework.RunOnFrameworkThread(() => + { + if (data.ObjectTableIndex >= 0) + api.Redraw.RedrawObject(data.ObjectTableIndex, data.Type); + else + api.Redraw.RedrawAll(data.Type); + }).ConfigureAwait(false); } - public partial void RedrawAll() + public async partial Task RedrawAll() { Penumbra.Log.Debug($"[HTTP] {nameof(RedrawAll)} triggered."); - _api.Redraw.RedrawAll(RedrawType.Redraw); + await framework.RunOnFrameworkThread(() => { api.Redraw.RedrawAll(RedrawType.Redraw); }).ConfigureAwait(false); } public async partial Task ReloadMod() { - var data = await HttpContext.GetRequestDataAsync(); + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); Penumbra.Log.Debug($"[HTTP] {nameof(ReloadMod)} triggered with {data}."); // Add the mod if it is not already loaded and if the directory name is given. // AddMod returns Success if the mod is already loaded. if (data.Path.Length != 0) - _api.Mods.AddMod(data.Path); + api.Mods.AddMod(data.Path); // Reload the mod by path or name, which will also remove no-longer existing mods. - _api.Mods.ReloadMod(data.Path, data.Name); + api.Mods.ReloadMod(data.Path, data.Name); } public async partial Task InstallMod() { - var data = await HttpContext.GetRequestDataAsync(); + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); Penumbra.Log.Debug($"[HTTP] {nameof(InstallMod)} triggered with {data}."); if (data.Path.Length != 0) - _api.Mods.InstallMod(data.Path); + api.Mods.InstallMod(data.Path); } public partial void OpenWindow() { Penumbra.Log.Debug($"[HTTP] {nameof(OpenWindow)} triggered."); - _api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty); + api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty); + } + + public async partial Task FocusMod() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] {nameof(FocusMod)} triggered."); + if (data.Path.Length != 0) + api.Ui.OpenMainWindow(TabType.Mods, data.Path, data.Name); + } + + public async partial Task SetModSettings() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] {nameof(SetModSettings)} triggered."); + await framework.RunOnFrameworkThread(() => + { + var collection = data.CollectionId ?? api.Collection.GetCollection(ApiCollectionType.Current)!.Value.Id; + if (data.Inherit.HasValue) + { + api.ModSettings.TryInheritMod(collection, data.ModPath, data.ModName, data.Inherit.Value); + if (data.Inherit.Value) + return; + } + + if (data.State.HasValue) + api.ModSettings.TrySetMod(collection, data.ModPath, data.ModName, data.State.Value); + if (data.Priority.HasValue) + api.ModSettings.TrySetModPriority(collection, data.ModPath, data.ModName, data.Priority.Value); + foreach (var (group, settings) in data.Settings ?? []) + api.ModSettings.TrySetModSettings(collection, data.ModPath, data.ModName, group, settings); + } + ).ConfigureAwait(false); } private record ModReloadData(string Path, string Name) @@ -122,6 +165,13 @@ public class HttpApi : IDisposable, IApiService { } } + private record ModFocusData(string Path, string Name) + { + public ModFocusData() + : this(string.Empty, string.Empty) + { } + } + private record ModInstallData(string Path) { public ModInstallData() @@ -135,5 +185,19 @@ public class HttpApi : IDisposable, IApiService : this(string.Empty, RedrawType.Redraw, -1) { } } + + private record SetModSettingsData( + Guid? CollectionId, + string ModPath, + string ModName, + bool? Inherit, + bool? State, + int? Priority, + Dictionary>? Settings) + { + public SetModSettingsData() + : this(null, string.Empty, string.Empty, null, null, null, null) + {} + } } } diff --git a/Penumbra/Api/IpcLaunchingProvider.cs b/Penumbra/Api/IpcLaunchingProvider.cs new file mode 100644 index 00000000..ff851003 --- /dev/null +++ b/Penumbra/Api/IpcLaunchingProvider.cs @@ -0,0 +1,28 @@ +using Dalamud.Plugin; +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Api.Api; +using Serilog.Events; + +namespace Penumbra.Api; + +public sealed class IpcLaunchingProvider : IApiService +{ + public IpcLaunchingProvider(IDalamudPluginInterface pi, Logger log) + { + try + { + using var subscriber = log.MainLogger.IsEnabled(LogEventLevel.Debug) + ? IpcSubscribers.Launching.Subscriber(pi, + (major, minor) => log.Debug($"[IPC] Invoked Penumbra.Launching IPC with API Version {major}.{minor}.")) + : null; + + using var provider = IpcSubscribers.Launching.Provider(pi); + provider.Invoke(PenumbraApi.BreakingVersion, PenumbraApi.FeatureVersion); + } + catch (Exception ex) + { + log.Error($"[IPC] Could not invoke Penumbra.Launching IPC:\n{ex}"); + } + } +} diff --git a/Penumbra/Api/IpcProviders.cs b/Penumbra/Api/IpcProviders.cs index 861225fa..5f04540f 100644 --- a/Penumbra/Api/IpcProviders.cs +++ b/Penumbra/Api/IpcProviders.cs @@ -2,6 +2,8 @@ using Dalamud.Plugin; using OtterGui.Services; using Penumbra.Api.Api; using Penumbra.Api.Helpers; +using Penumbra.Communication; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; namespace Penumbra.Api; @@ -9,11 +11,13 @@ public sealed class IpcProviders : IDisposable, IApiService { private readonly List _providers; - private readonly EventProvider _disposedProvider; - private readonly EventProvider _initializedProvider; + private readonly EventProvider _disposedProvider; + private readonly EventProvider _initializedProvider; + private readonly CharacterUtility _characterUtility; - public IpcProviders(IDalamudPluginInterface pi, IPenumbraApi api) + public IpcProviders(IDalamudPluginInterface pi, IPenumbraApi api, CharacterUtility characterUtility) { + _characterUtility = characterUtility; _disposedProvider = IpcSubscribers.Disposed.Provider(pi); _initializedProvider = IpcSubscribers.Initialized.Provider(pi); _providers = @@ -25,6 +29,7 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.GetCollectionForObject.Provider(pi, api.Collection), IpcSubscribers.SetCollection.Provider(pi, api.Collection), IpcSubscribers.SetCollectionForObject.Provider(pi, api.Collection), + IpcSubscribers.CheckCurrentChangedItemFunc.Provider(pi, api.Collection), IpcSubscribers.ConvertTextureFile.Provider(pi, api.Editing), IpcSubscribers.ConvertTextureData.Provider(pi, api.Editing), @@ -35,6 +40,8 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.CreatingCharacterBase.Provider(pi, api.GameState), IpcSubscribers.CreatedCharacterBase.Provider(pi, api.GameState), IpcSubscribers.GameObjectResourcePathResolved.Provider(pi, api.GameState), + IpcSubscribers.GetCutsceneParentIndexFunc.Provider(pi, api.GameState), + IpcSubscribers.GetGameObjectFromDrawObjectFunc.Provider(pi, api.GameState), IpcSubscribers.GetPlayerMetaManipulations.Provider(pi, api.Meta), IpcSubscribers.GetMetaManipulations.Provider(pi, api.Meta), @@ -47,12 +54,18 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.ModDeleted.Provider(pi, api.Mods), IpcSubscribers.ModAdded.Provider(pi, api.Mods), IpcSubscribers.ModMoved.Provider(pi, api.Mods), + IpcSubscribers.CreatingPcp.Provider(pi, api.Mods), + IpcSubscribers.ParsingPcp.Provider(pi, api.Mods), IpcSubscribers.GetModPath.Provider(pi, api.Mods), IpcSubscribers.SetModPath.Provider(pi, api.Mods), IpcSubscribers.GetChangedItems.Provider(pi, api.Mods), + IpcSubscribers.GetChangedItemAdapterDictionary.Provider(pi, api.Mods), + IpcSubscribers.GetChangedItemAdapterList.Provider(pi, api.Mods), IpcSubscribers.GetAvailableModSettings.Provider(pi, api.ModSettings), IpcSubscribers.GetCurrentModSettings.Provider(pi, api.ModSettings), + IpcSubscribers.GetCurrentModSettingsWithTemp.Provider(pi, api.ModSettings), + IpcSubscribers.GetAllModSettings.Provider(pi, api.ModSettings), IpcSubscribers.TryInheritMod.Provider(pi, api.ModSettings), IpcSubscribers.TrySetMod.Provider(pi, api.ModSettings), IpcSubscribers.TrySetModPriority.Provider(pi, api.ModSettings), @@ -63,16 +76,19 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.ApiVersion.Provider(pi, api), new FuncProvider<(int Major, int Minor)>(pi, "Penumbra.ApiVersions", () => api.ApiVersion), // backward compatibility - new FuncProvider(pi, "Penumbra.ApiVersion", () => api.ApiVersion.Breaking), // backward compatibility + new FuncProvider(pi, "Penumbra.ApiVersion", () => api.ApiVersion.Breaking), // backward compatibility IpcSubscribers.GetModDirectory.Provider(pi, api.PluginState), IpcSubscribers.GetConfiguration.Provider(pi, api.PluginState), IpcSubscribers.ModDirectoryChanged.Provider(pi, api.PluginState), IpcSubscribers.GetEnabledState.Provider(pi, api.PluginState), IpcSubscribers.EnabledChange.Provider(pi, api.PluginState), + IpcSubscribers.SupportedFeatures.Provider(pi, api.PluginState), + IpcSubscribers.CheckSupportedFeatures.Provider(pi, api.PluginState), IpcSubscribers.RedrawObject.Provider(pi, api.Redraw), IpcSubscribers.RedrawAll.Provider(pi, api.Redraw), IpcSubscribers.GameObjectRedrawn.Provider(pi, api.Redraw), + IpcSubscribers.RedrawCollectionMembers.Provider(pi, api.Redraw), IpcSubscribers.ResolveDefaultPath.Provider(pi, api.Resolve), IpcSubscribers.ResolveInterfacePath.Provider(pi, api.Resolve), @@ -97,6 +113,14 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.AddTemporaryMod.Provider(pi, api.Temporary), IpcSubscribers.RemoveTemporaryModAll.Provider(pi, api.Temporary), IpcSubscribers.RemoveTemporaryMod.Provider(pi, api.Temporary), + IpcSubscribers.SetTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.SetTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + IpcSubscribers.RemoveTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.RemoveTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + IpcSubscribers.RemoveAllTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.RemoveAllTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + IpcSubscribers.QueryTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.QueryTemporaryModSettingsPlayer.Provider(pi, api.Temporary), IpcSubscribers.ChangedItemTooltip.Provider(pi, api.Ui), IpcSubscribers.ChangedItemClicked.Provider(pi, api.Ui), @@ -107,11 +131,21 @@ public sealed class IpcProviders : IDisposable, IApiService IpcSubscribers.OpenMainWindow.Provider(pi, api.Ui), IpcSubscribers.CloseMainWindow.Provider(pi, api.Ui), ]; + if (_characterUtility.Ready) + _initializedProvider.Invoke(); + else + _characterUtility.LoadingFinished.Subscribe(OnCharacterUtilityReady, CharacterUtilityFinished.Priority.IpcProvider); + } + + private void OnCharacterUtilityReady() + { _initializedProvider.Invoke(); + _characterUtility.LoadingFinished.Unsubscribe(OnCharacterUtilityReady); } public void Dispose() { + _characterUtility.LoadingFinished.Unsubscribe(OnCharacterUtilityReady); foreach (var provider in _providers) provider.Dispose(); _providers.Clear(); diff --git a/Penumbra/Api/IpcTester/CollectionsIpcTester.cs b/Penumbra/Api/IpcTester/CollectionsIpcTester.cs index 1d516eba..f033b7c3 100644 --- a/Penumbra/Api/IpcTester/CollectionsIpcTester.cs +++ b/Penumbra/Api/IpcTester/CollectionsIpcTester.cs @@ -1,7 +1,7 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Plugin; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using OtterGui.Services; @@ -121,6 +121,10 @@ public class CollectionsIpcTester(IDalamudPluginInterface pi) : IUiService }).ToArray(); ImGui.OpenPopup("Changed Item List"); } + IpcTester.DrawIntro(RedrawCollectionMembers.Label, "Redraw Collection Members"); + if (ImGui.Button("Redraw##ObjectCollection")) + new RedrawCollectionMembers(pi).Invoke(collectionList[0].Id, RedrawType.Redraw); + } private void DrawChangedItemPopup() diff --git a/Penumbra/Api/IpcTester/EditingIpcTester.cs b/Penumbra/Api/IpcTester/EditingIpcTester.cs index a1001630..d754cf90 100644 --- a/Penumbra/Api/IpcTester/EditingIpcTester.cs +++ b/Penumbra/Api/IpcTester/EditingIpcTester.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Plugin; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using OtterGui.Services; diff --git a/Penumbra/Api/IpcTester/GameStateIpcTester.cs b/Penumbra/Api/IpcTester/GameStateIpcTester.cs index 04541a57..38a09714 100644 --- a/Penumbra/Api/IpcTester/GameStateIpcTester.cs +++ b/Penumbra/Api/IpcTester/GameStateIpcTester.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Plugin; -using ImGuiNET; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Api.Enums; diff --git a/Penumbra/Api/IpcTester/IpcTester.cs b/Penumbra/Api/IpcTester/IpcTester.cs index 201e7068..b03d7e03 100644 --- a/Penumbra/Api/IpcTester/IpcTester.cs +++ b/Penumbra/Api/IpcTester/IpcTester.cs @@ -1,6 +1,6 @@ using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.System.Framework; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using Penumbra.Api.Api; diff --git a/Penumbra/Api/IpcTester/MetaIpcTester.cs b/Penumbra/Api/IpcTester/MetaIpcTester.cs index 8b393ade..bee1981c 100644 --- a/Penumbra/Api/IpcTester/MetaIpcTester.cs +++ b/Penumbra/Api/IpcTester/MetaIpcTester.cs @@ -1,14 +1,20 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Plugin; -using ImGuiNET; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Api; using Penumbra.Api.IpcSubscribers; +using Penumbra.Meta.Manipulations; namespace Penumbra.Api.IpcTester; public class MetaIpcTester(IDalamudPluginInterface pi) : IUiService { - private int _gameObjectIndex; + private int _gameObjectIndex; + private string _metaBase64 = string.Empty; + private MetaDictionary _metaDict = new(); + private byte _parsedVersion = byte.MaxValue; public void Draw() { @@ -17,6 +23,11 @@ public class MetaIpcTester(IDalamudPluginInterface pi) : IUiService return; ImGui.InputInt("##metaIdx", ref _gameObjectIndex, 0, 0); + if (ImUtf8.InputText("##metaText"u8, ref _metaBase64, "Base64 Metadata..."u8)) + if (!MetaApi.ConvertManips(_metaBase64, out _metaDict!, out _parsedVersion)) + _metaDict ??= new MetaDictionary(); + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); if (!table) return; @@ -34,5 +45,8 @@ public class MetaIpcTester(IDalamudPluginInterface pi) : IUiService var base64 = new GetMetaManipulations(pi).Invoke(_gameObjectIndex); ImGui.SetClipboardText(base64); } + + IpcTester.DrawIntro(string.Empty, "Parsed Data"); + ImUtf8.Text($"Version: {_parsedVersion}, Count: {_metaDict.Count}"); } } diff --git a/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs b/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs index 23078576..152efa45 100644 --- a/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs +++ b/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs @@ -1,8 +1,9 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Plugin; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using Penumbra.Api.Enums; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; @@ -22,16 +23,20 @@ public class ModSettingsIpcTester : IUiService, IDisposable private bool _lastSettingChangeInherited; private DateTimeOffset _lastSettingChange; - private string _settingsModDirectory = string.Empty; - private string _settingsModName = string.Empty; - private Guid? _settingsCollection; - private string _settingsCollectionName = string.Empty; - private bool _settingsIgnoreInheritance; - private bool _settingsInherit; - private bool _settingsEnabled; - private int _settingsPriority; - private IReadOnlyDictionary? _availableSettings; - private Dictionary>? _currentSettings; + private string _settingsModDirectory = string.Empty; + private string _settingsModName = string.Empty; + private Guid? _settingsCollection; + private string _settingsCollectionName = string.Empty; + private bool _settingsIgnoreInheritance; + private bool _settingsIgnoreTemporary; + private int _settingsKey; + private bool _settingsInherit; + private bool _settingsTemporary; + private bool _settingsEnabled; + private int _settingsPriority; + private IReadOnlyDictionary? _availableSettings; + private Dictionary>? _currentSettings; + private Dictionary>, bool, bool)>? _allSettings; public ModSettingsIpcTester(IDalamudPluginInterface pi) { @@ -54,7 +59,9 @@ public class ModSettingsIpcTester : IUiService, IDisposable ImGui.InputTextWithHint("##settingsDir", "Mod Directory Name...", ref _settingsModDirectory, 100); ImGui.InputTextWithHint("##settingsName", "Mod Name...", ref _settingsModName, 100); ImGuiUtil.GuidInput("##settingsCollection", "Collection...", string.Empty, ref _settingsCollection, ref _settingsCollectionName); - ImGui.Checkbox("Ignore Inheritance", ref _settingsIgnoreInheritance); + ImUtf8.Checkbox("Ignore Inheritance"u8, ref _settingsIgnoreInheritance); + ImUtf8.Checkbox("Ignore Temporary"u8, ref _settingsIgnoreTemporary); + ImUtf8.InputScalar("Key"u8, ref _settingsKey); var collection = _settingsCollection.GetValueOrDefault(Guid.Empty); using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); @@ -83,10 +90,11 @@ public class ModSettingsIpcTester : IUiService, IDisposable _lastSettingsError = ret.Item1; if (ret.Item1 == PenumbraApiEc.Success) { - _settingsEnabled = ret.Item2?.Item1 ?? false; - _settingsInherit = ret.Item2?.Item4 ?? true; - _settingsPriority = ret.Item2?.Item2 ?? 0; - _currentSettings = ret.Item2?.Item3; + _settingsEnabled = ret.Item2?.Item1 ?? false; + _settingsInherit = ret.Item2?.Item4 ?? true; + _settingsTemporary = false; + _settingsPriority = ret.Item2?.Item2 ?? 0; + _currentSettings = ret.Item2?.Item3; } else { @@ -94,6 +102,40 @@ public class ModSettingsIpcTester : IUiService, IDisposable } } + IpcTester.DrawIntro(GetCurrentModSettingsWithTemp.Label, "Get Current Settings With Temp"); + if (ImGui.Button("Get##CurrentTemp")) + { + var ret = new GetCurrentModSettingsWithTemp(_pi) + .Invoke(collection, _settingsModDirectory, _settingsModName, _settingsIgnoreInheritance, _settingsIgnoreTemporary, _settingsKey); + _lastSettingsError = ret.Item1; + if (ret.Item1 == PenumbraApiEc.Success) + { + _settingsEnabled = ret.Item2?.Item1 ?? false; + _settingsInherit = ret.Item2?.Item4 ?? true; + _settingsTemporary = ret.Item2?.Item5 ?? false; + _settingsPriority = ret.Item2?.Item2 ?? 0; + _currentSettings = ret.Item2?.Item3; + } + else + { + _currentSettings = null; + } + } + + IpcTester.DrawIntro(GetAllModSettings.Label, "Get All Mod Settings"); + if (ImGui.Button("Get##All")) + { + var ret = new GetAllModSettings(_pi).Invoke(collection, _settingsIgnoreInheritance, _settingsIgnoreTemporary, _settingsKey); + _lastSettingsError = ret.Item1; + _allSettings = ret.Item2; + } + + if (_allSettings != null) + { + ImGui.SameLine(); + ImUtf8.Text($"{_allSettings.Count} Mods"); + } + IpcTester.DrawIntro(TryInheritMod.Label, "Inherit Mod"); ImGui.Checkbox("##inherit", ref _settingsInherit); ImGui.SameLine(); diff --git a/Penumbra/Api/IpcTester/ModsIpcTester.cs b/Penumbra/Api/IpcTester/ModsIpcTester.cs index a24861a3..9ea53366 100644 --- a/Penumbra/Api/IpcTester/ModsIpcTester.cs +++ b/Penumbra/Api/IpcTester/ModsIpcTester.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; using Dalamud.Plugin; -using ImGuiNET; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/Api/IpcTester/PluginStateIpcTester.cs b/Penumbra/Api/IpcTester/PluginStateIpcTester.cs index df82033d..073305d0 100644 --- a/Penumbra/Api/IpcTester/PluginStateIpcTester.cs +++ b/Penumbra/Api/IpcTester/PluginStateIpcTester.cs @@ -1,10 +1,11 @@ using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; @@ -12,7 +13,7 @@ namespace Penumbra.Api.IpcTester; public class PluginStateIpcTester : IUiService, IDisposable { - private readonly IDalamudPluginInterface _pi; + private readonly IDalamudPluginInterface _pi; public readonly EventSubscriber ModDirectoryChanged; public readonly EventSubscriber Initialized; public readonly EventSubscriber Disposed; @@ -26,6 +27,9 @@ public class PluginStateIpcTester : IUiService, IDisposable private readonly List _initializedList = []; private readonly List _disposedList = []; + private string _requiredFeatureString = string.Empty; + private string[] _requiredFeatures = []; + private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch; private bool? _lastEnabledValue; @@ -48,12 +52,15 @@ public class PluginStateIpcTester : IUiService, IDisposable EnabledChange.Dispose(); } + public void Draw() { using var _ = ImRaii.TreeNode("Plugin State"); if (!_) return; + if (ImUtf8.InputText("Required Features"u8, ref _requiredFeatureString)) + _requiredFeatures = _requiredFeatureString.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); if (!table) return; @@ -71,6 +78,12 @@ public class PluginStateIpcTester : IUiService, IDisposable IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change"); ImGui.TextUnformatted(_lastEnabledValue is { } v ? $"{_lastEnabledChange} (to {v})" : "Never"); + IpcTester.DrawIntro(SupportedFeatures.Label, "Supported Features"); + ImUtf8.Text(string.Join(", ", new SupportedFeatures(_pi).Invoke())); + + IpcTester.DrawIntro(CheckSupportedFeatures.Label, "Missing Features"); + ImUtf8.Text(string.Join(", ", new CheckSupportedFeatures(_pi).Invoke(_requiredFeatures))); + DrawConfigPopup(); IpcTester.DrawIntro(GetConfiguration.Label, "Configuration"); if (ImGui.Button("Get")) diff --git a/Penumbra/Api/IpcTester/RedrawingIpcTester.cs b/Penumbra/Api/IpcTester/RedrawingIpcTester.cs index b862dde5..6b853ed2 100644 --- a/Penumbra/Api/IpcTester/RedrawingIpcTester.cs +++ b/Penumbra/Api/IpcTester/RedrawingIpcTester.cs @@ -1,6 +1,6 @@ using Dalamud.Plugin; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Api.Enums; diff --git a/Penumbra/Api/IpcTester/ResolveIpcTester.cs b/Penumbra/Api/IpcTester/ResolveIpcTester.cs index a79b099d..9fc5bfc7 100644 --- a/Penumbra/Api/IpcTester/ResolveIpcTester.cs +++ b/Penumbra/Api/IpcTester/ResolveIpcTester.cs @@ -1,5 +1,5 @@ using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Api.IpcSubscribers; diff --git a/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs b/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs index 088a77bd..e6c8d52e 100644 --- a/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs +++ b/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs @@ -1,9 +1,10 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Plugin; -using ImGuiNET; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Api.Enums; diff --git a/Penumbra/Api/IpcTester/TemporaryIpcTester.cs b/Penumbra/Api/IpcTester/TemporaryIpcTester.cs index f6d1c9eb..d46c5728 100644 --- a/Penumbra/Api/IpcTester/TemporaryIpcTester.cs +++ b/Penumbra/Api/IpcTester/TemporaryIpcTester.cs @@ -1,7 +1,8 @@ using Dalamud.Interface; using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; @@ -33,9 +34,11 @@ public class TemporaryIpcTester( private string _tempCollectionName = string.Empty; private string _tempCollectionGuidName = string.Empty; private string _tempModName = string.Empty; + private string _modDirectory = string.Empty; private string _tempGamePath = "test/game/path.mtrl"; private string _tempFilePath = "test/success.mtrl"; private string _tempManipulation = string.Empty; + private string _identity = string.Empty; private PenumbraApiEc _lastTempError; private int _tempActorIndex; private bool _forceOverwrite; @@ -46,10 +49,12 @@ public class TemporaryIpcTester( if (!_) return; + ImGui.InputTextWithHint("##identity", "Identity...", ref _identity, 128); ImGui.InputTextWithHint("##tempCollection", "Collection Name...", ref _tempCollectionName, 128); ImGuiUtil.GuidInput("##guid", "Collection GUID...", string.Empty, ref _tempGuid, ref _tempCollectionGuidName); ImGui.InputInt("##tempActorIndex", ref _tempActorIndex, 0, 0); ImGui.InputTextWithHint("##tempMod", "Temporary Mod Name...", ref _tempModName, 32); + ImGui.InputTextWithHint("##mod", "Existing Mod Name...", ref _modDirectory, 256); ImGui.InputTextWithHint("##tempGame", "Game Path...", ref _tempGamePath, 256); ImGui.InputTextWithHint("##tempFile", "File Path...", ref _tempFilePath, 256); ImUtf8.InputText("##tempManip"u8, ref _tempManipulation, "Manipulation Base64 String..."u8); @@ -70,7 +75,7 @@ public class TemporaryIpcTester( IpcTester.DrawIntro(CreateTemporaryCollection.Label, "Create Temporary Collection"); if (ImGui.Button("Create##Collection")) { - LastCreatedCollectionId = new CreateTemporaryCollection(pi).Invoke(_tempCollectionName); + _lastTempError = new CreateTemporaryCollection(pi).Invoke(_identity, _tempCollectionName, out LastCreatedCollectionId); if (_tempGuid == null) { _tempGuid = LastCreatedCollectionId; @@ -121,6 +126,115 @@ public class TemporaryIpcTester( IpcTester.DrawIntro(RemoveTemporaryModAll.Label, "Remove Temporary Mod from all Collections"); if (ImGui.Button("Remove##ModAll")) _lastTempError = new RemoveTemporaryModAll(pi).Invoke(_tempModName, int.MaxValue); + + IpcTester.DrawIntro(SetTemporaryModSettings.Label, "Set Temporary Mod Settings (to default) in specific Collection"); + if (ImUtf8.Button("Set##SetTemporary"u8)) + _lastTempError = new SetTemporaryModSettings(pi).Invoke(guid, _modDirectory, false, true, 1337, + new Dictionary>(), + "IPC Tester", 1337); + + IpcTester.DrawIntro(SetTemporaryModSettingsPlayer.Label, "Set Temporary Mod Settings (to default) in game object collection"); + if (ImUtf8.Button("Set##SetTemporaryPlayer"u8)) + _lastTempError = new SetTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, false, true, 1337, + new Dictionary>(), + "IPC Tester", 1337); + + IpcTester.DrawIntro(RemoveTemporaryModSettings.Label, "Remove Temporary Mod Settings from specific Collection"); + if (ImUtf8.Button("Remove##RemoveTemporary"u8)) + _lastTempError = new RemoveTemporaryModSettings(pi).Invoke(guid, _modDirectory, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveTemporary"u8)) + _lastTempError = new RemoveTemporaryModSettings(pi).Invoke(guid, _modDirectory, 1338); + + IpcTester.DrawIntro(RemoveTemporaryModSettingsPlayer.Label, "Remove Temporary Mod Settings from game object Collection"); + if (ImUtf8.Button("Remove##RemoveTemporaryPlayer"u8)) + _lastTempError = new RemoveTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveTemporaryPlayer"u8)) + _lastTempError = new RemoveTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, 1338); + + IpcTester.DrawIntro(RemoveAllTemporaryModSettings.Label, "Remove All Temporary Mod Settings from specific Collection"); + if (ImUtf8.Button("Remove##RemoveAllTemporary"u8)) + _lastTempError = new RemoveAllTemporaryModSettings(pi).Invoke(guid, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveAllTemporary"u8)) + _lastTempError = new RemoveAllTemporaryModSettings(pi).Invoke(guid, 1338); + + IpcTester.DrawIntro(RemoveAllTemporaryModSettingsPlayer.Label, "Remove All Temporary Mod Settings from game object Collection"); + if (ImUtf8.Button("Remove##RemoveAllTemporaryPlayer"u8)) + _lastTempError = new RemoveAllTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveAllTemporaryPlayer"u8)) + _lastTempError = new RemoveAllTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, 1338); + + IpcTester.DrawIntro(QueryTemporaryModSettings.Label, "Query Temporary Mod Settings from specific Collection"); + ImUtf8.Button("Query##QueryTemporaryModSettings"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = new QueryTemporaryModSettings(pi).Invoke(guid, _modDirectory, out var settings, out var source, 1337); + DrawTooltip(settings, source); + } + + ImGui.SameLine(); + ImUtf8.Button("Query (Wrong Key)##RemoveAllTemporary"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = new QueryTemporaryModSettings(pi).Invoke(guid, _modDirectory, out var settings, out var source, 1338); + DrawTooltip(settings, source); + } + + IpcTester.DrawIntro(QueryTemporaryModSettingsPlayer.Label, "Query Temporary Mod Settings from game object Collection"); + ImUtf8.Button("Query##QueryTemporaryModSettingsPlayer"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = + new QueryTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, out var settings, out var source, 1337); + DrawTooltip(settings, source); + } + + ImGui.SameLine(); + ImUtf8.Button("Query (Wrong Key)##RemoveAllTemporaryPlayer"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = + new QueryTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, out var settings, out var source, 1338); + DrawTooltip(settings, source); + } + + void DrawTooltip((bool ForceInherit, bool Enabled, int Priority, Dictionary> Settings)? settings, string source) + { + using var tt = ImUtf8.Tooltip(); + ImUtf8.Text($"Query returned {_lastTempError}"); + if (settings != null) + ImUtf8.Text($"Settings created by {(source.Length == 0 ? "Unknown Source" : source)}:"); + else + ImUtf8.Text(source.Length > 0 ? $"Locked by {source}." : "No settings exist."); + ImGui.Separator(); + if (settings == null) + { + + return; + } + + using (ImUtf8.Group()) + { + ImUtf8.Text("Force Inherit"u8); + ImUtf8.Text("Enabled"u8); + ImUtf8.Text("Priority"u8); + foreach (var group in settings.Value.Settings.Keys) + ImUtf8.Text(group); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text($"{settings.Value.ForceInherit}"); + ImUtf8.Text($"{settings.Value.Enabled}"); + ImUtf8.Text($"{settings.Value.Priority}"); + foreach (var group in settings.Value.Settings.Values) + ImUtf8.Text(string.Join("; ", group)); + } + } } public void DrawCollections() @@ -146,10 +260,10 @@ public class TemporaryIpcTester( using (ImRaii.PushFont(UiBuilder.MonoFont)) { ImGui.TableNextColumn(); - ImGuiUtil.CopyOnClickSelectable(collection.Identifier); + ImGuiUtil.CopyOnClickSelectable(collection.Identity.Identifier); } - ImGuiUtil.DrawTableColumn(collection.Name); + ImGuiUtil.DrawTableColumn(collection.Identity.Name); ImGuiUtil.DrawTableColumn(collection.ResolvedFiles.Count.ToString()); ImGuiUtil.DrawTableColumn(collection.MetaCache?.Count.ToString() ?? "0"); ImGuiUtil.DrawTableColumn(string.Join(", ", @@ -170,7 +284,7 @@ public class TemporaryIpcTester( foreach (var mod in list) { ImGui.TableNextColumn(); - ImGui.TextUnformatted(mod.Name); + ImGui.TextUnformatted(mod.Name.Text); ImGui.TableNextColumn(); ImGui.TextUnformatted(mod.Priority.ToString()); ImGui.TableNextColumn(); @@ -199,7 +313,7 @@ public class TemporaryIpcTester( { PrintList("All", tempMods.ModsForAllCollections); foreach (var (collection, list) in tempMods.Mods) - PrintList(collection.Name, list); + PrintList(collection.Identity.Name, list); } } } diff --git a/Penumbra/Api/IpcTester/UiIpcTester.cs b/Penumbra/Api/IpcTester/UiIpcTester.cs index 647a4dda..852339c9 100644 --- a/Penumbra/Api/IpcTester/UiIpcTester.cs +++ b/Penumbra/Api/IpcTester/UiIpcTester.cs @@ -1,5 +1,5 @@ using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Api.Enums; diff --git a/Penumbra/Api/ModChangedItemAdapter.cs b/Penumbra/Api/ModChangedItemAdapter.cs new file mode 100644 index 00000000..8d2d473c --- /dev/null +++ b/Penumbra/Api/ModChangedItemAdapter.cs @@ -0,0 +1,103 @@ +using Penumbra.GameData.Data; +using Penumbra.Mods.Manager; + +namespace Penumbra.Api; + +public sealed class ModChangedItemAdapter(WeakReference storage) + : IReadOnlyDictionary>, + IReadOnlyList<(string ModDirectory, IReadOnlyDictionary ChangedItems)> +{ + IEnumerator<(string ModDirectory, IReadOnlyDictionary ChangedItems)> + IEnumerable<(string ModDirectory, IReadOnlyDictionary ChangedItems)>.GetEnumerator() + => Storage.Select(m => (m.Identifier, (IReadOnlyDictionary)new ChangedItemDictionaryAdapter(m.ChangedItems))) + .GetEnumerator(); + + public IEnumerator>> GetEnumerator() + => Storage.Select(m => new KeyValuePair>(m.Identifier, + new ChangedItemDictionaryAdapter(m.ChangedItems))) + .GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => Storage.Count; + + public bool ContainsKey(string key) + => Storage.TryGetMod(key, string.Empty, out _); + + public bool TryGetValue(string key, [NotNullWhen(true)] out IReadOnlyDictionary? value) + { + if (Storage.TryGetMod(key, string.Empty, out var mod)) + { + value = new ChangedItemDictionaryAdapter(mod.ChangedItems); + return true; + } + + value = null; + return false; + } + + public IReadOnlyDictionary this[string key] + => TryGetValue(key, out var v) ? v : throw new KeyNotFoundException(); + + (string ModDirectory, IReadOnlyDictionary ChangedItems) + IReadOnlyList<(string ModDirectory, IReadOnlyDictionary ChangedItems)>.this[int index] + { + get + { + var m = Storage[index]; + return (m.Identifier, new ChangedItemDictionaryAdapter(m.ChangedItems)); + } + } + + public IEnumerable Keys + => Storage.Select(m => m.Identifier); + + public IEnumerable> Values + => Storage.Select(m => new ChangedItemDictionaryAdapter(m.ChangedItems)); + + private ModStorage Storage + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => storage.TryGetTarget(out var t) + ? t + : throw new ObjectDisposedException("The underlying mod storage of this IPC container was disposed."); + } + + private sealed class ChangedItemDictionaryAdapter(SortedList data) : IReadOnlyDictionary + { + public IEnumerator> GetEnumerator() + => data.Select(d => new KeyValuePair(d.Key, d.Value?.ToInternalObject())).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => data.Count; + + public bool ContainsKey(string key) + => data.ContainsKey(key); + + public bool TryGetValue(string key, out object? value) + { + if (data.TryGetValue(key, out var v)) + { + value = v?.ToInternalObject(); + return true; + } + + value = null; + return false; + } + + public object? this[string key] + => data[key]?.ToInternalObject(); + + public IEnumerable Keys + => data.Keys; + + public IEnumerable Values + => data.Values.Select(v => v?.ToInternalObject()); + } +} diff --git a/Penumbra/Api/TempModManager.cs b/Penumbra/Api/TempModManager.cs index 0b52e64a..b3c6066a 100644 --- a/Penumbra/Api/TempModManager.cs +++ b/Penumbra/Api/TempModManager.cs @@ -85,13 +85,13 @@ public class TempModManager : IDisposable, IService { if (removed) { - Penumbra.Log.Verbose($"Removing temporary Mod {mod.Name} from {collection.AnonymizedName}."); + Penumbra.Log.Verbose($"Removing temporary Mod {mod.Name} from {collection.Identity.AnonymizedName}."); collection.Remove(mod); _communicator.ModSettingChanged.Invoke(collection, ModSettingChange.TemporaryMod, null, Setting.False, 0, false); } else { - Penumbra.Log.Verbose($"Adding {(created ? "new " : string.Empty)}temporary Mod {mod.Name} to {collection.AnonymizedName}."); + Penumbra.Log.Verbose($"Adding {(created ? "new " : string.Empty)}temporary Mod {mod.Name} to {collection.Identity.AnonymizedName}."); collection.Apply(mod, created); _communicator.ModSettingChanged.Invoke(collection, ModSettingChange.TemporaryMod, null, Setting.True, 0, false); } diff --git a/Penumbra/ChangedItemMode.cs b/Penumbra/ChangedItemMode.cs new file mode 100644 index 00000000..ddb79ee0 --- /dev/null +++ b/Penumbra/ChangedItemMode.cs @@ -0,0 +1,57 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Text; + +namespace Penumbra; + +public enum ChangedItemMode +{ + GroupedCollapsed, + GroupedExpanded, + Alphabetical, +} + +public static class ChangedItemModeExtensions +{ + public static ReadOnlySpan ToName(this ChangedItemMode mode) + => mode switch + { + ChangedItemMode.GroupedCollapsed => "Grouped (Collapsed)"u8, + ChangedItemMode.GroupedExpanded => "Grouped (Expanded)"u8, + ChangedItemMode.Alphabetical => "Alphabetical"u8, + _ => "Error"u8, + }; + + public static ReadOnlySpan ToTooltip(this ChangedItemMode mode) + => mode switch + { + ChangedItemMode.GroupedCollapsed => + "Display items as groups by their model and slot. Collapse those groups to a single item by default. Prefers items with more changes affecting them or configured items as the main item."u8, + ChangedItemMode.GroupedExpanded => + "Display items as groups by their model and slot. Expand those groups showing all items by default. Prefers items with more changes affecting them or configured items as the main item."u8, + ChangedItemMode.Alphabetical => "Display all changed items in a single list sorted alphabetically."u8, + _ => ""u8, + }; + + public static bool DrawCombo(ReadOnlySpan label, ChangedItemMode value, float width, Action setter) + { + ImGui.SetNextItemWidth(width); + using var combo = ImUtf8.Combo(label, value.ToName()); + if (!combo) + return false; + + var ret = false; + foreach (var newValue in Enum.GetValues()) + { + var selected = ImUtf8.Selectable(newValue.ToName(), newValue == value); + if (selected) + { + ret = true; + setter(newValue); + } + + ImUtf8.HoverTooltip(newValue.ToTooltip()); + } + + return ret; + } +} diff --git a/Penumbra/Collections/Cache/AtchCache.cs b/Penumbra/Collections/Cache/AtchCache.cs index 9e0f6caf..10990553 100644 --- a/Penumbra/Collections/Cache/AtchCache.cs +++ b/Penumbra/Collections/Cache/AtchCache.cs @@ -2,7 +2,6 @@ using Penumbra.GameData.Enums; using Penumbra.GameData.Files; using Penumbra.GameData.Files.AtchStructs; using Penumbra.Meta; -using Penumbra.Meta.Files; using Penumbra.Meta.Manipulations; namespace Penumbra.Collections.Cache; @@ -37,7 +36,7 @@ public sealed class AtchCache(MetaFileManager manager, ModCollection collection) protected override void ApplyModInternal(AtchIdentifier identifier, AtchEntry entry) { - ++Collection.AtchChangeCounter; + Collection.Counters.IncrementAtch(); ApplyFile(identifier, entry); } @@ -68,7 +67,7 @@ public sealed class AtchCache(MetaFileManager manager, ModCollection collection) protected override void RevertModInternal(AtchIdentifier identifier) { - ++Collection.AtchChangeCounter; + Collection.Counters.IncrementAtch(); if (!_atchFiles.TryGetValue(identifier.GenderRace, out var pair)) return; diff --git a/Penumbra/Collections/Cache/AtrCache.cs b/Penumbra/Collections/Cache/AtrCache.cs new file mode 100644 index 00000000..b017da32 --- /dev/null +++ b/Penumbra/Collections/Cache/AtrCache.cs @@ -0,0 +1,65 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class AtrCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public bool ShouldBeDisabled(in ShapeAttributeString attribute, HumanSlot slot, PrimaryId id, GenderRace genderRace) + => DisabledCount > 0 && _atrData.TryGetValue(attribute, out var value) && value.CheckEntry(slot, id, genderRace) is false; + + public int EnabledCount { get; private set; } + public int DisabledCount { get; private set; } + + + internal IReadOnlyDictionary Data + => _atrData; + + private readonly Dictionary _atrData = []; + + public void Reset() + { + Clear(); + _atrData.Clear(); + DisabledCount = 0; + EnabledCount = 0; + } + + protected override void Dispose(bool _) + => Reset(); + + protected override void ApplyModInternal(AtrIdentifier identifier, AtrEntry entry) + { + if (!_atrData.TryGetValue(identifier.Attribute, out var value)) + { + value = []; + _atrData.Add(identifier.Attribute, value); + } + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, entry.Value, out _)) + { + if (entry.Value) + ++EnabledCount; + else + ++DisabledCount; + } + } + + protected override void RevertModInternal(AtrIdentifier identifier) + { + if (!_atrData.TryGetValue(identifier.Attribute, out var value)) + return; + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, null, out var which)) + { + if (which) + --EnabledCount; + else + --DisabledCount; + if (value.IsEmpty) + _atrData.Remove(identifier.Attribute); + } + } +} diff --git a/Penumbra/Collections/Cache/CollectionCache.cs b/Penumbra/Collections/Cache/CollectionCache.cs index 64cf54ea..8294624b 100644 --- a/Penumbra/Collections/Cache/CollectionCache.cs +++ b/Penumbra/Collections/Cache/CollectionCache.cs @@ -1,4 +1,4 @@ -using OtterGui; +using Dalamud.Interface.ImGuiNotification; using OtterGui.Classes; using Penumbra.Meta.Manipulations; using Penumbra.Mods; @@ -7,6 +7,7 @@ using Penumbra.Mods.Editor; using Penumbra.String.Classes; using Penumbra.Util; using Penumbra.GameData.Data; +using OtterGui.Extensions; namespace Penumbra.Collections.Cache; @@ -22,7 +23,7 @@ public sealed class CollectionCache : IDisposable private readonly CollectionCacheManager _manager; private readonly ModCollection _collection; public readonly CollectionModData ModData = new(); - private readonly SortedList, IIdentifiedObjectData?)> _changedItems = []; + private readonly SortedList, IIdentifiedObjectData)> _changedItems = []; public readonly ConcurrentDictionary ResolvedFiles = new(); public readonly CustomResourceCache CustomResources; public readonly MetaCache Meta; @@ -31,7 +32,7 @@ public sealed class CollectionCache : IDisposable public int Calculating = -1; public string AnonymizedName - => _collection.AnonymizedName; + => _collection.Identity.AnonymizedName; public IEnumerable> AllConflicts => ConflictDict.Values; @@ -42,7 +43,7 @@ public sealed class CollectionCache : IDisposable private int _changedItemsSaveCounter = -1; // Obtain currently changed items. Computes them if they haven't been computed before. - public IReadOnlyDictionary, IIdentifiedObjectData?)> ChangedItems + public IReadOnlyDictionary, IIdentifiedObjectData)> ChangedItems { get { @@ -177,7 +178,7 @@ public sealed class CollectionCache : IDisposable var (paths, manipulations) = ModData.RemoveMod(mod); if (addMetaChanges) - _collection.IncrementCounter(); + _collection.Counters.IncrementChange(); foreach (var path in paths) { @@ -244,13 +245,17 @@ public sealed class CollectionCache : IDisposable AddManipulation(mod, identifier, entry); foreach (var (identifier, entry) in files.Manipulations.Atch) AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Shp) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Atr) + AddManipulation(mod, identifier, entry); foreach (var identifier in files.Manipulations.GlobalEqp) AddManipulation(mod, identifier, null!); } if (addMetaChanges) { - _collection.IncrementCounter(); + _collection.Counters.IncrementChange(); _manager.MetaFileManager.ApplyDefaultFiles(_collection); } } @@ -260,7 +265,7 @@ public sealed class CollectionCache : IDisposable if (mod.Index < 0) return mod.GetData(); - var settings = _collection[mod.Index].Settings; + var settings = _collection.GetActualSettings(mod.Index).Settings; return settings is not { Enabled: true } ? AppliedModData.Empty : mod.GetData(settings); @@ -274,6 +279,24 @@ public sealed class CollectionCache : IDisposable _manager.ResolvedFileChanged.Invoke(collection, type, key, value, old, mod); } + private static bool IsRedirectionSupported(Utf8GamePath path, IMod mod) + { + var ext = path.Extension().AsciiToLower().ToString(); + switch (ext) + { + case ".atch" or ".eqp" or ".eqdp" or ".est" or ".gmp" or ".cmp" or ".imc": + Penumbra.Messager.NotificationMessage( + $"Redirection of {ext} files for {mod.Name} is unsupported. This probably means that the mod is outdated and may not work correctly.\n\nPlease tell the mod creator to use the corresponding meta manipulations instead.", + NotificationType.Warning); + return false; + case ".lvb" or ".lgb" or ".sgb": + Penumbra.Messager.NotificationMessage($"Redirection of {ext} files for {mod.Name} is unsupported as this breaks the game.\n\nThis mod will probably not work correctly.", + NotificationType.Warning); + return false; + default: return true; + } + } + // Add a specific file redirection, handling potential conflicts. // For different mods, higher mod priority takes precedence before option group priority, // which takes precedence before option priority, which takes precedence before ordering. @@ -283,6 +306,9 @@ public sealed class CollectionCache : IDisposable if (!CheckFullPath(path, file)) return; + if (!IsRedirectionSupported(path, mod)) + return; + try { if (ResolvedFiles.TryAdd(path, new ModPath(mod, file))) @@ -342,8 +368,9 @@ public sealed class CollectionCache : IDisposable // Returns if the added mod takes priority before the existing mod. private bool AddConflict(object data, IMod addedMod, IMod existingMod) { - var addedPriority = addedMod.Index >= 0 ? _collection[addedMod.Index].Settings!.Priority : addedMod.Priority; - var existingPriority = existingMod.Index >= 0 ? _collection[existingMod.Index].Settings!.Priority : existingMod.Priority; + var addedPriority = addedMod.Index >= 0 ? _collection.GetActualSettings(addedMod.Index).Settings!.Priority : addedMod.Priority; + var existingPriority = + existingMod.Index >= 0 ? _collection.GetActualSettings(existingMod.Index).Settings!.Priority : existingMod.Priority; if (existingPriority < addedPriority) { @@ -408,17 +435,17 @@ public sealed class CollectionCache : IDisposable // Identify and record all manipulated objects for this entire collection. private void SetChangedItems() { - if (_changedItemsSaveCounter == _collection.ChangeCounter) + if (_changedItemsSaveCounter == _collection.Counters.Change) return; try { - _changedItemsSaveCounter = _collection.ChangeCounter; + _changedItemsSaveCounter = _collection.Counters.Change; _changedItems.Clear(); // Skip IMCs because they would result in far too many false-positive items, // since they are per set instead of per item-slot/item/variant. var identifier = _manager.MetaFileManager.Identifier; - var items = new SortedList(512); + var items = new SortedList(512); void AddItems(IMod mod) { @@ -427,7 +454,8 @@ public sealed class CollectionCache : IDisposable if (!_changedItems.TryGetValue(name, out var data)) _changedItems.Add(name, (new SingleArray(mod), obj)); else if (!data.Item1.Contains(mod)) - _changedItems[name] = (data.Item1.Append(mod), obj is IdentifiedCounter x && data.Item2 is IdentifiedCounter y ? x + y : obj); + _changedItems[name] = (data.Item1.Append(mod), + obj is IdentifiedCounter x && data.Item2 is IdentifiedCounter y ? x + y : obj); else if (obj is IdentifiedCounter x && data.Item2 is IdentifiedCounter y) _changedItems[name] = (data.Item1, x + y); } diff --git a/Penumbra/Collections/Cache/CollectionCacheManager.cs b/Penumbra/Collections/Cache/CollectionCacheManager.cs index a3b6bb83..ec48e608 100644 --- a/Penumbra/Collections/Cache/CollectionCacheManager.cs +++ b/Penumbra/Collections/Cache/CollectionCacheManager.cs @@ -71,7 +71,7 @@ public class CollectionCacheManager : IDisposable, IService _communicator.ModDiscoveryFinished.Subscribe(OnModDiscoveryFinished, ModDiscoveryFinished.Priority.CollectionCacheManager); if (!MetaFileManager.CharacterUtility.Ready) - MetaFileManager.CharacterUtility.LoadingFinished += IncrementCounters; + MetaFileManager.CharacterUtility.LoadingFinished.Subscribe(IncrementCounters, CharacterUtilityFinished.Priority.CollectionCacheManager); } public void Dispose() @@ -83,7 +83,7 @@ public class CollectionCacheManager : IDisposable, IService _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); _communicator.ModSettingChanged.Unsubscribe(OnModSettingChange); _communicator.CollectionInheritanceChanged.Unsubscribe(OnCollectionInheritanceChange); - MetaFileManager.CharacterUtility.LoadingFinished -= IncrementCounters; + MetaFileManager.CharacterUtility.LoadingFinished.Unsubscribe(IncrementCounters); foreach (var collection in _storage) { @@ -114,16 +114,16 @@ public class CollectionCacheManager : IDisposable, IService /// Only creates a new cache, does not update an existing one. public bool CreateCache(ModCollection collection) { - if (collection.Index == ModCollection.Empty.Index) + if (collection.Identity.Index == ModCollection.Empty.Identity.Index) return false; if (collection._cache != null) return false; collection._cache = new CollectionCache(this, collection); - if (collection.Index > 0) + if (collection.Identity.Index > 0) Interlocked.Increment(ref _count); - Penumbra.Log.Verbose($"Created new cache for collection {collection.AnonymizedName}."); + Penumbra.Log.Verbose($"Created new cache for collection {collection.Identity.AnonymizedName}."); return true; } @@ -132,32 +132,32 @@ public class CollectionCacheManager : IDisposable, IService /// Does not create caches. /// public void CalculateEffectiveFileList(ModCollection collection) - => _framework.RegisterImportant(nameof(CalculateEffectiveFileList) + collection.Identifier, + => _framework.RegisterImportant(nameof(CalculateEffectiveFileList) + collection.Identity.Identifier, () => CalculateEffectiveFileListInternal(collection)); private void CalculateEffectiveFileListInternal(ModCollection collection) { // Skip the empty collection. - if (collection.Index == 0) + if (collection.Identity.Index == 0) return; - Penumbra.Log.Debug($"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.AnonymizedName}"); + Penumbra.Log.Debug($"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.Identity.AnonymizedName}"); if (!collection.HasCache) { Penumbra.Log.Error( - $"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.AnonymizedName} failed, no cache exists."); + $"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.Identity.AnonymizedName} failed, no cache exists."); } else if (collection._cache!.Calculating != -1) { Penumbra.Log.Error( - $"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.AnonymizedName} failed, already in calculation on [{collection._cache!.Calculating}]."); + $"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.Identity.AnonymizedName} failed, already in calculation on [{collection._cache!.Calculating}]."); } else { FullRecalculation(collection); Penumbra.Log.Debug( - $"[{Environment.CurrentManagedThreadId}] Recalculation of effective file list for {collection.AnonymizedName} finished."); + $"[{Environment.CurrentManagedThreadId}] Recalculation of effective file list for {collection.Identity.AnonymizedName} finished."); } } @@ -171,8 +171,7 @@ public class CollectionCacheManager : IDisposable, IService try { ResolvedFileChanged.Invoke(collection, ResolvedFileChanged.Type.FullRecomputeStart, Utf8GamePath.Empty, FullPath.Empty, - FullPath.Empty, - null); + FullPath.Empty, null); cache.ResolvedFiles.Clear(); cache.Meta.Reset(); cache.ConflictDict.Clear(); @@ -187,7 +186,7 @@ public class CollectionCacheManager : IDisposable, IService foreach (var mod in _modStorage) cache.AddModSync(mod, false); - collection.IncrementCounter(); + collection.Counters.IncrementChange(); MetaFileManager.ApplyDefaultFiles(collection); ResolvedFileChanged.Invoke(collection, ResolvedFileChanged.Type.FullRecomputeFinished, Utf8GamePath.Empty, FullPath.Empty, @@ -213,7 +212,7 @@ public class CollectionCacheManager : IDisposable, IService else { RemoveCache(old); - if (type is not CollectionType.Inactive && newCollection != null && newCollection.Index != 0 && CreateCache(newCollection)) + if (type is not CollectionType.Inactive && newCollection != null && newCollection.Identity.Index != 0 && CreateCache(newCollection)) CalculateEffectiveFileList(newCollection); if (type is CollectionType.Default) @@ -231,11 +230,11 @@ public class CollectionCacheManager : IDisposable, IService { case ModPathChangeType.Deleted: case ModPathChangeType.StartingReload: - foreach (var collection in _storage.Where(c => c.HasCache && c[mod.Index].Settings?.Enabled == true)) + foreach (var collection in _storage.Where(c => c.HasCache && c.GetActualSettings(mod.Index).Settings?.Enabled == true)) collection._cache!.RemoveMod(mod, true); break; case ModPathChangeType.Moved: - foreach (var collection in _storage.Where(c => c.HasCache && c[mod.Index].Settings?.Enabled == true)) + foreach (var collection in _storage.Where(c => c.HasCache && c.GetActualSettings(mod.Index).Settings?.Enabled == true)) collection._cache!.ReloadMod(mod, true); break; } @@ -246,7 +245,7 @@ public class CollectionCacheManager : IDisposable, IService if (type is not (ModPathChangeType.Added or ModPathChangeType.Reloaded)) return; - foreach (var collection in _storage.Where(c => c.HasCache && c[mod.Index].Settings?.Enabled == true)) + foreach (var collection in _storage.Where(c => c.HasCache && c.GetActualSettings(mod.Index).Settings?.Enabled == true)) collection._cache!.AddMod(mod, true); } @@ -258,12 +257,12 @@ public class CollectionCacheManager : IDisposable, IService private void RemoveCache(ModCollection? collection) { if (collection != null - && collection.Index > ModCollection.Empty.Index - && collection.Index != _active.Default.Index - && collection.Index != _active.Interface.Index - && collection.Index != _active.Current.Index - && _active.SpecialAssignments.All(c => c.Value.Index != collection.Index) - && _active.Individuals.All(c => c.Collection.Index != collection.Index)) + && collection.Identity.Index > ModCollection.Empty.Identity.Index + && collection.Identity.Index != _active.Default.Identity.Index + && collection.Identity.Index != _active.Interface.Identity.Index + && collection.Identity.Index != _active.Current.Identity.Index + && _active.SpecialAssignments.All(c => c.Value.Identity.Index != collection.Identity.Index) + && _active.Individuals.All(c => c.Collection.Identity.Index != collection.Identity.Index)) ClearCache(collection); } @@ -273,7 +272,7 @@ public class CollectionCacheManager : IDisposable, IService { if (type is ModOptionChangeType.PrepareChange) { - foreach (var collection in _storage.Where(collection => collection.HasCache && collection[mod.Index].Settings is { Enabled: true })) + foreach (var collection in _storage.Where(collection => collection.HasCache && collection.GetActualSettings(mod.Index).Settings is { Enabled: true })) collection._cache!.RemoveMod(mod, false); return; @@ -284,7 +283,7 @@ public class CollectionCacheManager : IDisposable, IService if (!recomputeList) return; - foreach (var collection in _storage.Where(collection => collection.HasCache && collection[mod.Index].Settings is { Enabled: true })) + foreach (var collection in _storage.Where(collection => collection.HasCache && collection.GetActualSettings(mod.Index).Settings is { Enabled: true })) { if (justAdd) collection._cache!.AddMod(mod, true); @@ -297,8 +296,8 @@ public class CollectionCacheManager : IDisposable, IService private void IncrementCounters() { foreach (var collection in _storage.Where(c => c.HasCache)) - collection.IncrementCounter(); - MetaFileManager.CharacterUtility.LoadingFinished -= IncrementCounters; + collection.Counters.IncrementChange(); + MetaFileManager.CharacterUtility.LoadingFinished.Unsubscribe(IncrementCounters); } private void OnModSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx, bool _) @@ -317,7 +316,7 @@ public class CollectionCacheManager : IDisposable, IService cache.AddMod(mod!, true); else if (oldValue == Setting.True) cache.RemoveMod(mod!, true); - else if (collection[mod!.Index].Settings?.Enabled == true) + else if (collection.GetActualSettings(mod!.Index).Settings?.Enabled == true) cache.ReloadMod(mod!, true); else cache.RemoveMod(mod!, true); @@ -329,10 +328,13 @@ public class CollectionCacheManager : IDisposable, IService break; case ModSettingChange.Setting: - if (collection[mod!.Index].Settings?.Enabled == true) - cache.ReloadMod(mod!, true); + if (collection.GetActualSettings(mod!.Index).Settings?.Enabled == true) + cache.ReloadMod(mod, true); break; + case ModSettingChange.TemporarySetting: + cache.ReloadMod(mod!, true); + break; case ModSettingChange.MultiInheritance: case ModSettingChange.MultiEnableState: FullRecalculation(collection); @@ -359,9 +361,9 @@ public class CollectionCacheManager : IDisposable, IService collection._cache!.Dispose(); collection._cache = null; - if (collection.Index > 0) + if (collection.Identity.Index > 0) Interlocked.Decrement(ref _count); - Penumbra.Log.Verbose($"Cleared cache of collection {collection.AnonymizedName}."); + Penumbra.Log.Verbose($"Cleared cache of collection {collection.Identity.AnonymizedName}."); } /// diff --git a/Penumbra/Collections/Cache/EqpCache.cs b/Penumbra/Collections/Cache/EqpCache.cs index c681b230..da1a1d44 100644 --- a/Penumbra/Collections/Cache/EqpCache.cs +++ b/Penumbra/Collections/Cache/EqpCache.cs @@ -48,8 +48,8 @@ public sealed class EqpCache(MetaFileManager manager, ModCollection collection) ? entry.HasFlag(EqpEntry.BodyHideGlovesL) : entry.HasFlag(EqpEntry.BodyHideGlovesM); return testFlag - ? (entry | EqpEntry._4) & ~EqpEntry.BodyHideGlovesS - : entry & ~(EqpEntry._4 | EqpEntry.BodyHideGlovesS); + ? (entry | EqpEntry.BodyHideGloveCuffs) & ~EqpEntry.BodyHideGlovesS + : entry & ~(EqpEntry.BodyHideGloveCuffs | EqpEntry.BodyHideGlovesS); } [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] diff --git a/Penumbra/Collections/Cache/GlobalEqpCache.cs b/Penumbra/Collections/Cache/GlobalEqpCache.cs index 60e782b5..7d2fbf64 100644 --- a/Penumbra/Collections/Cache/GlobalEqpCache.cs +++ b/Penumbra/Collections/Cache/GlobalEqpCache.cs @@ -15,6 +15,9 @@ public class GlobalEqpCache : ReadWriteDictionary, private readonly HashSet _doNotHideRingR = []; private bool _doNotHideVieraHats; private bool _doNotHideHrothgarHats; + private bool _hideAuRaHorns; + private bool _hideVieraEars; + private bool _hideMiqoteEars; public new void Clear() { @@ -26,6 +29,9 @@ public class GlobalEqpCache : ReadWriteDictionary, _doNotHideRingR.Clear(); _doNotHideHrothgarHats = false; _doNotHideVieraHats = false; + _hideAuRaHorns = false; + _hideVieraEars = false; + _hideMiqoteEars = false; } public unsafe EqpEntry Apply(EqpEntry original, CharacterArmor* armor) @@ -39,8 +45,20 @@ public class GlobalEqpCache : ReadWriteDictionary, if (_doNotHideHrothgarHats) original |= EqpEntry.HeadShowHrothgarHat; + if (_hideAuRaHorns) + original &= ~EqpEntry.HeadShowEarAuRa; + + if (_hideVieraEars) + original &= ~EqpEntry.HeadShowEarViera; + + if (_hideMiqoteEars) + original &= ~EqpEntry.HeadShowEarMiqote; + if (_doNotHideEarrings.Contains(armor[5].Set)) - original |= EqpEntry.HeadShowEarringsHyurRoe | EqpEntry.HeadShowEarringsLalaElezen | EqpEntry.HeadShowEarringsMiqoHrothViera | EqpEntry.HeadShowEarringsAura; + original |= EqpEntry.HeadShowEarringsHyurRoe + | EqpEntry.HeadShowEarringsLalaElezen + | EqpEntry.HeadShowEarringsMiqoHrothViera + | EqpEntry.HeadShowEarringsAura; if (_doNotHideNecklace.Contains(armor[6].Set)) original |= EqpEntry.BodyShowNecklace | EqpEntry.HeadShowNecklace; @@ -53,6 +71,7 @@ public class GlobalEqpCache : ReadWriteDictionary, if (_doNotHideRingL.Contains(armor[9].Set)) original |= EqpEntry.HandShowRingL; + return original; } @@ -71,6 +90,9 @@ public class GlobalEqpCache : ReadWriteDictionary, GlobalEqpType.DoNotHideRingL => _doNotHideRingL.Add(manipulation.Condition), GlobalEqpType.DoNotHideHrothgarHats => !_doNotHideHrothgarHats && (_doNotHideHrothgarHats = true), GlobalEqpType.DoNotHideVieraHats => !_doNotHideVieraHats && (_doNotHideVieraHats = true), + GlobalEqpType.HideHorns => !_hideAuRaHorns && (_hideAuRaHorns = true), + GlobalEqpType.HideMiqoteEars => !_hideMiqoteEars && (_hideMiqoteEars = true), + GlobalEqpType.HideVieraEars => !_hideVieraEars && (_hideVieraEars = true), _ => false, }; return true; @@ -90,6 +112,9 @@ public class GlobalEqpCache : ReadWriteDictionary, GlobalEqpType.DoNotHideRingL => _doNotHideRingL.Remove(manipulation.Condition), GlobalEqpType.DoNotHideHrothgarHats => _doNotHideHrothgarHats && !(_doNotHideHrothgarHats = false), GlobalEqpType.DoNotHideVieraHats => _doNotHideVieraHats && !(_doNotHideVieraHats = false), + GlobalEqpType.HideHorns => _hideAuRaHorns && (_hideAuRaHorns = false), + GlobalEqpType.HideMiqoteEars => _hideMiqoteEars && (_hideMiqoteEars = false), + GlobalEqpType.HideVieraEars => _hideVieraEars && (_hideVieraEars = false), _ => false, }; return true; diff --git a/Penumbra/Collections/Cache/ImcCache.cs b/Penumbra/Collections/Cache/ImcCache.cs index cac52f99..461ffccc 100644 --- a/Penumbra/Collections/Cache/ImcCache.cs +++ b/Penumbra/Collections/Cache/ImcCache.cs @@ -39,7 +39,7 @@ public sealed class ImcCache(MetaFileManager manager, ModCollection collection) protected override void ApplyModInternal(ImcIdentifier identifier, ImcEntry entry) { - ++Collection.ImcChangeCounter; + Collection.Counters.IncrementImc(); ApplyFile(identifier, entry); } @@ -51,7 +51,6 @@ public sealed class ImcCache(MetaFileManager manager, ModCollection collection) if (!_imcFiles.TryGetValue(path, out var pair)) pair = (new ImcFile(Manager, identifier), []); - if (!Apply(pair.Item1, identifier, entry)) return; @@ -71,7 +70,7 @@ public sealed class ImcCache(MetaFileManager manager, ModCollection collection) protected override void RevertModInternal(ImcIdentifier identifier) { - ++Collection.ImcChangeCounter; + Collection.Counters.IncrementImc(); var path = identifier.GamePath().Path; if (!_imcFiles.TryGetValue(path, out var pair)) return; diff --git a/Penumbra/Collections/Cache/MetaCache.cs b/Penumbra/Collections/Cache/MetaCache.cs index 7d8586c3..011cdd23 100644 --- a/Penumbra/Collections/Cache/MetaCache.cs +++ b/Penumbra/Collections/Cache/MetaCache.cs @@ -16,11 +16,13 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) public readonly RspCache Rsp = new(manager, collection); public readonly ImcCache Imc = new(manager, collection); public readonly AtchCache Atch = new(manager, collection); + public readonly ShpCache Shp = new(manager, collection); + public readonly AtrCache Atr = new(manager, collection); public readonly GlobalEqpCache GlobalEqp = new(); public bool IsDisposed { get; private set; } public int Count - => Eqp.Count + Eqdp.Count + Est.Count + Gmp.Count + Rsp.Count + Imc.Count + Atch.Count + GlobalEqp.Count; + => Eqp.Count + Eqdp.Count + Est.Count + Gmp.Count + Rsp.Count + Imc.Count + Atch.Count + Shp.Count + Atr.Count + GlobalEqp.Count; public IEnumerable<(IMetaIdentifier, IMod)> IdentifierSources => Eqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source)) @@ -30,6 +32,8 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) .Concat(Rsp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) .Concat(Imc.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) .Concat(Atch.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Shp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Atr.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) .Concat(GlobalEqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value))); public void Reset() @@ -41,6 +45,8 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) Rsp.Reset(); Imc.Reset(); Atch.Reset(); + Shp.Reset(); + Atr.Reset(); GlobalEqp.Clear(); } @@ -57,6 +63,8 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) Rsp.Dispose(); Imc.Dispose(); Atch.Dispose(); + Shp.Dispose(); + Atr.Dispose(); } public bool TryGetMod(IMetaIdentifier identifier, [NotNullWhen(true)] out IMod? mod) @@ -71,6 +79,8 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) ImcIdentifier i => Imc.TryGetValue(i, out var p) && Convert(p, out mod), RspIdentifier i => Rsp.TryGetValue(i, out var p) && Convert(p, out mod), AtchIdentifier i => Atch.TryGetValue(i, out var p) && Convert(p, out mod), + ShpIdentifier i => Shp.TryGetValue(i, out var p) && Convert(p, out mod), + AtrIdentifier i => Atr.TryGetValue(i, out var p) && Convert(p, out mod), GlobalEqpManipulation i => GlobalEqp.TryGetValue(i, out mod), _ => false, }; @@ -92,6 +102,8 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) ImcIdentifier i => Imc.RevertMod(i, out mod), RspIdentifier i => Rsp.RevertMod(i, out mod), AtchIdentifier i => Atch.RevertMod(i, out mod), + ShpIdentifier i => Shp.RevertMod(i, out mod), + AtrIdentifier i => Atr.RevertMod(i, out mod), GlobalEqpManipulation i => GlobalEqp.RevertMod(i, out mod), _ => (mod = null) != null, }; @@ -108,6 +120,8 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) ImcIdentifier i when entry is ImcEntry e => Imc.ApplyMod(mod, i, e), RspIdentifier i when entry is RspEntry e => Rsp.ApplyMod(mod, i, e), AtchIdentifier i when entry is AtchEntry e => Atch.ApplyMod(mod, i, e), + ShpIdentifier i when entry is ShpEntry e => Shp.ApplyMod(mod, i, e), + AtrIdentifier i when entry is AtrEntry e => Atr.ApplyMod(mod, i, e), GlobalEqpManipulation i => GlobalEqp.ApplyMod(mod, i), _ => false, }; diff --git a/Penumbra/Collections/Cache/ShapeAttributeHashSet.cs b/Penumbra/Collections/Cache/ShapeAttributeHashSet.cs new file mode 100644 index 00000000..4c61bdd2 --- /dev/null +++ b/Penumbra/Collections/Cache/ShapeAttributeHashSet.cs @@ -0,0 +1,181 @@ +using System.Collections.Frozen; +using OtterGui.Extensions; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; + +namespace Penumbra.Collections.Cache; + +public sealed class ShapeAttributeHashSet : Dictionary<(HumanSlot Slot, PrimaryId Id), ulong> +{ + public static readonly IReadOnlyList GenderRaceValues = + [ + GenderRace.Unknown, GenderRace.MidlanderMale, GenderRace.MidlanderFemale, GenderRace.HighlanderMale, GenderRace.HighlanderFemale, + GenderRace.ElezenMale, GenderRace.ElezenFemale, GenderRace.MiqoteMale, GenderRace.MiqoteFemale, GenderRace.RoegadynMale, + GenderRace.RoegadynFemale, GenderRace.LalafellMale, GenderRace.LalafellFemale, GenderRace.AuRaMale, GenderRace.AuRaFemale, + GenderRace.HrothgarMale, GenderRace.HrothgarFemale, GenderRace.VieraMale, GenderRace.VieraFemale, + ]; + + public static readonly FrozenDictionary GenderRaceIndices = + GenderRaceValues.WithIndex().ToFrozenDictionary(p => p.Value, p => p.Index); + + private readonly BitArray _allIds = new(2 * (ShapeAttributeManager.ModelSlotSize + 1) * GenderRaceValues.Count); + + public bool? this[HumanSlot slot] + => AllCheck(ToIndex(slot, 0)); + + public bool? this[GenderRace genderRace] + => ToIndex(HumanSlot.Unknown, genderRace, out var index) ? AllCheck(index) : null; + + public bool? this[HumanSlot slot, GenderRace genderRace] + => ToIndex(slot, genderRace, out var index) ? AllCheck(index) : null; + + public bool? All + => Convert(_allIds[2 * AllIndex], _allIds[2 * AllIndex + 1]); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool? AllCheck(int idx) + => Convert(_allIds[idx], _allIds[idx + 1]); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static int ToIndex(HumanSlot slot, int genderRaceIndex) + => 2 * (slot is HumanSlot.Unknown ? genderRaceIndex + AllIndex : genderRaceIndex + (int)slot * GenderRaceValues.Count); + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public bool? CheckEntry(HumanSlot slot, PrimaryId id, GenderRace genderRace) + { + if (!GenderRaceIndices.TryGetValue(genderRace, out var index)) + return null; + + // Check for specific ID. + if (TryGetValue((slot, id), out var flags)) + { + // Check completely specified entry. + if (Convert(flags, 2 * index) is { } specified) + return specified; + + // Check any gender / race. + if (Convert(flags, 0) is { } anyGr) + return anyGr; + } + + // Check for specified gender / race and slot, but no ID. + if (AllCheck(ToIndex(slot, index)) is { } noIdButGr) + return noIdButGr; + + // Check for specified gender / race but no slot or ID. + if (AllCheck(ToIndex(HumanSlot.Unknown, index)) is { } noSlotButGr) + return noSlotButGr; + + // Check for specified slot but no gender / race or ID. + if (AllCheck(ToIndex(slot, 0)) is { } noGrButSlot) + return noGrButSlot; + + return All; + } + + public bool TrySet(HumanSlot slot, PrimaryId? id, GenderRace genderRace, bool? value, out bool which) + { + which = false; + if (!GenderRaceIndices.TryGetValue(genderRace, out var index)) + return false; + + if (!id.HasValue) + { + var slotIndex = ToIndex(slot, index); + var ret = false; + if (value is true) + { + if (!_allIds[slotIndex]) + ret = true; + _allIds[slotIndex] = true; + _allIds[slotIndex + 1] = false; + } + else if (value is false) + { + if (!_allIds[slotIndex + 1]) + ret = true; + _allIds[slotIndex] = false; + _allIds[slotIndex + 1] = true; + } + else + { + if (_allIds[slotIndex]) + { + which = true; + ret = true; + } + else if (_allIds[slotIndex + 1]) + { + which = false; + ret = true; + } + + _allIds[slotIndex] = false; + _allIds[slotIndex + 1] = false; + } + + return ret; + } + + if (TryGetValue((slot, id.Value), out var flags)) + { + index *= 2; + var newFlags = value switch + { + true => (flags | (1ul << index)) & ~(1ul << (index + 1)), + false => (flags & ~(1ul << index)) | (1ul << (index + 1)), + _ => flags & ~(1ul << index) & ~(1ul << (index + 1)), + }; + if (newFlags == flags) + return false; + + this[(slot, id.Value)] = newFlags; + which = (flags & (1ul << index)) is not 0; + return true; + } + + if (value is null) + return false; + + this[(slot, id.Value)] = 1ul << (2 * index + (value.Value ? 0 : 1)); + return true; + } + + public new void Clear() + { + base.Clear(); + _allIds.SetAll(false); + } + + public bool IsEmpty + => !_allIds.HasAnySet() && Count is 0; + + private static readonly int AllIndex = ShapeAttributeManager.ModelSlotSize * GenderRaceValues.Count; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool ToIndex(HumanSlot slot, GenderRace genderRace, out int index) + { + if (!GenderRaceIndices.TryGetValue(genderRace, out index)) + return false; + + index = ToIndex(slot, index); + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool? Convert(bool trueValue, bool falseValue) + => trueValue ? true : falseValue ? false : null; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool? Convert(ulong mask, int idx) + { + mask >>= idx; + return (mask & 3) switch + { + 1 => true, + 2 => false, + _ => null, + }; + } +} diff --git a/Penumbra/Collections/Cache/ShpCache.cs b/Penumbra/Collections/Cache/ShpCache.cs new file mode 100644 index 00000000..d8c3a036 --- /dev/null +++ b/Penumbra/Collections/Cache/ShpCache.cs @@ -0,0 +1,106 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class ShpCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public bool ShouldBeEnabled(in ShapeAttributeString shape, HumanSlot slot, PrimaryId id, GenderRace genderRace) + => EnabledCount > 0 && _shpData.TryGetValue(shape, out var value) && value.CheckEntry(slot, id, genderRace) is true; + + public bool ShouldBeDisabled(in ShapeAttributeString shape, HumanSlot slot, PrimaryId id, GenderRace genderRace) + => DisabledCount > 0 && _shpData.TryGetValue(shape, out var value) && value.CheckEntry(slot, id, genderRace) is false; + + internal IReadOnlyDictionary State(ShapeConnectorCondition connector) + => connector switch + { + ShapeConnectorCondition.None => _shpData, + ShapeConnectorCondition.Wrists => _wristConnectors, + ShapeConnectorCondition.Waist => _waistConnectors, + ShapeConnectorCondition.Ankles => _ankleConnectors, + _ => [], + }; + + public int EnabledCount { get; private set; } + public int DisabledCount { get; private set; } + + private readonly Dictionary _shpData = []; + private readonly Dictionary _wristConnectors = []; + private readonly Dictionary _waistConnectors = []; + private readonly Dictionary _ankleConnectors = []; + + public void Reset() + { + Clear(); + _shpData.Clear(); + _wristConnectors.Clear(); + _waistConnectors.Clear(); + _ankleConnectors.Clear(); + EnabledCount = 0; + DisabledCount = 0; + } + + protected override void Dispose(bool _) + => Reset(); + + protected override void ApplyModInternal(ShpIdentifier identifier, ShpEntry entry) + { + switch (identifier.ConnectorCondition) + { + case ShapeConnectorCondition.None: Func(_shpData); break; + case ShapeConnectorCondition.Wrists: Func(_wristConnectors); break; + case ShapeConnectorCondition.Waist: Func(_waistConnectors); break; + case ShapeConnectorCondition.Ankles: Func(_ankleConnectors); break; + } + + return; + + void Func(Dictionary dict) + { + if (!dict.TryGetValue(identifier.Shape, out var value)) + { + value = []; + dict.Add(identifier.Shape, value); + } + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, entry.Value, out _)) + { + if (entry.Value) + ++EnabledCount; + else + ++DisabledCount; + } + } + } + + protected override void RevertModInternal(ShpIdentifier identifier) + { + switch (identifier.ConnectorCondition) + { + case ShapeConnectorCondition.None: Func(_shpData); break; + case ShapeConnectorCondition.Wrists: Func(_wristConnectors); break; + case ShapeConnectorCondition.Waist: Func(_waistConnectors); break; + case ShapeConnectorCondition.Ankles: Func(_ankleConnectors); break; + } + + return; + + void Func(Dictionary dict) + { + if (!dict.TryGetValue(identifier.Shape, out var value)) + return; + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, null, out var which)) + { + if (which) + --EnabledCount; + else + --DisabledCount; + if (value.IsEmpty) + dict.Remove(identifier.Shape); + } + } + } +} diff --git a/Penumbra/Collections/CollectionAutoSelector.cs b/Penumbra/Collections/CollectionAutoSelector.cs new file mode 100644 index 00000000..f6e6bf72 --- /dev/null +++ b/Penumbra/Collections/CollectionAutoSelector.cs @@ -0,0 +1,82 @@ +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Collections; + +public sealed class CollectionAutoSelector : IService, IDisposable +{ + private readonly Configuration _config; + private readonly ActiveCollections _collections; + private readonly IClientState _clientState; + private readonly CollectionResolver _resolver; + private readonly ObjectManager _objects; + + public CollectionAutoSelector(Configuration config, ActiveCollections collections, IClientState clientState, CollectionResolver resolver, + ObjectManager objects) + { + _config = config; + _collections = collections; + _clientState = clientState; + _resolver = resolver; + _objects = objects; + + if (_config.AutoSelectCollection) + Attach(); + } + + public bool Disposed { get; private set; } + + public void SetAutomaticSelection(bool value) + { + _config.AutoSelectCollection = value; + if (value) + Attach(); + else + Detach(); + } + + private void Attach() + { + if (Disposed) + return; + + _clientState.Login += OnLogin; + Select(); + } + + private void OnLogin() + => Select(); + + private void Detach() + => _clientState.Login -= OnLogin; + + private void Select() + { + if (!_objects[0].IsCharacter) + return; + + var collection = _resolver.PlayerCollection(); + if (collection.Identity.Id == Guid.Empty) + { + Penumbra.Log.Debug($"Not setting current collection because character has no mods assigned."); + } + else + { + Penumbra.Log.Debug($"Setting current collection to {collection.Identity.Identifier} through automatic collection selection."); + _collections.SetCollection(collection, CollectionType.Current); + } + } + + + public void Dispose() + { + if (Disposed) + return; + + Disposed = true; + Detach(); + } +} diff --git a/Penumbra/Collections/CollectionCounters.cs b/Penumbra/Collections/CollectionCounters.cs new file mode 100644 index 00000000..6ca0d0a0 --- /dev/null +++ b/Penumbra/Collections/CollectionCounters.cs @@ -0,0 +1,28 @@ +namespace Penumbra.Collections; + +public struct CollectionCounters(int changeCounter) +{ + /// Count the number of changes of the effective file list. + public int Change { get; private set; } = changeCounter; + + /// Count the number of IMC-relevant changes of the effective file list. + public int Imc { get; private set; } + + /// Count the number of ATCH-relevant changes of the effective file list. + public int Atch { get; private set; } + + /// Increment the number of changes in the effective file list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int IncrementChange() + => ++Change; + + /// Increment the number of IMC-relevant changes in the effective file list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int IncrementImc() + => ++Imc; + + /// Increment the number of ATCH-relevant changes in the effective file list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int IncrementAtch() + => ++Atch; +} diff --git a/Penumbra/Collections/Manager/ActiveCollectionMigration.cs b/Penumbra/Collections/Manager/ActiveCollectionMigration.cs index 19f781fc..b4af0998 100644 --- a/Penumbra/Collections/Manager/ActiveCollectionMigration.cs +++ b/Penumbra/Collections/Manager/ActiveCollectionMigration.cs @@ -48,7 +48,7 @@ public static class ActiveCollectionMigration if (!storage.ByName(collectionName, out var collection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of <{player}>'s Collection {collectionName} is not available, reset to {ModCollection.Empty.Name}.", NotificationType.Warning); + $"Last choice of <{player}>'s Collection {collectionName} is not available, reset to {ModCollection.Empty.Identity.Name}.", NotificationType.Warning); dict.Add(player, ModCollection.Empty); } else diff --git a/Penumbra/Collections/Manager/ActiveCollections.cs b/Penumbra/Collections/Manager/ActiveCollections.cs index 60f9a427..ffec7fd2 100644 --- a/Penumbra/Collections/Manager/ActiveCollections.cs +++ b/Penumbra/Collections/Manager/ActiveCollections.cs @@ -1,8 +1,8 @@ using Dalamud.Interface.ImGuiNotification; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Communication; using Penumbra.GameData.Actors; @@ -219,7 +219,7 @@ public class ActiveCollections : ISavable, IDisposable, IService _ => null, }; - if (oldCollection == null || collection == oldCollection || collection.Index >= _storage.Count) + if (oldCollection == null || collection == oldCollection || collection.Identity.Index >= _storage.Count) return; switch (collectionType) @@ -262,13 +262,13 @@ public class ActiveCollections : ISavable, IDisposable, IService var jObj = new JObject { { nameof(Version), Version }, - { nameof(Default), Default.Id }, - { nameof(Interface), Interface.Id }, - { nameof(Current), Current.Id }, + { nameof(Default), Default.Identity.Id }, + { nameof(Interface), Interface.Identity.Id }, + { nameof(Current), Current.Identity.Id }, }; foreach (var (type, collection) in SpecialCollections.WithIndex().Where(p => p.Value != null) .Select(p => ((CollectionType)p.Index, p.Value!))) - jObj.Add(type.ToString(), collection.Id); + jObj.Add(type.ToString(), collection.Identity.Id); jObj.Add(nameof(Individuals), Individuals.ToJObject()); using var j = new JsonTextWriter(writer); @@ -282,7 +282,7 @@ public class ActiveCollections : ISavable, IDisposable, IService .Prepend(Interface) .Prepend(Default) .Concat(Individuals.Assignments.Select(kvp => kvp.Collection)) - .SelectMany(c => c.GetFlattenedInheritance()).Contains(Current); + .SelectMany(c => c.Inheritance.FlatHierarchy).Contains(Current); /// Save if any of the active collections is changed and set new collections to Current. private void OnCollectionChange(CollectionType collectionType, ModCollection? oldCollection, ModCollection? newCollection, string _3) @@ -300,7 +300,7 @@ public class ActiveCollections : ISavable, IDisposable, IService if (oldCollection == Interface) SetCollection(ModCollection.Empty, CollectionType.Interface); if (oldCollection == Current) - SetCollection(Default.Index > ModCollection.Empty.Index ? Default : _storage.DefaultNamed, CollectionType.Current); + SetCollection(Default.Identity.Index > ModCollection.Empty.Identity.Index ? Default : _storage.DefaultNamed, CollectionType.Current); for (var i = 0; i < SpecialCollections.Length; ++i) { @@ -325,11 +325,11 @@ public class ActiveCollections : ISavable, IDisposable, IService { var configChanged = false; // Load the default collection. If the name does not exist take the empty collection. - var defaultName = jObject[nameof(Default)]?.ToObject() ?? ModCollection.Empty.Name; + var defaultName = jObject[nameof(Default)]?.ToObject() ?? ModCollection.Empty.Identity.Name; if (!_storage.ByName(defaultName, out var defaultCollection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of {TutorialService.DefaultCollection} {defaultName} is not available, reset to {ModCollection.Empty.Name}.", + $"Last choice of {TutorialService.DefaultCollection} {defaultName} is not available, reset to {ModCollection.Empty.Identity.Name}.", NotificationType.Warning); Default = ModCollection.Empty; configChanged = true; @@ -340,11 +340,11 @@ public class ActiveCollections : ISavable, IDisposable, IService } // Load the interface collection. If no string is set, use the name of whatever was set as Default. - var interfaceName = jObject[nameof(Interface)]?.ToObject() ?? Default.Name; + var interfaceName = jObject[nameof(Interface)]?.ToObject() ?? Default.Identity.Name; if (!_storage.ByName(interfaceName, out var interfaceCollection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of {TutorialService.InterfaceCollection} {interfaceName} is not available, reset to {ModCollection.Empty.Name}.", + $"Last choice of {TutorialService.InterfaceCollection} {interfaceName} is not available, reset to {ModCollection.Empty.Identity.Name}.", NotificationType.Warning); Interface = ModCollection.Empty; configChanged = true; @@ -355,11 +355,11 @@ public class ActiveCollections : ISavable, IDisposable, IService } // Load the current collection. - var currentName = jObject[nameof(Current)]?.ToObject() ?? Default.Name; + var currentName = jObject[nameof(Current)]?.ToObject() ?? Default.Identity.Name; if (!_storage.ByName(currentName, out var currentCollection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of {TutorialService.SelectedCollection} {currentName} is not available, reset to {ModCollection.DefaultCollectionName}.", + $"Last choice of {TutorialService.SelectedCollection} {currentName} is not available, reset to {ModCollectionIdentity.DefaultCollectionName}.", NotificationType.Warning); Current = _storage.DefaultNamed; configChanged = true; @@ -404,7 +404,7 @@ public class ActiveCollections : ISavable, IDisposable, IService if (!_storage.ById(defaultId, out var defaultCollection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of {TutorialService.DefaultCollection} {defaultId} is not available, reset to {ModCollection.Empty.Name}.", + $"Last choice of {TutorialService.DefaultCollection} {defaultId} is not available, reset to {ModCollection.Empty.Identity.Name}.", NotificationType.Warning); Default = ModCollection.Empty; configChanged = true; @@ -415,11 +415,11 @@ public class ActiveCollections : ISavable, IDisposable, IService } // Load the interface collection. If no string is set, use the name of whatever was set as Default. - var interfaceId = jObject[nameof(Interface)]?.ToObject() ?? Default.Id; + var interfaceId = jObject[nameof(Interface)]?.ToObject() ?? Default.Identity.Id; if (!_storage.ById(interfaceId, out var interfaceCollection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of {TutorialService.InterfaceCollection} {interfaceId} is not available, reset to {ModCollection.Empty.Name}.", + $"Last choice of {TutorialService.InterfaceCollection} {interfaceId} is not available, reset to {ModCollection.Empty.Identity.Name}.", NotificationType.Warning); Interface = ModCollection.Empty; configChanged = true; @@ -430,11 +430,11 @@ public class ActiveCollections : ISavable, IDisposable, IService } // Load the current collection. - var currentId = jObject[nameof(Current)]?.ToObject() ?? _storage.DefaultNamed.Id; + var currentId = jObject[nameof(Current)]?.ToObject() ?? _storage.DefaultNamed.Identity.Id; if (!_storage.ById(currentId, out var currentCollection)) { Penumbra.Messager.NotificationMessage( - $"Last choice of {TutorialService.SelectedCollection} {currentId} is not available, reset to {ModCollection.DefaultCollectionName}.", + $"Last choice of {TutorialService.SelectedCollection} {currentId} is not available, reset to {ModCollectionIdentity.DefaultCollectionName}.", NotificationType.Warning); Current = _storage.DefaultNamed; configChanged = true; @@ -587,7 +587,7 @@ public class ActiveCollections : ISavable, IDisposable, IService case IdentifierType.Player when id.HomeWorld != ushort.MaxValue: { var global = ByType(CollectionType.Individual, _actors.CreatePlayer(id.PlayerName, ushort.MaxValue)); - return global?.Index == checkAssignment.Index + return (global != null ? global.Identity.Index : null) == checkAssignment.Identity.Index ? "Assignment is redundant due to an identical Any-World assignment existing.\nYou can remove it." : string.Empty; } @@ -596,12 +596,12 @@ public class ActiveCollections : ISavable, IDisposable, IService { var global = ByType(CollectionType.Individual, _actors.CreateOwned(id.PlayerName, ushort.MaxValue, id.Kind, id.DataId)); - if (global?.Index == checkAssignment.Index) + if ((global != null ? global.Identity.Index : null) == checkAssignment.Identity.Index) return "Assignment is redundant due to an identical Any-World assignment existing.\nYou can remove it."; } var unowned = ByType(CollectionType.Individual, _actors.CreateNpc(id.Kind, id.DataId)); - return unowned?.Index == checkAssignment.Index + return (unowned != null ? unowned.Identity.Index : null) == checkAssignment.Identity.Index ? "Assignment is redundant due to an identical unowned NPC assignment existing.\nYou can remove it." : string.Empty; } @@ -617,7 +617,7 @@ public class ActiveCollections : ISavable, IDisposable, IService if (maleNpc == null) { maleNpc = Default; - if (maleNpc.Index != checkAssignment.Index) + if (maleNpc.Identity.Index != checkAssignment.Identity.Index) return string.Empty; collection1 = CollectionType.Default; @@ -626,7 +626,7 @@ public class ActiveCollections : ISavable, IDisposable, IService if (femaleNpc == null) { femaleNpc = Default; - if (femaleNpc.Index != checkAssignment.Index) + if (femaleNpc.Identity.Index != checkAssignment.Identity.Index) return string.Empty; collection2 = CollectionType.Default; @@ -646,7 +646,7 @@ public class ActiveCollections : ISavable, IDisposable, IService if (assignment == null) continue; - if (assignment.Index == checkAssignment.Index) + if (assignment.Identity.Index == checkAssignment.Identity.Index) return $"Assignment is currently redundant due to overwriting {parentType.ToName()} with an identical collection.\nYou can remove it."; } diff --git a/Penumbra/Collections/Manager/CollectionEditor.cs b/Penumbra/Collections/Manager/CollectionEditor.cs index caff2c86..f62eea3f 100644 --- a/Penumbra/Collections/Manager/CollectionEditor.cs +++ b/Penumbra/Collections/Manager/CollectionEditor.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Api.Enums; using Penumbra.Mods; @@ -26,12 +26,12 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu /// public bool SetModState(ModCollection collection, Mod mod, bool newValue) { - var oldValue = collection.Settings[mod.Index]?.Enabled ?? collection[mod.Index].Settings?.Enabled ?? false; + var oldValue = collection.GetInheritedSettings(mod.Index).Settings?.Enabled ?? false; if (newValue == oldValue) return false; var inheritance = FixInheritance(collection, mod, false); - ((List)collection.Settings)[mod.Index]!.Enabled = newValue; + collection.GetOwnSettings(mod.Index)!.Enabled = newValue; InvokeChange(collection, ModSettingChange.EnableState, mod, inheritance ? Setting.Indefinite : newValue ? Setting.False : Setting.True, 0); return true; @@ -55,13 +55,13 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu var changes = false; foreach (var mod in mods) { - var oldValue = collection.Settings[mod.Index]?.Enabled; + var oldValue = collection.GetOwnSettings(mod.Index)?.Enabled; if (newValue == oldValue) continue; FixInheritance(collection, mod, false); - ((List)collection.Settings)[mod.Index]!.Enabled = newValue; - changes = true; + collection.GetOwnSettings(mod.Index)!.Enabled = newValue; + changes = true; } if (!changes) @@ -76,35 +76,64 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu /// public bool SetModPriority(ModCollection collection, Mod mod, ModPriority newValue) { - var oldValue = collection.Settings[mod.Index]?.Priority ?? collection[mod.Index].Settings?.Priority ?? ModPriority.Default; + var oldValue = collection.GetInheritedSettings(mod.Index).Settings?.Priority ?? ModPriority.Default; if (newValue == oldValue) return false; var inheritance = FixInheritance(collection, mod, false); - ((List)collection.Settings)[mod.Index]!.Priority = newValue; + collection.GetOwnSettings(mod.Index)!.Priority = newValue; InvokeChange(collection, ModSettingChange.Priority, mod, inheritance ? Setting.Indefinite : oldValue.AsSetting, 0); return true; } /// /// Set a given setting group settingName of mod idx to newValue if it differs from the current value and fix it if necessary. - /// /// If the mod is currently inherited, stop the inheritance. + /// If the mod is currently inherited, stop the inheritance. /// public bool SetModSetting(ModCollection collection, Mod mod, int groupIdx, Setting newValue) { - var settings = collection.Settings[mod.Index] != null - ? collection.Settings[mod.Index]!.Settings - : collection[mod.Index].Settings?.Settings; + var settings = collection.GetInheritedSettings(mod.Index).Settings?.Settings; var oldValue = settings?[groupIdx] ?? mod.Groups[groupIdx].DefaultSettings; if (oldValue == newValue) return false; var inheritance = FixInheritance(collection, mod, false); - ((List)collection.Settings)[mod.Index]!.SetValue(mod, groupIdx, newValue); + collection.GetOwnSettings(mod.Index)!.SetValue(mod, groupIdx, newValue); InvokeChange(collection, ModSettingChange.Setting, mod, inheritance ? Setting.Indefinite : oldValue, groupIdx); return true; } + public bool SetTemporarySettings(ModCollection collection, Mod mod, TemporaryModSettings? settings, int key = 0) + { + key = settings?.Lock ?? key; + if (!CanSetTemporarySettings(collection, mod, key)) + return false; + + collection.Settings.SetTemporary(mod.Index, settings); + InvokeChange(collection, ModSettingChange.TemporarySetting, mod, Setting.Indefinite, 0); + return true; + } + + public int ClearTemporarySettings(ModCollection collection, int key = 0) + { + var numRemoved = 0; + for (var i = 0; i < collection.Settings.Count; ++i) + { + if (collection.GetTempSettings(i) is { } tempSettings + && tempSettings.Lock == key + && SetTemporarySettings(collection, modStorage[i], null, key)) + ++numRemoved; + } + + return numRemoved; + } + + public bool CanSetTemporarySettings(ModCollection collection, Mod mod, int key) + { + var old = collection.GetTempSettings(mod.Index); + return old is not { Lock: > 0 } || old.Lock == key; + } + /// Copy the settings of an existing (sourceMod != null) or stored (sourceName) mod to another mod, if they exist. public bool CopyModSettings(ModCollection collection, Mod? sourceMod, string sourceName, Mod? targetMod, string targetName) { @@ -115,10 +144,10 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu // If it does not exist, check unused settings. // If it does not exist and has no unused settings, also use null. ModSettings.SavedSettings? savedSettings = sourceMod != null - ? collection.Settings[sourceMod.Index] != null - ? new ModSettings.SavedSettings(collection.Settings[sourceMod.Index]!, sourceMod) + ? collection.GetOwnSettings(sourceMod.Index) is { } ownSettings + ? new ModSettings.SavedSettings(ownSettings, sourceMod) : null - : collection.UnusedSettings.TryGetValue(sourceName, out var s) + : collection.Settings.Unused.TryGetValue(sourceName, out var s) ? s : null; @@ -148,10 +177,10 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu // or remove any unused settings for the target if they are inheriting. if (savedSettings != null) { - ((Dictionary)collection.UnusedSettings)[targetName] = savedSettings.Value; + ((Dictionary)collection.Settings.Unused)[targetName] = savedSettings.Value; saveService.QueueSave(new ModCollectionSave(modStorage, collection)); } - else if (((Dictionary)collection.UnusedSettings).Remove(targetName)) + else if (((Dictionary)collection.Settings.Unused).Remove(targetName)) { saveService.QueueSave(new ModCollectionSave(modStorage, collection)); } @@ -166,12 +195,12 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu /// private static bool FixInheritance(ModCollection collection, Mod mod, bool inherit) { - var settings = collection.Settings[mod.Index]; + var settings = collection.GetOwnSettings(mod.Index); if (inherit == (settings == null)) return false; - ((List)collection.Settings)[mod.Index] = - inherit ? null : collection[mod.Index].Settings?.DeepCopy() ?? ModSettings.DefaultSettings(mod); + var settings1 = inherit ? null : collection.GetInheritedSettings(mod.Index).Settings?.DeepCopy() ?? ModSettings.DefaultSettings(mod); + collection.Settings.Set(mod.Index, settings1); return true; } @@ -179,16 +208,18 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] private void InvokeChange(ModCollection changedCollection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx) { - saveService.QueueSave(new ModCollectionSave(modStorage, changedCollection)); + if (type is not ModSettingChange.TemporarySetting) + saveService.QueueSave(new ModCollectionSave(modStorage, changedCollection)); communicator.ModSettingChanged.Invoke(changedCollection, type, mod, oldValue, groupIdx, false); - RecurseInheritors(changedCollection, type, mod, oldValue, groupIdx); + if (type is not ModSettingChange.TemporarySetting) + RecurseInheritors(changedCollection, type, mod, oldValue, groupIdx); } /// Trigger changes in all inherited collections. [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] private void RecurseInheritors(ModCollection directParent, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx) { - foreach (var directInheritor in directParent.DirectParentOf) + foreach (var directInheritor in directParent.Inheritance.DirectlyInheritedBy) { switch (type) { @@ -197,7 +228,7 @@ public class CollectionEditor(SaveService saveService, CommunicatorService commu communicator.ModSettingChanged.Invoke(directInheritor, type, null, oldValue, groupIdx, true); break; default: - if (directInheritor.Settings[mod!.Index] == null) + if (directInheritor.GetOwnSettings(mod!.Index) == null) communicator.ModSettingChanged.Invoke(directInheritor, type, mod, oldValue, groupIdx, true); break; } diff --git a/Penumbra/Collections/Manager/CollectionStorage.cs b/Penumbra/Collections/Manager/CollectionStorage.cs index a326fb92..531b6333 100644 --- a/Penumbra/Collections/Manager/CollectionStorage.cs +++ b/Penumbra/Collections/Manager/CollectionStorage.cs @@ -1,6 +1,6 @@ using Dalamud.Interface.ImGuiNotification; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Communication; using Penumbra.Mods; @@ -41,8 +41,8 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer public ModCollection CreateFromData(Guid id, string name, int version, Dictionary allSettings, IReadOnlyList inheritances) { - var newCollection = ModCollection.CreateFromData(_saveService, _modStorage, id, name, CurrentCollectionId, version, Count, allSettings, - inheritances); + var newCollection = ModCollection.CreateFromData(_saveService, _modStorage, + new ModCollectionIdentity(id, CurrentCollectionId, name, Count), version, allSettings, inheritances); _collectionsByLocal[CurrentCollectionId] = newCollection; CurrentCollectionId += 1; return newCollection; @@ -57,7 +57,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer } public void Delete(ModCollection collection) - => _collectionsByLocal.Remove(collection.LocalId); + => _collectionsByLocal.Remove(collection.Identity.LocalId); /// The empty collection is always available at Index 0. private readonly List _collections = @@ -92,7 +92,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer public bool ByName(string name, [NotNullWhen(true)] out ModCollection? collection) { if (name.Length != 0) - return _collections.FindFirst(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase), out collection); + return _collections.FindFirst(c => string.Equals(c.Identity.Name, name, StringComparison.OrdinalIgnoreCase), out collection); collection = ModCollection.Empty; return true; @@ -102,7 +102,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer public bool ById(Guid id, [NotNullWhen(true)] out ModCollection? collection) { if (id != Guid.Empty) - return _collections.FindFirst(c => c.Id == id, out collection); + return _collections.FindFirst(c => c.Identity.Id == id, out collection); collection = ModCollection.Empty; return true; @@ -158,7 +158,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer var newCollection = Create(name, _collections.Count, duplicate); _collections.Add(newCollection); _saveService.ImmediateSave(new ModCollectionSave(_modStorage, newCollection)); - Penumbra.Messager.NotificationMessage($"Created new collection {newCollection.AnonymizedName}.", NotificationType.Success, false); + Penumbra.Messager.NotificationMessage($"Created new collection {newCollection.Identity.AnonymizedName}.", NotificationType.Success, false); _communicator.CollectionChange.Invoke(CollectionType.Inactive, null, newCollection, string.Empty); return true; } @@ -168,13 +168,13 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer /// public bool RemoveCollection(ModCollection collection) { - if (collection.Index <= ModCollection.Empty.Index || collection.Index >= _collections.Count) + if (collection.Identity.Index <= ModCollection.Empty.Identity.Index || collection.Identity.Index >= _collections.Count) { Penumbra.Messager.NotificationMessage("Can not remove the empty collection.", NotificationType.Error, false); return false; } - if (collection.Index == DefaultNamed.Index) + if (collection.Identity.Index == DefaultNamed.Identity.Index) { Penumbra.Messager.NotificationMessage("Can not remove the default collection.", NotificationType.Error, false); return false; @@ -182,30 +182,34 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer Delete(collection); _saveService.ImmediateDelete(new ModCollectionSave(_modStorage, collection)); - _collections.RemoveAt(collection.Index); + _collections.RemoveAt(collection.Identity.Index); // Update indices. - for (var i = collection.Index; i < Count; ++i) - _collections[i].Index = i; - _collectionsByLocal.Remove(collection.LocalId); + for (var i = collection.Identity.Index; i < Count; ++i) + _collections[i].Identity.Index = i; + _collectionsByLocal.Remove(collection.Identity.LocalId); - Penumbra.Messager.NotificationMessage($"Deleted collection {collection.AnonymizedName}.", NotificationType.Success, false); + Penumbra.Messager.NotificationMessage($"Deleted collection {collection.Identity.AnonymizedName}.", NotificationType.Success, false); _communicator.CollectionChange.Invoke(CollectionType.Inactive, collection, null, string.Empty); return true; } /// Remove all settings for not currently-installed mods from the given collection. - public void CleanUnavailableSettings(ModCollection collection) + public int CleanUnavailableSettings(ModCollection collection) { - var any = collection.UnusedSettings.Count > 0; - ((Dictionary)collection.UnusedSettings).Clear(); - if (any) + var count = collection.Settings.Unused.Count; + if (count > 0) + { + ((Dictionary)collection.Settings.Unused).Clear(); _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + } + + return count; } /// Remove a specific setting for not currently-installed mods from the given collection. public void CleanUnavailableSetting(ModCollection collection, string? setting) { - if (setting != null && ((Dictionary)collection.UnusedSettings).Remove(setting)) + if (setting != null && ((Dictionary)collection.Settings.Unused).Remove(setting)) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); } @@ -246,13 +250,13 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer { File.Move(file.FullName, correctName, false); Penumbra.Messager.NotificationMessage( - $"Collection {file.Name} does not correspond to {collection.Identifier}, renamed.", + $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, renamed.", NotificationType.Warning); } catch (Exception ex) { Penumbra.Messager.NotificationMessage( - $"Collection {file.Name} does not correspond to {collection.Identifier}, rename failed:\n{ex}", + $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, rename failed:\n{ex}", NotificationType.Warning); } } @@ -273,7 +277,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer catch (Exception e) { Penumbra.Messager.NotificationMessage(e, - $"Collection {file.Name} does not correspond to {collection.Identifier}, but could not rename.", + $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, but could not rename.", NotificationType.Error); } @@ -291,14 +295,14 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer /// private ModCollection SetDefaultNamedCollection() { - if (ByName(ModCollection.DefaultCollectionName, out var collection)) + if (ByName(ModCollectionIdentity.DefaultCollectionName, out var collection)) return collection; - if (AddCollection(ModCollection.DefaultCollectionName, null)) + if (AddCollection(ModCollectionIdentity.DefaultCollectionName, null)) return _collections[^1]; Penumbra.Messager.NotificationMessage( - $"Unknown problem creating a collection with the name {ModCollection.DefaultCollectionName}, which is required to exist.", + $"Unknown problem creating a collection with the name {ModCollectionIdentity.DefaultCollectionName}, which is required to exist.", NotificationType.Error); return Count > 1 ? _collections[1] : _collections[0]; } @@ -307,7 +311,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer private void OnModDiscoveryStarted() { foreach (var collection in this) - collection.PrepareModDiscovery(_modStorage); + collection.Settings.PrepareModDiscovery(_modStorage); } /// Restore all settings in all collections to mods. @@ -315,7 +319,7 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer { // Re-apply all mod settings. foreach (var collection in this) - collection.ApplyModSettings(_saveService, _modStorage); + collection.Settings.ApplyModSettings(collection, _saveService, _modStorage); } /// Add or remove a mod from all collections, or re-save all collections where the mod has settings. @@ -326,21 +330,22 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer { case ModPathChangeType.Added: foreach (var collection in this) - collection.AddMod(mod); + collection.Settings.AddMod(mod); break; case ModPathChangeType.Deleted: foreach (var collection in this) - collection.RemoveMod(mod); + collection.Settings.RemoveMod(mod); break; case ModPathChangeType.Moved: - foreach (var collection in this.Where(collection => collection.Settings[mod.Index] != null)) + foreach (var collection in this.Where(collection => collection.GetOwnSettings(mod.Index) != null)) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); break; case ModPathChangeType.Reloaded: foreach (var collection in this) { - if (collection.Settings[mod.Index]?.Settings.FixAll(mod) ?? false) + if (collection.GetOwnSettings(mod.Index)?.Settings.FixAll(mod) ?? false) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + collection.Settings.SetTemporary(mod.Index, null); } break; @@ -357,8 +362,9 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer foreach (var collection in this) { - if (collection.Settings[mod.Index]?.HandleChanges(type, mod, group, option, movedToIdx) ?? false) + if (collection.GetOwnSettings(mod.Index)?.HandleChanges(type, mod, group, option, movedToIdx) ?? false) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + collection.Settings.SetTemporary(mod.Index, null); } } @@ -370,9 +376,9 @@ public class CollectionStorage : IReadOnlyList, IDisposable, ISer foreach (var collection in this) { - var (settings, _) = collection[mod.Index]; + var (settings, _) = collection.GetActualSettings(mod.Index); if (settings is { Enabled: true }) - collection.IncrementCounter(); + collection.Counters.IncrementChange(); } } } diff --git a/Penumbra/Collections/Manager/IndividualCollections.Files.cs b/Penumbra/Collections/Manager/IndividualCollections.Files.cs index f7a26384..60e9fc5f 100644 --- a/Penumbra/Collections/Manager/IndividualCollections.Files.cs +++ b/Penumbra/Collections/Manager/IndividualCollections.Files.cs @@ -18,7 +18,7 @@ public partial class IndividualCollections foreach (var (name, identifiers, collection) in Assignments) { var tmp = identifiers[0].ToJson(); - tmp.Add("Collection", collection.Id); + tmp.Add("Collection", collection.Identity.Id); tmp.Add("Display", name); ret.Add(tmp); } @@ -182,7 +182,7 @@ public partial class IndividualCollections Penumbra.Log.Information($"Migrated {name} ({kind.ToName()}) to NPC Identifiers [{ids}]."); else Penumbra.Messager.NotificationMessage( - $"Could not migrate {name} ({collection.AnonymizedName}) which was assumed to be a {kind.ToName()} with IDs [{ids}], please look through your individual collections.", + $"Could not migrate {name} ({collection.Identity.AnonymizedName}) which was assumed to be a {kind.ToName()} with IDs [{ids}], please look through your individual collections.", NotificationType.Error); } // If it is not a valid NPC name, check if it can be a player name. @@ -192,16 +192,16 @@ public partial class IndividualCollections var shortName = string.Join(" ", name.Split().Select(n => $"{n[0]}.")); // Try to migrate the player name without logging full names. if (Add($"{name} ({_actors.Data.ToWorldName(identifier.HomeWorld)})", [identifier], collection)) - Penumbra.Log.Information($"Migrated {shortName} ({collection.AnonymizedName}) to Player Identifier."); + Penumbra.Log.Information($"Migrated {shortName} ({collection.Identity.AnonymizedName}) to Player Identifier."); else Penumbra.Messager.NotificationMessage( - $"Could not migrate {shortName} ({collection.AnonymizedName}), please look through your individual collections.", + $"Could not migrate {shortName} ({collection.Identity.AnonymizedName}), please look through your individual collections.", NotificationType.Error); } else { Penumbra.Messager.NotificationMessage( - $"Could not migrate {name} ({collection.AnonymizedName}), which can not be a player name nor is it a known NPC name, please look through your individual collections.", + $"Could not migrate {name} ({collection.Identity.AnonymizedName}), which can not be a player name nor is it a known NPC name, please look through your individual collections.", NotificationType.Error); } } diff --git a/Penumbra/Collections/Manager/InheritanceManager.cs b/Penumbra/Collections/Manager/InheritanceManager.cs index bc1a362c..34582677 100644 --- a/Penumbra/Collections/Manager/InheritanceManager.cs +++ b/Penumbra/Collections/Manager/InheritanceManager.cs @@ -1,7 +1,6 @@ using Dalamud.Interface.ImGuiNotification; -using OtterGui; using OtterGui.Classes; -using OtterGui.Filesystem; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Communication; using Penumbra.Mods.Manager; @@ -63,10 +62,10 @@ public class InheritanceManager : IDisposable, IService if (ReferenceEquals(potentialParent, potentialInheritor)) return ValidInheritance.Self; - if (potentialInheritor.DirectlyInheritsFrom.Contains(potentialParent)) + if (potentialInheritor.Inheritance.DirectlyInheritsFrom.Contains(potentialParent)) return ValidInheritance.Contained; - if (ModCollection.InheritedCollections(potentialParent).Any(c => ReferenceEquals(c, potentialInheritor))) + if (potentialParent.Inheritance.FlatHierarchy.Any(c => ReferenceEquals(c, potentialInheritor))) return ValidInheritance.Circle; return ValidInheritance.Valid; @@ -83,25 +82,23 @@ public class InheritanceManager : IDisposable, IService /// Remove an existing inheritance from a collection. public void RemoveInheritance(ModCollection inheritor, int idx) { - var parent = inheritor.DirectlyInheritsFrom[idx]; - ((List)inheritor.DirectlyInheritsFrom).RemoveAt(idx); - ((List)parent.DirectParentOf).Remove(inheritor); + var parent = inheritor.Inheritance.RemoveInheritanceAt(inheritor, idx); _saveService.QueueSave(new ModCollectionSave(_modStorage, inheritor)); _communicator.CollectionInheritanceChanged.Invoke(inheritor, false); - RecurseInheritanceChanges(inheritor); - Penumbra.Log.Debug($"Removed {parent.AnonymizedName} from {inheritor.AnonymizedName} inheritances."); + RecurseInheritanceChanges(inheritor, true); + Penumbra.Log.Debug($"Removed {parent.Identity.AnonymizedName} from {inheritor.Identity.AnonymizedName} inheritances."); } /// Order in the inheritance list is relevant. public void MoveInheritance(ModCollection inheritor, int from, int to) { - if (!((List)inheritor.DirectlyInheritsFrom).Move(from, to)) + if (!inheritor.Inheritance.MoveInheritance(inheritor, from, to)) return; _saveService.QueueSave(new ModCollectionSave(_modStorage, inheritor)); _communicator.CollectionInheritanceChanged.Invoke(inheritor, false); - RecurseInheritanceChanges(inheritor); - Penumbra.Log.Debug($"Moved {inheritor.AnonymizedName}s inheritance {from} to {to}."); + RecurseInheritanceChanges(inheritor, true); + Penumbra.Log.Debug($"Moved {inheritor.Identity.AnonymizedName}s inheritance {from} to {to}."); } /// @@ -110,16 +107,16 @@ public class InheritanceManager : IDisposable, IService if (CheckValidInheritance(inheritor, parent) != ValidInheritance.Valid) return false; - ((List)inheritor.DirectlyInheritsFrom).Add(parent); - ((List)parent.DirectParentOf).Add(inheritor); + inheritor.Inheritance.AddInheritance(inheritor, parent); if (invokeEvent) { _saveService.QueueSave(new ModCollectionSave(_modStorage, inheritor)); _communicator.CollectionInheritanceChanged.Invoke(inheritor, false); - RecurseInheritanceChanges(inheritor); } - Penumbra.Log.Debug($"Added {parent.AnonymizedName} to {inheritor.AnonymizedName} inheritances."); + RecurseInheritanceChanges(inheritor, invokeEvent); + + Penumbra.Log.Debug($"Added {parent.Identity.AnonymizedName} to {inheritor.Identity.AnonymizedName} inheritances."); return true; } @@ -131,11 +128,11 @@ public class InheritanceManager : IDisposable, IService { foreach (var collection in _storage) { - if (collection.InheritanceByName == null) + if (collection.Inheritance.ConsumeNames() is not { } byName) continue; var changes = false; - foreach (var subCollectionName in collection.InheritanceByName) + foreach (var subCollectionName in byName) { if (Guid.TryParse(subCollectionName, out var guid) && _storage.ById(guid, out var subCollection)) { @@ -143,29 +140,30 @@ public class InheritanceManager : IDisposable, IService continue; changes = true; - Penumbra.Messager.NotificationMessage($"{collection.Name} can not inherit from {subCollection.Name}, removed.", + Penumbra.Messager.NotificationMessage( + $"{collection.Identity.Name} can not inherit from {subCollection.Identity.Name}, removed.", NotificationType.Warning); } else if (_storage.ByName(subCollectionName, out subCollection)) { changes = true; - Penumbra.Log.Information($"Migrating inheritance for {collection.AnonymizedName} from name to GUID."); + Penumbra.Log.Information($"Migrating inheritance for {collection.Identity.AnonymizedName} from name to GUID."); if (AddInheritance(collection, subCollection, false)) continue; - Penumbra.Messager.NotificationMessage($"{collection.Name} can not inherit from {subCollection.Name}, removed.", + Penumbra.Messager.NotificationMessage( + $"{collection.Identity.Name} can not inherit from {subCollection.Identity.Name}, removed.", NotificationType.Warning); } else { Penumbra.Messager.NotificationMessage( - $"Inherited collection {subCollectionName} for {collection.AnonymizedName} does not exist, it was removed.", + $"Inherited collection {subCollectionName} for {collection.Identity.AnonymizedName} does not exist, it was removed.", NotificationType.Warning); changes = true; } } - collection.InheritanceByName = null; if (changes) _saveService.ImmediateSave(new ModCollectionSave(_modStorage, collection)); } @@ -178,20 +176,22 @@ public class InheritanceManager : IDisposable, IService foreach (var c in _storage) { - var inheritedIdx = c.DirectlyInheritsFrom.IndexOf(old); + var inheritedIdx = c.Inheritance.DirectlyInheritsFrom.IndexOf(old); if (inheritedIdx >= 0) RemoveInheritance(c, inheritedIdx); - ((List)c.DirectParentOf).Remove(old); + c.Inheritance.RemoveChild(old); } } - private void RecurseInheritanceChanges(ModCollection newInheritor) + private void RecurseInheritanceChanges(ModCollection newInheritor, bool invokeEvent) { - foreach (var inheritor in newInheritor.DirectParentOf) + foreach (var inheritor in newInheritor.Inheritance.DirectlyInheritedBy) { - _communicator.CollectionInheritanceChanged.Invoke(inheritor, true); - RecurseInheritanceChanges(inheritor); + ModCollectionInheritance.UpdateFlattenedInheritance(inheritor); + RecurseInheritanceChanges(inheritor, invokeEvent); + if (invokeEvent) + _communicator.CollectionInheritanceChanged.Invoke(inheritor, true); } } } diff --git a/Penumbra/Collections/Manager/ModCollectionMigration.cs b/Penumbra/Collections/Manager/ModCollectionMigration.cs index fe61285d..7db375f7 100644 --- a/Penumbra/Collections/Manager/ModCollectionMigration.cs +++ b/Penumbra/Collections/Manager/ModCollectionMigration.cs @@ -26,12 +26,12 @@ internal static class ModCollectionMigration // Remove all completely defaulted settings from active and inactive mods. for (var i = 0; i < collection.Settings.Count; ++i) { - if (SettingIsDefaultV0(collection.Settings[i])) - ((List)collection.Settings)[i] = null; + if (SettingIsDefaultV0(collection.GetOwnSettings(i))) + collection.Settings.SetAll(i, FullModSettings.Empty); } - foreach (var (key, _) in collection.UnusedSettings.Where(kvp => SettingIsDefaultV0(kvp.Value)).ToList()) - ((Dictionary)collection.UnusedSettings).Remove(key); + foreach (var (key, _) in collection.Settings.Unused.Where(kvp => SettingIsDefaultV0(kvp.Value)).ToList()) + collection.Settings.RemoveUnused(key); return true; } diff --git a/Penumbra/Collections/Manager/TempCollectionManager.cs b/Penumbra/Collections/Manager/TempCollectionManager.cs index 5c893232..9476e38c 100644 --- a/Penumbra/Collections/Manager/TempCollectionManager.cs +++ b/Penumbra/Collections/Manager/TempCollectionManager.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Api; using Penumbra.Communication; @@ -44,7 +44,7 @@ public class TempCollectionManager : IDisposable, IService => _customCollections.Values; public bool CollectionByName(string name, [NotNullWhen(true)] out ModCollection? collection) - => _customCollections.Values.FindFirst(c => string.Equals(name, c.Name, StringComparison.OrdinalIgnoreCase), out collection); + => _customCollections.Values.FindFirst(c => string.Equals(name, c.Identity.Name, StringComparison.OrdinalIgnoreCase), out collection); public bool CollectionById(Guid id, [NotNullWhen(true)] out ModCollection? collection) => _customCollections.TryGetValue(id, out collection); @@ -54,12 +54,12 @@ public class TempCollectionManager : IDisposable, IService if (GlobalChangeCounter == int.MaxValue) GlobalChangeCounter = 0; var collection = _storage.CreateTemporary(name, ~Count, GlobalChangeCounter++); - Penumbra.Log.Debug($"Creating temporary collection {collection.Name} with {collection.Id}."); - if (_customCollections.TryAdd(collection.Id, collection)) + Penumbra.Log.Debug($"Creating temporary collection {collection.Identity.Name} with {collection.Identity.Id}."); + if (_customCollections.TryAdd(collection.Identity.Id, collection)) { // Temporary collection created. _communicator.CollectionChange.Invoke(CollectionType.Temporary, null, collection, string.Empty); - return collection.Id; + return collection.Identity.Id; } return Guid.Empty; @@ -74,8 +74,8 @@ public class TempCollectionManager : IDisposable, IService } _storage.Delete(collection); - Penumbra.Log.Debug($"Deleted temporary collection {collection.Id}."); - GlobalChangeCounter += Math.Max(collection.ChangeCounter + 1 - GlobalChangeCounter, 0); + Penumbra.Log.Debug($"Deleted temporary collection {collection.Identity.Id}."); + GlobalChangeCounter += Math.Max(collection.Counters.Change + 1 - GlobalChangeCounter, 0); for (var i = 0; i < Collections.Count; ++i) { if (Collections[i].Collection != collection) @@ -83,7 +83,7 @@ public class TempCollectionManager : IDisposable, IService // Temporary collection assignment removed. _communicator.CollectionChange.Invoke(CollectionType.Temporary, collection, null, Collections[i].DisplayName); - Penumbra.Log.Verbose($"Unassigned temporary collection {collection.Id} from {Collections[i].DisplayName}."); + Penumbra.Log.Verbose($"Unassigned temporary collection {collection.Identity.Id} from {Collections[i].DisplayName}."); Collections.Delete(i--); } @@ -96,7 +96,7 @@ public class TempCollectionManager : IDisposable, IService return false; // Temporary collection assignment added. - Penumbra.Log.Verbose($"Assigned temporary collection {collection.AnonymizedName} to {Collections.Last().DisplayName}."); + Penumbra.Log.Verbose($"Assigned temporary collection {collection.Identity.AnonymizedName} to {Collections.Last().DisplayName}."); _communicator.CollectionChange.Invoke(CollectionType.Temporary, null, collection, Collections.Last().DisplayName); return true; } @@ -127,6 +127,6 @@ public class TempCollectionManager : IDisposable, IService return false; var identifier = _actors.CreatePlayer(byteString, worldId); - return Collections.TryGetValue(identifier, out var collection) && RemoveTemporaryCollection(collection.Id); + return Collections.TryGetValue(identifier, out var collection) && RemoveTemporaryCollection(collection.Identity.Id); } } diff --git a/Penumbra/Collections/ModCollection.Cache.Access.cs b/Penumbra/Collections/ModCollection.Cache.Access.cs index 0b38dde8..716b153e 100644 --- a/Penumbra/Collections/ModCollection.Cache.Access.cs +++ b/Penumbra/Collections/ModCollection.Cache.Access.cs @@ -46,8 +46,8 @@ public partial class ModCollection internal IReadOnlyDictionary ResolvedFiles => _cache?.ResolvedFiles ?? new ConcurrentDictionary(); - internal IReadOnlyDictionary, IIdentifiedObjectData?)> ChangedItems - => _cache?.ChangedItems ?? new Dictionary, IIdentifiedObjectData?)>(); + internal IReadOnlyDictionary, IIdentifiedObjectData)> ChangedItems + => _cache?.ChangedItems ?? new Dictionary, IIdentifiedObjectData)>(); internal IEnumerable> AllConflicts => _cache?.AllConflicts ?? Array.Empty>(); diff --git a/Penumbra/Collections/ModCollection.cs b/Penumbra/Collections/ModCollection.cs index db9c19cb..69f82458 100644 --- a/Penumbra/Collections/ModCollection.cs +++ b/Penumbra/Collections/ModCollection.cs @@ -1,4 +1,3 @@ -using Penumbra.Mods; using Penumbra.Mods.Manager; using Penumbra.Collections.Manager; using Penumbra.Mods.Settings; @@ -13,111 +12,83 @@ namespace Penumbra.Collections; /// - Index is the collections index in the ModCollection.Manager /// - Settings has the same size as ModManager.Mods. /// - any change in settings or inheritance of the collection causes a Save. -/// - the name can not contain invalid path characters and has to be unique when lower-cased. /// public partial class ModCollection { - public const int CurrentVersion = 2; - public const string DefaultCollectionName = "Default"; - public const string EmptyCollectionName = "None"; + public const int CurrentVersion = 2; /// /// Create the always available Empty Collection that will always sit at index 0, /// can not be deleted and does never create a cache. /// - public static readonly ModCollection Empty = new(Guid.Empty, EmptyCollectionName, LocalCollectionId.Zero, 0, 0, CurrentVersion, [], [], []); + public static readonly ModCollection Empty = new(ModCollectionIdentity.Empty, 0, CurrentVersion, new ModSettingProvider(), + new ModCollectionInheritance()); - /// The name of a collection. - public string Name { get; set; } - - public Guid Id { get; } - - public LocalCollectionId LocalId { get; } - - public string Identifier - => Id.ToString(); - - public string ShortIdentifier - => Identifier[..8]; + public ModCollectionIdentity Identity; public override string ToString() - => Name.Length > 0 ? Name : ShortIdentifier; + => Identity.ToString(); - /// Get the first two letters of a collection name and its Index (or None if it is the empty collection). - public string AnonymizedName - => this == Empty ? Empty.Name : Name == DefaultCollectionName ? Name : ShortIdentifier; + public readonly ModSettingProvider Settings; + public ModCollectionInheritance Inheritance; + public CollectionCounters Counters; - /// The index of the collection is set and kept up-to-date by the CollectionManager. - public int Index { get; internal set; } - /// - /// Count the number of changes of the effective file list. - /// This is used for material and imc changes. - /// - public int ChangeCounter { get; private set; } - - public uint ImcChangeCounter { get; set; } - public uint AtchChangeCounter { get; set; } - - /// Increment the number of changes in the effective file list. - public int IncrementCounter() - => ++ChangeCounter; - - /// - /// If a ModSetting is null, it can be inherited from other collections. - /// If no collection provides a setting for the mod, it is just disabled. - /// - public readonly IReadOnlyList Settings; - - /// Settings for deleted mods will be kept via the mods identifier (directory name). - public readonly IReadOnlyDictionary UnusedSettings; - - /// Inheritances stored before they can be applied. - public IReadOnlyList? InheritanceByName; - - /// Contains all direct parent collections this collection inherits settings from. - public readonly IReadOnlyList DirectlyInheritsFrom; - - /// Contains all direct child collections that inherit from this collection. - public readonly IReadOnlyList DirectParentOf = new List(); - - /// All inherited collections in application order without filtering for duplicates. - public static IEnumerable InheritedCollections(ModCollection collection) - => collection.DirectlyInheritsFrom.SelectMany(InheritedCollections).Prepend(collection); - - /// - /// Iterate over all collections inherited from in depth-first order. - /// Skip already visited collections to avoid circular dependencies. - /// - public IEnumerable GetFlattenedInheritance() - => InheritedCollections(this).Distinct(); - - /// - /// Obtain the actual settings for a given mod via index. - /// Also returns the collection the settings are taken from. - /// If no collection provides settings for this mod, this collection is returned together with null. - /// - public (ModSettings? Settings, ModCollection Collection) this[Index idx] + public ModSettings? GetOwnSettings(Index idx) { - get + if (Identity.Index <= 0) + return ModSettings.Empty; + + return Settings.Settings[idx].Settings; + } + + public TemporaryModSettings? GetTempSettings(Index idx) + { + if (Identity.Index <= 0) + return null; + + return Settings.Settings[idx].TempSettings; + } + + public (ModSettings? Settings, ModCollection Collection) GetInheritedSettings(Index idx) + { + if (Identity.Index <= 0) + return (ModSettings.Empty, this); + + foreach (var collection in Inheritance.FlatHierarchy) { - if (Index <= 0) - return (ModSettings.Empty, this); - - foreach (var collection in GetFlattenedInheritance()) - { - var settings = collection.Settings[idx]; - if (settings != null) - return (settings, collection); - } - - return (null, this); + var settings = collection.Settings.Settings[idx].Settings; + if (settings != null) + return (settings, collection); } + + return (null, this); + } + + public (ModSettings? Settings, ModCollection Collection) GetActualSettings(Index idx) + { + if (Identity.Index <= 0) + return (ModSettings.Empty, this); + + // Check temp settings. + var ownTempSettings = Settings.Settings[idx].Resolve(); + if (ownTempSettings != null) + return (ownTempSettings, this); + + // Ignore temp settings for inherited collections. + foreach (var collection in Inheritance.FlatHierarchy.Skip(1)) + { + var settings = collection.Settings.Settings[idx].Settings; + if (settings != null) + return (settings, collection); + } + + return (null, this); } /// Evaluates all settings along the whole inheritance tree. public IEnumerable ActualSettings - => Enumerable.Range(0, Settings.Count).Select(i => this[i].Settings); + => Enumerable.Range(0, Settings.Count).Select(i => GetActualSettings(i).Settings); /// /// Constructor for duplication. Deep copies all settings and parent collections and adds the new collection to their children lists. @@ -125,21 +96,16 @@ public partial class ModCollection public ModCollection Duplicate(string name, LocalCollectionId localId, int index) { Debug.Assert(index > 0, "Collection duplicated with non-positive index."); - return new ModCollection(Guid.NewGuid(), name, localId, index, 0, CurrentVersion, Settings.Select(s => s?.DeepCopy()).ToList(), - [.. DirectlyInheritsFrom], UnusedSettings.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.DeepCopy())); + return new ModCollection(ModCollectionIdentity.New(name, localId, index), 0, CurrentVersion, Settings.Clone(), Inheritance.Clone()); } /// Constructor for reading from files. - public static ModCollection CreateFromData(SaveService saver, ModStorage mods, Guid id, string name, LocalCollectionId localId, int version, - int index, + public static ModCollection CreateFromData(SaveService saver, ModStorage mods, ModCollectionIdentity identity, int version, Dictionary allSettings, IReadOnlyList inheritances) { - Debug.Assert(index > 0, "Collection read with non-positive index."); - var ret = new ModCollection(id, name, localId, index, 0, version, [], [], allSettings) - { - InheritanceByName = inheritances, - }; - ret.ApplyModSettings(saver, mods); + Debug.Assert(identity.Index > 0, "Collection read with non-positive index."); + var ret = new ModCollection(identity, 0, version, new ModSettingProvider(allSettings), new ModCollectionInheritance(inheritances)); + ret.Settings.ApplyModSettings(ret, saver, mods); ModCollectionMigration.Migrate(saver, mods, version, ret); return ret; } @@ -148,7 +114,8 @@ public partial class ModCollection public static ModCollection CreateTemporary(string name, LocalCollectionId localId, int index, int changeCounter) { Debug.Assert(index < 0, "Temporary collection created with non-negative index."); - var ret = new ModCollection(Guid.NewGuid(), name, localId, index, changeCounter, CurrentVersion, [], [], []); + var ret = new ModCollection(ModCollectionIdentity.New(name, localId, index), changeCounter, CurrentVersion, new ModSettingProvider(), + new ModCollectionInheritance()); return ret; } @@ -156,68 +123,18 @@ public partial class ModCollection public static ModCollection CreateEmpty(string name, LocalCollectionId localId, int index, int modCount) { Debug.Assert(index >= 0, "Empty collection created with negative index."); - return new ModCollection(Guid.NewGuid(), name, localId, index, 0, CurrentVersion, - Enumerable.Repeat((ModSettings?)null, modCount).ToList(), [], - []); + return new ModCollection(ModCollectionIdentity.New(name, localId, index), 0, CurrentVersion, ModSettingProvider.Empty(modCount), + new ModCollectionInheritance()); } - /// Add settings for a new appended mod, by checking if the mod had settings from a previous deletion. - internal bool AddMod(Mod mod) + private ModCollection(ModCollectionIdentity identity, int changeCounter, int version, ModSettingProvider settings, + ModCollectionInheritance inheritance) { - if (UnusedSettings.TryGetValue(mod.ModPath.Name, out var save)) - { - var ret = save.ToSettings(mod, out var settings); - ((List)Settings).Add(settings); - ((Dictionary)UnusedSettings).Remove(mod.ModPath.Name); - return ret; - } - - ((List)Settings).Add(null); - return false; - } - - /// Move settings from the current mod list to the unused mod settings. - internal void RemoveMod(Mod mod) - { - var settings = Settings[mod.Index]; - if (settings != null) - ((Dictionary)UnusedSettings)[mod.ModPath.Name] = new ModSettings.SavedSettings(settings, mod); - - ((List)Settings).RemoveAt(mod.Index); - } - - /// Move all settings to unused settings for rediscovery. - internal void PrepareModDiscovery(ModStorage mods) - { - foreach (var (mod, setting) in mods.Zip(Settings).Where(s => s.Second != null)) - ((Dictionary)UnusedSettings)[mod.ModPath.Name] = new ModSettings.SavedSettings(setting!, mod); - - ((List)Settings).Clear(); - } - - /// - /// Apply all mod settings from unused settings to the current set of mods. - /// Also fixes invalid settings. - /// - internal void ApplyModSettings(SaveService saver, ModStorage mods) - { - ((List)Settings).Capacity = Math.Max(((List)Settings).Capacity, mods.Count); - if (mods.Aggregate(false, (current, mod) => current | AddMod(mod))) - saver.ImmediateSave(new ModCollectionSave(mods, this)); - } - - private ModCollection(Guid id, string name, LocalCollectionId localId, int index, int changeCounter, int version, - List appliedSettings, List inheritsFrom, Dictionary settings) - { - Name = name; - Id = id; - LocalId = localId; - Index = index; - ChangeCounter = changeCounter; - Settings = appliedSettings; - UnusedSettings = settings; - DirectlyInheritsFrom = inheritsFrom; - foreach (var c in DirectlyInheritsFrom) - ((List)c.DirectParentOf).Add(this); + Identity = identity; + Counters = new CollectionCounters(changeCounter); + Settings = settings; + Inheritance = inheritance; + ModCollectionInheritance.UpdateChildren(this); + ModCollectionInheritance.UpdateFlattenedInheritance(this); } } diff --git a/Penumbra/Collections/ModCollectionIdentity.cs b/Penumbra/Collections/ModCollectionIdentity.cs new file mode 100644 index 00000000..7050450c --- /dev/null +++ b/Penumbra/Collections/ModCollectionIdentity.cs @@ -0,0 +1,43 @@ +using OtterGui; +using OtterGui.Extensions; +using Penumbra.Collections.Manager; + +namespace Penumbra.Collections; + +public struct ModCollectionIdentity(Guid id, LocalCollectionId localId) +{ + public const string DefaultCollectionName = "Default"; + public const string EmptyCollectionName = "None"; + + public static readonly ModCollectionIdentity Empty = new(Guid.Empty, LocalCollectionId.Zero, EmptyCollectionName, 0); + + public string Name { get; set; } = string.Empty; + public Guid Id { get; } = id; + public LocalCollectionId LocalId { get; } = localId; + + /// The index of the collection is set and kept up-to-date by the CollectionManager. + public int Index { get; internal set; } + + public string Identifier + => Id.ToString(); + + public string ShortIdentifier + => Id.ShortGuid(); + + /// Get the short identifier of a collection unless it is a well-known collection name. + public string AnonymizedName + => Id == Guid.Empty ? EmptyCollectionName : Name == DefaultCollectionName ? Name : ShortIdentifier; + + public override string ToString() + => Name.Length > 0 ? Name : ShortIdentifier; + + public ModCollectionIdentity(Guid id, LocalCollectionId localId, string name, int index) + : this(id, localId) + { + Name = name; + Index = index; + } + + public static ModCollectionIdentity New(string name, LocalCollectionId id, int index) + => new(Guid.NewGuid(), id, name, index); +} diff --git a/Penumbra/Collections/ModCollectionInheritance.cs b/Penumbra/Collections/ModCollectionInheritance.cs new file mode 100644 index 00000000..151ed7db --- /dev/null +++ b/Penumbra/Collections/ModCollectionInheritance.cs @@ -0,0 +1,92 @@ +using OtterGui.Filesystem; + +namespace Penumbra.Collections; + +public struct ModCollectionInheritance +{ + public IReadOnlyList? InheritanceByName { get; private set; } + private readonly List _directlyInheritsFrom = []; + private readonly List _directlyInheritedBy = []; + private readonly List _flatHierarchy = []; + + public ModCollectionInheritance() + { } + + private ModCollectionInheritance(List inheritsFrom) + => _directlyInheritsFrom = [.. inheritsFrom]; + + public ModCollectionInheritance(IReadOnlyList byName) + => InheritanceByName = byName; + + public ModCollectionInheritance Clone() + => new(_directlyInheritsFrom); + + public IEnumerable Identifiers + => InheritanceByName ?? _directlyInheritsFrom.Select(c => c.Identity.Identifier); + + public IReadOnlyList? ConsumeNames() + { + var ret = InheritanceByName; + InheritanceByName = null; + return ret; + } + + public static void UpdateChildren(ModCollection parent) + { + foreach (var inheritance in parent.Inheritance.DirectlyInheritsFrom) + inheritance.Inheritance._directlyInheritedBy.Add(parent); + } + + public void AddInheritance(ModCollection inheritor, ModCollection newParent) + { + _directlyInheritsFrom.Add(newParent); + newParent.Inheritance._directlyInheritedBy.Add(inheritor); + UpdateFlattenedInheritance(inheritor); + } + + public ModCollection RemoveInheritanceAt(ModCollection inheritor, int idx) + { + var parent = DirectlyInheritsFrom[idx]; + _directlyInheritsFrom.RemoveAt(idx); + parent.Inheritance._directlyInheritedBy.Remove(parent); + UpdateFlattenedInheritance(inheritor); + return parent; + } + + public bool MoveInheritance(ModCollection inheritor, int from, int to) + { + if (!_directlyInheritsFrom.Move(from, to)) + return false; + + UpdateFlattenedInheritance(inheritor); + return true; + } + + public void RemoveChild(ModCollection child) + => _directlyInheritedBy.Remove(child); + + /// Contains all direct parent collections this collection inherits settings from. + public readonly IReadOnlyList DirectlyInheritsFrom + => _directlyInheritsFrom; + + /// Contains all direct child collections that inherit from this collection. + public readonly IReadOnlyList DirectlyInheritedBy + => _directlyInheritedBy; + + /// + /// Iterate over all collections inherited from in depth-first order. + /// Skip already visited collections to avoid circular dependencies. + /// + public readonly IReadOnlyList FlatHierarchy + => _flatHierarchy; + + public static void UpdateFlattenedInheritance(ModCollection parent) + { + parent.Inheritance._flatHierarchy.Clear(); + parent.Inheritance._flatHierarchy.AddRange(InheritedCollections(parent).Distinct()); + } + + /// All inherited collections in application order without filtering for duplicates. + private static IEnumerable InheritedCollections(ModCollection parent) + => parent.Inheritance.DirectlyInheritsFrom.SelectMany(InheritedCollections).Prepend(parent); +} diff --git a/Penumbra/Collections/ModCollectionSave.cs b/Penumbra/Collections/ModCollectionSave.cs index e6bb069b..4c41a28c 100644 --- a/Penumbra/Collections/ModCollectionSave.cs +++ b/Penumbra/Collections/ModCollectionSave.cs @@ -15,7 +15,7 @@ internal readonly struct ModCollectionSave(ModStorage modStorage, ModCollection => fileNames.CollectionFile(modCollection); public string LogName(string _) - => modCollection.AnonymizedName; + => modCollection.Identity.AnonymizedName; public string TypeName => "Collection"; @@ -28,23 +28,23 @@ internal readonly struct ModCollectionSave(ModStorage modStorage, ModCollection j.WriteStartObject(); j.WritePropertyName("Version"); j.WriteValue(ModCollection.CurrentVersion); - j.WritePropertyName(nameof(ModCollection.Id)); - j.WriteValue(modCollection.Identifier); - j.WritePropertyName(nameof(ModCollection.Name)); - j.WriteValue(modCollection.Name); - j.WritePropertyName(nameof(ModCollection.Settings)); + j.WritePropertyName(nameof(ModCollectionIdentity.Id)); + j.WriteValue(modCollection.Identity.Identifier); + j.WritePropertyName(nameof(ModCollectionIdentity.Name)); + j.WriteValue(modCollection.Identity.Name); + j.WritePropertyName("Settings"); // Write all used and unused settings by mod directory name. j.WriteStartObject(); - var list = new List<(string, ModSettings.SavedSettings)>(modCollection.Settings.Count + modCollection.UnusedSettings.Count); + var list = new List<(string, ModSettings.SavedSettings)>(modCollection.Settings.Count + modCollection.Settings.Unused.Count); for (var i = 0; i < modCollection.Settings.Count; ++i) { - var settings = modCollection.Settings[i]; + var settings = modCollection.GetOwnSettings(i); if (settings != null) list.Add((modStorage[i].ModPath.Name, new ModSettings.SavedSettings(settings, modStorage[i]))); } - list.AddRange(modCollection.UnusedSettings.Select(kvp => (kvp.Key, kvp.Value))); + list.AddRange(modCollection.Settings.Unused.Select(kvp => (kvp.Key, kvp.Value))); list.Sort((a, b) => string.Compare(a.Item1, b.Item1, StringComparison.OrdinalIgnoreCase)); foreach (var (modDir, settings) in list) @@ -57,7 +57,7 @@ internal readonly struct ModCollectionSave(ModStorage modStorage, ModCollection // Inherit by collection name. j.WritePropertyName("Inheritance"); - x.Serialize(j, modCollection.InheritanceByName ?? modCollection.DirectlyInheritsFrom.Select(c => c.Identifier)); + x.Serialize(j, modCollection.Inheritance.Identifiers); j.WriteEndObject(); } @@ -79,10 +79,10 @@ internal readonly struct ModCollectionSave(ModStorage modStorage, ModCollection { var obj = JObject.Parse(File.ReadAllText(file.FullName)); version = obj["Version"]?.ToObject() ?? 0; - name = obj[nameof(ModCollection.Name)]?.ToObject() ?? string.Empty; - id = obj[nameof(ModCollection.Id)]?.ToObject() ?? (version == 1 ? Guid.NewGuid() : Guid.Empty); + name = obj[nameof(ModCollectionIdentity.Name)]?.ToObject() ?? string.Empty; + id = obj[nameof(ModCollectionIdentity.Id)]?.ToObject() ?? (version == 1 ? Guid.NewGuid() : Guid.Empty); // Custom deserialization that is converted with the constructor. - settings = obj[nameof(ModCollection.Settings)]?.ToObject>() ?? settings; + settings = obj["Settings"]?.ToObject>() ?? settings; inheritance = obj["Inheritance"]?.ToObject>() ?? inheritance; return true; } diff --git a/Penumbra/Collections/ModSettingProvider.cs b/Penumbra/Collections/ModSettingProvider.cs new file mode 100644 index 00000000..3bf2f949 --- /dev/null +++ b/Penumbra/Collections/ModSettingProvider.cs @@ -0,0 +1,98 @@ +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; + +namespace Penumbra.Collections; + +public readonly struct ModSettingProvider +{ + private ModSettingProvider(IEnumerable settings, Dictionary unusedSettings) + { + _settings = settings.Select(s => s.DeepCopy()).ToList(); + _unused = unusedSettings.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.DeepCopy()); + } + + public ModSettingProvider() + { } + + public static ModSettingProvider Empty(int count) + => new(Enumerable.Repeat(FullModSettings.Empty, count), []); + + public ModSettingProvider(Dictionary allSettings) + => _unused = allSettings; + + private readonly List _settings = []; + + /// Settings for deleted mods will be kept via the mods identifier (directory name). + private readonly Dictionary _unused = []; + + public int Count + => _settings.Count; + + public bool RemoveUnused(string key) + => _unused.Remove(key); + + internal void Set(Index index, ModSettings? settings) + => _settings[index] = _settings[index] with { Settings = settings }; + + internal void SetTemporary(Index index, TemporaryModSettings? settings) + => _settings[index] = _settings[index] with { TempSettings = settings }; + + internal void SetAll(Index index, FullModSettings settings) + => _settings[index] = settings; + + public IReadOnlyList Settings + => _settings; + + public IReadOnlyDictionary Unused + => _unused; + + public ModSettingProvider Clone() + => new(_settings, _unused); + + /// Add settings for a new appended mod, by checking if the mod had settings from a previous deletion. + internal bool AddMod(Mod mod) + { + if (_unused.Remove(mod.ModPath.Name, out var save)) + { + var ret = save.ToSettings(mod, out var settings); + _settings.Add(new FullModSettings(settings)); + return ret; + } + + _settings.Add(FullModSettings.Empty); + return false; + } + + /// Move settings from the current mod list to the unused mod settings. + internal void RemoveMod(Mod mod) + { + var settings = _settings[mod.Index]; + if (settings.Settings != null) + _unused[mod.ModPath.Name] = new ModSettings.SavedSettings(settings.Settings, mod); + + _settings.RemoveAt(mod.Index); + } + + /// Move all settings to unused settings for rediscovery. + internal void PrepareModDiscovery(ModStorage mods) + { + foreach (var (mod, setting) in mods.Zip(_settings).Where(s => s.Second.Settings != null)) + _unused[mod.ModPath.Name] = new ModSettings.SavedSettings(setting.Settings!, mod); + + _settings.Clear(); + } + + /// + /// Apply all mod settings from unused settings to the current set of mods. + /// Also fixes invalid settings. + /// + internal void ApplyModSettings(ModCollection parent, SaveService saver, ModStorage mods) + { + _settings.Capacity = Math.Max(_settings.Capacity, mods.Count); + var settings = this; + if (mods.Aggregate(false, (current, mod) => current | settings.AddMod(mod))) + saver.ImmediateSave(new ModCollectionSave(mods, parent)); + } +} diff --git a/Penumbra/Collections/ResolveData.cs b/Penumbra/Collections/ResolveData.cs index 8fe160b3..bda877ff 100644 --- a/Penumbra/Collections/ResolveData.cs +++ b/Penumbra/Collections/ResolveData.cs @@ -23,7 +23,7 @@ public readonly struct ResolveData(ModCollection collection, nint gameObject) { } public override string ToString() - => ModCollection.Name; + => ModCollection.Identity.Name; } public static class ResolveDataExtensions diff --git a/Penumbra/CommandHandler.cs b/Penumbra/CommandHandler.cs index db8d9aca..b5d307ef 100644 --- a/Penumbra/CommandHandler.cs +++ b/Penumbra/CommandHandler.cs @@ -1,9 +1,10 @@ using Dalamud.Game.Command; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Classes; using OtterGui.Services; +using Penumbra.Api.Api; using Penumbra.Api.Enums; using Penumbra.Collections; using Penumbra.Collections.Manager; @@ -74,20 +75,21 @@ public class CommandHandler : IDisposable, IApiService _ = argumentList[0].ToLowerInvariant() switch { - "window" => ToggleWindow(arguments), - "enable" => SetPenumbraState(arguments, true), - "disable" => SetPenumbraState(arguments, false), - "toggle" => SetPenumbraState(arguments, null), - "reload" => Reload(arguments), - "redraw" => Redraw(arguments), - "lockui" => SetUiLockState(arguments), - "size" => SetUiMinimumSize(arguments), - "debug" => SetDebug(arguments), - "collection" => SetCollection(arguments), - "mod" => SetMod(arguments), - "bulktag" => SetTag(arguments), - "knowledge" => HandleKnowledge(arguments), - _ => PrintHelp(argumentList[0]), + "window" => ToggleWindow(arguments), + "enable" => SetPenumbraState(arguments, true), + "disable" => SetPenumbraState(arguments, false), + "toggle" => SetPenumbraState(arguments, null), + "reload" => Reload(arguments), + "redraw" => Redraw(arguments), + "lockui" => SetUiLockState(arguments), + "size" => SetUiMinimumSize(arguments), + "debug" => SetDebug(arguments), + "collection" => SetCollection(arguments), + "mod" => SetMod(arguments), + "bulktag" => SetTag(arguments), + "clearsettings" => ClearSettings(arguments), + "knowledge" => HandleKnowledge(arguments), + _ => PrintHelp(argumentList[0]), }; } @@ -125,6 +127,21 @@ public class CommandHandler : IDisposable, IApiService _chat.Print(new SeStringBuilder() .AddCommand("bulktag", "Change multiple mods settings based on their tags. Use without further parameters for more detailed help.") .BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("clearsettings", + "Clear all temporary settings applied manually through Penumbra in the current or all collections. Use with 'all' parameter for all.") + .BuiltString); + return true; + } + + private bool ClearSettings(string arguments) + { + if (arguments.Trim().ToLowerInvariant() is "all") + foreach (var collection in _collectionManager.Storage) + _collectionEditor.ClearTemporarySettings(collection); + else + _collectionEditor.ClearTemporarySettings(_collectionManager.Active.Current); + return true; } @@ -325,7 +342,7 @@ public class CommandHandler : IDisposable, IApiService { _chat.Print(collection == null ? $"The {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}" : string.Empty)} is already unassigned" - : $"{collection.Name} already is the {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}." : ".")}"); + : $"{collection.Identity.Name} already is the {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}." : ".")}"); continue; } @@ -362,13 +379,13 @@ public class CommandHandler : IDisposable, IApiService } Print( - $"Removed {oldCollection.Name} as {type.ToName()} Collection assignment {(identifier.IsValid ? $" for {identifier}." : ".")}"); + $"Removed {oldCollection.Identity.Name} as {type.ToName()} Collection assignment {(identifier.IsValid ? $" for {identifier}." : ".")}"); anySuccess = true; continue; } _collectionManager.Active.SetCollection(collection!, type, individualIndex); - Print($"Assigned {collection!.Name} as {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}." : ".")}"); + Print($"Assigned {collection!.Identity.Name} as {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}." : ".")}"); } return anySuccess; @@ -379,16 +396,18 @@ public class CommandHandler : IDisposable, IApiService if (arguments.Length == 0) { var seString = new SeStringBuilder() - .AddText("Use with /penumbra mod ").AddBlue("[enable|disable|inherit|toggle]").AddText(" ").AddYellow("[Collection Name]") + .AddText("Use with /penumbra mod ").AddBlue("[enable|disable|inherit|toggle|").AddGreen("setting").AddBlue("]").AddText(" ") + .AddYellow("[Collection Name]") .AddText(" | ") - .AddPurple("[Mod Name or Mod Directory Name]"); + .AddPurple("[Mod Name or Mod Directory Name]") + .AddGreen(" <| [Option Group Name] | [Option1;Option2;...]>"); _chat.Print(seString.BuiltString); return true; } var split = arguments.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); var nameSplit = split.Length != 2 - ? Array.Empty() + ? [] : split[1].Split('|', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (nameSplit.Length != 2) { @@ -406,6 +425,24 @@ public class CommandHandler : IDisposable, IApiService if (!GetModCollection(nameSplit[0], out var collection) || collection == ModCollection.Empty) return false; + var groupName = string.Empty; + var optionNames = Array.Empty(); + if (state is 4) + { + var split2 = nameSplit[1].Split('|', 3, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (split2.Length < 2) + { + _chat.Print( + "Not enough arguments for changing settings provided. Please add a group name and a list of setting names - which can be empty for multi options."); + return false; + } + + nameSplit[1] = split2[0]; + groupName = split2[1]; + if (split2.Length == 3) + optionNames = split2[2].Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + if (!_modManager.TryGetMod(nameSplit[1], nameSplit[1], out var mod)) { _chat.Print(new SeStringBuilder().AddText("The mod ").AddRed(nameSplit[1], true).AddText(" does not exist.") @@ -413,12 +450,35 @@ public class CommandHandler : IDisposable, IApiService return false; } - if (HandleModState(state, collection!, mod)) - return true; + if (state < 4) + { + if (HandleModState(state, collection!, mod)) + return true; + + _chat.Print(new SeStringBuilder().AddText("Mod ").AddPurple(mod.Name, true) + .AddText("already had the desired state in collection ") + .AddYellow(collection!.Identity.Name, true).AddText(".").BuiltString); + return false; + } + + switch (ModSettingsApi.ConvertModSetting(mod, groupName, optionNames, out var groupIndex, out var setting)) + { + case PenumbraApiEc.OptionGroupMissing: + _chat.Print(new SeStringBuilder().AddText("The mod ").AddRed(nameSplit[1], true).AddText(" has no group ") + .AddGreen(groupName, true).AddText(".").BuiltString); + return false; + case PenumbraApiEc.OptionMissing: + _chat.Print(new SeStringBuilder().AddText("Not all set options in the mod ").AddRed(nameSplit[1], true) + .AddText(" could be found in group ").AddGreen(groupName, true).AddText(".").BuiltString); + return false; + case PenumbraApiEc.Success: + _collectionEditor.SetModSetting(collection!, mod, groupIndex, setting); + Print(() => new SeStringBuilder().AddText("Changed settings of group ").AddGreen(groupName, true).AddText(" in mod ") + .AddPurple(mod.Name, true).AddText(" in collection ") + .AddYellow(collection!.Identity.Name, true).AddText(".").BuiltString); + return true; + } - _chat.Print(new SeStringBuilder().AddText("Mod ").AddPurple(mod.Name, true) - .AddText("already had the desired state in collection ") - .AddYellow(collection!.Name, true).AddText(".").BuiltString); return false; } @@ -500,7 +560,7 @@ public class CommandHandler : IDisposable, IApiService changes |= HandleModState(state, collection!, mod); if (!changes) - Print(() => new SeStringBuilder().AddText("No mod states were changed in collection ").AddYellow(collection!.Name, true) + Print(() => new SeStringBuilder().AddText("No mod states were changed in collection ").AddYellow(collection!.Identity.Name, true) .AddText(".").BuiltString); return true; @@ -515,7 +575,7 @@ public class CommandHandler : IDisposable, IApiService return true; } - collection = string.Equals(lowerName, ModCollection.Empty.Name, StringComparison.OrdinalIgnoreCase) + collection = string.Equals(lowerName, ModCollection.Empty.Identity.Name, StringComparison.OrdinalIgnoreCase) ? ModCollection.Empty : _collectionManager.Storage.ByIdentifier(lowerName, out var c) ? c @@ -556,12 +616,14 @@ public class CommandHandler : IDisposable, IApiService "toggle" => 2, "inherit" => 3, "inherited" => 3, + "setting" => 4, + "settings" => 4, _ => -1, }; private bool HandleModState(int settingState, ModCollection collection, Mod mod) { - var settings = collection.Settings[mod.Index]; + var settings = collection.GetOwnSettings(mod.Index); switch (settingState) { case 0: @@ -569,7 +631,7 @@ public class CommandHandler : IDisposable, IApiService return false; Print(() => new SeStringBuilder().AddText("Enabled mod ").AddPurple(mod.Name, true).AddText(" in collection ") - .AddYellow(collection.Name, true) + .AddYellow(collection.Identity.Name, true) .AddText(".").BuiltString); return true; @@ -578,7 +640,7 @@ public class CommandHandler : IDisposable, IApiService return false; Print(() => new SeStringBuilder().AddText("Disabled mod ").AddPurple(mod.Name, true).AddText(" in collection ") - .AddYellow(collection.Name, true) + .AddYellow(collection.Identity.Name, true) .AddText(".").BuiltString); return true; @@ -589,7 +651,7 @@ public class CommandHandler : IDisposable, IApiService Print(() => new SeStringBuilder().AddText(setting ? "Enabled mod " : "Disabled mod ").AddPurple(mod.Name, true) .AddText(" in collection ") - .AddYellow(collection.Name, true) + .AddYellow(collection.Identity.Name, true) .AddText(".").BuiltString); return true; @@ -598,7 +660,7 @@ public class CommandHandler : IDisposable, IApiService return false; Print(() => new SeStringBuilder().AddText("Set mod ").AddPurple(mod.Name, true).AddText(" in collection ") - .AddYellow(collection.Name, true) + .AddYellow(collection.Identity.Name, true) .AddText(" to inherit.").BuiltString); return true; } diff --git a/Penumbra/Communication/ChangedItemClick.cs b/Penumbra/Communication/ChangedItemClick.cs index 1aac4454..2d27f36a 100644 --- a/Penumbra/Communication/ChangedItemClick.cs +++ b/Penumbra/Communication/ChangedItemClick.cs @@ -12,7 +12,7 @@ namespace Penumbra.Communication; /// Parameter is the clicked object data if any. /// /// -public sealed class ChangedItemClick() : EventWrapper(nameof(ChangedItemClick)) +public sealed class ChangedItemClick() : EventWrapper(nameof(ChangedItemClick)) { public enum Priority { diff --git a/Penumbra/Communication/ChangedItemHover.cs b/Penumbra/Communication/ChangedItemHover.cs index 4e72b558..92d770f7 100644 --- a/Penumbra/Communication/ChangedItemHover.cs +++ b/Penumbra/Communication/ChangedItemHover.cs @@ -10,7 +10,7 @@ namespace Penumbra.Communication; /// Parameter is the hovered object data if any. /// /// -public sealed class ChangedItemHover() : EventWrapper(nameof(ChangedItemHover)) +public sealed class ChangedItemHover() : EventWrapper(nameof(ChangedItemHover)) { public enum Priority { diff --git a/Penumbra/Communication/CharacterUtilityFinished.cs b/Penumbra/Communication/CharacterUtilityFinished.cs new file mode 100644 index 00000000..fbeeb8a7 --- /dev/null +++ b/Penumbra/Communication/CharacterUtilityFinished.cs @@ -0,0 +1,23 @@ +using OtterGui.Classes; +using Penumbra.Api; +using Penumbra.Interop.Services; + +namespace Penumbra.Communication; + +/// +/// Triggered when the Character Utility becomes ready. +/// +public sealed class CharacterUtilityFinished() : EventWrapper(nameof(CharacterUtilityFinished)) +{ + public enum Priority + { + /// + OnFinishedLoading = int.MaxValue, + + /// + IpcProvider = int.MinValue, + + /// + CollectionCacheManager = 0, + } +} diff --git a/Penumbra/Communication/ModPathChanged.cs b/Penumbra/Communication/ModPathChanged.cs index 1e4f8d36..efe59482 100644 --- a/Penumbra/Communication/ModPathChanged.cs +++ b/Penumbra/Communication/ModPathChanged.cs @@ -3,6 +3,7 @@ using Penumbra.Api; using Penumbra.Api.Api; using Penumbra.Mods; using Penumbra.Mods.Manager; +using Penumbra.Services; namespace Penumbra.Communication; @@ -20,11 +21,14 @@ public sealed class ModPathChanged() { public enum Priority { + /// + PcpService = int.MinValue, + /// - ApiMods = int.MinValue, + ApiMods = int.MinValue + 1, /// - ApiModSettings = int.MinValue, + ApiModSettings = int.MinValue + 1, /// EphemeralConfig = -500, diff --git a/Penumbra/Communication/PcpCreation.cs b/Penumbra/Communication/PcpCreation.cs new file mode 100644 index 00000000..ca0cfcf6 --- /dev/null +++ b/Penumbra/Communication/PcpCreation.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered when the character.json file for a .pcp file is written. +/// +/// Parameter is the JObject that gets written to file. +/// Parameter is the object index of the game object this is written for. +/// Parameter is the full path to the directory being set up for the PCP creation. +/// +/// +public sealed class PcpCreation() : EventWrapper(nameof(PcpCreation)) +{ + public enum Priority + { + /// + ModsApi = int.MinValue, + } +} diff --git a/Penumbra/Communication/PcpParsing.cs b/Penumbra/Communication/PcpParsing.cs new file mode 100644 index 00000000..95b78951 --- /dev/null +++ b/Penumbra/Communication/PcpParsing.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered when the character.json file for a .pcp file is parsed and applied. +/// +/// Parameter is parsed JObject that contains the data. +/// Parameter is the identifier of the created mod. +/// Parameter is the GUID of the created collection. +/// +/// +public sealed class PcpParsing() : EventWrapper(nameof(PcpParsing)) +{ + public enum Priority + { + /// + ModsApi = int.MinValue, + } +} diff --git a/Penumbra/Communication/ResolvedFileChanged.cs b/Penumbra/Communication/ResolvedFileChanged.cs index 75444340..0c91a18b 100644 --- a/Penumbra/Communication/ResolvedFileChanged.cs +++ b/Penumbra/Communication/ResolvedFileChanged.cs @@ -1,6 +1,5 @@ using OtterGui.Classes; using Penumbra.Collections; -using Penumbra.Mods; using Penumbra.Mods.Editor; using Penumbra.String.Classes; @@ -33,5 +32,8 @@ public sealed class ResolvedFileChanged() { /// DalamudSubstitutionProvider = 0, + + /// + SchedulerResourceManagementService = 0, } } diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index 50426b38..2991230e 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -1,8 +1,8 @@ using Dalamud.Configuration; using Dalamud.Interface.ImGuiNotification; using Newtonsoft.Json; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Filesystem; using OtterGui.Services; using OtterGui.Widgets; @@ -18,6 +18,15 @@ using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs; namespace Penumbra; +public record PcpSettings +{ + public bool CreateCollection { get; set; } = true; + public bool AssignCollection { get; set; } = true; + public bool AllowIpc { get; set; } = true; + public bool DisableHandling { get; set; } = false; + public string FolderName { get; set; } = "PCP"; +} + [Serializable] public class Configuration : IPluginConfiguration, ISavable, IService { @@ -39,15 +48,12 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool EnableMods { get => _enableMods; - set - { - _enableMods = value; - ModsEnabled?.Invoke(value); - } + set => SetField(ref _enableMods, value, ModsEnabled); } public string ModDirectory { get; set; } = string.Empty; public string ExportDirectory { get; set; } = string.Empty; + public string WatchDirectory { get; set; } = string.Empty; public bool? UseCrashHandler { get; set; } = null; public bool OpenWindowAtStart { get; set; } = false; @@ -56,20 +62,28 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool HideUiWhenUiHidden { get; set; } = false; public bool UseDalamudUiTextureRedirection { get; set; } = true; - public bool ShowModsInLobby { get; set; } = true; - public bool UseCharacterCollectionInMainWindow { get; set; } = true; - public bool UseCharacterCollectionsInCards { get; set; } = true; - public bool UseCharacterCollectionInInspect { get; set; } = true; - public bool UseCharacterCollectionInTryOn { get; set; } = true; - public bool UseOwnerNameForCharacterCollection { get; set; } = true; - public bool UseNoModsInInspect { get; set; } = false; - public bool HideChangedItemFilters { get; set; } = false; - public bool ReplaceNonAsciiOnImport { get; set; } = false; - public bool HidePrioritiesInSelector { get; set; } = false; - public bool HideRedrawBar { get; set; } = false; - public bool HideMachinistOffhandFromChangedItems { get; set; } = true; - public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; - public int OptionGroupCollapsibleMin { get; set; } = 5; + public bool AutoSelectCollection { get; set; } = false; + + public bool ShowModsInLobby { get; set; } = true; + public bool UseCharacterCollectionInMainWindow { get; set; } = true; + public bool UseCharacterCollectionsInCards { get; set; } = true; + public bool UseCharacterCollectionInInspect { get; set; } = true; + public bool UseCharacterCollectionInTryOn { get; set; } = true; + public bool UseOwnerNameForCharacterCollection { get; set; } = true; + public bool UseNoModsInInspect { get; set; } = false; + public bool HideChangedItemFilters { get; set; } = false; + public bool ReplaceNonAsciiOnImport { get; set; } = false; + public bool HidePrioritiesInSelector { get; set; } = false; + public bool HideRedrawBar { get; set; } = false; + public bool HideMachinistOffhandFromChangedItems { get; set; } = true; + public bool DefaultTemporaryMode { get; set; } = false; + public bool EnableDirectoryWatch { get; set; } = false; + public bool EnableAutomaticModImport { get; set; } = false; + public bool EnableCustomShapes { get; set; } = true; + public PcpSettings PcpSettings = new(); + public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; + public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; + public int OptionGroupCollapsibleMin { get; set; } = 5; public Vector2 MinimumSize = new(Constants.MinimumSizeX, Constants.MinimumSizeY); @@ -84,9 +98,6 @@ public class Configuration : IPluginConfiguration, ISavable, IService [JsonProperty(Order = int.MaxValue)] public ISortMode SortMode = ISortMode.FoldersFirst; - public bool ScaleModSelector { get; set; } = false; - public float ModSelectorAbsoluteSize { get; set; } = Constants.DefaultAbsoluteSize; - public int ModSelectorScaledSize { get; set; } = Constants.DefaultScaledSize; public bool OpenFoldersByDefault { get; set; } = false; public int SingleGroupRadioMax { get; set; } = 2; public string DefaultImportFolder { get; set; } = string.Empty; @@ -94,13 +105,14 @@ public class Configuration : IPluginConfiguration, ISavable, IService public string QuickMoveFolder2 { get; set; } = string.Empty; public string QuickMoveFolder3 { get; set; } = string.Empty; public DoubleModifier DeleteModModifier { get; set; } = new(ModifierHotkey.Control, ModifierHotkey.Shift); + public DoubleModifier IncognitoModifier { get; set; } = new(ModifierHotkey.Control); public bool PrintSuccessfulCommandsToChat { get; set; } = true; public bool AutoDeduplicateOnImport { get; set; } = true; public bool AutoReduplicateUiOnImport { get; set; } = true; public bool UseFileSystemCompression { get; set; } = true; public bool EnableHttpApi { get; set; } = true; - public bool MigrateImportedModelsToV6 { get; set; } = true; + public bool MigrateImportedModelsToV6 { get; set; } = true; public bool MigrateImportedMaterialsToLegacy { get; set; } = true; public string DefaultModImportPath { get; set; } = string.Empty; @@ -108,6 +120,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService public bool KeepDefaultMetaChanges { get; set; } = false; public string DefaultModAuthor { get; set; } = DefaultTexToolsData.Author; public bool EditRawTileTransforms { get; set; } = false; + public bool HdrRenderTargets { get; set; } = true; public Dictionary Colors { get; set; } = Enum.GetValues().ToDictionary(c => c, c => c.Data().DefaultColor); @@ -213,4 +226,45 @@ public class Configuration : IPluginConfiguration, ISavable, IService var serializer = new JsonSerializer { Formatting = Formatting.Indented }; serializer.Serialize(jWriter, this); } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool SetField(ref T field, T value, Action? @event, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(value)) + return false; + + var oldValue = field; + field = value; + try + { + @event?.Invoke(oldValue, field); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error in subscribers updating configuration field {propertyName} from {oldValue} to {field}:\n{ex}"); + throw; + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool SetField(ref T field, T value, Action? @event, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(value)) + return false; + + field = value; + try + { + @event?.Invoke(field); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error in subscribers updating configuration field {propertyName} to {field}:\n{ex}"); + throw; + } + + return true; + } } diff --git a/Penumbra/DebugConfiguration.cs b/Penumbra/DebugConfiguration.cs new file mode 100644 index 00000000..3f9e8207 --- /dev/null +++ b/Penumbra/DebugConfiguration.cs @@ -0,0 +1,7 @@ +namespace Penumbra; + +public class DebugConfiguration +{ + public static bool WriteImcBytesToLog = false; + public static bool UseSkinMaterialProcessing = true; +} diff --git a/Penumbra/EphemeralConfig.cs b/Penumbra/EphemeralConfig.cs index 24ab466b..ecb0218f 100644 --- a/Penumbra/EphemeralConfig.cs +++ b/Penumbra/EphemeralConfig.cs @@ -1,6 +1,7 @@ using Dalamud.Interface.ImGuiNotification; using Newtonsoft.Json; using OtterGui.Classes; +using OtterGui.FileSystem.Selector; using OtterGui.Services; using Penumbra.Api.Enums; using Penumbra.Communication; @@ -23,6 +24,10 @@ public class EphemeralConfig : ISavable, IDisposable, IService [JsonIgnore] private readonly ModPathChanged _modPathChanged; + public float CurrentModSelectorWidth { get; set; } = 200f; + public float ModSelectorMinimumScale { get; set; } = 0.1f; + public float ModSelectorMaximumScale { get; set; } = 0.5f; + public int Version { get; set; } = Configuration.Constants.CurrentVersion; public int LastSeenVersion { get; set; } = PenumbraChangelog.LastChangelogVersion; public bool DebugSeparateWindow { get; set; } = false; @@ -41,6 +46,7 @@ public class EphemeralConfig : ISavable, IDisposable, IService public string LastModPath { get; set; } = string.Empty; public bool AdvancedEditingOpen { get; set; } = false; public bool ForceRedrawOnFileChange { get; set; } = false; + public bool IncognitoMode { get; set; } = false; /// /// Load the current configuration. diff --git a/Penumbra/Import/Models/Export/MaterialExporter.cs b/Penumbra/Import/Models/Export/MaterialExporter.cs index 121e6eed..0d91534e 100644 --- a/Penumbra/Import/Models/Export/MaterialExporter.cs +++ b/Penumbra/Import/Models/Export/MaterialExporter.cs @@ -1,6 +1,7 @@ using Lumina.Data.Parsing; using Penumbra.GameData.Files; using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.UI.AdvancedWindow.Materials; using SharpGLTF.Materials; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Advanced; @@ -140,13 +141,13 @@ public class MaterialExporter // Lerp between table row values to fetch final pixel values for each subtexture. var lerpedDiffuse = Vector3.Lerp((Vector3)prevRow.DiffuseColor, (Vector3)nextRow.DiffuseColor, rowBlend); - baseColorSpan[x].FromVector4(new Vector4(lerpedDiffuse, 1)); + baseColorSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedDiffuse), 1)); var lerpedSpecularColor = Vector3.Lerp((Vector3)prevRow.SpecularColor, (Vector3)nextRow.SpecularColor, rowBlend); - specularSpan[x].FromVector4(new Vector4(lerpedSpecularColor, 1)); + specularSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedSpecularColor), 1)); var lerpedEmissive = Vector3.Lerp((Vector3)prevRow.EmissiveColor, (Vector3)nextRow.EmissiveColor, rowBlend); - emissiveSpan[x].FromVector4(new Vector4(lerpedEmissive, 1)); + emissiveSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedEmissive), 1)); } } } @@ -288,7 +289,7 @@ public class MaterialExporter const uint valueFace = 0x6E5B8F10; var isFace = material.Mtrl.ShaderPackage.ShaderKeys - .Any(key => key is { Category: categoryHairType, Value: valueFace }); + .Any(key => key is { Key: categoryHairType, Value: valueFace }); var normal = material.Textures[TextureUsage.SamplerNormal]; var mask = material.Textures[TextureUsage.SamplerMask]; @@ -363,7 +364,7 @@ public class MaterialExporter // Face is the default for the skin shader, so a lack of skin type category is also correct. var isFace = !material.Mtrl.ShaderPackage.ShaderKeys - .Any(key => key.Category == categorySkinType && key.Value != valueFace); + .Any(key => key.Key == categorySkinType && key.Value != valueFace); // TODO: There's more nuance to skin than this, but this should be enough for a baseline reference. // TODO: Specular? diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs index 73160615..6ea2b284 100644 --- a/Penumbra/Import/Models/Export/MeshExporter.cs +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -2,12 +2,11 @@ using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Nodes; using Lumina.Extensions; -using OtterGui; +using OtterGui.Extensions; using Penumbra.GameData.Files; using Penumbra.GameData.Files.ModelStructs; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; -using SharpGLTF.IO; using SharpGLTF.Materials; using SharpGLTF.Scenes; @@ -84,9 +83,12 @@ public class MeshExporter _boneIndexMap = BuildBoneIndexMap(skeleton.Value); var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements + .GroupBy(ele => (MdlFile.VertexUsage)ele.Usage, ele => ele) .ToImmutableDictionary( - element => (MdlFile.VertexUsage)element.Usage, - element => (MdlFile.VertexType)element.Type + g => g.Key, + g => g.OrderBy(ele => ele.UsageIndex) // OrderBy UsageIndex is probably unnecessary as they're probably already be in order + .Select(ele => (MdlFile.VertexType)ele.Type) + .ToList() ); _geometryType = GetGeometryType(usages); @@ -112,6 +114,7 @@ public class MeshExporter var indexMap = new Dictionary(); // #TODO @ackwell maybe fix for V6 Models, I think this works fine. + foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take((int)xivBoneTable.BoneCount).WithIndex()) { var boneName = _mdl.Bones[xivBoneIndex]; @@ -278,18 +281,22 @@ public class MeshExporter var sortedElements = _mdl.VertexDeclarations[_meshIndex].VertexElements .OrderBy(element => element.Offset) - .Select(element => ((MdlFile.VertexUsage)element.Usage, element)) .ToList(); - var vertices = new List(); - var attributes = new Dictionary(); + var attributes = new Dictionary>(); for (var vertexIndex = 0; vertexIndex < XivMesh.VertexCount; vertexIndex++) { attributes.Clear(); - - foreach (var (usage, element) in sortedElements) - attributes[usage] = ReadVertexAttribute((MdlFile.VertexType)element.Type, streams[element.Stream]); + attributes = sortedElements + .GroupBy(element => element.Usage) + .ToDictionary( + x => (MdlFile.VertexUsage)x.Key, + x => x.OrderBy(ele => ele.UsageIndex) // Once again, OrderBy UsageIndex is probably unnecessary + .Select(ele => ReadVertexAttribute((MdlFile.VertexType)ele.Type, streams[ele.Stream])) + .ToList() + ); + var vertexGeometry = BuildVertexGeometry(attributes); var vertexMaterial = BuildVertexMaterial(attributes); @@ -320,7 +327,7 @@ public class MeshExporter } /// Get the vertex geometry type for this mesh's vertex usages. - private Type GetGeometryType(IReadOnlyDictionary usages) + private Type GetGeometryType(IReadOnlyDictionary> usages) { if (!usages.ContainsKey(MdlFile.VertexUsage.Position)) throw _notifier.Exception("Mesh does not contain position vertex elements."); @@ -335,29 +342,29 @@ public class MeshExporter } /// Build a geometry vertex from a vertex's attributes. - private IVertexGeometry BuildVertexGeometry(IReadOnlyDictionary attributes) + private IVertexGeometry BuildVertexGeometry(IReadOnlyDictionary> attributes) { if (_geometryType == typeof(VertexPosition)) return new VertexPosition( - ToVector3(attributes[MdlFile.VertexUsage.Position]) + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)) ); if (_geometryType == typeof(VertexPositionNormal)) return new VertexPositionNormal( - ToVector3(attributes[MdlFile.VertexUsage.Position]), - ToVector3(attributes[MdlFile.VertexUsage.Normal]) + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)), + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Normal)) ); if (_geometryType == typeof(VertexPositionNormalTangent)) { // (Bi)tangents are universally stored as ByteFloat4, which uses 0..1 to represent the full -1..1 range. // TODO: While this assumption is safe, it would be sensible to actually check. - var bitangent = ToVector4(attributes[MdlFile.VertexUsage.Tangent1]) * 2 - Vector4.One; - + var bitangent = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Tangent1)) * 2 - Vector4.One; + return new VertexPositionNormalTangent( - ToVector3(attributes[MdlFile.VertexUsage.Position]), - ToVector3(attributes[MdlFile.VertexUsage.Normal]), - bitangent + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)), + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Normal)), + bitangent.SanitizeTangent() ); } @@ -365,60 +372,90 @@ public class MeshExporter } /// Get the vertex material type for this mesh's vertex usages. - private Type GetMaterialType(IReadOnlyDictionary usages) + private Type GetMaterialType(IReadOnlyDictionary> usages) { var uvCount = 0; - if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var type)) - uvCount = type switch + if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var list)) + { + foreach (var type in list) { - MdlFile.VertexType.Half2 => 1, - MdlFile.VertexType.Half4 => 2, - MdlFile.VertexType.Single2 => 1, - MdlFile.VertexType.Single4 => 2, - _ => throw _notifier.Exception($"Unexpected UV vertex type {type}."), - }; + uvCount += type switch + { + MdlFile.VertexType.Half2 => 1, + MdlFile.VertexType.Half4 => 2, + MdlFile.VertexType.Single2 => 1, + MdlFile.VertexType.Single4 => 2, + _ => throw _notifier.Exception($"Unexpected UV vertex type {type}."), + }; + } + } + + usages.TryGetValue(MdlFile.VertexUsage.Color, out var colours); + var nColors = colours?.Count ?? 0; var materialUsages = ( uvCount, - usages.ContainsKey(MdlFile.VertexUsage.Color) + nColors ); return materialUsages switch { - (2, true) => typeof(VertexTexture2ColorFfxiv), - (2, false) => typeof(VertexTexture2), - (1, true) => typeof(VertexTexture1ColorFfxiv), - (1, false) => typeof(VertexTexture1), - (0, true) => typeof(VertexColorFfxiv), - (0, false) => typeof(VertexEmpty), + (3, 2) => typeof(VertexTexture3Color2Ffxiv), + (3, 1) => typeof(VertexTexture3ColorFfxiv), + (3, 0) => typeof(VertexTexture3), + (2, 2) => typeof(VertexTexture2Color2Ffxiv), + (2, 1) => typeof(VertexTexture2ColorFfxiv), + (2, 0) => typeof(VertexTexture2), + (1, 2) => typeof(VertexTexture1Color2Ffxiv), + (1, 1) => typeof(VertexTexture1ColorFfxiv), + (1, 0) => typeof(VertexTexture1), + (0, 2) => typeof(VertexColor2Ffxiv), + (0, 1) => typeof(VertexColorFfxiv), + (0, 0) => typeof(VertexEmpty), - _ => throw new Exception("Unreachable."), + _ => throw _notifier.Exception($"Unhandled UV/color count of {uvCount}/{nColors} encountered."), }; } /// Build a material vertex from a vertex's attributes. - private IVertexMaterial BuildVertexMaterial(IReadOnlyDictionary attributes) + private IVertexMaterial BuildVertexMaterial(IReadOnlyDictionary> attributes) { if (_materialType == typeof(VertexEmpty)) return new VertexEmpty(); if (_materialType == typeof(VertexColorFfxiv)) - return new VertexColorFfxiv(ToVector4(attributes[MdlFile.VertexUsage.Color])); + return new VertexColorFfxiv(ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color))); + + if (_materialType == typeof(VertexColor2Ffxiv)) + { + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + return new VertexColor2Ffxiv(ToVector4(color0), ToVector4(color1)); + } if (_materialType == typeof(VertexTexture1)) - return new VertexTexture1(ToVector2(attributes[MdlFile.VertexUsage.UV])); + return new VertexTexture1(ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV))); if (_materialType == typeof(VertexTexture1ColorFfxiv)) return new VertexTexture1ColorFfxiv( - ToVector2(attributes[MdlFile.VertexUsage.UV]), - ToVector4(attributes[MdlFile.VertexUsage.Color]) + ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)), + ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) ); + if (_materialType == typeof(VertexTexture1Color2Ffxiv)) + { + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + return new VertexTexture1Color2Ffxiv( + ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)), + ToVector4(color0), + ToVector4(color1) + ); + } + // XIV packs two UVs into a single vec4 attribute. if (_materialType == typeof(VertexTexture2)) { - var uv = ToVector4(attributes[MdlFile.VertexUsage.UV]); + var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)); return new VertexTexture2( new Vector2(uv.X, uv.Y), new Vector2(uv.Z, uv.W) @@ -427,11 +464,63 @@ public class MeshExporter if (_materialType == typeof(VertexTexture2ColorFfxiv)) { - var uv = ToVector4(attributes[MdlFile.VertexUsage.UV]); + var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)); return new VertexTexture2ColorFfxiv( new Vector2(uv.X, uv.Y), new Vector2(uv.Z, uv.W), - ToVector4(attributes[MdlFile.VertexUsage.Color]) + ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) + ); + } + + if (_materialType == typeof(VertexTexture2Color2Ffxiv)) + { + var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)); + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + + return new VertexTexture2Color2Ffxiv( + new Vector2(uv.X, uv.Y), + new Vector2(uv.Z, uv.W), + ToVector4(color0), + ToVector4(color1) + ); + } + + if (_materialType == typeof(VertexTexture3)) + { + // Not 100% sure about this + var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]); + var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]); + return new VertexTexture3( + new Vector2(uv0.X, uv0.Y), + new Vector2(uv0.Z, uv0.W), + new Vector2(uv1.X, uv1.Y) + ); + } + + if (_materialType == typeof(VertexTexture3ColorFfxiv)) + { + var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]); + var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]); + return new VertexTexture3ColorFfxiv( + new Vector2(uv0.X, uv0.Y), + new Vector2(uv0.Z, uv0.W), + new Vector2(uv1.X, uv1.Y), + ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) + ); + } + + if (_materialType == typeof(VertexTexture3Color2Ffxiv)) + { + var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]); + var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]); + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + + return new VertexTexture3Color2Ffxiv( + new Vector2(uv0.X, uv0.Y), + new Vector2(uv0.Z, uv0.W), + new Vector2(uv1.X, uv1.Y), + ToVector4(color0), + ToVector4(color1) ); } @@ -439,25 +528,20 @@ public class MeshExporter } /// Get the vertex skinning type for this mesh's vertex usages. - private static Type GetSkinningType(IReadOnlyDictionary usages) + private Type GetSkinningType(IReadOnlyDictionary> usages) { if (usages.ContainsKey(MdlFile.VertexUsage.BlendWeights) && usages.ContainsKey(MdlFile.VertexUsage.BlendIndices)) { - if (usages[MdlFile.VertexUsage.BlendWeights] == MdlFile.VertexType.UShort4) - { - return typeof(VertexJoints8); - } - else - { - return typeof(VertexJoints4); - } + return GetFirstSafe(usages, MdlFile.VertexUsage.BlendWeights) == MdlFile.VertexType.UShort4 + ? typeof(VertexJoints8) + : typeof(VertexJoints4); } return typeof(VertexEmpty); } /// Build a skinning vertex from a vertex's attributes. - private IVertexSkinning BuildVertexSkinning(IReadOnlyDictionary attributes) + private IVertexSkinning BuildVertexSkinning(IReadOnlyDictionary> attributes) { if (_skinningType == typeof(VertexEmpty)) return new VertexEmpty(); @@ -467,8 +551,8 @@ public class MeshExporter if (_boneIndexMap == null) throw _notifier.Exception("Tried to build skinned vertex but no bone mappings are available."); - var indiciesData = attributes[MdlFile.VertexUsage.BlendIndices]; - var weightsData = attributes[MdlFile.VertexUsage.BlendWeights]; + var indiciesData = GetFirstSafe(attributes, MdlFile.VertexUsage.BlendIndices); + var weightsData = GetFirstSafe(attributes, MdlFile.VertexUsage.BlendWeights); var indices = ToByteArray(indiciesData); var weights = ToFloatArray(weightsData); @@ -495,6 +579,28 @@ public class MeshExporter throw _notifier.Exception($"Unknown skinning type {_skinningType}"); } + /// Check that the list has length 1 for any case where this is expected and return the one entry. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private T GetFirstSafe(IReadOnlyDictionary> attributes, MdlFile.VertexUsage usage) + { + var list = attributes[usage]; + if (list.Count != 1) + throw _notifier.Exception($"Multiple usage indices encountered for {usage}."); + + return list[0]; + } + + /// Check that the list has length 2 for any case where this is expected and return both entries. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private (T First, T Second) GetBothSafe(IReadOnlyDictionary> attributes, MdlFile.VertexUsage usage) + { + var list = attributes[usage]; + if (list.Count != 2) + throw _notifier.Exception($"{list.Count} usage indices encountered for {usage}, but expected 2."); + + return (list[0], list[1]); + } + /// Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. private static Vector2 ToVector2(object data) => data switch diff --git a/Penumbra/Import/Models/Export/VertexFragment.cs b/Penumbra/Import/Models/Export/VertexFragment.cs index eff34d54..463c59fc 100644 --- a/Penumbra/Import/Models/Export/VertexFragment.cs +++ b/Penumbra/Import/Models/Export/VertexFragment.cs @@ -1,4 +1,3 @@ -using System; using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.Memory; using SharpGLTF.Schema2; @@ -11,7 +10,7 @@ Realistically, it will need to stick around until transforms/mutations are built and there's reason to overhaul the export pipeline. */ -public struct VertexColorFfxiv : IVertexCustom +public struct VertexColorFfxiv(Vector4 ffxivColor) : IVertexCustom { public IEnumerable> GetEncodingAttributes() { @@ -20,7 +19,7 @@ public struct VertexColorFfxiv : IVertexCustom new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); } - public Vector4 FfxivColor; + public Vector4 FfxivColor = ffxivColor; public int MaxColors => 0; @@ -33,9 +32,6 @@ public struct VertexColorFfxiv : IVertexCustom public IEnumerable CustomAttributes => CustomNames; - public VertexColorFfxiv(Vector4 ffxivColor) - => FfxivColor = ffxivColor; - public void Add(in VertexMaterialDelta delta) { } @@ -88,7 +84,104 @@ public struct VertexColorFfxiv : IVertexCustom } } -public struct VertexTexture1ColorFfxiv : IVertexCustom +public struct VertexColor2Ffxiv(Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 0; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, Vector2.Zero, Vector2.Zero); + + public Vector2 GetTexCoord(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetTexCoord(int setIndex, Vector2 coord) + { } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } +} + + +public struct VertexTexture1ColorFfxiv(Vector2 texCoord0, Vector4 ffxivColor) : IVertexCustom { public IEnumerable> GetEncodingAttributes() { @@ -98,9 +191,9 @@ public struct VertexTexture1ColorFfxiv : IVertexCustom new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); } - public Vector2 TexCoord0; + public Vector2 TexCoord0 = texCoord0; - public Vector4 FfxivColor; + public Vector4 FfxivColor = ffxivColor; public int MaxColors => 0; @@ -113,12 +206,6 @@ public struct VertexTexture1ColorFfxiv : IVertexCustom public IEnumerable CustomAttributes => CustomNames; - public VertexTexture1ColorFfxiv(Vector2 texCoord0, Vector4 ffxivColor) - { - TexCoord0 = texCoord0; - FfxivColor = ffxivColor; - } - public void Add(in VertexMaterialDelta delta) { TexCoord0 += delta.TexCoord0Delta; @@ -182,7 +269,119 @@ public struct VertexTexture1ColorFfxiv : IVertexCustom } } -public struct VertexTexture2ColorFfxiv : IVertexCustom +public struct VertexTexture1Color2Ffxiv(Vector2 texCoord0, Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 1; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), Vector2.Zero); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex >= 1) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } +} + + +public struct VertexTexture2ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor) : IVertexCustom { public IEnumerable> GetEncodingAttributes() { @@ -194,9 +393,9 @@ public struct VertexTexture2ColorFfxiv : IVertexCustom new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); } - public Vector2 TexCoord0; - public Vector2 TexCoord1; - public Vector4 FfxivColor; + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector4 FfxivColor = ffxivColor; public int MaxColors => 0; @@ -209,13 +408,6 @@ public struct VertexTexture2ColorFfxiv : IVertexCustom public IEnumerable CustomAttributes => CustomNames; - public VertexTexture2ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor) - { - TexCoord0 = texCoord0; - TexCoord1 = texCoord1; - FfxivColor = ffxivColor; - } - public void Add(in VertexMaterialDelta delta) { TexCoord0 += delta.TexCoord0Delta; @@ -282,3 +474,346 @@ public struct VertexTexture2ColorFfxiv : IVertexCustom throw new ArgumentOutOfRangeException(nameof(FfxivColor)); } } + +public struct VertexTexture2Color2Ffxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 2; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex >= 2) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } + +} + +public struct VertexTexture3ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor) + : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_2", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector2 TexCoord2 = texCoord2; + public Vector4 FfxivColor = ffxivColor; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 3; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + TexCoord2 += delta.TexCoord2Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + 2 => TexCoord2, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex == 2) + TexCoord2 = coord; + if (setIndex >= 3) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR": + value = FfxivColor; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + if (attributeName == "_FFXIV_COLOR" && value is Vector4 valueVector4) + FfxivColor = valueVector4; + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor.X, + FfxivColor.Y, + FfxivColor.Z, + FfxivColor.W, + }; + if (components.Any(component => component is < 0f or > 1f)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor)); + } +} + +public struct VertexTexture3Color2Ffxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor0, Vector4 ffxivColor1) + : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector2 TexCoord2 = texCoord2; + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 3; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + TexCoord2 += delta.TexCoord2Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + 2 => TexCoord2, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex == 2) + TexCoord2 = coord; + if (setIndex >= 3) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } +} diff --git a/Penumbra/Import/Models/Import/MeshImporter.cs b/Penumbra/Import/Models/Import/MeshImporter.cs index 6a46fb9f..16fe2ca0 100644 --- a/Penumbra/Import/Models/Import/MeshImporter.cs +++ b/Penumbra/Import/Models/Import/MeshImporter.cs @@ -1,5 +1,5 @@ using Lumina.Data.Parsing; -using OtterGui; +using OtterGui.Extensions; using Penumbra.GameData.Files.ModelStructs; using SharpGLTF.Schema2; diff --git a/Penumbra/Import/Models/Import/ModelImporter.cs b/Penumbra/Import/Models/Import/ModelImporter.cs index a141d754..f4eefccc 100644 --- a/Penumbra/Import/Models/Import/ModelImporter.cs +++ b/Penumbra/Import/Models/Import/ModelImporter.cs @@ -1,5 +1,5 @@ using Lumina.Data.Parsing; -using OtterGui; +using OtterGui.Extensions; using Penumbra.GameData.Files; using Penumbra.GameData.Files.ModelStructs; using SharpGLTF.Schema2; @@ -8,6 +8,9 @@ namespace Penumbra.Import.Models.Import; public partial class ModelImporter(ModelRoot model, IoNotifier notifier) { + public const int BoneLimit = 128; + public const int MaterialLimit = 10; + public static MdlFile Import(ModelRoot model, IoNotifier notifier) { var importer = new ModelImporter(model, notifier); @@ -208,10 +211,9 @@ public partial class ModelImporter(ModelRoot model, IoNotifier notifier) if (index >= 0) return (ushort)index; - // If there's already 4 materials, we can't add any more. // TODO: permit, with a warning to reduce, and validation in MdlTab. var count = _materials.Count; - if (count >= 4) + if (count >= MaterialLimit) return 0; _materials.Add(materialName); @@ -234,11 +236,11 @@ public partial class ModelImporter(ModelRoot model, IoNotifier notifier) boneIndices.Add((ushort)boneIndex); } - if (boneIndices.Count > 64) - throw notifier.Exception("XIV does not support meshes weighted to a total of more than 64 bones."); + if (boneIndices.Count > BoneLimit) + throw notifier.Exception($"XIV does not support meshes weighted to a total of more than {BoneLimit} bones."); - var boneIndicesArray = new ushort[64]; - Array.Copy(boneIndices.ToArray(), boneIndicesArray, boneIndices.Count); + var boneIndicesArray = new ushort[BoneLimit]; + boneIndices.CopyTo(boneIndicesArray); var boneTableIndex = _boneTables.Count; _boneTables.Add(new BoneTableStruct() diff --git a/Penumbra/Import/Models/Import/PrimitiveImporter.cs b/Penumbra/Import/Models/Import/PrimitiveImporter.cs index 5df7597e..57c7929f 100644 --- a/Penumbra/Import/Models/Import/PrimitiveImporter.cs +++ b/Penumbra/Import/Models/Import/PrimitiveImporter.cs @@ -1,5 +1,5 @@ using Lumina.Data.Parsing; -using OtterGui; +using OtterGui.Extensions; using SharpGLTF.Schema2; namespace Penumbra.Import.Models.Import; diff --git a/Penumbra/Import/Models/Import/SubMeshImporter.cs b/Penumbra/Import/Models/Import/SubMeshImporter.cs index df08eea3..6aa46fb6 100644 --- a/Penumbra/Import/Models/Import/SubMeshImporter.cs +++ b/Penumbra/Import/Models/Import/SubMeshImporter.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Lumina.Data.Parsing; -using OtterGui; +using OtterGui.Extensions; using SharpGLTF.Schema2; namespace Penumbra.Import.Models.Import; diff --git a/Penumbra/Import/Models/Import/VertexAttribute.cs b/Penumbra/Import/Models/Import/VertexAttribute.cs index a1c3246b..155fa833 100644 --- a/Penumbra/Import/Models/Import/VertexAttribute.cs +++ b/Penumbra/Import/Models/Import/VertexAttribute.cs @@ -319,7 +319,7 @@ public class VertexAttribute var normals = normalAccessor.AsVector3Array(); var tangents = accessors.TryGetValue("TANGENT", out var accessor) - ? accessor.AsVector4Array() + ? accessor.AsVector4Array().ToArray() : CalculateTangents(accessors, indices, normals, notifier); if (tangents == null) diff --git a/Penumbra/Import/Models/ModelExtensions.cs b/Penumbra/Import/Models/ModelExtensions.cs new file mode 100644 index 00000000..2edb3ca4 --- /dev/null +++ b/Penumbra/Import/Models/ModelExtensions.cs @@ -0,0 +1,69 @@ +namespace Penumbra.Import.Models; + +public static class ModelExtensions +{ + // https://github.com/vpenades/SharpGLTF/blob/2073cf3cd671f8ecca9667f9a8c7f04ed865d3ac/src/Shared/_Extensions.cs#L158 + private const float UnitLengthThresholdVec3 = 0.00674f; + private const float UnitLengthThresholdVec4 = 0.00769f; + + internal static bool _IsFinite(this float value) + { + return float.IsFinite(value); + } + + internal static bool _IsFinite(this Vector2 v) + { + return v.X._IsFinite() && v.Y._IsFinite(); + } + + internal static bool _IsFinite(this Vector3 v) + { + return v.X._IsFinite() && v.Y._IsFinite() && v.Z._IsFinite(); + } + + internal static bool _IsFinite(this in Vector4 v) + { + return v.X._IsFinite() && v.Y._IsFinite() && v.Z._IsFinite() && v.W._IsFinite(); + } + + internal static Boolean IsNormalized(this Vector3 normal) + { + if (!normal._IsFinite()) return false; + + return Math.Abs(normal.Length() - 1) <= UnitLengthThresholdVec3; + } + + internal static void ValidateNormal(this Vector3 normal, string msg) + { + if (!normal._IsFinite()) throw new NotFiniteNumberException($"{msg} is invalid."); + + if (!normal.IsNormalized()) throw new ArithmeticException($"{msg} is not unit length."); + } + + internal static void ValidateTangent(this Vector4 tangent, string msg) + { + if (tangent.W != 1 && tangent.W != -1) throw new ArithmeticException(msg); + + new Vector3(tangent.X, tangent.Y, tangent.Z).ValidateNormal(msg); + } + + internal static Vector3 SanitizeNormal(this Vector3 normal) + { + if (normal == Vector3.Zero) return Vector3.UnitX; + return normal.IsNormalized() ? normal : Vector3.Normalize(normal); + } + + internal static bool IsValidTangent(this Vector4 tangent) + { + if (tangent.W != 1 && tangent.W != -1) return false; + + return new Vector3(tangent.X, tangent.Y, tangent.Z).IsNormalized(); + } + + internal static Vector4 SanitizeTangent(this Vector4 tangent) + { + var n = new Vector3(tangent.X, tangent.Y, tangent.Z).SanitizeNormal(); + var s = float.IsNaN(tangent.W) ? 1 : tangent.W; + return new Vector4(n, s > 0 ? 1 : -1); + } +} diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs index 0c19bc0a..6818ad64 100644 --- a/Penumbra/Import/Models/ModelManager.cs +++ b/Penumbra/Import/Models/ModelManager.cs @@ -1,6 +1,6 @@ using Dalamud.Plugin.Services; using Lumina.Data.Parsing; -using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using OtterGui.Tasks; using Penumbra.Collections.Manager; @@ -63,7 +63,7 @@ public sealed class ModelManager(IFramework framework, MetaFileManager metaFileM if (info.FileType is not FileType.Model) return []; - var baseSkeleton = GamePaths.Skeleton.Sklb.Path(info.GenderRace, "base", 1); + var baseSkeleton = GamePaths.Sklb.Customization(info.GenderRace, "base", 1); return info.ObjectType switch { @@ -79,9 +79,9 @@ public sealed class ModelManager(IFramework framework, MetaFileManager metaFileM ObjectType.Character when info.BodySlot is BodySlot.Face or BodySlot.Ear => [baseSkeleton, ..ResolveEstSkeleton(EstType.Face, info, estManipulations)], ObjectType.Character => throw new Exception($"Currently unsupported human model type \"{info.BodySlot}\"."), - ObjectType.DemiHuman => [GamePaths.DemiHuman.Sklb.Path(info.PrimaryId)], - ObjectType.Monster => [GamePaths.Monster.Sklb.Path(info.PrimaryId)], - ObjectType.Weapon => [GamePaths.Weapon.Sklb.Path(info.PrimaryId)], + ObjectType.DemiHuman => [GamePaths.Sklb.DemiHuman(info.PrimaryId)], + ObjectType.Monster => [GamePaths.Sklb.Monster(info.PrimaryId)], + ObjectType.Weapon => [GamePaths.Sklb.Weapon(info.PrimaryId)], _ => [], }; } @@ -105,7 +105,7 @@ public sealed class ModelManager(IFramework framework, MetaFileManager metaFileM if (targetId == EstEntry.Zero) return []; - return [GamePaths.Skeleton.Sklb.Path(info.GenderRace, type.ToName(), targetId.AsId)]; + return [GamePaths.Sklb.Customization(info.GenderRace, type.ToName(), targetId.AsId)]; } /// Try to resolve the absolute path to a .mtrl from the potentially-partial path provided by a model. @@ -137,7 +137,7 @@ public sealed class ModelManager(IFramework framework, MetaFileManager metaFileM var resolvedPath = info.ObjectType switch { - ObjectType.Character => GamePaths.Character.Mtrl.Path( + ObjectType.Character => GamePaths.Mtrl.Customization( info.GenderRace, info.BodySlot, info.PrimaryId, relativePath, out _, out _, info.Variant), _ => absolutePath, }; diff --git a/Penumbra/Import/Models/SkeletonConverter.cs b/Penumbra/Import/Models/SkeletonConverter.cs index 25e74332..e180662d 100644 --- a/Penumbra/Import/Models/SkeletonConverter.cs +++ b/Penumbra/Import/Models/SkeletonConverter.cs @@ -1,5 +1,5 @@ using System.Xml; -using OtterGui; +using OtterGui.Extensions; using Penumbra.Import.Models.Export; namespace Penumbra.Import.Models; diff --git a/Penumbra/Import/Structs/TexToolsStructs.cs b/Penumbra/Import/Structs/TexToolsStructs.cs index bf3bccd8..f5b5ef4a 100644 --- a/Penumbra/Import/Structs/TexToolsStructs.cs +++ b/Penumbra/Import/Structs/TexToolsStructs.cs @@ -26,7 +26,7 @@ public class SimpleMod public class ModPackPage { public int PageIndex = 0; - public ModGroup[] ModGroups = Array.Empty(); + public ModGroup[] ModGroups = []; } [Serializable] @@ -34,7 +34,7 @@ public class ModGroup { public string GroupName = string.Empty; public GroupType SelectionType = GroupType.Single; - public OptionList[] OptionList = Array.Empty(); + public OptionList[] OptionList = []; public string Description = string.Empty; } @@ -44,7 +44,7 @@ public class OptionList public string Name = string.Empty; public string Description = string.Empty; public string ImagePath = string.Empty; - public SimpleMod[] ModsJsons = Array.Empty(); + public SimpleMod[] ModsJsons = []; public string GroupName = string.Empty; public GroupType SelectionType = GroupType.Single; public bool IsChecked = false; @@ -59,8 +59,8 @@ public class ExtendedModPack public string Version = string.Empty; public string Description = DefaultTexToolsData.Description; public string Url = string.Empty; - public ModPackPage[] ModPackPages = Array.Empty(); - public SimpleMod[] SimpleModsList = Array.Empty(); + public ModPackPage[] ModPackPages = []; + public SimpleMod[] SimpleModsList = []; } [Serializable] @@ -72,5 +72,5 @@ public class SimpleModPack public string Version = string.Empty; public string Description = DefaultTexToolsData.Description; public string Url = string.Empty; - public SimpleMod[] SimpleModsList = Array.Empty(); + public SimpleMod[] SimpleModsList = []; } diff --git a/Penumbra/Import/TexToolsImport.cs b/Penumbra/Import/TexToolsImport.cs index fed06573..8e4fea41 100644 --- a/Penumbra/Import/TexToolsImport.cs +++ b/Penumbra/Import/TexToolsImport.cs @@ -119,7 +119,7 @@ public partial class TexToolsImporter : IDisposable // Puts out warnings if extension does not correspond to data. private DirectoryInfo VerifyVersionAndImport(FileInfo modPackFile) { - if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".zip" or ".7z" or ".rar") + if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".pcp" or ".zip" or ".7z" or ".rar") return HandleRegularArchive(modPackFile); using var zfs = modPackFile.OpenRead(); diff --git a/Penumbra/Import/TexToolsImporter.Archives.cs b/Penumbra/Import/TexToolsImporter.Archives.cs index febbe179..a80730bf 100644 --- a/Penumbra/Import/TexToolsImporter.Archives.cs +++ b/Penumbra/Import/TexToolsImporter.Archives.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq; using OtterGui.Filesystem; using Penumbra.Import.Structs; using Penumbra.Mods; +using Penumbra.Services; using SharpCompress.Archives; using SharpCompress.Archives.Rar; using SharpCompress.Archives.SevenZip; @@ -96,17 +97,36 @@ public partial class TexToolsImporter _token.ThrowIfCancellationRequested(); var oldName = _currentModDirectory.FullName; - // Use either the top-level directory as the mods base name, or the (fixed for path) name in the json. - if (leadDir) + + // Try renaming the folder three times because sometimes we get AccessDenied here for some unknown reason. + const int numTries = 3; + for (var i = 1;; ++i) { - _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, baseName, _config.ReplaceNonAsciiOnImport, false); - Directory.Move(Path.Combine(oldName, baseName), _currentModDirectory.FullName); - Directory.Delete(oldName); - } - else - { - _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, name, _config.ReplaceNonAsciiOnImport, false); - Directory.Move(oldName, _currentModDirectory.FullName); + // Use either the top-level directory as the mods base name, or the (fixed for path) name in the json. + try + { + if (leadDir) + { + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, baseName, _config.ReplaceNonAsciiOnImport, false); + Directory.Move(Path.Combine(oldName, baseName), _currentModDirectory.FullName); + Directory.Delete(oldName); + } + else + { + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, name, _config.ReplaceNonAsciiOnImport, false); + Directory.Move(oldName, _currentModDirectory.FullName); + } + } + catch (IOException io) + { + if (i == numTries) + throw; + + Penumbra.Log.Warning($"Error when renaming the extracted mod, try {i}/{numTries}: {io.Message}."); + continue; + } + + break; } _currentModDirectory.Refresh(); @@ -127,6 +147,9 @@ public partial class TexToolsImporter case ".mtrl": _migrationManager.MigrateMtrlDuringExtraction(reader, _currentModDirectory!.FullName, _extractionOptions); break; + case ".tex": + _migrationManager.FixMipMaps(reader, _currentModDirectory!.FullName, _extractionOptions); + break; default: reader.WriteEntryToDirectory(_currentModDirectory!.FullName, _extractionOptions); break; diff --git a/Penumbra/Import/TexToolsImporter.Gui.cs b/Penumbra/Import/TexToolsImporter.Gui.cs index f145f560..5cb99d72 100644 --- a/Penumbra/Import/TexToolsImporter.Gui.cs +++ b/Penumbra/Import/TexToolsImporter.Gui.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using Penumbra.Import.Structs; diff --git a/Penumbra/Import/TexToolsImporter.ModPack.cs b/Penumbra/Import/TexToolsImporter.ModPack.cs index 7bbb762e..fd9e50c0 100644 --- a/Penumbra/Import/TexToolsImporter.ModPack.cs +++ b/Penumbra/Import/TexToolsImporter.ModPack.cs @@ -1,5 +1,5 @@ using Newtonsoft.Json; -using OtterGui; +using OtterGui.Extensions; using Penumbra.Api.Enums; using Penumbra.Import.Structs; using Penumbra.Mods; @@ -259,6 +259,7 @@ public partial class TexToolsImporter { ".mdl" => _migrationManager.MigrateTtmpModel(extractedFile.FullName, data.Data), ".mtrl" => _migrationManager.MigrateTtmpMaterial(extractedFile.FullName, data.Data), + ".tex" => _migrationManager.FixTtmpMipMaps(extractedFile.FullName, data.Data), _ => data.Data, }; diff --git a/Penumbra/Import/TexToolsMeta.Deserialization.cs b/Penumbra/Import/TexToolsMeta.Deserialization.cs index 1f970dfe..7861a95b 100644 --- a/Penumbra/Import/TexToolsMeta.Deserialization.cs +++ b/Penumbra/Import/TexToolsMeta.Deserialization.cs @@ -2,7 +2,6 @@ using Lumina.Extensions; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; using Penumbra.Import.Structs; -using Penumbra.Meta.Files; using Penumbra.Meta.Manipulations; namespace Penumbra.Import; @@ -19,9 +18,7 @@ public partial class TexToolsMeta var identifier = new EqpIdentifier(metaFileInfo.PrimaryId, metaFileInfo.EquipSlot); var value = Eqp.FromSlotAndBytes(metaFileInfo.EquipSlot, data) & mask; - var def = ExpandedEqpFile.GetDefault(_metaFileManager, metaFileInfo.PrimaryId) & mask; - if (_keepDefault || def != value) - MetaManipulations.TryAdd(identifier, value); + MetaManipulations.TryAdd(identifier, value); } // Deserialize and check Eqdp Entries and add them to the list if they are non-default. @@ -41,11 +38,9 @@ public partial class TexToolsMeta continue; var identifier = new EqdpIdentifier(metaFileInfo.PrimaryId, metaFileInfo.EquipSlot, gr); - var mask = Eqdp.Mask(metaFileInfo.EquipSlot); - var value = Eqdp.FromSlotAndBits(metaFileInfo.EquipSlot, (byteValue & 1) == 1, (byteValue & 2) == 2) & mask; - var def = ExpandedEqdpFile.GetDefault(_metaFileManager, gr, metaFileInfo.EquipSlot.IsAccessory(), metaFileInfo.PrimaryId) & mask; - if (_keepDefault || def != value) - MetaManipulations.TryAdd(identifier, value); + var mask = Eqdp.Mask(metaFileInfo.EquipSlot); + var value = Eqdp.FromSlotAndBits(metaFileInfo.EquipSlot, (byteValue & 1) == 1, (byteValue & 2) == 2) & mask; + MetaManipulations.TryAdd(identifier, value); } } @@ -55,10 +50,9 @@ public partial class TexToolsMeta if (data == null) return; - var value = GmpEntry.FromTexToolsMeta(data.AsSpan(0, 5)); - var def = ExpandedGmpFile.GetDefault(_metaFileManager, metaFileInfo.PrimaryId); - if (_keepDefault || value != def) - MetaManipulations.TryAdd(new GmpIdentifier(metaFileInfo.PrimaryId), value); + var value = GmpEntry.FromTexToolsMeta(data.AsSpan(0, 5)); + var identifier = new GmpIdentifier(metaFileInfo.PrimaryId); + MetaManipulations.TryAdd(identifier, value); } // Deserialize and check Est Entries and add them to the list if they are non-default. @@ -86,9 +80,7 @@ public partial class TexToolsMeta continue; var identifier = new EstIdentifier(id, type, gr); - var def = EstFile.GetDefault(_metaFileManager, type, gr, id); - if (_keepDefault || def != value) - MetaManipulations.TryAdd(identifier, value); + MetaManipulations.TryAdd(identifier, value); } } @@ -108,15 +100,10 @@ public partial class TexToolsMeta { var identifier = new ImcIdentifier(metaFileInfo.PrimaryId, 0, metaFileInfo.PrimaryType, metaFileInfo.SecondaryId, metaFileInfo.EquipSlot, metaFileInfo.SecondaryType); - var file = new ImcFile(_metaFileManager, identifier); - var partIdx = ImcFile.PartIndex(identifier.EquipSlot); // Gets turned to unknown for things without equip, and unknown turns to 0. foreach (var value in values) { identifier = identifier with { Variant = (Variant)i }; - var def = file.GetEntry(partIdx, (Variant)i); - if (_keepDefault || def != value && identifier.Validate()) - MetaManipulations.TryAdd(identifier, value); - + MetaManipulations.TryAdd(identifier, value); ++i; } } diff --git a/Penumbra/Import/TexToolsMeta.Rgsp.cs b/Penumbra/Import/TexToolsMeta.Rgsp.cs index 7b0bb5a8..77c70e6c 100644 --- a/Penumbra/Import/TexToolsMeta.Rgsp.cs +++ b/Penumbra/Import/TexToolsMeta.Rgsp.cs @@ -1,6 +1,5 @@ using Penumbra.GameData.Enums; using Penumbra.Meta; -using Penumbra.Meta.Files; using Penumbra.Meta.Manipulations; namespace Penumbra.Import; @@ -8,7 +7,7 @@ namespace Penumbra.Import; public partial class TexToolsMeta { // Parse a single rgsp file. - public static TexToolsMeta FromRgspFile(MetaFileManager manager, string filePath, byte[] data, bool keepDefault) + public static TexToolsMeta FromRgspFile(MetaFileManager manager, string filePath, byte[] data) { if (data.Length != 45 && data.Length != 42) { @@ -70,9 +69,7 @@ public partial class TexToolsMeta void Add(RspAttribute attribute, float value) { var identifier = new RspIdentifier(subRace, attribute); - var def = CmpFile.GetDefault(manager, subRace, attribute); - if (keepDefault || value != def.Value) - ret.MetaManipulations.TryAdd(identifier, new RspEntry(value)); + ret.MetaManipulations.TryAdd(identifier, new RspEntry(value)); } } } diff --git a/Penumbra/Import/TexToolsMeta.cs b/Penumbra/Import/TexToolsMeta.cs index c4a8e81f..f98eddbe 100644 --- a/Penumbra/Import/TexToolsMeta.cs +++ b/Penumbra/Import/TexToolsMeta.cs @@ -23,15 +23,11 @@ public partial class TexToolsMeta // The info class determines the files or table locations the changes need to apply to from the filename. public readonly uint Version; public readonly string FilePath; - public readonly MetaDictionary MetaManipulations = new(); - private readonly bool _keepDefault; + public readonly MetaDictionary MetaManipulations = new(); - private readonly MetaFileManager _metaFileManager; - public TexToolsMeta(MetaFileManager metaFileManager, GamePathParser parser, byte[] data, bool keepDefault) + public TexToolsMeta(GamePathParser parser, byte[] data) { - _metaFileManager = metaFileManager; - _keepDefault = keepDefault; try { using var reader = new BinaryReader(new MemoryStream(data)); @@ -79,7 +75,6 @@ public partial class TexToolsMeta private TexToolsMeta(MetaFileManager metaFileManager, string filePath, uint version) { - _metaFileManager = metaFileManager; FilePath = filePath; Version = version; } diff --git a/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs b/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs index 2d131d71..7a7e5888 100644 --- a/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs +++ b/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; using OtterGui; using SixLabors.ImageSharp.PixelFormats; diff --git a/Penumbra/Import/Textures/CombinedTexture.cs b/Penumbra/Import/Textures/CombinedTexture.cs index c1a22088..f5f921be 100644 --- a/Penumbra/Import/Textures/CombinedTexture.cs +++ b/Penumbra/Import/Textures/CombinedTexture.cs @@ -6,7 +6,10 @@ public partial class CombinedTexture : IDisposable { AsIs, Bitmap, + BC1, BC3, + BC4, + BC5, BC7, } diff --git a/Penumbra/Import/Textures/TexFileParser.cs b/Penumbra/Import/Textures/TexFileParser.cs index 220095c1..04bbf5d8 100644 --- a/Penumbra/Import/Textures/TexFileParser.cs +++ b/Penumbra/Import/Textures/TexFileParser.cs @@ -61,6 +61,76 @@ public static class TexFileParser return 13; } + public static unsafe void FixMipOffsets(long size, ref TexFile.TexHeader header, out long newSize) + { + var width = (uint)header.Width; + var height = (uint)header.Height; + var format = header.Format.ToDXGI(); + var bits = format.BitsPerPixel(); + var totalSize = 80u; + size -= totalSize; + var minSize = format.IsCompressed() ? 4u : 1u; + for (var i = 0; i < 13; ++i) + { + var requiredSize = (uint)((long)width * height * bits / 8); + if (requiredSize > size) + { + newSize = totalSize; + if (header.MipCount != i) + { + Penumbra.Log.Debug( + $"-- Mip Map Count in TEX header was {header.MipCount}, but file only contains data for {i} Mip Maps, fixed."); + FixLodOffsets(ref header, i); + } + + return; + } + + if (header.OffsetToSurface[i] != totalSize) + { + Penumbra.Log.Debug( + $"-- Mip Map Offset {i + 1} in TEX header was {header.OffsetToSurface[i]} but should be {totalSize}, fixed."); + header.OffsetToSurface[i] = totalSize; + } + + if (width == minSize && height == minSize) + { + ++i; + newSize = totalSize + requiredSize; + if (header.MipCount != i) + { + Penumbra.Log.Debug($"-- Reduced number of Mip Maps from {header.MipCount} to {i} due to minimum size constraints."); + FixLodOffsets(ref header, i); + } + + return; + } + + totalSize += requiredSize; + size -= requiredSize; + width = Math.Max(width / 2, minSize); + height = Math.Max(height / 2, minSize); + } + + newSize = totalSize; + if (header.MipCount != 13) + { + Penumbra.Log.Debug($"-- Mip Map Count in TEX header was {header.MipCount}, but maximum is 13, fixed."); + FixLodOffsets(ref header, 13); + } + + void FixLodOffsets(ref TexFile.TexHeader header, int index) + { + header.MipCount = index; + if (header.LodOffset[2] >= header.MipCount) + header.LodOffset[2] = (byte)(header.MipCount - 1); + if (header.LodOffset[1] >= header.MipCount) + header.LodOffset[1] = header.MipCount > 2 ? (byte)(header.MipCount - 2) : (byte)(header.MipCount - 1); + for (++index; index < 13; ++index) + header.OffsetToSurface[index] = 0; + } + } + private static unsafe void CopyData(ScratchImage image, BinaryReader r) { fixed (byte* ptr = image.Pixels) diff --git a/Penumbra/Import/Textures/TextureDrawer.cs b/Penumbra/Import/Textures/TextureDrawer.cs index b0a65ac0..14203dff 100644 --- a/Penumbra/Import/Textures/TextureDrawer.cs +++ b/Penumbra/Import/Textures/TextureDrawer.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; -using ImGuiNET; using Lumina.Data.Files; using OtterGui; using OtterGui.Raii; @@ -20,7 +20,7 @@ public static class TextureDrawer { size = texture.TextureWrap.Size.Contain(size); - ImGui.Image(texture.TextureWrap.ImGuiHandle, size); + ImGui.Image(texture.TextureWrap.Handle, size); DrawData(texture); } else if (texture.LoadError != null) diff --git a/Penumbra/Import/Textures/TextureManager.cs b/Penumbra/Import/Textures/TextureManager.cs index 7118f8af..073fef2f 100644 --- a/Penumbra/Import/Textures/TextureManager.cs +++ b/Penumbra/Import/Textures/TextureManager.cs @@ -1,3 +1,4 @@ +using Dalamud.Interface; using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Plugin.Services; @@ -6,15 +7,17 @@ using OtterGui.Log; using OtterGui.Services; using OtterGui.Tasks; using OtterTex; +using SharpDX.Direct3D11; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Tga; using SixLabors.ImageSharp.PixelFormats; +using DxgiDevice = SharpDX.DXGI.Device; using Image = SixLabors.ImageSharp.Image; namespace Penumbra.Import.Textures; -public sealed class TextureManager(IDataManager gameData, Logger logger, ITextureProvider textureProvider) +public sealed class TextureManager(IDataManager gameData, Logger logger, ITextureProvider textureProvider, IUiBuilder uiBuilder) : SingleTaskQueue, IDisposable, IService { private readonly Logger _logger = logger; @@ -201,8 +204,11 @@ public sealed class TextureManager(IDataManager gameData, Logger logger, ITextur rgba, width, height), CombinedTexture.TextureSaveType.AsIs when imageTypeBehaviour is TextureType.Dds => AddMipMaps(image.AsDds!, _mipMaps), CombinedTexture.TextureSaveType.Bitmap => ConvertToRgbaDds(image, _mipMaps, cancel, rgba, width, height), - CombinedTexture.TextureSaveType.BC3 => ConvertToCompressedDds(image, _mipMaps, false, cancel, rgba, width, height), - CombinedTexture.TextureSaveType.BC7 => ConvertToCompressedDds(image, _mipMaps, true, cancel, rgba, width, height), + CombinedTexture.TextureSaveType.BC1 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC1UNorm, cancel, rgba, width, height), + CombinedTexture.TextureSaveType.BC3 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC3UNorm, cancel, rgba, width, height), + CombinedTexture.TextureSaveType.BC4 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC4UNorm, cancel, rgba, width, height), + CombinedTexture.TextureSaveType.BC5 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC5UNorm, cancel, rgba, width, height), + CombinedTexture.TextureSaveType.BC7 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC7UNorm, cancel, rgba, width, height), _ => throw new Exception("Wrong save type."), }; @@ -320,7 +326,7 @@ public sealed class TextureManager(IDataManager gameData, Logger logger, ITextur } /// Convert an existing image to a block compressed .dds. Does not create a deep copy of an existing dds of the correct format and just returns the existing one. - public static BaseImage ConvertToCompressedDds(BaseImage input, bool mipMaps, bool bc7, CancellationToken cancel, byte[]? rgba = null, + public BaseImage ConvertToCompressedDds(BaseImage input, bool mipMaps, DXGIFormat format, CancellationToken cancel, byte[]? rgba = null, int width = 0, int height = 0) { switch (input.Type.ReduceToBehaviour()) @@ -331,12 +337,12 @@ public sealed class TextureManager(IDataManager gameData, Logger logger, ITextur cancel.ThrowIfCancellationRequested(); var dds = ConvertToDds(rgba, width, height).AsDds!; cancel.ThrowIfCancellationRequested(); - return CreateCompressed(dds, mipMaps, bc7, cancel); + return CreateCompressed(dds, mipMaps, format, cancel); } case TextureType.Dds: { var scratch = input.AsDds!; - return CreateCompressed(scratch, mipMaps, bc7, cancel); + return CreateCompressed(scratch, mipMaps, format, cancel); } default: return new BaseImage(); } @@ -384,9 +390,8 @@ public sealed class TextureManager(IDataManager gameData, Logger logger, ITextur } /// Create a BC3 or BC7 block-compressed .dds from the input (optionally with mipmaps). Returns input (+ mipmaps) if it is already the correct format. - public static ScratchImage CreateCompressed(ScratchImage input, bool mipMaps, bool bc7, CancellationToken cancel) + public ScratchImage CreateCompressed(ScratchImage input, bool mipMaps, DXGIFormat format, CancellationToken cancel) { - var format = bc7 ? DXGIFormat.BC7UNorm : DXGIFormat.BC3UNorm; if (input.Meta.Format == format) return input; @@ -398,6 +403,16 @@ public sealed class TextureManager(IDataManager gameData, Logger logger, ITextur input = AddMipMaps(input, mipMaps); cancel.ThrowIfCancellationRequested(); + // See https://github.com/microsoft/DirectXTex/wiki/Compress#parameters for the format condition. + if (format is DXGIFormat.BC6HUF16 or DXGIFormat.BC6HSF16 or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB) + { + var device = new Device(uiBuilder.DeviceHandle); + var dxgiDevice = device.QueryInterface(); + + using var deviceClone = new Device(dxgiDevice.Adapter, device.CreationFlags, device.FeatureLevel); + return input.Compress(deviceClone.NativePointer, format, CompressFlags.Parallel); + } + return input.Compress(format, CompressFlags.BC7Quick | CompressFlags.Parallel); } diff --git a/Penumbra/Interop/CloudApi.cs b/Penumbra/Interop/CloudApi.cs new file mode 100644 index 00000000..603d4c9f --- /dev/null +++ b/Penumbra/Interop/CloudApi.cs @@ -0,0 +1,47 @@ +namespace Penumbra.Interop; + +public static unsafe partial class CloudApi +{ + private const int CfSyncRootInfoBasic = 0; + + /// Determines whether a file or directory is cloud-synced using OneDrive or other providers that use the Cloud API. + /// Can be expensive. Callers should cache the result when relevant. + public static bool IsCloudSynced(string path) + { + var buffer = stackalloc long[1]; + int hr; + uint length; + try + { + hr = CfGetSyncRootInfoByPath(path, CfSyncRootInfoBasic, buffer, sizeof(long), out length); + } + catch (DllNotFoundException) + { + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw DllNotFoundException"); + return false; + } + catch (EntryPointNotFoundException) + { + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw EntryPointNotFoundException"); + return false; + } + + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned HRESULT 0x{hr:X8}"); + if (hr < 0) + return false; + + if (length != sizeof(long)) + { + Penumbra.Log.Debug($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes"); + return false; + } + + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned {{ SyncRootFileId = 0x{*buffer:X16} }}"); + + return true; + } + + [LibraryImport("cldapi.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial int CfGetSyncRootInfoByPath(string filePath, int infoClass, void* infoBuffer, uint infoBufferLength, + out uint returnedLength); +} diff --git a/Penumbra/Interop/GameState.cs b/Penumbra/Interop/GameState.cs index 7e7abcd8..b5171244 100644 --- a/Penumbra/Interop/GameState.cs +++ b/Penumbra/Interop/GameState.cs @@ -11,7 +11,8 @@ public class GameState : IService { #region Last Game Object - private readonly ThreadLocal> _lastGameObject = new(() => new Queue()); + private readonly ThreadLocal> _lastGameObject = new(() => new Queue()); + public readonly ThreadLocal CharacterAssociated = new(() => false); public nint LastGameObject => _lastGameObject.IsValueCreated && _lastGameObject.Value!.Count > 0 ? _lastGameObject.Value.Peek() : nint.Zero; @@ -49,15 +50,15 @@ public class GameState : IService public void RestoreAnimationData(ResolveData old) => _animationLoadData.Value = old; + public readonly ThreadLocal InLoadActionTmb = new(() => false); + public readonly ThreadLocal SkipTmbCache = new(() => false); + #endregion #region Sound Data private readonly ThreadLocal _characterSoundData = new(() => ResolveData.Invalid, true); - public ResolveData SoundData - => _animationLoadData.Value; - [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] public ResolveData SetSoundData(ResolveData data) { diff --git a/Penumbra/Interop/Hooks/Animation/GetCachedScheduleResource.cs b/Penumbra/Interop/Hooks/Animation/GetCachedScheduleResource.cs new file mode 100644 index 00000000..6ce1f899 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/GetCachedScheduleResource.cs @@ -0,0 +1,53 @@ +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using JetBrains.Annotations; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Structs; +using Penumbra.String; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Load a cached TMB resource from SchedulerResourceManagement. +public sealed unsafe class GetCachedScheduleResource : FastHook +{ + private readonly GameState _state; + + public GetCachedScheduleResource(HookManager hooks, GameState state) + { + _state = state; + Task = hooks.CreateHook("Get Cached Schedule Resource", Sigs.GetCachedScheduleResource, Detour, + !HookOverrides.Instance.Animation.GetCachedScheduleResource); + } + + public delegate SchedulerResource* Delegate(SchedulerResourceManagement* a, ScheduleResourceLoadData* b, byte useMap); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private SchedulerResource* Detour(SchedulerResourceManagement* a, ScheduleResourceLoadData* b, byte c) + { + if (_state.SkipTmbCache.Value) + { + Penumbra.Log.Verbose( + $"[GetCachedScheduleResource] Called with 0x{(ulong)a:X}, {b->Id}, {new CiByteString(b->Path, MetaDataComputation.None)}, {c} from LoadActionTmb with forced skipping of cache, returning NULL."); + return null; + } + + var ret = Task.Result.Original(a, b, c); + Penumbra.Log.Excessive( + $"[GetCachedScheduleResource] Called with 0x{(ulong)a:X}, {b->Id}, {new CiByteString(b->Path, MetaDataComputation.None)}, {c}, returning 0x{(ulong)ret:X} ({(ret != null && Resource(ret) != null ? Resource(ret)->FileName().ToString() : "No Path")})."); + return ret; + } + + public struct ScheduleResourceLoadData + { + [UsedImplicitly] + public byte* Path; + + [UsedImplicitly] + public uint Id; + } + + + // #TODO: remove when fixed in CS. + public static ResourceHandle* Resource(SchedulerResource* r) + => ((ResourceHandle**)r)[3]; +} diff --git a/Penumbra/Interop/Hooks/Animation/LoadActionTmb.cs b/Penumbra/Interop/Hooks/Animation/LoadActionTmb.cs new file mode 100644 index 00000000..457465d2 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/LoadActionTmb.cs @@ -0,0 +1,55 @@ +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Services; +using Penumbra.String; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Load a Action TMB. +public sealed unsafe class LoadActionTmb : FastHook +{ + private readonly GameState _state; + private readonly SchedulerResourceManagementService _scheduler; + + public LoadActionTmb(HookManager hooks, GameState state, SchedulerResourceManagementService scheduler) + { + _state = state; + _scheduler = scheduler; + Task = hooks.CreateHook("Load Action TMB", Sigs.LoadActionTmb, Detour, !HookOverrides.Instance.Animation.LoadActionTmb); + } + + public delegate SchedulerResource* Delegate(SchedulerResourceManagement* scheduler, + GetCachedScheduleResource.ScheduleResourceLoadData* loadData, nint b, byte c, byte d, byte e); + + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private SchedulerResource* Detour(SchedulerResourceManagement* scheduler, GetCachedScheduleResource.ScheduleResourceLoadData* loadData, + nint b, byte c, byte d, byte e) + { + _state.InLoadActionTmb.Value = true; + SchedulerResource* ret; + if (ShouldSkipCache(loadData)) + { + _state.SkipTmbCache.Value = true; + ret = Task.Result.Original(scheduler, loadData, b, c, d, 1); + Penumbra.Log.Verbose( + $"[LoadActionTMB] Called with 0x{(ulong)scheduler:X}, {loadData->Id}, {new CiByteString(loadData->Path, MetaDataComputation.None)}, 0x{b:X}, {c}, {d}, {e}, forced no-cache use, returned 0x{(ulong)ret:X} ({(ret != null && GetCachedScheduleResource.Resource(ret) != null ? GetCachedScheduleResource.Resource(ret)->FileName().ToString() : "No Path")})."); + _state.SkipTmbCache.Value = false; + } + else + { + ret = Task.Result.Original(scheduler, loadData, b, c, d, e); + Penumbra.Log.Excessive( + $"[LoadActionTMB] Called with 0x{(ulong)scheduler:X}, {loadData->Id}, {new CiByteString(loadData->Path)}, 0x{b:X}, {c}, {d}, {e}, returned 0x{(ulong)ret:X} ({(ret != null && GetCachedScheduleResource.Resource(ret) != null ? GetCachedScheduleResource.Resource(ret)->FileName().ToString() : "No Path")})."); + } + + _state.InLoadActionTmb.Value = false; + + return ret; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool ShouldSkipCache(GetCachedScheduleResource.ScheduleResourceLoadData* loadData) + => _scheduler.Contains(loadData->Id); +} diff --git a/Penumbra/Interop/Hooks/HookSettings.cs b/Penumbra/Interop/Hooks/HookSettings.cs index 63d93c9d..bcff25d2 100644 --- a/Penumbra/Interop/Hooks/HookSettings.cs +++ b/Penumbra/Interop/Hooks/HookSettings.cs @@ -43,6 +43,8 @@ public class HookOverrides public bool SomeMountAnimation; public bool SomePapLoad; public bool SomeParasolAnimation; + public bool GetCachedScheduleResource; + public bool LoadActionTmb; } public struct MetaHooks @@ -74,6 +76,8 @@ public class HookOverrides public bool CreateCharacterBase; public bool EnableDraw; public bool WeaponReload; + public bool SetupPlayerNpc; + public bool ConstructCutsceneCharacter; } public struct PostProcessingHooks @@ -84,6 +88,7 @@ public class HookOverrides public bool ModelRendererOnRenderMaterial; public bool ModelRendererUnkFunc; public bool PrepareColorTable; + public bool RenderTargetManagerInitialize; } public struct ResourceLoadingHooks @@ -95,6 +100,7 @@ public class HookOverrides public bool DecRef; public bool GetResourceSync; public bool GetResourceAsync; + public bool UpdateResourceState; public bool CheckFileState; public bool TexResourceHandleOnLoad; public bool LoadMdlFileExtern; diff --git a/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs b/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs index 07e34a66..2a3d7468 100644 --- a/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs +++ b/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs @@ -25,12 +25,12 @@ public unsafe class AtchCallerHook1 : FastHook, IDispo private void Detour(DrawObjectData* data, uint slot, nint unk, Model playerModel) { - var collection = _collectionResolver.IdentifyCollection(playerModel.AsDrawObject, true); + var collection = playerModel.Valid ? _collectionResolver.IdentifyCollection(playerModel.AsDrawObject, true) : _collectionResolver.DefaultCollection; _metaState.AtchCollection.Push(collection); Task.Result.Original(data, slot, unk, playerModel); _metaState.AtchCollection.Pop(); - Penumbra.Log.Information( - $"[AtchCaller1] Invoked on 0x{(ulong)data:X} with {slot}, {unk:X}, 0x{playerModel.Address:X}, identified to {collection.ModCollection.AnonymizedName}."); + Penumbra.Log.Excessive( + $"[AtchCaller1] Invoked on 0x{(ulong)data:X} with {slot}, {unk:X}, 0x{playerModel.Address:X}, identified to {collection.ModCollection.Identity.AnonymizedName}."); } public void Dispose() diff --git a/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs b/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs index aa2d3f31..af38ce50 100644 --- a/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs +++ b/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs @@ -30,7 +30,7 @@ public unsafe class AtchCallerHook2 : FastHook, IDispo Task.Result.Original(data, slot, unk, playerModel, unk2); _metaState.AtchCollection.Pop(); Penumbra.Log.Excessive( - $"[AtchCaller2] Invoked on 0x{(ulong)data:X} with {slot}, {unk:X}, 0x{playerModel.Address:X}, {unk2}, identified to {collection.ModCollection.AnonymizedName}."); + $"[AtchCaller2] Invoked on 0x{(ulong)data:X} with {slot}, {unk:X}, 0x{playerModel.Address:X}, {unk2}, identified to {collection.ModCollection.Identity.AnonymizedName}."); } public void Dispose() diff --git a/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs b/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs index 368845b4..523ae610 100644 --- a/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs +++ b/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs @@ -16,7 +16,7 @@ public sealed unsafe class ChangeCustomize : FastHook { _collectionResolver = collectionResolver; _metaState = metaState; - Task = hooks.CreateHook("Change Customize", Sigs.ChangeCustomize, Detour, !HookOverrides.Instance.Meta.ChangeCustomize); + Task = hooks.CreateHook("Change Customize", Sigs.UpdateDrawData, Detour, !HookOverrides.Instance.Meta.ChangeCustomize); } public delegate bool Delegate(Human* human, CustomizeArray* data, byte skipEquipment); diff --git a/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs b/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs index 7636718e..2d8e60b2 100644 --- a/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs +++ b/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs @@ -2,7 +2,6 @@ using Dalamud.Hooking; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using OtterGui.Classes; using OtterGui.Services; -using Penumbra.UI.AdvancedWindow; namespace Penumbra.Interop.Hooks.Objects; @@ -13,7 +12,7 @@ public sealed unsafe class CharacterBaseDestructor : EventWrapperPtr DrawObjectState = 0, - /// + /// MtrlTab = -1000, } @@ -42,7 +41,7 @@ public sealed unsafe class CharacterBaseDestructor : EventWrapperPtr IdentifiedCollectionCache = 0, + + /// + DrawObjectState = 0, } public CharacterDestructor(HookManager hooks) @@ -42,7 +45,7 @@ public sealed unsafe class CharacterDestructor : EventWrapperPtr, IHookService +{ + private readonly GameState _gameState; + private readonly ObjectManager _objects; + + public enum Priority + { + /// + CutsceneService = 0, + } + + public ConstructCutsceneCharacter(GameState gameState, HookManager hooks, ObjectManager objects) + : base("ConstructCutsceneCharacter") + { + _gameState = gameState; + _objects = objects; + _task = hooks.CreateHook(Name, Sigs.ConstructCutsceneCharacter, Detour, !HookOverrides.Instance.Objects.ConstructCutsceneCharacter); + } + + private readonly Task> _task; + + public delegate int Delegate(SetupPlayerNpc.SchedulerStruct* scheduler); + + public int Detour(SetupPlayerNpc.SchedulerStruct* scheduler) + { + // This is the function that actually creates the new game object + // and fills it into the object table at a free index etc. + var ret = _task.Result.Original(scheduler); + // Check for the copy state from SetupPlayerNpc. + if (_gameState.CharacterAssociated.Value) + { + // If the newly created character exists, invoke the event. + var character = _objects[ret + (int)ScreenActor.CutsceneStart].AsCharacter; + if (character != null) + { + Invoke(character); + Penumbra.Log.Verbose( + $"[{Name}] Created indirect copy of player character at 0x{(nint)character}, index {character->ObjectIndex}."); + } + _gameState.CharacterAssociated.Value = false; + } + + return ret; + } + + public IntPtr Address + => _task.Result.Address; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; +} diff --git a/Penumbra/Interop/Hooks/Objects/EnableDraw.cs b/Penumbra/Interop/Hooks/Objects/EnableDraw.cs index 68bb28af..979cb87c 100644 --- a/Penumbra/Interop/Hooks/Objects/EnableDraw.cs +++ b/Penumbra/Interop/Hooks/Objects/EnableDraw.cs @@ -26,7 +26,7 @@ public sealed unsafe class EnableDraw : IHookService private void Detour(GameObject* gameObject) { _state.QueueGameObject(gameObject); - Penumbra.Log.Excessive($"[Enable Draw] Invoked on 0x{(nint)gameObject:X}."); + Penumbra.Log.Excessive($"[Enable Draw] Invoked on 0x{(nint)gameObject:X} at {gameObject->ObjectIndex}."); _task.Result.Original.Invoke(gameObject); _state.DequeueGameObject(); } diff --git a/Penumbra/Interop/Hooks/Objects/SetupPlayerNpc.cs b/Penumbra/Interop/Hooks/Objects/SetupPlayerNpc.cs new file mode 100644 index 00000000..8f1226c3 --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/SetupPlayerNpc.cs @@ -0,0 +1,55 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class SetupPlayerNpc : FastHook +{ + private readonly GameState _gameState; + + public SetupPlayerNpc(GameState gameState, HookManager hooks) + { + _gameState = gameState; + Task = hooks.CreateHook("SetupPlayerNPC", Sigs.SetupPlayerNpc, Detour, + !HookOverrides.Instance.Objects.SetupPlayerNpc); + } + + public delegate SchedulerStruct* Delegate(byte* npcType, nint unk, NpcSetupData* setupData); + + public SchedulerStruct* Detour(byte* npcType, nint unk, NpcSetupData* setupData) + { + // This function actually seems to generate all NPC. + + // If an ENPC is being created, check the creation parameters. + // If CopyPlayerCustomize is true, the event NPC gets a timeline that copies its customize and glasses from the local player. + // Keep track of this, so we can associate the actor to be created for this with the player character, see ConstructCutsceneCharacter. + if (setupData->CopyPlayerCustomize && npcType != null && *npcType is 8) + _gameState.CharacterAssociated.Value = true; + + var ret = Task.Result.Original.Invoke(npcType, unk, setupData); + Penumbra.Log.Excessive( + $"[Setup Player NPC] Invoked for type {*npcType} with 0x{unk:X} and Copy Player Customize: {setupData->CopyPlayerCustomize}."); + return ret; + } + + [StructLayout(LayoutKind.Explicit)] + public struct NpcSetupData + { + [FieldOffset(0x0B)] + private byte _copyPlayerCustomize; + + public bool CopyPlayerCustomize + { + get => _copyPlayerCustomize != 0; + set => _copyPlayerCustomize = value ? (byte)1 : (byte)0; + } + } + + [StructLayout(LayoutKind.Explicit)] + public struct SchedulerStruct + { + public static Character* GetCharacter(SchedulerStruct* s) + => ((delegate* unmanaged**)s)[0][19](s); + } +} diff --git a/Penumbra/Interop/Hooks/Objects/WeaponReload.cs b/Penumbra/Interop/Hooks/Objects/WeaponReload.cs index b09103f6..4231b027 100644 --- a/Penumbra/Interop/Hooks/Objects/WeaponReload.cs +++ b/Penumbra/Interop/Hooks/Objects/WeaponReload.cs @@ -35,14 +35,14 @@ public sealed unsafe class WeaponReload : EventWrapperPtr _task.IsCompletedSuccessfully; - private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g); + private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h); - private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g) + private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h) { var gameObject = drawData->OwnerObject; - Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}."); + Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}, {h}."); Invoke(drawData, gameObject, (CharacterWeapon*)(&weapon)); - _task.Result.Original(drawData, slot, weapon, d, e, f, g); + _task.Result.Original(drawData, slot, weapon, d, e, f, g, h); _postEvent.Invoke(drawData, gameObject); } diff --git a/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs b/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs new file mode 100644 index 00000000..00e5851f --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs @@ -0,0 +1,85 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +/// +/// Triggered whenever a model recomputes its attribute masks. +/// +/// Parameter is the game object that recomputed its attributes. +/// Parameter is the draw object on which the recomputation was called. +/// Parameter is the collection associated with the game object. +/// Parameter is the slot that was recomputed. If this is Unknown, it is a general new update call. +/// +public sealed unsafe class AttributeHook : EventWrapper, IHookService +{ + public enum Priority + { + /// + ShapeAttributeManager = 0, + } + + private readonly CollectionResolver _resolver; + private readonly Configuration _config; + + public AttributeHook(HookManager hooks, Configuration config, CollectionResolver resolver) + : base("Update Model Attributes") + { + _config = config; + _resolver = resolver; + _task = hooks.CreateHook(Name, Sigs.UpdateAttributes, Detour, config.EnableCustomShapes); + } + + private readonly Task> _task; + + public nint Address + => _task.Result.Address; + + public void Enable() + => SetState(true); + + public void Disable() + => SetState(false); + + public void SetState(bool enabled) + { + if (_config.EnableCustomShapes == enabled) + return; + + _config.EnableCustomShapes = enabled; + _config.Save(); + if (enabled) + _task.Result.Enable(); + else + _task.Result.Disable(); + } + + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate void Delegate(Human* human); + + private void Detour(Human* human) + { + _task.Result.Original(human); + var resolveData = _resolver.IdentifyCollection((DrawObject*)human, true); + var identifiedActor = resolveData.AssociatedGameObject; + var identifiedCollection = resolveData.ModCollection; + Penumbra.Log.Excessive($"[{Name}] Invoked on 0x{(ulong)human:X} (0x{identifiedActor:X})."); + Invoke(identifiedActor, human, identifiedCollection); + } + + protected override void Dispose(bool disposing) + => _task.Result.Dispose(); +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs b/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs index 30e643c7..51af5813 100644 --- a/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs +++ b/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs @@ -63,7 +63,7 @@ public sealed unsafe class PreBoneDeformerReplacer : IDisposable, IRequiredServi if (!_framework.IsInFrameworkUpdateThread) Penumbra.Log.Warning( $"{nameof(PreBoneDeformerReplacer)}.{nameof(SetupHssReplacements)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread"); - + var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject); try { diff --git a/Penumbra/Interop/Hooks/PostProcessing/RenderTargetHdrEnabler.cs b/Penumbra/Interop/Hooks/PostProcessing/RenderTargetHdrEnabler.cs new file mode 100644 index 00000000..653d9c1a --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/RenderTargetHdrEnabler.cs @@ -0,0 +1,170 @@ +using System.Collections.Immutable; +using Dalamud.Hooking; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +public unsafe class RenderTargetHdrEnabler : IService, IDisposable +{ + /// This array must be sorted by CreationOrder ascending. + private static readonly ImmutableArray ForcedTextureConfigs = + [ + new ForcedTextureConfig(9, TextureFormat.R16G16B16A16_FLOAT, "Opaque Diffuse GBuffer"), + new ForcedTextureConfig(10, TextureFormat.R16G16B16A16_FLOAT, "Semitransparent Diffuse GBuffer"), + ]; + + private static readonly IComparer ForcedTextureConfigComparer + = Comparer.Create((lhs, rhs) => lhs.CreationOrder.CompareTo(rhs.CreationOrder)); + + private readonly Configuration _config; + private readonly Tuple _share; + private readonly ThreadLocal _textureIndices = new(() => new TextureIndices(-1, -1)); + + private readonly ThreadLocal?> _textures = new(() => null); + + public TextureReportRecord[]? TextureReport { get; private set; } + + private readonly Hook? _renderTargetManagerInitialize; + private readonly Hook? _createTexture2D; + + public RenderTargetHdrEnabler(IGameInteropProvider interop, Configuration config, IDalamudPluginInterface pi, + DalamudConfigService dalamudConfig, PeSigScanner peScanner) + { + _config = config; + if (peScanner.TryScanText(Sigs.RenderTargetManagerInitialize, out var initializeAddress) + && peScanner.TryScanText(Sigs.DeviceCreateTexture2D, out var createAddress)) + { + _renderTargetManagerInitialize = + interop.HookFromAddress(initializeAddress, RenderTargetManagerInitializeDetour); + _createTexture2D = interop.HookFromAddress(createAddress, CreateTexture2DDetour); + + if (config.HdrRenderTargets && !HookOverrides.Instance.PostProcessing.RenderTargetManagerInitialize) + _renderTargetManagerInitialize.Enable(); + } + + _share = pi.GetOrCreateData("Penumbra.RenderTargetHDR.V1", () => + { + bool? waitForPlugins = dalamudConfig.GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool s) ? s : null; + return new Tuple(waitForPlugins, config.HdrRenderTargets, + !HookOverrides.Instance.PostProcessing.RenderTargetManagerInitialize, [0], [false]); + }); + ++_share.Item4[0]; + } + + public bool? FirstLaunchWaitForPluginsState + => _share.Item1; + + public bool FirstLaunchHdrState + => _share.Item2; + + public bool FirstLaunchHdrHookOverrideState + => _share.Item3; + + public int PenumbraReloadCount + => _share.Item4[0]; + + public bool HdrEnabledSuccess + => _share.Item5[0]; + + ~RenderTargetHdrEnabler() + => Dispose(false); + + + public static ForcedTextureConfig? GetForcedTextureConfig(int creationOrder) + { + var i = ForcedTextureConfigs.BinarySearch(new ForcedTextureConfig(creationOrder, 0, string.Empty), ForcedTextureConfigComparer); + return i >= 0 ? ForcedTextureConfigs[i] : null; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool _) + { + _createTexture2D?.Dispose(); + _renderTargetManagerInitialize?.Dispose(); + } + + private nint RenderTargetManagerInitializeDetour(RenderTargetManager* @this) + { + _createTexture2D!.Enable(); + _share.Item5[0] = true; + _textureIndices.Value = new TextureIndices(0, 0); + _textures.Value = _config.DebugMode ? [] : null; + try + { + return _renderTargetManagerInitialize!.Original(@this); + } + finally + { + if (_textures.Value != null) + { + TextureReport = CreateTextureReport(@this, _textures.Value); + _textures.Value = null; + } + + _textureIndices.Value = new TextureIndices(-1, -1); + _createTexture2D.Disable(); + } + } + + private Texture* CreateTexture2DDetour( + Device* @this, int* size, byte mipLevel, uint textureFormat, uint flags, uint unk) + { + var originalTextureFormat = textureFormat; + var indices = _textureIndices.IsValueCreated ? _textureIndices.Value : new TextureIndices(-1, -1); + if (indices.ConfigIndex >= 0 + && indices.ConfigIndex < ForcedTextureConfigs.Length + && ForcedTextureConfigs[indices.ConfigIndex].CreationOrder == indices.CreationOrder) + { + var config = ForcedTextureConfigs[indices.ConfigIndex++]; + textureFormat = (uint)config.ForcedTextureFormat; + } + + if (indices.CreationOrder >= 0) + { + ++indices.CreationOrder; + _textureIndices.Value = indices; + } + + var texture = _createTexture2D!.Original(@this, size, mipLevel, textureFormat, flags, unk); + if (_textures.IsValueCreated) + _textures.Value?.Add((nint)texture, (indices.CreationOrder - 1, originalTextureFormat)); + return texture; + } + + private static TextureReportRecord[] CreateTextureReport(RenderTargetManager* renderTargetManager, + Dictionary textures) + { + var rtmTextures = new Span(renderTargetManager, sizeof(RenderTargetManager) / sizeof(nint)); + var report = new List(); + for (var i = 0; i < rtmTextures.Length; ++i) + { + if (textures.TryGetValue(rtmTextures[i], out var texture)) + report.Add(new TextureReportRecord(i * sizeof(nint), texture.TextureIndex, (TextureFormat)texture.TextureFormat)); + } + + return report.ToArray(); + } + + private delegate nint RenderTargetManagerInitializeFunc(RenderTargetManager* @this); + + private delegate Texture* CreateTexture2DFunc(Device* @this, int* size, byte mipLevel, uint textureFormat, uint flags, uint unk); + + private record struct TextureIndices(int CreationOrder, int ConfigIndex); + + public readonly record struct ForcedTextureConfig(int CreationOrder, TextureFormat ForcedTextureFormat, string Comment); + + public readonly record struct TextureReportRecord(nint Offset, int CreationOrder, TextureFormat OriginalTextureFormat); +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs index 40958eb4..b9c21556 100644 --- a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs +++ b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs @@ -7,9 +7,11 @@ using OtterGui.Classes; using OtterGui.Services; using Penumbra.Communication; using Penumbra.GameData; +using Penumbra.GameData.Files.MaterialStructs; using Penumbra.Interop.Hooks.Resources; using Penumbra.Interop.Structs; using Penumbra.Services; +using Penumbra.String.Classes; using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; using CSModelRenderer = FFXIVClientStructs.FFXIV.Client.Graphics.Render.ModelRenderer; using ModelRenderer = Penumbra.Interop.Services.ModelRenderer; @@ -63,8 +65,6 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic private readonly ResourceHandleDestructor _resourceHandleDestructor; private readonly CommunicatorService _communicator; - private readonly CharacterUtility _utility; - private readonly ModelRenderer _modelRenderer; private readonly HumanSetupScalingHook _humanSetupScalingHook; private readonly ModdedShaderPackageState _skinState; @@ -110,31 +110,29 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic CommunicatorService communicator, HookManager hooks, CharacterBaseVTables vTables, HumanSetupScalingHook humanSetupScalingHook) { _resourceHandleDestructor = resourceHandleDestructor; - _utility = utility; - _modelRenderer = modelRenderer; _communicator = communicator; _humanSetupScalingHook = humanSetupScalingHook; _skinState = new ModdedShaderPackageState( - () => (ShaderPackageResourceHandle**)&_utility.Address->SkinShpkResource, - () => (ShaderPackageResourceHandle*)_utility.DefaultSkinShpkResource); + () => (ShaderPackageResourceHandle**)&utility.Address->SkinShpkResource, + () => (ShaderPackageResourceHandle*)utility.DefaultSkinShpkResource); _characterStockingsState = new ModdedShaderPackageState( - () => (ShaderPackageResourceHandle**)&_utility.Address->CharacterStockingsShpkResource, - () => (ShaderPackageResourceHandle*)_utility.DefaultCharacterStockingsShpkResource); + () => (ShaderPackageResourceHandle**)&utility.Address->CharacterStockingsShpkResource, + () => (ShaderPackageResourceHandle*)utility.DefaultCharacterStockingsShpkResource); _characterLegacyState = new ModdedShaderPackageState( - () => (ShaderPackageResourceHandle**)&_utility.Address->CharacterLegacyShpkResource, - () => (ShaderPackageResourceHandle*)_utility.DefaultCharacterLegacyShpkResource); - _irisState = new ModdedShaderPackageState(() => _modelRenderer.IrisShaderPackage, () => _modelRenderer.DefaultIrisShaderPackage); - _characterGlassState = new ModdedShaderPackageState(() => _modelRenderer.CharacterGlassShaderPackage, - () => _modelRenderer.DefaultCharacterGlassShaderPackage); - _characterTransparencyState = new ModdedShaderPackageState(() => _modelRenderer.CharacterTransparencyShaderPackage, - () => _modelRenderer.DefaultCharacterTransparencyShaderPackage); - _characterTattooState = new ModdedShaderPackageState(() => _modelRenderer.CharacterTattooShaderPackage, - () => _modelRenderer.DefaultCharacterTattooShaderPackage); - _characterOcclusionState = new ModdedShaderPackageState(() => _modelRenderer.CharacterOcclusionShaderPackage, - () => _modelRenderer.DefaultCharacterOcclusionShaderPackage); + () => (ShaderPackageResourceHandle**)&utility.Address->CharacterLegacyShpkResource, + () => (ShaderPackageResourceHandle*)utility.DefaultCharacterLegacyShpkResource); + _irisState = new ModdedShaderPackageState(() => modelRenderer.IrisShaderPackage, () => modelRenderer.DefaultIrisShaderPackage); + _characterGlassState = new ModdedShaderPackageState(() => modelRenderer.CharacterGlassShaderPackage, + () => modelRenderer.DefaultCharacterGlassShaderPackage); + _characterTransparencyState = new ModdedShaderPackageState(() => modelRenderer.CharacterTransparencyShaderPackage, + () => modelRenderer.DefaultCharacterTransparencyShaderPackage); + _characterTattooState = new ModdedShaderPackageState(() => modelRenderer.CharacterTattooShaderPackage, + () => modelRenderer.DefaultCharacterTattooShaderPackage); + _characterOcclusionState = new ModdedShaderPackageState(() => modelRenderer.CharacterOcclusionShaderPackage, + () => modelRenderer.DefaultCharacterOcclusionShaderPackage); _hairMaskState = - new ModdedShaderPackageState(() => _modelRenderer.HairMaskShaderPackage, () => _modelRenderer.DefaultHairMaskShaderPackage); + new ModdedShaderPackageState(() => modelRenderer.HairMaskShaderPackage, () => modelRenderer.DefaultHairMaskShaderPackage); _humanSetupScalingHook.SetupReplacements += SetupHssReplacements; _humanOnRenderMaterialHook = hooks.CreateHook("Human.OnRenderMaterial", vTables.HumanVTable[64], @@ -195,7 +193,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic if (shpk == null) return; - var shpkName = mtrl->ShpkNameSpan; + var shpkName = mtrl->ShpkName.AsSpan(); var shpkState = GetStateForHumanSetup(shpkName) ?? GetStateForHumanRender(shpkName) ?? GetStateForModelRendererRender(shpkName) @@ -219,7 +217,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic } private ModdedShaderPackageState? GetStateForHumanSetup(MaterialResourceHandle* mtrlResource) - => mtrlResource == null ? null : GetStateForHumanSetup(mtrlResource->ShpkNameSpan); + => mtrlResource == null ? null : GetStateForHumanSetup(mtrlResource->ShpkName.AsSpan()); private ModdedShaderPackageState? GetStateForHumanSetup(ReadOnlySpan shpkName) => CharacterStockingsShpkName.SequenceEqual(shpkName) ? _characterStockingsState : null; @@ -229,7 +227,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic => _characterStockingsState.MaterialCount; private ModdedShaderPackageState? GetStateForHumanRender(MaterialResourceHandle* mtrlResource) - => mtrlResource == null ? null : GetStateForHumanRender(mtrlResource->ShpkNameSpan); + => mtrlResource == null ? null : GetStateForHumanRender(mtrlResource->ShpkName.AsSpan()); private ModdedShaderPackageState? GetStateForHumanRender(ReadOnlySpan shpkName) => SkinShpkName.SequenceEqual(shpkName) ? _skinState : null; @@ -239,7 +237,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic => _skinState.MaterialCount; private ModdedShaderPackageState? GetStateForModelRendererRender(MaterialResourceHandle* mtrlResource) - => mtrlResource == null ? null : GetStateForModelRendererRender(mtrlResource->ShpkNameSpan); + => mtrlResource == null ? null : GetStateForModelRendererRender(mtrlResource->ShpkName.AsSpan()); private ModdedShaderPackageState? GetStateForModelRendererRender(ReadOnlySpan shpkName) { @@ -266,7 +264,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic + _hairMaskState.MaterialCount; private ModdedShaderPackageState? GetStateForModelRendererUnk(MaterialResourceHandle* mtrlResource) - => mtrlResource == null ? null : GetStateForModelRendererUnk(mtrlResource->ShpkNameSpan); + => mtrlResource == null ? null : GetStateForModelRendererUnk(mtrlResource->ShpkName.AsSpan()); private ModdedShaderPackageState? GetStateForModelRendererUnk(ReadOnlySpan shpkName) { @@ -462,8 +460,18 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic return mtrlResource; } + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static int GetDataSetExpectedSize(uint dataFlags) + => (dataFlags & 4) != 0 + ? ColorTable.Size + ((dataFlags & 8) != 0 ? ColorDyeTable.Size : 0) + : 0; + private Texture* PrepareColorTableDetour(MaterialResourceHandle* thisPtr, byte stain0Id, byte stain1Id) { + if (thisPtr->DataSetSize < GetDataSetExpectedSize(thisPtr->DataFlags) && Utf8GamePath.IsRooted(thisPtr->FileName.AsSpan())) + Penumbra.Log.Warning( + $"Material at {thisPtr->FileName} has data set of size {thisPtr->DataSetSize} bytes, but should have at least {GetDataSetExpectedSize(thisPtr->DataFlags)} bytes. This may cause crashes due to access violations."); + // If we don't have any on-screen instances of modded characterlegacy.shpk, we don't need the slow path at all. if (!Enabled || GetTotalMaterialCountForColorTable() == 0) return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); @@ -472,7 +480,7 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic if (material == null) return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); - var shpkState = GetStateForColorTable(thisPtr->ShpkNameSpan); + var shpkState = GetStateForColorTable(thisPtr->ShpkName.AsSpan()); if (shpkState == null || shpkState.MaterialCount == 0) return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); @@ -500,9 +508,8 @@ public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredServic private readonly ConcurrentSet _materials = new(); // ConcurrentDictionary.Count uses a lock in its current implementation. - private uint _materialCount = 0; - - private ulong _slowPathCallDelta = 0; + private uint _materialCount; + private ulong _slowPathCallDelta; public uint MaterialCount { diff --git a/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs b/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs index 5ba8c975..35ee86dc 100644 --- a/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs +++ b/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs @@ -2,9 +2,9 @@ namespace Penumbra.Interop.Hooks.ResourceLoading; -public sealed class PapHandler(PapRewriter.PapResourceHandlerPrototype papResourceHandler) : IDisposable +public sealed class PapHandler(PeSigScanner sigScanner, PapRewriter.PapResourceHandlerPrototype papResourceHandler) : IDisposable { - private readonly PapRewriter _papRewriter = new(papResourceHandler); + private readonly PapRewriter _papRewriter = new(sigScanner, papResourceHandler); public void Enable() { diff --git a/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs b/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs index 2fb1623d..caf43d08 100644 --- a/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs +++ b/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs @@ -1,27 +1,26 @@ using System.Text.Unicode; using Dalamud.Hooking; using Iced.Intel; -using OtterGui; +using OtterGui.Extensions; using Penumbra.String.Classes; using Swan; namespace Penumbra.Interop.Hooks.ResourceLoading; -public sealed class PapRewriter(PapRewriter.PapResourceHandlerPrototype papResourceHandler) : IDisposable +public sealed class PapRewriter(PeSigScanner sigScanner, PapRewriter.PapResourceHandlerPrototype papResourceHandler) : IDisposable { public unsafe delegate int PapResourceHandlerPrototype(void* self, byte* path, int length); - private readonly PeSigScanner _scanner = new(); private readonly Dictionary _hooks = []; private readonly Dictionary<(nint, Register, ulong), nint> _nativeAllocPaths = []; private readonly List _nativeAllocCaves = []; public void Rewrite(string sig, string name) { - if (!_scanner.TryScanText(sig, out var address)) + if (!sigScanner.TryScanText(sig, out var address)) throw new Exception($"Signature for {name} [{sig}] could not be found."); - var funcInstructions = _scanner.GetFunctionInstructions(address).ToArray(); + var funcInstructions = sigScanner.GetFunctionInstructions(address).ToArray(); var hookPoints = ScanPapHookPoints(funcInstructions).ToList(); foreach (var hookPoint in hookPoints) @@ -165,8 +164,6 @@ public sealed class PapRewriter(PapRewriter.PapResourceHandlerPrototype papResou public void Dispose() { - _scanner.Dispose(); - foreach (var hook in _hooks.Values) { hook.Disable(); diff --git a/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs b/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs index 4be0da00..620f3160 100644 --- a/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs +++ b/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs @@ -1,12 +1,13 @@ using System.IO.MemoryMappedFiles; using Iced.Intel; +using OtterGui.Services; using PeNet; using Decoder = Iced.Intel.Decoder; namespace Penumbra.Interop.Hooks.ResourceLoading; // A good chunk of this was blatantly stolen from Dalamud's SigScanner 'cause Winter could not be faffed, Winter will definitely not rewrite it later -public unsafe class PeSigScanner : IDisposable +public unsafe class PeSigScanner : IDisposable, IService { private readonly MemoryMappedFile _file; private readonly MemoryMappedViewAccessor _textSection; diff --git a/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs b/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs index 47f96d98..6ddcbfda 100644 --- a/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs +++ b/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs @@ -2,6 +2,7 @@ using FFXIVClientStructs.FFXIV.Client.System.Resource; using OtterGui.Services; using Penumbra.Api.Enums; using Penumbra.Collections; +using Penumbra.Interop.Hooks.Resources; using Penumbra.Interop.PathResolving; using Penumbra.Interop.SafeHandles; using Penumbra.Interop.Structs; @@ -13,29 +14,40 @@ namespace Penumbra.Interop.Hooks.ResourceLoading; public unsafe class ResourceLoader : IDisposable, IService { - private readonly ResourceService _resources; - private readonly FileReadService _fileReadService; - private readonly RsfService _rsfService; - private readonly PapHandler _papHandler; - private readonly Configuration _config; + private readonly ResourceService _resources; + private readonly FileReadService _fileReadService; + private readonly RsfService _rsfService; + private readonly PapHandler _papHandler; + private readonly Configuration _config; + private readonly ResourceHandleDestructor _destructor; - private ResolveData _resolvedData = ResolveData.Invalid; + private readonly ConcurrentDictionary _ongoingLoads = []; + + private readonly ThreadLocal _resolvedData = new(() => ResolveData.Invalid); public event Action? PapRequested; - public ResourceLoader(ResourceService resources, FileReadService fileReadService, RsfService rsfService, Configuration config) + public IReadOnlyDictionary OngoingLoads + => _ongoingLoads; + + public ResourceLoader(ResourceService resources, FileReadService fileReadService, RsfService rsfService, Configuration config, PeSigScanner sigScanner, + ResourceHandleDestructor destructor) { _resources = resources; _fileReadService = fileReadService; - _rsfService = rsfService; + _rsfService = rsfService; _config = config; + _destructor = destructor; ResetResolvePath(); - _resources.ResourceRequested += ResourceHandler; - _resources.ResourceHandleIncRef += IncRefProtection; - _resources.ResourceHandleDecRef += DecRefProtection; - _fileReadService.ReadSqPack += ReadSqPackDetour; + _resources.ResourceRequested += ResourceHandler; + _resources.ResourceStateUpdating += ResourceStateUpdatingHandler; + _resources.ResourceStateUpdated += ResourceStateUpdatedHandler; + _resources.ResourceHandleIncRef += IncRefProtection; + _resources.ResourceHandleDecRef += DecRefProtection; + _fileReadService.ReadSqPack += ReadSqPackDetour; + _destructor.Subscribe(ResourceDestructorHandler, ResourceHandleDestructor.Priority.ResourceLoader); - _papHandler = new PapHandler(PapResourceHandler); + _papHandler = new PapHandler(sigScanner, PapResourceHandler); _papHandler.Enable(); } @@ -44,10 +56,11 @@ public unsafe class ResourceLoader : IDisposable, IService if (!_config.EnableMods || !Utf8GamePath.FromPointer(path, MetaDataComputation.CiCrc32, out var gamePath)) return length; + var resolvedData = _resolvedData.Value; var (resolvedPath, data) = _incMode.Value ? (null, ResolveData.Invalid) - : _resolvedData.Valid - ? (_resolvedData.ModCollection.ResolvePath(gamePath), _resolvedData) + : resolvedData.Valid + ? (resolvedData.ModCollection.ResolvePath(gamePath), resolvedData) : ResolvePath(gamePath, ResourceCategory.Chara, ResourceType.Pap); @@ -66,19 +79,31 @@ public unsafe class ResourceLoader : IDisposable, IService /// Load a resource for a given path and a specific collection. public ResourceHandle* LoadResolvedResource(ResourceCategory category, ResourceType type, CiByteString path, ResolveData resolveData) { - _resolvedData = resolveData; - var ret = _resources.GetResource(category, type, path); - _resolvedData = ResolveData.Invalid; - return ret; + var previous = _resolvedData.Value; + _resolvedData.Value = resolveData; + try + { + return _resources.GetResource(category, type, path); + } + finally + { + _resolvedData.Value = previous; + } } /// Load a resource for a given path and a specific collection. public SafeResourceHandle LoadResolvedSafeResource(ResourceCategory category, ResourceType type, CiByteString path, ResolveData resolveData) { - _resolvedData = resolveData; - var ret = _resources.GetSafeResource(category, type, path); - _resolvedData = ResolveData.Invalid; - return ret; + var previous = _resolvedData.Value; + _resolvedData.Value = resolveData; + try + { + return _resources.GetSafeResource(category, type, path); + } + finally + { + _resolvedData.Value = previous; + } } /// The function to use to resolve a given path. @@ -109,12 +134,32 @@ public unsafe class ResourceLoader : IDisposable, IService /// public event FileLoadedDelegate? FileLoaded; + public delegate void ResourceCompleteDelegate(ResourceHandle* resource, CiByteString path, Utf8GamePath originalPath, + ReadOnlySpan additionalData, bool isAsync); + + /// + /// Event fired just before a resource finishes loading. + /// must be checked to know whether the load was successful or not. + /// AdditionalData is either empty or the part of the path inside the leading pipes. + /// + public event ResourceCompleteDelegate? BeforeResourceComplete; + + /// + /// Event fired when a resource has finished loading. + /// must be checked to know whether the load was successful or not. + /// AdditionalData is either empty or the part of the path inside the leading pipes. + /// + public event ResourceCompleteDelegate? ResourceComplete; + public void Dispose() { - _resources.ResourceRequested -= ResourceHandler; - _resources.ResourceHandleIncRef -= IncRefProtection; - _resources.ResourceHandleDecRef -= DecRefProtection; - _fileReadService.ReadSqPack -= ReadSqPackDetour; + _resources.ResourceRequested -= ResourceHandler; + _resources.ResourceStateUpdating -= ResourceStateUpdatingHandler; + _resources.ResourceStateUpdated -= ResourceStateUpdatedHandler; + _resources.ResourceHandleIncRef -= IncRefProtection; + _resources.ResourceHandleDecRef -= DecRefProtection; + _fileReadService.ReadSqPack -= ReadSqPackDetour; + _destructor.Unsubscribe(ResourceDestructorHandler); _papHandler.Dispose(); } @@ -127,15 +172,17 @@ public unsafe class ResourceLoader : IDisposable, IService CompareHash(ComputeHash(path.Path, parameters), hash, path); // If no replacements are being made, we still want to be able to trigger the event. + var resolvedData = _resolvedData.Value; var (resolvedPath, data) = _incMode.Value ? (null, ResolveData.Invalid) - : _resolvedData.Valid - ? (_resolvedData.ModCollection.ResolvePath(path), _resolvedData) + : resolvedData.Valid + ? (resolvedData.ModCollection.ResolvePath(path), resolvedData) : ResolvePath(path, category, type); if (resolvedPath == null || !Utf8GamePath.FromByteString(resolvedPath.Value.InternalName, out var p)) { - returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, parameters); + returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, original, parameters); + TrackResourceLoad(returnValue, original); ResourceLoaded?.Invoke(returnValue, path, resolvedPath, data); return; } @@ -145,10 +192,57 @@ public unsafe class ResourceLoader : IDisposable, IService hash = ComputeHash(resolvedPath.Value.InternalName, parameters); var oldPath = path; path = p; - returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, parameters); + returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, original, parameters); + TrackResourceLoad(returnValue, original); ResourceLoaded?.Invoke(returnValue, oldPath, resolvedPath.Value, data); } + private void TrackResourceLoad(ResourceHandle* handle, Utf8GamePath original) + { + if (handle->UnkState == 2 && handle->LoadState >= LoadState.Success) + return; + + _ongoingLoads.TryAdd((nint)handle, original.Clone()); + } + + private void ResourceStateUpdatedHandler(ResourceHandle* handle, Utf8GamePath syncOriginal, (byte, LoadState) previousState, ref uint returnValue) + { + if (handle->UnkState != 2 || handle->LoadState < LoadState.Success || previousState is { Item1: 2, Item2: >= LoadState.Success }) + return; + + if (!_ongoingLoads.TryRemove((nint)handle, out var asyncOriginal)) + asyncOriginal = Utf8GamePath.Empty; + + var path = handle->CsHandle.FileName; + if (!syncOriginal.IsEmpty && !asyncOriginal.IsEmpty && !syncOriginal.Equals(asyncOriginal)) + Penumbra.Log.Warning($"[ResourceLoader] Resource original paths inconsistency: 0x{(nint)handle:X}, of path {path}, sync original {syncOriginal}, async original {asyncOriginal}."); + var original = !asyncOriginal.IsEmpty ? asyncOriginal : syncOriginal; + + Penumbra.Log.Excessive($"[ResourceLoader] Resource is complete: 0x{(nint)handle:X}, of path {path}, original {original}, state {previousState.Item1}:{previousState.Item2} -> {handle->UnkState}:{handle->LoadState}, sync: {asyncOriginal.IsEmpty}"); + if (PathDataHandler.Split(path.AsSpan(), out var actualPath, out var additionalData)) + ResourceComplete?.Invoke(handle, new CiByteString(actualPath), original, additionalData, !asyncOriginal.IsEmpty); + else + ResourceComplete?.Invoke(handle, path.AsByteString(), original, [], !asyncOriginal.IsEmpty); + } + + private void ResourceStateUpdatingHandler(ResourceHandle* handle, Utf8GamePath syncOriginal) + { + if (handle->UnkState != 1 || handle->LoadState != LoadState.Success) + return; + + if (!_ongoingLoads.TryGetValue((nint)handle, out var asyncOriginal)) + asyncOriginal = Utf8GamePath.Empty; + + var path = handle->CsHandle.FileName; + var original = asyncOriginal.IsEmpty ? syncOriginal : asyncOriginal; + + Penumbra.Log.Excessive($"[ResourceLoader] Resource is about to be complete: 0x{(nint)handle:X}, of path {path}, original {original}"); + if (PathDataHandler.Split(path.AsSpan(), out var actualPath, out var additionalData)) + BeforeResourceComplete?.Invoke(handle, new CiByteString(actualPath), original, additionalData, !asyncOriginal.IsEmpty); + else + BeforeResourceComplete?.Invoke(handle, path.AsByteString(), original, [], !asyncOriginal.IsEmpty); + } + private void ReadSqPackDetour(SeFileDescriptor* fileDescriptor, ref int priority, ref bool isSync, ref byte? returnValue) { if (fileDescriptor->ResourceHandle == null) @@ -176,7 +270,6 @@ public unsafe class ResourceLoader : IDisposable, IService gamePath.Path.IsAscii); fileDescriptor->ResourceHandle->FileNameData = path.Path; fileDescriptor->ResourceHandle->FileNameLength = path.Length; - MtrlForceSync(fileDescriptor, ref isSync); returnValue = DefaultLoadResource(path, fileDescriptor, priority, isSync, data); // Return original resource handle path so that they can be loaded separately. fileDescriptor->ResourceHandle->FileNameData = gamePath.Path.Path; @@ -215,16 +308,6 @@ public unsafe class ResourceLoader : IDisposable, IService } } - /// Special handling for materials. - private static void MtrlForceSync(SeFileDescriptor* fileDescriptor, ref bool isSync) - { - // Force isSync = true for Materials. I don't really understand why, - // or where the difference even comes from. - // Was called with True on my client and with false on other peoples clients, - // which caused problems. - isSync |= fileDescriptor->ResourceHandle->FileType is ResourceType.Mtrl; - } - /// /// A resource with ref count 0 that gets incremented goes through GetResourceAsync again. /// This means, that if the path determined from that is different than the resources path, @@ -265,6 +348,11 @@ public unsafe class ResourceLoader : IDisposable, IService returnValue = 1; } + private void ResourceDestructorHandler(ResourceHandle* handle) + { + _ongoingLoads.TryRemove((nint)handle, out _); + } + /// Compute the CRC32 hash for a given path together with potential resource parameters. private static int ComputeHash(CiByteString path, GetResourceParameters* pGetResParams) { diff --git a/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs b/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs index 126505d1..1a40accc 100644 --- a/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs +++ b/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs @@ -1,4 +1,5 @@ using Dalamud.Hooking; +using Dalamud.Interface.Windowing; using Dalamud.Plugin.Services; using Dalamud.Utility.Signatures; using FFXIVClientStructs.FFXIV.Client.System.Resource; @@ -19,6 +20,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService private readonly PerformanceTracker _performance; private readonly ResourceManagerService _resourceManager; + private readonly ThreadLocal _currentGetResourcePath = new(() => Utf8GamePath.Empty); + public ResourceService(PerformanceTracker performance, ResourceManagerService resourceManager, IGameInteropProvider interop) { _performance = performance; @@ -34,6 +37,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService _getResourceSyncHook.Enable(); if (!HookOverrides.Instance.ResourceLoading.GetResourceAsync) _getResourceAsyncHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.UpdateResourceState) + _updateResourceStateHook.Enable(); if (!HookOverrides.Instance.ResourceLoading.IncRef) _incRefHook.Enable(); if (!HookOverrides.Instance.ResourceLoading.DecRef) @@ -54,8 +59,10 @@ public unsafe class ResourceService : IDisposable, IRequiredService { _getResourceSyncHook.Dispose(); _getResourceAsyncHook.Dispose(); + _updateResourceStateHook.Dispose(); _incRefHook.Dispose(); _decRefHook.Dispose(); + _currentGetResourcePath.Dispose(); } #region GetResource @@ -79,7 +86,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, nint unk7, uint unk8); private delegate ResourceHandle* GetResourceAsyncPrototype(ResourceManager* resourceManager, ResourceCategory* pCategoryId, - ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8, uint unk9); + ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8, + uint unk9); [Signature(Sigs.GetResourceSync, DetourName = nameof(GetResourceSyncDetour))] private readonly Hook _getResourceSyncHook = null!; @@ -112,28 +120,92 @@ public unsafe class ResourceService : IDisposable, IRequiredService unk9); } + if (gamePath.IsEmpty) + { + Penumbra.Log.Error($"[ResourceService] Empty resource path requested with category {*categoryId}, type {*resourceType}, hash {*resourceHash}."); + return null; + } + + var original = gamePath; ResourceHandle* returnValue = null; - ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, gamePath, pGetResParams, ref isSync, + ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, original, pGetResParams, ref isSync, ref returnValue); if (returnValue != null) return returnValue; - return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, pGetResParams, isUnk, unk8, unk9); + return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8, + unk9); } /// Call the original GetResource function. public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path, + Utf8GamePath original, GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0) - => sync - ? _getResourceSyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, - resourceParameters, unk8, unk9) - : _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, - resourceParameters, unk, unk8, unk9); + { + var previous = _currentGetResourcePath.Value; + try + { + _currentGetResourcePath.Value = original; + return sync + ? _getResourceSyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, + resourceParameters, unk8, unk9) + : _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, + resourceParameters, unk, unk8, unk9); + } + finally + { + _currentGetResourcePath.Value = previous; + } + } #endregion private delegate nint ResourceHandlePrototype(ResourceHandle* handle); + #region UpdateResourceState + + /// Invoked before a resource state is updated. + /// The resource handle. + /// The original game path of the resource, if loaded synchronously. + public delegate void ResourceStateUpdatingDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal); + + /// Invoked after a resource state is updated. + /// The resource handle. + /// The original game path of the resource, if loaded synchronously. + /// The previous state of the resource. + /// The return value to use. + public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal, + (byte UnkState, LoadState LoadState) previousState, ref uint returnValue); + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ResourceStateUpdatingDelegate? ResourceStateUpdating; + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ResourceStateUpdatedDelegate? ResourceStateUpdated; + + private delegate uint UpdateResourceStatePrototype(ResourceHandle* handle, byte offFileThread); + + [Signature(Sigs.UpdateResourceState, DetourName = nameof(UpdateResourceStateDetour))] + private readonly Hook _updateResourceStateHook = null!; + + private uint UpdateResourceStateDetour(ResourceHandle* handle, byte offFileThread) + { + var previousState = (handle->UnkState, handle->LoadState); + var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty; + ResourceStateUpdating?.Invoke(handle, syncOriginal); + var ret = _updateResourceStateHook.OriginalDisposeSafe(handle, offFileThread); + ResourceStateUpdated?.Invoke(handle, syncOriginal, previousState, ref ret); + return ret; + } + + #endregion + #region IncRef /// Invoked before a resource handle reference count is incremented. diff --git a/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs b/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs index 54066782..db39889e 100644 --- a/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs +++ b/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs @@ -6,6 +6,7 @@ using Penumbra.Collections; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Processing; using static FFXIVClientStructs.FFXIV.Client.Game.Character.ActionEffectHandler; namespace Penumbra.Interop.Hooks.Resources; @@ -35,6 +36,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable private readonly Hook _resolveMPapPathHook; private readonly Hook _resolveMdlPathHook; private readonly Hook _resolveMtrlPathHook; + private readonly Hook _resolveSkinMtrlPathHook; private readonly Hook _resolvePapPathHook; private readonly Hook _resolveKdbPathHook; private readonly Hook _resolvePhybPathHook; @@ -52,22 +54,23 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable { _parent = parent; // @formatter:off - _resolveSklbPathHook = Create($"{name}.{nameof(ResolveSklb)}", hooks, vTable[76], type, ResolveSklb, ResolveSklbHuman); - _resolveMdlPathHook = Create($"{name}.{nameof(ResolveMdl)}", hooks, vTable[77], type, ResolveMdl, ResolveMdlHuman); - _resolveSkpPathHook = Create($"{name}.{nameof(ResolveSkp)}", hooks, vTable[78], type, ResolveSkp, ResolveSkpHuman); - _resolvePhybPathHook = Create($"{name}.{nameof(ResolvePhyb)}", hooks, vTable[79], type, ResolvePhyb, ResolvePhybHuman); - _resolveKdbPathHook = Create($"{name}.{nameof(ResolveKdb)}", hooks, vTable[80], type, ResolveKdb, ResolveKdbHuman); - _vFunc81Hook = Create( $"{name}.{nameof(VFunc81)}", hooks, vTable[81], type, null, VFunc81); - _resolveBnmbPathHook = Create($"{name}.{nameof(ResolveBnmb)}", hooks, vTable[82], type, ResolveBnmb, ResolveBnmbHuman); - _vFunc83Hook = Create( $"{name}.{nameof(VFunc83)}", hooks, vTable[83], type, null, VFunc83); - _resolvePapPathHook = Create( $"{name}.{nameof(ResolvePap)}", hooks, vTable[84], type, ResolvePap, ResolvePapHuman); - _resolveTmbPathHook = Create( $"{name}.{nameof(ResolveTmb)}", hooks, vTable[85], ResolveTmb); - _resolveMPapPathHook = Create( $"{name}.{nameof(ResolveMPap)}", hooks, vTable[87], ResolveMPap); - _resolveImcPathHook = Create($"{name}.{nameof(ResolveImc)}", hooks, vTable[89], ResolveImc); - _resolveMtrlPathHook = Create( $"{name}.{nameof(ResolveMtrl)}", hooks, vTable[90], ResolveMtrl); - _resolveDecalPathHook = Create($"{name}.{nameof(ResolveDecal)}", hooks, vTable[92], ResolveDecal); - _resolveVfxPathHook = Create( $"{name}.{nameof(ResolveVfx)}", hooks, vTable[93], type, ResolveVfx, ResolveVfxHuman); - _resolveEidPathHook = Create( $"{name}.{nameof(ResolveEid)}", hooks, vTable[94], ResolveEid); + _resolveSklbPathHook = Create($"{name}.{nameof(ResolveSklb)}", hooks, vTable[76], type, ResolveSklb, ResolveSklbHuman); + _resolveMdlPathHook = Create($"{name}.{nameof(ResolveMdl)}", hooks, vTable[77], type, ResolveMdl, ResolveMdlHuman); + _resolveSkpPathHook = Create($"{name}.{nameof(ResolveSkp)}", hooks, vTable[78], type, ResolveSkp, ResolveSkpHuman); + _resolvePhybPathHook = Create($"{name}.{nameof(ResolvePhyb)}", hooks, vTable[79], type, ResolvePhyb, ResolvePhybHuman); + _resolveKdbPathHook = Create($"{name}.{nameof(ResolveKdb)}", hooks, vTable[80], type, ResolveKdb, ResolveKdbHuman); + _vFunc81Hook = Create( $"{name}.{nameof(VFunc81)}", hooks, vTable[81], type, null, VFunc81); + _resolveBnmbPathHook = Create($"{name}.{nameof(ResolveBnmb)}", hooks, vTable[82], type, ResolveBnmb, ResolveBnmbHuman); + _vFunc83Hook = Create( $"{name}.{nameof(VFunc83)}", hooks, vTable[83], type, null, VFunc83); + _resolvePapPathHook = Create( $"{name}.{nameof(ResolvePap)}", hooks, vTable[84], type, ResolvePap, ResolvePapHuman); + _resolveTmbPathHook = Create( $"{name}.{nameof(ResolveTmb)}", hooks, vTable[85], ResolveTmb); + _resolveMPapPathHook = Create( $"{name}.{nameof(ResolveMPap)}", hooks, vTable[87], ResolveMPap); + _resolveImcPathHook = Create($"{name}.{nameof(ResolveImc)}", hooks, vTable[89], ResolveImc); + _resolveMtrlPathHook = Create( $"{name}.{nameof(ResolveMtrl)}", hooks, vTable[90], ResolveMtrl); + _resolveSkinMtrlPathHook = Create($"{name}.{nameof(ResolveSkinMtrl)}", hooks, vTable[91], ResolveSkinMtrl); + _resolveDecalPathHook = Create($"{name}.{nameof(ResolveDecal)}", hooks, vTable[92], ResolveDecal); + _resolveVfxPathHook = Create( $"{name}.{nameof(ResolveVfx)}", hooks, vTable[93], type, ResolveVfx, ResolveVfxHuman); + _resolveEidPathHook = Create( $"{name}.{nameof(ResolveEid)}", hooks, vTable[94], ResolveEid); // @formatter:on @@ -83,6 +86,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable _resolveMPapPathHook.Enable(); _resolveMdlPathHook.Enable(); _resolveMtrlPathHook.Enable(); + _resolveSkinMtrlPathHook.Enable(); _resolvePapPathHook.Enable(); _resolveKdbPathHook.Enable(); _resolvePhybPathHook.Enable(); @@ -103,6 +107,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable _resolveMPapPathHook.Disable(); _resolveMdlPathHook.Disable(); _resolveMtrlPathHook.Disable(); + _resolveSkinMtrlPathHook.Disable(); _resolvePapPathHook.Disable(); _resolveKdbPathHook.Disable(); _resolvePhybPathHook.Disable(); @@ -123,6 +128,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable _resolveMPapPathHook.Dispose(); _resolveMdlPathHook.Dispose(); _resolveMtrlPathHook.Dispose(); + _resolveSkinMtrlPathHook.Dispose(); _resolvePapPathHook.Dispose(); _resolveKdbPathHook.Dispose(); _resolvePhybPathHook.Dispose(); @@ -153,6 +159,15 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable private nint ResolveMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint mtrlFileName) => ResolvePath(drawObject, _resolveMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, mtrlFileName)); + private nint ResolveSkinMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) + { + var finalPathBuffer = _resolveSkinMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex); + if (DebugConfiguration.UseSkinMaterialProcessing && finalPathBuffer != nint.Zero && finalPathBuffer == pathBuffer) + SkinMtrlPathEarlyProcessing.Process(new Span((void*)pathBuffer, (int)pathBufferSize), (CharacterBase*)drawObject, slotIndex); + + return ResolvePath(drawObject, finalPathBuffer); + } + private nint ResolvePap(nint drawObject, nint pathBuffer, nint pathBufferSize, uint unkAnimationIndex, nint animationName) => ResolvePath(drawObject, _resolvePapPathHook.Original(drawObject, pathBuffer, pathBufferSize, unkAnimationIndex, animationName)); @@ -246,28 +261,6 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable return ret; } - [StructLayout(LayoutKind.Explicit)] - private struct ChangedEquipData - { - [FieldOffset(0)] - public PrimaryId Model; - - [FieldOffset(2)] - public Variant Variant; - - [FieldOffset(8)] - public PrimaryId BonusModel; - - [FieldOffset(10)] - public Variant BonusVariant; - - [FieldOffset(20)] - public ushort VfxId; - - [FieldOffset(22)] - public GenderRace GenderRace; - } - private nint ResolveVfxHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint unkOutParam) { switch (slotIndex) diff --git a/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs b/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs index bdb11752..0e04029b 100644 --- a/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs +++ b/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs @@ -14,9 +14,12 @@ public sealed unsafe class ResourceHandleDestructor : EventWrapperPtr SubfileHelper, - /// + /// ShaderReplacementFixer, + /// + ResourceLoader, + /// ResourceWatcher, } diff --git a/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs b/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs index c459a67a..0415fc9d 100644 --- a/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs +++ b/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs @@ -84,7 +84,9 @@ public sealed unsafe class LiveColorTablePreviewer : LiveMaterialPreviewerBase textureSize[1] = Height; using var texture = - new SafeTextureHandle(Device.Instance()->CreateTexture2D(textureSize, 1, 0x2460, 0x80000804, 7), false); + new SafeTextureHandle( + Device.Instance()->CreateTexture2D(textureSize, 1, TextureFormat.R16G16B16A16_FLOAT, + TextureFlags.TextureNoSwizzle | TextureFlags.Immutable | TextureFlags.Managed, 7), false); if (texture.IsInvalid) return; diff --git a/Penumbra/Interop/MaterialPreview/MaterialInfo.cs b/Penumbra/Interop/MaterialPreview/MaterialInfo.cs index f2ea2d6c..a9fb46ff 100644 --- a/Penumbra/Interop/MaterialPreview/MaterialInfo.cs +++ b/Penumbra/Interop/MaterialPreview/MaterialInfo.cs @@ -85,7 +85,7 @@ public readonly record struct MaterialInfo(ObjectIndex ObjectIndex, DrawObjectTy if (mtrlHandle == null) continue; - PathDataHandler.Split(mtrlHandle->ResourceHandle.FileName.AsSpan(), out var path, out _); + PathDataHandler.Split(mtrlHandle->FileName.AsSpan(), out var path, out _); var fileName = CiByteString.FromSpanUnsafe(path, true); if (fileName == needle) result.Add(new MaterialInfo(index, type, i, j)); diff --git a/Penumbra/Interop/PathResolving/CollectionResolver.cs b/Penumbra/Interop/PathResolving/CollectionResolver.cs index 576b61bb..136393d4 100644 --- a/Penumbra/Interop/PathResolving/CollectionResolver.cs +++ b/Penumbra/Interop/PathResolving/CollectionResolver.cs @@ -1,7 +1,7 @@ using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.UI.Agent; -using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Collections; using Penumbra.Collections.Manager; @@ -89,12 +89,19 @@ public sealed unsafe class CollectionResolver( /// Identify the correct collection for a draw object. public ResolveData IdentifyCollection(DrawObject* drawObject, bool useCache) { - var obj = (GameObject*)(drawObjectState.TryGetValue((nint)drawObject, out var gameObject) + if (drawObject is null) + return DefaultCollection; + + Actor obj = drawObjectState.TryGetValue(drawObject, out var gameObject) ? gameObject.Item1 - : drawObjectState.LastGameObject); - return IdentifyCollection(obj, useCache); + : drawObjectState.LastGameObject; + return IdentifyCollection(obj.AsObject, useCache); } + /// Get the default collection. + public ResolveData DefaultCollection + => collectionManager.Active.Default.ToResolveData(); + /// Return whether the given ModelChara id refers to a human-type model. public bool IsModelHuman(uint modelCharaId) => humanModels.IsHuman(modelCharaId); @@ -130,7 +137,7 @@ public sealed unsafe class CollectionResolver( { var item = charaEntry.Value; var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId); - Penumbra.Log.Verbose( + Penumbra.Log.Excessive( $"Identified {identifier.Incognito(null)} in cutscene for actor {idx + 200} at 0x{(ulong)gameObject:X} of race {(gameObject->IsCharacter() ? ((Character*)gameObject)->DrawData.CustomizeData.Race.ToString() : "Unknown")}."); if (identifier.IsValid && CollectionByIdentifier(identifier) is { } coll) { diff --git a/Penumbra/Interop/PathResolving/CutsceneService.cs b/Penumbra/Interop/PathResolving/CutsceneService.cs index 8e32dd76..97e64f84 100644 --- a/Penumbra/Interop/PathResolving/CutsceneService.cs +++ b/Penumbra/Interop/PathResolving/CutsceneService.cs @@ -15,10 +15,11 @@ public sealed class CutsceneService : IRequiredService, IDisposable public const int CutsceneEndIdx = (int)ScreenActor.CutsceneEnd; public const int CutsceneSlots = CutsceneEndIdx - CutsceneStartIdx; - private readonly ObjectManager _objects; - private readonly CopyCharacter _copyCharacter; - private readonly CharacterDestructor _characterDestructor; - private readonly short[] _copiedCharacters = Enumerable.Repeat((short)-1, CutsceneSlots).ToArray(); + private readonly ObjectManager _objects; + private readonly CopyCharacter _copyCharacter; + private readonly CharacterDestructor _characterDestructor; + private readonly ConstructCutsceneCharacter _constructCutsceneCharacter; + private readonly short[] _copiedCharacters = Enumerable.Repeat((short)-1, CutsceneSlots).ToArray(); public IEnumerable> Actors => Enumerable.Range(CutsceneStartIdx, CutsceneSlots) @@ -26,13 +27,15 @@ public sealed class CutsceneService : IRequiredService, IDisposable .Select(i => KeyValuePair.Create(i, this[i] ?? _objects.GetDalamudObject(i)!)); public unsafe CutsceneService(ObjectManager objects, CopyCharacter copyCharacter, CharacterDestructor characterDestructor, - IClientState clientState) + ConstructCutsceneCharacter constructCutsceneCharacter, IClientState clientState) { - _objects = objects; - _copyCharacter = copyCharacter; - _characterDestructor = characterDestructor; + _objects = objects; + _copyCharacter = copyCharacter; + _characterDestructor = characterDestructor; + _constructCutsceneCharacter = constructCutsceneCharacter; _copyCharacter.Subscribe(OnCharacterCopy, CopyCharacter.Priority.CutsceneService); _characterDestructor.Subscribe(OnCharacterDestructor, CharacterDestructor.Priority.CutsceneService); + _constructCutsceneCharacter.Subscribe(OnSetupPlayerNpc, ConstructCutsceneCharacter.Priority.CutsceneService); if (clientState.IsGPosing) RecoverGPoseActors(); } @@ -72,6 +75,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable return false; _copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx; + _objects.InvokeRequiredUpdates(); return true; } @@ -87,6 +91,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable { _copyCharacter.Unsubscribe(OnCharacterCopy); _characterDestructor.Unsubscribe(OnCharacterDestructor); + _constructCutsceneCharacter.Unsubscribe(OnSetupPlayerNpc); } private unsafe void OnCharacterDestructor(Character* character) @@ -124,6 +129,15 @@ public sealed class CutsceneService : IRequiredService, IDisposable _copiedCharacters[idx] = (short)(source != null ? source->GameObject.ObjectIndex : -1); } + private unsafe void OnSetupPlayerNpc(Character* npc) + { + if (npc == null || npc->ObjectIndex is < CutsceneStartIdx or >= CutsceneEndIdx) + return; + + var idx = npc->GameObject.ObjectIndex - CutsceneStartIdx; + _copiedCharacters[idx] = 0; + } + /// Try to recover GPose actors on reloads into a running game. /// This is not 100% accurate due to world IDs, minions etc., but will be mostly sane. private void RecoverGPoseActors() diff --git a/Penumbra/Interop/PathResolving/DrawObjectState.cs b/Penumbra/Interop/PathResolving/DrawObjectState.cs index 5e413fe2..6f3e457c 100644 --- a/Penumbra/Interop/PathResolving/DrawObjectState.cs +++ b/Penumbra/Interop/PathResolving/DrawObjectState.cs @@ -9,39 +9,42 @@ using Penumbra.Interop.Hooks.Objects; namespace Penumbra.Interop.PathResolving; -public sealed class DrawObjectState : IDisposable, IReadOnlyDictionary, IService +public sealed class DrawObjectState : IDisposable, IReadOnlyDictionary, IService { private readonly ObjectManager _objects; private readonly CreateCharacterBase _createCharacterBase; private readonly WeaponReload _weaponReload; private readonly CharacterBaseDestructor _characterBaseDestructor; + private readonly CharacterDestructor _characterDestructor; private readonly GameState _gameState; - private readonly Dictionary _drawObjectToGameObject = []; + private readonly Dictionary _drawObjectToGameObject = []; public nint LastGameObject => _gameState.LastGameObject; public unsafe DrawObjectState(ObjectManager objects, CreateCharacterBase createCharacterBase, WeaponReload weaponReload, - CharacterBaseDestructor characterBaseDestructor, GameState gameState, IFramework framework) + CharacterBaseDestructor characterBaseDestructor, GameState gameState, IFramework framework, CharacterDestructor characterDestructor) { _objects = objects; _createCharacterBase = createCharacterBase; _weaponReload = weaponReload; _characterBaseDestructor = characterBaseDestructor; _gameState = gameState; + _characterDestructor = characterDestructor; framework.RunOnFrameworkThread(InitializeDrawObjects); _weaponReload.Subscribe(OnWeaponReloading, WeaponReload.Priority.DrawObjectState); _weaponReload.Subscribe(OnWeaponReloaded, WeaponReload.PostEvent.Priority.DrawObjectState); _createCharacterBase.Subscribe(OnCharacterBaseCreated, CreateCharacterBase.PostEvent.Priority.DrawObjectState); _characterBaseDestructor.Subscribe(OnCharacterBaseDestructor, CharacterBaseDestructor.Priority.DrawObjectState); + _characterDestructor.Subscribe(OnCharacterDestructor, CharacterDestructor.Priority.DrawObjectState); } - public bool ContainsKey(nint key) + public bool ContainsKey(Model key) => _drawObjectToGameObject.ContainsKey(key); - public IEnumerator> GetEnumerator() + public IEnumerator> GetEnumerator() => _drawObjectToGameObject.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() @@ -50,16 +53,28 @@ public sealed class DrawObjectState : IDisposable, IReadOnlyDictionary _drawObjectToGameObject.Count; - public bool TryGetValue(nint drawObject, out (nint, bool) gameObject) - => _drawObjectToGameObject.TryGetValue(drawObject, out gameObject); + public bool TryGetValue(Model drawObject, out (Actor, ObjectIndex, bool) gameObject) + { + if (!_drawObjectToGameObject.TryGetValue(drawObject, out gameObject)) + return false; - public (nint, bool) this[nint key] + var currentObject = _objects[gameObject.Item2]; + if (currentObject != gameObject.Item1) + { + Penumbra.Log.Warning($"[DrawObjectState] Stored association {drawObject} -> {gameObject.Item1} has index {gameObject.Item2}, which resolves to {currentObject}."); + return false; + } + + return true; + } + + public (Actor, ObjectIndex, bool) this[Model key] => _drawObjectToGameObject[key]; - public IEnumerable Keys + public IEnumerable Keys => _drawObjectToGameObject.Keys; - public IEnumerable<(nint, bool)> Values + public IEnumerable<(Actor, ObjectIndex, bool)> Values => _drawObjectToGameObject.Values; public unsafe void Dispose() @@ -68,6 +83,37 @@ public sealed class DrawObjectState : IDisposable, IReadOnlyDictionary + /// Seems like sometimes the draw object of a game object is destroyed in frames after the original game object is already destroyed. + /// So protect against outdated game object pointers in the dictionary. + /// + private unsafe void OnCharacterDestructor(Character* a) + { + if (a is null) + return; + + var character = (nint)a; + var delete = stackalloc nint[5]; + var current = 0; + foreach (var (drawObject, (gameObject, _, _)) in _drawObjectToGameObject) + { + if (gameObject != character) + continue; + + delete[current++] = drawObject; + if (current is 4) + break; + } + + for (var ptr = delete; *ptr != nint.Zero; ++ptr) + { + _drawObjectToGameObject.Remove(*ptr, out var pair); + Penumbra.Log.Excessive( + $"[DrawObjectState] Removed draw object 0x{*ptr:X} -> 0x{(nint)a:X} (actual: 0x{pair.GameObject.Address:X}, {pair.IsChild})."); + } } private unsafe void OnWeaponReloading(DrawDataContainer* _, Character* character, CharacterWeapon* _2) @@ -85,9 +131,9 @@ public sealed class DrawObjectState : IDisposable, IReadOnlyDictionary @@ -103,12 +149,12 @@ public sealed class DrawObjectState : IDisposable, IReadOnlyDictionaryChildObject, gameObject, true, true); if (!iterate) return; diff --git a/Penumbra/Interop/PathResolving/MetaState.cs b/Penumbra/Interop/PathResolving/MetaState.cs index e7fc3176..eeae77cc 100644 --- a/Penumbra/Interop/PathResolving/MetaState.cs +++ b/Penumbra/Interop/PathResolving/MetaState.cs @@ -98,7 +98,7 @@ public sealed unsafe class MetaState : IDisposable, IService _lastCreatedCollection = _collectionResolver.IdentifyLastGameObjectCollection(true); if (_lastCreatedCollection.Valid && _lastCreatedCollection.AssociatedGameObject != nint.Zero) _communicator.CreatingCharacterBase.Invoke(_lastCreatedCollection.AssociatedGameObject, - _lastCreatedCollection.ModCollection.Id, (nint)modelCharaId, (nint)customize, (nint)equipData); + _lastCreatedCollection.ModCollection.Identity.Id, (nint)modelCharaId, (nint)customize, (nint)equipData); var decal = new DecalReverter(Config, _characterUtility, _resources, _lastCreatedCollection, UsesDecal(*(uint*)modelCharaId, (nint)customize)); diff --git a/Penumbra/Interop/PathResolving/PathDataHandler.cs b/Penumbra/Interop/PathResolving/PathDataHandler.cs index 5439151f..e0c235a2 100644 --- a/Penumbra/Interop/PathResolving/PathDataHandler.cs +++ b/Penumbra/Interop/PathResolving/PathDataHandler.cs @@ -32,7 +32,7 @@ public static class PathDataHandler /// Create the encoding path for an IMC file. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static FullPath CreateImc(CiByteString path, ModCollection collection) - => new($"|{collection.LocalId.Id}_{collection.ImcChangeCounter}_{DiscriminatorString}|{path}"); + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Imc}_{DiscriminatorString}|{path}"); /// Create the encoding path for a TMB file. [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -47,17 +47,17 @@ public static class PathDataHandler /// Create the encoding path for an ATCH file. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static FullPath CreateAtch(CiByteString path, ModCollection collection) - => new($"|{collection.LocalId.Id}_{collection.AtchChangeCounter}_{DiscriminatorString}|{path}"); + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Atch}_{DiscriminatorString}|{path}"); /// Create the encoding path for a MTRL file. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static FullPath CreateMtrl(CiByteString path, ModCollection collection, Utf8GamePath originalPath) - => new($"|{collection.LocalId.Id}_{collection.ChangeCounter}_{originalPath.Path.Crc32:X8}_{DiscriminatorString}|{path}"); + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Change}_{originalPath.Path.Crc32:X8}_{DiscriminatorString}|{path}"); /// The base function shared by most file types. [MethodImpl(MethodImplOptions.AggressiveInlining)] private static FullPath CreateBase(CiByteString path, ModCollection collection) - => new($"|{collection.LocalId.Id}_{collection.ChangeCounter}_{DiscriminatorString}|{path}"); + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Change}_{DiscriminatorString}|{path}"); /// Read an additional data blurb and parse it into usable data for all file types but Materials. [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Penumbra/Interop/PathResolving/PathResolver.cs b/Penumbra/Interop/PathResolving/PathResolver.cs index 0b6c8340..ec421304 100644 --- a/Penumbra/Interop/PathResolving/PathResolver.cs +++ b/Penumbra/Interop/PathResolving/PathResolver.cs @@ -1,4 +1,3 @@ -using System.Linq; using FFXIVClientStructs.FFXIV.Client.System.Resource; using OtterGui.Services; using Penumbra.Api.Enums; @@ -49,42 +48,40 @@ public class PathResolver : IDisposable, IService if (!_config.EnableMods) return (null, ResolveData.Invalid); - // Do not allow manipulating layers to prevent very obvious cheating and softlocks. - if (resourceType is ResourceType.Lvb or ResourceType.Lgb or ResourceType.Sgb) - return (null, ResolveData.Invalid); - - // Prevent .atch loading to prevent crashes on outdated .atch files. TODO: handle atch modding differently. - if (resourceType is ResourceType.Atch) - return ResolveAtch(path); - - return category switch + return resourceType switch { - // Only Interface collection. - ResourceCategory.Ui => ResolveUi(path), - // Never allow changing scripts. - ResourceCategory.UiScript => (null, ResolveData.Invalid), - ResourceCategory.GameScript => (null, ResolveData.Invalid), - // Use actual resolving. - ResourceCategory.Chara => Resolve(path, resourceType), - ResourceCategory.Shader => ResolveShader(path, resourceType), - ResourceCategory.Vfx => Resolve(path, resourceType), - ResourceCategory.Sound => Resolve(path, resourceType), - // EXD Modding in general should probably be prohibited but is currently used for fan translations. - // We prevent WebURL specifically because it technically allows launching arbitrary programs / to execute arbitrary code. - ResourceCategory.Exd => path.Path.StartsWith("exd/weburl"u8) - ? (null, ResolveData.Invalid) - : DefaultResolver(path), - // None of these files are ever associated with specific characters, - // always use the default resolver for now, - // except that common/font is conceptually more UI. - ResourceCategory.Common => path.Path.StartsWith("common/font"u8) - ? ResolveUi(path) - : DefaultResolver(path), - ResourceCategory.BgCommon => DefaultResolver(path), - ResourceCategory.Bg => DefaultResolver(path), - ResourceCategory.Cut => DefaultResolver(path), - ResourceCategory.Music => DefaultResolver(path), - _ => DefaultResolver(path), + // Do not allow manipulating layers to prevent very obvious cheating and softlocks. + ResourceType.Lvb or ResourceType.Lgb or ResourceType.Sgb => (null, ResolveData.Invalid), + // Prevent .atch loading to prevent crashes on outdated .atch files. + ResourceType.Atch => ResolveAtch(path), + // These are manipulated through Meta Edits instead. + ResourceType.Eqp or ResourceType.Eqdp or ResourceType.Est or ResourceType.Gmp or ResourceType.Cmp => (null, ResolveData.Invalid), + + _ => category switch + { + // Only Interface collection. + ResourceCategory.Ui => ResolveUi(path), + // Never allow changing scripts. + ResourceCategory.UiScript => (null, ResolveData.Invalid), + ResourceCategory.GameScript => (null, ResolveData.Invalid), + // Use actual resolving. + ResourceCategory.Chara => Resolve(path, resourceType), + ResourceCategory.Shader => ResolveShader(path, resourceType), + ResourceCategory.Vfx => Resolve(path, resourceType), + ResourceCategory.Sound => Resolve(path, resourceType), + // EXD Modding in general should probably be prohibited but is currently used for fan translations. + // We prevent WebURL specifically because it technically allows launching arbitrary programs / to execute arbitrary code. + ResourceCategory.Exd => path.Path.StartsWith("exd/weburl"u8) ? (null, ResolveData.Invalid) : DefaultResolver(path), + // None of these files are ever associated with specific characters, + // always use the default resolver for now, + // except that common/font is conceptually more UI. + ResourceCategory.Common => path.Path.StartsWith("common/font"u8) ? ResolveUi(path) : DefaultResolver(path), + ResourceCategory.BgCommon => DefaultResolver(path), + ResourceCategory.Bg => DefaultResolver(path), + ResourceCategory.Cut => DefaultResolver(path), + ResourceCategory.Music => DefaultResolver(path), + _ => DefaultResolver(path), + } }; } diff --git a/Penumbra/Interop/ProcessThreadApi.cs b/Penumbra/Interop/ProcessThreadApi.cs new file mode 100644 index 00000000..5ee213d9 --- /dev/null +++ b/Penumbra/Interop/ProcessThreadApi.cs @@ -0,0 +1,7 @@ +namespace Penumbra.Interop; + +public static partial class ProcessThreadApi +{ + [LibraryImport("kernel32.dll")] + public static partial uint GetCurrentThreadId(); +} diff --git a/Penumbra/Interop/Processing/AtchPathPreProcessor.cs b/Penumbra/Interop/Processing/AtchPathPreProcessor.cs index 9a9096f3..428826bc 100644 --- a/Penumbra/Interop/Processing/AtchPathPreProcessor.cs +++ b/Penumbra/Interop/Processing/AtchPathPreProcessor.cs @@ -20,7 +20,7 @@ public sealed class AtchPathPreProcessor : IPathPreProcessor if (!TryGetAtchGenderRace(path, out var gr)) return resolved; - Penumbra.Log.Information($"Pre-Processed {path} with {resolveData.ModCollection} for {gr.ToName()}."); + Penumbra.Log.Excessive($"Pre-Processed {path} with {resolveData.ModCollection} for {gr.ToName()}."); if (resolveData.ModCollection.MetaCache?.Atch.GetFile(gr, out var file) == true) return PathDataHandler.CreateAtch(path, resolveData.ModCollection); diff --git a/Penumbra/Interop/Processing/FilePostProcessService.cs b/Penumbra/Interop/Processing/FilePostProcessService.cs index ecf78c69..71340178 100644 --- a/Penumbra/Interop/Processing/FilePostProcessService.cs +++ b/Penumbra/Interop/Processing/FilePostProcessService.cs @@ -4,6 +4,7 @@ using Penumbra.Api.Enums; using Penumbra.Interop.Hooks.ResourceLoading; using Penumbra.Interop.Structs; using Penumbra.String; +using Penumbra.String.Classes; namespace Penumbra.Interop.Processing; @@ -20,20 +21,20 @@ public unsafe class FilePostProcessService : IRequiredService, IDisposable public FilePostProcessService(ResourceLoader resourceLoader, ServiceManager services) { - _resourceLoader = resourceLoader; - _processors = services.GetServicesImplementing().ToFrozenDictionary(s => s.Type, s => s); - _resourceLoader.FileLoaded += OnFileLoaded; + _resourceLoader = resourceLoader; + _processors = services.GetServicesImplementing().ToFrozenDictionary(s => s.Type, s => s); + _resourceLoader.BeforeResourceComplete += OnBeforeResourceComplete; } public void Dispose() { - _resourceLoader.FileLoaded -= OnFileLoaded; + _resourceLoader.BeforeResourceComplete -= OnBeforeResourceComplete; } - private void OnFileLoaded(ResourceHandle* resource, CiByteString path, bool returnValue, bool custom, - ReadOnlySpan additionalData) + private void OnBeforeResourceComplete(ResourceHandle* resource, CiByteString path, Utf8GamePath original, + ReadOnlySpan additionalData, bool isAsync) { if (_processors.TryGetValue(resource->FileType, out var processor)) - processor.PostProcess(resource, path, additionalData); + processor.PostProcess(resource, original.Path, additionalData); } } diff --git a/Penumbra/Interop/Processing/ImcFilePostProcessor.cs b/Penumbra/Interop/Processing/ImcFilePostProcessor.cs index a3233cfb..949baaa3 100644 --- a/Penumbra/Interop/Processing/ImcFilePostProcessor.cs +++ b/Penumbra/Interop/Processing/ImcFilePostProcessor.cs @@ -1,4 +1,3 @@ -using Dalamud.Game.ClientState.JobGauge.Types; using Penumbra.Api.Enums; using Penumbra.Collections.Manager; using Penumbra.Interop.PathResolving; @@ -26,6 +25,6 @@ public sealed class ImcFilePostProcessor(CollectionStorage collections) : IFileP file.Replace(resource); Penumbra.Log.Verbose( - $"[ResourceLoader] Loaded {originalGamePath} from file and replaced with IMC from collection {collection.AnonymizedName}."); + $"[ResourceLoader] Loaded {originalGamePath} from file and replaced with IMC from collection {collection.Identity.AnonymizedName}."); } } diff --git a/Penumbra/Interop/Processing/PbdFilePostProcessor.cs b/Penumbra/Interop/Processing/PbdFilePostProcessor.cs new file mode 100644 index 00000000..674500cd --- /dev/null +++ b/Penumbra/Interop/Processing/PbdFilePostProcessor.cs @@ -0,0 +1,119 @@ +using Dalamud.Game; +using Dalamud.Plugin.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData; +using Penumbra.GameData.Data; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; +using Penumbra.String; + +namespace Penumbra.Interop.Processing; + +public sealed class PbdFilePostProcessor : IFilePostProcessor +{ + private readonly IFileAllocator _allocator; + private byte[] _epbdData; + private unsafe delegate* unmanaged _loadEpbdData; + + public ResourceType Type + => ResourceType.Pbd; + + public unsafe PbdFilePostProcessor(IDataManager dataManager, XivFileAllocator allocator, ISigScanner scanner) + { + _allocator = allocator; + _epbdData = SetEpbdData(dataManager); + _loadEpbdData = (delegate* unmanaged)scanner.ScanText(Sigs.LoadEpbdData); + } + + public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan additionalData) + { + if (_epbdData.Length is 0) + return; + + if (resource->LoadState is not LoadState.Success) + { + Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} failed load ({resource->LoadState})."); + return; + } + + var (data, length) = resource->GetData(); + if (length is 0 || data == nint.Zero) + { + Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} succeeded load but has no data."); + return; + } + + var span = new ReadOnlySpan((void*)data, (int)resource->FileSize); + var reader = new PackReader(span); + if (reader.HasData) + { + Penumbra.Log.Excessive($"[ResourceLoader] Successfully loaded PBD at {resource->FileName()} with EPBD data."); + return; + } + + var newData = AppendData(span); + fixed (byte* ptr = newData) + { + // Set the appended data and the actual file size, then re-load the EPBD data via game function call. + if (resource->SetData((nint)ptr, newData.Length)) + { + resource->FileSize = (uint)newData.Length; + resource->CsHandle.FileSize2 = (uint)newData.Length; + resource->CsHandle.FileSize3 = (uint)newData.Length; + _loadEpbdData(resource); + // Free original data. + _allocator.Release((void*)data, length); + Penumbra.Log.Debug($"[ResourceLoader] Loaded {resource->FileName()} from file and appended default EPBD data."); + } + else + { + Penumbra.Log.Warning( + $"[ResourceLoader] Failed to append EPBD data to custom PBD at {resource->FileName()}."); + } + } + } + + /// Combine the given data with the default PBD data using the game's file allocator. + private unsafe ReadOnlySpan AppendData(ReadOnlySpan data) + { + // offset has to be set, otherwise not called. + var newLength = data.Length + _epbdData.Length; + var memory = _allocator.Allocate(newLength); + var span = new Span(memory, newLength); + data.CopyTo(span); + _epbdData.CopyTo(span[data.Length..]); + return span; + } + + /// Fetch the default EPBD data from the .pbd file of the game's installation. + private static byte[] SetEpbdData(IDataManager dataManager) + { + try + { + var file = dataManager.GetFile(GamePaths.Pbd.Path); + if (file is null || file.Data.Length is 0) + { + Penumbra.Log.Warning("Default PBD file has no data."); + return []; + } + + ReadOnlySpan span = file.Data; + var reader = new PackReader(span); + if (!reader.HasData) + { + Penumbra.Log.Warning("Default PBD file has no EPBD section."); + return []; + } + + var offset = span.Length - (int)reader.PackLength; + var ret = span[offset..]; + Penumbra.Log.Verbose($"Default PBD file has EPBD section of length {ret.Length} at offset {offset}."); + return ret.ToArray(); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Unknown error getting default EPBD data:\n{ex}"); + return []; + } + } +} diff --git a/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs index 2fb35ae0..ddd59121 100644 --- a/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs +++ b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs @@ -49,15 +49,15 @@ public sealed class ShpkPathPreProcessor(ResourceManagerService resourceManager, return null; } - private static SanityCheckResult SanityCheck(string path) + internal static SanityCheckResult SanityCheck(string path) { try { using var file = MmioMemoryManager.CreateFromFile(path, access: MemoryMappedFileAccess.Read); var bytes = file.GetSpan(); - return ShpkFile.FastIsLegacy(bytes) - ? SanityCheckResult.Legacy + return ShpkFile.FastIsObsolete(bytes) + ? SanityCheckResult.Obsolete : SanityCheckResult.Success; } catch (FileNotFoundException) @@ -75,15 +75,15 @@ public sealed class ShpkPathPreProcessor(ResourceManagerService resourceManager, { SanityCheckResult.IoError => "Cannot read the modded file.", SanityCheckResult.NotFound => "The modded file does not exist.", - SanityCheckResult.Legacy => "This mod is not compatible with Dawntrail. Get an updated version, if possible, or disable it.", + SanityCheckResult.Obsolete => "This mod is not compatible with Dawntrail post patch 7.2. Get an updated version, if possible, or disable it.", _ => string.Empty, }; - private enum SanityCheckResult + internal enum SanityCheckResult { Success, IoError, NotFound, - Legacy, + Obsolete, } } diff --git a/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs new file mode 100644 index 00000000..bd066d83 --- /dev/null +++ b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs @@ -0,0 +1,63 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; + +namespace Penumbra.Interop.Processing; + +public static unsafe class SkinMtrlPathEarlyProcessing +{ + public static void Process(Span path, CharacterBase* character, uint slotIndex) + { + var end = path.IndexOf(MaterialExtension()); + if (end < 0) + return; + + var suffixPos = path[..end].LastIndexOf((byte)'_'); + if (suffixPos < 0) + return; + + var handle = GetModelResourceHandle(character, slotIndex); + if (handle == null) + return; + + var skinSuffix = GetSkinSuffix(handle); + if (skinSuffix.IsEmpty || skinSuffix.Length > path.Length - suffixPos - 7) + return; + + ++suffixPos; + skinSuffix.CopyTo(path[suffixPos..]); + suffixPos += skinSuffix.Length; + MaterialExtension().CopyTo(path[suffixPos..]); + return; + + static ReadOnlySpan MaterialExtension() + => ".mtrl\0"u8; + } + + private static ModelResourceHandle* GetModelResourceHandle(CharacterBase* character, uint slotIndex) + { + if (character is null) + return null; + + if (character->PerSlotStagingArea is not null) + { + var handle = character->PerSlotStagingArea[slotIndex].ModelResourceHandle; + if (handle != null) + return handle; + } + + var model = character->Models[slotIndex]; + return model is null ? null : model->ModelResourceHandle; + } + + private static ReadOnlySpan GetSkinSuffix(ModelResourceHandle* handle) + { + foreach (var (attribute, _) in handle->Attributes) + { + var attributeSpan = attribute.AsSpan(); + if (attributeSpan.Length > 12 && attributeSpan[..11].SequenceEqual("skin_suffix"u8) && attributeSpan[11] is (byte)'=' or (byte)'_') + return attributeSpan[12..]; + } + + return []; + } +} diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs index 0c36b745..c204f141 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs @@ -22,6 +22,13 @@ internal partial record ResolveContext private static bool IsEquipmentSlot(uint slotIndex) => slotIndex is < 5 or 16 or 17; + private unsafe Variant Variant + => ModelType switch + { + ModelType.Monster => (byte)((Monster*)CharacterBase)->Variant, + _ => Equipment.Variant, + }; + private Utf8GamePath ResolveModelPath() { // Correctness: @@ -36,8 +43,8 @@ internal partial record ResolveContext private Utf8GamePath ResolveEquipmentModelPath() { var path = IsEquipmentSlot(SlotIndex) - ? GamePaths.Equipment.Mdl.Path(Equipment.Set, ResolveModelRaceCode(), SlotIndex.ToEquipSlot()) - : GamePaths.Accessory.Mdl.Path(Equipment.Set, ResolveModelRaceCode(), SlotIndex.ToEquipSlot()); + ? GamePaths.Mdl.Equipment(Equipment.Set, ResolveModelRaceCode(), SlotIndex.ToEquipSlot()) + : GamePaths.Mdl.Accessory(Equipment.Set, ResolveModelRaceCode(), SlotIndex.ToEquipSlot()); return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; } @@ -92,7 +99,7 @@ internal partial record ResolveContext => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), ModelType.DemiHuman => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), ModelType.Weapon => ResolveWeaponMaterialPath(modelPath, imc, mtrlFileName), - ModelType.Monster => ResolveMonsterMaterialPath(modelPath, imc, mtrlFileName), + ModelType.Monster => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), _ => ResolveMaterialPathNative(mtrlFileName), }; } @@ -100,7 +107,7 @@ internal partial record ResolveContext [SkipLocalsInit] private unsafe Utf8GamePath ResolveEquipmentMaterialPath(Utf8GamePath modelPath, ResourceHandle* imc, byte* mtrlFileName) { - var variant = ResolveMaterialVariant(imc, Equipment.Variant); + var variant = ResolveImcData(imc).MaterialId; var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); Span pathBuffer = stackalloc byte[CharaBase.PathBufferSize]; @@ -115,12 +122,12 @@ internal partial record ResolveContext var setIdHigh = Equipment.Set.Id / 100; // All MCH (20??) weapons' materials C are one and the same if (setIdHigh is 20 && mtrlFileName[14] == (byte)'c') - return Utf8GamePath.FromString(GamePaths.Weapon.Mtrl.Path(2001, 1, 1, "c"), out var path) ? path : Utf8GamePath.Empty; + return Utf8GamePath.FromString(GamePaths.Mtrl.Weapon(2001, 1, 1, "c"), out var path) ? path : Utf8GamePath.Empty; // Some offhands share materials with the corresponding mainhand - if (ItemData.AdaptOffhandImc(Equipment.Set.Id, out var mirroredSetId)) + if (ItemData.AdaptOffhandImc(Equipment.Set, out var mirroredSetId)) { - var variant = ResolveMaterialVariant(imc, Equipment.Variant); + var variant = ResolveImcData(imc).MaterialId; var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); Span mirroredFileName = stackalloc byte[32]; @@ -141,31 +148,16 @@ internal partial record ResolveContext return ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName); } - private unsafe Utf8GamePath ResolveMonsterMaterialPath(Utf8GamePath modelPath, ResourceHandle* imc, byte* mtrlFileName) - { - var variant = ResolveMaterialVariant(imc, (byte)((Monster*)CharacterBase)->Variant); - var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); - - Span pathBuffer = stackalloc byte[CharaBase.PathBufferSize]; - pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, fileName); - - return Utf8GamePath.FromSpan(pathBuffer, MetaDataComputation.None, out var path) ? path.Clone() : Utf8GamePath.Empty; - } - - private unsafe byte ResolveMaterialVariant(ResourceHandle* imc, Variant variant) + private unsafe ImcEntry ResolveImcData(ResourceHandle* imc) { var imcFileData = imc->GetDataSpan(); if (imcFileData.IsEmpty) { Penumbra.Log.Warning($"IMC resource handle with path {imc->FileName.AsByteString()} doesn't have a valid data span"); - return variant.Id; + return default; } - var entry = ImcFile.GetEntry(imcFileData, Slot.ToSlot(), variant, out var exists); - if (!exists) - return variant.Id; - - return entry.MaterialId; + return ImcFile.GetEntry(imcFileData, SlotIndex.ToEquipSlot(), Variant, out _); } private static Span AssembleMaterialPath(Span materialPathBuffer, ReadOnlySpan modelPath, byte variant, @@ -238,7 +230,7 @@ internal partial record ResolveContext if (set == 0) return Utf8GamePath.Empty; - var path = GamePaths.Skeleton.Sklb.Path(raceCode, slot, set); + var path = GamePaths.Sklb.Customization(raceCode, slot, set); return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; } @@ -256,7 +248,7 @@ internal partial record ResolveContext if (faceId < 201) faceId -= tribe switch { - 0xB when modelType == 4 => 100, + 0xB when modelType is 4 => 100, 0xE | 0xF => 100, _ => 0, }; @@ -305,10 +297,10 @@ internal partial record ResolveContext private Utf8GamePath ResolveHumanSkeletonParameterPath(uint partialSkeletonIndex) { var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); - if (set == 0) + if (set.Id is 0) return Utf8GamePath.Empty; - var path = GamePaths.Skeleton.Skp.Path(raceCode, slot, set); + var path = GamePaths.Skp.Customization(raceCode, slot, set); return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; } @@ -317,4 +309,80 @@ internal partial record ResolveContext var path = CharacterBase->ResolveSkpPathAsByteString(partialSkeletonIndex); return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; } + + private Utf8GamePath ResolvePhysicsModulePath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a physics module path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanPhysicsModulePath(partialSkeletonIndex), + _ => ResolvePhysicsModulePathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanPhysicsModulePath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set.Id is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Phyb.Customization(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolvePhysicsModulePathNative(uint partialSkeletonIndex) + { + var path = CharacterBase->ResolvePhybPathAsByteString(partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolveKineDriverModulePath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a KineDriver module path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanKineDriverModulePath(partialSkeletonIndex), + _ => ResolveKineDriverModulePathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanKineDriverModulePath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set.Id is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Kdb.Customization(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveKineDriverModulePathNative(uint partialSkeletonIndex) + { + var path = CharacterBase->ResolveKdbPathAsByteString(partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveMaterialAnimationPath(ResourceHandle* imc) + { + var animation = ResolveImcData(imc).MaterialAnimationId; + if (animation is 0) + return Utf8GamePath.Empty; + + var path = CharacterBase->ResolveMaterialPapPathAsByteString(SlotIndex, animation); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveDecalPath(ResourceHandle* imc) + { + var decal = ResolveImcData(imc).DecalId; + if (decal is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Tex.EquipDecal(decal); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } } diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.cs b/Penumbra/Interop/ResourceTree/ResolveContext.cs index 54612070..501bbc56 100644 --- a/Penumbra/Interop/ResourceTree/ResolveContext.cs +++ b/Penumbra/Interop/ResourceTree/ResolveContext.cs @@ -2,7 +2,7 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; using FFXIVClientStructs.FFXIV.Client.Graphics.Render; using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; using FFXIVClientStructs.Interop; -using OtterGui; +using OtterGui.Extensions; using OtterGui.Text.HelperObjects; using Penumbra.Api.Enums; using Penumbra.Collections; @@ -52,20 +52,20 @@ internal unsafe partial record ResolveContext( private ResourceNode? CreateNodeFromShpk(ShaderPackageResourceHandle* resourceHandle, CiByteString gamePath) { - if (resourceHandle == null) + if (resourceHandle is null) return null; if (gamePath.IsEmpty) return null; if (!Utf8GamePath.FromByteString(CiByteString.Join((byte)'/', ShpkPrefix, gamePath), out var path)) return null; - return GetOrCreateNode(ResourceType.Shpk, (nint)resourceHandle->ShaderPackage, &resourceHandle->ResourceHandle, path); + return GetOrCreateNode(ResourceType.Shpk, (nint)resourceHandle->ShaderPackage, (ResourceHandle*)resourceHandle, path); } [SkipLocalsInit] private ResourceNode? CreateNodeFromTex(TextureResourceHandle* resourceHandle, CiByteString gamePath, bool dx11) { - if (resourceHandle == null) + if (resourceHandle is null) return null; Utf8GamePath path; @@ -105,7 +105,7 @@ internal unsafe partial record ResolveContext( private ResourceNode GetOrCreateNode(ResourceType type, nint objectAddress, ResourceHandle* resourceHandle, Utf8GamePath gamePath) { - if (resourceHandle == null) + if (resourceHandle is null) throw new ArgumentNullException(nameof(resourceHandle)); if (Global.Nodes.TryGetValue((gamePath, (nint)resourceHandle), out var cached)) @@ -117,7 +117,7 @@ internal unsafe partial record ResolveContext( private ResourceNode CreateNode(ResourceType type, nint objectAddress, ResourceHandle* resourceHandle, Utf8GamePath gamePath, bool autoAdd = true) { - if (resourceHandle == null) + if (resourceHandle is null) throw new ArgumentNullException(nameof(resourceHandle)); var fileName = (ReadOnlySpan)resourceHandle->FileName.AsSpan(); @@ -141,7 +141,7 @@ internal unsafe partial record ResolveContext( public ResourceNode? CreateNodeFromEid(ResourceHandle* eid) { - if (eid == null) + if (eid is null) return null; if (!Utf8GamePath.FromByteString(CharacterBase->ResolveEidPathAsByteString(), out var path)) @@ -152,7 +152,7 @@ internal unsafe partial record ResolveContext( public ResourceNode? CreateNodeFromImc(ResourceHandle* imc) { - if (imc == null) + if (imc is null) return null; if (!Utf8GamePath.FromByteString(CharacterBase->ResolveImcPathAsByteString(SlotIndex), out var path)) @@ -163,7 +163,7 @@ internal unsafe partial record ResolveContext( public ResourceNode? CreateNodeFromPbd(ResourceHandle* pbd) { - if (pbd == null) + if (pbd is null) return null; return GetOrCreateNode(ResourceType.Pbd, 0, pbd, PreBoneDeformerReplacer.PreBoneDeformerPath); @@ -171,7 +171,7 @@ internal unsafe partial record ResolveContext( public ResourceNode? CreateNodeFromTex(TextureResourceHandle* tex, string gamePath) { - if (tex == null) + if (tex is null) return null; if (!Utf8GamePath.FromString(gamePath, out var path)) @@ -180,9 +180,18 @@ internal unsafe partial record ResolveContext( return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, path); } - public ResourceNode? CreateNodeFromModel(Model* mdl, ResourceHandle* imc) + public ResourceNode? CreateNodeFromTex(TextureResourceHandle* tex, Utf8GamePath gamePath) { - if (mdl == null || mdl->ModelResourceHandle == null) + if (tex is null) + return null; + + return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, gamePath); + } + + public ResourceNode? CreateNodeFromModel(Model* mdl, ResourceHandle* imc, TextureResourceHandle* decalHandle, + MaterialResourceHandle* skinMtrlHandle, ResourceHandle* mpapHandle) + { + if (mdl is null || mdl->ModelResourceHandle is null) return null; var mdlResource = mdl->ModelResourceHandle; @@ -197,12 +206,12 @@ internal unsafe partial record ResolveContext( for (var i = 0; i < mdl->MaterialCount; i++) { var mtrl = mdl->Materials[i]; - if (mtrl == null) + if (mtrl is null) continue; var mtrlFileName = mdlResource->GetMaterialFileNameBySlot((uint)i); var mtrlNode = CreateNodeFromMaterial(mtrl, ResolveMaterialPath(path, imc, mtrlFileName)); - if (mtrlNode != null) + if (mtrlNode is not null) { if (Global.WithUiData) mtrlNode.FallbackName = $"Material #{i}"; @@ -210,6 +219,18 @@ internal unsafe partial record ResolveContext( } } + if (skinMtrlHandle is not null + && Utf8GamePath.FromByteString(CharacterBase->ResolveSkinMtrlPathAsByteString(SlotIndex), out var skinMtrlPath) + && CreateNodeFromMaterial(skinMtrlHandle->Material, skinMtrlPath) is + { } skinMaaterialNode) + node.Children.Add(skinMaaterialNode); + + if (CreateNodeFromDecal(decalHandle, imc) is { } decalNode) + node.Children.Add(decalNode); + + if (CreateNodeFromMaterialPap(mpapHandle, imc) is { } mpapNode) + node.Children.Add(mpapNode); + Global.Nodes.Add((path, (nint)mdl->ModelResourceHandle), node); return node; @@ -217,29 +238,29 @@ internal unsafe partial record ResolveContext( private ResourceNode? CreateNodeFromMaterial(Material* mtrl, Utf8GamePath path) { - if (mtrl == null || mtrl->MaterialResourceHandle == null) + if (mtrl is null || mtrl->MaterialResourceHandle is null) return null; var resource = mtrl->MaterialResourceHandle; if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached)) return cached; - var node = CreateNode(ResourceType.Mtrl, (nint)mtrl, &resource->ResourceHandle, path, false); - var shpkNode = CreateNodeFromShpk(resource->ShaderPackageResourceHandle, new CiByteString(resource->ShpkName)); - if (shpkNode != null) + var node = CreateNode(ResourceType.Mtrl, (nint)mtrl, (ResourceHandle*)resource, path, false); + var shpkNode = CreateNodeFromShpk(resource->ShaderPackageResourceHandle, new CiByteString(resource->ShpkName.Value)); + if (shpkNode is not null) { if (Global.WithUiData) shpkNode.Name = "Shader Package"; node.Children.Add(shpkNode); } - var shpkNames = Global.WithUiData && shpkNode != null ? Global.TreeBuildCache.ReadShaderPackageNames(shpkNode.FullPath) : null; - var shpk = Global.WithUiData && shpkNode != null ? (ShaderPackage*)shpkNode.ObjectAddress : null; + var shpkNames = Global.WithUiData && shpkNode is not null ? Global.TreeBuildCache.ReadShaderPackageNames(shpkNode.FullPath) : null; + var shpk = Global.WithUiData && shpkNode is not null ? (ShaderPackage*)shpkNode.ObjectAddress : null; var alreadyProcessedSamplerIds = new HashSet(); for (var i = 0; i < resource->TextureCount; i++) { - var texNode = CreateNodeFromTex(resource->Textures[i].TextureResourceHandle, new CiByteString(resource->TexturePath(i)), + var texNode = CreateNodeFromTex(resource->Textures[i].TextureResourceHandle, new CiByteString(resource->TexturePath(i).Value), resource->Textures[i].IsDX11); if (texNode == null) continue; @@ -247,7 +268,7 @@ internal unsafe partial record ResolveContext( if (Global.WithUiData) { string? name = null; - if (shpk != null) + if (shpk is not null) { var index = GetTextureIndex(mtrl, resource->Textures[i].Flags, alreadyProcessedSamplerIds); var samplerId = index != 0x001F @@ -256,13 +277,13 @@ internal unsafe partial record ResolveContext( if (samplerId.HasValue) { alreadyProcessedSamplerIds.Add(samplerId.Value); - var samplerCrc = GetSamplerCrcById(shpk, samplerId.Value); - if (samplerCrc.HasValue) + var textureCrc = GetTextureCrcById(shpk, samplerId.Value); + if (textureCrc.HasValue) { - if (shpkNames != null && shpkNames.TryGetValue(samplerCrc.Value, out var samplerName)) + if (shpkNames != null && shpkNames.TryGetValue(textureCrc.Value, out var samplerName)) name = samplerName.Value; else - name = $"Texture 0x{samplerCrc.Value:X8}"; + name = $"Texture 0x{textureCrc.Value:X8}"; } } } @@ -278,9 +299,9 @@ internal unsafe partial record ResolveContext( return node; - static uint? GetSamplerCrcById(ShaderPackage* shpk, uint id) - => shpk->SamplersSpan.FindFirst(s => s.Id == id, out var s) - ? s.CRC + static uint? GetTextureCrcById(ShaderPackage* shpk, uint id) + => shpk->TexturesSpan.FindFirst(t => t.Id == id, out var t) + ? t.CRC : null; static uint? GetTextureSamplerId(Material* mtrl, TextureResourceHandle* handle, HashSet alreadyVisitedSamplerIds) @@ -301,9 +322,59 @@ internal unsafe partial record ResolveContext( } } - public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, uint partialSkeletonIndex) + public ResourceNode? CreateNodeFromDecal(TextureResourceHandle* decalHandle, ResourceHandle* imc) { - if (sklb == null || sklb->SkeletonResourceHandle == null) + if (decalHandle is null) + return null; + + var path = ResolveDecalPath(imc); + if (path.IsEmpty) + return null; + + var node = CreateNodeFromTex(decalHandle, path)!; + if (Global.WithUiData) + node.FallbackName = "Decal"; + + return node; + } + + public ResourceNode? CreateNodeFromMaterialPap(ResourceHandle* mpapHandle, ResourceHandle* imc) + { + if (mpapHandle is null) + return null; + + var path = ResolveMaterialAnimationPath(imc); + if (path.IsEmpty) + return null; + + if (Global.Nodes.TryGetValue((path, (nint)mpapHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Pap, 0, mpapHandle, path); + if (Global.WithUiData) + node.FallbackName = "Material Animation"; + + return node; + } + + public ResourceNode? CreateNodeFromMaterialSklb(SkeletonResourceHandle* sklbHandle) + { + if (sklbHandle is null) + return null; + + if (Global.Nodes.TryGetValue((GamePaths.Sklb.MaterialAnimationSkeletonUtf8, (nint)sklbHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Sklb, 0, (ResourceHandle*)sklbHandle, GamePaths.Sklb.MaterialAnimationSkeletonUtf8); + node.ForceInternal = true; + + return node; + } + + public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, ResourceHandle* phybHandle, ResourceHandle* kdbHandle, + uint partialSkeletonIndex) + { + if (sklb is null || sklb->SkeletonResourceHandle is null) return null; var path = ResolveSkeletonPath(partialSkeletonIndex); @@ -311,10 +382,13 @@ internal unsafe partial record ResolveContext( if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonResourceHandle), out var cached)) return cached; - var node = CreateNode(ResourceType.Sklb, (nint)sklb, (ResourceHandle*)sklb->SkeletonResourceHandle, path, false); - var skpNode = CreateParameterNodeFromPartialSkeleton(sklb, partialSkeletonIndex); - if (skpNode != null) + var node = CreateNode(ResourceType.Sklb, (nint)sklb, (ResourceHandle*)sklb->SkeletonResourceHandle, path, false); + if (CreateParameterNodeFromPartialSkeleton(sklb, partialSkeletonIndex) is { } skpNode) node.Children.Add(skpNode); + if (CreateNodeFromPhyb(phybHandle, partialSkeletonIndex) is { } phybNode) + node.Children.Add(phybNode); + if (CreateNodeFromKdb(kdbHandle, partialSkeletonIndex) is { } kdbNode) + node.Children.Add(kdbNode); Global.Nodes.Add((path, (nint)sklb->SkeletonResourceHandle), node); return node; @@ -322,7 +396,7 @@ internal unsafe partial record ResolveContext( private ResourceNode? CreateParameterNodeFromPartialSkeleton(PartialSkeleton* sklb, uint partialSkeletonIndex) { - if (sklb == null || sklb->SkeletonParameterResourceHandle == null) + if (sklb is null || sklb->SkeletonParameterResourceHandle is null) return null; var path = ResolveSkeletonParameterPath(partialSkeletonIndex); @@ -338,11 +412,49 @@ internal unsafe partial record ResolveContext( return node; } + private ResourceNode? CreateNodeFromPhyb(ResourceHandle* phybHandle, uint partialSkeletonIndex) + { + if (phybHandle is null) + return null; + + var path = ResolvePhysicsModulePath(partialSkeletonIndex); + + if (Global.Nodes.TryGetValue((path, (nint)phybHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Phyb, 0, phybHandle, path, false); + if (Global.WithUiData) + node.FallbackName = "Physics Module"; + Global.Nodes.Add((path, (nint)phybHandle), node); + + return node; + } + + private ResourceNode? CreateNodeFromKdb(ResourceHandle* kdbHandle, uint partialSkeletonIndex) + { + if (kdbHandle is null) + return null; + + var path = ResolveKineDriverModulePath(partialSkeletonIndex); + + if (Global.Nodes.TryGetValue((path, (nint)kdbHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Kdb, 0, kdbHandle, path, false); + if (Global.WithUiData) + node.FallbackName = "KineDriver Module"; + Global.Nodes.Add((path, (nint)kdbHandle), node); + + return node; + } + internal ResourceNode.UiData GuessModelUiData(Utf8GamePath gamePath) { var path = gamePath.Path.Split((byte)'/'); // Weapons intentionally left out. - var isEquipment = path.Count >= 2 && path[0].Span.SequenceEqual("chara"u8) && (path[1].Span.SequenceEqual("accessory"u8) || path[1].Span.SequenceEqual("equipment"u8)); + var isEquipment = path.Count >= 2 + && path[0].Span.SequenceEqual("chara"u8) + && (path[1].Span.SequenceEqual("accessory"u8) || path[1].Span.SequenceEqual("equipment"u8)); if (isEquipment) foreach (var item in Global.Identifier.Identify(Equipment.Set, 0, Equipment.Variant, Slot.ToSlot())) { @@ -358,7 +470,7 @@ internal unsafe partial record ResolveContext( } var dataFromPath = GuessUiDataFromPath(gamePath); - if (dataFromPath.Name != null) + if (dataFromPath.Name is not null) return dataFromPath; return isEquipment @@ -368,29 +480,19 @@ internal unsafe partial record ResolveContext( internal ResourceNode.UiData GuessUiDataFromPath(Utf8GamePath gamePath) { + const string customization = "Customization: "; foreach (var obj in Global.Identifier.Identify(gamePath.ToString())) { var name = obj.Key; - if (obj.Value is IdentifiedCustomization) - name = name[14..].Trim(); - if (name != "Unknown") + if (name.StartsWith(customization)) + name = name.AsSpan(14).Trim().ToString(); + if (name is not "Unknown") return new ResourceNode.UiData(name, obj.Value.GetIcon().ToFlag()); } return new ResourceNode.UiData(null, ChangedItemIconFlag.Unknown); } - private static string? SafeGet(ReadOnlySpan array, Index index) - { - var i = index.GetOffset(array.Length); - return i >= 0 && i < array.Length ? array[i] : null; - } - private static ulong GetResourceHandleLength(ResourceHandle* handle) - { - if (handle == null) - return 0; - - return handle->GetLength(); - } + => handle is null ? 0ul : handle->GetLength(); } diff --git a/Penumbra/Interop/ResourceTree/ResourceNode.cs b/Penumbra/Interop/ResourceTree/ResourceNode.cs index 6c3e1ebe..08dee818 100644 --- a/Penumbra/Interop/ResourceTree/ResourceNode.cs +++ b/Penumbra/Interop/ResourceTree/ResourceNode.cs @@ -1,4 +1,5 @@ using Penumbra.Api.Enums; +using Penumbra.Mods; using Penumbra.String; using Penumbra.String.Classes; using Penumbra.UI; @@ -15,7 +16,11 @@ public class ResourceNode : ICloneable public readonly nint ResourceHandle; public Utf8GamePath[] PossibleGamePaths; public FullPath FullPath; + public PathStatus FullPathStatus; + public bool ForceInternal; + public bool ForceProtected; public string? ModName; + public readonly WeakReference Mod = new(null!); public string? ModRelativePath; public CiByteString AdditionalData; public readonly ulong Length; @@ -34,8 +39,15 @@ public class ResourceNode : ICloneable } } + /// Whether to treat the file as internal (hide from user unless debug mode is on). public bool Internal - => Type is ResourceType.Eid or ResourceType.Imc; + => ForceInternal || Type is ResourceType.Eid or ResourceType.Imc; + + /// Whether to treat the file as protected (require holding the Mod Deletion Modifier to make a quick import). + public bool Protected + => ForceProtected + || Internal + || Type is ResourceType.Shpk or ResourceType.Sklb or ResourceType.Skp or ResourceType.Phyb or ResourceType.Kdb or ResourceType.Pbd; internal ResourceNode(ResourceType type, nint objectAddress, nint resourceHandle, ulong length, ResolveContext? resolveContext) { @@ -59,9 +71,13 @@ public class ResourceNode : ICloneable ResourceHandle = other.ResourceHandle; PossibleGamePaths = other.PossibleGamePaths; FullPath = other.FullPath; + FullPathStatus = other.FullPathStatus; ModName = other.ModName; + Mod = other.Mod; ModRelativePath = other.ModRelativePath; AdditionalData = other.AdditionalData; + ForceInternal = other.ForceInternal; + ForceProtected = other.ForceProtected; Length = other.Length; Children = other.Children; ResolveContext = other.ResolveContext; @@ -95,6 +111,13 @@ public class ResourceNode : ICloneable public readonly record struct UiData(string? Name, ChangedItemIconFlag IconFlag) { public UiData PrependName(string prefix) - => Name == null ? this : new UiData(prefix + Name, IconFlag); + => Name == null ? this : this with { Name = prefix + Name }; + } + + public enum PathStatus : byte + { + Valid, + NonExistent, + External, } } diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs index b50fc695..1ebfe53d 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTree.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -1,7 +1,9 @@ using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Graphics.Physics; using FFXIVClientStructs.FFXIV.Client.Graphics.Render; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using FFXIVClientStructs.Interop; using Penumbra.GameData.Data; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; @@ -10,45 +12,39 @@ using Penumbra.UI; using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData; using CustomizeIndex = Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex; using ModelType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase.ModelType; +using ResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle; namespace Penumbra.Interop.ResourceTree; -public class ResourceTree +public class ResourceTree( + string name, + string anonymizedName, + int gameObjectIndex, + nint gameObjectAddress, + nint drawObjectAddress, + bool localPlayerRelated, + bool playerRelated, + bool networked, + string collectionName, + string anonymizedCollectionName) { - public readonly string Name; - public readonly string AnonymizedName; - public readonly int GameObjectIndex; - public readonly nint GameObjectAddress; - public readonly nint DrawObjectAddress; - public readonly bool LocalPlayerRelated; - public readonly bool PlayerRelated; - public readonly bool Networked; - public readonly string CollectionName; - public readonly string AnonymizedCollectionName; - public readonly List Nodes; - public readonly HashSet FlatNodes; + public readonly string Name = name; + public readonly string AnonymizedName = anonymizedName; + public readonly int GameObjectIndex = gameObjectIndex; + public readonly nint GameObjectAddress = gameObjectAddress; + public readonly nint DrawObjectAddress = drawObjectAddress; + public readonly bool LocalPlayerRelated = localPlayerRelated; + public readonly bool PlayerRelated = playerRelated; + public readonly bool Networked = networked; + public readonly string CollectionName = collectionName; + public readonly string AnonymizedCollectionName = anonymizedCollectionName; + public readonly List Nodes = []; + public readonly HashSet FlatNodes = []; public int ModelId; public CustomizeData CustomizeData; public GenderRace RaceCode; - public ResourceTree(string name, string anonymizedName, int gameObjectIndex, nint gameObjectAddress, nint drawObjectAddress, - bool localPlayerRelated, bool playerRelated, bool networked, string collectionName, string anonymizedCollectionName) - { - Name = name; - AnonymizedName = anonymizedName; - GameObjectIndex = gameObjectIndex; - GameObjectAddress = gameObjectAddress; - DrawObjectAddress = drawObjectAddress; - LocalPlayerRelated = localPlayerRelated; - Networked = networked; - PlayerRelated = playerRelated; - CollectionName = collectionName; - AnonymizedCollectionName = anonymizedCollectionName; - Nodes = []; - FlatNodes = []; - } - public void ProcessPostfix(Action action) { foreach (var node in Nodes) @@ -70,10 +66,26 @@ public class ResourceTree }; ModelId = character->ModelContainer.ModelCharaId; CustomizeData = character->DrawData.CustomizeData; - RaceCode = human != null ? (GenderRace)human->RaceSexId : GenderRace.Unknown; + RaceCode = human is not null ? (GenderRace)human->RaceSexId : GenderRace.Unknown; var genericContext = globalContext.CreateContext(model); + var mpapArrayPtr = model->MaterialAnimationPacks; + var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan>(mpapArrayPtr, model->SlotCount) : []; + var skinMtrlArray = modelType switch + { + ModelType.Human => ((Human*) model)->SlotSkinMaterials, + _ => [], + }; + var decalArray = modelType switch + { + ModelType.Human => human->SlotDecals, + ModelType.DemiHuman => ((Demihuman*)model)->SlotDecals, + ModelType.Weapon => [((Weapon*)model)->Decal], + ModelType.Monster => [((Monster*)model)->Decal], + _ => [], + }; + for (var i = 0u; i < model->SlotCount; ++i) { var slotContext = modelType switch @@ -90,18 +102,18 @@ public class ResourceTree : globalContext.CreateContext(model, i), }; - var imc = (ResourceHandle*)model->IMCArray[i]; - var imcNode = slotContext.CreateNodeFromImc(imc); - if (imcNode != null) + var imc = (ResourceHandle*)model->IMCArray[i]; + if (slotContext.CreateNodeFromImc(imc) is { } imcNode) { if (globalContext.WithUiData) imcNode.FallbackName = $"IMC #{i}"; Nodes.Add(imcNode); } - var mdl = model->Models[i]; - var mdlNode = slotContext.CreateNodeFromModel(mdl, imc); - if (mdlNode != null) + var mdl = model->Models[i]; + if (slotContext.CreateNodeFromModel(mdl, imc, i < decalArray.Length ? decalArray[(int)i].Value : null, + i < skinMtrlArray.Length ? skinMtrlArray[(int)i].Value : null, i < mpapArray.Length ? mpapArray[(int)i].Value : null) is + { } mdlNode) { if (globalContext.WithUiData) mdlNode.FallbackName = $"Model #{i}"; @@ -109,11 +121,12 @@ public class ResourceTree } } - AddSkeleton(Nodes, genericContext, model->EID, model->Skeleton); + AddSkeleton(Nodes, genericContext, model); + AddMaterialAnimationSkeleton(Nodes, genericContext, model->MaterialAnimationSkeleton); AddWeapons(globalContext, model); - if (human != null) + if (human is not null) AddHumanResources(globalContext, human); } @@ -123,12 +136,12 @@ public class ResourceTree var weaponNodes = new List(); foreach (var baseSubObject in model->DrawObject.Object.ChildObjects) { - if (baseSubObject->GetObjectType() != FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType.CharacterBase) + if (baseSubObject->GetObjectType() is not FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType.CharacterBase) continue; var subObject = (CharacterBase*)baseSubObject; - if (subObject->GetModelType() != ModelType.Weapon) + if (subObject->GetModelType() is not ModelType.Weapon) continue; var weapon = (Weapon*)subObject; @@ -140,22 +153,24 @@ public class ResourceTree var genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment, weaponType); + var mpapArrayPtr = subObject->MaterialAnimationPacks; + var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan>(mpapArrayPtr, subObject->SlotCount) : []; + for (var i = 0; i < subObject->SlotCount; ++i) { var slotContext = globalContext.CreateContext(subObject, (uint)i, slot, equipment, weaponType); - var imc = (ResourceHandle*)subObject->IMCArray[i]; - var imcNode = slotContext.CreateNodeFromImc(imc); - if (imcNode != null) + var imc = (ResourceHandle*)subObject->IMCArray[i]; + if (slotContext.CreateNodeFromImc(imc) is { } imcNode) { if (globalContext.WithUiData) imcNode.FallbackName = $"Weapon #{weaponIndex}, IMC #{i}"; weaponNodes.Add(imcNode); } - var mdl = subObject->Models[i]; - var mdlNode = slotContext.CreateNodeFromModel(mdl, imc); - if (mdlNode != null) + var mdl = subObject->Models[i]; + if (slotContext.CreateNodeFromModel(mdl, imc, weapon->Decal, null, i < mpapArray.Length ? mpapArray[i].Value : null) is + { } mdlNode) { if (globalContext.WithUiData) mdlNode.FallbackName = $"Weapon #{weaponIndex}, Model #{i}"; @@ -163,7 +178,9 @@ public class ResourceTree } } - AddSkeleton(weaponNodes, genericContext, subObject->EID, subObject->Skeleton, $"Weapon #{weaponIndex}, "); + AddSkeleton(weaponNodes, genericContext, subObject, $"Weapon #{weaponIndex}, "); + AddMaterialAnimationSkeleton(weaponNodes, genericContext, subObject->MaterialAnimationSkeleton, + $"Weapon #{weaponIndex}, "); ++weaponIndex; } @@ -176,28 +193,25 @@ public class ResourceTree var genericContext = globalContext.CreateContext(&human->CharacterBase); var cache = globalContext.Collection._cache; - if (cache != null && cache.CustomResources.TryGetValue(PreBoneDeformerReplacer.PreBoneDeformerPath, out var pbdHandle)) + if (cache is not null + && cache.CustomResources.TryGetValue(PreBoneDeformerReplacer.PreBoneDeformerPath, out var pbdHandle) + && genericContext.CreateNodeFromPbd(pbdHandle.ResourceHandle) is { } pbdNode) { - var pbdNode = genericContext.CreateNodeFromPbd(pbdHandle.ResourceHandle); - if (pbdNode != null) + if (globalContext.WithUiData) { - if (globalContext.WithUiData) - { - pbdNode = pbdNode.Clone(); - pbdNode.FallbackName = "Racial Deformer"; - pbdNode.IconFlag = ChangedItemIconFlag.Customization; - } - - Nodes.Add(pbdNode); + pbdNode = pbdNode.Clone(); + pbdNode.FallbackName = "Racial Deformer"; + pbdNode.IconFlag = ChangedItemIconFlag.Customization; } + + Nodes.Add(pbdNode); } var decalId = (byte)(human->Customize[(int)CustomizeIndex.Facepaint] & 0x7F); - var decalPath = decalId != 0 - ? GamePaths.Human.Decal.FaceDecalPath(decalId) - : GamePaths.Tex.TransparentPath; - var decalNode = genericContext.CreateNodeFromTex(human->Decal, decalPath); - if (decalNode != null) + var decalPath = decalId is not 0 + ? GamePaths.Tex.FaceDecal(decalId) + : GamePaths.Tex.Transparent; + if (genericContext.CreateNodeFromTex(human->Decal, decalPath) is { } decalNode) { if (globalContext.WithUiData) { @@ -211,11 +225,11 @@ public class ResourceTree var hasLegacyDecal = (human->Customize[(int)CustomizeIndex.FaceFeatures] & 0x80) != 0; var legacyDecalPath = hasLegacyDecal - ? GamePaths.Human.Decal.LegacyDecalPath - : GamePaths.Tex.TransparentPath; - var legacyDecalNode = genericContext.CreateNodeFromTex(human->LegacyBodyDecal, legacyDecalPath); - if (legacyDecalNode != null) + ? GamePaths.Tex.LegacyDecal + : GamePaths.Tex.Transparent; + if (genericContext.CreateNodeFromTex(human->LegacyBodyDecal, legacyDecalPath) is { } legacyDecalNode) { + legacyDecalNode.ForceProtected = !hasLegacyDecal; if (globalContext.WithUiData) { legacyDecalNode = legacyDecalNode.Clone(); @@ -227,7 +241,11 @@ public class ResourceTree } } - private unsafe void AddSkeleton(List nodes, ResolveContext context, void* eid, Skeleton* skeleton, string prefix = "") + private unsafe void AddSkeleton(List nodes, ResolveContext context, CharacterBase* model, string prefix = "") + => AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, model->BoneKineDriverModule, prefix); + + private unsafe void AddSkeleton(List nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics, + BoneKineDriverModule* kineDriver, string prefix = "") { var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid); if (eidNode != null) @@ -242,8 +260,9 @@ public class ResourceTree for (var i = 0; i < skeleton->PartialSkeletonCount; ++i) { - var sklbNode = context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], (uint)i); - if (sklbNode != null) + var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null; + var kdbHandle = kineDriver != null ? kineDriver->PartialSkeletonEntries[i].KineDriverResourceHandle : null; + if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode) { if (context.Global.WithUiData) sklbNode.FallbackName = $"{prefix}Skeleton #{i}"; @@ -251,4 +270,16 @@ public class ResourceTree } } } + + private unsafe void AddMaterialAnimationSkeleton(List nodes, ResolveContext context, SkeletonResourceHandle* sklbHandle, + string prefix = "") + { + var sklbNode = context.CreateNodeFromMaterialSklb(sklbHandle); + if (sklbNode is null) + return; + + if (context.Global.WithUiData) + sklbNode.FallbackName = $"{prefix}Material Animation Skeleton"; + nodes.Add(sklbNode); + } } diff --git a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs index 9738148f..49194c3a 100644 --- a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs +++ b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs @@ -23,22 +23,35 @@ public class ResourceTreeFactory( Configuration config, ActorManager actors, PathState pathState, + IFramework framework, ModManager modManager) : IService { + private static readonly string ParentDirectoryPrefix = $"..{Path.DirectorySeparatorChar}"; + private TreeBuildCache CreateTreeBuildCache() - => new(objects, gameData, actors); + => new(framework.IsInFrameworkUpdateThread ? objects : null, gameData, actors); + + private TreeBuildCache CreateTreeBuildCache(Flags flags) + => !framework.IsInFrameworkUpdateThread && flags.HasFlag(Flags.PopulateObjectTableData) + ? framework.RunOnFrameworkThread(CreateTreeBuildCache).Result + : CreateTreeBuildCache(); public IEnumerable GetLocalPlayerRelatedCharacters() - { - var cache = CreateTreeBuildCache(); - return cache.GetLocalPlayerRelatedCharacters(); - } + => framework.RunOnFrameworkThread(() => + { + var cache = CreateTreeBuildCache(); + return cache.GetLocalPlayerRelatedCharacters(); + }).Result; public IEnumerable<(ICharacter Character, ResourceTree ResourceTree)> FromObjectTable( Flags flags) { - var cache = CreateTreeBuildCache(); - var characters = (flags & Flags.LocalPlayerRelatedOnly) != 0 ? cache.GetLocalPlayerRelatedCharacters() : cache.GetCharacters(); + var (cache, characters) = framework.RunOnFrameworkThread(() => + { + var cache = CreateTreeBuildCache(); + var characters = ((flags & Flags.LocalPlayerRelatedOnly) != 0 ? cache.GetLocalPlayerRelatedCharacters() : cache.GetCharacters()).ToArray(); + return (cache, characters); + }).Result; foreach (var character in characters) { @@ -51,7 +64,7 @@ public class ResourceTreeFactory( public IEnumerable<(ICharacter Character, ResourceTree ResourceTree)> FromCharacters( IEnumerable characters, Flags flags) { - var cache = CreateTreeBuildCache(); + var cache = CreateTreeBuildCache(flags); foreach (var character in characters) { var tree = FromCharacter(character, cache, flags); @@ -61,7 +74,7 @@ public class ResourceTreeFactory( } public ResourceTree? FromCharacter(ICharacter character, Flags flags) - => FromCharacter(character, CreateTreeBuildCache(), flags); + => FromCharacter(character, CreateTreeBuildCache(flags), flags); private unsafe ResourceTree? FromCharacter(ICharacter character, TreeBuildCache cache, Flags flags) { @@ -78,10 +91,10 @@ public class ResourceTreeFactory( return null; var localPlayerRelated = cache.IsLocalPlayerRelated(character); - var (name, anonymizedName, related) = GetCharacterName(character); + var (name, anonymizedName, related) = GetCharacterName((GameObject*)character.Address); var networked = character.EntityId != 0xE0000000; var tree = new ResourceTree(name, anonymizedName, character.ObjectIndex, (nint)gameObjStruct, (nint)drawObjStruct, localPlayerRelated, related, - networked, collectionResolveData.ModCollection.Name, collectionResolveData.ModCollection.AnonymizedName); + networked, collectionResolveData.ModCollection.Identity.Name, collectionResolveData.ModCollection.Identity.AnonymizedName); var globalContext = new GlobalResolveContext(metaFileManager, objectIdentifier, collectionResolveData.ModCollection, cache, (flags & Flags.WithUiData) != 0); using (var _ = pathState.EnterInternalResolve()) @@ -135,6 +148,7 @@ public class ResourceTreeFactory( if (node.FullPath.IsRooted && modManager.TryIdentifyPath(node.FullPath.FullName, out var mod, out var relativePath)) { node.ModName = mod.Name; + node.Mod.SetTarget(mod); node.ModRelativePath = relativePath; } } @@ -144,25 +158,28 @@ public class ResourceTreeFactory( { foreach (var node in tree.FlatNodes) { - if (!ShallKeepPath(node.FullPath, onlyWithinPath)) + node.FullPathStatus = GetPathStatus(node.FullPath, onlyWithinPath); + if (node.FullPathStatus != ResourceNode.PathStatus.Valid) node.FullPath = FullPath.Empty; } return; - static bool ShallKeepPath(FullPath fullPath, string? onlyWithinPath) + static ResourceNode.PathStatus GetPathStatus(FullPath fullPath, string? onlyWithinPath) { if (!fullPath.IsRooted) - return true; + return ResourceNode.PathStatus.Valid; if (onlyWithinPath != null) { var relPath = Path.GetRelativePath(onlyWithinPath, fullPath.FullName); - if (relPath != "." && (relPath.StartsWith('.') || Path.IsPathRooted(relPath))) - return false; + if (relPath == ".." || relPath.StartsWith(ParentDirectoryPrefix) || Path.IsPathRooted(relPath)) + return ResourceNode.PathStatus.External; } - return fullPath.Exists; + return fullPath.Exists + ? ResourceNode.PathStatus.Valid + : ResourceNode.PathStatus.NonExistent; } } @@ -177,36 +194,37 @@ public class ResourceTreeFactory( } } - private unsafe (string Name, string AnonymizedName, bool PlayerRelated) GetCharacterName(ICharacter character) + private unsafe (string Name, string AnonymizedName, bool PlayerRelated) GetCharacterName(GameObject* character) { - var identifier = actors.FromObject((GameObject*)character.Address, out var owner, true, false, false); + var identifier = actors.FromObject(character, out var owner, true, false, false); var identifierStr = identifier.ToString(); return (identifierStr, identifier.Incognito(identifierStr), IsPlayerRelated(identifier, owner)); } - private unsafe bool IsPlayerRelated(ICharacter? character) + private unsafe bool IsPlayerRelated(GameObject* character) { - if (character == null) + if (character is null) return false; - var identifier = actors.FromObject((GameObject*)character.Address, out var owner, true, false, false); + var identifier = actors.FromObject(character, out var owner, true, false, false); return IsPlayerRelated(identifier, owner); } - private bool IsPlayerRelated(ActorIdentifier identifier, Actor owner) + private unsafe bool IsPlayerRelated(ActorIdentifier identifier, Actor owner) => identifier.Type switch { IdentifierType.Player => true, - IdentifierType.Owned => IsPlayerRelated(objects.Objects.CreateObjectReference(owner) as ICharacter), + IdentifierType.Owned => IsPlayerRelated(owner.AsObject), _ => false, }; [Flags] public enum Flags { - RedactExternalPaths = 1, - WithUiData = 2, - LocalPlayerRelatedOnly = 4, - WithOwnership = 8, + RedactExternalPaths = 1, + WithUiData = 2, + LocalPlayerRelatedOnly = 4, + WithOwnership = 8, + PopulateObjectTableData = 16, } } diff --git a/Penumbra/Interop/ResourceTree/TreeBuildCache.cs b/Penumbra/Interop/ResourceTree/TreeBuildCache.cs index 49e00547..c0114412 100644 --- a/Penumbra/Interop/ResourceTree/TreeBuildCache.cs +++ b/Penumbra/Interop/ResourceTree/TreeBuildCache.cs @@ -12,14 +12,15 @@ using Penumbra.String.Classes; namespace Penumbra.Interop.ResourceTree; -internal readonly struct TreeBuildCache(ObjectManager objects, IDataManager dataManager, ActorManager actors) +internal readonly struct TreeBuildCache(ObjectManager? objects, IDataManager dataManager, ActorManager actors) { private readonly Dictionary?> _shaderPackageNames = []; + private readonly IGameObject? _player = objects?.GetDalamudObject(0); + public unsafe bool IsLocalPlayerRelated(ICharacter character) { - var player = objects.GetDalamudObject(0); - if (player == null) + if (_player is null) return false; var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)character.Address; @@ -28,27 +29,26 @@ internal readonly struct TreeBuildCache(ObjectManager objects, IDataManager data return actualIndex switch { < 2 => true, - < (int)ScreenActor.CutsceneStart => gameObject->OwnerId == player.EntityId, + < (int)ScreenActor.CutsceneStart => gameObject->OwnerId == _player.EntityId, _ => false, }; } public IEnumerable GetCharacters() - => objects.Objects.OfType(); + => objects is not null ? objects.Objects.OfType() : []; public IEnumerable GetLocalPlayerRelatedCharacters() { - var player = objects.GetDalamudObject(0); - if (player == null) + if (_player is null) yield break; - yield return (ICharacter)player; + yield return (ICharacter)_player; - var minion = objects.GetDalamudObject(1); - if (minion != null) + var minion = objects!.GetDalamudObject(1); + if (minion is not null) yield return (ICharacter)minion; - var playerId = player.EntityId; + var playerId = _player.EntityId; for (var i = 2; i < ObjectIndex.CutsceneStart.Index; i += 2) { if (objects.GetDalamudObject(i) is ICharacter owned && owned.OwnerId == playerId) diff --git a/Penumbra/Interop/Services/CharacterUtility.cs b/Penumbra/Interop/Services/CharacterUtility.cs index 1641e42d..0add9d46 100644 --- a/Penumbra/Interop/Services/CharacterUtility.cs +++ b/Penumbra/Interop/Services/CharacterUtility.cs @@ -1,6 +1,7 @@ using Dalamud.Plugin.Services; using Dalamud.Utility.Signatures; using OtterGui.Services; +using Penumbra.Communication; using Penumbra.GameData; using Penumbra.Interop.Structs; @@ -26,14 +27,16 @@ public unsafe class CharacterUtility : IDisposable, IRequiredService public CharacterUtilityData* Address => *_characterUtilityAddress; - public bool Ready { get; private set; } - public event Action LoadingFinished; - public nint DefaultHumanPbdResource { get; private set; } - public nint DefaultTransparentResource { get; private set; } - public nint DefaultDecalResource { get; private set; } - public nint DefaultSkinShpkResource { get; private set; } - public nint DefaultCharacterStockingsShpkResource { get; private set; } - public nint DefaultCharacterLegacyShpkResource { get; private set; } + public bool Ready { get; private set; } + + public readonly CharacterUtilityFinished LoadingFinished = new(); + + public nint DefaultHumanPbdResource { get; private set; } + public nint DefaultTransparentResource { get; private set; } + public nint DefaultDecalResource { get; private set; } + public nint DefaultSkinShpkResource { get; private set; } + public nint DefaultCharacterStockingsShpkResource { get; private set; } + public nint DefaultCharacterLegacyShpkResource { get; private set; } /// /// The relevant indices depend on which meta manipulations we allow for. @@ -61,7 +64,7 @@ public unsafe class CharacterUtility : IDisposable, IRequiredService .Select(idx => new MetaList(new InternalIndex(idx))) .ToArray(); _framework = framework; - LoadingFinished += () => Penumbra.Log.Debug("Loading of CharacterUtility finished."); + LoadingFinished.Subscribe(() => Penumbra.Log.Debug("Loading of CharacterUtility finished."), CharacterUtilityFinished.Priority.OnFinishedLoading); LoadDefaultResources(null!); if (!Ready) _framework.Update += LoadDefaultResources; diff --git a/Penumbra/Interop/Services/RedrawService.cs b/Penumbra/Interop/Services/RedrawService.cs index 8f20ca5e..2d741277 100644 --- a/Penumbra/Interop/Services/RedrawService.cs +++ b/Penumbra/Interop/Services/RedrawService.cs @@ -10,7 +10,6 @@ using OtterGui.Services; using Penumbra.Api; using Penumbra.Api.Enums; using Penumbra.Communication; -using Penumbra.GameData; using Penumbra.GameData.Enums; using Penumbra.GameData.Interop; using Penumbra.Interop.Structs; @@ -354,21 +353,14 @@ public sealed unsafe partial class RedrawService : IDisposable { switch (settings) { - case RedrawType.Redraw: - ReloadActor(actor); - break; - case RedrawType.AfterGPose: - ReloadActorAfterGPose(actor); - break; - default: throw new ArgumentOutOfRangeException(nameof(settings), settings, null); + case RedrawType.Redraw: ReloadActor(actor); break; + case RedrawType.AfterGPose: ReloadActorAfterGPose(actor); break; + default: throw new ArgumentOutOfRangeException(nameof(settings), settings, null); } } private IGameObject? GetLocalPlayer() - { - var gPosePlayer = _objects.GetDalamudObject(GPosePlayerIdx); - return gPosePlayer ?? _objects.GetDalamudObject(0); - } + => InGPose ? _objects.GetDalamudObject(GPosePlayerIdx) ?? _objects.GetDalamudObject(0) : _objects.GetDalamudObject(0); public bool GetName(string lowerName, out IGameObject? actor) { @@ -429,9 +421,9 @@ public sealed unsafe partial class RedrawService : IDisposable return; - foreach (ref var f in currentTerritory->Furniture) + foreach (ref var f in currentTerritory->FurnitureManager.FurnitureMemory) { - var gameObject = f.Index >= 0 ? currentTerritory->HousingObjectManager.Objects[f.Index].Value : null; + var gameObject = f.Index >= 0 ? currentTerritory->FurnitureManager.ObjectManager.ObjectArray.Objects[f.Index].Value : null; if (gameObject == null) continue; diff --git a/Penumbra/Interop/Services/SchedulerResourceManagementService.cs b/Penumbra/Interop/Services/SchedulerResourceManagementService.cs new file mode 100644 index 00000000..b7f57a44 --- /dev/null +++ b/Penumbra/Interop/Services/SchedulerResourceManagementService.cs @@ -0,0 +1,92 @@ +using System.Collections.Frozen; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using Lumina.Excel.Sheets; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Communication; +using Penumbra.GameData; +using Penumbra.Mods.Editor; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Services; + +public unsafe class SchedulerResourceManagementService : IService, IDisposable +{ + private static readonly CiByteString TmbExtension = new(".tmb"u8, MetaDataComputation.All); + private static readonly CiByteString FolderPrefix = new("chara/action/"u8, MetaDataComputation.All); + + private readonly CommunicatorService _communicator; + private readonly FrozenDictionary _actionTmbs; + + private readonly ConcurrentDictionary _listedTmbIds = []; + + public bool Contains(uint tmbId) + => _listedTmbIds.ContainsKey(tmbId); + + public IReadOnlyDictionary ListedTmbs + => _listedTmbIds; + + public IReadOnlyDictionary ActionTmbs + => _actionTmbs; + + public SchedulerResourceManagementService(IGameInteropProvider interop, CommunicatorService communicator, IDataManager dataManager) + { + _communicator = communicator; + _actionTmbs = CreateActionTmbs(dataManager); + _communicator.ResolvedFileChanged.Subscribe(OnResolvedFileChange, ResolvedFileChanged.Priority.SchedulerResourceManagementService); + interop.InitializeFromAttributes(this); + } + + private void OnResolvedFileChange(ModCollection collection, ResolvedFileChanged.Type type, Utf8GamePath gamePath, FullPath oldPath, + FullPath newPath, IMod? mod) + { + switch (type) + { + case ResolvedFileChanged.Type.Added: + CheckFile(gamePath); + return; + case ResolvedFileChanged.Type.FullRecomputeFinished: + foreach (var path in collection.ResolvedFiles.Keys) + CheckFile(path); + return; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void CheckFile(Utf8GamePath gamePath) + { + if (!gamePath.Extension().Equals(TmbExtension)) + return; + + if (!gamePath.Path.StartsWith(FolderPrefix)) + return; + + var tmb = gamePath.Path.Substring(FolderPrefix.Length, gamePath.Length - FolderPrefix.Length - TmbExtension.Length).Clone(); + if (_actionTmbs.TryGetValue(tmb, out var rowId)) + _listedTmbIds[rowId] = tmb; + else + Penumbra.Log.Verbose($"Action TMB {gamePath} encountered with no corresponding row ID."); + } + + [Signature(Sigs.SchedulerResourceManagementInstance, ScanType = ScanType.StaticAddress)] + public readonly SchedulerResourceManagement** Address = null; + + public SchedulerResourceManagement* Scheduler + => *Address; + + public void Dispose() + { + _listedTmbIds.Clear(); + _communicator.ResolvedFileChanged.Unsubscribe(OnResolvedFileChange); + } + + private static FrozenDictionary CreateActionTmbs(IDataManager dataManager) + { + var sheet = dataManager.GetExcelSheet(); + return sheet.Where(row => !row.Key.IsEmpty).DistinctBy(row => row.Key).ToFrozenDictionary(row => new CiByteString(row.Key, MetaDataComputation.All).Clone(), row => row.RowId); + } +} diff --git a/Penumbra/Interop/Services/TextureArraySlicer.cs b/Penumbra/Interop/Services/TextureArraySlicer.cs index c934ac2b..11498878 100644 --- a/Penumbra/Interop/Services/TextureArraySlicer.cs +++ b/Penumbra/Interop/Services/TextureArraySlicer.cs @@ -1,3 +1,4 @@ +using Dalamud.Bindings.ImGui; using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; using OtterGui.Services; using SharpDX.Direct3D; @@ -16,7 +17,7 @@ public sealed unsafe class TextureArraySlicer : IUiService, IDisposable private readonly HashSet<(nint XivTexture, byte SliceIndex)> _expiredKeys = []; /// Caching this across frames will cause a crash to desktop. - public nint GetImGuiHandle(Texture* texture, byte sliceIndex) + public ImTextureID GetImGuiHandle(Texture* texture, byte sliceIndex) { if (texture == null) throw new ArgumentNullException(nameof(texture)); @@ -25,7 +26,7 @@ public sealed unsafe class TextureArraySlicer : IUiService, IDisposable if (_activeSlices.TryGetValue(((nint)texture, sliceIndex), out var state)) { state.Refresh(); - return (nint)state.ShaderResourceView; + return new ImTextureID((nint)state.ShaderResourceView); } var srv = (ShaderResourceView)(nint)texture->D3D11ShaderResourceView; var description = srv.Description; @@ -60,7 +61,7 @@ public sealed unsafe class TextureArraySlicer : IUiService, IDisposable } state = new SliceState(new ShaderResourceView(srv.Device, srv.Resource, description)); _activeSlices.Add(((nint)texture, sliceIndex), state); - return (nint)state.ShaderResourceView; + return new ImTextureID((nint)state.ShaderResourceView); } public void Tick() diff --git a/Penumbra/Interop/Structs/ResourceHandle.cs b/Penumbra/Interop/Structs/ResourceHandle.cs index 65550563..1558c035 100644 --- a/Penumbra/Interop/Structs/ResourceHandle.cs +++ b/Penumbra/Interop/Structs/ResourceHandle.cs @@ -24,10 +24,20 @@ public unsafe struct TextureResourceHandle public enum LoadState : byte { + Constructing = 0x00, + Constructed = 0x01, + Async2 = 0x02, + AsyncRequested = 0x03, + Async4 = 0x04, + AsyncLoading = 0x05, + Async6 = 0x06, Success = 0x07, - Async = 0x03, + Unknown8 = 0x08, Failure = 0x09, FailedSubResource = 0x0A, + FailureB = 0x0B, + FailureC = 0x0C, + FailureD = 0x0D, None = 0xFF, } @@ -74,6 +84,9 @@ public unsafe struct ResourceHandle [FieldOffset(0x58)] public int FileNameLength; + [FieldOffset(0xA8)] + public byte UnkState; + [FieldOffset(0xA9)] public LoadState LoadState; diff --git a/Penumbra/Interop/Structs/StructExtensions.cs b/Penumbra/Interop/Structs/StructExtensions.cs index 9dd9a96d..7349f6cc 100644 --- a/Penumbra/Interop/Structs/StructExtensions.cs +++ b/Penumbra/Interop/Structs/StructExtensions.cs @@ -1,5 +1,6 @@ using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using FFXIVClientStructs.STD; +using InteropGenerator.Runtime; using Penumbra.String; namespace Penumbra.Interop.Structs; @@ -9,44 +10,68 @@ internal static class StructExtensions public static CiByteString AsByteString(in this StdString str) => CiByteString.FromSpanUnsafe(str.AsSpan(), true); - public static CiByteString ResolveEidPathAsByteString(ref this CharacterBase character) - { - Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(character.ResolveEidPath(pathBuffer)); - } - - public static CiByteString ResolveImcPathAsByteString(ref this CharacterBase character, uint slotIndex) - { - Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(character.ResolveImcPath(pathBuffer, slotIndex)); + public static CiByteString ResolveEidPathAsByteString(ref this CharacterBase character) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveEidPath(pathBuffer)); } - public static CiByteString ResolveMdlPathAsByteString(ref this CharacterBase character, uint slotIndex) - { - Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(character.ResolveMdlPath(pathBuffer, slotIndex)); + public static CiByteString ResolveImcPathAsByteString(ref this CharacterBase character, uint slotIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveImcPath(pathBuffer, slotIndex)); } - public static unsafe CiByteString ResolveMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex, byte* mtrlFileName) - { - var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(character.ResolveMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex, mtrlFileName)); + public static CiByteString ResolveMdlPathAsByteString(ref this CharacterBase character, uint slotIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveMdlPath(pathBuffer, slotIndex)); } - public static CiByteString ResolveSklbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) - { - Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(character.ResolveSklbPath(pathBuffer, partialSkeletonIndex)); + public static unsafe CiByteString ResolveMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex, byte* mtrlFileName) + { + var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex, mtrlFileName)); } - public static CiByteString ResolveSkpPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) - { - Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; - return ToOwnedByteString(character.ResolveSkpPath(pathBuffer, partialSkeletonIndex)); + public static unsafe CiByteString ResolveSkinMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex) + { + var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveSkinMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex)); } - private static unsafe CiByteString ToOwnedByteString(byte* str) - => str == null ? CiByteString.Empty : new CiByteString(str).Clone(); + public static CiByteString ResolveMaterialPapPathAsByteString(ref this CharacterBase character, uint slotIndex, uint unkSId) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveMaterialPapPath(pathBuffer, slotIndex, unkSId)); + } + + public static CiByteString ResolveSklbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveSklbPath(pathBuffer, partialSkeletonIndex)); + } + + public static CiByteString ResolveSkpPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveSkpPath(pathBuffer, partialSkeletonIndex)); + } + + public static CiByteString ResolvePhybPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolvePhybPath(pathBuffer, partialSkeletonIndex)); + } + + public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveKdbPath(pathBuffer, CharacterBase.PathBufferSize, partialSkeletonIndex)); + } + + private static unsafe CiByteString ToOwnedByteString(CStringPointer str) + => str.HasValue ? new CiByteString(str.Value).Clone() : CiByteString.Empty; private static CiByteString ToOwnedByteString(ReadOnlySpan str) => str.Length == 0 ? CiByteString.Empty : CiByteString.FromSpanUnsafe(str, true).Clone(); diff --git a/Penumbra/Interop/VolatileOffsets.cs b/Penumbra/Interop/VolatileOffsets.cs index 2c6e3180..85008aae 100644 --- a/Penumbra/Interop/VolatileOffsets.cs +++ b/Penumbra/Interop/VolatileOffsets.cs @@ -6,7 +6,7 @@ public static class VolatileOffsets { public const int PlayTimeOffset = 0x254; public const int SomeIntermediate = 0x1F8; - public const int Flags = 0x4A4; + public const int Flags = 0x4A8; public const int IInstanceListenner = 0x270; public const int BitShift = 13; public const int CasterVFunc = 1; @@ -19,7 +19,7 @@ public static class VolatileOffsets public static class UpdateModel { - public const int ShortCircuit = 0xA2C; + public const int ShortCircuit = 0xA3C; } public static class FontReloader diff --git a/Penumbra/Meta/Files/ImcFile.cs b/Penumbra/Meta/Files/ImcFile.cs index 01ef3f16..b8db66dd 100644 --- a/Penumbra/Meta/Files/ImcFile.cs +++ b/Penumbra/Meta/Files/ImcFile.cs @@ -1,3 +1,5 @@ +using OtterGui.Extensions; +using Penumbra.GameData; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; using Penumbra.Interop.Structs; @@ -192,22 +194,52 @@ public unsafe class ImcFile : MetaBaseFile public void Replace(ResourceHandle* resource) { var (data, length) = resource->GetData(); - if (length == ActualLength) + var actualLength = ActualLength; + + if (DebugConfiguration.WriteImcBytesToLog) { - MemoryUtility.MemCpyUnchecked((byte*)data, Data, ActualLength); + Penumbra.Log.Information($"Default IMC file -> Modified IMC File for {Path}, current handle state {resource->LoadState}:"); + Penumbra.Log.Information(new Span((void*)data, length).WriteHexBytes()); + Penumbra.Log.Information(new Span(Data, actualLength).WriteHexBytes()); + Penumbra.Log.Information(new Span(Data, actualLength).WriteHexByteDiff(new Span((void*)data, length))); + } + + if (length >= actualLength) + { + MemoryUtility.MemCpyUnchecked((byte*)data, Data, actualLength); + if (length > actualLength) + MemoryUtility.MemSet((byte*)(data + actualLength), 0, length - actualLength); + if (DebugConfiguration.WriteImcBytesToLog) + { + Penumbra.Log.Information( + $"Copied {actualLength} bytes from local IMC file into {length} available bytes.{(length > actualLength ? $" Filled remaining {length - actualLength} bytes with 0." : string.Empty)}"); + Penumbra.Log.Information("Result IMC Resource Data:"); + Penumbra.Log.Information(new Span((void*)data, length).WriteHexBytes()); + } + return; } - var newData = Manager.XivAllocator.Allocate(ActualLength, 8); + var paddedLength = actualLength.PadToMultiple(128); + var newData = Manager.XivFileAllocator.Allocate(paddedLength, 8); if (newData == null) { Penumbra.Log.Error($"Could not replace loaded IMC data at 0x{(ulong)resource:X}, allocation failed."); return; } - MemoryUtility.MemCpyUnchecked(newData, Data, ActualLength); + MemoryUtility.MemCpyUnchecked(newData, Data, actualLength); + if (paddedLength > actualLength) + MemoryUtility.MemSet(newData + actualLength, 0, paddedLength - actualLength); + if (DebugConfiguration.WriteImcBytesToLog) + { + Penumbra.Log.Information( + $"Allocated {paddedLength} bytes for IMC file, copied {actualLength} bytes from local IMC file. {(length > actualLength ? $" Filled remaining {length - actualLength} bytes with 0." : string.Empty)}"); + Penumbra.Log.Information("Result IMC Resource Data:"); + Penumbra.Log.Information(new Span(newData, paddedLength).WriteHexBytes()); + } - Manager.XivAllocator.Release((void*)data, length); - resource->SetData((nint)newData, ActualLength); + Manager.XivFileAllocator.Release((void*)data, length); + resource->SetData((nint)newData, paddedLength); } } diff --git a/Penumbra/Meta/Files/MetaBaseFile.cs b/Penumbra/Meta/Files/MetaBaseFile.cs index 5bc36068..d04e1bdf 100644 --- a/Penumbra/Meta/Files/MetaBaseFile.cs +++ b/Penumbra/Meta/Files/MetaBaseFile.cs @@ -28,11 +28,16 @@ public unsafe interface IFileAllocator public sealed class MarshalAllocator : IFileAllocator { public unsafe T* Allocate(int length, int alignment = 1) where T : unmanaged - => (T*)Marshal.AllocHGlobal(length * sizeof(T)); + { + var ret = (T*)Marshal.AllocHGlobal(length * sizeof(T)); + Penumbra.Log.Verbose($"Allocating {length * sizeof(T)} bytes via Marshal Allocator to 0x{(nint)ret:X}."); + return ret; + } public unsafe void Release(ref T* pointer, int length) where T : unmanaged { Marshal.FreeHGlobal((nint)pointer); + Penumbra.Log.Verbose($"Freeing {length * sizeof(T)} bytes from 0x{(nint)pointer:X} via Marshal Allocator."); pointer = null; } } @@ -53,11 +58,35 @@ public sealed unsafe class XivFileAllocator : IFileAllocator, IService => ((delegate* unmanaged)_getFileSpaceAddress)(); public T* Allocate(int length, int alignment = 1) where T : unmanaged - => (T*)GetFileSpace()->Malloc((ulong)(length * sizeof(T)), (ulong)alignment); + { + var ret = (T*)GetFileSpace()->Malloc((ulong)(length * sizeof(T)), (ulong)alignment); + Penumbra.Log.Verbose($"Allocating {length * sizeof(T)} bytes via FFXIV File Allocator to 0x{(nint)ret:X}."); + return ret; + } public void Release(ref T* pointer, int length) where T : unmanaged { + IMemorySpace.Free(pointer, (ulong)(length * sizeof(T))); + Penumbra.Log.Verbose($"Freeing {length * sizeof(T)} bytes from 0x{(nint)pointer:X} via FFXIV File Allocator."); + pointer = null; + } +} + +public sealed unsafe class XivDefaultAllocator : IFileAllocator, IService +{ + public T* Allocate(int length, int alignment = 1) where T : unmanaged + { + var ret = (T*)IMemorySpace.GetDefaultSpace()->Malloc((ulong)(length * sizeof(T)), (ulong)alignment); + Penumbra.Log.Verbose($"Allocating {length * sizeof(T)} bytes via FFXIV Default Allocator to 0x{(nint)ret:X}."); + return ret; + } + + public void Release(ref T* pointer, int length) where T : unmanaged + { + + IMemorySpace.Free(pointer, (ulong)(length * sizeof(T))); + Penumbra.Log.Verbose($"Freeing {length * sizeof(T)} bytes from 0x{(nint)pointer:X} via FFXIV Default Allocator."); pointer = null; } } diff --git a/Penumbra/Meta/Manipulations/AtchIdentifier.cs b/Penumbra/Meta/Manipulations/AtchIdentifier.cs index bce37620..c248c48b 100644 --- a/Penumbra/Meta/Manipulations/AtchIdentifier.cs +++ b/Penumbra/Meta/Manipulations/AtchIdentifier.cs @@ -31,7 +31,7 @@ public readonly record struct AtchIdentifier(AtchType Type, GenderRace GenderRac public override string ToString() => $"Atch - {Type.ToAbbreviation()} - {GenderRace.ToName()} - {EntryIndex}"; - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) { // Nothing specific } diff --git a/Penumbra/Meta/Manipulations/AtrIdentifier.cs b/Penumbra/Meta/Manipulations/AtrIdentifier.cs new file mode 100644 index 00000000..ca65f6aa --- /dev/null +++ b/Penumbra/Meta/Manipulations/AtrIdentifier.cs @@ -0,0 +1,145 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct AtrIdentifier(HumanSlot Slot, PrimaryId? Id, ShapeAttributeString Attribute, GenderRace GenderRaceCondition) + : IComparable, IMetaIdentifier +{ + public int CompareTo(AtrIdentifier other) + { + var slotComparison = Slot.CompareTo(other.Slot); + if (slotComparison is not 0) + return slotComparison; + + if (Id.HasValue) + { + if (other.Id.HasValue) + { + var idComparison = Id.Value.Id.CompareTo(other.Id.Value.Id); + if (idComparison is not 0) + return idComparison; + } + else + { + return -1; + } + } + else if (other.Id.HasValue) + { + return 1; + } + + var genderRaceComparison = GenderRaceCondition.CompareTo(other.GenderRaceCondition); + if (genderRaceComparison is not 0) + return genderRaceComparison; + + return Attribute.CompareTo(other.Attribute); + } + + + public override string ToString() + { + var sb = new StringBuilder(64); + sb.Append("Shp - ") + .Append(Attribute); + if (Slot is HumanSlot.Unknown) + { + sb.Append(" - All Slots & IDs"); + } + else + { + sb.Append(" - ") + .Append(Slot.ToName()) + .Append(" - "); + if (Id.HasValue) + sb.Append(Id.Value.Id); + else + sb.Append("All IDs"); + } + + if (GenderRaceCondition is not GenderRace.Unknown) + sb.Append(" - ").Append(GenderRaceCondition.ToRaceCode()); + + return sb.ToString(); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + // Nothing for now since it depends entirely on the shape key. + } + + public MetaIndex FileIndex() + => (MetaIndex)(-1); + + public bool Validate() + { + if (!Enum.IsDefined(Slot) || Slot is HumanSlot.UnkBonus) + return false; + + if (!ShapeAttributeHashSet.GenderRaceIndices.ContainsKey(GenderRaceCondition)) + return false; + + if (Slot is HumanSlot.Unknown && Id is not null) + return false; + + if (Slot.ToSpecificEnum() is BodySlot && Id is { Id: > byte.MaxValue }) + return false; + + if (Id is { Id: > ExpandedEqpGmpBase.Count - 1 }) + return false; + + return Attribute.ValidateCustomAttributeString(); + } + + public JObject AddToJson(JObject jObj) + { + if (Slot is not HumanSlot.Unknown) + jObj["Slot"] = Slot.ToString(); + if (Id.HasValue) + jObj["Id"] = Id.Value.Id.ToString(); + jObj["Attribute"] = Attribute.ToString(); + if (GenderRaceCondition is not GenderRace.Unknown) + jObj["GenderRaceCondition"] = (uint)GenderRaceCondition; + return jObj; + } + + public static AtrIdentifier? FromJson(JObject jObj) + { + var attribute = jObj["Attribute"]?.ToObject(); + if (attribute is null || !ShapeAttributeString.TryRead(attribute, out var attributeString)) + return null; + + var slot = jObj["Slot"]?.ToObject() ?? HumanSlot.Unknown; + var id = jObj["Id"]?.ToObject(); + var genderRaceCondition = jObj["GenderRaceCondition"]?.ToObject() ?? 0; + var identifier = new AtrIdentifier(slot, id, attributeString, genderRaceCondition); + return identifier.Validate() ? identifier : null; + } + + public MetaManipulationType Type + => MetaManipulationType.Atr; +} + +[JsonConverter(typeof(Converter))] +public readonly record struct AtrEntry(bool Value) +{ + public static readonly AtrEntry True = new(true); + public static readonly AtrEntry False = new(false); + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, AtrEntry value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override AtrEntry ReadJson(JsonReader reader, Type objectType, AtrEntry existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } +} diff --git a/Penumbra/Meta/Manipulations/Eqdp.cs b/Penumbra/Meta/Manipulations/Eqdp.cs index 3a804d0c..c8423b92 100644 --- a/Penumbra/Meta/Manipulations/Eqdp.cs +++ b/Penumbra/Meta/Manipulations/Eqdp.cs @@ -15,8 +15,8 @@ public readonly record struct EqdpIdentifier(PrimaryId SetId, EquipSlot Slot, Ge public Gender Gender => GenderRace.Split().Item1; - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) - => identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(SetId, GenderRace, Slot)); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace, Slot)); public MetaIndex FileIndex() => CharacterUtilityData.EqdpIdx(GenderRace, Slot.IsAccessory()); diff --git a/Penumbra/Meta/Manipulations/Eqp.cs b/Penumbra/Meta/Manipulations/Eqp.cs index f758126c..154aca40 100644 --- a/Penumbra/Meta/Manipulations/Eqp.cs +++ b/Penumbra/Meta/Manipulations/Eqp.cs @@ -8,8 +8,8 @@ namespace Penumbra.Meta.Manipulations; public readonly record struct EqpIdentifier(PrimaryId SetId, EquipSlot Slot) : IMetaIdentifier, IComparable { - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) - => identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(SetId, GenderRace.MidlanderMale, Slot)); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace.MidlanderMale, Slot)); public MetaIndex FileIndex() => MetaIndex.Eqp; diff --git a/Penumbra/Meta/Manipulations/Est.cs b/Penumbra/Meta/Manipulations/Est.cs index cfe9b7d4..46a275a5 100644 --- a/Penumbra/Meta/Manipulations/Est.cs +++ b/Penumbra/Meta/Manipulations/Est.cs @@ -24,24 +24,29 @@ public readonly record struct EstIdentifier(PrimaryId SetId, EstType Slot, Gende public Gender Gender => GenderRace.Split().Item1; - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) { switch (Slot) { case EstType.Hair: - changedItems.TryAdd( - $"Customization: {GenderRace.Split().Item2.ToName()} {GenderRace.Split().Item1.ToName()} Hair (Hair) {SetId}", null); + { + var (gender, race) = GenderRace.Split(); + var id = (CustomizeValue)SetId.Id; + changedItems.UpdateCountOrSet( + $"Customization: {race.ToName()} {gender.ToName()} Hair {SetId}", () => IdentifiedCustomization.Hair(race, gender, id)); break; + } case EstType.Face: - changedItems.TryAdd( - $"Customization: {GenderRace.Split().Item2.ToName()} {GenderRace.Split().Item1.ToName()} Face (Face) {SetId}", null); - break; - case EstType.Body: - identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(SetId, GenderRace, EquipSlot.Body)); - break; - case EstType.Head: - identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(SetId, GenderRace, EquipSlot.Head)); + { + var (gender, race) = GenderRace.Split(); + var id = (CustomizeValue)SetId.Id; + changedItems.UpdateCountOrSet( + $"Customization: {race.ToName()} {gender.ToName()} Face {SetId}", + () => IdentifiedCustomization.Face(race, gender, id)); break; + } + case EstType.Body: identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace, EquipSlot.Body)); break; + case EstType.Head: identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace, EquipSlot.Head)); break; } } diff --git a/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs b/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs index ec59762b..33399a36 100644 --- a/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs +++ b/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs @@ -16,10 +16,10 @@ public readonly struct GlobalEqpManipulation : IMetaIdentifier if (!Enum.IsDefined(Type)) return false; - if (Type is GlobalEqpType.DoNotHideVieraHats or GlobalEqpType.DoNotHideHrothgarHats) - return Condition == 0; + if (Type.HasCondition()) + return Condition.Id is not 0; - return Condition != 0; + return Condition.Id is 0; } public JObject AddToJson(JObject jObj) @@ -70,15 +70,15 @@ public readonly struct GlobalEqpManipulation : IMetaIdentifier public override string ToString() => $"Global EQP - {Type}{(Condition != 0 ? $" - {Condition.Id}" : string.Empty)}"; - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) { var path = Type switch { - GlobalEqpType.DoNotHideEarrings => GamePaths.Accessory.Mdl.Path(Condition, GenderRace.MidlanderMale, EquipSlot.Ears), - GlobalEqpType.DoNotHideNecklace => GamePaths.Accessory.Mdl.Path(Condition, GenderRace.MidlanderMale, EquipSlot.Neck), - GlobalEqpType.DoNotHideBracelets => GamePaths.Accessory.Mdl.Path(Condition, GenderRace.MidlanderMale, EquipSlot.Wrists), - GlobalEqpType.DoNotHideRingR => GamePaths.Accessory.Mdl.Path(Condition, GenderRace.MidlanderMale, EquipSlot.RFinger), - GlobalEqpType.DoNotHideRingL => GamePaths.Accessory.Mdl.Path(Condition, GenderRace.MidlanderMale, EquipSlot.LFinger), + GlobalEqpType.DoNotHideEarrings => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.Ears), + GlobalEqpType.DoNotHideNecklace => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.Neck), + GlobalEqpType.DoNotHideBracelets => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.Wrists), + GlobalEqpType.DoNotHideRingR => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.RFinger), + GlobalEqpType.DoNotHideRingL => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.LFinger), GlobalEqpType.DoNotHideHrothgarHats => string.Empty, GlobalEqpType.DoNotHideVieraHats => string.Empty, _ => string.Empty, @@ -86,9 +86,15 @@ public readonly struct GlobalEqpManipulation : IMetaIdentifier if (path.Length > 0) identifier.Identify(changedItems, path); else if (Type is GlobalEqpType.DoNotHideVieraHats) - changedItems["All Hats for Viera"] = null; + changedItems.UpdateCountOrSet("All Hats for Viera", () => new IdentifiedName()); else if (Type is GlobalEqpType.DoNotHideHrothgarHats) - changedItems["All Hats for Hrothgar"] = null; + changedItems.UpdateCountOrSet("All Hats for Hrothgar", () => new IdentifiedName()); + else if (Type is GlobalEqpType.HideHorns) + changedItems.UpdateCountOrSet("All Au Ra Horns", () => new IdentifiedName()); + else if (Type is GlobalEqpType.HideVieraEars) + changedItems.UpdateCountOrSet("All Viera Ears", () => new IdentifiedName()); + else if (Type is GlobalEqpType.HideMiqoteEars) + changedItems.UpdateCountOrSet("All Miqo'te Ears", () => new IdentifiedName()); } public MetaIndex FileIndex() diff --git a/Penumbra/Meta/Manipulations/GlobalEqpType.cs b/Penumbra/Meta/Manipulations/GlobalEqpType.cs index 1a7396f9..29bfe825 100644 --- a/Penumbra/Meta/Manipulations/GlobalEqpType.cs +++ b/Penumbra/Meta/Manipulations/GlobalEqpType.cs @@ -13,6 +13,9 @@ public enum GlobalEqpType DoNotHideRingL, DoNotHideHrothgarHats, DoNotHideVieraHats, + HideHorns, + HideVieraEars, + HideMiqoteEars, } public static class GlobalEqpExtensions @@ -27,6 +30,9 @@ public static class GlobalEqpExtensions GlobalEqpType.DoNotHideRingL => true, GlobalEqpType.DoNotHideHrothgarHats => false, GlobalEqpType.DoNotHideVieraHats => false, + GlobalEqpType.HideHorns => false, + GlobalEqpType.HideVieraEars => false, + GlobalEqpType.HideMiqoteEars => false, _ => false, }; @@ -41,6 +47,9 @@ public static class GlobalEqpExtensions GlobalEqpType.DoNotHideRingL => "Always Show Rings (Left Finger)"u8, GlobalEqpType.DoNotHideHrothgarHats => "Always Show Hats for Hrothgar"u8, GlobalEqpType.DoNotHideVieraHats => "Always Show Hats for Viera"u8, + GlobalEqpType.HideHorns => "Always Hide Horns (Au Ra)"u8, + GlobalEqpType.HideVieraEars => "Always Hide Ears (Viera)"u8, + GlobalEqpType.HideMiqoteEars => "Always Hide Ears (Miqo'te)"u8, _ => "\0"u8, }; @@ -60,6 +69,9 @@ public static class GlobalEqpExtensions "Prevents the game from hiding any hats for Hrothgar that are normally flagged to not display on them."u8, GlobalEqpType.DoNotHideVieraHats => "Prevents the game from hiding any hats for Viera that are normally flagged to not display on them."u8, - _ => "\0"u8, + GlobalEqpType.HideHorns => "Forces the game to hide Au Ra horns regardless of headwear."u8, + GlobalEqpType.HideVieraEars => "Forces the game to hide Viera ears regardless of headwear."u8, + GlobalEqpType.HideMiqoteEars => "Forces the game to hide Miqo'te ears regardless of headwear."u8, + _ => "\0"u8, }; } diff --git a/Penumbra/Meta/Manipulations/Gmp.cs b/Penumbra/Meta/Manipulations/Gmp.cs index 1f41adfb..5bc81f26 100644 --- a/Penumbra/Meta/Manipulations/Gmp.cs +++ b/Penumbra/Meta/Manipulations/Gmp.cs @@ -8,8 +8,8 @@ namespace Penumbra.Meta.Manipulations; public readonly record struct GmpIdentifier(PrimaryId SetId) : IMetaIdentifier, IComparable { - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) - => identifier.Identify(changedItems, GamePaths.Equipment.Mdl.Path(SetId, GenderRace.MidlanderMale, EquipSlot.Head)); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace.MidlanderMale, EquipSlot.Head)); public MetaIndex FileIndex() => MetaIndex.Gmp; diff --git a/Penumbra/Meta/Manipulations/IMetaIdentifier.cs b/Penumbra/Meta/Manipulations/IMetaIdentifier.cs index 999fd906..922825c3 100644 --- a/Penumbra/Meta/Manipulations/IMetaIdentifier.cs +++ b/Penumbra/Meta/Manipulations/IMetaIdentifier.cs @@ -15,11 +15,13 @@ public enum MetaManipulationType : byte Rsp = 6, GlobalEqp = 7, Atch = 8, + Shp = 9, + Atr = 10, } public interface IMetaIdentifier { - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems); public MetaIndex FileIndex(); diff --git a/Penumbra/Meta/Manipulations/Imc.cs b/Penumbra/Meta/Manipulations/Imc.cs index cba6c379..fa726708 100644 --- a/Penumbra/Meta/Manipulations/Imc.cs +++ b/Penumbra/Meta/Manipulations/Imc.cs @@ -27,21 +27,21 @@ public readonly record struct ImcIdentifier( : this(primaryId, variant, slot.IsAccessory() ? ObjectType.Accessory : ObjectType.Equipment, 0, slot, BodySlot.Unknown) { } - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) => AddChangedItems(identifier, changedItems, false); - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems, bool allVariants) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems, bool allVariants) { var path = ObjectType switch { - ObjectType.Equipment when allVariants => GamePaths.Equipment.Mdl.Path(PrimaryId, GenderRace.MidlanderMale, EquipSlot), - ObjectType.Equipment => GamePaths.Equipment.Mtrl.Path(PrimaryId, GenderRace.MidlanderMale, EquipSlot, Variant, "a"), - ObjectType.Accessory when allVariants => GamePaths.Accessory.Mdl.Path(PrimaryId, GenderRace.MidlanderMale, EquipSlot), - ObjectType.Accessory => GamePaths.Accessory.Mtrl.Path(PrimaryId, GenderRace.MidlanderMale, EquipSlot, Variant, "a"), - ObjectType.Weapon => GamePaths.Weapon.Mtrl.Path(PrimaryId, SecondaryId.Id, Variant, "a"), - ObjectType.DemiHuman => GamePaths.DemiHuman.Mtrl.Path(PrimaryId, SecondaryId.Id, EquipSlot, Variant, + ObjectType.Equipment when allVariants => GamePaths.Mdl.Equipment(PrimaryId, GenderRace.MidlanderMale, EquipSlot), + ObjectType.Equipment => GamePaths.Mtrl.Equipment(PrimaryId, GenderRace.MidlanderMale, EquipSlot, Variant, "a"), + ObjectType.Accessory when allVariants => GamePaths.Mdl.Accessory(PrimaryId, GenderRace.MidlanderMale, EquipSlot), + ObjectType.Accessory => GamePaths.Mtrl.Accessory(PrimaryId, GenderRace.MidlanderMale, EquipSlot, Variant, "a"), + ObjectType.Weapon => GamePaths.Mtrl.Weapon(PrimaryId, SecondaryId.Id, Variant, "a"), + ObjectType.DemiHuman => GamePaths.Mtrl.DemiHuman(PrimaryId, SecondaryId.Id, EquipSlot, Variant, "a"), - ObjectType.Monster => GamePaths.Monster.Mtrl.Path(PrimaryId, SecondaryId.Id, Variant, "a"), + ObjectType.Monster => GamePaths.Mtrl.Monster(PrimaryId, SecondaryId.Id, Variant, "a"), _ => string.Empty, }; if (path.Length == 0) @@ -51,15 +51,7 @@ public readonly record struct ImcIdentifier( } public string GamePathString() - => ObjectType switch - { - ObjectType.Accessory => GamePaths.Accessory.Imc.Path(PrimaryId), - ObjectType.Equipment => GamePaths.Equipment.Imc.Path(PrimaryId), - ObjectType.DemiHuman => GamePaths.DemiHuman.Imc.Path(PrimaryId, SecondaryId.Id), - ObjectType.Monster => GamePaths.Monster.Imc.Path(PrimaryId, SecondaryId.Id), - ObjectType.Weapon => GamePaths.Weapon.Imc.Path(PrimaryId, SecondaryId.Id), - _ => string.Empty, - }; + => GamePaths.Imc.Path(ObjectType, PrimaryId, SecondaryId); public Utf8GamePath GamePath() => Utf8GamePath.FromString(GamePathString(), out var p) ? p : Utf8GamePath.Empty; diff --git a/Penumbra/Meta/Manipulations/MetaDictionary.cs b/Penumbra/Meta/Manipulations/MetaDictionary.cs index ca45c777..8b448ec6 100644 --- a/Penumbra/Meta/Manipulations/MetaDictionary.cs +++ b/Penumbra/Meta/Manipulations/MetaDictionary.cs @@ -1,7 +1,10 @@ +using System.Collections.Frozen; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; using Penumbra.GameData.Files.AtchStructs; +using Penumbra.GameData.Interop; using Penumbra.GameData.Structs; using Penumbra.Util; using ImcEntry = Penumbra.GameData.Structs.ImcEntry; @@ -11,120 +14,333 @@ namespace Penumbra.Meta.Manipulations; [JsonConverter(typeof(Converter))] public class MetaDictionary { - private readonly Dictionary _imc = []; - private readonly Dictionary _eqp = []; - private readonly Dictionary _eqdp = []; - private readonly Dictionary _est = []; - private readonly Dictionary _rsp = []; - private readonly Dictionary _gmp = []; - private readonly Dictionary _atch = []; - private readonly HashSet _globalEqp = []; + private class Wrapper : HashSet + { + public readonly Dictionary Imc = []; + public readonly Dictionary Eqp = []; + public readonly Dictionary Eqdp = []; + public readonly Dictionary Est = []; + public readonly Dictionary Rsp = []; + public readonly Dictionary Gmp = []; + public readonly Dictionary Atch = []; + public readonly Dictionary Shp = []; + public readonly Dictionary Atr = []; + + public Wrapper() + { } + + public Wrapper(MetaCache cache) + { + Imc = cache.Imc.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Eqp = cache.Eqp.ToDictionary(kvp => kvp.Key, kvp => new EqpEntryInternal(kvp.Value.Entry, kvp.Key.Slot)); + Eqdp = cache.Eqdp.ToDictionary(kvp => kvp.Key, kvp => new EqdpEntryInternal(kvp.Value.Entry, kvp.Key.Slot)); + Est = cache.Est.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Gmp = cache.Gmp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Rsp = cache.Rsp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Atch = cache.Atch.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Shp = cache.Shp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Atr = cache.Atr.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + foreach (var geqp in cache.GlobalEqp.Keys) + Add(geqp); + } + + public static unsafe Wrapper Filtered(MetaCache cache, Actor actor) + { + if (!actor.IsCharacter) + return new Wrapper(cache); + + var model = actor.Model; + if (!model.IsHuman) + return new Wrapper(cache); + + var headId = model.GetModelId(HumanSlot.Head); + var bodyId = model.GetModelId(HumanSlot.Body); + var equipIdSet = ((IEnumerable) + [ + headId, + bodyId, + model.GetModelId(HumanSlot.Hands), + model.GetModelId(HumanSlot.Legs), + model.GetModelId(HumanSlot.Feet), + ]).ToFrozenSet(); + var earsId = model.GetModelId(HumanSlot.Ears); + var neckId = model.GetModelId(HumanSlot.Neck); + var wristId = model.GetModelId(HumanSlot.Wrists); + var rFingerId = model.GetModelId(HumanSlot.RFinger); + var lFingerId = model.GetModelId(HumanSlot.LFinger); + + var wrapper = new Wrapper(); + // Check for all relevant primary IDs due to slot overlap. + foreach (var (eqp, value) in cache.Eqp) + { + if (eqp.Slot.IsEquipment()) + { + if (equipIdSet.Contains(eqp.SetId)) + wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot)); + } + else + { + switch (eqp.Slot) + { + case EquipSlot.Ears when eqp.SetId == earsId: + case EquipSlot.Neck when eqp.SetId == neckId: + case EquipSlot.Wrists when eqp.SetId == wristId: + case EquipSlot.RFinger when eqp.SetId == rFingerId: + case EquipSlot.LFinger when eqp.SetId == lFingerId: + wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot)); + break; + } + } + } + + // Check also for body IDs due to body occupying head. + foreach (var (gmp, value) in cache.Gmp) + { + if (gmp.SetId == headId || gmp.SetId == bodyId) + wrapper.Gmp.Add(gmp, value.Entry); + } + + // Check for all races due to inheritance and all slots due to overlap. + foreach (var (eqdp, value) in cache.Eqdp) + { + if (eqdp.Slot.IsEquipment()) + { + if (equipIdSet.Contains(eqdp.SetId)) + wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot)); + } + else + { + switch (eqdp.Slot) + { + case EquipSlot.Ears when eqdp.SetId == earsId: + case EquipSlot.Neck when eqdp.SetId == neckId: + case EquipSlot.Wrists when eqdp.SetId == wristId: + case EquipSlot.RFinger when eqdp.SetId == rFingerId: + case EquipSlot.LFinger when eqdp.SetId == lFingerId: + wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot)); + break; + } + } + } + + var genderRace = (GenderRace)model.AsHuman->RaceSexId; + var hairId = model.GetModelId(HumanSlot.Hair); + var faceId = model.GetModelId(HumanSlot.Face); + // We do not need to care for racial inheritance for ESTs. + foreach (var (est, value) in cache.Est) + { + switch (est.Slot) + { + case EstType.Hair when est.SetId == hairId && est.GenderRace == genderRace: + case EstType.Face when est.SetId == faceId && est.GenderRace == genderRace: + case EstType.Body when est.SetId == bodyId && est.GenderRace == genderRace: + case EstType.Head when (est.SetId == headId || est.SetId == bodyId) && est.GenderRace == genderRace: + wrapper.Est.Add(est, value.Entry); + break; + } + } + + foreach (var (geqp, _) in cache.GlobalEqp) + { + switch (geqp.Type) + { + case GlobalEqpType.DoNotHideEarrings when geqp.Condition != earsId: + case GlobalEqpType.DoNotHideNecklace when geqp.Condition != neckId: + case GlobalEqpType.DoNotHideBracelets when geqp.Condition != wristId: + case GlobalEqpType.DoNotHideRingR when geqp.Condition != rFingerId: + case GlobalEqpType.DoNotHideRingL when geqp.Condition != lFingerId: + continue; + default: wrapper.Add(geqp); break; + } + } + + var (_, _, main, off) = model.GetWeapons(actor); + foreach (var (imc, value) in cache.Imc) + { + switch (imc.ObjectType) + { + case ObjectType.Equipment when equipIdSet.Contains(imc.PrimaryId): wrapper.Imc.Add(imc, value.Entry); break; + + case ObjectType.Weapon: + if (imc.PrimaryId == main.Skeleton && imc.SecondaryId == main.Weapon) + wrapper.Imc.Add(imc, value.Entry); + else if (imc.PrimaryId == off.Skeleton && imc.SecondaryId == off.Weapon) + wrapper.Imc.Add(imc, value.Entry); + break; + case ObjectType.Accessory: + switch (imc.EquipSlot) + { + case EquipSlot.Ears when imc.PrimaryId == earsId: + case EquipSlot.Neck when imc.PrimaryId == neckId: + case EquipSlot.Wrists when imc.PrimaryId == wristId: + case EquipSlot.RFinger when imc.PrimaryId == rFingerId: + case EquipSlot.LFinger when imc.PrimaryId == lFingerId: + wrapper.Imc.Add(imc, value.Entry); + break; + } + + break; + } + } + + var subRace = (SubRace)model.AsHuman->Customize[4]; + foreach (var (rsp, value) in cache.Rsp) + { + if (rsp.SubRace == subRace) + wrapper.Rsp.Add(rsp, value.Entry); + } + + // Keep all atch, atr and shp. + wrapper.Atch.EnsureCapacity(cache.Atch.Count); + wrapper.Shp.EnsureCapacity(cache.Shp.Count); + wrapper.Atr.EnsureCapacity(cache.Atr.Count); + foreach (var (atch, value) in cache.Atch) + wrapper.Atch.Add(atch, value.Entry); + foreach (var (shp, value) in cache.Shp) + wrapper.Shp.Add(shp, value.Entry); + foreach (var (atr, value) in cache.Atr) + wrapper.Atr.Add(atr, value.Entry); + return wrapper; + } + } + + private Wrapper? _data; public IReadOnlyDictionary Imc - => _imc; + => _data?.Imc ?? []; public IReadOnlyDictionary Eqp - => _eqp; + => _data?.Eqp ?? []; public IReadOnlyDictionary Eqdp - => _eqdp; + => _data?.Eqdp ?? []; public IReadOnlyDictionary Est - => _est; + => _data?.Est ?? []; public IReadOnlyDictionary Gmp - => _gmp; + => _data?.Gmp ?? []; public IReadOnlyDictionary Rsp - => _rsp; + => _data?.Rsp ?? []; public IReadOnlyDictionary Atch - => _atch; + => _data?.Atch ?? []; + + public IReadOnlyDictionary Shp + => _data?.Shp ?? []; + + public IReadOnlyDictionary Atr + => _data?.Atr ?? []; public IReadOnlySet GlobalEqp - => _globalEqp; + => _data ?? []; public int Count { get; private set; } public int GetCount(MetaManipulationType type) - => type switch - { - MetaManipulationType.Imc => _imc.Count, - MetaManipulationType.Eqdp => _eqdp.Count, - MetaManipulationType.Eqp => _eqp.Count, - MetaManipulationType.Est => _est.Count, - MetaManipulationType.Gmp => _gmp.Count, - MetaManipulationType.Rsp => _rsp.Count, - MetaManipulationType.Atch => _atch.Count, - MetaManipulationType.GlobalEqp => _globalEqp.Count, - _ => 0, - }; + => _data is null + ? 0 + : type switch + { + MetaManipulationType.Imc => _data.Imc.Count, + MetaManipulationType.Eqdp => _data.Eqdp.Count, + MetaManipulationType.Eqp => _data.Eqp.Count, + MetaManipulationType.Est => _data.Est.Count, + MetaManipulationType.Gmp => _data.Gmp.Count, + MetaManipulationType.Rsp => _data.Rsp.Count, + MetaManipulationType.Atch => _data.Atch.Count, + MetaManipulationType.Shp => _data.Shp.Count, + MetaManipulationType.Atr => _data.Atr.Count, + MetaManipulationType.GlobalEqp => _data.Count, + _ => 0, + }; public bool Contains(IMetaIdentifier identifier) - => identifier switch - { - EqdpIdentifier i => _eqdp.ContainsKey(i), - EqpIdentifier i => _eqp.ContainsKey(i), - EstIdentifier i => _est.ContainsKey(i), - GlobalEqpManipulation i => _globalEqp.Contains(i), - GmpIdentifier i => _gmp.ContainsKey(i), - ImcIdentifier i => _imc.ContainsKey(i), - AtchIdentifier i => _atch.ContainsKey(i), - RspIdentifier i => _rsp.ContainsKey(i), - _ => false, - }; + => _data is not null + && identifier switch + { + EqdpIdentifier i => _data.Eqdp.ContainsKey(i), + EqpIdentifier i => _data.Eqp.ContainsKey(i), + EstIdentifier i => _data.Est.ContainsKey(i), + GlobalEqpManipulation i => _data.Contains(i), + GmpIdentifier i => _data.Gmp.ContainsKey(i), + ImcIdentifier i => _data.Imc.ContainsKey(i), + AtchIdentifier i => _data.Atch.ContainsKey(i), + ShpIdentifier i => _data.Shp.ContainsKey(i), + AtrIdentifier i => _data.Atr.ContainsKey(i), + RspIdentifier i => _data.Rsp.ContainsKey(i), + _ => false, + }; public void Clear() { + _data = null; Count = 0; - _imc.Clear(); - _eqp.Clear(); - _eqdp.Clear(); - _est.Clear(); - _rsp.Clear(); - _gmp.Clear(); - _atch.Clear(); - _globalEqp.Clear(); } public void ClearForDefault() { - Count = _globalEqp.Count; - _imc.Clear(); - _eqp.Clear(); - _eqdp.Clear(); - _est.Clear(); - _rsp.Clear(); - _gmp.Clear(); - _atch.Clear(); + if (_data is null) + return; + + if (_data.Count is 0 && Shp.Count is 0 && Atr.Count is 0) + { + _data = null; + Count = 0; + return; + } + + Count = GlobalEqp.Count + Shp.Count + Atr.Count; + _data!.Imc.Clear(); + _data!.Eqp.Clear(); + _data!.Eqdp.Clear(); + _data!.Est.Clear(); + _data!.Rsp.Clear(); + _data!.Gmp.Clear(); + _data!.Atch.Clear(); } public bool Equals(MetaDictionary other) - => Count == other.Count - && _imc.SetEquals(other._imc) - && _eqp.SetEquals(other._eqp) - && _eqdp.SetEquals(other._eqdp) - && _est.SetEquals(other._est) - && _rsp.SetEquals(other._rsp) - && _gmp.SetEquals(other._gmp) - && _atch.SetEquals(other._atch) - && _globalEqp.SetEquals(other._globalEqp); + { + if (Count != other.Count) + return false; + + if (_data is null) + return true; + + return _data.Imc.SetEquals(other._data!.Imc) + && _data.Eqp.SetEquals(other._data!.Eqp) + && _data.Eqdp.SetEquals(other._data!.Eqdp) + && _data.Est.SetEquals(other._data!.Est) + && _data.Rsp.SetEquals(other._data!.Rsp) + && _data.Gmp.SetEquals(other._data!.Gmp) + && _data.Atch.SetEquals(other._data!.Atch) + && _data.Shp.SetEquals(other._data!.Shp) + && _data.Atr.SetEquals(other._data!.Atr) + && _data.SetEquals(other._data!); + } public IEnumerable Identifiers - => _imc.Keys.Cast() - .Concat(_eqdp.Keys.Cast()) - .Concat(_eqp.Keys.Cast()) - .Concat(_est.Keys.Cast()) - .Concat(_gmp.Keys.Cast()) - .Concat(_rsp.Keys.Cast()) - .Concat(_atch.Keys.Cast()) - .Concat(_globalEqp.Cast()); + => _data is null + ? [] + : _data.Imc.Keys.Cast() + .Concat(_data!.Eqdp.Keys.Cast()) + .Concat(_data!.Eqp.Keys.Cast()) + .Concat(_data!.Est.Keys.Cast()) + .Concat(_data!.Gmp.Keys.Cast()) + .Concat(_data!.Rsp.Keys.Cast()) + .Concat(_data!.Atch.Keys.Cast()) + .Concat(_data!.Shp.Keys.Cast()) + .Concat(_data!.Atr.Keys.Cast()) + .Concat(_data!.Cast()); #region TryAdd public bool TryAdd(ImcIdentifier identifier, ImcEntry entry) { - if (!_imc.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Imc.TryAdd(identifier, entry)) return false; ++Count; @@ -133,7 +349,8 @@ public class MetaDictionary public bool TryAdd(EqpIdentifier identifier, EqpEntryInternal entry) { - if (!_eqp.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Eqp.TryAdd(identifier, entry)) return false; ++Count; @@ -145,7 +362,8 @@ public class MetaDictionary public bool TryAdd(EqdpIdentifier identifier, EqdpEntryInternal entry) { - if (!_eqdp.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Eqdp.TryAdd(identifier, entry)) return false; ++Count; @@ -157,7 +375,8 @@ public class MetaDictionary public bool TryAdd(EstIdentifier identifier, EstEntry entry) { - if (!_est.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Est.TryAdd(identifier, entry)) return false; ++Count; @@ -166,7 +385,8 @@ public class MetaDictionary public bool TryAdd(GmpIdentifier identifier, GmpEntry entry) { - if (!_gmp.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Gmp.TryAdd(identifier, entry)) return false; ++Count; @@ -175,7 +395,8 @@ public class MetaDictionary public bool TryAdd(RspIdentifier identifier, RspEntry entry) { - if (!_rsp.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Rsp.TryAdd(identifier, entry)) return false; ++Count; @@ -184,7 +405,28 @@ public class MetaDictionary public bool TryAdd(AtchIdentifier identifier, in AtchEntry entry) { - if (!_atch.TryAdd(identifier, entry)) + _data ??= []; + if (!_data!.Atch.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(ShpIdentifier identifier, in ShpEntry entry) + { + _data ??= []; + if (!_data!.Shp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(AtrIdentifier identifier, in AtrEntry entry) + { + _data ??= []; + if (!_data!.Atr.TryAdd(identifier, entry)) return false; ++Count; @@ -193,7 +435,8 @@ public class MetaDictionary public bool TryAdd(GlobalEqpManipulation identifier) { - if (!_globalEqp.Add(identifier)) + _data ??= []; + if (!_data.Add(identifier)) return false; ++Count; @@ -206,19 +449,19 @@ public class MetaDictionary public bool Update(ImcIdentifier identifier, ImcEntry entry) { - if (!_imc.ContainsKey(identifier)) + if (_data is null || !_data.Imc.ContainsKey(identifier)) return false; - _imc[identifier] = entry; + _data.Imc[identifier] = entry; return true; } public bool Update(EqpIdentifier identifier, EqpEntryInternal entry) { - if (!_eqp.ContainsKey(identifier)) + if (_data is null || !_data.Eqp.ContainsKey(identifier)) return false; - _eqp[identifier] = entry; + _data.Eqp[identifier] = entry; return true; } @@ -227,10 +470,10 @@ public class MetaDictionary public bool Update(EqdpIdentifier identifier, EqdpEntryInternal entry) { - if (!_eqdp.ContainsKey(identifier)) + if (_data is null || !_data.Eqdp.ContainsKey(identifier)) return false; - _eqdp[identifier] = entry; + _data.Eqdp[identifier] = entry; return true; } @@ -239,37 +482,55 @@ public class MetaDictionary public bool Update(EstIdentifier identifier, EstEntry entry) { - if (!_est.ContainsKey(identifier)) + if (_data is null || !_data.Est.ContainsKey(identifier)) return false; - _est[identifier] = entry; + _data.Est[identifier] = entry; return true; } public bool Update(GmpIdentifier identifier, GmpEntry entry) { - if (!_gmp.ContainsKey(identifier)) + if (_data is null || !_data.Gmp.ContainsKey(identifier)) return false; - _gmp[identifier] = entry; + _data.Gmp[identifier] = entry; return true; } public bool Update(RspIdentifier identifier, RspEntry entry) { - if (!_rsp.ContainsKey(identifier)) + if (_data is null || !_data.Rsp.ContainsKey(identifier)) return false; - _rsp[identifier] = entry; + _data.Rsp[identifier] = entry; return true; } public bool Update(AtchIdentifier identifier, in AtchEntry entry) { - if (!_atch.ContainsKey(identifier)) + if (_data is null || !_data.Atch.ContainsKey(identifier)) return false; - _atch[identifier] = entry; + _data.Atch[identifier] = entry; + return true; + } + + public bool Update(ShpIdentifier identifier, in ShpEntry entry) + { + if (_data is null || !_data.Shp.ContainsKey(identifier)) + return false; + + _data.Shp[identifier] = entry; + return true; + } + + public bool Update(AtrIdentifier identifier, in AtrEntry entry) + { + if (_data is null || !_data.Atr.ContainsKey(identifier)) + return false; + + _data.Atr[identifier] = entry; return true; } @@ -278,44 +539,63 @@ public class MetaDictionary #region TryGetValue public bool TryGetValue(EstIdentifier identifier, out EstEntry value) - => _est.TryGetValue(identifier, out value); + => _data?.Est.TryGetValue(identifier, out value) ?? SetDefault(out value); public bool TryGetValue(EqpIdentifier identifier, out EqpEntryInternal value) - => _eqp.TryGetValue(identifier, out value); + => _data?.Eqp.TryGetValue(identifier, out value) ?? SetDefault(out value); public bool TryGetValue(EqdpIdentifier identifier, out EqdpEntryInternal value) - => _eqdp.TryGetValue(identifier, out value); + => _data?.Eqdp.TryGetValue(identifier, out value) ?? SetDefault(out value); public bool TryGetValue(GmpIdentifier identifier, out GmpEntry value) - => _gmp.TryGetValue(identifier, out value); + => _data?.Gmp.TryGetValue(identifier, out value) ?? SetDefault(out value); public bool TryGetValue(RspIdentifier identifier, out RspEntry value) - => _rsp.TryGetValue(identifier, out value); + => _data?.Rsp.TryGetValue(identifier, out value) ?? SetDefault(out value); public bool TryGetValue(ImcIdentifier identifier, out ImcEntry value) - => _imc.TryGetValue(identifier, out value); + => _data?.Imc.TryGetValue(identifier, out value) ?? SetDefault(out value); public bool TryGetValue(AtchIdentifier identifier, out AtchEntry value) - => _atch.TryGetValue(identifier, out value); + => _data?.Atch.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(ShpIdentifier identifier, out ShpEntry value) + => _data?.Shp.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(AtrIdentifier identifier, out AtrEntry value) + => _data?.Atr.TryGetValue(identifier, out value) ?? SetDefault(out value); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool SetDefault(out T? value) + { + value = default; + return false; + } #endregion public bool Remove(IMetaIdentifier identifier) { + if (_data is null) + return false; + var ret = identifier switch { - EqdpIdentifier i => _eqdp.Remove(i), - EqpIdentifier i => _eqp.Remove(i), - EstIdentifier i => _est.Remove(i), - GlobalEqpManipulation i => _globalEqp.Remove(i), - GmpIdentifier i => _gmp.Remove(i), - ImcIdentifier i => _imc.Remove(i), - RspIdentifier i => _rsp.Remove(i), - AtchIdentifier i => _atch.Remove(i), + EqdpIdentifier i => _data.Eqdp.Remove(i), + EqpIdentifier i => _data.Eqp.Remove(i), + EstIdentifier i => _data.Est.Remove(i), + GlobalEqpManipulation i => _data.Remove(i), + GmpIdentifier i => _data.Gmp.Remove(i), + ImcIdentifier i => _data.Imc.Remove(i), + RspIdentifier i => _data.Rsp.Remove(i), + AtchIdentifier i => _data.Atch.Remove(i), + ShpIdentifier i => _data.Shp.Remove(i), + AtrIdentifier i => _data.Atr.Remove(i), _ => false, }; - if (ret) - --Count; + if (ret && --Count is 0) + _data = null; + return ret; } @@ -323,110 +603,164 @@ public class MetaDictionary public void UnionWith(MetaDictionary manips) { - foreach (var (identifier, entry) in manips._imc) + if (manips.Count is 0) + return; + + _data ??= []; + foreach (var (identifier, entry) in manips._data!.Imc) TryAdd(identifier, entry); - foreach (var (identifier, entry) in manips._eqp) + foreach (var (identifier, entry) in manips._data!.Eqp) TryAdd(identifier, entry); - foreach (var (identifier, entry) in manips._eqdp) + foreach (var (identifier, entry) in manips._data!.Eqdp) TryAdd(identifier, entry); - foreach (var (identifier, entry) in manips._gmp) + foreach (var (identifier, entry) in manips._data!.Gmp) TryAdd(identifier, entry); - foreach (var (identifier, entry) in manips._rsp) + foreach (var (identifier, entry) in manips._data!.Rsp) TryAdd(identifier, entry); - foreach (var (identifier, entry) in manips._est) + foreach (var (identifier, entry) in manips._data!.Est) TryAdd(identifier, entry); - foreach (var (identifier, entry) in manips._atch) + foreach (var (identifier, entry) in manips._data!.Atch) TryAdd(identifier, entry); - foreach (var identifier in manips._globalEqp) + foreach (var (identifier, entry) in manips._data!.Shp) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Atr) + TryAdd(identifier, entry); + + foreach (var identifier in manips._data!) TryAdd(identifier); } /// Try to merge all manipulations from manips into this, and return the first failure, if any. public bool MergeForced(MetaDictionary manips, out IMetaIdentifier? failedIdentifier) { - foreach (var (identifier, _) in manips._imc.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + if (manips.Count is 0) + { + failedIdentifier = null; + return true; + } + + _data ??= []; + foreach (var (identifier, _) in manips._data!.Imc.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var (identifier, _) in manips._eqp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + foreach (var (identifier, _) in manips._data!.Eqp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var (identifier, _) in manips._eqdp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + foreach (var (identifier, _) in manips._data!.Eqdp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var (identifier, _) in manips._gmp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + foreach (var (identifier, _) in manips._data!.Gmp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var (identifier, _) in manips._rsp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + foreach (var (identifier, _) in manips._data!.Rsp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var (identifier, _) in manips._est.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + foreach (var (identifier, _) in manips._data!.Est.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var (identifier, _) in manips._atch.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + foreach (var (identifier, _) in manips._data!.Atch.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - foreach (var identifier in manips._globalEqp.Where(identifier => !TryAdd(identifier))) + foreach (var (identifier, _) in manips._data!.Shp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) { failedIdentifier = identifier; return false; } - failedIdentifier = default; + foreach (var (identifier, _) in manips._data!.Atr.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var identifier in manips._data!.Where(identifier => !TryAdd(identifier))) + { + failedIdentifier = identifier; + return false; + } + + failedIdentifier = null; return true; } public void SetTo(MetaDictionary other) { - _imc.SetTo(other._imc); - _eqp.SetTo(other._eqp); - _eqdp.SetTo(other._eqdp); - _est.SetTo(other._est); - _rsp.SetTo(other._rsp); - _gmp.SetTo(other._gmp); - _atch.SetTo(other._atch); - _globalEqp.SetTo(other._globalEqp); - Count = _imc.Count + _eqp.Count + _eqdp.Count + _est.Count + _rsp.Count + _gmp.Count + _atch.Count + _globalEqp.Count; + if (other.Count is 0) + { + _data = null; + Count = 0; + return; + } + + _data ??= []; + _data!.Imc.SetTo(other._data!.Imc); + _data!.Eqp.SetTo(other._data!.Eqp); + _data!.Eqdp.SetTo(other._data!.Eqdp); + _data!.Est.SetTo(other._data!.Est); + _data!.Rsp.SetTo(other._data!.Rsp); + _data!.Gmp.SetTo(other._data!.Gmp); + _data!.Atch.SetTo(other._data!.Atch); + _data!.Shp.SetTo(other._data!.Shp); + _data!.Atr.SetTo(other._data!.Atr); + _data!.SetTo(other._data!); + Count = other.Count; } public void UpdateTo(MetaDictionary other) { - _imc.UpdateTo(other._imc); - _eqp.UpdateTo(other._eqp); - _eqdp.UpdateTo(other._eqdp); - _est.UpdateTo(other._est); - _rsp.UpdateTo(other._rsp); - _gmp.UpdateTo(other._gmp); - _atch.UpdateTo(other._atch); - _globalEqp.UnionWith(other._globalEqp); - Count = _imc.Count + _eqp.Count + _eqdp.Count + _est.Count + _rsp.Count + _gmp.Count + _atch.Count + _globalEqp.Count; + if (other.Count is 0) + return; + + _data ??= []; + _data!.Imc.UpdateTo(other._data!.Imc); + _data!.Eqp.UpdateTo(other._data!.Eqp); + _data!.Eqdp.UpdateTo(other._data!.Eqdp); + _data!.Est.UpdateTo(other._data!.Est); + _data!.Rsp.UpdateTo(other._data!.Rsp); + _data!.Gmp.UpdateTo(other._data!.Gmp); + _data!.Atch.UpdateTo(other._data!.Atch); + _data!.Shp.UpdateTo(other._data!.Shp); + _data!.Atr.UpdateTo(other._data!.Atr); + _data!.UnionWith(other._data!); + Count = _data!.Imc.Count + + _data!.Eqp.Count + + _data!.Eqdp.Count + + _data!.Est.Count + + _data!.Rsp.Count + + _data!.Gmp.Count + + _data!.Atch.Count + + _data!.Shp.Count + + _data!.Atr.Count + + _data!.Count; } #endregion @@ -514,6 +848,26 @@ public class MetaDictionary }), }; + public static JObject Serialize(ShpIdentifier identifier, ShpEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Shp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.Value, + }), + }; + + public static JObject Serialize(AtrIdentifier identifier, AtrEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Atr.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.Value, + }), + }; + public static JObject Serialize(GlobalEqpManipulation identifier) => new() { @@ -543,6 +897,10 @@ public class MetaDictionary return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); if (typeof(TIdentifier) == typeof(AtchIdentifier) && typeof(TEntry) == typeof(AtchEntry)) return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(ShpIdentifier) && typeof(TEntry) == typeof(ShpEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(AtrIdentifier) && typeof(TEntry) == typeof(AtrEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); if (typeof(TIdentifier) == typeof(GlobalEqpManipulation)) return Serialize(Unsafe.As(ref identifier)); @@ -581,14 +939,20 @@ public class MetaDictionary } var array = new JArray(); - SerializeTo(array, value._imc); - SerializeTo(array, value._eqp); - SerializeTo(array, value._eqdp); - SerializeTo(array, value._est); - SerializeTo(array, value._rsp); - SerializeTo(array, value._gmp); - SerializeTo(array, value._atch); - SerializeTo(array, value._globalEqp); + if (value._data is not null) + { + SerializeTo(array, value._data!.Imc); + SerializeTo(array, value._data!.Eqp); + SerializeTo(array, value._data!.Eqdp); + SerializeTo(array, value._data!.Est); + SerializeTo(array, value._data!.Rsp); + SerializeTo(array, value._data!.Gmp); + SerializeTo(array, value._data!.Atch); + SerializeTo(array, value._data!.Shp); + SerializeTo(array, value._data!.Atr); + SerializeTo(array, value._data!); + } + array.WriteTo(writer); } @@ -685,6 +1049,26 @@ public class MetaDictionary Penumbra.Log.Warning("Invalid ATCH Manipulation encountered."); break; } + case MetaManipulationType.Shp: + { + var identifier = ShpIdentifier.FromJson(manip); + var entry = new ShpEntry(manip["Entry"]?.Value() ?? true); + if (identifier.HasValue) + dict.TryAdd(identifier.Value, entry); + else + Penumbra.Log.Warning("Invalid SHP Manipulation encountered."); + break; + } + case MetaManipulationType.Atr: + { + var identifier = AtrIdentifier.FromJson(manip); + var entry = new AtrEntry(manip["Entry"]?.Value() ?? true); + if (identifier.HasValue) + dict.TryAdd(identifier.Value, entry); + else + Penumbra.Log.Warning("Invalid ATR Manipulation encountered."); + break; + } case MetaManipulationType.GlobalEqp: { var identifier = GlobalEqpManipulation.FromJson(manip); @@ -706,17 +1090,30 @@ public class MetaDictionary public MetaDictionary(MetaCache? cache) { - if (cache == null) + if (cache is null) return; - _imc = cache.Imc.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); - _eqp = cache.Eqp.ToDictionary(kvp => kvp.Key, kvp => new EqpEntryInternal(kvp.Value.Entry, kvp.Key.Slot)); - _eqdp = cache.Eqdp.ToDictionary(kvp => kvp.Key, kvp => new EqdpEntryInternal(kvp.Value.Entry, kvp.Key.Slot)); - _est = cache.Est.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); - _gmp = cache.Gmp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); - _rsp = cache.Rsp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); - _atch = cache.Atch.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); - _globalEqp = cache.GlobalEqp.Select(kvp => kvp.Key).ToHashSet(); - Count = cache.Count; + _data = new Wrapper(cache); + Count = cache.Count; + } + + public MetaDictionary(MetaCache? cache, Actor actor) + { + if (cache is null) + return; + + _data = Wrapper.Filtered(cache, actor); + Count = _data.Count + + _data.Eqp.Count + + _data.Eqdp.Count + + _data.Est.Count + + _data.Gmp.Count + + _data.Imc.Count + + _data.Rsp.Count + + _data.Atch.Count + + _data.Atr.Count + + _data.Shp.Count; + if (Count is 0) + _data = null; } } diff --git a/Penumbra/Meta/Manipulations/Rsp.cs b/Penumbra/Meta/Manipulations/Rsp.cs index 2d73ec7f..5f91a37c 100644 --- a/Penumbra/Meta/Manipulations/Rsp.cs +++ b/Penumbra/Meta/Manipulations/Rsp.cs @@ -8,8 +8,8 @@ namespace Penumbra.Meta.Manipulations; public readonly record struct RspIdentifier(SubRace SubRace, RspAttribute Attribute) : IMetaIdentifier { - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) - => changedItems.TryAdd($"{SubRace.ToName()} {Attribute.ToFullString()}", null); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => changedItems.UpdateCountOrSet($"{SubRace.ToName()} {Attribute.ToFullString()}", () => new IdentifiedName()); public MetaIndex FileIndex() => MetaIndex.HumanCmp; @@ -40,6 +40,9 @@ public readonly record struct RspIdentifier(SubRace SubRace, RspAttribute Attrib public MetaManipulationType Type => MetaManipulationType.Rsp; + + public override string ToString() + => $"RSP - {SubRace.ToName()} - {Attribute.ToFullString()}"; } [JsonConverter(typeof(Converter))] diff --git a/Penumbra/Meta/Manipulations/ShpIdentifier.cs b/Penumbra/Meta/Manipulations/ShpIdentifier.cs new file mode 100644 index 00000000..0a5b71b7 --- /dev/null +++ b/Penumbra/Meta/Manipulations/ShpIdentifier.cs @@ -0,0 +1,187 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; + +namespace Penumbra.Meta.Manipulations; + +[JsonConverter(typeof(StringEnumConverter))] +public enum ShapeConnectorCondition : byte +{ + None = 0, + Wrists = 1, + Waist = 2, + Ankles = 3, +} + +public readonly record struct ShpIdentifier( + HumanSlot Slot, + PrimaryId? Id, + ShapeAttributeString Shape, + ShapeConnectorCondition ConnectorCondition, + GenderRace GenderRaceCondition) + : IComparable, IMetaIdentifier +{ + public int CompareTo(ShpIdentifier other) + { + var slotComparison = Slot.CompareTo(other.Slot); + if (slotComparison is not 0) + return slotComparison; + + if (Id.HasValue) + { + if (other.Id.HasValue) + { + var idComparison = Id.Value.Id.CompareTo(other.Id.Value.Id); + if (idComparison is not 0) + return idComparison; + } + else + { + return -1; + } + } + else if (other.Id.HasValue) + { + return 1; + } + + var conditionComparison = ConnectorCondition.CompareTo(other.ConnectorCondition); + if (conditionComparison is not 0) + return conditionComparison; + + var genderRaceComparison = GenderRaceCondition.CompareTo(other.GenderRaceCondition); + if (genderRaceComparison is not 0) + return genderRaceComparison; + + return Shape.CompareTo(other.Shape); + } + + + public override string ToString() + { + var sb = new StringBuilder(64); + sb.Append("Shp - ") + .Append(Shape); + if (Slot is HumanSlot.Unknown) + { + sb.Append(" - All Slots & IDs"); + } + else + { + sb.Append(" - ") + .Append(Slot.ToName()) + .Append(" - "); + if (Id.HasValue) + sb.Append(Id.Value.Id); + else + sb.Append("All IDs"); + } + + switch (ConnectorCondition) + { + case ShapeConnectorCondition.Wrists: sb.Append(" - Wrist Connector"); break; + case ShapeConnectorCondition.Waist: sb.Append(" - Waist Connector"); break; + case ShapeConnectorCondition.Ankles: sb.Append(" - Ankle Connector"); break; + } + + if (GenderRaceCondition is not GenderRace.Unknown) + sb.Append(" - ").Append(GenderRaceCondition.ToRaceCode()); + + return sb.ToString(); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + // Nothing for now since it depends entirely on the shape key. + } + + public MetaIndex FileIndex() + => (MetaIndex)(-1); + + public bool Validate() + { + if (!Enum.IsDefined(Slot) || Slot is HumanSlot.UnkBonus) + return false; + + if (!ShapeAttributeHashSet.GenderRaceIndices.ContainsKey(GenderRaceCondition)) + return false; + + if (!Enum.IsDefined(ConnectorCondition)) + return false; + + if (Slot is HumanSlot.Unknown && Id is not null) + return false; + + if (Slot.ToSpecificEnum() is BodySlot && Id is { Id: > byte.MaxValue }) + return false; + + if (Id is { Id: > ExpandedEqpGmpBase.Count - 1 }) + return false; + + if (!Shape.ValidateCustomShapeString()) + return false; + + return ConnectorCondition switch + { + ShapeConnectorCondition.None => true, + ShapeConnectorCondition.Wrists => Slot is HumanSlot.Body or HumanSlot.Hands or HumanSlot.Unknown, + ShapeConnectorCondition.Waist => Slot is HumanSlot.Body or HumanSlot.Legs or HumanSlot.Unknown, + ShapeConnectorCondition.Ankles => Slot is HumanSlot.Legs or HumanSlot.Feet or HumanSlot.Unknown, + _ => false, + }; + } + + public JObject AddToJson(JObject jObj) + { + if (Slot is not HumanSlot.Unknown) + jObj["Slot"] = Slot.ToString(); + if (Id.HasValue) + jObj["Id"] = Id.Value.Id.ToString(); + jObj["Shape"] = Shape.ToString(); + if (ConnectorCondition is not ShapeConnectorCondition.None) + jObj["ConnectorCondition"] = ConnectorCondition.ToString(); + if (GenderRaceCondition is not GenderRace.Unknown) + jObj["GenderRaceCondition"] = (uint)GenderRaceCondition; + return jObj; + } + + public static ShpIdentifier? FromJson(JObject jObj) + { + var shape = jObj["Shape"]?.ToObject(); + if (shape is null || !ShapeAttributeString.TryRead(shape, out var shapeString)) + return null; + + var slot = jObj["Slot"]?.ToObject() ?? HumanSlot.Unknown; + var id = jObj["Id"]?.ToObject(); + var connectorCondition = jObj["ConnectorCondition"]?.ToObject() ?? ShapeConnectorCondition.None; + var genderRaceCondition = jObj["GenderRaceCondition"]?.ToObject() ?? 0; + var identifier = new ShpIdentifier(slot, id, shapeString, connectorCondition, genderRaceCondition); + return identifier.Validate() ? identifier : null; + } + + public MetaManipulationType Type + => MetaManipulationType.Shp; +} + +[JsonConverter(typeof(Converter))] +public readonly record struct ShpEntry(bool Value) +{ + public static readonly ShpEntry True = new(true); + public static readonly ShpEntry False = new(false); + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ShpEntry value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override ShpEntry ReadJson(JsonReader reader, Type objectType, ShpEntry existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } +} diff --git a/Penumbra/Meta/MetaFileManager.cs b/Penumbra/Meta/MetaFileManager.cs index 5250273b..6130a48f 100644 --- a/Penumbra/Meta/MetaFileManager.cs +++ b/Penumbra/Meta/MetaFileManager.cs @@ -28,24 +28,26 @@ public class MetaFileManager : IService internal readonly ImcChecker ImcChecker; internal readonly AtchManager AtchManager; internal readonly IFileAllocator MarshalAllocator = new MarshalAllocator(); - internal readonly IFileAllocator XivAllocator; + internal readonly IFileAllocator XivFileAllocator; + internal readonly IFileAllocator XivDefaultAllocator; public MetaFileManager(CharacterUtility characterUtility, ResidentResourceManager residentResources, IDataManager gameData, ActiveCollectionData activeCollections, Configuration config, ValidityChecker validityChecker, ObjectIdentification identifier, FileCompactor compactor, IGameInteropProvider interop, AtchManager atchManager) { - CharacterUtility = characterUtility; - ResidentResources = residentResources; - GameData = gameData; - ActiveCollections = activeCollections; - Config = config; - ValidityChecker = validityChecker; - Identifier = identifier; - Compactor = compactor; - AtchManager = atchManager; - ImcChecker = new ImcChecker(this); - XivAllocator = new XivFileAllocator(interop); + CharacterUtility = characterUtility; + ResidentResources = residentResources; + GameData = gameData; + ActiveCollections = activeCollections; + Config = config; + ValidityChecker = validityChecker; + Identifier = identifier; + Compactor = compactor; + AtchManager = atchManager; + ImcChecker = new ImcChecker(this); + XivFileAllocator = new XivFileAllocator(interop); + XivDefaultAllocator = new XivDefaultAllocator(); interop.InitializeFromAttributes(this); } diff --git a/Penumbra/Meta/ShapeAttributeManager.cs b/Penumbra/Meta/ShapeAttributeManager.cs new file mode 100644 index 00000000..a7f71ac7 --- /dev/null +++ b/Penumbra/Meta/ShapeAttributeManager.cs @@ -0,0 +1,227 @@ +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Meta; + +public unsafe class ShapeAttributeManager : IRequiredService, IDisposable +{ + public const int NumSlots = 14; + public const int ModelSlotSize = 18; + private readonly AttributeHook _attributeHook; + + public static ReadOnlySpan UsedModels + => + [ + HumanSlot.Head, HumanSlot.Body, HumanSlot.Hands, HumanSlot.Legs, HumanSlot.Feet, HumanSlot.Ears, HumanSlot.Neck, HumanSlot.Wrists, + HumanSlot.RFinger, HumanSlot.LFinger, HumanSlot.Glasses, HumanSlot.Hair, HumanSlot.Face, HumanSlot.Ear, + ]; + + public ShapeAttributeManager(AttributeHook attributeHook) + { + _attributeHook = attributeHook; + _attributeHook.Subscribe(OnAttributeComputed, AttributeHook.Priority.ShapeAttributeManager); + } + + private readonly Dictionary[] _temporaryShapes = + Enumerable.Range(0, NumSlots).Select(_ => new Dictionary()).ToArray(); + + private readonly PrimaryId[] _ids = new PrimaryId[ModelSlotSize]; + + private HumanSlot _modelIndex; + private int _slotIndex; + private GenderRace _genderRace; + + private FFXIVClientStructs.FFXIV.Client.Graphics.Render.Model* _model; + + public void Dispose() + => _attributeHook.Unsubscribe(OnAttributeComputed); + + private void OnAttributeComputed(Actor actor, Model model, ModCollection collection) + { + if (!collection.HasCache) + return; + + _genderRace = (GenderRace)model.AsHuman->RaceSexId; + for (_slotIndex = 0; _slotIndex < NumSlots; ++_slotIndex) + { + _modelIndex = UsedModels[_slotIndex]; + _model = model.AsHuman->Models[_modelIndex.ToIndex()]; + if (_model is null || _model->ModelResourceHandle is null) + continue; + + _ids[(int)_modelIndex] = model.GetModelId(_modelIndex); + CheckShapes(collection.MetaCache!.Shp); + CheckAttributes(collection.MetaCache!.Atr); + if (_modelIndex is <= HumanSlot.LFinger and >= HumanSlot.Ears) + AccessoryImcCheck(model); + } + + UpdateDefaultMasks(model, collection.MetaCache!.Shp); + } + + private void AccessoryImcCheck(Model model) + { + var imcMask = (ushort)(0x03FF & *(ushort*)(model.Address + 0xAAC + 6 * (int)_modelIndex)); + + Span attr = + [ + (byte)'a', + (byte)'t', + (byte)'r', + (byte)'_', + AccessoryByte(_modelIndex), + (byte)'v', + (byte)'_', + (byte)'a', + 0, + ]; + for (var i = 1; i < 10; ++i) + { + var flag = (ushort)(1 << i); + if ((imcMask & flag) is not 0) + continue; + + attr[^2] = (byte)('a' + i); + + foreach (var (attribute, index) in _model->ModelResourceHandle->Attributes) + { + if (!EqualAttribute(attr, attribute.Value)) + continue; + + _model->EnabledAttributeIndexMask &= ~(1u << index); + break; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + private static bool EqualAttribute(Span needle, byte* haystack) + { + foreach (var character in needle) + { + if (*haystack++ != character) + return false; + } + + return true; + } + + private static byte AccessoryByte(HumanSlot slot) + => slot switch + { + HumanSlot.Head => (byte)'m', + HumanSlot.Ears => (byte)'e', + HumanSlot.Neck => (byte)'n', + HumanSlot.Wrists => (byte)'w', + HumanSlot.RFinger => (byte)'r', + HumanSlot.LFinger => (byte)'r', + _ => 0, + }; + + private void CheckAttributes(AtrCache attributeCache) + { + if (attributeCache.DisabledCount is 0) + return; + + ref var attributes = ref _model->ModelResourceHandle->Attributes; + foreach (var (attribute, index) in attributes.Where(kvp => ShapeAttributeString.ValidateCustomAttributeString(kvp.Key.Value))) + { + if (ShapeAttributeString.TryRead(attribute.Value, out var attributeString)) + { + // Mask out custom attributes if they are disabled. Attributes are enabled by default. + if (attributeCache.ShouldBeDisabled(attributeString, _modelIndex, _ids[_modelIndex.ToIndex()], _genderRace)) + _model->EnabledAttributeIndexMask &= ~(1u << index); + } + else + { + Penumbra.Log.Warning($"Trying to read a attribute string that is too long: {attribute}."); + } + } + } + + private void CheckShapes(ShpCache shapeCache) + { + _temporaryShapes[_slotIndex].Clear(); + ref var shapes = ref _model->ModelResourceHandle->Shapes; + foreach (var (shape, index) in shapes.Where(kvp => ShapeAttributeString.ValidateCustomShapeString(kvp.Key.Value))) + { + if (ShapeAttributeString.TryRead(shape.Value, out var shapeString)) + { + _temporaryShapes[_slotIndex].TryAdd(shapeString, index); + // Add custom shapes if they are enabled. Shapes are disabled by default. + if (shapeCache.ShouldBeEnabled(shapeString, _modelIndex, _ids[_modelIndex.ToIndex()], _genderRace)) + _model->EnabledShapeKeyIndexMask |= 1u << index; + } + else + { + Penumbra.Log.Warning($"Trying to read a shape string that is too long: {shape}."); + } + } + } + + private void UpdateDefaultMasks(Model human, ShpCache cache) + { + var genderRace = (GenderRace)human.AsHuman->RaceSexId; + foreach (var (shape, topIndex) in _temporaryShapes[1]) + { + if (shape.IsWrist() + && _temporaryShapes[2].TryGetValue(shape, out var handIndex) + && !cache.ShouldBeDisabled(shape, HumanSlot.Body, _ids[1], genderRace) + && !cache.ShouldBeDisabled(shape, HumanSlot.Hands, _ids[2], genderRace) + && human.AsHuman->Models[1] is not null + && human.AsHuman->Models[2] is not null) + { + human.AsHuman->Models[1]->EnabledShapeKeyIndexMask |= 1u << topIndex; + human.AsHuman->Models[2]->EnabledShapeKeyIndexMask |= 1u << handIndex; + CheckCondition(cache.State(ShapeConnectorCondition.Wrists), genderRace, HumanSlot.Body, HumanSlot.Hands, 1, 2); + } + + if (shape.IsWaist() + && _temporaryShapes[3].TryGetValue(shape, out var legIndex) + && !cache.ShouldBeDisabled(shape, HumanSlot.Body, _ids[1], genderRace) + && !cache.ShouldBeDisabled(shape, HumanSlot.Legs, _ids[3], genderRace) + && human.AsHuman->Models[1] is not null + && human.AsHuman->Models[3] is not null) + { + human.AsHuman->Models[1]->EnabledShapeKeyIndexMask |= 1u << topIndex; + human.AsHuman->Models[3]->EnabledShapeKeyIndexMask |= 1u << legIndex; + CheckCondition(cache.State(ShapeConnectorCondition.Waist), genderRace, HumanSlot.Body, HumanSlot.Legs, 1, 3); + } + } + + foreach (var (shape, bottomIndex) in _temporaryShapes[3]) + { + if (shape.IsAnkle() + && _temporaryShapes[4].TryGetValue(shape, out var footIndex) + && !cache.ShouldBeDisabled(shape, HumanSlot.Legs, _ids[3], genderRace) + && !cache.ShouldBeDisabled(shape, HumanSlot.Feet, _ids[4], genderRace) + && human.AsHuman->Models[3] is not null + && human.AsHuman->Models[4] is not null) + { + human.AsHuman->Models[3]->EnabledShapeKeyIndexMask |= 1u << bottomIndex; + human.AsHuman->Models[4]->EnabledShapeKeyIndexMask |= 1u << footIndex; + CheckCondition(cache.State(ShapeConnectorCondition.Ankles), genderRace, HumanSlot.Legs, HumanSlot.Feet, 3, 4); + } + } + + return; + + void CheckCondition(IReadOnlyDictionary dict, GenderRace genderRace, HumanSlot slot1, + HumanSlot slot2, int idx1, int idx2) + { + foreach (var (shape, set) in dict) + { + if (set.CheckEntry(slot1, _ids[idx1], genderRace) is true && _temporaryShapes[idx1].TryGetValue(shape, out var index1)) + human.AsHuman->Models[idx1]->EnabledShapeKeyIndexMask |= 1u << index1; + if (set.CheckEntry(slot2, _ids[idx2], genderRace) is true && _temporaryShapes[idx2].TryGetValue(shape, out var index2)) + human.AsHuman->Models[idx2]->EnabledShapeKeyIndexMask |= 1u << index2; + } + } + } +} diff --git a/Penumbra/Meta/ShapeAttributeString.cs b/Penumbra/Meta/ShapeAttributeString.cs new file mode 100644 index 00000000..55e3f021 --- /dev/null +++ b/Penumbra/Meta/ShapeAttributeString.cs @@ -0,0 +1,203 @@ +using Lumina.Misc; +using Newtonsoft.Json; +using Penumbra.GameData.Files.PhybStructs; +using Penumbra.String.Functions; + +namespace Penumbra.Meta; + +[JsonConverter(typeof(Converter))] +public struct ShapeAttributeString : IEquatable, IComparable +{ + public const int MaxLength = 30; + + public static readonly ShapeAttributeString Empty = new(); + + private FixedString32 _buffer; + + public int Count + => _buffer[31]; + + public int Length + => _buffer[31]; + + public override string ToString() + => Encoding.UTF8.GetString(_buffer[..Length]); + + public byte this[int index] + => _buffer[index]; + + public unsafe ReadOnlySpan AsSpan + { + get + { + fixed (void* ptr = &this) + { + return new ReadOnlySpan(ptr, Length); + } + } + } + + public static unsafe bool ValidateCustomShapeString(byte* shape) + { + // "shpx_*" + if (shape is null) + return false; + + if (*shape++ is not (byte)'s' + || *shape++ is not (byte)'h' + || *shape++ is not (byte)'p' + || *shape++ is not (byte)'x' + || *shape++ is not (byte)'_' + || *shape is 0) + return false; + + return true; + } + + public bool ValidateCustomShapeString() + { + // "shpx_*" + if (Length < 6) + return false; + + if (_buffer[0] is not (byte)'s' + || _buffer[1] is not (byte)'h' + || _buffer[2] is not (byte)'p' + || _buffer[3] is not (byte)'x' + || _buffer[4] is not (byte)'_') + return false; + + return true; + } + + public static unsafe bool ValidateCustomAttributeString(byte* shape) + { + // "atrx_*" + if (shape is null) + return false; + + if (*shape++ is not (byte)'a' + || *shape++ is not (byte)'t' + || *shape++ is not (byte)'r' + || *shape++ is not (byte)'x' + || *shape++ is not (byte)'_' + || *shape is 0) + return false; + + return true; + } + + public bool ValidateCustomAttributeString() + { + // "atrx_*" + if (Length < 6) + return false; + + if (_buffer[0] is not (byte)'a' + || _buffer[1] is not (byte)'t' + || _buffer[2] is not (byte)'r' + || _buffer[3] is not (byte)'x' + || _buffer[4] is not (byte)'_') + return false; + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool IsAnkle() + => CheckCenter('a', 'n'); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool IsWaist() + => CheckCenter('w', 'a'); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool IsWrist() + => CheckCenter('w', 'r'); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool CheckCenter(char first, char second) + => Length > 8 && _buffer[5] == first && _buffer[6] == second && _buffer[7] is (byte)'_'; + + public bool Equals(ShapeAttributeString other) + => Length == other.Length && _buffer[..Length].SequenceEqual(other._buffer[..Length]); + + public override bool Equals(object? obj) + => obj is ShapeAttributeString other && Equals(other); + + public override int GetHashCode() + => (int)Crc32.Get(_buffer[..Length]); + + public static bool operator ==(ShapeAttributeString left, ShapeAttributeString right) + => left.Equals(right); + + public static bool operator !=(ShapeAttributeString left, ShapeAttributeString right) + => !left.Equals(right); + + public static unsafe bool TryRead(byte* pointer, out ShapeAttributeString ret) + { + var span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(pointer); + return TryRead(span, out ret); + } + + public unsafe int CompareTo(ShapeAttributeString other) + { + fixed (void* lhs = &this) + { + return ByteStringFunctions.Compare((byte*)lhs, Length, (byte*)&other, other.Length); + } + } + + public static bool TryRead(ReadOnlySpan utf8, out ShapeAttributeString ret) + { + if (utf8.Length is 0 or > MaxLength) + { + ret = Empty; + return false; + } + + ret = Empty; + utf8.CopyTo(ret._buffer); + ret._buffer[utf8.Length] = 0; + ret._buffer[31] = (byte)utf8.Length; + return true; + } + + public static bool TryRead(ReadOnlySpan utf16, out ShapeAttributeString ret) + { + ret = Empty; + if (!Encoding.UTF8.TryGetBytes(utf16, ret._buffer[..MaxLength], out var written)) + return false; + + ret._buffer[written] = 0; + ret._buffer[31] = (byte)written; + return true; + } + + public void ForceLength(byte length) + { + if (length > MaxLength) + length = MaxLength; + _buffer[length] = 0; + _buffer[31] = length; + } + + private sealed class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ShapeAttributeString value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString()); + } + + public override ShapeAttributeString ReadJson(JsonReader reader, Type objectType, ShapeAttributeString existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var value = serializer.Deserialize(reader); + if (!TryRead(value, out existingValue)) + throw new JsonReaderException($"Could not parse {value} into ShapeAttributeString."); + + return existingValue; + } + } +} diff --git a/Penumbra/Mods/Editor/MdlMaterialEditor.cs b/Penumbra/Mods/Editor/MdlMaterialEditor.cs index 2a23ffad..da580794 100644 --- a/Penumbra/Mods/Editor/MdlMaterialEditor.cs +++ b/Penumbra/Mods/Editor/MdlMaterialEditor.cs @@ -1,5 +1,5 @@ -using OtterGui; using OtterGui.Compression; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.GameData.Enums; using Penumbra.GameData.Files; diff --git a/Penumbra/Mods/Editor/ModFileCollection.cs b/Penumbra/Mods/Editor/ModFileCollection.cs index 20423493..15bd179e 100644 --- a/Penumbra/Mods/Editor/ModFileCollection.cs +++ b/Penumbra/Mods/Editor/ModFileCollection.cs @@ -1,4 +1,5 @@ using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Mods.SubMods; using Penumbra.String.Classes; @@ -13,6 +14,7 @@ public class ModFileCollection : IDisposable, IService private readonly List _tex = []; private readonly List _shpk = []; private readonly List _pbd = []; + private readonly List _atch = []; private readonly SortedSet _missing = []; private readonly HashSet _usedPaths = []; @@ -41,21 +43,24 @@ public class ModFileCollection : IDisposable, IService public IReadOnlyList Pbd => Ready ? _pbd : []; + public IReadOnlyList Atch + => Ready ? _atch : []; + public bool Ready { get; private set; } = true; public void UpdateAll(Mod mod, IModDataContainer option) { - UpdateFiles(mod, new CancellationToken()); - UpdatePaths(mod, option, false, new CancellationToken()); + UpdateFiles(mod, CancellationToken.None); + UpdatePaths(mod, option, false, CancellationToken.None); } public void UpdatePaths(Mod mod, IModDataContainer option) - => UpdatePaths(mod, option, true, new CancellationToken()); + => UpdatePaths(mod, option, true, CancellationToken.None); public void Clear() { ClearFiles(); - ClearPaths(false, new CancellationToken()); + ClearPaths(false, CancellationToken.None); } public void Dispose() @@ -135,6 +140,9 @@ public class ModFileCollection : IDisposable, IService case ".pbd": _pbd.Add(registry); break; + case ".atch": + _atch.Add(registry); + break; } } } @@ -147,6 +155,7 @@ public class ModFileCollection : IDisposable, IService _tex.Clear(); _shpk.Clear(); _pbd.Clear(); + _atch.Clear(); } private void ClearPaths(bool clearRegistries, CancellationToken tok) diff --git a/Penumbra/Mods/Editor/ModMerger.cs b/Penumbra/Mods/Editor/ModMerger.cs index e3eb5f54..eb270e13 100644 --- a/Penumbra/Mods/Editor/ModMerger.cs +++ b/Penumbra/Mods/Editor/ModMerger.cs @@ -2,9 +2,11 @@ using Dalamud.Interface.ImGuiNotification; using Dalamud.Utility; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Api.Enums; using Penumbra.Communication; +using Penumbra.Mods.Groups; using Penumbra.Mods.Manager; using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Mods.SubMods; @@ -43,13 +45,13 @@ public class ModMerger : IDisposable, IService public ModMerger(ModManager mods, ModGroupEditor editor, ModSelection selection, DuplicateManager duplicates, CommunicatorService communicator, ModCreator creator, Configuration config) { - _editor = editor; - _selection = selection; - _duplicates = duplicates; - _communicator = communicator; - _creator = creator; - _config = config; - _mods = mods; + _editor = editor; + _selection = selection; + _duplicates = duplicates; + _communicator = communicator; + _creator = creator; + _config = config; + _mods = mods; _selection.Subscribe(OnSelectionChange, ModSelection.Priority.ModMerger); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModMerger); } @@ -98,26 +100,117 @@ public class ModMerger : IDisposable, IService foreach (var originalGroup in MergeFromMod!.Groups) { - var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, originalGroup.Type, originalGroup.Name); - if (groupCreated) - _createdGroups.Add(groupIdx); - if (group == null) - throw new Exception( - $"The merged group {originalGroup.Name} already existed, but had a different type than the original group of type {originalGroup.Type}."); - - foreach (var originalOption in originalGroup.DataContainers) + switch (originalGroup.Type) { - var (option, _, optionCreated) = _editor.FindOrAddOption(group, originalOption.GetName()); - if (optionCreated) + case GroupType.Single: + case GroupType.Multi: { - _createdOptions.Add(option!); - // #TODO DataContainer <> Option. - MergeIntoOption([originalOption], (IModDataContainer)option!, false); + var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, originalGroup.Type, originalGroup.Name); + if (group is null) + throw new Exception( + $"The merged group {originalGroup.Name} already existed, but had a different type than the original group of type {originalGroup.Type}."); + + if (groupCreated) + { + _createdGroups.Add(groupIdx); + group.Description = originalGroup.Description; + group.Image = originalGroup.Image; + group.DefaultSettings = originalGroup.DefaultSettings; + group.Page = originalGroup.Page; + group.Priority = originalGroup.Priority; + } + + foreach (var originalOption in originalGroup.Options) + { + var (option, _, optionCreated) = _editor.FindOrAddOption(group, originalOption.Name); + if (optionCreated) + { + _createdOptions.Add(option!); + MergeIntoOption([(IModDataContainer)originalOption], (IModDataContainer)option!, false); + option!.Description = originalOption.Description; + if (option is MultiSubMod multiOption) + multiOption.Priority = ((MultiSubMod)originalOption).Priority; + } + else + { + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: The option {option!.FullName} already existed."); + } + } + + break; } - else + + case GroupType.Imc when originalGroup is ImcModGroup imc: { - throw new Exception( - $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: The option {option!.FullName} already existed."); + var group = _editor.ImcEditor.AddModGroup(MergeToMod!, imc.Name, imc.Identifier, imc.DefaultEntry); + if (group is null) + throw new Exception( + $"The merged group {originalGroup.Name} already existed, but groups of type {originalGroup.Type} can not be merged."); + + group.AllVariants = imc.AllVariants; + group.OnlyAttributes = imc.OnlyAttributes; + group.Description = imc.Description; + group.Image = imc.Image; + group.DefaultSettings = imc.DefaultSettings; + group.Page = imc.Page; + group.Priority = imc.Priority; + foreach (var originalOption in imc.OptionData) + { + if (originalOption.IsDisableSubMod) + { + _editor.ImcEditor.ChangeCanBeDisabled(group, true); + var disable = group.OptionData.First(s => s.IsDisableSubMod); + disable.Description = originalOption.Description; + disable.Name = originalOption.Name; + continue; + } + + var newOption = _editor.ImcEditor.AddOption(group, originalOption.Name); + if (newOption is null) + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error when creating IMC option {originalOption.FullName}."); + + newOption.Description = originalOption.Description; + newOption.AttributeMask = originalOption.AttributeMask; + } + + break; + } + case GroupType.Combining when originalGroup is CombiningModGroup combining: + { + var group = _editor.CombiningEditor.AddModGroup(MergeToMod!, combining.Name); + if (group is null) + throw new Exception( + $"The merged group {originalGroup.Name} already existed, but groups of type {originalGroup.Type} can not be merged."); + + group.Description = combining.Description; + group.Image = combining.Image; + group.DefaultSettings = combining.DefaultSettings; + group.Page = combining.Page; + group.Priority = combining.Priority; + foreach (var originalOption in combining.OptionData) + { + var option = _editor.CombiningEditor.AddOption(group, originalOption.Name); + if (option is null) + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error when creating combining option {originalOption.FullName}."); + + option.Description = originalOption.Description; + } + + if (group.Data.Count != combining.Data.Count) + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error caused data container counts in combining group {originalGroup.Name} to differ."); + + foreach (var (originalContainer, container) in combining.Data.Zip(group.Data)) + { + container.Name = originalContainer.Name; + MergeIntoOption([originalContainer], container, false); + } + + + break; } } } @@ -150,7 +243,6 @@ public class ModMerger : IDisposable, IService if (!dir.Exists) _createdDirectories.Add(dir.FullName); CopyFiles(dir); - // #TODO DataContainer <> Option. MergeIntoOption(MergeFromMod!.AllDataContainers.Reverse(), (IModDataContainer)option!, true); } @@ -280,7 +372,6 @@ public class ModMerger : IDisposable, IService } else { - // TODO DataContainer <> Option. var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name); var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName()); var folder = Path.Combine(dir.FullName, group!.Name, option!.Name); diff --git a/Penumbra/Mods/Editor/ModMetaEditor.cs b/Penumbra/Mods/Editor/ModMetaEditor.cs index 7a5142dc..b4db457d 100644 --- a/Penumbra/Mods/Editor/ModMetaEditor.cs +++ b/Penumbra/Mods/Editor/ModMetaEditor.cs @@ -1,10 +1,15 @@ using System.Collections.Frozen; +using OtterGui.Classes; using OtterGui.Services; +using Penumbra.Collections.Cache; using Penumbra.Meta; using Penumbra.Meta.Files; using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Groups; using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Mods.SubMods; +using Penumbra.Services; +using static Penumbra.GameData.Files.ShpkFile; namespace Penumbra.Mods.Editor; @@ -59,6 +64,8 @@ public class ModMetaEditor( OtherData[MetaManipulationType.Est].Add(name, option.Manipulations.GetCount(MetaManipulationType.Est)); OtherData[MetaManipulationType.Rsp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Rsp)); OtherData[MetaManipulationType.Atch].Add(name, option.Manipulations.GetCount(MetaManipulationType.Atch)); + OtherData[MetaManipulationType.Shp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Shp)); + OtherData[MetaManipulationType.Atr].Add(name, option.Manipulations.GetCount(MetaManipulationType.Atr)); OtherData[MetaManipulationType.GlobalEqp].Add(name, option.Manipulations.GetCount(MetaManipulationType.GlobalEqp)); } @@ -67,12 +74,164 @@ public class ModMetaEditor( Changes = false; } - public static bool DeleteDefaultValues(MetaFileManager metaFileManager, MetaDictionary dict) + public static bool DeleteDefaultValues(Mod mod, MetaFileManager metaFileManager, SaveService? saveService, bool deleteAll = false) { + if (deleteAll) + { + var changes = false; + foreach (var container in mod.AllDataContainers) + { + if (!DeleteDefaultValues(metaFileManager, container.Manipulations)) + continue; + + saveService?.ImmediateSaveSync(new ModSaveGroup(container, metaFileManager.Config.ReplaceNonAsciiOnImport)); + changes = true; + } + + return changes; + } + + var defaultEntries = new MultiDictionary(); + var actualEntries = new HashSet(); + if (!FilterDefaultValues(mod.AllDataContainers, metaFileManager, defaultEntries, actualEntries)) + return false; + + var groups = new HashSet(); + DefaultSubMod? defaultMod = null; + foreach (var (defaultIdentifier, containers) in defaultEntries.Grouped) + { + if (!deleteAll && actualEntries.Contains(defaultIdentifier)) + continue; + + foreach (var container in containers) + { + if (!container.Manipulations.Remove(defaultIdentifier)) + continue; + + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {defaultIdentifier}."); + if (container.Group is { } group) + groups.Add(group); + else if (container is DefaultSubMod d) + defaultMod = d; + } + } + + if (saveService is not null) + { + if (defaultMod is not null) + saveService.ImmediateSaveSync(new ModSaveGroup(defaultMod, metaFileManager.Config.ReplaceNonAsciiOnImport)); + foreach (var group in groups) + saveService.ImmediateSaveSync(new ModSaveGroup(group, metaFileManager.Config.ReplaceNonAsciiOnImport)); + } + + return defaultMod is not null || groups.Count > 0; + } + + public void DeleteDefaultValues() + => Changes = DeleteDefaultValues(metaFileManager, this); + + public void Apply(IModDataContainer container) + { + if (!Changes) + return; + + groupEditor.SetManipulations(container, this); + Changes = false; + } + + private static bool FilterDefaultValues(IEnumerable containers, MetaFileManager metaFileManager, + MultiDictionary defaultEntries, HashSet actualEntries) + { + if (!metaFileManager.CharacterUtility.Ready) + { + Penumbra.Log.Warning("Trying to filter default meta values before CharacterUtility was ready, skipped."); + return false; + } + + foreach (var container in containers) + { + foreach (var (key, value) in container.Manipulations.Imc) + { + var defaultEntry = ImcChecker.GetDefaultEntry(key, false); + if (defaultEntry.Entry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Eqp) + { + var defaultEntry = new EqpEntryInternal(ExpandedEqpFile.GetDefault(metaFileManager, key.SetId), key.Slot); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Eqdp) + { + var defaultEntry = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(metaFileManager, key), key.Slot); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Est) + { + var defaultEntry = EstFile.GetDefault(metaFileManager, key); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Gmp) + { + var defaultEntry = ExpandedGmpFile.GetDefault(metaFileManager, key); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Rsp) + { + var defaultEntry = CmpFile.GetDefault(metaFileManager, key.SubRace, key.Attribute); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Atch) + { + var defaultEntry = AtchCache.GetDefault(metaFileManager, key); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + } + + return true; + } + + private static bool DeleteDefaultValues(MetaFileManager metaFileManager, MetaDictionary dict) + { + if (!metaFileManager.CharacterUtility.Ready) + { + Penumbra.Log.Warning("Trying to delete default meta values before CharacterUtility was ready, skipped."); + return false; + } + var clone = dict.Clone(); dict.ClearForDefault(); var count = 0; + foreach (var value in clone.GlobalEqp) + dict.TryAdd(value); + foreach (var (key, value) in clone.Imc) { var defaultEntry = ImcChecker.GetDefaultEntry(key, false); @@ -157,22 +316,27 @@ public class ModMetaEditor( } } + foreach (var (key, value) in clone.Atch) + { + var defaultEntry = AtchCache.GetDefault(metaFileManager, key); + if (!defaultEntry.HasValue) + continue; + + if (!defaultEntry.Value.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + if (count == 0) return false; Penumbra.Log.Debug($"Deleted {count} default-valued meta-entries from a mod option."); return true; } - - public void DeleteDefaultValues() - => Changes = DeleteDefaultValues(metaFileManager, this); - - public void Apply(IModDataContainer container) - { - if (!Changes) - return; - - groupEditor.SetManipulations(container, this); - Changes = false; - } } diff --git a/Penumbra/Mods/Editor/ModNormalizer.cs b/Penumbra/Mods/Editor/ModNormalizer.cs index 3e367a3b..df1528f6 100644 --- a/Penumbra/Mods/Editor/ModNormalizer.cs +++ b/Penumbra/Mods/Editor/ModNormalizer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface.ImGuiNotification; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Services; using OtterGui.Tasks; using Penumbra.Mods.Groups; @@ -76,7 +76,7 @@ public class ModNormalizer(ModManager modManager, Configuration config, SaveServ else { var groupDir = ModCreator.NewOptionDirectory(mod.ModPath, container.Group.Name, config.ReplaceNonAsciiOnImport); - var optionDir = ModCreator.NewOptionDirectory(groupDir, container.GetName(), config.ReplaceNonAsciiOnImport); + var optionDir = ModCreator.NewOptionDirectory(groupDir, container.GetDirectoryName(), config.ReplaceNonAsciiOnImport); containers[container] = optionDir.FullName; } } @@ -286,7 +286,7 @@ public class ModNormalizer(ModManager modManager, Configuration config, SaveServ void HandleSubMod(DirectoryInfo groupDir, IModDataContainer option, Dictionary newDict) { - var name = option.GetName(); + var name = option.GetDirectoryName(); var optionDir = ModCreator.CreateModFolder(groupDir, name, config.ReplaceNonAsciiOnImport, true); newDict.Clear(); diff --git a/Penumbra/Mods/Editor/ModelMaterialInfo.cs b/Penumbra/Mods/Editor/ModelMaterialInfo.cs index 741c2388..fe46048f 100644 --- a/Penumbra/Mods/Editor/ModelMaterialInfo.cs +++ b/Penumbra/Mods/Editor/ModelMaterialInfo.cs @@ -1,5 +1,5 @@ -using OtterGui; using OtterGui.Compression; +using OtterGui.Extensions; using Penumbra.GameData.Files; using Penumbra.String.Classes; diff --git a/Penumbra/Mods/FeatureChecker.cs b/Penumbra/Mods/FeatureChecker.cs new file mode 100644 index 00000000..10874fc9 --- /dev/null +++ b/Penumbra/Mods/FeatureChecker.cs @@ -0,0 +1,95 @@ +using System.Collections.Frozen; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using OtterGui.Text; +using Penumbra.Mods.Manager; +using Penumbra.UI.Classes; +using Notification = OtterGui.Classes.Notification; + +namespace Penumbra.Mods; + +public static class FeatureChecker +{ + /// Manually setup supported features to exclude None and Invalid and not make something supported too early. + private static readonly FrozenDictionary SupportedFlags = new[] + { + FeatureFlags.Atch, + FeatureFlags.Shp, + FeatureFlags.Atr, + }.ToFrozenDictionary(f => f.ToString(), f => f); + + public static IReadOnlyCollection SupportedFeatures + => SupportedFlags.Keys; + + public static FeatureFlags ParseFlags(string modDirectory, string modName, IEnumerable features) + { + var featureFlags = FeatureFlags.None; + HashSet missingFeatures = []; + foreach (var feature in features) + { + if (SupportedFlags.TryGetValue(feature, out var featureFlag)) + featureFlags |= featureFlag; + else + missingFeatures.Add(feature); + } + + if (missingFeatures.Count > 0) + { + Penumbra.Messager.AddMessage(new Notification( + $"Please update Penumbra to use the mod {modName}{(modDirectory != modName ? $" at {modDirectory}" : string.Empty)}!\n\nLoading failed because it requires the unsupported feature{(missingFeatures.Count > 1 ? $"s\n\n\t[{string.Join("], [", missingFeatures)}]." : $" [{missingFeatures.First()}].")}", + NotificationType.Warning)); + return FeatureFlags.Invalid; + } + + return featureFlags; + } + + public static bool Supported(string features) + => SupportedFlags.ContainsKey(features); + + public static void DrawFeatureFlagInput(ModDataEditor editor, Mod mod, float width) + { + const int numButtons = 5; + var innerSpacing = ImGui.GetStyle().ItemInnerSpacing; + var size = new Vector2((width - (numButtons - 1) * innerSpacing.X) / numButtons, 0); + var buttonColor = ImGui.GetColorU32(ImGuiCol.FrameBg); + var textColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, innerSpacing) + .Push(ImGuiStyleVar.FrameBorderSize, 0); + using (var color = ImRaii.PushColor(ImGuiCol.Border, ColorId.FolderLine.Value()) + .Push(ImGuiCol.Button, buttonColor) + .Push(ImGuiCol.Text, textColor)) + { + foreach (var flag in SupportedFlags.Values) + { + if (mod.RequiredFeatures.HasFlag(flag)) + { + style.Push(ImGuiStyleVar.FrameBorderSize, ImUtf8.GlobalScale); + color.Pop(2); + if (ImUtf8.Button($"{flag}", size)) + editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures & ~flag); + color.Push(ImGuiCol.Button, buttonColor) + .Push(ImGuiCol.Text, textColor); + style.Pop(); + } + else if (ImUtf8.Button($"{flag}", size)) + { + editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures | flag); + } + + ImGui.SameLine(); + } + } + + if (ImUtf8.ButtonEx("Compute"u8, "Compute the required features automatically from the used features."u8, size)) + editor.ChangeRequiredFeatures(mod, mod.ComputeRequiredFeatures()); + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Clear"u8, "Clear all required features."u8, size)) + editor.ChangeRequiredFeatures(mod, FeatureFlags.None); + + ImGui.SameLine(); + ImUtf8.Text("Required Features"u8); + } +} diff --git a/Penumbra/Mods/Groups/CombiningModGroup.cs b/Penumbra/Mods/Groups/CombiningModGroup.cs new file mode 100644 index 00000000..d3f14101 --- /dev/null +++ b/Penumbra/Mods/Groups/CombiningModGroup.cs @@ -0,0 +1,197 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +/// Groups that allow all available options to be selected at once. +public sealed class CombiningModGroup : IModGroup +{ + public GroupType Type + => GroupType.Combining; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.MultiSelection; + + public Mod Mod { get; } + public string Name { get; set; } = "Group"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + public readonly List OptionData = []; + public List Data { get; private set; } + + /// Groups that allow all available options to be selected at once. + public CombiningModGroup(Mod mod) + { + Mod = mod; + Data = [new CombinedDataContainer(this)]; + } + + IReadOnlyList IModGroup.Options + => OptionData; + + public IReadOnlyList DataContainers + => Data; + + public bool IsOption + => OptionData.Count > 0; + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + { + foreach (var path in Data.SelectWhere(o + => (o.Files.TryGetValue(gamePath, out var file) || o.FileSwaps.TryGetValue(gamePath, out file), file))) + return path; + + return null; + } + + public IModOption? AddOption(string name, string description = "") + { + var groupIdx = Mod.Groups.IndexOf(this); + if (groupIdx < 0) + return null; + + var subMod = new CombiningSubMod(this) + { + Name = name, + Description = description, + }; + return OptionData.AddNewWithPowerSet(Data, subMod, () => new CombinedDataContainer(this), IModGroup.MaxCombiningOptions) + ? subMod + : null; + } + + public static CombiningModGroup? Load(Mod mod, JObject json) + { + var ret = new CombiningModGroup(mod, true); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + var options = json["Options"]; + if (options != null) + foreach (var child in options.Children()) + { + if (ret.OptionData.Count == IModGroup.MaxCombiningOptions) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxCombiningOptions} options, ignoring excessive options.", + NotificationType.Warning); + break; + } + + var subMod = new CombiningSubMod(ret, child); + ret.OptionData.Add(subMod); + } + + var requiredContainers = 1 << ret.OptionData.Count; + var containers = json["Containers"]; + if (containers != null) + foreach (var child in containers.Children()) + { + if (requiredContainers <= ret.Data.Count) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has more data containers than it can support with {ret.OptionData.Count} options, ignoring excessive containers.", + NotificationType.Warning); + break; + } + + var container = new CombinedDataContainer(ret, child); + ret.Data.Add(container); + } + + if (requiredContainers > ret.Data.Count) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has not enough data containers for its {ret.OptionData.Count} options, filling with empty containers.", + NotificationType.Warning); + ret.Data.EnsureCapacity(requiredContainers); + ret.Data.AddRange(Enumerable.Repeat(0, requiredContainers - ret.Data.Count).Select(_ => new CombinedDataContainer(ret))); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + + return ret; + } + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => new CombiningModGroupEditDrawer(editDrawer, this); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + => Data[setting.AsIndex].AddDataTo(redirections, manipulations); + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in DataContainers) + identifier.AddChangedItems(container, changedItems); + } + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in OptionData) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + + jWriter.WritePropertyName("Containers"); + jWriter.WriteStartArray(); + foreach (var container in Data) + { + jWriter.WriteStartObject(); + if (container.Name.Length > 0) + { + jWriter.WritePropertyName("Name"); + jWriter.WriteValue(container.Name); + } + + SubMod.WriteModContainer(jWriter, serializer, container, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public Setting FixSetting(Setting setting) + => new(Math.Min(setting.Value, (ulong)(Data.Count - 1))); + + /// Create a group without a mod only for saving it in the creator. + internal static CombiningModGroup WithoutMod(string name) + => new(null!) + { + Name = name, + }; + + /// For loading when no empty container should be created. + private CombiningModGroup(Mod mod, bool _) + { + Mod = mod; + Data = []; + } +} diff --git a/Penumbra/Mods/Groups/ComplexModGroup.cs b/Penumbra/Mods/Groups/ComplexModGroup.cs new file mode 100644 index 00000000..435bc253 --- /dev/null +++ b/Penumbra/Mods/Groups/ComplexModGroup.cs @@ -0,0 +1,180 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +public sealed class ComplexModGroup(Mod mod) : IModGroup +{ + public Mod Mod { get; } = mod; + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + + public GroupType Type + => GroupType.Complex; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.Complex; + + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + + public readonly List Options = []; + public readonly List Containers = []; + + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + => throw new NotImplementedException(); + + public IModOption? AddOption(string name, string description = "") + => throw new NotImplementedException(); + + IReadOnlyList IModGroup.Options + => Options; + + IReadOnlyList IModGroup.DataContainers + => Containers; + + public bool IsOption + => Options.Count > 0; + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => throw new NotImplementedException(); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + { + foreach (var container in Containers.Where(c => c.Association.IsEnabled(setting))) + SubMod.AddContainerTo(container, redirections, manipulations); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in Containers) + identifier.AddChangedItems(container, changedItems); + } + + public Setting FixSetting(Setting setting) + => new(setting.Value & ((1ul << Options.Count) - 1)); + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in Options) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + if (!option.Conditions.IsZero) + { + jWriter.WritePropertyName("ConditionMask"); + jWriter.WriteValue(option.Conditions.Mask.Value); + jWriter.WritePropertyName("ConditionValue"); + jWriter.WriteValue(option.Conditions.Value.Value); + } + + if (option.Indentation > 0) + { + jWriter.WritePropertyName("Indentation"); + jWriter.WriteValue(option.Indentation); + } + + if (option.SubGroupLabel.Length > 0) + { + jWriter.WritePropertyName("SubGroup"); + jWriter.WriteValue(option.SubGroupLabel); + } + + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + + jWriter.WritePropertyName("Containers"); + jWriter.WriteStartArray(); + foreach (var container in Containers) + { + jWriter.WriteStartObject(); + if (container.Name.Length > 0) + { + jWriter.WritePropertyName("Name"); + jWriter.WriteValue(container.Name); + } + + if (!container.Association.IsZero) + { + jWriter.WritePropertyName("AssociationMask"); + jWriter.WriteValue(container.Association.Mask.Value); + + jWriter.WritePropertyName("AssociationValue"); + jWriter.WriteValue(container.Association.Value.Value); + } + + SubMod.WriteModContainer(jWriter, serializer, container, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public static ComplexModGroup? Load(Mod mod, JObject json) + { + var ret = new ComplexModGroup(mod); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + var options = json["Options"]; + if (options != null) + foreach (var child in options.Children()) + { + if (ret.Options.Count == IModGroup.MaxComplexOptions) + { + Penumbra.Messager.NotificationMessage( + $"Complex Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxComplexOptions} options, ignoring excessive options.", + NotificationType.Warning); + break; + } + + var subMod = new ComplexSubMod(ret, child); + ret.Options.Add(subMod); + } + + // Fix up conditions: No condition on itself. + foreach (var (option, index) in ret.Options.WithIndex()) + { + option.Conditions = option.Conditions.Limit(ret.Options.Count); + option.Conditions = new MaskedSetting(option.Conditions.Mask.SetBit(index, false), option.Conditions.Value); + } + + var containers = json["Containers"]; + if (containers != null) + foreach (var child in containers.Children()) + { + var container = new ComplexDataContainer(ret, child); + container.Association = container.Association.Limit(ret.Options.Count); + ret.Containers.Add(container); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + + return ret; + } +} diff --git a/Penumbra/Mods/Groups/IModGroup.cs b/Penumbra/Mods/Groups/IModGroup.cs index a6f6e20d..98f62862 100644 --- a/Penumbra/Mods/Groups/IModGroup.cs +++ b/Penumbra/Mods/Groups/IModGroup.cs @@ -18,11 +18,14 @@ public enum GroupDrawBehaviour { SingleSelection, MultiSelection, + Complex, } public interface IModGroup { - public const int MaxMultiOptions = 32; + public const int MaxMultiOptions = 32; + public const int MaxComplexOptions = MaxMultiOptions; + public const int MaxCombiningOptions = 8; public Mod Mod { get; } public string Name { get; set; } @@ -52,7 +55,7 @@ public interface IModGroup public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer); public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations); - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems); /// Ensure that a value is valid for a group. public Setting FixSetting(Setting setting); diff --git a/Penumbra/Mods/Groups/ImcModGroup.cs b/Penumbra/Mods/Groups/ImcModGroup.cs index 2a1854ed..34174f7f 100644 --- a/Penumbra/Mods/Groups/ImcModGroup.cs +++ b/Penumbra/Mods/Groups/ImcModGroup.cs @@ -1,8 +1,8 @@ using Dalamud.Interface.ImGuiNotification; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using Penumbra.Api.Enums; using Penumbra.GameData.Data; using Penumbra.GameData.Structs; @@ -131,7 +131,7 @@ public class ImcModGroup(Mod mod) : IModGroup } } - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) => Identifier.AddChangedItems(identifier, changedItems, AllVariants); public Setting FixSetting(Setting setting) diff --git a/Penumbra/Mods/Groups/MultiModGroup.cs b/Penumbra/Mods/Groups/MultiModGroup.cs index 0c9aa805..558ee6be 100644 --- a/Penumbra/Mods/Groups/MultiModGroup.cs +++ b/Penumbra/Mods/Groups/MultiModGroup.cs @@ -1,8 +1,8 @@ using Dalamud.Interface.ImGuiNotification; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using Penumbra.Api.Enums; using Penumbra.GameData.Data; using Penumbra.Meta.Manipulations; @@ -122,7 +122,7 @@ public sealed class MultiModGroup(Mod mod) : IModGroup, ITexToolsGroup } } - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) { foreach (var container in DataContainers) identifier.AddChangedItems(container, changedItems); diff --git a/Penumbra/Mods/Groups/SingleModGroup.cs b/Penumbra/Mods/Groups/SingleModGroup.cs index ab0c2d4f..f376c1c9 100644 --- a/Penumbra/Mods/Groups/SingleModGroup.cs +++ b/Penumbra/Mods/Groups/SingleModGroup.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using OtterGui; +using OtterGui.Extensions; using Penumbra.Api.Enums; using Penumbra.GameData.Data; using Penumbra.Meta.Manipulations; @@ -107,7 +107,7 @@ public sealed class SingleModGroup(Mod mod) : IModGroup, ITexToolsGroup OptionData[setting.AsIndex].AddDataTo(redirections, manipulations); } - public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) { foreach (var container in DataContainers) identifier.AddChangedItems(container, changedItems); diff --git a/Penumbra/Mods/ItemSwap/CustomizationSwap.cs b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs index cd36de93..c5406f66 100644 --- a/Penumbra/Mods/ItemSwap/CustomizationSwap.cs +++ b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs @@ -17,8 +17,8 @@ public static class CustomizationSwap if (idFrom.Id > byte.MaxValue) throw new Exception($"The Customization ID {idFrom} is too large for {slot}."); - var mdlPathFrom = GamePaths.Character.Mdl.Path(race, slot, idFrom, slot.ToCustomizationType()); - var mdlPathTo = GamePaths.Character.Mdl.Path(race, slot, idTo, slot.ToCustomizationType()); + var mdlPathFrom = GamePaths.Mdl.Customization(race, slot, idFrom, slot.ToCustomizationType()); + var mdlPathTo = GamePaths.Mdl.Customization(race, slot, idTo, slot.ToCustomizationType()); var mdl = FileSwap.CreateSwap(manager, ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo); var range = slot == BodySlot.Tail @@ -47,8 +47,8 @@ public static class CustomizationSwap ref string fileName, ref bool dataWasChanged) { variant = slot is BodySlot.Face or BodySlot.Ear ? Variant.None.Id : variant; - var mtrlFromPath = GamePaths.Character.Mtrl.Path(race, slot, idFrom, fileName, out var gameRaceFrom, out var gameSetIdFrom, variant); - var mtrlToPath = GamePaths.Character.Mtrl.Path(race, slot, idTo, fileName, out var gameRaceTo, out var gameSetIdTo, variant); + var mtrlFromPath = GamePaths.Mtrl.Customization(race, slot, idFrom, fileName, out var gameRaceFrom, out var gameSetIdFrom, variant); + var mtrlToPath = GamePaths.Mtrl.Customization(race, slot, idTo, fileName, out var gameRaceTo, out var gameSetIdTo, variant); var newFileName = fileName; newFileName = ItemSwap.ReplaceRace(newFileName, gameRaceTo, race, gameRaceTo != race); @@ -60,7 +60,7 @@ public static class CustomizationSwap var actualMtrlFromPath = mtrlFromPath; if (newFileName != fileName) { - actualMtrlFromPath = GamePaths.Character.Mtrl.Path(race, slot, idFrom, newFileName, out _, out _, variant); + actualMtrlFromPath = GamePaths.Mtrl.Customization(race, slot, idFrom, newFileName, out _, out _, variant); fileName = newFileName; dataWasChanged = true; } diff --git a/Penumbra/Mods/ItemSwap/EquipmentSwap.cs b/Penumbra/Mods/ItemSwap/EquipmentSwap.cs index c7e43a26..216b5841 100644 --- a/Penumbra/Mods/ItemSwap/EquipmentSwap.cs +++ b/Penumbra/Mods/ItemSwap/EquipmentSwap.cs @@ -27,31 +27,31 @@ public static class EquipmentSwap : []; } - public static EquipItem[] CreateTypeSwap(MetaFileManager manager, ObjectIdentification identifier, List swaps, + public static HashSet CreateTypeSwap(MetaFileManager manager, ObjectIdentification identifier, List swaps, Func redirections, MetaDictionary manips, EquipSlot slotFrom, EquipItem itemFrom, EquipSlot slotTo, EquipItem itemTo) { LookupItem(itemFrom, out var actualSlotFrom, out var idFrom, out var variantFrom); - LookupItem(itemTo, out var actualSlotTo, out var idTo, out var variantTo); + LookupItem(itemTo, out var actualSlotTo, out var idTo, out var variantTo); if (actualSlotFrom != slotFrom.ToSlot() || actualSlotTo != slotTo.ToSlot()) throw new ItemSwap.InvalidItemTypeException(); var (imcFileFrom, variants, affectedItems) = GetVariants(manager, identifier, slotFrom, idFrom, idTo, variantFrom); var imcIdentifierTo = new ImcIdentifier(slotTo, idTo, variantTo); - var imcFileTo = new ImcFile(manager, imcIdentifierTo); + var imcFileTo = new ImcFile(manager, imcIdentifierTo); var imcEntry = manips.TryGetValue(imcIdentifierTo, out var entry) ? entry : imcFileTo.GetEntry(imcIdentifierTo.EquipSlot, imcIdentifierTo.Variant); var mtrlVariantTo = imcEntry.MaterialId; - var skipFemale = false; - var skipMale = false; + var skipFemale = false; + var skipMale = false; foreach (var gr in Enum.GetValues()) { switch (gr.Split().Item1) { - case Gender.Male when skipMale: continue; - case Gender.Female when skipFemale: continue; - case Gender.MaleNpc when skipMale: continue; + case Gender.Male when skipMale: continue; + case Gender.Female when skipFemale: continue; + case Gender.MaleNpc when skipMale: continue; case Gender.FemaleNpc when skipFemale: continue; } @@ -88,30 +88,40 @@ public static class EquipmentSwap return affectedItems; } - public static EquipItem[] CreateItemSwap(MetaFileManager manager, ObjectIdentification identifier, List swaps, + public static HashSet CreateItemSwap(MetaFileManager manager, ObjectIdentification identifier, List swaps, Func redirections, MetaDictionary manips, EquipItem itemFrom, EquipItem itemTo, bool rFinger = true, bool lFinger = true) { // Check actual ids, variants and slots. We only support using the same slot. LookupItem(itemFrom, out var slotFrom, out var idFrom, out var variantFrom); - LookupItem(itemTo, out var slotTo, out var idTo, out var variantTo); + LookupItem(itemTo, out var slotTo, out var idTo, out var variantTo); if (slotFrom != slotTo) throw new ItemSwap.InvalidItemTypeException(); - var eqp = CreateEqp(manager, manips, slotFrom, idFrom, idTo); + HashSet affectedItems = []; + var eqp = CreateEqp(manager, manips, slotFrom, idFrom, idTo); if (eqp != null) + { swaps.Add(eqp); + // Add items affected through multi-slot EQP edits. + foreach (var child in eqp.ChildSwaps.SelectMany(c => c.WithChildren()).OfType>()) + { + affectedItems.UnionWith(identifier + .Identify(GamePaths.Mdl.Equipment(idFrom, GenderRace.MidlanderMale, child.SwapFromIdentifier.Slot)) + .Select(kvp => kvp.Value).OfType().Select(i => i.Item)); + } + } var gmp = CreateGmp(manager, manips, slotFrom, idFrom, idTo); if (gmp != null) swaps.Add(gmp); - var affectedItems = Array.Empty(); foreach (var slot in ConvertSlots(slotFrom, rFinger, lFinger)) { - (var imcFileFrom, var variants, affectedItems) = GetVariants(manager, identifier, slot, idFrom, idTo, variantFrom); + var (imcFileFrom, variants, affectedItemsLocal) = GetVariants(manager, identifier, slot, idFrom, idTo, variantFrom); + affectedItems.UnionWith(affectedItemsLocal); var imcIdentifierTo = new ImcIdentifier(slotTo, idTo, variantTo); - var imcFileTo = new ImcFile(manager, imcIdentifierTo); + var imcFileTo = new ImcFile(manager, imcIdentifierTo); var imcEntry = manips.TryGetValue(imcIdentifierTo, out var entry) ? entry : imcFileTo.GetEntry(imcIdentifierTo.EquipSlot, imcIdentifierTo.Variant); @@ -122,18 +132,18 @@ public static class EquipmentSwap { EquipSlot.Head => EstType.Head, EquipSlot.Body => EstType.Body, - _ => (EstType)0, + _ => (EstType)0, }; var skipFemale = false; - var skipMale = false; + var skipMale = false; foreach (var gr in Enum.GetValues()) { switch (gr.Split().Item1) { - case Gender.Male when skipMale: continue; - case Gender.Female when skipFemale: continue; - case Gender.MaleNpc when skipMale: continue; + case Gender.Male when skipMale: continue; + case Gender.Female when skipFemale: continue; + case Gender.MaleNpc when skipMale: continue; case Gender.FemaleNpc when skipFemale: continue; } @@ -148,7 +158,7 @@ public static class EquipmentSwap swaps.Add(eqdp); var ownMdl = eqdp?.SwapToModdedEntry.Model ?? false; - var est = ItemSwap.CreateEst(manager, redirections, manips, estType, gr, idFrom, idTo, ownMdl); + var est = ItemSwap.CreateEst(manager, redirections, manips, estType, gr, idFrom, idTo, ownMdl); if (est != null) swaps.Add(est); } @@ -176,6 +186,7 @@ public static class EquipmentSwap return affectedItems; } + public static MetaSwap? CreateEqdp(MetaFileManager manager, Func redirections, MetaDictionary manips, EquipSlot slot, GenderRace gr, PrimaryId idFrom, PrimaryId idTo, byte mtrlTo) => CreateEqdp(manager, redirections, manips, slot, slot, gr, idFrom, idTo, mtrlTo); @@ -185,9 +196,9 @@ public static class EquipmentSwap PrimaryId idTo, byte mtrlTo) { var eqdpFromIdentifier = new EqdpIdentifier(idFrom, slotFrom, gr); - var eqdpToIdentifier = new EqdpIdentifier(idTo, slotTo, gr); - var eqdpFromDefault = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(manager, eqdpFromIdentifier), slotFrom); - var eqdpToDefault = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(manager, eqdpToIdentifier), slotTo); + var eqdpToIdentifier = new EqdpIdentifier(idTo, slotTo, gr); + var eqdpFromDefault = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(manager, eqdpFromIdentifier), slotFrom); + var eqdpToDefault = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(manager, eqdpToIdentifier), slotTo); var meta = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, eqdpFromIdentifier, eqdpFromDefault, eqdpToIdentifier, eqdpToDefault); @@ -212,11 +223,9 @@ public static class EquipmentSwap public static FileSwap CreateMdl(MetaFileManager manager, Func redirections, EquipSlot slotFrom, EquipSlot slotTo, GenderRace gr, PrimaryId idFrom, PrimaryId idTo, byte mtrlTo) { - var mdlPathFrom = slotFrom.IsAccessory() - ? GamePaths.Accessory.Mdl.Path(idFrom, gr, slotFrom) - : GamePaths.Equipment.Mdl.Path(idFrom, gr, slotFrom); - var mdlPathTo = slotTo.IsAccessory() ? GamePaths.Accessory.Mdl.Path(idTo, gr, slotTo) : GamePaths.Equipment.Mdl.Path(idTo, gr, slotTo); - var mdl = FileSwap.CreateSwap(manager, ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo); + var mdlPathFrom = GamePaths.Mdl.Gear(idFrom, gr, slotFrom); + var mdlPathTo = GamePaths.Mdl.Gear(idTo, gr, slotTo); + var mdl = FileSwap.CreateSwap(manager, ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo); foreach (ref var fileName in mdl.AsMdl()!.Materials.AsSpan()) { @@ -225,9 +234,56 @@ public static class EquipmentSwap mdl.ChildSwaps.Add(mtrl); } + FixAttributes(mdl, slotFrom, slotTo); + return mdl; } + private static void FixAttributes(FileSwap swap, EquipSlot slotFrom, EquipSlot slotTo) + { + if (slotFrom == slotTo) + return; + + var needle = slotTo switch + { + EquipSlot.Head => "atr_mv_", + EquipSlot.Ears => "atr_ev_", + EquipSlot.Neck => "atr_nv_", + EquipSlot.Wrists => "atr_wv_", + EquipSlot.RFinger or EquipSlot.LFinger => "atr_rv_", + _ => string.Empty, + }; + + var replacement = slotFrom switch + { + EquipSlot.Head => 'm', + EquipSlot.Ears => 'e', + EquipSlot.Neck => 'n', + EquipSlot.Wrists => 'w', + EquipSlot.RFinger or EquipSlot.LFinger => 'r', + _ => 'm', + }; + + var attributes = swap.AsMdl()!.Attributes; + for (var i = 0; i < attributes.Length; ++i) + { + if (FixAttribute(ref attributes[i], needle, replacement)) + swap.DataWasChanged = true; + } + } + + private static unsafe bool FixAttribute(ref string attribute, string from, char to) + { + if (!attribute.StartsWith(from) || attribute.Length != from.Length + 1 || attribute[^1] is < 'a' or > 'j') + return false; + + Span stack = stackalloc char[attribute.Length]; + attribute.CopyTo(stack); + stack[4] = to; + attribute = new string(stack); + return true; + } + private static void LookupItem(EquipItem i, out EquipSlot slot, out PrimaryId modelId, out Variant variant) { slot = i.Type.ToSlot(); @@ -238,25 +294,24 @@ public static class EquipmentSwap variant = i.Variant; } - private static (ImcFile, Variant[], EquipItem[]) GetVariants(MetaFileManager manager, ObjectIdentification identifier, EquipSlot slotFrom, + private static (ImcFile, Variant[], HashSet) GetVariants(MetaFileManager manager, ObjectIdentification identifier, + EquipSlot slotFrom, PrimaryId idFrom, PrimaryId idTo, Variant variantFrom) { - var ident = new ImcIdentifier(slotFrom, idFrom, variantFrom); - var imc = new ImcFile(manager, ident); - EquipItem[] items; - Variant[] variants; + var ident = new ImcIdentifier(slotFrom, idFrom, variantFrom); + var imc = new ImcFile(manager, ident); + HashSet items; + Variant[] variants; if (idFrom == idTo) { - items = identifier.Identify(idFrom, 0, variantFrom, slotFrom).ToArray(); + items = identifier.Identify(idFrom, 0, variantFrom, slotFrom).ToHashSet(); variants = [variantFrom]; } else { - items = identifier.Identify(slotFrom.IsEquipment() - ? GamePaths.Equipment.Mdl.Path(idFrom, GenderRace.MidlanderMale, slotFrom) - : GamePaths.Accessory.Mdl.Path(idFrom, GenderRace.MidlanderMale, slotFrom)) + items = identifier.Identify(GamePaths.Mdl.Gear(idFrom, GenderRace.MidlanderMale, slotFrom)) .Select(kvp => kvp.Value).OfType().Select(i => i.Item) - .ToArray(); + .ToHashSet(); variants = Enumerable.Range(0, imc.Count + 1).Select(i => (Variant)i).ToArray(); } @@ -270,9 +325,9 @@ public static class EquipmentSwap return null; var manipFromIdentifier = new GmpIdentifier(idFrom); - var manipToIdentifier = new GmpIdentifier(idTo); - var manipFromDefault = ExpandedGmpFile.GetDefault(manager, manipFromIdentifier); - var manipToDefault = ExpandedGmpFile.GetDefault(manager, manipToIdentifier); + var manipToIdentifier = new GmpIdentifier(idTo); + var manipFromDefault = ExpandedGmpFile.GetDefault(manager, manipFromIdentifier); + var manipToDefault = ExpandedGmpFile.GetDefault(manager, manipToIdentifier); return new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, manipFromDefault, manipToIdentifier, manipToDefault); } @@ -287,9 +342,9 @@ public static class EquipmentSwap Variant variantFrom, Variant variantTo, ImcFile imcFileFrom, ImcFile imcFileTo) { var manipFromIdentifier = new ImcIdentifier(slotFrom, idFrom, variantFrom); - var manipToIdentifier = new ImcIdentifier(slotTo, idTo, variantTo); - var manipFromDefault = imcFileFrom.GetEntry(ImcFile.PartIndex(slotFrom), variantFrom); - var manipToDefault = imcFileTo.GetEntry(ImcFile.PartIndex(slotTo), variantTo); + var manipToIdentifier = new ImcIdentifier(slotTo, idTo, variantTo); + var manipFromDefault = imcFileFrom.GetEntry(ImcFile.PartIndex(slotFrom), variantFrom); + var manipToDefault = imcFileTo.GetEntry(ImcFile.PartIndex(slotTo), variantTo); var imc = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, manipFromDefault, manipToIdentifier, manipToDefault); @@ -312,7 +367,7 @@ public static class EquipmentSwap if (decalId == 0) return null; - var decalPath = GamePaths.Equipment.Decal.Path(decalId); + var decalPath = GamePaths.Tex.EquipDecal(decalId); return FileSwap.CreateSwap(manager, ResourceType.Tex, redirections, decalPath, decalPath); } @@ -325,10 +380,10 @@ public static class EquipmentSwap if (vfxId == 0) return null; - var vfxPathFrom = GamePaths.Equipment.Avfx.Path(idFrom, vfxId); + var vfxPathFrom = GamePaths.Avfx.Path(slotFrom, idFrom, vfxId); vfxPathFrom = ItemSwap.ReplaceType(vfxPathFrom, slotFrom, slotTo, idFrom); - var vfxPathTo = GamePaths.Equipment.Avfx.Path(idTo, vfxId); - var avfx = FileSwap.CreateSwap(manager, ResourceType.Avfx, redirections, vfxPathFrom, vfxPathTo); + var vfxPathTo = GamePaths.Avfx.Path(slotTo, idTo, vfxId); + var avfx = FileSwap.CreateSwap(manager, ResourceType.Avfx, redirections, vfxPathFrom, vfxPathTo); foreach (ref var filePath in avfx.AsAvfx()!.Textures.AsSpan()) { @@ -339,18 +394,42 @@ public static class EquipmentSwap return avfx; } - public static MetaSwap? CreateEqp(MetaFileManager manager, MetaDictionary manips, - EquipSlot slot, PrimaryId idFrom, PrimaryId idTo) + public static MetaSwap? CreateEqp(MetaFileManager manager, MetaDictionary manips, EquipSlot slot, + PrimaryId idFrom, PrimaryId idTo) { if (slot.IsAccessory()) return null; var manipFromIdentifier = new EqpIdentifier(idFrom, slot); - var manipToIdentifier = new EqpIdentifier(idTo, slot); - var manipFromDefault = new EqpEntryInternal(ExpandedEqpFile.GetDefault(manager, idFrom), slot); - var manipToDefault = new EqpEntryInternal(ExpandedEqpFile.GetDefault(manager, idTo), slot); - return new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, + var manipToIdentifier = new EqpIdentifier(idTo, slot); + var manipFromDefault = new EqpEntryInternal(ExpandedEqpFile.GetDefault(manager, idFrom), slot); + var manipToDefault = new EqpEntryInternal(ExpandedEqpFile.GetDefault(manager, idTo), slot); + var swap = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, manipFromDefault, manipToIdentifier, manipToDefault); + var entry = swap.SwapToModdedEntry.ToEntry(slot); + // Add additional EQP entries if the swapped item is a multi-slot item, + // because those take the EQP entries of their other model-set slots when used. + switch (slot) + { + case EquipSlot.Body: + if (!entry.HasFlag(EqpEntry.BodyShowLeg) + && CreateEqp(manager, manips, EquipSlot.Legs, idFrom, idTo) is { } legChild) + swap.ChildSwaps.Add(legChild); + if (!entry.HasFlag(EqpEntry.BodyShowHead) + && CreateEqp(manager, manips, EquipSlot.Head, idFrom, idTo) is { } headChild) + swap.ChildSwaps.Add(headChild); + if (!entry.HasFlag(EqpEntry.BodyShowHand) + && CreateEqp(manager, manips, EquipSlot.Hands, idFrom, idTo) is { } handChild) + swap.ChildSwaps.Add(handChild); + break; + case EquipSlot.Legs: + if (!entry.HasFlag(EqpEntry.LegsShowFoot) + && CreateEqp(manager, manips, EquipSlot.Feet, idFrom, idTo) is { } footChild) + swap.ChildSwaps.Add(footChild); + break; + } + + return swap; } public static FileSwap? CreateMtrl(MetaFileManager manager, Func redirections, EquipSlot slot, PrimaryId idFrom, @@ -366,21 +445,17 @@ public static class EquipmentSwap if (!fileName.Contains($"{prefix}{idTo.Id:D4}")) return null; - var folderTo = slotTo.IsAccessory() - ? GamePaths.Accessory.Mtrl.FolderPath(idTo, variantTo) - : GamePaths.Equipment.Mtrl.FolderPath(idTo, variantTo); - var pathTo = $"{folderTo}{fileName}"; + var folderTo = GamePaths.Mtrl.GearFolder(slotTo, idTo, variantTo); + var pathTo = $"{folderTo}{fileName}"; - var folderFrom = slotFrom.IsAccessory() - ? GamePaths.Accessory.Mtrl.FolderPath(idFrom, variantTo) - : GamePaths.Equipment.Mtrl.FolderPath(idFrom, variantTo); + var folderFrom = GamePaths.Mtrl.GearFolder(slotFrom, idFrom, variantTo); var newFileName = ItemSwap.ReplaceId(fileName, prefix, idTo, idFrom); newFileName = ItemSwap.ReplaceSlot(newFileName, slotTo, slotFrom, slotTo != slotFrom); var pathFrom = $"{folderFrom}{newFileName}"; if (newFileName != fileName) { - fileName = newFileName; + fileName = newFileName; dataWasChanged = true; } @@ -405,13 +480,13 @@ public static class EquipmentSwap EquipSlot slotTo, PrimaryId idFrom, PrimaryId idTo, ref MtrlFile.Texture texture, ref bool dataWasChanged) { var addedDashes = GamePaths.Tex.HandleDx11Path(texture, out var path); - var newPath = ItemSwap.ReplaceAnyId(path, prefix, idFrom); + var newPath = ItemSwap.ReplaceAnyId(path, prefix, idFrom); newPath = ItemSwap.ReplaceSlot(newPath, slotTo, slotFrom, slotTo != slotFrom); newPath = ItemSwap.ReplaceType(newPath, slotFrom, slotTo, idFrom); newPath = ItemSwap.AddSuffix(newPath, ".tex", $"_{Path.GetFileName(texture.Path).GetStableHashCode():x8}"); if (newPath != path) { - texture.Path = addedDashes ? newPath.Replace("--", string.Empty) : newPath; + texture.Path = addedDashes ? newPath.Replace("--", string.Empty) : newPath; dataWasChanged = true; } @@ -421,7 +496,7 @@ public static class EquipmentSwap public static FileSwap CreateShader(MetaFileManager manager, Func redirections, ref string shaderName, ref bool dataWasChanged) { - var path = $"shader/sm5/shpk/{shaderName}"; + var path = GamePaths.Shader(shaderName); return FileSwap.CreateSwap(manager, ResourceType.Shpk, redirections, path, path); } @@ -429,8 +504,8 @@ public static class EquipmentSwap PrimaryId idFrom, ref string filePath, ref bool dataWasChanged) { var oldPath = filePath; - filePath = ItemSwap.AddSuffix(filePath, ".atex", $"_{Path.GetFileName(filePath).GetStableHashCode():x8}"); - filePath = ItemSwap.ReplaceType(filePath, slotFrom, slotTo, idFrom); + filePath = ItemSwap.AddSuffix(filePath, ".atex", $"_{Path.GetFileName(filePath).GetStableHashCode():x8}"); + filePath = ItemSwap.ReplaceType(filePath, slotFrom, slotTo, idFrom); dataWasChanged = true; return FileSwap.CreateSwap(manager, ResourceType.Atex, redirections, filePath, oldPath, oldPath); diff --git a/Penumbra/Mods/ItemSwap/ItemSwap.cs b/Penumbra/Mods/ItemSwap/ItemSwap.cs index 03abfc45..0049fa12 100644 --- a/Penumbra/Mods/ItemSwap/ItemSwap.cs +++ b/Penumbra/Mods/ItemSwap/ItemSwap.cs @@ -132,14 +132,14 @@ public static class ItemSwap public static FileSwap CreatePhyb(MetaFileManager manager, Func redirections, EstType type, GenderRace race, EstEntry estEntry) { - var phybPath = GamePaths.Skeleton.Phyb.Path(race, type.ToName(), estEntry.AsId); + var phybPath = GamePaths.Phyb.Customization(race, type.ToName(), estEntry.AsId); return FileSwap.CreateSwap(manager, ResourceType.Phyb, redirections, phybPath, phybPath); } public static FileSwap CreateSklb(MetaFileManager manager, Func redirections, EstType type, GenderRace race, EstEntry estEntry) { - var sklbPath = GamePaths.Skeleton.Sklb.Path(race, type.ToName(), estEntry.AsId); + var sklbPath = GamePaths.Sklb.Customization(race, type.ToName(), estEntry.AsId); return FileSwap.CreateSwap(manager, ResourceType.Sklb, redirections, sklbPath, sklbPath); } diff --git a/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs index d2deb9ef..a9d5e0d6 100644 --- a/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs +++ b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs @@ -127,7 +127,7 @@ public class ItemSwapContainer ? new MetaDictionary(cache) : _appliedModData.Manipulations; - public EquipItem[] LoadEquipment(EquipItem from, EquipItem to, ModCollection? collection = null, bool useRightRing = true, + public HashSet LoadEquipment(EquipItem from, EquipItem to, ModCollection? collection = null, bool useRightRing = true, bool useLeftRing = true) { Swaps.Clear(); @@ -138,7 +138,7 @@ public class ItemSwapContainer return ret; } - public EquipItem[] LoadTypeSwap(EquipSlot slotFrom, EquipItem from, EquipSlot slotTo, EquipItem to, ModCollection? collection = null) + public HashSet LoadTypeSwap(EquipSlot slotFrom, EquipItem from, EquipSlot slotTo, EquipItem to, ModCollection? collection = null) { Swaps.Clear(); Loaded = false; diff --git a/Penumbra/Mods/Manager/ModCacheManager.cs b/Penumbra/Mods/Manager/ModCacheManager.cs index 38d98d7c..130c8fcb 100644 --- a/Penumbra/Mods/Manager/ModCacheManager.cs +++ b/Penumbra/Mods/Manager/ModCacheManager.cs @@ -15,7 +15,7 @@ public class ModCacheManager : IDisposable, IService private readonly CommunicatorService _communicator; private readonly ObjectIdentification _identifier; private readonly ModStorage _modManager; - private bool _updatingItems = false; + private bool _updatingItems; public ModCacheManager(CommunicatorService communicator, ObjectIdentification identifier, ModStorage modStorage, Configuration config) { @@ -139,6 +139,7 @@ public class ModCacheManager : IDisposable, IService mod.ChangedItems.RemoveMachinistOffhands(); mod.LowerChangedItemsString = string.Join("\0", mod.ChangedItems.Keys.Select(k => k.ToLowerInvariant())); + ++mod.LastChangedItemsUpdate; } private static void UpdateCounts(Mod mod) diff --git a/Penumbra/Mods/Manager/ModDataEditor.cs b/Penumbra/Mods/Manager/ModDataEditor.cs index 162f823d..ffa73b76 100644 --- a/Penumbra/Mods/Manager/ModDataEditor.cs +++ b/Penumbra/Mods/Manager/ModDataEditor.cs @@ -1,7 +1,8 @@ using Dalamud.Utility; -using Newtonsoft.Json.Linq; using OtterGui.Classes; using OtterGui.Services; +using Penumbra.GameData.Data; +using Penumbra.GameData.Structs; using Penumbra.Services; namespace Penumbra.Mods.Manager; @@ -9,179 +10,44 @@ namespace Penumbra.Mods.Manager; [Flags] public enum ModDataChangeType : ushort { - None = 0x0000, - Name = 0x0001, - Author = 0x0002, - Description = 0x0004, - Version = 0x0008, - Website = 0x0010, - Deletion = 0x0020, - Migration = 0x0040, - ModTags = 0x0080, - ImportDate = 0x0100, - Favorite = 0x0200, - LocalTags = 0x0400, - Note = 0x0800, - Image = 0x1000, + None = 0x0000, + Name = 0x0001, + Author = 0x0002, + Description = 0x0004, + Version = 0x0008, + Website = 0x0010, + Deletion = 0x0020, + Migration = 0x0040, + ModTags = 0x0080, + ImportDate = 0x0100, + Favorite = 0x0200, + LocalTags = 0x0400, + Note = 0x0800, + Image = 0x1000, + DefaultChangedItems = 0x2000, + PreferredChangedItems = 0x4000, + RequiredFeatures = 0x8000, } -public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService) : IService +public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData) : IService { + public SaveService SaveService + => saveService; + /// Create the file containing the meta information about a mod from scratch. public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version, - string? website) + string? website, params string[] tags) { var mod = new Mod(directory); - mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name!); + mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name); mod.Author = author != null ? new LowerString(author) : mod.Author; mod.Description = description ?? mod.Description; mod.Version = version ?? mod.Version; mod.Website = website ?? mod.Website; + mod.ModTags = tags; saveService.ImmediateSaveSync(new ModMeta(mod)); } - public ModDataChangeType LoadLocalData(Mod mod) - { - var dataFile = saveService.FileNames.LocalDataFile(mod); - - var importDate = 0L; - var localTags = Enumerable.Empty(); - var favorite = false; - var note = string.Empty; - - var save = true; - if (File.Exists(dataFile)) - try - { - var text = File.ReadAllText(dataFile); - var json = JObject.Parse(text); - - importDate = json[nameof(Mod.ImportDate)]?.Value() ?? importDate; - favorite = json[nameof(Mod.Favorite)]?.Value() ?? favorite; - note = json[nameof(Mod.Note)]?.Value() ?? note; - localTags = (json[nameof(Mod.LocalTags)] as JArray)?.Values().OfType() ?? localTags; - save = false; - } - catch (Exception e) - { - Penumbra.Log.Error($"Could not load local mod data:\n{e}"); - } - - if (importDate == 0) - importDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - ModDataChangeType changes = 0; - if (mod.ImportDate != importDate) - { - mod.ImportDate = importDate; - changes |= ModDataChangeType.ImportDate; - } - - changes |= ModLocalData.UpdateTags(mod, null, localTags); - - if (mod.Favorite != favorite) - { - mod.Favorite = favorite; - changes |= ModDataChangeType.Favorite; - } - - if (mod.Note != note) - { - mod.Note = note; - changes |= ModDataChangeType.Note; - } - - if (save) - saveService.QueueSave(new ModLocalData(mod)); - - return changes; - } - - public ModDataChangeType LoadMeta(ModCreator creator, Mod mod) - { - var metaFile = saveService.FileNames.ModMetaPath(mod); - if (!File.Exists(metaFile)) - { - Penumbra.Log.Debug($"No mod meta found for {mod.ModPath.Name}."); - return ModDataChangeType.Deletion; - } - - try - { - var text = File.ReadAllText(metaFile); - var json = JObject.Parse(text); - - var newName = json[nameof(Mod.Name)]?.Value() ?? string.Empty; - var newAuthor = json[nameof(Mod.Author)]?.Value() ?? string.Empty; - var newDescription = json[nameof(Mod.Description)]?.Value() ?? string.Empty; - var newImage = json[nameof(Mod.Image)]?.Value() ?? string.Empty; - var newVersion = json[nameof(Mod.Version)]?.Value() ?? string.Empty; - var newWebsite = json[nameof(Mod.Website)]?.Value() ?? string.Empty; - var newFileVersion = json[nameof(ModMeta.FileVersion)]?.Value() ?? 0; - var importDate = json[nameof(Mod.ImportDate)]?.Value(); - var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values().OfType(); - - ModDataChangeType changes = 0; - if (mod.Name != newName) - { - changes |= ModDataChangeType.Name; - mod.Name = newName; - } - - if (mod.Author != newAuthor) - { - changes |= ModDataChangeType.Author; - mod.Author = newAuthor; - } - - if (mod.Description != newDescription) - { - changes |= ModDataChangeType.Description; - mod.Description = newDescription; - } - - if (mod.Image != newImage) - { - changes |= ModDataChangeType.Image; - mod.Image = newImage; - } - - if (mod.Version != newVersion) - { - changes |= ModDataChangeType.Version; - mod.Version = newVersion; - } - - if (mod.Website != newWebsite) - { - changes |= ModDataChangeType.Website; - mod.Website = newWebsite; - } - - if (newFileVersion != ModMeta.FileVersion) - if (ModMigration.Migrate(creator, saveService, mod, json, ref newFileVersion)) - { - changes |= ModDataChangeType.Migration; - saveService.ImmediateSave(new ModMeta(mod)); - } - - if (importDate != null && mod.ImportDate != importDate.Value) - { - mod.ImportDate = importDate.Value; - changes |= ModDataChangeType.ImportDate; - } - - changes |= ModLocalData.UpdateTags(mod, modTags, null); - - return changes; - } - catch (Exception e) - { - Penumbra.Log.Error($"Could not load mod meta for {metaFile}:\n{e}"); - return ModDataChangeType.Deletion; - } - } - public void ChangeModName(Mod mod, string newName) { if (mod.Name.Text == newName) @@ -231,6 +97,16 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic mod.Website = newWebsite; saveService.QueueSave(new ModMeta(mod)); communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null); + } + + public void ChangeRequiredFeatures(Mod mod, FeatureFlags flags) + { + if (mod.RequiredFeatures == flags) + return; + + mod.RequiredFeatures = flags; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.RequiredFeatures, mod, null); } public void ChangeModTag(Mod mod, int tagIdx, string newTag) @@ -314,4 +190,125 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic Penumbra.Log.Error($"Could not move local data file {oldFile} to {newFile}:\n{e}"); } } + + public void AddPreferredItem(Mod mod, CustomItemId id, bool toDefault, bool cleanExisting) + { + if (CleanExisting(mod.PreferredChangedItems)) + { + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } + + if (toDefault && CleanExisting(mod.DefaultPreferredItems)) + { + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.DefaultChangedItems, mod, null); + } + + bool CleanExisting(HashSet items) + { + if (!items.Add(id)) + return false; + + if (!cleanExisting) + return true; + + var it1Exists = itemData.Primary.TryGetValue(id, out var it1); + var it2Exists = itemData.Secondary.TryGetValue(id, out var it2); + var it3Exists = itemData.Tertiary.TryGetValue(id, out var it3); + + foreach (var item in items.ToArray()) + { + if (item == id) + continue; + + if (it1Exists + && itemData.Primary.TryGetValue(item, out var oldItem1) + && oldItem1.PrimaryId == it1.PrimaryId + && oldItem1.Type == it1.Type) + items.Remove(item); + + else if (it2Exists + && itemData.Primary.TryGetValue(item, out var oldItem2) + && oldItem2.PrimaryId == it2.PrimaryId + && oldItem2.Type == it2.Type) + items.Remove(item); + + else if (it3Exists + && itemData.Primary.TryGetValue(item, out var oldItem3) + && oldItem3.PrimaryId == it3.PrimaryId + && oldItem3.Type == it3.Type) + items.Remove(item); + } + + return true; + } + } + + public void RemovePreferredItem(Mod mod, CustomItemId id, bool fromDefault) + { + if (!fromDefault && mod.PreferredChangedItems.Remove(id)) + { + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } + + if (fromDefault && mod.DefaultPreferredItems.Remove(id)) + { + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.DefaultChangedItems, mod, null); + } + } + + public void ClearInvalidPreferredItems(Mod mod) + { + var currentChangedItems = mod.ChangedItems.Values.OfType().Select(i => i.Item.Id).Distinct().ToHashSet(); + var newSet = new HashSet(mod.PreferredChangedItems.Count); + + if (CheckItems(mod.PreferredChangedItems)) + { + mod.PreferredChangedItems = newSet; + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } + + newSet = new HashSet(mod.DefaultPreferredItems.Count); + if (CheckItems(mod.DefaultPreferredItems)) + { + mod.DefaultPreferredItems = newSet; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.DefaultChangedItems, mod, null); + } + + return; + + bool CheckItems(HashSet set) + { + var changes = false; + foreach (var item in set) + { + if (currentChangedItems.Contains(item)) + newSet.Add(item); + else + changes = true; + } + + return changes; + } + } + + public void ResetPreferredItems(Mod mod) + { + if (mod.PreferredChangedItems.SetEquals(mod.DefaultPreferredItems)) + return; + + mod.PreferredChangedItems.Clear(); + mod.PreferredChangedItems.UnionWith(mod.DefaultPreferredItems); + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } } diff --git a/Penumbra/Mods/Manager/ModFileSystem.cs b/Penumbra/Mods/Manager/ModFileSystem.cs index 693db944..20a78995 100644 --- a/Penumbra/Mods/Manager/ModFileSystem.cs +++ b/Penumbra/Mods/Manager/ModFileSystem.cs @@ -37,11 +37,11 @@ public sealed class ModFileSystem : FileSystem, IDisposable, ISavable, ISer public struct ImportDate : ISortMode { - public string Name - => "Import Date (Older First)"; + public ReadOnlySpan Name + => "Import Date (Older First)"u8; - public string Description - => "In each folder, sort all subfolders lexicographically, then sort all leaves using their import date."; + public ReadOnlySpan Description + => "In each folder, sort all subfolders lexicographically, then sort all leaves using their import date."u8; public IEnumerable GetChildren(Folder f) => f.GetSubFolders().Cast().Concat(f.GetLeaves().OrderBy(l => l.Value.ImportDate)); @@ -49,11 +49,11 @@ public sealed class ModFileSystem : FileSystem, IDisposable, ISavable, ISer public struct InverseImportDate : ISortMode { - public string Name - => "Import Date (Newer First)"; + public ReadOnlySpan Name + => "Import Date (Newer First)"u8; - public string Description - => "In each folder, sort all subfolders lexicographically, then sort all leaves using their inverse import date."; + public ReadOnlySpan Description + => "In each folder, sort all subfolders lexicographically, then sort all leaves using their inverse import date."u8; public IEnumerable GetChildren(Folder f) => f.GetSubFolders().Cast().Concat(f.GetLeaves().OrderByDescending(l => l.Value.ImportDate)); @@ -80,7 +80,7 @@ public sealed class ModFileSystem : FileSystem, IDisposable, ISavable, ISer // Update sort order when defaulted mod names change. private void OnModDataChange(ModDataChangeType type, Mod mod, string? oldName) { - if (!type.HasFlag(ModDataChangeType.Name) || oldName == null || !FindLeaf(mod, out var leaf)) + if (!type.HasFlag(ModDataChangeType.Name) || oldName == null || !TryGetValue(mod, out var leaf)) return; var old = oldName.FixName(); @@ -111,7 +111,7 @@ public sealed class ModFileSystem : FileSystem, IDisposable, ISavable, ISer CreateDuplicateLeaf(parent, mod.Name.Text, mod); break; case ModPathChangeType.Deleted: - if (FindLeaf(mod, out var leaf)) + if (TryGetValue(mod, out var leaf)) Delete(leaf); break; @@ -124,16 +124,6 @@ public sealed class ModFileSystem : FileSystem, IDisposable, ISavable, ISer } } - // Search the entire filesystem for the leaf corresponding to a mod. - public bool FindLeaf(Mod mod, [NotNullWhen(true)] out Leaf? leaf) - { - leaf = Root.GetAllDescendants(ISortMode.Lexicographical) - .OfType() - .FirstOrDefault(l => l.Value == mod); - return leaf != null; - } - - // Used for saving and loading. private static string ModToIdentifier(Mod mod) => mod.ModPath.Name; diff --git a/Penumbra/Mods/Manager/ModImportManager.cs b/Penumbra/Mods/Manager/ModImportManager.cs index 22cc0c86..bb282262 100644 --- a/Penumbra/Mods/Manager/ModImportManager.cs +++ b/Penumbra/Mods/Manager/ModImportManager.cs @@ -70,7 +70,6 @@ public class ModImportManager(ModManager modManager, Configuration config, ModEd _import = null; } - public bool AddUnpackedMod([NotNullWhen(true)] out Mod? mod) { if (!_modsToAdd.TryDequeue(out var directory)) diff --git a/Penumbra/Mods/Manager/ModManager.cs b/Penumbra/Mods/Manager/ModManager.cs index bf1b6637..77385bbd 100644 --- a/Penumbra/Mods/Manager/ModManager.cs +++ b/Penumbra/Mods/Manager/ModManager.cs @@ -1,5 +1,6 @@ using OtterGui.Services; using Penumbra.Communication; +using Penumbra.Interop; using Penumbra.Mods.Editor; using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Services; @@ -143,9 +144,10 @@ public sealed class ModManager : ModStorage, IDisposable, IService _communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath); if (!Creator.ReloadMod(mod, true, false, out var metaChange)) { - Penumbra.Log.Warning(mod.Name.Length == 0 - ? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead." - : $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead."); + if (mod.RequiredFeatures is not FeatureFlags.Invalid) + Penumbra.Log.Warning(mod.Name.Length == 0 + ? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead." + : $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead."); RemoveMod(mod); return; } @@ -251,12 +253,8 @@ public sealed class ModManager : ModStorage, IDisposable, IService { switch (type) { - case ModPathChangeType.Added: - SetNew(mod); - break; - case ModPathChangeType.Deleted: - SetKnown(mod); - break; + case ModPathChangeType.Added: SetNew(mod); break; + case ModPathChangeType.Deleted: SetKnown(mod); break; case ModPathChangeType.Moved: if (oldDirectory != null && newDirectory != null) DataEditor.MoveDataFile(oldDirectory, newDirectory); @@ -306,6 +304,9 @@ public sealed class ModManager : ModStorage, IDisposable, IService if (!firstTime && _config.ModDirectory != BasePath.FullName) TriggerModDirectoryChange(BasePath.FullName, Valid); } + + if (CloudApi.IsCloudSynced(BasePath.FullName)) + Penumbra.Log.Warning($"Mod base directory {BasePath.FullName} is cloud-synced. This may cause issues."); } private void TriggerModDirectoryChange(string newPath, bool valid) diff --git a/Penumbra/Mods/Manager/ModMigration.cs b/Penumbra/Mods/Manager/ModMigration.cs index 3e58c515..f3b25f1a 100644 --- a/Penumbra/Mods/Manager/ModMigration.cs +++ b/Penumbra/Mods/Manager/ModMigration.cs @@ -1,7 +1,8 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using OtterGui; +using OtterGui.Extensions; using Penumbra.Api.Enums; +using Penumbra.Mods.Editor; using Penumbra.Mods.Groups; using Penumbra.Mods.Settings; using Penumbra.Mods.SubMods; @@ -82,9 +83,8 @@ public static partial class ModMigration foreach (var (gamePath, swapPath) in swaps) mod.Default.FileSwaps.Add(gamePath, swapPath); - creator.IncorporateMetaChanges(mod.Default, mod.ModPath, true, true); - foreach (var group in mod.Groups) - saveService.ImmediateSave(new ModSaveGroup(group, creator.Config.ReplaceNonAsciiOnImport)); + creator.IncorporateAllMetaChanges(mod, true, true); + saveService.SaveAllOptionGroups(mod, false, creator.Config.ReplaceNonAsciiOnImport); // Delete meta files. foreach (var file in seenMetaFiles.Where(f => f.Exists)) @@ -182,7 +182,7 @@ public static partial class ModMigration Description = option.OptionDesc, }; AddFilesToSubMod(subMod, mod.ModPath, option, seenMetaFiles); - creator.IncorporateMetaChanges(subMod, mod.ModPath, false, true); + creator.IncorporateMetaChanges(subMod, mod.ModPath, false); return subMod; } @@ -196,7 +196,7 @@ public static partial class ModMigration Priority = priority, }; AddFilesToSubMod(subMod, mod.ModPath, option, seenMetaFiles); - creator.IncorporateMetaChanges(subMod, mod.ModPath, false, true); + creator.IncorporateMetaChanges(subMod, mod.ModPath, false); return subMod; } diff --git a/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs new file mode 100644 index 00000000..5acf5eb5 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs @@ -0,0 +1,49 @@ +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public sealed class CombiningModGroupEditor(CommunicatorService communicator, SaveService saveService, Configuration config) + : ModOptionEditor(communicator, saveService, config), IService +{ + protected override CombiningModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + }; + + protected override CombiningSubMod? CloneOption(CombiningModGroup group, IModOption option) + => throw new NotImplementedException(); + + protected override void RemoveOption(CombiningModGroup group, int optionIndex) + { + if (group.OptionData.RemoveWithPowerSet(group.Data, optionIndex)) + group.DefaultSettings.RemoveBit(optionIndex); + } + + protected override bool MoveOption(CombiningModGroup group, int optionIdxFrom, int optionIdxTo) + { + if (!group.OptionData.MoveWithPowerSet(group.Data, ref optionIdxFrom, ref optionIdxTo)) + return false; + + group.DefaultSettings.MoveBit(optionIdxFrom, optionIdxTo); + return true; + } + + public void SetDisplayName(CombinedDataContainer container, string name, SaveType saveType = SaveType.Queue) + { + if (container.Name == name) + return; + + container.Name = name; + SaveService.Save(saveType, new ModSaveGroup(container.Group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.DisplayChange, container.Group.Mod, container.Group, null, null, -1); + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs b/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs index a7b73ac9..12ed4c60 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using Penumbra.GameData.Structs; using Penumbra.Mods.Groups; using Penumbra.Mods.SubMods; diff --git a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs index d01297db..1c077c58 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs @@ -37,6 +37,7 @@ public class ModGroupEditor( SingleModGroupEditor singleEditor, MultiModGroupEditor multiEditor, ImcModGroupEditor imcEditor, + CombiningModGroupEditor combiningEditor, CommunicatorService communicator, SaveService saveService, Configuration config) : IService @@ -50,6 +51,9 @@ public class ModGroupEditor( public ImcModGroupEditor ImcEditor => imcEditor; + public CombiningModGroupEditor CombiningEditor + => combiningEditor; + /// Change the settings stored as default options in a mod. public void ChangeModGroupDefaultOption(IModGroup group, Setting defaultOption) { @@ -223,52 +227,60 @@ public class ModGroupEditor( case ImcSubMod i: ImcEditor.DeleteOption(i); return; + case CombiningSubMod c: + CombiningEditor.DeleteOption(c); + return; } } public IModOption? AddOption(IModGroup group, IModOption option) => group switch { - SingleModGroup s => SingleEditor.AddOption(s, option), - MultiModGroup m => MultiEditor.AddOption(m, option), - ImcModGroup i => ImcEditor.AddOption(i, option), - _ => null, + SingleModGroup s => SingleEditor.AddOption(s, option), + MultiModGroup m => MultiEditor.AddOption(m, option), + ImcModGroup i => ImcEditor.AddOption(i, option), + CombiningModGroup c => CombiningEditor.AddOption(c, option), + _ => null, }; public IModOption? AddOption(IModGroup group, string newName) => group switch { - SingleModGroup s => SingleEditor.AddOption(s, newName), - MultiModGroup m => MultiEditor.AddOption(m, newName), - ImcModGroup i => ImcEditor.AddOption(i, newName), - _ => null, + SingleModGroup s => SingleEditor.AddOption(s, newName), + MultiModGroup m => MultiEditor.AddOption(m, newName), + ImcModGroup i => ImcEditor.AddOption(i, newName), + CombiningModGroup c => CombiningEditor.AddOption(c, newName), + _ => null, }; public IModGroup? AddModGroup(Mod mod, GroupType type, string newName, SaveType saveType = SaveType.ImmediateSync) => type switch { - GroupType.Single => SingleEditor.AddModGroup(mod, newName, saveType), - GroupType.Multi => MultiEditor.AddModGroup(mod, newName, saveType), - GroupType.Imc => ImcEditor.AddModGroup(mod, newName, default, default, saveType), - _ => null, + GroupType.Single => SingleEditor.AddModGroup(mod, newName, saveType), + GroupType.Multi => MultiEditor.AddModGroup(mod, newName, saveType), + GroupType.Imc => ImcEditor.AddModGroup(mod, newName, default, default, saveType), + GroupType.Combining => CombiningEditor.AddModGroup(mod, newName, saveType), + _ => null, }; public (IModGroup?, int, bool) FindOrAddModGroup(Mod mod, GroupType type, string name, SaveType saveType = SaveType.ImmediateSync) => type switch { - GroupType.Single => SingleEditor.FindOrAddModGroup(mod, name, saveType), - GroupType.Multi => MultiEditor.FindOrAddModGroup(mod, name, saveType), - GroupType.Imc => ImcEditor.FindOrAddModGroup(mod, name, saveType), - _ => (null, -1, false), + GroupType.Single => SingleEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Multi => MultiEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Imc => ImcEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Combining => CombiningEditor.FindOrAddModGroup(mod, name, saveType), + _ => (null, -1, false), }; public (IModOption?, int, bool) FindOrAddOption(IModGroup group, string name, SaveType saveType = SaveType.ImmediateSync) => group switch { - SingleModGroup s => SingleEditor.FindOrAddOption(s, name, saveType), - MultiModGroup m => MultiEditor.FindOrAddOption(m, name, saveType), - ImcModGroup i => ImcEditor.FindOrAddOption(i, name, saveType), - _ => (null, -1, false), + SingleModGroup s => SingleEditor.FindOrAddOption(s, name, saveType), + MultiModGroup m => MultiEditor.FindOrAddOption(m, name, saveType), + ImcModGroup i => ImcEditor.FindOrAddOption(i, name, saveType), + CombiningModGroup c => CombiningEditor.FindOrAddOption(c, name, saveType), + _ => (null, -1, false), }; public void MoveOption(IModOption option, int toIdx) @@ -284,6 +296,9 @@ public class ModGroupEditor( case ImcSubMod i: ImcEditor.MoveOption(i, toIdx); return; + case CombiningSubMod c: + CombiningEditor.MoveOption(c, toIdx); + return; } } } diff --git a/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs index c067102e..d9d672e3 100644 --- a/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs +++ b/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs @@ -1,5 +1,5 @@ -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using Penumbra.Mods.Groups; using Penumbra.Mods.Settings; using Penumbra.Mods.SubMods; @@ -15,8 +15,8 @@ public abstract class ModOptionEditor( where TOption : class, IModOption { protected readonly CommunicatorService Communicator = communicator; - protected readonly SaveService SaveService = saveService; - protected readonly Configuration Config = config; + protected readonly SaveService SaveService = saveService; + protected readonly Configuration Config = config; /// Add a new, empty option group of the given type and name. public TGroup? AddModGroup(Mod mod, string newName, SaveType saveType = SaveType.ImmediateSync) @@ -25,7 +25,7 @@ public abstract class ModOptionEditor( return null; var maxPriority = mod.Groups.Count == 0 ? ModPriority.Default : mod.Groups.Max(o => o.Priority) + 1; - var group = CreateGroup(mod, newName, maxPriority); + var group = CreateGroup(mod, newName, maxPriority); mod.Groups.Add(group); SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, group, null, null, -1); @@ -92,8 +92,8 @@ public abstract class ModOptionEditor( /// Delete the given option from the given group. public void DeleteOption(TOption option) { - var mod = option.Mod; - var group = option.Group; + var mod = option.Mod; + var group = option.Group; var optionIdx = option.GetIndex(); Communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, mod, group, option, null, -1); RemoveOption((TGroup)group, optionIdx); @@ -104,7 +104,7 @@ public abstract class ModOptionEditor( /// Move an option inside the given option group. public void MoveOption(TOption option, int optionIdxTo) { - var idx = option.GetIndex(); + var idx = option.GetIndex(); var group = (TGroup)option.Group; if (!MoveOption(group, idx, optionIdxTo)) return; @@ -113,10 +113,10 @@ public abstract class ModOptionEditor( Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMoved, group.Mod, group, option, null, idx); } - protected abstract TGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync); + protected abstract TGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync); protected abstract TOption? CloneOption(TGroup group, IModOption option); - protected abstract void RemoveOption(TGroup group, int optionIndex); - protected abstract bool MoveOption(TGroup group, int optionIdxFrom, int optionIdxTo); + protected abstract void RemoveOption(TGroup group, int optionIndex); + protected abstract bool MoveOption(TGroup group, int optionIdxFrom, int optionIdxTo); } public static class ModOptionChangeTypeExtension @@ -132,22 +132,22 @@ public static class ModOptionChangeTypeExtension { (requiresSaving, requiresReloading, wasPrepared) = type switch { - ModOptionChangeType.GroupRenamed => (true, false, false), - ModOptionChangeType.GroupAdded => (true, false, false), - ModOptionChangeType.GroupDeleted => (true, true, false), - ModOptionChangeType.GroupMoved => (true, false, false), - ModOptionChangeType.GroupTypeChanged => (true, true, true), - ModOptionChangeType.PriorityChanged => (true, true, true), - ModOptionChangeType.OptionAdded => (true, true, true), - ModOptionChangeType.OptionDeleted => (true, true, false), - ModOptionChangeType.OptionMoved => (true, false, false), - ModOptionChangeType.OptionFilesChanged => (false, true, false), - ModOptionChangeType.OptionFilesAdded => (false, true, true), - ModOptionChangeType.OptionSwapsChanged => (false, true, false), - ModOptionChangeType.OptionMetaChanged => (false, true, false), - ModOptionChangeType.DisplayChange => (false, false, false), + ModOptionChangeType.GroupRenamed => (true, false, false), + ModOptionChangeType.GroupAdded => (true, false, false), + ModOptionChangeType.GroupDeleted => (true, true, false), + ModOptionChangeType.GroupMoved => (true, false, false), + ModOptionChangeType.GroupTypeChanged => (true, true, true), + ModOptionChangeType.PriorityChanged => (true, true, true), + ModOptionChangeType.OptionAdded => (true, true, true), + ModOptionChangeType.OptionDeleted => (true, true, false), + ModOptionChangeType.OptionMoved => (true, false, false), + ModOptionChangeType.OptionFilesChanged => (false, true, false), + ModOptionChangeType.OptionFilesAdded => (false, true, true), + ModOptionChangeType.OptionSwapsChanged => (false, true, false), + ModOptionChangeType.OptionMetaChanged => (false, true, false), + ModOptionChangeType.DisplayChange => (false, false, false), ModOptionChangeType.DefaultOptionChanged => (true, false, false), - _ => (false, false, false), + _ => (false, false, false), }; } } diff --git a/Penumbra/Mods/Mod.cs b/Penumbra/Mods/Mod.cs index 488e3dc1..e262e8f1 100644 --- a/Penumbra/Mods/Mod.cs +++ b/Penumbra/Mods/Mod.cs @@ -1,6 +1,7 @@ -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using Penumbra.GameData.Data; +using Penumbra.GameData.Structs; using Penumbra.Meta.Manipulations; using Penumbra.Mods.Editor; using Penumbra.Mods.Groups; @@ -10,6 +11,16 @@ using Penumbra.String.Classes; namespace Penumbra.Mods; +[Flags] +public enum FeatureFlags : ulong +{ + None = 0, + Atch = 1ul << 0, + Shp = 1ul << 1, + Atr = 1ul << 2, + Invalid = 1ul << 62, +} + public sealed class Mod : IMod { public static readonly TemporaryMod ForcedFiles = new() @@ -47,26 +58,45 @@ public sealed class Mod : IMod => Name.Text; // Meta Data - public LowerString Name { get; internal set; } = "New Mod"; - public LowerString Author { get; internal set; } = LowerString.Empty; - public string Description { get; internal set; } = string.Empty; - public string Version { get; internal set; } = string.Empty; - public string Website { get; internal set; } = string.Empty; - public string Image { get; internal set; } = string.Empty; - public IReadOnlyList ModTags { get; internal set; } = []; + public LowerString Name { get; internal set; } = "New Mod"; + public LowerString Author { get; internal set; } = LowerString.Empty; + public string Description { get; internal set; } = string.Empty; + public string Version { get; internal set; } = string.Empty; + public string Website { get; internal set; } = string.Empty; + public string Image { get; internal set; } = string.Empty; + public IReadOnlyList ModTags { get; internal set; } = []; + public HashSet DefaultPreferredItems { get; internal set; } = []; + public FeatureFlags RequiredFeatures { get; internal set; } = 0; // Local Data - public long ImportDate { get; internal set; } = DateTimeOffset.UnixEpoch.ToUnixTimeMilliseconds(); - public IReadOnlyList LocalTags { get; internal set; } = []; - public string Note { get; internal set; } = string.Empty; - public bool Favorite { get; internal set; } = false; - + public long ImportDate { get; internal set; } = DateTimeOffset.UnixEpoch.ToUnixTimeMilliseconds(); + public IReadOnlyList LocalTags { get; internal set; } = []; + public string Note { get; internal set; } = string.Empty; + public HashSet PreferredChangedItems { get; internal set; } = []; + public bool Favorite { get; internal set; } = false; // Options public readonly DefaultSubMod Default; public readonly List Groups = []; + /// Compute the required feature flags for this mod. + public FeatureFlags ComputeRequiredFeatures() + { + var flags = FeatureFlags.None; + foreach (var option in AllDataContainers) + { + if (option.Manipulations.Atch.Count > 0) + flags |= FeatureFlags.Atch; + if (option.Manipulations.Atr.Count > 0) + flags |= FeatureFlags.Atr; + if (option.Manipulations.Shp.Count > 0) + flags |= FeatureFlags.Shp; + } + + return flags; + } + public AppliedModData GetData(ModSettings? settings = null) { if (settings is not { Enabled: true }) @@ -101,13 +131,14 @@ public sealed class Mod : IMod } // Cache - public readonly SortedList ChangedItems = new(); + public readonly SortedList ChangedItems = new(); public string LowerChangedItemsString { get; internal set; } = string.Empty; public string AllTagsLower { get; internal set; } = string.Empty; - public int TotalFileCount { get; internal set; } - public int TotalSwapCount { get; internal set; } - public int TotalManipulations { get; internal set; } - public bool HasOptions { get; internal set; } + public int TotalFileCount { get; internal set; } + public int TotalSwapCount { get; internal set; } + public int TotalManipulations { get; internal set; } + public ushort LastChangedItemsUpdate { get; internal set; } + public bool HasOptions { get; internal set; } } diff --git a/Penumbra/Mods/ModCreator.cs b/Penumbra/Mods/ModCreator.cs index 1af9c1db..3a7bd105 100644 --- a/Penumbra/Mods/ModCreator.cs +++ b/Penumbra/Mods/ModCreator.cs @@ -3,6 +3,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Filesystem; using OtterGui.Services; using Penumbra.Api.Enums; @@ -27,15 +28,16 @@ public partial class ModCreator( MetaFileManager metaFileManager, GamePathParser gamePathParser) : IService { - public readonly Configuration Config = config; + public const FeatureFlags SupportedFeatures = FeatureFlags.Atch | FeatureFlags.Shp | FeatureFlags.Atr; + public readonly Configuration Config = config; /// Creates directory and files necessary for a new mod without adding it to the manager. - public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "") + public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null, params string[] tags) { try { var newDir = CreateModFolder(basePath, newName, Config.ReplaceNonAsciiOnImport, true); - dataEditor.CreateMeta(newDir, newName, Config.DefaultModAuthor, description, "1.0", string.Empty); + dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty, tags); CreateDefaultFiles(newDir); return newDir; } @@ -72,23 +74,17 @@ public partial class ModCreator( if (!Directory.Exists(mod.ModPath.FullName)) return false; - modDataChange = dataEditor.LoadMeta(this, mod); - if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0) + modDataChange = ModMeta.Load(dataEditor, this, mod); + if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0 || mod.RequiredFeatures is FeatureFlags.Invalid) return false; - modDataChange |= dataEditor.LoadLocalData(mod); + modDataChange |= ModLocalData.Load(dataEditor, mod); LoadDefaultOption(mod); LoadAllGroups(mod); if (incorporateMetaChanges) - IncorporateAllMetaChanges(mod, true); - if (deleteDefaultMetaChanges && !Config.KeepDefaultMetaChanges) - { - foreach (var container in mod.AllDataContainers) - { - if (ModMetaEditor.DeleteDefaultValues(metaFileManager, container.Manipulations)) - saveService.ImmediateSaveSync(new ModSaveGroup(container, Config.ReplaceNonAsciiOnImport)); - } - } + IncorporateAllMetaChanges(mod, true, deleteDefaultMetaChanges); + else if (deleteDefaultMetaChanges) + ModMetaEditor.DeleteDefaultValues(mod, metaFileManager, saveService, false); return true; } @@ -160,19 +156,21 @@ public partial class ModCreator( /// Convert all .meta and .rgsp files to their respective meta changes and add them to their options. /// Deletes the source files if delete is true. /// - public void IncorporateAllMetaChanges(Mod mod, bool delete) + public void IncorporateAllMetaChanges(Mod mod, bool delete, bool removeDefaultValues) { var changes = false; - List deleteList = new(); + List deleteList = []; foreach (var subMod in mod.AllDataContainers) { - var (localChanges, localDeleteList) = IncorporateMetaChanges(subMod, mod.ModPath, false, true); + var (localChanges, localDeleteList) = IncorporateMetaChanges(subMod, mod.ModPath, false); changes |= localChanges; if (delete) deleteList.AddRange(localDeleteList); } DeleteDeleteList(deleteList, delete); + if (removeDefaultValues && !Config.KeepDefaultMetaChanges) + changes |= ModMetaEditor.DeleteDefaultValues(mod, metaFileManager, null, false); if (!changes) return; @@ -186,7 +184,7 @@ public partial class ModCreator( /// If .meta or .rgsp files are encountered, parse them and incorporate their meta changes into the mod. /// If delete is true, the files are deleted afterwards. /// - public (bool Changes, List DeleteList) IncorporateMetaChanges(IModDataContainer option, DirectoryInfo basePath, bool delete, bool deleteDefault) + public (bool Changes, List DeleteList) IncorporateMetaChanges(IModDataContainer option, DirectoryInfo basePath, bool delete) { var deleteList = new List(); var oldSize = option.Manipulations.Count; @@ -203,8 +201,7 @@ public partial class ModCreator( if (!file.Exists) continue; - var meta = new TexToolsMeta(metaFileManager, gamePathParser, File.ReadAllBytes(file.FullName), - Config.KeepDefaultMetaChanges); + var meta = new TexToolsMeta(gamePathParser, File.ReadAllBytes(file.FullName)); Penumbra.Log.Verbose( $"Incorporating {file} as Metadata file of {meta.MetaManipulations.Count} manipulations {deleteString}"); deleteList.Add(file.FullName); @@ -216,8 +213,7 @@ public partial class ModCreator( if (!file.Exists) continue; - var rgsp = TexToolsMeta.FromRgspFile(metaFileManager, file.FullName, File.ReadAllBytes(file.FullName), - Config.KeepDefaultMetaChanges); + var rgsp = TexToolsMeta.FromRgspFile(metaFileManager, file.FullName, File.ReadAllBytes(file.FullName)); Penumbra.Log.Verbose( $"Incorporating {file} as racial scaling file of {rgsp.MetaManipulations.Count} manipulations {deleteString}"); deleteList.Add(file.FullName); @@ -233,9 +229,6 @@ public partial class ModCreator( DeleteDeleteList(deleteList, delete); var changes = oldSize < option.Manipulations.Count; - if (deleteDefault && !Config.KeepDefaultMetaChanges) - changes |= ModMetaEditor.DeleteDefaultValues(metaFileManager, option.Manipulations); - return (changes, deleteList); } @@ -290,7 +283,7 @@ public partial class ModCreator( foreach (var (_, gamePath, file) in list) mod.Files.TryAdd(gamePath, file); - IncorporateMetaChanges(mod, baseFolder, true, true); + IncorporateMetaChanges(mod, baseFolder, true); return mod; } @@ -309,7 +302,7 @@ public partial class ModCreator( mod.Default.Files.TryAdd(gamePath, file); } - IncorporateMetaChanges(mod.Default, directory, true, true); + IncorporateMetaChanges(mod.Default, directory, true); saveService.ImmediateSaveSync(new ModSaveGroup(mod.ModPath, mod.Default, Config.ReplaceNonAsciiOnImport)); } @@ -447,9 +440,10 @@ public partial class ModCreator( var json = JObject.Parse(File.ReadAllText(file.FullName)); switch (json[nameof(Type)]?.ToObject() ?? GroupType.Single) { - case GroupType.Multi: return MultiModGroup.Load(mod, json); - case GroupType.Single: return SingleModGroup.Load(mod, json); - case GroupType.Imc: return ImcModGroup.Load(mod, json); + case GroupType.Multi: return MultiModGroup.Load(mod, json); + case GroupType.Single: return SingleModGroup.Load(mod, json); + case GroupType.Imc: return ImcModGroup.Load(mod, json); + case GroupType.Combining: return CombiningModGroup.Load(mod, json); } } catch (Exception e) diff --git a/Penumbra/Mods/ModLocalData.cs b/Penumbra/Mods/ModLocalData.cs index beda0dc7..cc20fad6 100644 --- a/Penumbra/Mods/ModLocalData.cs +++ b/Penumbra/Mods/ModLocalData.cs @@ -1,5 +1,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Penumbra.GameData.Structs; using Penumbra.Mods.Manager; using Penumbra.Services; @@ -21,12 +22,83 @@ public readonly struct ModLocalData(Mod mod) : ISavable { nameof(Mod.LocalTags), JToken.FromObject(mod.LocalTags) }, { nameof(Mod.Note), JToken.FromObject(mod.Note) }, { nameof(Mod.Favorite), JToken.FromObject(mod.Favorite) }, + { nameof(Mod.PreferredChangedItems), JToken.FromObject(mod.PreferredChangedItems) }, }; using var jWriter = new JsonTextWriter(writer); jWriter.Formatting = Formatting.Indented; jObject.WriteTo(jWriter); } + public static ModDataChangeType Load(ModDataEditor editor, Mod mod) + { + var dataFile = editor.SaveService.FileNames.LocalDataFile(mod); + + var importDate = 0L; + var localTags = Enumerable.Empty(); + var favorite = false; + var note = string.Empty; + + HashSet preferredChangedItems = []; + + var save = true; + if (File.Exists(dataFile)) + try + { + var text = File.ReadAllText(dataFile); + var json = JObject.Parse(text); + + importDate = json[nameof(Mod.ImportDate)]?.Value() ?? importDate; + favorite = json[nameof(Mod.Favorite)]?.Value() ?? favorite; + note = json[nameof(Mod.Note)]?.Value() ?? note; + localTags = (json[nameof(Mod.LocalTags)] as JArray)?.Values().OfType() ?? localTags; + preferredChangedItems = (json[nameof(Mod.PreferredChangedItems)] as JArray)?.Values().Select(i => (CustomItemId) i).ToHashSet() ?? mod.DefaultPreferredItems; + save = false; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not load local mod data:\n{e}"); + } + else + { + preferredChangedItems = mod.DefaultPreferredItems; + } + + if (importDate == 0) + importDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + ModDataChangeType changes = 0; + if (mod.ImportDate != importDate) + { + mod.ImportDate = importDate; + changes |= ModDataChangeType.ImportDate; + } + + changes |= UpdateTags(mod, null, localTags); + + if (mod.Favorite != favorite) + { + mod.Favorite = favorite; + changes |= ModDataChangeType.Favorite; + } + + if (mod.Note != note) + { + mod.Note = note; + changes |= ModDataChangeType.Note; + } + + if (!preferredChangedItems.SetEquals(mod.PreferredChangedItems)) + { + mod.PreferredChangedItems = preferredChangedItems; + changes |= ModDataChangeType.PreferredChangedItems; + } + + if (save) + editor.SaveService.QueueSave(new ModLocalData(mod)); + + return changes; + } + internal static ModDataChangeType UpdateTags(Mod mod, IEnumerable? newModTags, IEnumerable? newLocalTags) { if (newModTags == null && newLocalTags == null) diff --git a/Penumbra/Mods/ModMeta.cs b/Penumbra/Mods/ModMeta.cs index 39dd20e4..b52eecf4 100644 --- a/Penumbra/Mods/ModMeta.cs +++ b/Penumbra/Mods/ModMeta.cs @@ -1,5 +1,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Penumbra.GameData.Structs; +using Penumbra.Mods.Manager; using Penumbra.Services; namespace Penumbra.Mods; @@ -23,9 +25,121 @@ public readonly struct ModMeta(Mod mod) : ISavable { nameof(Mod.Version), JToken.FromObject(mod.Version) }, { nameof(Mod.Website), JToken.FromObject(mod.Website) }, { nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) }, + { nameof(Mod.DefaultPreferredItems), JToken.FromObject(mod.DefaultPreferredItems) }, }; + if (mod.RequiredFeatures is not FeatureFlags.None) + { + var features = mod.RequiredFeatures; + var array = new JArray(); + foreach (var flag in Enum.GetValues()) + { + if ((features & flag) is not FeatureFlags.None) + array.Add(flag.ToString()); + } + + jObject[nameof(Mod.RequiredFeatures)] = array; + } + using var jWriter = new JsonTextWriter(writer); jWriter.Formatting = Formatting.Indented; jObject.WriteTo(jWriter); } + + public static ModDataChangeType Load(ModDataEditor editor, ModCreator creator, Mod mod) + { + var metaFile = editor.SaveService.FileNames.ModMetaPath(mod); + if (!File.Exists(metaFile)) + { + Penumbra.Log.Debug($"No mod meta found for {mod.ModPath.Name}."); + return ModDataChangeType.Deletion; + } + + try + { + var text = File.ReadAllText(metaFile); + var json = JObject.Parse(text); + + var newFileVersion = json[nameof(FileVersion)]?.Value() ?? 0; + + // Empty name gets checked after loading and is not allowed. + var newName = json[nameof(Mod.Name)]?.Value() ?? string.Empty; + + var newAuthor = json[nameof(Mod.Author)]?.Value() ?? string.Empty; + var newDescription = json[nameof(Mod.Description)]?.Value() ?? string.Empty; + var newImage = json[nameof(Mod.Image)]?.Value() ?? string.Empty; + var newVersion = json[nameof(Mod.Version)]?.Value() ?? string.Empty; + var newWebsite = json[nameof(Mod.Website)]?.Value() ?? string.Empty; + var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values().OfType(); + var defaultItems = (json[nameof(Mod.DefaultPreferredItems)] as JArray)?.Values().Select(i => (CustomItemId)i).ToHashSet() + ?? []; + var requiredFeatureArray = (json[nameof(Mod.RequiredFeatures)] as JArray)?.Values() ?? []; + var requiredFeatures = FeatureChecker.ParseFlags(mod.ModPath.Name, newName.Length > 0 ? newName : mod.Name.Length > 0 ? mod.Name : "Unknown", requiredFeatureArray!); + + ModDataChangeType changes = 0; + if (mod.Name != newName) + { + changes |= ModDataChangeType.Name; + mod.Name = newName; + } + + if (mod.Author != newAuthor) + { + changes |= ModDataChangeType.Author; + mod.Author = newAuthor; + } + + if (mod.Description != newDescription) + { + changes |= ModDataChangeType.Description; + mod.Description = newDescription; + } + + if (mod.Image != newImage) + { + changes |= ModDataChangeType.Image; + mod.Image = newImage; + } + + if (mod.Version != newVersion) + { + changes |= ModDataChangeType.Version; + mod.Version = newVersion; + } + + if (mod.Website != newWebsite) + { + changes |= ModDataChangeType.Website; + mod.Website = newWebsite; + } + + if (!mod.DefaultPreferredItems.SetEquals(defaultItems)) + { + changes |= ModDataChangeType.DefaultChangedItems; + mod.DefaultPreferredItems = defaultItems; + } + + if (newFileVersion != FileVersion) + if (ModMigration.Migrate(creator, editor.SaveService, mod, json, ref newFileVersion)) + { + changes |= ModDataChangeType.Migration; + editor.SaveService.ImmediateSave(new ModMeta(mod)); + } + + // Required features get checked during parsing, in which case the new required features signal invalid. + if (requiredFeatures != mod.RequiredFeatures) + { + changes |= ModDataChangeType.RequiredFeatures; + mod.RequiredFeatures = requiredFeatures; + } + + changes |= ModLocalData.UpdateTags(mod, modTags, null); + + return changes; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not load mod meta for {metaFile}:\n{e}"); + return ModDataChangeType.Deletion; + } + } } diff --git a/Penumbra/Mods/ModSelection.cs b/Penumbra/Mods/ModSelection.cs index 73d0272b..b728bd00 100644 --- a/Penumbra/Mods/ModSelection.cs +++ b/Penumbra/Mods/ModSelection.cs @@ -36,10 +36,11 @@ public class ModSelection : EventWrapper _communicator.ModSettingChanged.Subscribe(OnSettingChange, ModSettingChanged.Priority.ModSelection); } - public ModSettings Settings { get; private set; } = ModSettings.Empty; - public ModCollection Collection { get; private set; } = ModCollection.Empty; - public Mod? Mod { get; private set; } - + public ModSettings Settings { get; private set; } = ModSettings.Empty; + public ModCollection Collection { get; private set; } = ModCollection.Empty; + public Mod? Mod { get; private set; } + public ModSettings? OwnSettings { get; private set; } + public TemporaryModSettings? TemporarySettings { get; private set; } public void SelectMod(Mod? mod) { @@ -83,12 +84,15 @@ public class ModSelection : EventWrapper { if (Mod == null) { - Settings = ModSettings.Empty; - Collection = ModCollection.Empty; + Settings = ModSettings.Empty; + Collection = ModCollection.Empty; + OwnSettings = null; } else { - (var settings, Collection) = _collections.Current[Mod.Index]; + (var settings, Collection) = _collections.Current.GetActualSettings(Mod.Index); + OwnSettings = _collections.Current.GetOwnSettings(Mod.Index); + TemporarySettings = _collections.Current.GetTempSettings(Mod.Index); Settings = settings ?? ModSettings.Empty; } } diff --git a/Penumbra/Mods/Settings/FullModSettings.cs b/Penumbra/Mods/Settings/FullModSettings.cs new file mode 100644 index 00000000..904b56bd --- /dev/null +++ b/Penumbra/Mods/Settings/FullModSettings.cs @@ -0,0 +1,19 @@ +namespace Penumbra.Mods.Settings; + +public readonly record struct FullModSettings(ModSettings? Settings = null, TemporaryModSettings? TempSettings = null) +{ + public static readonly FullModSettings Empty = new(); + + public ModSettings? Resolve() + { + if (TempSettings == null) + return Settings; + if (TempSettings.ForceInherit) + return null; + + return TempSettings; + } + + public FullModSettings DeepCopy() + => new(Settings?.DeepCopy()); +} diff --git a/Penumbra/Mods/Settings/ModSettings.cs b/Penumbra/Mods/Settings/ModSettings.cs index 25e4805d..bbdd6bfa 100644 --- a/Penumbra/Mods/Settings/ModSettings.cs +++ b/Penumbra/Mods/Settings/ModSettings.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using OtterGui.Filesystem; using Penumbra.Api.Enums; using Penumbra.Mods.Editor; @@ -11,10 +11,18 @@ namespace Penumbra.Mods.Settings; /// Contains the settings for a given mod. public class ModSettings { - public static readonly ModSettings Empty = new(); - public SettingList Settings { get; private init; } = []; - public ModPriority Priority { get; set; } - public bool Enabled { get; set; } + public static readonly ModSettings Empty = new(true); + + public SettingList Settings { get; internal init; } = []; + public ModPriority Priority { get; set; } + public bool Enabled { get; set; } + public bool IsEmpty { get; protected init; } + + public ModSettings() + { } + + protected ModSettings(bool empty) + => IsEmpty = empty; // Create an independent copy of the current settings. public ModSettings DeepCopy() diff --git a/Penumbra/Mods/Settings/TemporaryModSettings.cs b/Penumbra/Mods/Settings/TemporaryModSettings.cs new file mode 100644 index 00000000..d3e36ef6 --- /dev/null +++ b/Penumbra/Mods/Settings/TemporaryModSettings.cs @@ -0,0 +1,54 @@ +namespace Penumbra.Mods.Settings; + +public sealed class TemporaryModSettings : ModSettings +{ + public new static readonly TemporaryModSettings Empty = new(true); + + public const string OwnSource = "yourself"; + public string Source = string.Empty; + public int Lock; + public bool ForceInherit; + + // Create default settings for a given mod. + public static TemporaryModSettings DefaultSettings(Mod mod, string source, bool enabled = false, int key = 0) + => new() + { + Enabled = enabled, + Source = source, + Lock = key, + Priority = ModPriority.Default, + Settings = SettingList.Default(mod), + }; + + public TemporaryModSettings() + { } + + private TemporaryModSettings(bool empty) + : base(empty) + { } + + public TemporaryModSettings(Mod mod, ModSettings? clone, string source = OwnSource, int key = 0) + { + Source = source; + Lock = key; + ForceInherit = clone == null; + if (clone is { IsEmpty: false }) + { + Enabled = clone.Enabled; + Priority = clone.Priority; + Settings = clone.Settings.Clone(); + } + else + { + Enabled = false; + Priority = ModPriority.Default; + Settings = SettingList.Default(mod); + } + } +} + +public static class ModSettingsExtensions +{ + public static bool IsTemporary(this ModSettings? settings) + => settings is TemporaryModSettings; +} diff --git a/Penumbra/Mods/SubMods/CombinedDataContainer.cs b/Penumbra/Mods/SubMods/CombinedDataContainer.cs new file mode 100644 index 00000000..bfca2afd --- /dev/null +++ b/Penumbra/Mods/SubMods/CombinedDataContainer.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public class CombinedDataContainer(IModGroup group) : IModDataContainer +{ + public IMod Mod + => Group.Mod; + + public IModGroup Group { get; } = group; + + public string Name { get; set; } = string.Empty; + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + public void AddDataTo(Dictionary redirections, MetaDictionary manipulations) + => SubMod.AddContainerTo(this, redirections, manipulations); + + public string GetName() + { + if (Name.Length > 0) + return Name; + + var index = GetDataIndex(); + if (index == 0) + return "None"; + + var sb = new StringBuilder(128); + for (var i = 0; i < IModGroup.MaxCombiningOptions; ++i) + { + if ((index & 1) != 0) + { + sb.Append(Group.Options[i].Name); + sb.Append(' ').Append('+').Append(' '); + } + + index >>= 1; + if (index == 0) + break; + } + + return sb.ToString(0, sb.Length - 3); + } + + public unsafe string GetDirectoryName() + { + if (Name.Length > 0) + return Name; + + var index = GetDataIndex(); + if (index == 0) + return "None"; + + var text = stackalloc char[IModGroup.MaxCombiningOptions].Slice(0, Group.Options.Count); + for (var i = 0; i < Group.Options.Count; ++i) + { + text[Group.Options.Count - 1 - i] = (index & 1) is 0 ? '0' : '1'; + index >>= 1; + } + + return new string(text); + } + + public string GetFullName() + => $"{Group.Name}: {GetName()}"; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (Group.GetIndex(), GetDataIndex()); + + private int GetDataIndex() + { + var dataIndex = Group.DataContainers.IndexOf(this); + if (dataIndex < 0) + throw new Exception($"Group {Group.Name} from SubMod {Name} does not contain this SubMod."); + + return dataIndex; + } + + public CombinedDataContainer(CombiningModGroup group, JToken token) + : this(group) + { + SubMod.LoadDataContainer(token, this, group.Mod.ModPath); + Name = token["Name"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/CombiningSubMod.cs b/Penumbra/Mods/SubMods/CombiningSubMod.cs new file mode 100644 index 00000000..6eb5de9d --- /dev/null +++ b/Penumbra/Mods/SubMods/CombiningSubMod.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json.Linq; +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public class CombiningSubMod(IModGroup group) : IModOption +{ + public IModGroup Group { get; } = group; + + public Mod Mod + => Group.Mod; + + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + + public string FullName + => $"{Group.Name}: {Name}"; + + public int GetIndex() + => SubMod.GetIndex(this); + + public CombiningSubMod(CombiningModGroup group, JToken json) + : this(group) + => SubMod.LoadOptionData(json, this); +} diff --git a/Penumbra/Mods/SubMods/ComplexDataContainer.cs b/Penumbra/Mods/SubMods/ComplexDataContainer.cs new file mode 100644 index 00000000..0f0fdef8 --- /dev/null +++ b/Penumbra/Mods/SubMods/ComplexDataContainer.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public sealed class ComplexDataContainer(ComplexModGroup group) : IModDataContainer +{ + public IMod Mod + => Group.Mod; + + public IModGroup Group { get; } = group; + + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + public MaskedSetting Association = MaskedSetting.Zero; + + public string Name { get; set; } = string.Empty; + + public string GetName() + => Name.Length > 0 ? Name : $"Container {Group.DataContainers.IndexOf(this)}"; + + public string GetDirectoryName() + => Name.Length > 0 ? Name : $"{Group.DataContainers.IndexOf(this)}"; + + public string GetFullName() + => $"{Group.Name}: {GetName()}"; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (Group.GetIndex(), Group.DataContainers.IndexOf(this)); + + public ComplexDataContainer(ComplexModGroup group, JToken json) + : this(group) + { + SubMod.LoadDataContainer(json, this, group.Mod.ModPath); + var mask = json["AssociationMask"]?.ToObject() ?? 0; + var value = json["AssociationMask"]?.ToObject() ?? 0; + Association = new MaskedSetting(mask, value); + Name = json["Name"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/ComplexSubMod.cs b/Penumbra/Mods/SubMods/ComplexSubMod.cs new file mode 100644 index 00000000..7c189170 --- /dev/null +++ b/Penumbra/Mods/SubMods/ComplexSubMod.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public sealed class ComplexSubMod(ComplexModGroup group) : IModOption +{ + public Mod Mod + => group.Mod; + + public IModGroup Group { get; } = group; + public string Name { get; set; } = "Option"; + + public string FullName + => $"{Group.Name}: {Name}"; + + public MaskedSetting Conditions = MaskedSetting.Zero; + public int Indentation = 0; + public string SubGroupLabel = string.Empty; + + public string Description { get; set; } = string.Empty; + + public int GetIndex() + => Group.Options.IndexOf(this); + + public ComplexSubMod(ComplexModGroup group, JToken json) + : this(group) + { + SubMod.LoadOptionData(json, this); + var mask = json["ConditionMask"]?.ToObject() ?? 0; + var value = json["ConditionMask"]?.ToObject() ?? 0; + Conditions = new MaskedSetting(mask, value); + Indentation = json["Indentation"]?.ToObject() ?? 0; + SubGroupLabel = json["SubGroup"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/DefaultSubMod.cs b/Penumbra/Mods/SubMods/DefaultSubMod.cs index 3840468f..3282f518 100644 --- a/Penumbra/Mods/SubMods/DefaultSubMod.cs +++ b/Penumbra/Mods/SubMods/DefaultSubMod.cs @@ -27,6 +27,9 @@ public class DefaultSubMod(IMod mod) : IModDataContainer public string GetName() => FullName; + public string GetDirectoryName() + => GetName(); + public string GetFullName() => FullName; diff --git a/Penumbra/Mods/SubMods/IModDataContainer.cs b/Penumbra/Mods/SubMods/IModDataContainer.cs index 1a89ec17..92ccf7e1 100644 --- a/Penumbra/Mods/SubMods/IModDataContainer.cs +++ b/Penumbra/Mods/SubMods/IModDataContainer.cs @@ -15,6 +15,7 @@ public interface IModDataContainer public MetaDictionary Manipulations { get; set; } public string GetName(); + public string GetDirectoryName(); public string GetFullName(); public (int GroupIndex, int DataIndex) GetDataIndices(); } diff --git a/Penumbra/Mods/SubMods/MaskedSetting.cs b/Penumbra/Mods/SubMods/MaskedSetting.cs new file mode 100644 index 00000000..75bb46c2 --- /dev/null +++ b/Penumbra/Mods/SubMods/MaskedSetting.cs @@ -0,0 +1,27 @@ +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; + +namespace Penumbra.Mods.SubMods; + +public readonly struct MaskedSetting(Setting mask, Setting value) +{ + public const int MaxSettings = IModGroup.MaxMultiOptions; + public static readonly MaskedSetting Zero = new(Setting.Zero, Setting.Zero); + public static readonly MaskedSetting FullMask = new(Setting.AllBits(IModGroup.MaxComplexOptions), Setting.Zero); + + public readonly Setting Mask = mask; + public readonly Setting Value = new(value.Value & mask.Value); + + public MaskedSetting(ulong mask, ulong value) + : this(new Setting(mask), new Setting(value)) + { } + + public MaskedSetting Limit(int numOptions) + => new(Mask.Value & Setting.AllBits(numOptions).Value, Value.Value); + + public bool IsZero + => Mask.Value is 0; + + public bool IsEnabled(Setting input) + => (input.Value & Mask.Value) == Value.Value; +} diff --git a/Penumbra/Mods/SubMods/OptionSubMod.cs b/Penumbra/Mods/SubMods/OptionSubMod.cs index 8fac52d8..aa3fed8f 100644 --- a/Penumbra/Mods/SubMods/OptionSubMod.cs +++ b/Penumbra/Mods/SubMods/OptionSubMod.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using Penumbra.Meta.Manipulations; using Penumbra.Mods.Editor; using Penumbra.Mods.Groups; @@ -41,6 +41,9 @@ public abstract class OptionSubMod(IModGroup group) : IModOption, IModDataContai public string GetName() => Name; + public string GetDirectoryName() + => GetName(); + public string GetFullName() => FullName; diff --git a/Penumbra/Mods/SubMods/SubMod.cs b/Penumbra/Mods/SubMods/SubMod.cs index f6b1be96..a7a2ee61 100644 --- a/Penumbra/Mods/SubMods/SubMod.cs +++ b/Penumbra/Mods/SubMods/SubMod.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using OtterGui; +using OtterGui.Extensions; using Penumbra.Meta.Manipulations; using Penumbra.String.Classes; @@ -81,29 +81,41 @@ public static class SubMod [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] public static void WriteModContainer(JsonWriter j, JsonSerializer serializer, IModDataContainer data, DirectoryInfo basePath) { - j.WritePropertyName(nameof(data.Files)); - j.WriteStartObject(); - foreach (var (gamePath, file) in data.Files) - { - if (file.ToRelPath(basePath, out var relPath)) + // #TODO: remove comments when TexTools updated. + //if (data.Files.Count > 0) + //{ + j.WritePropertyName(nameof(data.Files)); + j.WriteStartObject(); + foreach (var (gamePath, file) in data.Files) + { + if (file.ToRelPath(basePath, out var relPath)) + { + j.WritePropertyName(gamePath.ToString()); + j.WriteValue(relPath.ToString()); + } + } + + j.WriteEndObject(); + //} + + //if (data.FileSwaps.Count > 0) + //{ + j.WritePropertyName(nameof(data.FileSwaps)); + j.WriteStartObject(); + foreach (var (gamePath, file) in data.FileSwaps) { j.WritePropertyName(gamePath.ToString()); - j.WriteValue(relPath.ToString()); + j.WriteValue(file.ToString()); } - } - j.WriteEndObject(); - j.WritePropertyName(nameof(data.FileSwaps)); - j.WriteStartObject(); - foreach (var (gamePath, file) in data.FileSwaps) - { - j.WritePropertyName(gamePath.ToString()); - j.WriteValue(file.ToString()); - } + j.WriteEndObject(); + //} - j.WriteEndObject(); - j.WritePropertyName(nameof(data.Manipulations)); - serializer.Serialize(j, data.Manipulations); + //if (data.Manipulations.Count > 0) + //{ + j.WritePropertyName(nameof(data.Manipulations)); + serializer.Serialize(j, data.Manipulations); + //} } /// Write the data for a selectable mod option on a JsonWriter. diff --git a/Penumbra/Mods/TemporaryMod.cs b/Penumbra/Mods/TemporaryMod.cs index b5499624..8fdd09c5 100644 --- a/Penumbra/Mods/TemporaryMod.cs +++ b/Penumbra/Mods/TemporaryMod.cs @@ -63,10 +63,10 @@ public class TemporaryMod : IMod DirectoryInfo? dir = null; try { - dir = ModCreator.CreateModFolder(modManager.BasePath, collection.Name, config.ReplaceNonAsciiOnImport, true); + dir = ModCreator.CreateModFolder(modManager.BasePath, collection.Identity.Name, config.ReplaceNonAsciiOnImport, true); var fileDir = Directory.CreateDirectory(Path.Combine(dir.FullName, "files")); - modManager.DataEditor.CreateMeta(dir, collection.Name, character ?? config.DefaultModAuthor, - $"Mod generated from temporary collection {collection.Id} for {character ?? "Unknown Character"} with name {collection.Name}.", + modManager.DataEditor.CreateMeta(dir, collection.Identity.Name, character ?? config.DefaultModAuthor, + $"Mod generated from temporary collection {collection.Identity.Id} for {character ?? "Unknown Character"} with name {collection.Identity.Name}.", null, null); var mod = new Mod(dir); var defaultMod = mod.Default; @@ -99,11 +99,11 @@ public class TemporaryMod : IMod saveService.ImmediateSaveSync(new ModSaveGroup(dir, defaultMod, config.ReplaceNonAsciiOnImport)); modManager.AddMod(dir, false); Penumbra.Log.Information( - $"Successfully generated mod {mod.Name} at {mod.ModPath.FullName} for collection {collection.Identifier}."); + $"Successfully generated mod {mod.Name} at {mod.ModPath.FullName} for collection {collection.Identity.Identifier}."); } catch (Exception e) { - Penumbra.Log.Error($"Could not save temporary collection {collection.Identifier} to permanent Mod:\n{e}"); + Penumbra.Log.Error($"Could not save temporary collection {collection.Identity.Identifier} to permanent Mod:\n{e}"); if (dir != null && Directory.Exists(dir.FullName)) { try diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index 534911df..d433a0fb 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -1,5 +1,6 @@ using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; +using Dalamud.Game; using OtterGui; using OtterGui.Log; using OtterGui.Services; @@ -21,8 +22,9 @@ using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManage using Dalamud.Plugin.Services; using Lumina.Excel.Sheets; using Penumbra.GameData.Data; -using Penumbra.GameData.Files; +using Penumbra.Interop; using Penumbra.Interop.Hooks; +using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.ResourceLoading; namespace Penumbra; @@ -34,6 +36,7 @@ public class Penumbra : IDalamudPlugin public static readonly Logger Log = new(); public static MessageService Messager { get; private set; } = null!; + public static DynamisIpc Dynamis { get; private set; } = null!; private readonly ValidityChecker _validityChecker; private readonly ResidentResourceManager _residentResources; @@ -57,8 +60,11 @@ public class Penumbra : IDalamudPlugin { HookOverrides.Instance = HookOverrides.LoadFile(pluginInterface); _services = StaticServiceManager.CreateProvider(this, pluginInterface, Log); - Messager = _services.GetService(); - _validityChecker = _services.GetService(); + // Invoke the IPC Penumbra.Launching method before any hooks or other services are created. + _services.GetService(); + Messager = _services.GetService(); + Dynamis = _services.GetService(); + _validityChecker = _services.GetService(); _services.EnsureRequiredServices(); var startup = _services.GetService() @@ -187,7 +193,7 @@ public class Penumbra : IDalamudPlugin ReadOnlySpan relevantPlugins = [ "Glamourer", "MareSynchronos", "CustomizePlus", "SimpleHeels", "VfxEditor", "heliosphere-plugin", "Ktisis", "Brio", "DynamicBridge", - "IllusioVitae", "Aetherment", "LoporritSync", "GagSpeak", "RoleplayingVoiceDalamud", + "IllusioVitae", "Aetherment", "LoporritSync", "GagSpeak", "ProjectGagSpeak", "RoleplayingVoiceDalamud", "AQuestReborn", ]; var plugins = _services.GetService().InstalledPlugins .GroupBy(p => p.InternalName) @@ -205,9 +211,11 @@ public class Penumbra : IDalamudPlugin public string GatherSupportInformation() { - var sb = new StringBuilder(10240); - var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); - var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null; + var sb = new StringBuilder(10240); + var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); + var cloudSynced = exists && CloudApi.IsCloudSynced(_config.ModDirectory); + var hdrEnabler = _services.GetService(); + var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null; sb.AppendLine("**Settings**"); sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n"); sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n"); @@ -216,16 +224,21 @@ public class Penumbra : IDalamudPlugin sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n"); if (Dalamud.Utility.Util.IsWine()) sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n"); - sb.Append($"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}\n"); + sb.Append( + $"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}{(cloudSynced ? ", Cloud-Synced" : "")}\n"); sb.Append( $"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n"); sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n"); sb.Append($"> **`Auto-Deduplication: `** {_config.AutoDeduplicateOnImport}\n"); sb.Append($"> **`Auto-UI-Reduplication: `** {_config.AutoReduplicateUiOnImport}\n"); sb.Append($"> **`Debug Mode: `** {_config.DebugMode}\n"); + sb.Append($"> **`Penumbra Reloads: `** {hdrEnabler.PenumbraReloadCount}\n"); + sb.Append( + $"> **`HDR Enabled (from Start): `** {_config.HdrRenderTargets} ({hdrEnabler is { FirstLaunchHdrState: true, FirstLaunchHdrHookOverrideState: true }}){(hdrEnabler.HdrEnabledSuccess ? ", Detour Called" : ", **NEVER CALLED**")}\n"); + sb.Append($"> **`Custom Shapes Enabled: `** {_config.EnableCustomShapes}\n"); sb.Append($"> **`Hook Overrides: `** {HookOverrides.Instance.IsCustomLoaded}\n"); sb.Append( - $"> **`Synchronous Load (Dalamud): `** {(_services.GetService().GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool v) ? v.ToString() : "Unknown")}\n"); + $"> **`Synchronous Load (Dalamud): `** {(_services.GetService().GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool v) ? v.ToString() : "Unknown")} (first Start: {hdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"})\n"); sb.Append( $"> **`Logging: `** Log: {_config.Ephemeral.EnableResourceLogging}, Watcher: {_config.Ephemeral.EnableResourceWatcher} ({_config.MaxResourceWatcherRecords})\n"); sb.Append($"> **`Use Ownership: `** {_config.UseOwnerNameForCharacterCollection}\n"); @@ -245,24 +258,24 @@ public class Penumbra : IDalamudPlugin void PrintCollection(ModCollection c, CollectionCache _) => sb.Append( - $"> **`Collection {c.AnonymizedName + ':',-18}`** Inheritances: `{c.DirectlyInheritsFrom.Count,3}`, Enabled Mods: `{c.ActualSettings.Count(s => s is { Enabled: true }),4}`, Conflicts: `{c.AllConflicts.SelectMany(x => x).Sum(x => x is { HasPriority: true, Solved: true } ? x.Conflicts.Count : 0),5}/{c.AllConflicts.SelectMany(x => x).Sum(x => x.HasPriority ? x.Conflicts.Count : 0),5}`\n"); + $"> **`Collection {c.Identity.AnonymizedName + ':',-18}`** Inheritances: `{c.Inheritance.DirectlyInheritsFrom.Count,3}`, Enabled Mods: `{c.ActualSettings.Count(s => s is { Enabled: true }),4}`, Conflicts: `{c.AllConflicts.SelectMany(x => x).Sum(x => x is { HasPriority: true, Solved: true } ? x.Conflicts.Count : 0),5}/{c.AllConflicts.SelectMany(x => x).Sum(x => x.HasPriority ? x.Conflicts.Count : 0),5}`\n"); sb.AppendLine("**Collections**"); sb.Append($"> **`#Collections: `** {_collectionManager.Storage.Count - 1}\n"); sb.Append($"> **`#Temp Collections: `** {_tempCollections.Count}\n"); sb.Append($"> **`Active Collections: `** {_collectionManager.Caches.Count}\n"); - sb.Append($"> **`Base Collection: `** {_collectionManager.Active.Default.AnonymizedName}\n"); - sb.Append($"> **`Interface Collection: `** {_collectionManager.Active.Interface.AnonymizedName}\n"); - sb.Append($"> **`Selected Collection: `** {_collectionManager.Active.Current.AnonymizedName}\n"); + sb.Append($"> **`Base Collection: `** {_collectionManager.Active.Default.Identity.AnonymizedName}\n"); + sb.Append($"> **`Interface Collection: `** {_collectionManager.Active.Interface.Identity.AnonymizedName}\n"); + sb.Append($"> **`Selected Collection: `** {_collectionManager.Active.Current.Identity.AnonymizedName}\n"); foreach (var (type, name, _) in CollectionTypeExtensions.Special) { var collection = _collectionManager.Active.ByType(type); if (collection != null) - sb.Append($"> **`{name,-29}`** {collection.AnonymizedName}\n"); + sb.Append($"> **`{name,-29}`** {collection.Identity.AnonymizedName}\n"); } foreach (var (name, id, collection) in _collectionManager.Active.Individuals.Assignments) - sb.Append($"> **`{id[0].Incognito(name) + ':',-29}`** {collection.AnonymizedName}\n"); + sb.Append($"> **`{id[0].Incognito(name) + ':',-29}`** {collection.Identity.AnonymizedName}\n"); foreach (var collection in _collectionManager.Caches.Active) PrintCollection(collection, collection._cache!); diff --git a/Penumbra/Penumbra.csproj b/Penumbra/Penumbra.csproj index 9b613729..fa45ffbf 100644 --- a/Penumbra/Penumbra.csproj +++ b/Penumbra/Penumbra.csproj @@ -1,26 +1,17 @@ - + - net8.0-windows - preview - x64 Penumbra absolute gangstas Penumbra - Copyright © 2022 + Copyright © 2025 9.0.0.1 9.0.0.1 bin\$(Configuration)\ - true - enable - true - false - false - true - $(MSBuildWarningsAsMessages);MSB3277 PROFILING; + false @@ -33,41 +24,16 @@ PreserveNewest + + PreserveNewest + DirectXTexC.dll + + + PreserveNewest + - - $(AppData)\XIVLauncher\addon\Hooks\dev\ - - - - $(DalamudLibPath)Dalamud.dll - False - - - $(DalamudLibPath)ImGui.NET.dll - False - - - $(DalamudLibPath)ImGuiScene.dll - False - - - $(DalamudLibPath)Lumina.dll - False - - - $(DalamudLibPath)Lumina.Excel.dll - False - - - $(DalamudLibPath)FFXIVClientStructs.dll - False - - - $(DalamudLibPath)Newtonsoft.Json.dll - False - $(DalamudLibPath)Iced.dll False @@ -80,20 +46,22 @@ $(DalamudLibPath)SharpDX.Direct3D11.dll False + + $(DalamudLibPath)SharpDX.DXGI.dll + False + lib\OtterTex.dll - - - - - - - + + + + + @@ -104,16 +72,6 @@ - - - PreserveNewest - DirectXTexC.dll - - - PreserveNewest - - - diff --git a/Penumbra/Penumbra.json b/Penumbra/Penumbra.json index 4790da18..32032282 100644 --- a/Penumbra/Penumbra.json +++ b/Penumbra/Penumbra.json @@ -1,5 +1,5 @@ { - "Author": "Ottermandias, Adam, Wintermute", + "Author": "Ottermandias, Nylfae, Adam, Wintermute", "Name": "Penumbra", "Punchline": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.", @@ -8,9 +8,9 @@ "RepoUrl": "https://github.com/xivdev/Penumbra", "ApplicableVersion": "any", "Tags": [ "modding" ], - "DalamudApiLevel": 11, + "DalamudApiLevel": 13, "LoadPriority": 69420, - "LoadState": 2, + "LoadRequiredState": 2, "LoadSync": true, "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" } diff --git a/Penumbra/Services/CleanupService.cs b/Penumbra/Services/CleanupService.cs new file mode 100644 index 00000000..bf76f5f0 --- /dev/null +++ b/Penumbra/Services/CleanupService.cs @@ -0,0 +1,165 @@ +using OtterGui.Services; +using Penumbra.Collections.Manager; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class CleanupService(SaveService saveService, ModManager mods, CollectionManager collections) : IService +{ + private CancellationTokenSource _cancel = new(); + private Task? _task; + + public double Progress { get; private set; } + + public bool IsRunning + => _task is { IsCompleted: false }; + + public void Cancel() + => _cancel.Cancel(); + + public void CleanUnusedLocalData() + { + if (IsRunning) + return; + + var usedFiles = mods.Select(saveService.FileNames.LocalDataFile).ToHashSet(); + Progress = 0; + var deleted = 0; + _cancel = new CancellationTokenSource(); + _task = Task.Run(() => + { + var localFiles = saveService.FileNames.LocalDataFiles.ToList(); + var step = 0.9 / localFiles.Count; + Progress = 0.1; + foreach (var file in localFiles) + { + if (_cancel.IsCancellationRequested) + break; + + try + { + if (!file.Exists || usedFiles.Contains(file.FullName)) + continue; + + file.Delete(); + Penumbra.Log.Debug($"[CleanupService] Deleted unused local data file {file.Name}."); + ++deleted; + } + catch (Exception ex) + { + Penumbra.Log.Error($"[CleanupService] Failed to delete unused local data file {file.Name}:\n{ex}"); + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Deleted {deleted} unused local data files."); + Progress = 1; + }); + } + + public void CleanBackupFiles() + { + if (IsRunning) + return; + + Progress = 0; + var deleted = 0; + _cancel = new CancellationTokenSource(); + _task = Task.Run(() => + { + var configFiles = Directory.EnumerateFiles(saveService.FileNames.ConfigDirectory, "*.json.bak", SearchOption.AllDirectories) + .ToList(); + Progress = 0.1; + if (_cancel.IsCancellationRequested) + return; + + var groupFiles = mods.BasePath.EnumerateFiles("group_*.json.bak", SearchOption.AllDirectories).ToList(); + Progress = 0.5; + var step = 0.4 / (groupFiles.Count + configFiles.Count); + foreach (var file in groupFiles) + { + if (_cancel.IsCancellationRequested) + break; + + try + { + if (!file.Exists) + continue; + + file.Delete(); + ++deleted; + Penumbra.Log.Debug($"[CleanupService] Deleted group backup file {file.FullName}."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"[CleanupService] Failed to delete group backup file {file.FullName}:\n{ex}"); + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Deleted {deleted} group backup files."); + + deleted = 0; + foreach (var file in configFiles) + { + if (_cancel.IsCancellationRequested) + break; + + try + { + if (!File.Exists(file)) + continue; + + File.Delete(file); + ++deleted; + Penumbra.Log.Debug($"[CleanupService] Deleted config backup file {file}."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"[CleanupService] Failed to delete config backup file {file}:\n{ex}"); + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Deleted {deleted} config backup files."); + Progress = 1; + }); + } + + public void CleanupAllUnusedSettings() + { + if (IsRunning) + return; + + Progress = 0; + var totalRemoved = 0; + var diffCollections = 0; + _cancel = new CancellationTokenSource(); + _task = Task.Run(() => + { + var step = 1.0 / collections.Storage.Count; + foreach (var collection in collections.Storage) + { + if (_cancel.IsCancellationRequested) + break; + + var count = collections.Storage.CleanUnavailableSettings(collection); + if (count > 0) + { + Penumbra.Log.Debug( + $"[CleanupService] Removed {count} unused settings from collection {collection.Identity.AnonymizedName}."); + totalRemoved += count; + ++diffCollections; + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Removed {totalRemoved} unused settings from {diffCollections} separate collections."); + Progress = 1; + }); + } +} diff --git a/Penumbra/Services/CommunicatorService.cs b/Penumbra/Services/CommunicatorService.cs index 5d745419..35f15e9e 100644 --- a/Penumbra/Services/CommunicatorService.cs +++ b/Penumbra/Services/CommunicatorService.cs @@ -81,6 +81,12 @@ public class CommunicatorService : IDisposable, IService /// public readonly ResolvedFileChanged ResolvedFileChanged = new(); + /// + public readonly PcpCreation PcpCreation = new(); + + /// + public readonly PcpParsing PcpParsing = new(); + public void Dispose() { CollectionChange.Dispose(); @@ -105,5 +111,7 @@ public class CommunicatorService : IDisposable, IService ChangedItemClick.Dispose(); SelectTab.Dispose(); ResolvedFileChanged.Dispose(); + PcpCreation.Dispose(); + PcpParsing.Dispose(); } } diff --git a/Penumbra/Services/ConfigMigrationService.cs b/Penumbra/Services/ConfigMigrationService.cs index 5ba57cf4..9fe8c420 100644 --- a/Penumbra/Services/ConfigMigrationService.cs +++ b/Penumbra/Services/ConfigMigrationService.cs @@ -27,8 +27,8 @@ public class ConfigMigrationService(SaveService saveService, BackupService backu private Configuration _config = null!; private JObject _data = null!; - public string CurrentCollection = ModCollection.DefaultCollectionName; - public string DefaultCollection = ModCollection.DefaultCollectionName; + public string CurrentCollection = ModCollectionIdentity.DefaultCollectionName; + public string DefaultCollection = ModCollectionIdentity.DefaultCollectionName; public string ForcedCollection = string.Empty; public Dictionary CharacterCollections = []; public Dictionary ModSortOrder = []; @@ -240,7 +240,7 @@ public class ConfigMigrationService(SaveService saveService, BackupService backu if (jObject["Name"]?.ToObject() == ForcedCollection) continue; - jObject[nameof(ModCollection.DirectlyInheritsFrom)] = JToken.FromObject(new List { ForcedCollection }); + jObject[nameof(ModCollectionInheritance.DirectlyInheritsFrom)] = JToken.FromObject(new List { ForcedCollection }); File.WriteAllText(collection.FullName, jObject.ToString()); } catch (Exception e) @@ -346,7 +346,7 @@ public class ConfigMigrationService(SaveService saveService, BackupService backu if (!collectionJson.Exists) return; - var defaultCollectionFile = new FileInfo(saveService.FileNames.CollectionFile(ModCollection.DefaultCollectionName)); + var defaultCollectionFile = new FileInfo(saveService.FileNames.CollectionFile(ModCollectionIdentity.DefaultCollectionName)); if (defaultCollectionFile.Exists) return; @@ -380,7 +380,7 @@ public class ConfigMigrationService(SaveService saveService, BackupService backu var emptyStorage = new ModStorage(); // Only used for saving and immediately discarded, so the local collection id here is irrelevant. - var collection = ModCollection.CreateFromData(saveService, emptyStorage, Guid.NewGuid(), ModCollection.DefaultCollectionName, LocalCollectionId.Zero, 0, 1, dict, []); + var collection = ModCollection.CreateFromData(saveService, emptyStorage, ModCollectionIdentity.New(ModCollectionIdentity.DefaultCollectionName, LocalCollectionId.Zero, 1), 0, dict, []); saveService.ImmediateSaveSync(new ModCollectionSave(emptyStorage, collection)); } catch (Exception e) diff --git a/Penumbra/Services/CrashHandlerService.cs b/Penumbra/Services/CrashHandlerService.cs index 9103b29c..4814795c 100644 --- a/Penumbra/Services/CrashHandlerService.cs +++ b/Penumbra/Services/CrashHandlerService.cs @@ -240,7 +240,7 @@ public sealed class CrashHandlerService : IDisposable, IService var name = GetActorName(character); lock (_eventWriter) { - _eventWriter?.AnimationFuncInvoked.WriteLine(character, name.Span, collection.Id, type); + _eventWriter?.AnimationFuncInvoked.WriteLine(character, name.Span, collection.Identity.Id, type); } } catch (Exception ex) @@ -293,7 +293,7 @@ public sealed class CrashHandlerService : IDisposable, IService var name = GetActorName(resolveData.AssociatedGameObject); lock (_eventWriter) { - _eventWriter!.FileLoaded.WriteLine(resolveData.AssociatedGameObject, name.Span, resolveData.ModCollection.Id, + _eventWriter!.FileLoaded.WriteLine(resolveData.AssociatedGameObject, name.Span, resolveData.ModCollection.Identity.Id, manipulatedPath.Value.InternalName.Span, originalPath.Path.Span); } } diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs new file mode 100644 index 00000000..1d572f05 --- /dev/null +++ b/Penumbra/Services/FileWatcher.cs @@ -0,0 +1,209 @@ +using OtterGui.Services; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class FileWatcher : IDisposable, IService +{ + // TODO: use ConcurrentSet when it supports comparers in Luna. + private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase); + private readonly ModImportManager _modImportManager; + private readonly MessageService _messageService; + private readonly Configuration _config; + + private bool _pausedConsumer; + private FileSystemWatcher? _fsw; + private CancellationTokenSource? _cts = new(); + private Task? _consumer; + + public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) + { + _modImportManager = modImportManager; + _messageService = messageService; + _config = config; + + if (_config.EnableDirectoryWatch) + { + SetupFileWatcher(_config.WatchDirectory); + SetupConsumerTask(); + } + } + + public void Toggle(bool value) + { + if (_config.EnableDirectoryWatch == value) + return; + + _config.EnableDirectoryWatch = value; + _config.Save(); + if (value) + { + SetupFileWatcher(_config.WatchDirectory); + SetupConsumerTask(); + } + else + { + EndFileWatcher(); + EndConsumerTask(); + } + } + + internal void PauseConsumer(bool pause) + => _pausedConsumer = pause; + + private void EndFileWatcher() + { + if (_fsw is null) + return; + + _fsw.Dispose(); + _fsw = null; + } + + private void SetupFileWatcher(string directory) + { + EndFileWatcher(); + _fsw = new FileSystemWatcher + { + IncludeSubdirectories = false, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, + InternalBufferSize = 32 * 1024, + }; + + // Only wake us for the exact patterns we care about + _fsw.Filters.Add("*.pmp"); + _fsw.Filters.Add("*.pcp"); + _fsw.Filters.Add("*.ttmp"); + _fsw.Filters.Add("*.ttmp2"); + + _fsw.Created += OnPath; + _fsw.Renamed += OnPath; + UpdateDirectory(directory); + } + + + private void EndConsumerTask() + { + if (_cts is not null) + { + _cts.Cancel(); + _cts = null; + } + _consumer = null; + } + + private void SetupConsumerTask() + { + EndConsumerTask(); + _cts = new CancellationTokenSource(); + _consumer = Task.Factory.StartNew( + () => ConsumerLoopAsync(_cts.Token), + _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } + + public void UpdateDirectory(string newPath) + { + if (_config.WatchDirectory != newPath) + { + _config.WatchDirectory = newPath; + _config.Save(); + } + + if (_fsw is null) + return; + + _fsw.EnableRaisingEvents = false; + if (!Directory.Exists(newPath) || newPath.Length is 0) + { + _fsw.Path = string.Empty; + } + else + { + _fsw.Path = newPath; + _fsw.EnableRaisingEvents = true; + } + } + + private void OnPath(object? sender, FileSystemEventArgs e) + => _pending.TryAdd(e.FullPath, 0); + + private async Task ConsumerLoopAsync(CancellationToken token) + { + while (true) + { + var (path, _) = _pending.FirstOrDefault(); + if (path is null || _pausedConsumer) + { + await Task.Delay(500, token).ConfigureAwait(false); + continue; + } + + try + { + await ProcessOneAsync(path, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Penumbra.Log.Debug("[FileWatcher] Canceled via Token."); + } + catch (Exception ex) + { + Penumbra.Log.Warning($"[FileWatcher] Error during Processing: {ex}"); + } + finally + { + _pending.TryRemove(path, out _); + } + } + } + + private async Task ProcessOneAsync(string path, CancellationToken token) + { + // Downloads often finish via rename; file may be locked briefly. + // Wait until it exists and is readable; also require two stable size checks. + const int maxTries = 40; + long lastLen = -1; + + for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++) + { + if (!File.Exists(path)) + { + await Task.Delay(100, token); + continue; + } + + try + { + var fi = new FileInfo(path); + var len = fi.Length; + if (len > 0 && len == lastLen) + { + if (_config.EnableAutomaticModImport) + _modImportManager.AddUnpack(path); + else + _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); + return; + } + + lastLen = len; + } + catch (IOException) + { + Penumbra.Log.Debug($"[FileWatcher] File is still being written to."); + } + catch (UnauthorizedAccessException) + { + Penumbra.Log.Debug($"[FileWatcher] File is locked."); + } + + await Task.Delay(150, token); + } + } + + + public void Dispose() + { + EndConsumerTask(); + EndFileWatcher(); + } +} diff --git a/Penumbra/Services/FilenameService.cs b/Penumbra/Services/FilenameService.cs index 817af0d2..ee096109 100644 --- a/Penumbra/Services/FilenameService.cs +++ b/Penumbra/Services/FilenameService.cs @@ -24,7 +24,7 @@ public class FilenameService(IDalamudPluginInterface pi) : IService /// Obtain the path of a collection file given its name. public string CollectionFile(ModCollection collection) - => CollectionFile(collection.Identifier); + => CollectionFile(collection.Identity.Identifier); /// Obtain the path of a collection file given its name. public string CollectionFile(string collectionName) diff --git a/Penumbra/Services/InstallNotification.cs b/Penumbra/Services/InstallNotification.cs new file mode 100644 index 00000000..e3956076 --- /dev/null +++ b/Penumbra/Services/InstallNotification.cs @@ -0,0 +1,39 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.EventArgs; +using OtterGui.Text; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class InstallNotification(ModImportManager modImportManager, string filePath) : OtterGui.Classes.MessageService.IMessage +{ + public string Message + => "A new mod has been found!"; + + public NotificationType NotificationType + => NotificationType.Info; + + public uint NotificationDuration + => uint.MaxValue; + + public string NotificationTitle { get; } = Path.GetFileNameWithoutExtension(filePath); + + public string LogMessage + => $"A new mod has been found: {Path.GetFileName(filePath)}"; + + public void OnNotificationActions(INotificationDrawArgs args) + { + var region = ImGui.GetContentRegionAvail(); + var buttonSize = new Vector2((region.X - ImGui.GetStyle().ItemSpacing.X) / 2, 0); + if (ImUtf8.ButtonEx("Install"u8, ""u8, buttonSize)) + { + modImportManager.AddUnpack(filePath); + args.Notification.DismissNow(); + } + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Ignore"u8, ""u8, buttonSize)) + args.Notification.DismissNow(); + } +} diff --git a/Penumbra/Services/MessageService.cs b/Penumbra/Services/MessageService.cs index e610cb6a..70ccf47b 100644 --- a/Penumbra/Services/MessageService.cs +++ b/Penumbra/Services/MessageService.cs @@ -7,6 +7,7 @@ using Dalamud.Plugin.Services; using Lumina.Excel.Sheets; using OtterGui.Log; using OtterGui.Services; +using Penumbra.GameData.Data; using Penumbra.Mods.Manager; using Penumbra.String.Classes; using Notification = OtterGui.Classes.Notification; @@ -29,7 +30,7 @@ public class MessageService(Logger log, IUiBuilder builder, IChatGui chat, INoti new TextPayload($"{(char)SeIconChar.LinkMarker}"), new UIForegroundPayload(0), new UIGlowPayload(0), - new TextPayload(item.Name.ExtractText()), + new TextPayload(item.Name.ExtractTextExtended()), new RawPayload([0x02, 0x27, 0x07, 0xCF, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]), new RawPayload([0x02, 0x13, 0x02, 0xEC, 0x03]), }; diff --git a/Penumbra/Services/MigrationManager.cs b/Penumbra/Services/MigrationManager.cs index aa2d445e..8db62e48 100644 --- a/Penumbra/Services/MigrationManager.cs +++ b/Penumbra/Services/MigrationManager.cs @@ -1,9 +1,14 @@ using Dalamud.Interface.ImGuiNotification; +using Lumina.Data.Files; using OtterGui.Classes; using OtterGui.Services; -using Penumbra.GameData.Files; +using Lumina.Extensions; +using Penumbra.GameData.Files.Utility; +using Penumbra.Import.Textures; using SharpCompress.Common; using SharpCompress.Readers; +using MdlFile = Penumbra.GameData.Files.MdlFile; +using MtrlFile = Penumbra.GameData.Files.MtrlFile; namespace Penumbra.Services; @@ -295,6 +300,26 @@ public class MigrationManager(Configuration config) : IService } } + public void FixMipMaps(IReader reader, string directory, ExtractionOptions options) + { + var path = Path.Combine(directory, reader.Entry.Key!); + using var s = new MemoryStream(); + using var e = reader.OpenEntryStream(); + e.CopyTo(s); + var length = s.Position; + s.Seek(0, SeekOrigin.Begin); + var br = new BinaryReader(s, Encoding.UTF8, true); + var header = br.ReadStructure(); + br.Dispose(); + TexFileParser.FixMipOffsets(length, ref header, out var actualSize); + + s.Seek(0, SeekOrigin.Begin); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var f = File.Open(path, FileMode.Create, FileAccess.Write); + f.Write(header); + f.Write(s.GetBuffer().AsSpan(80, (int)actualSize - 80)); + } + /// Update the data of a .mdl file during TTMP extraction. Returns either the existing array or a new one. public byte[] MigrateTtmpModel(string path, byte[] data) { @@ -347,6 +372,25 @@ public class MigrationManager(Configuration config) : IService } } + public byte[] FixTtmpMipMaps(string path, byte[] data) + { + using var m = new MemoryStream(data); + var br = new BinaryReader(m, Encoding.UTF8, true); + var header = br.ReadStructure(); + br.Dispose(); + TexFileParser.FixMipOffsets(data.Length, ref header, out var actualSize); + if (actualSize == data.Length) + return data; + + var ret = new byte[actualSize]; + using var m2 = new MemoryStream(ret); + using var bw = new BinaryWriter(m2); + bw.Write(header); + bw.Write(data.AsSpan(80, (int)actualSize - 80)); + + return ret; + } + private static bool MigrateModel(string path, MdlFile mdl, bool createBackup) { diff --git a/Penumbra/Services/ModMigrator.cs b/Penumbra/Services/ModMigrator.cs new file mode 100644 index 00000000..043d9631 --- /dev/null +++ b/Penumbra/Services/ModMigrator.cs @@ -0,0 +1,349 @@ +using Dalamud.Plugin.Services; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Structs; +using Penumbra.Import.Textures; +using Penumbra.Mods; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Services; + +public class ModMigrator(IDataManager gameData, TextureManager textures) : IService +{ + private sealed class FileDataDict : MultiDictionary; + + private readonly Lazy _glassReferenceMaterial = new(() => + { + var bytes = gameData.GetFile("chara/equipment/e5001/material/v0001/mt_c0101e5001_met_b.mtrl"); + return new MtrlFile(bytes!.Data); + }); + + private readonly HashSet _changedMods = []; + private readonly HashSet _failedMods = []; + + private readonly FileDataDict Textures = []; + private readonly FileDataDict Models = []; + private readonly FileDataDict Materials = []; + private readonly FileDataDict FileSwaps = []; + + private readonly ConcurrentBag _messages = []; + + public void Update(IEnumerable mods) + { + CollectFiles(mods); + foreach (var (from, (to, container)) in FileSwaps) + MigrateFileSwaps(from, to, container); + foreach (var (model, list) in Models.Grouped) + MigrateModel(model, (Mod)list[0].Container.Mod); + } + + private void CollectFiles(IEnumerable mods) + { + foreach (var mod in mods) + { + foreach (var container in mod.AllDataContainers) + { + foreach (var (gamePath, file) in container.Files) + { + switch (ResourceTypeExtensions.FromExtension(gamePath.Extension().Span)) + { + case ResourceType.Tex: Textures.TryAdd(file.FullName, (gamePath.ToString(), container)); break; + case ResourceType.Mdl: Models.TryAdd(file.FullName, (gamePath.ToString(), container)); break; + case ResourceType.Mtrl: Materials.TryAdd(file.FullName, (gamePath.ToString(), container)); break; + } + } + + foreach (var (swapFrom, swapTo) in container.FileSwaps) + FileSwaps.TryAdd(swapTo.FullName, (swapFrom.ToString(), container)); + } + } + } + + public Task CreateIndexFile(string normalPath, string targetPath) + { + const int rowBlend = 17; + + return Task.Run(async () => + { + var tex = textures.LoadTex(normalPath); + var data = tex.GetPixelData(); + var rgbaData = new RgbaPixelData(data.Width, data.Height, data.Rgba); + if (!BitOperations.IsPow2(rgbaData.Height) || !BitOperations.IsPow2(rgbaData.Width)) + { + var requiredHeight = (int)BitOperations.RoundUpToPowerOf2((uint)rgbaData.Height); + var requiredWidth = (int)BitOperations.RoundUpToPowerOf2((uint)rgbaData.Width); + rgbaData = rgbaData.Resize((requiredWidth, requiredHeight)); + } + + Parallel.ForEach(Enumerable.Range(0, rgbaData.PixelData.Length / 4), idx => + { + var pixelIdx = 4 * idx; + var normal = rgbaData.PixelData[pixelIdx + 3]; + + // Copied from TT + var blendRem = normal % (2 * rowBlend); + var originalRow = normal / rowBlend; + switch (blendRem) + { + // Goes to next row, clamped to the closer row. + case > 25: + blendRem = 0; + ++originalRow; + break; + // Stays in this row, clamped to the closer row. + case > 17: blendRem = 17; break; + } + + var newBlend = (byte)(255 - MathF.Round(blendRem / 17f * 255f)); + + // Slight add here to push the color deeper into the row to ensure BC5 compression doesn't + // cause any artifacting. + var newRow = (byte)(originalRow / 2 * 17 + 4); + + rgbaData.PixelData[pixelIdx] = newRow; + rgbaData.PixelData[pixelIdx] = newBlend; + rgbaData.PixelData[pixelIdx] = 0; + rgbaData.PixelData[pixelIdx] = 255; + }); + await textures.SaveAs(CombinedTexture.TextureSaveType.BC5, true, true, new BaseImage(), targetPath, rgbaData.PixelData, + rgbaData.Width, rgbaData.Height); + }); + } + + private void MigrateModel(string filePath, Mod mod) + { + if (MigrationManager.TryMigrateSingleModel(filePath, true)) + { + _messages.Add($"Migrated model {filePath} in {mod.Name}."); + } + else + { + _messages.Add($"Failed to migrate model {filePath} in {mod.Name}"); + _failedMods.Add(mod); + } + } + + private void SetGlassReferenceValues(MtrlFile mtrl) + { + var reference = _glassReferenceMaterial.Value; + mtrl.ShaderPackage.ShaderKeys = reference.ShaderPackage.ShaderKeys.ToArray(); + mtrl.ShaderPackage.Constants = reference.ShaderPackage.Constants.ToArray(); + mtrl.AdditionalData = reference.AdditionalData.ToArray(); + mtrl.ShaderPackage.Flags &= ~(0x04u | 0x08u); + // From TT. + if (mtrl.Table is ColorTable t) + foreach (ref var row in t.AsRows()) + row.SpecularColor = new HalfColor((Half)0.8100586, (Half)0.8100586, (Half)0.8100586); + } + + private ref struct MaterialPack + { + public readonly MtrlFile File; + public readonly bool UsesMaskAsSpecular; + + private readonly Dictionary Samplers = []; + + public MaterialPack(MtrlFile file) + { + File = file; + UsesMaskAsSpecular = File.ShaderPackage.ShaderKeys.Any(x => x.Key is 0xC8BD1DEF && x.Value is 0xA02F4828 or 0x198D11CD); + Add(Samplers, TextureUsage.Normal, ShpkFile.NormalSamplerId); + Add(Samplers, TextureUsage.Index, ShpkFile.IndexSamplerId); + Add(Samplers, TextureUsage.Mask, ShpkFile.MaskSamplerId); + Add(Samplers, TextureUsage.Diffuse, ShpkFile.DiffuseSamplerId); + Add(Samplers, TextureUsage.Specular, ShpkFile.SpecularSamplerId); + return; + + void Add(Dictionary dict, TextureUsage usage, uint samplerId) + { + var idx = new SamplerIndex(file, samplerId); + if (idx.Texture >= 0) + dict.Add(usage, idx); + } + } + + public readonly record struct SamplerIndex(int Sampler, int Texture) + { + public SamplerIndex(MtrlFile file, uint samplerId) + : this(file.FindSampler(samplerId), -1) + => Texture = Sampler < 0 ? -1 : file.ShaderPackage.Samplers[Sampler].TextureIndex; + } + + public enum TextureUsage + { + Unknown, + Normal, + Index, + Mask, + Diffuse, + Specular, + } + + public static bool AdaptPath(IDataManager data, string path, TextureUsage usage, out string newPath) + { + newPath = path; + if (Path.GetExtension(newPath) is not ".tex") + return false; + + if (data.FileExists(newPath)) + return true; + + ReadOnlySpan<(string, string)> pairs = usage switch + { + TextureUsage.Unknown => + [ + ("_n.tex", "_norm.tex"), + ("_m.tex", "_mult.tex"), + ("_m.tex", "_mask.tex"), + ("_d.tex", "_base.tex"), + ], + TextureUsage.Normal => + [ + ("_n_", "_norm_"), + ("_n.tex", "_norm.tex"), + ], + TextureUsage.Mask => + [ + ("_m_", "_mult_"), + ("_m_", "_mask_"), + ("_m.tex", "_mult.tex"), + ("_m.tex", "_mask.tex"), + ], + TextureUsage.Diffuse => + [ + ("_d_", "_base_"), + ("_d.tex", "_base.tex"), + ], + TextureUsage.Index => [], + TextureUsage.Specular => [], + _ => [], + }; + foreach (var (from, to) in pairs) + { + newPath = path.Replace(from, to); + if (data.FileExists(newPath)) + return true; + } + + return false; + } + } + + private void MigrateMaterial(string filePath, IReadOnlyList<(string GamePath, IModDataContainer Container)> redirections) + { + try + { + var bytes = File.ReadAllBytes(filePath); + var mtrl = new MtrlFile(bytes); + if (!CheckUpdateNeeded(mtrl)) + return; + + // Update colorsets, flags and character shader package. + var changes = mtrl.MigrateToDawntrail(); + + if (!changes) + switch (mtrl.ShaderPackage.Name) + { + case "hair.shpk": break; + case "characterglass.shpk": + SetGlassReferenceValues(mtrl); + changes = true; + break; + } + + // Remove DX11 flags and update paths if necessary. + foreach (ref var tex in mtrl.Textures.AsSpan()) + { + if (tex.DX11) + { + changes = true; + if (GamePaths.Tex.HandleDx11Path(tex, out var newPath)) + tex.Path = newPath; + tex.DX11 = false; + } + + if (gameData.FileExists(tex.Path)) + continue; + } + + // Dyeing, from TT. + if (mtrl.DyeTable is ColorDyeTable dye) + foreach (ref var row in dye.AsRows()) + row.Template += 1000; + } + catch + { + // ignored + } + + static bool CheckUpdateNeeded(MtrlFile mtrl) + { + if (!mtrl.IsDawntrail) + return true; + + if (mtrl.ShaderPackage.Name is not "hair.shpk") + return false; + + var foundOld = 0; + foreach (var c in mtrl.ShaderPackage.Constants) + { + switch (c.Id) + { + case 0x36080AD0: foundOld |= 1; break; // == 1, from TT + case 0x992869AB: foundOld |= 2; break; // == 3 (skin) or 4 (hair) from TT + } + + if (foundOld is 3) + return true; + } + + return false; + } + } + + private void MigrateFileSwaps(string swapFrom, string swapTo, IModDataContainer container) + { + var fromExists = gameData.FileExists(swapFrom); + var toExists = gameData.FileExists(swapTo); + if (fromExists && toExists) + return; + + if (ResourceTypeExtensions.FromExtension(Path.GetExtension(swapFrom.AsSpan())) is not ResourceType.Tex + || ResourceTypeExtensions.FromExtension(Path.GetExtension(swapTo.AsSpan())) is not ResourceType.Tex) + { + _messages.Add( + $"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}. Only textures may be migrated.{(fromExists ? "\n\tSource File does not exist." : "")}{(toExists ? "\n\tTarget File does not exist." : "")}"); + return; + } + + var newSwapFrom = swapFrom; + if (!fromExists && !MaterialPack.AdaptPath(gameData, swapFrom, MaterialPack.TextureUsage.Unknown, out newSwapFrom)) + { + _messages.Add($"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}."); + return; + } + + var newSwapTo = swapTo; + if (!toExists && !MaterialPack.AdaptPath(gameData, swapTo, MaterialPack.TextureUsage.Unknown, out newSwapTo)) + { + _messages.Add($"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}."); + return; + } + + if (!Utf8GamePath.FromString(swapFrom, out var path) || !Utf8GamePath.FromString(newSwapFrom, out var newPath)) + { + _messages.Add( + $"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}. Unknown Error."); + return; + } + + container.FileSwaps.Remove(path); + container.FileSwaps.Add(newPath, new FullPath(newSwapTo)); + _changedMods.Add((Mod)container.Mod); + } +} diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs new file mode 100644 index 00000000..17646564 --- /dev/null +++ b/Penumbra/Services/PcpService.cs @@ -0,0 +1,308 @@ +using Dalamud.Game.ClientState.Objects.Types; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.ResourceTree; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Services; + +public class PcpService : IApiService, IDisposable +{ + public const string Extension = ".pcp"; + + private readonly Configuration _config; + private readonly SaveService _files; + private readonly ResourceTreeFactory _treeFactory; + private readonly ObjectManager _objectManager; + private readonly ActorManager _actors; + private readonly FrameworkManager _framework; + private readonly CollectionResolver _collectionResolver; + private readonly CollectionManager _collections; + private readonly ModCreator _modCreator; + private readonly ModExportManager _modExport; + private readonly CommunicatorService _communicator; + private readonly SHA1 _sha1 = SHA1.Create(); + private readonly ModFileSystem _fileSystem; + private readonly ModManager _mods; + + public PcpService(Configuration config, + SaveService files, + ResourceTreeFactory treeFactory, + ObjectManager objectManager, + ActorManager actors, + FrameworkManager framework, + CollectionManager collections, + CollectionResolver collectionResolver, + ModCreator modCreator, + ModExportManager modExport, + CommunicatorService communicator, + ModFileSystem fileSystem, + ModManager mods) + { + _config = config; + _files = files; + _treeFactory = treeFactory; + _objectManager = objectManager; + _actors = actors; + _framework = framework; + _collectionResolver = collectionResolver; + _collections = collections; + _modCreator = modCreator; + _modExport = modExport; + _communicator = communicator; + _fileSystem = fileSystem; + _mods = mods; + + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.PcpService); + } + + public void CleanPcpMods() + { + var mods = _mods.Where(m => m.ModTags.Contains("PCP")).ToList(); + Penumbra.Log.Information($"[PCPService] Deleting {mods.Count} mods containing the tag PCP."); + foreach (var mod in mods) + _mods.DeleteMod(mod); + } + + public void CleanPcpCollections() + { + var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList(); + Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} collections starting with PCP/."); + foreach (var collection in collections) + _collections.Storage.RemoveCollection(collection); + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) + { + if (type is not ModPathChangeType.Added || _config.PcpSettings.DisableHandling || newDirectory is null) + return; + + try + { + var file = Path.Combine(newDirectory.FullName, "character.json"); + if (!File.Exists(file)) + { + // First version had collection.json, changed. + var oldFile = Path.Combine(newDirectory.FullName, "collection.json"); + if (File.Exists(oldFile)) + { + Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json."); + File.Move(oldFile, file, true); + } + else + return; + } + + Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying."); + var text = File.ReadAllText(file); + var jObj = JObject.Parse(text); + var collection = ModCollection.Empty; + // Create collection. + if (_config.PcpSettings.CreateCollection) + { + var identifier = _actors.FromJson(jObj["Actor"] as JObject); + if (identifier.IsValid && jObj["Collection"]?.ToObject() is { } collectionName) + { + var name = $"PCP/{collectionName}"; + if (_collections.Storage.AddCollection(name, null)) + { + collection = _collections.Storage[^1]; + _collections.Editor.SetModState(collection, mod, true); + + // Assign collection. + if (_config.PcpSettings.AssignCollection) + { + var identifierGroup = _collections.Active.Individuals.GetGroup(identifier); + _collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup); + } + } + } + } + + // Move to folder. + if (_fileSystem.TryGetValue(mod, out var leaf)) + { + try + { + var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpSettings.FolderName); + _fileSystem.Move(leaf, folder); + } + catch + { + // ignored. + } + } + + // Invoke IPC. + if (_config.PcpSettings.AllowIpc) + _communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error reading the character.json file from {mod.Identifier}:\n{ex}"); + } + } + + public void Dispose() + => _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + + public async Task<(bool, string)> CreatePcp(ObjectIndex objectIndex, string note = "", CancellationToken cancel = default) + { + try + { + Penumbra.Log.Information($"[PCPService] Creating PCP file for game object {objectIndex.Index}."); + var (identifier, tree, meta) = await _framework.Framework.RunOnFrameworkThread(() => + { + var (actor, identifier) = CheckActor(objectIndex); + cancel.ThrowIfCancellationRequested(); + unsafe + { + var collection = _collectionResolver.IdentifyCollection((GameObject*)actor.Address, true); + if (!collection.Valid || !collection.ModCollection.HasCache) + throw new Exception($"Actor {identifier} has no mods applying, nothing to do."); + + cancel.ThrowIfCancellationRequested(); + if (_treeFactory.FromCharacter(actor, 0) is not { } tree) + throw new Exception($"Unable to fetch modded resources for {identifier}."); + + var meta = new MetaDictionary(collection.ModCollection.MetaCache, actor.Address); + return (identifier.CreatePermanent(), tree, meta); + } + }); + cancel.ThrowIfCancellationRequested(); + var time = DateTime.Now; + var modDirectory = CreateMod(identifier, note, time); + await CreateDefaultMod(modDirectory, meta, tree, cancel); + await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel); + var file = ZipUp(modDirectory); + return (true, file); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + private static string ZipUp(DirectoryInfo directory) + { + var fileName = directory.FullName + Extension; + ZipFile.CreateFromDirectory(directory.FullName, fileName, CompressionLevel.Optimal, false); + directory.Delete(true); + return fileName; + } + + private async Task CreateCollectionInfo(DirectoryInfo directory, ObjectIndex index, ActorIdentifier actor, string note, DateTime time, + CancellationToken cancel = default) + { + var jObj = new JObject + { + ["Version"] = 1, + ["Actor"] = actor.ToJson(), + ["Mod"] = directory.Name, + ["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(), + ["Time"] = time, + ["Note"] = note, + }; + if (note.Length > 0) + cancel.ThrowIfCancellationRequested(); + if (_config.PcpSettings.AllowIpc) + await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index, directory.FullName)); + var filePath = Path.Combine(directory.FullName, "character.json"); + await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); + await using var stream = new StreamWriter(file); + await using var json = new JsonTextWriter(stream); + json.Formatting = Formatting.Indented; + await jObj.WriteToAsync(json, cancel); + } + + private DirectoryInfo CreateMod(ActorIdentifier actor, string note, DateTime time) + { + var directory = _modExport.ExportDirectory; + directory.Create(); + var actorName = actor.ToName(); + var authorName = _actors.GetCurrentPlayer().ToName(); + var suffix = note.Length > 0 + ? note + : time.ToString("yyyy-MM-ddTHH\\:mm", CultureInfo.InvariantCulture); + var modName = $"{actorName} - {suffix}"; + var description = $"On-Screen Data for {actorName} as snapshotted on {time}."; + return _modCreator.CreateEmptyMod(directory, modName, description, authorName, "PCP") + ?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}."); + } + + private async Task CreateDefaultMod(DirectoryInfo modDirectory, MetaDictionary meta, ResourceTree tree, + CancellationToken cancel = default) + { + var subDirectory = modDirectory.CreateSubdirectory("files"); + var subMod = new DefaultSubMod(null!) + { + Manipulations = meta, + }; + + foreach (var node in tree.FlatNodes) + { + cancel.ThrowIfCancellationRequested(); + var gamePath = node.GamePath; + var fullPath = node.FullPath; + if (fullPath.IsRooted) + { + var hash = await _sha1.ComputeHashAsync(File.OpenRead(fullPath.FullName), cancel).ConfigureAwait(false); + cancel.ThrowIfCancellationRequested(); + var name = Convert.ToHexString(hash) + fullPath.Extension; + var newFile = Path.Combine(subDirectory.FullName, name); + if (!File.Exists(newFile)) + File.Copy(fullPath.FullName, newFile); + subMod.Files.TryAdd(gamePath, new FullPath(newFile)); + } + else if (gamePath.Path != fullPath.InternalName) + { + subMod.FileSwaps.TryAdd(gamePath, fullPath); + } + } + + cancel.ThrowIfCancellationRequested(); + + var saveGroup = new ModSaveGroup(modDirectory, subMod, _config.ReplaceNonAsciiOnImport); + var filePath = _files.FileNames.OptionGroupFile(modDirectory.FullName, -1, string.Empty, _config.ReplaceNonAsciiOnImport); + cancel.ThrowIfCancellationRequested(); + await using var fileStream = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); + await using var writer = new StreamWriter(fileStream); + saveGroup.Save(writer); + } + + private (ICharacter Actor, ActorIdentifier Identifier) CheckActor(ObjectIndex objectIndex) + { + var actor = _objectManager[objectIndex]; + if (!actor.Valid) + throw new Exception($"No Actor at index {objectIndex} found."); + + if (!actor.Identifier(_actors, out var identifier)) + throw new Exception($"Could not create valid identifier for actor at index {objectIndex}."); + + if (!actor.IsCharacter) + throw new Exception($"Actor {identifier} at index {objectIndex} is not a valid character."); + + if (!actor.Model.Valid) + throw new Exception($"Actor {identifier} at index {objectIndex} has no model."); + + if (_objectManager.Objects.CreateObjectReference(actor.Address) is not ICharacter character) + throw new Exception($"Actor {identifier} at index {objectIndex} could not be converted to ICharacter"); + + return (character, identifier); + } +} diff --git a/Penumbra/Services/StainService.cs b/Penumbra/Services/StainService.cs index 0a437da0..17294aa8 100644 --- a/Penumbra/Services/StainService.cs +++ b/Penumbra/Services/StainService.cs @@ -1,7 +1,7 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin.Services; -using ImGuiNET; using OtterGui.Services; using OtterGui.Widgets; using Penumbra.GameData.DataContainers; @@ -16,7 +16,7 @@ namespace Penumbra.Services; public class StainService : IService { public sealed class StainTemplateCombo(FilterComboColors[] stainCombos, StmFile stmFile) - : FilterComboCache(stmFile.Entries.Keys.Prepend((ushort)0), MouseWheelType.None, Penumbra.Log) + : FilterComboCache(stmFile.Entries.Keys.Prepend(0), MouseWheelType.None, Penumbra.Log) where TDyePack : unmanaged, IDyePack { // FIXME There might be a better way to handle that. @@ -31,7 +31,7 @@ public class StainService : IService return baseSize + ImGui.GetTextLineHeight() * 3 + ImGui.GetStyle().ItemInnerSpacing.X * 3; } - protected override string ToString(ushort obj) + protected override string ToString(StmKeyType obj) => $"{obj,4}"; protected override void DrawFilter(int currentSelected, float width) diff --git a/Penumbra/Services/StaticServiceManager.cs b/Penumbra/Services/StaticServiceManager.cs index c0dc9314..27582395 100644 --- a/Penumbra/Services/StaticServiceManager.cs +++ b/Penumbra/Services/StaticServiceManager.cs @@ -17,6 +17,8 @@ using IPenumbraApi = Penumbra.Api.Api.IPenumbraApi; namespace Penumbra.Services; +#pragma warning disable SeStringEvaluator + public static class StaticServiceManager { public static ServiceManager CreateProvider(Penumbra penumbra, IDalamudPluginInterface pi, Logger log) @@ -60,5 +62,6 @@ public static class StaticServiceManager .AddDalamudService(pi) .AddDalamudService(pi) .AddDalamudService(pi) - .AddDalamudService(pi); + .AddDalamudService(pi) + .AddDalamudService(pi); } diff --git a/Penumbra/UI/AdvancedWindow/FileEditor.cs b/Penumbra/UI/AdvancedWindow/FileEditor.cs index c783e17f..424bc56f 100644 --- a/Penumbra/UI/AdvancedWindow/FileEditor.cs +++ b/Penumbra/UI/AdvancedWindow/FileEditor.cs @@ -1,13 +1,14 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; using OtterGui.Compression; using OtterGui.Raii; using OtterGui.Text; using OtterGui.Widgets; +using Penumbra.GameData.Data; using Penumbra.GameData.Files; using Penumbra.Mods.Editor; using Penumbra.Services; @@ -80,7 +81,7 @@ public class FileEditor( private Exception? _currentException; private bool _changed; - private string _defaultPath = string.Empty; + private string _defaultPath = typeof(T) == typeof(ModEditWindow.PbdTab) ? GamePaths.Pbd.Path : string.Empty; private bool _inInput; private Utf8GamePath _defaultPathUtf8; private bool _isDefaultPathUtf8Valid; diff --git a/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs b/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs index 3f7f2f6c..e9d76990 100644 --- a/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs +++ b/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs @@ -1,9 +1,10 @@ using Dalamud.Interface.ImGuiNotification; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using OtterGui.Widgets; using Penumbra.Api.Enums; using Penumbra.Collections; @@ -12,8 +13,10 @@ using Penumbra.Communication; using Penumbra.GameData.Data; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; +using Penumbra.Import.Structs; using Penumbra.Meta; using Penumbra.Mods; +using Penumbra.Mods.Editor; using Penumbra.Mods.Groups; using Penumbra.Mods.ItemSwap; using Penumbra.Mods.Manager; @@ -45,19 +48,20 @@ public class ItemSwapTab : IDisposable, ITab, IUiService _config = config; _swapData = new ItemSwapContainer(metaFileManager, identifier); + var a = collectionManager.Active; _selectors = new Dictionary { // @formatter:off - [SwapType.Hat] = (new ItemSelector(itemService, selector, FullEquipType.Head), new ItemSelector(itemService, null, FullEquipType.Head), "Take this Hat", "and put it on this one" ), - [SwapType.Top] = (new ItemSelector(itemService, selector, FullEquipType.Body), new ItemSelector(itemService, null, FullEquipType.Body), "Take this Top", "and put it on this one" ), - [SwapType.Gloves] = (new ItemSelector(itemService, selector, FullEquipType.Hands), new ItemSelector(itemService, null, FullEquipType.Hands), "Take these Gloves", "and put them on these" ), - [SwapType.Pants] = (new ItemSelector(itemService, selector, FullEquipType.Legs), new ItemSelector(itemService, null, FullEquipType.Legs), "Take these Pants", "and put them on these" ), - [SwapType.Shoes] = (new ItemSelector(itemService, selector, FullEquipType.Feet), new ItemSelector(itemService, null, FullEquipType.Feet), "Take these Shoes", "and put them on these" ), - [SwapType.Earrings] = (new ItemSelector(itemService, selector, FullEquipType.Ears), new ItemSelector(itemService, null, FullEquipType.Ears), "Take these Earrings", "and put them on these" ), - [SwapType.Necklace] = (new ItemSelector(itemService, selector, FullEquipType.Neck), new ItemSelector(itemService, null, FullEquipType.Neck), "Take this Necklace", "and put it on this one" ), - [SwapType.Bracelet] = (new ItemSelector(itemService, selector, FullEquipType.Wrists), new ItemSelector(itemService, null, FullEquipType.Wrists), "Take these Bracelets", "and put them on these" ), - [SwapType.Ring] = (new ItemSelector(itemService, selector, FullEquipType.Finger), new ItemSelector(itemService, null, FullEquipType.Finger), "Take this Ring", "and put it on this one" ), - [SwapType.Glasses] = (new ItemSelector(itemService, selector, FullEquipType.Glasses), new ItemSelector(itemService, null, FullEquipType.Glasses), "Take these Glasses", "and put them on these" ), + [SwapType.Hat] = (new ItemSelector(a, itemService, selector, FullEquipType.Head), new ItemSelector(a, itemService, null, FullEquipType.Head), "Take this Hat", "and put it on this one" ), + [SwapType.Top] = (new ItemSelector(a, itemService, selector, FullEquipType.Body), new ItemSelector(a, itemService, null, FullEquipType.Body), "Take this Top", "and put it on this one" ), + [SwapType.Gloves] = (new ItemSelector(a, itemService, selector, FullEquipType.Hands), new ItemSelector(a, itemService, null, FullEquipType.Hands), "Take these Gloves", "and put them on these" ), + [SwapType.Pants] = (new ItemSelector(a, itemService, selector, FullEquipType.Legs), new ItemSelector(a, itemService, null, FullEquipType.Legs), "Take these Pants", "and put them on these" ), + [SwapType.Shoes] = (new ItemSelector(a, itemService, selector, FullEquipType.Feet), new ItemSelector(a, itemService, null, FullEquipType.Feet), "Take these Shoes", "and put them on these" ), + [SwapType.Earrings] = (new ItemSelector(a, itemService, selector, FullEquipType.Ears), new ItemSelector(a, itemService, null, FullEquipType.Ears), "Take these Earrings", "and put them on these" ), + [SwapType.Necklace] = (new ItemSelector(a, itemService, selector, FullEquipType.Neck), new ItemSelector(a, itemService, null, FullEquipType.Neck), "Take this Necklace", "and put it on this one" ), + [SwapType.Bracelet] = (new ItemSelector(a, itemService, selector, FullEquipType.Wrists), new ItemSelector(a, itemService, null, FullEquipType.Wrists), "Take these Bracelets", "and put them on these" ), + [SwapType.Ring] = (new ItemSelector(a, itemService, selector, FullEquipType.Finger), new ItemSelector(a, itemService, null, FullEquipType.Finger), "Take this Ring", "and put it on this one" ), + [SwapType.Glasses] = (new ItemSelector(a, itemService, selector, FullEquipType.Glasses), new ItemSelector(a, itemService, null, FullEquipType.Glasses), "Take these Glasses", "and put them on these" ), // @formatter:on }; @@ -133,23 +137,34 @@ public class ItemSwapTab : IDisposable, ITab, IUiService Glasses, } - private class ItemSelector(ItemData data, ModFileSystemSelector? selector, FullEquipType type) - : FilterComboCache<(EquipItem Item, bool InMod)>(() => + private class ItemSelector(ActiveCollections collections, ItemData data, ModFileSystemSelector? selector, FullEquipType type) + : FilterComboCache<(EquipItem Item, bool InMod, SingleArray InCollection)>(() => { var list = data.ByType[type]; - if (selector?.Selected is { } mod && mod.ChangedItems.Values.Any(o => o is IdentifiedItem i && i.Item.Type == type)) - return list.Select(i => (i, mod.ChangedItems.ContainsKey(i.Name))).OrderByDescending(p => p.Item2).ToList(); - - return list.Select(i => (i, false)).ToList(); + var enumerable = selector?.Selected is { } mod && mod.ChangedItems.Values.Any(o => o is IdentifiedItem i && i.Item.Type == type) + ? list.Select(i => (i, mod.ChangedItems.ContainsKey(i.Name), collections.Current.ChangedItems.TryGetValue(i.Name, out var m) ? m.Item1 : new SingleArray())) + .OrderByDescending(p => p.Item2).ThenByDescending(p => p.Item3.Count) + : selector is null + ? list.Select(i => (i, false, collections.Current.ChangedItems.TryGetValue(i.Name, out var m) ? m.Item1 : new SingleArray())).OrderBy(p => p.Item3.Count) + : list.Select(i => (i, false, collections.Current.ChangedItems.TryGetValue(i.Name, out var m) ? m.Item1 : new SingleArray())).OrderByDescending(p => p.Item3.Count); + return enumerable.ToList(); }, MouseWheelType.None, Penumbra.Log) { protected override bool DrawSelectable(int globalIdx, bool selected) { - using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ResTreeLocalPlayer.Value(), Items[globalIdx].InMod); - return base.DrawSelectable(globalIdx, selected); + var (_, inMod, inCollection) = Items[globalIdx]; + using var color = inMod + ? ImRaii.PushColor(ImGuiCol.Text, ColorId.ResTreeLocalPlayer.Value()) + : inCollection.Count > 0 + ? ImRaii.PushColor(ImGuiCol.Text, ColorId.ResTreeNonNetworked.Value()) + : null; + var ret = base.DrawSelectable(globalIdx, selected); + if (inCollection.Count > 0) + ImUtf8.HoverTooltip(string.Join('\n', inCollection.Select(m => m.Name.Text))); + return ret; } - protected override string ToString((EquipItem Item, bool InMod) obj) + protected override string ToString((EquipItem Item, bool InMod, SingleArray InCollection) obj) => obj.Item.Name; } @@ -179,7 +194,7 @@ public class ItemSwapTab : IDisposable, ITab, IUiService private bool _useLeftRing = true; private bool _useRightRing = true; - private EquipItem[]? _affectedItems; + private HashSet? _affectedItems; private void UpdateState() { @@ -275,16 +290,38 @@ public class ItemSwapTab : IDisposable, ITab, IUiService case SwapType.Hair: case SwapType.Tail: return - $"Created by swapping {_lastTab} {_sourceId} onto {_lastTab} {_targetId} for {_currentRace.ToName()} {_currentGender.ToName()}s in {_mod!.Name}."; + $"Created by swapping {_lastTab} {_sourceId} onto {_lastTab} {_targetId} for {_currentRace.ToName()} {_currentGender.ToName()}s in {_mod!.Name}{OriginalAuthor()}"; case SwapType.BetweenSlots: return - $"Created by swapping {GetAccessorySelector(_slotFrom, true).Item3.CurrentSelection.Item.Name} onto {GetAccessorySelector(_slotTo, false).Item3.CurrentSelection.Item.Name} in {_mod!.Name}."; + $"Created by swapping {GetAccessorySelector(_slotFrom, true).Item3.CurrentSelection.Item.Name} onto {GetAccessorySelector(_slotTo, false).Item3.CurrentSelection.Item.Name} in {_mod!.Name}{OriginalAuthor()}"; default: return - $"Created by swapping {_selectors[_lastTab].Source.CurrentSelection.Item.Name} onto {_selectors[_lastTab].Target.CurrentSelection.Item.Name} in {_mod!.Name}."; + $"Created by swapping {_selectors[_lastTab].Source.CurrentSelection.Item.Name} onto {_selectors[_lastTab].Target.CurrentSelection.Item.Name} in {_mod!.Name}{OriginalAuthor()}"; } } + private string OriginalAuthor() + { + if (_mod!.Author.IsEmpty || _mod!.Author.Text is "TexTools User" or DefaultTexToolsData.Author) + return "."; + + return $" by {_mod!.Author}."; + } + + private string CreateAuthor() + { + if (_mod!.Author.IsEmpty) + return _config.DefaultModAuthor; + if (_mod!.Author.Text == _config.DefaultModAuthor) + return _config.DefaultModAuthor; + if (_mod!.Author.Text is "TexTools User" or DefaultTexToolsData.Author) + return _config.DefaultModAuthor; + if (_config.DefaultModAuthor is DefaultTexToolsData.Author) + return _mod!.Author; + + return $"{_mod!.Author} (Swap by {_config.DefaultModAuthor})"; + } + private void UpdateOption() { _selectedGroup = _mod?.Groups.FirstOrDefault(g => g.Name == _newGroupName); @@ -296,7 +333,7 @@ public class ItemSwapTab : IDisposable, ITab, IUiService private void CreateMod() { - var newDir = _modManager.Creator.CreateEmptyMod(_modManager.BasePath, _newModName, CreateDescription()); + var newDir = _modManager.Creator.CreateEmptyMod(_modManager.BasePath, _newModName, CreateDescription(), CreateAuthor()); if (newDir == null) return; @@ -518,11 +555,11 @@ public class ItemSwapTab : IDisposable, ITab, IUiService _dirty |= selector.Draw("##itemTarget", selector.CurrentSelection.Item.Name, string.Empty, InputWidth * 2 * UiHelpers.Scale, ImGui.GetTextLineHeightWithSpacing()); - if (_affectedItems is not { Length: > 1 }) + if (_affectedItems is not { Count: > 1 }) return; ImGui.SameLine(); - ImGuiUtil.DrawTextButton($"which will also affect {_affectedItems.Length - 1} other Items.", Vector2.Zero, + ImGuiUtil.DrawTextButton($"which will also affect {_affectedItems.Count - 1} other Items.", Vector2.Zero, Colors.PressEnterWarningBg); if (ImGui.IsItemHovered()) ImGui.SetTooltip(string.Join('\n', _affectedItems.Where(i => !ReferenceEquals(i.Name, selector.CurrentSelection.Item.Name)) @@ -579,11 +616,11 @@ public class ItemSwapTab : IDisposable, ITab, IUiService _dirty |= ImGui.Checkbox("Swap Left Ring", ref _useLeftRing); } - if (_affectedItems is not { Length: > 1 }) + if (_affectedItems is not { Count: > 1 }) return; ImGui.SameLine(); - ImGuiUtil.DrawTextButton($"which will also affect {_affectedItems.Length - 1} other Items.", Vector2.Zero, + ImGuiUtil.DrawTextButton($"which will also affect {_affectedItems.Count - 1} other Items.", Vector2.Zero, Colors.PressEnterWarningBg); if (ImGui.IsItemHovered()) ImGui.SetTooltip(string.Join('\n', _affectedItems.Where(i => !ReferenceEquals(i.Name, targetSelector.CurrentSelection.Item.Name)) @@ -737,12 +774,12 @@ public class ItemSwapTab : IDisposable, ITab, IUiService if (collectionType is not CollectionType.Current || _mod == null || newCollection == null) return; - UpdateMod(_mod, _mod.Index < newCollection.Settings.Count ? newCollection[_mod.Index].Settings : null); + UpdateMod(_mod, _mod.Index < newCollection.Settings.Count ? newCollection.GetInheritedSettings(_mod.Index).Settings : null); } private void OnSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx, bool inherited) { - if (collection != _collectionManager.Active.Current || mod != _mod) + if (collection != _collectionManager.Active.Current || mod != _mod || type is ModSettingChange.TemporarySetting) return; _swapData.LoadMod(_mod, _modSettings); @@ -754,7 +791,7 @@ public class ItemSwapTab : IDisposable, ITab, IUiService if (collection != _collectionManager.Active.Current || _mod == null) return; - UpdateMod(_mod, collection[_mod.Index].Settings); + UpdateMod(_mod, collection.GetInheritedSettings(_mod.Index).Settings); _swapData.LoadMod(_mod, _modSettings); _dirty = true; } diff --git a/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs b/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs index 5c636b1d..24a5f9c2 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using FFXIVClientStructs.Interop; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using OtterGui.Services; @@ -131,7 +131,7 @@ public sealed unsafe class MaterialTemplatePickers : IUiService if (texture == null) continue; var handle = _textureArraySlicer.GetImGuiHandle(texture, sliceIndex); - if (handle == 0) + if (handle.IsNull) continue; var position = regionStart with { X = regionStart.X + (itemSize.X + itemSpacing) * j }; diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs index ab93dc5f..fad9adeb 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs @@ -1,5 +1,5 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using OtterGui.Text; @@ -603,10 +603,11 @@ public partial class MtrlTab value => dyeTable[rowIdx].Channel = (byte)(Math.Clamp(value, 1, StainService.ChannelCount) - 1)); ImGui.SameLine(subColWidth); ImGui.SetNextItemWidth(scalarSize); + _stainService.GudTemplateCombo.CurrentDyeChannel = dye.Channel; if (_stainService.GudTemplateCombo.Draw("##dyeTemplate", dye.Template.ToString(), string.Empty, scalarSize + ImGui.GetStyle().ScrollbarSize / 2, ImGui.GetTextLineHeightWithSpacing(), ImGuiComboFlags.NoArrowButton)) { - dye.Template = _stainService.GudTemplateCombo.CurrentSelection; + dye.Template = _stainService.GudTemplateCombo.CurrentSelection.UShort; ret = true; } diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs index 38f02100..9ea9c2e0 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; -using ImGuiNET; using Penumbra.GameData.Files.MaterialStructs; using Penumbra.GameData.Files; using OtterGui.Text; @@ -22,7 +22,7 @@ public partial class MtrlTab private bool DrawColorTableSection(bool disabled) { - if (!_shpkLoading && !SamplerIds.Contains(ShpkFile.TableSamplerId) || Mtrl.Table == null) + if (!_shpkLoading && !TextureIds.Contains(ShpkFile.TableSamplerId) || Mtrl.Table == null) return false; ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); @@ -202,24 +202,74 @@ public partial class MtrlTab if (Mtrl.Table == null) return false; - if (!ImUtf8.IconButton(FontAwesomeIcon.Paste, "Import an exported row from your clipboard onto this row."u8, + if (ImUtf8.IconButton(FontAwesomeIcon.Paste, + "Import an exported row from your clipboard onto this row.\n\nRight-Click for more options."u8, ImGui.GetFrameHeight() * Vector2.One, disabled)) + try + { + var text = ImGui.GetClipboardText(); + var data = Convert.FromBase64String(text); + var row = Mtrl.Table.RowAsBytes(rowIdx); + var dyeRow = Mtrl.DyeTable != null ? Mtrl.DyeTable.RowAsBytes(rowIdx) : []; + if (data.Length != row.Length && data.Length != row.Length + dyeRow.Length) + return false; + + data.AsSpan(0, row.Length).TryCopyTo(row); + data.AsSpan(row.Length).TryCopyTo(dyeRow); + + UpdateColorTableRowPreview(rowIdx); + + return true; + } + catch + { + return false; + } + + return ColorTablePasteFromClipboardContext(rowIdx, disabled); + } + + private unsafe bool ColorTablePasteFromClipboardContext(int rowIdx, bool disabled) + { + if (!disabled && ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup("context"u8); + + using var context = ImUtf8.Popup("context"u8); + if (!context) + return false; + + using var _ = ImRaii.Disabled(disabled); + + IColorTable.ValueTypes copy = 0; + IColorDyeTable.ValueTypes dyeCopy = 0; + if (ImUtf8.Selectable("Import Colors Only"u8)) + { + copy = IColorTable.ValueTypes.Colors; + dyeCopy = IColorDyeTable.ValueTypes.Colors; + } + + if (ImUtf8.Selectable("Import Other Values Only"u8)) + { + copy = ~IColorTable.ValueTypes.Colors; + dyeCopy = ~IColorDyeTable.ValueTypes.Colors; + } + + if (copy == 0) return false; try { var text = ImGui.GetClipboardText(); var data = Convert.FromBase64String(text); - var row = Mtrl.Table.RowAsBytes(rowIdx); + var row = Mtrl.Table!.RowAsHalves(rowIdx); + var halves = new Span(Unsafe.AsPointer(ref data[0]), row.Length); var dyeRow = Mtrl.DyeTable != null ? Mtrl.DyeTable.RowAsBytes(rowIdx) : []; - if (data.Length != row.Length && data.Length != row.Length + dyeRow.Length) + if (!Mtrl.Table.MergeSpecificValues(row, halves, copy)) return false; - data.AsSpan(0, row.Length).TryCopyTo(row); - data.AsSpan(row.Length).TryCopyTo(dyeRow); + Mtrl.DyeTable?.MergeSpecificValues(dyeRow, data.AsSpan(row.Length * 2), dyeCopy); UpdateColorTableRowPreview(rowIdx); - return true; } catch @@ -288,10 +338,10 @@ public partial class MtrlTab var tmp = inputSqrt; if (ImUtf8.ColorEdit(label, ref tmp, ImGuiColorEditFlags.NoInputs - | ImGuiColorEditFlags.DisplayRGB - | ImGuiColorEditFlags.InputRGB + | ImGuiColorEditFlags.DisplayRgb + | ImGuiColorEditFlags.InputRgb | ImGuiColorEditFlags.NoTooltip - | ImGuiColorEditFlags.HDR) + | ImGuiColorEditFlags.Hdr) && tmp != inputSqrt) { setter((HalfColor)PseudoSquareRgb(tmp)); @@ -323,10 +373,10 @@ public partial class MtrlTab var tmp = Vector4.Zero; ImUtf8.ColorEdit(label, ref tmp, ImGuiColorEditFlags.NoInputs - | ImGuiColorEditFlags.DisplayRGB - | ImGuiColorEditFlags.InputRGB + | ImGuiColorEditFlags.DisplayRgb + | ImGuiColorEditFlags.InputRgb | ImGuiColorEditFlags.NoTooltip - | ImGuiColorEditFlags.HDR + | ImGuiColorEditFlags.Hdr | ImGuiColorEditFlags.AlphaPreview); if (letter.Length > 0 && ImGui.IsItemVisible()) @@ -544,7 +594,7 @@ public partial class MtrlTab internal static float PseudoSqrtRgb(float x) => x < 0.0f ? -MathF.Sqrt(-x) : MathF.Sqrt(x); - internal static Vector3 PseudoSqrtRgb(Vector3 vec) + public static Vector3 PseudoSqrtRgb(Vector3 vec) => new(PseudoSqrtRgb(vec.X), PseudoSqrtRgb(vec.Y), PseudoSqrtRgb(vec.Z)); internal static Vector4 PseudoSqrtRgb(Vector4 vec) diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs index 176ec3f4..4ad6968b 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs @@ -1,7 +1,8 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using OtterGui.Text.Widget.Editors; diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs index f21d86a9..e75cd633 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Text; using Penumbra.GameData.Files.MaterialStructs; @@ -67,7 +67,7 @@ public partial class MtrlTab private static void DrawLegacyColorTableHeader(bool hasDyeTable) { ImGui.TableNextColumn(); - ImUtf8.TableHeader(default(ReadOnlySpan)); + ImUtf8.TableHeader(""u8); ImGui.TableNextColumn(); ImUtf8.TableHeader("Row"u8); ImGui.TableNextColumn(); @@ -184,7 +184,7 @@ public partial class MtrlTab if (_stainService.LegacyTemplateCombo.Draw("##dyeTemplate", dye.Template.ToString(), string.Empty, intSize + ImGui.GetStyle().ScrollbarSize / 2, ImGui.GetTextLineHeightWithSpacing(), ImGuiComboFlags.NoArrowButton)) { - dyeTable[rowIdx].Template = _stainService.LegacyTemplateCombo.CurrentSelection; + dyeTable[rowIdx].Template = _stainService.LegacyTemplateCombo.CurrentSelection.UShort; ret = true; } @@ -299,7 +299,7 @@ public partial class MtrlTab if (_stainService.LegacyTemplateCombo.Draw("##dyeTemplate", dye.Template.ToString(), string.Empty, intSize + ImGui.GetStyle().ScrollbarSize / 2, ImGui.GetTextLineHeightWithSpacing(), ImGuiComboFlags.NoArrowButton)) { - dyeTable[rowIdx].Template = _stainService.LegacyTemplateCombo.CurrentSelection; + dyeTable[rowIdx].Template = _stainService.LegacyTemplateCombo.CurrentSelection.UShort; ret = true; } diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs index 01a40980..dfa3a963 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; -using ImGuiNET; using OtterGui.Raii; using OtterGui.Text; using Penumbra.GameData.Files.MaterialStructs; @@ -138,7 +138,7 @@ public partial class MtrlTab foreach (var constant in Mtrl.ShaderPackage.Constants) { var values = Mtrl.GetConstantValue(constant); - if (values != null) + if (values != []) SetMaterialParameter(constant.Id, 0, values); } diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs index ae57a122..43040ca3 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs @@ -1,15 +1,17 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using Penumbra.GameData; using Penumbra.GameData.Data; using Penumbra.GameData.Files; using Penumbra.GameData.Files.ShaderStructs; +using Penumbra.Interop.Processing; using Penumbra.String.Classes; using static Penumbra.GameData.Files.ShpkFile; @@ -124,11 +126,15 @@ public partial class MtrlTab private FullPath FindAssociatedShpk(out string defaultPath, out Utf8GamePath defaultGamePath) { - defaultPath = GamePaths.Shader.ShpkPath(Mtrl.ShaderPackage.Name); + defaultPath = GamePaths.Shader(Mtrl.ShaderPackage.Name); if (!Utf8GamePath.FromString(defaultPath, out defaultGamePath)) return FullPath.Empty; - return _edit.FindBestMatch(defaultGamePath); + var path = _edit.FindBestMatch(defaultGamePath); + if (!path.IsRooted || ShpkPathPreProcessor.SanityCheck(path.FullName) == ShpkPathPreProcessor.SanityCheckResult.Success) + return path; + + return new FullPath(defaultPath); } private void LoadShpk(FullPath path) @@ -216,7 +222,7 @@ public partial class MtrlTab else foreach (var (key, index) in Mtrl.ShaderPackage.ShaderKeys.WithIndex()) { - var keyName = Names.KnownNames.TryResolve(key.Category); + var keyName = Names.KnownNames.TryResolve(key.Key); var valueName = keyName.WithKnownSuffixes().TryResolve(Names.KnownNames, key.Value); _shaderKeys.Add((keyName.ToString(), index, string.Empty, true, [(valueName.ToString(), key.Value, string.Empty)])); } @@ -366,6 +372,7 @@ public partial class MtrlTab ret = true; _associatedShpk = null; _loadedShpkPath = FullPath.Empty; + UnpinResources(true); LoadShpk(FindAssociatedShpk(out _, out _)); } @@ -377,7 +384,7 @@ public partial class MtrlTab var shpkFlags = (int)Mtrl.ShaderPackage.Flags; ImGui.SetNextItemWidth(UiHelpers.Scale * 250.0f); if (!ImGui.InputInt("Shader Flags", ref shpkFlags, 0, 0, - ImGuiInputTextFlags.CharsHexadecimal | (disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None))) + flags: ImGuiInputTextFlags.CharsHexadecimal | (disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None))) return false; Mtrl.ShaderPackage.Flags = (uint)shpkFlags; @@ -442,8 +449,8 @@ public partial class MtrlTab { using var font = ImRaii.PushFont(UiBuilder.MonoFont, monoFont); ref var key = ref Mtrl.ShaderPackage.ShaderKeys[index]; - using var id = ImUtf8.PushId((int)key.Category); - var shpkKey = _associatedShpk?.GetMaterialKeyById(key.Category); + using var id = ImUtf8.PushId((int)key.Key); + var shpkKey = _associatedShpk?.GetMaterialKeyById(key.Key); var currentValue = key.Value; var (currentLabel, _, currentDescription) = values.FirstOrNull(v => v.Value == currentValue) ?? ($"0x{currentValue:X8}", currentValue, string.Empty); @@ -459,6 +466,7 @@ public partial class MtrlTab { key.Value = value; ret = true; + UnpinResources(false); Update(); } diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs index 7ab2900d..82ba7be4 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs @@ -1,9 +1,9 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; -using Penumbra.GameData; using Penumbra.GameData.Files.MaterialStructs; using Penumbra.String.Classes; using static Penumbra.GameData.Files.MaterialStructs.SamplerFlags; @@ -16,18 +16,22 @@ public partial class MtrlTab public readonly List<(string Label, int TextureIndex, int SamplerIndex, string Description, bool MonoFont)> Textures = new(4); public readonly HashSet UnfoldedTextures = new(4); + public readonly HashSet TextureIds = new(16); public readonly HashSet SamplerIds = new(16); public float TextureLabelWidth; + private bool _samplersPinned; private void UpdateTextures() { Textures.Clear(); + TextureIds.Clear(); SamplerIds.Clear(); if (_associatedShpk == null) { + TextureIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); SamplerIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); if (Mtrl.Table != null) - SamplerIds.Add(TableSamplerId); + TextureIds.Add(TableSamplerId); foreach (var (sampler, index) in Mtrl.ShaderPackage.Samplers.WithIndex()) Textures.Add(($"0x{sampler.SamplerId:X8}", sampler.TextureIndex, index, string.Empty, true)); @@ -35,31 +39,39 @@ public partial class MtrlTab else { foreach (var index in _vertexShaders) - SamplerIds.UnionWith(_associatedShpk.VertexShaders[index].Samplers.Select(sampler => sampler.Id)); - foreach (var index in _pixelShaders) - SamplerIds.UnionWith(_associatedShpk.PixelShaders[index].Samplers.Select(sampler => sampler.Id)); - if (!_shadersKnown) { - SamplerIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); - if (Mtrl.Table != null) - SamplerIds.Add(TableSamplerId); + TextureIds.UnionWith(_associatedShpk.VertexShaders[index].Textures.Select(texture => texture.Id)); + SamplerIds.UnionWith(_associatedShpk.VertexShaders[index].Samplers.Select(sampler => sampler.Id)); } - foreach (var samplerId in SamplerIds) + foreach (var index in _pixelShaders) { - var shpkSampler = _associatedShpk.GetSamplerById(samplerId); - if (shpkSampler is not { Slot: 2 }) + TextureIds.UnionWith(_associatedShpk.PixelShaders[index].Textures.Select(texture => texture.Id)); + SamplerIds.UnionWith(_associatedShpk.PixelShaders[index].Samplers.Select(sampler => sampler.Id)); + } + + if (_samplersPinned || !_shadersKnown) + { + TextureIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); + if (Mtrl.Table != null) + TextureIds.Add(TableSamplerId); + } + + foreach (var textureId in TextureIds) + { + var shpkTexture = _associatedShpk.GetTextureById(textureId); + if (shpkTexture is not { Slot: 2 } && (shpkTexture is not null || textureId == TableSamplerId)) continue; - var dkData = TryGetShpkDevkitData("Samplers", samplerId, true); + var dkData = TryGetShpkDevkitData("Samplers", textureId, true); var hasDkLabel = !string.IsNullOrEmpty(dkData?.Label); - var sampler = Mtrl.GetOrAddSampler(samplerId, dkData?.DefaultTexture ?? string.Empty, out var samplerIndex); - Textures.Add((hasDkLabel ? dkData!.Label : shpkSampler.Value.Name, sampler.TextureIndex, samplerIndex, + var sampler = Mtrl.GetOrAddSampler(textureId, dkData?.DefaultTexture ?? string.Empty, out var samplerIndex); + Textures.Add((hasDkLabel ? dkData!.Label : shpkTexture!.Value.Name, sampler.TextureIndex, samplerIndex, dkData?.Description ?? string.Empty, !hasDkLabel)); } - if (SamplerIds.Contains(TableSamplerId)) + if (TextureIds.Contains(TableSamplerId)) Mtrl.Table ??= new ColorTable(); } @@ -150,13 +162,13 @@ public partial class MtrlTab } ImGui.TableNextColumn(); - using (var font = ImRaii.PushFont(UiBuilder.MonoFont, monoFont)) - { - ImGui.AlignTextToFramePadding(); - if (description.Length > 0) - ImGuiUtil.LabeledHelpMarker(label, description); - else - ImGui.TextUnformatted(label); + using (ImRaii.PushFont(UiBuilder.MonoFont, monoFont)) + { + ImGui.AlignTextToFramePadding(); + if (description.Length > 0) + ImGuiUtil.LabeledHelpMarker(label, description); + else + ImGui.TextUnformatted(label); } if (unfolded) @@ -205,58 +217,67 @@ public partial class MtrlTab ret = true; } - ref var samplerFlags = ref Wrap(ref sampler.Flags); - - ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); - var addressMode = samplerFlags.UAddressMode; - if (ComboTextureAddressMode("##UAddressMode"u8, ref addressMode)) + if (SamplerIds.Contains(sampler.SamplerId)) { - samplerFlags.UAddressMode = addressMode; - ret = true; - SetSamplerFlags(sampler.SamplerId, sampler.Flags); + ref var samplerFlags = ref Wrap(ref sampler.Flags); + + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + var addressMode = samplerFlags.UAddressMode; + if (ComboTextureAddressMode("##UAddressMode"u8, ref addressMode)) + { + samplerFlags.UAddressMode = addressMode; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("U Address Mode"u8, + "Method to use for resolving a U texture coordinate that is outside the 0 to 1 range."); + + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + addressMode = samplerFlags.VAddressMode; + if (ComboTextureAddressMode("##VAddressMode"u8, ref addressMode)) + { + samplerFlags.VAddressMode = addressMode; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("V Address Mode"u8, + "Method to use for resolving a V texture coordinate that is outside the 0 to 1 range."); + + var lodBias = samplerFlags.LodBias; + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + if (ImUtf8.DragScalar("##LoDBias"u8, ref lodBias, -8.0f, 7.984375f, 0.1f)) + { + samplerFlags.LodBias = lodBias; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("Level of Detail Bias"u8, + "Offset from the calculated mipmap level.\n\nHigher means that the texture will start to lose detail nearer.\nLower means that the texture will keep its detail until farther."); + + var minLod = samplerFlags.MinLod; + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + if (ImUtf8.DragScalar("##MinLoD"u8, ref minLod, 0, 15, 0.1f)) + { + samplerFlags.MinLod = minLod; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("Minimum Level of Detail"u8, + "Most detailed mipmap level to use.\n\n0 is the full-sized texture, 1 is the half-sized texture, 2 is the quarter-sized texture, and so on.\n15 will forcibly reduce the texture to its smallest mipmap."); } - - ImGui.SameLine(); - ImUtf8.LabeledHelpMarker("U Address Mode"u8, "Method to use for resolving a U texture coordinate that is outside the 0 to 1 range."); - - ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); - addressMode = samplerFlags.VAddressMode; - if (ComboTextureAddressMode("##VAddressMode"u8, ref addressMode)) + else { - samplerFlags.VAddressMode = addressMode; - ret = true; - SetSamplerFlags(sampler.SamplerId, sampler.Flags); + ImUtf8.Text("This texture does not have a dedicated sampler."u8); } - ImGui.SameLine(); - ImUtf8.LabeledHelpMarker("V Address Mode"u8, "Method to use for resolving a V texture coordinate that is outside the 0 to 1 range."); - - var lodBias = samplerFlags.LodBias; - ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); - if (ImUtf8.DragScalar("##LoDBias"u8, ref lodBias, -8.0f, 7.984375f, 0.1f)) - { - samplerFlags.LodBias = lodBias; - ret = true; - SetSamplerFlags(sampler.SamplerId, sampler.Flags); - } - - ImGui.SameLine(); - ImUtf8.LabeledHelpMarker("Level of Detail Bias"u8, - "Offset from the calculated mipmap level.\n\nHigher means that the texture will start to lose detail nearer.\nLower means that the texture will keep its detail until farther."); - - var minLod = samplerFlags.MinLod; - ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); - if (ImUtf8.DragScalar("##MinLoD"u8, ref minLod, 0, 15, 0.1f)) - { - samplerFlags.MinLod = minLod; - ret = true; - SetSamplerFlags(sampler.SamplerId, sampler.Flags); - } - - ImGui.SameLine(); - ImUtf8.LabeledHelpMarker("Minimum Level of Detail"u8, - "Most detailed mipmap level to use.\n\n0 is the full-sized texture, 1 is the half-sized texture, 2 is the quarter-sized texture, and so on.\n15 will forcibly reduce the texture to its smallest mipmap."); - using var t = ImUtf8.TreeNode("Advanced Settings"u8); if (!t) return ret; diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs index 6e16de99..2c7c889e 100644 --- a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs @@ -1,5 +1,6 @@ +using Dalamud.Interface.Components; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using OtterGui.Text; @@ -57,6 +58,7 @@ public sealed partial class MtrlTab : IWritable, IDisposable Mtrl = file; FilePath = filePath; Writable = writable; + _samplersPinned = true; _associatedBaseDevkit = TryLoadShpkDevkit("_base", out _loadedBaseDevkitPathName); Update(); LoadShpk(FindAssociatedShpk(out _, out _)); @@ -118,11 +120,22 @@ public sealed partial class MtrlTab : IWritable, IDisposable using var dis = ImRaii.Disabled(disabled); var tmp = shaderFlags.EnableTransparency; - if (ImUtf8.Checkbox("Enable Transparency"u8, ref tmp)) + + // guardrail: the game crashes if transparency is enabled on characterstockings.shpk + var disallowTransparency = Mtrl.ShaderPackage.Name == "characterstockings.shpk"; + using (ImRaii.Disabled(disallowTransparency)) { - shaderFlags.EnableTransparency = tmp; - ret = true; - SetShaderPackageFlags(Mtrl.ShaderPackage.Flags); + if (ImUtf8.Checkbox("Enable Transparency"u8, ref tmp)) + { + shaderFlags.EnableTransparency = tmp; + ret = true; + SetShaderPackageFlags(Mtrl.ShaderPackage.Flags); + } + } + + if (disallowTransparency) + { + ImGuiComponents.HelpMarker("Enabling transparency for shader package characterstockings.shpk will crash the game."); } ImGui.SameLine(200 * UiHelpers.Scale + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X); @@ -172,6 +185,22 @@ public sealed partial class MtrlTab : IWritable, IDisposable Widget.DrawHexViewer(Mtrl.AdditionalData); } + private void UnpinResources(bool all) + { + _samplersPinned = false; + + if (!all) + return; + + var keys = Mtrl.ShaderPackage.ShaderKeys; + for (var i = 0; i < keys.Length; i++) + keys[i].Pinned = false; + + var constants = Mtrl.ShaderPackage.Constants; + for (var i = 0; i < constants.Length; i++) + constants[i].Pinned = false; + } + private void Update() { UpdateShaders(); @@ -187,12 +216,12 @@ public sealed partial class MtrlTab : IWritable, IDisposable } public bool Valid - => _shadersKnown && Mtrl.Valid; + => Mtrl.Valid; // FIXME This should be _shadersKnown && Mtrl.Valid but the algorithm for _shadersKnown is flawed as of 7.2. public byte[] Write() { var output = Mtrl.Clone(); - output.GarbageCollect(_associatedShpk, SamplerIds); + output.GarbageCollect(_associatedShpk, TextureIds); return output.Write(); } diff --git a/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs index 4cf01faa..4a74cda5 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs @@ -1,11 +1,14 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; using OtterGui.Widgets; using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; using Penumbra.GameData.Enums; using Penumbra.GameData.Files; using Penumbra.GameData.Files.AtchStructs; @@ -13,6 +16,7 @@ using Penumbra.Meta; using Penumbra.Meta.Manipulations; using Penumbra.Mods.Editor; using Penumbra.UI.Classes; +using Notification = OtterGui.Classes.Notification; namespace Penumbra.UI.AdvancedWindow.Meta; @@ -27,9 +31,10 @@ public sealed class AtchMetaDrawer : MetaDrawer, ISer public override float ColumnHeight => 2 * ImUtf8.FrameHeightSpacing; - private AtchFile? _currentBaseAtchFile; - private AtchPoint? _currentBaseAtchPoint; - private AtchPointCombo _combo; + private AtchFile? _currentBaseAtchFile; + private AtchPoint? _currentBaseAtchPoint; + private readonly AtchPointCombo _combo; + private string _fileImport = string.Empty; public AtchMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) : base(editor, metaFiles) @@ -44,6 +49,50 @@ public sealed class AtchMetaDrawer : MetaDrawer, ISer => obj.ToName(); } + private sealed class RaceCodeException(string filePath) : Exception($"Could not identify race code from path {filePath}."); + + public void ImportFile(string filePath) + { + try + { + if (filePath.Length == 0 || !File.Exists(filePath)) + throw new FileNotFoundException(); + + var gr = Parser.ParseRaceCode(filePath); + if (gr is GenderRace.Unknown) + throw new RaceCodeException(filePath); + + var text = File.ReadAllBytes(filePath); + var file = new AtchFile(text); + foreach (var point in file.Points) + { + foreach (var (entry, index) in point.Entries.WithIndex()) + { + var identifier = new AtchIdentifier(point.Type, gr, (ushort)index); + var defaultValue = AtchCache.GetDefault(MetaFiles, identifier); + if (defaultValue == null) + continue; + + if (defaultValue.Value.Equals(entry)) + Editor.Changes |= Editor.Remove(identifier); + else + Editor.Changes |= Editor.TryAdd(identifier, entry) || Editor.Update(identifier, entry); + } + } + } + catch (RaceCodeException ex) + { + Penumbra.Messager.AddMessage(new Notification(ex, "The imported .atch file does not contain a race code (cXXXX) in its name.", + "Could not import .atch file:", + NotificationType.Warning)); + } + catch (Exception ex) + { + Penumbra.Messager.AddMessage(new Notification(ex, "Unable to import .atch file.", "Could not import .atch file:", + NotificationType.Warning)); + } + } + protected override void DrawNew() { @@ -118,12 +167,12 @@ public sealed class AtchMetaDrawer : MetaDrawer, ISer private void UpdateFile() { - _currentBaseAtchFile = MetaFiles.AtchManager.AtchFileBase[Identifier.GenderRace]; + _currentBaseAtchFile = MetaFiles.AtchManager.AtchFileBase[Identifier.GenderRace]; _currentBaseAtchPoint = _currentBaseAtchFile.GetPoint(Identifier.Type); if (_currentBaseAtchPoint == null) { _currentBaseAtchPoint = _currentBaseAtchFile.Points.First(); - Identifier = Identifier with { Type = _currentBaseAtchPoint.Type }; + Identifier = Identifier with { Type = _currentBaseAtchPoint.Type }; } if (Identifier.EntryIndex >= _currentBaseAtchPoint.Entries.Length) @@ -238,7 +287,7 @@ public sealed class AtchMetaDrawer : MetaDrawer, ISer if (!ret) return false; - index = Math.Clamp(index, (ushort)0, (ushort)(currentAtchPoint!.Entries.Length - 1)); + index = Math.Clamp(index, (ushort)0, (ushort)(currentAtchPoint.Entries.Length - 1)); identifier = identifier with { EntryIndex = index }; return true; } diff --git a/Penumbra/UI/AdvancedWindow/Meta/AtrMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/AtrMetaDrawer.cs new file mode 100644 index 00000000..4b375c26 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/AtrMetaDrawer.cs @@ -0,0 +1,274 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class AtrMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Attributes(ATR)###ATR"u8; + + private ShapeAttributeString _buffer = ShapeAttributeString.TryRead("atrx_"u8, out var s) ? s : ShapeAttributeString.Empty; + private bool _identifierValid; + + public override int NumColumns + => 7; + + public override float ColumnHeight + => ImUtf8.FrameHeightSpacing; + + protected override void Initialize() + { + Identifier = new AtrIdentifier(HumanSlot.Unknown, null, ShapeAttributeString.Empty, GenderRace.Unknown); + Entry = AtrEntry.True; + } + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current ATR manipulations to clipboard."u8, + new Lazy(() => MetaDictionary.SerializeTo([], Editor.Atr))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier) && _identifierValid; + var tt = canAdd + ? "Stage this edit."u8 + : _identifierValid + ? "This entry does not contain a valid attribute."u8 + : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, AtrEntry.False); + + DrawIdentifierInput(ref Identifier); + DrawEntry(ref Entry, true); + } + + protected override void DrawEntry(AtrIdentifier identifier, AtrEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + if (DrawEntry(ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(AtrIdentifier, AtrEntry)> Enumerate() + => Editor.Atr + .OrderBy(kvp => kvp.Key.Attribute) + .ThenBy(kvp => kvp.Key.Slot) + .ThenBy(kvp => kvp.Key.Id) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Atr.Count; + + private bool DrawIdentifierInput(ref AtrIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawHumanSlot(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawGenderRaceConditionInput(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawAttributeKeyInput(ref identifier, ref _buffer, ref _identifierValid); + return changes; + } + + private static void DrawIdentifier(AtrIdentifier identifier) + { + ImGui.TableNextColumn(); + + ImUtf8.TextFramed(ShpMetaDrawer.SlotName(identifier.Slot), FrameColor); + ImUtf8.HoverTooltip("Model Slot"u8); + + ImGui.TableNextColumn(); + if (identifier.GenderRaceCondition is not GenderRace.Unknown) + { + ImUtf8.TextFramed($"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})", FrameColor); + ImUtf8.HoverTooltip("Gender & Race Code for this attribute to be set."); + } + else + { + ImUtf8.TextFramed("Any Gender & Race"u8, FrameColor); + } + + ImGui.TableNextColumn(); + if (identifier.Id.HasValue) + ImUtf8.TextFramed($"{identifier.Id.Value.Id}", FrameColor); + else + ImUtf8.TextFramed("All IDs"u8, FrameColor); + ImUtf8.HoverTooltip("Primary ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Attribute.AsSpan, FrameColor); + } + + private static bool DrawEntry(ref AtrEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var value = entry.Value; + var changes = ImUtf8.Checkbox("##atrEntry"u8, ref value); + if (changes) + entry = new AtrEntry(value); + ImUtf8.HoverTooltip("Whether to enable or disable this attribute for the selected items."); + return changes; + } + + public static bool DrawPrimaryId(ref AtrIdentifier identifier, float unscaledWidth = 100) + { + var allSlots = identifier.Slot is HumanSlot.Unknown; + var all = !identifier.Id.HasValue; + var ret = false; + using (ImRaii.Disabled(allSlots)) + { + if (ImUtf8.Checkbox("##atrAll"u8, ref all)) + { + identifier = identifier with { Id = all ? null : 0 }; + ret = true; + } + } + + ImUtf8.HoverTooltip(allSlots + ? "When using all slots, you also need to use all IDs."u8 + : "Enable this attribute for all model IDs."u8); + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + if (all) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.05f, 0.5f)); + ImUtf8.TextFramed("All IDs"u8, ImGui.GetColorU32(ImGuiCol.FrameBg, all || allSlots ? ImGui.GetStyle().DisabledAlpha : 1f), + new Vector2(unscaledWidth, 0), ImGui.GetColorU32(ImGuiCol.TextDisabled)); + } + else + { + var max = identifier.Slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1; + if (IdInput("##atrPrimaryId"u8, unscaledWidth, identifier.Id.GetValueOrDefault(0).Id, out var setId, 0, max, false)) + { + identifier = identifier with { Id = setId }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Primary ID - You can usually find this as the 'e####' part of an item path or similar for customizations."u8); + + return ret; + } + + public bool DrawHumanSlot(ref AtrIdentifier identifier, float unscaledWidth = 150) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + using (var combo = ImUtf8.Combo("##atrSlot"u8, ShpMetaDrawer.SlotName(identifier.Slot))) + { + if (combo) + foreach (var slot in ShpMetaDrawer.AvailableSlots) + { + if (!ImUtf8.Selectable(ShpMetaDrawer.SlotName(slot), slot == identifier.Slot) || slot == identifier.Slot) + continue; + + ret = true; + if (slot is HumanSlot.Unknown) + { + identifier = identifier with + { + Id = null, + Slot = slot, + }; + } + else + { + identifier = identifier with + { + Id = identifier.Id.HasValue + ? (PrimaryId)Math.Clamp(identifier.Id.Value.Id, 0, + slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1) + : null, + Slot = slot, + }; + ret = true; + } + } + } + + ImUtf8.HoverTooltip("Model Slot"u8); + return ret; + } + + private static bool DrawGenderRaceConditionInput(ref AtrIdentifier identifier, float unscaledWidth = 250) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + + using (var combo = ImUtf8.Combo("##shpGenderRace"u8, + identifier.GenderRaceCondition is GenderRace.Unknown + ? "Any Gender & Race" + : $"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})")) + { + if (combo) + { + if (ImUtf8.Selectable("Any Gender & Race"u8, identifier.GenderRaceCondition is GenderRace.Unknown) + && identifier.GenderRaceCondition is not GenderRace.Unknown) + { + identifier = identifier with { GenderRaceCondition = GenderRace.Unknown }; + ret = true; + } + + foreach (var gr in ShapeAttributeHashSet.GenderRaceValues.Skip(1)) + { + if (ImUtf8.Selectable($"{gr.ToName()} ({gr.ToRaceCode()})", identifier.GenderRaceCondition == gr) + && identifier.GenderRaceCondition != gr) + { + identifier = identifier with { GenderRaceCondition = gr }; + ret = true; + } + } + } + } + + ImUtf8.HoverTooltip( + "Only activate this attribute for this gender & race code."u8); + + return ret; + } + + public static unsafe bool DrawAttributeKeyInput(ref AtrIdentifier identifier, ref ShapeAttributeString buffer, ref bool valid, + float unscaledWidth = 150) + { + var ret = false; + var ptr = Unsafe.AsPointer(ref buffer); + var span = new Span(ptr, ShapeAttributeString.MaxLength + 1); + using (new ImRaii.ColorStyle().Push(ImGuiCol.Border, Colors.RegexWarningBorder, !valid).Push(ImGuiStyleVar.FrameBorderSize, 1f, !valid)) + { + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + if (ImUtf8.InputText("##atrAttribute"u8, span, out int newLength, "Attribute..."u8)) + { + buffer.ForceLength((byte)newLength); + valid = buffer.ValidateCustomAttributeString(); + if (valid) + identifier = identifier with { Attribute = buffer }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Supported attribute need to have the format `atrx_*` and a maximum length of 30 characters."u8); + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs index 348a0d4c..16af5217 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs index d6df95cb..77c2915a 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs @@ -1,5 +1,5 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui.Raii; using OtterGui.Services; diff --git a/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs index e5e28a3d..84e09be5 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs index 929feadd..b03f4aa5 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs @@ -1,5 +1,5 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs index 3691a4f7..4053560b 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using OtterGui.Text; using Penumbra.GameData.Structs; diff --git a/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs index 34488a87..bb87cd47 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs @@ -1,5 +1,5 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui.Raii; using OtterGui.Services; diff --git a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs index 4c9142d8..f608a194 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs @@ -1,5 +1,5 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui; using OtterGui.Raii; @@ -44,9 +44,13 @@ public abstract class MetaDrawer(ModMetaEditor editor, Meta DrawNew(); var height = ColumnHeight; - var skips = ImGuiClip.GetNecessarySkipsAtPos(height, ImGui.GetCursorPosY()); - var remainder = ImGuiClip.ClippedTableDraw(Enumerate(), skips, DrawLine, Count); - ImGuiClip.DrawEndDummy(remainder, height); + var skips = ImGuiClip.GetNecessarySkipsAtPos(height, ImGui.GetCursorPosY(), Count); + if (skips < Count) + { + var remainder = ImGuiClip.ClippedTableDraw(Enumerate(), skips, DrawLine, Count); + if (remainder > 0) + ImGuiClip.DrawEndDummy(remainder, height); + } void DrawLine((TIdentifier Identifier, TEntry Value) pair) => DrawEntry(pair.Identifier, pair.Value); @@ -154,7 +158,7 @@ public abstract class MetaDrawer(ModMetaEditor editor, Meta if (!ImUtf8.IconButton(FontAwesomeIcon.Clipboard, tooltip)) return; - var text = Functions.ToCompressedBase64(manipulations, MetaApi.CurrentVersion); + var text = Functions.ToCompressedBase64(manipulations.Value, 0); if (text.Length > 0) ImGui.SetClipboardText(text); } diff --git a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs index d1c7cd52..792611e2 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs @@ -11,7 +11,9 @@ public class MetaDrawers( GmpMetaDrawer gmp, ImcMetaDrawer imc, RspMetaDrawer rsp, - AtchMetaDrawer atch) : IService + AtchMetaDrawer atch, + ShpMetaDrawer shp, + AtrMetaDrawer atr) : IService { public readonly EqdpMetaDrawer Eqdp = eqdp; public readonly EqpMetaDrawer Eqp = eqp; @@ -21,6 +23,8 @@ public class MetaDrawers( public readonly ImcMetaDrawer Imc = imc; public readonly GlobalEqpMetaDrawer GlobalEqp = globalEqp; public readonly AtchMetaDrawer Atch = atch; + public readonly ShpMetaDrawer Shp = shp; + public readonly AtrMetaDrawer Atr = atr; public IMetaDrawer? Get(MetaManipulationType type) => type switch @@ -32,6 +36,8 @@ public class MetaDrawers( MetaManipulationType.Gmp => Gmp, MetaManipulationType.Rsp => Rsp, MetaManipulationType.Atch => Atch, + MetaManipulationType.Shp => Shp, + MetaManipulationType.Atr => Atr, MetaManipulationType.GlobalEqp => GlobalEqp, _ => null, }; diff --git a/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs index d60f877b..88abe0cb 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Newtonsoft.Json.Linq; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs new file mode 100644 index 00000000..59692195 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs @@ -0,0 +1,366 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class ShpMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Shape Keys (SHP)###SHP"u8; + + private ShapeAttributeString _buffer = ShapeAttributeString.TryRead("shpx_"u8, out var s) ? s : ShapeAttributeString.Empty; + private bool _identifierValid; + + public override int NumColumns + => 8; + + public override float ColumnHeight + => ImUtf8.FrameHeightSpacing; + + protected override void Initialize() + { + Identifier = new ShpIdentifier(HumanSlot.Unknown, null, ShapeAttributeString.Empty, ShapeConnectorCondition.None, GenderRace.Unknown); + } + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current SHP manipulations to clipboard."u8, + new Lazy(() => MetaDictionary.SerializeTo([], Editor.Shp))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier) && _identifierValid; + var tt = canAdd + ? "Stage this edit."u8 + : _identifierValid + ? "This entry does not contain a valid shape key."u8 + : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, ShpEntry.True); + + DrawIdentifierInput(ref Identifier); + DrawEntry(ref Entry, true); + } + + protected override void DrawEntry(ShpIdentifier identifier, ShpEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + if (DrawEntry(ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(ShpIdentifier, ShpEntry)> Enumerate() + => Editor.Shp + .OrderBy(kvp => kvp.Key.Shape) + .ThenBy(kvp => kvp.Key.Slot) + .ThenBy(kvp => kvp.Key.Id) + .ThenBy(kvp => kvp.Key.ConnectorCondition) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Shp.Count; + + private bool DrawIdentifierInput(ref ShpIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawHumanSlot(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawGenderRaceConditionInput(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawShapeKeyInput(ref identifier, ref _buffer, ref _identifierValid); + + ImGui.TableNextColumn(); + changes |= DrawConnectorConditionInput(ref identifier); + return changes; + } + + private static void DrawIdentifier(ShpIdentifier identifier) + { + ImGui.TableNextColumn(); + + ImUtf8.TextFramed(SlotName(identifier.Slot), FrameColor); + ImUtf8.HoverTooltip("Model Slot"u8); + + ImGui.TableNextColumn(); + if (identifier.GenderRaceCondition is not GenderRace.Unknown) + { + ImUtf8.TextFramed($"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})", FrameColor); + ImUtf8.HoverTooltip("Gender & Race Code for this shape key to be set."); + } + else + { + ImUtf8.TextFramed("Any Gender & Race"u8, FrameColor); + } + + ImGui.TableNextColumn(); + if (identifier.Id.HasValue) + ImUtf8.TextFramed($"{identifier.Id.Value.Id}", FrameColor); + else + ImUtf8.TextFramed("All IDs"u8, FrameColor); + ImUtf8.HoverTooltip("Primary ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Shape.AsSpan, FrameColor); + + ImGui.TableNextColumn(); + if (identifier.ConnectorCondition is not ShapeConnectorCondition.None) + { + ImUtf8.TextFramed($"{identifier.ConnectorCondition}", FrameColor); + ImUtf8.HoverTooltip("Connector condition for this shape to be activated."); + } + } + + private static bool DrawEntry(ref ShpEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var value = entry.Value; + var changes = ImUtf8.Checkbox("##shpEntry"u8, ref value); + if (changes) + entry = new ShpEntry(value); + ImUtf8.HoverTooltip("Whether to enable or disable this shape key for the selected items."); + return changes; + } + + public static bool DrawPrimaryId(ref ShpIdentifier identifier, float unscaledWidth = 100) + { + var allSlots = identifier.Slot is HumanSlot.Unknown; + var all = !identifier.Id.HasValue; + var ret = false; + using (ImRaii.Disabled(allSlots)) + { + if (ImUtf8.Checkbox("##shpAll"u8, ref all)) + { + identifier = identifier with { Id = all ? null : 0 }; + ret = true; + } + } + + ImUtf8.HoverTooltip(allSlots ? "When using all slots, you also need to use all IDs."u8 : "Enable this shape key for all model IDs."u8); + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + if (all) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.05f, 0.5f)); + ImUtf8.TextFramed("All IDs"u8, ImGui.GetColorU32(ImGuiCol.FrameBg, all || allSlots ? ImGui.GetStyle().DisabledAlpha : 1f), + new Vector2(unscaledWidth, 0), ImGui.GetColorU32(ImGuiCol.TextDisabled)); + } + else + { + var max = identifier.Slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1; + if (IdInput("##shpPrimaryId"u8, unscaledWidth, identifier.Id.GetValueOrDefault(0).Id, out var setId, 0, max, false)) + { + identifier = identifier with { Id = setId }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Primary ID - You can usually find this as the 'e####' part of an item path or similar for customizations."u8); + + return ret; + } + + public bool DrawHumanSlot(ref ShpIdentifier identifier, float unscaledWidth = 170) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + using (var combo = ImUtf8.Combo("##shpSlot"u8, SlotName(identifier.Slot))) + { + if (combo) + foreach (var slot in AvailableSlots) + { + if (!ImUtf8.Selectable(SlotName(slot), slot == identifier.Slot) || slot == identifier.Slot) + continue; + + ret = true; + if (slot is HumanSlot.Unknown) + { + identifier = identifier with + { + Id = null, + Slot = slot, + }; + } + else + { + identifier = identifier with + { + Id = identifier.Id.HasValue + ? (PrimaryId)Math.Clamp(identifier.Id.Value.Id, 0, + slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1) + : null, + Slot = slot, + ConnectorCondition = Identifier.ConnectorCondition switch + { + ShapeConnectorCondition.Wrists when slot is HumanSlot.Body or HumanSlot.Hands => ShapeConnectorCondition.Wrists, + ShapeConnectorCondition.Waist when slot is HumanSlot.Body or HumanSlot.Legs => ShapeConnectorCondition.Waist, + ShapeConnectorCondition.Ankles when slot is HumanSlot.Legs or HumanSlot.Feet => ShapeConnectorCondition.Ankles, + _ => ShapeConnectorCondition.None, + }, + }; + ret = true; + } + } + } + + ImUtf8.HoverTooltip("Model Slot"u8); + return ret; + } + + public static unsafe bool DrawShapeKeyInput(ref ShpIdentifier identifier, ref ShapeAttributeString buffer, ref bool valid, + float unscaledWidth = 200) + { + var ret = false; + var ptr = Unsafe.AsPointer(ref buffer); + var span = new Span(ptr, ShapeAttributeString.MaxLength + 1); + using (new ImRaii.ColorStyle().Push(ImGuiCol.Border, Colors.RegexWarningBorder, !valid).Push(ImGuiStyleVar.FrameBorderSize, 1f, !valid)) + { + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + if (ImUtf8.InputText("##shpShape"u8, span, out int newLength, "Shape Key..."u8)) + { + buffer.ForceLength((byte)newLength); + valid = buffer.ValidateCustomShapeString(); + if (valid) + identifier = identifier with { Shape = buffer }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Supported shape keys need to have the format `shpx_*` and a maximum length of 30 characters."u8); + return ret; + } + + private static bool DrawConnectorConditionInput(ref ShpIdentifier identifier, float unscaledWidth = 80) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + var (showWrists, showWaist, showAnkles, disable) = identifier.Slot switch + { + HumanSlot.Unknown => (true, true, true, false), + HumanSlot.Body => (true, true, false, false), + HumanSlot.Legs => (false, true, true, false), + HumanSlot.Hands => (true, false, false, false), + HumanSlot.Feet => (false, false, true, false), + _ => (false, false, false, true), + }; + using var disabled = ImRaii.Disabled(disable); + using (var combo = ImUtf8.Combo("##shpCondition"u8, $"{identifier.ConnectorCondition}")) + { + if (combo) + { + if (ImUtf8.Selectable("None"u8, identifier.ConnectorCondition is ShapeConnectorCondition.None)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.None }; + + if (showWrists && ImUtf8.Selectable("Wrists"u8, identifier.ConnectorCondition is ShapeConnectorCondition.Wrists)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.Wrists }; + + if (showWaist && ImUtf8.Selectable("Waist"u8, identifier.ConnectorCondition is ShapeConnectorCondition.Waist)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.Waist }; + + if (showAnkles && ImUtf8.Selectable("Ankles"u8, identifier.ConnectorCondition is ShapeConnectorCondition.Ankles)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.Ankles }; + } + } + + ImUtf8.HoverTooltip( + "Only activate this shape key if any custom connector shape keys (shpx_[wr|wa|an]_*) are also enabled through matching attributes."u8); + return ret; + } + + private static bool DrawGenderRaceConditionInput(ref ShpIdentifier identifier, float unscaledWidth = 250) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + + using (var combo = ImUtf8.Combo("##shpGenderRace"u8, identifier.GenderRaceCondition is GenderRace.Unknown + ? "Any Gender & Race" + : $"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})")) + { + if (combo) + { + if (ImUtf8.Selectable("Any Gender & Race"u8, identifier.GenderRaceCondition is GenderRace.Unknown) + && identifier.GenderRaceCondition is not GenderRace.Unknown) + { + identifier = identifier with { GenderRaceCondition = GenderRace.Unknown }; + ret = true; + } + + foreach (var gr in ShapeAttributeHashSet.GenderRaceValues.Skip(1)) + { + if (ImUtf8.Selectable($"{gr.ToName()} ({gr.ToRaceCode()})", identifier.GenderRaceCondition == gr) + && identifier.GenderRaceCondition != gr) + { + identifier = identifier with { GenderRaceCondition = gr }; + ret = true; + } + } + } + } + + ImUtf8.HoverTooltip( + "Only activate this shape key for this gender & race code."u8); + + return ret; + } + + public static ReadOnlySpan AvailableSlots + => + [ + HumanSlot.Unknown, + HumanSlot.Head, + HumanSlot.Body, + HumanSlot.Hands, + HumanSlot.Legs, + HumanSlot.Feet, + HumanSlot.Ears, + HumanSlot.Neck, + HumanSlot.Wrists, + HumanSlot.RFinger, + HumanSlot.LFinger, + HumanSlot.Glasses, + HumanSlot.Hair, + HumanSlot.Face, + HumanSlot.Ear, + ]; + + public static ReadOnlySpan SlotName(HumanSlot slot) + => slot switch + { + HumanSlot.Unknown => "All Slots"u8, + HumanSlot.Head => "Equipment: Head"u8, + HumanSlot.Body => "Equipment: Body"u8, + HumanSlot.Hands => "Equipment: Hands"u8, + HumanSlot.Legs => "Equipment: Legs"u8, + HumanSlot.Feet => "Equipment: Feet"u8, + HumanSlot.Ears => "Equipment: Ears"u8, + HumanSlot.Neck => "Equipment: Neck"u8, + HumanSlot.Wrists => "Equipment: Wrists"u8, + HumanSlot.RFinger => "Equipment: Right Finger"u8, + HumanSlot.LFinger => "Equipment: Left Finger"u8, + HumanSlot.Glasses => "Equipment: Glasses"u8, + HumanSlot.Hair => "Customization: Hair"u8, + HumanSlot.Face => "Customization: Face"u8, + HumanSlot.Ear => "Customization: Ears"u8, + _ => "Unknown"u8, + }; +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs index 258e51ff..4f7ae8da 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs @@ -1,8 +1,9 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Text; using Penumbra.GameData.Data; using Penumbra.GameData.Enums; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs index b07633b6..63c99b8a 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs @@ -1,8 +1,8 @@ -using System.Linq; using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using Penumbra.Mods.Editor; @@ -15,6 +15,7 @@ namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { private readonly HashSet _selectedFiles = new(256); + private readonly HashSet _cutPaths = []; private LowerString _fileFilter = LowerString.Empty; private bool _showGamePaths = true; private string _gamePathEdit = string.Empty; @@ -125,7 +126,7 @@ public partial class ModEditWindow using var id = ImRaii.PushId(i); ImGui.TableNextColumn(); - DrawSelectable(registry); + DrawSelectable(registry, i); if (!_showGamePaths) continue; @@ -177,24 +178,79 @@ public partial class ModEditWindow } } - private void DrawSelectable(FileRegistry registry) + private void DrawSelectable(FileRegistry registry, int i) { var selected = _selectedFiles.Contains(registry); var color = registry.SubModUsage.Count == 0 ? ColorId.ConflictingMod : registry.CurrentUsage == registry.SubModUsage.Count ? ColorId.NewMod : ColorId.InheritedMod; - using var c = ImRaii.PushColor(ImGuiCol.Text, color.Value()); - if (UiHelpers.Selectable(registry.RelPath.Path, selected)) + using (ImRaii.PushColor(ImGuiCol.Text, color.Value())) { - if (selected) - _selectedFiles.Remove(registry); - else - _selectedFiles.Add(registry); + if (UiHelpers.Selectable(registry.RelPath.Path, selected)) + { + if (selected) + _selectedFiles.Remove(registry); + else + _selectedFiles.Add(registry); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup("context"u8); + + var rightText = DrawFileTooltip(registry, color); + + ImGui.SameLine(); + ImGuiUtil.RightAlign(rightText); } - var rightText = DrawFileTooltip(registry, color); + DrawContextMenu(registry, i); + } - ImGui.SameLine(); - ImGuiUtil.RightAlign(rightText); + private void DrawContextMenu(FileRegistry registry, int i) + { + using var context = ImUtf8.Popup("context"u8); + if (!context) + return; + + if (ImUtf8.Selectable("Copy Full File Path")) + ImUtf8.SetClipboardText(registry.File.FullName); + + using (ImRaii.Disabled(registry.CurrentUsage == 0)) + { + if (ImUtf8.Selectable("Copy Game Paths"u8)) + { + _cutPaths.Clear(); + for (var j = 0; j < registry.SubModUsage.Count; ++j) + { + if (registry.SubModUsage[j].Item1 != _editor.Option) + continue; + + _cutPaths.Add(registry.SubModUsage[j].Item2); + } + } + } + + using (ImRaii.Disabled(registry.CurrentUsage == 0)) + { + if (ImUtf8.Selectable("Cut Game Paths"u8)) + { + _cutPaths.Clear(); + for (var j = 0; j < registry.SubModUsage.Count; ++j) + { + if (registry.SubModUsage[j].Item1 != _editor.Option) + continue; + + _cutPaths.Add(registry.SubModUsage[j].Item2); + _editor.FileEditor.SetGamePath(_editor.Option, i, j--, Utf8GamePath.Empty); + } + } + } + + using (ImRaii.Disabled(_cutPaths.Count == 0)) + { + if (ImUtf8.Selectable("Paste Game Paths"u8)) + foreach (var path in _cutPaths) + _editor.FileEditor.SetGamePath(_editor.Option!, i, -1, path); + } } private void PrintGamePath(int i, int j, FileRegistry registry, IModDataContainer subMod, Utf8GamePath gamePath) @@ -231,6 +287,17 @@ public partial class ModEditWindow using var font = ImRaii.PushFont(UiBuilder.IconFont); ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); } + else if (tmp.Length > 0 && Path.GetExtension(tmp) != registry.File.Extension) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF00B0B0, FontAwesomeIcon.ExclamationCircle.ToIconString()); + } + + ImUtf8.HoverTooltip("The game path and the file do not have the same extension."u8); + } } private void PrintNewGamePath(int i, FileRegistry registry, IModDataContainer subMod) @@ -263,6 +330,17 @@ public partial class ModEditWindow using var font = ImRaii.PushFont(UiBuilder.IconFont); ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); } + else if (tmp.Length > 0 && Path.GetExtension(tmp) != registry.File.Extension) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF00B0B0, FontAwesomeIcon.ExclamationCircle.ToIconString()); + } + + ImUtf8.HoverTooltip("The game path and the file do not have the same extension."u8); + } } private void DrawButtonHeader() diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs index 59b38465..3caff226 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs @@ -1,7 +1,7 @@ using Dalamud.Interface; using Dalamud.Interface.Utility; -using ImGuiNET; -using OtterGui; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using Penumbra.UI.AdvancedWindow.Materials; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs index 22271d38..06cd0763 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs @@ -1,9 +1,11 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using OtterGui.Text; using Penumbra.Api.Api; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; using Penumbra.Meta.Manipulations; using Penumbra.UI.AdvancedWindow.Meta; using Penumbra.UI.Classes; @@ -43,8 +45,10 @@ public partial class ModEditWindow if (ImUtf8.Button("Write as TexTools Files"u8)) _metaFileManager.WriteAllTexToolsMeta(Mod!); ImGui.SameLine(); - if (ImUtf8.ButtonEx("Remove All Default-Values", "Delete any entries from all lists that set the value to its default value."u8)) + if (ImUtf8.ButtonEx("Remove All Default-Values"u8, "Delete any entries from all lists that set the value to its default value."u8)) _editor.MetaEditor.DeleteDefaultValues(); + ImGui.SameLine(); + DrawAtchDragDrop(); using var child = ImRaii.Child("##meta", -Vector2.One, true); if (!child) @@ -57,9 +61,52 @@ public partial class ModEditWindow DrawEditHeader(MetaManipulationType.Gmp); DrawEditHeader(MetaManipulationType.Rsp); DrawEditHeader(MetaManipulationType.Atch); + DrawEditHeader(MetaManipulationType.Shp); + DrawEditHeader(MetaManipulationType.Atr); DrawEditHeader(MetaManipulationType.GlobalEqp); } + private void DrawAtchDragDrop() + { + _dragDropManager.CreateImGuiSource("atchDrag", f => f.Extensions.Contains(".atch"), f => + { + var gr = Parser.ParseRaceCode(f.Files.FirstOrDefault() ?? string.Empty); + if (gr is GenderRace.Unknown) + return false; + + ImUtf8.Text($"Dragging .atch for {gr.ToName()}..."); + return true; + }); + var hasAtch = _editor.Files.Atch.Count > 0; + if (ImUtf8.ButtonEx("Import .atch"u8, + _dragDropManager.IsDragging + ? ""u8 + : hasAtch + ? "Drag a .atch file containing its race code in the path here to import its values.\n\nClick to select an .atch file from the mod."u8 + : "Drag a .atch file containing its race code in the path here to import its values."u8, default, + !_dragDropManager.IsDragging && !hasAtch) + && hasAtch) + ImUtf8.OpenPopup("##atchPopup"u8); + if (_dragDropManager.CreateImGuiTarget("atchDrag", out var files, out _) && files.FirstOrDefault() is { } file) + _metaDrawers.Atch.ImportFile(file); + + using var popup = ImUtf8.Popup("##atchPopup"u8); + if (!popup) + return; + + if (!hasAtch) + { + ImGui.CloseCurrentPopup(); + return; + } + + foreach (var atchFile in _editor.Files.Atch) + { + if (ImUtf8.Selectable(atchFile.RelPath.Path.Span) && atchFile.File.Exists) + _metaDrawers.Atch.ImportFile(atchFile.File.FullName); + } + } + private void DrawEditHeader(MetaManipulationType type) { var drawer = _metaDrawers.Get(type); @@ -111,43 +158,41 @@ public partial class ModEditWindow if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Clipboard.ToIconString(), iconSize, tooltip, false, true)) return; - var text = Functions.ToCompressedBase64(manipulations, MetaApi.CurrentVersion); + var text = Functions.ToCompressedBase64(manipulations, 0); if (text.Length > 0) ImGui.SetClipboardText(text); } private void AddFromClipboardButton() { - if (ImGui.Button("Add from Clipboard")) + if (ImUtf8.Button("Add from Clipboard"u8)) { var clipboard = ImGuiUtil.GetClipboardText(); - var version = Functions.FromCompressedBase64(clipboard, out var manips); - if (version == MetaApi.CurrentVersion && manips != null) + if (MetaApi.ConvertManips(clipboard, out var manips, out _)) { _editor.MetaEditor.UpdateTo(manips); _editor.MetaEditor.Changes = true; } } - ImGuiUtil.HoverTooltip( - "Try to add meta manipulations currently stored in the clipboard to the current manipulations.\nOverwrites already existing manipulations."); + ImUtf8.HoverTooltip( + "Try to add meta manipulations currently stored in the clipboard to the current manipulations.\nOverwrites already existing manipulations."u8); } private void SetFromClipboardButton() { - if (ImGui.Button("Set from Clipboard")) + if (ImUtf8.Button("Set from Clipboard"u8)) { var clipboard = ImGuiUtil.GetClipboardText(); - var version = Functions.FromCompressedBase64(clipboard, out var manips); - if (version == MetaApi.CurrentVersion && manips != null) + if (MetaApi.ConvertManips(clipboard, out var manips, out _)) { _editor.MetaEditor.SetTo(manips); _editor.MetaEditor.Changes = true; } } - ImGuiUtil.HoverTooltip( - "Try to set the current meta manipulations to the set currently stored in the clipboard.\nRemoves all other manipulations."); + ImUtf8.HoverTooltip( + "Try to set the current meta manipulations to the set currently stored in the clipboard.\nRemoves all other manipulations."u8); } } diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs index b436448f..fc197bc0 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -1,4 +1,4 @@ -using OtterGui; +using OtterGui.Extensions; using Penumbra.GameData; using Penumbra.GameData.Files; using Penumbra.Import.Models; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index de088736..a7db7f25 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -1,14 +1,16 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; -using ImGuiNET; using Lumina.Data.Parsing; using OtterGui; using OtterGui.Custom; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using OtterGui.Widgets; using Penumbra.GameData; using Penumbra.GameData.Files; using Penumbra.Import.Models; +using Penumbra.Import.Models.Import; using Penumbra.String.Classes; using Penumbra.UI.Classes; @@ -16,7 +18,7 @@ namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { - private const int MdlMaterialMaximum = 4; + private const int MdlMaterialMaximum = ModelImporter.MaterialLimit; private const string MdlImportDocumentation = @"https://github.com/xivdev/Penumbra/wiki/Model-IO#user-content-9b49d296-23ab-410a-845b-a3be769b71ea"; @@ -29,19 +31,18 @@ public partial class ModEditWindow private class LoadedData { - public MdlFile LastFile = null!; + public MdlFile LastFile = null!; public readonly List SubMeshAttributeTags = []; public long[] LodTriCount = []; } - private string _modelNewMaterial = string.Empty; + private string _modelNewMaterial = string.Empty; private readonly LoadedData _main = new(); private readonly LoadedData _preview = new(); - private string _customPath = string.Empty; - private Utf8GamePath _customGamePath = Utf8GamePath.Empty; - + private string _customPath = string.Empty; + private Utf8GamePath _customGamePath = Utf8GamePath.Empty; private LoadedData UpdateFile(MdlFile file, bool force, bool disabled) @@ -66,7 +67,7 @@ public partial class ModEditWindow private bool DrawModelPanel(MdlTab tab, bool disabled) { - var ret = tab.Dirty; + var ret = tab.Dirty; var data = UpdateFile(tab.Mdl, ret, disabled); DrawVersionUpdate(tab, disabled); DrawImportExport(tab, disabled); @@ -87,13 +88,14 @@ public partial class ModEditWindow if (disabled || tab.Mdl.Version is not MdlFile.V5) return; - if (!ImUtf8.ButtonEx("Update MDL Version from V5 to V6"u8, "Try using this if the bone weights of a pre-Dawntrail model seem wrong.\n\nThis is not revertible."u8, + if (!ImUtf8.ButtonEx("Update MDL Version from V5 to V6"u8, + "Try using this if the bone weights of a pre-Dawntrail model seem wrong.\n\nThis is not revertible."u8, new Vector2(-0.1f, 0), false, 0, Colors.PressEnterWarningBg)) return; tab.Mdl.ConvertV5ToV6(); _modelTab.SaveFile(); - } + } private void DrawImportExport(MdlTab tab, bool disabled) { @@ -348,7 +350,7 @@ public partial class ModEditWindow if (!disabled) { ImGui.TableSetupColumn("actions", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); - ImGui.TableSetupColumn("help", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + ImGui.TableSetupColumn("help", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); } var inputFlags = disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None; @@ -367,10 +369,11 @@ public partial class ModEditWindow ImGui.TableNextColumn(); if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), UiHelpers.IconButtonSize, string.Empty, !validName, true)) { - ret |= true; - tab.Mdl.Materials = materials.AddItem(_modelNewMaterial); - _modelNewMaterial = string.Empty; + ret |= true; + tab.Mdl.Materials = materials.AddItem(_modelNewMaterial); + _modelNewMaterial = string.Empty; } + ImGui.TableNextColumn(); if (!validName && _modelNewMaterial.Length > 0) DrawInvalidMaterialMarker(); @@ -421,14 +424,16 @@ public partial class ModEditWindow // Add markers to invalid materials. if (!tab.ValidateMaterial(temp)) DrawInvalidMaterialMarker(); - + return ret; } private static void DrawInvalidMaterialMarker() { - using (ImRaii.PushFont(UiBuilder.IconFont)) + using (ImRaii.PushFont(UiBuilder.IconFont)) + { ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); + } ImGuiUtil.HoverTooltip( "Materials must be either relative (e.g. \"/filename.mtrl\")\n" @@ -496,11 +501,11 @@ public partial class ModEditWindow using var node = ImRaii.TreeNode($"Click to expand"); if (!node) return; - + var flags = ImGuiTableFlags.SizingFixedFit - | ImGuiTableFlags.RowBg - | ImGuiTableFlags.Borders - | ImGuiTableFlags.NoHostExtendX; + | ImGuiTableFlags.RowBg + | ImGuiTableFlags.Borders + | ImGuiTableFlags.NoHostExtendX; using var table = ImRaii.Table(string.Empty, 4, flags); if (!table) return; @@ -588,6 +593,7 @@ public partial class ModEditWindow if (!header) return false; + var ret = false; using (var table = ImRaii.Table("##data", 2, ImGuiTableFlags.SizingFixedFit)) { if (table) @@ -648,22 +654,49 @@ public partial class ModEditWindow using (var attributes = ImRaii.TreeNode("Attributes", ImGuiTreeNodeFlags.DefaultOpen)) { if (attributes) - foreach (var attribute in data.LastFile.Attributes) - ImRaii.TreeNode(attribute, ImGuiTreeNodeFlags.Leaf).Dispose(); + for (var i = 0; i < data.LastFile.Attributes.Length; ++i) + { + using var id = ImUtf8.PushId(i); + ref var attribute = ref data.LastFile.Attributes[i]; + var name = attribute; + if (ImUtf8.InputText("##attribute"u8, ref name, "Attribute Name..."u8) && name.Length > 0 && name != attribute) + { + attribute = name; + ret = true; + } + } } using (var bones = ImRaii.TreeNode("Bones", ImGuiTreeNodeFlags.DefaultOpen)) { if (bones) - foreach (var bone in data.LastFile.Bones) - ImRaii.TreeNode(bone, ImGuiTreeNodeFlags.Leaf).Dispose(); + for (var i = 0; i < data.LastFile.Bones.Length; ++i) + { + using var id = ImUtf8.PushId(i); + ref var bone = ref data.LastFile.Bones[i]; + var name = bone; + if (ImUtf8.InputText("##bone"u8, ref name, "Bone Name..."u8) && name.Length > 0 && name != bone) + { + bone = name; + ret = true; + } + } } using (var shapes = ImRaii.TreeNode("Shapes", ImGuiTreeNodeFlags.DefaultOpen)) { if (shapes) - foreach (var shape in data.LastFile.Shapes) - ImRaii.TreeNode(shape.ShapeName, ImGuiTreeNodeFlags.Leaf).Dispose(); + for (var i = 0; i < data.LastFile.Shapes.Length; ++i) + { + using var id = ImUtf8.PushId(i); + ref var shape = ref data.LastFile.Shapes[i]; + var name = shape.ShapeName; + if (ImUtf8.InputText("##shape"u8, ref name, "Shape Name..."u8) && name.Length > 0 && name != shape.ShapeName) + { + shape.ShapeName = name; + ret = true; + } + } } if (data.LastFile.RemainingData.Length > 0) @@ -673,7 +706,7 @@ public partial class ModEditWindow Widget.DrawHexViewer(data.LastFile.RemainingData); } - return false; + return ret; } private static bool GetFirstModel(IEnumerable files, [NotNullWhen(true)] out string? file) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs index 6fb223df..f55ae576 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs @@ -1,8 +1,7 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Lumina.Data; -using OtterGui; -using OtterGui.Raii; +using OtterGui.Text; using Penumbra.Api.Enums; using Penumbra.GameData.Files; using Penumbra.Interop.ResourceTree; @@ -18,7 +17,6 @@ public partial class ModEditWindow private readonly FileDialogService _fileDialog; private readonly ResourceTreeFactory _resourceTreeFactory; private readonly ResourceTreeViewer _quickImportViewer; - private readonly Dictionary _quickImportWritables = new(); private readonly Dictionary<(Utf8GamePath, IWritable?), QuickImportAction> _quickImportActions = new(); private HashSet GetPlayerResourcesOfType(ResourceType type) @@ -43,7 +41,7 @@ public partial class ModEditWindow private void DrawQuickImportTab() { - using var tab = ImRaii.TabItem("Import from Screen"); + using var tab = ImUtf8.TabItem("Import from Screen"u8); if (!tab) { _quickImportActions.Clear(); @@ -57,52 +55,11 @@ public partial class ModEditWindow private void OnQuickImportRefresh() { - _quickImportWritables.Clear(); _quickImportActions.Clear(); } - private void DrawQuickImportActions(ResourceNode resourceNode, Vector2 buttonSize) + private void DrawQuickImportActions(ResourceNode resourceNode, IWritable? writable, Vector2 buttonSize) { - if (!_quickImportWritables!.TryGetValue(resourceNode.FullPath, out var writable)) - { - var path = resourceNode.FullPath.ToPath(); - if (resourceNode.FullPath.IsRooted) - { - writable = new RawFileWritable(path); - } - else - { - var file = _gameData.GetFile(path); - writable = file == null ? null : new RawGameFileWritable(file); - } - - _quickImportWritables.Add(resourceNode.FullPath, writable); - } - - if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Save.ToIconString(), buttonSize, "Export this file.", - resourceNode.FullPath.FullName.Length == 0 || writable == null, true)) - { - var fullPathStr = resourceNode.FullPath.FullName; - var ext = resourceNode.PossibleGamePaths.Length == 1 - ? Path.GetExtension(resourceNode.GamePath.ToString()) - : Path.GetExtension(fullPathStr); - _fileDialog.OpenSavePicker($"Export {Path.GetFileName(fullPathStr)} to...", ext, Path.GetFileNameWithoutExtension(fullPathStr), ext, - (success, name) => - { - if (!success) - return; - - try - { - _editor.Compactor.WriteAllBytes(name, writable!.Write()); - } - catch (Exception e) - { - Penumbra.Log.Error($"Could not export {fullPathStr}:\n{e}"); - } - }, null, false); - } - ImGui.SameLine(); if (!_quickImportActions!.TryGetValue((resourceNode.GamePath, writable), out var quickImport)) { @@ -110,32 +67,18 @@ public partial class ModEditWindow _quickImportActions.Add((resourceNode.GamePath, writable), quickImport); } - if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.FileImport.ToIconString(), buttonSize, - $"Add a copy of this file to {quickImport.OptionName}.", !quickImport.CanExecute, true)) + var canQuickImport = quickImport.CanExecute; + var quickImportEnabled = canQuickImport && (!resourceNode.Protected || _config.DeleteModModifier.IsActive()); + if (ImUtf8.IconButton(FontAwesomeIcon.FileImport, + $"Add a copy of this file to {quickImport.OptionName}.{(canQuickImport && !quickImportEnabled ? $"\nHold {_config.DeleteModModifier} while clicking to add." : string.Empty)}", + buttonSize, + !quickImportEnabled)) { quickImport.Execute(); _quickImportActions.Remove((resourceNode.GamePath, writable)); } } - private record class RawFileWritable(string Path) : IWritable - { - public bool Valid - => true; - - public byte[] Write() - => File.ReadAllBytes(Path); - } - - private record class RawGameFileWritable(FileResource FileResource) : IWritable - { - public bool Valid - => true; - - public byte[] Write() - => FileResource.Data; - } - public class QuickImportAction { public const string FallbackOptionName = "the current option"; @@ -185,19 +128,19 @@ public partial class ModEditWindow public static QuickImportAction Prepare(ModEditWindow owner, Utf8GamePath gamePath, IWritable? file) { var editor = owner._editor; - if (editor == null) + if (editor is null) return new QuickImportAction(owner._editor, FallbackOptionName, gamePath); var subMod = editor.Option!; var optionName = subMod is IModOption o ? o.FullName : FallbackOptionName; - if (gamePath.IsEmpty || file == null || editor.FileEditor.Changes) + if (gamePath.IsEmpty || file is null || editor.FileEditor.Changes) return new QuickImportAction(editor, optionName, gamePath); if (subMod.Files.ContainsKey(gamePath) || subMod.FileSwaps.ContainsKey(gamePath)) return new QuickImportAction(editor, optionName, gamePath); var mod = owner.Mod; - if (mod == null) + if (mod is null) return new QuickImportAction(editor, optionName, gamePath); var (preferredPath, subDirs) = GetPreferredPath(mod, subMod as IModOption, owner._config.ReplaceNonAsciiOnImport); @@ -232,7 +175,7 @@ public partial class ModEditWindow { var path = mod.ModPath; var subDirs = 0; - if (subMod == null) + if (subMod is null) return (path, subDirs); var name = subMod.Name; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs index 41f1da26..baaf4a82 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; using OtterGui; using OtterGui.Classes; @@ -12,6 +12,7 @@ using static Penumbra.GameData.Files.ShpkFile; using OtterGui.Widgets; using OtterGui.Text; using Penumbra.GameData.Structs; +using OtterGui.Extensions; namespace Penumbra.UI.AdvancedWindow; @@ -146,7 +147,7 @@ public partial class ModEditWindow using var font = ImRaii.PushFont(UiBuilder.MonoFont); var size = new Vector2(ImGui.GetContentRegionAvail().X, ImGui.GetTextLineHeight() * 20); - ImGuiNative.igInputTextMultiline(DisassemblyLabel.Path, shader.Disassembly!.RawDisassembly.Path, + ImGuiNative.InputTextMultiline(DisassemblyLabel.Path, shader.Disassembly!.RawDisassembly.Path, (uint)shader.Disassembly!.RawDisassembly.Length + 1, size, ImGuiInputTextFlags.ReadOnly, null, null); } diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs index b5b39e90..6c2953e0 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs @@ -1,7 +1,7 @@ using Dalamud.Utility; using Newtonsoft.Json.Linq; -using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using Penumbra.GameData.Files; using Penumbra.GameData.Files.ShaderStructs; using Penumbra.GameData.Interop; diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs index c08e8a8e..34e1e0d4 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs @@ -1,5 +1,6 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterTex; using Penumbra.Import.Textures; @@ -25,11 +26,17 @@ public partial class ModEditWindow { ("As Is", "Save the current texture with its own format without additional conversion or compression, if possible."), ("RGBA (Uncompressed)", - "Save the current texture as an uncompressed BGRA bitmap. This requires the most space but technically offers the best quality."), - ("BC3 (Simple Compression)", - "Save the current texture compressed via BC3/DXT5 compression. This offers a 4:1 compression ratio and is quick with acceptable quality."), - ("BC7 (Complex Compression)", - "Save the current texture compressed via BC7 compression. This offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while."), + "Save the current texture as an uncompressed BGRA bitmap.\nThis requires the most space but technically offers the best quality."), + ("BC1 (Simple Compression for Opaque RGB)", + "Save the current texture compressed via BC1/DXT1 compression.\nThis offers a 8:1 compression ratio and is quick with acceptable quality, but only supports RGB, without Alpha.\n\nCan be used for diffuse maps and equipment textures to save extra space."), + ("BC3 (Simple Compression for RGBA)", + "Save the current texture compressed via BC3/DXT5 compression.\nThis offers a 4:1 compression ratio and is quick with acceptable quality, and fully supports RGBA.\n\nGeneric format that can be used for most textures."), + ("BC4 (Simple Compression for Opaque Grayscale)", + "Save the current texture compressed via BC4 compression.\nThis offers a 8:1 compression ratio and has almost indistinguishable quality, but only supports Grayscale, without Alpha.\n\nCan be used for face paints and legacy marks."), + ("BC5 (Simple Compression for Opaque RG)", + "Save the current texture compressed via BC5 compression.\nThis offers a 4:1 compression ratio and has almost indistinguishable quality, but only supports RG, without B or Alpha.\n\nRecommended for index maps, unrecommended for normal maps."), + ("BC7 (Complex Compression for RGBA)", + "Save the current texture compressed via BC7 compression.\nThis offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while.\n\nGeneric format that can be used for most textures."), }; private void DrawInputChild(string label, Texture tex, Vector2 size, Vector2 imageSize) @@ -134,7 +141,7 @@ public partial class ModEditWindow tt, !isActive || !canSaveInPlace || _center.IsLeftCopy && _currentSaveAs == (int)CombinedTexture.TextureSaveType.AsIs)) { _center.SaveAs(_left.Type, _textures, _left.Path, (CombinedTexture.TextureSaveType)_currentSaveAs, _addMipMaps); - InvokeChange(Mod, _left.Path); + AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } @@ -159,7 +166,7 @@ public partial class ModEditWindow !canConvertInPlace || _left.Format is DXGIFormat.BC7Typeless or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB)) { _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.BC7, _left.MipMaps > 1); - InvokeChange(Mod, _left.Path); + AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } @@ -169,7 +176,7 @@ public partial class ModEditWindow !canConvertInPlace || _left.Format is DXGIFormat.BC3Typeless or DXGIFormat.BC3UNorm or DXGIFormat.BC3UNormSRGB)) { _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.BC3, _left.MipMaps > 1); - InvokeChange(Mod, _left.Path); + AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } @@ -180,7 +187,7 @@ public partial class ModEditWindow || _left.Format is DXGIFormat.B8G8R8A8UNorm or DXGIFormat.B8G8R8A8Typeless or DXGIFormat.B8G8R8A8UNormSRGB)) { _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.Bitmap, _left.MipMaps > 1); - InvokeChange(Mod, _left.Path); + AddChangeTask(_left.Path); AddReloadTask(_left.Path, false); } } @@ -235,7 +242,7 @@ public partial class ModEditWindow if (a) { _center.SaveAs(null, _textures, b, (CombinedTexture.TextureSaveType)_currentSaveAs, _addMipMaps); - InvokeChange(Mod, b); + AddChangeTask(b); if (b == _left.Path) AddReloadTask(_left.Path, false); else if (b == _right.Path) @@ -245,6 +252,17 @@ public partial class ModEditWindow _forceTextureStartPath = false; } + private void AddChangeTask(string path) + { + _center.SaveTask.ContinueWith(t => + { + if (!t.IsCompletedSuccessfully) + return; + + _framework.RunOnFrameworkThread(() => InvokeChange(Mod, path)); + }, TaskScheduler.Default); + } + private void AddReloadTask(string path, bool right) { _center.SaveTask.ContinueWith(t => diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index 1a4065bb..5a0fb849 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -4,11 +4,14 @@ using Dalamud.Interface.DragDrop; using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; +using OtterGui.Log; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; +using OtterGui.Widgets; using Penumbra.Api.Enums; using Penumbra.Collections.Manager; using Penumbra.Communication; @@ -48,11 +51,12 @@ public partial class ModEditWindow : Window, IDisposable, IUiService private readonly IDragDropManager _dragDropManager; private readonly IDataManager _gameData; private readonly IFramework _framework; + private readonly OptionSelectCombo _optionSelect; private Vector2 _iconSize = Vector2.Zero; private bool _allowReduplicate; - public Mod? Mod { get; private set; } + public Mod? Mod { get; private set; } public bool IsLoading @@ -101,7 +105,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService _modelTab.Reset(); _materialTab.Reset(); _shaderPackageTab.Reset(); - _itemSwapTab.UpdateMod(mod, _activeCollections.Current[mod.Index].Settings); + _itemSwapTab.UpdateMod(mod, _activeCollections.Current.GetInheritedSettings(mod.Index).Settings); UpdateModels(); _forceTextureStartPath = true; }); @@ -207,7 +211,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService if (IsLoading) { var radius = 100 * ImUtf8.GlobalScale; - var thickness = (int) (20 * ImUtf8.GlobalScale); + var thickness = (int)(20 * ImUtf8.GlobalScale); var offsetX = ImGui.GetContentRegionAvail().X / 2 - radius; var offsetY = ImGui.GetContentRegionAvail().Y / 2 - radius; ImGui.SetCursorPos(ImGui.GetCursorPos() + new Vector2(offsetX, offsetY)); @@ -215,7 +219,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService return; } - using var tabBar = ImRaii.TabBar("##tabs"); + using var tabBar = ImUtf8.TabBar("##tabs"u8); if (!tabBar) return; @@ -230,7 +234,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService _materialTab.Draw(); DrawTextureTab(); _shaderPackageTab.Draw(); - using (var tab = ImRaii.TabItem("Item Swap")) + using (var tab = ImUtf8.TabItem("Item Swap"u8)) { if (tab) _itemSwapTab.DrawContent(); @@ -452,11 +456,10 @@ public partial class ModEditWindow : Window, IDisposable, IUiService private bool DrawOptionSelectHeader() { - const string defaultOption = "Default Option"; - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero).Push(ImGuiStyleVar.FrameRounding, 0); - var width = new Vector2(ImGui.GetContentRegionAvail().X / 3, 0); - var ret = false; - if (ImGuiUtil.DrawDisabledButton(defaultOption, width, "Switch to the default option for the mod.\nThis resets unsaved changes.", + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero).Push(ImGuiStyleVar.FrameRounding, 0); + var width = new Vector2(ImGui.GetContentRegionAvail().X / 3, 0); + var ret = false; + if (ImUtf8.ButtonEx("Default Option"u8, "Switch to the default option for the mod.\nThis resets unsaved changes."u8, width, _editor.Option is DefaultSubMod)) { _editor.LoadOption(-1, 0).Wait(); @@ -464,29 +467,18 @@ public partial class ModEditWindow : Window, IDisposable, IUiService } ImGui.SameLine(); - if (ImGuiUtil.DrawDisabledButton("Refresh Data", width, "Refresh data for the current option.\nThis resets unsaved changes.", false)) + if (ImUtf8.ButtonEx("Refresh Data"u8, "Refresh data for the current option.\nThis resets unsaved changes."u8, width)) { _editor.LoadMod(_editor.Mod!, _editor.GroupIdx, _editor.DataIdx).Wait(); ret = true; } ImGui.SameLine(); - ImGui.SetNextItemWidth(width.X); - style.Push(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale); - using var color = ImRaii.PushColor(ImGuiCol.Border, ColorId.FolderLine.Value()); - using var combo = ImRaii.Combo("##optionSelector", _editor.Option!.GetFullName()); - if (!combo) - return ret; - - foreach (var (option, idx) in Mod!.AllDataContainers.WithIndex()) + if (_optionSelect.Draw(width.X)) { - using var id = ImRaii.PushId(idx); - if (ImGui.Selectable(option.GetFullName(), option == _editor.Option)) - { - var (groupIdx, dataIdx) = option.GetDataIndices(); - _editor.LoadOption(groupIdx, dataIdx).Wait(); - ret = true; - } + var (groupIdx, dataIdx) = _optionSelect.CurrentSelection.Index; + _editor.LoadOption(groupIdx, dataIdx).Wait(); + ret = true; } return ret; @@ -657,6 +649,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService _fileDialog = fileDialog; _framework = framework; _metaDrawers = metaDrawers; + _optionSelect = new OptionSelectCombo(editor, this); _materialTab = new FileEditor(this, _communicator, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl", () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => Mod?.ModPath.FullName ?? string.Empty, (bytes, path, writable) => mtrlTabFactory.Create(this, new MtrlFile(bytes), path, writable)); @@ -674,7 +667,7 @@ public partial class ModEditWindow : Window, IDisposable, IUiService _center = new CombinedTexture(_left, _right); _textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex)); _resourceTreeFactory = resourceTreeFactory; - _quickImportViewer = resourceTreeViewerFactory.Create(2, OnQuickImportRefresh, DrawQuickImportActions); + _quickImportViewer = resourceTreeViewerFactory.Create(1, OnQuickImportRefresh, DrawQuickImportActions); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow); IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true }; if (IsOpen && selection.Mod != null) diff --git a/Penumbra/UI/AdvancedWindow/ModMergeTab.cs b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs index bd62089f..bf16fa37 100644 --- a/Penumbra/UI/AdvancedWindow/ModMergeTab.cs +++ b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs @@ -1,6 +1,7 @@ using Dalamud.Interface.Utility; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/UI/AdvancedWindow/OptionSelectCombo.cs b/Penumbra/UI/AdvancedWindow/OptionSelectCombo.cs new file mode 100644 index 00000000..c9996a1e --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/OptionSelectCombo.cs @@ -0,0 +1,43 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public sealed class OptionSelectCombo(ModEditor editor, ModEditWindow window) + : FilterComboCache<(string FullName, (int Group, int Data) Index)>( + () => window.Mod!.AllDataContainers.Select(c => (c.GetFullName(), c.GetDataIndices())).ToList(), MouseWheelType.Control, Penumbra.Log) +{ + private ImRaii.ColorStyle _border; + + protected override void DrawCombo(string label, string preview, string tooltip, int currentSelected, float previewWidth, float itemHeight, + ImGuiComboFlags flags) + { + _border = ImRaii.PushFrameBorder(ImUtf8.GlobalScale, ColorId.FolderLine.Value()); + base.DrawCombo(label, preview, tooltip, currentSelected, previewWidth, itemHeight, flags); + _border.Dispose(); + } + + protected override void DrawFilter(int currentSelected, float width) + { + _border.Dispose(); + base.DrawFilter(currentSelected, width); + } + + public bool Draw(float width) + { + var flags = window.Mod!.AllDataContainers.Count() switch + { + 0 => ImGuiComboFlags.NoArrowButton, + > 8 => ImGuiComboFlags.HeightLargest, + _ => ImGuiComboFlags.None, + }; + return Draw("##optionSelector", editor.Option!.GetFullName(), string.Empty, width, ImGui.GetTextLineHeight(), flags); + } + + protected override bool DrawSelectable(int globalIdx, bool selected) + => ImUtf8.Selectable(Items[globalIdx].FullName, selected); +} diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs index 3aff2ac9..ae450bec 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -1,59 +1,60 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.Utility; -using ImGuiNET; -using OtterGui.Raii; +using Dalamud.Plugin.Services; +using Lumina.Data; using OtterGui; +using OtterGui.Classes; +using OtterGui.Compression; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; using Penumbra.Interop.ResourceTree; -using Penumbra.UI.Classes; +using Penumbra.Services; using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; namespace Penumbra.UI.AdvancedWindow; -public class ResourceTreeViewer +public class ResourceTreeViewer( + Configuration config, + ResourceTreeFactory treeFactory, + ChangedItemDrawer changedItemDrawer, + IncognitoService incognito, + int actionCapacity, + Action onRefresh, + Action drawActions, + CommunicatorService communicator, + PcpService pcpService, + IDataManager gameData, + FileDialogService fileDialog, + FileCompactor compactor) { private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags = - ResourceTreeFactory.Flags.RedactExternalPaths | ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; + ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; - private readonly Configuration _config; - private readonly ResourceTreeFactory _treeFactory; - private readonly ChangedItemDrawer _changedItemDrawer; - private readonly IncognitoService _incognito; - private readonly int _actionCapacity; - private readonly Action _onRefresh; - private readonly Action _drawActions; - private readonly HashSet _unfolded; + private readonly HashSet _unfolded = []; - private readonly Dictionary _filterCache; + private readonly Dictionary _filterCache = []; + private readonly Dictionary _writableCache = []; - private TreeCategory _categoryFilter; - private ChangedItemIconFlag _typeFilter; - private string _nameFilter; - private string _nodeFilter; + private TreeCategory _categoryFilter = AllCategories; + private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags; + private string _nameFilter = string.Empty; + private string _nodeFilter = string.Empty; + private string _note = string.Empty; private Task? _task; - public ResourceTreeViewer(Configuration config, ResourceTreeFactory treeFactory, ChangedItemDrawer changedItemDrawer, - IncognitoService incognito, int actionCapacity, Action onRefresh, Action drawActions) - { - _config = config; - _treeFactory = treeFactory; - _changedItemDrawer = changedItemDrawer; - _incognito = incognito; - _actionCapacity = actionCapacity; - _onRefresh = onRefresh; - _drawActions = drawActions; - _unfolded = []; - - _filterCache = []; - - _categoryFilter = AllCategories; - _typeFilter = ChangedItemFlagExtensions.AllFlags; - _nameFilter = string.Empty; - _nodeFilter = string.Empty; - } - public void Draw() { + DrawModifiedGameFilesWarning(); DrawControls(); _task ??= RefreshCharacterList(); @@ -74,7 +75,7 @@ public class ResourceTreeViewer } else if (_task.IsCompletedSuccessfully) { - var debugMode = _config.DebugMode; + var debugMode = config.DebugMode; foreach (var (tree, index) in _task.Result.WithIndex()) { var category = Classify(tree); @@ -83,7 +84,7 @@ public class ResourceTreeViewer using (var c = ImRaii.PushColor(ImGuiCol.Text, CategoryColor(category).Value())) { - var isOpen = ImGui.CollapsingHeader($"{(_incognito.IncognitoMode ? tree.AnonymizedName : tree.Name)}###{index}", + var isOpen = ImGui.CollapsingHeader($"{(incognito.IncognitoMode ? tree.AnonymizedName : tree.Name)}###{index}", index == 0 ? ImGuiTreeNodeFlags.DefaultOpen : 0); if (debugMode) { @@ -98,9 +99,30 @@ public class ResourceTreeViewer using var id = ImRaii.PushId(index); - ImGui.TextUnformatted($"Collection: {(_incognito.IncognitoMode ? tree.AnonymizedCollectionName : tree.CollectionName)}"); + ImUtf8.TextFrameAligned($"Collection: {(incognito.IncognitoMode ? tree.AnonymizedCollectionName : tree.CollectionName)}"); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Export Character Pack"u8, + "Note that this recomputes the current data of the actor if it still exists, and does not use the cached data."u8)) + { + pcpService.CreatePcp((ObjectIndex)tree.GameObjectIndex, _note).ContinueWith(t => + { - using var table = ImRaii.Table("##ResourceTree", _actionCapacity > 0 ? 4 : 3, + var (success, text) = t.Result; + + if (success) + Penumbra.Messager.NotificationMessage($"Created {text}.", NotificationType.Success, false); + else + Penumbra.Messager.NotificationMessage(text, NotificationType.Error, false); + }); + _note = string.Empty; + } + + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8); + + + using var table = ImRaii.Table("##ResourceTree", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); if (!table) continue; @@ -108,9 +130,8 @@ public class ResourceTreeViewer ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthStretch, 0.2f); ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthStretch, 0.3f); ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f); - if (_actionCapacity > 0) - ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, - (_actionCapacity - 1) * 3 * ImGuiHelpers.GlobalScale + _actionCapacity * ImGui.GetFrameHeight()); + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, + actionCapacity * 3 * ImGuiHelpers.GlobalScale + (actionCapacity + 1) * ImGui.GetFrameHeight()); ImGui.TableHeadersRow(); DrawNodes(tree.Nodes, 0, unchecked(tree.DrawObjectAddress * 31), 0); @@ -118,6 +139,24 @@ public class ResourceTreeViewer } } + private void DrawModifiedGameFilesWarning() + { + if (!gameData.HasModifiedGameDataFiles) + return; + + using var style = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudOrange); + + ImUtf8.TextWrapped( + "Dalamud is reporting your FFXIV installation has modified game files. Any mods installed through TexTools will produce this message."u8); + ImUtf8.TextWrapped("Penumbra and some other plugins assume your FFXIV installation is unmodified in order to work."u8); + ImUtf8.TextWrapped( + "Data displayed here may be inaccurate because of this, which, in turn, can break functionality relying on it, such as Character Pack exports/imports, or mod synchronization functions provided by other plugins."u8); + ImUtf8.TextWrapped( + "Exit the game, open XIVLauncher, click the arrow next to Log In and select \"repair game files\" to resolve this issue. Afterwards, do not install any mods with TexTools. Your plugin configurations will remain, as will mods enabled in Penumbra."u8); + + ImGui.Separator(); + } + private void DrawControls() { var yOffset = (ChangedItemDrawer.TypeFilterIconSize.Y - ImGui.GetFrameHeight()) / 2f; @@ -150,7 +189,7 @@ public class ResourceTreeViewer ImGui.SetCursorPosY(ImGui.GetCursorPosY() - yOffset); using (ImRaii.Child("##typeFilter", new Vector2(ImGui.GetContentRegionAvail().X, ChangedItemDrawer.TypeFilterIconSize.Y))) { - filterChanged |= _changedItemDrawer.DrawTypeFilter(ref _typeFilter); + filterChanged |= changedItemDrawer.DrawTypeFilter(ref _typeFilter); } var fieldWidth = (ImGui.GetContentRegionAvail().X - checkSpacing * 2.0f - ImGui.GetFrameHeightWithSpacing()) / 2.0f; @@ -160,7 +199,7 @@ public class ResourceTreeViewer ImGui.SetNextItemWidth(fieldWidth); filterChanged |= ImGui.InputTextWithHint("##NodeFilter", "Filter by Item/Part Name or Path...", ref _nodeFilter, 128); ImGui.SameLine(0, checkSpacing); - _incognito.DrawToggle(ImGui.GetFrameHeightWithSpacing()); + incognito.DrawToggle(ImGui.GetFrameHeightWithSpacing()); if (filterChanged) _filterCache.Clear(); @@ -171,24 +210,24 @@ public class ResourceTreeViewer { try { - return _treeFactory.FromObjectTable(ResourceTreeFactoryFlags) + return treeFactory.FromObjectTable(ResourceTreeFactoryFlags) .Select(entry => entry.ResourceTree) .ToArray(); } finally { _filterCache.Clear(); + _writableCache.Clear(); _unfolded.Clear(); - _onRefresh(); + onRefresh(); } }); private void DrawNodes(IEnumerable resourceNodes, int level, nint pathHash, ChangedItemIconFlag parentFilterIconFlag) { - var debugMode = _config.DebugMode; + var debugMode = config.DebugMode; var frameHeight = ImGui.GetFrameHeight(); - var cellHeight = _actionCapacity > 0 ? frameHeight : 0.0f; foreach (var (resourceNode, index) in resourceNodes.WithIndex()) { @@ -231,7 +270,7 @@ public class ResourceTreeViewer ImGui.SameLine(0f, ImGui.GetStyle().ItemInnerSpacing.X); } - _changedItemDrawer.DrawCategoryIcon(resourceNode.IconFlag); + changedItemDrawer.DrawCategoryIcon(resourceNode.IconFlag); ImGui.SameLine(0f, ImGui.GetStyle().ItemInnerSpacing.X); ImGui.TableHeader(resourceNode.Name); if (ImGui.IsItemClicked() && unfoldable) @@ -258,7 +297,7 @@ public class ResourceTreeViewer 0 => "(none)", 1 => resourceNode.GamePath.ToString(), _ => "(multiple)", - }, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + }, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); if (hasGamePaths) { var allPaths = string.Join('\n', resourceNode.PossibleGamePaths); @@ -270,32 +309,62 @@ public class ResourceTreeViewer ImGui.TableNextColumn(); if (resourceNode.FullPath.FullName.Length > 0) { - var uiFullPathStr = resourceNode.ModName != null && resourceNode.ModRelativePath != null - ? $"[{resourceNode.ModName}] {resourceNode.ModRelativePath}" - : resourceNode.FullPath.ToPath(); - ImGui.Selectable(uiFullPathStr, false, 0, new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + var hasMod = resourceNode.Mod.TryGetTarget(out var mod); + if (resourceNode is { ModName: not null, ModRelativePath: not null }) + { + var modName = $"[{(hasMod ? mod!.Name : resourceNode.ModName)}]"; + var textPos = ImGui.GetCursorPosX() + ImUtf8.CalcTextSize(modName).X + ImGui.GetStyle().ItemInnerSpacing.X; + using var group = ImUtf8.Group(); + using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value())) + { + ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(textPos); + ImUtf8.Text(resourceNode.ModRelativePath); + } + else if (resourceNode.FullPath.IsRooted) + { + var path = resourceNode.FullPath.FullName; + var lastDirectorySeparator = path.LastIndexOf('\\'); + var secondLastDirectorySeparator = lastDirectorySeparator > 0 + ? path.LastIndexOf('\\', lastDirectorySeparator - 1) + : -1; + if (secondLastDirectorySeparator >= 0) + path = $"…{path.AsSpan(secondLastDirectorySeparator)}"; + ImGui.Selectable(path.AsSpan(), false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } + else + { + ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } + if (ImGui.IsItemClicked()) ImGui.SetClipboardText(resourceNode.FullPath.ToPath()); + if (hasMod && ImGui.IsItemClicked(ImGuiMouseButton.Right) && ImGui.GetIO().KeyCtrl) + communicator.SelectTab.Invoke(TabType.Mods, mod); + ImGuiUtil.HoverTooltip( - $"{resourceNode.FullPath.ToPath()}\n\nClick to copy to clipboard.{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); + $"{resourceNode.FullPath.ToPath()}\n\nClick to copy to clipboard.{(hasMod ? "\nControl + Right-Click to jump to mod." : string.Empty)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); } else { - ImGui.Selectable("(unavailable)", false, ImGuiSelectableFlags.Disabled, - new Vector2(ImGui.GetContentRegionAvail().X, cellHeight)); + ImUtf8.Selectable(GetPathStatusLabel(resourceNode.FullPathStatus), false, ImGuiSelectableFlags.Disabled, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); ImGuiUtil.HoverTooltip( - $"The actual path to this file is unavailable.\nIt may be managed by another plug-in.{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); + $"{GetPathStatusDescription(resourceNode.FullPathStatus)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); } mutedColor.Dispose(); - if (_actionCapacity > 0) - { - ImGui.TableNextColumn(); - using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, - ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale }); - _drawActions(resourceNode, new Vector2(frameHeight)); - } + ImGui.TableNextColumn(); + using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, + ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale }); + DrawActions(resourceNode, new Vector2(frameHeight)); if (unfolded) DrawNodes(resourceNode.Children, level + 1, unchecked(nodePathHash * 31), filterIcon); @@ -348,8 +417,70 @@ public class ResourceTreeViewer || node.FullPath.InternalName.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase) || Array.Exists(node.PossibleGamePaths, path => path.Path.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase)); } + + void DrawActions(ResourceNode resourceNode, Vector2 buttonSize) + { + if (!_writableCache!.TryGetValue(resourceNode.FullPath, out var writable)) + { + var path = resourceNode.FullPath.ToPath(); + if (resourceNode.FullPath.IsRooted) + { + writable = new RawFileWritable(path); + } + else + { + var file = gameData.GetFile(path); + writable = file is null ? null : new RawGameFileWritable(file); + } + + _writableCache.Add(resourceNode.FullPath, writable); + } + + if (ImUtf8.IconButton(FontAwesomeIcon.Save, "Export this file."u8, buttonSize, + resourceNode.FullPath.FullName.Length is 0 || writable is null)) + { + var fullPathStr = resourceNode.FullPath.FullName; + var ext = resourceNode.PossibleGamePaths.Length == 1 + ? Path.GetExtension(resourceNode.GamePath.ToString()) + : Path.GetExtension(fullPathStr); + fileDialog.OpenSavePicker($"Export {Path.GetFileName(fullPathStr)} to...", ext, Path.GetFileNameWithoutExtension(fullPathStr), ext, + (success, name) => + { + if (!success) + return; + + try + { + compactor.WriteAllBytes(name, writable!.Write()); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not export {fullPathStr}:\n{e}"); + } + }, null, false); + } + + drawActions(resourceNode, writable, new Vector2(frameHeight)); + } } + private static ReadOnlySpan GetPathStatusLabel(ResourceNode.PathStatus status) + => status switch + { + ResourceNode.PathStatus.External => "(managed by external tools)"u8, + ResourceNode.PathStatus.NonExistent => "(not found)"u8, + _ => "(unavailable)"u8, + }; + + private static string GetPathStatusDescription(ResourceNode.PathStatus status) + => status switch + { + ResourceNode.PathStatus.External => "The actual path to this file is unavailable, because it is managed by external tools.", + ResourceNode.PathStatus.NonExistent => + "The actual path to this file is unavailable, because it seems to have been moved or deleted since it was loaded.", + _ => "The actual path to this file is unavailable.", + }; + [Flags] private enum TreeCategory : uint { @@ -394,4 +525,22 @@ public class ResourceTreeViewer Visible = 1, DescendentsOnly = 2, } + + private record RawFileWritable(string Path) : IWritable + { + public bool Valid + => true; + + public byte[] Write() + => File.ReadAllBytes(Path); + } + + private record RawGameFileWritable(FileResource FileResource) : IWritable + { + public bool Valid + => true; + + public byte[] Write() + => FileResource.Data; + } } diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs index ea64c0bf..6518ae67 100644 --- a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs @@ -1,5 +1,9 @@ +using Dalamud.Plugin.Services; +using OtterGui.Compression; using OtterGui.Services; +using Penumbra.GameData.Files; using Penumbra.Interop.ResourceTree; +using Penumbra.Services; namespace Penumbra.UI.AdvancedWindow; @@ -7,8 +11,14 @@ public class ResourceTreeViewerFactory( Configuration config, ResourceTreeFactory treeFactory, ChangedItemDrawer changedItemDrawer, - IncognitoService incognito) : IService + IncognitoService incognito, + CommunicatorService communicator, + PcpService pcpService, + IDataManager gameData, + FileDialogService fileDialog, + FileCompactor compactor) : IService { - public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action drawActions) - => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions); + public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action drawActions) + => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData, + fileDialog, compactor); } diff --git a/Penumbra/UI/ChangedItemDrawer.cs b/Penumbra/UI/ChangedItemDrawer.cs index af9782d5..db54a8e5 100644 --- a/Penumbra/UI/ChangedItemDrawer.cs +++ b/Penumbra/UI/ChangedItemDrawer.cs @@ -3,12 +3,13 @@ using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Plugin.Services; using Dalamud.Utility; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using Lumina.Data.Files; using OtterGui; using OtterGui.Classes; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using Penumbra.Api.Enums; using Penumbra.GameData.Data; using Penumbra.Services; @@ -86,78 +87,80 @@ public class ChangedItemDrawer : IDisposable, IUiService } /// Check if a changed item should be drawn based on its category. - public bool FilterChangedItem(string name, IIdentifiedObjectData? data, LowerString filter) + public bool FilterChangedItem(string name, IIdentifiedObjectData data, LowerString filter) => (_config.Ephemeral.ChangedItemFilter == ChangedItemFlagExtensions.AllFlags || _config.Ephemeral.ChangedItemFilter.HasFlag(data.GetIcon().ToFlag())) && (filter.IsEmpty || !data.IsFilteredOut(name, filter)); /// Draw the icon corresponding to the category of a changed item. - public void DrawCategoryIcon(IIdentifiedObjectData? data) - => DrawCategoryIcon(data.GetIcon().ToFlag()); + public void DrawCategoryIcon(IIdentifiedObjectData data, float height) + => DrawCategoryIcon(data.GetIcon().ToFlag(), height); public void DrawCategoryIcon(ChangedItemIconFlag iconFlagType) + => DrawCategoryIcon(iconFlagType, ImGui.GetFrameHeight()); + + public void DrawCategoryIcon(ChangedItemIconFlag iconFlagType, float height) { - var height = ImGui.GetFrameHeight(); if (!_icons.TryGetValue(iconFlagType, out var icon)) { ImGui.Dummy(new Vector2(height)); return; } - ImGui.Image(icon.ImGuiHandle, new Vector2(height)); + ImGui.Image(icon.Handle, new Vector2(height)); if (ImGui.IsItemHovered()) { using var tt = ImRaii.Tooltip(); - ImGui.Image(icon.ImGuiHandle, new Vector2(_smallestIconWidth)); + ImGui.Image(icon.Handle, new Vector2(_smallestIconWidth)); ImGui.SameLine(); ImGuiUtil.DrawTextButton(iconFlagType.ToDescription(), new Vector2(0, _smallestIconWidth), 0); } } - /// - /// Draw a changed item, invoking the Api-Events for clicks and tooltips. - /// Also draw the item ID in grey if requested. - /// - public void DrawChangedItem(string name, IIdentifiedObjectData? data) + public void ChangedItemHandling(IIdentifiedObjectData data, bool leftClicked) { - name = data?.ToName(name) ?? name; - using (ImRaii.PushStyle(ImGuiStyleVar.SelectableTextAlign, new Vector2(0, 0.5f)) - .Push(ImGuiStyleVar.ItemSpacing, new Vector2(ImGui.GetStyle().ItemSpacing.X, ImGui.GetStyle().CellPadding.Y * 2))) - { - var ret = ImGui.Selectable(name, false, ImGuiSelectableFlags.None, new Vector2(0, ImGui.GetFrameHeight())) - ? MouseButton.Left - : MouseButton.None; - ret = ImGui.IsItemClicked(ImGuiMouseButton.Right) ? MouseButton.Right : ret; - ret = ImGui.IsItemClicked(ImGuiMouseButton.Middle) ? MouseButton.Middle : ret; - if (ret != MouseButton.None) - _communicator.ChangedItemClick.Invoke(ret, data); - } + var ret = leftClicked ? MouseButton.Left : MouseButton.None; + ret = ImGui.IsItemClicked(ImGuiMouseButton.Right) ? MouseButton.Right : ret; + ret = ImGui.IsItemClicked(ImGuiMouseButton.Middle) ? MouseButton.Middle : ret; + if (ret != MouseButton.None) + _communicator.ChangedItemClick.Invoke(ret, data); + if (!ImGui.IsItemHovered()) + return; - if (_communicator.ChangedItemHover.HasTooltip && ImGui.IsItemHovered()) - { - // We can not be sure that any subscriber actually prints something in any case. - // Circumvent ugly blank tooltip with less-ugly useless tooltip. - using var tt = ImRaii.Tooltip(); - using (ImRaii.Group()) - { - _communicator.ChangedItemHover.Invoke(data); - } - - if (ImGui.GetItemRectSize() == Vector2.Zero) - ImGui.TextUnformatted("No actions available."); - } + using var tt = ImUtf8.Tooltip(); + if (data.Count == 1) + ImUtf8.Text("This item is changed through a single effective change.\n"); + else + ImUtf8.Text($"This item is changed through {data.Count} distinct effective changes.\n"); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3 * ImUtf8.GlobalScale); + ImGui.Separator(); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3 * ImUtf8.GlobalScale); + _communicator.ChangedItemHover.Invoke(data); } /// Draw the model information, right-justified. - public static void DrawModelData(IIdentifiedObjectData? data) + public static void DrawModelData(IIdentifiedObjectData data, float height) { - var additionalData = data?.AdditionalData ?? string.Empty; + var additionalData = data.AdditionalData; if (additionalData.Length == 0) return; - ImGui.SameLine(ImGui.GetContentRegionAvail().X); - ImGui.AlignTextToFramePadding(); - ImGuiUtil.RightJustify(additionalData, ColorId.ItemId.Value()); + ImGui.SameLine(); + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ItemId.Value()); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (height - ImGui.GetTextLineHeight()) / 2); + ImUtf8.TextRightAligned(additionalData, ImGui.GetStyle().ItemInnerSpacing.X); + } + + /// Draw the model information, right-justified. + public static void DrawModelData(ReadOnlySpan text, float height) + { + if (text.Length == 0) + return; + + ImGui.SameLine(); + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ItemId.Value()); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (height - ImGui.GetTextLineHeight()) / 2); + ImUtf8.TextRightAligned(text, ImGui.GetStyle().ItemInnerSpacing.X); } /// Draw a header line with the different icon types to filter them. @@ -190,7 +193,7 @@ public class ChangedItemDrawer : IDisposable, IUiService } ImGui.SetCursorPosX(ImGui.GetContentRegionMax().X - size.X); - ImGui.Image(_icons[ChangedItemFlagExtensions.AllFlags].ImGuiHandle, size, Vector2.Zero, Vector2.One, + ImGui.Image(_icons[ChangedItemFlagExtensions.AllFlags].Handle, size, Vector2.Zero, Vector2.One, typeFilter switch { 0 => new Vector4(0.6f, 0.3f, 0.3f, 1f), @@ -210,7 +213,7 @@ public class ChangedItemDrawer : IDisposable, IUiService var localRet = false; var icon = _icons[type]; var flag = typeFilter.HasFlag(type); - ImGui.Image(icon.ImGuiHandle, size, Vector2.Zero, Vector2.One, flag ? Vector4.One : new Vector4(0.6f, 0.3f, 0.3f, 1f)); + ImGui.Image(icon.Handle, size, Vector2.Zero, Vector2.One, flag ? Vector4.One : new Vector4(0.6f, 0.3f, 0.3f, 1f)); if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) { typeFilter = flag ? typeFilter & ~type : typeFilter | type; @@ -229,7 +232,7 @@ public class ChangedItemDrawer : IDisposable, IUiService if (ImGui.IsItemHovered()) { using var tt = ImRaii.Tooltip(); - ImGui.Image(icon.ImGuiHandle, new Vector2(_smallestIconWidth)); + ImGui.Image(icon.Handle, new Vector2(_smallestIconWidth)); ImGui.SameLine(); ImGuiUtil.DrawTextButton(type.ToDescription(), new Vector2(0, _smallestIconWidth), 0); } @@ -276,7 +279,7 @@ public class ChangedItemDrawer : IDisposable, IUiService return true; } - private static unsafe IDalamudTextureWrap? LoadUnknownTexture(IDataManager gameData, ITextureProvider textureProvider) + private static IDalamudTextureWrap? LoadUnknownTexture(IDataManager gameData, ITextureProvider textureProvider) { var unk = gameData.GetFile("ui/uld/levelup2_hr1.tex"); if (unk == null) diff --git a/Penumbra/UI/Changelog.cs b/Penumbra/UI/Changelog.cs index 0b0ca81a..306dcc79 100644 --- a/Penumbra/UI/Changelog.cs +++ b/Penumbra/UI/Changelog.cs @@ -55,46 +55,287 @@ public class PenumbraChangelog : IUiService Add1_2_1_0(Changelog); Add1_3_0_0(Changelog); Add1_3_1_0(Changelog); - } - + Add1_3_2_0(Changelog); + Add1_3_3_0(Changelog); + Add1_3_4_0(Changelog); + Add1_3_5_0(Changelog); + Add1_3_6_0(Changelog); + Add1_3_6_4(Changelog); + Add1_4_0_0(Changelog); + Add1_5_0_0(Changelog); + Add1_5_1_0(Changelog); + } + #region Changelogs + private static void Add1_5_1_0(Changelog log) + => log.NextVersion("Version 1.5.1.0") + .RegisterHighlight("Added the option to export a characters current data as a .pcp modpack in the On-Screen tab.") + .RegisterEntry("Other plugins can attach to this functionality and package and interpret their own data.", 1) + .RegisterEntry("When a .pcp modpack is installed, it can create and assign collections for the corresponding character it was created for.", 1) + .RegisterEntry("This basically provides an easier way to manually synchronize other players, but does not contain any automation.", 1) + .RegisterEntry("The settings provide some fine control about what happens when a PCP is installed, as well as buttons to cleanup any PCP-created data.", 1) + .RegisterEntry("Added a warning message when the game's integrity is corrupted to the On-Screen tab.") + .RegisterEntry("Added .kdb files to the On-Screen tab and associated functionality (thanks Ny!).") + .RegisterEntry("Updated the creation of temporary collections to require a passed identity.") + .RegisterEntry("Added the option to change the skin material suffix in models using the stockings shader by adding specific attributes (thanks Ny!).") + .RegisterEntry("Added predefined tag utility to the multi-mod selection.") + .RegisterEntry("Fixed an issue with the automatic collection selection on character login when no mods are assigned.") + .RegisterImportant( + "Fixed issue with new deformer data that makes modded deformers not containing this data work implicitly. Updates are still recommended (1.5.0.5).") + .RegisterEntry("Fixed various issues after patch (1.5.0.1 - 1.5.0.4)."); + + private static void Add1_5_0_0(Changelog log) + => log.NextVersion("Version 1.5.0.0") + .RegisterImportant("Updated for game version 7.30 and Dalamud API13, which uses a new GUI backend. Some things may not work as expected. Please let me know any issues you encounter.") + .RegisterEntry("Added support for exporting models using two vertex color schemes (thanks zeroeightysix!).") + .RegisterEntry("Possibly improved the color accuracy of the basecolor texture created when exporting models (thanks zeroeightysix!).") + .RegisterEntry("Disabled enabling transparency for materials that use the characterstockings shader due to crashes (thanks zeroeightysix!).") + .RegisterEntry("Fixed some issues with model i/o and invalid tangents (thanks PassiveModding!)") + .RegisterEntry("Changed the behavior for default directory names when using the mod normalizer with combining groups.") + .RegisterEntry("Added jumping to specific mods to the HTTP API.") + .RegisterEntry("Fixed an issue with character sound modding (1.4.0.6).") + .RegisterHighlight("Added support for IMC-toggle attributes to accessories beyond the first toggle (1.4.0.5).") + .RegisterEntry("Fixed up some slot-specific attributes and shapes in models when swapping items between slots (1.4.0.5).") + .RegisterEntry("Added handling for human skin materials to the OnScreen tab and similar functionality (thanks Ny!) (1.4.0.5).") + .RegisterEntry("The OS thread ID a resource was loaded from was added to the resource logger (1.4.0.5).") + .RegisterEntry("A button linking to my (Ottermandias') Ko-Fi and Patreon was added in the settings tab. Feel free, but not pressured, to use it! :D ") + .RegisterHighlight("Mod setting combos now support mouse-wheel scrolling with Control and have filters (1.4.0.4).") + .RegisterEntry("Using the middle mouse button to toggle designs now works correctly with temporary settings (1.4.0.4).") + .RegisterEntry("Updated some BNPC associations (1.4.0.3).") + .RegisterEntry("Fixed further issues with shapes and attributes (1.4.0.4).") + .RegisterEntry("Penumbra now handles textures with MipMap offsets broken by TexTools on import and removes unnecessary MipMaps (1.4.0.3).") + .RegisterEntry("Updated the Mod Merger for the new group types (1.4.0.3).") + .RegisterEntry("Added querying Penumbra for supported features via IPC (1.4.0.3).") + .RegisterEntry("Shape names can now be edited in Penumbras model editor (1.4.0.2).") + .RegisterEntry("Attributes and Shapes can be fully toggled (1.4.0.2).") + .RegisterEntry("Fixed several issues with attributes and shapes (1.4.0.1)."); + + private static void Add1_4_0_0(Changelog log) + => log.NextVersion("Version 1.4.0.0") + .RegisterHighlight("Added two types of new Meta Changes, SHP and ATR (Thanks Karou!).") + .RegisterEntry("Those allow mod creators to toggle custom shape keys and attributes for models on and off, respectively.", 1) + .RegisterEntry("Custom shape keys need to have the format 'shpx_*' and custom attributes need 'atrx_*'.", 1) + .RegisterHighlight( + "Shapes of the following formats will automatically be toggled on if both relevant slots contain the same shape key:", 1) + .RegisterEntry("'shpx_wa_*', for the waist seam between the body and leg slot,", 2) + .RegisterEntry("'shpx_wr_*', for the wrist seams between the body and hands slot,", 2) + .RegisterEntry("'shpx_an_*', for the ankle seams between the leg and feet slot.", 2) + .RegisterEntry( + "Custom shape key and attributes can be turned off in the advanced settings section for the moment, but this is not recommended.", + 1) + .RegisterHighlight("The mod selector width is now draggable within certain restrictions that depend on the total window width.") + .RegisterEntry("The current behavior may not be final, let me know if you have any comments.", 1) + .RegisterEntry("Improved the naming of NPCs for identifiers by using Haselnussbombers new naming functionality (Thanks Hasel!).") + .RegisterEntry("Added global EQP entries to always hide Au Ra horns, Viera ears, or Miqo'te ears, respectively.") + .RegisterEntry("This will leave holes in the heads of the respective race if not modded in some way.", 1) + .RegisterEntry("Added a filter for mods that have temporary settings in the mod selector panel (Thanks Caraxi).") + .RegisterEntry("Made the checkbox for toggling Temporary Settings Mode in the mod tab more visible.") + .RegisterEntry("Improved the option select combo in advanced editing.") + .RegisterEntry("Fixed some issues with item identification for EST changes.") + .RegisterEntry("Fixed the sizing of the mod panel being off by 1 pixel sometimes.") + .RegisterEntry("Fixed an issue with redrawing while in GPose when other plugins broke some assumptions about the game state.") + .RegisterEntry("Fixed a clipping issue within the Meta Manipulations tab in advanced editing.") + .RegisterEntry("Fixed an issue with empty and temporary settings.") + .RegisterHighlight( + "In the Item Swap tab, items changed by this mod are now sorted and highlighted before items changed in the current collection before other items for the source, and inversely for the target. (1.3.6.8)") + .RegisterHighlight( + "Default-valued meta edits should now be kept on import and only removed when the option to keep them is not set AND no other options in the mod edit the same entry. (1.3.6.8)") + .RegisterEntry("Added a right-click context menu on file redirections to copy the full file path. (1.3.6.8)") + .RegisterEntry( + "Added a right-click context menu on the mod export button to open the backup directory in your file explorer. (1.3.6.8)") + .RegisterEntry("Fixed some issues when redrawing characters from other plugins. (1.3.6.8)") + .RegisterEntry( + "Added a modifier key separate from the delete modifier key that is used for less important key-checks, specifically toggling incognito mode. (1.3.6.7)") + .RegisterEntry("Fixed some issues with the Material Editor (Thanks Ny). (1.3.6.6)"); + + private static void Add1_3_6_4(Changelog log) + => log.NextVersion("Version 1.3.6.4") + .RegisterEntry("The material editor should be functional again."); + + private static void Add1_3_6_0(Changelog log) + => log.NextVersion("Version 1.3.6.0") + .RegisterImportant("Updated Penumbra for update 7.20 and Dalamud API 12.") + .RegisterEntry( + "This is not thoroughly tested, but I decided to push to stable instead of testing because otherwise a lot of people would just go to testing just for early access again despite having no business doing so.", + 1) + .RegisterEntry( + "I also do not use most of the functionality of Penumbra myself, so I am unable to even encounter most issues myself.", 1) + .RegisterEntry("If you encounter any issues, please report them quickly on the discord.", 1) + .RegisterHighlight( + "The texture editor now has encoding support for Block Compression 1, 4 and 5 and tooltips explaining when to use which format.") + .RegisterEntry("It also is able to use GPU compression and thus has become much faster for BC7 in particular. (Thanks Ny!)", 1) + .RegisterEntry( + "Added the option to import .atch files found in the particular mod via right-click context menu on the import drag & drop button.") + .RegisterEntry("Added a chat command to clear temporary settings done manually in Penumbra.") + .RegisterEntry( + "The changed item star to select the preferred changed item is a bit more noticeable by default, and its color can be configured.") + .RegisterEntry("Some minor fixes for computing changed items. (Thanks Anna!)") + .RegisterEntry("The EQP entry previously named Unknown 4 was renamed to 'Hide Glove Cuffs'.") + .RegisterEntry("Fixed the changed item identification for EST changes.") + .RegisterEntry("Fixed clipping issues in the changed items panel when no grouping was active."); + + + private static void Add1_3_5_0(Changelog log) + => log.NextVersion("Version 1.3.5.0") + .RegisterImportant( + "Redirections of unsupported file types like .atch will now produce warnings when they are enabled. Please update mods still containing them or request updates from their creators.") + .RegisterEntry("You can now import .atch in the Meta section of advanced editing to add their non-default changes to the mod.") + .RegisterHighlight("Added an option in settings and in the collection bar in the mod tab to always use temporary settings.") + .RegisterEntry( + "While this option is enabled, all changes you make in the current collection will be applied as temporary changes, and you have to use Turn Permanent to make them permanent.", + 1) + .RegisterEntry( + "This should be useful for trying out new mods without needing to reset their settings later, or for creating mod associations in Glamourer from them.", + 1) + .RegisterEntry( + "Added a context menu entry on the mod selector blank-space context menu to clear all temporary settings made manually.") + .RegisterHighlight( + "Resource Trees now consider some additional files like decals, and improved the quick-import behaviour for some files that should not generally be modded.") + .RegisterHighlight("The Changed Item display for single mods has been heavily improved.") + .RegisterEntry("Any changed item will now show how many individual edits are affecting it in the mod in its tooltip.", 1) + .RegisterEntry("Equipment pieces are now grouped by their model id, reducing clutter.", 1) + .RegisterEntry( + "The primary equipment piece displayed is the one with the most changes affecting it, but can be configured to a specific item by the mod creator and locally.", + 1) + .RegisterEntry( + "Preferred changed items stored in the mod will be shared when exporting the mod, and used as the default for local preferences, which will not be shared.", + 2) + .RegisterEntry( + "You can configure whether groups are automatically collapsed or expanded, or remove grouping entirely in the settings.", 1) + .RegisterHighlight("Fixed support for model import/export with more than one UV.") + .RegisterEntry("Added some IPC relating to changed items.") + .RegisterEntry("Skeleton and Physics changes should now be identified in Changed Items.") + .RegisterEntry("Item Swaps will now also correctly swap EQP entries of multi-slot pieces.") + .RegisterEntry("Meta edit transmission through IPC should be a lot more efficient than before.") + .RegisterEntry("Fixed an issue with incognito names in some cutscenes.") + .RegisterEntry("Newly extracted mod folders will now try to rename themselves three times before being considered a failure."); + + private static void Add1_3_4_0(Changelog log) + => log.NextVersion("Version 1.3.4.0") + .RegisterHighlight( + "Added HDR functionality to diffuse buffers. This allows more accurate representation of non-standard color values for e.g. skin or hair colors when used with advanced customizations in Glamourer.") + .RegisterEntry( + "This option requires Wait For Plugins On Load to be enabled in Dalamud and to be enabled on start to work. It is on by default but can be turned off.", + 1) + .RegisterHighlight("Added a new option group type: Combining Groups.") + .RegisterEntry( + "A combining group behaves similarly to a multi group for the user, but instead of enabling the different options separately, it results in exactly one option per choice of settings.", + 1) + .RegisterEntry( + "Example: The user sees 2 checkboxes [+25%, +50%], but the 4 different selection states result in +0%, +25%, +50% or +75% if both are toggled on. Every choice of settings can be configured separately by the mod creator.", + 1) + .RegisterEntry( + "Added new functionality to better track copies of the player character in cutscenes if they get forced to specific clothing, like in the Margrat cutscene. Might improve tracking in wedding ceremonies, too, let me know.") + .RegisterEntry("Added a display of the number of selected files and folders to the multi mod selection.") + .RegisterEntry( + "Added cleaning functionality to remove outdated or unused files or backups from the config and mod folders via manual action.") + .RegisterEntry("Updated the Bone and Material limits in the Model Importer.") + .RegisterEntry("Improved handling of IMC and Material files loaded asynchronously.") + .RegisterEntry("Added IPC functionality to query temporary settings.") + .RegisterEntry("Improved some mod setting IPC functions.") + .RegisterEntry("Fixed some path detection issues in the OnScreen tab.") + .RegisterEntry("Fixed some issues with temporary mod settings.") + .RegisterEntry("Fixed issues with IPC calls before the game has finished loading.") + .RegisterEntry("Fixed using the wrong dye channel in the material editor previews.") + .RegisterEntry("Added some log warnings if outdated materials are loaded by the game.") + .RegisterEntry("Added Schemas for some of the json files generated and read by Penumbra to the solution."); + + private static void Add1_3_3_0(Changelog log) + => log.NextVersion("Version 1.3.3.0") + .RegisterHighlight("Added Temporary Settings to collections.") + .RegisterEntry( + "Settings can be manually turned temporary (and turned back) while editing mod settings via right-click context on the mod or buttons in the settings panel.", + 1) + .RegisterEntry( + "This can be used to test mods or changes without saving those changes permanently or having to reinstate the old settings afterwards.", + 1) + .RegisterEntry( + "More importantly, this can be set via IPC by other plugins, allowing Glamourer to only set and reset temporary settings when applying Mod Associations.", + 1) + .RegisterEntry( + "As an extreme example, it would be possible to only enable the consistent mods for your character in the collection, and let Glamourer handle all outfit mods itself via temporary settings only.", + 1) + .RegisterEntry( + "This required some pretty big changes that were in testing for a while now, but nobody talked about it much so it may still have some bugs or usability issues. Let me know!", + 1) + .RegisterHighlight( + "Added an option to automatically select the collection assigned to the current character on login events. This is off by default.") + .RegisterEntry( + "Added partial copying of color tables in material editing via right-click context menu entries on the import buttons.") + .RegisterHighlight( + "Added handling for TMB files cached by the game that should resolve issues of leaky TMBs from animation and VFX mods.") + .RegisterEntry( + "The enabled checkbox, Priority and Inheriting buttons now stick at the top of the Mod Settings panel even when scrolling down for specific settings.") + .RegisterEntry("When creating new mods with Item Swap, the attributed author of the resulting mod was improved.") + .RegisterEntry("Fixed an issue with rings in the On-Screen tab and in the data sent over to other plugins via IPC.") + .RegisterEntry( + "Fixed some issues when writing material files that resulted in technically valid files that still caused some issues with the game for unknown reasons.") + .RegisterEntry("Fixed some ImGui assertions."); + + private static void Add1_3_2_0(Changelog log) + => log.NextVersion("Version 1.3.2.0") + .RegisterHighlight("Added ATCH meta manipulations that allow the composite editing of attachment points across multiple mods.") + .RegisterEntry("Those ATCH manipulations should be shared via Mare Synchronos.", 1) + .RegisterEntry( + "This is an early implementation and might be bug-prone. Let me know of any issues. It was in testing for quite a while without reports.", + 1) + .RegisterEntry( + "Added jumping to identified mods in the On-Screen tab via Control + Right-Click and improved their display slightly.") + .RegisterEntry("Added some right-click context menu copy options in the File Redirections editor for paths.") + .RegisterHighlight("Added the option to change a specific mod's settings via chat commands by using '/penumbra mod settings'.") + .RegisterEntry("Fixed issues with the copy-pasting of meta manipulations.") + .RegisterEntry("Fixed some other issues related to meta manipulations.") + .RegisterEntry( + "Updated available NPC names and fixed an issue with some supposedly invisible characters in names showing in ImGui."); + + private static void Add1_3_1_0(Changelog log) => log.NextVersion("Version 1.3.1.0") .RegisterEntry("Penumbra has been updated for Dalamud API 11 and patch 7.1.") - .RegisterImportant("There are some known issues with potential crashes using certain VFX/SFX mods, probably related to sound files.") - .RegisterEntry("If you encounter those issues, please report them in the discord and potentially disable the corresponding mods for the time being.", 1) - .RegisterImportant("The modding of .atch files has been disabled. Outdated modded versions of these files cause crashes when loaded.") + .RegisterImportant( + "There are some known issues with potential crashes using certain VFX/SFX mods, probably related to sound files.") + .RegisterEntry( + "If you encounter those issues, please report them in the discord and potentially disable the corresponding mods for the time being.", + 1) + .RegisterImportant( + "The modding of .atch files has been disabled. Outdated modded versions of these files cause crashes when loaded.") .RegisterEntry("A better way for modular modding of .atch files via meta changes will release to the testing branch soonish.", 1) .RegisterHighlight("Temporary collections (as created by Mare) will now always respect ownership.") - .RegisterEntry("This means that you can toggle this setting off if you do not want it, and Mare will still work for minions and mounts of other players.", 1) - .RegisterEntry("The new physics and animation engine files (.kdb and .bnmb) should now be correctly redirected and respect EST changes.") + .RegisterEntry( + "This means that you can toggle this setting off if you do not want it, and Mare will still work for minions and mounts of other players.", + 1) + .RegisterEntry( + "The new physics and animation engine files (.kdb and .bnmb) should now be correctly redirected and respect EST changes.") .RegisterEntry("Fixed issues with EQP entries being labeled wrongly and global EQP not changing all required values for earrings.") .RegisterEntry("Fixed an issue with global EQP changes of a mod being reset upon reloading the mod.") .RegisterEntry("Fixed another issue with left rings and mare synchronization / the on-screen tab.") .RegisterEntry("Maybe fixed some issues with characters appearing in the login screen being misidentified.") .RegisterEntry("Some improvements for debug visualization have been made."); - + private static void Add1_3_0_0(Changelog log) => log.NextVersion("Version 1.3.0.0") - .RegisterHighlight("The textures tab in the advanced editing window can now import and export .tga files.") .RegisterEntry("BC4 and BC6 textures can now also be imported.", 1) .RegisterHighlight("Added item swapping from and to the Glasses slot.") .RegisterEntry("Reworked quite a bit of things around face wear / bonus items. Please let me know if anything broke.", 1) .RegisterEntry("The import date of a mod is now shown in the Edit Mod tab, and can be reset via button.") .RegisterEntry("A button to open the file containing local mod data for a mod was also added.", 1) - .RegisterHighlight("IMC groups can now be configured to only apply the attribute flags for their entry, and take the other values from the default value.") + .RegisterHighlight( + "IMC groups can now be configured to only apply the attribute flags for their entry, and take the other values from the default value.") .RegisterEntry("This allows keeping the material index of every IMC entry of a group, while setting the attributes.", 1) .RegisterHighlight("Model Import/Export was fixed and re-enabled (thanks ackwell and ramen).") .RegisterHighlight("Added a hack to allow bonus items (face wear, glasses) to have VFX.") .RegisterEntry("Also fixed the hack that allowed accessories to have VFX not working anymore.", 1) .RegisterHighlight("Added rudimentary options to edit PBD files in the advanced editing window.") .RegisterEntry("Preparing the advanced editing window for a mod now does not freeze the game until it is ready.") - .RegisterEntry("Meta Manipulations in the advanced editing window are now ordered and do not eat into performance as much when drawn.") + .RegisterEntry( + "Meta Manipulations in the advanced editing window are now ordered and do not eat into performance as much when drawn.") .RegisterEntry("Added a button to the advanced editing window to remove all default-valued meta manipulations from a mod") - .RegisterEntry("Default-valued manipulations will now also be removed on import from archives and .pmps, not just .ttmps, if not configured otherwise.", 1) + .RegisterEntry( + "Default-valued manipulations will now also be removed on import from archives and .pmps, not just .ttmps, if not configured otherwise.", + 1) .RegisterEntry("Checkbox-based mod filters are now tri-state checkboxes instead of two disjoint checkboxes.") .RegisterEntry("Paths from the resource logger can now be copied.") .RegisterEntry("Silenced some redundant error logs when updating mods via Heliosphere.") diff --git a/Penumbra/UI/Classes/CollectionSelectHeader.cs b/Penumbra/UI/Classes/CollectionSelectHeader.cs index 3972e350..355a6106 100644 --- a/Penumbra/UI/Classes/CollectionSelectHeader.cs +++ b/Penumbra/UI/Classes/CollectionSelectHeader.cs @@ -1,7 +1,9 @@ -using ImGuiNET; -using OtterGui.Raii; +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using Penumbra.Collections; using Penumbra.Collections.Manager; using Penumbra.Interop.PathResolving; @@ -17,15 +19,17 @@ public class CollectionSelectHeader : IUiService private readonly TutorialService _tutorial; private readonly ModSelection _selection; private readonly CollectionResolver _resolver; + private readonly Configuration _config; public CollectionSelectHeader(CollectionManager collectionManager, TutorialService tutorial, ModSelection selection, - CollectionResolver resolver) + CollectionResolver resolver, Configuration config) { _tutorial = tutorial; _selection = selection; _resolver = resolver; + _config = config; _activeCollections = collectionManager.Active; - _collectionCombo = new CollectionCombo(collectionManager, () => collectionManager.Storage.OrderBy(c => c.Name).ToList()); + _collectionCombo = new CollectionCombo(collectionManager, () => collectionManager.Storage.OrderBy(c => c.Identity.Name).ToList()); } /// Draw the header line that can quick switch between collections. @@ -33,6 +37,8 @@ public class CollectionSelectHeader : IUiService { using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0) .Push(ImGuiStyleVar.ItemSpacing, new Vector2(0, spacing ? ImGui.GetStyle().ItemSpacing.Y : 0)); + DrawTemporaryCheckbox(); + ImGui.SameLine(); var comboWidth = ImGui.GetContentRegionAvail().X / 4f; var buttonSize = new Vector2(comboWidth * 3f / 4f, 0f); using (var _ = ImRaii.Group()) @@ -51,6 +57,30 @@ public class CollectionSelectHeader : IUiService ImGuiUtil.DrawTextButton("The currently selected collection is not used in any way.", -Vector2.UnitX, Colors.PressEnterWarningBg); } + private void DrawTemporaryCheckbox() + { + var hold = _config.IncognitoModifier.IsActive(); + using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, ImUtf8.GlobalScale)) + { + var tint = _config.DefaultTemporaryMode + ? ImGuiCol.Text.Tinted(ColorId.TemporaryModSettingsTint) + : ImGui.GetColorU32(ImGuiCol.TextDisabled); + using var color = ImRaii.PushColor(ImGuiCol.ButtonHovered, ImGui.GetColorU32(ImGuiCol.FrameBg), !hold) + .Push(ImGuiCol.ButtonActive, ImGui.GetColorU32(ImGuiCol.FrameBg), !hold) + .Push(ImGuiCol.Border, tint, _config.DefaultTemporaryMode); + if (ImUtf8.IconButton(FontAwesomeIcon.Stopwatch, ""u8, default, false, tint, ImGui.GetColorU32(ImGuiCol.FrameBg)) && hold) + { + _config.DefaultTemporaryMode = !_config.DefaultTemporaryMode; + _config.Save(); + } + } + + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + "Toggle the temporary settings mode, where all changes you do create temporary settings first and need to be made permanent if desired."u8); + if (!hold) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"\nHold {_config.IncognitoModifier} while clicking to toggle."); + } + private enum CollectionState { Empty, @@ -77,10 +107,10 @@ public class CollectionSelectHeader : IUiService return CheckCollection(collection) switch { CollectionState.Empty => (collection, "None", "The base collection is configured to use no mods.", true), - CollectionState.Selected => (collection, collection.Name, + CollectionState.Selected => (collection, collection.Identity.Name, "The configured base collection is already selected as the current collection.", true), - CollectionState.Available => (collection, collection.Name, - $"Select the configured base collection {collection.Name} as the current collection.", false), + CollectionState.Available => (collection, collection.Identity.Name, + $"Select the configured base collection {collection.Identity.Name} as the current collection.", false), _ => throw new Exception("Can not happen."), }; } @@ -91,10 +121,11 @@ public class CollectionSelectHeader : IUiService return CheckCollection(collection) switch { CollectionState.Empty => (collection, "None", "The loaded player character is configured to use no mods.", true), - CollectionState.Selected => (collection, collection.Name, + CollectionState.Selected => (collection, collection.Identity.Name, "The collection configured to apply to the loaded player character is already selected as the current collection.", true), - CollectionState.Available => (collection, collection.Name, - $"Select the collection {collection.Name} that applies to the loaded player character as the current collection.", false), + CollectionState.Available => (collection, collection.Identity.Name, + $"Select the collection {collection.Identity.Name} that applies to the loaded player character as the current collection.", + false), _ => throw new Exception("Can not happen."), }; } @@ -105,10 +136,10 @@ public class CollectionSelectHeader : IUiService return CheckCollection(collection) switch { CollectionState.Empty => (collection, "None", "The interface collection is configured to use no mods.", true), - CollectionState.Selected => (collection, collection.Name, + CollectionState.Selected => (collection, collection.Identity.Name, "The configured interface collection is already selected as the current collection.", true), - CollectionState.Available => (collection, collection.Name, - $"Select the configured interface collection {collection.Name} as the current collection.", false), + CollectionState.Available => (collection, collection.Identity.Name, + $"Select the configured interface collection {collection.Identity.Name} as the current collection.", false), _ => throw new Exception("Can not happen."), }; } @@ -120,8 +151,8 @@ public class CollectionSelectHeader : IUiService { CollectionState.Unavailable => (null, "Not Inherited", "The settings of the selected mod are not inherited from another collection.", true), - CollectionState.Available => (collection, collection!.Name, - $"Select the collection {collection!.Name} from which the selected mod inherits its settings as the current collection.", + CollectionState.Available => (collection, collection!.Identity.Name, + $"Select the collection {collection!.Identity.Name} from which the selected mod inherits its settings as the current collection.", false), _ => throw new Exception("Can not happen."), }; diff --git a/Penumbra/UI/Classes/Colors.cs b/Penumbra/UI/Classes/Colors.cs index d135e10c..90ef0591 100644 --- a/Penumbra/UI/Classes/Colors.cs +++ b/Penumbra/UI/Classes/Colors.cs @@ -1,8 +1,9 @@ +using Dalamud.Bindings.ImGui; using OtterGui.Custom; namespace Penumbra.UI.Classes; -public enum ColorId +public enum ColorId : short { EnabledMod, DisabledMod, @@ -10,6 +11,7 @@ public enum ColorId InheritedMod, InheritedDisabledMod, NewMod, + NewModTint, ConflictingMod, HandledConflictMod, FolderExpanded, @@ -31,6 +33,9 @@ public enum ColorId ResTreeNonNetworked, PredefinedTagAdd, PredefinedTagRemove, + TemporaryModSettingsTint, + ChangedItemPreferenceStar, + NoTint, } public static class Colors @@ -48,38 +53,66 @@ public static class Colors public const uint ReniColorHovered = CustomGui.ReniColorHovered; public const uint ReniColorActive = CustomGui.ReniColorActive; + public static uint Tinted(this ColorId color, ColorId tint) + { + var tintValue = ImGui.ColorConvertU32ToFloat4(tint.Value()); + var value = ImGui.ColorConvertU32ToFloat4(color.Value()); + return ImGui.ColorConvertFloat4ToU32(TintColor(value, tintValue)); + } + + public static unsafe uint Tinted(this ImGuiCol color, ColorId tint) + { + var tintValue = ImGui.ColorConvertU32ToFloat4(tint.Value()); + ref var value = ref *ImGui.GetStyleColorVec4(color); + return ImGui.ColorConvertFloat4ToU32(TintColor(value, tintValue)); + } + + private static unsafe Vector4 TintColor(in Vector4 color, in Vector4 tint) + { + var negAlpha = 1 - tint.W; + var newAlpha = negAlpha * color.W + tint.W; + var newR = (negAlpha * color.W * color.X + tint.W * tint.X) / newAlpha; + var newG = (negAlpha * color.W * color.Y + tint.W * tint.Y) / newAlpha; + var newB = (negAlpha * color.W * color.Z + tint.W * tint.Z) / newAlpha; + return new Vector4(newR, newG, newB, newAlpha); + } + public static (uint DefaultColor, string Name, string Description) Data(this ColorId color) => color switch { // @formatter:off - ColorId.EnabledMod => ( 0xFFFFFFFF, "Enabled Mod", "A mod that is enabled by the currently selected collection." ), - ColorId.DisabledMod => ( 0xFF686880, "Disabled Mod", "A mod that is disabled by the currently selected collection." ), - ColorId.UndefinedMod => ( 0xFF808080, "Mod With No Settings", "A mod that is not configured in the currently selected collection or any of the collections it inherits from, and thus implicitly disabled." ), - ColorId.InheritedMod => ( 0xFFD0FFFF, "Mod Enabled By Inheritance", "A mod that is not configured in the currently selected collection, but enabled in a collection it inherits from." ), - ColorId.InheritedDisabledMod => ( 0xFF688080, "Mod Disabled By Inheritance", "A mod that is not configured in the currently selected collection, but disabled in a collection it inherits from."), - ColorId.NewMod => ( 0xFF66DD66, "New Mod", "A mod that was newly imported or created during this session and has not been enabled yet." ), - ColorId.ConflictingMod => ( 0xFFAAAAFF, "Mod With Unresolved Conflicts", "An enabled mod that has conflicts with another enabled mod on the same priority level." ), - ColorId.HandledConflictMod => ( 0xFFD0FFD0, "Mod With Resolved Conflicts", "An enabled mod that has conflicts with another enabled mod on a different priority level." ), - ColorId.FolderExpanded => ( 0xFFFFF0C0, "Expanded Mod Folder", "A mod folder that is currently expanded." ), - ColorId.FolderCollapsed => ( 0xFFFFF0C0, "Collapsed Mod Folder", "A mod folder that is currently collapsed." ), - ColorId.FolderLine => ( 0xFFFFF0C0, "Expanded Mod Folder Line", "The line signifying which descendants belong to an expanded mod folder." ), - ColorId.ItemId => ( 0xFF808080, "Item Id", "The numeric model id of the given item to the right of changed items." ), - ColorId.IncreasedMetaValue => ( 0x80008000, "Increased Meta Manipulation Value", "An increased meta manipulation value for floats or an enabled toggle where the default is disabled."), - ColorId.DecreasedMetaValue => ( 0x80000080, "Decreased Meta Manipulation Value", "A decreased meta manipulation value for floats or a disabled toggle where the default is enabled."), - ColorId.SelectedCollection => ( 0x6069C056, "Currently Selected Collection", "The collection that is currently selected and being edited."), - ColorId.RedundantAssignment => ( 0x6050D0D0, "Redundant Collection Assignment", "A collection assignment that currently has no effect as it is redundant with more general assignments."), - ColorId.NoModsAssignment => ( 0x50000080, "'Use No Mods' Collection Assignment", "A collection assignment set to not use any mods at all."), - ColorId.NoAssignment => ( 0x00000000, "Unassigned Collection Assignment", "A collection assignment that is not configured to any collection and thus just has no specific treatment."), - ColorId.SelectorPriority => ( 0xFF808080, "Mod Selector Priority", "The priority displayed for non-zero priority mods in the mod selector."), - ColorId.InGameHighlight => ( 0xFFEBCF89, "In-Game Highlight (Primary)", "An in-game element that has been highlighted for ease of editing."), - ColorId.InGameHighlight2 => ( 0xFF446CC0, "In-Game Highlight (Secondary)", "Another in-game element that has been highlighted for ease of editing."), - ColorId.ResTreeLocalPlayer => ( 0xFFFFE0A0, "On-Screen: You", "You and what you own (mount, minion, accessory, pets and so on), in the On-Screen tab." ), - ColorId.ResTreePlayer => ( 0xFFC0FFC0, "On-Screen: Other Players", "Other players and what they own, in the On-Screen tab." ), - ColorId.ResTreeNetworked => ( 0xFFFFFFFF, "On-Screen: Non-Players (Networked)", "Non-player entities handled by the game server, in the On-Screen tab." ), - ColorId.ResTreeNonNetworked => ( 0xFFC0C0FF, "On-Screen: Non-Players (Local)", "Non-player entities handled locally, in the On-Screen tab." ), - ColorId.PredefinedTagAdd => ( 0xFF44AA44, "Predefined Tags: Add Tag", "A predefined tag that is not present on the current mod and can be added." ), - ColorId.PredefinedTagRemove => ( 0xFF2222AA, "Predefined Tags: Remove Tag", "A predefined tag that is already present on the current mod and can be removed." ), - _ => throw new ArgumentOutOfRangeException( nameof( color ), color, null ), + ColorId.EnabledMod => ( 0xFFFFFFFF, "Enabled Mod", "A mod that is enabled by the currently selected collection." ), + ColorId.DisabledMod => ( 0xFF686880, "Disabled Mod", "A mod that is disabled by the currently selected collection." ), + ColorId.UndefinedMod => ( 0xFF808080, "Mod With No Settings", "A mod that is not configured in the currently selected collection or any of the collections it inherits from, and thus implicitly disabled." ), + ColorId.InheritedMod => ( 0xFFD0FFFF, "Mod Enabled By Inheritance", "A mod that is not configured in the currently selected collection, but enabled in a collection it inherits from." ), + ColorId.InheritedDisabledMod => ( 0xFF688080, "Mod Disabled By Inheritance", "A mod that is not configured in the currently selected collection, but disabled in a collection it inherits from."), + ColorId.NewMod => ( 0xFF66DD66, "New Mod", "A mod that was newly imported or created during this session and has not been enabled yet." ), + ColorId.ConflictingMod => ( 0xFFAAAAFF, "Mod With Unresolved Conflicts", "An enabled mod that has conflicts with another enabled mod on the same priority level." ), + ColorId.HandledConflictMod => ( 0xFFD0FFD0, "Mod With Resolved Conflicts", "An enabled mod that has conflicts with another enabled mod on a different priority level." ), + ColorId.FolderExpanded => ( 0xFFFFF0C0, "Expanded Mod Folder", "A mod folder that is currently expanded." ), + ColorId.FolderCollapsed => ( 0xFFFFF0C0, "Collapsed Mod Folder", "A mod folder that is currently collapsed." ), + ColorId.FolderLine => ( 0xFFFFF0C0, "Expanded Mod Folder Line", "The line signifying which descendants belong to an expanded mod folder." ), + ColorId.ItemId => ( 0xFF808080, "Item Id", "The numeric model id of the given item to the right of changed items." ), + ColorId.IncreasedMetaValue => ( 0x80008000, "Increased Meta Manipulation Value", "An increased meta manipulation value for floats or an enabled toggle where the default is disabled."), + ColorId.DecreasedMetaValue => ( 0x80000080, "Decreased Meta Manipulation Value", "A decreased meta manipulation value for floats or a disabled toggle where the default is enabled."), + ColorId.SelectedCollection => ( 0x6069C056, "Currently Selected Collection", "The collection that is currently selected and being edited."), + ColorId.RedundantAssignment => ( 0x6050D0D0, "Redundant Collection Assignment", "A collection assignment that currently has no effect as it is redundant with more general assignments."), + ColorId.NoModsAssignment => ( 0x50000080, "'Use No Mods' Collection Assignment", "A collection assignment set to not use any mods at all."), + ColorId.NoAssignment => ( 0x00000000, "Unassigned Collection Assignment", "A collection assignment that is not configured to any collection and thus just has no specific treatment."), + ColorId.SelectorPriority => ( 0xFF808080, "Mod Selector Priority", "The priority displayed for non-zero priority mods in the mod selector."), + ColorId.InGameHighlight => ( 0xFFEBCF89, "In-Game Highlight (Primary)", "An in-game element that has been highlighted for ease of editing."), + ColorId.InGameHighlight2 => ( 0xFF446CC0, "In-Game Highlight (Secondary)", "Another in-game element that has been highlighted for ease of editing."), + ColorId.ResTreeLocalPlayer => ( 0xFFFFE0A0, "On-Screen: You", "You and what you own (mount, minion, accessory, pets and so on), in the On-Screen tab." ), + ColorId.ResTreePlayer => ( 0xFFC0FFC0, "On-Screen: Other Players", "Other players and what they own, in the On-Screen tab." ), + ColorId.ResTreeNetworked => ( 0xFFFFFFFF, "On-Screen: Non-Players (Networked)", "Non-player entities handled by the game server, in the On-Screen tab." ), + ColorId.ResTreeNonNetworked => ( 0xFFC0C0FF, "On-Screen: Non-Players (Local)", "Non-player entities handled locally, in the On-Screen tab." ), + ColorId.PredefinedTagAdd => ( 0xFF44AA44, "Predefined Tags: Add Tag", "A predefined tag that is not present on the current mod and can be added." ), + ColorId.PredefinedTagRemove => ( 0xFF2222AA, "Predefined Tags: Remove Tag", "A predefined tag that is already present on the current mod and can be removed." ), + ColorId.TemporaryModSettingsTint => ( 0x30FF0000, "Mod with Temporary Settings", "A mod that has temporary settings. This color is used as a tint for the regular state colors." ), + ColorId.NewModTint => ( 0x8000FF00, "New Mod Tint", "A mod that was newly imported or created during this session and has not been enabled yet. This color is used as a tint for the regular state colors."), + ColorId.NoTint => ( 0x00000000, "No Tint", "The default tint for all mods."), + ColorId.ChangedItemPreferenceStar => ( 0x30FFFFFF, "Preferred Changed Item Star", "The color of the star button in the mod panel's changed items tab to prioritize specific items."), + _ => throw new ArgumentOutOfRangeException( nameof( color ), color, null ), // @formatter:on }; diff --git a/Penumbra/UI/Classes/MigrationSectionDrawer.cs b/Penumbra/UI/Classes/MigrationSectionDrawer.cs index a3dcd23a..98a59a5b 100644 --- a/Penumbra/UI/Classes/MigrationSectionDrawer.cs +++ b/Penumbra/UI/Classes/MigrationSectionDrawer.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using OtterGui.Text; using Penumbra.Services; diff --git a/Penumbra/UI/CollectionTab/CollectionCombo.cs b/Penumbra/UI/CollectionTab/CollectionCombo.cs index 1670be5e..bf97f178 100644 --- a/Penumbra/UI/CollectionTab/CollectionCombo.cs +++ b/Penumbra/UI/CollectionTab/CollectionCombo.cs @@ -1,5 +1,5 @@ -using ImGuiNET; -using OtterGui; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using OtterGui.Widgets; @@ -29,13 +29,13 @@ public sealed class CollectionCombo(CollectionManager manager, Func obj.Name; + => obj.Identity.Name; protected override void DrawCombo(string label, string preview, string tooltip, int currentSelected, float previewWidth, float itemHeight, ImGuiComboFlags flags) diff --git a/Penumbra/UI/CollectionTab/CollectionPanel.cs b/Penumbra/UI/CollectionTab/CollectionPanel.cs index 914f10d9..e41ceade 100644 --- a/Penumbra/UI/CollectionTab/CollectionPanel.cs +++ b/Penumbra/UI/CollectionTab/CollectionPanel.cs @@ -6,15 +6,18 @@ using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Raii; +using OtterGui.Text; using Penumbra.Collections; using Penumbra.Collections.Manager; using Penumbra.GameData.Actors; using Penumbra.GameData.Enums; using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; using Penumbra.Services; using Penumbra.UI.Classes; @@ -220,29 +223,34 @@ public sealed class CollectionPanel( ImGui.EndGroup(); ImGui.SameLine(); ImGui.BeginGroup(); - using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); - var name = _newName ?? collection.Name; - var identifier = collection.Identifier; - var width = ImGui.GetContentRegionAvail().X; - var fileName = saveService.FileNames.CollectionFile(collection); - ImGui.SetNextItemWidth(width); - if (ImGui.InputText("##name", ref name, 128)) - _newName = name; - if (ImGui.IsItemDeactivatedAfterEdit() && _newName != null && _newName != collection.Name) + var width = ImGui.GetContentRegionAvail().X; + using (ImRaii.Disabled(_collections.DefaultNamed == collection)) { - collection.Name = _newName; - saveService.QueueSave(new ModCollectionSave(mods, collection)); - selector.RestoreCollections(); - _newName = null; - } - else if (ImGui.IsItemDeactivated()) - { - _newName = null; + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + var name = _newName ?? collection.Identity.Name; + ImGui.SetNextItemWidth(width); + if (ImGui.InputText("##name", ref name, 128)) + _newName = name; + if (ImGui.IsItemDeactivatedAfterEdit() && _newName != null && _newName != collection.Identity.Name) + { + collection.Identity.Name = _newName; + saveService.QueueSave(new ModCollectionSave(mods, collection)); + selector.RestoreCollections(); + _newName = null; + } + else if (ImGui.IsItemDeactivated()) + { + _newName = null; + } } + if (_collections.DefaultNamed == collection) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "The Default collection can not be renamed."u8); + var identifier = collection.Identity.Identifier; + var fileName = saveService.FileNames.CollectionFile(collection); using (ImRaii.PushFont(UiBuilder.MonoFont)) { - if (ImGui.Button(collection.Identifier, new Vector2(width, 0))) + if (ImGui.Button(collection.Identity.Identifier, new Vector2(width, 0))) try { Process.Start(new ProcessStartInfo(fileName) { UseShellExecute = true }); @@ -289,9 +297,9 @@ public sealed class CollectionPanel( _active.SetCollection(null, type, _active.Individuals.GetGroup(identifier)); } - foreach (var coll in _collections.OrderBy(c => c.Name)) + foreach (var coll in _collections.OrderBy(c => c.Identity.Name)) { - if (coll != collection && ImGui.MenuItem($"Use {coll.Name}.")) + if (coll != collection && ImGui.MenuItem($"Use {coll.Identity.Name}.")) _active.SetCollection(coll, type, _active.Individuals.GetGroup(identifier)); } } @@ -344,7 +352,7 @@ public sealed class CollectionPanel( if (!source) return; - ImGui.SetDragDropPayload("DragIndividual", nint.Zero, 0); + ImGui.SetDragDropPayload("DragIndividual", null, 0); ImGui.TextUnformatted($"Re-ordering {text}..."); _draggedIndividualAssignment = _active.Individuals.Index(id); } @@ -373,9 +381,7 @@ public sealed class CollectionPanel( ImGuiUtil.TextWrapped(type.ToDescription()); switch (type) { - case CollectionType.Default: - ImGui.TextUnformatted("Overruled by any other Assignment."); - break; + case CollectionType.Default: ImGui.TextUnformatted("Overruled by any other Assignment."); break; case CollectionType.Yourself: ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); break; @@ -418,7 +424,7 @@ public sealed class CollectionPanel( private string Name(ModCollection? collection) => collection == null ? "Unassigned" : collection == ModCollection.Empty ? "Use No Mods" : - incognito.IncognitoMode ? collection.AnonymizedName : collection.Name; + incognito.IncognitoMode ? collection.Identity.AnonymizedName : collection.Identity.Name; private void DrawIndividualButton(string intro, Vector2 width, string tooltip, char suffix, params ActorIdentifier[] identifiers) { @@ -497,7 +503,7 @@ public sealed class CollectionPanel( ImGui.Separator(); var buttonHeight = 2 * ImGui.GetTextLineHeightWithSpacing(); - if (_inUseCache.Count == 0 && collection.DirectParentOf.Count == 0) + if (_inUseCache.Count == 0 && collection.Inheritance.DirectlyInheritedBy.Count == 0) { ImGui.Dummy(Vector2.One); using var f = _nameFont.Push(); @@ -559,7 +565,7 @@ public sealed class CollectionPanel( private void DrawInheritanceStatistics(ModCollection collection, Vector2 buttonWidth) { - if (collection.DirectParentOf.Count <= 0) + if (collection.Inheritance.DirectlyInheritedBy.Count <= 0) return; using (var _ = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero)) @@ -570,11 +576,11 @@ public sealed class CollectionPanel( using var f = _nameFont.Push(); using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale); using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.MetaInfoText); - ImGuiUtil.DrawTextButton(Name(collection.DirectParentOf[0]), Vector2.Zero, 0); + ImGuiUtil.DrawTextButton(Name(collection.Inheritance.DirectlyInheritedBy[0]), Vector2.Zero, 0); var constOffset = (ImGui.GetStyle().FramePadding.X + ImGuiHelpers.GlobalScale) * 2 + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X; - foreach (var parent in collection.DirectParentOf.Skip(1)) + foreach (var parent in collection.Inheritance.DirectlyInheritedBy.Skip(1)) { var name = Name(parent); var size = ImGui.CalcTextSize(name).X; @@ -602,7 +608,7 @@ public sealed class CollectionPanel( ImGui.TableSetupColumn("State", ImGuiTableColumnFlags.WidthFixed, 1.75f * ImGui.GetFrameHeight()); ImGui.TableSetupColumn("Priority", ImGuiTableColumnFlags.WidthFixed, 2.5f * ImGui.GetFrameHeight()); ImGui.TableHeadersRow(); - foreach (var (mod, (settings, parent)) in mods.Select(m => (m, collection[m.Index])) + foreach (var (mod, (settings, parent)) in mods.Select(m => (m, collection.GetInheritedSettings(m.Index))) .Where(t => t.Item2.Settings != null) .OrderBy(t => t.m.Name)) { @@ -625,12 +631,12 @@ public sealed class CollectionPanel( private void DrawInactiveSettingsList(ModCollection collection) { - if (collection.UnusedSettings.Count == 0) + if (collection.Settings.Unused.Count == 0) return; ImGui.Dummy(Vector2.One); - var text = collection.UnusedSettings.Count > 1 - ? $"Clear all {collection.UnusedSettings.Count} unused settings from deleted mods." + var text = collection.Settings.Unused.Count > 1 + ? $"Clear all {collection.Settings.Unused.Count} unused settings from deleted mods." : "Clear the currently unused setting from a deleted mods."; if (ImGui.Button(text, new Vector2(ImGui.GetContentRegionAvail().X, 0))) _collections.CleanUnavailableSettings(collection); @@ -638,7 +644,7 @@ public sealed class CollectionPanel( ImGui.Dummy(Vector2.One); var size = new Vector2(ImGui.GetContentRegionAvail().X, - Math.Min(10, collection.UnusedSettings.Count + 1) * ImGui.GetFrameHeightWithSpacing()); + Math.Min(10, collection.Settings.Unused.Count + 1) * ImGui.GetFrameHeightWithSpacing()); using var table = ImRaii.Table("##inactiveSettings", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, size); if (!table) return; @@ -650,7 +656,7 @@ public sealed class CollectionPanel( ImGui.TableSetupColumn("Priority", ImGuiTableColumnFlags.WidthFixed, 2.5f * ImGui.GetFrameHeight()); ImGui.TableHeadersRow(); string? delete = null; - foreach (var (name, settings) in collection.UnusedSettings.OrderBy(n => n.Key)) + foreach (var (name, settings) in collection.Settings.Unused.OrderBy(n => n.Key)) { using var id = ImRaii.PushId(name); ImGui.TableNextColumn(); diff --git a/Penumbra/UI/CollectionTab/CollectionSelector.cs b/Penumbra/UI/CollectionTab/CollectionSelector.cs index 024873bf..79254090 100644 --- a/Penumbra/UI/CollectionTab/CollectionSelector.cs +++ b/Penumbra/UI/CollectionTab/CollectionSelector.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using Penumbra.Collections; @@ -69,7 +69,7 @@ public sealed class CollectionSelector : ItemSelector, IDisposabl } protected override bool Filtered(int idx) - => !Items[idx].Name.Contains(Filter, StringComparison.OrdinalIgnoreCase); + => !Items[idx].Identity.Name.Contains(Filter, StringComparison.OrdinalIgnoreCase); private const string PayloadString = "Collection"; @@ -85,7 +85,7 @@ public sealed class CollectionSelector : ItemSelector, IDisposabl if (source) { _dragging = Items[idx]; - ImGui.SetDragDropPayload(PayloadString, nint.Zero, 0); + ImGui.SetDragDropPayload(PayloadString, null, 0); ImGui.TextUnformatted($"Assigning {Name(_dragging)} to..."); } @@ -111,12 +111,13 @@ public sealed class CollectionSelector : ItemSelector, IDisposabl } private string Name(ModCollection collection) - => _incognito.IncognitoMode || collection.Name.Length == 0 ? collection.AnonymizedName : collection.Name; + => _incognito.IncognitoMode || collection.Identity.Name.Length == 0 ? collection.Identity.AnonymizedName : collection.Identity.Name; public void RestoreCollections() { Items.Clear(); - foreach (var c in _storage.OrderBy(c => c.Name)) + Items.Add(_storage.DefaultNamed); + foreach (var c in _storage.OrderBy(c => c.Identity.Name).Where(c => c != _storage.DefaultNamed)) Items.Add(c); SetFilterDirty(); SetCurrent(_active.Current); diff --git a/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs b/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs index fd8f9b25..f472e346 100644 --- a/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs +++ b/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs @@ -1,5 +1,5 @@ using Dalamud.Game.ClientState.Objects.Enums; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Custom; using Penumbra.Collections; using Penumbra.Collections.Manager; diff --git a/Penumbra/UI/CollectionTab/InheritanceUi.cs b/Penumbra/UI/CollectionTab/InheritanceUi.cs index 418fe52c..2053f269 100644 --- a/Penumbra/UI/CollectionTab/InheritanceUi.cs +++ b/Penumbra/UI/CollectionTab/InheritanceUi.cs @@ -1,6 +1,7 @@ using Dalamud.Interface; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Collections; @@ -93,7 +94,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService /// private void DrawInheritedChildren(ModCollection collection) { - using var id = ImRaii.PushId(collection.Index); + using var id = ImRaii.PushId(collection.Identity.Index); using var indent = ImRaii.PushIndent(); // Get start point for the lines (top of the selector). @@ -107,14 +108,14 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService var lineEnd = lineStart; // Skip the collection itself. - foreach (var inheritance in collection.GetFlattenedInheritance().Skip(1)) + foreach (var inheritance in collection.Inheritance.FlatHierarchy.Skip(1)) { // Draw the child, already seen collections are colored as conflicts. using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.HandledConflictMod.Value(), _seenInheritedCollections.Contains(inheritance)); _seenInheritedCollections.Add(inheritance); - ImRaii.TreeNode($"{Name(inheritance)}###{inheritance.Id}", + ImRaii.TreeNode($"{Name(inheritance)}###{inheritance.Identity.Id}", ImGuiTreeNodeFlags.NoTreePushOnOpen | ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet); var (minRect, maxRect) = (ImGui.GetItemRectMin(), ImGui.GetItemRectMax()); DrawInheritanceTreeClicks(inheritance, false); @@ -140,7 +141,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.HandledConflictMod.Value(), _seenInheritedCollections.Contains(collection)); _seenInheritedCollections.Add(collection); - using var tree = ImRaii.TreeNode($"{Name(collection)}###{collection.Name}", ImGuiTreeNodeFlags.NoTreePushOnOpen); + using var tree = ImRaii.TreeNode($"{Name(collection)}###{collection.Identity.Name}", ImGuiTreeNodeFlags.NoTreePushOnOpen); color.Pop(); DrawInheritanceTreeClicks(collection, true); DrawInheritanceDropSource(collection); @@ -150,7 +151,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService DrawInheritedChildren(collection); else // We still want to keep track of conflicts. - _seenInheritedCollections.UnionWith(collection.GetFlattenedInheritance()); + _seenInheritedCollections.UnionWith(collection.Inheritance.FlatHierarchy); } /// Draw the list box containing the current inheritance information. @@ -163,7 +164,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService _seenInheritedCollections.Clear(); _seenInheritedCollections.Add(_active.Current); - foreach (var collection in _active.Current.DirectlyInheritsFrom.ToList()) + foreach (var collection in _active.Current.Inheritance.DirectlyInheritsFrom.ToList()) DrawInheritance(collection); } @@ -180,7 +181,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService using var target = ImRaii.DragDropTarget(); if (target.Success && ImGuiUtil.IsDropping(InheritanceDragDropLabel)) - _inheritanceAction = (_active.Current.DirectlyInheritsFrom.IndexOf(_movedInheritance!), -1); + _inheritanceAction = (_active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(_movedInheritance!), -1); } /// @@ -244,7 +245,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService { ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton); _newInheritance ??= _collections.FirstOrDefault(c - => c != _active.Current && !_active.Current.DirectlyInheritsFrom.Contains(c)) + => c != _active.Current && !_active.Current.Inheritance.DirectlyInheritsFrom.Contains(c)) ?? ModCollection.Empty; using var combo = ImRaii.Combo("##newInheritance", Name(_newInheritance)); if (!combo) @@ -252,7 +253,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService foreach (var collection in _collections .Where(c => InheritanceManager.CheckValidInheritance(_active.Current, c) == InheritanceManager.ValidInheritance.Valid) - .OrderBy(c => c.Name)) + .OrderBy(c => c.Identity.Name)) { if (ImGui.Selectable(Name(collection), _newInheritance == collection)) _newInheritance = collection; @@ -271,8 +272,8 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService if (_movedInheritance != null) { - var idx1 = _active.Current.DirectlyInheritsFrom.IndexOf(_movedInheritance); - var idx2 = _active.Current.DirectlyInheritsFrom.IndexOf(collection); + var idx1 = _active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(_movedInheritance); + var idx2 = _active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(collection); if (idx1 >= 0 && idx2 >= 0) _inheritanceAction = (idx1, idx2); } @@ -287,7 +288,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService if (!source) return; - ImGui.SetDragDropPayload(InheritanceDragDropLabel, nint.Zero, 0); + ImGui.SetDragDropPayload(InheritanceDragDropLabel, null, 0); _movedInheritance = collection; ImGui.TextUnformatted($"Moving {(_movedInheritance != null ? Name(_movedInheritance) : "Unknown")}..."); } @@ -302,7 +303,7 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService if (ImGui.GetIO().KeyCtrl && ImGui.IsItemClicked(ImGuiMouseButton.Right)) { if (withDelete && ImGui.GetIO().KeyShift) - _inheritanceAction = (_active.Current.DirectlyInheritsFrom.IndexOf(collection), -1); + _inheritanceAction = (_active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(collection), -1); else _newCurrentCollection = collection; } @@ -312,5 +313,5 @@ public class InheritanceUi(CollectionManager collectionManager, IncognitoService } private string Name(ModCollection collection) - => incognito.IncognitoMode ? collection.AnonymizedName : collection.Name; + => incognito.IncognitoMode ? collection.Identity.AnonymizedName : collection.Identity.Name; } diff --git a/Penumbra/UI/ConfigWindow.cs b/Penumbra/UI/ConfigWindow.cs index 53fa0b33..55d0bc19 100644 --- a/Penumbra/UI/ConfigWindow.cs +++ b/Penumbra/UI/ConfigWindow.cs @@ -1,6 +1,6 @@ using Dalamud.Interface.Windowing; using Dalamud.Plugin; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Custom; using OtterGui.Raii; @@ -17,12 +17,12 @@ namespace Penumbra.UI; public sealed class ConfigWindow : Window, IUiService { private readonly IDalamudPluginInterface _pluginInterface; - private readonly Configuration _config; - private readonly PerformanceTracker _tracker; - private readonly ValidityChecker _validityChecker; - private Penumbra? _penumbra; - private ConfigTabBar _configTabs = null!; - private string? _lastException; + private readonly Configuration _config; + private readonly PerformanceTracker _tracker; + private readonly ValidityChecker _validityChecker; + private Penumbra? _penumbra; + private ConfigTabBar _configTabs = null!; + private string? _lastException; public ConfigWindow(PerformanceTracker tracker, IDalamudPluginInterface pi, Configuration config, ValidityChecker checker, TutorialService tutorial) diff --git a/Penumbra/UI/FileDialogService.cs b/Penumbra/UI/FileDialogService.cs index cc2a7f6a..3bbc4ba8 100644 --- a/Penumbra/UI/FileDialogService.cs +++ b/Penumbra/UI/FileDialogService.cs @@ -1,8 +1,9 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Utility; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Communication; using Penumbra.Services; diff --git a/Penumbra/UI/ImportPopup.cs b/Penumbra/UI/ImportPopup.cs index 28767edc..59ed0308 100644 --- a/Penumbra/UI/ImportPopup.cs +++ b/Penumbra/UI/ImportPopup.cs @@ -1,7 +1,7 @@ using Dalamud.Game.ClientState.Keys; using Dalamud.Interface.Windowing; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; using OtterGui.Services; using Penumbra.Import.Structs; diff --git a/Penumbra/UI/IncognitoService.cs b/Penumbra/UI/IncognitoService.cs index d58ea1ec..678e072e 100644 --- a/Penumbra/UI/IncognitoService.cs +++ b/Penumbra/UI/IncognitoService.cs @@ -1,4 +1,5 @@ using Dalamud.Interface; +using Dalamud.Bindings.ImGui; using Penumbra.UI.Classes; using OtterGui.Raii; using OtterGui.Services; @@ -6,19 +7,27 @@ using OtterGui.Text; namespace Penumbra.UI; -public class IncognitoService(TutorialService tutorial) : IService +public class IncognitoService(TutorialService tutorial, Configuration config) : IService { - public bool IncognitoMode; + public bool IncognitoMode + => config.Ephemeral.IncognitoMode; public void DrawToggle(float width) { + var hold = config.IncognitoModifier.IsActive(); var color = ColorId.FolderExpanded.Value(); using (ImRaii.PushFrameBorder(ImUtf8.GlobalScale, color)) { var tt = IncognitoMode ? "Toggle incognito mode off."u8 : "Toggle incognito mode on."u8; var icon = IncognitoMode ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye; - if (ImUtf8.IconButton(icon, tt, new Vector2(width, ImUtf8.FrameHeight), false, color)) - IncognitoMode = !IncognitoMode; + if (ImUtf8.IconButton(icon, tt, new Vector2(width, ImUtf8.FrameHeight), false, color) && hold) + { + config.Ephemeral.IncognitoMode = !IncognitoMode; + config.Ephemeral.Save(); + } + + if (!hold) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"\nHold {config.IncognitoModifier} while clicking to toggle."); } tutorial.OpenTutorial(BasicTutorialSteps.Incognito); diff --git a/Penumbra/UI/Knowledge/KnowledgeWindow.cs b/Penumbra/UI/Knowledge/KnowledgeWindow.cs index f831975b..118ed479 100644 --- a/Penumbra/UI/Knowledge/KnowledgeWindow.cs +++ b/Penumbra/UI/Knowledge/KnowledgeWindow.cs @@ -1,6 +1,6 @@ using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using OtterGui.Text; using Penumbra.String; diff --git a/Penumbra/UI/Knowledge/RaceCodeTab.cs b/Penumbra/UI/Knowledge/RaceCodeTab.cs index 36b048aa..44b544eb 100644 --- a/Penumbra/UI/Knowledge/RaceCodeTab.cs +++ b/Penumbra/UI/Knowledge/RaceCodeTab.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Text; using Penumbra.GameData.Enums; diff --git a/Penumbra/UI/LaunchButton.cs b/Penumbra/UI/LaunchButton.cs index cb533a00..49161c31 100644 --- a/Penumbra/UI/LaunchButton.cs +++ b/Penumbra/UI/LaunchButton.cs @@ -1,4 +1,5 @@ using Dalamud.Interface; +using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Plugin; using Dalamud.Plugin.Services; @@ -18,7 +19,6 @@ public class LaunchButton : IDisposable, IUiService private readonly string _fileName; private readonly ITextureProvider _textureProvider; - private IDalamudTextureWrap? _icon; private IReadOnlyTitleScreenMenuEntry? _entry; /// @@ -30,7 +30,6 @@ public class LaunchButton : IDisposable, IUiService _configWindow = ui; _textureProvider = textureProvider; _title = title; - _icon = null; _entry = null; _fileName = Path.Combine(pi.AssemblyLocation.DirectoryName!, "tsmLogo.png"); @@ -39,7 +38,6 @@ public class LaunchButton : IDisposable, IUiService public void Dispose() { - _icon?.Dispose(); if (_entry != null) _title.RemoveEntry(_entry); } @@ -52,9 +50,8 @@ public class LaunchButton : IDisposable, IUiService try { // TODO: update when API updated. - _icon = _textureProvider.GetFromFile(_fileName).RentAsync().Result; - if (_icon != null) - _entry = _title.AddEntry("Manage Penumbra", _icon, OnTriggered); + var icon = _textureProvider.GetFromFile(_fileName); + _entry = _title.AddEntry("Manage Penumbra", icon, OnTriggered); _uiBuilder.Draw -= CreateEntry; } diff --git a/Penumbra/UI/ModsTab/DescriptionEditPopup.cs b/Penumbra/UI/ModsTab/DescriptionEditPopup.cs index c284afc3..7d7a6967 100644 --- a/Penumbra/UI/ModsTab/DescriptionEditPopup.cs +++ b/Penumbra/UI/ModsTab/DescriptionEditPopup.cs @@ -1,5 +1,5 @@ using Dalamud.Interface.Utility; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using OtterGui.Text; using Penumbra.Mods; diff --git a/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs b/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs index c30239bc..1430f17b 100644 --- a/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using OtterGui.Text; using Penumbra.Api.Enums; @@ -48,6 +48,7 @@ public class AddGroupDrawer : IUiService DrawSingleGroupButton(mod, buttonWidth); ImUtf8.SameLineInner(); DrawMultiGroupButton(mod, buttonWidth); + DrawCombiningGroupButton(mod, buttonWidth); } private void DrawSingleGroupButton(Mod mod, Vector2 width) @@ -76,6 +77,18 @@ public class AddGroupDrawer : IUiService _groupNameValid = false; } + private void DrawCombiningGroupButton(Mod mod, Vector2 width) + { + if (!ImUtf8.ButtonEx("Add Combining Group"u8, _groupNameValid + ? "Add a new combining option group to this mod."u8 + : "Can not add a new group of this name."u8, + width, !_groupNameValid)) + return; + + _modManager.OptionEditor.AddModGroup(mod, GroupType.Combining, _groupName); + _groupName = string.Empty; + _groupNameValid = false; + } private void DrawImcInput(float width) { var change = ImcMetaDrawer.DrawObjectType(ref _imcIdentifier, width); diff --git a/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs new file mode 100644 index 00000000..e9840e6c --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs @@ -0,0 +1,112 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Mods.Groups; +using Penumbra.Mods.SubMods; + +namespace Penumbra.UI.ModsTab.Groups; + +public readonly struct CombiningModGroupEditDrawer(ModGroupEditDrawer editor, CombiningModGroup group) : IModGroupEditDrawer +{ + public void Draw() + { + foreach (var (option, optionIdx) in group.OptionData.WithIndex()) + { + using var id = ImUtf8.PushId(optionIdx); + editor.DrawOptionPosition(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionDefaultMultiBehaviour(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionName(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDescription(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDelete(option); + } + + DrawNewOption(); + DrawContainerNames(); + } + + private void DrawNewOption() + { + var count = group.OptionData.Count; + if (count >= IModGroup.MaxCombiningOptions) + return; + + var name = editor.DrawNewOptionBase(group, count); + + var validName = name.Length > 0; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, validName + ? "Add a new option to this group."u8 + : "Please enter a name for the new option."u8, default, !validName)) + { + editor.ModManager.OptionEditor.CombiningEditor.AddOption(group, name); + editor.NewOptionName = null; + } + } + + private unsafe void DrawContainerNames() + { + if (ImUtf8.ButtonEx("Edit Container Names"u8, + "Add optional names to separate data containers of the combining group.\nThose are just for easier identification while editing the mod, and are not generally displayed to the user."u8, + new Vector2(400 * ImUtf8.GlobalScale, 0))) + ImUtf8.OpenPopup("DataContainerNames"u8); + + var sizeX = group.OptionData.Count * (ImGui.GetStyle().ItemInnerSpacing.X + ImGui.GetFrameHeight()) + 300 * ImUtf8.GlobalScale; + ImGui.SetNextWindowSize(new Vector2(sizeX, ImGui.GetFrameHeightWithSpacing() * Math.Min(16, group.Data.Count) + 200 * ImUtf8.GlobalScale)); + using var popup = ImUtf8.Popup("DataContainerNames"u8); + if (!popup) + return; + + foreach (var option in group.OptionData) + { + ImUtf8.RotatedText(option.Name, true); + ImUtf8.SameLineInner(); + } + + ImGui.NewLine(); + ImGui.Separator(); + using var child = ImUtf8.Child("##Child"u8, ImGui.GetContentRegionAvail()); + ImGuiClip.ClippedDraw(group.Data, DrawRow, ImGui.GetFrameHeightWithSpacing()); + } + + private void DrawRow(CombinedDataContainer container, int index) + { + using var id = ImUtf8.PushId(index); + using (ImRaii.Disabled()) + { + for (var i = 0; i < group.OptionData.Count; ++i) + { + id.Push(i); + var check = (index & (1 << i)) != 0; + ImUtf8.Checkbox(""u8, ref check); + ImUtf8.SameLineInner(); + id.Pop(); + } + } + + var name = editor.CombiningDisplayIndex == index ? editor.CombiningDisplayName ?? container.Name : container.Name; + if (ImUtf8.InputText("##Nothing"u8, ref name, "Optional Display Name..."u8)) + { + editor.CombiningDisplayIndex = index; + editor.CombiningDisplayName = name; + } + + if (ImGui.IsItemDeactivatedAfterEdit()) + editor.ModManager.OptionEditor.CombiningEditor.SetDisplayName(container, name); + + if (ImGui.IsItemDeactivated()) + { + editor.CombiningDisplayIndex = -1; + editor.CombiningDisplayName = null; + } + } +} diff --git a/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs index 786bb8ff..fa5b0ef6 100644 --- a/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; -using ImGuiNET; -using OtterGui; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using OtterGui.Text.Widget; diff --git a/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs b/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs index dec77430..3d8409ad 100644 --- a/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs @@ -1,8 +1,10 @@ using Dalamud.Interface.Components; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using OtterGui.Widgets; using Penumbra.Collections; using Penumbra.Collections.Manager; @@ -13,16 +15,66 @@ using Penumbra.Mods.SubMods; namespace Penumbra.UI.ModsTab.Groups; -public sealed class ModGroupDrawer(Configuration config, CollectionManager collectionManager) : IUiService +public sealed class ModGroupDrawer : IUiService { private readonly List<(IModGroup, int)> _blockGroupCache = []; + private bool _temporary; + private bool _locked; + private TemporaryModSettings? _tempSettings; + private ModSettings? _settings; + private readonly SingleGroupCombo _combo; + private readonly Configuration _config; + private readonly CollectionManager _collectionManager; - public void Draw(Mod mod, ModSettings settings) + public ModGroupDrawer(Configuration config, CollectionManager collectionManager) + { + _config = config; + _collectionManager = collectionManager; + _combo = new SingleGroupCombo(this); + } + + private sealed class SingleGroupCombo(ModGroupDrawer parent) + : FilterComboCache(() => _group!.Options, MouseWheelType.Control, Penumbra.Log) + { + private static IModGroup? _group; + private static int _groupIdx; + + protected override bool DrawSelectable(int globalIdx, bool selected) + { + var option = _group!.Options[globalIdx]; + var ret = ImUtf8.Selectable(option.Name, globalIdx == CurrentSelectionIdx); + + if (option.Description.Length > 0) + ImUtf8.SelectableHelpMarker(option.Description); + + return ret; + } + + protected override string ToString(IModOption obj) + => obj.Name; + + public void Draw(IModGroup group, int groupIndex, int currentOption) + { + _group = group; + _groupIdx = groupIndex; + CurrentSelectionIdx = currentOption; + CurrentSelection = _group.Options[CurrentSelectionIdx]; + if (Draw(string.Empty, CurrentSelection.Name, string.Empty, ref CurrentSelectionIdx, UiHelpers.InputTextWidth.X * 3 / 4, + ImGui.GetTextLineHeightWithSpacing())) + parent.SetModSetting(_group, _groupIdx, Setting.Single(CurrentSelectionIdx)); + } + } + + public void Draw(Mod mod, ModSettings settings, TemporaryModSettings? tempSettings) { if (mod.Groups.Count <= 0) return; _blockGroupCache.Clear(); + _settings = settings; + _tempSettings = tempSettings; + _temporary = tempSettings != null; + _locked = (tempSettings?.Lock ?? 0) > 0; var useDummy = true; foreach (var (group, idx) in mod.Groups.WithIndex()) { @@ -31,7 +83,7 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle switch (group.Behaviour) { - case GroupDrawBehaviour.SingleSelection when group.Options.Count <= config.SingleGroupRadioMax: + case GroupDrawBehaviour.SingleSelection when group.Options.Count <= _config.SingleGroupRadioMax: case GroupDrawBehaviour.MultiSelection: _blockGroupCache.Add((group, idx)); break; @@ -39,7 +91,7 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle case GroupDrawBehaviour.SingleSelection: ImGuiUtil.Dummy(UiHelpers.DefaultSpace, useDummy); useDummy = false; - DrawSingleGroupCombo(group, idx, settings == ModSettings.Empty ? group.DefaultSettings : settings.Settings[idx]); + DrawSingleGroupCombo(group, idx, settings.IsEmpty ? group.DefaultSettings : settings.Settings[idx]); break; } } @@ -49,7 +101,7 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle { ImGuiUtil.Dummy(UiHelpers.DefaultSpace, useDummy); useDummy = false; - var option = settings == ModSettings.Empty ? group.DefaultSettings : settings.Settings[idx]; + var option = settings.IsEmpty ? group.DefaultSettings : settings.Settings[idx]; if (group.Behaviour is GroupDrawBehaviour.MultiSelection) DrawMultiGroup(group, idx, option); else @@ -63,32 +115,15 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle /// private void DrawSingleGroupCombo(IModGroup group, int groupIdx, Setting setting) { - using var id = ImRaii.PushId(groupIdx); - var selectedOption = setting.AsIndex; - ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X * 3 / 4); - var options = group.Options; - using (var combo = ImRaii.Combo(string.Empty, options[selectedOption].Name)) - { - if (combo) - for (var idx2 = 0; idx2 < options.Count; ++idx2) - { - id.Push(idx2); - var option = options[idx2]; - if (ImGui.Selectable(option.Name, idx2 == selectedOption)) - SetModSetting(group, groupIdx, Setting.Single(idx2)); - - if (option.Description.Length > 0) - ImGuiUtil.SelectableHelpMarker(option.Description); - - id.Pop(); - } - } - + using var id = ImUtf8.PushId(groupIdx); + var selectedOption = setting.AsIndex; + using var disabled = ImRaii.Disabled(_locked); + _combo.Draw(group, groupIdx, selectedOption); ImGui.SameLine(); if (group.Description.Length > 0) - ImGuiUtil.LabeledHelpMarker(group.Name, group.Description); + ImUtf8.LabeledHelpMarker(group.Name, group.Description); else - ImGui.TextUnformatted(group.Name); + ImUtf8.Text(group.Name); } /// @@ -97,10 +132,10 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle /// private void DrawSingleGroupRadio(IModGroup group, int groupIdx, Setting setting) { - using var id = ImRaii.PushId(groupIdx); - var selectedOption = setting.AsIndex; - var minWidth = Widget.BeginFramedGroup(group.Name, group.Description); - var options = group.Options; + using var id = ImUtf8.PushId(groupIdx); + var selectedOption = setting.AsIndex; + var minWidth = Widget.BeginFramedGroup(group.Name, group.Description); + var options = group.Options; DrawCollapseHandling(options, minWidth, DrawOptions); Widget.EndFramedGroup(); @@ -108,11 +143,12 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle void DrawOptions() { + using var disabled = ImRaii.Disabled(_locked); for (var idx = 0; idx < group.Options.Count; ++idx) { - using var i = ImRaii.PushId(idx); - var option = options[idx]; - if (ImGui.RadioButton(option.Name, selectedOption == idx)) + using var i = ImUtf8.PushId(idx); + var option = options[idx]; + if (ImUtf8.RadioButton(option.Name, selectedOption == idx)) SetModSetting(group, groupIdx, Setting.Single(idx)); if (option.Description.Length <= 0) @@ -130,28 +166,29 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle /// private void DrawMultiGroup(IModGroup group, int groupIdx, Setting setting) { - using var id = ImRaii.PushId(groupIdx); - var minWidth = Widget.BeginFramedGroup(group.Name, group.Description); - var options = group.Options; + using var id = ImUtf8.PushId(groupIdx); + var minWidth = Widget.BeginFramedGroup(group.Name, group.Description); + var options = group.Options; DrawCollapseHandling(options, minWidth, DrawOptions); Widget.EndFramedGroup(); var label = $"##multi{groupIdx}"; if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) - ImGui.OpenPopup($"##multi{groupIdx}"); + ImUtf8.OpenPopup($"##multi{groupIdx}"); DrawMultiPopup(group, groupIdx, label); return; void DrawOptions() { + using var disabled = ImRaii.Disabled(_locked); for (var idx = 0; idx < options.Count; ++idx) { - using var i = ImRaii.PushId(idx); - var option = options[idx]; - var enabled = setting.HasFlag(idx); + using var i = ImUtf8.PushId(idx); + var option = options[idx]; + var enabled = setting.HasFlag(idx); - if (ImGui.Checkbox(option.Name, ref enabled)) + if (ImUtf8.Checkbox(option.Name, ref enabled)) SetModSetting(group, groupIdx, setting.SetBit(idx, enabled)); if (option.Description.Length > 0) @@ -171,27 +208,28 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle return; ImGui.TextUnformatted(group.Name); + using var disabled = ImRaii.Disabled(_locked); ImGui.Separator(); - if (ImGui.Selectable("Enable All")) + if (ImUtf8.Selectable("Enable All"u8)) SetModSetting(group, groupIdx, Setting.AllBits(group.Options.Count)); - if (ImGui.Selectable("Disable All")) + if (ImUtf8.Selectable("Disable All"u8)) SetModSetting(group, groupIdx, Setting.Zero); } private void DrawCollapseHandling(IReadOnlyList options, float minWidth, Action draw) { - if (options.Count <= config.OptionGroupCollapsibleMin) + if (options.Count <= _config.OptionGroupCollapsibleMin) { draw(); } else { - var collapseId = ImGui.GetID("Collapse"); - var shown = ImGui.GetStateStorage().GetBool(collapseId, true); + var collapseId = ImUtf8.GetId("Collapse"); + var shown = ImGui.GetStateStorage().GetBool(collapseId, true); var buttonTextShow = $"Show {options.Count} Options"; var buttonTextHide = $"Hide {options.Count} Options"; - var buttonWidth = Math.Max(ImGui.CalcTextSize(buttonTextShow).X, ImGui.CalcTextSize(buttonTextHide).X) + var buttonWidth = Math.Max(ImUtf8.CalcTextSize(buttonTextShow).X, ImUtf8.CalcTextSize(buttonTextHide).X) + 2 * ImGui.GetStyle().FramePadding.X; minWidth = Math.Max(buttonWidth, minWidth); if (shown) @@ -204,30 +242,43 @@ public sealed class ModGroupDrawer(Configuration config, CollectionManager colle } - var width = Math.Max(ImGui.GetItemRectSize().X, minWidth); + var width = Math.Max(ImGui.GetItemRectSize().X, minWidth); var endPos = ImGui.GetCursorPos(); ImGui.SetCursorPos(pos); - if (ImGui.Button(buttonTextHide, new Vector2(width, 0))) + if (ImUtf8.Button(buttonTextHide, new Vector2(width, 0))) ImGui.GetStateStorage().SetBool(collapseId, !shown); ImGui.SetCursorPos(endPos); } else { - var optionWidth = options.Max(o => ImGui.CalcTextSize(o.Name).X) + var optionWidth = options.Max(o => ImUtf8.CalcTextSize(o.Name).X) + ImGui.GetStyle().ItemInnerSpacing.X + ImGui.GetFrameHeight() + ImGui.GetStyle().FramePadding.X; var width = Math.Max(optionWidth, minWidth); - if (ImGui.Button(buttonTextShow, new Vector2(width, 0))) + if (ImUtf8.Button(buttonTextShow, new Vector2(width, 0))) ImGui.GetStateStorage().SetBool(collapseId, !shown); } } } private ModCollection Current - => collectionManager.Active.Current; + => _collectionManager.Active.Current; + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] private void SetModSetting(IModGroup group, int groupIdx, Setting setting) - => collectionManager.Editor.SetModSetting(Current, group.Mod, groupIdx, setting); + { + if (_temporary || _config.DefaultTemporaryMode) + { + _tempSettings ??= new TemporaryModSettings(group.Mod, _settings); + _tempSettings!.ForceInherit = false; + _tempSettings!.Settings[groupIdx] = setting; + _collectionManager.Editor.SetTemporarySettings(Current, group.Mod, _tempSettings); + } + else + { + _collectionManager.Editor.SetModSetting(Current, group.Mod, groupIdx, setting); + } + } } diff --git a/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs index ec5bb920..9610f173 100644 --- a/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs @@ -1,8 +1,9 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; @@ -58,6 +59,9 @@ public sealed class ModGroupEditDrawer( private IModOption? _dragDropOption; private bool _draggingAcross; + internal string? CombiningDisplayName; + internal int CombiningDisplayIndex; + public void Draw(Mod mod) { PrepareStyle(); @@ -275,6 +279,7 @@ public sealed class ModGroupEditDrawer( [MethodImpl(MethodImplOptions.AggressiveInlining)] internal string DrawNewOptionBase(IModGroup group, int count) { + ImGui.AlignTextToFramePadding(); ImUtf8.Selectable($"Option #{count + 1}", false, size: OptionIdxSelectable); Target(group, count); diff --git a/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs index f0275853..04ca6c82 100644 --- a/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs @@ -1,5 +1,5 @@ using Dalamud.Interface; -using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using Penumbra.Mods.Groups; diff --git a/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs index be2dbd73..8fa6a377 100644 --- a/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs +++ b/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs @@ -1,6 +1,6 @@ using Dalamud.Interface; -using ImGuiNET; -using OtterGui; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Text; using Penumbra.Mods.Groups; diff --git a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs index 0781312c..3f3c82aa 100644 --- a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs +++ b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs @@ -2,7 +2,7 @@ using Dalamud.Interface; using Dalamud.Interface.DragDrop; using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; using OtterGui.Filesystem; @@ -62,7 +62,9 @@ public sealed class ModFileSystemSelector : FileSystemSelector SetQuickMove(f, 1, _config.QuickMoveFolder2, s => { _config.QuickMoveFolder2 = s; _config.Save(); }), 120); SubscribeRightClickFolder(f => SetQuickMove(f, 2, _config.QuickMoveFolder3, s => { _config.QuickMoveFolder3 = s; _config.Save(); }), 130); SubscribeRightClickLeaf(ToggleLeafFavorite); + SubscribeRightClickLeaf(DrawTemporaryOptions); SubscribeRightClickLeaf(l => QuickMove(l, _config.QuickMoveFolder1, _config.QuickMoveFolder2, _config.QuickMoveFolder3)); + SubscribeRightClickMain(ClearTemporarySettings, 105); SubscribeRightClickMain(ClearDefaultImportFolder, 100); SubscribeRightClickMain(() => ClearQuickMove(0, _config.QuickMoveFolder1, () => {_config.QuickMoveFolder1 = string.Empty; _config.Save();}), 110); SubscribeRightClickMain(() => ClearQuickMove(1, _config.QuickMoveFolder2, () => {_config.QuickMoveFolder2 = string.Empty; _config.Save();}), 120); @@ -124,23 +126,47 @@ public sealed class ModFileSystemSelector : FileSystemSelector m.Extensions.Any(e => ValidModExtensions.Contains(e.ToLowerInvariant())), m => { - ImGui.TextUnformatted($"Dragging mods for import:\n\t{string.Join("\n\t", m.Files.Select(Path.GetFileName))}"); + ImUtf8.Text($"Dragging mods for import:\n\t{string.Join("\n\t", m.Files.Select(Path.GetFileName))}"); return true; }); - base.Draw(width); + base.Draw(); if (_dragDrop.CreateImGuiTarget("ModDragDrop", out var files, out _)) _modImportManager.AddUnpack(files.Where(f => ValidModExtensions.Contains(Path.GetExtension(f.ToLowerInvariant())))); } + protected override float CurrentWidth + => _config.Ephemeral.CurrentModSelectorWidth * ImUtf8.GlobalScale; + + protected override float MinimumAbsoluteRemainder + => 550 * ImUtf8.GlobalScale; + + protected override float MinimumScaling + => _config.Ephemeral.ModSelectorMinimumScale; + + protected override float MaximumScaling + => _config.Ephemeral.ModSelectorMaximumScale; + + protected override void SetSize(Vector2 size) + { + base.SetSize(size); + var adaptedSize = MathF.Round(size.X / ImUtf8.GlobalScale); + if (adaptedSize == _config.Ephemeral.CurrentModSelectorWidth) + return; + + _config.Ephemeral.CurrentModSelectorWidth = adaptedSize; + _config.Ephemeral.Save(); + } + public override void Dispose() { base.Dispose(); @@ -194,24 +220,37 @@ public sealed class ModFileSystemSelector : FileSystemSelector.Leaf leaf, in ModState state, bool selected) { var flags = selected ? ImGuiTreeNodeFlags.Selected | LeafFlags : LeafFlags; - using var c = ImRaii.PushColor(ImGuiCol.Text, state.Color.Value()) + using var c = ImRaii.PushColor(ImGuiCol.Text, state.Color.Tinted(state.Tint)) .Push(ImGuiCol.HeaderHovered, 0x4000FFFF, leaf.Value.Favorite); - using var id = ImRaii.PushId(leaf.Value.Index); - ImRaii.TreeNode(leaf.Value.Name, flags).Dispose(); + using var id = ImUtf8.PushId(leaf.Value.Index); + ImUtf8.TreeNode(leaf.Value.Name.Text, flags).Dispose(); if (ImGui.IsItemClicked(ImGuiMouseButton.Middle)) { _modManager.SetKnown(leaf.Value); - var (setting, collection) = _collectionManager.Active.Current[leaf.Value.Index]; + var (setting, collection) = _collectionManager.Active.Current.GetActualSettings(leaf.Value.Index); if (_config.DeleteModModifier.ForcedModifier(new DoubleModifier(ModifierHotkey.Control, ModifierHotkey.Shift)).IsActive()) { - _collectionManager.Editor.SetModInheritance(_collectionManager.Active.Current, leaf.Value, true); + // Delete temporary settings if they exist, regardless of mode, or set to inheriting if none exist. + if (_collectionManager.Active.Current.GetTempSettings(leaf.Value.Index) is not null) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, leaf.Value, null); + else + _collectionManager.Editor.SetModInheritance(_collectionManager.Active.Current, leaf.Value, true); } else { - var inherited = collection != _collectionManager.Active.Current; - if (inherited) - _collectionManager.Editor.SetModInheritance(_collectionManager.Active.Current, leaf.Value, false); - _collectionManager.Editor.SetModState(_collectionManager.Active.Current, leaf.Value, setting is not { Enabled: true }); + if (_config.DefaultTemporaryMode) + { + var settings = new TemporaryModSettings(leaf.Value, setting) { ForceInherit = false }; + settings.Enabled = !settings.Enabled; + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, leaf.Value, settings); + } + else + { + var inherited = collection != _collectionManager.Active.Current; + if (inherited) + _collectionManager.Editor.SetModInheritance(_collectionManager.Active.Current, leaf.Value, false); + _collectionManager.Editor.SetModState(_collectionManager.Active.Current, leaf.Value, setting is not { Enabled: true }); + } } } @@ -236,37 +275,69 @@ public sealed class ModFileSystemSelector : FileSystemSelector.Leaf mod) { - if (ImGui.MenuItem(mod.Value.Favorite ? "Remove Favorite" : "Mark as Favorite")) + if (ImUtf8.MenuItem(mod.Value.Favorite ? "Remove Favorite"u8 : "Mark as Favorite"u8)) _modManager.DataEditor.ChangeModFavorite(mod.Value, !mod.Value.Favorite); } + private void DrawTemporaryOptions(FileSystem.Leaf mod) + { + var tempSettings = _collectionManager.Active.Current.GetTempSettings(mod.Value.Index); + if (tempSettings is { Lock: > 0 }) + return; + + if (tempSettings is { Lock: <= 0 } && ImUtf8.MenuItem("Remove Temporary Settings"u8)) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, null); + var actual = _collectionManager.Active.Current.GetActualSettings(mod.Value.Index).Settings; + if (actual?.Enabled is true && ImUtf8.MenuItem("Disable Temporarily"u8)) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, + new TemporaryModSettings(mod.Value, actual) { Enabled = false }); + + if (actual is not { Enabled: true } && ImUtf8.MenuItem("Enable Temporarily"u8)) + { + var newSettings = actual is null + ? TemporaryModSettings.DefaultSettings(mod.Value, TemporaryModSettings.OwnSource, true) + : new TemporaryModSettings(mod.Value, actual) { Enabled = true }; + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, newSettings); + } + + if (tempSettings is null && ImUtf8.MenuItem("Turn Temporary"u8)) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, + new TemporaryModSettings(mod.Value, actual)); + } + private void SetDefaultImportFolder(ModFileSystem.Folder folder) { - if (!ImGui.MenuItem("Set As Default Import Folder")) + if (!ImUtf8.MenuItem("Set As Default Import Folder"u8)) return; var newName = folder.FullName(); @@ -279,7 +350,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector Add an import mods button that opens a file selector. private void AddImportModButton(Vector2 size) { - var button = ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.FileImport.ToIconString(), size, - "Import one or multiple mods from Tex Tools Mod Pack Files or Penumbra Mod Pack Files.", !_modManager.Valid, true); + var button = ImUtf8.IconButton(FontAwesomeIcon.FileImport, + "Import one or multiple mods from Tex Tools Mod Pack Files or Penumbra Mod Pack Files."u8, size, !_modManager.Valid); _tutorial.OpenTutorial(BasicTutorialSteps.ModImport); if (!button) return; @@ -311,7 +381,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector + "Mod Packs{.ttmp,.ttmp2,.pmp,.pcp},TexTools Mod Packs{.ttmp,.ttmp2},Penumbra Mod Packs{.pmp,.pcp},Archives{.zip,.7z,.rar},Penumbra Character Packs{.pcp}", (s, f) => { if (!s) return; @@ -332,14 +402,14 @@ public sealed class ModFileSystemSelector : FileSystemSelector { ImGui.Dummy(Vector2.UnitY * ImGui.GetTextLineHeight()); - ImGui.TextUnformatted("Mod Management"); - ImGui.BulletText("You can create empty mods or import mods with the buttons in this row."); + ImUtf8.Text("Mod Management"u8); + ImUtf8.BulletText("You can create empty mods or import mods with the buttons in this row."u8); using var indent = ImRaii.PushIndent(); - ImGui.BulletText("Supported formats for import are: .ttmp, .ttmp2, .pmp."); - ImGui.BulletText( - "You can also support .zip, .7z or .rar archives, but only if they already contain Penumbra-styled mods with appropriate metadata."); + ImUtf8.BulletText("Supported formats for import are: .ttmp, .ttmp2, .pmp, .pcp."u8); + ImUtf8.BulletText( + "You can also support .zip, .7z or .rar archives, but only if they already contain Penumbra-styled mods with appropriate metadata."u8); indent.Pop(1); - ImGui.BulletText("You can also create empty mod folders and delete mods."); - ImGui.BulletText("For further editing of mods, select them and use the Edit Mod tab in the panel or the Advanced Editing popup."); + ImUtf8.BulletText("You can also create empty mod folders and delete mods."u8); + ImUtf8.BulletText( + "For further editing of mods, select them and use the Edit Mod tab in the panel or the Advanced Editing popup."u8); ImGui.Dummy(Vector2.UnitY * ImGui.GetTextLineHeight()); - ImGui.TextUnformatted("Mod Selector"); - ImGui.BulletText("Select a mod to obtain more information or change settings."); - ImGui.BulletText("Names are colored according to your config and their current state in the collection:"); + ImUtf8.Text("Mod Selector"u8); + ImUtf8.BulletText("Select a mod to obtain more information or change settings."u8); + ImUtf8.BulletText("Names are colored according to your config and their current state in the collection:"u8); indent.Push(); - ImGuiUtil.BulletTextColored(ColorId.EnabledMod.Value(), "enabled in the current collection."); - ImGuiUtil.BulletTextColored(ColorId.DisabledMod.Value(), "disabled in the current collection."); - ImGuiUtil.BulletTextColored(ColorId.InheritedMod.Value(), "enabled due to inheritance from another collection."); - ImGuiUtil.BulletTextColored(ColorId.InheritedDisabledMod.Value(), "disabled due to inheritance from another collection."); - ImGuiUtil.BulletTextColored(ColorId.UndefinedMod.Value(), "unconfigured in all inherited collections."); - ImGuiUtil.BulletTextColored(ColorId.NewMod.Value(), - "newly imported during this session. Will go away when first enabling a mod or when Penumbra is reloaded."); - ImGuiUtil.BulletTextColored(ColorId.HandledConflictMod.Value(), - "enabled and conflicting with another enabled Mod, but on different priorities (i.e. the conflict is solved)."); - ImGuiUtil.BulletTextColored(ColorId.ConflictingMod.Value(), - "enabled and conflicting with another enabled Mod on the same priority."); - ImGuiUtil.BulletTextColored(ColorId.FolderExpanded.Value(), "expanded mod folder."); - ImGuiUtil.BulletTextColored(ColorId.FolderCollapsed.Value(), "collapsed mod folder"); + ImUtf8.BulletTextColored(ColorId.EnabledMod.Value(), "enabled in the current collection."u8); + ImUtf8.BulletTextColored(ColorId.DisabledMod.Value(), "disabled in the current collection."u8); + ImUtf8.BulletTextColored(ColorId.InheritedMod.Value(), "enabled due to inheritance from another collection."u8); + ImUtf8.BulletTextColored(ColorId.InheritedDisabledMod.Value(), "disabled due to inheritance from another collection."u8); + ImUtf8.BulletTextColored(ColorId.UndefinedMod.Value(), "unconfigured in all inherited collections."u8); + ImUtf8.BulletTextColored(ColorId.HandledConflictMod.Value(), + "enabled and conflicting with another enabled Mod, but on different priorities (i.e. the conflict is solved)."u8); + ImUtf8.BulletTextColored(ColorId.ConflictingMod.Value(), + "enabled and conflicting with another enabled Mod on the same priority."u8); + ImUtf8.BulletTextColored(ColorId.FolderExpanded.Value(), "expanded mod folder."u8); + ImUtf8.BulletTextColored(ColorId.FolderCollapsed.Value(), "collapsed mod folder"u8); indent.Pop(1); - ImGui.BulletText("Middle-click a mod to disable it if it is enabled or enable it if it is disabled."); + ImUtf8.BulletText("Middle-click a mod to disable it if it is enabled or enable it if it is disabled."u8); indent.Push(); - ImGui.BulletText( + ImUtf8.BulletText( $"Holding {_config.DeleteModModifier.ForcedModifier(new DoubleModifier(ModifierHotkey.Control, ModifierHotkey.Shift))} while middle-clicking lets it inherit, discarding settings."); indent.Pop(1); - ImGui.BulletText("Right-click a mod to enter its sort order, which is its name by default, possibly with a duplicate number."); + ImUtf8.BulletText("Right-click a mod to enter its sort order, which is its name by default, possibly with a duplicate number."u8); indent.Push(); - ImGui.BulletText("A sort order differing from the mods name will not be displayed, it will just be used for ordering."); - ImGui.BulletText( - "If the sort order string contains Forward-Slashes ('/'), the preceding substring will be turned into folders automatically."); + ImUtf8.BulletText("A sort order differing from the mods name will not be displayed, it will just be used for ordering."u8); + ImUtf8.BulletText( + "If the sort order string contains Forward-Slashes ('/'), the preceding substring will be turned into folders automatically."u8); indent.Pop(1); - ImGui.BulletText( - "You can drag and drop mods and subfolders into existing folders. Dropping them onto mods is the same as dropping them onto the parent of the mod."); + ImUtf8.BulletText( + "You can drag and drop mods and subfolders into existing folders. Dropping them onto mods is the same as dropping them onto the parent of the mod."u8); indent.Push(); - ImGui.BulletText( - "You can select multiple mods and folders by holding Control while clicking them, and then drag all of them at once."); - ImGui.BulletText( - "Selected mods inside an also selected folder will be ignored when dragging and move inside their folder instead of directly into the target."); + ImUtf8.BulletText( + "You can select multiple mods and folders by holding Control while clicking them, and then drag all of them at once."u8); + ImUtf8.BulletText( + "Selected mods inside an also selected folder will be ignored when dragging and move inside their folder instead of directly into the target."u8); indent.Pop(1); - ImGui.BulletText("Right-clicking a folder opens a context menu."); - ImGui.BulletText("Right-clicking empty space allows you to expand or collapse all folders at once."); - ImGui.BulletText("Use the Filter Mods... input at the top to filter the list for mods whose name or path contain the text."); + ImUtf8.BulletText("Right-clicking a folder opens a context menu."u8); + ImUtf8.BulletText("Right-clicking empty space allows you to expand or collapse all folders at once."u8); + ImUtf8.BulletText("Use the Filter Mods... input at the top to filter the list for mods whose name or path contain the text."u8); indent.Push(); - ImGui.BulletText("You can enter n:[string] to filter only for names, without path."); - ImGui.BulletText("You can enter c:[string] to filter for Changed Items instead."); - ImGui.BulletText("You can enter a:[string] to filter for Mod Authors instead."); + ImUtf8.BulletText("You can enter n:[string] to filter only for names, without path."u8); + ImUtf8.BulletText("You can enter c:[string] to filter for Changed Items instead."u8); + ImUtf8.BulletText("You can enter a:[string] to filter for Mod Authors instead."u8); indent.Pop(1); - ImGui.BulletText("Use the expandable menu beside the input to filter for mods fulfilling specific criteria."); + ImUtf8.BulletText("Use the expandable menu beside the input to filter for mods fulfilling specific criteria."u8); }); } @@ -501,6 +570,7 @@ public sealed class ModFileSystemSelector : FileSystemSelector !_filter.IsVisible(leaf); /// Only get the text color for a mod if no filters are set. - private ColorId GetTextColor(Mod mod, ModSettings? settings, ModCollection collection) + private (ColorId Color, ColorId Tint) GetTextColor(Mod mod, ModSettings? settings, ModCollection collection) { - if (_modManager.IsNew(mod)) - return ColorId.NewMod; + var tint = settings.IsTemporary() + ? ColorId.TemporaryModSettingsTint + : _modManager.IsNew(mod) + ? ColorId.NewModTint + : ColorId.NoTint; + if (settings.IsTemporary()) + tint = ColorId.TemporaryModSettingsTint; if (settings == null) - return ColorId.UndefinedMod; + return (ColorId.UndefinedMod, tint); if (!settings.Enabled) - return collection != _collectionManager.Active.Current ? ColorId.InheritedDisabledMod : ColorId.DisabledMod; + return (collection != _collectionManager.Active.Current + ? ColorId.InheritedDisabledMod + : ColorId.DisabledMod, tint); var conflicts = _collectionManager.Active.Current.Conflicts(mod); if (conflicts.Count == 0) - return collection != _collectionManager.Active.Current ? ColorId.InheritedMod : ColorId.EnabledMod; + return (collection != _collectionManager.Active.Current ? ColorId.InheritedMod : ColorId.EnabledMod, tint); - return conflicts.Any(c => !c.Solved) + return (conflicts.Any(c => !c.Solved) ? ColorId.ConflictingMod - : ColorId.HandledConflictMod; + : ColorId.HandledConflictMod, tint); } private bool CheckStateFilters(Mod mod, ModSettings? settings, ModCollection collection, ref ModState state) @@ -607,6 +684,16 @@ public sealed class ModFileSystemSelector : FileSystemSelector TriStatePairs = [ @@ -38,6 +40,7 @@ public static class ModFilterExtensions (ModFilter.HasFiles, ModFilter.HasNoFiles, "Has Redirections"), (ModFilter.HasMetaManipulations, ModFilter.HasNoMetaManipulations, "Has Meta Manipulations"), (ModFilter.HasFileSwaps, ModFilter.HasNoFileSwaps, "Has File Swaps"), + (ModFilter.Temporary, ModFilter.NotTemporary, "Temporary"), ]; public static IReadOnlyList> Groups = diff --git a/Penumbra/UI/ModsTab/ModPanel.cs b/Penumbra/UI/ModsTab/ModPanel.cs index 9d6ead62..b7546699 100644 --- a/Penumbra/UI/ModsTab/ModPanel.cs +++ b/Penumbra/UI/ModsTab/ModPanel.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility.Raii; using Dalamud.Plugin; -using ImGuiNET; using OtterGui.Services; using Penumbra.Mods; using Penumbra.Services; diff --git a/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs b/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs index a7bdadd3..332b64f0 100644 --- a/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs +++ b/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs @@ -1,47 +1,348 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; using OtterGui; using OtterGui.Classes; -using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using OtterGui.Widgets; using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.String; +using Penumbra.UI.Classes; namespace Penumbra.UI.ModsTab; -public class ModPanelChangedItemsTab(ModFileSystemSelector selector, ChangedItemDrawer drawer) : ITab, IUiService +public class ModPanelChangedItemsTab( + ModFileSystemSelector selector, + ChangedItemDrawer drawer, + ImGuiCacheService cacheService, + Configuration config, + ModDataEditor dataEditor) + : ITab, IUiService { + private readonly ImGuiCacheService.CacheId _cacheId = cacheService.GetNewId(); + + private class ChangedItemsCache + { + private Mod? _lastSelected; + private ushort _lastUpdate; + private ChangedItemIconFlag _filter = ChangedItemFlagExtensions.DefaultFlags; + private ChangedItemMode _lastMode; + private bool _reset; + public readonly List Data = []; + public bool AnyExpandable { get; private set; } + + public record struct Container + { + public IIdentifiedObjectData Data; + public ByteString Text; + public ByteString ModelData; + public uint Id; + public int Children; + public ChangedItemIconFlag Icon; + public bool Expandable; + public bool Expanded; + public bool Child; + + public static Container Single(string text, IIdentifiedObjectData data) + => new() + { + Child = false, + Text = ByteString.FromStringUnsafe(data.ToName(text), false), + ModelData = ByteString.FromStringUnsafe(data.AdditionalData, false), + Icon = data.GetIcon().ToFlag(), + Expandable = false, + Expanded = false, + Data = data, + Id = 0, + Children = 0, + }; + + public static Container Parent(string text, IIdentifiedObjectData data, uint id, int children, bool expanded) + => new() + { + Child = false, + Text = ByteString.FromStringUnsafe(data.ToName(text), false), + ModelData = ByteString.FromStringUnsafe(data.AdditionalData, false), + Icon = data.GetIcon().ToFlag(), + Expandable = true, + Expanded = expanded, + Data = data, + Id = id, + Children = children, + }; + + public static Container Indent(string text, IIdentifiedObjectData data) + => new() + { + Child = true, + Text = ByteString.FromStringUnsafe(data.ToName(text), false), + ModelData = ByteString.FromStringUnsafe(data.AdditionalData, false), + Icon = data.GetIcon().ToFlag(), + Expandable = false, + Expanded = false, + Data = data, + Id = 0, + Children = 0, + }; + } + + public void Reset() + => _reset = true; + + public void Update(Mod? mod, ChangedItemDrawer drawer, ChangedItemIconFlag filter, ChangedItemMode mode) + { + if (mod == _lastSelected + && _lastSelected!.LastChangedItemsUpdate == _lastUpdate + && _filter == filter + && !_reset + && _lastMode == mode) + return; + + _reset = false; + Data.Clear(); + AnyExpandable = false; + _lastSelected = mod; + _filter = filter; + _lastMode = mode; + if (_lastSelected == null) + return; + + _lastUpdate = _lastSelected.LastChangedItemsUpdate; + + if (mode is ChangedItemMode.Alphabetical) + { + foreach (var (s, i) in _lastSelected.ChangedItems) + { + if (drawer.FilterChangedItem(s, i, LowerString.Empty)) + Data.Add(Container.Single(s, i)); + } + + return; + } + + var tmp = new Dictionary<(PrimaryId, FullEquipType), List>(); + var defaultExpansion = _lastMode is ChangedItemMode.GroupedExpanded; + foreach (var (s, i) in _lastSelected.ChangedItems) + { + if (i is not IdentifiedItem item) + continue; + + if (!drawer.FilterChangedItem(s, item, LowerString.Empty)) + continue; + + if (tmp.TryGetValue((item.Item.PrimaryId, item.Item.Type), out var p)) + p.Add(item); + else + tmp[(item.Item.PrimaryId, item.Item.Type)] = [item]; + } + + foreach (var list in tmp.Values) + { + list.Sort((i1, i2) => + { + // reversed + var preferred = _lastSelected.PreferredChangedItems.Contains(i2.Item.Id) + .CompareTo(_lastSelected.PreferredChangedItems.Contains(i1.Item.Id)); + if (preferred != 0) + return preferred; + + // reversed + var count = i2.Count.CompareTo(i1.Count); + if (count != 0) + return count; + + return string.Compare(i1.Item.Name, i2.Item.Name, StringComparison.Ordinal); + }); + } + + var sortedTmp = tmp.Values.OrderBy(s => s[0].Item.Name).ToArray(); + + var sortedTmpIdx = 0; + foreach (var (s, i) in _lastSelected.ChangedItems) + { + if (i is IdentifiedItem) + continue; + + if (!drawer.FilterChangedItem(s, i, LowerString.Empty)) + continue; + + while (sortedTmpIdx < sortedTmp.Length + && string.Compare(sortedTmp[sortedTmpIdx][0].Item.Name, s, StringComparison.Ordinal) <= 0) + AddList(sortedTmp[sortedTmpIdx++]); + + Data.Add(Container.Single(s, i)); + } + + for (; sortedTmpIdx < sortedTmp.Length; ++sortedTmpIdx) + AddList(sortedTmp[sortedTmpIdx]); + return; + + void AddList(List list) + { + var mainItem = list[0]; + if (list.Count == 1) + { + Data.Add(Container.Single(mainItem.Item.Name, mainItem)); + } + else + { + var id = ImUtf8.GetId($"{mainItem.Item.PrimaryId}{(int)mainItem.Item.Type}"); + var expanded = ImGui.GetStateStorage().GetBool(id, defaultExpansion); + Data.Add(Container.Parent(mainItem.Item.Name, mainItem, id, list.Count - 1, expanded)); + AnyExpandable = true; + if (!expanded) + return; + + foreach (var item in list.Skip(1)) + Data.Add(Container.Indent(item.Item.Name, item)); + } + } + } + } + public ReadOnlySpan Label => "Changed Items"u8; public bool IsVisible => selector.Selected!.ChangedItems.Count > 0; + private ImGuiStoragePtr _stateStorage; + + private Vector2 _buttonSize; + private uint _starColor; + public void DrawContent() { + if (cacheService.Cache(_cacheId, () => (new ChangedItemsCache(), "ModPanelChangedItemsCache")) is not { } cache) + return; + drawer.DrawTypeFilter(); + + _stateStorage = ImGui.GetStateStorage(); + cache.Update(selector.Selected, drawer, config.Ephemeral.ChangedItemFilter, config.ChangedItemDisplay); ImGui.Separator(); - using var table = ImRaii.Table("##changedItems", 1, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, + _buttonSize = new Vector2(ImGui.GetStyle().ItemSpacing.Y + ImGui.GetFrameHeight()); + using var style = ImRaii.PushStyle(ImGuiStyleVar.CellPadding, Vector2.Zero) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .Push(ImGuiStyleVar.FramePadding, Vector2.Zero) + .Push(ImGuiStyleVar.SelectableTextAlign, new Vector2(0.01f, 0.5f)); + using var color = ImRaii.PushColor(ImGuiCol.Button, 0); + + using var table = ImUtf8.Table("##changedItems"u8, cache.AnyExpandable ? 2 : 1, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, new Vector2(ImGui.GetContentRegionAvail().X, -1)); if (!table) return; - var zipList = ZipList.FromSortedList(selector.Selected!.ChangedItems); - var height = ImGui.GetFrameHeightWithSpacing(); - ImGui.TableNextColumn(); - var skips = ImGuiClip.GetNecessarySkips(height); - var remainder = ImGuiClip.FilteredClippedDraw(zipList, skips, CheckFilter, DrawChangedItem); - ImGuiClip.DrawEndDummy(remainder, height); + _starColor = ColorId.ChangedItemPreferenceStar.Value(); + if (cache.AnyExpandable) + { + ImUtf8.TableSetupColumn("##exp"u8, ImGuiTableColumnFlags.WidthFixed, _buttonSize.Y); + ImUtf8.TableSetupColumn("##text"u8, ImGuiTableColumnFlags.WidthStretch); + ImGuiClip.ClippedDraw(cache.Data, DrawContainerExpandable, _buttonSize.Y); + } + else + { + ImGuiClip.ClippedDraw(cache.Data, DrawContainer, _buttonSize.Y); + } } - private bool CheckFilter((string Name, IIdentifiedObjectData? Data) kvp) - => drawer.FilterChangedItem(kvp.Name, kvp.Data, LowerString.Empty); + private void DrawContainerExpandable(ChangedItemsCache.Container obj, int idx) + { + using var id = ImUtf8.PushId(idx); + ImGui.TableNextColumn(); + if (obj.Expandable) + { + if (ImUtf8.IconButton(obj.Expanded ? FontAwesomeIcon.CaretDown : FontAwesomeIcon.CaretRight, + obj.Expanded ? "Hide the other items using the same model." : + obj.Children > 1 ? $"Show {obj.Children} other items using the same model." : + "Show one other item using the same model.", + _buttonSize)) + { + _stateStorage.SetBool(obj.Id, !obj.Expanded); + if (cacheService.TryGetCache(_cacheId, out var cache)) + cache.Reset(); + } + } + else if (obj is { Child: true, Data: IdentifiedItem item }) + { + DrawPreferredButton(item, idx); + } + else + { + ImGui.Dummy(_buttonSize); + } - private void DrawChangedItem((string Name, IIdentifiedObjectData? Data) kvp) + DrawBaseContainer(obj, idx); + } + + private void DrawContainer(ChangedItemsCache.Container obj, int idx) + { + using var id = ImUtf8.PushId(idx); + DrawBaseContainer(obj, idx); + } + + private void DrawPreferredButton(IdentifiedItem item, int idx) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Star, "Prefer displaying this item instead of the current primary item.\n\nRight-click for more options."u8, _buttonSize, + false, _starColor)) + dataEditor.AddPreferredItem(selector.Selected!, item.Item.Id, false, true); + using var context = ImUtf8.PopupContextItem("StarContext"u8, ImGuiPopupFlags.MouseButtonRight); + if (!context) + return; + + if (cacheService.TryGetCache(_cacheId, out var cache)) + for (--idx; idx >= 0; --idx) + { + if (!cache.Data[idx].Expanded) + continue; + + if (cache.Data[idx].Data is IdentifiedItem it) + { + if (selector.Selected!.PreferredChangedItems.Contains(it.Item.Id) + && ImUtf8.MenuItem("Remove Parent from Local Preferred Items"u8)) + dataEditor.RemovePreferredItem(selector.Selected!, it.Item.Id, false); + if (selector.Selected!.DefaultPreferredItems.Contains(it.Item.Id) + && ImUtf8.MenuItem("Remove Parent from Default Preferred Items"u8)) + dataEditor.RemovePreferredItem(selector.Selected!, it.Item.Id, true); + } + + break; + } + + var enabled = !selector.Selected!.DefaultPreferredItems.Contains(item.Item.Id); + if (enabled) + { + if (ImUtf8.MenuItem("Add to Local and Default Preferred Changed Items"u8)) + dataEditor.AddPreferredItem(selector.Selected!, item.Item.Id, true, true); + } + else + { + if (ImUtf8.MenuItem("Remove from Default Preferred Changed Items"u8)) + dataEditor.RemovePreferredItem(selector.Selected!, item.Item.Id, true); + } + + if (ImUtf8.MenuItem("Reset Local Preferred Items to Default"u8)) + dataEditor.ResetPreferredItems(selector.Selected!); + + if (ImUtf8.MenuItem("Clear Local and Default Preferred Items not Changed by the Mod"u8)) + dataEditor.ClearInvalidPreferredItems(selector.Selected!); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DrawBaseContainer(in ChangedItemsCache.Container obj, int idx) { ImGui.TableNextColumn(); - drawer.DrawCategoryIcon(kvp.Data); - ImGui.SameLine(); - drawer.DrawChangedItem(kvp.Name, kvp.Data); - ChangedItemDrawer.DrawModelData(kvp.Data); + using var indent = ImRaii.PushIndent(1, obj.Child); + drawer.DrawCategoryIcon(obj.Icon, _buttonSize.Y); + ImGui.SameLine(0, 0); + var clicked = ImUtf8.Selectable(obj.Text.Span, false, ImGuiSelectableFlags.None, _buttonSize with { X = 0 }); + drawer.ChangedItemHandling(obj.Data, clicked); + ChangedItemDrawer.DrawModelData(obj.ModelData.Span, _buttonSize.Y); } } diff --git a/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs b/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs index b7648428..70cad148 100644 --- a/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs +++ b/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; -using ImGuiNET; -using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; @@ -56,7 +56,7 @@ public class ModPanelCollectionsTab(CollectionManager manager, ModFileSystemSele foreach (var ((collection, parent, color, state), idx) in _cache.WithIndex()) { using var id = ImUtf8.PushId(idx); - ImUtf8.DrawTableColumn(collection.Name); + ImUtf8.DrawTableColumn(collection.Identity.Name); ImGui.TableNextColumn(); ImUtf8.Text(ToText(state), color); @@ -65,7 +65,7 @@ public class ModPanelCollectionsTab(CollectionManager manager, ModFileSystemSele { if (context) { - ImUtf8.Text(collection.Name); + ImUtf8.Text(collection.Identity.Name); ImGui.Separator(); using (ImRaii.Disabled(state is ModState.Enabled && parent == collection)) { @@ -95,7 +95,7 @@ public class ModPanelCollectionsTab(CollectionManager manager, ModFileSystemSele } } - ImUtf8.DrawTableColumn(parent == collection ? string.Empty : parent.Name); + ImUtf8.DrawTableColumn(parent == collection ? string.Empty : parent.Identity.Name); } } @@ -120,7 +120,7 @@ public class ModPanelCollectionsTab(CollectionManager manager, ModFileSystemSele var inheritedCount = 0; foreach (var collection in manager.Storage) { - var (settings, parent) = collection[mod.Index]; + var (settings, parent) = collection.GetInheritedSettings(mod.Index); var (color, text) = settings == null ? (undefined, ModState.Unconfigured) : settings.Enabled diff --git a/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs b/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs index bc18ac51..1002d8ca 100644 --- a/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs +++ b/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs @@ -1,7 +1,8 @@ using Dalamud.Interface; using Dalamud.Interface.Utility; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Text; @@ -34,7 +35,7 @@ public class ModPanelConflictsTab(CollectionManager collectionManager, ModFileSy if (conflicts.Mod2.Index < 0) return conflicts.Mod2.Priority; - return collectionManager.Active.Current[conflicts.Mod2.Index].Settings?.Priority ?? ModPriority.Default; + return collectionManager.Active.Current.GetActualSettings(conflicts.Mod2.Index).Settings?.Priority ?? ModPriority.Default; } public void DrawContent() @@ -72,24 +73,29 @@ public class ModPanelConflictsTab(CollectionManager collectionManager, ModFileSy ImGui.TableNextColumn(); using var c = ImRaii.PushColor(ImGuiCol.Text, ColorId.FolderLine.Value()); ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(selector.Selected!.Name); + ImGui.TextUnformatted(selector.Selected!.Name.Text); ImGui.TableNextColumn(); - var priority = collectionManager.Active.Current[selector.Selected!.Index].Settings!.Priority.Value; - ImGui.SetNextItemWidth(priorityWidth); - if (ImGui.InputInt("##priority", ref priority, 0, 0, ImGuiInputTextFlags.EnterReturnsTrue)) - _currentPriority = priority; - - if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) + var actualSettings = collectionManager.Active.Current.GetActualSettings(selector.Selected!.Index).Settings!; + var priority = actualSettings.Priority.Value; + // TODO + using (ImRaii.Disabled(actualSettings is TemporaryModSettings)) { - if (_currentPriority != collectionManager.Active.Current[selector.Selected!.Index].Settings!.Priority.Value) - collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selector.Selected!, - new ModPriority(_currentPriority.Value)); + ImGui.SetNextItemWidth(priorityWidth); + if (ImGui.InputInt("##priority", ref priority, 0, 0, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + _currentPriority = priority; - _currentPriority = null; - } - else if (ImGui.IsItemDeactivated()) - { - _currentPriority = null; + if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) + { + if (_currentPriority != actualSettings.Priority.Value) + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selector.Selected!, + new ModPriority(_currentPriority.Value)); + + _currentPriority = null; + } + else if (ImGui.IsItemDeactivated()) + { + _currentPriority = null; + } } ImGui.TableNextColumn(); @@ -98,7 +104,7 @@ public class ModPanelConflictsTab(CollectionManager collectionManager, ModFileSy private void DrawConflictSelectable(ModConflicts conflict) { ImGui.AlignTextToFramePadding(); - if (ImGui.Selectable(conflict.Mod2.Name) && conflict.Mod2 is Mod otherMod) + if (ImGui.Selectable(conflict.Mod2.Name.Text) && conflict.Mod2 is Mod otherMod) selector.SelectByValue(otherMod); var hovered = ImGui.IsItemHovered(); var rightClicked = ImGui.IsItemClicked(ImGuiMouseButton.Right); @@ -138,7 +144,7 @@ public class ModPanelConflictsTab(CollectionManager collectionManager, ModFileSy ImGui.TableNextColumn(); var conflictPriority = DrawPriorityInput(conflict, priorityWidth); ImGui.SameLine(); - var selectedPriority = collectionManager.Active.Current[selector.Selected!.Index].Settings!.Priority.Value; + var selectedPriority = collectionManager.Active.Current.GetActualSettings(selector.Selected!.Index).Settings!.Priority.Value; DrawPriorityButtons(conflict.Mod2 as Mod, conflictPriority, selectedPriority, buttonSize); ImGui.TableNextColumn(); DrawExpandButton(conflict.Mod2, expanded, buttonSize); @@ -166,7 +172,7 @@ public class ModPanelConflictsTab(CollectionManager collectionManager, ModFileSy var priority = _currentPriority ?? GetPriority(conflict).Value; ImGui.SetNextItemWidth(priorityWidth); - if (ImGui.InputInt("##priority", ref priority, 0, 0, ImGuiInputTextFlags.EnterReturnsTrue)) + if (ImGui.InputInt("##priority", ref priority, 0, 0, flags: ImGuiInputTextFlags.EnterReturnsTrue)) _currentPriority = priority; if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) diff --git a/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs b/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs index 6fe3e4c6..b8710707 100644 --- a/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs +++ b/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; -using ImGuiNET; using OtterGui.Raii; using OtterGui; using OtterGui.Services; @@ -30,7 +30,7 @@ public class ModPanelDescriptionTab( ImGui.Dummy(ImGuiHelpers.ScaledVector2(2)); ImGui.Dummy(ImGuiHelpers.ScaledVector2(2)); - var (predefinedTagsEnabled, predefinedTagButtonOffset) = predefinedTagsConfig.Count > 0 + var (predefinedTagsEnabled, predefinedTagButtonOffset) = predefinedTagsConfig.Enabled ? (true, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.X + (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize : 0)) : (false, 0); var tagIdx = _localTags.Draw("Local Tags: ", diff --git a/Penumbra/UI/ModsTab/ModPanelEditTab.cs b/Penumbra/UI/ModsTab/ModPanelEditTab.cs index 1ccee2cb..c3737b40 100644 --- a/Penumbra/UI/ModsTab/ModPanelEditTab.cs +++ b/Penumbra/UI/ModsTab/ModPanelEditTab.cs @@ -1,7 +1,7 @@ using Dalamud.Interface; using Dalamud.Interface.Components; using Dalamud.Interface.ImGuiNotification; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using OtterGui.Widgets; @@ -65,7 +65,11 @@ public class ModPanelEditTab( } UiHelpers.DefaultLineSpace(); - var sharedTagsEnabled = predefinedTagManager.Count > 0; + + FeatureChecker.DrawFeatureFlagInput(modManager.DataEditor, _mod, UiHelpers.InputTextWidth.X); + + UiHelpers.DefaultLineSpace(); + var sharedTagsEnabled = predefinedTagManager.Enabled; var sharedTagButtonOffset = sharedTagsEnabled ? ImGui.GetFrameHeight() + ImGui.GetStyle().FramePadding.X : 0; var tagIdx = _modTags.Draw("Mod Tags: ", "Edit tags by clicking them, or add new tags. Empty tags are removed.", _mod.ModTags, out var editedTag, rightEndOffset: sharedTagButtonOffset); @@ -76,6 +80,7 @@ public class ModPanelEditTab( predefinedTagManager.DrawAddFromSharedTagsAndUpdateTags(selector.Selected!.LocalTags, selector.Selected!.ModTags, false, selector.Selected!); + UiHelpers.DefaultLineSpace(); addGroupDrawer.Draw(_mod, UiHelpers.InputTextWidth.X); UiHelpers.DefaultLineSpace(); @@ -121,32 +126,42 @@ public class ModPanelEditTab( : backup.Exists ? $"Overwrite current exported mod \"{backup.Name}\" with current mod." : $"Create exported archive of current mod at \"{backup.Name}\"."; - if (ImGuiUtil.DrawDisabledButton("Export Mod", buttonSize, tt, ModBackup.CreatingBackup)) + if (ImUtf8.ButtonEx("Export Mod"u8, tt, buttonSize, ModBackup.CreatingBackup)) backup.CreateAsync(); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup("context"u8); + ImGui.SameLine(); tt = backup.Exists ? $"Delete existing mod export \"{backup.Name}\" (hold {config.DeleteModModifier} while clicking)." : $"Exported mod \"{backup.Name}\" does not exist."; - if (ImGuiUtil.DrawDisabledButton("Delete Export", buttonSize, tt, !backup.Exists || !config.DeleteModModifier.IsActive())) + if (ImUtf8.ButtonEx("Delete Export"u8, tt, buttonSize, !backup.Exists || !config.DeleteModModifier.IsActive())) backup.Delete(); tt = backup.Exists ? $"Restore mod from exported file \"{backup.Name}\" (hold {config.DeleteModModifier} while clicking)." : $"Exported mod \"{backup.Name}\" does not exist."; ImGui.SameLine(); - if (ImGuiUtil.DrawDisabledButton("Restore From Export", buttonSize, tt, !backup.Exists || !config.DeleteModModifier.IsActive())) + if (ImUtf8.ButtonEx("Restore From Export"u8, tt, buttonSize, !backup.Exists || !config.DeleteModModifier.IsActive())) backup.Restore(modManager); if (backup.Exists) { ImGui.SameLine(); - using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + using (ImRaii.PushFont(UiBuilder.IconFont)) { - ImGui.TextUnformatted(FontAwesomeIcon.CheckCircle.ToIconString()); + ImUtf8.Text(FontAwesomeIcon.CheckCircle.ToIconString()); } - ImGuiUtil.HoverTooltip($"Export exists in \"{backup.Name}\"."); + ImUtf8.HoverTooltip($"Export exists in \"{backup.Name}\"."); } + + using var context = ImUtf8.Popup("context"u8); + if (!context) + return; + + if (ImUtf8.Selectable("Open Backup Directory"u8)) + Process.Start(new ProcessStartInfo(modExportManager.ExportDirectory.FullName) { UseShellExecute = true }); } /// Anything about editing the regular meta information about the mod. @@ -310,7 +325,7 @@ public class ModPanelEditTab( var tmp = field == _currentField && option == _optionIndex ? _currentEdit ?? oldValue : oldValue; ImGui.SetNextItemWidth(width); - if (ImGui.InputText(label, ref tmp, maxLength)) + if (ImGui.InputText(label, ref tmp)) { _currentEdit = tmp; _optionIndex = option; diff --git a/Penumbra/UI/ModsTab/ModPanelHeader.cs b/Penumbra/UI/ModsTab/ModPanelHeader.cs index aafbffa6..b42ac680 100644 --- a/Penumbra/UI/ModsTab/ModPanelHeader.cs +++ b/Penumbra/UI/ModsTab/ModPanelHeader.cs @@ -1,7 +1,7 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface.GameFonts; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Plugin; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using Penumbra.Communication; diff --git a/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs b/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs index 8d889c3b..84f69bcb 100644 --- a/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs +++ b/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs @@ -1,6 +1,5 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Raii; -using OtterGui; using OtterGui.Services; using OtterGui.Text; using OtterGui.Widgets; @@ -11,6 +10,7 @@ using Penumbra.Mods.Manager; using Penumbra.Services; using Penumbra.Mods.Settings; using Penumbra.UI.ModsTab.Groups; +using OtterGui.Extensions; namespace Penumbra.UI.ModsTab; @@ -20,10 +20,13 @@ public class ModPanelSettingsTab( ModSelection selection, TutorialService tutorial, CommunicatorService communicator, - ModGroupDrawer modGroupDrawer) + ModGroupDrawer modGroupDrawer, + Configuration config) : ITab, IUiService { private bool _inherited; + private bool _temporary; + private bool _locked; private int? _currentPriority; public ReadOnlySpan Label @@ -37,13 +40,19 @@ public class ModPanelSettingsTab( public void DrawContent() { - using var child = ImRaii.Child("##settings"); - if (!child) + using var table = ImUtf8.Table("##settings"u8, 1, ImGuiTableFlags.ScrollY, ImGui.GetContentRegionAvail()); + if (!table) return; - _inherited = selection.Collection != collectionManager.Active.Current; + _inherited = selection.Collection != collectionManager.Active.Current; + _temporary = selection.TemporarySettings != null; + _locked = (selection.TemporarySettings?.Lock ?? 0) > 0; + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableNextColumn(); + DrawTemporaryWarning(); DrawInheritedWarning(); - UiHelpers.DefaultLineSpace(); + ImGui.Dummy(Vector2.Zero); communicator.PreSettingsPanelDraw.Invoke(selection.Mod!.Identifier); DrawEnabledInput(); tutorial.OpenTutorial(BasicTutorialSteps.EnablingMods); @@ -52,13 +61,31 @@ public class ModPanelSettingsTab( tutorial.OpenTutorial(BasicTutorialSteps.Priority); DrawRemoveSettings(); + ImGui.TableNextColumn(); communicator.PostEnabledDraw.Invoke(selection.Mod!.Identifier); - modGroupDrawer.Draw(selection.Mod!, selection.Settings); + modGroupDrawer.Draw(selection.Mod!, selection.Settings, selection.TemporarySettings); UiHelpers.DefaultLineSpace(); communicator.PostSettingsPanelDraw.Invoke(selection.Mod!.Identifier); } + /// Draw a big tinted bar if the current setting is temporary. + private void DrawTemporaryWarning() + { + if (!_temporary) + return; + + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGuiCol.Button.Tinted(ColorId.TemporaryModSettingsTint)); + var width = new Vector2(ImGui.GetContentRegionAvail().X, 0); + if (ImUtf8.ButtonEx($"These settings are temporarily set by {selection.TemporarySettings!.Source}{(_locked ? " and locked." : ".")}", + width, + _locked)) + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, null); + + ImUtf8.HoverTooltip("Changing settings in temporary settings will not save them across sessions.\n"u8 + + "You can click this button to remove the temporary settings and return to your normal settings."u8); + } + /// Draw a big red bar if the current setting is inherited. private void DrawInheritedWarning() { @@ -67,22 +94,43 @@ public class ModPanelSettingsTab( using var color = ImRaii.PushColor(ImGuiCol.Button, Colors.PressEnterWarningBg); var width = new Vector2(ImGui.GetContentRegionAvail().X, 0); - if (ImGui.Button($"These settings are inherited from {selection.Collection.Name}.", width)) - collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod!, false); + if (ImUtf8.ButtonEx($"These settings are inherited from {selection.Collection.Identity.Name}.", width, _locked)) + { + if (_temporary) + { + selection.TemporarySettings!.ForceInherit = false; + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, selection.TemporarySettings); + } + else + { + collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod!, false); + } + } - ImGuiUtil.HoverTooltip("You can click this button to copy the current settings to the current selection.\n" - + "You can also just change any setting, which will copy the settings with the single setting changed to the current selection."); + ImUtf8.HoverTooltip("You can click this button to copy the current settings to the current selection.\n"u8 + + "You can also just change any setting, which will copy the settings with the single setting changed to the current selection."u8); } /// Draw a checkbox for the enabled status of the mod. private void DrawEnabledInput() { - var enabled = selection.Settings.Enabled; - if (!ImGui.Checkbox("Enabled", ref enabled)) + var enabled = selection.Settings.Enabled; + using var disabled = ImRaii.Disabled(_locked); + if (!ImUtf8.Checkbox("Enabled"u8, ref enabled)) return; modManager.SetKnown(selection.Mod!); - collectionManager.Editor.SetModState(collectionManager.Active.Current, selection.Mod!, enabled); + if (_temporary || config.DefaultTemporaryMode) + { + var temporarySettings = selection.TemporarySettings ?? new TemporaryModSettings(selection.Mod!, selection.Settings); + temporarySettings.ForceInherit = false; + temporarySettings.Enabled = enabled; + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, temporarySettings); + } + else + { + collectionManager.Editor.SetModState(collectionManager.Active.Current, selection.Mod!, enabled); + } } /// @@ -91,45 +139,123 @@ public class ModPanelSettingsTab( /// private void DrawPriorityInput() { - using var group = ImRaii.Group(); + using var group = ImUtf8.Group(); var settings = selection.Settings; var priority = _currentPriority ?? settings.Priority.Value; ImGui.SetNextItemWidth(50 * UiHelpers.Scale); - if (ImGui.InputInt("##Priority", ref priority, 0, 0)) + using var disabled = ImRaii.Disabled(_locked); + if (ImUtf8.InputScalar("##Priority"u8, ref priority)) _currentPriority = priority; if (new ModPriority(priority).IsHidden) - ImUtf8.HoverTooltip($"This priority is special-cased to hide this mod in conflict tabs ({ModPriority.HiddenMin}, {ModPriority.HiddenMax})."); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + $"This priority is special-cased to hide this mod in conflict tabs ({ModPriority.HiddenMin}, {ModPriority.HiddenMax})."); if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) { if (_currentPriority != settings.Priority.Value) - collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selection.Mod!, - new ModPriority(_currentPriority.Value)); + { + if (_temporary || config.DefaultTemporaryMode) + { + var temporarySettings = selection.TemporarySettings ?? new TemporaryModSettings(selection.Mod!, selection.Settings); + temporarySettings.ForceInherit = false; + temporarySettings.Priority = new ModPriority(_currentPriority.Value); + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, + temporarySettings); + } + else + { + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selection.Mod!, + new ModPriority(_currentPriority.Value)); + } + } _currentPriority = null; } - ImGuiUtil.LabeledHelpMarker("Priority", "Mods with a higher number here take precedence before Mods with a lower number.\n" - + "That means, if Mod A should overwrite changes from Mod B, Mod A should have a higher priority number than Mod B."); + ImUtf8.LabeledHelpMarker("Priority"u8, "Mods with a higher number here take precedence before Mods with a lower number.\n"u8 + + "That means, if Mod A should overwrite changes from Mod B, Mod A should have a higher priority number than Mod B."u8); } /// /// Draw a button to remove the current settings and inherit them instead - /// on the top-right corner of the window/tab. + /// in the top-right corner of the window/tab. /// private void DrawRemoveSettings() { - const string text = "Inherit Settings"; - if (_inherited || selection.Settings == ModSettings.Empty) - return; + var drawInherited = !_inherited && !selection.Settings.IsEmpty; + var scroll = ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize + ImGui.GetStyle().ItemInnerSpacing.X : 0; + var buttonSize = ImUtf8.CalcTextSize("Turn Permanent_"u8).X; + var offset = drawInherited + ? buttonSize + ImUtf8.CalcTextSize("Inherit Settings"u8).X + ImGui.GetStyle().FramePadding.X * 4 + ImGui.GetStyle().ItemSpacing.X + : buttonSize + ImGui.GetStyle().FramePadding.X * 2; + ImGui.SameLine(ImGui.GetWindowWidth() - offset - scroll); + var enabled = config.DeleteModModifier.IsActive(); + if (drawInherited) + { + var inherit = (enabled, _locked) switch + { + (true, false) => ImUtf8.ButtonEx("Inherit Settings"u8, + "Remove current settings from this collection so that it can inherit them.\n"u8 + + "If no inherited collection has settings for this mod, it will be disabled."u8, default, false), + (false, false) => ImUtf8.ButtonEx("Inherit Settings"u8, + $"Remove current settings from this collection so that it can inherit them.\nHold {config.DeleteModModifier} to inherit.", + default, true), + (_, true) => ImUtf8.ButtonEx("Inherit Settings"u8, + "Remove current settings from this collection so that it can inherit them.\nThe settings are currently locked and can not be changed."u8, + default, true), + }; + if (inherit) + { + if (_temporary || config.DefaultTemporaryMode) + { + var temporarySettings = selection.TemporarySettings ?? new TemporaryModSettings(selection.Mod!, selection.Settings); + temporarySettings.ForceInherit = true; + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, + temporarySettings); + } + else + { + collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod!, true); + } + } - var scroll = ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize : 0; - ImGui.SameLine(ImGui.GetWindowWidth() - ImGui.CalcTextSize(text).X - ImGui.GetStyle().FramePadding.X * 2 - scroll); - if (ImGui.Button(text)) - collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod!, true); + ImGui.SameLine(); + } - ImGuiUtil.HoverTooltip("Remove current settings from this collection so that it can inherit them.\n" - + "If no inherited collection has settings for this mod, it will be disabled."); + if (_temporary) + { + var overwrite = enabled + ? ImUtf8.ButtonEx("Turn Permanent"u8, + "Overwrite the actual settings for this mod in this collection with the current temporary settings."u8, + new Vector2(buttonSize, 0)) + : ImUtf8.ButtonEx("Turn Permanent"u8, + $"Overwrite the actual settings for this mod in this collection with the current temporary settings.\nHold {config.DeleteModModifier} to overwrite.", + new Vector2(buttonSize, 0), true); + if (overwrite) + { + var settings = collectionManager.Active.Current.GetTempSettings(selection.Mod!.Index)!; + if (settings.ForceInherit) + { + collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod, true); + } + else + { + collectionManager.Editor.SetModState(collectionManager.Active.Current, selection.Mod, settings.Enabled); + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selection.Mod, settings.Priority); + foreach (var (setting, index) in settings.Settings.WithIndex()) + collectionManager.Editor.SetModSetting(collectionManager.Active.Current, selection.Mod, index, setting); + } + + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod, null); + } + } + else + { + var actual = collectionManager.Active.Current.GetActualSettings(selection.Mod!.Index).Settings; + if (ImUtf8.ButtonEx("Turn Temporary"u8, "Copy the current settings over to temporary settings to experiment with them."u8)) + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, + new TemporaryModSettings(selection.Mod!, actual)); + } } } diff --git a/Penumbra/UI/ModsTab/ModPanelTabBar.cs b/Penumbra/UI/ModsTab/ModPanelTabBar.cs index 639118f5..5981d979 100644 --- a/Penumbra/UI/ModsTab/ModPanelTabBar.cs +++ b/Penumbra/UI/ModsTab/ModPanelTabBar.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using OtterGui.Services; diff --git a/Penumbra/UI/ModsTab/MultiModPanel.cs b/Penumbra/UI/ModsTab/MultiModPanel.cs index 4079748e..947ede14 100644 --- a/Penumbra/UI/ModsTab/MultiModPanel.cs +++ b/Penumbra/UI/ModsTab/MultiModPanel.cs @@ -1,68 +1,92 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; -using ImGuiNET; -using OtterGui; +using OtterGui.Extensions; +using OtterGui.Filesystem; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using Penumbra.Mods; using Penumbra.Mods.Manager; namespace Penumbra.UI.ModsTab; -public class MultiModPanel(ModFileSystemSelector _selector, ModDataEditor _editor) : IUiService +public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor, PredefinedTagManager tagManager) : IUiService { public void Draw() { - if (_selector.SelectedPaths.Count == 0) + if (selector.SelectedPaths.Count == 0) return; ImGui.NewLine(); - DrawModList(); + var treeNodePos = ImGui.GetCursorPos(); + var numLeaves = DrawModList(); + DrawCounts(treeNodePos, numLeaves); DrawMultiTagger(); } - private void DrawModList() + private void DrawCounts(Vector2 treeNodePos, int numLeaves) { - using var tree = ImRaii.TreeNode("Currently Selected Objects", ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.NoTreePushOnOpen); - ImGui.Separator(); - if (!tree) - return; + var startPos = ImGui.GetCursorPos(); + var numFolders = selector.SelectedPaths.Count - numLeaves; + var text = (numLeaves, numFolders) switch + { + (0, 0) => string.Empty, // should not happen + (> 0, 0) => $"{numLeaves} Mods", + (0, > 0) => $"{numFolders} Folders", + _ => $"{numLeaves} Mods, {numFolders} Folders", + }; + ImGui.SetCursorPos(treeNodePos); + ImUtf8.TextRightAligned(text); + ImGui.SetCursorPos(startPos); + } - var sizeType = ImGui.GetFrameHeight(); - var availableSizePercent = (ImGui.GetContentRegionAvail().X - sizeType - 4 * ImGui.GetStyle().CellPadding.X) / 100; + private int DrawModList() + { + using var tree = ImUtf8.TreeNode("Currently Selected Objects###Selected"u8, + ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.NoTreePushOnOpen); + ImGui.Separator(); + + + if (!tree) + return selector.SelectedPaths.Count(l => l is ModFileSystem.Leaf); + + var sizeType = new Vector2(ImGui.GetFrameHeight()); + var availableSizePercent = (ImGui.GetContentRegionAvail().X - sizeType.X - 4 * ImGui.GetStyle().CellPadding.X) / 100; var sizeMods = availableSizePercent * 35; var sizeFolders = availableSizePercent * 65; - using (var table = ImRaii.Table("mods", 3, ImGuiTableFlags.RowBg)) + var leaves = 0; + using (var table = ImUtf8.Table("mods"u8, 3, ImGuiTableFlags.RowBg)) { if (!table) - return; + return selector.SelectedPaths.Count(l => l is ModFileSystem.Leaf); - ImGui.TableSetupColumn("type", ImGuiTableColumnFlags.WidthFixed, sizeType); - ImGui.TableSetupColumn("mod", ImGuiTableColumnFlags.WidthFixed, sizeMods); - ImGui.TableSetupColumn("path", ImGuiTableColumnFlags.WidthFixed, sizeFolders); + ImUtf8.TableSetupColumn("type"u8, ImGuiTableColumnFlags.WidthFixed, sizeType.X); + ImUtf8.TableSetupColumn("mod"u8, ImGuiTableColumnFlags.WidthFixed, sizeMods); + ImUtf8.TableSetupColumn("path"u8, ImGuiTableColumnFlags.WidthFixed, sizeFolders); var i = 0; - foreach (var (fullName, path) in _selector.SelectedPaths.Select(p => (p.FullName(), p)) + foreach (var (fullName, path) in selector.SelectedPaths.Select(p => (p.FullName(), p)) .OrderBy(p => p.Item1, StringComparer.OrdinalIgnoreCase)) { using var id = ImRaii.PushId(i++); + var (icon, text) = path is ModFileSystem.Leaf l + ? (FontAwesomeIcon.FileCircleMinus, l.Value.Name.Text) + : (FontAwesomeIcon.FolderMinus, string.Empty); ImGui.TableNextColumn(); - var icon = (path is ModFileSystem.Leaf ? FontAwesomeIcon.FileCircleMinus : FontAwesomeIcon.FolderMinus).ToIconString(); - if (ImGuiUtil.DrawDisabledButton(icon, new Vector2(sizeType), "Remove from selection.", false, true)) - _selector.RemovePathFromMultiSelection(path); + if (ImUtf8.IconButton(icon, "Remove from selection."u8, sizeType)) + selector.RemovePathFromMultiSelection(path); - ImGui.TableNextColumn(); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(path is ModFileSystem.Leaf l ? l.Value.Name : string.Empty); - - ImGui.TableNextColumn(); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(fullName); + ImUtf8.DrawFrameColumn(text); + ImUtf8.DrawFrameColumn(fullName); + if (path is ModFileSystem.Leaf) + ++leaves; } } ImGui.Separator(); + return leaves; } private string _tag = string.Empty; @@ -72,11 +96,15 @@ public class MultiModPanel(ModFileSystemSelector _selector, ModDataEditor _edito private void DrawMultiTagger() { var width = ImGuiHelpers.ScaledVector2(150, 0); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("Multi Tagger:"); + ImUtf8.TextFrameAligned("Multi Tagger:"u8); ImGui.SameLine(); - ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X - 2 * (width.X + ImGui.GetStyle().ItemSpacing.X)); - ImGui.InputTextWithHint("##tag", "Local Tag Name...", ref _tag, 128); + + var predefinedTagsEnabled = tagManager.Enabled; + var inputWidth = predefinedTagsEnabled + ? ImGui.GetContentRegionAvail().X - 2 * width.X - 3 * ImGui.GetStyle().ItemInnerSpacing.X - ImGui.GetFrameHeight() + : ImGui.GetContentRegionAvail().X - 2 * (width.X + ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.SetNextItemWidth(inputWidth); + ImUtf8.InputText("##tag"u8, ref _tag, "Local Tag Name..."u8); UpdateTagCache(); var label = _addMods.Count > 0 @@ -87,10 +115,10 @@ public class MultiModPanel(ModFileSystemSelector _selector, ModDataEditor _edito ? "No tag specified." : $"All mods selected already contain the tag \"{_tag}\", either locally or as mod data." : $"Add the tag \"{_tag}\" to {_addMods.Count} mods as a local tag:\n\n\t{string.Join("\n\t", _addMods.Select(m => m.Name.Text))}"; - ImGui.SameLine(); - if (ImGuiUtil.DrawDisabledButton(label, width, tooltip, _addMods.Count == 0)) + ImUtf8.SameLineInner(); + if (ImUtf8.ButtonEx(label, tooltip, width, _addMods.Count == 0)) foreach (var mod in _addMods) - _editor.ChangeLocalTag(mod, mod.LocalTags.Count, _tag); + editor.ChangeLocalTag(mod, mod.LocalTags.Count, _tag); label = _removeMods.Count > 0 ? $"Remove from {_removeMods.Count} Mods" @@ -100,10 +128,18 @@ public class MultiModPanel(ModFileSystemSelector _selector, ModDataEditor _edito ? "No tag specified." : $"No selected mod contains the tag \"{_tag}\" locally." : $"Remove the local tag \"{_tag}\" from {_removeMods.Count} mods:\n\n\t{string.Join("\n\t", _removeMods.Select(m => m.Item1.Name.Text))}"; - ImGui.SameLine(); - if (ImGuiUtil.DrawDisabledButton(label, width, tooltip, _removeMods.Count == 0)) + ImUtf8.SameLineInner(); + if (ImUtf8.ButtonEx(label, tooltip, width, _removeMods.Count == 0)) foreach (var (mod, index) in _removeMods) - _editor.ChangeLocalTag(mod, index, string.Empty); + editor.ChangeLocalTag(mod, index, string.Empty); + + if (predefinedTagsEnabled) + { + ImUtf8.SameLineInner(); + tagManager.DrawToggleButton(); + tagManager.DrawListMulti(selector.SelectedPaths.OfType().Select(l => l.Value)); + } + ImGui.Separator(); } @@ -114,7 +150,7 @@ public class MultiModPanel(ModFileSystemSelector _selector, ModDataEditor _edito if (_tag.Length == 0) return; - foreach (var leaf in _selector.SelectedPaths.OfType()) + foreach (var leaf in selector.SelectedPaths.OfType()) { var index = leaf.Value.LocalTags.IndexOf(_tag); if (index >= 0) diff --git a/Penumbra/UI/PredefinedTagManager.cs b/Penumbra/UI/PredefinedTagManager.cs index 8de613d4..5a3a4b62 100644 --- a/Penumbra/UI/PredefinedTagManager.cs +++ b/Penumbra/UI/PredefinedTagManager.cs @@ -1,12 +1,15 @@ -using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; using Dalamud.Interface.ImGuiNotification; -using ImGuiNET; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Mods; using Penumbra.Mods.Manager; using Penumbra.Services; using Penumbra.UI.Classes; @@ -51,6 +54,9 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList, ISer jObj.WriteTo(jWriter); } + public bool Enabled + => Count > 0; + public void Save() => _saveService.DelaySave(this, TimeSpan.FromSeconds(5)); @@ -97,9 +103,9 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList, ISer } public void DrawAddFromSharedTagsAndUpdateTags(IReadOnlyCollection localTags, IReadOnlyCollection modTags, bool editLocal, - Mods.Mod mod) + Mod mod) { - DrawToggleButton(); + DrawToggleButtonTopRight(); if (!DrawList(localTags, modTags, editLocal, out var changedTag, out var index)) return; @@ -109,17 +115,22 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList, ISer _modManager.DataEditor.ChangeModTag(mod, index, changedTag); } - private void DrawToggleButton() + public void DrawToggleButton() { - ImGui.SameLine(ImGui.GetContentRegionMax().X - - ImGui.GetFrameHeight() - - (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ItemInnerSpacing.X : 0)); using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.ButtonActive), _isListOpen); if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Tags.ToIconString(), new Vector2(ImGui.GetFrameHeight()), "Add Predefined Tags...", false, true)) _isListOpen = !_isListOpen; } + private void DrawToggleButtonTopRight() + { + ImGui.SameLine(ImGui.GetContentRegionMax().X + - ImGui.GetFrameHeight() + - (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ItemInnerSpacing.X : 0)); + DrawToggleButton(); + } + private bool DrawList(IReadOnlyCollection localTags, IReadOnlyCollection modTags, bool editLocal, out string changedTag, out int changedIndex) { @@ -129,7 +140,7 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList, ISer if (!_isListOpen) return false; - ImGui.TextUnformatted("Predefined Tags"); + ImUtf8.Text("Predefined Tags"u8); ImGui.Separator(); var ret = false; @@ -154,6 +165,101 @@ public sealed class PredefinedTagManager : ISavable, IReadOnlyList, ISer return ret; } + private readonly List _selectedMods = []; + private readonly List<(int Index, int DataIndex)> _countedMods = []; + + private void PrepareLists(IEnumerable selection) + { + _selectedMods.Clear(); + _selectedMods.AddRange(selection); + _countedMods.EnsureCapacity(_selectedMods.Count); + while (_countedMods.Count < _selectedMods.Count) + _countedMods.Add((-1, -1)); + } + + public void DrawListMulti(IEnumerable selection) + { + if (!_isListOpen) + return; + + ImUtf8.Text("Predefined Tags"u8); + PrepareLists(selection); + + _enabledColor = ColorId.PredefinedTagAdd.Value(); + _disabledColor = ColorId.PredefinedTagRemove.Value(); + using var color = new ImRaii.Color(); + foreach (var (tag, idx) in _predefinedTags.Keys.WithIndex()) + { + var alreadyContained = 0; + var inModData = 0; + var missing = 0; + + foreach (var (modIndex, mod) in _selectedMods.Index()) + { + var tagIdx = mod.LocalTags.IndexOf(tag); + if (tagIdx >= 0) + { + ++alreadyContained; + _countedMods[modIndex] = (tagIdx, -1); + } + else + { + var dataIdx = mod.ModTags.IndexOf(tag); + if (dataIdx >= 0) + { + ++inModData; + _countedMods[modIndex] = (-1, dataIdx); + } + else + { + ++missing; + _countedMods[modIndex] = (-1, -1); + } + } + } + + using var id = ImRaii.PushId(idx); + var buttonWidth = CalcTextButtonWidth(tag); + // Prevent adding a new tag past the right edge of the popup + if (buttonWidth + ImGui.GetStyle().ItemSpacing.X >= ImGui.GetContentRegionAvail().X) + ImGui.NewLine(); + + var (usedColor, disabled, tt) = (missing, alreadyContained) switch + { + (> 0, _) => (_enabledColor, false, + $"Add this tag to {missing} mods.{(inModData > 0 ? $" {inModData} mods contain it in their mod tags and are untouched." : string.Empty)}"), + (_, > 0) => (_disabledColor, false, + $"Remove this tag from {alreadyContained} mods.{(inModData > 0 ? $" {inModData} mods contain it in their mod tags and are untouched." : string.Empty)}"), + _ => (_disabledColor, true, "This tag is already present in the mod tags of all selected mods."), + }; + color.Push(ImGuiCol.Button, usedColor); + if (ImUtf8.ButtonEx(tag, tt, new Vector2(buttonWidth, 0), disabled)) + { + if (missing > 0) + foreach (var (mod, (localIdx, _)) in _selectedMods.Zip(_countedMods)) + { + if (localIdx >= 0) + continue; + + _modManager.DataEditor.ChangeLocalTag(mod, mod.LocalTags.Count, tag); + } + else + foreach (var (mod, (localIdx, _)) in _selectedMods.Zip(_countedMods)) + { + if (localIdx < 0) + continue; + + _modManager.DataEditor.ChangeLocalTag(mod, localIdx, string.Empty); + } + } + ImGui.SameLine(); + + color.Pop(); + } + + ImGui.NewLine(); + } + private bool DrawColoredButton(string buttonLabel, int index, int tagIdx, bool inOther) { using var id = ImRaii.PushId(index); diff --git a/Penumbra/UI/ResourceWatcher/Record.cs b/Penumbra/UI/ResourceWatcher/Record.cs index 7338e5a9..ba718bc9 100644 --- a/Penumbra/UI/ResourceWatcher/Record.cs +++ b/Penumbra/UI/ResourceWatcher/Record.cs @@ -1,6 +1,7 @@ using OtterGui.Classes; using Penumbra.Collections; using Penumbra.Enums; +using Penumbra.Interop; using Penumbra.Interop.Structs; using Penumbra.String; using Penumbra.String.Classes; @@ -10,10 +11,11 @@ namespace Penumbra.UI.ResourceWatcher; [Flags] public enum RecordType : byte { - Request = 0x01, - ResourceLoad = 0x02, - FileLoad = 0x04, - Destruction = 0x08, + Request = 0x01, + ResourceLoad = 0x02, + FileLoad = 0x04, + Destruction = 0x08, + ResourceComplete = 0x10, } internal unsafe struct Record @@ -33,6 +35,7 @@ internal unsafe struct Record public OptionalBool ReturnValue; public OptionalBool CustomLoad; public LoadState LoadState; + public uint OsThreadId; public static Record CreateRequest(CiByteString path, bool sync) @@ -53,6 +56,28 @@ internal unsafe struct Record AssociatedGameObject = string.Empty, LoadState = LoadState.None, Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + public static Record CreateRequest(CiByteString path, bool sync, FullPath fullPath, ResolveData resolve) + => new() + { + Time = DateTime.UtcNow, + Path = fullPath.InternalName.IsOwned ? fullPath.InternalName : fullPath.InternalName.Clone(), + OriginalPath = path.IsOwned ? path : path.Clone(), + Collection = resolve.Valid ? resolve.ModCollection : null, + Handle = null, + ResourceType = ResourceExtensions.Type(path).ToFlag(), + Category = ResourceExtensions.Category(path).ToFlag(), + RefCount = 0, + RecordType = RecordType.Request, + Synchronously = sync, + ReturnValue = OptionalBool.Null, + CustomLoad = fullPath.InternalName != path, + AssociatedGameObject = string.Empty, + LoadState = LoadState.None, + Crc64 = fullPath.Crc64, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), }; public static Record CreateDefaultLoad(CiByteString path, ResourceHandle* handle, ModCollection collection, string associatedGameObject) @@ -75,6 +100,7 @@ internal unsafe struct Record AssociatedGameObject = associatedGameObject, LoadState = handle->LoadState, Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), }; } @@ -97,6 +123,7 @@ internal unsafe struct Record AssociatedGameObject = associatedGameObject, LoadState = handle->LoadState, Crc64 = path.Crc64, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), }; public static Record CreateDestruction(ResourceHandle* handle) @@ -119,6 +146,7 @@ internal unsafe struct Record AssociatedGameObject = string.Empty, LoadState = handle->LoadState, Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), }; } @@ -140,5 +168,40 @@ internal unsafe struct Record AssociatedGameObject = string.Empty, LoadState = handle->LoadState, Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), }; + + public static Record CreateResourceComplete(CiByteString path, ResourceHandle* handle, Utf8GamePath originalPath, ReadOnlySpan additionalData) + => new() + { + Time = DateTime.UtcNow, + Path = CombinedPath(path, additionalData), + OriginalPath = originalPath.Path.IsOwned ? originalPath.Path : originalPath.Path.Clone(), + Collection = null, + Handle = handle, + ResourceType = handle->FileType.ToFlag(), + Category = handle->Category.ToFlag(), + RefCount = handle->RefCount, + RecordType = RecordType.ResourceComplete, + Synchronously = false, + ReturnValue = OptionalBool.Null, + CustomLoad = OptionalBool.Null, + AssociatedGameObject = string.Empty, + LoadState = handle->LoadState, + Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + private static CiByteString CombinedPath(CiByteString path, ReadOnlySpan additionalData) + { + if (additionalData.Length is 0) + return path.IsOwned ? path : path.Clone(); + + fixed (byte* ptr = additionalData) + { + // If a path has additional data and is split, it is always in the form of |{additionalData}|{path}, + // so we can just read from the start of additional data - 1 and sum their length +2 for the pipes. + return new CiByteString(new ReadOnlySpan(ptr - 1, additionalData.Length + 2 + path.Length)).Clone(); + } + } } diff --git a/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs b/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs index d432e97e..ee3613fc 100644 --- a/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs +++ b/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.System.Resource; -using ImGuiNET; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Widgets; @@ -47,9 +47,10 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService _table = new ResourceWatcherTable(config.Ephemeral, _records); _resources.ResourceRequested += OnResourceRequested; _destructor.Subscribe(OnResourceDestroyed, ResourceHandleDestructor.Priority.ResourceWatcher); - _loader.ResourceLoaded += OnResourceLoaded; - _loader.FileLoaded += OnFileLoaded; - _loader.PapRequested += OnPapRequested; + _loader.ResourceLoaded += OnResourceLoaded; + _loader.ResourceComplete += OnResourceComplete; + _loader.FileLoaded += OnFileLoaded; + _loader.PapRequested += OnPapRequested; UpdateFilter(_ephemeral.ResourceLoggingFilter, false); _newMaxEntries = _config.MaxResourceWatcherRecords; } @@ -57,12 +58,19 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService private void OnPapRequested(Utf8GamePath original, FullPath? _1, ResolveData _2) { if (_ephemeral.EnableResourceLogging && FilterMatch(original.Path, out var match)) + { Penumbra.Log.Information($"[ResourceLoader] [REQ] {match} was requested asynchronously."); + if (_1.HasValue) + Penumbra.Log.Information( + $"[ResourceLoader] [LOAD] Resolved {_1.Value.FullName} for {match} from collection {_2.ModCollection} for object 0x{_2.AssociatedGameObject:X}."); + } if (!_ephemeral.EnableResourceWatcher) return; - var record = Record.CreateRequest(original.Path, false); + var record = _1.HasValue + ? Record.CreateRequest(original.Path, false, _1.Value, _2) + : Record.CreateRequest(original.Path, false); if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) _newRecords.Enqueue(record); } @@ -73,9 +81,10 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService _records.TrimExcess(); _resources.ResourceRequested -= OnResourceRequested; _destructor.Unsubscribe(OnResourceDestroyed); - _loader.ResourceLoaded -= OnResourceLoaded; - _loader.FileLoaded -= OnFileLoaded; - _loader.PapRequested -= OnPapRequested; + _loader.ResourceLoaded -= OnResourceLoaded; + _loader.ResourceComplete -= OnResourceComplete; + _loader.FileLoaded -= OnFileLoaded; + _loader.PapRequested -= OnPapRequested; } private void Clear() @@ -241,7 +250,7 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService { var pathString = manipulatedPath != null ? $"custom file {name2} instead of {name}" : name; Penumbra.Log.Information( - $"[ResourceLoader] [LOAD] [{handle->FileType}] Loaded {pathString} to 0x{(ulong)handle:X} using collection {data.ModCollection.AnonymizedName} for {Name(data, "no associated object.")} (Refcount {handle->RefCount}) "); + $"[ResourceLoader] [LOAD] [{handle->FileType}] Loaded {pathString} to 0x{(ulong)handle:X} using collection {data.ModCollection.Identity.AnonymizedName} for {Name(data, "no associated object.")} (Refcount {handle->RefCount}) "); } } @@ -255,6 +264,24 @@ public sealed class ResourceWatcher : IDisposable, ITab, IUiService _newRecords.Enqueue(record); } + private unsafe void OnResourceComplete(ResourceHandle* resource, CiByteString path, Utf8GamePath original, + ReadOnlySpan additionalData, bool isAsync) + { + if (!isAsync) + return; + + if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match)) + Penumbra.Log.Information( + $"[ResourceLoader] [DONE] [{resource->FileType}] Finished loading {match} into 0x{(ulong)resource:X}, state {resource->LoadState}."); + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = Record.CreateResourceComplete(path, resource, original, additionalData); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + private unsafe void OnFileLoaded(ResourceHandle* resource, CiByteString path, bool success, bool custom, ReadOnlySpan _) { if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match)) diff --git a/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs b/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs index 88b7120d..97df095e 100644 --- a/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs +++ b/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; -using ImGuiNET; using OtterGui; using OtterGui.Classes; using OtterGui.Raii; @@ -30,7 +30,8 @@ internal sealed class ResourceWatcherTable : Table new LoadStateColumn { Label = "State" }, new RefCountColumn { Label = "#Ref" }, new DateColumn { Label = "Time" }, - new Crc64Column { Label = "Crc64" } + new Crc64Column { Label = "Crc64" }, + new OsThreadColumn { Label = "TID" } ) { } @@ -124,11 +125,12 @@ internal sealed class ResourceWatcherTable : Table { ImGui.TextUnformatted(item.RecordType switch { - RecordType.Request => "REQ", - RecordType.ResourceLoad => "LOAD", - RecordType.FileLoad => "FILE", - RecordType.Destruction => "DEST", - _ => string.Empty, + RecordType.Request => "REQ", + RecordType.ResourceLoad => "LOAD", + RecordType.FileLoad => "FILE", + RecordType.Destruction => "DEST", + RecordType.ResourceComplete => "DONE", + _ => string.Empty, }); } } @@ -167,7 +169,7 @@ internal sealed class ResourceWatcherTable : Table => 80 * UiHelpers.Scale; public override string ToName(Record item) - => item.Collection?.Name ?? string.Empty; + => (item.Collection != null ? item.Collection.Identity.Name : null) ?? string.Empty; } private sealed class ObjectColumn : ColumnString @@ -317,10 +319,10 @@ internal sealed class ResourceWatcherTable : Table { LoadState.None => FilterValue.HasFlag(LoadStateFlag.None), LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Success), - LoadState.Async => FilterValue.HasFlag(LoadStateFlag.Async), - LoadState.Failure => FilterValue.HasFlag(LoadStateFlag.Failed), LoadState.FailedSubResource => FilterValue.HasFlag(LoadStateFlag.FailedSub), - _ => FilterValue.HasFlag(LoadStateFlag.Unknown), + <= LoadState.Constructed => FilterValue.HasFlag(LoadStateFlag.Unknown), + < LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Async), + > LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Failed), }; public override void DrawColumn(Record item, int _) @@ -332,12 +334,12 @@ internal sealed class ResourceWatcherTable : Table { LoadState.Success => (FontAwesomeIcon.CheckCircle, ColorId.IncreasedMetaValue.Value(), $"Successfully loaded ({(byte)item.LoadState})."), - LoadState.Async => (FontAwesomeIcon.Clock, ColorId.FolderLine.Value(), $"Loading asynchronously ({(byte)item.LoadState})."), - LoadState.Failure => (FontAwesomeIcon.Times, ColorId.DecreasedMetaValue.Value(), - $"Failed to load ({(byte)item.LoadState})."), LoadState.FailedSubResource => (FontAwesomeIcon.ExclamationCircle, ColorId.DecreasedMetaValue.Value(), $"Dependencies failed to load ({(byte)item.LoadState})."), - _ => (FontAwesomeIcon.QuestionCircle, ColorId.UndefinedMod.Value(), $"Unknown state ({(byte)item.LoadState})."), + <= LoadState.Constructed => (FontAwesomeIcon.QuestionCircle, ColorId.UndefinedMod.Value(), $"Not yet loaded ({(byte)item.LoadState})."), + < LoadState.Success => (FontAwesomeIcon.Clock, ColorId.FolderLine.Value(), $"Loading asynchronously ({(byte)item.LoadState})."), + > LoadState.Success => (FontAwesomeIcon.Times, ColorId.DecreasedMetaValue.Value(), + $"Failed to load ({(byte)item.LoadState})."), }; using (var font = ImRaii.PushFont(UiBuilder.IconFont)) { @@ -452,4 +454,19 @@ internal sealed class ResourceWatcherTable : Table public override int Compare(Record lhs, Record rhs) => lhs.RefCount.CompareTo(rhs.RefCount); } + + private sealed class OsThreadColumn : ColumnString + { + public override float Width + => 60 * UiHelpers.Scale; + + public override string ToName(Record item) + => item.OsThreadId.ToString(); + + public override void DrawColumn(Record item, int _) + => ImGuiUtil.RightAlign(ToName(item)); + + public override int Compare(Record lhs, Record rhs) + => lhs.OsThreadId.CompareTo(rhs.OsThreadId); + } } diff --git a/Penumbra/UI/Tabs/ChangedItemsTab.cs b/Penumbra/UI/Tabs/ChangedItemsTab.cs index 256b0d79..4dc9474f 100644 --- a/Penumbra/UI/Tabs/ChangedItemsTab.cs +++ b/Penumbra/UI/Tabs/ChangedItemsTab.cs @@ -1,8 +1,9 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using OtterGui.Widgets; using Penumbra.Api.Enums; using Penumbra.Collections.Manager; @@ -26,30 +27,36 @@ public class ChangedItemsTab( private LowerString _changedItemFilter = LowerString.Empty; private LowerString _changedItemModFilter = LowerString.Empty; + private Vector2 _buttonSize; public void DrawContent() { collectionHeader.Draw(true); drawer.DrawTypeFilter(); var varWidth = DrawFilters(); - using var child = ImRaii.Child("##changedItemsChild", -Vector2.One); + using var child = ImUtf8.Child("##changedItemsChild"u8, -Vector2.One); if (!child) return; - var height = ImGui.GetFrameHeight() + 2 * ImGui.GetStyle().CellPadding.Y; - var skips = ImGuiClip.GetNecessarySkips(height); - using var list = ImRaii.Table("##changedItems", 3, ImGuiTableFlags.RowBg, -Vector2.One); + _buttonSize = new Vector2(ImGui.GetStyle().ItemSpacing.Y + ImGui.GetFrameHeight()); + using var style = ImRaii.PushStyle(ImGuiStyleVar.CellPadding, Vector2.Zero) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .Push(ImGuiStyleVar.FramePadding, Vector2.Zero) + .Push(ImGuiStyleVar.SelectableTextAlign, new Vector2(0.01f, 0.5f)); + + var skips = ImGuiClip.GetNecessarySkips(_buttonSize.Y); + using var list = ImUtf8.Table("##changedItems"u8, 3, ImGuiTableFlags.RowBg, -Vector2.One); if (!list) return; const ImGuiTableColumnFlags flags = ImGuiTableColumnFlags.NoResize | ImGuiTableColumnFlags.WidthFixed; - ImGui.TableSetupColumn("items", flags, 450 * UiHelpers.Scale); - ImGui.TableSetupColumn("mods", flags, varWidth - 130 * UiHelpers.Scale); - ImGui.TableSetupColumn("id", flags, 130 * UiHelpers.Scale); + ImUtf8.TableSetupColumn("items"u8, flags, 450 * UiHelpers.Scale); + ImUtf8.TableSetupColumn("mods"u8, flags, varWidth - 140 * UiHelpers.Scale); + ImUtf8.TableSetupColumn("id"u8, flags, 140 * UiHelpers.Scale); var items = collectionManager.Active.Current.ChangedItems; var rest = ImGuiClip.FilteredClippedDraw(items, skips, FilterChangedItem, DrawChangedItemColumn); - ImGuiClip.DrawEndDummy(rest, height); + ImGuiClip.DrawEndDummy(rest, _buttonSize.Y); } /// Draw a pair of filters and return the variable width of the flexible column. @@ -67,22 +74,25 @@ public class ChangedItemsTab( } /// Apply the current filters. - private bool FilterChangedItem(KeyValuePair, IIdentifiedObjectData?)> item) + private bool FilterChangedItem(KeyValuePair, IIdentifiedObjectData)> item) => drawer.FilterChangedItem(item.Key, item.Value.Item2, _changedItemFilter) && (_changedItemModFilter.IsEmpty || item.Value.Item1.Any(m => m.Name.Contains(_changedItemModFilter))); /// Draw a full column for a changed item. - private void DrawChangedItemColumn(KeyValuePair, IIdentifiedObjectData?)> item) + private void DrawChangedItemColumn(KeyValuePair, IIdentifiedObjectData)> item) { ImGui.TableNextColumn(); - drawer.DrawCategoryIcon(item.Value.Item2); - ImGui.SameLine(); - drawer.DrawChangedItem(item.Key, item.Value.Item2); + drawer.DrawCategoryIcon(item.Value.Item2, _buttonSize.Y); + ImGui.SameLine(0, 0); + var name = item.Value.Item2.ToName(item.Key); + var clicked = ImUtf8.Selectable(name, false, ImGuiSelectableFlags.None, _buttonSize with { X = 0 }); + drawer.ChangedItemHandling(item.Value.Item2, clicked); + ImGui.TableNextColumn(); DrawModColumn(item.Value.Item1); ImGui.TableNextColumn(); - ChangedItemDrawer.DrawModelData(item.Value.Item2); + ChangedItemDrawer.DrawModelData(item.Value.Item2, _buttonSize.Y); } private void DrawModColumn(SingleArray mods) @@ -90,19 +100,18 @@ public class ChangedItemsTab( if (mods.Count <= 0) return; - var first = mods[0]; - using var style = ImRaii.PushStyle(ImGuiStyleVar.SelectableTextAlign, new Vector2(0, 0.5f)); - if (ImGui.Selectable(first.Name, false, ImGuiSelectableFlags.None, new Vector2(0, ImGui.GetFrameHeight())) + var first = mods[0]; + if (ImUtf8.Selectable(first.Name.Text, false, ImGuiSelectableFlags.None, _buttonSize with { X = 0 }) && ImGui.GetIO().KeyCtrl && first is Mod mod) communicator.SelectTab.Invoke(TabType.Mods, mod); - if (ImGui.IsItemHovered()) - { - using var _ = ImRaii.Tooltip(); - ImGui.TextUnformatted("Hold Control and click to jump to mod.\n"); - if (mods.Count > 1) - ImGui.TextUnformatted("Other mods affecting this item:\n" + string.Join("\n", mods.Skip(1).Select(m => m.Name))); - } + if (!ImGui.IsItemHovered()) + return; + + using var _ = ImRaii.Tooltip(); + ImUtf8.Text("Hold Control and click to jump to mod.\n"u8); + if (mods.Count > 1) + ImUtf8.Text("Other mods affecting this item:\n" + string.Join("\n", mods.Skip(1).Select(m => m.Name))); } } diff --git a/Penumbra/UI/Tabs/CollectionsTab.cs b/Penumbra/UI/Tabs/CollectionsTab.cs index 05a1f33b..f2a041eb 100644 --- a/Penumbra/UI/Tabs/CollectionsTab.cs +++ b/Penumbra/UI/Tabs/CollectionsTab.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Game.ClientState.Objects; using Dalamud.Plugin; -using ImGuiNET; using OtterGui.Raii; using OtterGui.Services; using OtterGui.Widgets; diff --git a/Penumbra/UI/Tabs/ConfigTabBar.cs b/Penumbra/UI/Tabs/ConfigTabBar.cs index 28827ad9..43ae2488 100644 --- a/Penumbra/UI/Tabs/ConfigTabBar.cs +++ b/Penumbra/UI/Tabs/ConfigTabBar.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui.Services; using OtterGui.Widgets; using Penumbra.Api.Enums; diff --git a/Penumbra/UI/Tabs/Debug/AtchDrawer.cs b/Penumbra/UI/Tabs/Debug/AtchDrawer.cs index d9058083..f136bacd 100644 --- a/Penumbra/UI/Tabs/Debug/AtchDrawer.cs +++ b/Penumbra/UI/Tabs/Debug/AtchDrawer.cs @@ -1,11 +1,11 @@ -using ImGuiNET; -using OtterGui; -using OtterGui.Text; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; +using OtterGui.Text; using Penumbra.GameData.Files; -using Penumbra.GameData.Files.AtchStructs; - -namespace Penumbra.UI.Tabs.Debug; - +using Penumbra.GameData.Files.AtchStructs; + +namespace Penumbra.UI.Tabs.Debug; + public static class AtchDrawer { public static void Draw(AtchFile file) diff --git a/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs b/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs index 94c6cbd6..471d770a 100644 --- a/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs +++ b/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs @@ -1,4 +1,4 @@ -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Raii; using Penumbra.CrashHandler; diff --git a/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs b/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs index c8e7f001..672b8c79 100644 --- a/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs +++ b/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs @@ -1,6 +1,6 @@ using System.Text.Json; +using Dalamud.Bindings.ImGui; using Dalamud.Interface.DragDrop; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using OtterGui.Services; diff --git a/Penumbra/UI/Tabs/Debug/DebugConfigurationDrawer.cs b/Penumbra/UI/Tabs/Debug/DebugConfigurationDrawer.cs new file mode 100644 index 00000000..087670c1 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/DebugConfigurationDrawer.cs @@ -0,0 +1,16 @@ +using OtterGui.Text; + +namespace Penumbra.UI.Tabs.Debug; + +public static class DebugConfigurationDrawer +{ + public static void Draw() + { + using var id = ImUtf8.CollapsingHeaderId("Debugging Options"u8); + if (!id) + return; + + ImUtf8.Checkbox("Log IMC File Replacements"u8, ref DebugConfiguration.WriteImcBytesToLog); + ImUtf8.Checkbox("Scan for Skin Material Attributes"u8, ref DebugConfiguration.UseSkinMaterialProcessing); + } +} diff --git a/Penumbra/UI/Tabs/Debug/DebugTab.cs b/Penumbra/UI/Tabs/Debug/DebugTab.cs index fc735d04..05f77e29 100644 --- a/Penumbra/UI/Tabs/Debug/DebugTab.cs +++ b/Penumbra/UI/Tabs/Debug/DebugTab.cs @@ -6,13 +6,14 @@ using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Group; using FFXIVClientStructs.FFXIV.Client.Game.Object; -using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; using FFXIVClientStructs.FFXIV.Client.System.Resource; using FFXIVClientStructs.FFXIV.Client.UI.Agent; -using ImGuiNET; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Colors; using Microsoft.Extensions.DependencyInjection; using OtterGui; using OtterGui.Classes; +using OtterGui.Extensions; using OtterGui.Services; using OtterGui.Text; using OtterGui.Widgets; @@ -35,13 +36,13 @@ using Penumbra.UI.Classes; using Penumbra.Util; using static OtterGui.Raii.ImRaii; using CharacterBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase; -using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; -using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager; using ImGuiClip = OtterGui.ImGuiClip; using Penumbra.Api.IpcTester; +using Penumbra.GameData.Data; using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.ResourceLoading; using Penumbra.GameData.Files.StainMapStructs; +using Penumbra.Interop; using Penumbra.String.Classes; using Penumbra.UI.AdvancedWindow.Materials; @@ -72,50 +73,56 @@ public class Diagnostics(ServiceManager provider) : IUiService public class DebugTab : Window, ITab, IUiService { - private readonly PerformanceTracker _performance; - private readonly Configuration _config; - private readonly CollectionManager _collectionManager; - private readonly ModManager _modManager; - private readonly ValidityChecker _validityChecker; - private readonly HttpApi _httpApi; - private readonly ActorManager _actors; - private readonly StainService _stains; - private readonly CharacterUtility _characterUtility; - private readonly ResidentResourceManager _residentResources; - private readonly ResourceManagerService _resourceManager; - private readonly CollectionResolver _collectionResolver; - private readonly DrawObjectState _drawObjectState; - private readonly PathState _pathState; - private readonly SubfileHelper _subfileHelper; - private readonly IdentifiedCollectionCache _identifiedCollectionCache; - private readonly CutsceneService _cutsceneService; - private readonly ModImportManager _modImporter; - private readonly ImportPopup _importPopup; - private readonly FrameworkManager _framework; - private readonly TextureManager _textureManager; - private readonly ShaderReplacementFixer _shaderReplacementFixer; - private readonly RedrawService _redraws; - private readonly DictEmote _emotes; - private readonly Diagnostics _diagnostics; - private readonly ObjectManager _objects; - private readonly IClientState _clientState; - private readonly IDataManager _dataManager; - private readonly IpcTester _ipcTester; - private readonly CrashHandlerPanel _crashHandlerPanel; - private readonly TexHeaderDrawer _texHeaderDrawer; - private readonly HookOverrideDrawer _hookOverrides; - private readonly RsfService _rsfService; + private readonly PerformanceTracker _performance; + private readonly Configuration _config; + private readonly CollectionManager _collectionManager; + private readonly ModManager _modManager; + private readonly ValidityChecker _validityChecker; + private readonly HttpApi _httpApi; + private readonly ActorManager _actors; + private readonly StainService _stains; + private readonly GlobalVariablesDrawer _globalVariablesDrawer; + private readonly ResourceManagerService _resourceManager; + private readonly ResourceLoader _resourceLoader; + private readonly CollectionResolver _collectionResolver; + private readonly DrawObjectState _drawObjectState; + private readonly PathState _pathState; + private readonly SubfileHelper _subfileHelper; + private readonly IdentifiedCollectionCache _identifiedCollectionCache; + private readonly CutsceneService _cutsceneService; + private readonly ModImportManager _modImporter; + private readonly ImportPopup _importPopup; + private readonly FrameworkManager _framework; + private readonly TextureManager _textureManager; + private readonly ShaderReplacementFixer _shaderReplacementFixer; + private readonly RedrawService _redraws; + private readonly DictEmote _emotes; + private readonly Diagnostics _diagnostics; + private readonly ObjectManager _objects; + private readonly IClientState _clientState; + private readonly IDataManager _dataManager; + private readonly IpcTester _ipcTester; + private readonly CrashHandlerPanel _crashHandlerPanel; + private readonly TexHeaderDrawer _texHeaderDrawer; + private readonly HookOverrideDrawer _hookOverrides; + private readonly RsfService _rsfService; + private readonly SchedulerResourceManagementService _schedulerService; + private readonly ObjectIdentification _objectIdentification; + private readonly RenderTargetDrawer _renderTargetDrawer; + private readonly ModMigratorDebug _modMigratorDebug; + private readonly ShapeInspector _shapeInspector; public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, ObjectManager objects, IClientState clientState, IDataManager dataManager, ValidityChecker validityChecker, ModManager modManager, HttpApi httpApi, ActorManager actors, StainService stains, - CharacterUtility characterUtility, ResidentResourceManager residentResources, - ResourceManagerService resourceManager, CollectionResolver collectionResolver, + ResourceManagerService resourceManager, ResourceLoader resourceLoader, CollectionResolver collectionResolver, DrawObjectState drawObjectState, PathState pathState, SubfileHelper subfileHelper, IdentifiedCollectionCache identifiedCollectionCache, CutsceneService cutsceneService, ModImportManager modImporter, ImportPopup importPopup, FrameworkManager framework, TextureManager textureManager, ShaderReplacementFixer shaderReplacementFixer, RedrawService redraws, DictEmote emotes, Diagnostics diagnostics, IpcTester ipcTester, CrashHandlerPanel crashHandlerPanel, TexHeaderDrawer texHeaderDrawer, - HookOverrideDrawer hookOverrides, RsfService rsfService) + HookOverrideDrawer hookOverrides, RsfService rsfService, GlobalVariablesDrawer globalVariablesDrawer, + SchedulerResourceManagementService schedulerService, ObjectIdentification objectIdentification, RenderTargetDrawer renderTargetDrawer, + ModMigratorDebug modMigratorDebug, ShapeInspector shapeInspector) : base("Penumbra Debug Window", ImGuiWindowFlags.NoCollapse) { IsOpen = true; @@ -132,9 +139,8 @@ public class DebugTab : Window, ITab, IUiService _httpApi = httpApi; _actors = actors; _stains = stains; - _characterUtility = characterUtility; - _residentResources = residentResources; _resourceManager = resourceManager; + _resourceLoader = resourceLoader; _collectionResolver = collectionResolver; _drawObjectState = drawObjectState; _pathState = pathState; @@ -153,7 +159,13 @@ public class DebugTab : Window, ITab, IUiService _crashHandlerPanel = crashHandlerPanel; _texHeaderDrawer = texHeaderDrawer; _hookOverrides = hookOverrides; - _rsfService = rsfService; + _rsfService = rsfService; + _globalVariablesDrawer = globalVariablesDrawer; + _schedulerService = schedulerService; + _objectIdentification = objectIdentification; + _renderTargetDrawer = renderTargetDrawer; + _modMigratorDebug = modMigratorDebug; + _shapeInspector = shapeInspector; _objects = objects; _clientState = clientState; _dataManager = dataManager; @@ -179,20 +191,24 @@ public class DebugTab : Window, ITab, IUiService DrawDebugTabGeneral(); _crashHandlerPanel.Draw(); + DebugConfigurationDrawer.Draw(); _diagnostics.DrawDiagnostics(); DrawPerformanceTab(); DrawPathResolverDebug(); DrawActorsDebug(); DrawCollectionCaches(); _texHeaderDrawer.Draw(); - DrawDebugCharacterUtility(); + _modMigratorDebug.Draw(); DrawShaderReplacementFixer(); DrawData(); DrawCrcCache(); + DrawResourceLoader(); DrawResourceProblems(); + _renderTargetDrawer.Draw(); _hookOverrides.Draw(); DrawPlayerModelInfo(); - DrawGlobalVariableInfo(); + _globalVariablesDrawer.Draw(); + DrawCloudApi(); DrawDebugTabIpc(); } @@ -208,17 +224,53 @@ public class DebugTab : Window, ITab, IUiService if (collection.HasCache) { using var color = PushColor(ImGuiCol.Text, ColorId.FolderExpanded.Value()); - using var node = TreeNode($"{collection.Name} (Change Counter {collection.ChangeCounter})###{collection.Name}"); + using var node = + TreeNode($"{collection.Identity.Name} (Change Counter {collection.Counters.Change})###{collection.Identity.Name}"); if (!node) continue; color.Pop(); + using (var inheritanceNode = ImUtf8.TreeNode("Inheritance"u8)) + { + if (inheritanceNode) + { + using var table = ImUtf8.Table("table"u8, 3, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV); + if (table) + { + var max = Math.Max( + Math.Max(collection.Inheritance.DirectlyInheritedBy.Count, collection.Inheritance.DirectlyInheritsFrom.Count), + collection.Inheritance.FlatHierarchy.Count); + for (var i = 0; i < max; ++i) + { + ImGui.TableNextColumn(); + if (i < collection.Inheritance.DirectlyInheritsFrom.Count) + ImUtf8.Text(collection.Inheritance.DirectlyInheritsFrom[i].Identity.Name); + else + ImGui.Dummy(new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetTextLineHeight())); + ImGui.TableNextColumn(); + if (i < collection.Inheritance.DirectlyInheritedBy.Count) + ImUtf8.Text(collection.Inheritance.DirectlyInheritedBy[i].Identity.Name); + else + ImGui.Dummy(new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetTextLineHeight())); + ImGui.TableNextColumn(); + if (i < collection.Inheritance.FlatHierarchy.Count) + ImUtf8.Text(collection.Inheritance.FlatHierarchy[i].Identity.Name); + else + ImGui.Dummy(new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetTextLineHeight())); + } + } + } + } + using (var resourceNode = ImUtf8.TreeNode("Custom Resources"u8)) { if (resourceNode) foreach (var (path, resource) in collection._cache!.CustomResources) + { ImUtf8.TreeNode($"{path} -> 0x{(ulong)resource.ResourceHandle:X}", ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + } } using var modNode = ImUtf8.TreeNode("Enabled Mods"u8); @@ -241,7 +293,7 @@ public class DebugTab : Window, ITab, IUiService else { using var color = PushColor(ImGuiCol.Text, ColorId.UndefinedMod.Value()); - TreeNode($"{collection.AnonymizedName} (Change Counter {collection.ChangeCounter})", + TreeNode($"{collection.Identity.Name} (Change Counter {collection.Counters.Change})", ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); } } @@ -267,9 +319,9 @@ public class DebugTab : Window, ITab, IUiService { PrintValue("Penumbra Version", $"{_validityChecker.Version} {DebugVersionString}"); PrintValue("Git Commit Hash", _validityChecker.CommitHash); - PrintValue(TutorialService.SelectedCollection, _collectionManager.Active.Current.Name); + PrintValue(TutorialService.SelectedCollection, _collectionManager.Active.Current.Identity.Name); PrintValue(" has Cache", _collectionManager.Active.Current.HasCache.ToString()); - PrintValue(TutorialService.DefaultCollection, _collectionManager.Active.Default.Name); + PrintValue(TutorialService.DefaultCollection, _collectionManager.Active.Default.Identity.Name); PrintValue(" has Cache", _collectionManager.Active.Default.HasCache.ToString()); PrintValue("Mod Manager BasePath", _modManager.BasePath.Name); PrintValue("Mod Manager BasePath-Full", _modManager.BasePath.FullName); @@ -461,30 +513,50 @@ public class DebugTab : Window, ITab, IUiService if (!ImGui.CollapsingHeader("Actors")) return; - using var table = Table("##actors", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, - -Vector2.UnitX); - if (!table) - return; - - DrawSpecial("Current Player", _actors.GetCurrentPlayer()); - DrawSpecial("Current Inspect", _actors.GetInspectPlayer()); - DrawSpecial("Current Card", _actors.GetCardPlayer()); - DrawSpecial("Current Glamour", _actors.GetGlamourPlayer()); - - foreach (var obj in _objects) + using (var objectTree = ImUtf8.TreeNode("Object Manager"u8)) { - ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"{((GameObject*)obj.Address)->ObjectIndex}" : "NULL"); - ImGui.TableNextColumn(); - ImGuiUtil.CopyOnClickSelectable($"0x{obj.Address:X}"); - ImGui.TableNextColumn(); - if (obj.Address != nint.Zero) - ImGuiUtil.CopyOnClickSelectable($"0x{(nint)((Character*)obj.Address)->GameObject.GetDrawObject():X}"); - var identifier = _actors.FromObject(obj, out _, false, true, false); - ImGuiUtil.DrawTableColumn(_actors.ToString(identifier)); - var id = obj.AsObject->ObjectKind is ObjectKind.BattleNpc - ? $"{identifier.DataId} | {obj.AsObject->BaseId}" - : identifier.DataId.ToString(); - ImGuiUtil.DrawTableColumn(id); + if (objectTree) + { + _objects.DrawDebug(); + + using var table = Table("##actors", 8, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + DrawSpecial("Current Player", _actors.GetCurrentPlayer()); + DrawSpecial("Current Inspect", _actors.GetInspectPlayer()); + DrawSpecial("Current Card", _actors.GetCardPlayer()); + DrawSpecial("Current Glamour", _actors.GetGlamourPlayer()); + + foreach (var obj in _objects) + { + ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"{((GameObject*)obj.Address)->ObjectIndex}" : "NULL"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(obj.Address); + ImGui.TableNextColumn(); + if (obj.Address != nint.Zero) + Penumbra.Dynamis.DrawPointer((nint)((Character*)obj.Address)->GameObject.GetDrawObject()); + var identifier = _actors.FromObject(obj, out _, false, true, false); + ImGuiUtil.DrawTableColumn(_actors.ToString(identifier)); + var id = obj.AsObject->ObjectKind is ObjectKind.BattleNpc + ? $"{identifier.DataId} | {obj.AsObject->BaseId}" + : identifier.DataId.ToString(); + ImGuiUtil.DrawTableColumn(id); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(obj.Address != nint.Zero ? *(nint*)obj.Address : nint.Zero); + ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"0x{obj.AsObject->EntityId:X}" : "NULL"); + ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero + ? obj.AsObject->IsCharacter() ? $"Character: {obj.AsCharacter->ObjectKind}" : "No Character" + : "NULL"); + } + } + } + + using (var shapeTree = ImUtf8.TreeNode("Shape Inspector"u8)) + { + if (shapeTree) + _shapeInspector.Draw(); } return; @@ -499,6 +571,9 @@ public class DebugTab : Window, ITab, IUiService ImGuiUtil.DrawTableColumn(string.Empty); ImGuiUtil.DrawTableColumn(_actors.ToString(id)); ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); } } @@ -512,34 +587,37 @@ public class DebugTab : Window, ITab, IUiService return; ImGui.TextUnformatted( - $"Last Game Object: 0x{_collectionResolver.IdentifyLastGameObjectCollection(true).AssociatedGameObject:X} ({_collectionResolver.IdentifyLastGameObjectCollection(true).ModCollection.Name})"); + $"Last Game Object: 0x{_collectionResolver.IdentifyLastGameObjectCollection(true).AssociatedGameObject:X} ({_collectionResolver.IdentifyLastGameObjectCollection(true).ModCollection.Identity.Name})"); using (var drawTree = TreeNode("Draw Object to Object")) { if (drawTree) { - using var table = Table("###DrawObjectResolverTable", 6, ImGuiTableFlags.SizingFixedFit); + using var table = Table("###DrawObjectResolverTable", 8, ImGuiTableFlags.SizingFixedFit); if (table) - foreach (var (drawObject, (gameObjectPtr, child)) in _drawObjectState - .OrderBy(kvp => ((GameObject*)kvp.Value.Item1)->ObjectIndex) - .ThenBy(kvp => kvp.Value.Item2) - .ThenBy(kvp => kvp.Key)) + foreach (var (drawObject, (gameObjectPtr, idx, child)) in _drawObjectState + .OrderBy(kvp => kvp.Value.Item2.Index) + .ThenBy(kvp => kvp.Value.Item3) + .ThenBy(kvp => kvp.Key.Address)) { - var gameObject = (GameObject*)gameObjectPtr; ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable($"{drawObject}"); + ImUtf8.DrawTableColumn($"{gameObjectPtr.Index}"); + using (ImRaii.PushColor(ImGuiCol.Text, 0xFF0000FF, gameObjectPtr.Index != idx)) + { + ImUtf8.DrawTableColumn($"{idx}"); + } - ImGuiUtil.CopyOnClickSelectable($"0x{drawObject:X}"); + ImUtf8.DrawTableColumn(child ? "Child"u8 : "Main"u8); ImGui.TableNextColumn(); - ImGui.TextUnformatted(gameObject->ObjectIndex.ToString()); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(child ? "Child" : "Main"); - ImGui.TableNextColumn(); - var (address, name) = ($"0x{gameObjectPtr:X}", new ByteString(gameObject->Name).ToString()); - ImGuiUtil.CopyOnClickSelectable(address); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(name); - ImGui.TableNextColumn(); - var collection = _collectionResolver.IdentifyCollection(gameObject, true); - ImGui.TextUnformatted(collection.ModCollection.Name); + ImUtf8.CopyOnClickSelectable($"{gameObjectPtr}"); + using (ImRaii.PushColor(ImGuiCol.Text, 0xFF0000FF, _objects[idx] != gameObjectPtr)) + { + ImUtf8.DrawTableColumn($"{_objects[idx]}"); + } + + ImUtf8.DrawTableColumn(gameObjectPtr.Utf8Name.Span); + var collection = _collectionResolver.IdentifyCollection(gameObjectPtr.AsObject, true); + ImUtf8.DrawTableColumn(collection.ModCollection.Identity.Name); } } } @@ -555,7 +633,7 @@ public class DebugTab : Window, ITab, IUiService ImGui.TableNextColumn(); ImGui.TextUnformatted($"{data.AssociatedGameObject:X}"); ImGui.TableNextColumn(); - ImGui.TextUnformatted(data.ModCollection.Name); + ImGui.TextUnformatted(data.ModCollection.Identity.Name); } } } @@ -568,12 +646,12 @@ public class DebugTab : Window, ITab, IUiService if (table) { ImGuiUtil.DrawTableColumn("Current Mtrl Data"); - ImGuiUtil.DrawTableColumn(_subfileHelper.MtrlData.ModCollection.Name); + ImGuiUtil.DrawTableColumn(_subfileHelper.MtrlData.ModCollection.Identity.Name); ImGuiUtil.DrawTableColumn($"0x{_subfileHelper.MtrlData.AssociatedGameObject:X}"); ImGui.TableNextColumn(); ImGuiUtil.DrawTableColumn("Current Avfx Data"); - ImGuiUtil.DrawTableColumn(_subfileHelper.AvfxData.ModCollection.Name); + ImGuiUtil.DrawTableColumn(_subfileHelper.AvfxData.ModCollection.Identity.Name); ImGuiUtil.DrawTableColumn($"0x{_subfileHelper.AvfxData.AssociatedGameObject:X}"); ImGui.TableNextColumn(); @@ -585,7 +663,7 @@ public class DebugTab : Window, ITab, IUiService foreach (var (resource, resolve) in _subfileHelper) { ImGuiUtil.DrawTableColumn($"0x{resource:X}"); - ImGuiUtil.DrawTableColumn(resolve.ModCollection.Name); + ImGuiUtil.DrawTableColumn(resolve.ModCollection.Identity.Name); ImGuiUtil.DrawTableColumn($"0x{resolve.AssociatedGameObject:X}"); ImGuiUtil.DrawTableColumn($"{((ResourceHandle*)resource)->FileName()}"); } @@ -605,7 +683,7 @@ public class DebugTab : Window, ITab, IUiService ImGuiUtil.DrawTableColumn($"{((GameObject*)address)->ObjectIndex}"); ImGuiUtil.DrawTableColumn($"0x{address:X}"); ImGuiUtil.DrawTableColumn(identifier.ToString()); - ImGuiUtil.DrawTableColumn(collection.Name); + ImGuiUtil.DrawTableColumn(collection.Identity.Name); } } } @@ -651,6 +729,9 @@ public class DebugTab : Window, ITab, IUiService if (agent->Data == null) agent = &AgentBannerMIP.Instance()->AgentBannerInterface; + ImUtf8.Text("Agent: "); + ImGui.SameLine(0, 0); + Penumbra.Dynamis.DrawPointer((nint)agent); if (agent->Data != null) { using var table = Table("###PBannerTable", 2, ImGuiTableFlags.SizingFixedFit); @@ -669,6 +750,20 @@ public class DebugTab : Window, ITab, IUiService } } } + + using (var tmbCache = TreeNode("TMB Cache")) + { + if (tmbCache) + { + using var table = Table("###TmbTable", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + foreach (var (id, name) in _schedulerService.ListedTmbs.OrderBy(kvp => kvp.Key)) + { + ImUtf8.DrawTableColumn($"{id:D6}"); + ImUtf8.DrawTableColumn(name.Span); + } + } + } } private void DrawData() @@ -677,10 +772,40 @@ public class DebugTab : Window, ITab, IUiService return; DrawEmotes(); + DrawActionTmbs(); DrawStainTemplates(); DrawAtch(); + DrawChangedItemTest(); } + private string _changedItemPath = string.Empty; + private readonly Dictionary _changedItems = []; + + private void DrawChangedItemTest() + { + using var node = TreeNode("Changed Item Test"); + if (!node) + return; + + if (ImUtf8.InputText("##ChangedItemTest"u8, ref _changedItemPath, "Changed Item File Path..."u8)) + { + _changedItems.Clear(); + _objectIdentification.Identify(_changedItems, _changedItemPath); + } + + if (_changedItems.Count == 0) + return; + + using var list = ImUtf8.ListBox("##ChangedItemList"u8, + new Vector2(ImGui.GetContentRegionAvail().X, 8 * ImGui.GetTextLineHeightWithSpacing())); + if (!list) + return; + + foreach (var item in _changedItems) + ImUtf8.Selectable(item.Key); + } + + private string _emoteSearchFile = string.Empty; private string _emoteSearchName = string.Empty; @@ -736,6 +861,33 @@ public class DebugTab : Window, ITab, IUiService ImGuiClip.DrawEndDummy(dummy, ImGui.GetTextLineHeightWithSpacing()); } + private string _tmbKeyFilter = string.Empty; + private CiByteString _tmbKeyFilterU8 = CiByteString.Empty; + + private void DrawActionTmbs() + { + using var mainTree = TreeNode("Action TMBs"); + if (!mainTree) + return; + + if (ImGui.InputText("Key", ref _tmbKeyFilter, 256)) + _tmbKeyFilterU8 = CiByteString.FromString(_tmbKeyFilter, out var r, MetaDataComputation.All) ? r : CiByteString.Empty; + using var table = Table("##table", 2, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, + new Vector2(-1, 12 * ImGui.GetTextLineHeightWithSpacing())); + if (!table) + return; + + var skips = ImGuiClip.GetNecessarySkips(ImGui.GetTextLineHeightWithSpacing()); + var dummy = ImGuiClip.FilteredClippedDraw(_schedulerService.ActionTmbs.OrderBy(r => r.Value), skips, + kvp => kvp.Key.Contains(_tmbKeyFilterU8), + p => + { + ImUtf8.DrawTableColumn($"{p.Value}"); + ImUtf8.DrawTableColumn(p.Key.Span); + }); + ImGuiClip.DrawEndDummy(dummy, ImGui.GetTextLineHeightWithSpacing()); + } + private void DrawStainTemplates() { using var mainTree = TreeNode("Staining Templates"); @@ -789,68 +941,6 @@ public class DebugTab : Window, ITab, IUiService } } - /// - /// Draw information about the character utility class from SE, - /// displaying all files, their sizes, the default files and the default sizes. - /// - private unsafe void DrawDebugCharacterUtility() - { - if (!ImGui.CollapsingHeader("Character Utility")) - return; - - using var table = Table("##CharacterUtility", 7, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, - -Vector2.UnitX); - if (!table) - return; - - for (var idx = 0; idx < CharacterUtility.ReverseIndices.Length; ++idx) - { - var intern = CharacterUtility.ReverseIndices[idx]; - var resource = _characterUtility.Address->Resource(idx); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"[{idx}]"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"0x{(ulong)resource:X}"); - ImGui.TableNextColumn(); - if (resource == null) - { - ImGui.TableNextRow(); - continue; - } - - UiHelpers.Text(resource); - ImGui.TableNextColumn(); - var data = (nint)resource->CsHandle.GetData(); - var length = resource->CsHandle.GetLength(); - if (ImGui.Selectable($"0x{data:X}")) - if (data != nint.Zero && length > 0) - ImGui.SetClipboardText(string.Join("\n", - new ReadOnlySpan((byte*)data, (int)length).ToArray().Select(b => b.ToString("X2")))); - - ImGuiUtil.HoverTooltip("Click to copy bytes to clipboard."); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(length.ToString()); - - ImGui.TableNextColumn(); - if (intern.Value != -1) - { - ImGui.Selectable($"0x{_characterUtility.DefaultResource(intern).Address:X}"); - if (ImGui.IsItemClicked()) - ImGui.SetClipboardText(string.Join("\n", - new ReadOnlySpan((byte*)_characterUtility.DefaultResource(intern).Address, - _characterUtility.DefaultResource(intern).Size).ToArray().Select(b => b.ToString("X2")))); - - ImGuiUtil.HoverTooltip("Click to copy bytes to clipboard."); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{_characterUtility.DefaultResource(intern).Size}"); - } - else - { - ImGui.TableNextColumn(); - } - } - } private void DrawShaderReplacementFixer() { @@ -940,45 +1030,6 @@ public class DebugTab : Window, ITab, IUiService ImGui.TextUnformatted($"{slowPathCallDeltas.Skin}"); } - /// Draw information about the resident resource files. - private unsafe void DrawDebugResidentResources() - { - using var tree = TreeNode("Resident Resources"); - if (!tree) - return; - - if (_residentResources.Address == null || _residentResources.Address->NumResources == 0) - return; - - using var table = Table("##ResidentResources", 2, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, - -Vector2.UnitX); - if (!table) - return; - - for (var i = 0; i < _residentResources.Address->NumResources; ++i) - { - var resource = _residentResources.Address->ResourceList[i]; - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"0x{(ulong)resource:X}"); - ImGui.TableNextColumn(); - UiHelpers.Text(resource); - } - } - - private static void DrawCopyableAddress(string label, nint address) - { - using (var _ = PushFont(UiBuilder.MonoFont)) - { - if (ImGui.Selectable($"0x{address:X16} {label}")) - ImGui.SetClipboardText($"{address:X16}"); - } - - ImGuiUtil.HoverTooltip("Click to copy address to clipboard."); - } - - private static unsafe void DrawCopyableAddress(string label, void* address) - => DrawCopyableAddress(label, (nint)address); - /// Draw information about the models, materials and resources currently loaded by the local player. private unsafe void DrawPlayerModelInfo() { @@ -987,20 +1038,20 @@ public class DebugTab : Window, ITab, IUiService if (!ImGui.CollapsingHeader($"Player Model Info: {name}##Draw") || player == null) return; - DrawCopyableAddress("PlayerCharacter", player.Address); + DrawCopyableAddress("PlayerCharacter"u8, player.Address); var model = (CharacterBase*)((Character*)player.Address)->GameObject.GetDrawObject(); if (model == null) return; - DrawCopyableAddress("CharacterBase", model); + DrawCopyableAddress("CharacterBase"u8, model); using (var t1 = Table("##table", 2, ImGuiTableFlags.SizingFixedFit)) { if (t1) { ImGuiUtil.DrawTableColumn("Flags"); - ImGuiUtil.DrawTableColumn($"{model->UnkFlags_01:X2}"); + ImGuiUtil.DrawTableColumn($"{model->StateFlags}"); ImGuiUtil.DrawTableColumn("Has Model In Slot Loaded"); ImGuiUtil.DrawTableColumn($"{model->HasModelInSlotLoaded:X8}"); ImGuiUtil.DrawTableColumn("Has Model Files In Slot Loaded"); @@ -1030,14 +1081,14 @@ public class DebugTab : Window, ITab, IUiService ImGui.TableNextColumn(); ImGui.TextUnformatted($"Slot {i}"); ImGui.TableNextColumn(); - ImGui.TextUnformatted(imc == null ? "NULL" : $"0x{(ulong)imc:X}"); + Penumbra.Dynamis.DrawPointer((nint)imc); ImGui.TableNextColumn(); if (imc != null) UiHelpers.Text(imc); var mdl = (RenderModel*)model->Models[i]; ImGui.TableNextColumn(); - ImGui.TextUnformatted(mdl == null ? "NULL" : $"0x{(ulong)mdl:X}"); + Penumbra.Dynamis.DrawPointer((nint)mdl); if (mdl == null || mdl->ResourceHandle == null || mdl->ResourceHandle->Category != ResourceCategory.Chara) continue; @@ -1048,20 +1099,6 @@ public class DebugTab : Window, ITab, IUiService } } - /// Draw information about some game global variables. - private unsafe void DrawGlobalVariableInfo() - { - var header = ImGui.CollapsingHeader("Global Variables"); - ImGuiUtil.HoverTooltip("Draw information about global variables. Can provide useful starting points for a memory viewer."); - if (!header) - return; - - DrawCopyableAddress("CharacterUtility", _characterUtility.Address); - DrawCopyableAddress("ResidentResourceManager", _residentResources.Address); - DrawCopyableAddress("Device", Device.Instance()); - DrawDebugResidentResources(); - } - private string _crcInput = string.Empty; private FullPath _crcPath = FullPath.Empty; @@ -1096,6 +1133,35 @@ public class DebugTab : Window, ITab, IUiService } } + private unsafe void DrawResourceLoader() + { + if (!ImUtf8.CollapsingHeader("Resource Loader"u8)) + return; + + var ongoingLoads = _resourceLoader.OngoingLoads; + var ongoingLoadCount = ongoingLoads.Count; + ImUtf8.Text($"Ongoing Loads: {ongoingLoadCount}"); + + if (ongoingLoadCount == 0) + return; + + using var table = ImUtf8.Table("ongoingLoadTable"u8, 3); + if (!table) + return; + + ImUtf8.TableSetupColumn("Resource Handle"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImUtf8.TableSetupColumn("Actual Path"u8, ImGuiTableColumnFlags.WidthStretch, 0.4f); + ImUtf8.TableSetupColumn("Original Path"u8, ImGuiTableColumnFlags.WidthStretch, 0.4f); + ImGui.TableHeadersRow(); + + foreach (var (handle, original) in ongoingLoads) + { + ImUtf8.DrawTableColumn($"0x{handle:X}"); + ImUtf8.DrawTableColumn(((ResourceHandle*)handle)->CsHandle.FileName); + ImUtf8.DrawTableColumn(original.Path.Span); + } + } + /// Draw resources with unusual reference count. private unsafe void DrawResourceProblems() { @@ -1136,11 +1202,55 @@ public class DebugTab : Window, ITab, IUiService } + private string _cloudTesterPath = string.Empty; + private bool? _cloudTesterReturn; + private Exception? _cloudTesterError; + + private void DrawCloudApi() + { + if (!ImUtf8.CollapsingHeader("Cloud API"u8)) + return; + + using var id = ImRaii.PushId("CloudApiTester"u8); + + if (ImUtf8.InputText("Path"u8, ref _cloudTesterPath, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + { + try + { + _cloudTesterReturn = CloudApi.IsCloudSynced(_cloudTesterPath); + _cloudTesterError = null; + } + catch (Exception e) + { + _cloudTesterReturn = null; + _cloudTesterError = e; + } + } + + if (_cloudTesterReturn.HasValue) + ImUtf8.Text($"Is Cloud Synced? {_cloudTesterReturn}"); + + if (_cloudTesterError is not null) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + ImUtf8.Text($"{_cloudTesterError}"); + } + } + + /// Draw information about IPC options and availability. private void DrawDebugTabIpc() { - if (ImGui.CollapsingHeader("IPC")) - _ipcTester.Draw(); + if (!ImUtf8.CollapsingHeader("IPC"u8)) + return; + + using (var tree = ImUtf8.TreeNode("Dynamis"u8)) + { + if (tree) + Penumbra.Dynamis.DrawDebugInfo(); + } + + _ipcTester.Draw(); } /// Helper to print a property and its value in a 2-column table. @@ -1163,4 +1273,14 @@ public class DebugTab : Window, ITab, IUiService _config.Ephemeral.DebugSeparateWindow = false; _config.Ephemeral.Save(); } + + public static unsafe void DrawCopyableAddress(ReadOnlySpan label, void* address) + => DrawCopyableAddress(label, (nint)address); + + public static unsafe void DrawCopyableAddress(ReadOnlySpan label, nint address) + { + Penumbra.Dynamis.DrawPointer(address); + ImUtf8.SameLineInner(); + ImUtf8.Text(label); + } } diff --git a/Penumbra/UI/Tabs/Debug/GlobalVariablesDrawer.cs b/Penumbra/UI/Tabs/Debug/GlobalVariablesDrawer.cs new file mode 100644 index 00000000..f0ab1125 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/GlobalVariablesDrawer.cs @@ -0,0 +1,252 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using FFXIVClientStructs.Interop; +using FFXIVClientStructs.STD; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.String; +using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager; + +namespace Penumbra.UI.Tabs.Debug; + +public unsafe class GlobalVariablesDrawer( + CharacterUtility characterUtility, + ResidentResourceManager residentResources, + SchedulerResourceManagementService scheduler) : IUiService +{ + /// Draw information about some game global variables. + public void Draw() + { + var header = ImUtf8.CollapsingHeader("Global Variables"u8); + ImUtf8.HoverTooltip("Draw information about global variables. Can provide useful starting points for a memory viewer."u8); + if (!header) + return; + + var actionManager = (ActionTimelineManager**)ActionTimelineManager.Instance(); + using (ImUtf8.Group()) + { + Penumbra.Dynamis.DrawPointer(characterUtility.Address); + Penumbra.Dynamis.DrawPointer(residentResources.Address); + Penumbra.Dynamis.DrawPointer(ScheduleManagement.Instance()); + Penumbra.Dynamis.DrawPointer(actionManager); + Penumbra.Dynamis.DrawPointer(actionManager != null ? *actionManager : null); + Penumbra.Dynamis.DrawPointer(scheduler.Address); + Penumbra.Dynamis.DrawPointer(scheduler.Address != null ? *scheduler.Address : null); + Penumbra.Dynamis.DrawPointer(Device.Instance()); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text("CharacterUtility"u8); + ImUtf8.Text("ResidentResourceManager"u8); + ImUtf8.Text("ScheduleManagement"u8); + ImUtf8.Text("ActionTimelineManager*"u8); + ImUtf8.Text("ActionTimelineManager"u8); + ImUtf8.Text("SchedulerResourceManagement*"u8); + ImUtf8.Text("SchedulerResourceManagement"u8); + ImUtf8.Text("Device"u8); + } + + DrawCharacterUtility(); + DrawResidentResources(); + DrawSchedulerResourcesMap(); + DrawSchedulerResourcesList(); + } + + + /// + /// Draw information about the character utility class from SE, + /// displaying all files, their sizes, the default files and the default sizes. + /// + private void DrawCharacterUtility() + { + using var tree = ImUtf8.TreeNode("Character Utility"u8); + if (!tree) + return; + + using var table = ImUtf8.Table("##CharacterUtility"u8, 7, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + for (var idx = 0; idx < CharacterUtility.ReverseIndices.Length; ++idx) + { + var intern = CharacterUtility.ReverseIndices[idx]; + var resource = characterUtility.Address->Resource(idx); + ImUtf8.DrawTableColumn($"[{idx}]"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + if (resource == null) + { + ImGui.TableNextRow(); + continue; + } + + ImUtf8.DrawTableColumn(resource->CsHandle.FileName.AsSpan()); + ImGui.TableNextColumn(); + var data = (nint)resource->CsHandle.GetData(); + var length = resource->CsHandle.GetLength(); + Penumbra.Dynamis.DrawPointer(data); + ImUtf8.DrawTableColumn(length.ToString()); + ImGui.TableNextColumn(); + if (intern.Value != -1) + { + Penumbra.Dynamis.DrawPointer(characterUtility.DefaultResource(intern).Address); + ImUtf8.DrawTableColumn($"{characterUtility.DefaultResource(intern).Size}"); + } + else + { + ImGui.TableNextColumn(); + } + } + } + + /// Draw information about the resident resource files. + private void DrawResidentResources() + { + using var tree = ImUtf8.TreeNode("Resident Resources"u8); + if (!tree) + return; + + if (residentResources.Address == null || residentResources.Address->NumResources == 0) + return; + + using var table = ImUtf8.Table("##ResidentResources"u8, 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + for (var idx = 0; idx < residentResources.Address->NumResources; ++idx) + { + var resource = residentResources.Address->ResourceList[idx]; + ImUtf8.DrawTableColumn($"[{idx}]"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + if (resource == null) + { + ImGui.TableNextRow(); + continue; + } + + ImUtf8.DrawTableColumn(resource->CsHandle.FileName.AsSpan()); + ImGui.TableNextColumn(); + var data = (nint)resource->CsHandle.GetData(); + var length = resource->CsHandle.GetLength(); + Penumbra.Dynamis.DrawPointer(data); + ImUtf8.DrawTableColumn(length.ToString()); + } + } + + private string _schedulerFilterList = string.Empty; + private string _schedulerFilterMap = string.Empty; + private CiByteString _schedulerFilterListU8 = CiByteString.Empty; + private CiByteString _schedulerFilterMapU8 = CiByteString.Empty; + private int _shownResourcesList = 0; + private int _shownResourcesMap = 0; + + private void DrawSchedulerResourcesMap() + { + using var tree = ImUtf8.TreeNode("Scheduler Resources (Map)"u8); + if (!tree) + return; + + if (scheduler.Address == null || scheduler.Scheduler == null) + return; + + if (ImUtf8.InputText("##SchedulerMapFilter"u8, ref _schedulerFilterMap, "Filter..."u8)) + _schedulerFilterMapU8 = CiByteString.FromString(_schedulerFilterMap, out var t, MetaDataComputation.All, false) + ? t + : CiByteString.Empty; + ImUtf8.Text($"{_shownResourcesMap} / {scheduler.Scheduler->Resources.LongCount}"); + using var table = ImUtf8.Table("##SchedulerMapResources"u8, 10, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + // TODO Remove cast when it'll have the right type in CS. + var map = (StdMap>*)&scheduler.Scheduler->Resources; + var total = 0; + _shownResourcesMap = 0; + foreach (var (key, resourcePtr) in *map) + { + var resource = resourcePtr.Value; + if (_schedulerFilterMap.Length is 0 || resource->Name.Buffer.IndexOf(_schedulerFilterMapU8.Span) >= 0) + { + ImUtf8.DrawTableColumn($"[{total:D4}]"); + ImUtf8.DrawTableColumn($"{resource->Name.Unk1}"); + ImUtf8.DrawTableColumn(new CiByteString(resource->Name.Buffer, MetaDataComputation.None).Span); + ImUtf8.DrawTableColumn($"{resource->Consumers}"); + ImUtf8.DrawTableColumn($"{resource->Unk1}"); // key + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + ImGui.TableNextColumn(); + var resourceHandle = *((ResourceHandle**)resource + 3); + Penumbra.Dynamis.DrawPointer(resourceHandle); + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(resourceHandle->FileName().Span); + ImGui.TableNextColumn(); + uint dataLength = 0; + Penumbra.Dynamis.DrawPointer(resource->GetResourceData(&dataLength)); + ImUtf8.DrawTableColumn($"{dataLength}"); + ++_shownResourcesMap; + } + + ++total; + } + } + + private void DrawSchedulerResourcesList() + { + using var tree = ImUtf8.TreeNode("Scheduler Resources (List)"u8); + if (!tree) + return; + + if (scheduler.Address == null || scheduler.Scheduler == null) + return; + + if (ImUtf8.InputText("##SchedulerListFilter"u8, ref _schedulerFilterList, "Filter..."u8)) + _schedulerFilterListU8 = CiByteString.FromString(_schedulerFilterList, out var t, MetaDataComputation.All, false) + ? t + : CiByteString.Empty; + ImUtf8.Text($"{_shownResourcesList} / {scheduler.Scheduler->Resources.LongCount}"); + using var table = ImUtf8.Table("##SchedulerListResources"u8, 10, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + var resource = scheduler.Scheduler->Begin; + var total = 0; + _shownResourcesList = 0; + while (resource != null && total < scheduler.Scheduler->Resources.Count) + { + if (_schedulerFilterList.Length is 0 || resource->Name.Buffer.IndexOf(_schedulerFilterListU8.Span) >= 0) + { + ImUtf8.DrawTableColumn($"[{total:D4}]"); + ImUtf8.DrawTableColumn($"{resource->Name.Unk1}"); + ImUtf8.DrawTableColumn(new CiByteString(resource->Name.Buffer, MetaDataComputation.None).Span); + ImUtf8.DrawTableColumn($"{resource->Consumers}"); + ImUtf8.DrawTableColumn($"{resource->Unk1}"); // key + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + ImGui.TableNextColumn(); + var resourceHandle = *((ResourceHandle**)resource + 3); + Penumbra.Dynamis.DrawPointer(resourceHandle); + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(resourceHandle->FileName().Span); + ImGui.TableNextColumn(); + uint dataLength = 0; + Penumbra.Dynamis.DrawPointer(resource->GetResourceData(&dataLength)); + ImUtf8.DrawTableColumn($"{dataLength}"); + ++_shownResourcesList; + } + + resource = resource->Previous; + ++total; + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs b/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs index e8ff9b9c..f1024950 100644 --- a/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs +++ b/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Plugin; -using ImGuiNET; using OtterGui.Services; using OtterGui.Text; using Penumbra.Interop.Hooks; diff --git a/Penumbra/UI/Tabs/Debug/ModMigratorDebug.cs b/Penumbra/UI/Tabs/Debug/ModMigratorDebug.cs new file mode 100644 index 00000000..e6e01107 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/ModMigratorDebug.cs @@ -0,0 +1,55 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Services; + +namespace Penumbra.UI.Tabs.Debug; + +public class ModMigratorDebug(ModMigrator migrator) : IUiService +{ + private string _inputPath = string.Empty; + private string _outputPath = string.Empty; + private Task? _indexTask; + private Task? _mdlTask; + + public void Draw() + { + if (!ImUtf8.CollapsingHeaderId("Mod Migrator"u8)) + return; + + ImUtf8.InputText("##input"u8, ref _inputPath, "Input Path..."u8); + ImUtf8.InputText("##output"u8, ref _outputPath, "Output Path..."u8); + + if (ImUtf8.ButtonEx("Create Index Texture"u8, "Requires input to be a path to a normal texture."u8, default, _inputPath.Length == 0 + || _outputPath.Length == 0 + || _indexTask is + { + IsCompleted: false, + })) + _indexTask = migrator.CreateIndexFile(_inputPath, _outputPath); + + if (_indexTask is not null) + { + ImGui.SameLine(); + ImUtf8.TextFrameAligned($"{_indexTask.Status}"); + } + + if (ImUtf8.ButtonEx("Update Model File"u8, "Requires input to be a path to a mdl."u8, default, _inputPath.Length == 0 + || _outputPath.Length == 0 + || _mdlTask is + { + IsCompleted: false, + })) + _mdlTask = Task.Run(() => + { + File.Copy(_inputPath, _outputPath, true); + MigrationManager.TryMigrateSingleModel(_outputPath, false); + }); + + if (_mdlTask is not null) + { + ImGui.SameLine(); + ImUtf8.TextFrameAligned($"{_mdlTask.Status}"); + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/RenderTargetDrawer.cs b/Penumbra/UI/Tabs/Debug/RenderTargetDrawer.cs new file mode 100644 index 00000000..d497f90a --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/RenderTargetDrawer.cs @@ -0,0 +1,98 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using OtterGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Interop.Hooks; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Services; + +namespace Penumbra.UI.Tabs.Debug; + +public class RenderTargetDrawer(RenderTargetHdrEnabler renderTargetHdrEnabler, DalamudConfigService dalamudConfig, Configuration config) : IUiService +{ + private void DrawStatistics() + { + using (ImUtf8.Group()) + { + ImUtf8.Text("Wait For Plugins (Now)"); + ImUtf8.Text("Wait For Plugins (First Launch)"); + + ImUtf8.Text("HDR Enabled (Now)"); + ImUtf8.Text("HDR Enabled (First Launch)"); + + ImUtf8.Text("HDR Hook Overriden (Now)"); + ImUtf8.Text("HDR Hook Overriden (First Launch)"); + + ImUtf8.Text("HDR Detour Called"); + ImUtf8.Text("Penumbra Reload Count"); + } + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text($"{(dalamudConfig.GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool w) ? w.ToString() : "Unknown")}"); + ImUtf8.Text($"{renderTargetHdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"}"); + + ImUtf8.Text($"{config.HdrRenderTargets}"); + ImUtf8.Text($"{renderTargetHdrEnabler.FirstLaunchHdrState}"); + + ImUtf8.Text($"{HookOverrides.Instance.PostProcessing.RenderTargetManagerInitialize}"); + ImUtf8.Text($"{!renderTargetHdrEnabler.FirstLaunchHdrHookOverrideState}"); + + ImUtf8.Text($"{renderTargetHdrEnabler.HdrEnabledSuccess}"); + ImUtf8.Text($"{renderTargetHdrEnabler.PenumbraReloadCount}"); + } + } + + /// Draw information about render targets. + public unsafe void Draw() + { + if (!ImUtf8.CollapsingHeader("Render Targets"u8)) + return; + + DrawStatistics(); + ImUtf8.Dummy(0); + ImGui.Separator(); + ImUtf8.Dummy(0); + var report = renderTargetHdrEnabler.TextureReport; + if (report == null) + { + ImUtf8.Text("The RenderTargetManager report has not been gathered."u8); + ImUtf8.Text("Please restart the game with Debug Mode and Wait for Plugins on Startup enabled to fill this section."u8); + return; + } + + using var table = ImUtf8.Table("##RenderTargetTable"u8, 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + ImUtf8.TableSetupColumn("Offset"u8, ImGuiTableColumnFlags.WidthStretch, 0.15f); + ImUtf8.TableSetupColumn("Creation Order"u8, ImGuiTableColumnFlags.WidthStretch, 0.15f); + ImUtf8.TableSetupColumn("Original Texture Format"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImUtf8.TableSetupColumn("Current Texture Format"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImUtf8.TableSetupColumn("Comment"u8, ImGuiTableColumnFlags.WidthStretch, 0.3f); + ImGui.TableHeadersRow(); + + foreach (var record in report) + { + ImUtf8.DrawTableColumn($"0x{record.Offset:X}"); + ImUtf8.DrawTableColumn($"{record.CreationOrder}"); + ImUtf8.DrawTableColumn($"{record.OriginalTextureFormat}"); + ImGui.TableNextColumn(); + var texture = *(Texture**)((nint)RenderTargetManager.Instance() + + record.Offset); + if (texture != null) + { + using var color = Dalamud.Interface.Utility.Raii.ImRaii.PushColor(ImGuiCol.Text, ImGuiUtil.HalfBlendText(0xFF), + texture->TextureFormat != record.OriginalTextureFormat); + ImUtf8.Text($"{texture->TextureFormat}"); + } + + ImGui.TableNextColumn(); + var forcedConfig = RenderTargetHdrEnabler.GetForcedTextureConfig(record.CreationOrder); + if (forcedConfig.HasValue) + ImUtf8.Text(forcedConfig.Value.Comment); + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/ShapeInspector.cs b/Penumbra/UI/Tabs/Debug/ShapeInspector.cs new file mode 100644 index 00000000..4c3b43bf --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/ShapeInspector.cs @@ -0,0 +1,284 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using OtterGui.Extensions; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.UI.Tabs.Debug; + +public class ShapeInspector(ObjectManager objects, CollectionResolver resolver) : IUiService +{ + private int _objectIndex; + + public void Draw() + { + ImUtf8.InputScalar("Object Index"u8, ref _objectIndex); + var actor = objects[0]; + if (!actor.IsCharacter) + { + ImUtf8.Text("No valid character."u8); + return; + } + + var human = actor.Model; + if (!human.IsHuman) + { + ImUtf8.Text("No valid character."u8); + return; + } + + DrawCollectionShapeCache(actor); + DrawCharacterShapes(human); + DrawCollectionAttributeCache(actor); + DrawCharacterAttributes(human); + } + + private unsafe void DrawCollectionAttributeCache(Actor actor) + { + var data = resolver.IdentifyCollection(actor.AsObject, true); + using var treeNode1 = ImUtf8.TreeNode($"Collection Attribute Cache ({data.ModCollection})"); + if (!treeNode1.Success || !data.ModCollection.HasCache) + return; + + using var table = ImUtf8.Table("##aCache"u8, 2, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("Attribute"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("State"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + foreach (var (attribute, set) in data.ModCollection.MetaCache!.Atr.Data.OrderBy(a => a.Key)) + { + ImUtf8.DrawTableColumn(attribute.AsSpan); + DrawValues(attribute, set); + } + } + + private unsafe void DrawCollectionShapeCache(Actor actor) + { + var data = resolver.IdentifyCollection(actor.AsObject, true); + using var treeNode1 = ImUtf8.TreeNode($"Collection Shape Cache ({data.ModCollection})"); + if (!treeNode1.Success || !data.ModCollection.HasCache) + return; + + using var table = ImUtf8.Table("##sCache"u8, 3, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("Condition"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Shape"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("State"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + foreach (var condition in Enum.GetValues()) + { + foreach (var (shape, set) in data.ModCollection.MetaCache!.Shp.State(condition).OrderBy(shp => shp.Key)) + { + ImUtf8.DrawTableColumn(condition.ToString()); + ImUtf8.DrawTableColumn(shape.AsSpan); + DrawValues(shape, set); + } + } + } + + private static void DrawValues(in ShapeAttributeString shapeAttribute, ShapeAttributeHashSet set) + { + ImGui.TableNextColumn(); + + if (set.All is { } value) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value); + ImUtf8.Text("All, "u8); + ImGui.SameLine(0, 0); + } + + foreach (var slot in ShapeAttributeManager.UsedModels) + { + if (set[slot] is not { } value2) + continue; + + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value2); + ImUtf8.Text($"All {slot.ToName()}, "); + ImGui.SameLine(0, 0); + } + + foreach (var gr in ShapeAttributeHashSet.GenderRaceValues.Skip(1)) + { + if (set[gr] is { } value3) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value3); + ImUtf8.Text($"All {gr.ToName()}, "); + ImGui.SameLine(0, 0); + } + else + { + foreach (var slot in ShapeAttributeManager.UsedModels) + { + if (set[slot, gr] is not { } value4) + continue; + + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value4); + ImUtf8.Text($"All {gr.ToName()} {slot.ToName()}, "); + ImGui.SameLine(0, 0); + } + } + } + + foreach (var ((slot, id), flags) in set) + { + if ((flags & 3) is not 0) + { + var enabled = (flags & 1) is 1; + + if (set[slot, GenderRace.Unknown] != enabled) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !enabled); + ImUtf8.Text($"{slot.ToName()} {id.Id:D4}, "); + ImGui.SameLine(0, 0); + } + } + else + { + var currentIndex = BitOperations.TrailingZeroCount(flags) / 2; + var currentFlags = flags >> (2 * currentIndex); + while (currentIndex < ShapeAttributeHashSet.GenderRaceValues.Count) + { + var enabled = (currentFlags & 1) is 1; + var gr = ShapeAttributeHashSet.GenderRaceValues[currentIndex]; + if (set[slot, gr] != enabled) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !enabled); + ImUtf8.Text($"{gr.ToName()} {slot.ToName()} #{id.Id:D4}, "); + ImGui.SameLine(0, 0); + } + + currentFlags &= ~0x3u; + currentIndex += BitOperations.TrailingZeroCount(currentFlags) / 2; + currentFlags = flags >> (2 * currentIndex); + } + } + } + } + + private unsafe void DrawCharacterShapes(Model human) + { + using var treeNode2 = ImUtf8.TreeNode("Character Model Shapes"u8); + if (!treeNode2) + return; + + using var table = ImUtf8.Table("##shapes"u8, 7, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("#"u8, ImGuiTableColumnFlags.WidthFixed, 25 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Slot"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Address"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 14); + ImUtf8.TableSetupColumn("Mask"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 8); + ImUtf8.TableSetupColumn("ID"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 4); + ImUtf8.TableSetupColumn("Count"u8, ImGuiTableColumnFlags.WidthFixed, 30 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Shapes"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + + var disabledColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); + for (var i = 0; i < human.AsHuman->SlotCount; ++i) + { + ImUtf8.DrawTableColumn($"{(uint)i:D2}"); + ImUtf8.DrawTableColumn(((HumanSlot)i).ToName()); + + ImGui.TableNextColumn(); + var model = human.AsHuman->Models[i]; + Penumbra.Dynamis.DrawPointer((nint)model); + if (model is not null) + { + var mask = model->EnabledShapeKeyIndexMask; + ImUtf8.DrawTableColumn($"{mask:X8}"); + ImUtf8.DrawTableColumn($"{human.GetModelId((HumanSlot)i):D4}"); + ImUtf8.DrawTableColumn($"{model->ModelResourceHandle->Shapes.Count}"); + ImGui.TableNextColumn(); + foreach (var ((shape, flag), idx) in model->ModelResourceHandle->Shapes.WithIndex()) + { + var disabled = (mask & (1u << flag)) is 0; + using var color = ImRaii.PushColor(ImGuiCol.Text, disabledColor, disabled); + ImUtf8.Text(shape.AsSpan()); + ImGui.SameLine(0, 0); + ImUtf8.Text(", "u8); + if (idx % 8 < 7) + ImGui.SameLine(0, 0); + } + } + else + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + } + } + } + + private unsafe void DrawCharacterAttributes(Model human) + { + using var treeNode2 = ImUtf8.TreeNode("Character Model Attributes"u8); + if (!treeNode2) + return; + + using var table = ImUtf8.Table("##attributes"u8, 7, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("#"u8, ImGuiTableColumnFlags.WidthFixed, 25 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Slot"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Address"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 14); + ImUtf8.TableSetupColumn("Mask"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 8); + ImUtf8.TableSetupColumn("ID"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 4); + ImUtf8.TableSetupColumn("Count"u8, ImGuiTableColumnFlags.WidthFixed, 30 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Attributes"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + + var disabledColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); + for (var i = 0; i < human.AsHuman->SlotCount; ++i) + { + ImUtf8.DrawTableColumn($"{(uint)i:D2}"); + ImUtf8.DrawTableColumn(((HumanSlot)i).ToName()); + + ImGui.TableNextColumn(); + var model = human.AsHuman->Models[i]; + Penumbra.Dynamis.DrawPointer((nint)model); + if (model is not null) + { + var mask = model->EnabledAttributeIndexMask; + ImUtf8.DrawTableColumn($"{mask:X8}"); + ImUtf8.DrawTableColumn($"{human.GetModelId((HumanSlot)i):D4}"); + ImUtf8.DrawTableColumn($"{model->ModelResourceHandle->Attributes.Count}"); + ImGui.TableNextColumn(); + foreach (var ((attribute, flag), idx) in model->ModelResourceHandle->Attributes.WithIndex()) + { + var disabled = (mask & (1u << flag)) is 0; + using var color = ImRaii.PushColor(ImGuiCol.Text, disabledColor, disabled); + ImUtf8.Text(attribute.AsSpan()); + ImGui.SameLine(0, 0); + ImUtf8.Text(", "u8); + if (idx % 8 < 7) + ImGui.SameLine(0, 0); + } + } + else + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + } + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs b/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs index 08d51184..4244e455 100644 --- a/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs +++ b/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs @@ -1,6 +1,6 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface.DragDrop; using Dalamud.Interface.Utility.Raii; -using ImGuiNET; using Lumina.Data.Files; using OtterGui.Services; using OtterGui.Text; diff --git a/Penumbra/UI/Tabs/EffectiveTab.cs b/Penumbra/UI/Tabs/EffectiveTab.cs index ecf9a886..5691f821 100644 --- a/Penumbra/UI/Tabs/EffectiveTab.cs +++ b/Penumbra/UI/Tabs/EffectiveTab.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; -using ImGuiNET; using OtterGui; using OtterGui.Classes; using OtterGui.Raii; diff --git a/Penumbra/UI/Tabs/ModsTab.cs b/Penumbra/UI/Tabs/ModsTab.cs index 87338bdb..79dcbb9e 100644 --- a/Penumbra/UI/Tabs/ModsTab.cs +++ b/Penumbra/UI/Tabs/ModsTab.cs @@ -1,5 +1,5 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Game.ClientState.Objects; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using Penumbra.UI.Classes; @@ -53,14 +53,14 @@ public class ModsTab( { try { - selector.Draw(GetModSelectorSize(config)); + selector.Draw(); ImGui.SameLine(); + ImGui.SetCursorPosX(MathF.Round(ImGui.GetCursorPosX())); using var group = ImRaii.Group(); collectionHeader.Draw(false); using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - - using (var child = ImRaii.Child("##ModsTabMod", new Vector2(-1, config.HideRedrawBar ? 0 : -ImGui.GetFrameHeight()), + using (var child = ImRaii.Child("##ModsTabMod", new Vector2(ImGui.GetContentRegionAvail().X, config.HideRedrawBar ? 0 : -ImGui.GetFrameHeight()), true, ImGuiWindowFlags.HorizontalScrollbar)) { style.Pop(); @@ -77,28 +77,15 @@ public class ModsTab( { Penumbra.Log.Error($"Exception thrown during ModPanel Render:\n{e}"); Penumbra.Log.Error($"{modManager.Count} Mods\n" - + $"{_activeCollections.Current.AnonymizedName} Current Collection\n" + + $"{_activeCollections.Current.Identity.AnonymizedName} Current Collection\n" + $"{_activeCollections.Current.Settings.Count} Settings\n" + $"{selector.SortMode.Name} Sort Mode\n" + $"{selector.SelectedLeaf?.Name ?? "NULL"} Selected Leaf\n" + $"{selector.Selected?.Name ?? "NULL"} Selected Mod\n" - + $"{string.Join(", ", _activeCollections.Current.DirectlyInheritsFrom.Select(c => c.AnonymizedName))} Inheritances\n"); + + $"{string.Join(", ", _activeCollections.Current.Inheritance.DirectlyInheritsFrom.Select(c => c.Identity.AnonymizedName))} Inheritances\n"); } } - /// Get the correct size for the mod selector based on current config. - public static float GetModSelectorSize(Configuration config) - { - var absoluteSize = Math.Clamp(config.ModSelectorAbsoluteSize, Configuration.Constants.MinAbsoluteSize, - Math.Min(Configuration.Constants.MaxAbsoluteSize, ImGui.GetContentRegionAvail().X - 100)); - var relativeSize = config.ScaleModSelector - ? Math.Clamp(config.ModSelectorScaledSize, Configuration.Constants.MinScaledSize, Configuration.Constants.MaxScaledSize) - : 0; - return !config.ScaleModSelector - ? absoluteSize - : Math.Max(absoluteSize, relativeSize * ImGui.GetContentRegionAvail().X / 100); - } - private void DrawRedrawLine() { if (config.HideRedrawBar) diff --git a/Penumbra/UI/Tabs/ResourceTab.cs b/Penumbra/UI/Tabs/ResourceTab.cs index c54e3433..593adde1 100644 --- a/Penumbra/UI/Tabs/ResourceTab.cs +++ b/Penumbra/UI/Tabs/ResourceTab.cs @@ -1,9 +1,9 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Game; using FFXIVClientStructs.FFXIV.Client.System.Resource; using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; using FFXIVClientStructs.Interop; using FFXIVClientStructs.STD; -using ImGuiNET; using OtterGui; using OtterGui.Raii; using OtterGui.Services; diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 9d8ea21c..86c01cb2 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -1,17 +1,21 @@ +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Components; using Dalamud.Interface.Utility; using Dalamud.Plugin; using Dalamud.Plugin.Services; using Dalamud.Utility; -using ImGuiNET; using OtterGui; using OtterGui.Compression; using OtterGui.Custom; using OtterGui.Raii; using OtterGui.Services; +using OtterGui.Text; using OtterGui.Widgets; using Penumbra.Api; +using Penumbra.Collections; +using Penumbra.Interop; +using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Services; using Penumbra.Mods.Manager; using Penumbra.Services; @@ -33,6 +37,7 @@ public class SettingsTab : ITab, IUiService private readonly Penumbra _penumbra; private readonly FileDialogService _fileDialog; private readonly ModManager _modManager; + private readonly FileWatcher _fileWatcher; private readonly ModExportManager _modExportManager; private readonly ModFileSystemSelector _selector; private readonly CharacterUtility _characterUtility; @@ -46,18 +51,27 @@ public class SettingsTab : ITab, IUiService private readonly PredefinedTagManager _predefinedTagManager; private readonly CrashHandlerService _crashService; private readonly MigrationSectionDrawer _migrationDrawer; + private readonly CollectionAutoSelector _autoSelector; + private readonly CleanupService _cleanupService; + private readonly AttributeHook _attributeHook; + private readonly PcpService _pcpService; private int _minimumX = int.MaxValue; private int _minimumY = int.MaxValue; private readonly TagButtons _sharedTags = new(); + private string _lastCloudSyncTestedPath = string.Empty; + private bool _lastCloudSyncTestResult = false; + public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, - CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, HttpApi httpApi, + CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, + FileWatcher fileWatcher, HttpApi httpApi, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, - MigrationSectionDrawer migrationDrawer) + MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, + AttributeHook attributeHook, PcpService pcpService) { _pluginInterface = pluginInterface; _config = config; @@ -70,6 +84,7 @@ public class SettingsTab : ITab, IUiService _characterUtility = characterUtility; _residentResources = residentResources; _modExportManager = modExportManager; + _fileWatcher = fileWatcher; _httpApi = httpApi; _dalamudSubstitutionProvider = dalamudSubstitutionProvider; _compactor = compactor; @@ -80,6 +95,10 @@ public class SettingsTab : ITab, IUiService _predefinedTagManager = predefinedTagConfig; _crashService = crashService; _migrationDrawer = migrationDrawer; + _autoSelector = autoSelector; + _cleanupService = cleanupService; + _attributeHook = attributeHook; + _pcpService = pcpService; } public void DrawHeader() @@ -103,6 +122,7 @@ public class SettingsTab : ITab, IUiService DrawRootFolder(); DrawDirectoryButtons(); ImGui.NewLine(); + ImGui.NewLine(); DrawGeneralSettings(); _migrationDrawer.Draw(); @@ -195,6 +215,15 @@ public class SettingsTab : ITab, IUiService if (IsSubPathOf(gameDir, newName)) return ("Path is not allowed to be inside your game folder.", false); + if (_lastCloudSyncTestedPath != newName) + { + _lastCloudSyncTestResult = CloudApi.IsCloudSynced(newName); + _lastCloudSyncTestedPath = newName; + } + + if (_lastCloudSyncTestResult) + return ("Path is not allowed to be cloud-synced.", false); + return selected ? ($"Press Enter or Click Here to Save (Current Directory: {old})", true) : ($"Click Here to Save (Current Directory: {old})", true); @@ -421,6 +450,9 @@ public class SettingsTab : ITab, IUiService /// Draw all settings that do not fit into other categories. private void DrawMiscSettings() { + Checkbox("Automatically Select Character-Associated Collection", + "On every login, automatically select the collection associated with the current character as the current collection for editing.", + _config.AutoSelectCollection, _autoSelector.SetAutomaticSelection); Checkbox("Print Chat Command Success Messages to Chat", "Chat Commands usually print messages on failure but also on success to confirm your action. You can disable this here.", _config.PrintSuccessfulCommandsToChat, v => _config.PrintSuccessfulCommandsToChat = v); @@ -436,6 +468,15 @@ public class SettingsTab : ITab, IUiService _config.Ephemeral.Save(); } }); + + ChangedItemModeExtensions.DrawCombo("##ChangedItemMode"u8, _config.ChangedItemDisplay, UiHelpers.InputTextWidth.X, v => + { + _config.ChangedItemDisplay = v; + _config.Save(); + }); + ImUtf8.LabeledHelpMarker("Mod Changed Item Display"u8, + "Configure how to display the changed items of a single mod in the mods info panel."u8); + Checkbox("Omit Machinist Offhands in Changed Items", "Omits all Aetherotransformers (machinist offhands) in the changed items tabs because any change on them changes all of them at the moment.\n\n" + "Changing this triggers a rediscovery of your mods so all changed items can be updated.", @@ -484,74 +525,25 @@ public class SettingsTab : ITab, IUiService { var sortMode = _config.SortMode; ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); - using (var combo = ImRaii.Combo("##sortMode", sortMode.Name)) + using (var combo = ImUtf8.Combo("##sortMode", sortMode.Name)) { if (combo) foreach (var val in Configuration.Constants.ValidSortModes) { - if (ImGui.Selectable(val.Name, val.GetType() == sortMode.GetType()) && val.GetType() != sortMode.GetType()) + if (ImUtf8.Selectable(val.Name, val.GetType() == sortMode.GetType()) && val.GetType() != sortMode.GetType()) { _config.SortMode = val; _selector.SetFilterDirty(); _config.Save(); } - ImGuiUtil.HoverTooltip(val.Description); + ImUtf8.HoverTooltip(val.Description); } } ImGuiUtil.LabeledHelpMarker("Sort Mode", "Choose the sort mode for the mod selector in the mods tab."); } - private float _absoluteSelectorSize = float.NaN; - - /// Draw a selector for the absolute size of the mod selector in pixels. - private void DrawAbsoluteSizeSelector() - { - if (float.IsNaN(_absoluteSelectorSize)) - _absoluteSelectorSize = _config.ModSelectorAbsoluteSize; - - if (ImGuiUtil.DragFloat("##absoluteSize", ref _absoluteSelectorSize, UiHelpers.InputTextWidth.X, 1, - Configuration.Constants.MinAbsoluteSize, Configuration.Constants.MaxAbsoluteSize, "%.0f") - && _absoluteSelectorSize != _config.ModSelectorAbsoluteSize) - { - _config.ModSelectorAbsoluteSize = _absoluteSelectorSize; - _config.Save(); - } - - ImGui.SameLine(); - ImGuiUtil.LabeledHelpMarker("Mod Selector Absolute Size", - "The minimal absolute size of the mod selector in the mod tab in pixels."); - } - - private int _relativeSelectorSize = int.MaxValue; - - /// Draw a selector for the relative size of the mod selector as a percentage and a toggle to enable relative sizing. - private void DrawRelativeSizeSelector() - { - var scaleModSelector = _config.ScaleModSelector; - if (ImGui.Checkbox("Scale Mod Selector With Window Size", ref scaleModSelector)) - { - _config.ScaleModSelector = scaleModSelector; - _config.Save(); - } - - ImGui.SameLine(); - if (_relativeSelectorSize == int.MaxValue) - _relativeSelectorSize = _config.ModSelectorScaledSize; - if (ImGuiUtil.DragInt("##relativeSize", ref _relativeSelectorSize, UiHelpers.InputTextWidth.X - ImGui.GetCursorPosX(), 0.1f, - Configuration.Constants.MinScaledSize, Configuration.Constants.MaxScaledSize, "%i%%") - && _relativeSelectorSize != _config.ModSelectorScaledSize) - { - _config.ModSelectorScaledSize = _relativeSelectorSize; - _config.Save(); - } - - ImGui.SameLine(); - ImGuiUtil.LabeledHelpMarker("Mod Selector Relative Size", - "Instead of keeping the mod-selector in the Installed Mods tab a fixed width, this will let it scale with the total size of the Penumbra window."); - } - private void DrawRenameSettings() { ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); @@ -585,8 +577,6 @@ public class SettingsTab : ITab, IUiService private void DrawModSelectorSettings() { DrawFolderSortType(); - DrawAbsoluteSizeSelector(); - DrawRelativeSizeSelector(); DrawRenameSettings(); Checkbox("Open Folders by Default", "Whether to start with all folders collapsed or expanded in the mod selector.", _config.OpenFoldersByDefault, v => @@ -603,21 +593,70 @@ public class SettingsTab : ITab, IUiService _config.DeleteModModifier = v; _config.Save(); }); + Widget.DoubleModifierSelector("Incognito Modifier", + "A modifier you need to hold while clicking the Incognito or Temporary Settings Mode button for it to take effect.", + UiHelpers.InputTextWidth.X, + _config.IncognitoModifier, + v => + { + _config.IncognitoModifier = v; + _config.Save(); + }); } /// Draw all settings pertaining to import and export of mods. private void DrawModHandlingSettings() { + Checkbox("Use Temporary Settings Per Default", + "When you make any changes to your collection, apply them as temporary changes first and require a click to 'turn permanent' if you want to keep them.", + _config.DefaultTemporaryMode, v => _config.DefaultTemporaryMode = v); Checkbox("Replace Non-Standard Symbols On Import", "Replace all non-ASCII symbols in mod and option names with underscores when importing mods.", _config.ReplaceNonAsciiOnImport, v => _config.ReplaceNonAsciiOnImport = v); Checkbox("Always Open Import at Default Directory", "Open the import window at the location specified here every time, forgetting your previous path.", _config.AlwaysOpenDefaultImport, v => _config.AlwaysOpenDefaultImport = v); + Checkbox("Handle PCP Files", + "When encountering specific mods, usually but not necessarily denoted by a .pcp file ending, Penumbra will automatically try to create an associated collection and assign it to a specific character for this mod package. This can turn this behaviour off if unwanted.", + !_config.PcpSettings.DisableHandling, v => _config.PcpSettings.DisableHandling = !v); + + var active = _config.DeleteModModifier.IsActive(); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Delete all PCP Mods"u8, "Deletes all mods tagged with 'PCP' from the mod list."u8, disabled: !active)) + _pcpService.CleanPcpMods(); + if (!active) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8, + disabled: !active)) + _pcpService.CleanPcpCollections(); + if (!active) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); + + Checkbox("Allow Other Plugins Access to PCP Handling", + "When creating or importing PCP files, other plugins can add and interpret their own data to the character.json file.", + _config.PcpSettings.AllowIpc, v => _config.PcpSettings.AllowIpc = v); + + Checkbox("Create PCP Collections", + "When importing PCP files, create the associated collection.", + _config.PcpSettings.CreateCollection, v => _config.PcpSettings.CreateCollection = v); + + Checkbox("Assign PCP Collections", + "When importing PCP files and creating the associated collection, assign it to the associated character.", + _config.PcpSettings.AssignCollection, v => _config.PcpSettings.AssignCollection = v); DrawDefaultModImportPath(); DrawDefaultModAuthor(); DrawDefaultModImportFolder(); + DrawPcpFolder(); DrawDefaultModExportPath(); + Checkbox("Enable Directory Watcher", + "Enables a File Watcher that automatically listens for Mod files that enter a specified directory, causing Penumbra to open a popup to import these mods.", + _config.EnableDirectoryWatch, _fileWatcher.Toggle); + Checkbox("Enable Fully Automatic Import", + "Uses the File Watcher in order to skip the query popup and automatically import any new mods.", + _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); + DrawFileWatcherPath(); } @@ -697,6 +736,46 @@ public class SettingsTab : ITab, IUiService + "Keep this empty to use the root directory."); } + private string? _tempWatchDirectory; + + /// Draw input for the Automatic Mod import path. + private void DrawFileWatcherPath() + { + var tmp = _tempWatchDirectory ?? _config.WatchDirectory; + var spacing = new Vector2(UiHelpers.ScaleX3); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3); + if (ImGui.InputText("##fileWatchPath", ref tmp, 256)) + _tempWatchDirectory = tmp; + + if (ImGui.IsItemDeactivated() && _tempWatchDirectory is not null) + { + if (ImGui.IsItemDeactivatedAfterEdit()) + _fileWatcher.UpdateDirectory(_tempWatchDirectory); + _tempWatchDirectory = null; + } + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##fileWatch", UiHelpers.IconButtonSize, + "Select a directory via dialog.", false, true)) + { + var startDir = _config.WatchDirectory.Length > 0 && Directory.Exists(_config.WatchDirectory) + ? _config.WatchDirectory + : Directory.Exists(_config.ModDirectory) + ? _config.ModDirectory + : null; + _fileDialog.OpenFolderPicker("Choose Automatic Import Directory", (b, s) => + { + if (b) + _fileWatcher.UpdateDirectory(s); + }, startDir, false); + } + + style.Pop(); + ImGuiUtil.LabeledHelpMarker("Automatic Import Director", + "Choose the Directory the File Watcher listens to."); + } + /// Draw input for the default name to input as author into newly generated mods. private void DrawDefaultModAuthor() { @@ -726,6 +805,21 @@ public class SettingsTab : ITab, IUiService "Set the default Penumbra mod folder to place newly imported mods into.\nLeave blank to import into Root."); } + /// Draw input for the default folder to sort put newly imported mods into. + private void DrawPcpFolder() + { + var tmp = _config.PcpSettings.FolderName; + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImUtf8.InputText("##pcpFolder"u8, ref tmp)) + _config.PcpSettings.FolderName = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _config.Save(); + + ImGuiUtil.LabeledHelpMarker("Default PCP Organizational Folder", + "The folder any penumbra character packs are moved to on import.\nLeave blank to import into Root."); + } + /// Draw all settings pertaining to advanced editing of mods. private void DrawModEditorSettings() @@ -766,6 +860,7 @@ public class SettingsTab : ITab, IUiService DrawCrashHandler(); DrawMinimumDimensionConfig(); + DrawHdrRenderTargets(); Checkbox("Auto Deduplicate on Import", "Automatically deduplicate mod files on import. This will make mod file sizes smaller, but deletes (binary identical) files.", _config.AutoDeduplicateOnImport, v => _config.AutoDeduplicateOnImport = v); @@ -777,11 +872,17 @@ public class SettingsTab : ITab, IUiService "Normally, metadata changes that equal their default values, which are sometimes exported by TexTools, are discarded. " + "Toggle this to keep them, for example if an option in a mod is supposed to disable a metadata change from a prior option.", _config.KeepDefaultMetaChanges, v => _config.KeepDefaultMetaChanges = v); + Checkbox("Enable Custom Shape and Attribute Support", + "Penumbra will allow for custom shape keys and attributes for modded models to be considered and combined.", + _config.EnableCustomShapes, _attributeHook.SetState); DrawWaitForPluginsReflection(); DrawEnableHttpApiBox(); DrawEnableDebugModeBox(); + ImGui.Separator(); DrawReloadResourceButton(); DrawReloadFontsButton(); + ImGui.Separator(); + DrawCleanupButtons(); ImGui.NewLine(); } @@ -816,13 +917,15 @@ public class SettingsTab : ITab, IUiService if (ImGuiUtil.DrawDisabledButton("Compress Existing Files", Vector2.Zero, "Try to compress all files in your root directory. This will take a while.", _compactor.MassCompactRunning || !_modManager.Valid)) - _compactor.StartMassCompact(_modManager.BasePath.EnumerateFiles("*.*", SearchOption.AllDirectories), CompressionAlgorithm.Xpress8K, true); + _compactor.StartMassCompact(_modManager.BasePath.EnumerateFiles("*.*", SearchOption.AllDirectories), CompressionAlgorithm.Xpress8K, + true); ImGui.SameLine(); if (ImGuiUtil.DrawDisabledButton("Decompress Existing Files", Vector2.Zero, "Try to decompress all files in your root directory. This will take a while.", _compactor.MassCompactRunning || !_modManager.Valid)) - _compactor.StartMassCompact(_modManager.BasePath.EnumerateFiles("*.*", SearchOption.AllDirectories), CompressionAlgorithm.None, true); + _compactor.StartMassCompact(_modManager.BasePath.EnumerateFiles("*.*", SearchOption.AllDirectories), CompressionAlgorithm.None, + true); if (_compactor.MassCompactRunning) { @@ -893,6 +996,33 @@ public class SettingsTab : ITab, IUiService _config.Save(); } + private void DrawHdrRenderTargets() + { + ImGui.SetNextItemWidth(ImUtf8.CalcTextSize("M"u8).X * 5.0f + ImGui.GetFrameHeight()); + using (var combo = ImUtf8.Combo("##hdrRenderTarget"u8, _config.HdrRenderTargets ? "HDR"u8 : "SDR"u8)) + { + if (combo) + { + if (ImUtf8.Selectable("HDR"u8, _config.HdrRenderTargets) && !_config.HdrRenderTargets) + { + _config.HdrRenderTargets = true; + _config.Save(); + } + + if (ImUtf8.Selectable("SDR"u8, !_config.HdrRenderTargets) && _config.HdrRenderTargets) + { + _config.HdrRenderTargets = false; + _config.Save(); + } + } + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("Diffuse Dynamic Range"u8, + "Set the dynamic range that can be used for diffuse colors in materials without causing visual artifacts.\n"u8 + + "Changing this setting requires a game restart. It also only works if Wait for Plugins on Startup is enabled."u8); + } + /// Draw a checkbox for the HTTP API that creates and destroys the web server when toggled. private void DrawEnableHttpApiBox() { @@ -944,6 +1074,44 @@ public class SettingsTab : ITab, IUiService _fontReloader.Reload(); } + private void DrawCleanupButtons() + { + var enabled = _config.DeleteModModifier.IsActive(); + if (_cleanupService.Progress is not 0.0 and not 1.0) + { + ImUtf8.ProgressBar((float)_cleanupService.Progress, new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetFrameHeight()), + $"{_cleanupService.Progress * 100}%"); + ImGui.SameLine(); + if (ImUtf8.Button("Cancel##FileCleanup"u8)) + _cleanupService.Cancel(); + } + else + { + ImGui.NewLine(); + } + + if (ImUtf8.ButtonEx("Clear Unused Local Mod Data Files"u8, + "Delete all local mod data files that do not correspond to currently installed mods."u8, default, + !enabled || _cleanupService.IsRunning)) + _cleanupService.CleanUnusedLocalData(); + if (!enabled) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to delete files."); + + if (ImUtf8.ButtonEx("Clear Backup Files"u8, + "Delete all backups of .json configuration files in your configuration folder and all backups of mod group files in your mod directory."u8, + default, !enabled || _cleanupService.IsRunning)) + _cleanupService.CleanBackupFiles(); + if (!enabled) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to delete files."); + + if (ImUtf8.ButtonEx("Clear All Unused Settings"u8, + "Remove all mod settings in all of your collections that do not correspond to currently installed mods."u8, default, + !enabled || _cleanupService.IsRunning)) + _cleanupService.CleanupAllUnusedSettings(); + if (!enabled) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to remove settings."); + } + /// Draw a checkbox that toggles the dalamud setting to wait for plugins on open. private void DrawWaitForPluginsReflection() { @@ -994,6 +1162,9 @@ public class SettingsTab : ITab, IUiService ImGui.SetCursorPos(new Vector2(xPos, 4 * ImGui.GetFrameHeightWithSpacing())); if (ImGui.Button("Show Changelogs", new Vector2(width, 0))) _penumbra.ForceChangelogOpen(); + + ImGui.SetCursorPos(new Vector2(xPos, 5 * ImGui.GetFrameHeightWithSpacing())); + CustomGui.DrawKofiPatreonButton(Penumbra.Messager, new Vector2(width, 0)); } private void DrawPredefinedTagsSection() diff --git a/Penumbra/UI/TutorialService.cs b/Penumbra/UI/TutorialService.cs index 7d2a0d2a..69f2b616 100644 --- a/Penumbra/UI/TutorialService.cs +++ b/Penumbra/UI/TutorialService.cs @@ -83,14 +83,14 @@ public class TutorialService : IUiService + "Go here after setting up your root folder to continue the tutorial!") .Register("Initial Setup, Step 4: Managing Collections", "On the left, we have the collection selector. Here, we can create new collections - either empty ones or by duplicating existing ones - and delete any collections not needed anymore.\n" - + $"There will always be one collection called {ModCollection.DefaultCollectionName} that can not be deleted.") + + $"There will always be one collection called {ModCollectionIdentity.DefaultCollectionName} that can not be deleted.") .Register($"Initial Setup, Step 5: {SelectedCollection}", $"The {SelectedCollection} is the one we highlighted in the selector. It is the collection we are currently looking at and editing.\nAny changes we make in our mod settings later in the next tab will edit this collection.\n" - + $"We should already have the collection named {ModCollection.DefaultCollectionName} selected, and for our simple setup, we do not need to do anything here.\n\n") + + $"We should already have the collection named {ModCollectionIdentity.DefaultCollectionName} selected, and for our simple setup, we do not need to do anything here.\n\n") .Register("Initial Setup, Step 6: Simple Assignments", "Aside from being a collection of settings, we can also assign collections to different functions. This is used to make different mods apply to different characters.\n" + "The Simple Assignments panel shows you the possible assignments that are enough for most people along with descriptions.\n" - + $"If you are just starting, you can see that the {ModCollection.DefaultCollectionName} is currently assigned to {CollectionType.Default.ToName()} and {CollectionType.Interface.ToName()}.\n" + + $"If you are just starting, you can see that the {ModCollectionIdentity.DefaultCollectionName} is currently assigned to {CollectionType.Default.ToName()} and {CollectionType.Interface.ToName()}.\n" + "You can also assign 'Use No Mods' instead of a collection by clicking on the function buttons.") .Register("Individual Assignments", "In the Individual Assignments panel, you can manually create assignments for very specific characters or monsters, not just yourself or ones you can currently target.") diff --git a/Penumbra/UI/UiHelpers.cs b/Penumbra/UI/UiHelpers.cs index deba7023..9fe90ee8 100644 --- a/Penumbra/UI/UiHelpers.cs +++ b/Penumbra/UI/UiHelpers.cs @@ -1,6 +1,6 @@ using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.Utility; -using ImGuiNET; +using Dalamud.Bindings.ImGui; using OtterGui; using OtterGui.Classes; using OtterGui.Raii; @@ -13,11 +13,11 @@ public static class UiHelpers { /// Draw text given by a ByteString. public static unsafe void Text(ByteString s) - => ImGuiNative.igTextUnformatted(s.Path, s.Path + s.Length); + => ImGuiNative.TextUnformatted(s.Path, s.Path + s.Length); /// Draw text given by a byte pointer and length. public static unsafe void Text(byte* s, int length) - => ImGuiNative.igTextUnformatted(s, s + length); + => ImGuiNative.TextUnformatted(s, s + length); /// Draw text given by a byte span. public static unsafe void Text(ReadOnlySpan s) @@ -36,7 +36,7 @@ public static class UiHelpers public static unsafe bool Selectable(ByteString s, bool selected) { var tmp = (byte)(selected ? 1 : 0); - return ImGuiNative.igSelectable_Bool(s.Path, tmp, ImGuiSelectableFlags.None, Vector2.Zero) != 0; + return ImGuiNative.Selectable(s.Path, tmp, ImGuiSelectableFlags.None, Vector2.Zero) != 0; } /// @@ -45,8 +45,8 @@ public static class UiHelpers /// public static unsafe void CopyOnClickSelectable(ByteString text) { - if (ImGuiNative.igSelectable_Bool(text.Path, 0, ImGuiSelectableFlags.None, Vector2.Zero) != 0) - ImGuiNative.igSetClipboardText(text.Path); + if (ImGuiNative.Selectable(text.Path, 0, ImGuiSelectableFlags.None, Vector2.Zero) != 0) + ImGuiNative.SetClipboardText(text.Path); if (ImGui.IsItemHovered()) ImGui.SetTooltip("Click to copy to clipboard."); diff --git a/Penumbra/Util/IdentifierExtensions.cs b/Penumbra/Util/IdentifierExtensions.cs index 5bd3f77c..f744e940 100644 --- a/Penumbra/Util/IdentifierExtensions.cs +++ b/Penumbra/Util/IdentifierExtensions.cs @@ -10,7 +10,7 @@ namespace Penumbra.Util; public static class IdentifierExtensions { public static void AddChangedItems(this ObjectIdentification identifier, IModDataContainer container, - IDictionary changedItems) + IDictionary changedItems) { foreach (var gamePath in container.Files.Keys.Concat(container.FileSwaps.Keys)) identifier.Identify(changedItems, gamePath.ToString()); @@ -19,7 +19,7 @@ public static class IdentifierExtensions manip.AddChangedItems(identifier, changedItems); } - public static void RemoveMachinistOffhands(this SortedList changedItems) + public static void RemoveMachinistOffhands(this SortedList changedItems) { for (var i = 0; i < changedItems.Count; i++) { @@ -31,7 +31,7 @@ public static class IdentifierExtensions } } - public static void RemoveMachinistOffhands(this SortedList, IIdentifiedObjectData?)> changedItems) + public static void RemoveMachinistOffhands(this SortedList, IIdentifiedObjectData)> changedItems) { for (var i = 0; i < changedItems.Count; i++) { diff --git a/Penumbra/lib/OtterTex.dll b/Penumbra/lib/OtterTex.dll index 29912e62..c137aee1 100644 Binary files a/Penumbra/lib/OtterTex.dll and b/Penumbra/lib/OtterTex.dll differ diff --git a/Penumbra/packages.lock.json b/Penumbra/packages.lock.json index 5b868212..7499bffa 100644 --- a/Penumbra/packages.lock.json +++ b/Penumbra/packages.lock.json @@ -1,7 +1,13 @@ { "version": 1, "dependencies": { - "net8.0-windows7.0": { + "net9.0-windows7.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.25, )", + "resolved": "1.2.25", + "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" + }, "EmbedIO": { "type": "Direct", "requested": "[3.5.2, )", @@ -13,67 +19,75 @@ }, "PeNet": { "type": "Direct", - "requested": "[4.0.5, )", - "resolved": "4.0.5", - "contentHash": "/OUfRnMG8STVuK8kTpdfm+WQGTDoUiJnI845kFw4QrDmv2gYourmSnH84pqVjHT1YHBSuRfCzfioIpHGjFJrGA==", + "requested": "[5.1.0, )", + "resolved": "5.1.0", + "contentHash": "XSd1PUwWo5uI8iqVHk7Mm02RT1bjndtAYsaRwLmdYZoHOAmb4ohkvRcZiqxJ7iLfBfdiwm+PHKQIMqDmOavBtw==", "dependencies": { "PeNet.Asn1": "2.0.1", - "System.Security.Cryptography.Pkcs": "8.0.0" + "System.Security.Cryptography.Pkcs": "8.0.1" } }, "SharpCompress": { "type": "Direct", - "requested": "[0.37.2, )", - "resolved": "0.37.2", - "contentHash": "cFBpTct57aubLQXkdqMmgP8GGTFRh7fnRWP53lgE/EYUpDZJ27SSvTkdjB4OYQRZ20SJFpzczUquKLbt/9xkhw==", + "requested": "[0.40.0, )", + "resolved": "0.40.0", + "contentHash": "yP/aFX1jqGikVF7u2f05VEaWN4aCaKNLxSas82UgA2GGVECxq/BcqZx3STHCJ78qilo1azEOk1XpBglIuGMb7w==", "dependencies": { - "ZstdSharp.Port": "0.8.0" + "System.Buffers": "4.6.0", + "ZstdSharp.Port": "0.8.5" } }, "SharpGLTF.Core": { "type": "Direct", - "requested": "[1.0.1, )", - "resolved": "1.0.1", - "contentHash": "ykeV1oNHcJrEJE7s0pGAsf/nYGYY7wqF9nxCMxJUjp/WdW+UUgR1cGdbAa2lVZPkiXEwLzWenZ5wPz7yS0Gj9w==" + "requested": "[1.0.5, )", + "resolved": "1.0.5", + "contentHash": "HNHKPqaHXm7R1nlXZ764K5UI02IeDOQ5DQKLjwYUVNTsSW27jJpw+wLGQx6ZFoiFYqUlyZjmsu+WfEak2GmJAg==" }, "SharpGLTF.Toolkit": { "type": "Direct", - "requested": "[1.0.1, )", - "resolved": "1.0.1", - "contentHash": "LYBjHdHW5Z8R1oT1iI04si3559tWdZ3jTdHfDEu0jqhuyU8w3oJRLFUoDfVeCOI5zWXlVQPtlpjhH9XTfFFAcA==", + "requested": "[1.0.5, )", + "resolved": "1.0.5", + "contentHash": "piQKk7PH2pSWQSQmCSd8cYPaDtAy/ppAD+Mrh2RUhhHI8awl81HqqLyAauwQhJwea3LNaiJ6f4ehZuOGk89TlA==", "dependencies": { - "SharpGLTF.Runtime": "1.0.1" + "SharpGLTF.Runtime": "1.0.5" } }, "SixLabors.ImageSharp": { "type": "Direct", - "requested": "[3.1.5, )", - "resolved": "3.1.5", - "contentHash": "lNtlq7dSI/QEbYey+A0xn48z5w4XHSffF8222cC4F4YwTXfEImuiBavQcWjr49LThT/pRmtWJRcqA/PlL+eJ6g==" + "requested": "[3.1.11, )", + "resolved": "3.1.11", + "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" }, - "System.Formats.Asn1": { - "type": "Direct", - "requested": "[8.0.1, )", - "resolved": "8.0.1", - "contentHash": "XqKba7Mm/koKSjKMfW82olQdmfbI5yqeoLV/tidRp7fbh5rmHAQ5raDI/7SU0swTzv+jgqtUGkzmFxuUg0it1A==" + "FlatSharp.Compiler": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "MU6808xvdbWJ3Ev+5PKalqQuzvVbn1DzzQH8txRDHGFUNDvHjd+ejqpvnYc9BSJ8Qp8VjkkpJD8OzRYilbPp3A==" + }, + "FlatSharp.Runtime": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "Bm8+WqzEsWNpxqrD5x4x+zQ8dyINlToCreM5FI2oNSfUVc9U9ZB+qztX/jd8rlJb3r0vBSlPwVLpw0xBtPa3Vw==", + "dependencies": { + "System.Memory": "4.5.5" + } }, "JetBrains.Annotations": { "type": "Transitive", - "resolved": "2024.2.0", - "contentHash": "GNnqCFW/163p1fOehKx0CnAqjmpPrUSqrgfHM6qca+P+RN39C9rhlfZHQpJhxmQG/dkOYe/b3Z0P8b6Kv5m1qw==" + "resolved": "2024.3.0", + "contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug==" }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "resolved": "9.0.2", + "contentHash": "ZffbJrskOZ40JTzcTyKwFHS5eACSWp2bUQBBApIgGV+es8RaTD4OxUG7XxFr3RIPLXtYQ1jQzF2DjKB5fZn7Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" + "resolved": "9.0.2", + "contentHash": "MNe7GSTBf3jQx5vYrXF0NZvn6l7hUKF6J54ENfAgCO8y6xjN1XUmKKWG464LP2ye6QqDiA1dkaWEZBYnhoZzjg==" }, "PeNet.Asn1": { "type": "Transitive", @@ -82,19 +96,26 @@ }, "SharpGLTF.Runtime": { "type": "Transitive", - "resolved": "1.0.1", - "contentHash": "KsgEBKLfsEnu2IPeKaWp4Ih97+kby17IohrAB6Ev8gET18iS80nKMW/APytQWpenMmcWU06utInpANqyrwRlDg==", + "resolved": "1.0.5", + "contentHash": "EVP32k4LqERxSVICiupT8xQvhHSHJCiXajBjNpqdfdajtREHayuVhH0Jmk6uSjTLX8/IIH9XfT34sw3TwvCziw==", "dependencies": { - "SharpGLTF.Core": "1.0.1" + "SharpGLTF.Core": "1.0.5" } }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.0", + "contentHash": "lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA==" + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "ULmp3xoOwNYjOYp4JZ2NK/6NdTgiN1GQXzVVN1njQ7LOZ0d0B9vyMnhyqbIi9Qw4JXj1JgCsitkTShboHRx7Eg==", - "dependencies": { - "System.Formats.Asn1": "8.0.0" - } + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" }, "System.ValueTuple": { "type": "Transitive", @@ -111,14 +132,14 @@ }, "ZstdSharp.Port": { "type": "Transitive", - "resolved": "0.8.0", - "contentHash": "Z62eNBIu8E8YtbqlMy57tK3dV1+m2b9NhPeaYovB5exmLKvrGCqOhJTzrEUH5VyUWU6vwX3c1XHJGhW5HVs8dA==" + "resolved": "0.8.5", + "contentHash": "TR4j17WeVSEb3ncgL2NqlXEqcy04I+Kk9CaebNDplUeL8XOgjkZ7fP4Wg4grBdPLIqsV86p2QaXTkZoRMVOsew==" }, "ottergui": { "type": "Project", "dependencies": { - "JetBrains.Annotations": "[2024.2.0, )", - "Microsoft.Extensions.DependencyInjection": "[8.0.0, )" + "JetBrains.Annotations": "[2024.3.0, )", + "Microsoft.Extensions.DependencyInjection": "[9.0.2, )" } }, "penumbra.api": { @@ -130,9 +151,11 @@ "penumbra.gamedata": { "type": "Project", "dependencies": { + "FlatSharp.Compiler": "[7.9.0, )", + "FlatSharp.Runtime": "[7.9.0, )", "OtterGui": "[1.0.0, )", - "Penumbra.Api": "[5.3.0, )", - "Penumbra.String": "[1.0.4, )" + "Penumbra.Api": "[5.10.0, )", + "Penumbra.String": "[1.0.6, )" } }, "penumbra.string": { diff --git a/repo.json b/repo.json index f5a8e2f1..7ddffd7c 100644 --- a/repo.json +++ b/repo.json @@ -1,16 +1,16 @@ [ { - "Author": "Ottermandias, Adam, Wintermute", + "Author": "Ottermandias, Nylfae, Adam, Wintermute", "Name": "Penumbra", "Punchline": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", - "AssemblyVersion": "1.3.1.3", - "TestingAssemblyVersion": "1.3.1.3", + "AssemblyVersion": "1.5.1.8", + "TestingAssemblyVersion": "1.5.1.8", "RepoUrl": "https://github.com/xivdev/Penumbra", "ApplicableVersion": "any", - "DalamudApiLevel": 11, - "TestingDalamudApiLevel": 11, + "DalamudApiLevel": 13, + "TestingDalamudApiLevel": 13, "IsHide": "False", "IsTestingExclusive": "False", "DownloadCount": 0, @@ -18,9 +18,9 @@ "LoadPriority": 69420, "LoadRequiredState": 2, "LoadSync": true, - "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.3.1.3/Penumbra.zip", - "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.3.1.3/Penumbra.zip", - "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.3.1.3/Penumbra.zip", + "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.8/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.8/Penumbra.zip", + "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.8/Penumbra.zip", "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" } ] diff --git a/schemas/default_mod.json b/schemas/default_mod.json new file mode 100644 index 00000000..8f50c5db --- /dev/null +++ b/schemas/default_mod.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { + "Version": { + "description": "Mod Container version, currently unused.", + "type": "integer", + "minimum": 0, + "maximum": 0 + } + } + }, + { + "$ref": "structs/container.json" + } + ] +} diff --git a/schemas/group.json b/schemas/group.json new file mode 100644 index 00000000..4c37b631 --- /dev/null +++ b/schemas/group.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Version": { + "description": "Mod Container version, currently unused.", + "type": "integer" + }, + "Name": { + "description": "Name of the group.", + "type": "string", + "minLength": 1 + }, + "Description": { + "description": "Description of the group.", + "type": [ "string", "null" ] + }, + "Image": { + "description": "Relative path to a preview image for the group. Unused by Penumbra, present for round-trip import/export of TexTools-generated mods.", + "type": ["string", "null" ] + }, + "Page": { + "description": "TexTools page of the group. Unused by Penumbra, present for round-trip import/export of TexTools-generated mods.", + "type": "integer" + }, + "Priority": { + "description": "Priority of the group. If several groups define conflicting files or manipulations, the highest priority wins.", + "type": "integer" + }, + "Type": { + "description": "Group type. Single groups have one and only one of their options active at any point. Multi groups can have zero, one or many of their options active. Combining groups have n options, 2^n containers, and will have one and only one container active depending on the selected options.", + "enum": [ "Single", "Multi", "Imc", "Combining" ] + }, + "DefaultSettings": { + "description": "Default configuration for the group.", + "type": "integer" + } + }, + "required": [ + "Name", + "Type" + ], + "oneOf": [ + { + "$ref": "structs/group_combining.json" + }, + { + "$ref": "structs/group_imc.json" + }, + { + "$ref": "structs/group_multi.json" + }, + { + "$ref": "structs/group_single.json" + } + ] +} diff --git a/schemas/local_mod_data-v3.json b/schemas/local_mod_data-v3.json new file mode 100644 index 00000000..c50e130e --- /dev/null +++ b/schemas/local_mod_data-v3.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Local Penumbra Mod Data", + "description": "The locally stored data for an installed mod in Penumbra", + "type": "object", + "properties": { + "FileVersion": { + "description": "Major version of the local data schema used.", + "type": "integer", + "minimum": 3, + "maximum": 3 + }, + "ImportDate": { + "description": "The date and time of the installation of the mod as a Unix Epoch millisecond timestamp.", + "type": "integer" + }, + "LocalTags": { + "description": "User-defined local tags for the mod.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "Favorite": { + "description": "Whether the mod is favourited by the user.", + "type": "boolean" + }, + "PreferredChangedItems": { + "description": "Preferred items to list as the main item of a group.", + "type": "array", + "items": { + "minimum": 0, + "type": "integer" + }, + "uniqueItems": true + } + }, + "required": [ "FileVersion" ] +} diff --git a/schemas/mod_meta-v3.json b/schemas/mod_meta-v3.json new file mode 100644 index 00000000..6fc68714 --- /dev/null +++ b/schemas/mod_meta-v3.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Penumbra Mod Metadata", + "description": "Metadata of a Penumbra mod.", + "type": "object", + "properties": { + "FileVersion": { + "description": "Major version of the metadata schema used.", + "type": "integer", + "minimum": 3, + "maximum": 3 + }, + "Name": { + "description": "Name of the mod.", + "type": "string", + "minLength": 1 + }, + "Author": { + "description": "Author of the mod.", + "type": [ "string", "null" ] + }, + "Description": { + "description": "Description of the mod. Can span multiple paragraphs.", + "type": [ "string", "null" ] + }, + "Image": { + "description": "Relative path to a preview image for the mod. Unused by Penumbra, present for round-trip import/export of TexTools-generated mods.", + "type": [ "string", "null" ] + }, + "Version": { + "description": "Version of the mod. Can be an arbitrary string.", + "type": [ "string", "null" ] + }, + "Website": { + "description": "URL of the web page of the mod.", + "type": [ "string", "null" ] + }, + "ModTags": { + "description": "Author-defined tags for the mod.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "DefaultPreferredItems": { + "description": "Default preferred items to list as the main item of a group managed by the mod creator.", + "type": "array", + "items": { + "minimum": 0, + "type": "integer" + }, + "uniqueItems": true + }, + "RequiredFeatures": { + "description": "A list of required features by name.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "FileVersion", + "Name" + ] +} diff --git a/schemas/shpk_devkit.json b/schemas/shpk_devkit.json new file mode 100644 index 00000000..f03fbb05 --- /dev/null +++ b/schemas/shpk_devkit.json @@ -0,0 +1,499 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "ShaderKeys": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/ShaderKey" + } + }, + "additionalProperties": false + }, + "Comment": { + "$ref": "#/$defs/MayVary" + }, + "Samplers": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/MayVary" + } + }, + "additionalProperties": false + }, + "Constants": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/MayVary" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "ShaderKeyValue": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ShaderKey": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "Values": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/ShaderKeyValue" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "Varying": { + "type": "object", + "properties": { + "Vary": { + "type": "array", + "items": { + "$ref": "#/$defs/LaxInteger" + } + }, + "Selectors": { + "description": "Keys are Σ 31^i shaderKey(Vary[i]).", + "type": "object", + "patternProperties": { + "^\\d+$": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "Items": { + "type": "array", + "$comment": "Varying is defined by constraining this array's items to T" + } + }, + "required": [ + "Vary", + "Selectors", + "Items" + ], + "additionalProperties": false + }, + "MayVary": { + "oneOf": [ + { + "type": ["string", "null"] + }, { + "allOf": [ + { + "$ref": "#/$defs/Varying" + }, { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "type": ["string", "null"] + } + } + } + } + ] + } + ] + }, + "Sampler": { + "type": ["object", "null"], + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "DefaultTexture": { + "type": "string", + "pattern": "^[^/\\\\][^\\\\]*$" + } + }, + "additionalProperties": false + }, + "MayVary": { + "oneOf": [ + { + "$ref": "#/$defs/Sampler" + }, { + "allOf": [ + { + "$ref": "#/$defs/Varying" + }, { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/$defs/Sampler" + } + } + } + } + ] + } + ] + }, + "ConstantBase": { + "type": "object", + "properties": { + "Offset": { + "description": "Defaults to 0. Mutually exclusive with ByteOffset.", + "type": "integer", + "minimum": 0 + }, + "Length": { + "description": "Defaults to up to the end. Mutually exclusive with ByteLength.", + "type": "integer", + "minimum": 0 + }, + "ByteOffset": { + "description": "Defaults to 0. Mutually exclusive with Offset.", + "type": "integer", + "minimum": 0 + }, + "ByteLength": { + "description": "Defaults to up to the end. Mutually exclusive with Length.", + "type": "integer", + "minimum": 0 + }, + "Group": { + "description": "Defaults to \"Further Constants\".", + "type": "string" + }, + "Label": { + "type": "string" + }, + "Description": { + "description": "Defaults to empty.", + "type": "string" + }, + "Type": { + "description": "Defaults to Float.", + "enum": ["Hidden", "Float", "Integer", "Color", "Enum", "Int32", "Int32Enum", "Int8", "Int8Enum", "Int16", "Int16Enum", "Int64", "Int64Enum", "Half", "Double", "TileIndex", "SphereMapIndex"] + } + }, + "not": { + "anyOf": [ + { + "required": ["Offset", "ByteOffset"] + }, { + "required": ["Length", "ByteLenngth"] + } + ] + } + }, + "HiddenConstant": { + "type": "object", + "properties": { + "Type": { + "const": "Hidden" + } + }, + "required": [ + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "FloatConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["Float", "Half", "Double"] + }, + "Minimum": { + "description": "Defaults to -∞.", + "type": "number" + }, + "Maximum": { + "description": "Defaults to ∞.", + "type": "number" + }, + "Speed": { + "description": "Defaults to 0.1.", + "type": "number", + "minimum": 0 + }, + "RelativeSpeed": { + "description": "Defaults to 0.", + "type": "number", + "minimum": 0 + }, + "Exponent": { + "description": "Defaults to 1. Uses an odd pseudo-power function, f(x) = sgn(x) |x|^n.", + "type": "number" + }, + "Factor": { + "description": "Defaults to 1.", + "type": "number" + }, + "Bias": { + "description": "Defaults to 0.", + "type": "number" + }, + "Precision": { + "description": "Defaults to 3.", + "type": "integer", + "minimum": 0, + "maximum": 9 + }, + "Slider": { + "description": "Defaults to true. Drag has priority over this.", + "type": "boolean" + }, + "Drag": { + "description": "Defaults to true. Has priority over Slider.", + "type": "boolean" + }, + "Unit": { + "description": "Defaults to no unit.", + "type": "string" + } + }, + "required": [ + "Label" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "IntConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["Integer", "Int32", "Int8", "Int16", "Int64"] + }, + "Minimum": { + "description": "Defaults to -2^N, N being the explicit integer width specified in the type, or 32 for Int.", + "type": "number" + }, + "Maximum": { + "description": "Defaults to 2^N - 1, N being the explicit integer width specified in the type, or 32 for Int.", + "type": "number" + }, + "Speed": { + "description": "Defaults to 0.25.", + "type": "number", + "minimum": 0 + }, + "RelativeSpeed": { + "description": "Defaults to 0.", + "type": "number", + "minimum": 0 + }, + "Factor": { + "description": "Defaults to 1.", + "type": "number" + }, + "Bias": { + "description": "Defaults to 0.", + "type": "number" + }, + "Hex": { + "description": "Defaults to false. Has priority over Slider and Drag.", + "type": "boolean" + }, + "Slider": { + "description": "Defaults to true. Hex and Drag have priority over this.", + "type": "boolean" + }, + "Drag": { + "description": "Defaults to true. Has priority over Slider, but Hex has priority over this.", + "type": "boolean" + }, + "Unit": { + "description": "Defaults to no unit.", + "type": "string" + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "ColorConstant": { + "type": "object", + "properties": { + "Type": { + "const": "Color" + }, + "SquaredRgb": { + "description": "Defaults to false. Uses an odd pseudo-square function, f(x) = sgn(x) |x|².", + "type": "boolean" + }, + "Clamped": { + "description": "Defaults to false.", + "type": "boolean" + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "EnumValue": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "Value": { + "type": "number" + } + }, + "required": [ + "Label", + "Value" + ], + "additionalProperties": false + }, + "EnumConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["Enum", "Int32Enum", "Int8Enum", "Int16Enum", "Int64Enum"] + }, + "Values": { + "type": "array", + "items": { + "$ref": "#/$defs/EnumValue" + } + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "SpecialConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["TileIndex", "SphereMapIndex"] + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "Constant": { + "oneOf": [ + { + "$ref": "#/$defs/HiddenConstant" + }, { + "$ref": "#/$defs/FloatConstant" + }, { + "$ref": "#/$defs/IntConstant" + }, { + "$ref": "#/$defs/ColorConstant" + }, { + "$ref": "#/$defs/EnumConstant" + }, { + "$ref": "#/$defs/SpecialConstant" + } + ] + }, + "MayVary": { + "oneOf": [ + { + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/Constant" + } + }, { + "allOf": [ + { + "$ref": "#/$defs/Varying" + }, { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/Constant" + } + } + } + } + } + ] + } + ] + }, + "LaxInteger": { + "oneOf": [ + { + "type": "integer" + }, { + "type": "string", + "pattern": "^\\d+$" + } + ] + } + } +} diff --git a/schemas/structs/container.json b/schemas/structs/container.json new file mode 100644 index 00000000..74db4a23 --- /dev/null +++ b/schemas/structs/container.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Files": { + "description": "File redirections in this container. Keys are game paths, values are relative file paths.", + "type": [ "object", "null" ], + "patternProperties": { + "^[^/\\\\.:?][^\\\\?:]+$": { + "type": "string", + "pattern": "^[^/\\\\.:?][^?:]+$" + } + }, + "additionalProperties": false + }, + "FileSwaps": { + "description": "File swaps in this container. Keys are original game paths, values are actual game paths.", + "type": [ "object", "null" ], + "patternProperties": { + "^[^/\\\\.?:][^\\\\?:]+$": { + "type": "string", + "pattern": "^[^/\\\\.:?][^?:]+$" + } + }, + "additionalProperties": false + }, + "Manipulations": { + "type": [ "array", "null" ], + "items": { + "$ref": "manipulation.json" + } + } + } +} diff --git a/schemas/structs/group_combining.json b/schemas/structs/group_combining.json new file mode 100644 index 00000000..e42edcb8 --- /dev/null +++ b/schemas/structs/group_combining.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "Type": { + "const": "Combining" + }, + "Options": { + "type": "array", + "items": { + "$ref": "option.json" + } + }, + "Containers": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "container.json" + }, + { + "properties": { + "Name": { + "type": [ "string", "null" ] + } + } + } + ] + } + } + } +} diff --git a/schemas/structs/group_imc.json b/schemas/structs/group_imc.json new file mode 100644 index 00000000..48a04bd9 --- /dev/null +++ b/schemas/structs/group_imc.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "Type": { + "const": "Imc" + }, + "AllVariants": { + "type": "boolean" + }, + "OnlyAttributes": { + "type": "boolean" + }, + "Identifier": { + "$ref": "meta_imc.json#ImcIdentifier" + }, + "DefaultEntry": { + "$ref": "meta_imc.json#ImcEntry" + }, + "Options": { + "type": "array", + "items": { + "$ref": "option.json", + "oneOf": [ + { + "properties": { + "AttributeMask": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + } + }, + "required": [ + "AttributeMask" + ] + }, + { + "properties": { + "IsDisableSubMod": { + "const": true + } + }, + "required": [ + "IsDisableSubMod" + ] + } + ] + } + } + } +} diff --git a/schemas/structs/group_multi.json b/schemas/structs/group_multi.json new file mode 100644 index 00000000..ca7d4dfa --- /dev/null +++ b/schemas/structs/group_multi.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Type": { + "const": "Multi" + }, + "Options": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "option.json" + }, + { + "$ref": "container.json" + }, + { + "properties": { + "Priority": { + "type": "integer" + } + } + } + ] + } + } + } +} + + + diff --git a/schemas/structs/group_single.json b/schemas/structs/group_single.json new file mode 100644 index 00000000..24cda88d --- /dev/null +++ b/schemas/structs/group_single.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Type": { + "const": "Single" + }, + "Options": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "option.json" + }, + { + "$ref": "container.json" + } + ] + } + } + } +} diff --git a/schemas/structs/manipulation.json b/schemas/structs/manipulation.json new file mode 100644 index 00000000..81f2cef3 --- /dev/null +++ b/schemas/structs/manipulation.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Type": { + "enum": [ "Unknown", "Imc", "Eqdp", "Eqp", "Est", "Gmp", "Rsp", "GlobalEqp", "Atch", "Shp", "Atr" ] + }, + "Manipulation": { + "type": "object" + } + }, + "required": [ "Type", "Manipulation" ], + "oneOf": [ + { + "properties": { + "Type": { + "const": "Imc" + }, + "Manipulation": { + "$ref": "meta_imc.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Eqdp" + }, + "Manipulation": { + "$ref": "meta_eqdp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Eqp" + }, + "Manipulation": { + "$ref": "meta_eqp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Est" + }, + "Manipulation": { + "$ref": "meta_est.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Gmp" + }, + "Manipulation": { + "$ref": "meta_gmp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Rsp" + }, + "Manipulation": { + "$ref": "meta_rsp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "GlobalEqp" + }, + "Manipulation": { + "$ref": "meta_geqp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Atch" + }, + "Manipulation": { + "$ref": "meta_atch.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Shp" + }, + "Manipulation": { + "$ref": "meta_shp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Atr" + }, + "Manipulation": { + "$ref": "meta_atr.json" + } + } + } + ] +} diff --git a/schemas/structs/meta_atch.json b/schemas/structs/meta_atch.json new file mode 100644 index 00000000..3c9cbef5 --- /dev/null +++ b/schemas/structs/meta_atch.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "object", + "properties": { + "Bone": { + "type": "string", + "maxLength": 34 + }, + "Scale": { + "type": "number" + }, + "OffsetX": { + "type": "number" + }, + "OffsetY": { + "type": "number" + }, + "OffsetZ": { + "type": "number" + }, + "RotationX": { + "type": "number" + }, + "RotationY": { + "type": "number" + }, + "RotationZ": { + "type": "number" + } + }, + "required": [ + "Bone", + "Scale", + "OffsetX", + "OffsetY", + "OffsetZ", + "RotationX", + "RotationY", + "RotationZ" + ] + }, + "Gender": { + "$ref": "meta_enums.json#Gender" + }, + "Race": { + "$ref": "meta_enums.json#ModelRace" + }, + "Type": { + "type": "string", + "minLength": 1, + "maxLength": 4 + }, + "Index": { + "$ref": "meta_enums.json#U16" + } + }, + "required": [ + "Entry", + "Gender", + "Race", + "Type", + "Index" + ] +} diff --git a/schemas/structs/meta_atr.json b/schemas/structs/meta_atr.json new file mode 100644 index 00000000..479d4127 --- /dev/null +++ b/schemas/structs/meta_atr.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "boolean" + }, + "Slot": { + "$ref": "meta_enums.json#HumanSlot" + }, + "Id": { + "$ref": "meta_enums.json#U16" + }, + "Attribute": { + "type": "string", + "minLength": 5, + "maxLength": 30, + "pattern": "^atrx_" + }, + "GenderRaceCondition": { + "enum": [ 0, 101, 201, 301, 401, 501, 601, 701, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, 1601, 1701, 1801 ] + } + }, + "required": [ + "Attribute" + ] +} diff --git a/schemas/structs/meta_enums.json b/schemas/structs/meta_enums.json new file mode 100644 index 00000000..bad184e0 --- /dev/null +++ b/schemas/structs/meta_enums.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "EquipSlot": { + "$anchor": "EquipSlot", + "enum": [ "Unknown", "MainHand", "OffHand", "Head", "Body", "Hands", "Belt", "Legs", "Feet", "Ears", "Neck", "Wrists", "RFinger", "BothHand", "LFinger", "HeadBody", "BodyHandsLegsFeet", "SoulCrystal", "LegsFeet", "FullBody", "BodyHands", "BodyLegsFeet", "ChestHands", "Nothing", "All" ] + }, + "HumanSlot": { + "$anchor": "HumanSlot", + "enum": [ "Head", "Body", "Hands", "Legs", "Feet", "Ears", "Neck", "Wrists", "RFinger", "LFinger", "Hair", "Face", "Ear", "Glasses", "Unknown" ] + }, + "Gender": { + "$anchor": "Gender", + "enum": [ "Unknown", "Male", "Female", "MaleNpc", "FemaleNpc" ] + }, + "ModelRace": { + "$anchor": "ModelRace", + "enum": [ "Unknown", "Midlander", "Highlander", "Elezen", "Lalafell", "Miqote", "Roegadyn", "AuRa", "Hrothgar", "Viera" ] + }, + "ObjectType": { + "$anchor": "ObjectType", + "enum": [ "Unknown", "Vfx", "DemiHuman", "Accessory", "World", "Housing", "Monster", "Icon", "LoadingScreen", "Map", "Interface", "Equipment", "Character", "Weapon", "Font" ] + }, + "BodySlot": { + "$anchor": "BodySlot", + "enum": [ "Unknown", "Hair", "Face", "Tail", "Body", "Zear" ] + }, + "SubRace": { + "$anchor": "SubRace", + "enum": [ "Unknown", "Midlander", "Highlander", "Wildwood", "Duskwight", "Plainsfolk", "Dunesfolk", "SeekerOfTheSun", "KeeperOfTheMoon", "Seawolf", "Hellsguard", "Raen", "Xaela", "Helion", "Lost", "Rava", "Veena" ] + }, + "ShapeConnectorCondition": { + "$anchor": "ShapeConnectorCondition", + "enum": [ "None", "Wrists", "Waist", "Ankles" ] + }, + "U8": { + "$anchor": "U8", + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "string", + "pattern": "^0*(1[0-9][0-9]|[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$" + } + ] + }, + "U16": { + "$anchor": "U16", + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "string", + "pattern": "^0*([1-5][0-9]{4}|[0-9]{0,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" + } + ] + } + } +} diff --git a/schemas/structs/meta_eqdp.json b/schemas/structs/meta_eqdp.json new file mode 100644 index 00000000..f27606b9 --- /dev/null +++ b/schemas/structs/meta_eqdp.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "Gender": { + "$ref": "meta_enums.json#Gender" + }, + "Race": { + "$ref": "meta_enums.json#ModelRace" + }, + "SetId": { + "$ref": "meta_enums.json#U16" + }, + "Slot": { + "$ref": "meta_enums.json#EquipSlot" + } + }, + "required": [ + "Entry", + "Gender", + "Race", + "SetId", + "Slot" + ] +} diff --git a/schemas/structs/meta_eqp.json b/schemas/structs/meta_eqp.json new file mode 100644 index 00000000..c829d7a7 --- /dev/null +++ b/schemas/structs/meta_eqp.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "integer" + }, + "SetId": { + "$ref": "meta_enums.json#U16" + }, + "Slot": { + "$ref": "meta_enums.json#EquipSlot" + } + }, + "required": [ + "Entry", + "SetId", + "Slot" + ] +} diff --git a/schemas/structs/meta_est.json b/schemas/structs/meta_est.json new file mode 100644 index 00000000..22bce12b --- /dev/null +++ b/schemas/structs/meta_est.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "$ref": "meta_enums.json#U16" + }, + "Gender": { + "$ref": "meta_enums.json#Gender" + }, + "Race": { + "$ref": "meta_enums.json#ModelRace" + }, + "SetId": { + "$ref": "meta_enums.json#U16" + }, + "Slot": { + "enum": [ "Hair", "Face", "Body", "Head" ] + } + }, + "required": [ + "Entry", + "Gender", + "Race", + "SetId", + "Slot" + ] +} diff --git a/schemas/structs/meta_geqp.json b/schemas/structs/meta_geqp.json new file mode 100644 index 00000000..e38fbb86 --- /dev/null +++ b/schemas/structs/meta_geqp.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Condition": { + "$ref": "meta_enums.json#U16" + }, + "Type": { + "enum": [ "DoNotHideEarrings", "DoNotHideNecklace", "DoNotHideBracelets", "DoNotHideRingR", "DoNotHideRingL", "DoNotHideHrothgarHats", "DoNotHideVieraHats", "HideHorns", "HideVieraEars", "HideMiqoteEars" ] + } + }, + "required": [ "Type" ], + "oneOf": [ + { + "properties": { + "Type": { + "const": [ "DoNotHideHrothgarHats", "DoNotHideVieraHats", "HideHorns", "HideVieraEars", "HideMiqoteEars" ] + }, + "Condition": { + "const": 0 + } + } + }, + { + "properties": { + "Type": { + "const": [ "DoNotHideHrothgarHats", "DoNotHideVieraHats", "HideHorns", "HideVieraEars", "HideMiqoteEars" ] + } + } + }, + { + "properties": { + "Type": { + "const": [ "DoNotHideEarrings", "DoNotHideNecklace", "DoNotHideBracelets", "DoNotHideRingR", "DoNotHideRingL" ] + }, + "Condition": {} + } + } + ] +} diff --git a/schemas/structs/meta_gmp.json b/schemas/structs/meta_gmp.json new file mode 100644 index 00000000..bf1fb1df --- /dev/null +++ b/schemas/structs/meta_gmp.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "object", + "properties": { + "Enabled": { + "type": "boolean" + }, + "Animated": { + "type": "boolean" + }, + "RotationA": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "RotationB": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "RotationC": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "UnknownA": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "UnknownB": { + "type": "integer", + "minimum": 0, + "maximum": 15 + } + }, + "required": [ + "Enabled", + "Animated", + "RotationA", + "RotationB", + "RotationC", + "UnknownA", + "UnknownB" + ], + "additionalProperties": false + }, + "SetId": { + "$ref": "meta_enums.json#U16" + } + }, + "required": [ + "Entry", + "SetId" + ] +} diff --git a/schemas/structs/meta_imc.json b/schemas/structs/meta_imc.json new file mode 100644 index 00000000..aa9a4fca --- /dev/null +++ b/schemas/structs/meta_imc.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "$ref": "#ImcEntry" + } + }, + "required": [ + "Entry" + ], + "allOf": [ + { + "$ref": "#ImcIdentifier" + } + ], + "$defs": { + "ImcIdentifier": { + "type": "object", + "properties": { + "PrimaryId": { + "$ref": "meta_enums.json#U16" + }, + "SecondaryId": { + "$ref": "meta_enums.json#U16" + }, + "Variant": { + "$ref": "meta_enums.json#U8" + }, + "ObjectType": { + "$ref": "meta_enums.json#ObjectType" + }, + "EquipSlot": { + "$ref": "meta_enums.json#EquipSlot" + }, + "BodySlot": { + "$ref": "meta_enums.json#BodySlot" + } + }, + "$anchor": "ImcIdentifier", + "required": [ + "PrimaryId", + "SecondaryId", + "Variant", + "ObjectType", + "EquipSlot", + "BodySlot" + ] + }, + "ImcEntry": { + "type": "object", + "properties": { + "MaterialId": { + "$ref": "meta_enums.json#U8" + }, + "DecalId": { + "$ref": "meta_enums.json#U8" + }, + "VfxId": { + "$ref": "meta_enums.json#U8" + }, + "MaterialAnimationId": { + "$ref": "meta_enums.json#U8" + }, + "AttributeMask": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "SoundId": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "$anchor": "ImcEntry", + "required": [ + "MaterialId", + "DecalId", + "VfxId", + "MaterialAnimationId", + "AttributeMask", + "SoundId" + ] + } + } +} diff --git a/schemas/structs/meta_rsp.json b/schemas/structs/meta_rsp.json new file mode 100644 index 00000000..3354281b --- /dev/null +++ b/schemas/structs/meta_rsp.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "number" + }, + "SubRace": { + "$ref": "meta_enums.json#SubRace" + }, + "Attribute": { + "enum": [ "MaleMinSize", "MaleMaxSize", "MaleMinTail", "MaleMaxTail", "FemaleMinSize", "FemaleMaxSize", "FemaleMinTail", "FemaleMaxTail", "BustMinX", "BustMinY", "BustMinZ", "BustMaxX", "BustMaxY", "BustMaxZ" ] + } + }, + "required": [ + "Entry", + "SubRace", + "Attribute" + ] +} diff --git a/schemas/structs/meta_shp.json b/schemas/structs/meta_shp.json new file mode 100644 index 00000000..cb7fd0ec --- /dev/null +++ b/schemas/structs/meta_shp.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "boolean" + }, + "Slot": { + "$ref": "meta_enums.json#HumanSlot" + }, + "Id": { + "$ref": "meta_enums.json#U16" + }, + "Shape": { + "type": "string", + "minLength": 5, + "maxLength": 30, + "pattern": "^shpx_" + }, + "ConnectorCondition": { + "$ref": "meta_enums.json#ShapeConnectorCondition" + }, + "GenderRaceCondition": { + "enum": [ 0, 101, 201, 301, 401, 501, 601, 701, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, 1601, 1701, 1801 ] + } + }, + "required": [ + "Shape" + ] +} diff --git a/schemas/structs/option.json b/schemas/structs/option.json new file mode 100644 index 00000000..c45ccfdb --- /dev/null +++ b/schemas/structs/option.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Name": { + "description": "Name of the option.", + "type": "string", + "minLength": 1 + }, + "Description": { + "description": "Description of the option.", + "type": [ "string", "null" ] + }, + "Priority": { + "description": "Priority of the option. If several enabled options within the group define conflicting files or manipulations, the highest priority wins.", + "type": "integer" + }, + "Image": { + "description": "Unused by Penumbra.", + "type": [ "string", "null" ] + } + }, + "required": [ "Name" ] +}