Add initial support for custom shapes.

This commit is contained in:
Ottermandias 2025-05-15 00:26:59 +02:00
parent 0fe4a3671a
commit 0adec35848
15 changed files with 502 additions and 75 deletions

@ -1 +1 @@
Subproject commit 089ed82a53dc75b0d3be469d2a005e6096c4b2d2
Subproject commit 86b492422565abde2e8ad17c0295896a21c3439c

@ -1 +1 @@
Subproject commit 002260d9815e571f1496c50374f5b712818e9880
Subproject commit 0ca501050de72ee1cc7382dfae894f984ce241b6

View file

@ -0,0 +1,24 @@
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

@ -67,6 +67,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public bool HideRedrawBar { get; set; } = false;
public bool HideMachinistOffhandFromChangedItems { get; set; } = true;
public bool DefaultTemporaryMode { get; set; } = false;
public bool EnableCustomShapes { get; set; } = true;
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;
public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed;
public int OptionGroupCollapsibleMin { get; set; } = 5;
@ -84,9 +85,6 @@ public class Configuration : IPluginConfiguration, ISavable, IService
[JsonProperty(Order = int.MaxValue)]
public ISortMode<Mod> SortMode = ISortMode<Mod>.FoldersFirst;
public bool ScaleModSelector { get; set; } = false;
public float ModSelectorAbsoluteSize { get; set; } = Constants.DefaultAbsoluteSize;
public int ModSelectorScaledSize { get; set; } = Constants.DefaultScaledSize;
public bool OpenFoldersByDefault { get; set; } = false;
public int SingleGroupRadioMax { get; set; } = 2;
public string DefaultImportFolder { get; set; } = string.Empty;

View file

@ -0,0 +1,110 @@
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

@ -246,28 +246,6 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
return ret;
}
[StructLayout(LayoutKind.Explicit)]
private struct ChangedEquipData
{
[FieldOffset(0)]
public PrimaryId Model;
[FieldOffset(2)]
public Variant Variant;
[FieldOffset(8)]
public PrimaryId BonusModel;
[FieldOffset(10)]
public Variant BonusVariant;
[FieldOffset(20)]
public ushort VfxId;
[FieldOffset(22)]
public GenderRace GenderRace;
}
private nint ResolveVfxHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint unkOutParam)
{
switch (slotIndex)

View file

@ -0,0 +1,127 @@
using OtterGui.Services;
using Penumbra.Collections;
using Penumbra.Communication;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Interop;
using Penumbra.Services;
namespace Penumbra.Meta;
public class ShapeManager : IRequiredService, IDisposable
{
public const int NumSlots = 4;
private readonly CommunicatorService _communicator;
private static ReadOnlySpan<byte> UsedModels
=> [1, 2, 3, 4];
public ShapeManager(CommunicatorService communicator)
{
_communicator = communicator;
_communicator.ModelAttributeComputed.Subscribe(OnAttributeComputed, ModelAttributeComputed.Priority.ShapeManager);
}
private readonly Dictionary<ShapeString, short>[] _temporaryIndices =
Enumerable.Range(0, NumSlots).Select(_ => new Dictionary<ShapeString, short>()).ToArray();
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;
}
if (_temporaryMasks[index] is 0)
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;
_temporaryValues[i] = 0;
_temporaryIndices[i].Clear();
var modelIndex = UsedModels[i];
var model = human.AsHuman->Models[modelIndex];
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)))
{
if (ShapeString.TryRead(shape.Value, out var shapeString))
{
_temporaryIndices[i].TryAdd(shapeString, index);
_temporaryMasks[i] |= (ushort)(1 << index);
}
else
{
Penumbra.Log.Warning($"Trying to read a shape string that is too long: {shape}.");
}
}
}
UpdateMasks();
}
private static bool CheckShapes(ReadOnlySpan<byte> 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()
{
foreach (var (shape, topIndex) in _temporaryIndices[0])
{
if (_temporaryIndices[1].TryGetValue(shape, out var handIndex))
{
_temporaryValues[0] |= 1u << topIndex;
_temporaryValues[1] |= 1u << handIndex;
}
if (_temporaryIndices[2].TryGetValue(shape, out var legIndex))
{
_temporaryValues[0] |= 1u << topIndex;
_temporaryValues[2] |= 1u << legIndex;
}
}
foreach (var (shape, bottomIndex) in _temporaryIndices[2])
{
if (_temporaryIndices[3].TryGetValue(shape, out var footIndex))
{
_temporaryValues[2] |= 1u << bottomIndex;
_temporaryValues[3] |= 1u << footIndex;
}
}
}
}

View file

@ -0,0 +1,89 @@
using Lumina.Misc;
using Newtonsoft.Json;
using Penumbra.GameData.Files.PhybStructs;
namespace Penumbra.Meta;
[JsonConverter(typeof(Converter))]
public struct ShapeString : IEquatable<ShapeString>
{
public const int MaxLength = 30;
public static readonly ShapeString Empty = new();
private FixedString32 _buffer;
public int Count
=> _buffer[31];
public int Length
=> _buffer[31];
public override string ToString()
=> Encoding.UTF8.GetString(_buffer[..Length]);
public bool Equals(ShapeString other)
=> Length == other.Length && _buffer[..Length].SequenceEqual(other._buffer[..Length]);
public override bool Equals(object? obj)
=> obj is ShapeString other && Equals(other);
public override int GetHashCode()
=> (int)Crc32.Get(_buffer[..Length]);
public static bool operator ==(ShapeString left, ShapeString right)
=> left.Equals(right);
public static bool operator !=(ShapeString left, ShapeString right)
=> !left.Equals(right);
public static unsafe bool TryRead(byte* pointer, out ShapeString ret)
{
var span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(pointer);
return TryRead(span, out ret);
}
public static bool TryRead(ReadOnlySpan<byte> utf8, out ShapeString ret)
{
if (utf8.Length is 0 or > MaxLength)
{
ret = Empty;
return false;
}
ret = Empty;
utf8.CopyTo(ret._buffer);
ret._buffer[utf8.Length] = 0;
ret._buffer[31] = (byte)utf8.Length;
return true;
}
public static bool TryRead(ReadOnlySpan<char> utf16, out ShapeString ret)
{
ret = Empty;
if (!Encoding.UTF8.TryGetBytes(utf16, ret._buffer[..MaxLength], out var written))
return false;
ret._buffer[written] = 0;
ret._buffer[31] = (byte)written;
return true;
}
private sealed class Converter : JsonConverter<ShapeString>
{
public override void WriteJson(JsonWriter writer, ShapeString value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
public override ShapeString ReadJson(JsonReader reader, Type objectType, ShapeString existingValue, bool hasExistingValue,
JsonSerializer serializer)
{
var value = serializer.Deserialize<string>(reader);
if (!TryRead(value, out existingValue))
throw new JsonReaderException($"Could not parse {value} into ShapeString.");
return existingValue;
}
}
}

View file

@ -34,6 +34,7 @@ public class Penumbra : IDalamudPlugin
public static readonly Logger Log = new();
public static MessageService Messager { get; private set; } = null!;
public static DynamisIpc Dynamis { get; private set; } = null!;
private readonly ValidityChecker _validityChecker;
private readonly ResidentResourceManager _residentResources;
@ -60,6 +61,7 @@ public class Penumbra : IDalamudPlugin
// Invoke the IPC Penumbra.Launching method before any hooks or other services are created.
_services.GetService<IpcLaunchingProvider>();
Messager = _services.GetService<MessageService>();
Dynamis = _services.GetService<DynamisIpc>();
_validityChecker = _services.GetService<ValidityChecker>();
_services.EnsureRequiredServices();
@ -228,6 +230,7 @@ public class Penumbra : IDalamudPlugin
sb.Append($"> **`Debug Mode: `** {_config.DebugMode}\n");
sb.Append($"> **`Penumbra Reloads: `** {hdrEnabler.PenumbraReloadCount}\n");
sb.Append($"> **`HDR Enabled (from Start): `** {_config.HdrRenderTargets} ({hdrEnabler is { FirstLaunchHdrState: true, FirstLaunchHdrHookOverrideState: true }}){(hdrEnabler.HdrEnabledSuccess ? ", Detour Called" : ", **NEVER CALLED**")}\n");
sb.Append($"> **`Custom Shapes Enabled: `** {_config.EnableCustomShapes}\n");
sb.Append($"> **`Hook Overrides: `** {HookOverrides.Instance.IsCustomLoaded}\n");
sb.Append($"> **`Synchronous Load (Dalamud): `** {(_services.GetService<DalamudConfigService>().GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool v) ? v.ToString() : "Unknown")} (first Start: {hdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"})\n");
sb.Append(

View file

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

View file

@ -108,6 +108,7 @@ public class DebugTab : Window, ITab, IUiService
private readonly ObjectIdentification _objectIdentification;
private readonly RenderTargetDrawer _renderTargetDrawer;
private readonly ModMigratorDebug _modMigratorDebug;
private readonly ShapeInspector _shapeInspector;
public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, ObjectManager objects,
IClientState clientState, IDataManager dataManager,
@ -119,7 +120,7 @@ public class DebugTab : Window, ITab, IUiService
Diagnostics diagnostics, IpcTester ipcTester, CrashHandlerPanel crashHandlerPanel, TexHeaderDrawer texHeaderDrawer,
HookOverrideDrawer hookOverrides, RsfService rsfService, GlobalVariablesDrawer globalVariablesDrawer,
SchedulerResourceManagementService schedulerService, ObjectIdentification objectIdentification, RenderTargetDrawer renderTargetDrawer,
ModMigratorDebug modMigratorDebug)
ModMigratorDebug modMigratorDebug, ShapeInspector shapeInspector)
: base("Penumbra Debug Window", ImGuiWindowFlags.NoCollapse)
{
IsOpen = true;
@ -162,6 +163,7 @@ public class DebugTab : Window, ITab, IUiService
_objectIdentification = objectIdentification;
_renderTargetDrawer = renderTargetDrawer;
_modMigratorDebug = modMigratorDebug;
_shapeInspector = shapeInspector;
_objects = objects;
_clientState = clientState;
_dataManager = dataManager;
@ -508,6 +510,10 @@ public class DebugTab : Window, ITab, IUiService
if (!ImGui.CollapsingHeader("Actors"))
return;
using (var objectTree = ImUtf8.TreeNode("Object Manager"u8))
{
if (objectTree)
{
_objects.DrawDebug();
using var table = Table("##actors", 8, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit,
@ -524,22 +530,31 @@ public class DebugTab : Window, ITab, IUiService
{
ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"{((GameObject*)obj.Address)->ObjectIndex}" : "NULL");
ImGui.TableNextColumn();
ImGuiUtil.CopyOnClickSelectable($"0x{obj.Address:X}");
Penumbra.Dynamis.DrawPointer(obj.Address);
ImGui.TableNextColumn();
if (obj.Address != nint.Zero)
ImGuiUtil.CopyOnClickSelectable($"0x{(nint)((Character*)obj.Address)->GameObject.GetDrawObject():X}");
Penumbra.Dynamis.DrawPointer((nint)((Character*)obj.Address)->GameObject.GetDrawObject());
var identifier = _actors.FromObject(obj, out _, false, true, false);
ImGuiUtil.DrawTableColumn(_actors.ToString(identifier));
var id = obj.AsObject->ObjectKind is ObjectKind.BattleNpc
? $"{identifier.DataId} | {obj.AsObject->BaseId}"
: identifier.DataId.ToString();
ImGuiUtil.DrawTableColumn(id);
ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"0x{*(nint*)obj.Address:X}" : "NULL");
ImGui.TableNextColumn();
Penumbra.Dynamis.DrawPointer(obj.Address != nint.Zero ? *(nint*)obj.Address : nint.Zero);
ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"0x{obj.AsObject->EntityId:X}" : "NULL");
ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero
? obj.AsObject->IsCharacter() ? $"Character: {obj.AsCharacter->ObjectKind}" : "No Character"
: "NULL");
}
}
}
using (var shapeTree = ImUtf8.TreeNode("Shape Inspector"u8))
{
if (shapeTree)
_shapeInspector.Draw();
}
return;
@ -1184,7 +1199,15 @@ public class DebugTab : Window, ITab, IUiService
/// <summary> Draw information about IPC options and availability. </summary>
private void DrawDebugTabIpc()
{
if (ImGui.CollapsingHeader("IPC"))
if (!ImUtf8.CollapsingHeader("IPC"u8))
return;
using (var tree = ImUtf8.TreeNode("Dynamis"u8))
{
if (tree)
Penumbra.Dynamis.DrawDebugInfo();
}
_ipcTester.Draw();
}

View file

@ -0,0 +1,71 @@
using Dalamud.Interface;
using Dalamud.Interface.Utility.Raii;
using ImGuiNET;
using OtterGui.Services;
using OtterGui.Text;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Interop;
namespace Penumbra.UI.Tabs.Debug;
public class ShapeInspector(ObjectManager objects) : IUiService
{
private int _objectIndex = 0;
public unsafe void Draw()
{
ImUtf8.InputScalar("Object Index"u8, ref _objectIndex);
var actor = objects[0];
if (!actor.IsCharacter)
{
ImUtf8.Text("No valid character."u8);
return;
}
var human = actor.Model;
if (!human.IsHuman)
{
ImUtf8.Text("No valid character."u8);
return;
}
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<HumanSlot>())
{
ImUtf8.DrawTableColumn($"{(uint)slot:D2}");
ImGui.TableNextColumn();
var model = human.AsHuman->Models[(int)slot];
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

@ -14,6 +14,7 @@ using OtterGui.Text;
using OtterGui.Widgets;
using Penumbra.Api;
using Penumbra.Collections;
using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Interop.Services;
using Penumbra.Mods.Manager;
using Penumbra.Services;
@ -50,6 +51,7 @@ public class SettingsTab : ITab, IUiService
private readonly MigrationSectionDrawer _migrationDrawer;
private readonly CollectionAutoSelector _autoSelector;
private readonly CleanupService _cleanupService;
private readonly AttributeHooks _attributeHooks;
private int _minimumX = int.MaxValue;
private int _minimumY = int.MaxValue;
@ -61,7 +63,8 @@ public class SettingsTab : ITab, IUiService
CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, HttpApi httpApi,
DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig,
IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService,
MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService)
MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService,
AttributeHooks attributeHooks)
{
_pluginInterface = pluginInterface;
_config = config;
@ -86,6 +89,7 @@ public class SettingsTab : ITab, IUiService
_migrationDrawer = migrationDrawer;
_autoSelector = autoSelector;
_cleanupService = cleanupService;
_attributeHooks = attributeHooks;
}
public void DrawHeader()
@ -807,6 +811,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);
DrawWaitForPluginsReflection();
DrawEnableHttpApiBox();
DrawEnableDebugModeBox();

View file

@ -2,12 +2,6 @@
"version": 1,
"dependencies": {
"net9.0-windows7.0": {
"DalamudPackager": {
"type": "Direct",
"requested": "[12.0.0, )",
"resolved": "12.0.0",
"contentHash": "J5TJLV3f16T/E2H2P17ClWjtfEBPpq3yxvqW46eN36JCm6wR+EaoaYkqG9Rm5sHqs3/nK/vKjWWyvEs/jhKoXw=="
},
"DotNet.ReproducibleBuilds": {
"type": "Direct",
"requested": "[1.2.25, )",