From 0c17892f03f5b59071763a9ba52bd09111c96c0b Mon Sep 17 00:00:00 2001 From: Exter-N Date: Wed, 15 Feb 2023 02:07:10 +0100 Subject: [PATCH] Mtrl shader resource editing, ShPk editing --- Penumbra.GameData/Data/DisassembledShader.cs | 481 +++++++++++++ Penumbra.GameData/Files/MtrlFile.Write.cs | 3 +- Penumbra.GameData/Files/MtrlFile.cs | 57 +- Penumbra.GameData/Files/ShpkFile.Write.cs | 123 ++++ Penumbra.GameData/Files/ShpkFile.cs | 644 ++++++++++++++++++ Penumbra.GameData/Interop/D3DCompiler.cs | 65 ++ Penumbra/Mods/Editor/Mod.Editor.Files.cs | 5 + .../UI/Classes/ModEditWindow.FileEditor.cs | 14 +- .../UI/Classes/ModEditWindow.Materials.cs | 484 +++++++++++-- .../Classes/ModEditWindow.ShaderPackages.cs | 557 +++++++++++++++ Penumbra/UI/Classes/ModEditWindow.cs | 55 +- Penumbra/Util/IndexSet.cs | 110 +++ 12 files changed, 2535 insertions(+), 63 deletions(-) create mode 100644 Penumbra.GameData/Data/DisassembledShader.cs create mode 100644 Penumbra.GameData/Files/ShpkFile.Write.cs create mode 100644 Penumbra.GameData/Files/ShpkFile.cs create mode 100644 Penumbra.GameData/Interop/D3DCompiler.cs create mode 100644 Penumbra/UI/Classes/ModEditWindow.ShaderPackages.cs create mode 100644 Penumbra/Util/IndexSet.cs diff --git a/Penumbra.GameData/Data/DisassembledShader.cs b/Penumbra.GameData/Data/DisassembledShader.cs new file mode 100644 index 00000000..b782bf74 --- /dev/null +++ b/Penumbra.GameData/Data/DisassembledShader.cs @@ -0,0 +1,481 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Penumbra.GameData.Interop; +using static Penumbra.GameData.Files.ShpkFile; + +namespace Penumbra.GameData.Data; + +public class DisassembledShader +{ + public struct ResourceBinding + { + public string Name; + public ResourceType Type; + public Format Format; + public ResourceDimension Dimension; + public uint Slot; + public uint Elements; + public uint RegisterCount; + public VectorComponents[] Used; + public VectorComponents UsedDynamically; + } + + // Abbreviated using the uppercased first char of their name + public enum ResourceType : byte + { + Unspecified = 0, + ConstantBuffer = 0x43, // 'C' + Sampler = 0x53, // 'S' + Texture = 0x54, // 'T' + UAV = 0x55, // 'U' + } + + // Abbreviated using the uppercased first and last char of their name + public enum Format : ushort + { + Unspecified = 0, + NotApplicable = 0x4E41, // 'NA' + Int = 0x4954, // 'IT' + Int4 = 0x4934, // 'I4' + Float = 0x4654, // 'FT' + Float4 = 0x4634, // 'F4' + } + + // Abbreviated using the uppercased first and last char of their name + public enum ResourceDimension : ushort + { + Unspecified = 0, + NotApplicable = 0x4E41, // 'NA' + TwoD = 0x3244, // '2D' + ThreeD = 0x3344, // '3D' + Cube = 0x4345, // 'CE' + } + + public struct InputOutput + { + public string Name; + public uint Index; + public VectorComponents Mask; + public uint Register; + public string SystemValue; + public Format Format; + public VectorComponents Used; + } + + [Flags] + public enum VectorComponents : byte + { + X = 1, + Y = 2, + Z = 4, + W = 8, + All = 15, + } + + public enum ShaderStage : byte + { + Unspecified = 0, + Pixel = 0x50, // 'P' + Vertex = 0x56, // 'V' + } + + private static readonly Regex ResourceBindingSizeRegex = new(@"\s(\w+)(?:\[\d+\])?;\s*//\s*Offset:\s*0\s*Size:\s*(\d+)$", RegexOptions.Multiline | RegexOptions.NonBacktracking); + private static readonly Regex SM3ConstantBufferUsageRegex = new(@"c(\d+)(?:\[([^\]]+)\])?(?:\.([wxyz]+))?", RegexOptions.NonBacktracking); + private static readonly Regex SM3TextureUsageRegex = new(@"^\s*texld\S*\s+[^,]+,[^,]+,\s*s(\d+)", RegexOptions.NonBacktracking); + private static readonly Regex SM5ConstantBufferUsageRegex = new(@"cb(\d+)\[([^\]]+)\]\.([wxyz]+)", RegexOptions.NonBacktracking); + private static readonly Regex SM5TextureUsageRegex = new(@"^\s*sample_\S*\s+[^.]+\.([wxyz]+),[^,]+,\s*t(\d+)\.([wxyz]+)", RegexOptions.NonBacktracking); + private static readonly char[] Digits = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; + + public readonly string RawDisassembly; + public readonly uint ShaderModel; + public readonly ShaderStage Stage; + public readonly string BufferDefinitions; + public readonly ResourceBinding[] ResourceBindings; + public readonly InputOutput[] InputSignature; + public readonly InputOutput[] OutputSignature; + public readonly string[] Instructions; + + public DisassembledShader(string rawDisassembly) + { + RawDisassembly = rawDisassembly; + var lines = rawDisassembly.Split('\n'); + Instructions = Array.FindAll(lines, ln => !ln.StartsWith("//") && ln.Length > 0); + var shaderModel = Instructions[0].Trim().Split('_'); + Stage = (ShaderStage)(byte)char.ToUpper(shaderModel[0][0]); + ShaderModel = (uint.Parse(shaderModel[1]) << 8) | uint.Parse(shaderModel[2]); + var header = PreParseHeader(lines.AsSpan()[..Array.IndexOf(lines, Instructions[0])]); + switch (ShaderModel >> 8) + { + case 3: + ParseSM3Header(header, out BufferDefinitions, out ResourceBindings, out InputSignature, out OutputSignature); + ParseSM3ResourceUsage(Instructions, ResourceBindings); + break; + case 5: + ParseSM5Header(header, out BufferDefinitions, out ResourceBindings, out InputSignature, out OutputSignature); + ParseSM5ResourceUsage(Instructions, ResourceBindings); + break; + default: + throw new NotImplementedException(); + } + } + + public ResourceBinding? GetResourceBindingByName(ResourceType type, string name) + { + return ResourceBindings.Select(binding => new ResourceBinding?(binding)).FirstOrDefault(binding => binding!.Value.Type == type && binding!.Value.Name == name); + } + + public ResourceBinding? GetResourceBindingBySlot(ResourceType type, uint slot) + { + return ResourceBindings.Select(binding => new ResourceBinding?(binding)).FirstOrDefault(binding => binding!.Value.Type == type && binding!.Value.Slot == slot); + } + + public static DisassembledShader Disassemble(ReadOnlySpan shaderBlob) + { + return new DisassembledShader(D3DCompiler.Disassemble(shaderBlob)); + } + + private static void ParseSM3Header(Dictionary header, out string bufferDefinitions, out ResourceBinding[] resourceBindings, out InputOutput[] inputSignature, out InputOutput[] outputSignature) + { + if (header.TryGetValue("Parameters", out var rawParameters)) + { + bufferDefinitions = string.Join('\n', rawParameters); + } + else + { + bufferDefinitions = string.Empty; + } + if (header.TryGetValue("Registers", out var rawRegisters)) + { + var (_, registers) = ParseTable(rawRegisters); + resourceBindings = Array.ConvertAll(registers, register => + { + var type = (ResourceType)(byte)char.ToUpper(register[1][0]); + if (type == ResourceType.Sampler) + { + type = ResourceType.Texture; + } + uint size = uint.Parse(register[2]); + return new ResourceBinding + { + Name = register[0], + Type = type, + Format = Format.Unspecified, + Dimension = ResourceDimension.Unspecified, + Slot = uint.Parse(register[1][1..]), + Elements = 1, + RegisterCount = size, + Used = new VectorComponents[size], + }; + }); + } + else + { + resourceBindings = Array.Empty(); + } + inputSignature = Array.Empty(); + outputSignature = Array.Empty(); + } + + private static void ParseSM3ResourceUsage(string[] instructions, ResourceBinding[] resourceBindings) + { + var cbIndices = new Dictionary(); + var tIndices = new Dictionary(); + { + var i = 0; + foreach (var binding in resourceBindings) + { + switch (binding.Type) + { + case ResourceType.ConstantBuffer: + for (var j = 0u; j < binding.RegisterCount; j++) + { + cbIndices[binding.Slot + j] = i; + } + break; + case ResourceType.Texture: + tIndices[binding.Slot] = i; + break; + } + ++i; + } + } + foreach (var instruction in instructions) + { + var trimmed = instruction.Trim(); + if (trimmed.StartsWith("def") || trimmed.StartsWith("dcl")) + { + continue; + } + foreach (Match cbMatch in SM3ConstantBufferUsageRegex.Matches(instruction)) + { + var buffer = uint.Parse(cbMatch.Groups[1].Value); + if (cbIndices.TryGetValue(buffer, out var i)) + { + var swizzle = cbMatch.Groups[3].Success ? ParseVectorComponents(cbMatch.Groups[3].Value) : VectorComponents.All; + if (cbMatch.Groups[2].Success) + { + resourceBindings[i].UsedDynamically |= swizzle; + } + else + { + resourceBindings[i].Used[buffer - resourceBindings[i].Slot] |= swizzle; + } + } + } + var tMatch = SM3TextureUsageRegex.Match(instruction); + if (tMatch.Success) + { + var texture = uint.Parse(tMatch.Groups[1].Value); + if (tIndices.TryGetValue(texture, out var i)) + { + resourceBindings[i].Used[0] = VectorComponents.All; + } + } + } + } + + private static void ParseSM5Header(Dictionary header, out string bufferDefinitions, out ResourceBinding[] resourceBindings, out InputOutput[] inputSignature, out InputOutput[] outputSignature) + { + if (header.TryGetValue("Resource Bindings", out var rawResBindings)) + { + var (head, resBindings) = ParseTable(rawResBindings); + resourceBindings = Array.ConvertAll(resBindings, binding => { + var type = (ResourceType)(byte)char.ToUpper(binding[1][0]); + return new ResourceBinding + { + Name = binding[0], + Type = type, + Format = (Format)(((byte)char.ToUpper(binding[2][0]) << 8) | (byte)char.ToUpper(binding[2][^1])), + Dimension = (ResourceDimension)(((byte)char.ToUpper(binding[3][0]) << 8) | (byte)char.ToUpper(binding[3][^1])), + Slot = uint.Parse(binding[4][binding[4].IndexOfAny(Digits)..]), + Elements = uint.Parse(binding[5]), + RegisterCount = type == ResourceType.Texture ? 1u : 0u, + Used = type == ResourceType.Texture ? new VectorComponents[1] : Array.Empty(), + }; + }); + } + else + { + resourceBindings = Array.Empty(); + } + if (header.TryGetValue("Buffer Definitions", out var rawBufferDefs)) + { + bufferDefinitions = string.Join('\n', rawBufferDefs); + foreach (Match match in ResourceBindingSizeRegex.Matches(bufferDefinitions)) + { + var name = match.Groups[1].Value; + var bytesSize = uint.Parse(match.Groups[2].Value); + var pos = Array.FindIndex(resourceBindings, binding => binding.Type == ResourceType.ConstantBuffer && binding.Name == name); + if (pos >= 0) + { + resourceBindings[pos].RegisterCount = (bytesSize + 0xF) >> 4; + resourceBindings[pos].Used = new VectorComponents[resourceBindings[pos].RegisterCount]; + } + } + } + else + { + bufferDefinitions = string.Empty; + } + + static InputOutput ParseInputOutput(string[] inOut) => new() + { + Name = inOut[0], + Index = uint.Parse(inOut[1]), + Mask = ParseVectorComponents(inOut[2]), + Register = uint.Parse(inOut[3]), + SystemValue = string.Intern(inOut[4]), + Format = (Format)(((byte)char.ToUpper(inOut[5][0]) << 8) | (byte)char.ToUpper(inOut[5][^1])), + Used = ParseVectorComponents(inOut[6]), + }; + + if (header.TryGetValue("Input signature", out var rawInputSig)) + { + var (_, inputSig) = ParseTable(rawInputSig); + inputSignature = Array.ConvertAll(inputSig, ParseInputOutput); + } + else + { + inputSignature = Array.Empty(); + } + if (header.TryGetValue("Output signature", out var rawOutputSig)) + { + var (_, outputSig) = ParseTable(rawOutputSig); + outputSignature = Array.ConvertAll(outputSig, ParseInputOutput); + } + else + { + outputSignature = Array.Empty(); + } + } + + private static void ParseSM5ResourceUsage(string[] instructions, ResourceBinding[] resourceBindings) + { + var cbIndices = new Dictionary(); + var tIndices = new Dictionary(); + { + var i = 0; + foreach (var binding in resourceBindings) + { + switch (binding.Type) + { + case ResourceType.ConstantBuffer: + cbIndices[binding.Slot] = i; + break; + case ResourceType.Texture: + tIndices[binding.Slot] = i; + break; + } + ++i; + } + } + foreach (var instruction in instructions) + { + var trimmed = instruction.Trim(); + if (trimmed.StartsWith("def") || trimmed.StartsWith("dcl")) + { + continue; + } + foreach (Match cbMatch in SM5ConstantBufferUsageRegex.Matches(instruction)) + { + var buffer = uint.Parse(cbMatch.Groups[1].Value); + if (cbIndices.TryGetValue(buffer, out var i)) + { + var swizzle = ParseVectorComponents(cbMatch.Groups[3].Value); + if (int.TryParse(cbMatch.Groups[2].Value, out var vector)) + { + if (vector < resourceBindings[i].Used.Length) + { + resourceBindings[i].Used[vector] |= swizzle; + } + } + else + { + resourceBindings[i].UsedDynamically |= swizzle; + } + } + } + var tMatch = SM5TextureUsageRegex.Match(instruction); + if (tMatch.Success) + { + var texture = uint.Parse(tMatch.Groups[2].Value); + if (tIndices.TryGetValue(texture, out var i)) + { + var outSwizzle = ParseVectorComponents(tMatch.Groups[1].Value); + var rawInSwizzle = tMatch.Groups[3].Value; + var inSwizzle = new StringBuilder(4); + if ((outSwizzle & VectorComponents.X) != 0) + { + inSwizzle.Append(rawInSwizzle[0]); + } + if ((outSwizzle & VectorComponents.Y) != 0) + { + inSwizzle.Append(rawInSwizzle[1]); + } + if ((outSwizzle & VectorComponents.Z) != 0) + { + inSwizzle.Append(rawInSwizzle[2]); + } + if ((outSwizzle & VectorComponents.W) != 0) + { + inSwizzle.Append(rawInSwizzle[3]); + } + resourceBindings[i].Used[0] |= ParseVectorComponents(inSwizzle.ToString()); + } + } + } + } + + private static VectorComponents ParseVectorComponents(string components) + { + components = components.ToUpperInvariant(); + return (components.Contains('X') ? VectorComponents.X : 0) + | (components.Contains('Y') ? VectorComponents.Y : 0) + | (components.Contains('Z') ? VectorComponents.Z : 0) + | (components.Contains('W') ? VectorComponents.W : 0); + } + + private static Dictionary PreParseHeader(ReadOnlySpan header) + { + var sections = new Dictionary(); + + void AddSection(string name, ReadOnlySpan section) + { + while (section.Length > 0 && section[0].Length <= 3) + { + section = section[1..]; + } + while (section.Length > 0 && section[^1].Length <= 3) + { + section = section[..^1]; + } + sections.Add(name, Array.ConvertAll(section.ToArray(), ln => ln.Length <= 3 ? string.Empty : ln[3..])); + } + + var lastSectionName = ""; + var lastSectionStart = 0; + for (var i = 1; i < header.Length - 1; ++i) + { + string current; + if (header[i - 1].Length <= 3 && header[i + 1].Length <= 3 && (current = header[i].TrimEnd()).EndsWith(':')) + { + AddSection(lastSectionName, header[lastSectionStart..(i - 1)]); + lastSectionName = current[3..^1]; + lastSectionStart = i + 2; + ++i; // The next line cannot match + } + } + AddSection(lastSectionName, header[lastSectionStart..]); + + return sections; + } + + private static (string[], string[][]) ParseTable(ReadOnlySpan lines) + { + var columns = new List(); + { + var dashLine = lines[1]; + for (var i = 0; true; /* this part intentionally left blank */) + { + var start = dashLine.IndexOf('-', i); + if (start < 0) + { + break; + } + var end = dashLine.IndexOf(' ', start + 1); + if (end < 0) + { + columns.Add(start..dashLine.Length); + break; + } + else + { + columns.Add(start..end); + i = end + 1; + } + } + } + var headers = new string[columns.Count]; + { + var headerLine = lines[0]; + for (var i = 0; i < columns.Count; ++i) + { + headers[i] = headerLine[columns[i]].Trim(); + } + } + var data = new List(); + foreach (var line in lines[2..]) + { + var row = new string[columns.Count]; + for (var i = 0; i < columns.Count; ++i) + { + row[i] = line[columns[i]].Trim(); + } + data.Add(row); + } + return (headers, data.ToArray()); + } +} diff --git a/Penumbra.GameData/Files/MtrlFile.Write.cs b/Penumbra.GameData/Files/MtrlFile.Write.cs index 1c2b9e6b..7faabc68 100644 --- a/Penumbra.GameData/Files/MtrlFile.Write.cs +++ b/Penumbra.GameData/Files/MtrlFile.Write.cs @@ -77,7 +77,8 @@ public partial class MtrlFile foreach( var constant in ShaderPackage.Constants ) { w.Write( constant.Id ); - w.Write( constant.Value ); + w.Write( constant.ByteOffset ); + w.Write( constant.ByteSize ); } foreach( var sampler in ShaderPackage.Samplers ) diff --git a/Penumbra.GameData/Files/MtrlFile.cs b/Penumbra.GameData/Files/MtrlFile.cs index 7508688f..910e4adb 100644 --- a/Penumbra.GameData/Files/MtrlFile.cs +++ b/Penumbra.GameData/Files/MtrlFile.cs @@ -239,7 +239,8 @@ public partial class MtrlFile : IWritable public struct Constant { public uint Id; - public uint Value; + public ushort ByteOffset; + public ushort ByteSize; } public struct ShaderPackageData @@ -254,7 +255,20 @@ public partial class MtrlFile : IWritable public readonly uint Version; - public bool Valid { get; } + public bool Valid + { + get + { + foreach (var texture in Textures) + { + if (!texture.Path.Contains('/')) + { + return false; + } + } + return true; + } + } public Texture[] Textures; public UvSet[] UvSets; @@ -263,6 +277,8 @@ public partial class MtrlFile : IWritable public ShaderPackageData ShaderPackage; public byte[] AdditionalData; + public ShpkFile? AssociatedShpk; + public bool ApplyDyeTemplate(StmFile stm, int colorSetIdx, int rowIdx, StainId stainId) { if (colorSetIdx < 0 || colorSetIdx >= ColorDyeSets.Length || rowIdx is < 0 or >= ColorSet.RowArray.NumRows) @@ -306,7 +322,41 @@ public partial class MtrlFile : IWritable return ret; } + public Span GetConstantValues(Constant constant) + { + if ((constant.ByteOffset & 0x3) == 0 && (constant.ByteSize & 0x3) == 0 + && ((constant.ByteOffset + constant.ByteSize) >> 2) <= ShaderPackage.ShaderValues.Length) + { + return ShaderPackage.ShaderValues.AsSpan().Slice(constant.ByteOffset >> 2, constant.ByteSize >> 2); + } + else + { + return null; + } + } + + public List<(Sampler?, ShpkFile.Resource?)> GetSamplersByTexture() + { + var samplers = new List<(Sampler?, ShpkFile.Resource?)>(); + for (var i = 0; i < Textures.Length; ++i) + { + samplers.Add((null, null)); + } + foreach (var sampler in ShaderPackage.Samplers) + { + samplers[sampler.TextureIndex] = (sampler, AssociatedShpk?.GetSamplerById(sampler.SamplerId)); + } + + return samplers; + } + + // Activator.CreateInstance can't use a ctor with a default value so this has to be made explicit public MtrlFile(byte[] data) + : this(data, null) + { + } + + public MtrlFile(byte[] data, Func? loadAssociatedShpk = null) { using var stream = new MemoryStream(data); using var r = new BinaryReader(stream); @@ -345,6 +395,8 @@ public partial class MtrlFile : IWritable ShaderPackage.Name = UseOffset(strings, shaderPackageNameOffset); + AssociatedShpk = loadAssociatedShpk?.Invoke(ShaderPackage.Name); + AdditionalData = r.ReadBytes(additionalDataSize); for (var i = 0; i < ColorSets.Length; ++i) { @@ -372,7 +424,6 @@ public partial class MtrlFile : IWritable ShaderPackage.Constants = r.ReadStructuresAsArray(constantCount); ShaderPackage.Samplers = r.ReadStructuresAsArray(samplerCount); ShaderPackage.ShaderValues = r.ReadStructuresAsArray(shaderValueListSize / 4); - Valid = true; } private static Texture[] ReadTextureOffsets(BinaryReader r, int count, out ushort[] offsets) diff --git a/Penumbra.GameData/Files/ShpkFile.Write.cs b/Penumbra.GameData/Files/ShpkFile.Write.cs new file mode 100644 index 00000000..89effe84 --- /dev/null +++ b/Penumbra.GameData/Files/ShpkFile.Write.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; + +namespace Penumbra.GameData.Files; + +public partial class ShpkFile +{ + public byte[] Write() + { + using var stream = new MemoryStream(); + using var blobs = new MemoryStream(); + using (var w = new BinaryWriter(stream)) + { + w.Write(ShPkMagic); + w.Write(Unknown1); + w.Write(DirectXVersion switch + { + DXVersion.DirectX9 => DX9Magic, + DXVersion.DirectX11 => DX11Magic, + _ => throw new NotImplementedException(), + }); + long offsetsPosition = stream.Position; + w.Write(0u); // Placeholder for file size + w.Write(0u); // Placeholder for blobs offset + w.Write(0u); // Placeholder for strings offset + w.Write((uint)VertexShaders.Length); + w.Write((uint)PixelShaders.Length); + w.Write(MaterialParamsSize); + w.Write((uint)MaterialParams.Length); + w.Write((uint)Constants.Length); + w.Write((uint)Samplers.Length); + w.Write((uint)UnknownA.Length); + w.Write((uint)UnknownB.Length); + w.Write((uint)UnknownC.Length); + w.Write(Unknown2); + w.Write(Unknown3); + w.Write(Unknown4); + + WriteShaderArray(w, VertexShaders, blobs, Strings); + WriteShaderArray(w, PixelShaders, blobs, Strings); + + foreach (var materialParam in MaterialParams) + { + w.Write(materialParam.Id); + w.Write(materialParam.ByteOffset); + w.Write(materialParam.ByteSize); + } + + WriteResourceArray(w, Constants, Strings); + WriteResourceArray(w, Samplers, Strings); + + w.Write(Unknowns.Item1); + w.Write(Unknowns.Item2); + w.Write(Unknowns.Item3); + + WriteUInt32PairArray(w, UnknownA); + WriteUInt32PairArray(w, UnknownB); + WriteUInt32PairArray(w, UnknownC); + + w.Write(AdditionalData); + + var blobsOffset = (int)stream.Position; + blobs.WriteTo(stream); + + var stringsOffset = (int)stream.Position; + Strings.Data.WriteTo(stream); + + var fileSize = (int)stream.Position; + + stream.Seek(offsetsPosition, SeekOrigin.Begin); + w.Write(fileSize); + w.Write(blobsOffset); + w.Write(stringsOffset); + } + + return stream.ToArray(); + } + + private static void WriteResourceArray(BinaryWriter w, Resource[] array, StringPool strings) + { + foreach (var buf in array) + { + var (strOffset, strSize) = strings.FindOrAddString(buf.Name); + w.Write(buf.Id); + w.Write(strOffset); + w.Write(strSize); + w.Write(buf.Slot); + w.Write(buf.Size); + } + } + + private static void WriteShaderArray(BinaryWriter w, Shader[] array, MemoryStream blobs, StringPool strings) + { + foreach (var shader in array) + { + var blobOffset = (int)blobs.Position; + blobs.Write(shader.AdditionalHeader); + blobs.Write(shader.Blob); + var blobSize = (int)blobs.Position - blobOffset; + + w.Write(blobOffset); + w.Write(blobSize); + w.Write((ushort)shader.Constants.Length); + w.Write((ushort)shader.Samplers.Length); + w.Write((ushort)shader.UnknownX.Length); + w.Write((ushort)shader.UnknownY.Length); + + WriteResourceArray(w, shader.Constants, strings); + WriteResourceArray(w, shader.Samplers, strings); + WriteResourceArray(w, shader.UnknownX, strings); + WriteResourceArray(w, shader.UnknownY, strings); + } + } + + private static void WriteUInt32PairArray(BinaryWriter w, (uint, uint)[] array) + { + foreach (var (first, second) in array) + { + w.Write(first); + w.Write(second); + } + } +} diff --git a/Penumbra.GameData/Files/ShpkFile.cs b/Penumbra.GameData/Files/ShpkFile.cs new file mode 100644 index 00000000..ae25a7c5 --- /dev/null +++ b/Penumbra.GameData/Files/ShpkFile.cs @@ -0,0 +1,644 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Lumina.Extensions; +using Lumina.Misc; +using Penumbra.GameData.Data; + +namespace Penumbra.GameData.Files; + +public partial class ShpkFile : IWritable +{ + public enum DXVersion : uint + { + DirectX9 = 9, + DirectX11 = 11, + } + + public struct Resource + { + public uint Id; + public string Name; + public ushort Slot; + public ushort Size; + public DisassembledShader.VectorComponents[]? Used; + public DisassembledShader.VectorComponents? UsedDynamically; + } + + public struct Shader + { + public DisassembledShader.ShaderStage Stage; + public DXVersion DirectXVersion; + public Resource[] Constants; + public Resource[] Samplers; + public Resource[] UnknownX; + public Resource[] UnknownY; + public byte[] AdditionalHeader; + private byte[] _blob; + private DisassembledShader? _disassembly; + + public byte[] Blob + { + get => _blob; + set + { + if (_blob == value) + { + return; + } + if (Stage != DisassembledShader.ShaderStage.Unspecified) + { + // Reject the blob entirely if we can't disassemble it or if we find inconsistencies. + var disasm = DisassembledShader.Disassemble(value); + if (disasm.Stage != Stage || (disasm.ShaderModel >> 8) + 6 != (uint)DirectXVersion) + { + throw new ArgumentException($"The supplied blob is a DirectX {(disasm.ShaderModel >> 8) + 6} {disasm.Stage} shader ; expected a DirectX {(uint)DirectXVersion} {Stage} shader.", nameof(value)); + } + if (disasm.ShaderModel >= 0x0500) + { + var samplers = new Dictionary(); + var textures = new Dictionary(); + foreach (var binding in disasm.ResourceBindings) + { + switch (binding.Type) + { + case DisassembledShader.ResourceType.Texture: + textures[binding.Slot] = NormalizeResourceName(binding.Name); + break; + case DisassembledShader.ResourceType.Sampler: + samplers[binding.Slot] = NormalizeResourceName(binding.Name); + break; + } + } + if (samplers.Count != textures.Count || !samplers.All(pair => textures.TryGetValue(pair.Key, out var texName) && pair.Value == texName)) + { + throw new ArgumentException($"The supplied blob has inconsistent shader and texture allocation."); + } + } + _blob = value; + _disassembly = disasm; + } + else + { + _blob = value; + _disassembly = null; + } + UpdateUsed(); + } + } + + public DisassembledShader? Disassembly => _disassembly; + + public Resource? GetConstantById(uint id) + { + return Constants.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Id == id); + } + + public Resource? GetConstantByName(string name) + { + return Constants.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Name == name); + } + + public Resource? GetSamplerById(uint id) + { + return Samplers.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Id == id); + } + + public Resource? GetSamplerByName(string name) + { + return Samplers.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Name == name); + } + + public void UpdateResources(ShpkFile file) + { + if (_disassembly == null) + { + throw new InvalidOperationException(); + } + var constants = new List(); + var samplers = new List(); + foreach (var binding in _disassembly.ResourceBindings) + { + switch (binding.Type) + { + case DisassembledShader.ResourceType.ConstantBuffer: + var name = NormalizeResourceName(binding.Name); + // We want to preserve IDs as much as possible, and to deterministically generate new ones, to maximize compatibility. + var id = GetConstantByName(name)?.Id ?? file.GetConstantByName(name)?.Id ?? Crc32.Get(name); + constants.Add(new Resource + { + Id = id, + Name = name, + Slot = (ushort)binding.Slot, + Size = (ushort)binding.RegisterCount, + Used = binding.Used, + }); + break; + case DisassembledShader.ResourceType.Texture: + name = NormalizeResourceName(binding.Name); + id = GetSamplerByName(name)?.Id ?? file.GetSamplerByName(name)?.Id ?? Crc32.Get(name); + samplers.Add(new Resource + { + Id = id, + Name = name, + Slot = (ushort)binding.Slot, + Size = (ushort)binding.Slot, + Used = binding.Used, + }); + break; + } + } + Constants = constants.ToArray(); + Samplers = samplers.ToArray(); + } + + private void UpdateUsed() + { + if (_disassembly != null) + { + var cbUsage = new Dictionary(); + var tUsage = new Dictionary(); + foreach (var binding in _disassembly.ResourceBindings) + { + switch (binding.Type) + { + case DisassembledShader.ResourceType.ConstantBuffer: + cbUsage[NormalizeResourceName(binding.Name)] = (binding.Used, binding.UsedDynamically); + break; + case DisassembledShader.ResourceType.Texture: + tUsage[NormalizeResourceName(binding.Name)] = (binding.Used, binding.UsedDynamically); + break; + } + } + for (var i = 0; i < Constants.Length; ++i) + { + if (cbUsage.TryGetValue(Constants[i].Name, out var usage)) + { + Constants[i].Used = usage.Item1; + Constants[i].UsedDynamically = usage.Item2; + } + else + { + Constants[i].Used = null; + Constants[i].UsedDynamically = null; + } + } + for (var i = 0; i < Samplers.Length; ++i) + { + if (tUsage.TryGetValue(Samplers[i].Name, out var usage)) + { + Samplers[i].Used = usage.Item1; + Samplers[i].UsedDynamically = usage.Item2; + } + else + { + Samplers[i].Used = null; + Samplers[i].UsedDynamically = null; + } + } + } + else + { + ClearUsed(Constants); + ClearUsed(Samplers); + } + } + + private static string NormalizeResourceName(string resourceName) + { + var dot = resourceName.IndexOf('.'); + if (dot >= 0) + { + return resourceName[..dot]; + } + else if (resourceName.EndsWith("_S") || resourceName.EndsWith("_T")) + { + return resourceName[..^2]; + } + else + { + return resourceName; + } + } + } + + public struct MaterialParam + { + public uint Id; + public ushort ByteOffset; + public ushort ByteSize; + } + + public class StringPool + { + public MemoryStream Data; + public List StartingOffsets; + + public StringPool(ReadOnlySpan bytes) + { + Data = new MemoryStream(); + Data.Write(bytes); + StartingOffsets = new List + { + 0, + }; + for (var i = 0; i < bytes.Length; ++i) + { + if (bytes[i] == 0) + { + StartingOffsets.Add(i + 1); + } + } + if (StartingOffsets[^1] == bytes.Length) + { + StartingOffsets.RemoveAt(StartingOffsets.Count - 1); + } + else + { + Data.WriteByte(0); + } + } + + public string GetString(int offset, int size) + { + return Encoding.UTF8.GetString(Data.GetBuffer().AsSpan().Slice(offset, size)); + } + + public string GetNullTerminatedString(int offset) + { + var str = Data.GetBuffer().AsSpan()[offset..]; + var size = str.IndexOf((byte)0); + if (size >= 0) + { + str = str[..size]; + } + return Encoding.UTF8.GetString(str); + } + + public (int, int) FindOrAddString(string str) + { + var dataSpan = Data.GetBuffer().AsSpan(); + var bytes = Encoding.UTF8.GetBytes(str); + foreach (var offset in StartingOffsets) + { + if (offset + bytes.Length > Data.Length) + { + break; + } + var strSpan = dataSpan[offset..]; + var match = true; + for (var i = 0; i < bytes.Length; ++i) + { + if (strSpan[i] != bytes[i]) + { + match = false; + break; + } + } + if (match && strSpan[bytes.Length] == 0) + { + return (offset, bytes.Length); + } + } + Data.Seek(0L, SeekOrigin.End); + var newOffset = (int)Data.Position; + StartingOffsets.Add(newOffset); + Data.Write(bytes); + Data.WriteByte(0); + return (newOffset, bytes.Length); + } + } + + private const uint ShPkMagic = 0x6B506853u; // bytes of ShPk + private const uint DX9Magic = 0x00395844u; // bytes of DX9\0 + private const uint DX11Magic = 0x31315844u; // bytes of DX11 + + public const uint MaterialParamsConstantId = 0x64D12851u; + + public uint Unknown1; + public DXVersion DirectXVersion; + public Shader[] VertexShaders; + public Shader[] PixelShaders; + public uint MaterialParamsSize; + public MaterialParam[] MaterialParams; + public Resource[] Constants; + public Resource[] Samplers; + public (uint, uint)[] UnknownA; + public (uint, uint)[] UnknownB; + public (uint, uint)[] UnknownC; + public uint Unknown2; + public uint Unknown3; + public uint Unknown4; + public (uint, uint, uint) Unknowns; + public byte[] AdditionalData; + public StringPool Strings; // Cannot be safely discarded yet, we don't know if AdditionalData references it + + public bool Valid { get; private set; } + private bool _changed; + + public MaterialParam? GetMaterialParamById(uint id) + { + return MaterialParams.Select(param => new MaterialParam?(param)).FirstOrDefault(param => param!.Value.Id == id); + } + + public Resource? GetConstantById(uint id) + { + return Constants.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Id == id); + } + + public Resource? GetConstantByName(string name) + { + return Constants.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Name == name); + } + + public Resource? GetSamplerById(uint id) + { + return Samplers.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Id == id); + } + + public Resource? GetSamplerByName(string name) + { + return Samplers.Select(res => new Resource?(res)).FirstOrDefault(res => res!.Value.Name == name); + } + + // Activator.CreateInstance can't use a ctor with a default value so this has to be made explicit + public ShpkFile(byte[] data) + : this(data, false) + { + } + + public ShpkFile(byte[] data, bool disassemble = false) + { + using var stream = new MemoryStream(data); + using var r = new BinaryReader(stream); + + if (r.ReadUInt32() != ShPkMagic) + { + throw new InvalidDataException(); + } + Unknown1 = r.ReadUInt32(); + DirectXVersion = r.ReadUInt32() switch + { + DX9Magic => DXVersion.DirectX9, + DX11Magic => DXVersion.DirectX11, + _ => throw new InvalidDataException(), + }; + if (r.ReadUInt32() != data.Length) + { + throw new InvalidDataException(); + } + var blobsOffset = r.ReadUInt32(); + var stringsOffset = r.ReadUInt32(); + var vertexShaderCount = r.ReadUInt32(); + var pixelShaderCount = r.ReadUInt32(); + MaterialParamsSize = r.ReadUInt32(); + var materialParamCount = r.ReadUInt32(); + var constantCount = r.ReadUInt32(); + var samplerCount = r.ReadUInt32(); + var unknownACount = r.ReadUInt32(); + var unknownBCount = r.ReadUInt32(); + var unknownCCount = r.ReadUInt32(); + Unknown2 = r.ReadUInt32(); + Unknown3 = r.ReadUInt32(); + Unknown4 = r.ReadUInt32(); + + var blobs = new ReadOnlySpan(data, (int)blobsOffset, (int)(stringsOffset - blobsOffset)); + Strings = new StringPool(new ReadOnlySpan(data, (int)stringsOffset, (int)(data.Length - stringsOffset))); + + VertexShaders = ReadShaderArray(r, (int)vertexShaderCount, DisassembledShader.ShaderStage.Vertex, DirectXVersion, disassemble, blobs, Strings); + PixelShaders = ReadShaderArray(r, (int)pixelShaderCount, DisassembledShader.ShaderStage.Pixel, DirectXVersion, disassemble, blobs, Strings); + + MaterialParams = r.ReadStructuresAsArray((int)materialParamCount); + + Constants = ReadResourceArray(r, (int)constantCount, Strings); + Samplers = ReadResourceArray(r, (int)samplerCount, Strings); + + var unk1 = r.ReadUInt32(); + var unk2 = r.ReadUInt32(); + var unk3 = r.ReadUInt32(); + Unknowns = (unk1, unk2, unk3); + + UnknownA = ReadUInt32PairArray(r, (int)unknownACount); + UnknownB = ReadUInt32PairArray(r, (int)unknownBCount); + UnknownC = ReadUInt32PairArray(r, (int)unknownCCount); + + AdditionalData = r.ReadBytes((int)(blobsOffset - r.BaseStream.Position)); + + if (disassemble) + { + UpdateUsed(); + } + + Valid = true; + _changed = false; + } + + public void UpdateResources() + { + var constants = new Dictionary(); + var samplers = new Dictionary(); + static void CollectResources(Dictionary resources, Resource[] shaderResources, Func getExistingById, bool isSamplers) + { + foreach (var resource in shaderResources) + { + if (resources.TryGetValue(resource.Id, out var carry) && isSamplers) + { + continue; + } + var existing = getExistingById(resource.Id); + resources[resource.Id] = new Resource + { + Id = resource.Id, + Name = resource.Name, + Slot = existing?.Slot ?? (isSamplers ? (ushort)2 : (ushort)65535), + Size = isSamplers ? (existing?.Size ?? 0) : Math.Max(carry.Size, resource.Size), + Used = null, + UsedDynamically = null, + }; + } + } + foreach (var shader in VertexShaders) + { + CollectResources(constants, shader.Constants, GetConstantById, false); + CollectResources(samplers, shader.Samplers, GetSamplerById, true); + } + foreach (var shader in PixelShaders) + { + CollectResources(constants, shader.Constants, GetConstantById, false); + CollectResources(samplers, shader.Samplers, GetSamplerById, true); + } + Constants = constants.Values.ToArray(); + Samplers = samplers.Values.ToArray(); + UpdateUsed(); + MaterialParamsSize = (GetConstantById(MaterialParamsConstantId)?.Size ?? 0u) << 4; + foreach (var param in MaterialParams) + { + MaterialParamsSize = Math.Max(MaterialParamsSize, (uint)param.ByteOffset + param.ByteSize); + } + MaterialParamsSize = (MaterialParamsSize + 0xFu) & ~0xFu; + } + + private void UpdateUsed() + { + var cUsage = new Dictionary(); + var sUsage = new Dictionary(); + static void CollectUsage(Dictionary usage, Resource[] resources) + { + foreach (var resource in resources) + { + if (resource.Used == null) + { + continue; + } + usage.TryGetValue(resource.Id, out var carry); + carry.Item1 ??= Array.Empty(); + var combined = new DisassembledShader.VectorComponents[Math.Max(carry.Item1.Length, resource.Used.Length)]; + for (var i = 0; i < combined.Length; ++i) + { + combined[i] = (i < carry.Item1.Length ? carry.Item1[i] : 0) | (i < resource.Used.Length ? resource.Used[i] : 0); + } + usage[resource.Id] = (combined, carry.Item2 | (resource.UsedDynamically ?? 0)); + } + } + foreach (var shader in VertexShaders) + { + CollectUsage(cUsage, shader.Constants); + CollectUsage(sUsage, shader.Samplers); + } + foreach (var shader in PixelShaders) + { + CollectUsage(cUsage, shader.Constants); + CollectUsage(sUsage, shader.Samplers); + } + for (var i = 0; i < Constants.Length; ++i) + { + if (cUsage.TryGetValue(Constants[i].Id, out var usage)) + { + Constants[i].Used = usage.Item1; + Constants[i].UsedDynamically = usage.Item2; + } + else + { + Constants[i].Used = null; + Constants[i].UsedDynamically = null; + } + } + for (var i = 0; i < Samplers.Length; ++i) + { + if (sUsage.TryGetValue(Samplers[i].Id, out var usage)) + { + Samplers[i].Used = usage.Item1; + Samplers[i].UsedDynamically = usage.Item2; + } + else + { + Samplers[i].Used = null; + Samplers[i].UsedDynamically = null; + } + } + } + + public void SetInvalid() + { + Valid = false; + } + + public void SetChanged() + { + _changed = true; + } + + public bool IsChanged() + { + var changed = _changed; + _changed = false; + return changed; + } + + private static void ClearUsed(Resource[] resources) + { + for (var i = 0; i < resources.Length; ++i) + { + resources[i].Used = null; + resources[i].UsedDynamically = null; + } + } + + private static Resource[] ReadResourceArray(BinaryReader r, int count, StringPool strings) + { + var ret = new Resource[count]; + for (var i = 0; i < count; ++i) + { + var buf = new Resource(); + + buf.Id = r.ReadUInt32(); + var strOffset = r.ReadUInt32(); + var strSize = r.ReadUInt32(); + buf.Name = strings.GetString((int)strOffset, (int)strSize); + buf.Slot = r.ReadUInt16(); + buf.Size = r.ReadUInt16(); + + ret[i] = buf; + } + + return ret; + } + + private static Shader[] ReadShaderArray(BinaryReader r, int count, DisassembledShader.ShaderStage stage, DXVersion directX, bool disassemble, ReadOnlySpan blobs, StringPool strings) + { + var extraHeaderSize = stage switch + { + DisassembledShader.ShaderStage.Vertex => directX switch + { + DXVersion.DirectX9 => 4, + DXVersion.DirectX11 => 8, + _ => throw new NotImplementedException(), + }, + _ => 0, + }; + + var ret = new Shader[count]; + for (var i = 0; i < count; ++i) + { + var blobOffset = r.ReadUInt32(); + var blobSize = r.ReadUInt32(); + var constantCount = r.ReadUInt16(); + var samplerCount = r.ReadUInt16(); + var unknownXCount = r.ReadUInt16(); + var unknownYCount = r.ReadUInt16(); + + var rawBlob = blobs.Slice((int)blobOffset, (int)blobSize); + + var shader = new Shader(); + + shader.Stage = disassemble ? stage : DisassembledShader.ShaderStage.Unspecified; + shader.DirectXVersion = directX; + shader.Constants = ReadResourceArray(r, constantCount, strings); + shader.Samplers = ReadResourceArray(r, samplerCount, strings); + shader.UnknownX = ReadResourceArray(r, unknownXCount, strings); + shader.UnknownY = ReadResourceArray(r, unknownYCount, strings); + shader.AdditionalHeader = rawBlob[..extraHeaderSize].ToArray(); + shader.Blob = rawBlob[extraHeaderSize..].ToArray(); + + ret[i] = shader; + } + + return ret; + } + + private static (uint, uint)[] ReadUInt32PairArray(BinaryReader r, int count) + { + var ret = new (uint, uint)[count]; + for (var i = 0; i < count; ++i) + { + var first = r.ReadUInt32(); + var second = r.ReadUInt32(); + + ret[i] = (first, second); + } + + return ret; + } +} diff --git a/Penumbra.GameData/Interop/D3DCompiler.cs b/Penumbra.GameData/Interop/D3DCompiler.cs new file mode 100644 index 00000000..ed6366c2 --- /dev/null +++ b/Penumbra.GameData/Interop/D3DCompiler.cs @@ -0,0 +1,65 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace Penumbra.GameData.Interop; + +internal static class D3DCompiler +{ + [Guid("8BA5FB08-5195-40e2-AC58-0D989C3A0102")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface ID3DBlob + { + [PreserveSig] + public unsafe void* GetBufferPointer(); + + [PreserveSig] + public UIntPtr GetBufferSize(); + } + + [Flags] + public enum DisassembleFlags : uint + { + EnableColorCode = 1, + EnableDefaultValuePrints = 2, + EnableInstructionNumbering = 4, + EnableInstructionCycle = 8, + DisableDebugInfo = 16, + EnableInstructionOffset = 32, + InstructionOnly = 64, + PrintHexLiterals = 128, + } + + public static unsafe string Disassemble(ReadOnlySpan blob, DisassembleFlags flags = 0, string comments = "") + { + ID3DBlob? disassembly; + int hr; + fixed (byte* pSrcData = blob) + { + hr = D3DDisassemble(pSrcData, new UIntPtr((uint)blob.Length), (uint)flags, comments, out disassembly); + } + Marshal.ThrowExceptionForHR(hr); + var ret = Encoding.UTF8.GetString(BlobContents(disassembly)); + GC.KeepAlive(disassembly); + return ret; + } + + private static unsafe ReadOnlySpan BlobContents(ID3DBlob? blob) + { + if (blob == null) + { + return ReadOnlySpan.Empty; + } + + return new ReadOnlySpan(blob.GetBufferPointer(), (int)blob.GetBufferSize().ToUInt32()); + } + + [PreserveSig] + [DllImport("D3DCompiler_47.dll")] + private extern static unsafe int D3DDisassemble( + [In] byte* pSrcData, + [In] UIntPtr srcDataSize, + uint flags, + [MarshalAs(UnmanagedType.LPStr)] string szComments, + out ID3DBlob? ppDisassembly); +} diff --git a/Penumbra/Mods/Editor/Mod.Editor.Files.cs b/Penumbra/Mods/Editor/Mod.Editor.Files.cs index 583442fd..ea149216 100644 --- a/Penumbra/Mods/Editor/Mod.Editor.Files.cs +++ b/Penumbra/Mods/Editor/Mod.Editor.Files.cs @@ -76,6 +76,7 @@ public partial class Mod private List< FileRegistry > _mtrlFiles = null!; private List< FileRegistry > _mdlFiles = null!; private List< FileRegistry > _texFiles = null!; + private List< FileRegistry > _shpkFiles = null!; private readonly HashSet< Utf8GamePath > _usedPaths = new(); // All paths that are used in @@ -93,6 +94,9 @@ public partial class Mod public IReadOnlyList< FileRegistry > TexFiles => _texFiles; + public IReadOnlyList< FileRegistry > ShpkFiles + => _shpkFiles; + // Remove all path redirections where the pointed-to file does not exist. public void RemoveMissingPaths() { @@ -140,6 +144,7 @@ public partial class Mod _mtrlFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".mtrl", StringComparison.OrdinalIgnoreCase ) ).ToList(); _mdlFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".mdl", StringComparison.OrdinalIgnoreCase ) ).ToList(); _texFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".tex", StringComparison.OrdinalIgnoreCase ) ).ToList(); + _shpkFiles = _availableFiles.Where( f => f.File.FullName.EndsWith( ".shpk", StringComparison.OrdinalIgnoreCase ) ).ToList(); FileChanges = false; foreach( var subMod in _mod.AllSubMods ) { diff --git a/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs b/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs index 343ef0ce..9f4b4562 100644 --- a/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs +++ b/Penumbra/UI/Classes/ModEditWindow.FileEditor.cs @@ -11,6 +11,7 @@ using OtterGui.Raii; using Penumbra.GameData.Files; using Penumbra.Mods; using Penumbra.String.Classes; +using SixLabors.ImageSharp.PixelFormats; namespace Penumbra.UI.Classes; @@ -23,6 +24,7 @@ public partial class ModEditWindow private readonly Func< IReadOnlyList< Mod.Editor.FileRegistry > > _getFiles; private readonly Func< T, bool, bool > _drawEdit; private readonly Func< string > _getInitialPath; + private readonly Func< byte[], T? > _parseFile; private Mod.Editor.FileRegistry? _currentPath; private T? _currentFile; @@ -39,13 +41,14 @@ public partial class ModEditWindow private readonly FileDialogManager _fileDialog = ConfigWindow.SetupFileManager(); public FileEditor( string tabName, string fileType, Func< IReadOnlyList< Mod.Editor.FileRegistry > > getFiles, - Func< T, bool, bool > drawEdit, Func< string > getInitialPath ) + Func< T, bool, bool > drawEdit, Func< string > getInitialPath, Func< byte[], T? >? parseFile ) { _tabName = tabName; _fileType = fileType; _getFiles = getFiles; _drawEdit = drawEdit; _getInitialPath = getInitialPath; + _parseFile = parseFile ?? DefaultParseFile; } public void Draw() @@ -84,7 +87,7 @@ public partial class ModEditWindow if( file != null ) { _defaultException = null; - _defaultFile = Activator.CreateInstance( typeof( T ), file.Data ) as T; + _defaultFile = _parseFile( file.Data ); } else { @@ -172,6 +175,11 @@ public partial class ModEditWindow } } + private static T? DefaultParseFile( byte[] bytes ) + { + return Activator.CreateInstance( typeof( T ), bytes ) as T; + } + private void UpdateCurrentFile( Mod.Editor.FileRegistry path ) { if( ReferenceEquals( _currentPath, path ) ) @@ -185,7 +193,7 @@ public partial class ModEditWindow try { var bytes = File.ReadAllBytes( _currentPath.File.FullName ); - _currentFile = Activator.CreateInstance( typeof( T ), bytes ) as T; + _currentFile = _parseFile( bytes ); } catch( Exception e ) { diff --git a/Penumbra/UI/Classes/ModEditWindow.Materials.cs b/Penumbra/UI/Classes/ModEditWindow.Materials.cs index 400a7f47..d0a0c4bb 100644 --- a/Penumbra/UI/Classes/ModEditWindow.Materials.cs +++ b/Penumbra/UI/Classes/ModEditWindow.Materials.cs @@ -1,15 +1,23 @@ using System; +using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Internal.Notifications; using ImGuiNET; +using Lumina.Data.Parsing; +using Newtonsoft.Json.Linq; using OtterGui; using OtterGui.Raii; using Penumbra.GameData.Files; using Penumbra.String.Classes; using Penumbra.String.Functions; +using Penumbra.Util; +using static OtterGui.Raii.ImRaii; namespace Penumbra.UI.Classes; @@ -17,7 +25,12 @@ public partial class ModEditWindow { private readonly FileEditor< MtrlFile > _materialTab; - private static bool DrawMaterialPanel( MtrlFile file, bool disabled ) + private readonly FileDialogManager _materialFileDialog = ConfigWindow.SetupFileManager(); + + private uint _materialNewConstantId = 0; + private uint _materialNewSamplerId = 0; + + private bool DrawMaterialPanel( MtrlFile file, bool disabled ) { var ret = DrawMaterialTextureChange( file, disabled ); @@ -27,22 +40,38 @@ public partial class ModEditWindow ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); ret |= DrawMaterialColorSetChange( file, disabled ); + ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); + ret |= DrawMaterialShaderResources( file, disabled ); + ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); ret |= DrawOtherMaterialDetails( file, disabled ); + _materialFileDialog.Draw(); + return !disabled && ret; } private static bool DrawMaterialTextureChange( MtrlFile file, bool disabled ) { + var samplers = file.GetSamplersByTexture(); + var names = new List(); + var maxWidth = 0.0f; + for( var i = 0; i < file.Textures.Length; ++i ) + { + var (sampler, shpkSampler) = samplers[i]; + var name = shpkSampler.HasValue ? shpkSampler.Value.Name : sampler.HasValue ? $"0x{sampler.Value.SamplerId:X8}" : $"#{i}"; + names.Add( name ); + maxWidth = Math.Max( maxWidth, ImGui.CalcTextSize( name ).X ); + } + using var id = ImRaii.PushId( "Textures" ); var ret = false; for( var i = 0; i < file.Textures.Length; ++i ) { using var _ = ImRaii.PushId( i ); var tmp = file.Textures[ i ].Path; - ImGui.SetNextItemWidth( ImGui.GetContentRegionAvail().X ); - if( ImGui.InputText( string.Empty, ref tmp, Utf8GamePath.MaxGamePathLength, + ImGui.SetNextItemWidth( ImGui.GetContentRegionAvail().X - maxWidth ); + if( ImGui.InputText( names[i], ref tmp, Utf8GamePath.MaxGamePathLength, disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) && tmp.Length > 0 && tmp != file.Textures[ i ].Path ) @@ -140,24 +169,350 @@ public partial class ModEditWindow return ret; } - private static bool DrawOtherMaterialDetails( MtrlFile file, bool _ ) + private bool DrawMaterialShaderResources( MtrlFile file, bool disabled ) { - if( !ImGui.CollapsingHeader( "Further Content" ) ) + var ret = false; + + if( !ImGui.CollapsingHeader( "Advanced Shader Resources" ) ) { return false; } - using( var textures = ImRaii.TreeNode( "Textures", ImGuiTreeNodeFlags.DefaultOpen ) ) + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 150.0f ); + if( ImGui.InputText( "Shader Package Name", ref file.ShaderPackage.Name, 63, disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) ) { - if( textures ) + ret = true; + } + var shpkFlags = ( int )file.ShaderPackage.Flags; + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 150.0f ); + if( ImGui.InputInt( "Shader Package Flags", ref shpkFlags, 0, 0, ImGuiInputTextFlags.CharsHexadecimal | ( disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) ) ) + { + file.ShaderPackage.Flags = ( uint )shpkFlags; + ret = true; + } + ImRaii.TreeNode( $"Has associated ShPk file (for advanced editing): {( file.AssociatedShpk != null ? "Yes" : "No" )}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + if( !disabled && ImGui.Button( "Associate modded ShPk file" ) ) + { + _materialFileDialog.OpenFileDialog( $"Associate modded ShPk file...", ".shpk", ( success, name ) => { - foreach( var tex in file.Textures ) + if( !success ) { - ImRaii.TreeNode( $"{tex.Path} - {tex.Flags:X4}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + return; + } + + try + { + file.AssociatedShpk = new ShpkFile( File.ReadAllBytes( name ) ); + } + catch( Exception e ) + { + Penumbra.Log.Error( $"Could not load ShPk file {name}:\n{e}" ); + ChatUtil.NotificationMessage( $"Could not load {Path.GetFileName( name )}:\n{e.Message}", "Penumbra Advanced Editing", NotificationType.Error ); + return; + } + ChatUtil.NotificationMessage( $"Advanced Shader Resources for this material will now be based on the supplied {Path.GetFileName( name )}", "Penumbra Advanced Editing", NotificationType.Success ); + } ); + } + + if( file.ShaderPackage.ShaderKeys.Length > 0 ) + { + using var t = ImRaii.TreeNode( "Shader Keys" ); + if( t ) + { + foreach( var (key, idx) in file.ShaderPackage.ShaderKeys.WithIndex() ) + { + using var t2 = ImRaii.TreeNode( $"Shader Key #{idx}", file.ShaderPackage.ShaderKeys.Length == 1 ? ImGuiTreeNodeFlags.DefaultOpen : 0 ); + if( t2 ) + { + ImRaii.TreeNode( $"Category: 0x{key.Category:X8} ({key.Category})", ImGuiTreeNodeFlags.Leaf ).Dispose(); + ImRaii.TreeNode( $"Value: 0x{key.Value:X8} ({key.Value})", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } } } } + if( file.ShaderPackage.Constants.Length > 0 || file.ShaderPackage.ShaderValues.Length > 0 + || file.AssociatedShpk != null && file.AssociatedShpk.Constants.Length > 0 ) + { + var materialParams = file.AssociatedShpk?.GetConstantById( ShpkFile.MaterialParamsConstantId ); + + using var t = ImRaii.TreeNode( materialParams?.Name ?? "Constants" ); + if( t ) + { + var orphanValues = new IndexSet( file.ShaderPackage.ShaderValues.Length, true ); + var aliasedValueCount = 0; + var definedConstants = new HashSet< uint >(); + var hasMalformedConstants = false; + + foreach( var constant in file.ShaderPackage.Constants ) + { + definedConstants.Add( constant.Id ); + var values = file.GetConstantValues( constant ); + if( file.GetConstantValues( constant ).Length > 0 ) + { + var unique = orphanValues.RemoveRange( constant.ByteOffset >> 2, values.Length ); + aliasedValueCount += values.Length - unique; + } + else + { + hasMalformedConstants = true; + } + } + + foreach( var (constant, idx) in file.ShaderPackage.Constants.WithIndex() ) + { + var values = file.GetConstantValues( constant ); + var paramValueOffset = -values.Length; + if( values.Length > 0 ) + { + var shpkParam = file.AssociatedShpk?.GetMaterialParamById( constant.Id ); + var paramByteOffset = shpkParam.HasValue ? shpkParam.Value.ByteOffset : -1; + if( ( paramByteOffset & 0x3 ) == 0 ) + { + paramValueOffset = paramByteOffset >> 2; + } + } + var (constantName, componentOnly) = MaterialParamRangeName( materialParams?.Name ?? "", paramValueOffset, values.Length ); + + using var t2 = ImRaii.TreeNode( $"#{idx}{( constantName != null ? ( ": " + constantName ) : "" )} (ID: 0x{constant.Id:X8})" ); + if( t2 ) + { + if( values.Length > 0 ) + { + var valueOffset = constant.ByteOffset >> 2; + + for( var valueIdx = 0; valueIdx < values.Length; ++valueIdx ) + { + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 150.0f ); + if( ImGui.InputFloat( $"{MaterialParamName( componentOnly, paramValueOffset + valueIdx ) ?? $"#{valueIdx}"} (at 0x{( ( valueOffset + valueIdx ) << 2 ):X4})", + ref values[valueIdx], 0.0f, 0.0f, "%.3f", + disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) ) + { + ret = true; + } + } + } + else + { + ImRaii.TreeNode( $"Offset: 0x{constant.ByteOffset:X4}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + ImRaii.TreeNode( $"Size: 0x{constant.ByteSize:X4}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + + if( !disabled && !hasMalformedConstants && orphanValues.Count == 0 && aliasedValueCount == 0 + && ImGui.Button( "Remove Constant" ) ) + { + ArrayRemove( ref file.ShaderPackage.ShaderValues, constant.ByteOffset >> 2, constant.ByteSize >> 2 ); + ArrayRemove( ref file.ShaderPackage.Constants, idx ); + for( var i = 0; i < file.ShaderPackage.Constants.Length; ++i ) + { + if( file.ShaderPackage.Constants[i].ByteOffset >= constant.ByteOffset ) + { + file.ShaderPackage.Constants[i].ByteOffset -= constant.ByteSize; + } + } + ret = true; + } + } + } + + if( orphanValues.Count > 0 ) + { + using var t2 = ImRaii.TreeNode( $"Orphan Values ({orphanValues.Count})" ); + if( t2 ) + { + foreach( var idx in orphanValues ) + { + ImGui.SetNextItemWidth( ImGui.GetFontSize() * 10.0f ); + if( ImGui.InputFloat( $"#{idx} (at 0x{( idx << 2 ):X4})", + ref file.ShaderPackage.ShaderValues[idx], 0.0f, 0.0f, "%.3f", + disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) ) + { + ret = true; + } + } + } + } + else if ( !disabled && !hasMalformedConstants && file.AssociatedShpk != null ) + { + var missingConstants = file.AssociatedShpk.MaterialParams.Where( constant => ( constant.ByteOffset & 0x3 ) == 0 && ( constant.ByteSize & 0x3 ) == 0 && !definedConstants.Contains( constant.Id ) ).ToArray(); + if( missingConstants.Length > 0 ) + { + var selectedConstant = Array.Find( missingConstants, constant => constant.Id == _materialNewConstantId ); + if( selectedConstant.ByteSize == 0 ) + { + selectedConstant = missingConstants[0]; + _materialNewConstantId = selectedConstant.Id; + } + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 450.0f ); + var (selectedConstantName, _) = MaterialParamRangeName( materialParams?.Name ?? "", selectedConstant.ByteOffset >> 2, selectedConstant.ByteSize >> 2 ); + using( var c = ImRaii.Combo( "##NewConstantId", $"{selectedConstantName} (ID: 0x{selectedConstant.Id:X8})" ) ) + { + if( c ) + { + foreach( var constant in missingConstants ) + { + var (constantName, _) = MaterialParamRangeName( materialParams?.Name ?? "", constant.ByteOffset >> 2, constant.ByteSize >> 2 ); + if( ImGui.Selectable( $"{constantName} (ID: 0x{constant.Id:X8})" ) ) + { + selectedConstant = constant; + _materialNewConstantId = constant.Id; + } + } + } + } + ImGui.SameLine(); + if( ImGui.Button( "Add Constant" ) ) + { + var valueOffset = ArrayAdd( ref file.ShaderPackage.ShaderValues, 0.0f, selectedConstant.ByteSize >> 2 ); + ArrayAdd( ref file.ShaderPackage.Constants, new MtrlFile.Constant + { + Id = _materialNewConstantId, + ByteOffset = ( ushort )( valueOffset << 2 ), + ByteSize = selectedConstant.ByteSize, + } ); + ret = true; + } + } + } + } + } + + if( file.ShaderPackage.Samplers.Length > 0 || file.Textures.Length > 0 + || file.AssociatedShpk != null && file.AssociatedShpk.Samplers.Any( sampler => sampler.Slot == 2 ) ) + { + using var t = ImRaii.TreeNode( "Samplers" ); + if( t ) + { + var orphanTextures = new IndexSet( file.Textures.Length, true ); + var aliasedTextureCount = 0; + var definedSamplers = new HashSet< uint >(); + + foreach( var sampler in file.ShaderPackage.Samplers ) + { + if( !orphanTextures.Remove( sampler.TextureIndex ) ) + { + ++aliasedTextureCount; + } + definedSamplers.Add( sampler.SamplerId ); + } + + foreach( var (sampler, idx) in file.ShaderPackage.Samplers.WithIndex() ) + { + var shpkSampler = file.AssociatedShpk?.GetSamplerById( sampler.SamplerId ); + using var t2 = ImRaii.TreeNode( $"#{idx}{( shpkSampler.HasValue ? ( ": " + shpkSampler.Value.Name ) : "" )} (ID: 0x{sampler.SamplerId:X8})" ); + if( t2 ) + { + ImRaii.TreeNode( $"Texture: #{sampler.TextureIndex} - {Path.GetFileName( file.Textures[sampler.TextureIndex].Path )}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + + // FIXME this probably doesn't belong here + static unsafe bool InputHexUInt16( string label, ref ushort v, ImGuiInputTextFlags flags ) + { + fixed( ushort* v2 = &v ) + { + return ImGui.InputScalar( label, ImGuiDataType.U16, new nint( v2 ), nint.Zero, nint.Zero, "%04X", flags ); + } + } + + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 150.0f ); + if( InputHexUInt16( "Texture Flags", ref file.Textures[sampler.TextureIndex].Flags, disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) ) + { + ret = true; + } + var sampFlags = ( int )sampler.Flags; + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 150.0f ); + if( ImGui.InputInt( "Sampler Flags", ref sampFlags, 0, 0, ImGuiInputTextFlags.CharsHexadecimal | ( disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None ) ) ) + { + file.ShaderPackage.Samplers[idx].Flags = ( uint )sampFlags; + ret = true; + } + + if( !disabled && orphanTextures.Count == 0 && aliasedTextureCount == 0 + && ImGui.Button( "Remove Sampler" ) ) + { + ArrayRemove( ref file.Textures, sampler.TextureIndex ); + ArrayRemove( ref file.ShaderPackage.Samplers, idx ); + for( var i = 0; i < file.ShaderPackage.Samplers.Length; ++i ) + { + if( file.ShaderPackage.Samplers[i].TextureIndex >= sampler.TextureIndex ) + { + --file.ShaderPackage.Samplers[i].TextureIndex; + } + } + ret = true; + } + } + } + + if( orphanTextures.Count > 0 ) + { + using var t2 = ImRaii.TreeNode( $"Orphan Textures ({orphanTextures.Count})" ); + if( t2 ) + { + foreach( var idx in orphanTextures ) + { + ImRaii.TreeNode( $"#{idx}: {Path.GetFileName( file.Textures[idx].Path )} - {file.Textures[idx].Flags:X4}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + else if( !disabled && file.AssociatedShpk != null && aliasedTextureCount == 0 && file.Textures.Length < 255 ) + { + var missingSamplers = file.AssociatedShpk.Samplers.Where( sampler => sampler.Slot == 2 && !definedSamplers.Contains( sampler.Id ) ).ToArray(); + if( missingSamplers.Length > 0 ) + { + var selectedSampler = Array.Find( missingSamplers, sampler => sampler.Id == _materialNewSamplerId ); + if( selectedSampler.Name == null ) + { + selectedSampler = missingSamplers[0]; + _materialNewSamplerId = selectedSampler.Id; + } + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 450.0f ); + using( var c = ImRaii.Combo( "##NewSamplerId", $"{selectedSampler.Name} (ID: 0x{selectedSampler.Id:X8})" ) ) + { + if( c ) + { + foreach( var sampler in missingSamplers ) + { + if( ImGui.Selectable( $"{sampler.Name} (ID: 0x{sampler.Id:X8})" ) ) + { + selectedSampler = sampler; + _materialNewSamplerId = sampler.Id; + } + } + } + } + ImGui.SameLine(); + if( ImGui.Button( "Add Sampler" ) ) + { + var texIndex = ArrayAdd( ref file.Textures, new MtrlFile.Texture + { + Path = string.Empty, + Flags = 0, + } ); + ArrayAdd( ref file.ShaderPackage.Samplers, new Sampler + { + SamplerId = _materialNewSamplerId, + TextureIndex = ( byte )texIndex, + Flags = 0, + } ); + ret = true; + } + } + } + } + } + + return ret; + } + + private bool DrawOtherMaterialDetails( MtrlFile file, bool disabled ) + { + var ret = false; + + if( !ImGui.CollapsingHeader( "Further Content" ) ) + { + return false; + } + using( var sets = ImRaii.TreeNode( "UV Sets", ImGuiTreeNodeFlags.DefaultOpen ) ) { if( sets ) @@ -169,50 +524,6 @@ public partial class ModEditWindow } } - using( var shaders = ImRaii.TreeNode( "Shaders", ImGuiTreeNodeFlags.DefaultOpen ) ) - { - if( shaders ) - { - ImRaii.TreeNode( $"Name: {file.ShaderPackage.Name}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - ImRaii.TreeNode( $"Flags: {file.ShaderPackage.Flags:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - foreach( var (key, idx) in file.ShaderPackage.ShaderKeys.WithIndex() ) - { - using var t = ImRaii.TreeNode( $"Shader Key #{idx}" ); - if( t ) - { - ImRaii.TreeNode( $"Category: {key.Category}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - ImRaii.TreeNode( $"Value: {key.Value}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - } - } - - foreach( var (constant, idx) in file.ShaderPackage.Constants.WithIndex() ) - { - using var t = ImRaii.TreeNode( $"Constant #{idx}" ); - if( t ) - { - ImRaii.TreeNode( $"Category: {constant.Id}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - ImRaii.TreeNode( $"Value: 0x{constant.Value:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - } - } - - foreach( var (sampler, idx) in file.ShaderPackage.Samplers.WithIndex() ) - { - using var t = ImRaii.TreeNode( $"Sampler #{idx}" ); - if( t ) - { - ImRaii.TreeNode( $"ID: {sampler.SamplerId}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - ImRaii.TreeNode( $"Texture Index: {sampler.TextureIndex}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - ImRaii.TreeNode( $"Flags: 0x{sampler.Flags:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - } - } - - foreach( var (value, idx) in file.ShaderPackage.ShaderValues.WithIndex() ) - { - ImRaii.TreeNode( $"Value #{idx}: {value.ToString( CultureInfo.InvariantCulture )}", ImGuiTreeNodeFlags.Leaf ).Dispose(); - } - } - } - if( file.AdditionalData.Length > 0 ) { using var t = ImRaii.TreeNode( $"Additional Data (Size: {file.AdditionalData.Length})###AdditionalData" ); @@ -222,7 +533,7 @@ public partial class ModEditWindow } } - return false; + return ret; } private static void ColorSetCopyAllClipboardButton( MtrlFile file, int colorSetIdx ) @@ -659,4 +970,69 @@ public partial class ModEditWindow } } } + + // FIXME this probably doesn't belong here + // Also used in ShaderPackages + private static int ArrayAdd( ref T[] array, T element, int count = 1 ) + { + var length = array.Length; + var newArray = new T[array.Length + count]; + Array.Copy( array, newArray, length ); + for( var i = 0; i < count; ++i ) + { + newArray[length + i] = element; + } + array = newArray; + return length; + } + + private static void ArrayRemove( ref T[] array, int offset, int count = 1 ) + { + var newArray = new T[array.Length - count]; + Array.Copy( array, newArray, offset ); + Array.Copy( array, offset + count, newArray, offset, newArray.Length - offset ); + array = newArray; + } + + private static (string?, bool) MaterialParamRangeName( string prefix, int valueOffset, int valueLength ) + { + if( valueLength == 0 || valueOffset < 0 ) + { + return (null, false); + } + + var firstVector = valueOffset >> 2; + var lastVector = ( valueOffset + valueLength - 1 ) >> 2; + var firstComponent = valueOffset & 0x3; + var lastComponent = ( valueOffset + valueLength - 1 ) & 0x3; + + static string VectorSwizzle( int firstComponent, int numComponents ) + => ( numComponents == 4 ) ? "" : string.Concat( ".", "xyzw".AsSpan( firstComponent, numComponents ) ); + + if( firstVector == lastVector ) + { + return ($"{prefix}[{firstVector}]{VectorSwizzle( firstComponent, lastComponent + 1 - firstComponent )}", true); + } + + var parts = new string[lastVector + 1 - firstVector]; + parts[0] = $"{prefix}[{firstVector}]{VectorSwizzle( firstComponent, 4 - firstComponent )}"; + parts[^1] = $"[{lastVector}]{VectorSwizzle( 0, lastComponent + 1 )}"; + for( var i = firstVector + 1; i < lastVector; ++i ) + { + parts[i - firstVector] = $"[{i}]"; + } + + return (string.Join( ", ", parts ), false); + } + + private static string? MaterialParamName( bool componentOnly, int offset ) + { + if( offset < 0 ) + { + return null; + } + var component = "xyzw"[offset & 0x3]; + + return componentOnly ? new string( component, 1 ) : $"[{offset >> 2}].{component}"; + } } \ No newline at end of file diff --git a/Penumbra/UI/Classes/ModEditWindow.ShaderPackages.cs b/Penumbra/UI/Classes/ModEditWindow.ShaderPackages.cs new file mode 100644 index 00000000..4facc34f --- /dev/null +++ b/Penumbra/UI/Classes/ModEditWindow.ShaderPackages.cs @@ -0,0 +1,557 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Interface; +using ImGuiNET; +using OtterGui.Raii; +using OtterGui; +using Penumbra.GameData.Data; +using Penumbra.GameData.Files; +using Penumbra.Util; +using Lumina.Data.Parsing; +using static OtterGui.Raii.ImRaii; + +namespace Penumbra.UI.Classes; + +public partial class ModEditWindow +{ + private readonly FileEditor _shaderPackageTab; + + private readonly FileDialogManager _shaderPackageFileDialog = ConfigWindow.SetupFileManager(); + + private uint _shaderPackageNewMaterialParamId = 0; + private ushort _shaderPackageNewMaterialParamStart = 0; + private ushort _shaderPackageNewMaterialParamEnd = 0; + + private bool DrawShaderPackagePanel( ShpkFile file, bool disabled ) + { + var ret = DrawShaderPackageSummary( file, disabled ); + + ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); + ret |= DrawShaderPackageShaderArray( "Vertex Shader", file.VertexShaders, file, disabled ); + + ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); + ret |= DrawShaderPackageShaderArray( "Pixel Shader", file.PixelShaders, file, disabled ); + + ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); + ret |= DrawShaderPackageMaterialParamLayout( file, disabled ); + + ImGui.Dummy( new Vector2( ImGui.GetTextLineHeight() / 2 ) ); + ret |= DrawOtherShaderPackageDetails( file, disabled ); + + _shaderPackageFileDialog.Draw(); + + ret |= file.IsChanged(); + + return !disabled && ret; + } + + private static bool DrawShaderPackageSummary( ShpkFile file, bool _ ) + { + ImGui.Text( $"Shader Package for DirectX {( int )file.DirectXVersion}" ); + + return false; + } + + private bool DrawShaderPackageShaderArray( string objectName, ShpkFile.Shader[] shaders, ShpkFile file, bool disabled ) + { + if( shaders.Length == 0 ) + { + return false; + } + + if( !ImGui.CollapsingHeader( $"{objectName}s" ) ) + { + return false; + } + + var ret = false; + + foreach( var (shader, idx) in shaders.WithIndex() ) + { + using var t = ImRaii.TreeNode( $"{objectName} #{idx}" ); + if( t ) + { + if( ImGui.Button( $"Export Shader Blob ({shader.Blob.Length} bytes)" ) ) + { + var extension = file.DirectXVersion switch + { + ShpkFile.DXVersion.DirectX9 => ".cso", + ShpkFile.DXVersion.DirectX11 => ".dxbc", + _ => throw new NotImplementedException(), + }; + var defaultName = new string( objectName.Where( char.IsUpper ).ToArray() ).ToLower() + idx.ToString(); + var blob = shader.Blob; + _shaderPackageFileDialog.SaveFileDialog( $"Export {objectName} #{idx} Blob to...", extension, defaultName, extension, ( success, name ) => + { + if( !success ) + { + return; + } + + try + { + File.WriteAllBytes( name, blob ); + } + catch( Exception e ) + { + Penumbra.Log.Error( $"Could not export {defaultName}{extension} to {name}:\n{e}" ); + ChatUtil.NotificationMessage( $"Could not export {defaultName}{extension} to {Path.GetFileName( name )}:\n{e.Message}", "Penumbra Advanced Editing", NotificationType.Error ); + return; + } + ChatUtil.NotificationMessage( $"Shader Blob {defaultName}{extension} exported successfully to {Path.GetFileName( name )}", "Penumbra Advanced Editing", NotificationType.Success ); + } ); + } + if( !disabled ) + { + ImGui.SameLine(); + if( ImGui.Button( "Replace Shader Blob" ) ) + { + _shaderPackageFileDialog.OpenFileDialog( $"Replace {objectName} #{idx} Blob...", "Shader Blobs{.o,.cso,.dxbc,.dxil}", ( success, name ) => + { + if( !success ) + { + return; + } + + try + { + shaders[idx].Blob = File.ReadAllBytes( name ); + } + catch( Exception e ) + { + Penumbra.Log.Error( $"Could not import Shader Blob {name}:\n{e}" ); + ChatUtil.NotificationMessage( $"Could not import {Path.GetFileName( name )}:\n{e.Message}", "Penumbra Advanced Editing", NotificationType.Error ); + return; + } + try + { + shaders[idx].UpdateResources( file ); + file.UpdateResources(); + } + catch( Exception e ) + { + file.SetInvalid(); + Penumbra.Log.Error( $"Failed to update resources after importing Shader Blob {name}:\n{e}" ); + ChatUtil.NotificationMessage( $"Failed to update resources after importing {Path.GetFileName( name )}:\n{e.Message}", "Penumbra Advanced Editing", NotificationType.Error ); + return; + } + file.SetChanged(); + ChatUtil.NotificationMessage( $"Shader Blob {Path.GetFileName( name )} imported successfully", "Penumbra Advanced Editing", NotificationType.Success ); + } ); + } + } + + ret |= DrawShaderPackageResourceArray( "Constant Buffers", "slot", true, shader.Constants, disabled ); + ret |= DrawShaderPackageResourceArray( "Samplers", "slot", false, shader.Samplers, disabled ); + ret |= DrawShaderPackageResourceArray( "Unknown Type X Resources", "slot", true, shader.UnknownX, disabled ); + ret |= DrawShaderPackageResourceArray( "Unknown Type Y Resources", "slot", true, shader.UnknownY, disabled ); + + if( shader.AdditionalHeader.Length > 0 ) + { + using var t2 = ImRaii.TreeNode( $"Additional Header (Size: {shader.AdditionalHeader.Length})###AdditionalHeader" ); + if( t2 ) + { + ImGuiUtil.TextWrapped( string.Join( ' ', shader.AdditionalHeader.Select( c => $"{c:X2}" ) ) ); + } + } + + using( var t2 = ImRaii.TreeNode( "Raw Disassembly" ) ) + { + if( t2 ) + { + using( var font = ImRaii.PushFont( UiBuilder.MonoFont ) ) + { + ImGui.TextUnformatted( shader.Disassembly!.RawDisassembly ); + } + } + } + } + } + + return ret; + } + + private bool DrawShaderPackageMaterialParamLayout( ShpkFile file, bool disabled ) + { + var ret = false; + + var materialParams = file.GetConstantById( ShpkFile.MaterialParamsConstantId ); + + if( !ImGui.CollapsingHeader( $"{materialParams?.Name ?? "Material Parameter"} Layout" ) ) + { + return false; + } + + var isSizeWellDefined = ( file.MaterialParamsSize & 0xF ) == 0 && ( !materialParams.HasValue || file.MaterialParamsSize == ( materialParams.Value.Size << 4 ) ); + + if( !isSizeWellDefined ) + { + if( materialParams.HasValue ) + { + ImGui.Text( $"Buffer size mismatch: {file.MaterialParamsSize} bytes ≠ {materialParams.Value.Size} registers ({materialParams.Value.Size << 4} bytes)" ); + } + else + { + ImGui.Text( $"Buffer size mismatch: {file.MaterialParamsSize} bytes, not a multiple of 16" ); + } + } + + var parameters = new (uint, bool)?[( ( file.MaterialParamsSize + 0xFu ) & ~0xFu) >> 2]; + var orphanParameters = new IndexSet( parameters.Length, true ); + var definedParameters = new HashSet< uint >(); + var hasMalformedParameters = false; + + foreach( var param in file.MaterialParams ) + { + definedParameters.Add( param.Id ); + if( ( param.ByteOffset & 0x3 ) == 0 && ( param.ByteSize & 0x3 ) == 0 + && ( param.ByteOffset + param.ByteSize ) <= file.MaterialParamsSize ) + { + var valueOffset = param.ByteOffset >> 2; + var valueCount = param.ByteSize >> 2; + orphanParameters.RemoveRange( valueOffset, valueCount ); + + parameters[valueOffset] = (param.Id, true); + + for( var i = 1; i < valueCount; ++i ) + { + parameters[valueOffset + i] = (param.Id, false); + } + } + else + { + hasMalformedParameters = true; + } + } + + ImGui.Text( "Parameter positions (continuations are grayed out, unused values are red):" ); + + using( var table = ImRaii.Table( "##MaterialParamLayout", 5, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg ) ) + { + if( table ) + { + ImGui.TableNextColumn(); + ImGui.TableHeader( string.Empty ); + ImGui.TableNextColumn(); + ImGui.TableHeader( "x" ); + ImGui.TableNextColumn(); + ImGui.TableHeader( "y" ); + ImGui.TableNextColumn(); + ImGui.TableHeader( "z" ); + ImGui.TableNextColumn(); + ImGui.TableHeader( "w" ); + + var textColorStart = ImGui.GetColorU32( ImGuiCol.Text ); + var textColorCont = ( textColorStart & 0xFFFFFFu ) | ( ( textColorStart & 0xFE000000u ) >> 1 ); // Half opacity + var textColorUnusedStart = ( textColorStart & 0xFF000000u ) | ( ( textColorStart & 0xFEFEFE ) >> 1 ) | 0x80u; // Half red + var textColorUnusedCont = ( textColorUnusedStart & 0xFFFFFFu ) | ( ( textColorUnusedStart & 0xFE000000u ) >> 1 ); + + for( var idx = 0; idx < parameters.Length; idx += 4 ) + { + var usedComponents = materialParams?.Used?[idx >> 2] ?? DisassembledShader.VectorComponents.All; + ImGui.TableNextColumn(); + ImGui.Text( $"[{idx >> 2}]" ); + for( var col = 0; col < 4; ++col ) + { + var cell = parameters[idx + col]; + ImGui.TableNextColumn(); + var start = cell.HasValue && cell.Value.Item2; + var used = ( ( byte )usedComponents & ( 1 << col ) ) != 0; + using var c = ImRaii.PushColor( ImGuiCol.Text, used ? ( start ? textColorStart : textColorCont ) : ( start ? textColorUnusedStart : textColorUnusedCont ) ); + ImGui.Text( cell.HasValue ? $"0x{cell.Value.Item1:X8}" : "(none)" ); + } + ImGui.TableNextRow(); + } + } + } + + if( hasMalformedParameters ) + { + using var t = ImRaii.TreeNode( "Misaligned / Overflowing Parameters" ); + if( t ) + { + foreach( var param in file.MaterialParams ) + { + if( ( param.ByteOffset & 0x3 ) != 0 || ( param.ByteSize & 0x3 ) != 0 ) + { + ImRaii.TreeNode( $"ID: 0x{param.Id:X8}, offset: 0x{param.ByteOffset:X4}, size: 0x{param.ByteSize:X4}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + else if( ( param.ByteOffset + param.ByteSize ) > file.MaterialParamsSize ) + { + ImRaii.TreeNode( $"{MaterialParamRangeName( materialParams?.Name ?? string.Empty, param.ByteOffset >> 2, param.ByteSize >> 2 )} (ID: 0x{param.Id:X8})", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + } + else if( !disabled && isSizeWellDefined ) + { + using var t = ImRaii.TreeNode( "Add / Remove Parameters" ); + if( t ) + { + for( var i = 0; i < file.MaterialParams.Length; ++i ) + { + var param = file.MaterialParams[i]; + using var t2 = ImRaii.TreeNode( $"{MaterialParamRangeName(materialParams?.Name ?? string.Empty, param.ByteOffset >> 2, param.ByteSize >> 2).Item1} (ID: 0x{param.Id:X8})" ); + if( t2 ) + { + if( ImGui.Button( "Remove" ) ) + { + ArrayRemove( ref file.MaterialParams, i ); + ret = true; + } + } + } + if( orphanParameters.Count > 0 ) + { + using var t2 = ImRaii.TreeNode( "New Parameter" ); + if( t2 ) + { + var starts = orphanParameters.ToArray(); + if( !orphanParameters[_shaderPackageNewMaterialParamStart] ) + { + _shaderPackageNewMaterialParamStart = ( ushort )starts[0]; + } + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 225.0f ); + var startName = MaterialParamName( false, _shaderPackageNewMaterialParamStart )!; + using( var c = ImRaii.Combo( "Start", $"{materialParams?.Name ?? ""}{startName}" ) ) + { + if( c ) + { + foreach( var start in starts ) + { + var name = MaterialParamName( false, start )!; + if( ImGui.Selectable( $"{materialParams?.Name ?? ""}{name}" ) ) + { + _shaderPackageNewMaterialParamStart = ( ushort )start; + } + } + } + } + var lastEndCandidate = ( int )_shaderPackageNewMaterialParamStart; + var ends = starts.SkipWhile( i => i < _shaderPackageNewMaterialParamStart ).TakeWhile( i => { + var ret = i <= lastEndCandidate + 1; + lastEndCandidate = i; + return ret; + } ).ToArray(); + if( Array.IndexOf(ends, _shaderPackageNewMaterialParamEnd) < 0 ) + { + _shaderPackageNewMaterialParamEnd = ( ushort )ends[0]; + } + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 225.0f ); + var endName = MaterialParamName( false, _shaderPackageNewMaterialParamEnd )!; + using( var c = ImRaii.Combo( "End", $"{materialParams?.Name ?? ""}{endName}" ) ) + { + if( c ) + { + foreach( var end in ends ) + { + var name = MaterialParamName( false, end )!; + if( ImGui.Selectable( $"{materialParams?.Name ?? ""}{name}" ) ) + { + _shaderPackageNewMaterialParamEnd = ( ushort )end; + } + } + } + } + var id = ( int )_shaderPackageNewMaterialParamId; + ImGui.SetNextItemWidth( ImGuiHelpers.GlobalScale * 150.0f ); + if( ImGui.InputInt( "ID", ref id, 0, 0, ImGuiInputTextFlags.CharsHexadecimal ) ) + { + _shaderPackageNewMaterialParamId = ( uint )id; + } + if( ImGui.Button( "Add" ) ) + { + if( definedParameters.Contains( _shaderPackageNewMaterialParamId ) ) + { + ChatUtil.NotificationMessage( $"Duplicate parameter ID 0x{_shaderPackageNewMaterialParamId:X8}", "Penumbra Advanced Editing", NotificationType.Error ); + } + else + { + ArrayAdd( ref file.MaterialParams, new ShpkFile.MaterialParam + { + Id = _shaderPackageNewMaterialParamId, + ByteOffset = ( ushort )( _shaderPackageNewMaterialParamStart << 2 ), + ByteSize = ( ushort )( ( _shaderPackageNewMaterialParamEnd + 1 - _shaderPackageNewMaterialParamStart ) << 2 ), + } ); + ret = true; + } + } + } + } + } + } + + return ret; + } + + private static bool DrawShaderPackageResourceArray( string arrayName, string slotLabel, bool withSize, ShpkFile.Resource[] resources, bool _ ) + { + if( resources.Length == 0 ) + { + return false; + } + + using var t = ImRaii.TreeNode( arrayName ); + if( !t ) + { + return false; + } + + var ret = false; + + foreach( var (buf, idx) in resources.WithIndex() ) + { + using var t2 = ImRaii.TreeNode( $"#{idx}: {buf.Name} (ID: 0x{buf.Id:X8}), {slotLabel}: {buf.Slot}" + ( withSize ? $", size: {buf.Size} registers" : string.Empty ), ( buf.Used != null ) ? 0 : ImGuiTreeNodeFlags.Leaf ); + if( t2 ) + { + var used = new List< string >(); + if( withSize ) + { + foreach( var (components, i) in ( buf.Used ?? Array.Empty() ).WithIndex() ) + { + switch( components ) + { + case 0: + break; + case DisassembledShader.VectorComponents.All: + used.Add( $"[{i}]" ); + break; + default: + used.Add( $"[{i}].{new string( components.ToString().Where( char.IsUpper ).ToArray() ).ToLower()}" ); + break; + } + } + switch( buf.UsedDynamically ?? 0 ) + { + case 0: + break; + case DisassembledShader.VectorComponents.All: + used.Add( "[*]" ); + break; + default: + used.Add( $"[*].{new string( buf.UsedDynamically!.Value.ToString().Where( char.IsUpper ).ToArray() ).ToLower()}" ); + break; + } + } + else + { + var components = ( ( buf.Used != null && buf.Used.Length > 0 ) ? buf.Used[0] : 0 ) | (buf.UsedDynamically ?? 0); + if( ( components & DisassembledShader.VectorComponents.X ) != 0 ) + { + used.Add( "Red" ); + } + if( ( components & DisassembledShader.VectorComponents.Y ) != 0 ) + { + used.Add( "Green" ); + } + if( ( components & DisassembledShader.VectorComponents.Z ) != 0 ) + { + used.Add( "Blue" ); + } + if( ( components & DisassembledShader.VectorComponents.W ) != 0 ) + { + used.Add( "Alpha" ); + } + } + if( used.Count > 0 ) + { + ImRaii.TreeNode( $"Used: {string.Join(", ", used)}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + else + { + ImRaii.TreeNode( "Unused", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + + return ret; + } + + private static bool DrawOtherShaderPackageDetails( ShpkFile file, bool disabled ) + { + var ret = false; + + if( !ImGui.CollapsingHeader( "Further Content" ) ) + { + return false; + } + + ret |= DrawShaderPackageResourceArray( "Constant Buffers", "type", true, file.Constants, disabled ); + ret |= DrawShaderPackageResourceArray( "Samplers", "type", false, file.Samplers, disabled ); + + if( file.UnknownA.Length > 0 ) + { + using var t = ImRaii.TreeNode( $"Unknown Type A Structures ({file.UnknownA.Length})" ); + if( t ) + { + foreach( var (unk, idx) in file.UnknownA.WithIndex() ) + { + ImRaii.TreeNode( $"#{idx}: 0x{unk.Item1:X8}, 0x{unk.Item2:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + + if( file.UnknownB.Length > 0 ) + { + using var t = ImRaii.TreeNode( $"Unknown Type B Structures ({file.UnknownB.Length})" ); + if( t ) + { + foreach( var (unk, idx) in file.UnknownB.WithIndex() ) + { + ImRaii.TreeNode( $"#{idx}: 0x{unk.Item1:X8}, 0x{unk.Item2:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + + if( file.UnknownC.Length > 0 ) + { + using var t = ImRaii.TreeNode( $"Unknown Type C Structures ({file.UnknownC.Length})" ); + if( t ) + { + foreach( var (unk, idx) in file.UnknownC.WithIndex() ) + { + ImRaii.TreeNode( $"#{idx}: 0x{unk.Item1:X8}, 0x{unk.Item2:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + } + + using( var t = ImRaii.TreeNode( $"Misc. Unknown Fields" ) ) + { + if( t ) + { + ImRaii.TreeNode( $"#1 (at 0x0004): 0x{file.Unknown1:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + ImRaii.TreeNode( $"#2 (at 0x003C): 0x{file.Unknown2:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + ImRaii.TreeNode( $"#3 (at 0x0040): 0x{file.Unknown3:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + ImRaii.TreeNode( $"#4 (at 0x0044): 0x{file.Unknown4:X8}", ImGuiTreeNodeFlags.Leaf ).Dispose(); + } + } + + if( file.AdditionalData.Length > 0 ) + { + using var t = ImRaii.TreeNode( $"Additional Data (Size: {file.AdditionalData.Length})###AdditionalData" ); + if( t ) + { + ImGuiUtil.TextWrapped( string.Join( ' ', file.AdditionalData.Select( c => $"{c:X2}" ) ) ); + } + } + + using( var t = ImRaii.TreeNode( $"String Pool" ) ) + { + if( t ) + { + foreach( var offset in file.Strings.StartingOffsets ) + { + ImGui.Text( file.Strings.GetNullTerminatedString( offset ) ); + } + } + } + + return ret; + } +} \ No newline at end of file diff --git a/Penumbra/UI/Classes/ModEditWindow.cs b/Penumbra/UI/Classes/ModEditWindow.cs index e1fecd92..5e5c514e 100644 --- a/Penumbra/UI/Classes/ModEditWindow.cs +++ b/Penumbra/UI/Classes/ModEditWindow.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Numerics; using System.Text; @@ -47,6 +48,7 @@ public partial class ModEditWindow : Window, IDisposable _selectedFiles.Clear(); _modelTab.Reset(); _materialTab.Reset(); + _shaderPackageTab.Reset(); _swapWindow.UpdateMod( mod, Penumbra.CollectionManager.Current[ mod.Index ].Settings ); } @@ -155,6 +157,7 @@ public partial class ModEditWindow : Window, IDisposable _modelTab.Draw(); _materialTab.Draw(); DrawTextureTab(); + _shaderPackageTab.Draw(); _swapWindow.DrawItemSwapPanel(); } @@ -532,17 +535,65 @@ public partial class ModEditWindow : Window, IDisposable ImGui.InputTextWithHint( "##swapValue", "... instead of this file.", ref _newSwapKey, Utf8GamePath.MaxGamePathLength ); } + // FIXME this probably doesn't belong here + private T? LoadAssociatedFile( string gamePath, Func< byte[], T? > parse ) + { + var defaultFiles = _mod?.Default?.Files; + if( defaultFiles != null ) + { + if( Utf8GamePath.FromString( gamePath, out var utf8Path, true ) ) + { + try + { + if (defaultFiles.TryGetValue( utf8Path, out var fsPath )) + { + return parse( File.ReadAllBytes( fsPath.FullName ) ); + } + } + finally + { + utf8Path.Dispose(); + } + } + } + + var file = Dalamud.GameData.GetFile( gamePath )?.Data; + return file == null ? default : parse( file ); + } + + // FIXME neither does this + private ShpkFile? LoadAssociatedShpk( string shaderName ) + { + var path = $"shader/sm5/shpk/{shaderName}"; + try + { + return LoadAssociatedFile( path, file => new ShpkFile( file ) ); + } + catch( Exception e ) + { + Penumbra.Log.Debug( $"Could not parse associated file {path} to Shpk:\n{e}" ); + return null; + } + } + public ModEditWindow() : base( WindowBaseLabel ) { _materialTab = new FileEditor< MtrlFile >( "Materials", ".mtrl", () => _editor?.MtrlFiles ?? Array.Empty< Editor.FileRegistry >(), DrawMaterialPanel, - () => _mod?.ModPath.FullName ?? string.Empty ); + () => _mod?.ModPath.FullName ?? string.Empty, + bytes => new MtrlFile( bytes, LoadAssociatedShpk ) ); _modelTab = new FileEditor< MdlFile >( "Models", ".mdl", () => _editor?.MdlFiles ?? Array.Empty< Editor.FileRegistry >(), DrawModelPanel, - () => _mod?.ModPath.FullName ?? string.Empty ); + () => _mod?.ModPath.FullName ?? string.Empty, + null ); + _shaderPackageTab = new FileEditor< ShpkFile >( "Shader Packages", ".shpk", + () => _editor?.ShpkFiles ?? Array.Empty< Editor.FileRegistry >(), + DrawShaderPackagePanel, + () => _mod?.ModPath.FullName ?? string.Empty, + bytes => new ShpkFile( bytes, true ) ); _center = new CombinedTexture( _left, _right ); } diff --git a/Penumbra/Util/IndexSet.cs b/Penumbra/Util/IndexSet.cs new file mode 100644 index 00000000..e0ed7921 --- /dev/null +++ b/Penumbra/Util/IndexSet.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Penumbra.Util; + +public class IndexSet : IEnumerable +{ + private readonly BitArray _set; + private int _count; + + public int Capacity => _set.Count; + + public int Count => _count; + + public bool this[Index index] + { + get => _set[index]; + set + { + if( value ) + { + Add( index ); + } + else + { + Remove( index ); + } + } + } + + public IndexSet( int capacity, bool initiallyFull ) + { + _set = new BitArray( capacity, initiallyFull ); + _count = initiallyFull ? capacity : 0; + } + + public bool Add( Index index ) + { + var ret = !_set[index]; + if( ret ) + { + ++_count; + _set[index] = true; + } + return ret; + } + + public bool Remove( Index index ) + { + var ret = _set[index]; + if( ret ) + { + --_count; + _set[index] = false; + } + return ret; + } + + public int AddRange( int offset, int length ) + { + var ret = 0; + for( var idx = 0; idx < length; ++idx ) + { + if( Add( offset + idx ) ) + { + ++ret; + } + } + return ret; + } + + public int RemoveRange( int offset, int length ) + { + var ret = 0; + for( var idx = 0; idx < length; ++idx ) + { + if( Remove( offset + idx ) ) + { + ++ret; + } + } + return ret; + } + + public IEnumerator GetEnumerator() + { + if( _count > 0 ) + { + var capacity = _set.Count; + var remaining = _count; + for( var i = 0; i < capacity; ++i ) + { + if( _set[i] ) + { + yield return i; + if( --remaining == 0 ) + { + yield break; + } + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +}