From 6ad0b4299a29486af69a02d96bbf71cbbb77e939 Mon Sep 17 00:00:00 2001 From: Ottermandias Date: Thu, 15 May 2025 17:46:53 +0200 Subject: [PATCH] Add shape meta manipulations and rework attribute hook. --- OtterGui | 2 +- Penumbra.GameData | 2 +- Penumbra.sln | 1 + Penumbra/Collections/Cache/CollectionCache.cs | 2 + Penumbra/Collections/Cache/MetaCache.cs | 9 +- Penumbra/Collections/Cache/ShpCache.cs | 109 ++++++++ .../Communication/ModelAttributeComputed.cs | 24 -- .../Hooks/PostProcessing/AttributeHook.cs | 85 ++++++ .../Hooks/PostProcessing/AttributeHooks.cs | 110 -------- .../Meta/Manipulations/IMetaIdentifier.cs | 1 + Penumbra/Meta/Manipulations/MetaDictionary.cs | 72 ++++- Penumbra/Meta/Manipulations/ShpIdentifier.cs | 157 +++++++++++ Penumbra/Meta/ShapeManager.cs | 117 ++++----- Penumbra/Meta/ShapeString.cs | 33 ++- Penumbra/Services/CommunicatorService.cs | 4 - .../UI/AdvancedWindow/Meta/MetaDrawers.cs | 5 +- .../UI/AdvancedWindow/Meta/ShpMetaDrawer.cs | 247 ++++++++++++++++++ .../UI/AdvancedWindow/ModEditWindow.Meta.cs | 1 + Penumbra/UI/Tabs/Debug/ShapeInspector.cs | 114 +++++--- Penumbra/UI/Tabs/SettingsTab.cs | 64 +---- schemas/structs/manipulation.json | 12 +- schemas/structs/meta_enums.json | 4 + schemas/structs/meta_shp.json | 23 ++ 23 files changed, 900 insertions(+), 298 deletions(-) create mode 100644 Penumbra/Collections/Cache/ShpCache.cs delete mode 100644 Penumbra/Communication/ModelAttributeComputed.cs create mode 100644 Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs delete mode 100644 Penumbra/Interop/Hooks/PostProcessing/AttributeHooks.cs create mode 100644 Penumbra/Meta/Manipulations/ShpIdentifier.cs create mode 100644 Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs create mode 100644 schemas/structs/meta_shp.json diff --git a/OtterGui b/OtterGui index 86b49242..f130c928 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 86b492422565abde2e8ad17c0295896a21c3439c +Subproject commit f130c928928cb0d48d3c807b7df5874c2460fe98 diff --git a/Penumbra.GameData b/Penumbra.GameData index 0ca50105..8e57c2e1 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 0ca501050de72ee1cc7382dfae894f984ce241b6 +Subproject commit 8e57c2e12570bb1795efb9e5c6e38617aa8dd5e3 diff --git a/Penumbra.sln b/Penumbra.sln index e52045b0..642876ef 100644 --- a/Penumbra.sln +++ b/Penumbra.sln @@ -50,6 +50,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "structs", "structs", "{B03F 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 diff --git a/Penumbra/Collections/Cache/CollectionCache.cs b/Penumbra/Collections/Cache/CollectionCache.cs index f6f038a1..c48a487c 100644 --- a/Penumbra/Collections/Cache/CollectionCache.cs +++ b/Penumbra/Collections/Cache/CollectionCache.cs @@ -245,6 +245,8 @@ 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 in files.Manipulations.GlobalEqp) AddManipulation(mod, identifier, null!); } diff --git a/Penumbra/Collections/Cache/MetaCache.cs b/Penumbra/Collections/Cache/MetaCache.cs index 7d8586c3..790dd3af 100644 --- a/Penumbra/Collections/Cache/MetaCache.cs +++ b/Penumbra/Collections/Cache/MetaCache.cs @@ -16,11 +16,12 @@ 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 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 + GlobalEqp.Count; public IEnumerable<(IMetaIdentifier, IMod)> IdentifierSources => Eqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source)) @@ -30,6 +31,7 @@ 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(GlobalEqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value))); public void Reset() @@ -41,6 +43,7 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) Rsp.Reset(); Imc.Reset(); Atch.Reset(); + Shp.Reset(); GlobalEqp.Clear(); } @@ -57,6 +60,7 @@ public class MetaCache(MetaFileManager manager, ModCollection collection) Rsp.Dispose(); Imc.Dispose(); Atch.Dispose(); + Shp.Dispose(); } public bool TryGetMod(IMetaIdentifier identifier, [NotNullWhen(true)] out IMod? mod) @@ -71,6 +75,7 @@ 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), GlobalEqpManipulation i => GlobalEqp.TryGetValue(i, out mod), _ => false, }; @@ -92,6 +97,7 @@ 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), GlobalEqpManipulation i => GlobalEqp.RevertMod(i, out mod), _ => (mod = null) != null, }; @@ -108,6 +114,7 @@ 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), GlobalEqpManipulation i => GlobalEqp.ApplyMod(mod, i), _ => false, }; diff --git a/Penumbra/Collections/Cache/ShpCache.cs b/Penumbra/Collections/Cache/ShpCache.cs new file mode 100644 index 00000000..2e90052d --- /dev/null +++ b/Penumbra/Collections/Cache/ShpCache.cs @@ -0,0 +1,109 @@ +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 ShapeString shape, HumanSlot slot, PrimaryId id) + => _shpData.TryGetValue(shape, out var value) && value.Contains(slot, id); + + internal IReadOnlyDictionary State + => _shpData; + + internal sealed class ShpHashSet : HashSet<(HumanSlot Slot, PrimaryId Id)> + { + private readonly BitArray _allIds = new(ShapeManager.ModelSlotSize); + + public bool All + { + get => _allIds[^1]; + set => _allIds[^1] = value; + } + + public bool this[HumanSlot slot] + { + get + { + if (slot is HumanSlot.Unknown) + return All; + + return _allIds[(int)slot]; + } + set + { + if (slot is HumanSlot.Unknown) + _allIds[^1] = value; + else + _allIds[(int)slot] = value; + } + } + + public bool Contains(HumanSlot slot, PrimaryId id) + => All || this[slot] || Contains((slot, id)); + + public bool TrySet(HumanSlot slot, PrimaryId? id, ShpEntry value) + { + if (slot is HumanSlot.Unknown) + { + var old = All; + All = value.Value; + return old != value.Value; + } + + if (!id.HasValue) + { + var old = this[slot]; + this[slot] = value.Value; + return old != value.Value; + } + + if (value.Value) + return Add((slot, id.Value)); + + return Remove((slot, id.Value)); + } + + public new void Clear() + { + base.Clear(); + _allIds.SetAll(false); + } + + public bool IsEmpty + => !_allIds.HasAnySet() && Count is 0; + } + + private readonly Dictionary _shpData = []; + + public void Reset() + { + Clear(); + _shpData.Clear(); + } + + protected override void Dispose(bool _) + => Clear(); + + protected override void ApplyModInternal(ShpIdentifier identifier, ShpEntry entry) + { + if (!_shpData.TryGetValue(identifier.Shape, out var value)) + { + value = []; + _shpData.Add(identifier.Shape, value); + } + + value.TrySet(identifier.Slot, identifier.Id, entry); + } + + protected override void RevertModInternal(ShpIdentifier identifier) + { + if (!_shpData.TryGetValue(identifier.Shape, out var value)) + return; + + if (value.TrySet(identifier.Slot, identifier.Id, ShpEntry.False) && value.IsEmpty) + _shpData.Remove(identifier.Shape); + } +} diff --git a/Penumbra/Communication/ModelAttributeComputed.cs b/Penumbra/Communication/ModelAttributeComputed.cs deleted file mode 100644 index 389f56b6..00000000 --- a/Penumbra/Communication/ModelAttributeComputed.cs +++ /dev/null @@ -1,24 +0,0 @@ -using OtterGui.Classes; -using Penumbra.Collections; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Interop; - -namespace Penumbra.Communication; - -/// -/// 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 class ModelAttributeComputed() - : EventWrapper(nameof(ModelAttributeComputed)) -{ - public enum Priority - { - /// - ShapeManager = 0, - } -} diff --git a/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs b/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs new file mode 100644 index 00000000..cad049ad --- /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 + { + /// + ShapeManager = 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/AttributeHooks.cs b/Penumbra/Interop/Hooks/PostProcessing/AttributeHooks.cs deleted file mode 100644 index 861962ee..00000000 --- a/Penumbra/Interop/Hooks/PostProcessing/AttributeHooks.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Dalamud.Hooking; -using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; -using OtterGui.Services; -using Penumbra.Collections; -using Penumbra.Communication; -using Penumbra.GameData; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Interop; -using Penumbra.Interop.PathResolving; -using Penumbra.Services; - -namespace Penumbra.Interop.Hooks.PostProcessing; - -public sealed unsafe class AttributeHooks : IRequiredService, IDisposable -{ - private delegate void SetupAttributes(Human* human, byte* data); - private delegate void AttributeUpdate(Human* human); - - private readonly Configuration _config; - private readonly ModelAttributeComputed _event; - private readonly CollectionResolver _resolver; - - private readonly AttributeHook[] _hooks; - private readonly Task> _updateHook; - private ModCollection _identifiedCollection = ModCollection.Empty; - private Actor _identifiedActor = Actor.Null; - private bool _inUpdate; - - public AttributeHooks(Configuration config, CommunicatorService communication, CollectionResolver resolver, HookManager hooks) - { - _config = config; - _event = communication.ModelAttributeComputed; - _resolver = resolver; - _hooks = - [ - new AttributeHook(this, hooks, Sigs.SetupTopModelAttributes, _config.EnableCustomShapes, HumanSlot.Body), - new AttributeHook(this, hooks, Sigs.SetupHandModelAttributes, _config.EnableCustomShapes, HumanSlot.Hands), - new AttributeHook(this, hooks, Sigs.SetupLegModelAttributes, _config.EnableCustomShapes, HumanSlot.Legs), - new AttributeHook(this, hooks, Sigs.SetupFootModelAttributes, _config.EnableCustomShapes, HumanSlot.Feet), - ]; - _updateHook = hooks.CreateHook("UpdateAttributes", Sigs.UpdateAttributes, UpdateAttributesDetour, - _config.EnableCustomShapes); - } - - private class AttributeHook - { - private readonly AttributeHooks _parent; - public readonly string Name; - public readonly Task> Hook; - public readonly uint ModelIndex; - public readonly HumanSlot Slot; - - public AttributeHook(AttributeHooks parent, HookManager hooks, string signature, bool enabled, HumanSlot slot) - { - _parent = parent; - Name = $"Setup{slot}Attributes"; - Slot = slot; - ModelIndex = slot.ToIndex(); - Hook = hooks.CreateHook(Name, signature, Detour, enabled); - } - - private void Detour(Human* human, byte* data) - { - Penumbra.Log.Excessive($"[{Name}] Invoked on 0x{(ulong)human:X} (0x{_parent._identifiedActor.Address:X})."); - Hook.Result.Original(human, data); - if (_parent is { _inUpdate: true, _identifiedActor.Valid: true }) - _parent._event.Invoke(_parent._identifiedActor, human, _parent._identifiedCollection, Slot); - } - } - - private void UpdateAttributesDetour(Human* human) - { - var resolveData = _resolver.IdentifyCollection((DrawObject*)human, true); - _identifiedActor = resolveData.AssociatedGameObject; - _identifiedCollection = resolveData.ModCollection; - _inUpdate = true; - Penumbra.Log.Excessive($"[UpdateAttributes] Invoked on 0x{(ulong)human:X} (0x{_identifiedActor.Address:X})."); - _event.Invoke(_identifiedActor, human, _identifiedCollection, HumanSlot.Unknown); - _updateHook.Result.Original(human); - _inUpdate = false; - } - - public void SetState(bool enabled) - { - if (_config.EnableCustomShapes == enabled) - return; - - _config.EnableCustomShapes = enabled; - _config.Save(); - if (enabled) - { - foreach (var hook in _hooks) - hook.Hook.Result.Enable(); - _updateHook.Result.Enable(); - } - else - { - foreach (var hook in _hooks) - hook.Hook.Result.Disable(); - _updateHook.Result.Disable(); - } - } - - public void Dispose() - { - foreach (var hook in _hooks) - hook.Hook.Result.Dispose(); - _updateHook.Result.Dispose(); - } -} diff --git a/Penumbra/Meta/Manipulations/IMetaIdentifier.cs b/Penumbra/Meta/Manipulations/IMetaIdentifier.cs index c897bb2a..13feba51 100644 --- a/Penumbra/Meta/Manipulations/IMetaIdentifier.cs +++ b/Penumbra/Meta/Manipulations/IMetaIdentifier.cs @@ -15,6 +15,7 @@ public enum MetaManipulationType : byte Rsp = 6, GlobalEqp = 7, Atch = 8, + Shp = 9, } public interface IMetaIdentifier diff --git a/Penumbra/Meta/Manipulations/MetaDictionary.cs b/Penumbra/Meta/Manipulations/MetaDictionary.cs index ca45c777..a7225067 100644 --- a/Penumbra/Meta/Manipulations/MetaDictionary.cs +++ b/Penumbra/Meta/Manipulations/MetaDictionary.cs @@ -18,6 +18,7 @@ public class MetaDictionary private readonly Dictionary _rsp = []; private readonly Dictionary _gmp = []; private readonly Dictionary _atch = []; + private readonly Dictionary _shp = []; private readonly HashSet _globalEqp = []; public IReadOnlyDictionary Imc @@ -41,6 +42,9 @@ public class MetaDictionary public IReadOnlyDictionary Atch => _atch; + public IReadOnlyDictionary Shp + => _shp; + public IReadOnlySet GlobalEqp => _globalEqp; @@ -56,6 +60,7 @@ public class MetaDictionary MetaManipulationType.Gmp => _gmp.Count, MetaManipulationType.Rsp => _rsp.Count, MetaManipulationType.Atch => _atch.Count, + MetaManipulationType.Shp => _shp.Count, MetaManipulationType.GlobalEqp => _globalEqp.Count, _ => 0, }; @@ -70,6 +75,7 @@ public class MetaDictionary GmpIdentifier i => _gmp.ContainsKey(i), ImcIdentifier i => _imc.ContainsKey(i), AtchIdentifier i => _atch.ContainsKey(i), + ShpIdentifier i => _shp.ContainsKey(i), RspIdentifier i => _rsp.ContainsKey(i), _ => false, }; @@ -84,6 +90,7 @@ public class MetaDictionary _rsp.Clear(); _gmp.Clear(); _atch.Clear(); + _shp.Clear(); _globalEqp.Clear(); } @@ -108,6 +115,7 @@ public class MetaDictionary && _rsp.SetEquals(other._rsp) && _gmp.SetEquals(other._gmp) && _atch.SetEquals(other._atch) + && _shp.SetEquals(other._shp) && _globalEqp.SetEquals(other._globalEqp); public IEnumerable Identifiers @@ -118,6 +126,7 @@ public class MetaDictionary .Concat(_gmp.Keys.Cast()) .Concat(_rsp.Keys.Cast()) .Concat(_atch.Keys.Cast()) + .Concat(_shp.Keys.Cast()) .Concat(_globalEqp.Cast()); #region TryAdd @@ -191,6 +200,15 @@ public class MetaDictionary return true; } + public bool TryAdd(ShpIdentifier identifier, in ShpEntry entry) + { + if (!_shp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + public bool TryAdd(GlobalEqpManipulation identifier) { if (!_globalEqp.Add(identifier)) @@ -273,6 +291,15 @@ public class MetaDictionary return true; } + public bool Update(ShpIdentifier identifier, in ShpEntry entry) + { + if (!_shp.ContainsKey(identifier)) + return false; + + _shp[identifier] = entry; + return true; + } + #endregion #region TryGetValue @@ -298,6 +325,9 @@ public class MetaDictionary public bool TryGetValue(AtchIdentifier identifier, out AtchEntry value) => _atch.TryGetValue(identifier, out value); + public bool TryGetValue(ShpIdentifier identifier, out ShpEntry value) + => _shp.TryGetValue(identifier, out value); + #endregion public bool Remove(IMetaIdentifier identifier) @@ -312,6 +342,7 @@ public class MetaDictionary ImcIdentifier i => _imc.Remove(i), RspIdentifier i => _rsp.Remove(i), AtchIdentifier i => _atch.Remove(i), + ShpIdentifier i => _shp.Remove(i), _ => false, }; if (ret) @@ -344,6 +375,9 @@ public class MetaDictionary foreach (var (identifier, entry) in manips._atch) TryAdd(identifier, entry); + foreach (var (identifier, entry) in manips._shp) + TryAdd(identifier, entry); + foreach (var identifier in manips._globalEqp) TryAdd(identifier); } @@ -393,13 +427,19 @@ public class MetaDictionary return false; } + foreach (var (identifier, _) in manips._shp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + foreach (var identifier in manips._globalEqp.Where(identifier => !TryAdd(identifier))) { failedIdentifier = identifier; return false; } - failedIdentifier = default; + failedIdentifier = null; return true; } @@ -412,8 +452,9 @@ public class MetaDictionary _rsp.SetTo(other._rsp); _gmp.SetTo(other._gmp); _atch.SetTo(other._atch); + _shp.SetTo(other._shp); _globalEqp.SetTo(other._globalEqp); - Count = _imc.Count + _eqp.Count + _eqdp.Count + _est.Count + _rsp.Count + _gmp.Count + _atch.Count + _globalEqp.Count; + Count = _imc.Count + _eqp.Count + _eqdp.Count + _est.Count + _rsp.Count + _gmp.Count + _atch.Count + _shp.Count + _globalEqp.Count; } public void UpdateTo(MetaDictionary other) @@ -425,8 +466,9 @@ public class MetaDictionary _rsp.UpdateTo(other._rsp); _gmp.UpdateTo(other._gmp); _atch.UpdateTo(other._atch); + _shp.UpdateTo(other._shp); _globalEqp.UnionWith(other._globalEqp); - Count = _imc.Count + _eqp.Count + _eqdp.Count + _est.Count + _rsp.Count + _gmp.Count + _atch.Count + _globalEqp.Count; + Count = _imc.Count + _eqp.Count + _eqdp.Count + _est.Count + _rsp.Count + _gmp.Count + _atch.Count + _shp.Count + _globalEqp.Count; } #endregion @@ -514,6 +556,16 @@ 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(GlobalEqpManipulation identifier) => new() { @@ -543,6 +595,8 @@ 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(GlobalEqpManipulation)) return Serialize(Unsafe.As(ref identifier)); @@ -588,6 +642,7 @@ public class MetaDictionary SerializeTo(array, value._rsp); SerializeTo(array, value._gmp); SerializeTo(array, value._atch); + SerializeTo(array, value._shp); SerializeTo(array, value._globalEqp); array.WriteTo(writer); } @@ -685,6 +740,16 @@ 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.GlobalEqp: { var identifier = GlobalEqpManipulation.FromJson(manip); @@ -716,6 +781,7 @@ public class MetaDictionary _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); _globalEqp = cache.GlobalEqp.Select(kvp => kvp.Key).ToHashSet(); Count = cache.Count; } diff --git a/Penumbra/Meta/Manipulations/ShpIdentifier.cs b/Penumbra/Meta/Manipulations/ShpIdentifier.cs new file mode 100644 index 00000000..fffa51ba --- /dev/null +++ b/Penumbra/Meta/Manipulations/ShpIdentifier.cs @@ -0,0 +1,157 @@ +using Lumina.Models.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct ShpIdentifier(HumanSlot Slot, PrimaryId? Id, ShapeString Shape) + : 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; + } + + + return Shape.CompareTo(other.Shape); + } + + public override string ToString() + => $"Shp - {Shape}{(Slot is HumanSlot.Unknown ? " - All Slots & IDs" : $" - {Slot.ToName()}{(Id.HasValue ? $" - {Id.Value.Id}" : " - All IDs")}")}"; + + 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 (Slot is HumanSlot.Unknown && Id is not null) + return false; + + return ValidateCustomShapeString(Shape); + } + + public static bool ValidateCustomShapeString(ReadOnlySpan shape) + { + // "shp_xx_y" + if (shape.Length < 8) + return false; + + if (shape[0] is not (byte)'s' + || shape[1] is not (byte)'h' + || shape[2] is not (byte)'p' + || shape[3] is not (byte)'_' + || shape[6] is not (byte)'_') + return false; + + return true; + } + + public static unsafe bool ValidateCustomShapeString(byte* shape) + { + // "shp_xx_y" + 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)'_' + || *shape++ is 0 + || *shape++ is 0 + || *shape++ is not (byte)'_' + || *shape is 0) + return false; + + return true; + } + + public static bool ValidateCustomShapeString(in ShapeString shape) + { + // "shp_xx_y" + if (shape.Length < 8) + return false; + + var span = shape.AsSpan; + if (span[0] is not (byte)'s' + || span[1] is not (byte)'h' + || span[2] is not (byte)'p' + || span[3] is not (byte)'_' + || span[6] is not (byte)'_') + return false; + + return true; + } + + 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(); + return jObj; + } + + public static ShpIdentifier? FromJson(JObject jObj) + { + var slot = jObj["Slot"]?.ToObject() ?? HumanSlot.Unknown; + var id = jObj["Id"]?.ToObject(); + var shape = jObj["Shape"]?.ToObject(); + if (shape is null || !ShapeString.TryRead(shape, out var shapeString)) + return null; + + var identifier = new ShpIdentifier(slot, id, shapeString); + 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/ShapeManager.cs b/Penumbra/Meta/ShapeManager.cs index 4356086a..ec8ddb50 100644 --- a/Penumbra/Meta/ShapeManager.cs +++ b/Penumbra/Meta/ShapeManager.cs @@ -1,24 +1,30 @@ +using System.Reflection.Metadata.Ecma335; using OtterGui.Services; using Penumbra.Collections; -using Penumbra.Communication; using Penumbra.GameData.Enums; using Penumbra.GameData.Interop; -using Penumbra.Services; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Meta.Manipulations; namespace Penumbra.Meta; public class ShapeManager : IRequiredService, IDisposable { - public const int NumSlots = 4; - private readonly CommunicatorService _communicator; + public const int NumSlots = 14; + public const int ModelSlotSize = 18; + private readonly AttributeHook _attributeHook; - private static ReadOnlySpan UsedModels - => [1, 2, 3, 4]; + 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 ShapeManager(CommunicatorService communicator) + public ShapeManager(AttributeHook attributeHook) { - _communicator = communicator; - _communicator.ModelAttributeComputed.Subscribe(OnAttributeComputed, ModelAttributeComputed.Priority.ShapeManager); + _attributeHook = attributeHook; + _attributeHook.Subscribe(OnAttributeComputed, AttributeHook.Priority.ShapeManager); } private readonly Dictionary[] _temporaryIndices = @@ -27,38 +33,30 @@ public class ShapeManager : IRequiredService, IDisposable private readonly uint[] _temporaryMasks = new uint[NumSlots]; private readonly uint[] _temporaryValues = new uint[NumSlots]; - private unsafe void OnAttributeComputed(Actor actor, Model model, ModCollection collection, HumanSlot slot) - { - int index; - switch (slot) - { - case HumanSlot.Unknown: - ResetCache(model); - return; - case HumanSlot.Body: index = 0; break; - case HumanSlot.Hands: index = 1; break; - case HumanSlot.Legs: index = 2; break; - case HumanSlot.Feet: index = 3; break; - default: return; - } + public void Dispose() + => _attributeHook.Unsubscribe(OnAttributeComputed); - if (_temporaryMasks[index] is 0) + private unsafe void OnAttributeComputed(Actor actor, Model model, ModCollection collection) + { + ComputeCache(model, collection); + for (var i = 0; i < NumSlots; ++i) + { + if (_temporaryMasks[i] is 0) + continue; + + var modelIndex = UsedModels[i]; + var currentMask = model.AsHuman->Models[modelIndex.ToIndex()]->EnabledShapeKeyIndexMask; + var newMask = (currentMask & ~_temporaryMasks[i]) | _temporaryValues[i]; + Penumbra.Log.Excessive($"Changed Model Mask from {currentMask:X} to {newMask:X}."); + model.AsHuman->Models[modelIndex.ToIndex()]->EnabledShapeKeyIndexMask = newMask; + } + } + + private unsafe void ComputeCache(Model human, ModCollection collection) + { + if (!collection.HasCache) return; - var modelIndex = UsedModels[index]; - var currentMask = model.AsHuman->Models[modelIndex]->EnabledShapeKeyIndexMask; - var newMask = (currentMask & ~_temporaryMasks[index]) | _temporaryValues[index]; - Penumbra.Log.Excessive($"Changed Model Mask from {currentMask:X} to {newMask:X}."); - model.AsHuman->Models[modelIndex]->EnabledShapeKeyIndexMask = newMask; - } - - public void Dispose() - { - _communicator.ModelAttributeComputed.Unsubscribe(OnAttributeComputed); - } - - private unsafe void ResetCache(Model human) - { for (var i = 0; i < NumSlots; ++i) { _temporaryMasks[i] = 0; @@ -66,17 +64,20 @@ public class ShapeManager : IRequiredService, IDisposable _temporaryIndices[i].Clear(); var modelIndex = UsedModels[i]; - var model = human.AsHuman->Models[modelIndex]; + var model = human.AsHuman->Models[modelIndex.ToIndex()]; if (model is null || model->ModelResourceHandle is null) continue; ref var shapes = ref model->ModelResourceHandle->Shapes; - foreach (var (shape, index) in shapes.Where(kvp => CheckShapes(kvp.Key.AsSpan(), modelIndex))) + foreach (var (shape, index) in shapes.Where(kvp => ShpIdentifier.ValidateCustomShapeString(kvp.Key.Value))) { if (ShapeString.TryRead(shape.Value, out var shapeString)) { _temporaryIndices[i].TryAdd(shapeString, index); _temporaryMasks[i] |= (ushort)(1 << index); + if (collection.MetaCache!.Shp.State.Count > 0 + && collection.MetaCache!.Shp.ShouldBeEnabled(shapeString, modelIndex, human.GetArmorChanged(modelIndex).Set)) + _temporaryValues[i] |= (ushort)(1 << index); } else { @@ -85,42 +86,32 @@ public class ShapeManager : IRequiredService, IDisposable } } - UpdateMasks(); + UpdateDefaultMasks(); } - private static bool CheckShapes(ReadOnlySpan shape, byte index) - => index switch - { - 1 => shape.StartsWith("shp_wa_"u8) || shape.StartsWith("shp_wr_"u8), - 2 => shape.StartsWith("shp_wr_"u8), - 3 => shape.StartsWith("shp_wa_"u8) || shape.StartsWith("shp_an"u8), - 4 => shape.StartsWith("shp_an"u8), - _ => false, - }; - - private void UpdateMasks() + private void UpdateDefaultMasks() { - foreach (var (shape, topIndex) in _temporaryIndices[0]) + foreach (var (shape, topIndex) in _temporaryIndices[1]) { - if (_temporaryIndices[1].TryGetValue(shape, out var handIndex)) + if (shape[4] is (byte)'w' && shape[5] is (byte)'r' && _temporaryIndices[2].TryGetValue(shape, out var handIndex)) { - _temporaryValues[0] |= 1u << topIndex; - _temporaryValues[1] |= 1u << handIndex; + _temporaryValues[1] |= 1u << topIndex; + _temporaryValues[2] |= 1u << handIndex; } - if (_temporaryIndices[2].TryGetValue(shape, out var legIndex)) + if (shape[4] is (byte)'w' && shape[5] is (byte)'a' && _temporaryIndices[3].TryGetValue(shape, out var legIndex)) { - _temporaryValues[0] |= 1u << topIndex; - _temporaryValues[2] |= 1u << legIndex; + _temporaryValues[1] |= 1u << topIndex; + _temporaryValues[3] |= 1u << legIndex; } } - foreach (var (shape, bottomIndex) in _temporaryIndices[2]) + foreach (var (shape, bottomIndex) in _temporaryIndices[3]) { - if (_temporaryIndices[3].TryGetValue(shape, out var footIndex)) + if (shape[4] is (byte)'a' && shape[5] is (byte)'n' && _temporaryIndices[4].TryGetValue(shape, out var footIndex)) { - _temporaryValues[2] |= 1u << bottomIndex; - _temporaryValues[3] |= 1u << footIndex; + _temporaryValues[3] |= 1u << bottomIndex; + _temporaryValues[4] |= 1u << footIndex; } } } diff --git a/Penumbra/Meta/ShapeString.cs b/Penumbra/Meta/ShapeString.cs index 987ed474..5b6f9c52 100644 --- a/Penumbra/Meta/ShapeString.cs +++ b/Penumbra/Meta/ShapeString.cs @@ -1,11 +1,12 @@ using Lumina.Misc; using Newtonsoft.Json; using Penumbra.GameData.Files.PhybStructs; +using Penumbra.String.Functions; namespace Penumbra.Meta; [JsonConverter(typeof(Converter))] -public struct ShapeString : IEquatable +public struct ShapeString : IEquatable, IComparable { public const int MaxLength = 30; @@ -22,6 +23,20 @@ public struct ShapeString : IEquatable 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 bool Equals(ShapeString other) => Length == other.Length && _buffer[..Length].SequenceEqual(other._buffer[..Length]); @@ -43,6 +58,14 @@ public struct ShapeString : IEquatable return TryRead(span, out ret); } + public unsafe int CompareTo(ShapeString other) + { + fixed (void* lhs = &this) + { + return ByteStringFunctions.Compare((byte*)lhs, Length, (byte*)&other, other.Length); + } + } + public static bool TryRead(ReadOnlySpan utf8, out ShapeString ret) { if (utf8.Length is 0 or > MaxLength) @@ -69,6 +92,14 @@ public struct ShapeString : IEquatable 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, ShapeString value, JsonSerializer serializer) diff --git a/Penumbra/Services/CommunicatorService.cs b/Penumbra/Services/CommunicatorService.cs index e008752f..5d745419 100644 --- a/Penumbra/Services/CommunicatorService.cs +++ b/Penumbra/Services/CommunicatorService.cs @@ -81,9 +81,6 @@ public class CommunicatorService : IDisposable, IService /// public readonly ResolvedFileChanged ResolvedFileChanged = new(); - /// - public readonly ModelAttributeComputed ModelAttributeComputed = new(); - public void Dispose() { CollectionChange.Dispose(); @@ -108,6 +105,5 @@ public class CommunicatorService : IDisposable, IService ChangedItemClick.Dispose(); SelectTab.Dispose(); ResolvedFileChanged.Dispose(); - ModelAttributeComputed.Dispose(); } } diff --git a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs index d1c7cd52..70b5f83b 100644 --- a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs +++ b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs @@ -11,7 +11,8 @@ public class MetaDrawers( GmpMetaDrawer gmp, ImcMetaDrawer imc, RspMetaDrawer rsp, - AtchMetaDrawer atch) : IService + AtchMetaDrawer atch, + ShpMetaDrawer shp) : IService { public readonly EqdpMetaDrawer Eqdp = eqdp; public readonly EqpMetaDrawer Eqp = eqp; @@ -21,6 +22,7 @@ public class MetaDrawers( public readonly ImcMetaDrawer Imc = imc; public readonly GlobalEqpMetaDrawer GlobalEqp = globalEqp; public readonly AtchMetaDrawer Atch = atch; + public readonly ShpMetaDrawer Shp = shp; public IMetaDrawer? Get(MetaManipulationType type) => type switch @@ -32,6 +34,7 @@ public class MetaDrawers( MetaManipulationType.Gmp => Gmp, MetaManipulationType.Rsp => Rsp, MetaManipulationType.Atch => Atch, + MetaManipulationType.Shp => Shp, MetaManipulationType.GlobalEqp => GlobalEqp, _ => null, }; diff --git a/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs new file mode 100644 index 00000000..4be6e6aa --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs @@ -0,0 +1,247 @@ +using Dalamud.Interface; +using ImGuiNET; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Enums; +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 ShapeString _buffer = ShapeString.TryRead("shp_"u8, out var s) ? s : ShapeString.Empty; + private bool _identifierValid; + + public override int NumColumns + => 6; + + public override float ColumnHeight + => ImUtf8.FrameHeightSpacing; + + protected override void Initialize() + { + Identifier = new ShpIdentifier(HumanSlot.Unknown, null, ShapeString.Empty); + } + + 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) + .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 |= DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawShapeKeyInput(ref identifier, ref _buffer, ref _identifierValid); + 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.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); + } + + 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 + { + if (IdInput("##shpPrimaryId"u8, unscaledWidth, identifier.Id.GetValueOrDefault(0).Id, out var setId, 0, + ExpandedEqpGmpBase.Count - 1, + 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 static bool DrawHumanSlot(ref ShpIdentifier identifier, float unscaledWidth = 150) + { + 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 { Slot = slot }; + } + } + + ImUtf8.HoverTooltip("Model Slot"u8); + return ret; + } + + public static unsafe bool DrawShapeKeyInput(ref ShpIdentifier identifier, ref ShapeString buffer, ref bool valid, float unscaledWidth = 150) + { + var ret = false; + var ptr = Unsafe.AsPointer(ref buffer); + var span = new Span(ptr, ShapeString.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 = ShpIdentifier.ValidateCustomShapeString(buffer); + if (valid) + identifier = identifier with { Shape = buffer }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Supported shape keys need to have the format `shp_xx_*` and a maximum length of 30 characters."u8); + return ret; + } + + private 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, + ]; + + private 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.Meta.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs index 68424ae9..70a15373 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs @@ -61,6 +61,7 @@ public partial class ModEditWindow DrawEditHeader(MetaManipulationType.Gmp); DrawEditHeader(MetaManipulationType.Rsp); DrawEditHeader(MetaManipulationType.Atch); + DrawEditHeader(MetaManipulationType.Shp); DrawEditHeader(MetaManipulationType.GlobalEqp); } diff --git a/Penumbra/UI/Tabs/Debug/ShapeInspector.cs b/Penumbra/UI/Tabs/Debug/ShapeInspector.cs index 968bc484..5292bd17 100644 --- a/Penumbra/UI/Tabs/Debug/ShapeInspector.cs +++ b/Penumbra/UI/Tabs/Debug/ShapeInspector.cs @@ -5,10 +5,12 @@ using OtterGui.Services; using OtterGui.Text; using Penumbra.GameData.Enums; using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; namespace Penumbra.UI.Tabs.Debug; -public class ShapeInspector(ObjectManager objects) : IUiService +public class ShapeInspector(ObjectManager objects, CollectionResolver resolver) : IUiService { private int _objectIndex = 0; @@ -29,42 +31,92 @@ public class ShapeInspector(ObjectManager objects) : IUiService return; } - using var table = ImUtf8.Table("##table"u8, 4, ImGuiTableFlags.RowBg); - if (!table) - return; - - ImUtf8.TableSetupColumn("idx"u8, ImGuiTableColumnFlags.WidthFixed, 25 * ImUtf8.GlobalScale); - ImUtf8.TableSetupColumn("ptr"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 14); - ImUtf8.TableSetupColumn("mask"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 8); - ImUtf8.TableSetupColumn("shapes"u8, ImGuiTableColumnFlags.WidthStretch); - - var disabledColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); - foreach (var slot in Enum.GetValues()) + var data = resolver.IdentifyCollection(actor.AsObject, true); + using (var treeNode1 = ImUtf8.TreeNode($"Collection Shape Cache ({data.ModCollection})")) { - ImUtf8.DrawTableColumn($"{(uint)slot:D2}"); - ImGui.TableNextColumn(); - var model = human.AsHuman->Models[(int)slot]; - Penumbra.Dynamis.DrawPointer((nint)model); - if (model is not null) + if (treeNode1.Success && data.ModCollection.HasCache) { - var mask = model->EnabledShapeKeyIndexMask; - ImUtf8.DrawTableColumn($"{mask:X8}"); - ImGui.TableNextColumn(); - foreach (var (shape, idx) in model->ModelResourceHandle->Shapes) + using var table = ImUtf8.Table("##cacheTable"u8, 2, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("shape"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("enabled"u8, ImGuiTableColumnFlags.WidthStretch); + + foreach (var (shape, set) in data.ModCollection.MetaCache!.Shp.State) { - var disabled = (mask & (1u << idx)) 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); + ImUtf8.DrawTableColumn(shape.AsSpan); + if (set.All) + { + ImUtf8.DrawTableColumn("All"u8); + } + else + { + ImGui.TableNextColumn(); + foreach (var slot in ShapeManager.UsedModels) + { + if (!set[slot]) + continue; + + ImUtf8.Text($"All {slot.ToName()}, "); + ImGui.SameLine(0, 0); + } + + foreach (var item in set.Where(i => !set[i.Slot])) + { + ImUtf8.Text($"{item.Slot.ToName()} {item.Id.Id:D4}, "); + ImGui.SameLine(0, 0); + } + } } } - else + } + + using (var treeNode2 = ImUtf8.TreeNode("Character Model Shapes"u8)) + { + if (treeNode2) { - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); + using var table = ImUtf8.Table("##table"u8, 5, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("idx"u8, ImGuiTableColumnFlags.WidthFixed, 25 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("name"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("ptr"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 14); + ImUtf8.TableSetupColumn("mask"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 8); + ImUtf8.TableSetupColumn("shapes"u8, ImGuiTableColumnFlags.WidthStretch); + + 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}"); + ImGui.TableNextColumn(); + foreach (var (shape, idx) in model->ModelResourceHandle->Shapes) + { + var disabled = (mask & (1u << idx)) 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(); + } + } } } } diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs index 7b3a3c8b..cb22b54a 100644 --- a/Penumbra/UI/Tabs/SettingsTab.cs +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -51,7 +51,7 @@ public class SettingsTab : ITab, IUiService private readonly MigrationSectionDrawer _migrationDrawer; private readonly CollectionAutoSelector _autoSelector; private readonly CleanupService _cleanupService; - private readonly AttributeHooks _attributeHooks; + private readonly AttributeHook _attributeHook; private int _minimumX = int.MaxValue; private int _minimumY = int.MaxValue; @@ -64,7 +64,7 @@ public class SettingsTab : ITab, IUiService DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, - AttributeHooks attributeHooks) + AttributeHook attributeHook) { _pluginInterface = pluginInterface; _config = config; @@ -89,7 +89,7 @@ public class SettingsTab : ITab, IUiService _migrationDrawer = migrationDrawer; _autoSelector = autoSelector; _cleanupService = cleanupService; - _attributeHooks = attributeHooks; + _attributeHook = attributeHook; } public void DrawHeader() @@ -525,55 +525,6 @@ public class SettingsTab : ITab, IUiService 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); @@ -607,8 +558,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 => @@ -626,7 +575,8 @@ public class SettingsTab : ITab, IUiService _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, + "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 => { @@ -811,8 +761,8 @@ 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 Advanced Shape Support", "Penumbra will allow for custom shape keys for modded models to be considered and combined.", - _config.EnableAttributeHooks, _attributeHooks.SetState); + Checkbox("Enable Custom Shape Support", "Penumbra will allow for custom shape keys for modded models to be considered and combined.", + _config.EnableCustomShapes, _attributeHook.SetState); DrawWaitForPluginsReflection(); DrawEnableHttpApiBox(); DrawEnableDebugModeBox(); diff --git a/schemas/structs/manipulation.json b/schemas/structs/manipulation.json index 4a41dbe2..55fc5cad 100644 --- a/schemas/structs/manipulation.json +++ b/schemas/structs/manipulation.json @@ -3,7 +3,7 @@ "type": "object", "properties": { "Type": { - "enum": [ "Unknown", "Imc", "Eqdp", "Eqp", "Est", "Gmp", "Rsp", "GlobalEqp", "Atch" ] + "enum": [ "Unknown", "Imc", "Eqdp", "Eqp", "Est", "Gmp", "Rsp", "GlobalEqp", "Atch", "Shp" ] }, "Manipulation": { "type": "object" @@ -90,6 +90,16 @@ "$ref": "meta_atch.json" } } + }, + { + "properties": { + "Type": { + "const": "Shp" + }, + "Manipulation": { + "$ref": "meta_shp.json" + } + } } ] } diff --git a/schemas/structs/meta_enums.json b/schemas/structs/meta_enums.json index 747da849..2fc65a0d 100644 --- a/schemas/structs/meta_enums.json +++ b/schemas/structs/meta_enums.json @@ -5,6 +5,10 @@ "$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" ] diff --git a/schemas/structs/meta_shp.json b/schemas/structs/meta_shp.json new file mode 100644 index 00000000..e6b66420 --- /dev/null +++ b/schemas/structs/meta_shp.json @@ -0,0 +1,23 @@ +{ + "$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": 8, + "maxLength": 30 + } + }, + "required": [ + "Shape" + ] +}