Add shape meta manipulations and rework attribute hook.

This commit is contained in:
Ottermandias 2025-05-15 17:46:53 +02:00
parent 0adec35848
commit 6ad0b4299a
23 changed files with 900 additions and 298 deletions

@ -1 +1 @@
Subproject commit 86b492422565abde2e8ad17c0295896a21c3439c Subproject commit f130c928928cb0d48d3c807b7df5874c2460fe98

@ -1 +1 @@
Subproject commit 0ca501050de72ee1cc7382dfae894f984ce241b6 Subproject commit 8e57c2e12570bb1795efb9e5c6e38617aa8dd5e3

View file

@ -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_gmp.json = schemas\structs\meta_gmp.json
schemas\structs\meta_imc.json = schemas\structs\meta_imc.json schemas\structs\meta_imc.json = schemas\structs\meta_imc.json
schemas\structs\meta_rsp.json = schemas\structs\meta_rsp.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 schemas\structs\option.json = schemas\structs\option.json
EndProjectSection EndProjectSection
EndProject EndProject

View file

@ -245,6 +245,8 @@ public sealed class CollectionCache : IDisposable
AddManipulation(mod, identifier, entry); AddManipulation(mod, identifier, entry);
foreach (var (identifier, entry) in files.Manipulations.Atch) foreach (var (identifier, entry) in files.Manipulations.Atch)
AddManipulation(mod, identifier, entry); AddManipulation(mod, identifier, entry);
foreach (var (identifier, entry) in files.Manipulations.Shp)
AddManipulation(mod, identifier, entry);
foreach (var identifier in files.Manipulations.GlobalEqp) foreach (var identifier in files.Manipulations.GlobalEqp)
AddManipulation(mod, identifier, null!); AddManipulation(mod, identifier, null!);
} }

View file

@ -16,11 +16,12 @@ public class MetaCache(MetaFileManager manager, ModCollection collection)
public readonly RspCache Rsp = new(manager, collection); public readonly RspCache Rsp = new(manager, collection);
public readonly ImcCache Imc = new(manager, collection); public readonly ImcCache Imc = new(manager, collection);
public readonly AtchCache Atch = new(manager, collection); public readonly AtchCache Atch = new(manager, collection);
public readonly ShpCache Shp = new(manager, collection);
public readonly GlobalEqpCache GlobalEqp = new(); public readonly GlobalEqpCache GlobalEqp = new();
public bool IsDisposed { get; private set; } public bool IsDisposed { get; private set; }
public int Count 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 public IEnumerable<(IMetaIdentifier, IMod)> IdentifierSources
=> Eqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source)) => 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(Rsp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source)))
.Concat(Imc.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(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))); .Concat(GlobalEqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value)));
public void Reset() public void Reset()
@ -41,6 +43,7 @@ public class MetaCache(MetaFileManager manager, ModCollection collection)
Rsp.Reset(); Rsp.Reset();
Imc.Reset(); Imc.Reset();
Atch.Reset(); Atch.Reset();
Shp.Reset();
GlobalEqp.Clear(); GlobalEqp.Clear();
} }
@ -57,6 +60,7 @@ public class MetaCache(MetaFileManager manager, ModCollection collection)
Rsp.Dispose(); Rsp.Dispose();
Imc.Dispose(); Imc.Dispose();
Atch.Dispose(); Atch.Dispose();
Shp.Dispose();
} }
public bool TryGetMod(IMetaIdentifier identifier, [NotNullWhen(true)] out IMod? mod) 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), ImcIdentifier i => Imc.TryGetValue(i, out var p) && Convert(p, out mod),
RspIdentifier i => Rsp.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), 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), GlobalEqpManipulation i => GlobalEqp.TryGetValue(i, out mod),
_ => false, _ => false,
}; };
@ -92,6 +97,7 @@ public class MetaCache(MetaFileManager manager, ModCollection collection)
ImcIdentifier i => Imc.RevertMod(i, out mod), ImcIdentifier i => Imc.RevertMod(i, out mod),
RspIdentifier i => Rsp.RevertMod(i, out mod), RspIdentifier i => Rsp.RevertMod(i, out mod),
AtchIdentifier i => Atch.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), GlobalEqpManipulation i => GlobalEqp.RevertMod(i, out mod),
_ => (mod = null) != null, _ => (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), ImcIdentifier i when entry is ImcEntry e => Imc.ApplyMod(mod, i, e),
RspIdentifier i when entry is RspEntry e => Rsp.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), 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), GlobalEqpManipulation i => GlobalEqp.ApplyMod(mod, i),
_ => false, _ => false,
}; };

View file

@ -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<ShpIdentifier, ShpEntry>(manager, collection)
{
public bool ShouldBeEnabled(in ShapeString shape, HumanSlot slot, PrimaryId id)
=> _shpData.TryGetValue(shape, out var value) && value.Contains(slot, id);
internal IReadOnlyDictionary<ShapeString, ShpHashSet> 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<ShapeString, ShpHashSet> _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);
}
}

View file

@ -1,24 +0,0 @@
using OtterGui.Classes;
using Penumbra.Collections;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Interop;
namespace Penumbra.Communication;
/// <summary>
/// Triggered whenever a model recomputes its attribute masks.
/// <list type="number">
/// <item>Parameter is the game object that recomputed its attributes. </item>
/// <item>Parameter is the draw object on which the recomputation was called. </item>
/// <item>Parameter is the collection associated with the game object. </item>
/// <item>Parameter is the slot that was recomputed. If this is Unknown, it is a general new update call. </item>
/// </list> </summary>
public sealed class ModelAttributeComputed()
: EventWrapper<Actor, Model, ModCollection, HumanSlot, ModelAttributeComputed.Priority>(nameof(ModelAttributeComputed))
{
public enum Priority
{
/// <seealso cref="Meta.ShapeManager.OnAttributeComputed"/>
ShapeManager = 0,
}
}

View file

@ -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;
/// <summary>
/// Triggered whenever a model recomputes its attribute masks.
/// <list type="number">
/// <item>Parameter is the game object that recomputed its attributes. </item>
/// <item>Parameter is the draw object on which the recomputation was called. </item>
/// <item>Parameter is the collection associated with the game object. </item>
/// <item>Parameter is the slot that was recomputed. If this is Unknown, it is a general new update call. </item>
/// </list> </summary>
public sealed unsafe class AttributeHook : EventWrapper<Actor, Model, ModCollection, AttributeHook.Priority>, IHookService
{
public enum Priority
{
/// <seealso cref="ShapeManager.OnAttributeComputed"/>
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<Delegate>(Name, Sigs.UpdateAttributes, Detour, config.EnableCustomShapes);
}
private readonly Task<Hook<Delegate>> _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();
}

View file

@ -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<Hook<AttributeUpdate>> _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<AttributeUpdate>("UpdateAttributes", Sigs.UpdateAttributes, UpdateAttributesDetour,
_config.EnableCustomShapes);
}
private class AttributeHook
{
private readonly AttributeHooks _parent;
public readonly string Name;
public readonly Task<Hook<SetupAttributes>> 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<SetupAttributes>(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();
}
}

View file

@ -15,6 +15,7 @@ public enum MetaManipulationType : byte
Rsp = 6, Rsp = 6,
GlobalEqp = 7, GlobalEqp = 7,
Atch = 8, Atch = 8,
Shp = 9,
} }
public interface IMetaIdentifier public interface IMetaIdentifier

View file

@ -18,6 +18,7 @@ public class MetaDictionary
private readonly Dictionary<RspIdentifier, RspEntry> _rsp = []; private readonly Dictionary<RspIdentifier, RspEntry> _rsp = [];
private readonly Dictionary<GmpIdentifier, GmpEntry> _gmp = []; private readonly Dictionary<GmpIdentifier, GmpEntry> _gmp = [];
private readonly Dictionary<AtchIdentifier, AtchEntry> _atch = []; private readonly Dictionary<AtchIdentifier, AtchEntry> _atch = [];
private readonly Dictionary<ShpIdentifier, ShpEntry> _shp = [];
private readonly HashSet<GlobalEqpManipulation> _globalEqp = []; private readonly HashSet<GlobalEqpManipulation> _globalEqp = [];
public IReadOnlyDictionary<ImcIdentifier, ImcEntry> Imc public IReadOnlyDictionary<ImcIdentifier, ImcEntry> Imc
@ -41,6 +42,9 @@ public class MetaDictionary
public IReadOnlyDictionary<AtchIdentifier, AtchEntry> Atch public IReadOnlyDictionary<AtchIdentifier, AtchEntry> Atch
=> _atch; => _atch;
public IReadOnlyDictionary<ShpIdentifier, ShpEntry> Shp
=> _shp;
public IReadOnlySet<GlobalEqpManipulation> GlobalEqp public IReadOnlySet<GlobalEqpManipulation> GlobalEqp
=> _globalEqp; => _globalEqp;
@ -56,6 +60,7 @@ public class MetaDictionary
MetaManipulationType.Gmp => _gmp.Count, MetaManipulationType.Gmp => _gmp.Count,
MetaManipulationType.Rsp => _rsp.Count, MetaManipulationType.Rsp => _rsp.Count,
MetaManipulationType.Atch => _atch.Count, MetaManipulationType.Atch => _atch.Count,
MetaManipulationType.Shp => _shp.Count,
MetaManipulationType.GlobalEqp => _globalEqp.Count, MetaManipulationType.GlobalEqp => _globalEqp.Count,
_ => 0, _ => 0,
}; };
@ -70,6 +75,7 @@ public class MetaDictionary
GmpIdentifier i => _gmp.ContainsKey(i), GmpIdentifier i => _gmp.ContainsKey(i),
ImcIdentifier i => _imc.ContainsKey(i), ImcIdentifier i => _imc.ContainsKey(i),
AtchIdentifier i => _atch.ContainsKey(i), AtchIdentifier i => _atch.ContainsKey(i),
ShpIdentifier i => _shp.ContainsKey(i),
RspIdentifier i => _rsp.ContainsKey(i), RspIdentifier i => _rsp.ContainsKey(i),
_ => false, _ => false,
}; };
@ -84,6 +90,7 @@ public class MetaDictionary
_rsp.Clear(); _rsp.Clear();
_gmp.Clear(); _gmp.Clear();
_atch.Clear(); _atch.Clear();
_shp.Clear();
_globalEqp.Clear(); _globalEqp.Clear();
} }
@ -108,6 +115,7 @@ public class MetaDictionary
&& _rsp.SetEquals(other._rsp) && _rsp.SetEquals(other._rsp)
&& _gmp.SetEquals(other._gmp) && _gmp.SetEquals(other._gmp)
&& _atch.SetEquals(other._atch) && _atch.SetEquals(other._atch)
&& _shp.SetEquals(other._shp)
&& _globalEqp.SetEquals(other._globalEqp); && _globalEqp.SetEquals(other._globalEqp);
public IEnumerable<IMetaIdentifier> Identifiers public IEnumerable<IMetaIdentifier> Identifiers
@ -118,6 +126,7 @@ public class MetaDictionary
.Concat(_gmp.Keys.Cast<IMetaIdentifier>()) .Concat(_gmp.Keys.Cast<IMetaIdentifier>())
.Concat(_rsp.Keys.Cast<IMetaIdentifier>()) .Concat(_rsp.Keys.Cast<IMetaIdentifier>())
.Concat(_atch.Keys.Cast<IMetaIdentifier>()) .Concat(_atch.Keys.Cast<IMetaIdentifier>())
.Concat(_shp.Keys.Cast<IMetaIdentifier>())
.Concat(_globalEqp.Cast<IMetaIdentifier>()); .Concat(_globalEqp.Cast<IMetaIdentifier>());
#region TryAdd #region TryAdd
@ -191,6 +200,15 @@ public class MetaDictionary
return true; 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) public bool TryAdd(GlobalEqpManipulation identifier)
{ {
if (!_globalEqp.Add(identifier)) if (!_globalEqp.Add(identifier))
@ -273,6 +291,15 @@ public class MetaDictionary
return true; return true;
} }
public bool Update(ShpIdentifier identifier, in ShpEntry entry)
{
if (!_shp.ContainsKey(identifier))
return false;
_shp[identifier] = entry;
return true;
}
#endregion #endregion
#region TryGetValue #region TryGetValue
@ -298,6 +325,9 @@ public class MetaDictionary
public bool TryGetValue(AtchIdentifier identifier, out AtchEntry value) public bool TryGetValue(AtchIdentifier identifier, out AtchEntry value)
=> _atch.TryGetValue(identifier, out value); => _atch.TryGetValue(identifier, out value);
public bool TryGetValue(ShpIdentifier identifier, out ShpEntry value)
=> _shp.TryGetValue(identifier, out value);
#endregion #endregion
public bool Remove(IMetaIdentifier identifier) public bool Remove(IMetaIdentifier identifier)
@ -312,6 +342,7 @@ public class MetaDictionary
ImcIdentifier i => _imc.Remove(i), ImcIdentifier i => _imc.Remove(i),
RspIdentifier i => _rsp.Remove(i), RspIdentifier i => _rsp.Remove(i),
AtchIdentifier i => _atch.Remove(i), AtchIdentifier i => _atch.Remove(i),
ShpIdentifier i => _shp.Remove(i),
_ => false, _ => false,
}; };
if (ret) if (ret)
@ -344,6 +375,9 @@ public class MetaDictionary
foreach (var (identifier, entry) in manips._atch) foreach (var (identifier, entry) in manips._atch)
TryAdd(identifier, entry); TryAdd(identifier, entry);
foreach (var (identifier, entry) in manips._shp)
TryAdd(identifier, entry);
foreach (var identifier in manips._globalEqp) foreach (var identifier in manips._globalEqp)
TryAdd(identifier); TryAdd(identifier);
} }
@ -393,13 +427,19 @@ public class MetaDictionary
return false; 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))) foreach (var identifier in manips._globalEqp.Where(identifier => !TryAdd(identifier)))
{ {
failedIdentifier = identifier; failedIdentifier = identifier;
return false; return false;
} }
failedIdentifier = default; failedIdentifier = null;
return true; return true;
} }
@ -412,8 +452,9 @@ public class MetaDictionary
_rsp.SetTo(other._rsp); _rsp.SetTo(other._rsp);
_gmp.SetTo(other._gmp); _gmp.SetTo(other._gmp);
_atch.SetTo(other._atch); _atch.SetTo(other._atch);
_shp.SetTo(other._shp);
_globalEqp.SetTo(other._globalEqp); _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) public void UpdateTo(MetaDictionary other)
@ -425,8 +466,9 @@ public class MetaDictionary
_rsp.UpdateTo(other._rsp); _rsp.UpdateTo(other._rsp);
_gmp.UpdateTo(other._gmp); _gmp.UpdateTo(other._gmp);
_atch.UpdateTo(other._atch); _atch.UpdateTo(other._atch);
_shp.UpdateTo(other._shp);
_globalEqp.UnionWith(other._globalEqp); _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 #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) public static JObject Serialize(GlobalEqpManipulation identifier)
=> new() => new()
{ {
@ -543,6 +595,8 @@ public class MetaDictionary
return Serialize(Unsafe.As<TIdentifier, ImcIdentifier>(ref identifier), Unsafe.As<TEntry, ImcEntry>(ref entry)); return Serialize(Unsafe.As<TIdentifier, ImcIdentifier>(ref identifier), Unsafe.As<TEntry, ImcEntry>(ref entry));
if (typeof(TIdentifier) == typeof(AtchIdentifier) && typeof(TEntry) == typeof(AtchEntry)) if (typeof(TIdentifier) == typeof(AtchIdentifier) && typeof(TEntry) == typeof(AtchEntry))
return Serialize(Unsafe.As<TIdentifier, AtchIdentifier>(ref identifier), Unsafe.As<TEntry, AtchEntry>(ref entry)); return Serialize(Unsafe.As<TIdentifier, AtchIdentifier>(ref identifier), Unsafe.As<TEntry, AtchEntry>(ref entry));
if (typeof(TIdentifier) == typeof(ShpIdentifier) && typeof(TEntry) == typeof(ShpEntry))
return Serialize(Unsafe.As<TIdentifier, ShpIdentifier>(ref identifier), Unsafe.As<TEntry, ShpEntry>(ref entry));
if (typeof(TIdentifier) == typeof(GlobalEqpManipulation)) if (typeof(TIdentifier) == typeof(GlobalEqpManipulation))
return Serialize(Unsafe.As<TIdentifier, GlobalEqpManipulation>(ref identifier)); return Serialize(Unsafe.As<TIdentifier, GlobalEqpManipulation>(ref identifier));
@ -588,6 +642,7 @@ public class MetaDictionary
SerializeTo(array, value._rsp); SerializeTo(array, value._rsp);
SerializeTo(array, value._gmp); SerializeTo(array, value._gmp);
SerializeTo(array, value._atch); SerializeTo(array, value._atch);
SerializeTo(array, value._shp);
SerializeTo(array, value._globalEqp); SerializeTo(array, value._globalEqp);
array.WriteTo(writer); array.WriteTo(writer);
} }
@ -685,6 +740,16 @@ public class MetaDictionary
Penumbra.Log.Warning("Invalid ATCH Manipulation encountered."); Penumbra.Log.Warning("Invalid ATCH Manipulation encountered.");
break; break;
} }
case MetaManipulationType.Shp:
{
var identifier = ShpIdentifier.FromJson(manip);
var entry = new ShpEntry(manip["Entry"]?.Value<bool>() ?? true);
if (identifier.HasValue)
dict.TryAdd(identifier.Value, entry);
else
Penumbra.Log.Warning("Invalid SHP Manipulation encountered.");
break;
}
case MetaManipulationType.GlobalEqp: case MetaManipulationType.GlobalEqp:
{ {
var identifier = GlobalEqpManipulation.FromJson(manip); var identifier = GlobalEqpManipulation.FromJson(manip);
@ -716,6 +781,7 @@ public class MetaDictionary
_gmp = cache.Gmp.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); _rsp = cache.Rsp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry);
_atch = cache.Atch.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(); _globalEqp = cache.GlobalEqp.Select(kvp => kvp.Key).ToHashSet();
Count = cache.Count; Count = cache.Count;
} }

View file

@ -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<ShpIdentifier>, 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<string, IIdentifiedObjectData> 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<byte> 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>() ?? HumanSlot.Unknown;
var id = jObj["Id"]?.ToObject<ushort>();
var shape = jObj["Shape"]?.ToObject<string>();
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<ShpEntry>
{
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<bool>(reader));
}
}

View file

@ -1,24 +1,30 @@
using System.Reflection.Metadata.Ecma335;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Collections; using Penumbra.Collections;
using Penumbra.Communication;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Interop; using Penumbra.GameData.Interop;
using Penumbra.Services; using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Meta.Manipulations;
namespace Penumbra.Meta; namespace Penumbra.Meta;
public class ShapeManager : IRequiredService, IDisposable public class ShapeManager : IRequiredService, IDisposable
{ {
public const int NumSlots = 4; public const int NumSlots = 14;
private readonly CommunicatorService _communicator; public const int ModelSlotSize = 18;
private readonly AttributeHook _attributeHook;
private static ReadOnlySpan<byte> UsedModels public static ReadOnlySpan<HumanSlot> UsedModels
=> [1, 2, 3, 4]; =>
[
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; _attributeHook = attributeHook;
_communicator.ModelAttributeComputed.Subscribe(OnAttributeComputed, ModelAttributeComputed.Priority.ShapeManager); _attributeHook.Subscribe(OnAttributeComputed, AttributeHook.Priority.ShapeManager);
} }
private readonly Dictionary<ShapeString, short>[] _temporaryIndices = private readonly Dictionary<ShapeString, short>[] _temporaryIndices =
@ -27,38 +33,30 @@ public class ShapeManager : IRequiredService, IDisposable
private readonly uint[] _temporaryMasks = new uint[NumSlots]; private readonly uint[] _temporaryMasks = new uint[NumSlots];
private readonly uint[] _temporaryValues = new uint[NumSlots]; private readonly uint[] _temporaryValues = new uint[NumSlots];
private unsafe void OnAttributeComputed(Actor actor, Model model, ModCollection collection, HumanSlot slot) public void Dispose()
{ => _attributeHook.Unsubscribe(OnAttributeComputed);
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;
}
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; 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) for (var i = 0; i < NumSlots; ++i)
{ {
_temporaryMasks[i] = 0; _temporaryMasks[i] = 0;
@ -66,17 +64,20 @@ public class ShapeManager : IRequiredService, IDisposable
_temporaryIndices[i].Clear(); _temporaryIndices[i].Clear();
var modelIndex = UsedModels[i]; 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) if (model is null || model->ModelResourceHandle is null)
continue; continue;
ref var shapes = ref model->ModelResourceHandle->Shapes; 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)) if (ShapeString.TryRead(shape.Value, out var shapeString))
{ {
_temporaryIndices[i].TryAdd(shapeString, index); _temporaryIndices[i].TryAdd(shapeString, index);
_temporaryMasks[i] |= (ushort)(1 << 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 else
{ {
@ -85,42 +86,32 @@ public class ShapeManager : IRequiredService, IDisposable
} }
} }
UpdateMasks(); UpdateDefaultMasks();
} }
private static bool CheckShapes(ReadOnlySpan<byte> shape, byte index) private void UpdateDefaultMasks()
=> 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()
{ {
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 << topIndex;
_temporaryValues[1] |= 1u << handIndex; _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[1] |= 1u << topIndex;
_temporaryValues[2] |= 1u << legIndex; _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 << bottomIndex;
_temporaryValues[3] |= 1u << footIndex; _temporaryValues[4] |= 1u << footIndex;
} }
} }
} }

View file

@ -1,11 +1,12 @@
using Lumina.Misc; using Lumina.Misc;
using Newtonsoft.Json; using Newtonsoft.Json;
using Penumbra.GameData.Files.PhybStructs; using Penumbra.GameData.Files.PhybStructs;
using Penumbra.String.Functions;
namespace Penumbra.Meta; namespace Penumbra.Meta;
[JsonConverter(typeof(Converter))] [JsonConverter(typeof(Converter))]
public struct ShapeString : IEquatable<ShapeString> public struct ShapeString : IEquatable<ShapeString>, IComparable<ShapeString>
{ {
public const int MaxLength = 30; public const int MaxLength = 30;
@ -22,6 +23,20 @@ public struct ShapeString : IEquatable<ShapeString>
public override string ToString() public override string ToString()
=> Encoding.UTF8.GetString(_buffer[..Length]); => Encoding.UTF8.GetString(_buffer[..Length]);
public byte this[int index]
=> _buffer[index];
public unsafe ReadOnlySpan<byte> AsSpan
{
get
{
fixed (void* ptr = &this)
{
return new ReadOnlySpan<byte>(ptr, Length);
}
}
}
public bool Equals(ShapeString other) public bool Equals(ShapeString other)
=> Length == other.Length && _buffer[..Length].SequenceEqual(other._buffer[..Length]); => Length == other.Length && _buffer[..Length].SequenceEqual(other._buffer[..Length]);
@ -43,6 +58,14 @@ public struct ShapeString : IEquatable<ShapeString>
return TryRead(span, out ret); 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<byte> utf8, out ShapeString ret) public static bool TryRead(ReadOnlySpan<byte> utf8, out ShapeString ret)
{ {
if (utf8.Length is 0 or > MaxLength) if (utf8.Length is 0 or > MaxLength)
@ -69,6 +92,14 @@ public struct ShapeString : IEquatable<ShapeString>
return true; return true;
} }
public void ForceLength(byte length)
{
if (length > MaxLength)
length = MaxLength;
_buffer[length] = 0;
_buffer[31] = length;
}
private sealed class Converter : JsonConverter<ShapeString> private sealed class Converter : JsonConverter<ShapeString>
{ {
public override void WriteJson(JsonWriter writer, ShapeString value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, ShapeString value, JsonSerializer serializer)

View file

@ -81,9 +81,6 @@ public class CommunicatorService : IDisposable, IService
/// <inheritdoc cref="Communication.ResolvedFileChanged"/> /// <inheritdoc cref="Communication.ResolvedFileChanged"/>
public readonly ResolvedFileChanged ResolvedFileChanged = new(); public readonly ResolvedFileChanged ResolvedFileChanged = new();
/// <inheritdoc cref="Communication.ModelAttributeComputed"/>
public readonly ModelAttributeComputed ModelAttributeComputed = new();
public void Dispose() public void Dispose()
{ {
CollectionChange.Dispose(); CollectionChange.Dispose();
@ -108,6 +105,5 @@ public class CommunicatorService : IDisposable, IService
ChangedItemClick.Dispose(); ChangedItemClick.Dispose();
SelectTab.Dispose(); SelectTab.Dispose();
ResolvedFileChanged.Dispose(); ResolvedFileChanged.Dispose();
ModelAttributeComputed.Dispose();
} }
} }

View file

@ -11,7 +11,8 @@ public class MetaDrawers(
GmpMetaDrawer gmp, GmpMetaDrawer gmp,
ImcMetaDrawer imc, ImcMetaDrawer imc,
RspMetaDrawer rsp, RspMetaDrawer rsp,
AtchMetaDrawer atch) : IService AtchMetaDrawer atch,
ShpMetaDrawer shp) : IService
{ {
public readonly EqdpMetaDrawer Eqdp = eqdp; public readonly EqdpMetaDrawer Eqdp = eqdp;
public readonly EqpMetaDrawer Eqp = eqp; public readonly EqpMetaDrawer Eqp = eqp;
@ -21,6 +22,7 @@ public class MetaDrawers(
public readonly ImcMetaDrawer Imc = imc; public readonly ImcMetaDrawer Imc = imc;
public readonly GlobalEqpMetaDrawer GlobalEqp = globalEqp; public readonly GlobalEqpMetaDrawer GlobalEqp = globalEqp;
public readonly AtchMetaDrawer Atch = atch; public readonly AtchMetaDrawer Atch = atch;
public readonly ShpMetaDrawer Shp = shp;
public IMetaDrawer? Get(MetaManipulationType type) public IMetaDrawer? Get(MetaManipulationType type)
=> type switch => type switch
@ -32,6 +34,7 @@ public class MetaDrawers(
MetaManipulationType.Gmp => Gmp, MetaManipulationType.Gmp => Gmp,
MetaManipulationType.Rsp => Rsp, MetaManipulationType.Rsp => Rsp,
MetaManipulationType.Atch => Atch, MetaManipulationType.Atch => Atch,
MetaManipulationType.Shp => Shp,
MetaManipulationType.GlobalEqp => GlobalEqp, MetaManipulationType.GlobalEqp => GlobalEqp,
_ => null, _ => null,
}; };

View file

@ -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<ShpIdentifier, ShpEntry>(editor, metaFiles), IService
{
public override ReadOnlySpan<byte> 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<JToken?>(() => 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<byte>(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<HumanSlot> 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<byte> 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,
};
}

View file

@ -61,6 +61,7 @@ public partial class ModEditWindow
DrawEditHeader(MetaManipulationType.Gmp); DrawEditHeader(MetaManipulationType.Gmp);
DrawEditHeader(MetaManipulationType.Rsp); DrawEditHeader(MetaManipulationType.Rsp);
DrawEditHeader(MetaManipulationType.Atch); DrawEditHeader(MetaManipulationType.Atch);
DrawEditHeader(MetaManipulationType.Shp);
DrawEditHeader(MetaManipulationType.GlobalEqp); DrawEditHeader(MetaManipulationType.GlobalEqp);
} }

View file

@ -5,10 +5,12 @@ using OtterGui.Services;
using OtterGui.Text; using OtterGui.Text;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Interop; using Penumbra.GameData.Interop;
using Penumbra.Interop.PathResolving;
using Penumbra.Meta;
namespace Penumbra.UI.Tabs.Debug; namespace Penumbra.UI.Tabs.Debug;
public class ShapeInspector(ObjectManager objects) : IUiService public class ShapeInspector(ObjectManager objects, CollectionResolver resolver) : IUiService
{ {
private int _objectIndex = 0; private int _objectIndex = 0;
@ -29,42 +31,92 @@ public class ShapeInspector(ObjectManager objects) : IUiService
return; return;
} }
using var table = ImUtf8.Table("##table"u8, 4, ImGuiTableFlags.RowBg); var data = resolver.IdentifyCollection(actor.AsObject, true);
if (!table) using (var treeNode1 = ImUtf8.TreeNode($"Collection Shape Cache ({data.ModCollection})"))
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<HumanSlot>())
{ {
ImUtf8.DrawTableColumn($"{(uint)slot:D2}"); if (treeNode1.Success && data.ModCollection.HasCache)
ImGui.TableNextColumn();
var model = human.AsHuman->Models[(int)slot];
Penumbra.Dynamis.DrawPointer((nint)model);
if (model is not null)
{ {
var mask = model->EnabledShapeKeyIndexMask; using var table = ImUtf8.Table("##cacheTable"u8, 2, ImGuiTableFlags.RowBg);
ImUtf8.DrawTableColumn($"{mask:X8}"); if (!table)
ImGui.TableNextColumn(); return;
foreach (var (shape, idx) in model->ModelResourceHandle->Shapes)
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; ImUtf8.DrawTableColumn(shape.AsSpan);
using var color = ImRaii.PushColor(ImGuiCol.Text, disabledColor, disabled); if (set.All)
ImUtf8.Text(shape.AsSpan()); {
ImGui.SameLine(0, 0); ImUtf8.DrawTableColumn("All"u8);
ImUtf8.Text(", "u8); }
if ((idx % 8) < 7) else
ImGui.SameLine(0, 0); {
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(); using var table = ImUtf8.Table("##table"u8, 5, ImGuiTableFlags.RowBg);
ImGui.TableNextColumn(); 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();
}
}
} }
} }
} }

View file

@ -51,7 +51,7 @@ public class SettingsTab : ITab, IUiService
private readonly MigrationSectionDrawer _migrationDrawer; private readonly MigrationSectionDrawer _migrationDrawer;
private readonly CollectionAutoSelector _autoSelector; private readonly CollectionAutoSelector _autoSelector;
private readonly CleanupService _cleanupService; private readonly CleanupService _cleanupService;
private readonly AttributeHooks _attributeHooks; private readonly AttributeHook _attributeHook;
private int _minimumX = int.MaxValue; private int _minimumX = int.MaxValue;
private int _minimumY = int.MaxValue; private int _minimumY = int.MaxValue;
@ -64,7 +64,7 @@ public class SettingsTab : ITab, IUiService
DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig,
IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService,
MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService,
AttributeHooks attributeHooks) AttributeHook attributeHook)
{ {
_pluginInterface = pluginInterface; _pluginInterface = pluginInterface;
_config = config; _config = config;
@ -89,7 +89,7 @@ public class SettingsTab : ITab, IUiService
_migrationDrawer = migrationDrawer; _migrationDrawer = migrationDrawer;
_autoSelector = autoSelector; _autoSelector = autoSelector;
_cleanupService = cleanupService; _cleanupService = cleanupService;
_attributeHooks = attributeHooks; _attributeHook = attributeHook;
} }
public void DrawHeader() 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."); ImGuiUtil.LabeledHelpMarker("Sort Mode", "Choose the sort mode for the mod selector in the mods tab.");
} }
private float _absoluteSelectorSize = float.NaN;
/// <summary> Draw a selector for the absolute size of the mod selector in pixels. </summary>
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;
/// <summary> Draw a selector for the relative size of the mod selector as a percentage and a toggle to enable relative sizing. </summary>
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() private void DrawRenameSettings()
{ {
ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X);
@ -607,8 +558,6 @@ public class SettingsTab : ITab, IUiService
private void DrawModSelectorSettings() private void DrawModSelectorSettings()
{ {
DrawFolderSortType(); DrawFolderSortType();
DrawAbsoluteSizeSelector();
DrawRelativeSizeSelector();
DrawRenameSettings(); DrawRenameSettings();
Checkbox("Open Folders by Default", "Whether to start with all folders collapsed or expanded in the mod selector.", Checkbox("Open Folders by Default", "Whether to start with all folders collapsed or expanded in the mod selector.",
_config.OpenFoldersByDefault, v => _config.OpenFoldersByDefault, v =>
@ -626,7 +575,8 @@ public class SettingsTab : ITab, IUiService
_config.Save(); _config.Save();
}); });
Widget.DoubleModifierSelector("Incognito Modifier", 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, _config.IncognitoModifier,
v => 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. " "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.", + "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); _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.", Checkbox("Enable Custom Shape Support", "Penumbra will allow for custom shape keys for modded models to be considered and combined.",
_config.EnableAttributeHooks, _attributeHooks.SetState); _config.EnableCustomShapes, _attributeHook.SetState);
DrawWaitForPluginsReflection(); DrawWaitForPluginsReflection();
DrawEnableHttpApiBox(); DrawEnableHttpApiBox();
DrawEnableDebugModeBox(); DrawEnableDebugModeBox();

View file

@ -3,7 +3,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"Type": { "Type": {
"enum": [ "Unknown", "Imc", "Eqdp", "Eqp", "Est", "Gmp", "Rsp", "GlobalEqp", "Atch" ] "enum": [ "Unknown", "Imc", "Eqdp", "Eqp", "Est", "Gmp", "Rsp", "GlobalEqp", "Atch", "Shp" ]
}, },
"Manipulation": { "Manipulation": {
"type": "object" "type": "object"
@ -90,6 +90,16 @@
"$ref": "meta_atch.json" "$ref": "meta_atch.json"
} }
} }
},
{
"properties": {
"Type": {
"const": "Shp"
},
"Manipulation": {
"$ref": "meta_shp.json"
}
}
} }
] ]
} }

View file

@ -5,6 +5,10 @@
"$anchor": "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" ] "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": { "Gender": {
"$anchor": "Gender", "$anchor": "Gender",
"enum": [ "Unknown", "Male", "Female", "MaleNpc", "FemaleNpc" ] "enum": [ "Unknown", "Male", "Female", "MaleNpc", "FemaleNpc" ]

View file

@ -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"
]
}