diff --git a/Glamourer/GameData/CustomizeParameterValue.cs b/Glamourer/GameData/CustomizeParameterValue.cs
index e1e0943..0e22d18 100644
--- a/Glamourer/GameData/CustomizeParameterValue.cs
+++ b/Glamourer/GameData/CustomizeParameterValue.cs
@@ -1,6 +1,4 @@
-using Newtonsoft.Json;
-
-namespace Glamourer.GameData;
+namespace Glamourer.GameData;
public readonly struct CustomizeParameterValue
{
diff --git a/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs b/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs
index bef2ea5..68eb89e 100644
--- a/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs
+++ b/Glamourer/Gui/Tabs/ActorTab/ActorPanel.cs
@@ -8,6 +8,7 @@ using Glamourer.Designs;
using Glamourer.Gui.Customization;
using Glamourer.Gui.Equipment;
using Glamourer.Interop;
+using Glamourer.Interop.Material;
using Glamourer.Interop.Structs;
using Glamourer.State;
using ImGuiNET;
@@ -97,6 +98,12 @@ public class ActorPanel(
return (_selector.IncognitoMode ? _identifier.Incognito(null) : _identifier.ToString(), Actor.Null);
}
+ private Vector3 _test;
+ private int _rowId;
+ private MaterialValueIndex.ColorTableIndex _index;
+ private int _materialId;
+ private int _slotId;
+
private unsafe void DrawPanel()
{
using var child = ImRaii.Child("##Panel", -Vector2.One, true);
@@ -114,6 +121,17 @@ public class ActorPanel(
RevertButtons();
+ ImGui.InputInt("Row", ref _rowId);
+ ImGui.InputInt("Material", ref _materialId);
+ ImGui.InputInt("Slot", ref _slotId);
+ ImGuiUtil.GenericEnumCombo("Value", 300, _index, out _index);
+
+ var index = new MaterialValueIndex(MaterialValueIndex.DrawObjectType.Human, (byte) _slotId, (byte) _materialId, (byte)_rowId, _index);
+ index.TryGetValue(_actor, out _test);
+ if (ImGui.ColorPicker3("TestPicker", ref _test) && _actor.Valid)
+ MaterialService.Test(_actor, index, _test);
+
+
using var disabled = ImRaii.Disabled(transformationId != 0);
if (_state.ModelData.IsHuman)
DrawHumanPanel();
diff --git a/Glamourer/Interop/Material/DirectXTextureHelper.cs b/Glamourer/Interop/Material/DirectXTextureHelper.cs
new file mode 100644
index 0000000..9932abc
--- /dev/null
+++ b/Glamourer/Interop/Material/DirectXTextureHelper.cs
@@ -0,0 +1,116 @@
+using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
+using Penumbra.GameData.Files;
+using Penumbra.String.Functions;
+using SharpGen.Runtime;
+using Vortice.Direct3D11;
+using Vortice.DXGI;
+using MapFlags = Vortice.Direct3D11.MapFlags;
+
+namespace Glamourer.Interop.Material;
+
+public static unsafe class DirectXTextureHelper
+{
+ /// Try to turn a color table GPU-loaded texture (R16G16B16A16Float, 4 Width, 16 Height) into an actual color table.
+ /// A pointer to the internal texture struct containing the GPU handle.
+ /// The returned color table.
+ /// Whether the table could be fetched.
+ public static bool TryGetColorTable(Texture* texture, out MtrlFile.ColorTable table)
+ {
+ if (texture == null)
+ {
+ table = default;
+ return false;
+ }
+
+ try
+ {
+ // Create direct x resource and ensure that it is kept alive.
+ using var tex = new ID3D11Texture2D1((nint)texture->D3D11Texture2D);
+ tex.AddRef();
+
+ table = GetResourceData(tex, CreateStagedClone, GetTextureData);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// Create a staging clone of the existing texture handle for stability reasons.
+ private static ID3D11Texture2D1 CreateStagedClone(ID3D11Texture2D1 resource)
+ {
+ var desc = resource.Description1 with
+ {
+ Usage = ResourceUsage.Staging,
+ BindFlags = 0,
+ CPUAccessFlags = CpuAccessFlags.Read,
+ MiscFlags = 0,
+ };
+
+ return resource.Device.As().CreateTexture2D1(desc);
+ }
+
+ /// Turn a mapped texture into a color table.
+ private static MtrlFile.ColorTable GetTextureData(ID3D11Texture2D1 resource, MappedSubresource map)
+ {
+ var desc = resource.Description1;
+
+ if (desc.Format is not Format.R16G16B16A16_Float
+ || desc.Width != MaterialService.TextureWidth
+ || desc.Height != MaterialService.TextureHeight
+ || map.DepthPitch != map.RowPitch * desc.Height)
+ throw new InvalidDataException("The texture was not a valid color table texture.");
+
+ return ReadTexture(map.DataPointer, map.DepthPitch, desc.Height, map.RowPitch);
+ }
+
+ /// Transform the GPU data into the color table.
+ /// The pointer to the raw texture data.
+ /// The size of the raw texture data.
+ /// The height of the texture. (Needs to be 16).
+ /// The stride in the texture data.
+ ///
+ private static MtrlFile.ColorTable ReadTexture(nint data, int length, int height, int pitch)
+ {
+ // Check that the data has sufficient dimension and size.
+ var expectedSize = sizeof(Half) * MaterialService.TextureWidth * height * 4;
+ if (length < expectedSize || sizeof(MtrlFile.ColorTable) != expectedSize || height != MaterialService.TextureHeight)
+ return default;
+
+ var ret = new MtrlFile.ColorTable();
+ var target = (byte*)&ret;
+ // If the stride is the same as in the table, just copy.
+ if (pitch == MaterialService.TextureWidth)
+ MemoryUtility.MemCpyUnchecked(target, (void*)data, length);
+ // Otherwise, adapt the stride.
+ else
+
+ for (var y = 0; y < height; ++y)
+ {
+ MemoryUtility.MemCpyUnchecked(target + y * MaterialService.TextureWidth * sizeof(Half) * 4, (byte*)data + y * pitch,
+ MaterialService.TextureWidth * sizeof(Half) * 4);
+ }
+
+ return ret;
+ }
+
+ /// Get resources of a texture.
+ private static TRet GetResourceData(T res, Func cloneResource, Func getData)
+ where T : ID3D11Resource
+ {
+ using var stagingRes = cloneResource(res);
+
+ res.Device.ImmediateContext.CopyResource(stagingRes, res);
+ stagingRes.Device.ImmediateContext.Map(stagingRes, 0, MapMode.Read, MapFlags.None, out var mapInfo).CheckError();
+
+ try
+ {
+ return getData(stagingRes, mapInfo);
+ }
+ finally
+ {
+ stagingRes.Device.ImmediateContext.Unmap(stagingRes, 0);
+ }
+ }
+}
diff --git a/Glamourer/Interop/Material/MaterialService.cs b/Glamourer/Interop/Material/MaterialService.cs
new file mode 100644
index 0000000..91635c5
--- /dev/null
+++ b/Glamourer/Interop/Material/MaterialService.cs
@@ -0,0 +1,130 @@
+using Dalamud.Interface.Utility.Raii;
+using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
+using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
+using Glamourer.Interop.Structs;
+using ImGuiNET;
+using Lumina.Data.Files;
+using OtterGui;
+using OtterGui.Services;
+using Penumbra.GameData.Actors;
+using Penumbra.GameData.Enums;
+using static Penumbra.GameData.Files.MtrlFile;
+using Texture = FFXIVClientStructs.FFXIV.Client.Graphics.Kernel.Texture;
+
+namespace Glamourer.Interop.Material;
+
+
+public class MaterialServiceDrawer(ActorManager actors) : IService
+{
+ private ActorIdentifier _openIdentifier;
+ private uint _openSlotIndex;
+
+ public unsafe void PopupButton(Actor actor, EquipSlot slot)
+ {
+ var slotIndex = slot.ToIndex();
+
+ var identifier = actor.GetIdentifier(actors);
+ var buttonActive = actor.Valid
+ && identifier.IsValid
+ && actor.Model.IsCharacterBase
+ && slotIndex < actor.Model.AsCharacterBase->SlotCount
+ && (actor.Model.AsCharacterBase->HasModelInSlotLoaded & (1 << (int)slotIndex)) != 0;
+ using var id = ImRaii.PushId((int)slot);
+ if (ImGuiUtil.DrawDisabledButton("Advanced", Vector2.Zero, "Open advanced window.", !buttonActive))
+ {
+ _openIdentifier = identifier;
+ _openSlotIndex = slotIndex;
+ ImGui.OpenPopup($"Popup{slot}");
+ }
+ }
+}
+
+public static unsafe class MaterialService
+{
+ public const int TextureWidth = 4;
+ public const int TextureHeight = ColorTable.NumRows;
+ public const int MaterialsPerModel = 4;
+
+ /// Generate a color table the way the game does inside the original texture, and release the original.
+ /// The original texture that will be replaced with a new one.
+ /// The input color table.
+ /// Success or failure.
+ public static bool GenerateColorTable(Texture** original, in ColorTable colorTable)
+ {
+ if (original == null)
+ return false;
+
+ var textureSize = stackalloc int[2];
+ textureSize[0] = TextureWidth;
+ textureSize[1] = TextureHeight;
+
+ using var texture = new SafeTextureHandle(Device.Instance()->CreateTexture2D(textureSize, 1, (uint)TexFile.TextureFormat.R16G16B16A16F,
+ (uint)(TexFile.Attribute.TextureType2D | TexFile.Attribute.Managed | TexFile.Attribute.Immutable), 7), false);
+ if (texture.IsInvalid)
+ return false;
+
+ fixed(ColorTable* ptr = &colorTable)
+ {
+ if (!texture.Texture->InitializeContents(ptr))
+ return false;
+ }
+
+ texture.Exchange(ref *(nint*)original);
+ return true;
+ }
+
+
+ /// Obtain a pointer to the models pointer to a specific color table texture.
+ ///
+ ///
+ ///
+ ///
+ public static Texture** GetColorTableTexture(Model model, int modelSlot, byte materialSlot)
+ {
+ if (!model.IsCharacterBase)
+ return null;
+
+ var index = modelSlot * MaterialsPerModel + materialSlot;
+ if (index < 0 || index >= model.AsCharacterBase->ColorTableTexturesSpan.Length)
+ return null;
+
+ var texture = (Texture**)Unsafe.AsPointer(ref model.AsCharacterBase->ColorTableTexturesSpan[index]);
+ return texture;
+ }
+
+ /// Obtain a pointer to the color table of a certain material from a model.
+ /// The draw object.
+ /// The model slot.
+ /// The material slot in the model.
+ /// A pointer to the color table or null.
+ public static ColorTable* GetMaterialColorTable(Model model, int modelSlot, byte materialSlot)
+ {
+ if (!model.IsCharacterBase)
+ return null;
+
+ var index = modelSlot * MaterialsPerModel + materialSlot;
+ if (index < 0 || index >= model.AsCharacterBase->MaterialsSpan.Length)
+ return null;
+
+ var material = (MaterialResourceHandle*)model.AsCharacterBase->MaterialsSpan[index].Value;
+ if (material == null || material->ColorTable == null)
+ return null;
+
+ return (ColorTable*)material->ColorTable;
+ }
+
+ public static void Test(Actor actor, MaterialValueIndex index, Vector3 value)
+ {
+ if (!index.TryGetColorTable(actor, out var table))
+ return;
+
+ ref var row = ref table[index.RowIndex];
+ if (!index.DataIndex.SetValue(ref row, value))
+ return;
+
+ var texture = GetColorTableTexture(index.TryGetModel(actor, out var model) ? model : Model.Null, index.SlotIndex,
+ index.MaterialIndex);
+ if (texture != null)
+ GenerateColorTable(texture, table);
+ }
+}
diff --git a/Glamourer/Interop/Material/MaterialValueIndex.cs b/Glamourer/Interop/Material/MaterialValueIndex.cs
new file mode 100644
index 0000000..4cbc116
--- /dev/null
+++ b/Glamourer/Interop/Material/MaterialValueIndex.cs
@@ -0,0 +1,266 @@
+using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
+using FFXIVClientStructs.Interop;
+using Glamourer.Interop.Structs;
+using Newtonsoft.Json;
+using Penumbra.GameData.Files;
+
+namespace Glamourer.Interop.Material;
+
+[JsonConverter(typeof(Converter))]
+public readonly record struct MaterialValueIndex(
+ MaterialValueIndex.DrawObjectType DrawObject,
+ byte SlotIndex,
+ byte MaterialIndex,
+ byte RowIndex,
+ MaterialValueIndex.ColorTableIndex DataIndex)
+{
+ public uint Key
+ => ToKey(DrawObject, SlotIndex, MaterialIndex, RowIndex, DataIndex);
+
+ public bool Valid
+ => Validate(DrawObject) && ValidateSlot(SlotIndex) && ValidateMaterial(MaterialIndex) && ValidateRow(RowIndex) && Validate(DataIndex);
+
+ public static bool FromKey(uint key, out MaterialValueIndex index)
+ {
+ index = new MaterialValueIndex(key);
+ return index.Valid;
+ }
+
+ public unsafe bool TryGetModel(Actor actor, out Model model)
+ {
+ if (!actor.Valid)
+ {
+ model = Model.Null;
+ return false;
+ }
+
+ model = DrawObject switch
+ {
+ DrawObjectType.Human => actor.Model,
+ DrawObjectType.Mainhand => actor.IsCharacter ? actor.AsCharacter->DrawData.WeaponDataSpan[0].DrawObject : Model.Null,
+ DrawObjectType.Offhand => actor.IsCharacter ? actor.AsCharacter->DrawData.WeaponDataSpan[1].DrawObject : Model.Null,
+ _ => Model.Null,
+ };
+ return model.IsCharacterBase;
+ }
+
+ public unsafe bool TryGetTextures(Actor actor, out ReadOnlySpan> textures)
+ {
+ if (!TryGetModel(actor, out var model)
+ || SlotIndex >= model.AsCharacterBase->SlotCount
+ || model.AsCharacterBase->ColorTableTexturesSpan.Length < (SlotIndex + 1) * MaterialService.MaterialsPerModel)
+ {
+ textures = [];
+ return false;
+ }
+
+ textures = model.AsCharacterBase->ColorTableTexturesSpan.Slice(SlotIndex * MaterialService.MaterialsPerModel,
+ MaterialService.MaterialsPerModel);
+ return true;
+ }
+
+ public unsafe bool TryGetTexture(Actor actor, out Texture* texture)
+ {
+ if (!TryGetTextures(actor, out var textures) || MaterialIndex >= MaterialService.MaterialsPerModel)
+ {
+ texture = null;
+ return false;
+ }
+
+ texture = textures[MaterialIndex].Value;
+ return texture != null;
+ }
+
+ public unsafe bool TryGetColorTable(Actor actor, out MtrlFile.ColorTable table)
+ {
+ if (TryGetTexture(actor, out var texture))
+ return DirectXTextureHelper.TryGetColorTable(texture, out table);
+
+ table = default;
+ return false;
+ }
+
+ public unsafe bool TryGetColorRow(Actor actor, out MtrlFile.ColorTable.Row row)
+ {
+ if (!TryGetColorTable(actor, out var table))
+ {
+ row = default;
+ return false;
+ }
+
+ row = table[RowIndex];
+ return true;
+ }
+
+ public unsafe bool TryGetValue(Actor actor, out Vector3 value)
+ {
+ if (!TryGetColorRow(actor, out var row))
+ {
+ value = Vector3.Zero;
+ return false;
+ }
+
+ value = DataIndex switch
+ {
+ ColorTableIndex.Diffuse => row.Diffuse,
+ ColorTableIndex.Specular => row.Specular,
+ ColorTableIndex.SpecularStrength => new Vector3(row.SpecularStrength, 0, 0),
+ ColorTableIndex.Emissive => row.Emissive,
+ ColorTableIndex.GlossStrength => new Vector3(row.GlossStrength, 0, 0),
+ ColorTableIndex.TileSet => new Vector3(row.TileSet),
+ ColorTableIndex.MaterialRepeat => new Vector3(row.MaterialRepeat, 0),
+ ColorTableIndex.MaterialSkew => new Vector3(row.MaterialSkew, 0),
+ _ => new Vector3(float.NaN),
+ };
+ return !float.IsNaN(value.X);
+ }
+
+ public static MaterialValueIndex FromKey(uint key)
+ => new(key);
+
+ public static MaterialValueIndex Min(DrawObjectType drawObject = 0, byte slotIndex = 0, byte materialIndex = 0, byte rowIndex = 0,
+ ColorTableIndex dataIndex = 0)
+ => new(drawObject, slotIndex, materialIndex, rowIndex, dataIndex);
+
+ public static MaterialValueIndex Max(DrawObjectType drawObject = (DrawObjectType)byte.MaxValue, byte slotIndex = byte.MaxValue,
+ byte materialIndex = byte.MaxValue, byte rowIndex = byte.MaxValue,
+ ColorTableIndex dataIndex = (ColorTableIndex)byte.MaxValue)
+ => new(drawObject, slotIndex, materialIndex, rowIndex, dataIndex);
+
+ public enum DrawObjectType : byte
+ {
+ Human,
+ Mainhand,
+ Offhand,
+ };
+
+ public enum ColorTableIndex : byte
+ {
+ Diffuse,
+ Specular,
+ SpecularStrength,
+ Emissive,
+ GlossStrength,
+ TileSet,
+ MaterialRepeat,
+ MaterialSkew,
+ }
+
+ public static bool Validate(DrawObjectType type)
+ => Enum.IsDefined(type);
+
+ public static bool ValidateSlot(byte slotIndex)
+ => slotIndex < 10;
+
+ public static bool ValidateMaterial(byte materialIndex)
+ => materialIndex < MaterialService.MaterialsPerModel;
+
+ public static bool ValidateRow(byte rowIndex)
+ => rowIndex < MtrlFile.ColorTable.NumRows;
+
+ public static bool Validate(ColorTableIndex dataIndex)
+ => Enum.IsDefined(dataIndex);
+
+ private static uint ToKey(DrawObjectType type, byte slotIndex, byte materialIndex, byte rowIndex, ColorTableIndex index)
+ {
+ var result = (uint)index & 0xFF;
+ result |= (uint)(rowIndex & 0xFF) << 8;
+ result |= (uint)(materialIndex & 0xF) << 16;
+ result |= (uint)(slotIndex & 0xFF) << 20;
+ result |= (uint)((byte)type & 0xF) << 28;
+ return result;
+ }
+
+ private MaterialValueIndex(uint key)
+ : this((DrawObjectType)((key >> 28) & 0xF), (byte)(key >> 20), (byte)((key >> 16) & 0xF), (byte)(key >> 8),
+ (ColorTableIndex)(key & 0xFF))
+ { }
+
+ private class Converter : JsonConverter
+ {
+ public override void WriteJson(JsonWriter writer, MaterialValueIndex value, JsonSerializer serializer)
+ => serializer.Serialize(writer, value.Key);
+
+ public override MaterialValueIndex ReadJson(JsonReader reader, Type objectType, MaterialValueIndex existingValue, bool hasExistingValue,
+ JsonSerializer serializer)
+ => FromKey(serializer.Deserialize(reader), out var value) ? value : throw new Exception($"Invalid material key {value.Key}.");
+ }
+}
+
+public static class MaterialExtensions
+{
+ public static bool TryGetValue(this MaterialValueIndex.ColorTableIndex index, in MtrlFile.ColorTable.Row row, out Vector3 value)
+ {
+ value = index switch
+ {
+ MaterialValueIndex.ColorTableIndex.Diffuse => row.Diffuse,
+ MaterialValueIndex.ColorTableIndex.Specular => row.Specular,
+ MaterialValueIndex.ColorTableIndex.SpecularStrength => new Vector3(row.SpecularStrength, 0, 0),
+ MaterialValueIndex.ColorTableIndex.Emissive => row.Emissive,
+ MaterialValueIndex.ColorTableIndex.GlossStrength => new Vector3(row.GlossStrength, 0, 0),
+ MaterialValueIndex.ColorTableIndex.TileSet => new Vector3(row.TileSet),
+ MaterialValueIndex.ColorTableIndex.MaterialRepeat => new Vector3(row.MaterialRepeat, 0),
+ MaterialValueIndex.ColorTableIndex.MaterialSkew => new Vector3(row.MaterialSkew, 0),
+ _ => new Vector3(float.NaN),
+ };
+ return !float.IsNaN(value.X);
+ }
+
+ public static bool SetValue(this MaterialValueIndex.ColorTableIndex index, ref MtrlFile.ColorTable.Row row, in Vector3 value)
+ {
+ switch (index)
+ {
+ case MaterialValueIndex.ColorTableIndex.Diffuse:
+ if (value == row.Diffuse)
+ return false;
+
+ row.Diffuse = value;
+ return true;
+
+ case MaterialValueIndex.ColorTableIndex.Specular:
+ if (value == row.Specular)
+ return false;
+
+ row.Specular = value;
+ return true;
+ case MaterialValueIndex.ColorTableIndex.SpecularStrength:
+ if (value.X == row.SpecularStrength)
+ return false;
+
+ row.SpecularStrength = value.X;
+ return true;
+ case MaterialValueIndex.ColorTableIndex.Emissive:
+ if (value == row.Emissive)
+ return false;
+
+ row.Emissive = value;
+ return true;
+ case MaterialValueIndex.ColorTableIndex.GlossStrength:
+ if (value.X == row.GlossStrength)
+ return false;
+
+ row.GlossStrength = value.X;
+ return true;
+ case MaterialValueIndex.ColorTableIndex.TileSet:
+ var @ushort = (ushort)(value.X + 0.5f);
+ if (@ushort == row.TileSet)
+ return false;
+
+ row.TileSet = @ushort;
+ return true;
+ case MaterialValueIndex.ColorTableIndex.MaterialRepeat:
+ if (value.X == row.MaterialRepeat.X && value.Y == row.MaterialRepeat.Y)
+ return false;
+
+ row.MaterialRepeat = new Vector2(value.X, value.Y);
+ return true;
+ case MaterialValueIndex.ColorTableIndex.MaterialSkew:
+ if (value.X == row.MaterialSkew.X && value.Y == row.MaterialSkew.Y)
+ return false;
+
+ row.MaterialSkew = new Vector2(value.X, value.Y);
+ return true;
+ default: return false;
+ }
+ }
+}
diff --git a/Glamourer/Interop/Material/MaterialValueManager.cs b/Glamourer/Interop/Material/MaterialValueManager.cs
new file mode 100644
index 0000000..257dfc8
--- /dev/null
+++ b/Glamourer/Interop/Material/MaterialValueManager.cs
@@ -0,0 +1,162 @@
+namespace Glamourer.Interop.Material;
+
+public readonly struct MaterialValueManager
+{
+ private readonly List<(uint Key, Vector3 Value)> _values = [];
+
+ public MaterialValueManager()
+ { }
+
+ public bool TryGetValue(MaterialValueIndex index, out Vector3 value)
+ {
+ if (_values.Count == 0)
+ {
+ value = Vector3.Zero;
+ return false;
+ }
+
+ var idx = Search(index.Key);
+ if (idx >= 0)
+ {
+ value = _values[idx].Value;
+ return true;
+ }
+
+ value = Vector3.Zero;
+ return false;
+ }
+
+ public bool TryAddValue(MaterialValueIndex index, in Vector3 value)
+ {
+ var key = index.Key;
+ var idx = Search(key);
+ if (idx >= 0)
+ return false;
+
+ _values.Insert(~idx, (key, value));
+ return true;
+ }
+
+ public bool RemoveValue(MaterialValueIndex index)
+ {
+ if (_values.Count == 0)
+ return false;
+
+ var idx = Search(index.Key);
+ if (idx < 0)
+ return false;
+
+ _values.RemoveAt(idx);
+ return true;
+ }
+
+ public void AddOrUpdateValue(MaterialValueIndex index, in Vector3 value)
+ {
+ var key = index.Key;
+ var idx = Search(key);
+ if (idx < 0)
+ _values.Insert(~idx, (key, value));
+ else
+ _values[idx] = (key, value);
+ }
+
+ public bool UpdateValue(MaterialValueIndex index, in Vector3 value, out Vector3 oldValue)
+ {
+ if (_values.Count == 0)
+ {
+ oldValue = Vector3.Zero;
+ return false;
+ }
+
+ var key = index.Key;
+ var idx = Search(key);
+ if (idx < 0)
+ {
+ oldValue = Vector3.Zero;
+ return false;
+ }
+
+ oldValue = _values[idx].Value;
+ _values[idx] = (key, value);
+ return true;
+ }
+
+ public int RemoveValues(MaterialValueIndex min, MaterialValueIndex max)
+ {
+ var (minIdx, maxIdx) = GetMinMax(CollectionsMarshal.AsSpan(_values), min.Key, max.Key);
+ if (minIdx < 0)
+ return 0;
+
+ var count = maxIdx - minIdx;
+ _values.RemoveRange(minIdx, count);
+ return count;
+ }
+
+ public ReadOnlySpan<(uint key, Vector3 Value)> GetValues(MaterialValueIndex min, MaterialValueIndex max)
+ => Filter(CollectionsMarshal.AsSpan(_values), min, max);
+
+ public static ReadOnlySpan<(uint Key, Vector3 Value)> Filter(ReadOnlySpan<(uint Key, Vector3 Value)> values, MaterialValueIndex min,
+ MaterialValueIndex max)
+ {
+ var (minIdx, maxIdx) = GetMinMax(values, min.Key, max.Key);
+ return minIdx < 0 ? [] : values[minIdx..(maxIdx - minIdx)];
+ }
+
+ /// Obtain the minimum index and maximum index for a minimum and maximum key.
+ private static (int MinIdx, int MaxIdx) GetMinMax(ReadOnlySpan<(uint Key, Vector3 Value)> values, uint minKey, uint maxKey)
+ {
+ // Find the minimum index by binary search.
+ var idx = values.BinarySearch((minKey, Vector3.Zero), Comparer.Instance);
+ var minIdx = idx;
+
+ // If the key does not exist, check if it is an invalid range or set it correctly.
+ if (minIdx < 0)
+ {
+ minIdx = ~minIdx;
+ if (minIdx == values.Length || values[minIdx].Key > maxKey)
+ return (-1, -1);
+
+ idx = minIdx;
+ }
+ else
+ {
+ // If it does exist, go upwards until the first key is reached that is actually smaller.
+ while (minIdx > 0 && values[minIdx - 1].Key >= minKey)
+ --minIdx;
+ }
+
+ // Check if the range can be valid.
+ if (values[minIdx].Key < minKey || values[minIdx].Key > maxKey)
+ return (-1, -1);
+
+
+ // Do pretty much the same but in the other direction with the maximum key.
+ var maxIdx = values[idx..].BinarySearch((maxKey, Vector3.Zero), Comparer.Instance);
+ if (maxIdx < 0)
+ {
+ maxIdx = ~maxIdx;
+ return maxIdx > minIdx ? (minIdx, maxIdx - 1) : (-1, -1);
+ }
+
+ while (maxIdx < values.Length - 1 && values[maxIdx + 1].Key <= maxKey)
+ ++maxIdx;
+
+ if (values[maxIdx].Key < minKey || values[maxIdx].Key > maxKey)
+ return (-1, -1);
+
+ return (minIdx, maxIdx);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ private int Search(uint key)
+ => _values.BinarySearch((key, Vector3.Zero), Comparer.Instance);
+
+ private class Comparer : IComparer<(uint Key, Vector3 Value)>
+ {
+ public static readonly Comparer Instance = new();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ public int Compare((uint Key, Vector3 Value) x, (uint Key, Vector3 Value) y)
+ => x.Key.CompareTo(y.Key);
+ }
+}
diff --git a/Glamourer/Interop/Material/PrepareColorSet.cs b/Glamourer/Interop/Material/PrepareColorSet.cs
new file mode 100644
index 0000000..8d44ee9
--- /dev/null
+++ b/Glamourer/Interop/Material/PrepareColorSet.cs
@@ -0,0 +1,162 @@
+using Dalamud.Hooking;
+using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
+using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
+using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
+using Glamourer.Interop.Penumbra;
+using Glamourer.Interop.Structs;
+using Glamourer.State;
+using OtterGui.Classes;
+using OtterGui.Services;
+using Penumbra.GameData.Actors;
+using Penumbra.GameData.Structs;
+
+namespace Glamourer.Interop.Material;
+
+public sealed unsafe class PrepareColorSet
+ : EventWrapperPtr12Ref34, IHookService
+{
+ public enum Priority
+ {
+ ///
+ MaterialManager = 0,
+ }
+
+ public PrepareColorSet(HookManager hooks)
+ : base("Prepare Color Set ")
+ => _task = hooks.CreateHook(Name, "40 55 56 41 56 48 83 EC ?? 80 BA", Detour, true);
+
+ private readonly Task> _task;
+
+ public nint Address
+ => (nint)CharacterBase.MemberFunctionPointers.Destroy;
+
+ public void Enable()
+ => _task.Result.Enable();
+
+ public void Disable()
+ => _task.Result.Disable();
+
+ public Task Awaiter
+ => _task;
+
+ public bool Finished
+ => _task.IsCompletedSuccessfully;
+
+ private delegate Texture* Delegate(CharacterBase* characterBase, MaterialResourceHandle* material, StainId stainId);
+
+ private Texture* Detour(CharacterBase* characterBase, MaterialResourceHandle* material, StainId stainId)
+ {
+ Glamourer.Log.Excessive($"[{Name}] Triggered with 0x{(nint)characterBase:X} 0x{(nint)material:X} {stainId.Id}.");
+ var ret = nint.Zero;
+ Invoke(characterBase, material, ref stainId, ref ret);
+ if (ret != nint.Zero)
+ return (Texture*)ret;
+
+ return _task.Result.Original(characterBase, material, stainId);
+ }
+}
+
+public sealed unsafe class MaterialManager : IRequiredService, IDisposable
+{
+ private readonly PrepareColorSet _event;
+ private readonly StateManager _stateManager;
+ private readonly PenumbraService _penumbra;
+ private readonly ActorManager _actors;
+
+ private int _lastSlot;
+
+ public MaterialManager(PrepareColorSet prepareColorSet, StateManager stateManager, ActorManager actors, PenumbraService penumbra)
+ {
+ _stateManager = stateManager;
+ _actors = actors;
+ _penumbra = penumbra;
+ _event = prepareColorSet;
+
+ _event.Subscribe(OnPrepareColorSet, PrepareColorSet.Priority.MaterialManager);
+ }
+
+ public void Dispose()
+ => _event.Unsubscribe(OnPrepareColorSet);
+
+ private void OnPrepareColorSet(CharacterBase* characterBase, MaterialResourceHandle* material, ref StainId stain, ref nint ret)
+ {
+ var actor = _penumbra.GameObjectFromDrawObject(characterBase);
+ var validType = FindType(characterBase, actor, out var type);
+ var (slotId, materialId) = FindMaterial(characterBase, material);
+ Glamourer.Log.Information(
+ $" Triggered with 0x{(nint)characterBase:X} 0x{(nint)material:X} {stain.Id} --- Actor: 0x{actor.Address:X} Slot: {slotId} Material: {materialId} DrawObject: {type}.");
+ if (!validType
+ || slotId == byte.MaxValue
+ || !actor.Identifier(_actors, out var identifier)
+ || !_stateManager.TryGetValue(identifier, out var state))
+ return;
+
+ var min = MaterialValueIndex.Min(type, slotId, materialId);
+ var max = MaterialValueIndex.Max(type, slotId, materialId);
+ var manager = new MaterialValueManager();
+ var values = manager.GetValues(min, max);
+ foreach (var (key, value) in values)
+ ;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
+ private (byte SlotId, byte MaterialId) FindMaterial(CharacterBase* characterBase, MaterialResourceHandle* material)
+ {
+ for (var i = _lastSlot; i < characterBase->SlotCount; ++i)
+ {
+ var idx = MaterialService.MaterialsPerModel * i;
+ for (var j = 0; j < MaterialService.MaterialsPerModel; ++j)
+ {
+ var mat = (nint)characterBase->Materials[idx++];
+ if (mat != (nint)material)
+ continue;
+
+ _lastSlot = i;
+ return ((byte)i, (byte)j);
+ }
+ }
+
+ for (var i = 0; i < _lastSlot; ++i)
+ {
+ var idx = MaterialService.MaterialsPerModel * i;
+ for (var j = 0; j < MaterialService.MaterialsPerModel; ++j)
+ {
+ var mat = (nint)characterBase->Materials[idx++];
+ if (mat != (nint)material)
+ continue;
+
+ _lastSlot = i;
+ return ((byte)i, (byte)j);
+ }
+ }
+
+ return (byte.MaxValue, byte.MaxValue);
+ }
+
+ private static bool FindType(CharacterBase* characterBase, Actor actor, out MaterialValueIndex.DrawObjectType type)
+ {
+ type = MaterialValueIndex.DrawObjectType.Human;
+ if (!actor.Valid)
+ return false;
+
+ if (actor.Model.AsCharacterBase == characterBase)
+ return true;
+
+ if (!actor.AsObject->IsCharacter())
+ return false;
+
+ if (actor.AsCharacter->DrawData.WeaponDataSpan[0].DrawObject == characterBase)
+ {
+ type = MaterialValueIndex.DrawObjectType.Mainhand;
+ return true;
+ }
+
+ if (actor.AsCharacter->DrawData.WeaponDataSpan[1].DrawObject == characterBase)
+ {
+ type = MaterialValueIndex.DrawObjectType.Offhand;
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/Glamourer/Interop/Material/SafeTextureHandle.cs b/Glamourer/Interop/Material/SafeTextureHandle.cs
new file mode 100644
index 0000000..20e6f65
--- /dev/null
+++ b/Glamourer/Interop/Material/SafeTextureHandle.cs
@@ -0,0 +1,49 @@
+using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
+
+namespace Glamourer.Interop.Material;
+
+public unsafe class SafeTextureHandle : SafeHandle
+{
+ public Texture* Texture
+ => (Texture*)handle;
+
+ public override bool IsInvalid
+ => handle == 0;
+
+ public SafeTextureHandle(Texture* handle, bool incRef, bool ownsHandle = true)
+ : base(0, ownsHandle)
+ {
+ if (incRef && !ownsHandle)
+ throw new ArgumentException("Non-owning SafeTextureHandle with IncRef is unsupported");
+
+ if (incRef && handle != null)
+ handle->IncRef();
+ SetHandle((nint)handle);
+ }
+
+ public void Exchange(ref nint ppTexture)
+ {
+ lock (this)
+ {
+ handle = Interlocked.Exchange(ref ppTexture, handle);
+ }
+ }
+
+ public static SafeTextureHandle CreateInvalid()
+ => new(null, false);
+
+ protected override bool ReleaseHandle()
+ {
+ nint handle;
+ lock (this)
+ {
+ handle = this.handle;
+ this.handle = 0;
+ }
+
+ if (handle != 0)
+ ((Texture*)handle)->DecRef();
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Glamourer/Services/ServiceManager.cs b/Glamourer/Services/ServiceManager.cs
index d9d76b4..dd06e3a 100644
--- a/Glamourer/Services/ServiceManager.cs
+++ b/Glamourer/Services/ServiceManager.cs
@@ -20,6 +20,7 @@ using Glamourer.Unlocks;
using Microsoft.Extensions.DependencyInjection;
using OtterGui.Classes;
using OtterGui.Log;
+using OtterGui.Raii;
using OtterGui.Services;
using Penumbra.GameData.Actors;
using Penumbra.GameData.Data;
@@ -47,7 +48,7 @@ public static class ServiceManagerA
DalamudServices.AddServices(services, pi);
services.AddIServices(typeof(EquipItem).Assembly);
services.AddIServices(typeof(Glamourer).Assembly);
- services.AddIServices(typeof(EquipFlag).Assembly);
+ services.AddIServices(typeof(ImRaii).Assembly);
services.CreateProvider();
return services;
}