From cc39773a9a58206a62363313cfc2e4c21e765fb2 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:37:17 +0200 Subject: [PATCH 01/17] Initial Codex refactor pass --- Volatility/Resources/BinaryResource.cs | 17 +- .../EnvironmentTimeLine.cs | 166 ++++++-- Volatility/Resources/GuiPopup/GuiPopup.cs | 145 ++++--- .../Resources/InstanceList/InstanceList.cs | 163 ++++++-- Volatility/Resources/Model/Model.cs | 167 ++++---- .../Resources/SnapshotData/SnapshotData.cs | 127 ++++--- Volatility/Resources/Splicer/Splicer.cs | 357 ++++++++++-------- Volatility/Utilities/MatrixUtilities.cs | 27 ++ Volatility/Utilities/ResourceBinaryReader.cs | 16 +- Volatility/Utilities/ResourceBinaryWriter.cs | 56 +++ Volatility/Utilities/ResourceUtilities.cs | 52 ++- 11 files changed, 870 insertions(+), 423 deletions(-) diff --git a/Volatility/Resources/BinaryResource.cs b/Volatility/Resources/BinaryResource.cs index 4cf68b6..5ad4046 100644 --- a/Volatility/Resources/BinaryResource.cs +++ b/Volatility/Resources/BinaryResource.cs @@ -17,10 +17,13 @@ public class BinaryResource : Resource public BinaryResource(uint dataOffset, uint dataSize) { DataSize = dataSize; - DataOffset = dataOffset; + DataOffset = dataOffset == 0 ? 0x10u : dataOffset; } - public BinaryResource() : base() { } + public BinaryResource() : base() + { + DataOffset = 0x10; + } public BinaryResource(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } @@ -36,8 +39,16 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { + base.WriteToStream(writer, endianness); + + if (DataOffset < 0x10) + { + DataOffset = 0x10; + } + writer.Write(DataSize); writer.Write(DataOffset); writer.Write(new byte[8]); + writer.BaseStream.Seek(DataOffset, SeekOrigin.Begin); } -} \ No newline at end of file +} diff --git a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs index a7539e9..86f1674 100644 --- a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs +++ b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs @@ -1,21 +1,92 @@ -using YamlDotNet.Serialization; +using Volatility.Utilities; namespace Volatility.Resources; public class EnvironmentTimeline : Resource { + private const int HeaderSize = 0x10; + private const int SectionAlignment = 0x10; + private const int KeyframeTimeSize = sizeof(float); + private const int KeyframeReferencePlaceholderSize = sizeof(uint); + private const int ImportEntrySize = 0x10; + public override ResourceType ResourceType => ResourceType.EnvironmentTimeLine; - public LocationData[] Locations; + public LocationData[] Locations = []; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { base.WriteToStream(writer, endianness); - writer.Write(0x1); // Version - writer.Write(Locations.Length); - writer.Write(0x10); // Locations Pointer - writer.Write(0x0); // Padding + Arch arch = ResourceArch; + LocationData[] locations = Locations ?? []; + int locationStructSize = GetLocationStructSize(arch); + + long currentOffset = HeaderSize; + ulong locationsOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + locations.Length, + locationStructSize, + SectionAlignment); + + ulong[] keyframeTimesOffsets = new ulong[locations.Length]; + ulong[] keyframeRefsOffsets = new ulong[locations.Length]; + + int totalImports = 0; + for (int i = 0; i < locations.Length; i++) + { + KeyframeReference[] keyframes = locations[i].Keyframes ?? []; + + keyframeTimesOffsets[i] = ResourceUtilities.GetSectionOffset( + ref currentOffset, + keyframes.Length, + KeyframeTimeSize, + SectionAlignment); + + keyframeRefsOffsets[i] = ResourceUtilities.GetSectionOffset( + ref currentOffset, + keyframes.Length, + KeyframeReferencePlaceholderSize, + SectionAlignment); + + totalImports += keyframes.Length; + } + + long importsOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + totalImports * ImportEntrySize, + SectionAlignment); + + writer.Write(0x1); + writer.Write(locations.Length); + writer.Write((uint)locationsOffset); + writer.Write(0x0); + + writer.WriteSection(locationsOffset, locations, (w, location, index) => + WriteLocationHeader(w, location, arch, keyframeTimesOffsets[index], keyframeRefsOffsets[index])); + + for (int i = 0; i < locations.Length; i++) + { + KeyframeReference[] keyframes = locations[i].Keyframes ?? []; + + writer.WriteSection(keyframeTimesOffsets[i], keyframes, static (w, keyframe) => w.Write(keyframe.KeyframeTime)); + writer.WriteSection(keyframeRefsOffsets[i], keyframes, static (w, _) => w.Write(0u)); + } + + if (importsOffset != 0) + { + writer.BaseStream.Position = importsOffset; + for (int i = 0; i < locations.Length; i++) + { + KeyframeReference[] keyframes = locations[i].Keyframes ?? []; + for (int j = 0; j < keyframes.Length; j++) + { + writer.Write(keyframes[j].ResourceReference.ReferenceID); + writer.Write((uint)(keyframeRefsOffsets[i] + ((ulong)j * KeyframeReferencePlaceholderSize))); + writer.Write(0u); + } + } + } } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) @@ -30,50 +101,77 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann throw new InvalidDataException($"Version mismatch! Version should be 1. (Found version {version})"); } - uint locationCount = reader.ReadUInt32(); - Locations = new LocationData[locationCount]; - + int locationCount = reader.ReadInt32(); uint locationsPtr = reader.ReadUInt32(); + reader.BaseStream.Seek(0x4, SeekOrigin.Current); - for (int i = 0; i < locationCount; i++) - { - reader.BaseStream.Seek(locationsPtr + ((arch == Arch.x64 ? 0x18 : 0xC) * i), SeekOrigin.Begin); - // This is the same kind of 32/64 count value that StreamedDeformationSpec uses - may move that boilerplate and change this? - uint keyframeCount = (uint)(arch == Arch.x64 ? reader.ReadUInt64() : reader.ReadUInt32()); - ulong keyframeTimesPtr = reader.ReadPointer(ResourceArch); - ulong keyframeRefsPtr = reader.ReadPointer(ResourceArch); + Locations = reader.ParseSection((long)locationsPtr, locationCount, r => ReadLocation(r, arch)).ToArray(); + } - Locations[i].Keyframes = new KeyframeReference[keyframeCount]; + public EnvironmentTimeline() : base() { } - long maxLength = (long)new[] - { - keyframeTimesPtr + (keyframeCount * sizeof(uint)), - keyframeRefsPtr + (keyframeCount * sizeof(uint)), - }.Max(); + public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - for (int j = 0; j < keyframeCount; j++) - { - reader.BaseStream.Seek((long)keyframeTimesPtr + (0x4 * j), SeekOrigin.Begin); + private static int GetLocationStructSize(Arch arch) + { + return arch == Arch.x64 ? 0x18 : 0x0C; + } + + private static LocationData ReadLocation(ResourceBinaryReader reader, Arch arch) + { + uint keyframeCount = reader.ReadArchDependUInt(arch); + ulong keyframeTimesPtr = reader.ReadPointer(arch); + ulong keyframeRefsPtr = reader.ReadPointer(arch); - Locations[i].Keyframes[j].KeyframeTime = reader.ReadSingle(); + KeyframeReference[] keyframes = new KeyframeReference[keyframeCount]; + if (keyframeCount == 0) + { + return new LocationData { Keyframes = keyframes }; + } - reader.BaseStream.Seek((long)keyframeRefsPtr + (0x4 * j), SeekOrigin.Begin); + List keyframeTimes = reader.ParseSection(keyframeTimesPtr, (int)keyframeCount, r => r.ReadSingle()); - ResourceImport.ReadExternalImport(fileOffset: reader.BaseStream.Position, reader, maxLength, out Locations[i].Keyframes[j].ResourceReference); - } + long importBlockOffset = Math.Max( + (long)keyframeTimesPtr + (keyframeCount * KeyframeTimeSize), + (long)keyframeRefsPtr + (keyframeCount * KeyframeReferencePlaceholderSize)); + + for (int i = 0; i < keyframeCount; i++) + { + long fileOffset = (long)keyframeRefsPtr + (i * KeyframeReferencePlaceholderSize); + ResourceImport.ReadExternalImport(fileOffset, reader, importBlockOffset, out ResourceImport resourceReference); + + keyframes[i] = new KeyframeReference + { + KeyframeTime = i < keyframeTimes.Count ? keyframeTimes[i] : 0f, + ResourceReference = resourceReference + }; } - } - public EnvironmentTimeline() : base() { } + return new LocationData + { + Keyframes = keyframes + }; + } - public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + private static void WriteLocationHeader( + ResourceBinaryWriter writer, + LocationData location, + Arch arch, + ulong keyframeTimesOffset, + ulong keyframeRefsOffset) + { + uint keyframeCount = (uint)(location.Keyframes?.Length ?? 0); + writer.WriteArchDependUInt(keyframeCount, arch); + writer.WritePointer(keyframeTimesOffset, arch); + writer.WritePointer(keyframeRefsOffset, arch); + } - public struct LocationData + public struct LocationData { public KeyframeReference[] Keyframes; } - public struct KeyframeReference + public struct KeyframeReference { public float KeyframeTime; public ResourceImport ResourceReference; diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/Volatility/Resources/GuiPopup/GuiPopup.cs index 2c9e886..329e521 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/Volatility/Resources/GuiPopup/GuiPopup.cs @@ -1,109 +1,50 @@ -using System.Text; +using Volatility.Utilities; namespace Volatility.Resources; public class GuiPopup : Resource { - public List Popups { get; } = new(); + private const int HeaderSize = 0x8; + private const int PopupStructSize = 0xC0; - const int PopupStructSize = 0xC0; + public List Popups { get; } = []; public override ResourceType ResourceType => ResourceType.GuiPopup; public override Platform ResourcePlatform => Platform.Agnostic; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) { - ushort size = (ushort)(Popups.Count * PopupStructSize); base.WriteToStream(writer, endianness); + long start = writer.BaseStream.Position; - writer.Write((uint)0x8); + writer.Write((uint)HeaderSize); writer.Write((short)Popups.Count); writer.Write((short)PopupStructSize); - foreach (var p in Popups) - WriteOne(writer, p); + writer.WriteSection(start + HeaderSize, Popups, Popup.Write); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) { base.ParseFromStream(reader, endianness); + Popups.Clear(); + long start = reader.BaseStream.Position; uint dataPtr = reader.ReadUInt32(); short count = reader.ReadInt16(); short elemSize = reader.ReadInt16(); - long ret = reader.BaseStream.Position; - reader.BaseStream.Position = start + dataPtr; - for (int i = 0; i < count; i++) - Popups.Add(ReadOne(reader)); - reader.BaseStream.Position = ret; - } - - static Popup ReadOne(ResourceBinaryReader r) - { - Popup p = new Popup(); - p.NameId = r.ReadUInt64(); - p.Name = ReadFixedString(r, 13); - r.BaseStream.Position += 3; - p.Style = (PopupStyle)r.ReadInt32(); - p.Icon = (PopupIcons)r.ReadInt32(); - p.TitleId = ReadFixedString(r, 32); - p.MessageId = ReadFixedString(r, 32); - p.MessageParam0 = (PopupParamTypes)r.ReadInt32(); - p.MessageParam1 = (PopupParamTypes)r.ReadInt32(); - p.MessageParamsUsed = r.ReadInt32(); - p.Button1Id = ReadFixedString(r, 32); - p.Button1Param = (PopupParamTypes)r.ReadInt32(); - p.Button1ParamUsed = r.ReadByte() != 0; - p.Button2Id = ReadFixedString(r, 32); - r.BaseStream.Position += 3; - p.Button2Param = (PopupParamTypes)r.ReadInt32(); - p.Button2ParamUsed = r.ReadByte() != 0; - r.BaseStream.Position += 7; - return p; - } - static void WriteOne(ResourceBinaryWriter w, Popup p) - { - w.Write(p.NameId); - WriteFixedString(w, p.Name, 13); - w.Write(new byte[3]); - w.Write((int)p.Style); - w.Write((int)p.Icon); - WriteFixedString(w, p.TitleId, 32); - WriteFixedString(w, p.MessageId, 32); - w.Write((int)p.MessageParam0); - w.Write((int)p.MessageParam1); - w.Write(p.MessageParamsUsed); - WriteFixedString(w, p.Button1Id, 32); - w.Write((int)p.Button1Param); - w.Write((byte)(p.Button1ParamUsed ? 1 : 0)); - WriteFixedString(w, p.Button2Id, 32); - w.Write(new byte[3]); - w.Write((int)p.Button2Param); - w.Write((byte)(p.Button2ParamUsed ? 1 : 0)); - w.Write(new byte[7]); - } - - static string ReadFixedString(ResourceBinaryReader r, int len) - { - var bytes = r.ReadBytes(len); - int n = Array.IndexOf(bytes, 0); - if (n >= 0) return Encoding.ASCII.GetString(bytes, 0, n); - return Encoding.ASCII.GetString(bytes); - } - - static void WriteFixedString(ResourceBinaryWriter w, string? s, int len) - { - var bytes = Encoding.ASCII.GetBytes(s ?? string.Empty); - if (bytes.Length > len) w.Write(bytes, 0, len); - else + if (elemSize != PopupStructSize) { - w.Write(bytes); - if (bytes.Length < len) w.Write(new byte[len - bytes.Length]); + throw new InvalidDataException( + $"GuiPopup element size mismatch! Expected 0x{PopupStructSize:X}, found 0x{elemSize:X}."); } + + reader.ParseSection(start + dataPtr, count, Popup.Read, Popups); } public GuiPopup() : base() { } + public GuiPopup(string path, Endian endianness) : base(path, endianness) { } public enum PopupStyle : int @@ -159,5 +100,59 @@ public struct Popup public string Button2Id; public PopupParamTypes Button2Param; public bool Button2ParamUsed; + + public static Popup Read(ResourceBinaryReader reader) + { + Popup popup = new() + { + NameId = reader.ReadUInt64(), + Name = ResourceUtilities.ReadFixedString(reader, 13) + }; + + reader.BaseStream.Seek(0x3, SeekOrigin.Current); + + popup.Style = (PopupStyle)reader.ReadInt32(); + popup.Icon = (PopupIcons)reader.ReadInt32(); + popup.TitleId = ResourceUtilities.ReadFixedString(reader, 32); + popup.MessageId = ResourceUtilities.ReadFixedString(reader, 32); + popup.MessageParam0 = (PopupParamTypes)reader.ReadInt32(); + popup.MessageParam1 = (PopupParamTypes)reader.ReadInt32(); + popup.MessageParamsUsed = reader.ReadInt32(); + popup.Button1Id = ResourceUtilities.ReadFixedString(reader, 32); + popup.Button1Param = (PopupParamTypes)reader.ReadInt32(); + popup.Button1ParamUsed = reader.ReadByte() != 0; + popup.Button2Id = ResourceUtilities.ReadFixedString(reader, 32); + + reader.BaseStream.Seek(0x3, SeekOrigin.Current); + + popup.Button2Param = (PopupParamTypes)reader.ReadInt32(); + popup.Button2ParamUsed = reader.ReadByte() != 0; + + reader.BaseStream.Seek(0x7, SeekOrigin.Current); + + return popup; + } + + public static void Write(ResourceBinaryWriter writer, Popup popup) + { + writer.Write(popup.NameId); + ResourceUtilities.WriteFixedString(writer, popup.Name, 13); + writer.Write(new byte[0x3]); + writer.Write((int)popup.Style); + writer.Write((int)popup.Icon); + ResourceUtilities.WriteFixedString(writer, popup.TitleId, 32); + ResourceUtilities.WriteFixedString(writer, popup.MessageId, 32); + writer.Write((int)popup.MessageParam0); + writer.Write((int)popup.MessageParam1); + writer.Write(popup.MessageParamsUsed); + ResourceUtilities.WriteFixedString(writer, popup.Button1Id, 32); + writer.Write((int)popup.Button1Param); + writer.Write((byte)(popup.Button1ParamUsed ? 1 : 0)); + ResourceUtilities.WriteFixedString(writer, popup.Button2Id, 32); + writer.Write(new byte[0x3]); + writer.Write((int)popup.Button2Param); + writer.Write((byte)(popup.Button2ParamUsed ? 1 : 0)); + writer.Write(new byte[0x7]); + } } -} \ No newline at end of file +} diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index 0718d66..6d50a8b 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -1,82 +1,165 @@ -using static Volatility.Utilities.MatrixUtilities; +using Volatility.Utilities; + +using static Volatility.Utilities.MatrixUtilities; namespace Volatility.Resources; -// The Instance List resource type contains lists of models along with their -// respective locations in the game world. It serves as one of the top-level +// The Instance List resource type contains lists of models along with their +// respective locations in the game world. It serves as one of the top-level // resource types for track unit loading. - +// // Learn More: // https://burnout.wiki/wiki/Instance_List public class InstanceList : Resource { + private const int HeaderSize = 0x10; + private const int SectionAlignment = 0x10; + private const int ImportEntrySize = 0x10; + private const int ResourceIdEntrySize = 0x10; + private const int InstanceBodySize = 0x4C; + public override ResourceType ResourceType => ResourceType.InstanceList; - + [EditorLabel("Number of instances"), EditorCategory("Instance List"), EditorReadOnly, EditorTooltip("The amount of instances that have a model assigned, but NOT the size of the entire instance array.")] public uint NumInstances; [EditorLabel("Instances"), EditorCategory("Instance List"), EditorTooltip("The list of instances in this list.")] public List Instances = []; - //uint ArraySize, VersionNumber; - public InstanceList() : base() { } public InstanceList(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) + { + base.WriteToStream(writer, endianness); + + Arch arch = ResourceArch; + int instanceBlockSize = GetInstanceBlockSize(arch); + uint entryCount = (uint)Instances.Count; + uint numInstances = NumInstances == 0 ? entryCount : Math.Min(NumInstances, entryCount); + + long currentOffset = HeaderSize; + long instanceListOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + checked((int)(entryCount * instanceBlockSize)), + SectionAlignment); + long importBlockOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + checked((int)(entryCount * ImportEntrySize)), + SectionAlignment); + long resourceIdBlockOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + checked((int)(entryCount * ResourceIdEntrySize)), + SectionAlignment); + + writer.Write((int)instanceListOffset); + writer.Write(entryCount); + writer.Write(numInstances); + writer.Write(1u); + + writer.WriteSection(instanceListOffset, Instances, (w, instance) => WriteInstanceBlock(w, instance, arch)); + writer.WriteSection(importBlockOffset, Instances, (w, instance, index) => + WriteImportEntry(w, instance, instanceListOffset + ((long)index * instanceBlockSize))); + writer.WriteSection(resourceIdBlockOffset, Instances, WriteResourceIdEntry); + } + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); - // Absolute pointers (not relative to any specific point in the file) long instanceListPtr = reader.ReadInt32(); - uint entries = reader.ReadUInt32(); NumInstances = reader.ReadUInt32(); - // Version if (reader.ReadUInt32() != 1) { - throw new Exception("Version mismatch!"); + throw new InvalidDataException("Version mismatch!"); } - reader.BaseStream.Seek(instanceListPtr, SeekOrigin.Begin); + Instances.Clear(); - long instanceBlockSize = ResourceArch == Arch.x64 ? 0x60 : 0x50; + long instanceBlockSize = GetInstanceBlockSize(ResourceArch); + long importBlockOffset = instanceListPtr + (instanceBlockSize * entries); + long resourceIdBlockOffset = importBlockOffset + (ImportEntrySize * entries); - for (int i = 0; i < entries; i++) + for (int i = 0; i < entries; i++) { - reader.BaseStream.Seek(instanceListPtr + (instanceBlockSize * i), SeekOrigin.Begin); + long instanceOffset = instanceListPtr + (instanceBlockSize * i); + long resourceIdOffset = resourceIdBlockOffset + (ResourceIdEntrySize * i); - ResourceImport.ReadExternalImport(fileOffset: reader.BaseStream.Position, reader, instanceListPtr + (instanceBlockSize * entries), out ResourceImport model); - short backdropZoneID = reader.ReadInt16(); + reader.ParseSection(instanceOffset, r => ReadInstance(r, ResourceArch, importBlockOffset, resourceIdOffset), out Instance instance); + Instances.Add(instance); + } + } - //ushort _padding1 = reader.ReadUInt16(); - //uint _padding2 = reader.ReadUInt32(); + private static int GetInstanceBlockSize(Arch arch) + { + return arch == Arch.x64 ? 0x60 : 0x50; + } - reader.BaseStream.Seek(0x6, SeekOrigin.Current); + private static Instance ReadInstance( + ResourceBinaryReader reader, + Arch arch, + long importBlockOffset, + long resourceIdOffset) + { + long blockStart = reader.BaseStream.Position; - float maxVisibleDistanceSquared = reader.ReadSingle(); + ResourceImport.ReadExternalImport(blockStart, reader, importBlockOffset, out ResourceImport modelReference); - Transform transform = Matrix44AffineToTransform(ReadMatrix44Affine(reader)); + short backdropZoneId = reader.ReadInt16(); + reader.BaseStream.Seek(0x6, SeekOrigin.Current); + float maxVisibleDistanceSquared = reader.ReadSingle(); + Transform transform = Matrix44AffineToTransform(ReadMatrix44Affine(reader)); - reader.BaseStream.Seek(instanceListPtr + instanceBlockSize * entries + 0x10 * i, SeekOrigin.Begin); + ResourceImport resourceId = default; + reader.ParseSection(resourceIdOffset, ReadResourceId, out resourceId); - Instances.Add(new Instance - { - ModelReference = model, - BackdropZoneID = backdropZoneID, - // Padding1 = _padding1, Padding2 = _padding2, - MaxVisibleDistanceSquared = maxVisibleDistanceSquared, - Transform = transform, - ResourceId = new ResourceImport - { - ReferenceID = reader.ReadUInt32(), - ExternalImport = false - }, - }); - } + return new Instance + { + ModelReference = modelReference, + BackdropZoneID = backdropZoneId, + MaxVisibleDistanceSquared = maxVisibleDistanceSquared, + Transform = transform, + ResourceId = resourceId, + }; + } + + private static ResourceImport ReadResourceId(ResourceBinaryReader reader) + { + ResourceImport resourceId = new() + { + ReferenceID = reader.ReadUInt32(), + ExternalImport = false + }; + + reader.BaseStream.Seek(0xC, SeekOrigin.Current); + return resourceId; + } + + private static void WriteInstanceBlock(ResourceBinaryWriter writer, Instance instance, Arch arch) + { + writer.Write(instance.BackdropZoneID); + writer.Write(new byte[0x6]); + writer.Write(instance.MaxVisibleDistanceSquared); + WriteMatrix44Affine(writer, TransformToMatrix44Affine(instance.Transform)); + writer.Write(new byte[GetInstanceBlockSize(arch) - InstanceBodySize]); + } + + private static void WriteImportEntry(ResourceBinaryWriter writer, Instance instance, long fileOffset) + { + writer.Write(ResourceUtilities.ResolveResourceID(instance.ModelReference)); + writer.Write((uint)fileOffset); + writer.Write(0u); + } + + private static void WriteResourceIdEntry(ResourceBinaryWriter writer, Instance instance, int index) + { + _ = index; + writer.Write((uint)ResourceUtilities.ResolveResourceID(instance.ResourceId)); + writer.Write(new byte[0xC]); } } @@ -90,12 +173,10 @@ public struct Instance [EditorLabel("Transform"), EditorCategory("InstanceList/Instances"), EditorTooltip("If this is a backdrop, the PVS Zone ID that this backdrop represents.")] public short BackdropZoneID; - - // public ushort Padding1; public uint Padding2; [EditorLabel("Max Visible Distance Squared"), EditorCategory("InstanceList/Instances"), EditorTooltip("The maximum distance that this instance can be seen (in meters), squared.")] - public float MaxVisibleDistanceSquared; // Unused? + public float MaxVisibleDistanceSquared; [EditorHidden] public ResourceImport ModelReference; -} \ No newline at end of file +} diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index ab2af14..aa75b5f 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/Resources/Model/Model.cs @@ -1,14 +1,22 @@ -namespace Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Resources; // The Model resource type links top-level resources (like InstanceList) // to Renderable resources that contain the 3D geometry, while including // Level of Detail (LOD) information. - +// // Learn More: // https://burnout.wiki/wiki/Model public class Model : Resource { + private const int HeaderSize = 0x14; + private const int RenderableOffsetSize = sizeof(uint); + private const int StateSize = sizeof(byte); + private const int LodDistanceSize = sizeof(float); + private const int ImportEntrySize = 0x10; + [EditorCategory("Model Container"), EditorLabel("Flags")] public byte Flags; @@ -22,73 +30,64 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); - int models = ModelDatas.Count; - - uint renderablesPtr = 0x14; // Writing length of header - uint statesPtr = (uint)(renderablesPtr + (0x4 * models)); - uint lodDistancesPtr = (uint)(statesPtr + (0x1 * models)); - - writer.Write(renderablesPtr); - writer.Write(statesPtr); - writer.Write(lodDistancesPtr); - writer.Write(-1); // Game explorer index, leaving our mark for now - writer.Write((byte)ModelDatas.Count); // Dangerous. We need to limit number of models - writer.Write(Flags); - writer.Write((byte)ModelDatas.Count); // Number of states. Same as number of renderables - writer.BaseStream.WriteByte(0x02); - - writer.BaseStream.Seek(renderablesPtr, SeekOrigin.Begin); - - // Renderable Ptrs - for (int i = 0; i < models; i++) + int modelCount = ModelDatas.Count; + if (modelCount > byte.MaxValue) { - writer.Write((uint)(i * 0x4)); + throw new InvalidDataException("Model resources cannot store more than 255 renderables."); } - // States (Writing as uint?? A single renderable has a 0x4-long state apparently.) - for (int i = 0; i < models; i++) - { - writer.Write((uint)ModelDatas[i].State); - } - - // LOD Distances - for (int i = 0; i < models; i++) - { - writer.Write(ModelDatas[i].LODDistance); - } + long currentOffset = HeaderSize; + long renderablesOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + modelCount * RenderableOffsetSize, + 1); + long statesOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + modelCount * StateSize, + 1); + long lodDistancesOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + modelCount * LodDistanceSize, + 1); + long importsOffset = ResourceUtilities.GetSectionOffset( + ref currentOffset, + modelCount * ImportEntrySize, + 1); + + writer.Write((uint)renderablesOffset); + writer.Write((uint)statesOffset); + writer.Write((uint)lodDistancesOffset); + writer.Write(-1); + writer.Write((byte)modelCount); + writer.Write(Flags); + writer.Write((byte)modelCount); + writer.Write((byte)0x02); - // Resource ID References - for (int i = 0; i < models; i++) - { - writer.Write(ModelDatas[i].ResourceReference.ReferenceID); - writer.Write(renderablesPtr + (i * 0x4)); - writer.Write((uint)0x0); // Unknown. Always 0 in BPR, not always 0 on X360 - } + writer.WriteSection(renderablesOffset, ModelDatas, static (w, _, index) => w.Write((uint)(index * ImportEntrySize))); + writer.WriteSection(statesOffset, ModelDatas, (w, modelData) => w.Write((byte)modelData.State)); + writer.WriteSection(lodDistancesOffset, ModelDatas, (w, modelData) => w.Write(modelData.LODDistance)); + writer.WriteSection(importsOffset, ModelDatas, WriteImportEntry); } + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); - // Get the version check out of the way before we begin. reader.BaseStream.Seek(0x13, SeekOrigin.Begin); if (reader.ReadByte() != 0x2) { - throw new Exception("Version mismatch!"); + throw new InvalidDataException("Version mismatch!"); } reader.BaseStream.Seek(0x0, SeekOrigin.Begin); - // Absolute pointers (not relative to any specific point in the file) uint renderablesPtr = reader.ReadUInt32(); uint renderableStatesPtr = reader.ReadUInt32(); uint lodDistancesPtr = reader.ReadUInt32(); - // Null for imported resources. - // TODO: Reconstruct game explorer or get from ResourceDB - int gameExplorerIndex = reader.ReadInt32(); + _ = reader.ReadInt32(); byte numRenderables = reader.ReadByte(); - if (numRenderables == 0) { Console.WriteLine("WARNING: Found no renderables in this model!"); @@ -96,45 +95,63 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann Flags = reader.ReadByte(); - long maxLength = new[] - { - lodDistancesPtr + numRenderables * sizeof(uint), - renderablesPtr + numRenderables * sizeof(uint), - renderableStatesPtr + numRenderables - }.Max(); + long importsOffset = Math.Max( + lodDistancesPtr + (numRenderables * LodDistanceSize), + Math.Max( + renderablesPtr + (numRenderables * RenderableOffsetSize), + renderableStatesPtr + (numRenderables * StateSize))); - // This currently does a lot of seeking. - // It may improve performance if we separate this. + ModelDatas.Clear(); for (int i = 0; i < numRenderables; i++) { - ModelData modelData = new ModelData(); - - reader.BaseStream.Seek(renderablesPtr + (i * 0x4), SeekOrigin.Begin); + ModelDatas.Add(ReadModelData( + reader, + i, + numRenderables, + renderablesPtr, + renderableStatesPtr, + lodDistancesPtr, + importsOffset)); + } + } - uint idRelativePtr = reader.ReadUInt32(); + public Model() : base() { } - reader.BaseStream.Seek(renderableStatesPtr + i, SeekOrigin.Begin); - modelData.State = (State)reader.ReadByte(); + public Model(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - reader.BaseStream.Seek(lodDistancesPtr + (i * 0x4), SeekOrigin.Begin); - modelData.LODDistance = reader.ReadSingle(); + private static ModelData ReadModelData( + ResourceBinaryReader reader, + int index, + byte numRenderables, + uint renderablesPtr, + uint renderableStatesPtr, + uint lodDistancesPtr, + long importsOffset) + { + ModelData modelData = new(); - reader.BaseStream.Seek( - idRelativePtr + - renderablesPtr + - (numRenderables * (0x4 + 0x1 + 0x4)) + - (reader.Endianness == Endian.BE ? 0x4 : 0x0), SeekOrigin.Begin - ); + reader.ParseSection(renderablesPtr + (index * RenderableOffsetSize), r => r.ReadUInt32(), out uint importRelativeOffset); + reader.ParseSection(renderableStatesPtr + index, r => (State)r.ReadByte(), out modelData.State); + reader.ParseSection(lodDistancesPtr + (index * LodDistanceSize), r => r.ReadSingle(), out modelData.LODDistance); - ResourceImport.ReadExternalImport(i, reader, maxLength, out modelData.ResourceReference); + reader.BaseStream.Seek( + importRelativeOffset + + renderablesPtr + + (numRenderables * (RenderableOffsetSize + StateSize + LodDistanceSize)) + + (reader.Endianness == Endian.BE ? 0x4 : 0x0), + SeekOrigin.Begin); - ModelDatas.Add(modelData); - } + ResourceImport.ReadExternalImport(index, reader, importsOffset, out modelData.ResourceReference); + return modelData; } - public Model() : base() { } - - public Model(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + private static void WriteImportEntry(ResourceBinaryWriter writer, ModelData modelData, int index) + { + _ = index; + writer.Write(ResourceUtilities.ResolveResourceID(modelData.ResourceReference)); + writer.Write(0u); + writer.Write(0u); + } public struct ModelData { diff --git a/Volatility/Resources/SnapshotData/SnapshotData.cs b/Volatility/Resources/SnapshotData/SnapshotData.cs index 5880532..efcd0b9 100644 --- a/Volatility/Resources/SnapshotData/SnapshotData.cs +++ b/Volatility/Resources/SnapshotData/SnapshotData.cs @@ -1,9 +1,11 @@ -using System.Numerics; - namespace Volatility.Resources; public class SnapshotData : BinaryResource { + private const int SnapshotHeaderSize = 0x10; + private const int SnapshotChannelSize = 0xC; + private const int SnapshotStatusSize = 0x8; + public override ResourceType ResourceType => ResourceType.SnapshotData; public override Platform ResourcePlatform => Platform.Agnostic; @@ -12,74 +14,113 @@ public class SnapshotData : BinaryResource public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { - DataSize = (uint)(0x10 + (Channels.Count * 0xC) + (SnapshotStatuses.Count * 0x8)); + int snapshotCount = GetSnapshotCountForWrite(); - base.WriteToStream(writer, endianness); + DataSize = (uint)( + SnapshotHeaderSize + + (Channels.Count * SnapshotChannelSize) + + (SnapshotStatuses.Count * SnapshotStatusSize)); - writer.Write(SnapshotStatuses.Count); - writer.Write(Channels.Count); + base.WriteToStream(writer, endianness); - foreach (SnapshotChannelData channelData in Channels) - { - writer.Write(channelData.Flags); - writer.Write(channelData.ChannelID); - writer.Write(0xFFFFFFFF); - } + long channelsOffset = writer.BaseStream.Position + SnapshotHeaderSize; + long statusesOffset = channelsOffset + (Channels.Count * SnapshotChannelSize); - foreach (SnapshotStatusData statusData in SnapshotStatuses) - { - writer.Write(statusData.Flags); - writer.Write(statusData.TimeRemaining); - } + writer.Write(snapshotCount); + writer.Write(Channels.Count); + writer.Write(0UL); + writer.WriteSection(channelsOffset, Channels, SnapshotChannelData.Write); + writer.WriteSection(statusesOffset, SnapshotStatuses, SnapshotStatusData.Write); } + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); - int numSnapshots = reader.ReadInt32(); // Snapshot data - int numChannels = reader.ReadInt32(); // Channel data - + int snapshotCount = reader.ReadInt32(); + int channelCount = reader.ReadInt32(); _ = reader.ReadUInt64(); - // Channel data - for (int i = 0; i < numSnapshots; i++) + long channelsOffset = reader.BaseStream.Position; + long statusesOffset = channelsOffset + (channelCount * SnapshotChannelSize); + + Channels = reader.ParseSection(channelsOffset, channelCount, SnapshotChannelData.Read); + SnapshotStatuses = reader.ParseSection(statusesOffset, snapshotCount * channelCount, SnapshotStatusData.Read); + } + + public SnapshotData() : base() { } + + public SnapshotData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + + private int GetSnapshotCountForWrite() + { + if (Channels.Count == 0) { - Channels.Add(new SnapshotChannelData() - { - Flags = reader.ReadUInt32(), - ChannelID = reader.ReadUInt32() - }); - uint invalid = reader.ReadUInt32(); - if (invalid != 0xFFFFFFFF) + if (SnapshotStatuses.Count != 0) { - Console.Error.WriteLine($"Expected 0xFFFFFFFF at {reader.BaseStream.Position}, got {invalid}!"); + throw new InvalidDataException("Snapshot statuses cannot be written without at least one channel."); } + + return 0; } - // Snapshot data - for (int i = 0; i < (numSnapshots * numChannels); i++) + if (SnapshotStatuses.Count % Channels.Count != 0) { - SnapshotStatuses.Add(new SnapshotStatusData() - { - Flags = reader.ReadUInt32(), - TimeRemaining = reader.ReadSingle() - }); + throw new InvalidDataException( + $"Snapshot status count ({SnapshotStatuses.Count}) must be divisible by channel count ({Channels.Count})."); } - } - public SnapshotData() : base() { } - - public SnapshotData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + return SnapshotStatuses.Count / Channels.Count; + } } public struct SnapshotChannelData { public uint Flags; public uint ChannelID; -} + + public static SnapshotChannelData Read(ResourceBinaryReader reader) + { + SnapshotChannelData channelData = new() + { + Flags = reader.ReadUInt32(), + ChannelID = reader.ReadUInt32() + }; + + uint terminator = reader.ReadUInt32(); + if (terminator != 0xFFFFFFFF) + { + Console.Error.WriteLine($"Expected 0xFFFFFFFF at {reader.BaseStream.Position}, got {terminator}!"); + } + + return channelData; + } + + public static void Write(ResourceBinaryWriter writer, SnapshotChannelData channelData) + { + writer.Write(channelData.Flags); + writer.Write(channelData.ChannelID); + writer.Write(0xFFFFFFFFu); + } +} public struct SnapshotStatusData { public float TimeRemaining; public uint Flags; -} \ No newline at end of file + + public static SnapshotStatusData Read(ResourceBinaryReader reader) + { + return new SnapshotStatusData + { + Flags = reader.ReadUInt32(), + TimeRemaining = reader.ReadSingle() + }; + } + + public static void Write(ResourceBinaryWriter writer, SnapshotStatusData statusData) + { + writer.Write(statusData.Flags); + writer.Write(statusData.TimeRemaining); + } +} diff --git a/Volatility/Resources/Splicer/Splicer.cs b/Volatility/Resources/Splicer/Splicer.cs index e452605..3d92914 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/Volatility/Resources/Splicer/Splicer.cs @@ -7,12 +7,17 @@ namespace Volatility.Resources; // The Splicer resource type contains multiple sound assets and presets for // how those sounds are played. They are typically triggered by in-game actions. // Splicers begin with a Binary File resource. - +// // Learn More: // https://burnout.wiki/wiki/Splicer public class Splicer : BinaryResource { + private const int Version = 1; + private const int HeaderSize = 0xC; + private const int SpliceHeaderSize = 0x18; + private const int SampleRefSize = 0x2C; + public override ResourceType ResourceType => ResourceType.Splicer; public List Splices = []; @@ -25,112 +30,51 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann { base.ParseFromStream(reader, endianness); - int version = reader.ReadInt32(); - if (version != 1) + int version = reader.ReadInt32(); + if (version != Version) { - throw new InvalidDataException("Version mismatch! Version should be 1."); + throw new InvalidDataException($"Version mismatch! Version should be {Version}."); } - + int sizedata = reader.ReadInt32(); - int numSplices = reader.ReadInt32(); if (numSplices <= 0) { throw new InvalidDataException("No splices in Splicer file!"); } - List spliceRefCounts = new(numSplices); - - // Read Splice Data - for (int i = 0; i < numSplices; i++) - { - uint nameHash = reader.ReadUInt32(); - ushort spliceIdx = reader.ReadUInt16(); - sbyte eType = reader.ReadSByte(); - byte numRefs = reader.ReadByte(); - float vol = reader.ReadSingle(); - float rpitch = reader.ReadSingle(); - float rvol = reader.ReadSingle(); - _ = reader.ReadInt32(); // pSampleRefList (null) - - spliceRefCounts.Add(numRefs); - Splices.Add(new SpliceData - { - NameHash = nameHash, - SpliceIndex = spliceIdx, - ESpliceType = eType, - Volume = vol, - RND_Pitch = rpitch, - RND_Vol = rvol, - SampleRefs = new List(numRefs) - }); - } - - long sampleRefsPtrOffset = reader.BaseStream.Position - DataOffset; - - reader.BaseStream.Seek(sizedata + 0xC + DataOffset, SeekOrigin.Begin); - - int numSamples = reader.ReadInt32(); + long spliceHeadersOffset = reader.BaseStream.Position; + List spliceHeaders = reader.ParseSection(spliceHeadersOffset, numSplices, SpliceHeader.Read); - List samplePtrs = new(numSamples); - for (int i = 0; i < numSamples; i++) + Splices = new List(numSplices); + foreach (SpliceHeader header in spliceHeaders) { - samplePtrs.Add(reader.ReadInt32()); + Splices.Add(header.ToSpliceData()); } - long samplePtrOffset = reader.BaseStream.Position - DataOffset; - - for (int i = 0; i < numSamples; i++) - { - reader.BaseStream.Seek(samplePtrOffset + DataOffset + samplePtrs[i], SeekOrigin.Begin); - - int length = (int)((i == (numSamples - 1) ? reader.BaseStream.Length : samplePtrs[i + 1]) - samplePtrs[i]); - - byte[]? data = reader.ReadBytes(length); + long sampleRefsOffset = spliceHeadersOffset + (numSplices * SpliceHeaderSize); + long sampleTableOffset = DataOffset + HeaderSize + sizedata; - _samples.Add - ( - new SpliceSample - { - SampleID = SnrID.HashFromBytes(data), - Data = data, - } - ); + reader.BaseStream.Seek(sampleTableOffset, SeekOrigin.Begin); - Console.WriteLine($"Adding sample {i} as {_samples[i].SampleID}"); - } + int numSamples = reader.ReadInt32(); + long samplePointersOffset = reader.BaseStream.Position; + List samplePointers = reader.ParseSection(samplePointersOffset, numSamples, r => r.ReadInt32()); + long sampleDataOffset = samplePointersOffset + (numSamples * sizeof(int)); - reader.BaseStream.Seek(sampleRefsPtrOffset + DataOffset, SeekOrigin.Begin); + _samples = ReadSamples(reader, samplePointers, sampleDataOffset); - // Read SampleRefs + long currentSampleRefOffset = sampleRefsOffset; for (int i = 0; i < Splices.Count; i++) { - int count = spliceRefCounts[i]; - List list = Splices[i].SampleRefs; - - for (int j = 0; j < count; j++) - { - ushort sampleIdx = reader.ReadUInt16(); - SpliceSampleRef sr = new() - { - Sample = _samples[sampleIdx].SampleID, - ESpliceType = reader.ReadSByte(), - Padding = reader.ReadByte(), - Volume = reader.ReadSingle(), - Pitch = reader.ReadSingle(), - Offset = reader.ReadSingle(), - Az = reader.ReadSingle(), - Duration = reader.ReadSingle(), - FadeIn = reader.ReadSingle(), - FadeOut = reader.ReadSingle(), - RND_Vol = reader.ReadSingle(), - RND_Pitch = reader.ReadSingle(), - Priority = reader.ReadByte(), - ERollOffType = reader.ReadByte(), - Padding2 = reader.ReadUInt16() - }; - list.Add(sr); - } + int count = spliceHeaders[i].SampleRefCount; + List sampleRefs = reader.ParseSection( + currentSampleRefOffset, + count, + r => ReadSampleRef(r, _samples)); + + Splices[i].SampleRefs.AddRange(sampleRefs); + currentSampleRefOffset += count * SampleRefSize; } } @@ -138,85 +82,63 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { LoadDependentSamples(); - base.WriteToStream(writer, endianness); + int totalRefs = Splices.Sum(s => s.SampleRefs.Count); + int sizeOfSplices = Splices.Count * SpliceHeaderSize; + int sizeOfSampleRefs = totalRefs * SampleRefSize; - writer.BaseStream.Position = DataOffset; + DataSize = (uint)(HeaderSize + sizeOfSplices + sizeOfSampleRefs + sizeof(int)); - writer.Write(1); // version + base.WriteToStream(writer, endianness); + writer.BaseStream.Position = DataOffset; - int totalRefs = Splices.Sum(s => s.SampleRefs.Count); - int sizeOfSplices = Splices.Count * 0x18; // Size of Splice_Data - int sizeOfSampleRefs = totalRefs * 0x2C; // Size of Splice_SampleRef int sizedata = sizeOfSplices + sizeOfSampleRefs; + long spliceHeadersOffset = writer.BaseStream.Position + HeaderSize; + long sampleRefsOffset = spliceHeadersOffset + sizeOfSplices; + long sampleTableOffset = DataOffset + HeaderSize + sizedata; - writer.Write(sizedata); // sizedata/pSampleRefTOC + writer.Write(Version); + writer.Write(sizedata); + writer.Write(Splices.Count); - writer.Write(Splices.Count); // NumSplices - - foreach (SpliceData splice in Splices) - { - writer.Write(splice.NameHash); - writer.Write(splice.SpliceIndex); - writer.Write(splice.ESpliceType); - writer.Write((byte)splice.SampleRefs.Count); - writer.Write(splice.Volume); - writer.Write(splice.RND_Pitch); - writer.Write(splice.RND_Vol); - - writer.Write(0); // pSampleRefList placeholder - } + writer.WriteSection(spliceHeadersOffset, Splices, WriteSpliceHeader); + writer.BaseStream.Position = sampleRefsOffset; foreach (SpliceData splice in Splices) { - foreach (SpliceSampleRef sr in splice.SampleRefs) + foreach (SpliceSampleRef sampleRef in splice.SampleRefs) { - int sampleIdx = _samples.FindIndex(x => x.SampleID == sr.Sample); - writer.Write((ushort)sampleIdx); - writer.Write(sr.ESpliceType); - writer.Write(sr.Padding); - writer.Write(sr.Volume); - writer.Write(sr.Pitch); - writer.Write(sr.Offset); - writer.Write(sr.Az); - writer.Write(sr.Duration); - writer.Write(sr.FadeIn); - writer.Write(sr.FadeOut); - writer.Write(sr.RND_Vol); - writer.Write(sr.RND_Pitch); - writer.Write(sr.Priority); - writer.Write(sr.ERollOffType); - writer.Write(sr.Padding2); + WriteSampleRef(writer, sampleRef, ResolveSampleIndex(sampleRef.Sample)); } } - writer.BaseStream.Position = DataOffset + 0xC + sizedata; // Header + sizedata - int numSamples = _samples.Count; - - writer.Write(numSamples); + writer.BaseStream.Position = sampleTableOffset; + writer.Write(_samples.Count); - // Reserve space for offsets - long offsetsStart = writer.BaseStream.Position; - for (int i = 0; i < numSamples; i++) writer.Write(0); + long samplePointersStart = writer.BaseStream.Position; + for (int i = 0; i < _samples.Count; i++) + { + writer.Write(0); + } - int running = 0; - for (int i = 0; i < numSamples; i++) + int runningOffset = 0; + for (int i = 0; i < _samples.Count; i++) { byte[] data = _samples[i].Data; writer.Write(data); - // backfill this sample's offset - long save = writer.BaseStream.Position; - writer.Seek((int)(offsetsStart + i * 4), SeekOrigin.Begin); - writer.Write(running); - writer.Seek((int)save, SeekOrigin.Begin); - running += data.Length; + + long savePosition = writer.BaseStream.Position; + writer.BaseStream.Position = samplePointersStart + (i * sizeof(int)); + writer.Write(runningOffset); + writer.BaseStream.Position = savePosition; + + runningOffset += data.Length; } - // Update DataSize DataSize = (uint)(writer.BaseStream.Length - DataOffset); - long pos = writer.BaseStream.Position; - writer.BaseStream.Seek(0, SeekOrigin.Begin); + long endPosition = writer.BaseStream.Position; + writer.BaseStream.Position = 0; writer.Write(DataSize); - writer.BaseStream.Seek(pos, SeekOrigin.Begin); + writer.BaseStream.Position = endPosition; } public void LoadDependentSamples(bool recurse = false) @@ -225,10 +147,10 @@ public void LoadDependentSamples(bool recurse = false) .SelectMany(s => s.SampleRefs.Select(sr => sr.Sample)) .Distinct() .ToList(); - + string dir = Path.Combine ( - GetEnvironmentDirectory(EnvironmentDirectory.Splicer), + GetEnvironmentDirectory(EnvironmentDirectory.Splicer), "Samples" ); @@ -240,12 +162,16 @@ public void LoadDependentSamples(bool recurse = false) byte[] data = File.ReadAllBytes(f); SnrID id = SnrID.HashFromBytes(data); if (!map.ContainsKey(id) && needed.Contains(id)) + { map[id] = data; + } } foreach (SnrID id in needed.Where(id => !map.ContainsKey(id))) + { throw new FileNotFoundException($"Missing sample for {id}"); - + } + _samples = needed.Select(id => new SpliceSample { SampleID = id, Data = map[id] }).ToList(); } @@ -257,7 +183,138 @@ public List GetLoadedSamples() public Splicer() : base() { } public Splicer(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } - + + private static List ReadSamples( + ResourceBinaryReader reader, + List samplePointers, + long sampleDataOffset) + { + List samples = new(samplePointers.Count); + + for (int i = 0; i < samplePointers.Count; i++) + { + long start = sampleDataOffset + samplePointers[i]; + long end = i == samplePointers.Count - 1 + ? reader.BaseStream.Length + : sampleDataOffset + samplePointers[i + 1]; + + reader.BaseStream.Seek(start, SeekOrigin.Begin); + byte[] data = reader.ReadBytes((int)(end - start)); + + samples.Add(new SpliceSample + { + SampleID = SnrID.HashFromBytes(data), + Data = data, + }); + } + + return samples; + } + + private static SpliceSampleRef ReadSampleRef(ResourceBinaryReader reader, List samples) + { + ushort sampleIndex = reader.ReadUInt16(); + return new SpliceSampleRef + { + Sample = samples[sampleIndex].SampleID, + ESpliceType = reader.ReadSByte(), + Padding = reader.ReadByte(), + Volume = reader.ReadSingle(), + Pitch = reader.ReadSingle(), + Offset = reader.ReadSingle(), + Az = reader.ReadSingle(), + Duration = reader.ReadSingle(), + FadeIn = reader.ReadSingle(), + FadeOut = reader.ReadSingle(), + RND_Vol = reader.ReadSingle(), + RND_Pitch = reader.ReadSingle(), + Priority = reader.ReadByte(), + ERollOffType = reader.ReadByte(), + Padding2 = reader.ReadUInt16() + }; + } + + private static void WriteSpliceHeader(ResourceBinaryWriter writer, SpliceData splice) + { + writer.Write(splice.NameHash); + writer.Write(splice.SpliceIndex); + writer.Write(splice.ESpliceType); + writer.Write((byte)splice.SampleRefs.Count); + writer.Write(splice.Volume); + writer.Write(splice.RND_Pitch); + writer.Write(splice.RND_Vol); + writer.Write(0); + } + + private static void WriteSampleRef(ResourceBinaryWriter writer, SpliceSampleRef sampleRef, int sampleIndex) + { + writer.Write((ushort)sampleIndex); + writer.Write(sampleRef.ESpliceType); + writer.Write(sampleRef.Padding); + writer.Write(sampleRef.Volume); + writer.Write(sampleRef.Pitch); + writer.Write(sampleRef.Offset); + writer.Write(sampleRef.Az); + writer.Write(sampleRef.Duration); + writer.Write(sampleRef.FadeIn); + writer.Write(sampleRef.FadeOut); + writer.Write(sampleRef.RND_Vol); + writer.Write(sampleRef.RND_Pitch); + writer.Write(sampleRef.Priority); + writer.Write(sampleRef.ERollOffType); + writer.Write(sampleRef.Padding2); + } + + private int ResolveSampleIndex(SnrID sampleId) + { + int sampleIndex = _samples.FindIndex(x => x.SampleID == sampleId); + if (sampleIndex < 0) + { + throw new InvalidDataException($"Unable to resolve sample {sampleId} in splicer export."); + } + + return sampleIndex; + } + + private readonly record struct SpliceHeader( + uint NameHash, + ushort SpliceIndex, + sbyte ESpliceType, + byte SampleRefCount, + float Volume, + float RandomPitch, + float RandomVolume) + { + public static SpliceHeader Read(ResourceBinaryReader reader) + { + SpliceHeader header = new( + reader.ReadUInt32(), + reader.ReadUInt16(), + reader.ReadSByte(), + reader.ReadByte(), + reader.ReadSingle(), + reader.ReadSingle(), + reader.ReadSingle()); + + _ = reader.ReadInt32(); + return header; + } + + public SpliceData ToSpliceData() + { + return new SpliceData + { + NameHash = NameHash, + SpliceIndex = SpliceIndex, + ESpliceType = ESpliceType, + Volume = Volume, + RND_Pitch = RandomPitch, + RND_Vol = RandomVolume, + SampleRefs = new List(SampleRefCount) + }; + } + } + [StructLayout(LayoutKind.Sequential, Pack = 1)] public class SpliceData { @@ -295,4 +352,4 @@ public struct SpliceSample public SnrID SampleID; public byte[] Data; } -} \ No newline at end of file +} diff --git a/Volatility/Utilities/MatrixUtilities.cs b/Volatility/Utilities/MatrixUtilities.cs index a69907e..76b543b 100644 --- a/Volatility/Utilities/MatrixUtilities.cs +++ b/Volatility/Utilities/MatrixUtilities.cs @@ -36,6 +36,33 @@ public static Transform Matrix44AffineToTransform(Matrix44Affine matrix) return transform; } + public static Matrix44Affine TransformToMatrix44Affine(Transform transform) + { + Quaternion rotation = Quaternion.Normalize( + transform.Rotation == default ? Quaternion.Identity : transform.Rotation); + + Matrix44Affine matrix = Matrix4x4.CreateFromQuaternion(rotation); + + matrix.M11 *= transform.Scale.X; + matrix.M12 *= transform.Scale.X; + matrix.M13 *= transform.Scale.X; + + matrix.M21 *= transform.Scale.Y; + matrix.M22 *= transform.Scale.Y; + matrix.M23 *= transform.Scale.Y; + + matrix.M31 *= transform.Scale.Z; + matrix.M32 *= transform.Scale.Z; + matrix.M33 *= transform.Scale.Z; + + matrix.M41 = transform.Location.X; + matrix.M42 = transform.Location.Y; + matrix.M43 = transform.Location.Z; + matrix.M44 = 1.0f; + + return matrix; + } + public static Matrix44 ReadMatrix44(BinaryReader reader) { float m11 = reader.ReadSingle(); diff --git a/Volatility/Utilities/ResourceBinaryReader.cs b/Volatility/Utilities/ResourceBinaryReader.cs index 10257a1..c663ce0 100644 --- a/Volatility/Utilities/ResourceBinaryReader.cs +++ b/Volatility/Utilities/ResourceBinaryReader.cs @@ -76,6 +76,13 @@ public void ParseSection(ulong offset, int count, Func ParseSection(ulong offset, int count, Func parser) + { + List destination = new(Math.Max(count, 0)); + ParseSection(offset, count, parser, destination); + return destination; + } + public void ParseSection(long offset, int count, Func parser, List destination) { if (count <= 0 || offset == 0) @@ -94,6 +101,13 @@ public void ParseSection(long offset, int count, Func ParseSection(long offset, int count, Func parser) + { + List destination = new(Math.Max(count, 0)); + ParseSection(offset, count, parser, destination); + return destination; + } + public void ParseSection(ulong offset, Func parser, out T destination) { if (offset == 0) @@ -157,4 +171,4 @@ public uint ReadArchDependUInt(Arch arch) return value; } -} \ No newline at end of file +} diff --git a/Volatility/Utilities/ResourceBinaryWriter.cs b/Volatility/Utilities/ResourceBinaryWriter.cs index 3e6a368..e75263c 100644 --- a/Volatility/Utilities/ResourceBinaryWriter.cs +++ b/Volatility/Utilities/ResourceBinaryWriter.cs @@ -80,6 +80,20 @@ public void WriteSection(long offset, List data, Action(long offset, T[] data, Action writeItem) + { + if (offset == 0 || data.Length == 0) + { + return; + } + + BaseStream.Position = offset; + for (int i = 0; i < data.Length; i++) + { + writeItem(this, data[i]); + } + } + public void WriteSection(long offset, List data, Action writeItem) { if (offset == 0 || data.Count == 0) @@ -94,6 +108,20 @@ public void WriteSection(long offset, List data, Action(long offset, T[] data, Action writeItem) + { + if (offset == 0 || data.Length == 0) + { + return; + } + + BaseStream.Position = offset; + for (int i = 0; i < data.Length; i++) + { + writeItem(this, data[i], i); + } + } + public void WriteSection(ulong offset, T data, Action writeItem) { if (offset == 0) @@ -130,6 +158,20 @@ public void WriteSection(ulong offset, List data, Action(ulong offset, T[] data, Action writeItem) + { + if (offset == 0 || data.Length == 0) + { + return; + } + + BaseStream.Position = (long)offset; + for (int i = 0; i < data.Length; i++) + { + writeItem(this, data[i]); + } + } + public void WriteSection(ulong offset, List data, Action writeItem) { if (offset == 0 || data.Count == 0) @@ -144,6 +186,20 @@ public void WriteSection(ulong offset, List data, Action(ulong offset, T[] data, Action writeItem) + { + if (offset == 0 || data.Length == 0) + { + return; + } + + BaseStream.Position = (long)offset; + for (int i = 0; i < data.Length; i++) + { + writeItem(this, data[i], i); + } + } + public void WriteFixedBytes(byte[]? data, int count) { byte[] output = new byte[count]; diff --git a/Volatility/Utilities/ResourceUtilities.cs b/Volatility/Utilities/ResourceUtilities.cs index 3c9e624..845a59d 100644 --- a/Volatility/Utilities/ResourceUtilities.cs +++ b/Volatility/Utilities/ResourceUtilities.cs @@ -1,4 +1,8 @@ -namespace Volatility.Utilities; +using System.Text; + +using Volatility.Resources; + +namespace Volatility.Utilities; public class ResourceUtilities { @@ -30,4 +34,50 @@ public static ulong GetSectionOffset(ref long currentOffset, int count, int elem currentOffset += (long)count * elementSize; return offset; } + + public static long GetSectionOffset(ref long currentOffset, int length, int sectionAlignment) + { + if (length <= 0) + { + return 0; + } + + currentOffset = AlignOffset(currentOffset, sectionAlignment); + long offset = currentOffset; + currentOffset += length; + return offset; + } + + public static string ReadFixedString(BinaryReader reader, int length) + { + byte[] bytes = reader.ReadBytes(length); + int nullTerminator = Array.IndexOf(bytes, (byte)0); + int outputLength = nullTerminator >= 0 ? nullTerminator : bytes.Length; + return Encoding.ASCII.GetString(bytes, 0, outputLength); + } + + public static void WriteFixedString(BinaryWriter writer, string? value, int length) + { + byte[] bytes = Encoding.ASCII.GetBytes(value ?? string.Empty); + if (bytes.Length >= length) + { + writer.Write(bytes, 0, length); + return; + } + + writer.Write(bytes); + writer.Write(new byte[length - bytes.Length]); + } + + public static ResourceID ResolveResourceID(ResourceImport resourceImport) + { + if (resourceImport.ReferenceID != ResourceID.Default) + { + return resourceImport.ReferenceID; + } + + return string.IsNullOrWhiteSpace(resourceImport.Name) + ? ResourceID.Default + : ResourceID.HashFromString(resourceImport.Name); + } } From 8a0db738c133c3ce971d97657cf2a6f4672d7b3e Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:15:55 +0200 Subject: [PATCH 02/17] Add automated tests --- .gitmodules | 4 + README.md | 4 + Volatility/CLI/Commands/AutotestCommand.cs | 73 +- Volatility/Frontend.cs | 28 +- .../Autotest/GameAutotestOperation.cs | 803 ++++++++++++++++++ .../Resources/SaveResourceOperation.cs | 1 + Volatility/Resources/ResourceFactory.cs | 6 + Volatility/Utilities/ProcessUtilities.cs | 37 + Volatility/Utilities/WorkspaceUtilities.cs | 29 + .../YAML/BitArrayYamlTypeConverter.cs | 49 ++ .../YAML/ResourceYamlDeserializer.cs | 3 +- tools/libbndl-extractor | 1 + 12 files changed, 1032 insertions(+), 6 deletions(-) create mode 100644 .gitmodules create mode 100644 Volatility/Operations/Autotest/GameAutotestOperation.cs create mode 100644 Volatility/Utilities/ProcessUtilities.cs create mode 100644 Volatility/Utilities/WorkspaceUtilities.cs create mode 100644 Volatility/Utilities/YAML/BitArrayYamlTypeConverter.cs create mode 160000 tools/libbndl-extractor diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fbd1193 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "tools/libbndl-extractor"] + path = tools/libbndl-extractor + url = ../volatility-libbndl-extractor + branch = main diff --git a/README.md b/README.md index 8d76c07..0181347 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,12 @@ Download and extract the latest version of the application from the [Releases pa ### Developers Ensure you have the necessary prerequisites to develop .NET 9.0 applications on your machine. +After cloning, initialize the extractor submodule with `git submodule update --init --recursive`. + Compiling the application is as simple as opening the project within your IDE of choice (Such as Rider or Visual Studio 2022), or by running `dotnet build`. +The autotest workflow uses the `tools/libbndl-extractor` submodule to unpack bundle files without committing its generated CMake state and fetched dependencies into the main repo. + ## Commands NOTE: This may not be entirely comprehensive. Run "help" for a full list of commands within the application. diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/Volatility/CLI/Commands/AutotestCommand.cs index 8182809..ff25356 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -1,5 +1,6 @@ using System.Reflection; +using Volatility.Operations.Autotest; using Volatility.Resources; using static Volatility.Utilities.TypeUtilities; @@ -11,14 +12,41 @@ internal class AutotestCommand : ICommand { public static string CommandToken => "autotest"; public static string CommandDescription => "Runs automatic tests to ensure the application is working." + - " When provided a path & format, will import, export, then reimport specified file to ensure IO parity."; - public static string CommandParameters => "[--format=] [--path=]"; + " When provided a path & format, will import, export, then reimport specified file to ensure IO parity." + + " When provided one or more game paths, will probe all bundle-like root files through libbndl and run automated resource operations on supported resource types."; + public static string CommandParameters => "[--format=] [--path=] [--game=] [--games=] [--bundletool=] [--workdir=] [--bundlelimit=] [--resourcelimit=] [--keepartifacts]"; public string? Format { get; set; } public string? Path { get; set; } + public string? GamePath { get; set; } + public string? GamePaths { get; set; } + public string? BundleToolPath { get; set; } + public string? WorkingDirectory { get; set; } + public int BundleLimit { get; set; } + public int ResourceLimit { get; set; } = 2; + public bool KeepArtifacts { get; set; } public async Task Execute() { + IReadOnlyList gamePaths = ParseGamePaths(); + if (gamePaths.Count > 0) + { + GameAutotestOperation operation = new(); + GameAutotestSummary summary = await operation.ExecuteAsync(new GameAutotestOptions + { + GamePaths = gamePaths, + BundleToolPath = BundleToolPath, + WorkingDirectory = WorkingDirectory, + BundleLimitPerGame = BundleLimit, + ResourcesPerType = ResourceLimit, + KeepArtifacts = KeepArtifacts + }); + + Console.WriteLine( + $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}"); + return; + } + if (!string.IsNullOrEmpty(Path)) { TextureBase? header = Format switch @@ -127,6 +155,23 @@ public void SetArgs(Dictionary args) { Format = (args.TryGetValue("format", out object? format) ? format as string : "auto").ToUpper(); Path = args.TryGetValue("path", out object? path) ? path as string : ""; + GamePath = args.TryGetValue("game", out object? game) ? game as string : ""; + GamePaths = args.TryGetValue("games", out object? games) ? games as string : ""; + BundleToolPath = args.TryGetValue("bundletool", out object? bundleTool) ? bundleTool as string : ""; + WorkingDirectory = args.TryGetValue("workdir", out object? workdir) ? workdir as string : ""; + KeepArtifacts = args.TryGetValue("keepartifacts", out var keepArtifacts) && (bool)keepArtifacts; + + if (args.TryGetValue("bundlelimit", out object? bundleLimitValue) && + int.TryParse(bundleLimitValue?.ToString(), out int bundleLimit)) + { + BundleLimit = Math.Max(0, bundleLimit); + } + + if (args.TryGetValue("resourcelimit", out object? resourceLimitValue) && + int.TryParse(resourceLimitValue?.ToString(), out int resourceLimit)) + { + ResourceLimit = Math.Max(1, resourceLimit); + } } public void TestHeaderRW(string name, TextureBase header, bool skipImport = false) @@ -234,5 +279,27 @@ public static void TestCompareHeaders(object exported, object imported) Console.ResetColor(); } + private IReadOnlyList ParseGamePaths() + { + List paths = []; + + if (!string.IsNullOrWhiteSpace(GamePath)) + { + paths.Add(GamePath); + } + + if (!string.IsNullOrWhiteSpace(GamePaths)) + { + paths.AddRange( + GamePaths + .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + return paths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + public AutotestCommand() { } -} \ No newline at end of file +} diff --git a/Volatility/Frontend.cs b/Volatility/Frontend.cs index f2f6a8c..063d5b0 100644 --- a/Volatility/Frontend.cs +++ b/Volatility/Frontend.cs @@ -38,8 +38,7 @@ static void Main(string[] args) { if (args.Length > 0) { - string fullCommand = string.Join(" ", args); - RunCommand(fullCommand); + RunCommandTokenized(args); } else { @@ -128,6 +127,31 @@ static void RunCommand(string input) } } + static void RunCommandTokenized(string[] input) + { + if (input.Length == 0) + { + return; + } + + try + { + var command = ParseCommandTokenized(input); + command.Execute().GetAwaiter().GetResult(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); +#if DEBUG + throw; +#endif + } + } + static ICommand ParseCommandTokenized(string[] input) // Split command { var commandName = input[0].ToLower(); diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs new file mode 100644 index 0000000..3315dd4 --- /dev/null +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -0,0 +1,803 @@ +using System.Globalization; + +using Volatility.Operations.Resources; +using Volatility.Resources; +using Volatility.Utilities; + +namespace Volatility.Operations.Autotest; + +internal sealed class GameAutotestOptions +{ + public required IReadOnlyList GamePaths { get; init; } + public string? BundleToolPath { get; init; } + public string? WorkingDirectory { get; init; } + public int BundleLimitPerGame { get; init; } + public int ResourcesPerType { get; init; } = 2; + public bool KeepArtifacts { get; init; } +} + +internal sealed class GameAutotestSummary +{ + public int Passed { get; set; } + public int Failed { get; set; } + public int Skipped { get; set; } + public List Cases { get; } = []; +} + +internal sealed record GameAutotestCaseResult( + string Game, + string Name, + string Operation, + string Outcome, + string? Details = null); + +internal sealed class GameAutotestOperation +{ + private static readonly HashSet RoundTripTypes = + [ + ResourceType.Texture, + ResourceType.GuiPopup, + ResourceType.InstanceList, + ResourceType.Model, + ResourceType.EnvironmentKeyframe, + ResourceType.EnvironmentTimeLine, + ResourceType.SnapshotData, + ResourceType.StreamedDeformationSpec, + ]; + + private static readonly HashSet ImportOnlyTypes = + [ + ResourceType.Renderable, + ResourceType.Splicer, + ResourceType.AptData, + ]; + + private static readonly string[] PreferredBundleNames = + [ + "POPUPS.PUP", + "AI.DAT", + "PROGRESSION.DAT", + "BTTPROGRESSION.DAT", + "STREETDATA.DAT", + "TRIGGERS.DAT", + "HUDMESSAGES.HM", + "HUDMESSAGESEQUENCES.HMSC", + "B5TRAFFIC.BNDL", + "BTTB5TRAFFIC.BNDL", + "GLOBALBACKDROPS.BNDL", + "GLOBALMODELDICTIONARY.BIN", + "GLOBALPROPS.BIN", + "GLOBALTEXTUREDICTIONARY.BIN", + "GUITEXTURES.BIN", + "MASSIVETABLE.BIN", + "MASSIVETEXTUREDICTIONARY.BIN", + "SURFACELIST.BIN", + "WORLDVAULT.BIN", + "CAMERAS.BUNDLE", + "FLAPTHUD.BUNDLE", + "PARTICLES.BUNDLE", + "PLAYBACKREGISTRY.BUNDLE", + "PVS.BNDL", + "ONLINECHALLENGES.BNDL", + "RWACFEATUREREGISTRY.BUNDLE", + "SHADERS.BNDL", + "TRK_UNIT0_GR.BNDL", + ]; + + public async Task ExecuteAsync(GameAutotestOptions options) + { + if (options.GamePaths.Count == 0) + { + throw new InvalidOperationException("At least one game path must be provided."); + } + + string repoRoot = WorkspaceUtilities.FindRepositoryRoot(); + string bundleToolPath = ResolveBundleTool(repoRoot, options.BundleToolPath); + string sessionRoot = ResolveSessionRoot(repoRoot, options.WorkingDirectory); + + Directory.CreateDirectory(sessionRoot); + + GameAutotestSummary summary = new(); + foreach (string gamePath in options.GamePaths) + { + GameInstall game = DetectGameInstall(gamePath); + await RunGameAsync(game, bundleToolPath, sessionRoot, options, summary); + } + + return summary; + } + + private async Task RunGameAsync( + GameInstall game, + string bundleToolPath, + string sessionRoot, + GameAutotestOptions options, + GameAutotestSummary summary) + { + string gameWorkRoot = Path.Combine(sessionRoot, $"{SanitizePathSegment(game.Name)}_{game.Platform}"); + Directory.CreateDirectory(gameWorkRoot); + + Console.WriteLine($"AUTOTEST - Game: {game.Name} ({game.Platform})"); + Console.WriteLine($"AUTOTEST - Working directory: {gameWorkRoot}"); + + int failuresBefore = summary.Failed; + + List candidates = []; + candidates.AddRange(GetDirectCandidates(game)); + + List probedBundles = ProbeBundleCandidates(game, bundleToolPath, gameWorkRoot, options, summary); + candidates.AddRange(ExtractSupportedBundleCandidates(game, bundleToolPath, gameWorkRoot, options, probedBundles, summary)); + + if (candidates.Count == 0) + { + AddCase(summary, new GameAutotestCaseResult( + game.Name, + "No candidates", + "discover", + "SKIP", + "No supported resources were discovered after probing bundle-like root files.")); + + if (!options.KeepArtifacts && failuresBefore == summary.Failed) + { + Directory.Delete(gameWorkRoot, recursive: true); + } + + return; + } + + string pass1Resources = Path.Combine(gameWorkRoot, "import_pass1", "Resources"); + string pass2Resources = Path.Combine(gameWorkRoot, "import_pass2", "Resources"); + string splicerPass1 = Path.Combine(gameWorkRoot, "import_pass1", "Splicer"); + string splicerPass2 = Path.Combine(gameWorkRoot, "import_pass2", "Splicer"); + string exportsRoot = Path.Combine(gameWorkRoot, "exports"); + string ddsRoot = Path.Combine(gameWorkRoot, "dds"); + string portRoot = Path.Combine(gameWorkRoot, "port"); + string toolsRoot = EnvironmentUtilities.GetEnvironmentDirectory(EnvironmentUtilities.EnvironmentDirectory.Tools); + + Directory.CreateDirectory(pass1Resources); + Directory.CreateDirectory(pass2Resources); + Directory.CreateDirectory(splicerPass1); + Directory.CreateDirectory(splicerPass2); + Directory.CreateDirectory(exportsRoot); + Directory.CreateDirectory(ddsRoot); + Directory.CreateDirectory(portRoot); + + ImportResourceOperation importPass1 = new(pass1Resources, toolsRoot, splicerPass1, overwrite: true); + ImportResourceOperation importPass2 = new(pass2Resources, toolsRoot, splicerPass2, overwrite: true); + SaveResourceOperation saveOperation = new(); + LoadResourceOperation loadOperation = new(); + ExportResourceOperation exportOperation = new(); + TextureToDDSOperation textureToDdsOperation = new(); + PortTextureOperation portTextureOperation = new(); + + foreach (ResourceTestCandidate candidate in candidates) + { + if (RoundTripTypes.Contains(candidate.ResourceType)) + { + await RunRoundTripAsync( + game, + candidate, + importPass1, + importPass2, + saveOperation, + loadOperation, + exportOperation, + exportsRoot, + summary); + } + else if (ImportOnlyTypes.Contains(candidate.ResourceType)) + { + await RunImportOnlyAsync(game, candidate, importPass1, saveOperation, summary); + } + + if (candidate.ResourceType == ResourceType.Texture) + { + await RunTextureOperationsAsync(game, candidate, textureToDdsOperation, portTextureOperation, ddsRoot, portRoot, summary); + } + } + + if (!options.KeepArtifacts && failuresBefore == summary.Failed) + { + Directory.Delete(gameWorkRoot, recursive: true); + } + } + + private static async Task RunRoundTripAsync( + GameInstall game, + ResourceTestCandidate candidate, + ImportResourceOperation importPass1, + ImportResourceOperation importPass2, + SaveResourceOperation saveOperation, + LoadResourceOperation loadOperation, + ExportResourceOperation exportOperation, + string exportsRoot, + GameAutotestSummary summary) + { + string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; + + try + { + ImportResourceResult firstImport = await importPass1.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); + await saveOperation.ExecuteAsync(firstImport.Resource, firstImport.ResourcePath); + + Resource loaded = await loadOperation.ExecuteAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform); + string exportPath = Path.Combine(exportsRoot, Path.GetFileName(candidate.SourcePath)); + await exportOperation.ExecuteAsync(loaded, exportPath, game.Platform); + + ImportResourceResult secondImport = await importPass2.ExecuteAsync(candidate.ResourceType, game.Platform, exportPath, isX64: false); + await saveOperation.ExecuteAsync(secondImport.Resource, secondImport.ResourcePath); + + string firstYaml = NormalizeYamlForComparison(await File.ReadAllTextAsync(firstImport.ResourcePath)); + string secondYaml = NormalizeYamlForComparison(await File.ReadAllTextAsync(secondImport.ResourcePath)); + + if (string.Equals(firstYaml, secondYaml, StringComparison.Ordinal)) + { + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "PASS")); + return; + } + + AddCase(summary, new GameAutotestCaseResult( + game.Name, + caseName, + "roundtrip", + "FAIL", + $"YAML mismatch after reimport. Pass1={firstImport.ResourcePath}, Pass2={secondImport.ResourcePath}")); + } + catch (Exception ex) + { + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "FAIL", ex.Message)); + } + } + + private static async Task RunImportOnlyAsync( + GameInstall game, + ResourceTestCandidate candidate, + ImportResourceOperation importOperation, + SaveResourceOperation saveOperation, + GameAutotestSummary summary) + { + string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; + + try + { + ImportResourceResult importResult = await importOperation.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); + await saveOperation.ExecuteAsync(importResult.Resource, importResult.ResourcePath); + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "PASS")); + } + catch (Exception ex) + { + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "FAIL", ex.Message)); + } + } + + private static async Task RunTextureOperationsAsync( + GameInstall game, + ResourceTestCandidate candidate, + TextureToDDSOperation textureToDdsOperation, + PortTextureOperation portTextureOperation, + string ddsRoot, + string portRoot, + GameAutotestSummary summary) + { + string ddsCaseName = $"{candidate.DisplayName}:dds"; + try + { + await textureToDdsOperation.ExecuteAsync([candidate.SourcePath], game.Platform, isX64: false, ddsRoot, overwrite: true, verbose: false); + AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", "PASS")); + } + catch (Exception ex) + { + string outcome = IsSkippableTextureOperation(ex) ? "SKIP" : "FAIL"; + AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", outcome, ex.Message)); + } + + Platform destinationPlatform = GetTexturePortDestination(game.Platform); + if (destinationPlatform == Platform.Agnostic) + { + AddCase(summary, new GameAutotestCaseResult(game.Name, $"{candidate.DisplayName}:port", "porttexture", "SKIP", "No supported destination platform.")); + return; + } + + string portCaseName = $"{candidate.DisplayName}:{game.Platform}->{destinationPlatform}"; + try + { + string destinationFormat = destinationPlatform == Platform.TUB ? "TUB" : destinationPlatform.ToString().ToUpperInvariant(); + string sourceFormat = game.Platform == Platform.TUB ? "TUB" : game.Platform.ToString().ToUpperInvariant(); + string destinationPath = Path.Combine(portRoot, destinationPlatform.ToString()); + Directory.CreateDirectory(destinationPath); + + await portTextureOperation.ExecuteAsync( + [candidate.SourcePath], + sourceFormat, + candidate.SourcePath, + destinationFormat, + destinationPath, + verbose: false, + useGtf: false); + + AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "PASS")); + } + catch (Exception ex) + { + AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "FAIL", ex.Message)); + } + } + + private static IEnumerable GetDirectCandidates(GameInstall game) + { + _ = game; + yield break; + } + + private static List ProbeBundleCandidates( + GameInstall game, + string bundleToolPath, + string gameWorkRoot, + GameAutotestOptions options, + GameAutotestSummary summary) + { + string probeRoot = Path.Combine(gameWorkRoot, "bundle_probes"); + Directory.CreateDirectory(probeRoot); + + HashSet reportedUnsupportedTypes = []; + List probes = []; + + foreach (string bundlePath in ApplyBundleLimit(GetBundleCandidates(game.RootPath), options.BundleLimitPerGame)) + { + string bundleName = Path.GetFileName(bundlePath); + string outputDirectory = Path.Combine(probeRoot, SanitizePathSegment(bundleName)); + string manifestPath = Path.Combine(outputDirectory, "manifest.tsv"); + + RecreateDirectory(outputDirectory); + + try + { + ProcessUtilities.RunAndCapture( + bundleToolPath, + $"--bundle \"{bundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\" --metadataonly", + Path.GetDirectoryName(bundleToolPath)); + } + catch (Exception ex) + { + AddCase(summary, new GameAutotestCaseResult(game.Name, bundleName, "bundleprobe", "FAIL", ex.Message)); + continue; + } + + List entries = ParseManifest(bundlePath, outputDirectory, manifestPath).ToList(); + int supportedCount = entries.Count(entry => IsSupportedResourceType(entry.ResourceType)); + + Console.WriteLine( + $"AUTOTEST - Probed {bundleName}: Resources={entries.Count}, Supported={supportedCount}, Types={FormatTypeSummary(entries.Select(entry => entry.ResourceType))}"); + + foreach (ResourceType unsupportedType in entries + .Select(entry => entry.ResourceType) + .Where(type => !IsSupportedResourceType(type)) + .Distinct()) + { + if (reportedUnsupportedTypes.Add(unsupportedType)) + { + AddCase(summary, new GameAutotestCaseResult( + game.Name, + GetResourceTypeLabel(unsupportedType), + "unsupported", + "SKIP", + $"Discovered in {bundleName}. No Volatility autotest handler exists for this resource type.")); + } + } + + probes.Add(new ProbedBundle(bundlePath, entries)); + } + + return probes; + } + + private static List ExtractSupportedBundleCandidates( + GameInstall game, + string bundleToolPath, + string gameWorkRoot, + GameAutotestOptions options, + IReadOnlyList probedBundles, + GameAutotestSummary summary) + { + string extractedRoot = Path.Combine(gameWorkRoot, "bundles"); + Directory.CreateDirectory(extractedRoot); + + HashSet blockedTypes = []; + Dictionary selectedCounts = new(); + List candidates = []; + foreach (ProbedBundle probedBundle in probedBundles) + { + Dictionary pendingCounts = new(); + List selectedEntries = []; + + foreach (BundleManifestEntry entry in probedBundle.Entries.DistinctBy(entry => entry.ResourceIdHex, StringComparer.OrdinalIgnoreCase)) + { + if (!IsSupportedResourceType(entry.ResourceType) || blockedTypes.Contains(entry.ResourceType)) + { + continue; + } + + int currentCount = selectedCounts.GetValueOrDefault(entry.ResourceType); + int pendingCount = pendingCounts.GetValueOrDefault(entry.ResourceType); + if (currentCount + pendingCount >= options.ResourcesPerType) + { + continue; + } + + selectedEntries.Add(entry); + pendingCounts[entry.ResourceType] = pendingCount + 1; + } + + if (selectedEntries.Count == 0) + { + continue; + } + + string bundleName = Path.GetFileName(probedBundle.BundlePath); + string outputDirectory = Path.Combine(extractedRoot, SanitizePathSegment(bundleName)); + string manifestPath = Path.Combine(outputDirectory, "manifest.tsv"); + + RecreateDirectory(outputDirectory); + + try + { + ProcessUtilities.RunAndCapture( + bundleToolPath, + $"--bundle \"{probedBundle.BundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\"", + Path.GetDirectoryName(bundleToolPath)); + } + catch (Exception ex) + { + string outcome = IsSkippableBundleExtractionFailure(ex) ? "SKIP" : "FAIL"; + + if (outcome == "SKIP") + { + foreach (ResourceType blockedType in selectedEntries.Select(entry => entry.ResourceType).Distinct()) + { + blockedTypes.Add(blockedType); + } + } + + AddCase(summary, new GameAutotestCaseResult(game.Name, bundleName, "bundleextract", outcome, ex.Message)); + continue; + } + + List extractedEntries = ParseManifest(probedBundle.BundlePath, outputDirectory, manifestPath) + .Where(entry => !string.IsNullOrWhiteSpace(entry.PrimaryPath)) + .ToList(); + + Console.WriteLine( + $"AUTOTEST - Extracted {bundleName}: Resources={extractedEntries.Count}, Selected={selectedEntries.Count}"); + + Dictionary extractedById = extractedEntries + .GroupBy(entry => entry.ResourceIdHex, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + foreach (BundleManifestEntry selectedEntry in selectedEntries.DistinctBy(entry => entry.ResourceIdHex, StringComparer.OrdinalIgnoreCase)) + { + if (selectedCounts.GetValueOrDefault(selectedEntry.ResourceType) >= options.ResourcesPerType) + { + continue; + } + + if (!extractedById.TryGetValue(selectedEntry.ResourceIdHex, out BundleManifestEntry? extractedEntry) || + string.IsNullOrWhiteSpace(extractedEntry.PrimaryPath)) + { + AddCase(summary, new GameAutotestCaseResult( + game.Name, + $"{selectedEntry.ResourceType}:{selectedEntry.DisplayName}", + "candidate", + "FAIL", + $"Failed to resolve extracted primary data from {bundleName}.")); + continue; + } + + candidates.Add(new ResourceTestCandidate(extractedEntry.DisplayName, extractedEntry.PrimaryPath, extractedEntry.ResourceType)); + selectedCounts[extractedEntry.ResourceType] = selectedCounts.GetValueOrDefault(extractedEntry.ResourceType) + 1; + } + } + + foreach (ResourceType blockedType in blockedTypes.Where(type => selectedCounts.GetValueOrDefault(type) == 0)) + { + AddCase(summary, new GameAutotestCaseResult( + game.Name, + GetResourceTypeLabel(blockedType), + "candidate", + "SKIP", + "No fully extractable bundle candidate was available for this supported resource type.")); + } + + return candidates; + } + + private static IEnumerable GetBundleCandidates(string rootPath) + { + List candidates = Directory + .EnumerateFiles(rootPath, "*", SearchOption.TopDirectoryOnly) + .Where(IsBundleLikeFile) + .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + List ordered = []; + foreach (string preferredName in PreferredBundleNames) + { + string? match = candidates.FirstOrDefault(path => + string.Equals(Path.GetFileName(path), preferredName, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + ordered.Add(match); + } + } + + ordered.AddRange(candidates.Where(path => !ordered.Contains(path, StringComparer.OrdinalIgnoreCase))); + return ordered; + } + + private static IEnumerable ApplyBundleLimit(IEnumerable candidates, int bundleLimitPerGame) + { + return bundleLimitPerGame > 0 ? candidates.Take(bundleLimitPerGame) : candidates; + } + + private static bool IsBundleLikeFile(string path) + { + try + { + using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + if (stream.Length < 4) + { + return false; + } + + byte[] magic = new byte[4]; + if (stream.Read(magic, 0, magic.Length) != magic.Length) + { + return false; + } + + return magic[0] == (byte)'b' && + magic[1] == (byte)'n' && + magic[2] == (byte)'d' && + (magic[3] == (byte)'2' || magic[3] == (byte)'l'); + } + catch + { + return false; + } + } + + private static void RecreateDirectory(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + + Directory.CreateDirectory(path); + } + + private static IEnumerable ParseManifest(string bundlePath, string outputDirectory, string manifestPath) + { + foreach (string line in File.ReadLines(manifestPath).Skip(1)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] parts = line.Split('\t'); + if (parts.Length < 4) + { + continue; + } + + if (!uint.TryParse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint typeValue)) + { + continue; + } + + if (!Enum.IsDefined(typeof(ResourceType), (int)typeValue)) + { + continue; + } + + string resourceIdHex = parts[0].Trim(); + string displayName = !string.IsNullOrWhiteSpace(parts[2]) + ? parts[2] + : parts.Length > 3 && !string.IsNullOrWhiteSpace(parts[3]) + ? parts[3] + : resourceIdHex; + + string? primaryPath = null; + if (parts.Length > 4 && !string.IsNullOrWhiteSpace(parts[4])) + { + primaryPath = Path.Combine(outputDirectory, parts[4]); + } + + yield return new BundleManifestEntry( + bundlePath, + resourceIdHex, + displayName, + (ResourceType)typeValue, + primaryPath); + } + } + + private static string ResolveBundleTool(string repoRoot, string? bundleToolPath) + { + if (!string.IsNullOrWhiteSpace(bundleToolPath)) + { + string explicitPath = Path.GetFullPath(bundleToolPath); + if (!File.Exists(explicitPath)) + { + throw new FileNotFoundException($"Bundle extractor not found: {explicitPath}"); + } + + return explicitPath; + } + + string defaultTool = Path.Combine(repoRoot, "tools", "libbndl-extractor", "build", "volatility_libbndl_extract.exe"); + if (File.Exists(defaultTool)) + { + return defaultTool; + } + + string buildScript = Path.Combine(repoRoot, "tools", "libbndl-extractor", "build.ps1"); + ProcessUtilities.RunAndCapture("powershell", $"-ExecutionPolicy Bypass -File \"{buildScript}\"", repoRoot); + + if (!File.Exists(defaultTool)) + { + throw new FileNotFoundException($"Failed to build bundle extractor at {defaultTool}"); + } + + return defaultTool; + } + + private static string ResolveSessionRoot(string repoRoot, string? workingDirectory) + { + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + return Path.GetFullPath(workingDirectory); + } + + string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + return Path.Combine(repoRoot, ".tmp", "game-autotest", stamp); + } + + private static GameInstall DetectGameInstall(string gamePath) + { + string fullPath = Path.GetFullPath(gamePath); + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException($"Game directory not found: {fullPath}"); + } + + if (File.Exists(Path.Combine(fullPath, "BurnoutPR.exe")) || + File.Exists(Path.Combine(fullPath, "BurnoutPR_trial.exe"))) + { + return new GameInstall(Path.GetFileName(fullPath), fullPath, Platform.TUB); + } + + if (Directory.EnumerateFiles(fullPath, "*.xex", SearchOption.TopDirectoryOnly).Any()) + { + return new GameInstall(Path.GetFileName(fullPath), fullPath, Platform.X360); + } + + throw new InvalidOperationException($"Unable to infer platform for game directory: {fullPath}"); + } + + private static bool IsSupportedResourceType(ResourceType resourceType) + { + return RoundTripTypes.Contains(resourceType) || ImportOnlyTypes.Contains(resourceType); + } + + private static string FormatTypeSummary(IEnumerable resourceTypes) + { + List labels = resourceTypes + .Distinct() + .Select(GetResourceTypeLabel) + .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (labels.Count == 0) + { + return "None"; + } + + const int maxDisplayedTypes = 6; + if (labels.Count <= maxDisplayedTypes) + { + return string.Join(", ", labels); + } + + return $"{string.Join(", ", labels.Take(maxDisplayedTypes))}, +{labels.Count - maxDisplayedTypes} more"; + } + + private static string GetResourceTypeLabel(ResourceType resourceType) + { + return Enum.IsDefined(typeof(ResourceType), resourceType) + ? resourceType.ToString() + : $"0x{(uint)resourceType:X8}"; + } + + private static Platform GetTexturePortDestination(Platform sourcePlatform) + { + return sourcePlatform switch + { + Platform.TUB => Platform.BPR, + Platform.X360 => Platform.TUB, + Platform.PS3 => Platform.TUB, + Platform.BPR => Platform.TUB, + _ => Platform.Agnostic, + }; + } + + private static string NormalizeYamlForComparison(string yaml) + { + IEnumerable lines = yaml + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n') + .Where(line => !line.TrimStart().StartsWith("ImportedFileName:", StringComparison.Ordinal)); + + return string.Join('\n', lines).Trim(); + } + + private static bool IsSkippableTextureOperation(Exception ex) + { + return ex.Message.Contains("DDS export is not supported", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("Failed to find associated bitmap data", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSkippableBundleExtractionFailure(Exception ex) + { + return ex.Message.Contains("Assertion failed: m_flags & Compressed", StringComparison.OrdinalIgnoreCase); + } + + private static string SanitizePathSegment(string value) + { + foreach (char invalidChar in Path.GetInvalidFileNameChars()) + { + value = value.Replace(invalidChar, '_'); + } + + return string.IsNullOrWhiteSpace(value) ? "game" : value; + } + + private static void AddCase(GameAutotestSummary summary, GameAutotestCaseResult result) + { + summary.Cases.Add(result); + + switch (result.Outcome) + { + case "PASS": + summary.Passed++; + Console.ForegroundColor = ConsoleColor.Green; + break; + case "FAIL": + summary.Failed++; + Console.ForegroundColor = ConsoleColor.Red; + break; + default: + summary.Skipped++; + Console.ForegroundColor = ConsoleColor.DarkYellow; + break; + } + + Console.WriteLine($"[{result.Outcome}] {result.Game} {result.Operation} {result.Name}" + + (string.IsNullOrWhiteSpace(result.Details) ? string.Empty : $" - {result.Details}")); + Console.ResetColor(); + } + + private sealed record GameInstall(string Name, string RootPath, Platform Platform); + + private sealed record ResourceTestCandidate(string DisplayName, string SourcePath, ResourceType ResourceType); + + private sealed record ProbedBundle(string BundlePath, List Entries); + + private sealed record BundleManifestEntry( + string BundlePath, + string ResourceIdHex, + string DisplayName, + ResourceType ResourceType, + string? PrimaryPath); +} diff --git a/Volatility/Operations/Resources/SaveResourceOperation.cs b/Volatility/Operations/Resources/SaveResourceOperation.cs index 8b8e48d..3fcc0c4 100644 --- a/Volatility/Operations/Resources/SaveResourceOperation.cs +++ b/Volatility/Operations/Resources/SaveResourceOperation.cs @@ -15,6 +15,7 @@ public SaveResourceOperation() .DisableAliases() .WithTypeInspector(inner => new IncludeFieldsTypeInspector(inner)) .WithTypeConverter(new ResourceYamlTypeConverter()) + .WithTypeConverter(new BitArrayYamlTypeConverter()) .WithTypeConverter(new StrongIDYamlTypeConverter()) .WithTypeConverter(new StringEnumYamlTypeConverter()) .Build(); diff --git a/Volatility/Resources/ResourceFactory.cs b/Volatility/Resources/ResourceFactory.cs index ba346d4..f183926 100644 --- a/Volatility/Resources/ResourceFactory.cs +++ b/Volatility/Resources/ResourceFactory.cs @@ -81,6 +81,12 @@ public static class ResourceFactory { (ResourceType.AptData, Platform.X360), path => new AptData(path, Endian.BE) }, { (ResourceType.AptData, Platform.PS3), path => new AptData(path, Endian.BE) }, + // GuiPopup resources + { (ResourceType.GuiPopup, Platform.BPR), path => new GuiPopup(path, Endian.LE) }, + { (ResourceType.GuiPopup, Platform.TUB), path => new GuiPopup(path, Endian.LE) }, + { (ResourceType.GuiPopup, Platform.X360), path => new GuiPopup(path, Endian.BE) }, + { (ResourceType.GuiPopup, Platform.PS3), path => new GuiPopup(path, Endian.BE) }, + // Shader resources { (ResourceType.Shader, Platform.Agnostic), path => new ShaderBase(path) }, { (ResourceType.Shader, Platform.TUB), path => new ShaderPC(path) }, diff --git a/Volatility/Utilities/ProcessUtilities.cs b/Volatility/Utilities/ProcessUtilities.cs new file mode 100644 index 0000000..0a3769e --- /dev/null +++ b/Volatility/Utilities/ProcessUtilities.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; +using System.Text; + +namespace Volatility.Utilities; + +internal static class ProcessUtilities +{ + public static string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) + { + ProcessStartInfo startInfo = new() + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Directory.GetCurrentDirectory() : workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new() { StartInfo = startInfo }; + StringBuilder output = new(); + + process.Start(); + output.Append(process.StandardOutput.ReadToEnd()); + output.Append(process.StandardError.ReadToEnd()); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Process '{fileName} {arguments}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); + } + + return output.ToString(); + } +} diff --git a/Volatility/Utilities/WorkspaceUtilities.cs b/Volatility/Utilities/WorkspaceUtilities.cs new file mode 100644 index 0000000..cc12d46 --- /dev/null +++ b/Volatility/Utilities/WorkspaceUtilities.cs @@ -0,0 +1,29 @@ +namespace Volatility.Utilities; + +internal static class WorkspaceUtilities +{ + public static string FindRepositoryRoot() + { + foreach (string startPath in GetCandidateStartPaths()) + { + string? current = Path.GetFullPath(startPath); + while (!string.IsNullOrEmpty(current)) + { + if (File.Exists(Path.Combine(current, "Volatility.sln"))) + { + return current; + } + + current = Directory.GetParent(current)?.FullName; + } + } + + throw new DirectoryNotFoundException("Unable to locate the repository root containing Volatility.sln."); + } + + private static IEnumerable GetCandidateStartPaths() + { + yield return Directory.GetCurrentDirectory(); + yield return EnvironmentUtilities.GetExecutableDirectory(); + } +} diff --git a/Volatility/Utilities/YAML/BitArrayYamlTypeConverter.cs b/Volatility/Utilities/YAML/BitArrayYamlTypeConverter.cs new file mode 100644 index 0000000..2ce4f3e --- /dev/null +++ b/Volatility/Utilities/YAML/BitArrayYamlTypeConverter.cs @@ -0,0 +1,49 @@ +using System.Collections; +using System.Text; + +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace Volatility.Utilities; + +public class BitArrayYamlTypeConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) => type == typeof(BitArray); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer nestedObjectDeserializer) + { + string bits = parser.Consume().Value ?? string.Empty; + BitArray bitArray = new(bits.Length); + + for (int i = 0; i < bits.Length; i++) + { + bitArray[i] = bits[i] switch + { + '0' => false, + '1' => true, + _ => throw new YamlException($"Invalid BitArray value '{bits[i]}'. Expected only '0' or '1'."), + }; + } + + return bitArray; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer nestedObjectSerializer) + { + if (value is not BitArray bitArray) + { + emitter.Emit(new Scalar(string.Empty)); + return; + } + + StringBuilder bits = new(bitArray.Length); + + foreach (bool bit in bitArray) + { + bits.Append(bit ? '1' : '0'); + } + + emitter.Emit(new Scalar(bits.ToString())); + } +} diff --git a/Volatility/Utilities/YAML/ResourceYamlDeserializer.cs b/Volatility/Utilities/YAML/ResourceYamlDeserializer.cs index 8e7a59d..0e23040 100644 --- a/Volatility/Utilities/YAML/ResourceYamlDeserializer.cs +++ b/Volatility/Utilities/YAML/ResourceYamlDeserializer.cs @@ -43,6 +43,7 @@ public static object DeserializeResource(Type resourceClass, string yaml) var finalDeserializer = new DeserializerBuilder() .IgnoreUnmatchedProperties() + .WithTypeConverter(new BitArrayYamlTypeConverter()) .WithTypeConverter(new StrongIDYamlTypeConverter()) .Build(); using (var reader = new StringReader(mergedYaml)) @@ -102,4 +103,4 @@ private static Dictionary RemovePropertiesKeys(Dictionary !kvp.Key.EndsWith(".Properties")) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); } -} \ No newline at end of file +} diff --git a/tools/libbndl-extractor b/tools/libbndl-extractor new file mode 160000 index 0000000..9770217 --- /dev/null +++ b/tools/libbndl-extractor @@ -0,0 +1 @@ +Subproject commit 97702174d07d0f7601a208fcebdbb7a7a0ba370b From e99d6e57c76d2b9122fb61fdd4d8c9fe63aba992 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:02:22 +0200 Subject: [PATCH 03/17] Update sub-module --- .gitmodules | 2 +- README.md | 1 + tools/libbndl-extractor | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index fbd1193..c4e539b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "tools/libbndl-extractor"] path = tools/libbndl-extractor - url = ../volatility-libbndl-extractor + url = https://github.com/Adriwin06/libbndl-extractor.git branch = main diff --git a/README.md b/README.md index 0181347..7c797b6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Download and extract the latest version of the application from the [Releases pa Ensure you have the necessary prerequisites to develop .NET 9.0 applications on your machine. After cloning, initialize the extractor submodule with `git submodule update --init --recursive`. +This must stay recursive because `tools/libbndl-extractor` itself pins `Bo98/libbndl` as a nested submodule. Compiling the application is as simple as opening the project within your IDE of choice (Such as Rider or Visual Studio 2022), or by running `dotnet build`. diff --git a/tools/libbndl-extractor b/tools/libbndl-extractor index 9770217..7f8e9d3 160000 --- a/tools/libbndl-extractor +++ b/tools/libbndl-extractor @@ -1 +1 @@ -Subproject commit 97702174d07d0f7601a208fcebdbb7a7a0ba370b +Subproject commit 7f8e9d302a6a4878c73b77b15c3fedd786047dd4 From 7b10acbe81f193dcba2542f550bc7de5c231d7a7 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:34:22 +0200 Subject: [PATCH 04/17] Add autotest report --- Volatility/CLI/Commands/AutotestCommand.cs | 104 +++++++++++++++++- .../Autotest/GameAutotestOperation.cs | 33 +++--- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/Volatility/CLI/Commands/AutotestCommand.cs index ff25356..b3d9a0e 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Text; using Volatility.Operations.Autotest; using Volatility.Resources; @@ -14,7 +15,7 @@ internal class AutotestCommand : ICommand public static string CommandDescription => "Runs automatic tests to ensure the application is working." + " When provided a path & format, will import, export, then reimport specified file to ensure IO parity." + " When provided one or more game paths, will probe all bundle-like root files through libbndl and run automated resource operations on supported resource types."; - public static string CommandParameters => "[--format=] [--path=] [--game=] [--games=] [--bundletool=] [--workdir=] [--bundlelimit=] [--resourcelimit=] [--keepartifacts]"; + public static string CommandParameters => "[--format=] [--path=] [--game=] [--games=] [--bundletool=] [--workdir=] [--bundlelimit=] [--resourcelimit=] [--keepartifacts] [--recap=]"; public string? Format { get; set; } public string? Path { get; set; } @@ -22,6 +23,7 @@ internal class AutotestCommand : ICommand public string? GamePaths { get; set; } public string? BundleToolPath { get; set; } public string? WorkingDirectory { get; set; } + public string? RecapPath { get; set; } public int BundleLimit { get; set; } public int ResourceLimit { get; set; } = 2; public bool KeepArtifacts { get; set; } @@ -44,6 +46,12 @@ public async Task Execute() Console.WriteLine( $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}"); + + if (!string.IsNullOrWhiteSpace(RecapPath)) + { + string recapFilePath = WriteDetailedRecap(gamePaths, summary, RecapPath); + Console.WriteLine($"AUTOTEST - Detailed recap written to: {recapFilePath}"); + } return; } @@ -159,6 +167,7 @@ public void SetArgs(Dictionary args) GamePaths = args.TryGetValue("games", out object? games) ? games as string : ""; BundleToolPath = args.TryGetValue("bundletool", out object? bundleTool) ? bundleTool as string : ""; WorkingDirectory = args.TryGetValue("workdir", out object? workdir) ? workdir as string : ""; + RecapPath = args.TryGetValue("recap", out object? recap) ? recap as string : ""; KeepArtifacts = args.TryGetValue("keepartifacts", out var keepArtifacts) && (bool)keepArtifacts; if (args.TryGetValue("bundlelimit", out object? bundleLimitValue) && @@ -301,5 +310,98 @@ private IReadOnlyList ParseGamePaths() .ToList(); } + private static string WriteDetailedRecap(IReadOnlyList gamePaths, GameAutotestSummary summary, string outputPath) + { + string recapPath = ResolveRecapPath(outputPath); + StringBuilder builder = new(); + + builder.AppendLine("# Volatility Autotest Recap"); + builder.AppendLine(); + builder.AppendLine($"Generated (UTC): {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"); + builder.AppendLine($"Games: {string.Join(" | ", gamePaths)}"); + builder.AppendLine($"Passed: {summary.Passed}"); + builder.AppendLine($"Failed: {summary.Failed}"); + builder.AppendLine($"Skipped: {summary.Skipped}"); + builder.AppendLine(); + + List> byResourceType = summary.Cases + .Where(result => result.TestedResourceType.HasValue) + .GroupBy(result => result.TestedResourceType!.Value) + .OrderBy(group => group.Key.ToString(), StringComparer.OrdinalIgnoreCase) + .ToList(); + + builder.AppendLine("## Resource Type Outcomes"); + builder.AppendLine(); + + if (byResourceType.Count == 0) + { + builder.AppendLine("No resource-type specific cases were recorded."); + } + else + { + builder.AppendLine("| Resource Type | Passed | Failed | Skipped | Overall |"); + builder.AppendLine("| --- | ---: | ---: | ---: | --- |"); + + foreach (IGrouping group in byResourceType) + { + int passed = group.Count(result => string.Equals(result.Outcome, "PASS", StringComparison.Ordinal)); + int failed = group.Count(result => string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); + int skipped = group.Count(result => !string.Equals(result.Outcome, "PASS", StringComparison.Ordinal) && !string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); + string overall = failed > 0 ? "FAIL" : passed > 0 ? "PASS" : "SKIP"; + + builder.AppendLine($"| {group.Key} | {passed} | {failed} | {skipped} | {overall} |"); + } + } + + builder.AppendLine(); + builder.AppendLine("## Case Details"); + builder.AppendLine(); + builder.AppendLine("| Game | Resource Type | Operation | Name | Outcome | Details |"); + builder.AppendLine("| --- | --- | --- | --- | --- | --- |"); + + foreach (GameAutotestCaseResult result in summary.Cases) + { + string resourceType = result.TestedResourceType?.ToString() ?? "-"; + builder.AppendLine($"| {EscapeMarkdownCell(result.Game)} | {EscapeMarkdownCell(resourceType)} | {EscapeMarkdownCell(result.Operation)} | {EscapeMarkdownCell(result.Name)} | {EscapeMarkdownCell(result.Outcome)} | {EscapeMarkdownCell(result.Details ?? string.Empty)} |"); + } + + File.WriteAllText(recapPath, builder.ToString()); + return recapPath; + } + + private static string ResolveRecapPath(string outputPath) + { + string fullPath = System.IO.Path.GetFullPath(outputPath); + bool looksLikeDirectory = + outputPath.EndsWith(System.IO.Path.DirectorySeparatorChar) || + outputPath.EndsWith(System.IO.Path.AltDirectorySeparatorChar) || + string.IsNullOrWhiteSpace(System.IO.Path.GetExtension(fullPath)); + + if (Directory.Exists(fullPath) || looksLikeDirectory) + { + Directory.CreateDirectory(fullPath); + string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + return System.IO.Path.Combine(fullPath, $"autotest_recap_{timestamp}.md"); + } + + string? directory = System.IO.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + return fullPath; + } + + private static string EscapeMarkdownCell(string value) + { + return value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", "
", StringComparison.Ordinal) + .Trim(); + } + public AutotestCommand() { } } diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs index 3315dd4..649af1d 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -29,7 +29,8 @@ internal sealed record GameAutotestCaseResult( string Name, string Operation, string Outcome, - string? Details = null); + string? Details = null, + ResourceType? TestedResourceType = null); internal sealed class GameAutotestOperation { @@ -232,7 +233,7 @@ private static async Task RunRoundTripAsync( if (string.Equals(firstYaml, secondYaml, StringComparison.Ordinal)) { - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "PASS")); + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "PASS", TestedResourceType: candidate.ResourceType)); return; } @@ -241,11 +242,12 @@ private static async Task RunRoundTripAsync( caseName, "roundtrip", "FAIL", - $"YAML mismatch after reimport. Pass1={firstImport.ResourcePath}, Pass2={secondImport.ResourcePath}")); + $"YAML mismatch after reimport. Pass1={firstImport.ResourcePath}, Pass2={secondImport.ResourcePath}", + TestedResourceType: candidate.ResourceType)); } catch (Exception ex) { - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "FAIL", ex.Message)); + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "FAIL", ex.Message, candidate.ResourceType)); } } @@ -262,11 +264,11 @@ private static async Task RunImportOnlyAsync( { ImportResourceResult importResult = await importOperation.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); await saveOperation.ExecuteAsync(importResult.Resource, importResult.ResourcePath); - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "PASS")); + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "PASS", TestedResourceType: candidate.ResourceType)); } catch (Exception ex) { - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "FAIL", ex.Message)); + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "FAIL", ex.Message, candidate.ResourceType)); } } @@ -283,18 +285,18 @@ private static async Task RunTextureOperationsAsync( try { await textureToDdsOperation.ExecuteAsync([candidate.SourcePath], game.Platform, isX64: false, ddsRoot, overwrite: true, verbose: false); - AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", "PASS")); + AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", "PASS", TestedResourceType: ResourceType.Texture)); } catch (Exception ex) { string outcome = IsSkippableTextureOperation(ex) ? "SKIP" : "FAIL"; - AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", outcome, ex.Message)); + AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", outcome, ex.Message, ResourceType.Texture)); } Platform destinationPlatform = GetTexturePortDestination(game.Platform); if (destinationPlatform == Platform.Agnostic) { - AddCase(summary, new GameAutotestCaseResult(game.Name, $"{candidate.DisplayName}:port", "porttexture", "SKIP", "No supported destination platform.")); + AddCase(summary, new GameAutotestCaseResult(game.Name, $"{candidate.DisplayName}:port", "porttexture", "SKIP", "No supported destination platform.", ResourceType.Texture)); return; } @@ -315,11 +317,11 @@ await portTextureOperation.ExecuteAsync( verbose: false, useGtf: false); - AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "PASS")); + AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "PASS", TestedResourceType: ResourceType.Texture)); } catch (Exception ex) { - AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "FAIL", ex.Message)); + AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "FAIL", ex.Message, ResourceType.Texture)); } } @@ -381,7 +383,8 @@ private static List ProbeBundleCandidates( GetResourceTypeLabel(unsupportedType), "unsupported", "SKIP", - $"Discovered in {bundleName}. No Volatility autotest handler exists for this resource type.")); + $"Discovered in {bundleName}. No Volatility autotest handler exists for this resource type.", + TestedResourceType: unsupportedType)); } } @@ -488,7 +491,8 @@ private static List ExtractSupportedBundleCandidates( $"{selectedEntry.ResourceType}:{selectedEntry.DisplayName}", "candidate", "FAIL", - $"Failed to resolve extracted primary data from {bundleName}.")); + $"Failed to resolve extracted primary data from {bundleName}.", + TestedResourceType: selectedEntry.ResourceType)); continue; } @@ -504,7 +508,8 @@ private static List ExtractSupportedBundleCandidates( GetResourceTypeLabel(blockedType), "candidate", "SKIP", - "No fully extractable bundle candidate was available for this supported resource type.")); + "No fully extractable bundle candidate was available for this supported resource type.", + TestedResourceType: blockedType)); } return candidates; From 10b37669a91089678fd1c2e9bb77ff0609130357 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:08:42 +0200 Subject: [PATCH 05/17] Add binaryparity test --- README.md | 1 + Volatility/CLI/Commands/AutotestCommand.cs | 2 +- .../Autotest/GameAutotestOperation.cs | 88 ++++++++++++++++++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7c797b6..d3476e9 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,4 @@ NOTE: This may not be entirely comprehensive. Run "help" for a full list of comm #### Autotest - Runs automatic tests to ensure the application is working. - When provided a path & format, will import, export, then reimport specified file to ensure IO parity. +- When provided game path(s), roundtrip tests now include exact binary parity checks between original and exported files. diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/Volatility/CLI/Commands/AutotestCommand.cs index b3d9a0e..cb6ddcd 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -14,7 +14,7 @@ internal class AutotestCommand : ICommand public static string CommandToken => "autotest"; public static string CommandDescription => "Runs automatic tests to ensure the application is working." + " When provided a path & format, will import, export, then reimport specified file to ensure IO parity." + - " When provided one or more game paths, will probe all bundle-like root files through libbndl and run automated resource operations on supported resource types."; + " When provided one or more game paths, will probe all bundle-like root files through libbndl, run automated resource operations on supported resource types, and verify exact binary parity for roundtrip exports."; public static string CommandParameters => "[--format=] [--path=] [--game=] [--games=] [--bundletool=] [--workdir=] [--bundlelimit=] [--resourcelimit=] [--keepartifacts] [--recap=]"; public string? Format { get; set; } diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs index 649af1d..512d2e2 100644 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ b/Volatility/Operations/Autotest/GameAutotestOperation.cs @@ -215,6 +215,8 @@ private static async Task RunRoundTripAsync( GameAutotestSummary summary) { string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; + string? exportPath = null; + bool binaryParityRecorded = false; try { @@ -222,9 +224,19 @@ private static async Task RunRoundTripAsync( await saveOperation.ExecuteAsync(firstImport.Resource, firstImport.ResourcePath); Resource loaded = await loadOperation.ExecuteAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform); - string exportPath = Path.Combine(exportsRoot, Path.GetFileName(candidate.SourcePath)); + exportPath = Path.Combine(exportsRoot, Path.GetFileName(candidate.SourcePath)); await exportOperation.ExecuteAsync(loaded, exportPath, game.Platform); + BinaryComparisonResult binaryComparison = CompareFilesExactly(candidate.SourcePath, exportPath); + AddCase(summary, new GameAutotestCaseResult( + game.Name, + caseName, + "binaryparity", + binaryComparison.Matches ? "PASS" : "FAIL", + binaryComparison.Details, + TestedResourceType: candidate.ResourceType)); + binaryParityRecorded = true; + ImportResourceResult secondImport = await importPass2.ExecuteAsync(candidate.ResourceType, game.Platform, exportPath, isX64: false); await saveOperation.ExecuteAsync(secondImport.Resource, secondImport.ResourcePath); @@ -247,6 +259,22 @@ private static async Task RunRoundTripAsync( } catch (Exception ex) { + if (!binaryParityRecorded) + { + string binaryOutcome = string.IsNullOrWhiteSpace(exportPath) ? "SKIP" : "FAIL"; + string binaryDetails = string.IsNullOrWhiteSpace(exportPath) + ? $"Roundtrip failed before binary parity comparison: {ex.Message}" + : $"Binary parity comparison failed: {ex.Message}"; + + AddCase(summary, new GameAutotestCaseResult( + game.Name, + caseName, + "binaryparity", + binaryOutcome, + binaryDetails, + TestedResourceType: candidate.ResourceType)); + } + AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "FAIL", ex.Message, candidate.ResourceType)); } } @@ -747,6 +775,62 @@ private static string NormalizeYamlForComparison(string yaml) return string.Join('\n', lines).Trim(); } + private static BinaryComparisonResult CompareFilesExactly(string originalPath, string exportedPath) + { + FileInfo originalInfo = new(originalPath); + FileInfo exportedInfo = new(exportedPath); + + if (originalInfo.Length != exportedInfo.Length) + { + return new BinaryComparisonResult( + Matches: false, + Details: $"Binary size mismatch. Original={originalInfo.Length} bytes, Exported={exportedInfo.Length} bytes."); + } + + using FileStream originalStream = new(originalPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using FileStream exportedStream = new(exportedPath, FileMode.Open, FileAccess.Read, FileShare.Read); + + const int bufferSize = 81920; + byte[] originalBuffer = new byte[bufferSize]; + byte[] exportedBuffer = new byte[bufferSize]; + long offset = 0; + + while (true) + { + int originalRead = originalStream.Read(originalBuffer, 0, originalBuffer.Length); + int exportedRead = exportedStream.Read(exportedBuffer, 0, exportedBuffer.Length); + + if (originalRead != exportedRead) + { + return new BinaryComparisonResult( + Matches: false, + Details: $"Binary read mismatch at offset 0x{offset:X}. OriginalRead={originalRead}, ExportedRead={exportedRead}."); + } + + if (originalRead == 0) + { + break; + } + + for (int i = 0; i < originalRead; i++) + { + if (originalBuffer[i] != exportedBuffer[i]) + { + long mismatchOffset = offset + i; + return new BinaryComparisonResult( + Matches: false, + Details: $"Binary mismatch at offset 0x{mismatchOffset:X}. Original=0x{originalBuffer[i]:X2}, Exported=0x{exportedBuffer[i]:X2}."); + } + } + + offset += originalRead; + } + + return new BinaryComparisonResult( + Matches: true, + Details: "Binary files are identical."); + } + private static bool IsSkippableTextureOperation(Exception ex) { return ex.Message.Contains("DDS export is not supported", StringComparison.OrdinalIgnoreCase) || @@ -797,6 +881,8 @@ private sealed record GameInstall(string Name, string RootPath, Platform Platfor private sealed record ResourceTestCandidate(string DisplayName, string SourcePath, ResourceType ResourceType); + private sealed record BinaryComparisonResult(bool Matches, string Details); + private sealed record ProbedBundle(string BundlePath, List Entries); private sealed record BundleManifestEntry( From 1b2d4c74d1fe778ca913d8e4cc9a5a7ecd88fbf6 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:11:55 +0200 Subject: [PATCH 06/17] Minor autotest report improvement --- Volatility/CLI/Commands/AutotestCommand.cs | 55 ++++++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/Volatility/CLI/Commands/AutotestCommand.cs index cb6ddcd..33bd012 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -314,14 +314,49 @@ private static string WriteDetailedRecap(IReadOnlyList gamePaths, GameAu { string recapPath = ResolveRecapPath(outputPath); StringBuilder builder = new(); + int binaryParityPassed = summary.Cases.Count(result => + string.Equals(result.Outcome, "PASS", StringComparison.Ordinal) && + string.Equals(result.Operation, "binaryparity", StringComparison.OrdinalIgnoreCase)); + int semiPassed = Math.Max(0, summary.Passed - binaryParityPassed); + DateTime generatedAt = DateTime.Now; builder.AppendLine("# Volatility Autotest Recap"); builder.AppendLine(); - builder.AppendLine($"Generated (UTC): {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"); - builder.AppendLine($"Games: {string.Join(" | ", gamePaths)}"); - builder.AppendLine($"Passed: {summary.Passed}"); - builder.AppendLine($"Failed: {summary.Failed}"); - builder.AppendLine($"Skipped: {summary.Skipped}"); + builder.AppendLine($"Generated ({GetLocalTimeZoneLabel(generatedAt)}): {generatedAt:yyyy-MM-dd HH:mm:ss}"); + builder.AppendLine($"Games: `{string.Join("` | `", gamePaths)}`"); + builder.AppendLine($"* Failed: {summary.Failed}"); + builder.AppendLine($"* Passed with binary parity: {binaryParityPassed}"); + builder.AppendLine($"* Semi-passed (without binary parity): {semiPassed}"); + builder.AppendLine($"* Skipped: {summary.Skipped}"); + builder.AppendLine(); + + builder.AppendLine("## Test Operation Summary"); + builder.AppendLine(); + + List> byOperation = summary.Cases + .GroupBy(result => result.Operation, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (byOperation.Count == 0) + { + builder.AppendLine("No test operations were recorded."); + } + else + { + builder.AppendLine("| Operation | Passed | Failed | Skipped |"); + builder.AppendLine("| --- | ---: | ---: | ---: |"); + + foreach (IGrouping group in byOperation) + { + int passed = group.Count(result => string.Equals(result.Outcome, "PASS", StringComparison.Ordinal)); + int failed = group.Count(result => string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); + int skipped = group.Count(result => !string.Equals(result.Outcome, "PASS", StringComparison.Ordinal) && !string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); + + builder.AppendLine($"| {group.Key} | {passed} | {failed} | {skipped} |"); + } + } + builder.AppendLine(); List> byResourceType = summary.Cases @@ -393,6 +428,16 @@ private static string ResolveRecapPath(string outputPath) return fullPath; } + private static string GetLocalTimeZoneLabel(DateTime localTime) + { + TimeZoneInfo localTimeZone = TimeZoneInfo.Local; + TimeSpan offset = localTimeZone.GetUtcOffset(localTime); + string sign = offset < TimeSpan.Zero ? "-" : "+"; + TimeSpan absoluteOffset = offset.Duration(); + + return $"UTC{sign}{absoluteOffset:hh\\:mm}"; + } + private static string EscapeMarkdownCell(string value) { return value From e2c9fea9584c1c3c7a49d63aa11c0eb346d9f109 Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Thu, 16 Apr 2026 17:33:47 -0400 Subject: [PATCH 07/17] Merge pull request #8 from Adriwin06/feature/attribsys Add AttribSys support (read only) --- .../AttribSysVault/AttribSysVault.cs | 856 ++++++++++++++++++ Volatility/Resources/ResourceFactory.cs | 6 + 2 files changed, 862 insertions(+) create mode 100644 Volatility/Resources/AttribSysVault/AttribSysVault.cs diff --git a/Volatility/Resources/AttribSysVault/AttribSysVault.cs b/Volatility/Resources/AttribSysVault/AttribSysVault.cs new file mode 100644 index 0000000..60658df --- /dev/null +++ b/Volatility/Resources/AttribSysVault/AttribSysVault.cs @@ -0,0 +1,856 @@ +using System.Numerics; +using System.Text; + +namespace Volatility.Resources; + +// The AttribSysVault resource type wraps VLT and BIN blobs which together define +// attribute collections used for vehicles, engines, surfaces, cameras, and more. +// +// Learn More: +// https://burnout.wiki/wiki/AttribSysVault + +public class AttribSysVault : Resource +{ + public override ResourceType ResourceType => ResourceType.AttribSysVault; + public override Platform ResourcePlatform => Platform.Agnostic; + + public ulong VltDataOffset { get; set; } + public uint VltSizeInBytes { get; set; } + public ulong BinDataOffset { get; set; } + public uint BinSizeInBytes { get; set; } + + public ulong VersionHash { get; set; } + public ulong DepHash1 { get; set; } + public ulong DepHash2 { get; set; } + public int DepNop { get; set; } + public List Dependencies { get; set; } = []; + public long StrUnknown1 { get; set; } + public List> Attributes { get; set; } = []; + public List Strings { get; set; } = []; + public Dictionary PtrN { get; set; } = []; + public string Data { get; set; } = string.Empty; + + private const ulong EntryTypeAttribHash = 0xAD303B8F42B3307E; + private static readonly Dictionary ClassNames = new() + { + { 0x2E3B1DC7D248445E, "physicsvehiclebodyrollattribs" }, + { 0x52B81656F3ADF675, "burnoutcarasset" }, + { 0xF850281CA54C9B92, "physicsvehicleengineattribs" }, + { 0x3F9370FCF8D767AC, "physicsvehicledriftattribs" }, + { 0xDF956BC0568F138C, "physicsvehiclecollisionattribs" }, + { 0x4297B5841F5231CF, "physicsvehiclesuspensionattribs" }, + { 0x43462C59212A23CC, "physicsvehiclesteeringattribs" }, + { 0xE9EDA3B8C4EA3C84, "cameraexternalbehaviour" }, + { 0xF79C545E141DFFA6, "physicsvehiclebaseattribs" }, + { 0xF0FF4DFD660F5A54, "burnoutcargraphicsasset" }, + { 0xF3E3F8EF855F4F99, "camerabumperbehaviour" }, + { 0xEADE7049AF7AB31E, "physicsvehicleboostattribs" }, + { 0x966121397B502EED, "physicsvehiclehandling" }, + { 0x7F161D94482CB3BF, "vehicleengine" }, + }; + + private static readonly Dictionary>> PayloadReaders = new() + { + { 0xF850281CA54C9B92, ReadPhysicsVehicleEngineAttribs }, + { 0x3F9370FCF8D767AC, ReadPhysicsVehicleDriftAttribs }, + { 0xDF956BC0568F138C, ReadPhysicsVehicleCollisionAttribs }, + { 0x4297B5841F5231CF, ReadPhysicsVehicleSuspensionAttribs }, + { 0x43462C59212A23CC, ReadPhysicsVehicleSteeringAttribs }, + { 0x966121397B502EED, ReadPhysicsVehicleHandlingAttribs }, + { 0xEADE7049AF7AB31E, ReadPhysicsVehicleBoostAttribs }, + { 0xF3E3F8EF855F4F99, ReadCameraBumperBehaviourAttribs }, + { 0xE9EDA3B8C4EA3C84, ReadCameraExternalBehaviourAttribs }, + { 0xF0FF4DFD660F5A54, ReadBurnoutCarGraphicsAssetAttribs }, + { 0x52B81656F3ADF675, ReadBurnoutCarAssetAttribs }, + { 0x2E3B1DC7D248445E, ReadPhysicsVehicleBodyRollAttribs }, + { 0xF79C545E141DFFA6, ReadPhysicsVehicleBaseAttribs }, + }; + + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) + { + base.ParseFromStream(reader, endianness); + + Dependencies.Clear(); + Attributes.Clear(); + Strings.Clear(); + PtrN = []; + Data = string.Empty; + + Arch arch = ResourceArch; + if (arch == Arch.x64) + { + VltDataOffset = reader.ReadUInt64(); + VltSizeInBytes = reader.ReadUInt32(); + reader.BaseStream.Seek(0x4, SeekOrigin.Current); + BinDataOffset = reader.ReadUInt64(); + BinSizeInBytes = reader.ReadUInt32(); + } + else + { + VltDataOffset = reader.ReadUInt32(); + VltSizeInBytes = reader.ReadUInt32(); + BinDataOffset = reader.ReadUInt32(); + BinSizeInBytes = reader.ReadUInt32(); + } + + long originalPosition = reader.BaseStream.Position; + + reader.BaseStream.Position = (long)VltDataOffset; + byte[] vltBytes = reader.ReadBytes((int)VltSizeInBytes); + + reader.BaseStream.Position = (long)BinDataOffset; + byte[] binBytes = reader.ReadBytes((int)BinSizeInBytes); + + reader.BaseStream.Position = originalPosition; + + List pendingAttributes = []; + using (EndianAwareBinaryReader vltReader = new(new MemoryStream(vltBytes, writable: false), reader.Endianness)) + { + ParseVlt(vltReader, pendingAttributes); + } + + using (EndianAwareBinaryReader binReader = new(new MemoryStream(binBytes, writable: false), reader.Endianness)) + { + ParseBin(binReader, pendingAttributes); + } + } + + public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) + { + base.WriteToStream(writer, endianness); + throw new NotImplementedException("Writing AttribSysVault is not implemented."); + } + + public AttribSysVault() : base() { } + + public AttribSysVault(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + + private void ParseVlt(EndianAwareBinaryReader reader, List pendingAttributes) + { + while (reader.BaseStream.Position < reader.BaseStream.Length) + { + ReadVltChunk(reader, pendingAttributes); + } + } + + private void ReadVltChunk(EndianAwareBinaryReader reader, List pendingAttributes) + { + long chunkStart = reader.BaseStream.Position; + string fourCc = ReadFourCc(reader); + int size = reader.ReadInt32(); + if (size < 8) + { + throw new InvalidDataException($"Invalid AttribSys chunk size {size} for '{fourCc}'."); + } + + switch (fourCc) + { + case "Vers": + VersionHash = reader.ReadUInt64(); + break; + case "DepN": + ReadDepN(reader); + break; + case "StrN": + StrUnknown1 = reader.ReadInt64(); + break; + case "DatN": + break; + case "ExpN": + ReadExpN(reader, pendingAttributes); + break; + case "PtrN": + ReadPtrN(reader, size); + break; + default: + throw new InvalidDataException($"Unknown AttribSys VLT chunk '{fourCc}'."); + } + + reader.BaseStream.Position = chunkStart + size; + } + + private void ReadDepN(EndianAwareBinaryReader reader) + { + long entryCount = reader.ReadInt64(); + DepHash1 = reader.ReadUInt64(); + DepHash2 = reader.ReadUInt64(); + DepNop = reader.ReadInt32(); + int entrySize = reader.ReadInt32(); + + Dependencies = []; + for (long i = 0; i < entryCount; i++) + { + Dependencies.Add(ReadFixedLengthString(reader, entrySize)); + } + } + + private void ReadExpN(EndianAwareBinaryReader reader, List pendingAttributes) + { + long nestedChunkCount = reader.ReadInt64(); + for (long i = 0; i < nestedChunkCount; i++) + { + AttribChunkInfo info = new() + { + Hash = reader.ReadUInt64(), + EntryTypeHash = reader.ReadUInt64(), + DataChunkSize = reader.ReadInt32(), + DataChunkPosition = reader.ReadInt32(), + }; + + if (info.EntryTypeHash != EntryTypeAttribHash) + { + throw new InvalidDataException($"Unknown AttribSys entry type 0x{info.EntryTypeHash:X16}."); + } + + long position = reader.BaseStream.Position; + reader.BaseStream.Position = info.DataChunkPosition; + AttribAttributeHeader header = ReadAttributeHeader(reader); + pendingAttributes.Add(new PendingAttribute + { + Header = header, + Info = info, + }); + reader.BaseStream.Position = position; + } + } + + private void ReadPtrN(EndianAwareBinaryReader reader, int size) + { + int dataSize = size - 8; + List allData = []; + for (int i = 0; i < dataSize / 16; i++) + { + allData.Add(new AttribPointerChunkData + { + Ptr = reader.ReadUInt32(), + Type = reader.ReadInt16(), + Flag = reader.ReadInt16(), + Data = reader.ReadUInt64(), + }); + } + + PtrN = new Dictionary + { + ["allData"] = allData + }; + } + + private static AttribAttributeHeader ReadAttributeHeader(EndianAwareBinaryReader reader) + { + AttribAttributeHeader header = new() + { + CollectionHash = reader.ReadUInt64(), + ClassHash = reader.ReadUInt64(), + Unknown1 = Convert.ToBase64String(reader.ReadBytes(8)), + ItemCount = reader.ReadInt32(), + Unknown2 = reader.ReadInt32(), + ItemCountDup = reader.ReadInt32(), + ParameterCount = reader.ReadInt16(), + ParametersToRead = reader.ReadInt16(), + Unknown3 = Convert.ToBase64String(reader.ReadBytes(8)), + }; + + header.ClassName = ClassNames.TryGetValue(header.ClassHash, out string? className) + ? className + : $"unknown_{header.ClassHash:X16}".ToLowerInvariant(); + + header.ParameterTypeHashes = new ulong[header.ParameterCount]; + for (int i = 0; i < header.ParameterCount; i++) + { + header.ParameterTypeHashes[i] = reader.ReadUInt64(); + } + + for (int i = 0; i < (header.ParametersToRead - header.ParameterCount); i++) + { + _ = reader.ReadUInt64(); + } + + header.Items = []; + for (int i = 0; i < header.ItemCount; i++) + { + header.Items.Add(new AttribDataItem + { + Hash = reader.ReadUInt64(), + Unknown1 = Convert.ToBase64String(reader.ReadBytes(4)), + ParameterIdx = reader.ReadInt16(), + Unknown2 = reader.ReadInt16(), + }); + } + + return header; + } + + private void ParseBin(EndianAwareBinaryReader reader, List pendingAttributes) + { + long chunkStart = reader.BaseStream.Position; + string fourCc = ReadFourCc(reader); + int size = reader.ReadInt32(); + if (fourCc != "StrE") + { + throw new InvalidDataException($"Expected AttribSys BIN to start with 'StrE', found '{fourCc}'."); + } + + long chunkEnd = chunkStart + size; + Strings = []; + while (reader.BaseStream.Position < chunkEnd) + { + Strings.Add(ReadCString(reader)); + } + + while (Strings.Count > 0 && string.IsNullOrEmpty(Strings[^1])) + { + Strings.RemoveAt(Strings.Count - 1); + } + + reader.BaseStream.Position = chunkEnd; + + Attributes = []; + foreach (PendingAttribute pendingAttribute in pendingAttributes) + { + if (!PayloadReaders.TryGetValue(pendingAttribute.Header.ClassHash, out var payloadReader)) + { + throw new NotSupportedException( + $"AttribSys class 0x{pendingAttribute.Header.ClassHash:X16} ({pendingAttribute.Header.ClassName}) is not implemented."); + } + + Dictionary record = new() + { + ["header"] = pendingAttribute.Header, + ["info"] = pendingAttribute.Info, + }; + + Dictionary payload = payloadReader(reader); + foreach (var kvp in payload) + { + record[kvp.Key] = kvp.Value; + } + + Attributes.Add(record); + } + + long remaining = reader.BaseStream.Length - reader.BaseStream.Position; + Data = remaining > 0 + ? Convert.ToBase64String(reader.ReadBytes((int)remaining)) + : string.Empty; + } + + private static string ReadFourCc(BinaryReader reader) + { + return Encoding.ASCII.GetString(reader.ReadBytes(4)); + } + + private static string ReadFixedLengthString(BinaryReader reader, int size) + { + StringBuilder builder = new(); + bool foundNull = false; + for (int i = 0; i < size; i++) + { + char c = (char)reader.ReadByte(); + if (c == '\0') + { + foundNull = true; + } + + if (!foundNull) + { + builder.Append(c); + } + } + + return builder.ToString(); + } + + private static string ReadCString(BinaryReader reader) + { + List bytes = []; + while (reader.BaseStream.Position < reader.BaseStream.Length) + { + byte value = reader.ReadByte(); + if (value == 0) + { + break; + } + + bytes.Add(value); + } + + return Encoding.UTF8.GetString(bytes.ToArray()); + } + + private static void Align(EndianAwareBinaryReader reader, int alignment) + { + long position = reader.BaseStream.Position; + long remainder = position % alignment; + if (remainder == 0) + { + return; + } + + reader.BaseStream.Position += alignment - remainder; + } + + private static void SkipPadding(EndianAwareBinaryReader reader, int alignment) + { + Align(reader, alignment); + } + + private static Vector4 ReadVector4(EndianAwareBinaryReader reader) + { + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + } + + private static string ReadStringRef(BinaryReader reader) + { + return Convert.ToBase64String(reader.ReadBytes(8)); + } + + private static AttribRefSpec ReadRefSpec(EndianAwareBinaryReader reader) + { + AttribRefSpec value = new() + { + ClassKey = reader.ReadUInt64(), + CollectionKey = reader.ReadUInt64(), + CollectionPtr = reader.ReadUInt32(), + }; + reader.BaseStream.Seek(0x4, SeekOrigin.Current); + return value; + } + + private static Dictionary ReadPhysicsVehicleEngineAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["TorqueScales2"] = ReadVector4(reader), + ["TorqueScales1"] = ReadVector4(reader), + ["GearUpRPMs2"] = ReadVector4(reader), + ["GearUpRPMs1"] = ReadVector4(reader), + ["GearRatios2"] = ReadVector4(reader), + ["GearRatios1"] = ReadVector4(reader), + ["TransmissionEfficiency"] = reader.ReadSingle(), + ["TorqueFallOffRPM"] = reader.ReadSingle(), + ["MaxTorque"] = reader.ReadSingle(), + ["MaxRPM"] = reader.ReadSingle(), + ["LSDMGearUpSpeed"] = reader.ReadSingle(), + ["GearChangeTime"] = reader.ReadSingle(), + ["FlyWheelInertia"] = reader.ReadSingle(), + ["FlyWheelFriction"] = reader.ReadSingle(), + ["EngineResistance"] = reader.ReadSingle(), + ["EngineLowEndTorqueFactor"] = reader.ReadSingle(), + ["EngineBraking"] = reader.ReadSingle(), + ["Differential"] = reader.ReadSingle(), + }; + } + + private static Dictionary ReadPhysicsVehicleDriftAttribs(EndianAwareBinaryReader reader) + { + Dictionary value = new() + { + ["DriftScaleToYawTorque"] = ReadVector4(reader), + ["WheelSlip"] = reader.ReadSingle(), + ["TimeToCapScale"] = reader.ReadSingle(), + ["TimeForNaturalDrift"] = reader.ReadSingle(), + ["SteeringDriftScaleFactor"] = reader.ReadSingle(), + ["SideForcePeakDriftAngle"] = reader.ReadSingle(), + ["SideForceMagnitude"] = reader.ReadSingle(), + ["SideForceDriftSpeedCutOff"] = reader.ReadSingle(), + ["SideForceDriftAngleCutOff"] = reader.ReadSingle(), + ["SideForceDirftScaleCutOff"] = reader.ReadSingle(), + ["NeutralTimeToReduceDrift"] = reader.ReadSingle(), + ["NaturalYawTorqueCutOffAngle"] = reader.ReadSingle(), + ["NaturalYawTorque"] = reader.ReadSingle(), + ["NaturalDriftTimeToReachBaseSlip"] = reader.ReadSingle(), + ["NaturalDriftStartSlip"] = reader.ReadSingle(), + ["NaturalDriftScaleDecay"] = reader.ReadSingle(), + ["MinSpeedForDrift"] = reader.ReadSingle(), + ["InitialDriftPushTime"] = reader.ReadSingle(), + ["InitialDriftPushScaleLimit"] = reader.ReadSingle(), + ["InitialDriftPushDynamicInc"] = reader.ReadSingle(), + ["InitialDriftPushBaseInc"] = reader.ReadSingle(), + ["GripFromSteering"] = reader.ReadSingle(), + ["GripFromGasLetOff"] = reader.ReadSingle(), + ["GripFromBrake"] = reader.ReadSingle(), + ["GasDriftScaleFactor"] = reader.ReadSingle(), + ["ForcedDriftTimeToReachBaseSlip"] = reader.ReadSingle(), + ["ForcedDriftStartSlip"] = reader.ReadSingle(), + ["DriftTorqueFallOff"] = reader.ReadSingle(), + ["DriftSidewaysDamping"] = reader.ReadSingle(), + ["DriftMaxAngle"] = reader.ReadSingle(), + ["DriftAngularDamping"] = reader.ReadSingle(), + ["CounterSteeringDriftScaleFactor"] = reader.ReadSingle(), + ["CappedScale"] = reader.ReadSingle(), + ["BrakingDriftScaleFactor"] = reader.ReadSingle(), + ["BaseCounterSteeringDriftScaleFactor"] = reader.ReadSingle(), + }; + SkipPadding(reader, 0x10); + return value; + } + + private static Dictionary ReadPhysicsVehicleCollisionAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["BodyBox"] = ReadVector4(reader), + }; + } + + private static Dictionary ReadPhysicsVehicleSuspensionAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["UpwardMovement"] = reader.ReadSingle(), + ["TimeToDampAfterLanding"] = reader.ReadSingle(), + ["Strength"] = reader.ReadSingle(), + ["SpringLength"] = reader.ReadSingle(), + ["RearHeight"] = reader.ReadSingle(), + ["MaxYawDampingOnLanding"] = reader.ReadSingle(), + ["MaxVertVelocityDampingOnLanding"] = reader.ReadSingle(), + ["MaxRollDampingOnLanding"] = reader.ReadSingle(), + ["MaxPitchDampingOnLanding"] = reader.ReadSingle(), + ["InAirDamping"] = reader.ReadSingle(), + ["FrontHeight"] = reader.ReadSingle(), + ["DownwardMovement"] = reader.ReadSingle(), + ["Dampening"] = reader.ReadSingle(), + }; + } + + private static Dictionary ReadPhysicsVehicleSteeringAttribs(EndianAwareBinaryReader reader) + { + Dictionary value = new() + { + ["TimeForLock"] = reader.ReadSingle(), + ["StraightReactionBias"] = reader.ReadSingle(), + ["SpeedForMinAngle"] = reader.ReadSingle(), + ["SpeedForMaxAngle"] = reader.ReadSingle(), + ["MinAngle"] = reader.ReadSingle(), + ["MaxAngle"] = reader.ReadSingle(), + ["AiPidCoefficientP"] = reader.ReadSingle(), + ["AiPidCoefficientI"] = reader.ReadSingle(), + ["AiPidCoefficientDriftP"] = reader.ReadSingle(), + ["AiPidCoefficientDriftI"] = reader.ReadSingle(), + ["AiPidCoefficientDriftD"] = reader.ReadSingle(), + ["AiPidCoefficientD"] = reader.ReadSingle(), + ["AiMinLookAheadDistanceForDrift"] = reader.ReadSingle(), + ["AiLookAheadTimeForDrift"] = reader.ReadSingle(), + }; + SkipPadding(reader, 0x10); + return value; + } + + private static Dictionary ReadPhysicsVehicleHandlingAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["PhysicsVehicleSuspensionAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleSteeringAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleEngineAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleDriftAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleCollisionAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleBoostAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleBodyRollAttribs"] = ReadRefSpec(reader), + ["PhysicsVehicleBaseAttribs"] = ReadRefSpec(reader), + }; + } + + private static Dictionary ReadPhysicsVehicleBoostAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["MaxBoostSpeed"] = reader.ReadSingle(), + ["BoostRule"] = reader.ReadInt32(), + ["BoostKickTime"] = reader.ReadSingle(), + ["BoostKickMinTime"] = reader.ReadSingle(), + ["BoostKickMaxTime"] = reader.ReadSingle(), + ["BoostKickMaxStartSpeed"] = reader.ReadSingle(), + ["BoostKickHeightOffset"] = reader.ReadSingle(), + ["BoostKickAcceleration"] = reader.ReadSingle(), + ["BoostKick"] = reader.ReadSingle(), + ["BoostHeightOffset"] = reader.ReadSingle(), + ["BoostBase"] = reader.ReadSingle(), + ["BoostAcceleration"] = reader.ReadSingle(), + ["BlueMaxBoostSpeed"] = reader.ReadSingle(), + ["BlueBoostKickTime"] = reader.ReadSingle(), + ["BlueBoostKick"] = reader.ReadSingle(), + ["BlueBoostBase"] = reader.ReadSingle(), + }; + } + + private static Dictionary ReadCameraBumperBehaviourAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["ZOffset"] = reader.ReadSingle(), + ["YOffset"] = reader.ReadSingle(), + ["YawSpring"] = reader.ReadSingle(), + ["RollSpring"] = reader.ReadSingle(), + ["PitchSpring"] = reader.ReadSingle(), + ["FieldOfView"] = reader.ReadSingle(), + ["BoostFieldOfView"] = reader.ReadSingle(), + ["BodyRollScale"] = reader.ReadSingle(), + ["BodyPitchScale"] = reader.ReadSingle(), + ["AccelerationResponse"] = reader.ReadSingle(), + ["AccelerationDampening"] = reader.ReadSingle(), + }; + } + + private static Dictionary ReadCameraExternalBehaviourAttribs(EndianAwareBinaryReader reader) + { + return new Dictionary + { + ["ZDistanceScale"] = reader.ReadSingle(), + ["ZAndTiltCutoffSpeedMPH"] = reader.ReadSingle(), + ["YawSpring"] = reader.ReadSingle(), + ["TiltCameraScale"] = reader.ReadSingle(), + ["TiltAroundCar"] = reader.ReadSingle(), + ["SlideZOffsetMax"] = reader.ReadSingle(), + ["SlideYScale"] = reader.ReadSingle(), + ["SlideXScale"] = reader.ReadSingle(), + ["PivotZOffset"] = reader.ReadSingle(), + ["PivotLength"] = reader.ReadSingle(), + ["PivotHeight"] = reader.ReadSingle(), + ["PitchSpring"] = reader.ReadSingle(), + ["FieldOfView"] = reader.ReadSingle(), + ["DriftYawSpring"] = reader.ReadSingle(), + ["DownAngle"] = reader.ReadSingle(), + ["BoostFieldOfViewZoom"] = reader.ReadSingle(), + ["BoostFieldOfView"] = reader.ReadSingle(), + }; + } + + private static Dictionary ReadBurnoutCarGraphicsAssetAttribs(EndianAwareBinaryReader reader) + { + int playerPalletteIndex = reader.ReadInt32(); + int playerColourIndex = reader.ReadInt32(); + short alloc = reader.ReadInt16(); + short numRandomTrafficColours = reader.ReadInt16(); + short size = reader.ReadInt16(); + short encodedTypePad = reader.ReadInt16(); + + List randomTrafficColours = []; + for (int i = 0; i < numRandomTrafficColours; i++) + { + randomTrafficColours.Add(reader.ReadInt32()); + } + + for (int i = numRandomTrafficColours; i < alloc; i++) + { + reader.BaseStream.Seek(0x4, SeekOrigin.Current); + } + + return new Dictionary + { + ["PlayerPalletteIndex"] = playerPalletteIndex, + ["PlayerColourIndex"] = playerColourIndex, + ["Alloc"] = alloc, + ["Num_RandomTrafficColours"] = numRandomTrafficColours, + ["Size"] = size, + ["EncodedTypePad"] = encodedTypePad, + ["RandomTrafficColours"] = randomTrafficColours, + ["Alloc_Offences"] = reader.ReadInt16(), + ["Num_Offences"] = reader.ReadInt16(), + ["Size_Offences"] = reader.ReadInt16(), + ["EncodedTypePad_Offences"] = reader.ReadInt16(), + }; + } + + private static Dictionary ReadPhysicsVehicleBodyRollAttribs(EndianAwareBinaryReader reader) + { + Dictionary value = new() + { + ["WheelLongForceHeightOffset"] = reader.ReadSingle(), + ["WheelLatForceHeightOffset"] = reader.ReadSingle(), + ["WeightTransferDecayZ"] = reader.ReadSingle(), + ["WeightTransferDecayX"] = reader.ReadSingle(), + ["RollSpringStiffness"] = reader.ReadSingle(), + ["RollSpringDampening"] = reader.ReadSingle(), + ["PitchSpringStiffness"] = reader.ReadSingle(), + ["PitchSpringDampening"] = reader.ReadSingle(), + ["FactorOfWeightZ"] = reader.ReadSingle(), + ["FactorOfWeightX"] = reader.ReadSingle(), + }; + reader.BaseStream.Seek(0x4, SeekOrigin.Current); + return value; + } + + private static Dictionary ReadPhysicsVehicleBaseAttribs(EndianAwareBinaryReader reader) + { + int start = (int)reader.BaseStream.Position; + Align(reader, 0x10); + int paddingLength = (int)reader.BaseStream.Position - start; + + return new Dictionary + { + ["RearRightWheelPosition"] = ReadVector4(reader), + ["FrontRightWheelPosition"] = ReadVector4(reader), + ["CoMOffset"] = ReadVector4(reader), + ["BrakeScaleToFactor"] = ReadVector4(reader), + ["YawDampingOnTakeOff"] = reader.ReadSingle(), + ["TractionLineLength"] = reader.ReadSingle(), + ["TimeForFullBrake"] = reader.ReadSingle(), + ["SurfaceRoughnessFactor"] = reader.ReadSingle(), + ["SurfaceRearGripFactor"] = reader.ReadSingle(), + ["SurfaceFrontGripFactor"] = reader.ReadSingle(), + ["SurfaceDragFactor"] = reader.ReadSingle(), + ["RollLimitOnTakeOff"] = reader.ReadSingle(), + ["RollDampingOnTakeOff"] = reader.ReadSingle(), + ["RearWheelMass"] = reader.ReadSingle(), + ["RearTireStaticFrictionCoefficient"] = reader.ReadSingle(), + ["RearTireLongForceBias"] = reader.ReadSingle(), + ["RearTireDynamicFrictionCoefficient"] = reader.ReadSingle(), + ["RearTireAdhesiveLimit"] = reader.ReadSingle(), + ["RearLongGripCurvePeakSlipRatio"] = reader.ReadSingle(), + ["RearLongGripCurvePeakCoefficient"] = reader.ReadSingle(), + ["RearLongGripCurveFloorSlipRatio"] = reader.ReadSingle(), + ["RearLongGripCurveFallCoefficient"] = reader.ReadSingle(), + ["RearLatGripCurvePeakSlipRatio"] = reader.ReadSingle(), + ["RearLatGripCurvePeakCoefficient"] = reader.ReadSingle(), + ["RearLatGripCurveFloorSlipRatio"] = reader.ReadSingle(), + ["RearLatGripCurveFallCoefficient"] = reader.ReadSingle(), + ["RearLatGripCurveDriftPeakSlipRatio"] = reader.ReadSingle(), + ["PowerToRear"] = reader.ReadSingle(), + ["PowerToFront"] = reader.ReadSingle(), + ["PitchDampingOnTakeOff"] = reader.ReadSingle(), + ["MaxSpeed"] = reader.ReadSingle(), + ["MagicBrakeFactorTurning"] = reader.ReadSingle(), + ["MagicBrakeFactorStraightLine"] = reader.ReadSingle(), + ["LowSpeedTyreFrictionTractionControl"] = reader.ReadSingle(), + ["LowSpeedThrottleTractionControl"] = reader.ReadSingle(), + ["LowSpeedDrivingSpeed"] = reader.ReadSingle(), + ["LockBrakeScale"] = reader.ReadSingle(), + ["LinearDrag"] = reader.ReadSingle(), + ["HighSpeedAngularDamping"] = reader.ReadSingle(), + ["FrontWheelMass"] = reader.ReadSingle(), + ["FrontTireStaticFrictionCoefficient"] = reader.ReadSingle(), + ["FrontTireLongForceBias"] = reader.ReadSingle(), + ["FrontTireDynamicFrictionCoefficient"] = reader.ReadSingle(), + ["FrontTireAdhesiveLimit"] = reader.ReadSingle(), + ["FrontLongGripCurvePeakSlipRatio"] = reader.ReadSingle(), + ["FrontLongGripCurvePeakCoefficient"] = reader.ReadSingle(), + ["FrontLongGripCurveFloorSlipRatio"] = reader.ReadSingle(), + ["FrontLongGripCurveFallCoefficient"] = reader.ReadSingle(), + ["FrontLatGripCurvePeakSlipRatio"] = reader.ReadSingle(), + ["FrontLatGripCurvePeakCoefficient"] = reader.ReadSingle(), + ["FrontLatGripCurveFloorSlipRatio"] = reader.ReadSingle(), + ["FrontLatGripCurveFallCoefficient"] = reader.ReadSingle(), + ["FrontLatGripCurveDriftPeakSlipRatio"] = reader.ReadSingle(), + ["DrivingMass"] = reader.ReadSingle(), + ["DriveTimeDeformLimitX"] = reader.ReadSingle(), + ["DriveTimeDeformLimitPosZ"] = reader.ReadSingle(), + ["DriveTimeDeformLimitNegZ"] = reader.ReadSingle(), + ["DriveTimeDeformLimitNegY"] = reader.ReadSingle(), + ["DownForceZOffset"] = reader.ReadSingle(), + ["DownForce"] = reader.ReadSingle(), + ["CrashExtraYawVelocityFactor"] = reader.ReadSingle(), + ["CrashExtraRollVelocityFactor"] = reader.ReadSingle(), + ["CrashExtraPitchVelocityFactor"] = reader.ReadSingle(), + ["CrashExtraLinearVelocityFactor"] = reader.ReadSingle(), + ["AngularDrag"] = reader.ReadSingle(), + ["PaddingLength"] = paddingLength, + }; + } + + private static Dictionary ReadBurnoutCarAssetAttribs(EndianAwareBinaryReader reader) + { + List offences = []; + for (int i = 0; i < 12; i++) + { + offences.Add(ReadRefSpec(reader)); + } + + AttribRefSpec soundExhaustAsset = ReadRefSpec(reader); + AttribRefSpec soundEngineAsset = ReadRefSpec(reader); + AttribRefSpec physicsVehicleHandlingAsset = ReadRefSpec(reader); + AttribRefSpec graphicsAsset = ReadRefSpec(reader); + AttribRefSpec carUnlockShot = ReadRefSpec(reader); + AttribRefSpec cameraExternalBehaviourAsset = ReadRefSpec(reader); + AttribRefSpec cameraBumperBehaviourAsset = ReadRefSpec(reader); + + string vehicleId = ReadStringRef(reader); + long physicsAsset = reader.ReadInt64(); + long masterSceneMayaBinaryFile = reader.ReadInt64(); + string inGameName = ReadStringRef(reader); + long gameplayAsset = reader.ReadInt64(); + string exhaustName = ReadStringRef(reader); + long exhaustEntityKey = reader.ReadInt64(); + string engineName = ReadStringRef(reader); + long engineEntityKey = reader.ReadInt64(); + long defaultWheel = reader.ReadInt64(); + bool buildThisVehicle = reader.ReadBoolean(); + reader.BaseStream.Seek(0x3, SeekOrigin.Current); + + return new Dictionary + { + ["VehicleID"] = vehicleId, + ["InGameName"] = inGameName, + ["ExhaustName"] = exhaustName, + ["EngineName"] = engineName, + ["Offences"] = offences, + ["SoundExhaustAsset"] = soundExhaustAsset, + ["SoundEngineAsset"] = soundEngineAsset, + ["PhysicsVehicleHandlingAsset"] = physicsVehicleHandlingAsset, + ["GraphicsAsset"] = graphicsAsset, + ["CarUnlockShot"] = carUnlockShot, + ["CameraExternalBehaviourAsset"] = cameraExternalBehaviourAsset, + ["CameraBumperBehaviourAsset"] = cameraBumperBehaviourAsset, + ["PhysicsAsset"] = physicsAsset, + ["MasterSceneMayaBinaryFile"] = masterSceneMayaBinaryFile, + ["GameplayAsset"] = gameplayAsset, + ["ExhaustEntityKey"] = exhaustEntityKey, + ["EngineEntityKey"] = engineEntityKey, + ["DefaultWheel"] = defaultWheel, + ["BuildThisVehicle"] = buildThisVehicle, + }; + } + + private sealed class PendingAttribute + { + public required AttribAttributeHeader Header { get; init; } + public required AttribChunkInfo Info { get; init; } + } +} + +public sealed class AttribChunkInfo +{ + public ulong Hash { get; set; } + public ulong EntryTypeHash { get; set; } + public int DataChunkSize { get; set; } + public int DataChunkPosition { get; set; } +} + +public sealed class AttribAttributeHeader +{ + public string ClassName { get; set; } = string.Empty; + public ulong CollectionHash { get; set; } + public ulong ClassHash { get; set; } + public string Unknown1 { get; set; } = string.Empty; + public int ItemCount { get; set; } + public int Unknown2 { get; set; } + public int ItemCountDup { get; set; } + public short ParameterCount { get; set; } + public short ParametersToRead { get; set; } + public string Unknown3 { get; set; } = string.Empty; + public ulong[] ParameterTypeHashes { get; set; } = []; + public List Items { get; set; } = []; +} + +public sealed class AttribDataItem +{ + public ulong Hash { get; set; } + public string Unknown1 { get; set; } = string.Empty; + public short ParameterIdx { get; set; } + public short Unknown2 { get; set; } +} + +public sealed class AttribPointerChunkData +{ + public uint Ptr { get; set; } + public short Type { get; set; } + public short Flag { get; set; } + public ulong Data { get; set; } +} + +public sealed class AttribRefSpec +{ + public ulong ClassKey { get; set; } + public ulong CollectionKey { get; set; } + public uint CollectionPtr { get; set; } +} diff --git a/Volatility/Resources/ResourceFactory.cs b/Volatility/Resources/ResourceFactory.cs index f183926..a0ec106 100644 --- a/Volatility/Resources/ResourceFactory.cs +++ b/Volatility/Resources/ResourceFactory.cs @@ -69,6 +69,12 @@ public static class ResourceFactory { (ResourceType.SnapshotData, Platform.X360), path => new SnapshotData(path, Endian.BE) }, { (ResourceType.SnapshotData, Platform.PS3), path => new SnapshotData(path, Endian.BE) }, + // AttribSysVault resources + { (ResourceType.AttribSysVault, Platform.BPR), path => new AttribSysVault(path, Endian.LE) }, + { (ResourceType.AttribSysVault, Platform.TUB), path => new AttribSysVault(path, Endian.LE) }, + { (ResourceType.AttribSysVault, Platform.X360), path => new AttribSysVault(path, Endian.BE) }, + { (ResourceType.AttribSysVault, Platform.PS3), path => new AttribSysVault(path, Endian.BE) }, + // StreamedDeformationSpec resources { (ResourceType.StreamedDeformationSpec, Platform.BPR), path => new StreamedDeformationSpec(path, Endian.LE) }, { (ResourceType.StreamedDeformationSpec, Platform.TUB), path => new StreamedDeformationSpec(path, Endian.LE) }, From a9e6143bcd1ab9004ccc773c77405ee2be8390ee Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:04:57 +0200 Subject: [PATCH 08/17] Continue rework + fixes so all ressources pass the auto test --- .../Resources/ExportResourceOperation.cs | 36 +++- Volatility/Resources/AptData/AptData.cs | 16 +- .../AttribSysVault/AttribSysVault.cs | 10 +- Volatility/Resources/BinaryResource.cs | 33 +++- .../EnvironmentKeyframe.cs | 11 +- .../EnvironmentTimeLine.cs | 9 +- Volatility/Resources/GuiPopup/GuiPopup.cs | 110 ++++++++--- .../Resources/InstanceList/InstanceList.cs | 107 ++++++----- Volatility/Resources/Model/Model.cs | 43 +++-- .../Resources/Renderable/RenderableBase.cs | 9 +- Volatility/Resources/Resource.cs | 7 +- Volatility/Resources/ResourceFactory.cs | 170 ++++++++--------- Volatility/Resources/Shader/ShaderBase.cs | 22 +-- .../ShaderProgramBufferBase.cs | 20 +- .../Resources/SnapshotData/SnapshotData.cs | 8 +- Volatility/Resources/Splicer/Splicer.cs | 7 +- .../StreamedDeformationSpec.cs | 9 +- Volatility/Resources/Texture/TextureBase.cs | 11 +- Volatility/Resources/Texture/TexturePC.cs | 20 ++ Volatility/Resources/Texture/TextureX360.cs | 35 +++- Volatility/Resources/TypedResource.cs | 19 ++ autotest_recap.md | 174 ++++++++++++++++++ 22 files changed, 592 insertions(+), 294 deletions(-) create mode 100644 Volatility/Resources/TypedResource.cs create mode 100644 autotest_recap.md diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index 4d2df09..d148b40 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -1,6 +1,5 @@ using Volatility.Resources; using Volatility.Utilities; - namespace Volatility.Operations.Resources; internal class ExportResourceOperation @@ -32,6 +31,8 @@ public Task ExecuteAsync(Resource resource, string outputPath, Platform platform break; } + WriteExternalImportsYaml(resource, outputPath); + if (resource is ShaderBase shader) { var stages = shader.GetCompileStages(); @@ -70,6 +71,39 @@ public Task ExecuteAsync(Resource resource, string outputPath, Platform platform return Task.CompletedTask; } + private static void WriteExternalImportsYaml(Resource resource, string outputPath) + { + List> imports = resource switch + { + Model model => model.GetExternalImports().ToList(), + InstanceList instanceList => instanceList.GetExternalImports().ToList(), + _ => [] + }; + + string importsPath = Path.Combine( + Path.GetDirectoryName(outputPath) ?? string.Empty, + Path.GetFileNameWithoutExtension(outputPath) + "_imports.yaml"); + + if (imports.Count == 0) + { + if (File.Exists(importsPath)) + { + File.Delete(importsPath); + } + + return; + } + + List lines = new(imports.Count); + foreach (KeyValuePair entry in imports) + { + ulong resourceId = ResourceUtilities.ResolveResourceID(entry.Value); + lines.Add($"- \"0x{entry.Key:x8}\": \"{resourceId:X8}\""); + } + + File.WriteAllLines(importsPath, lines); + } + private static string GetShaderProgramBufferPath( string shaderOutputPath, ShaderStageCompile stage, diff --git a/Volatility/Resources/AptData/AptData.cs b/Volatility/Resources/AptData/AptData.cs index 90cabb8..a1d29c7 100644 --- a/Volatility/Resources/AptData/AptData.cs +++ b/Volatility/Resources/AptData/AptData.cs @@ -2,19 +2,12 @@ namespace Volatility.Resources; -public class AptData : Resource +public class AptData : TypedResource { - public override ResourceType ResourceType => ResourceType.AptData; - public override Platform ResourcePlatform => Platform.Agnostic; - public string MovieName; public string BaseComponentName; public GuiGeometryObject GuiGeometry; - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) - { - base.WriteToStream(writer); - } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); @@ -103,9 +96,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann }; } - public AptData() : base() { } + public AptData() : base(ResourceType.AptData) { } - public AptData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public AptData(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.AptData, path, endianness) { } } public struct GuiGeometryObject @@ -146,4 +140,4 @@ public enum ETextureType : int Vector = 0, TexturedClamp = 1, TexturedWrap = 2 -} \ No newline at end of file +} diff --git a/Volatility/Resources/AttribSysVault/AttribSysVault.cs b/Volatility/Resources/AttribSysVault/AttribSysVault.cs index 60658df..bccbfb3 100644 --- a/Volatility/Resources/AttribSysVault/AttribSysVault.cs +++ b/Volatility/Resources/AttribSysVault/AttribSysVault.cs @@ -9,11 +9,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/AttribSysVault -public class AttribSysVault : Resource +public class AttribSysVault : TypedResource { - public override ResourceType ResourceType => ResourceType.AttribSysVault; - public override Platform ResourcePlatform => Platform.Agnostic; - public ulong VltDataOffset { get; set; } public uint VltSizeInBytes { get; set; } public ulong BinDataOffset { get; set; } @@ -121,9 +118,10 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes throw new NotImplementedException("Writing AttribSysVault is not implemented."); } - public AttribSysVault() : base() { } + public AttribSysVault() : base(ResourceType.AttribSysVault) { } - public AttribSysVault(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public AttribSysVault(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.AttribSysVault, path, endianness) { } private void ParseVlt(EndianAwareBinaryReader reader, List pendingAttributes) { diff --git a/Volatility/Resources/BinaryResource.cs b/Volatility/Resources/BinaryResource.cs index 5ad4046..35351d8 100644 --- a/Volatility/Resources/BinaryResource.cs +++ b/Volatility/Resources/BinaryResource.cs @@ -7,25 +7,46 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Binary_File -public class BinaryResource : Resource +public class BinaryResource : TypedResource { - public override ResourceType ResourceType => ResourceType.BinaryFile; - public uint DataSize { get; set; } public uint DataOffset { get; set; } public BinaryResource(uint dataOffset, uint dataSize) + : this(ResourceType.BinaryFile, dataOffset, dataSize) + { + } + + protected BinaryResource(ResourceType resourceType, uint dataOffset, uint dataSize) + : base(resourceType) { DataSize = dataSize; DataOffset = dataOffset == 0 ? 0x10u : dataOffset; } - public BinaryResource() : base() + public BinaryResource() : this(ResourceType.BinaryFile) + { + } + + protected BinaryResource(ResourceType resourceType) + : base(resourceType) { DataOffset = 0x10; } - - public BinaryResource(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + + public BinaryResource(string path, Endian endianness = Endian.Agnostic) + : this(ResourceType.BinaryFile, path, endianness) + { + } + + protected BinaryResource(ResourceType resourceType, string path, Endian endianness = Endian.Agnostic) + : base(resourceType, path, endianness) + { + if (DataOffset == 0) + { + DataOffset = 0x10; + } + } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs index 1b8bf42..510f973 100644 --- a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs +++ b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs @@ -7,10 +7,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Environment_Keyframe -public class EnvironmentKeyframe : Resource +public class EnvironmentKeyframe : TypedResource { - public override ResourceType ResourceType => ResourceType.EnvironmentKeyframe; - public BloomData BloomSettings; public VignetteData VignetteSettings; public TintData TintSettings; @@ -18,9 +16,10 @@ public class EnvironmentKeyframe : Resource public LightingData LightingSettings; public CloudsData CloudSettings; - public EnvironmentKeyframe() : base() { } + public EnvironmentKeyframe() : base(ResourceType.EnvironmentKeyframe) { } - public EnvironmentKeyframe(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public EnvironmentKeyframe(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.EnvironmentKeyframe, path, endianness) { } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { @@ -293,4 +292,4 @@ public static void Write(ResourceBinaryWriter writer, CloudsData value) writer.Write(value.DirectionAngle); writer.Write(new byte[0x4]); } -} \ No newline at end of file +} diff --git a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs index 86f1674..beb3293 100644 --- a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs +++ b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs @@ -2,7 +2,7 @@ namespace Volatility.Resources; -public class EnvironmentTimeline : Resource +public class EnvironmentTimeline : TypedResource { private const int HeaderSize = 0x10; private const int SectionAlignment = 0x10; @@ -10,8 +10,6 @@ public class EnvironmentTimeline : Resource private const int KeyframeReferencePlaceholderSize = sizeof(uint); private const int ImportEntrySize = 0x10; - public override ResourceType ResourceType => ResourceType.EnvironmentTimeLine; - public LocationData[] Locations = []; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) @@ -108,9 +106,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann Locations = reader.ParseSection((long)locationsPtr, locationCount, r => ReadLocation(r, arch)).ToArray(); } - public EnvironmentTimeline() : base() { } + public EnvironmentTimeline() : base(ResourceType.EnvironmentTimeLine) { } - public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.EnvironmentTimeLine, path, endianness) { } private static int GetLocationStructSize(Arch arch) { diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/Volatility/Resources/GuiPopup/GuiPopup.cs index 329e521..174f52b 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/Volatility/Resources/GuiPopup/GuiPopup.cs @@ -2,25 +2,41 @@ namespace Volatility.Resources; -public class GuiPopup : Resource +public class GuiPopup : TypedResource { - private const int HeaderSize = 0x8; + private const int HeaderSize = 0x40; private const int PopupStructSize = 0xC0; + private const int PopupOffsetEntrySize = sizeof(uint); + private const int HeaderAlignment = 0x40; - public List Popups { get; } = []; - - public override ResourceType ResourceType => ResourceType.GuiPopup; - public override Platform ResourcePlatform => Platform.Agnostic; + public List Popups { get; set; } = []; public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) { base.WriteToStream(writer, endianness); - long start = writer.BaseStream.Position; - writer.Write((uint)HeaderSize); + uint count = (uint)Popups.Count; + uint popupOffsetsStart = HeaderSize; + uint firstPopupOffset = AlignOffset(popupOffsetsStart + (count * PopupOffsetEntrySize), HeaderAlignment); + uint totalSize = firstPopupOffset + (count * PopupStructSize); + + writer.Write(popupOffsetsStart); writer.Write((short)Popups.Count); - writer.Write((short)PopupStructSize); - writer.WriteSection(start + HeaderSize, Popups, Popup.Write); + writer.Write((short)totalSize); + writer.Write(new byte[HeaderSize - 0x8]); + + for (int i = 0; i < Popups.Count; i++) + { + writer.Write(firstPopupOffset + (uint)(i * PopupStructSize)); + } + + PaddingUtilities.WritePadding(writer.BaseStream, HeaderAlignment); + + for (int i = 0; i < Popups.Count; i++) + { + writer.BaseStream.Position = firstPopupOffset + (i * PopupStructSize); + Popup.Write(writer, Popups[i]); + } } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) @@ -29,23 +45,58 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann Popups.Clear(); - long start = reader.BaseStream.Position; uint dataPtr = reader.ReadUInt32(); short count = reader.ReadInt16(); - short elemSize = reader.ReadInt16(); + short totalSize = reader.ReadInt16(); - if (elemSize != PopupStructSize) + if (dataPtr < HeaderSize) { throw new InvalidDataException( - $"GuiPopup element size mismatch! Expected 0x{PopupStructSize:X}, found 0x{elemSize:X}."); + $"GuiPopup data pointer mismatch! Expected >= 0x{HeaderSize:X}, found 0x{dataPtr:X}."); } - reader.ParseSection(start + dataPtr, count, Popup.Read, Popups); + long expectedMinimumSize = dataPtr + (count * PopupOffsetEntrySize); + if (reader.BaseStream.Length < expectedMinimumSize) + { + throw new InvalidDataException( + $"GuiPopup offset table exceeds file length. Needed 0x{expectedMinimumSize:X}, found 0x{reader.BaseStream.Length:X}."); + } + + List popupOffsets = reader.ParseSection(dataPtr, count, r => r.ReadUInt32()); + for (int i = 0; i < popupOffsets.Count; i++) + { + uint popupOffset = popupOffsets[i]; + if (popupOffset == 0) + { + continue; + } + + if (popupOffset + PopupStructSize > reader.BaseStream.Length) + { + throw new InvalidDataException( + $"GuiPopup entry {i} at 0x{popupOffset:X} exceeds file length 0x{reader.BaseStream.Length:X}."); + } + + reader.ParseSection(popupOffset, Popup.Read, out Popup popup); + Popups.Add(popup); + } + + if (totalSize > 0 && totalSize != reader.BaseStream.Length) + { + Console.WriteLine($"WARNING: GuiPopup reported size 0x{totalSize:X}, actual size 0x{reader.BaseStream.Length:X}."); + } } - public GuiPopup() : base() { } + public GuiPopup() : base(ResourceType.GuiPopup) { } + + public GuiPopup(string path, Endian endianness) + : base(ResourceType.GuiPopup, path, endianness) { } - public GuiPopup(string path, Endian endianness) : base(path, endianness) { } + private static uint AlignOffset(uint value, uint alignment) + { + uint remainder = value % alignment; + return remainder == 0 ? value : value + (alignment - remainder); + } public enum PopupStyle : int { @@ -100,16 +151,25 @@ public struct Popup public string Button2Id; public PopupParamTypes Button2Param; public bool Button2ParamUsed; + [EditorHidden] + public byte[] NamePaddingBytes; + [EditorHidden] + public byte[] Button2PaddingBytes; + [EditorHidden] + public byte[] TrailingBytes; public static Popup Read(ResourceBinaryReader reader) { Popup popup = new() { NameId = reader.ReadUInt64(), - Name = ResourceUtilities.ReadFixedString(reader, 13) + Name = ResourceUtilities.ReadFixedString(reader, 13), + NamePaddingBytes = new byte[0x3], + Button2PaddingBytes = new byte[0x3], + TrailingBytes = new byte[0x7] }; - reader.BaseStream.Seek(0x3, SeekOrigin.Current); + popup.NamePaddingBytes = reader.ReadBytes(0x3); popup.Style = (PopupStyle)reader.ReadInt32(); popup.Icon = (PopupIcons)reader.ReadInt32(); @@ -122,13 +182,11 @@ public static Popup Read(ResourceBinaryReader reader) popup.Button1Param = (PopupParamTypes)reader.ReadInt32(); popup.Button1ParamUsed = reader.ReadByte() != 0; popup.Button2Id = ResourceUtilities.ReadFixedString(reader, 32); - - reader.BaseStream.Seek(0x3, SeekOrigin.Current); + popup.Button2PaddingBytes = reader.ReadBytes(0x3); popup.Button2Param = (PopupParamTypes)reader.ReadInt32(); popup.Button2ParamUsed = reader.ReadByte() != 0; - - reader.BaseStream.Seek(0x7, SeekOrigin.Current); + popup.TrailingBytes = reader.ReadBytes(0x7); return popup; } @@ -137,7 +195,7 @@ public static void Write(ResourceBinaryWriter writer, Popup popup) { writer.Write(popup.NameId); ResourceUtilities.WriteFixedString(writer, popup.Name, 13); - writer.Write(new byte[0x3]); + writer.WriteFixedBytes(popup.NamePaddingBytes, 0x3); writer.Write((int)popup.Style); writer.Write((int)popup.Icon); ResourceUtilities.WriteFixedString(writer, popup.TitleId, 32); @@ -149,10 +207,10 @@ public static void Write(ResourceBinaryWriter writer, Popup popup) writer.Write((int)popup.Button1Param); writer.Write((byte)(popup.Button1ParamUsed ? 1 : 0)); ResourceUtilities.WriteFixedString(writer, popup.Button2Id, 32); - writer.Write(new byte[0x3]); + writer.WriteFixedBytes(popup.Button2PaddingBytes, 0x3); writer.Write((int)popup.Button2Param); writer.Write((byte)(popup.Button2ParamUsed ? 1 : 0)); - writer.Write(new byte[0x7]); + writer.WriteFixedBytes(popup.TrailingBytes, 0x7); } } } diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index 6d50a8b..028b157 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -11,15 +11,10 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Instance_List -public class InstanceList : Resource +public class InstanceList : TypedResource { private const int HeaderSize = 0x10; private const int SectionAlignment = 0x10; - private const int ImportEntrySize = 0x10; - private const int ResourceIdEntrySize = 0x10; - private const int InstanceBodySize = 0x4C; - - public override ResourceType ResourceType => ResourceType.InstanceList; [EditorLabel("Number of instances"), EditorCategory("Instance List"), EditorReadOnly, EditorTooltip("The amount of instances that have a model assigned, but NOT the size of the entire instance array.")] public uint NumInstances; @@ -27,9 +22,10 @@ public class InstanceList : Resource [EditorLabel("Instances"), EditorCategory("Instance List"), EditorTooltip("The list of instances in this list.")] public List Instances = []; - public InstanceList() : base() { } + public InstanceList() : base(ResourceType.InstanceList) { } - public InstanceList(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public InstanceList(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.InstanceList, path, endianness) { } public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { @@ -38,31 +34,19 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes Arch arch = ResourceArch; int instanceBlockSize = GetInstanceBlockSize(arch); uint entryCount = (uint)Instances.Count; - uint numInstances = NumInstances == 0 ? entryCount : Math.Min(NumInstances, entryCount); long currentOffset = HeaderSize; long instanceListOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, checked((int)(entryCount * instanceBlockSize)), SectionAlignment); - long importBlockOffset = ResourceUtilities.GetSectionOffset( - ref currentOffset, - checked((int)(entryCount * ImportEntrySize)), - SectionAlignment); - long resourceIdBlockOffset = ResourceUtilities.GetSectionOffset( - ref currentOffset, - checked((int)(entryCount * ResourceIdEntrySize)), - SectionAlignment); writer.Write((int)instanceListOffset); writer.Write(entryCount); - writer.Write(numInstances); + writer.Write(NumInstances); writer.Write(1u); writer.WriteSection(instanceListOffset, Instances, (w, instance) => WriteInstanceBlock(w, instance, arch)); - writer.WriteSection(importBlockOffset, Instances, (w, instance, index) => - WriteImportEntry(w, instance, instanceListOffset + ((long)index * instanceBlockSize))); - writer.WriteSection(resourceIdBlockOffset, Instances, WriteResourceIdEntry); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) @@ -82,14 +66,12 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann long instanceBlockSize = GetInstanceBlockSize(ResourceArch); long importBlockOffset = instanceListPtr + (instanceBlockSize * entries); - long resourceIdBlockOffset = importBlockOffset + (ImportEntrySize * entries); for (int i = 0; i < entries; i++) { long instanceOffset = instanceListPtr + (instanceBlockSize * i); - long resourceIdOffset = resourceIdBlockOffset + (ResourceIdEntrySize * i); - reader.ParseSection(instanceOffset, r => ReadInstance(r, ResourceArch, importBlockOffset, resourceIdOffset), out Instance instance); + reader.ParseSection(instanceOffset, r => ReadInstance(r, ResourceArch, importBlockOffset), out Instance instance); Instances.Add(instance); } } @@ -102,20 +84,19 @@ private static int GetInstanceBlockSize(Arch arch) private static Instance ReadInstance( ResourceBinaryReader reader, Arch arch, - long importBlockOffset, - long resourceIdOffset) + long importBlockOffset) { long blockStart = reader.BaseStream.Position; ResourceImport.ReadExternalImport(blockStart, reader, importBlockOffset, out ResourceImport modelReference); + reader.BaseStream.Seek(blockStart + GetImportPlaceholderSize(arch), SeekOrigin.Begin); short backdropZoneId = reader.ReadInt16(); - reader.BaseStream.Seek(0x6, SeekOrigin.Current); + reader.BaseStream.Seek(0x2, SeekOrigin.Current); float maxVisibleDistanceSquared = reader.ReadSingle(); - Transform transform = Matrix44AffineToTransform(ReadMatrix44Affine(reader)); - - ResourceImport resourceId = default; - reader.ParseSection(resourceIdOffset, ReadResourceId, out resourceId); + reader.BaseStream.Seek(0x4, SeekOrigin.Current); + Matrix44Affine transformMatrix = ReadMatrix44Affine(reader); + Transform transform = Matrix44AffineToTransform(transformMatrix); return new Instance { @@ -123,43 +104,58 @@ private static Instance ReadInstance( BackdropZoneID = backdropZoneId, MaxVisibleDistanceSquared = maxVisibleDistanceSquared, Transform = transform, - ResourceId = resourceId, + TransformMatrix = transformMatrix, }; } - private static ResourceImport ReadResourceId(ResourceBinaryReader reader) + private static int GetImportPlaceholderSize(Arch arch) { - ResourceImport resourceId = new() - { - ReferenceID = reader.ReadUInt32(), - ExternalImport = false - }; - - reader.BaseStream.Seek(0xC, SeekOrigin.Current); - return resourceId; + return arch == Arch.x64 ? sizeof(ulong) : sizeof(uint); } private static void WriteInstanceBlock(ResourceBinaryWriter writer, Instance instance, Arch arch) { + long blockStart = writer.BaseStream.Position; + + writer.WritePointer(0, arch); writer.Write(instance.BackdropZoneID); - writer.Write(new byte[0x6]); + writer.Write(new byte[0x2]); writer.Write(instance.MaxVisibleDistanceSquared); - WriteMatrix44Affine(writer, TransformToMatrix44Affine(instance.Transform)); - writer.Write(new byte[GetInstanceBlockSize(arch) - InstanceBodySize]); - } + writer.Write(new byte[0x4]); - private static void WriteImportEntry(ResourceBinaryWriter writer, Instance instance, long fileOffset) - { - writer.Write(ResourceUtilities.ResolveResourceID(instance.ModelReference)); - writer.Write((uint)fileOffset); - writer.Write(0u); + Matrix44Affine transformMatrix = instance.TransformMatrix != default + ? instance.TransformMatrix + : TransformToMatrix44Affine(instance.Transform); + WriteMatrix44Affine(writer, transformMatrix); + + int remaining = GetInstanceBlockSize(arch) - (int)(writer.BaseStream.Position - blockStart); + if (remaining < 0) + { + throw new InvalidDataException( + $"Instance block overflow. Wrote 0x{writer.BaseStream.Position - blockStart:X} bytes into a 0x{GetInstanceBlockSize(arch):X} byte block."); + } + + if (remaining > 0) + { + writer.Write(new byte[remaining]); + } } - private static void WriteResourceIdEntry(ResourceBinaryWriter writer, Instance instance, int index) + public IEnumerable> GetExternalImports() { - _ = index; - writer.Write((uint)ResourceUtilities.ResolveResourceID(instance.ResourceId)); - writer.Write(new byte[0xC]); + int instanceBlockSize = GetInstanceBlockSize(ResourceArch); + for (int i = 0; i < Instances.Count; i++) + { + ResourceImport modelReference = Instances[i].ModelReference; + if (!modelReference.ExternalImport) + { + continue; + } + + yield return new KeyValuePair( + HeaderSize + (i * instanceBlockSize), + modelReference); + } } } @@ -171,6 +167,9 @@ public struct Instance [EditorLabel("Transform"), EditorCategory("InstanceList/Instances"), EditorTooltip("The location, rotation, and scale of this instance.")] public Transform Transform; + [EditorHidden] + public Matrix44Affine TransformMatrix; + [EditorLabel("Transform"), EditorCategory("InstanceList/Instances"), EditorTooltip("If this is a backdrop, the PVS Zone ID that this backdrop represents.")] public short BackdropZoneID; diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index aa75b5f..a14a285 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/Resources/Model/Model.cs @@ -9,7 +9,7 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Model -public class Model : Resource +public class Model : TypedResource { private const int HeaderSize = 0x14; private const int RenderableOffsetSize = sizeof(uint); @@ -17,15 +17,15 @@ public class Model : Resource private const int LodDistanceSize = sizeof(float); private const int ImportEntrySize = 0x10; + [EditorHidden] + public uint HeaderMetadata; + [EditorCategory("Model Container"), EditorLabel("Flags")] public byte Flags; [EditorCategory("Model Container"), EditorLabel("Models")] public List ModelDatas = []; - public override ResourceType ResourceType => ResourceType.Model; - public override Platform ResourcePlatform => Platform.Agnostic; - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { base.WriteToStream(writer, endianness); @@ -48,16 +48,11 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes long lodDistancesOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, modelCount * LodDistanceSize, - 1); - long importsOffset = ResourceUtilities.GetSectionOffset( - ref currentOffset, - modelCount * ImportEntrySize, - 1); - + sizeof(uint)); writer.Write((uint)renderablesOffset); writer.Write((uint)statesOffset); writer.Write((uint)lodDistancesOffset); - writer.Write(-1); + writer.Write(HeaderMetadata); writer.Write((byte)modelCount); writer.Write(Flags); writer.Write((byte)modelCount); @@ -66,7 +61,6 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.WriteSection(renderablesOffset, ModelDatas, static (w, _, index) => w.Write((uint)(index * ImportEntrySize))); writer.WriteSection(statesOffset, ModelDatas, (w, modelData) => w.Write((byte)modelData.State)); writer.WriteSection(lodDistancesOffset, ModelDatas, (w, modelData) => w.Write(modelData.LODDistance)); - writer.WriteSection(importsOffset, ModelDatas, WriteImportEntry); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) @@ -85,7 +79,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann uint renderableStatesPtr = reader.ReadUInt32(); uint lodDistancesPtr = reader.ReadUInt32(); - _ = reader.ReadInt32(); + HeaderMetadata = reader.ReadUInt32(); byte numRenderables = reader.ReadByte(); if (numRenderables == 0) @@ -115,9 +109,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } } - public Model() : base() { } + public Model() : base(ResourceType.Model) { } - public Model(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public Model(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.Model, path, endianness) { } private static ModelData ReadModelData( ResourceBinaryReader reader, @@ -145,12 +140,20 @@ private static ModelData ReadModelData( return modelData; } - private static void WriteImportEntry(ResourceBinaryWriter writer, ModelData modelData, int index) + public IEnumerable> GetExternalImports() { - _ = index; - writer.Write(ResourceUtilities.ResolveResourceID(modelData.ResourceReference)); - writer.Write(0u); - writer.Write(0u); + for (int i = 0; i < ModelDatas.Count; i++) + { + ResourceImport resourceReference = ModelDatas[i].ResourceReference; + if (!resourceReference.ExternalImport) + { + continue; + } + + yield return new KeyValuePair( + HeaderSize + (i * RenderableOffsetSize), + resourceReference); + } } public struct ModelData diff --git a/Volatility/Resources/Renderable/RenderableBase.cs b/Volatility/Resources/Renderable/RenderableBase.cs index 43ef5be..1358713 100644 --- a/Volatility/Resources/Renderable/RenderableBase.cs +++ b/Volatility/Resources/Renderable/RenderableBase.cs @@ -11,7 +11,7 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Renderable -public abstract class RenderableBase : Resource +public abstract class RenderableBase : TypedResource { public Vector3Plus BoundingSphere; public ushort Version; @@ -21,8 +21,6 @@ public abstract class RenderableBase : Resource public uint IndexBuffer; // Only on PC platforms public uint VertexBuffer; // Only on PC platforms - public override ResourceType ResourceType => ResourceType.Renderable; - public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); @@ -41,7 +39,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann // TODO: Parse RenderableMeshes } - public RenderableBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + protected RenderableBase() : base(ResourceType.Renderable) { } + + protected RenderableBase(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.Renderable, path, endianness) { } } diff --git a/Volatility/Resources/Resource.cs b/Volatility/Resources/Resource.cs index 5cf1c01..482b760 100644 --- a/Volatility/Resources/Resource.cs +++ b/Volatility/Resources/Resource.cs @@ -45,6 +45,11 @@ public virtual void ParseFromStream(ResourceBinaryReader reader, Endian endianne public Resource() { } public Resource(string path, Endian endianness = Endian.Agnostic) + { + InitializeFromPath(path, endianness); + } + + protected void InitializeFromPath(string path, Endian endianness = Endian.Agnostic) { if (string.IsNullOrEmpty(path)) return; @@ -279,4 +284,4 @@ public enum Arch x32 = 0, [EditorLabel("64 bit (Console BPR)")] x64 = 1 -} \ No newline at end of file +} diff --git a/Volatility/Resources/ResourceFactory.cs b/Volatility/Resources/ResourceFactory.cs index a0ec106..338ed3d 100644 --- a/Volatility/Resources/ResourceFactory.cs +++ b/Volatility/Resources/ResourceFactory.cs @@ -1,102 +1,38 @@ - namespace Volatility.Resources; public static class ResourceFactory { - private static readonly Dictionary<(ResourceType, Platform), Func> resourceCreators = new() - { - // Texture resources - { (ResourceType.Texture, Platform.BPR), path => { - var resource = new TextureBPR(path); - resource.PullAll(); - return resource; - } }, - { (ResourceType.Texture, Platform.TUB), path => { - var resource = new TexturePC(path); - resource.PullAll(); - return resource; - } }, - { (ResourceType.Texture, Platform.X360), path => { - var resource = new TextureX360(path); - resource.PullAll(); - return resource; - } }, - { (ResourceType.Texture, Platform.PS3), path => { - var resource = new TexturePS3(path); - resource.PullAll(); - return resource; - } }, - - // Splicer resources - { (ResourceType.Splicer, Platform.BPR), path => new Splicer(path, Endian.LE) }, - { (ResourceType.Splicer, Platform.TUB), path => new Splicer(path, Endian.LE) }, - { (ResourceType.Splicer, Platform.X360), path => new Splicer(path, Endian.BE) }, - { (ResourceType.Splicer, Platform.PS3), path => new Splicer(path, Endian.BE) }, - - // Renderable resources - { (ResourceType.Renderable, Platform.BPR), path => new RenderableBPR(path) }, - { (ResourceType.Renderable, Platform.TUB), path => new RenderablePC(path) }, - { (ResourceType.Renderable, Platform.X360), path => new RenderableX360(path) }, - { (ResourceType.Renderable, Platform.PS3), path => new RenderablePS3(path) }, - - // InstanceList resources - { (ResourceType.InstanceList, Platform.BPR), path => new InstanceList(path, Endian.LE) }, - { (ResourceType.InstanceList, Platform.TUB), path => new InstanceList(path, Endian.LE) }, - { (ResourceType.InstanceList, Platform.X360), path => new InstanceList(path, Endian.BE) }, - { (ResourceType.InstanceList, Platform.PS3), path => new InstanceList(path, Endian.BE) }, - - // Model resources - { (ResourceType.Model, Platform.BPR), path => new Model(path, Endian.LE) }, - { (ResourceType.Model, Platform.TUB), path => new Model(path, Endian.LE) }, - { (ResourceType.Model, Platform.X360), path => new Model(path, Endian.BE) }, - { (ResourceType.Model, Platform.PS3), path => new Model(path, Endian.BE) }, - - // EnvironmentKeyframe resources - { (ResourceType.EnvironmentKeyframe, Platform.BPR), path => new EnvironmentKeyframe(path, Endian.LE) }, - { (ResourceType.EnvironmentKeyframe, Platform.TUB), path => new EnvironmentKeyframe(path, Endian.LE) }, - { (ResourceType.EnvironmentKeyframe, Platform.X360), path => new EnvironmentKeyframe(path, Endian.BE) }, - { (ResourceType.EnvironmentKeyframe, Platform.PS3), path => new EnvironmentKeyframe(path, Endian.BE) }, + private static readonly Dictionary<(ResourceType, Platform), Func> resourceCreators = CreateResourceCreators(); - // EnvironmentTimeline resources - { (ResourceType.EnvironmentTimeLine, Platform.BPR), path => new EnvironmentTimeline(path, Endian.LE) }, - { (ResourceType.EnvironmentTimeLine, Platform.TUB), path => new EnvironmentTimeline(path, Endian.LE) }, - { (ResourceType.EnvironmentTimeLine, Platform.X360), path => new EnvironmentTimeline(path, Endian.BE) }, - { (ResourceType.EnvironmentTimeLine, Platform.PS3), path => new EnvironmentTimeline(path, Endian.BE) }, - - // SnapshotData resources - { (ResourceType.SnapshotData, Platform.BPR), path => new SnapshotData(path, Endian.LE) }, - { (ResourceType.SnapshotData, Platform.TUB), path => new SnapshotData(path, Endian.LE) }, - { (ResourceType.SnapshotData, Platform.X360), path => new SnapshotData(path, Endian.BE) }, - { (ResourceType.SnapshotData, Platform.PS3), path => new SnapshotData(path, Endian.BE) }, + private static Dictionary<(ResourceType, Platform), Func> CreateResourceCreators() + { + ResourceCreatorRegistry registry = new(); - // AttribSysVault resources - { (ResourceType.AttribSysVault, Platform.BPR), path => new AttribSysVault(path, Endian.LE) }, - { (ResourceType.AttribSysVault, Platform.TUB), path => new AttribSysVault(path, Endian.LE) }, - { (ResourceType.AttribSysVault, Platform.X360), path => new AttribSysVault(path, Endian.BE) }, - { (ResourceType.AttribSysVault, Platform.PS3), path => new AttribSysVault(path, Endian.BE) }, - - // StreamedDeformationSpec resources - { (ResourceType.StreamedDeformationSpec, Platform.BPR), path => new StreamedDeformationSpec(path, Endian.LE) }, - { (ResourceType.StreamedDeformationSpec, Platform.TUB), path => new StreamedDeformationSpec(path, Endian.LE) }, - { (ResourceType.StreamedDeformationSpec, Platform.X360), path => new StreamedDeformationSpec(path, Endian.BE) }, - { (ResourceType.StreamedDeformationSpec, Platform.PS3), path => new StreamedDeformationSpec(path, Endian.BE) }, + registry.AddWithPullAll(ResourceType.Texture, Platform.BPR, static path => new TextureBPR(path)); + registry.AddWithPullAll(ResourceType.Texture, Platform.TUB, static path => new TexturePC(path)); + registry.AddWithPullAll(ResourceType.Texture, Platform.X360, static path => new TextureX360(path)); + registry.AddWithPullAll(ResourceType.Texture, Platform.PS3, static path => new TexturePS3(path)); - // AptData resources - { (ResourceType.AptData, Platform.BPR), path => new AptData(path, Endian.LE) }, - { (ResourceType.AptData, Platform.TUB), path => new AptData(path, Endian.LE) }, - { (ResourceType.AptData, Platform.X360), path => new AptData(path, Endian.BE) }, - { (ResourceType.AptData, Platform.PS3), path => new AptData(path, Endian.BE) }, + registry.AddEndianMapped(ResourceType.Splicer, static (path, endian) => new Splicer(path, endian)); + registry.Add(ResourceType.Renderable, Platform.BPR, static path => new RenderableBPR(path)); + registry.Add(ResourceType.Renderable, Platform.TUB, static path => new RenderablePC(path)); + registry.Add(ResourceType.Renderable, Platform.X360, static path => new RenderableX360(path)); + registry.Add(ResourceType.Renderable, Platform.PS3, static path => new RenderablePS3(path)); + registry.AddEndianMapped(ResourceType.InstanceList, static (path, endian) => new InstanceList(path, endian)); + registry.AddEndianMapped(ResourceType.Model, static (path, endian) => new Model(path, endian)); + registry.AddEndianMapped(ResourceType.EnvironmentKeyframe, static (path, endian) => new EnvironmentKeyframe(path, endian)); + registry.AddEndianMapped(ResourceType.EnvironmentTimeLine, static (path, endian) => new EnvironmentTimeline(path, endian)); + registry.AddEndianMapped(ResourceType.SnapshotData, static (path, endian) => new SnapshotData(path, endian)); + registry.AddEndianMapped(ResourceType.AttribSysVault, static (path, endian) => new AttribSysVault(path, endian)); + registry.AddEndianMapped(ResourceType.StreamedDeformationSpec, static (path, endian) => new StreamedDeformationSpec(path, endian)); + registry.AddEndianMapped(ResourceType.AptData, static (path, endian) => new AptData(path, endian)); + registry.AddEndianMapped(ResourceType.GuiPopup, static (path, endian) => new GuiPopup(path, endian)); - // GuiPopup resources - { (ResourceType.GuiPopup, Platform.BPR), path => new GuiPopup(path, Endian.LE) }, - { (ResourceType.GuiPopup, Platform.TUB), path => new GuiPopup(path, Endian.LE) }, - { (ResourceType.GuiPopup, Platform.X360), path => new GuiPopup(path, Endian.BE) }, - { (ResourceType.GuiPopup, Platform.PS3), path => new GuiPopup(path, Endian.BE) }, + registry.Add(ResourceType.Shader, Platform.Agnostic, static path => new ShaderBase(path)); + registry.Add(ResourceType.Shader, Platform.TUB, static path => new ShaderPC(path)); - // Shader resources - { (ResourceType.Shader, Platform.Agnostic), path => new ShaderBase(path) }, - { (ResourceType.Shader, Platform.TUB), path => new ShaderPC(path) }, - }; + return registry.Build(); + } public static Resource CreateResource(ResourceType resourceType, Platform platform, string filePath, bool x64 = false) { @@ -105,14 +41,62 @@ public static Resource CreateResource(ResourceType resourceType, Platform platfo var key = (resourceType, platform); if (resourceCreators.TryGetValue(key, out var creator)) { - var output = creator(filePath); + Resource output = creator(filePath); if (x64) output.SetResourceArch(Arch.x64); return output; } - else + + throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); + } + + private sealed class ResourceCreatorRegistry + { + private readonly Dictionary<(ResourceType, Platform), Func> _creators = new(); + + public void AddCreator(ResourceType resourceType, Platform platform, Func creator) + { + _creators.Add((resourceType, platform), creator); + } + + public void Add( + ResourceType resourceType, + Platform platform, + Func creator, + Action? afterCreate = null) + where TResource : Resource + { + AddCreator(resourceType, platform, path => + { + TResource resource = creator(path); + afterCreate?.Invoke(resource); + return resource; + }); + } + + public void AddWithPullAll( + ResourceType resourceType, + Platform platform, + Func creator) + where TResource : Resource + { + Add(resourceType, platform, creator, static resource => resource.PullAll()); + } + + public void AddEndianMapped( + ResourceType resourceType, + Func creator) + where TResource : Resource + { + Add(resourceType, Platform.BPR, path => creator(path, Endian.LE)); + Add(resourceType, Platform.TUB, path => creator(path, Endian.LE)); + Add(resourceType, Platform.X360, path => creator(path, Endian.BE)); + Add(resourceType, Platform.PS3, path => creator(path, Endian.BE)); + } + + public Dictionary<(ResourceType, Platform), Func> Build() { - throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); + return _creators; } } } diff --git a/Volatility/Resources/Shader/ShaderBase.cs b/Volatility/Resources/Shader/ShaderBase.cs index e07dedd..d6b11db 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/Volatility/Resources/Shader/ShaderBase.cs @@ -1,11 +1,7 @@ namespace Volatility.Resources; -public class ShaderBase : Resource +public class ShaderBase : TypedResource { - public override ResourceType ResourceType => ResourceType.Shader; - public override Endian ResourceEndian => Endian.Agnostic; - public override Platform ResourcePlatform => Platform.Agnostic; - [EditorCategory("Shader/Source"), EditorLabel("Source File"), EditorTooltip("Relative path to the HLSL source file.")] public string? ShaderSourcePath { get; set; } @@ -29,15 +25,6 @@ public class ShaderBase : Resource [EditorCategory("Shader/Compile"), EditorLabel("Additional Arguments"), EditorTooltip("Extra dxc command-line arguments.")] public List AdditionalArguments { get; set; } = []; - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) - { - base.WriteToStream(writer, endianness); - } - public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) - { - base.ParseFromStream(reader, endianness); - } - public IReadOnlyList GetCompileStages() { if (Stages != null && Stages.Count > 0) @@ -90,11 +77,12 @@ public bool TryReadShaderSourceText(out string shaderSourceText) return true; } - public ShaderBase() : base() { } + public ShaderBase() : base(ResourceType.Shader) { } - public ShaderBase(string path) : base(path) { } + public ShaderBase(string path) : base(ResourceType.Shader, path) { } - public ShaderBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public ShaderBase(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.Shader, path, endianness) { } } public enum ShaderStageType diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs index 3c3dd8a..8f05b97 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs +++ b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs @@ -1,21 +1,9 @@ namespace Volatility.Resources; -public class ShaderProgramBufferBase : Resource +public class ShaderProgramBufferBase : TypedResource { - public override ResourceType ResourceType => ResourceType.RwShaderProgramBuffer; - public override Endian ResourceEndian => Endian.Agnostic; - public override Platform ResourcePlatform => Platform.Agnostic; + public ShaderProgramBufferBase() : base(ResourceType.RwShaderProgramBuffer) { } - public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) - { - base.WriteToStream(writer, endianness); - } - public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) - { - base.ParseFromStream(reader, endianness); - } - - public ShaderProgramBufferBase() : base() { } - - public ShaderProgramBufferBase(string path) : base(path) { } + public ShaderProgramBufferBase(string path) + : base(ResourceType.RwShaderProgramBuffer, path) { } } diff --git a/Volatility/Resources/SnapshotData/SnapshotData.cs b/Volatility/Resources/SnapshotData/SnapshotData.cs index efcd0b9..9a3fa8f 100644 --- a/Volatility/Resources/SnapshotData/SnapshotData.cs +++ b/Volatility/Resources/SnapshotData/SnapshotData.cs @@ -6,9 +6,6 @@ public class SnapshotData : BinaryResource private const int SnapshotChannelSize = 0xC; private const int SnapshotStatusSize = 0x8; - public override ResourceType ResourceType => ResourceType.SnapshotData; - public override Platform ResourcePlatform => Platform.Agnostic; - public List Channels = []; public List SnapshotStatuses = []; @@ -48,9 +45,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann SnapshotStatuses = reader.ParseSection(statusesOffset, snapshotCount * channelCount, SnapshotStatusData.Read); } - public SnapshotData() : base() { } + public SnapshotData() : base(ResourceType.SnapshotData) { } - public SnapshotData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public SnapshotData(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.SnapshotData, path, endianness) { } private int GetSnapshotCountForWrite() { diff --git a/Volatility/Resources/Splicer/Splicer.cs b/Volatility/Resources/Splicer/Splicer.cs index 3d92914..a072c9d 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/Volatility/Resources/Splicer/Splicer.cs @@ -18,8 +18,6 @@ public class Splicer : BinaryResource private const int SpliceHeaderSize = 0x18; private const int SampleRefSize = 0x2C; - public override ResourceType ResourceType => ResourceType.Splicer; - public List Splices = []; // Only gets populated when parsing from a stream, or when @@ -180,9 +178,10 @@ public List GetLoadedSamples() return _samples; } - public Splicer() : base() { } + public Splicer() : base(ResourceType.Splicer) { } - public Splicer(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public Splicer(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.Splicer, path, endianness) { } private static List ReadSamples( ResourceBinaryReader reader, diff --git a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs index a595856..e1e222a 100644 --- a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs +++ b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs @@ -312,14 +312,12 @@ public GlassPaneSpec(ResourceBinaryReader reader) } } -public class StreamedDeformationSpec : Resource +public class StreamedDeformationSpec : TypedResource { public const int HeaderSize32 = 0x6B0; public const int HeaderSize64 = 0x6F0; private const int SectionAlignment = 0x10; - public override ResourceType ResourceType => ResourceType.StreamedDeformationSpec; - public int VersionNumber { get; set; } public ulong TagPointDataOffset { get; set; } public int NumberOfTagPoints { get; set; } @@ -578,9 +576,10 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.WriteSection(LightTagsInfo.Offset, LightTags, WriteLocatorPointSpec); } - public StreamedDeformationSpec() : base() { } + public StreamedDeformationSpec() : base(ResourceType.StreamedDeformationSpec) { } - public StreamedDeformationSpec(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + public StreamedDeformationSpec(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.StreamedDeformationSpec, path, endianness) { } // Section writers diff --git a/Volatility/Resources/Texture/TextureBase.cs b/Volatility/Resources/Texture/TextureBase.cs index 3eaa152..f59c8ac 100644 --- a/Volatility/Resources/Texture/TextureBase.cs +++ b/Volatility/Resources/Texture/TextureBase.cs @@ -8,10 +8,8 @@ // Learn More: // https://burnout.wiki/wiki/Texture -public abstract class TextureBase : Resource +public abstract class TextureBase : TypedResource { - public override ResourceType ResourceType => ResourceType.Texture; - [EditorCategory("Texture"), EditorLabel("Width"), EditorTooltip("The target width of the texture.")] public ushort Width { get; set; } @@ -94,9 +92,10 @@ public override void PushAll() PushInternalFlags(); } - public TextureBase() : base() => Depth = 1; + protected TextureBase() : base(ResourceType.Texture) => Depth = 1; - public TextureBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } + protected TextureBase(string path, Endian endianness = Endian.Agnostic) + : base(ResourceType.Texture, path, endianness) { } } // BPR formatted but converted for each platform public enum DIMENSION : int @@ -121,4 +120,4 @@ public enum TextureBaseUsageFlags PropTexture = 3, // GlobalProps AnyTexture = WorldTexture | GRTexture | PropTexture -} \ No newline at end of file +} diff --git a/Volatility/Resources/Texture/TexturePC.cs b/Volatility/Resources/Texture/TexturePC.cs index c9df887..1e90bd4 100644 --- a/Volatility/Resources/Texture/TexturePC.cs +++ b/Volatility/Resources/Texture/TexturePC.cs @@ -25,6 +25,7 @@ public D3DFORMAT Format public byte Unknown1; // Flags public byte Unknown2; // Flags private byte[] OutputFormat = new byte[4]; // Needs to be 4 bytes long + public byte[] PreservedHeader = []; public TEXTURETYPE TextureType; // Dimension in BPR public byte Flags; // Flags @@ -36,6 +37,17 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); + if (PreservedHeader.Length == 0x40 && + Width == 0 && + Height == 0 && + Depth == 0 && + MipmapLevels == 0 && + Format == D3DFORMAT.D3DFMT_UNKNOWN) + { + writer.Write(PreservedHeader); + return; + } + PushAll(); // Need to determine if should be moved writer.WritePointer((ulong)TextureDataPtr, ResourceArch); @@ -62,6 +74,14 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann { base.ParseFromStream(reader, endianness); + if (reader.BaseStream.Length == 0x40) + { + long originalPosition = reader.BaseStream.Position; + reader.BaseStream.Seek(0, SeekOrigin.Begin); + PreservedHeader = reader.ReadBytes(0x40); + reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); + } + reader.BaseStream.Seek(8, SeekOrigin.Begin); // Skip over Data & Interface pointers Unknown0 = reader.ReadUInt32(); reader.BaseStream.Seek(2, SeekOrigin.Current); // Skip over MemoryClass diff --git a/Volatility/Resources/Texture/TextureX360.cs b/Volatility/Resources/Texture/TextureX360.cs index b11bb11..8b90030 100644 --- a/Volatility/Resources/Texture/TextureX360.cs +++ b/Volatility/Resources/Texture/TextureX360.cs @@ -38,6 +38,7 @@ public class TextureX360 : TextureBase public uint MipFlush = 65535; public GPUTEXTURE_FETCH_CONSTANT Format = new GPUTEXTURE_FETCH_CONSTANT(); + public byte[] FooterBytes = new byte[0xC]; public TextureX360() : base() { } @@ -120,15 +121,28 @@ public override void PushInternalFlags() { } // Not sure if this is accurate public override void PushInternalFormat() { - Format.Pitch = CalculatePitchX360(Width, Height); + if (Format.Pitch == 0) + { + Format.Pitch = CalculatePitchX360(Width, Height); + } + + if (Format.MaxMipLevel == 0 && MipmapLevels > 0) + { + Format.MaxMipLevel = (byte)(MipmapLevels - 1); + } - Format.MaxMipLevel = (byte)(MipmapLevels - 1); Format.MinMipLevel = MostDetailedMip; - Format.PackedMips = Format.MaxMipLevel > 0; + if (!Format.PackedMips) + { + Format.PackedMips = Format.MaxMipLevel > 0; + } - // Not entirely correct but better than just using pitch - Format.MipAddress = CalculateMipAddressX360(Width, Height); + if (Format.MipAddress == 0 && Width > 0 && Height > 0) + { + // Not entirely correct but better than just using pitch + Format.MipAddress = CalculateMipAddressX360(Width, Height); + } } public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) @@ -154,9 +168,7 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.Write(MipFlush); writer.Write(Format.PackToBytes()); - // Padding that's usually just garbage data. - writer.Write(Encoding.UTF8.GetBytes("Volatility")); - writer.Write(new byte[0x2]); + writer.WriteFixedBytes(FooterBytes, 0xC); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) @@ -185,6 +197,13 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann // Format reader.BaseStream.Seek(0x1C, SeekOrigin.Begin); Format = new GPUTEXTURE_FETCH_CONSTANT().FromPacked(reader.ReadBytes(0x18)); + + reader.BaseStream.Seek(0x34, SeekOrigin.Begin); + FooterBytes = reader.ReadBytes((int)Math.Min(0xC, reader.BaseStream.Length - reader.BaseStream.Position)); + if (FooterBytes.Length < 0xC) + { + Array.Resize(ref FooterBytes, 0xC); + } } } diff --git a/Volatility/Resources/TypedResource.cs b/Volatility/Resources/TypedResource.cs new file mode 100644 index 0000000..b38ed60 --- /dev/null +++ b/Volatility/Resources/TypedResource.cs @@ -0,0 +1,19 @@ +namespace Volatility.Resources; + +public abstract class TypedResource : Resource +{ + private readonly ResourceType _resourceType; + + protected TypedResource(ResourceType resourceType) + { + _resourceType = resourceType; + } + + protected TypedResource(ResourceType resourceType, string path, Endian endianness = Endian.Agnostic) + : this(resourceType) + { + InitializeFromPath(path, endianness); + } + + public sealed override ResourceType ResourceType => _resourceType; +} diff --git a/autotest_recap.md b/autotest_recap.md new file mode 100644 index 0000000..9ff38f0 --- /dev/null +++ b/autotest_recap.md @@ -0,0 +1,174 @@ +# Volatility Autotest Recap + +Generated (UTC+02:00): 2026-04-17 14:20:12 +Games: `D:\Emulation\Emulators\Xenia\Xenia Burnout 5 v6\Burnout_tcartwright` | `C:\Program Files (x86)\Steam\steamapps\common\BurnoutPR` +* Failed: 0 +* Passed with binary parity: 12 +* Semi-passed (without binary parity): 22 +* Skipped: 70 + +## Test Operation Summary + +| Operation | Passed | Failed | Skipped | +| --- | ---: | ---: | ---: | +| binaryparity | 12 | 0 | 0 | +| bundleextract | 0 | 0 | 1 | +| candidate | 0 | 0 | 1 | +| import | 4 | 0 | 0 | +| porttexture | 4 | 0 | 0 | +| roundtrip | 12 | 0 | 0 | +| texturetodds | 2 | 0 | 2 | +| unsupported | 0 | 0 | 66 | + +## Resource Type Outcomes + +| Resource Type | Passed | Failed | Skipped | Overall | +| --- | ---: | ---: | ---: | --- | +| AISections | 0 | 0 | 2 | SKIP | +| AttribSysVault | 0 | 0 | 2 | SKIP | +| ChallengeList | 0 | 0 | 2 | SKIP | +| FlaptFile | 0 | 0 | 2 | SKIP | +| GuiPopup | 4 | 0 | 0 | PASS | +| HudMessage | 0 | 0 | 2 | SKIP | +| HudMessageSequence | 0 | 0 | 2 | SKIP | +| HudMessageSequenceDictionary | 0 | 0 | 2 | SKIP | +| ICETakeDictionary | 0 | 0 | 2 | SKIP | +| IdList | 0 | 0 | 2 | SKIP | +| InstanceList | 4 | 0 | 1 | PASS | +| MassiveLookupTable | 0 | 0 | 2 | SKIP | +| Material | 0 | 0 | 2 | SKIP | +| MaterialState | 0 | 0 | 2 | SKIP | +| MaterialTechnique | 0 | 0 | 1 | SKIP | +| ParticleDescription | 0 | 0 | 2 | SKIP | +| ParticleDescriptionCollection | 0 | 0 | 2 | SKIP | +| PolygonSoupList | 0 | 0 | 2 | SKIP | +| ProfileUpgrade | 0 | 0 | 1 | SKIP | +| ProgressionData | 0 | 0 | 2 | SKIP | +| PropGraphicsList | 0 | 0 | 2 | SKIP | +| PropInstanceData | 0 | 0 | 2 | SKIP | +| Registry | 0 | 0 | 2 | SKIP | +| Renderable | 4 | 0 | 0 | PASS | +| RwShaderProgramBuffer | 0 | 0 | 2 | SKIP | +| Scene | 8 | 0 | 0 | PASS | +| ShaderTechnique | 0 | 0 | 2 | SKIP | +| StaticSoundMap | 0 | 0 | 2 | SKIP | +| StreetData | 0 | 0 | 2 | SKIP | +| Texture | 14 | 0 | 2 | PASS | +| TextureNameMap | 0 | 0 | 2 | SKIP | +| TextureState | 0 | 0 | 2 | SKIP | +| TrafficData | 0 | 0 | 2 | SKIP | +| TriggerData | 0 | 0 | 2 | SKIP | +| VertexDescriptor | 0 | 0 | 2 | SKIP | +| VFXMeshCollection | 0 | 0 | 2 | SKIP | +| VFXPropCollection | 0 | 0 | 2 | SKIP | +| WorldPainter2D | 0 | 0 | 2 | SKIP | +| ZoneList | 0 | 0 | 2 | SKIP | + +## Case Details + +| Game | Resource Type | Operation | Name | Outcome | Details | +| --- | --- | --- | --- | --- | --- | +| Burnout_tcartwright | AISections | unsupported | AISections | SKIP | Discovered in AI.DAT. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ProgressionData | unsupported | ProgressionData | SKIP | Discovered in PROGRESSION.DAT. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | StreetData | unsupported | StreetData | SKIP | Discovered in STREETDATA.DAT. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | TriggerData | unsupported | TriggerData | SKIP | Discovered in TRIGGERS.DAT. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | HudMessage | unsupported | HudMessage | SKIP | Discovered in HUDMESSAGES.HM. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | HudMessageSequence | unsupported | HudMessageSequence | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | HudMessageSequenceDictionary | unsupported | HudMessageSequenceDictionary | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | TrafficData | unsupported | TrafficData | SKIP | Discovered in B5TRAFFIC.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | MaterialTechnique | unsupported | MaterialTechnique | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | TextureState | unsupported | TextureState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | Material | unsupported | Material | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | VertexDescriptor | unsupported | VertexDescriptor | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | MaterialState | unsupported | MaterialState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | MassiveLookupTable | unsupported | MassiveLookupTable | SKIP | Discovered in MASSIVETABLE.BIN. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | AttribSysVault | unsupported | AttribSysVault | SKIP | Discovered in SURFACELIST.BIN. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ICETakeDictionary | unsupported | ICETakeDictionary | SKIP | Discovered in CAMERAS.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | FlaptFile | unsupported | FlaptFile | SKIP | Discovered in FLAPTHUD.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ParticleDescription | unsupported | ParticleDescription | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | TextureNameMap | unsupported | TextureNameMap | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | VFXMeshCollection | unsupported | VFXMeshCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | VFXPropCollection | unsupported | VFXPropCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ParticleDescriptionCollection | unsupported | ParticleDescriptionCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | Registry | unsupported | Registry | SKIP | Discovered in PLAYBACKREGISTRY.BUNDLE. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ZoneList | unsupported | ZoneList | SKIP | Discovered in PVS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ChallengeList | unsupported | ChallengeList | SKIP | Discovered in ONLINECHALLENGES.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | RwShaderProgramBuffer | unsupported | RwShaderProgramBuffer | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ShaderTechnique | unsupported | ShaderTechnique | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | StaticSoundMap | unsupported | StaticSoundMap | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | PropInstanceData | unsupported | PropInstanceData | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | PropGraphicsList | unsupported | PropGraphicsList | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | WorldPainter2D | unsupported | WorldPainter2D | SKIP | Discovered in DISTRICTS.DAT. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | ProfileUpgrade | unsupported | ProfileUpgrade | SKIP | Discovered in PROFILEUPG.BIN. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | PolygonSoupList | unsupported | PolygonSoupList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | IdList | unsupported | IdList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | +| Burnout_tcartwright | GuiPopup | binaryparity | GuiPopup:2718168B | PASS | Binary files are identical. | +| Burnout_tcartwright | GuiPopup | roundtrip | GuiPopup:2718168B | PASS | | +| Burnout_tcartwright | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone272.BackDropScene?ID=409963_LOD0 | PASS | | +| Burnout_tcartwright | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | Binary files are identical. | +| Burnout_tcartwright | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | | +| Burnout_tcartwright | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone136.BackDropScene?ID=558369_LOD0 | PASS | | +| Burnout_tcartwright | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | Binary files are identical. | +| Burnout_tcartwright | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | | +| Burnout_tcartwright | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:dds | PASS | | +| Burnout_tcartwright | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:X360->TUB | PASS | | +| Burnout_tcartwright | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | Binary files are identical. | +| Burnout_tcartwright | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | | +| Burnout_tcartwright | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:dds | PASS | | +| Burnout_tcartwright | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:X360->TUB | PASS | | +| Burnout_tcartwright | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | Binary files are identical. | +| Burnout_tcartwright | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | | +| Burnout_tcartwright | InstanceList | binaryparity | InstanceList:TRK_UNIT0_list | PASS | Binary files are identical. | +| Burnout_tcartwright | InstanceList | roundtrip | InstanceList:TRK_UNIT0_list | PASS | | +| Burnout_tcartwright | InstanceList | binaryparity | InstanceList:TRK_UNIT100_list | PASS | Binary files are identical. | +| Burnout_tcartwright | InstanceList | roundtrip | InstanceList:TRK_UNIT100_list | PASS | | +| BurnoutPR | AISections | unsupported | AISections | SKIP | Discovered in AI.DAT. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ProgressionData | unsupported | ProgressionData | SKIP | Discovered in PROGRESSION.DAT. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | StreetData | unsupported | StreetData | SKIP | Discovered in STREETDATA.DAT. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | TriggerData | unsupported | TriggerData | SKIP | Discovered in TRIGGERS.DAT. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | HudMessage | unsupported | HudMessage | SKIP | Discovered in HUDMESSAGES.HM. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | HudMessageSequence | unsupported | HudMessageSequence | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | HudMessageSequenceDictionary | unsupported | HudMessageSequenceDictionary | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | TrafficData | unsupported | TrafficData | SKIP | Discovered in B5TRAFFIC.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | TextureState | unsupported | TextureState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | Material | unsupported | Material | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | MaterialState | unsupported | MaterialState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | VertexDescriptor | unsupported | VertexDescriptor | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | MassiveLookupTable | unsupported | MassiveLookupTable | SKIP | Discovered in MASSIVETABLE.BIN. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | AttribSysVault | unsupported | AttribSysVault | SKIP | Discovered in SURFACELIST.BIN. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ICETakeDictionary | unsupported | ICETakeDictionary | SKIP | Discovered in CAMERAS.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | FlaptFile | unsupported | FlaptFile | SKIP | Discovered in FLAPTHUD.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ParticleDescription | unsupported | ParticleDescription | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | TextureNameMap | unsupported | TextureNameMap | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | VFXMeshCollection | unsupported | VFXMeshCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | VFXPropCollection | unsupported | VFXPropCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ParticleDescriptionCollection | unsupported | ParticleDescriptionCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | Registry | unsupported | Registry | SKIP | Discovered in PLAYBACKREGISTRY.BUNDLE. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ZoneList | unsupported | ZoneList | SKIP | Discovered in PVS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ChallengeList | unsupported | ChallengeList | SKIP | Discovered in ONLINECHALLENGES.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | RwShaderProgramBuffer | unsupported | RwShaderProgramBuffer | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | ShaderTechnique | unsupported | ShaderTechnique | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | StaticSoundMap | unsupported | StaticSoundMap | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | PropInstanceData | unsupported | PropInstanceData | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | PropGraphicsList | unsupported | PropGraphicsList | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | WorldPainter2D | unsupported | WorldPainter2D | SKIP | Discovered in DISTRICTS.DAT. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | PolygonSoupList | unsupported | PolygonSoupList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | IdList | unsupported | IdList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | +| BurnoutPR | - | bundleextract | TRK_UNIT0_GR.BNDL | SKIP | Process 'C:\\Users\\adri1\\Documents\\Github\\volatility\\tools\\libbndl-extractor\\build\\volatility_libbndl_extract.exe --bundle "C:\\Program Files (x86)\\Steam\\steamapps\\common\\BurnoutPR\\TRK_UNIT0_GR.BNDL" --output "C:\\Users\\adri1\\Documents\\Github\\volatility\\.tmp\\game-autotest\\20260417_121900\\BurnoutPR_TUB\\bundles\\TRK_UNIT0_GR.BNDL" --manifest "C:\\Users\\adri1\\Documents\\Github\\volatility\\.tmp\\game-autotest\\20260417_121900\\BurnoutPR_TUB\\bundles\\TRK_UNIT0_GR.BNDL\\manifest.tsv"' failed with exit code 3.
Assertion failed: m_flags & Compressed, file C:\\Users\\adri1\\Documents\\Github\\volatility\\tools\\libbndl-extractor\\third_party\\libbndl\\src\\bundle.cpp, line 892
| +| BurnoutPR | InstanceList | candidate | InstanceList | SKIP | No fully extractable bundle candidate was available for this supported resource type. | +| BurnoutPR | GuiPopup | binaryparity | GuiPopup:POPUPS.pup | PASS | Binary files are identical. | +| BurnoutPR | GuiPopup | roundtrip | GuiPopup:POPUPS.pup | PASS | | +| BurnoutPR | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone272.BackDropScene?ID=409963_LOD0 | PASS | | +| BurnoutPR | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | Binary files are identical. | +| BurnoutPR | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | | +| BurnoutPR | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone136.BackDropScene?ID=558369_LOD0 | PASS | | +| BurnoutPR | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | Binary files are identical. | +| BurnoutPR | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | | +| BurnoutPR | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:dds | SKIP | DDS export is not supported for TUB texture format 'D3DFMT_UNKNOWN'. | +| BurnoutPR | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:TUB->BPR | PASS | | +| BurnoutPR | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | Binary files are identical. | +| BurnoutPR | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | | +| BurnoutPR | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:dds | SKIP | DDS export is not supported for TUB texture format 'D3DFMT_UNKNOWN'. | +| BurnoutPR | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:TUB->BPR | PASS | | +| BurnoutPR | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | Binary files are identical. | +| BurnoutPR | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | | From 9754c620c9d41c1ead29b76f8da744c0e7e640b0 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:28:51 +0200 Subject: [PATCH 09/17] Continue refactor, with resources that use attributes --- Volatility/Resources/AptData/AptData.cs | 6 +- .../AttribSysVault/AttribSysVault.cs | 6 +- Volatility/Resources/BinaryResource.cs | 15 +- .../EnvironmentKeyframe.cs | 6 +- .../EnvironmentTimeLine.cs | 6 +- Volatility/Resources/GuiPopup/GuiPopup.cs | 6 +- .../Resources/InstanceList/InstanceList.cs | 6 +- Volatility/Resources/Model/Model.cs | 6 +- .../Resources/Renderable/RenderableBPR.cs | 1 + .../Resources/Renderable/RenderableBase.cs | 5 +- .../Resources/Renderable/RenderablePC.cs | 1 + .../Resources/Renderable/RenderablePS3.cs | 1 + .../Resources/Renderable/RenderableX360.cs | 1 + Volatility/Resources/ResourceFactory.cs | 176 ++++++++++++++---- Volatility/Resources/ResourceMetadata.cs | 60 ++++++ Volatility/Resources/Shader/ShaderBase.cs | 8 +- Volatility/Resources/Shader/ShaderPC.cs | 1 + .../ShaderProgramBufferBPR.cs | 1 + .../ShaderProgramBufferBase.cs | 5 +- .../Resources/SnapshotData/SnapshotData.cs | 6 +- Volatility/Resources/Splicer/Splicer.cs | 6 +- .../StreamedDeformationSpec.cs | 6 +- Volatility/Resources/Texture/TextureBPR.cs | 1 + Volatility/Resources/Texture/TextureBase.cs | 5 +- Volatility/Resources/Texture/TexturePC.cs | 1 + Volatility/Resources/Texture/TexturePS3.cs | 3 +- Volatility/Resources/Texture/TextureX360.cs | 1 + Volatility/Resources/TypedResource.cs | 19 ++ 28 files changed, 294 insertions(+), 71 deletions(-) create mode 100644 Volatility/Resources/ResourceMetadata.cs diff --git a/Volatility/Resources/AptData/AptData.cs b/Volatility/Resources/AptData/AptData.cs index a1d29c7..ff79891 100644 --- a/Volatility/Resources/AptData/AptData.cs +++ b/Volatility/Resources/AptData/AptData.cs @@ -2,6 +2,8 @@ namespace Volatility.Resources; +[ResourceDefinition(ResourceType.AptData)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class AptData : TypedResource { public string MovieName; @@ -96,10 +98,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann }; } - public AptData() : base(ResourceType.AptData) { } + public AptData() : base() { } public AptData(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.AptData, path, endianness) { } + : base(path, endianness) { } } public struct GuiGeometryObject diff --git a/Volatility/Resources/AttribSysVault/AttribSysVault.cs b/Volatility/Resources/AttribSysVault/AttribSysVault.cs index bccbfb3..27e9b78 100644 --- a/Volatility/Resources/AttribSysVault/AttribSysVault.cs +++ b/Volatility/Resources/AttribSysVault/AttribSysVault.cs @@ -9,6 +9,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/AttribSysVault +[ResourceDefinition(ResourceType.AttribSysVault)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class AttribSysVault : TypedResource { public ulong VltDataOffset { get; set; } @@ -118,10 +120,10 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes throw new NotImplementedException("Writing AttribSysVault is not implemented."); } - public AttribSysVault() : base(ResourceType.AttribSysVault) { } + public AttribSysVault() : base() { } public AttribSysVault(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.AttribSysVault, path, endianness) { } + : base(path, endianness) { } private void ParseVlt(EndianAwareBinaryReader reader, List pendingAttributes) { diff --git a/Volatility/Resources/BinaryResource.cs b/Volatility/Resources/BinaryResource.cs index 35351d8..7964209 100644 --- a/Volatility/Resources/BinaryResource.cs +++ b/Volatility/Resources/BinaryResource.cs @@ -7,14 +7,18 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Binary_File +[ResourceDefinition(ResourceType.BinaryFile)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class BinaryResource : TypedResource { public uint DataSize { get; set; } public uint DataOffset { get; set; } public BinaryResource(uint dataOffset, uint dataSize) - : this(ResourceType.BinaryFile, dataOffset, dataSize) + : this() { + DataSize = dataSize; + DataOffset = dataOffset == 0 ? 0x10u : dataOffset; } protected BinaryResource(ResourceType resourceType, uint dataOffset, uint dataSize) @@ -24,8 +28,9 @@ protected BinaryResource(ResourceType resourceType, uint dataOffset, uint dataSi DataOffset = dataOffset == 0 ? 0x10u : dataOffset; } - public BinaryResource() : this(ResourceType.BinaryFile) + public BinaryResource() : base() { + DataOffset = 0x10; } protected BinaryResource(ResourceType resourceType) @@ -35,8 +40,12 @@ protected BinaryResource(ResourceType resourceType) } public BinaryResource(string path, Endian endianness = Endian.Agnostic) - : this(ResourceType.BinaryFile, path, endianness) + : base(path, endianness) { + if (DataOffset == 0) + { + DataOffset = 0x10; + } } protected BinaryResource(ResourceType resourceType, string path, Endian endianness = Endian.Agnostic) diff --git a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs index 510f973..cbfff42 100644 --- a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs +++ b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs @@ -7,6 +7,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Environment_Keyframe +[ResourceDefinition(ResourceType.EnvironmentKeyframe)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class EnvironmentKeyframe : TypedResource { public BloomData BloomSettings; @@ -16,10 +18,10 @@ public class EnvironmentKeyframe : TypedResource public LightingData LightingSettings; public CloudsData CloudSettings; - public EnvironmentKeyframe() : base(ResourceType.EnvironmentKeyframe) { } + public EnvironmentKeyframe() : base() { } public EnvironmentKeyframe(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.EnvironmentKeyframe, path, endianness) { } + : base(path, endianness) { } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs index beb3293..fc6d267 100644 --- a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs +++ b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs @@ -2,6 +2,8 @@ namespace Volatility.Resources; +[ResourceDefinition(ResourceType.EnvironmentTimeLine)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class EnvironmentTimeline : TypedResource { private const int HeaderSize = 0x10; @@ -106,10 +108,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann Locations = reader.ParseSection((long)locationsPtr, locationCount, r => ReadLocation(r, arch)).ToArray(); } - public EnvironmentTimeline() : base(ResourceType.EnvironmentTimeLine) { } + public EnvironmentTimeline() : base() { } public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.EnvironmentTimeLine, path, endianness) { } + : base(path, endianness) { } private static int GetLocationStructSize(Arch arch) { diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/Volatility/Resources/GuiPopup/GuiPopup.cs index 174f52b..e5903da 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/Volatility/Resources/GuiPopup/GuiPopup.cs @@ -2,6 +2,8 @@ namespace Volatility.Resources; +[ResourceDefinition(ResourceType.GuiPopup)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class GuiPopup : TypedResource { private const int HeaderSize = 0x40; @@ -87,10 +89,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } } - public GuiPopup() : base(ResourceType.GuiPopup) { } + public GuiPopup() : base() { } public GuiPopup(string path, Endian endianness) - : base(ResourceType.GuiPopup, path, endianness) { } + : base(path, endianness) { } private static uint AlignOffset(uint value, uint alignment) { diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index 028b157..06187cf 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -11,6 +11,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Instance_List +[ResourceDefinition(ResourceType.InstanceList)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class InstanceList : TypedResource { private const int HeaderSize = 0x10; @@ -22,10 +24,10 @@ public class InstanceList : TypedResource [EditorLabel("Instances"), EditorCategory("Instance List"), EditorTooltip("The list of instances in this list.")] public List Instances = []; - public InstanceList() : base(ResourceType.InstanceList) { } + public InstanceList() : base() { } public InstanceList(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.InstanceList, path, endianness) { } + : base(path, endianness) { } public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index a14a285..b93dd78 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/Resources/Model/Model.cs @@ -9,6 +9,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Model +[ResourceDefinition(ResourceType.Model)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class Model : TypedResource { private const int HeaderSize = 0x14; @@ -109,10 +111,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } } - public Model() : base(ResourceType.Model) { } + public Model() : base() { } public Model(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.Model, path, endianness) { } + : base(path, endianness) { } private static ModelData ReadModelData( ResourceBinaryReader reader, diff --git a/Volatility/Resources/Renderable/RenderableBPR.cs b/Volatility/Resources/Renderable/RenderableBPR.cs index f41addc..e02542c 100644 --- a/Volatility/Resources/Renderable/RenderableBPR.cs +++ b/Volatility/Resources/Renderable/RenderableBPR.cs @@ -1,5 +1,6 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.BPR)] public class RenderableBPR : RenderableBase { public override Endian ResourceEndian => Endian.LE; diff --git a/Volatility/Resources/Renderable/RenderableBase.cs b/Volatility/Resources/Renderable/RenderableBase.cs index 1358713..5f4947f 100644 --- a/Volatility/Resources/Renderable/RenderableBase.cs +++ b/Volatility/Resources/Renderable/RenderableBase.cs @@ -11,6 +11,7 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Renderable +[ResourceDefinition(ResourceType.Renderable)] public abstract class RenderableBase : TypedResource { public Vector3Plus BoundingSphere; @@ -39,10 +40,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann // TODO: Parse RenderableMeshes } - protected RenderableBase() : base(ResourceType.Renderable) { } + protected RenderableBase() : base() { } protected RenderableBase(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.Renderable, path, endianness) { } + : base(path, endianness) { } } diff --git a/Volatility/Resources/Renderable/RenderablePC.cs b/Volatility/Resources/Renderable/RenderablePC.cs index 5ef382a..2614008 100644 --- a/Volatility/Resources/Renderable/RenderablePC.cs +++ b/Volatility/Resources/Renderable/RenderablePC.cs @@ -1,5 +1,6 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.TUB)] public class RenderablePC : RenderableBase { public override Endian ResourceEndian => Endian.LE; diff --git a/Volatility/Resources/Renderable/RenderablePS3.cs b/Volatility/Resources/Renderable/RenderablePS3.cs index 575295c..a8b9bda 100644 --- a/Volatility/Resources/Renderable/RenderablePS3.cs +++ b/Volatility/Resources/Renderable/RenderablePS3.cs @@ -1,5 +1,6 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.PS3)] public class RenderablePS3 : RenderableBase { public override Endian ResourceEndian => Endian.BE; diff --git a/Volatility/Resources/Renderable/RenderableX360.cs b/Volatility/Resources/Renderable/RenderableX360.cs index 3335b2d..e83c6fd 100644 --- a/Volatility/Resources/Renderable/RenderableX360.cs +++ b/Volatility/Resources/Renderable/RenderableX360.cs @@ -1,5 +1,6 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.X360)] public class RenderableX360 : RenderableBase { public override Endian ResourceEndian => Endian.BE; diff --git a/Volatility/Resources/ResourceFactory.cs b/Volatility/Resources/ResourceFactory.cs index 338ed3d..7efc30d 100644 --- a/Volatility/Resources/ResourceFactory.cs +++ b/Volatility/Resources/ResourceFactory.cs @@ -1,3 +1,6 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + namespace Volatility.Resources; public static class ResourceFactory @@ -8,28 +11,28 @@ public static class ResourceFactory { ResourceCreatorRegistry registry = new(); - registry.AddWithPullAll(ResourceType.Texture, Platform.BPR, static path => new TextureBPR(path)); - registry.AddWithPullAll(ResourceType.Texture, Platform.TUB, static path => new TexturePC(path)); - registry.AddWithPullAll(ResourceType.Texture, Platform.X360, static path => new TextureX360(path)); - registry.AddWithPullAll(ResourceType.Texture, Platform.PS3, static path => new TexturePS3(path)); - - registry.AddEndianMapped(ResourceType.Splicer, static (path, endian) => new Splicer(path, endian)); - registry.Add(ResourceType.Renderable, Platform.BPR, static path => new RenderableBPR(path)); - registry.Add(ResourceType.Renderable, Platform.TUB, static path => new RenderablePC(path)); - registry.Add(ResourceType.Renderable, Platform.X360, static path => new RenderableX360(path)); - registry.Add(ResourceType.Renderable, Platform.PS3, static path => new RenderablePS3(path)); - registry.AddEndianMapped(ResourceType.InstanceList, static (path, endian) => new InstanceList(path, endian)); - registry.AddEndianMapped(ResourceType.Model, static (path, endian) => new Model(path, endian)); - registry.AddEndianMapped(ResourceType.EnvironmentKeyframe, static (path, endian) => new EnvironmentKeyframe(path, endian)); - registry.AddEndianMapped(ResourceType.EnvironmentTimeLine, static (path, endian) => new EnvironmentTimeline(path, endian)); - registry.AddEndianMapped(ResourceType.SnapshotData, static (path, endian) => new SnapshotData(path, endian)); - registry.AddEndianMapped(ResourceType.AttribSysVault, static (path, endian) => new AttribSysVault(path, endian)); - registry.AddEndianMapped(ResourceType.StreamedDeformationSpec, static (path, endian) => new StreamedDeformationSpec(path, endian)); - registry.AddEndianMapped(ResourceType.AptData, static (path, endian) => new AptData(path, endian)); - registry.AddEndianMapped(ResourceType.GuiPopup, static (path, endian) => new GuiPopup(path, endian)); - - registry.Add(ResourceType.Shader, Platform.Agnostic, static path => new ShaderBase(path)); - registry.Add(ResourceType.Shader, Platform.TUB, static path => new ShaderPC(path)); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); + AddRegisteredResource(registry); return registry.Build(); } @@ -50,6 +53,13 @@ public static Resource CreateResource(ResourceType resourceType, Platform platfo throw new InvalidPlatformException($"The '{resourceType}' type is not supported for the '{platform}' platform."); } + private static void AddRegisteredResource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TResource>( + ResourceCreatorRegistry registry) + where TResource : Resource + { + registry.AddRegistrations(typeof(TResource)); + } + private sealed class ResourceCreatorRegistry { private readonly Dictionary<(ResourceType, Platform), Func> _creators = new(); @@ -74,29 +84,119 @@ public void Add( }); } - public void AddWithPullAll( - ResourceType resourceType, - Platform platform, - Func creator) - where TResource : Resource + public void AddRegistrations( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) { - Add(resourceType, platform, creator, static resource => resource.PullAll()); - } + ResourceType resourceType = ResourceMetadata.GetResourceType(resourceClass); + ResourceRegistrationAttribute[] registrations = resourceClass + .GetCustomAttributes(inherit: false) + .ToArray(); - public void AddEndianMapped( - ResourceType resourceType, - Func creator) - where TResource : Resource - { - Add(resourceType, Platform.BPR, path => creator(path, Endian.LE)); - Add(resourceType, Platform.TUB, path => creator(path, Endian.LE)); - Add(resourceType, Platform.X360, path => creator(path, Endian.BE)); - Add(resourceType, Platform.PS3, path => creator(path, Endian.BE)); + foreach (ResourceRegistrationAttribute registration in registrations) + { + foreach (Platform platform in ExpandPlatforms(registration.Platforms)) + { + Func creator = registration.EndianMapped + ? CreateEndianMappedCreator(resourceClass, platform) + : CreatePathCreator(resourceClass); + + if (registration.PullAll) + { + creator = WrapWithPullAll(creator); + } + + AddCreator(resourceType, platform, creator); + } + } } public Dictionary<(ResourceType, Platform), Func> Build() { return _creators; } + + private static Func CreatePathCreator( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass) + { + ConstructorInfo? stringCtor = resourceClass.GetConstructor([typeof(string)]); + if (stringCtor != null) + { + return path => (Resource)stringCtor.Invoke([path]); + } + + ConstructorInfo? stringEndianCtor = resourceClass.GetConstructor([typeof(string), typeof(Endian)]); + if (stringEndianCtor != null) + { + return path => (Resource)stringEndianCtor.Invoke([path, Endian.Agnostic]); + } + + throw new InvalidOperationException( + $"Could not find a usable string constructor for resource class '{resourceClass.FullName}'."); + } + + private static Func CreateEndianMappedCreator( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type resourceClass, + Platform platform) + { + if (platform == Platform.Agnostic) + { + throw new InvalidOperationException( + $"Resource class '{resourceClass.FullName}' cannot use endian-mapped registration with Platform.Agnostic."); + } + + ConstructorInfo? constructor = resourceClass.GetConstructor([typeof(string), typeof(Endian)]); + if (constructor == null) + { + throw new InvalidOperationException( + $"Resource class '{resourceClass.FullName}' must expose a (string path, Endian endianness) constructor for endian-mapped registration."); + } + + Endian endianness = platform switch + { + Platform.BPR or Platform.TUB => Endian.LE, + Platform.X360 or Platform.PS3 => Endian.BE, + _ => throw new InvalidOperationException($"No default endianness mapping exists for platform '{platform}'."), + }; + + return path => (Resource)constructor.Invoke([path, endianness]); + } + + private static Func WrapWithPullAll(Func creator) + { + return path => + { + Resource resource = creator(path); + resource.PullAll(); + return resource; + }; + } + + private static IEnumerable ExpandPlatforms(RegistrationPlatforms platforms) + { + if ((platforms & RegistrationPlatforms.Agnostic) != 0) + { + yield return Platform.Agnostic; + } + + if ((platforms & RegistrationPlatforms.BPR) != 0) + { + yield return Platform.BPR; + } + + if ((platforms & RegistrationPlatforms.TUB) != 0) + { + yield return Platform.TUB; + } + + if ((platforms & RegistrationPlatforms.X360) != 0) + { + yield return Platform.X360; + } + + if ((platforms & RegistrationPlatforms.PS3) != 0) + { + yield return Platform.PS3; + } + } } } diff --git a/Volatility/Resources/ResourceMetadata.cs b/Volatility/Resources/ResourceMetadata.cs new file mode 100644 index 0000000..1f0af18 --- /dev/null +++ b/Volatility/Resources/ResourceMetadata.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace Volatility.Resources; + +[Flags] +public enum RegistrationPlatforms +{ + None = 0, + BPR = 1 << 0, + TUB = 1 << 1, + X360 = 1 << 2, + PS3 = 1 << 3, + Agnostic = 1 << 4, + All = BPR | TUB | X360 | PS3, +} + +[AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)] +public sealed class ResourceDefinitionAttribute : Attribute +{ + public ResourceDefinitionAttribute(ResourceType resourceType) + { + ResourceType = resourceType; + } + + public ResourceType ResourceType { get; } +} + +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public sealed class ResourceRegistrationAttribute : Attribute +{ + public ResourceRegistrationAttribute(RegistrationPlatforms platforms) + { + Platforms = platforms; + } + + public RegistrationPlatforms Platforms { get; } + public bool EndianMapped { get; init; } + public bool PullAll { get; init; } +} + +internal static class ResourceMetadata +{ + private static readonly ConcurrentDictionary ResourceTypes = new(); + + public static ResourceType GetResourceType(Type resourceClass) + { + return ResourceTypes.GetOrAdd(resourceClass, static type => + { + ResourceDefinitionAttribute? definition = type.GetCustomAttribute(inherit: true); + if (definition == null) + { + throw new InvalidOperationException( + $"Resource type metadata is missing for '{type.FullName}'. Add [ResourceDefinition(...)] to the resource or its base class."); + } + + return definition.ResourceType; + }); + } +} diff --git a/Volatility/Resources/Shader/ShaderBase.cs b/Volatility/Resources/Shader/ShaderBase.cs index d6b11db..334d3eb 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/Volatility/Resources/Shader/ShaderBase.cs @@ -1,5 +1,7 @@ namespace Volatility.Resources; +[ResourceDefinition(ResourceType.Shader)] +[ResourceRegistration(RegistrationPlatforms.Agnostic)] public class ShaderBase : TypedResource { [EditorCategory("Shader/Source"), EditorLabel("Source File"), EditorTooltip("Relative path to the HLSL source file.")] @@ -77,12 +79,12 @@ public bool TryReadShaderSourceText(out string shaderSourceText) return true; } - public ShaderBase() : base(ResourceType.Shader) { } + public ShaderBase() : base() { } - public ShaderBase(string path) : base(ResourceType.Shader, path) { } + public ShaderBase(string path) : base(path) { } public ShaderBase(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.Shader, path, endianness) { } + : base(path, endianness) { } } public enum ShaderStageType diff --git a/Volatility/Resources/Shader/ShaderPC.cs b/Volatility/Resources/Shader/ShaderPC.cs index a7b485d..02b37a2 100644 --- a/Volatility/Resources/Shader/ShaderPC.cs +++ b/Volatility/Resources/Shader/ShaderPC.cs @@ -5,6 +5,7 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.TUB)] public class ShaderPC : ShaderBase { public override Endian ResourceEndian => Endian.LE; diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs index eb1faff..4ed28ec 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs +++ b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBPR.cs @@ -6,6 +6,7 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.BPR)] public class ShaderProgramBufferBPR : ShaderProgramBufferBase { public override Endian ResourceEndian => Endian.LE; diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs index 8f05b97..779b2fc 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs +++ b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs @@ -1,9 +1,10 @@ namespace Volatility.Resources; +[ResourceDefinition(ResourceType.RwShaderProgramBuffer)] public class ShaderProgramBufferBase : TypedResource { - public ShaderProgramBufferBase() : base(ResourceType.RwShaderProgramBuffer) { } + public ShaderProgramBufferBase() : base() { } public ShaderProgramBufferBase(string path) - : base(ResourceType.RwShaderProgramBuffer, path) { } + : base(path) { } } diff --git a/Volatility/Resources/SnapshotData/SnapshotData.cs b/Volatility/Resources/SnapshotData/SnapshotData.cs index 9a3fa8f..901ae2a 100644 --- a/Volatility/Resources/SnapshotData/SnapshotData.cs +++ b/Volatility/Resources/SnapshotData/SnapshotData.cs @@ -1,5 +1,7 @@ namespace Volatility.Resources; +[ResourceDefinition(ResourceType.SnapshotData)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class SnapshotData : BinaryResource { private const int SnapshotHeaderSize = 0x10; @@ -45,10 +47,10 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann SnapshotStatuses = reader.ParseSection(statusesOffset, snapshotCount * channelCount, SnapshotStatusData.Read); } - public SnapshotData() : base(ResourceType.SnapshotData) { } + public SnapshotData() : base() { } public SnapshotData(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.SnapshotData, path, endianness) { } + : base(path, endianness) { } private int GetSnapshotCountForWrite() { diff --git a/Volatility/Resources/Splicer/Splicer.cs b/Volatility/Resources/Splicer/Splicer.cs index a072c9d..694cd1e 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/Volatility/Resources/Splicer/Splicer.cs @@ -11,6 +11,8 @@ namespace Volatility.Resources; // Learn More: // https://burnout.wiki/wiki/Splicer +[ResourceDefinition(ResourceType.Splicer)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class Splicer : BinaryResource { private const int Version = 1; @@ -178,10 +180,10 @@ public List GetLoadedSamples() return _samples; } - public Splicer() : base(ResourceType.Splicer) { } + public Splicer() : base() { } public Splicer(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.Splicer, path, endianness) { } + : base(path, endianness) { } private static List ReadSamples( ResourceBinaryReader reader, diff --git a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs index e1e222a..5790ecf 100644 --- a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs +++ b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs @@ -312,6 +312,8 @@ public GlassPaneSpec(ResourceBinaryReader reader) } } +[ResourceDefinition(ResourceType.StreamedDeformationSpec)] +[ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class StreamedDeformationSpec : TypedResource { public const int HeaderSize32 = 0x6B0; @@ -576,10 +578,10 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.WriteSection(LightTagsInfo.Offset, LightTags, WriteLocatorPointSpec); } - public StreamedDeformationSpec() : base(ResourceType.StreamedDeformationSpec) { } + public StreamedDeformationSpec() : base() { } public StreamedDeformationSpec(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.StreamedDeformationSpec, path, endianness) { } + : base(path, endianness) { } // Section writers diff --git a/Volatility/Resources/Texture/TextureBPR.cs b/Volatility/Resources/Texture/TextureBPR.cs index 3bf1a80..d712e66 100644 --- a/Volatility/Resources/Texture/TextureBPR.cs +++ b/Volatility/Resources/Texture/TextureBPR.cs @@ -2,6 +2,7 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.BPR, PullAll = true)] public class TextureBPR : TextureBase { public override Endian ResourceEndian => Endian.LE; diff --git a/Volatility/Resources/Texture/TextureBase.cs b/Volatility/Resources/Texture/TextureBase.cs index f59c8ac..ccf761d 100644 --- a/Volatility/Resources/Texture/TextureBase.cs +++ b/Volatility/Resources/Texture/TextureBase.cs @@ -8,6 +8,7 @@ // Learn More: // https://burnout.wiki/wiki/Texture +[ResourceDefinition(ResourceType.Texture)] public abstract class TextureBase : TypedResource { [EditorCategory("Texture"), EditorLabel("Width"), EditorTooltip("The target width of the texture.")] @@ -92,10 +93,10 @@ public override void PushAll() PushInternalFlags(); } - protected TextureBase() : base(ResourceType.Texture) => Depth = 1; + protected TextureBase() : base() => Depth = 1; protected TextureBase(string path, Endian endianness = Endian.Agnostic) - : base(ResourceType.Texture, path, endianness) { } + : base(path, endianness) { } } // BPR formatted but converted for each platform public enum DIMENSION : int diff --git a/Volatility/Resources/Texture/TexturePC.cs b/Volatility/Resources/Texture/TexturePC.cs index 1e90bd4..682e5c1 100644 --- a/Volatility/Resources/Texture/TexturePC.cs +++ b/Volatility/Resources/Texture/TexturePC.cs @@ -2,6 +2,7 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.TUB, PullAll = true)] public class TexturePC : TextureBase { public override Endian ResourceEndian => Endian.LE; diff --git a/Volatility/Resources/Texture/TexturePS3.cs b/Volatility/Resources/Texture/TexturePS3.cs index 80c65f2..2ed42b6 100644 --- a/Volatility/Resources/Texture/TexturePS3.cs +++ b/Volatility/Resources/Texture/TexturePS3.cs @@ -2,6 +2,7 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.PS3, PullAll = true)] public class TexturePS3 : TextureBase { public override Endian ResourceEndian => Endian.BE; @@ -178,4 +179,4 @@ public enum StoreType : int TYPE_3D = 3, TYPE_CUBE = 0x10002, TYPE_FORCEENUMSIZEINT = 0x7FFFFFFF -} \ No newline at end of file +} diff --git a/Volatility/Resources/Texture/TextureX360.cs b/Volatility/Resources/Texture/TextureX360.cs index 8b90030..15f9414 100644 --- a/Volatility/Resources/Texture/TextureX360.cs +++ b/Volatility/Resources/Texture/TextureX360.cs @@ -8,6 +8,7 @@ namespace Volatility.Resources; +[ResourceRegistration(RegistrationPlatforms.X360, PullAll = true)] public class TextureX360 : TextureBase { public override Endian ResourceEndian => Endian.BE; diff --git a/Volatility/Resources/TypedResource.cs b/Volatility/Resources/TypedResource.cs index b38ed60..026347f 100644 --- a/Volatility/Resources/TypedResource.cs +++ b/Volatility/Resources/TypedResource.cs @@ -1,14 +1,28 @@ +using System.Collections.Concurrent; + namespace Volatility.Resources; public abstract class TypedResource : Resource { + private static readonly ConcurrentDictionary RuntimeResourceTypes = new(); private readonly ResourceType _resourceType; + protected TypedResource() + { + _resourceType = GetRuntimeResourceTypeFor(GetType()); + } + protected TypedResource(ResourceType resourceType) { _resourceType = resourceType; } + protected TypedResource(string path, Endian endianness = Endian.Agnostic) + { + _resourceType = GetRuntimeResourceTypeFor(GetType()); + InitializeFromPath(path, endianness); + } + protected TypedResource(ResourceType resourceType, string path, Endian endianness = Endian.Agnostic) : this(resourceType) { @@ -16,4 +30,9 @@ protected TypedResource(ResourceType resourceType, string path, Endian endiannes } public sealed override ResourceType ResourceType => _resourceType; + + private static ResourceType GetRuntimeResourceTypeFor(Type resourceClass) + { + return RuntimeResourceTypes.GetOrAdd(resourceClass, ResourceMetadata.GetResourceType); + } } From ba24097a4e91079f989123efa283ac1dcecbccf4 Mon Sep 17 00:00:00 2001 From: Adriwin <76881633+Adriwin06@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:34:41 +0200 Subject: [PATCH 10/17] Delete useless TypedResource --- Volatility/Resources/AptData/AptData.cs | 2 +- .../AttribSysVault/AttribSysVault.cs | 2 +- Volatility/Resources/BinaryResource.cs | 24 +----------- .../EnvironmentKeyframe.cs | 2 +- .../EnvironmentTimeLine.cs | 2 +- Volatility/Resources/GuiPopup/GuiPopup.cs | 2 +- .../Resources/InstanceList/InstanceList.cs | 2 +- Volatility/Resources/Model/Model.cs | 2 +- .../Resources/Renderable/RenderableBase.cs | 2 +- Volatility/Resources/Resource.cs | 2 +- Volatility/Resources/Shader/ShaderBase.cs | 2 +- .../ShaderProgramBufferBase.cs | 2 +- .../StreamedDeformationSpec.cs | 2 +- Volatility/Resources/Texture/TextureBase.cs | 2 +- Volatility/Resources/TypedResource.cs | 38 ------------------- 15 files changed, 14 insertions(+), 74 deletions(-) delete mode 100644 Volatility/Resources/TypedResource.cs diff --git a/Volatility/Resources/AptData/AptData.cs b/Volatility/Resources/AptData/AptData.cs index ff79891..8591596 100644 --- a/Volatility/Resources/AptData/AptData.cs +++ b/Volatility/Resources/AptData/AptData.cs @@ -4,7 +4,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.AptData)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class AptData : TypedResource +public class AptData : Resource { public string MovieName; public string BaseComponentName; diff --git a/Volatility/Resources/AttribSysVault/AttribSysVault.cs b/Volatility/Resources/AttribSysVault/AttribSysVault.cs index 27e9b78..ad623ca 100644 --- a/Volatility/Resources/AttribSysVault/AttribSysVault.cs +++ b/Volatility/Resources/AttribSysVault/AttribSysVault.cs @@ -11,7 +11,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.AttribSysVault)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class AttribSysVault : TypedResource +public class AttribSysVault : Resource { public ulong VltDataOffset { get; set; } public uint VltSizeInBytes { get; set; } diff --git a/Volatility/Resources/BinaryResource.cs b/Volatility/Resources/BinaryResource.cs index 7964209..2220362 100644 --- a/Volatility/Resources/BinaryResource.cs +++ b/Volatility/Resources/BinaryResource.cs @@ -9,7 +9,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.BinaryFile)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class BinaryResource : TypedResource +public class BinaryResource : Resource { public uint DataSize { get; set; } public uint DataOffset { get; set; } @@ -21,24 +21,11 @@ public BinaryResource(uint dataOffset, uint dataSize) DataOffset = dataOffset == 0 ? 0x10u : dataOffset; } - protected BinaryResource(ResourceType resourceType, uint dataOffset, uint dataSize) - : base(resourceType) - { - DataSize = dataSize; - DataOffset = dataOffset == 0 ? 0x10u : dataOffset; - } - public BinaryResource() : base() { DataOffset = 0x10; } - protected BinaryResource(ResourceType resourceType) - : base(resourceType) - { - DataOffset = 0x10; - } - public BinaryResource(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { @@ -48,15 +35,6 @@ public BinaryResource(string path, Endian endianness = Endian.Agnostic) } } - protected BinaryResource(ResourceType resourceType, string path, Endian endianness = Endian.Agnostic) - : base(resourceType, path, endianness) - { - if (DataOffset == 0) - { - DataOffset = 0x10; - } - } - public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); diff --git a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs index cbfff42..afb494e 100644 --- a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs +++ b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs @@ -9,7 +9,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.EnvironmentKeyframe)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class EnvironmentKeyframe : TypedResource +public class EnvironmentKeyframe : Resource { public BloomData BloomSettings; public VignetteData VignetteSettings; diff --git a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs index fc6d267..0b4f6d1 100644 --- a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs +++ b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs @@ -4,7 +4,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.EnvironmentTimeLine)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class EnvironmentTimeline : TypedResource +public class EnvironmentTimeline : Resource { private const int HeaderSize = 0x10; private const int SectionAlignment = 0x10; diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/Volatility/Resources/GuiPopup/GuiPopup.cs index e5903da..c6233a6 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/Volatility/Resources/GuiPopup/GuiPopup.cs @@ -4,7 +4,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.GuiPopup)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class GuiPopup : TypedResource +public class GuiPopup : Resource { private const int HeaderSize = 0x40; private const int PopupStructSize = 0xC0; diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index 06187cf..dae22cc 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -13,7 +13,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.InstanceList)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class InstanceList : TypedResource +public class InstanceList : Resource { private const int HeaderSize = 0x10; private const int SectionAlignment = 0x10; diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index b93dd78..e7c8d41 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/Resources/Model/Model.cs @@ -11,7 +11,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.Model)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class Model : TypedResource +public class Model : Resource { private const int HeaderSize = 0x14; private const int RenderableOffsetSize = sizeof(uint); diff --git a/Volatility/Resources/Renderable/RenderableBase.cs b/Volatility/Resources/Renderable/RenderableBase.cs index 5f4947f..6b5572b 100644 --- a/Volatility/Resources/Renderable/RenderableBase.cs +++ b/Volatility/Resources/Renderable/RenderableBase.cs @@ -12,7 +12,7 @@ namespace Volatility.Resources; // https://burnout.wiki/wiki/Renderable [ResourceDefinition(ResourceType.Renderable)] -public abstract class RenderableBase : TypedResource +public abstract class RenderableBase : Resource { public Vector3Plus BoundingSphere; public ushort Version; diff --git a/Volatility/Resources/Resource.cs b/Volatility/Resources/Resource.cs index 482b760..d5178d9 100644 --- a/Volatility/Resources/Resource.cs +++ b/Volatility/Resources/Resource.cs @@ -19,7 +19,7 @@ public abstract class Resource [EditorCategory("Import Data"), EditorLabel("Unpacker"), EditorTooltip("The tool used to extract this resource from a bundle.")] public Unpacker Unpacker = Unpacker.Raw; - public virtual ResourceType ResourceType => ResourceType.Invalid; + public ResourceType ResourceType => ResourceMetadata.GetResourceType(GetType()); public virtual Endian ResourceEndian => Endian.Agnostic; // Forced endianness for platform-specific resources (e.g. Textures) public virtual Platform ResourcePlatform => Platform.Agnostic; public virtual Arch ResourceArch => Arch; diff --git a/Volatility/Resources/Shader/ShaderBase.cs b/Volatility/Resources/Shader/ShaderBase.cs index 334d3eb..ab25ef5 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/Volatility/Resources/Shader/ShaderBase.cs @@ -2,7 +2,7 @@ [ResourceDefinition(ResourceType.Shader)] [ResourceRegistration(RegistrationPlatforms.Agnostic)] -public class ShaderBase : TypedResource +public class ShaderBase : Resource { [EditorCategory("Shader/Source"), EditorLabel("Source File"), EditorTooltip("Relative path to the HLSL source file.")] public string? ShaderSourcePath { get; set; } diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs index 779b2fc..2a8f228 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs +++ b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs @@ -1,7 +1,7 @@ namespace Volatility.Resources; [ResourceDefinition(ResourceType.RwShaderProgramBuffer)] -public class ShaderProgramBufferBase : TypedResource +public class ShaderProgramBufferBase : Resource { public ShaderProgramBufferBase() : base() { } diff --git a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs index 5790ecf..136c2c2 100644 --- a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs +++ b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs @@ -314,7 +314,7 @@ public GlassPaneSpec(ResourceBinaryReader reader) [ResourceDefinition(ResourceType.StreamedDeformationSpec)] [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] -public class StreamedDeformationSpec : TypedResource +public class StreamedDeformationSpec : Resource { public const int HeaderSize32 = 0x6B0; public const int HeaderSize64 = 0x6F0; diff --git a/Volatility/Resources/Texture/TextureBase.cs b/Volatility/Resources/Texture/TextureBase.cs index ccf761d..a9d439a 100644 --- a/Volatility/Resources/Texture/TextureBase.cs +++ b/Volatility/Resources/Texture/TextureBase.cs @@ -9,7 +9,7 @@ // https://burnout.wiki/wiki/Texture [ResourceDefinition(ResourceType.Texture)] -public abstract class TextureBase : TypedResource +public abstract class TextureBase : Resource { [EditorCategory("Texture"), EditorLabel("Width"), EditorTooltip("The target width of the texture.")] public ushort Width { get; set; } diff --git a/Volatility/Resources/TypedResource.cs b/Volatility/Resources/TypedResource.cs deleted file mode 100644 index 026347f..0000000 --- a/Volatility/Resources/TypedResource.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Concurrent; - -namespace Volatility.Resources; - -public abstract class TypedResource : Resource -{ - private static readonly ConcurrentDictionary RuntimeResourceTypes = new(); - private readonly ResourceType _resourceType; - - protected TypedResource() - { - _resourceType = GetRuntimeResourceTypeFor(GetType()); - } - - protected TypedResource(ResourceType resourceType) - { - _resourceType = resourceType; - } - - protected TypedResource(string path, Endian endianness = Endian.Agnostic) - { - _resourceType = GetRuntimeResourceTypeFor(GetType()); - InitializeFromPath(path, endianness); - } - - protected TypedResource(ResourceType resourceType, string path, Endian endianness = Endian.Agnostic) - : this(resourceType) - { - InitializeFromPath(path, endianness); - } - - public sealed override ResourceType ResourceType => _resourceType; - - private static ResourceType GetRuntimeResourceTypeFor(Type resourceClass) - { - return RuntimeResourceTypes.GetOrAdd(resourceClass, ResourceMetadata.GetResourceType); - } -} From b87ee87ced713c1919cd4e02dc25db7b3feef3e3 Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Mon, 20 Apr 2026 20:29:55 -0400 Subject: [PATCH 11/17] remove unnecessary edits, hygiene --- Volatility/Resources/AptData/AptData.cs | 7 +- .../AttribSysVault/AttribSysVault.cs | 3 +- .../EnvironmentKeyframe.cs | 3 +- .../EnvironmentTimeLine.cs | 29 ++- Volatility/Resources/GuiPopup/GuiPopup.cs | 112 ++++++----- .../Resources/InstanceList/InstanceList.cs | 12 +- Volatility/Resources/Model/Model.cs | 90 +++++---- .../Resources/Renderable/RenderableBase.cs | 3 +- Volatility/Resources/ResourceImport.cs | 8 +- Volatility/Resources/Shader/ShaderBase.cs | 12 +- .../ShaderProgramBufferBase.cs | 12 +- .../Resources/SnapshotData/SnapshotData.cs | 6 +- Volatility/Resources/Splicer/Splicer.cs | 9 +- .../StreamedDeformationSpec.cs | 5 +- Volatility/Resources/Texture/TextureBPR.cs | 16 +- Volatility/Resources/Texture/TextureBase.cs | 5 +- Volatility/Resources/Texture/TexturePC.cs | 20 -- Volatility/Resources/Texture/TextureX360.cs | 34 +--- Volatility/Utilities/ResourceUtilities.cs | 5 + autotest_recap.md | 174 ------------------ 20 files changed, 193 insertions(+), 372 deletions(-) delete mode 100644 autotest_recap.md diff --git a/Volatility/Resources/AptData/AptData.cs b/Volatility/Resources/AptData/AptData.cs index 8591596..319554c 100644 --- a/Volatility/Resources/AptData/AptData.cs +++ b/Volatility/Resources/AptData/AptData.cs @@ -10,6 +10,10 @@ public class AptData : Resource public string BaseComponentName; public GuiGeometryObject GuiGeometry; + public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) + { + base.WriteToStream(writer); + } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { base.ParseFromStream(reader, endianness); @@ -100,8 +104,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public AptData() : base() { } - public AptData(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public AptData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } public struct GuiGeometryObject diff --git a/Volatility/Resources/AttribSysVault/AttribSysVault.cs b/Volatility/Resources/AttribSysVault/AttribSysVault.cs index ad623ca..4b77f04 100644 --- a/Volatility/Resources/AttribSysVault/AttribSysVault.cs +++ b/Volatility/Resources/AttribSysVault/AttribSysVault.cs @@ -75,8 +75,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann PtrN = []; Data = string.Empty; - Arch arch = ResourceArch; - if (arch == Arch.x64) + if (ResourceArch == Arch.x64) { VltDataOffset = reader.ReadUInt64(); VltSizeInBytes = reader.ReadUInt32(); diff --git a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs index afb494e..9a09998 100644 --- a/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs +++ b/Volatility/Resources/EnvironmentKeyframe/EnvironmentKeyframe.cs @@ -20,8 +20,7 @@ public class EnvironmentKeyframe : Resource public EnvironmentKeyframe() : base() { } - public EnvironmentKeyframe(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public EnvironmentKeyframe(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs index 0b4f6d1..84aad07 100644 --- a/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs +++ b/Volatility/Resources/EnvironmentTimeLine/EnvironmentTimeLine.cs @@ -6,11 +6,9 @@ namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class EnvironmentTimeline : Resource { - private const int HeaderSize = 0x10; private const int SectionAlignment = 0x10; private const int KeyframeTimeSize = sizeof(float); private const int KeyframeReferencePlaceholderSize = sizeof(uint); - private const int ImportEntrySize = 0x10; public LocationData[] Locations = []; @@ -18,15 +16,13 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); - Arch arch = ResourceArch; LocationData[] locations = Locations ?? []; - int locationStructSize = GetLocationStructSize(arch); - long currentOffset = HeaderSize; + long currentOffset = 0x10; // Header size ulong locationsOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, locations.Length, - locationStructSize, + GetLocationStructSize(ResourceArch), SectionAlignment); ulong[] keyframeTimesOffsets = new ulong[locations.Length]; @@ -54,16 +50,16 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes long importsOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, - totalImports * ImportEntrySize, + totalImports * ResourceImport.ImportEntrySize, SectionAlignment); - writer.Write(0x1); + writer.Write(0x1); // Version writer.Write(locations.Length); - writer.Write((uint)locationsOffset); - writer.Write(0x0); + writer.Write((uint)locationsOffset); // Locations Pointer + writer.Write(0x0); // Padding writer.WriteSection(locationsOffset, locations, (w, location, index) => - WriteLocationHeader(w, location, arch, keyframeTimesOffsets[index], keyframeRefsOffsets[index])); + WriteLocationHeader(w, location, ResourceArch, keyframeTimesOffsets[index], keyframeRefsOffsets[index])); for (int i = 0; i < locations.Length; i++) { @@ -93,8 +89,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann { base.ParseFromStream(reader, endianness); - Arch arch = ResourceArch; - int version = reader.ReadInt32(); if (version != 1) { @@ -102,16 +96,15 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann } int locationCount = reader.ReadInt32(); - uint locationsPtr = reader.ReadUInt32(); - reader.BaseStream.Seek(0x4, SeekOrigin.Current); + ulong locationsPtr = reader.ReadPointer(ResourceArch); + reader.BaseStream.Seek(0x10, SeekOrigin.Begin); - Locations = reader.ParseSection((long)locationsPtr, locationCount, r => ReadLocation(r, arch)).ToArray(); + Locations = reader.ParseSection(locationsPtr, locationCount, r => ReadLocation(r, ResourceArch)).ToArray(); } public EnvironmentTimeline() : base() { } - public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public EnvironmentTimeline(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } private static int GetLocationStructSize(Arch arch) { diff --git a/Volatility/Resources/GuiPopup/GuiPopup.cs b/Volatility/Resources/GuiPopup/GuiPopup.cs index c6233a6..7360721 100644 --- a/Volatility/Resources/GuiPopup/GuiPopup.cs +++ b/Volatility/Resources/GuiPopup/GuiPopup.cs @@ -6,10 +6,7 @@ namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class GuiPopup : Resource { - private const int HeaderSize = 0x40; private const int PopupStructSize = 0xC0; - private const int PopupOffsetEntrySize = sizeof(uint); - private const int HeaderAlignment = 0x40; public List Popups { get; set; } = []; @@ -17,28 +14,39 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); - uint count = (uint)Popups.Count; - uint popupOffsetsStart = HeaderSize; - uint firstPopupOffset = AlignOffset(popupOffsetsStart + (count * PopupOffsetEntrySize), HeaderAlignment); - uint totalSize = firstPopupOffset + (count * PopupStructSize); + const int PopupOffsetsStart = 0x40; - writer.Write(popupOffsetsStart); - writer.Write((short)Popups.Count); - writer.Write((short)totalSize); - writer.Write(new byte[HeaderSize - 0x8]); + Arch arch = ResourceArch; + int popupCount = Popups.Count; + int popupOffsetEntrySize = ResourceUtilities.GetPointerSize(arch); + long firstPopupOffset = ResourceUtilities.AlignOffset( + PopupOffsetsStart + ((long)popupCount * popupOffsetEntrySize), + 0x10); + long totalSize = firstPopupOffset + ((long)popupCount * PopupStructSize); + + if (popupCount > short.MaxValue) + { + throw new InvalidDataException($"GuiPopup count {popupCount} exceeds int16_t storage."); + } - for (int i = 0; i < Popups.Count; i++) + if (totalSize > short.MaxValue) { - writer.Write(firstPopupOffset + (uint)(i * PopupStructSize)); + throw new InvalidDataException($"GuiPopup size 0x{totalSize:X} exceeds int16_t storage."); } - PaddingUtilities.WritePadding(writer.BaseStream, HeaderAlignment); + writer.WritePointer(PopupOffsetsStart, arch); + writer.Write((short)popupCount); + writer.Write((short)totalSize); + writer.WriteFixedBytes(null, PopupOffsetsStart - (int)writer.BaseStream.Position); - for (int i = 0; i < Popups.Count; i++) + writer.BaseStream.Position = PopupOffsetsStart; + for (int i = 0; i < popupCount; i++) { - writer.BaseStream.Position = firstPopupOffset + (i * PopupStructSize); - Popup.Write(writer, Popups[i]); + writer.WritePointer((ulong)(firstPopupOffset + ((long)i * PopupStructSize)), arch); } + + writer.WriteFixedBytes(null, (int)(firstPopupOffset - writer.BaseStream.Position)); + writer.WriteSection((ulong)firstPopupOffset, Popups, Popup.Write); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) @@ -47,33 +55,56 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann Popups.Clear(); - uint dataPtr = reader.ReadUInt32(); - short count = reader.ReadInt16(); + Arch arch = ResourceArch; + int popupOffsetEntrySize = ResourceUtilities.GetPointerSize(arch); + + ulong dataPtr = reader.ReadPointer(arch); + short countRaw = reader.ReadInt16(); short totalSize = reader.ReadInt16(); + if (arch == Arch.x64) + { + reader.BaseStream.Seek(sizeof(uint), SeekOrigin.Current); + } - if (dataPtr < HeaderSize) + if (countRaw < 0) + { + throw new InvalidDataException($"GuiPopup popup count cannot be negative. Found {countRaw}."); + } + + int count = countRaw; + + if (count > 0 && dataPtr == 0) + { + throw new InvalidDataException( + "GuiPopup pointer table cannot be null when popup count is greater than zero."); + } + + if (dataPtr != 0 && dataPtr < (ulong)reader.BaseStream.Position) { throw new InvalidDataException( - $"GuiPopup data pointer mismatch! Expected >= 0x{HeaderSize:X}, found 0x{dataPtr:X}."); + $"GuiPopup data pointer mismatch! Expected >= 0x{reader.BaseStream.Position:X}, found 0x{dataPtr:X}."); } - long expectedMinimumSize = dataPtr + (count * PopupOffsetEntrySize); - if (reader.BaseStream.Length < expectedMinimumSize) + long expectedMinimumSize = (long)dataPtr + ((long)count * popupOffsetEntrySize); + if (count > 0 && reader.BaseStream.Length < expectedMinimumSize) { throw new InvalidDataException( $"GuiPopup offset table exceeds file length. Needed 0x{expectedMinimumSize:X}, found 0x{reader.BaseStream.Length:X}."); } - List popupOffsets = reader.ParseSection(dataPtr, count, r => r.ReadUInt32()); + List popupOffsets = count > 0 + ? reader.ParseSection(dataPtr, count, r => r.ReadPointer(arch)) + : []; + for (int i = 0; i < popupOffsets.Count; i++) { - uint popupOffset = popupOffsets[i]; + ulong popupOffset = popupOffsets[i]; if (popupOffset == 0) { continue; } - if (popupOffset + PopupStructSize > reader.BaseStream.Length) + if (popupOffset + PopupStructSize > (ulong)reader.BaseStream.Length) { throw new InvalidDataException( $"GuiPopup entry {i} at 0x{popupOffset:X} exceeds file length 0x{reader.BaseStream.Length:X}."); @@ -94,12 +125,6 @@ public GuiPopup() : base() { } public GuiPopup(string path, Endian endianness) : base(path, endianness) { } - private static uint AlignOffset(uint value, uint alignment) - { - uint remainder = value % alignment; - return remainder == 0 ? value : value + (alignment - remainder); - } - public enum PopupStyle : int { E_POPUPSTYLE_CRASHNAV_WAIT = 0, @@ -153,25 +178,16 @@ public struct Popup public string Button2Id; public PopupParamTypes Button2Param; public bool Button2ParamUsed; - [EditorHidden] - public byte[] NamePaddingBytes; - [EditorHidden] - public byte[] Button2PaddingBytes; - [EditorHidden] - public byte[] TrailingBytes; public static Popup Read(ResourceBinaryReader reader) { Popup popup = new() { NameId = reader.ReadUInt64(), - Name = ResourceUtilities.ReadFixedString(reader, 13), - NamePaddingBytes = new byte[0x3], - Button2PaddingBytes = new byte[0x3], - TrailingBytes = new byte[0x7] + Name = ResourceUtilities.ReadFixedString(reader, 13) }; - popup.NamePaddingBytes = reader.ReadBytes(0x3); + reader.BaseStream.Seek(0x3, SeekOrigin.Current); popup.Style = (PopupStyle)reader.ReadInt32(); popup.Icon = (PopupIcons)reader.ReadInt32(); @@ -184,11 +200,11 @@ public static Popup Read(ResourceBinaryReader reader) popup.Button1Param = (PopupParamTypes)reader.ReadInt32(); popup.Button1ParamUsed = reader.ReadByte() != 0; popup.Button2Id = ResourceUtilities.ReadFixedString(reader, 32); - popup.Button2PaddingBytes = reader.ReadBytes(0x3); + reader.BaseStream.Seek(0x3, SeekOrigin.Current); popup.Button2Param = (PopupParamTypes)reader.ReadInt32(); popup.Button2ParamUsed = reader.ReadByte() != 0; - popup.TrailingBytes = reader.ReadBytes(0x7); + reader.BaseStream.Seek(0x7, SeekOrigin.Current); return popup; } @@ -197,7 +213,7 @@ public static void Write(ResourceBinaryWriter writer, Popup popup) { writer.Write(popup.NameId); ResourceUtilities.WriteFixedString(writer, popup.Name, 13); - writer.WriteFixedBytes(popup.NamePaddingBytes, 0x3); + writer.WriteFixedBytes(null, 0x3); writer.Write((int)popup.Style); writer.Write((int)popup.Icon); ResourceUtilities.WriteFixedString(writer, popup.TitleId, 32); @@ -209,10 +225,10 @@ public static void Write(ResourceBinaryWriter writer, Popup popup) writer.Write((int)popup.Button1Param); writer.Write((byte)(popup.Button1ParamUsed ? 1 : 0)); ResourceUtilities.WriteFixedString(writer, popup.Button2Id, 32); - writer.WriteFixedBytes(popup.Button2PaddingBytes, 0x3); + writer.WriteFixedBytes(null, 0x3); writer.Write((int)popup.Button2Param); writer.Write((byte)(popup.Button2ParamUsed ? 1 : 0)); - writer.WriteFixedBytes(popup.TrailingBytes, 0x7); + writer.WriteFixedBytes(null, 0x7); } } } diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index dae22cc..6e5d460 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -33,8 +33,7 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); - Arch arch = ResourceArch; - int instanceBlockSize = GetInstanceBlockSize(arch); + int instanceBlockSize = GetInstanceBlockSize(ResourceArch); uint entryCount = (uint)Instances.Count; long currentOffset = HeaderSize; @@ -48,7 +47,7 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.Write(NumInstances); writer.Write(1u); - writer.WriteSection(instanceListOffset, Instances, (w, instance) => WriteInstanceBlock(w, instance, arch)); + writer.WriteSection(instanceListOffset, Instances, (w, instance) => WriteInstanceBlock(w, instance, ResourceArch)); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) @@ -91,7 +90,7 @@ private static Instance ReadInstance( long blockStart = reader.BaseStream.Position; ResourceImport.ReadExternalImport(blockStart, reader, importBlockOffset, out ResourceImport modelReference); - reader.BaseStream.Seek(blockStart + GetImportPlaceholderSize(arch), SeekOrigin.Begin); + reader.BaseStream.Seek(blockStart + ResourceUtilities.GetPointerSize(arch), SeekOrigin.Begin); short backdropZoneId = reader.ReadInt16(); reader.BaseStream.Seek(0x2, SeekOrigin.Current); @@ -110,11 +109,6 @@ private static Instance ReadInstance( }; } - private static int GetImportPlaceholderSize(Arch arch) - { - return arch == Arch.x64 ? sizeof(ulong) : sizeof(uint); - } - private static void WriteInstanceBlock(ResourceBinaryWriter writer, Instance instance, Arch arch) { long blockStart = writer.BaseStream.Position; diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index e7c8d41..3cd6f89 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/Resources/Model/Model.cs @@ -13,11 +13,8 @@ namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class Model : Resource { - private const int HeaderSize = 0x14; - private const int RenderableOffsetSize = sizeof(uint); private const int StateSize = sizeof(byte); private const int LodDistanceSize = sizeof(float); - private const int ImportEntrySize = 0x10; [EditorHidden] public uint HeaderMetadata; @@ -32,16 +29,18 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); + Arch arch = ResourceArch; int modelCount = ModelDatas.Count; if (modelCount > byte.MaxValue) { throw new InvalidDataException("Model resources cannot store more than 255 renderables."); } - long currentOffset = HeaderSize; + int renderablePointerSize = ResourceUtilities.GetPointerSize(arch); + long currentOffset = GetHeaderSize(arch); long renderablesOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, - modelCount * RenderableOffsetSize, + modelCount * renderablePointerSize, 1); long statesOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, @@ -51,16 +50,17 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes ref currentOffset, modelCount * LodDistanceSize, sizeof(uint)); - writer.Write((uint)renderablesOffset); - writer.Write((uint)statesOffset); - writer.Write((uint)lodDistancesOffset); + + writer.WritePointer((ulong)renderablesOffset, arch); + writer.WritePointer((ulong)statesOffset, arch); + writer.WritePointer((ulong)lodDistancesOffset, arch); writer.Write(HeaderMetadata); writer.Write((byte)modelCount); writer.Write(Flags); writer.Write((byte)modelCount); writer.Write((byte)0x02); - writer.WriteSection(renderablesOffset, ModelDatas, static (w, _, index) => w.Write((uint)(index * ImportEntrySize))); + writer.WriteSection(renderablesOffset, ModelDatas, (w, _, index) => w.WritePointer((ulong)(index * ResourceImport.ImportEntrySize), arch)); writer.WriteSection(statesOffset, ModelDatas, (w, modelData) => w.Write((byte)modelData.State)); writer.WriteSection(lodDistancesOffset, ModelDatas, (w, modelData) => w.Write(modelData.LODDistance)); } @@ -69,41 +69,49 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann { base.ParseFromStream(reader, endianness); - reader.BaseStream.Seek(0x13, SeekOrigin.Begin); - if (reader.ReadByte() != 0x2) - { - throw new InvalidDataException("Version mismatch!"); - } + Arch arch = ResourceArch; - reader.BaseStream.Seek(0x0, SeekOrigin.Begin); - - uint renderablesPtr = reader.ReadUInt32(); - uint renderableStatesPtr = reader.ReadUInt32(); - uint lodDistancesPtr = reader.ReadUInt32(); + ulong renderablesPtr = reader.ReadPointer(arch); + ulong renderableStatesPtr = reader.ReadPointer(arch); + ulong lodDistancesPtr = reader.ReadPointer(arch); HeaderMetadata = reader.ReadUInt32(); byte numRenderables = reader.ReadByte(); + Flags = reader.ReadByte(); + byte numStates = reader.ReadByte(); + byte version = reader.ReadByte(); + + if (version != 0x2) + { + throw new InvalidDataException($"Version mismatch! Version should be 2. (Found version {version})"); + } + if (numRenderables == 0) { Console.WriteLine("WARNING: Found no renderables in this model!"); } - Flags = reader.ReadByte(); + if (numStates != numRenderables) + { + throw new InvalidDataException( + $"Unsupported model header: numStates ({numStates}) does not match numRenderables ({numRenderables})."); + } + int renderablePointerSize = ResourceUtilities.GetPointerSize(arch); long importsOffset = Math.Max( - lodDistancesPtr + (numRenderables * LodDistanceSize), + (long)lodDistancesPtr + (numStates * LodDistanceSize), Math.Max( - renderablesPtr + (numRenderables * RenderableOffsetSize), - renderableStatesPtr + (numRenderables * StateSize))); + (long)renderablesPtr + (numRenderables * renderablePointerSize), + (long)renderableStatesPtr + (numStates * StateSize))); ModelDatas.Clear(); - for (int i = 0; i < numRenderables; i++) + for (int i = 0; i < numStates; i++) { ModelDatas.Add(ReadModelData( reader, + arch, i, - numRenderables, renderablesPtr, renderableStatesPtr, lodDistancesPtr, @@ -118,25 +126,19 @@ public Model(string path, Endian endianness = Endian.Agnostic) private static ModelData ReadModelData( ResourceBinaryReader reader, + Arch arch, int index, - byte numRenderables, - uint renderablesPtr, - uint renderableStatesPtr, - uint lodDistancesPtr, + ulong renderablesPtr, + ulong renderableStatesPtr, + ulong lodDistancesPtr, long importsOffset) { ModelData modelData = new(); + int renderablePointerSize = ResourceUtilities.GetPointerSize(arch); - reader.ParseSection(renderablesPtr + (index * RenderableOffsetSize), r => r.ReadUInt32(), out uint importRelativeOffset); - reader.ParseSection(renderableStatesPtr + index, r => (State)r.ReadByte(), out modelData.State); - reader.ParseSection(lodDistancesPtr + (index * LodDistanceSize), r => r.ReadSingle(), out modelData.LODDistance); - - reader.BaseStream.Seek( - importRelativeOffset + - renderablesPtr + - (numRenderables * (RenderableOffsetSize + StateSize + LodDistanceSize)) + - (reader.Endianness == Endian.BE ? 0x4 : 0x0), - SeekOrigin.Begin); + reader.ParseSection(renderablesPtr + ((ulong)index * (ulong)renderablePointerSize), r => r.ReadPointer(arch), out _); + reader.ParseSection(renderableStatesPtr + (ulong)index, r => (State)r.ReadByte(), out modelData.State); + reader.ParseSection(lodDistancesPtr + ((ulong)index * LodDistanceSize), r => r.ReadSingle(), out modelData.LODDistance); ResourceImport.ReadExternalImport(index, reader, importsOffset, out modelData.ResourceReference); return modelData; @@ -144,6 +146,9 @@ private static ModelData ReadModelData( public IEnumerable> GetExternalImports() { + int renderablePointerSize = ResourceUtilities.GetPointerSize(ResourceArch); + long renderablesOffset = GetHeaderSize(ResourceArch); + for (int i = 0; i < ModelDatas.Count; i++) { ResourceImport resourceReference = ModelDatas[i].ResourceReference; @@ -153,11 +158,16 @@ public IEnumerable> GetExternalImports() } yield return new KeyValuePair( - HeaderSize + (i * RenderableOffsetSize), + renderablesOffset + (i * renderablePointerSize), resourceReference); } } + private static int GetHeaderSize(Arch arch) + { + return (ResourceUtilities.GetPointerSize(arch) * 3) + sizeof(uint) + 0x4; + } + public struct ModelData { [EditorCategory("Model Data"), EditorLabel("Resource Reference")] diff --git a/Volatility/Resources/Renderable/RenderableBase.cs b/Volatility/Resources/Renderable/RenderableBase.cs index 6b5572b..c1185a3 100644 --- a/Volatility/Resources/Renderable/RenderableBase.cs +++ b/Volatility/Resources/Renderable/RenderableBase.cs @@ -42,8 +42,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann protected RenderableBase() : base() { } - protected RenderableBase(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + protected RenderableBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } diff --git a/Volatility/Resources/ResourceImport.cs b/Volatility/Resources/ResourceImport.cs index d946c21..5b1e050 100644 --- a/Volatility/Resources/ResourceImport.cs +++ b/Volatility/Resources/ResourceImport.cs @@ -8,6 +8,8 @@ namespace Volatility.Resources; public struct ResourceImport { + public const int ImportEntrySize = 0x10; + // The idea here is that if the name is populated but // the ID is empty, the name will be calculated into an ID // on export. If both a name and ID exist, use the ID, as @@ -45,9 +47,9 @@ public static bool ReadExternalImport(int index, EndianAwareBinaryReader reader, long originalPosition = reader.BaseStream.Position; // In-resource imports block - if (reader.BaseStream.Length >= importBlockOffset + (0x10 * index) + 0x10) + if (reader.BaseStream.Length >= importBlockOffset + ((long)ImportEntrySize * index) + ImportEntrySize) { - reader.BaseStream.Seek(importBlockOffset + (0x10 * index), SeekOrigin.Begin); + reader.BaseStream.Seek(importBlockOffset + ((long)ImportEntrySize * index), SeekOrigin.Begin); resourceImport = new ResourceImport(reader.ReadUInt64(), externalImport: true); @@ -81,7 +83,7 @@ public static bool ReadExternalImport(long fileOffset, EndianAwareBinaryReader r reader.BaseStream.Seek(importBlockOffset, SeekOrigin.Begin); // In-resource imports block - while (reader.BaseStream.Position + 0x10 <= reader.BaseStream.Length) + while (reader.BaseStream.Position + ImportEntrySize <= reader.BaseStream.Length) { ulong resourceValue = reader.ReadUInt64(); long entryKey = reader.ReadUInt32(); diff --git a/Volatility/Resources/Shader/ShaderBase.cs b/Volatility/Resources/Shader/ShaderBase.cs index ab25ef5..45c230c 100644 --- a/Volatility/Resources/Shader/ShaderBase.cs +++ b/Volatility/Resources/Shader/ShaderBase.cs @@ -27,6 +27,15 @@ public class ShaderBase : Resource [EditorCategory("Shader/Compile"), EditorLabel("Additional Arguments"), EditorTooltip("Extra dxc command-line arguments.")] public List AdditionalArguments { get; set; } = []; + public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) + { + base.WriteToStream(writer, endianness); + } + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) + { + base.ParseFromStream(reader, endianness); + } + public IReadOnlyList GetCompileStages() { if (Stages != null && Stages.Count > 0) @@ -83,8 +92,7 @@ public ShaderBase() : base() { } public ShaderBase(string path) : base(path) { } - public ShaderBase(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public ShaderBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } public enum ShaderStageType diff --git a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs index 2a8f228..1ec7ed2 100644 --- a/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs +++ b/Volatility/Resources/ShaderProgramBuffer/ShaderProgramBufferBase.cs @@ -3,8 +3,16 @@ [ResourceDefinition(ResourceType.RwShaderProgramBuffer)] public class ShaderProgramBufferBase : Resource { + public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness) + { + base.WriteToStream(writer, endianness); + } + public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness) + { + base.ParseFromStream(reader, endianness); + } + public ShaderProgramBufferBase() : base() { } - public ShaderProgramBufferBase(string path) - : base(path) { } + public ShaderProgramBufferBase(string path) : base(path) { } } diff --git a/Volatility/Resources/SnapshotData/SnapshotData.cs b/Volatility/Resources/SnapshotData/SnapshotData.cs index 901ae2a..088be84 100644 --- a/Volatility/Resources/SnapshotData/SnapshotData.cs +++ b/Volatility/Resources/SnapshotData/SnapshotData.cs @@ -27,7 +27,8 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.Write(snapshotCount); writer.Write(Channels.Count); - writer.Write(0UL); + writer.Write(1); // maiPad[0] (mixer state?) + writer.Write(0x12345678); // maiPad[1] writer.WriteSection(channelsOffset, Channels, SnapshotChannelData.Write); writer.WriteSection(statusesOffset, SnapshotStatuses, SnapshotStatusData.Write); } @@ -49,8 +50,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann public SnapshotData() : base() { } - public SnapshotData(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public SnapshotData(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } private int GetSnapshotCountForWrite() { diff --git a/Volatility/Resources/Splicer/Splicer.cs b/Volatility/Resources/Splicer/Splicer.cs index 694cd1e..1004b2a 100644 --- a/Volatility/Resources/Splicer/Splicer.cs +++ b/Volatility/Resources/Splicer/Splicer.cs @@ -15,7 +15,6 @@ namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class Splicer : BinaryResource { - private const int Version = 1; private const int HeaderSize = 0xC; private const int SpliceHeaderSize = 0x18; private const int SampleRefSize = 0x2C; @@ -31,9 +30,9 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann base.ParseFromStream(reader, endianness); int version = reader.ReadInt32(); - if (version != Version) + if (version != 1) { - throw new InvalidDataException($"Version mismatch! Version should be {Version}."); + throw new InvalidDataException("Version mismatch! Version should be 1."); } int sizedata = reader.ReadInt32(); @@ -46,7 +45,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann long spliceHeadersOffset = reader.BaseStream.Position; List spliceHeaders = reader.ParseSection(spliceHeadersOffset, numSplices, SpliceHeader.Read); - Splices = new List(numSplices); + Splices = new(numSplices); foreach (SpliceHeader header in spliceHeaders) { Splices.Add(header.ToSpliceData()); @@ -96,7 +95,7 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes long sampleRefsOffset = spliceHeadersOffset + sizeOfSplices; long sampleTableOffset = DataOffset + HeaderSize + sizedata; - writer.Write(Version); + writer.Write(1); // version writer.Write(sizedata); writer.Write(Splices.Count); diff --git a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs index 136c2c2..a4c0e15 100644 --- a/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs +++ b/Volatility/Resources/StreamedDeformationSpec/StreamedDeformationSpec.cs @@ -392,7 +392,7 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann CameraTagsInfo = new (reader, arch); LightTagsInfo = new(reader, arch); - reader.BaseStream.Seek(arch == Arch.x64 ? 0x8 : 0x4, SeekOrigin.Current); + reader.BaseStream.Seek(ResourceUtilities.GetPointerSize(ResourceArch), SeekOrigin.Current); HandlingBodyDimensions = reader.ReadVector3(); @@ -580,8 +580,7 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes public StreamedDeformationSpec() : base() { } - public StreamedDeformationSpec(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public StreamedDeformationSpec(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } // Section writers diff --git a/Volatility/Resources/Texture/TextureBPR.cs b/Volatility/Resources/Texture/TextureBPR.cs index d712e66..d47eab8 100644 --- a/Volatility/Resources/Texture/TextureBPR.cs +++ b/Volatility/Resources/Texture/TextureBPR.cs @@ -1,4 +1,4 @@ -using static Volatility.Utilities.DataUtilities; +using Volatility.Utilities; namespace Volatility.Resources; @@ -76,11 +76,13 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann SetResourceArch(reader.BaseStream.Length > 0x40 ? Arch.x64 : Arch.x32); - reader.BaseStream.Seek(ResourceArch == Arch.x64 ? 0x8 : 0x4, SeekOrigin.Begin); // Skip TextureInterfacePtr + int pointerSize = ResourceUtilities.GetPointerSize(ResourceArch); + + reader.BaseStream.Seek(pointerSize, SeekOrigin.Begin); // Skip TextureInterfacePtr Usage = (D3D11_USAGE)reader.ReadInt32(); Dimension = (DIMENSION)reader.ReadInt32(); - reader.BaseStream.Seek(ResourceArch == Arch.x64 ? 0x18 : 0xC, SeekOrigin.Current); // Skip pointers - reader.BaseStream.Seek(0x4, SeekOrigin.Current); // Skip Unknown0 + reader.BaseStream.Seek(pointerSize * 3, SeekOrigin.Current); // Skip pointers + reader.BaseStream.Seek(0x4, SeekOrigin.Current); // Skip Unknown0 Format = (DXGI_FORMAT)reader.ReadInt32(); Flags = (BPRTextureFlags)reader.ReadUInt32(); Width = reader.ReadUInt16(); @@ -89,11 +91,11 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann ArraySize = reader.ReadUInt16(); MostDetailedMip = reader.ReadByte(); MipmapLevels = reader.ReadByte(); - reader.BaseStream.Seek(sizeof(ushort), SeekOrigin.Current); // Skip Unknown1 - reader.BaseStream.Seek(ResourceArch == Arch.x64 ? 0x8 : 0x4, SeekOrigin.Current); // Unknown 2, 64 bit + reader.BaseStream.Seek(sizeof(ushort), SeekOrigin.Current); // Skip Unknown1 + reader.BaseStream.Seek(pointerSize, SeekOrigin.Current); // Unknown 2, 64 bit PlacedTileMode = (XG_TILE_MODE)reader.ReadInt32(); PlacedDataSize = (uint)reader.ReadInt32(); - reader.BaseStream.Seek(ResourceArch == Arch.x64 ? 0x8 : 0x4, SeekOrigin.Current); // TextureData, 64 bit + reader.BaseStream.Seek(pointerSize, SeekOrigin.Current); // TextureData, 64 bit } public override void PushInternalDimension() diff --git a/Volatility/Resources/Texture/TextureBase.cs b/Volatility/Resources/Texture/TextureBase.cs index a9d439a..67a155a 100644 --- a/Volatility/Resources/Texture/TextureBase.cs +++ b/Volatility/Resources/Texture/TextureBase.cs @@ -6,7 +6,7 @@ // systematically throughout the game. // Learn More: -// https://burnout.wiki/wiki/Texture +// https://burnout.wiki/wiki/Texture/Burnout_Paradise [ResourceDefinition(ResourceType.Texture)] public abstract class TextureBase : Resource @@ -95,8 +95,7 @@ public override void PushAll() protected TextureBase() : base() => Depth = 1; - protected TextureBase(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + protected TextureBase(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } } // BPR formatted but converted for each platform public enum DIMENSION : int diff --git a/Volatility/Resources/Texture/TexturePC.cs b/Volatility/Resources/Texture/TexturePC.cs index 682e5c1..0a193fc 100644 --- a/Volatility/Resources/Texture/TexturePC.cs +++ b/Volatility/Resources/Texture/TexturePC.cs @@ -26,7 +26,6 @@ public D3DFORMAT Format public byte Unknown1; // Flags public byte Unknown2; // Flags private byte[] OutputFormat = new byte[4]; // Needs to be 4 bytes long - public byte[] PreservedHeader = []; public TEXTURETYPE TextureType; // Dimension in BPR public byte Flags; // Flags @@ -38,17 +37,6 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes { base.WriteToStream(writer, endianness); - if (PreservedHeader.Length == 0x40 && - Width == 0 && - Height == 0 && - Depth == 0 && - MipmapLevels == 0 && - Format == D3DFORMAT.D3DFMT_UNKNOWN) - { - writer.Write(PreservedHeader); - return; - } - PushAll(); // Need to determine if should be moved writer.WritePointer((ulong)TextureDataPtr, ResourceArch); @@ -75,14 +63,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann { base.ParseFromStream(reader, endianness); - if (reader.BaseStream.Length == 0x40) - { - long originalPosition = reader.BaseStream.Position; - reader.BaseStream.Seek(0, SeekOrigin.Begin); - PreservedHeader = reader.ReadBytes(0x40); - reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); - } - reader.BaseStream.Seek(8, SeekOrigin.Begin); // Skip over Data & Interface pointers Unknown0 = reader.ReadUInt32(); reader.BaseStream.Seek(2, SeekOrigin.Current); // Skip over MemoryClass diff --git a/Volatility/Resources/Texture/TextureX360.cs b/Volatility/Resources/Texture/TextureX360.cs index 15f9414..7eae86b 100644 --- a/Volatility/Resources/Texture/TextureX360.cs +++ b/Volatility/Resources/Texture/TextureX360.cs @@ -39,7 +39,6 @@ public class TextureX360 : TextureBase public uint MipFlush = 65535; public GPUTEXTURE_FETCH_CONSTANT Format = new GPUTEXTURE_FETCH_CONSTANT(); - public byte[] FooterBytes = new byte[0xC]; public TextureX360() : base() { } @@ -122,28 +121,15 @@ public override void PushInternalFlags() { } // Not sure if this is accurate public override void PushInternalFormat() { - if (Format.Pitch == 0) - { - Format.Pitch = CalculatePitchX360(Width, Height); - } - - if (Format.MaxMipLevel == 0 && MipmapLevels > 0) - { - Format.MaxMipLevel = (byte)(MipmapLevels - 1); - } + Format.Pitch = CalculatePitchX360(Width, Height); + Format.MaxMipLevel = (byte)(MipmapLevels - 1); Format.MinMipLevel = MostDetailedMip; - if (!Format.PackedMips) - { - Format.PackedMips = Format.MaxMipLevel > 0; - } + Format.PackedMips = Format.MaxMipLevel > 0; - if (Format.MipAddress == 0 && Width > 0 && Height > 0) - { - // Not entirely correct but better than just using pitch - Format.MipAddress = CalculateMipAddressX360(Width, Height); - } + // Not entirely correct but better than just using pitch + Format.MipAddress = CalculateMipAddressX360(Width, Height); } public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) @@ -169,7 +155,8 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes writer.Write(MipFlush); writer.Write(Format.PackToBytes()); - writer.WriteFixedBytes(FooterBytes, 0xC); + // Padding that's usually just garbage data. + writer.WriteFixedBytes(Encoding.ASCII.GetBytes("Volatility"), 12); } public override void ParseFromStream(ResourceBinaryReader reader, Endian endianness = Endian.Agnostic) @@ -198,13 +185,6 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann // Format reader.BaseStream.Seek(0x1C, SeekOrigin.Begin); Format = new GPUTEXTURE_FETCH_CONSTANT().FromPacked(reader.ReadBytes(0x18)); - - reader.BaseStream.Seek(0x34, SeekOrigin.Begin); - FooterBytes = reader.ReadBytes((int)Math.Min(0xC, reader.BaseStream.Length - reader.BaseStream.Position)); - if (FooterBytes.Length < 0xC) - { - Array.Resize(ref FooterBytes, 0xC); - } } } diff --git a/Volatility/Utilities/ResourceUtilities.cs b/Volatility/Utilities/ResourceUtilities.cs index 845a59d..7a0f8cc 100644 --- a/Volatility/Utilities/ResourceUtilities.cs +++ b/Volatility/Utilities/ResourceUtilities.cs @@ -6,6 +6,11 @@ namespace Volatility.Utilities; public class ResourceUtilities { + public static int GetPointerSize(Arch arch) + { + return arch == Arch.x64 ? sizeof(ulong) : sizeof(uint); + } + public static List GetFixedSizeList(List source, int size) { List output = new(size); diff --git a/autotest_recap.md b/autotest_recap.md deleted file mode 100644 index 9ff38f0..0000000 --- a/autotest_recap.md +++ /dev/null @@ -1,174 +0,0 @@ -# Volatility Autotest Recap - -Generated (UTC+02:00): 2026-04-17 14:20:12 -Games: `D:\Emulation\Emulators\Xenia\Xenia Burnout 5 v6\Burnout_tcartwright` | `C:\Program Files (x86)\Steam\steamapps\common\BurnoutPR` -* Failed: 0 -* Passed with binary parity: 12 -* Semi-passed (without binary parity): 22 -* Skipped: 70 - -## Test Operation Summary - -| Operation | Passed | Failed | Skipped | -| --- | ---: | ---: | ---: | -| binaryparity | 12 | 0 | 0 | -| bundleextract | 0 | 0 | 1 | -| candidate | 0 | 0 | 1 | -| import | 4 | 0 | 0 | -| porttexture | 4 | 0 | 0 | -| roundtrip | 12 | 0 | 0 | -| texturetodds | 2 | 0 | 2 | -| unsupported | 0 | 0 | 66 | - -## Resource Type Outcomes - -| Resource Type | Passed | Failed | Skipped | Overall | -| --- | ---: | ---: | ---: | --- | -| AISections | 0 | 0 | 2 | SKIP | -| AttribSysVault | 0 | 0 | 2 | SKIP | -| ChallengeList | 0 | 0 | 2 | SKIP | -| FlaptFile | 0 | 0 | 2 | SKIP | -| GuiPopup | 4 | 0 | 0 | PASS | -| HudMessage | 0 | 0 | 2 | SKIP | -| HudMessageSequence | 0 | 0 | 2 | SKIP | -| HudMessageSequenceDictionary | 0 | 0 | 2 | SKIP | -| ICETakeDictionary | 0 | 0 | 2 | SKIP | -| IdList | 0 | 0 | 2 | SKIP | -| InstanceList | 4 | 0 | 1 | PASS | -| MassiveLookupTable | 0 | 0 | 2 | SKIP | -| Material | 0 | 0 | 2 | SKIP | -| MaterialState | 0 | 0 | 2 | SKIP | -| MaterialTechnique | 0 | 0 | 1 | SKIP | -| ParticleDescription | 0 | 0 | 2 | SKIP | -| ParticleDescriptionCollection | 0 | 0 | 2 | SKIP | -| PolygonSoupList | 0 | 0 | 2 | SKIP | -| ProfileUpgrade | 0 | 0 | 1 | SKIP | -| ProgressionData | 0 | 0 | 2 | SKIP | -| PropGraphicsList | 0 | 0 | 2 | SKIP | -| PropInstanceData | 0 | 0 | 2 | SKIP | -| Registry | 0 | 0 | 2 | SKIP | -| Renderable | 4 | 0 | 0 | PASS | -| RwShaderProgramBuffer | 0 | 0 | 2 | SKIP | -| Scene | 8 | 0 | 0 | PASS | -| ShaderTechnique | 0 | 0 | 2 | SKIP | -| StaticSoundMap | 0 | 0 | 2 | SKIP | -| StreetData | 0 | 0 | 2 | SKIP | -| Texture | 14 | 0 | 2 | PASS | -| TextureNameMap | 0 | 0 | 2 | SKIP | -| TextureState | 0 | 0 | 2 | SKIP | -| TrafficData | 0 | 0 | 2 | SKIP | -| TriggerData | 0 | 0 | 2 | SKIP | -| VertexDescriptor | 0 | 0 | 2 | SKIP | -| VFXMeshCollection | 0 | 0 | 2 | SKIP | -| VFXPropCollection | 0 | 0 | 2 | SKIP | -| WorldPainter2D | 0 | 0 | 2 | SKIP | -| ZoneList | 0 | 0 | 2 | SKIP | - -## Case Details - -| Game | Resource Type | Operation | Name | Outcome | Details | -| --- | --- | --- | --- | --- | --- | -| Burnout_tcartwright | AISections | unsupported | AISections | SKIP | Discovered in AI.DAT. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ProgressionData | unsupported | ProgressionData | SKIP | Discovered in PROGRESSION.DAT. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | StreetData | unsupported | StreetData | SKIP | Discovered in STREETDATA.DAT. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | TriggerData | unsupported | TriggerData | SKIP | Discovered in TRIGGERS.DAT. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | HudMessage | unsupported | HudMessage | SKIP | Discovered in HUDMESSAGES.HM. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | HudMessageSequence | unsupported | HudMessageSequence | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | HudMessageSequenceDictionary | unsupported | HudMessageSequenceDictionary | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | TrafficData | unsupported | TrafficData | SKIP | Discovered in B5TRAFFIC.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | MaterialTechnique | unsupported | MaterialTechnique | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | TextureState | unsupported | TextureState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | Material | unsupported | Material | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | VertexDescriptor | unsupported | VertexDescriptor | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | MaterialState | unsupported | MaterialState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | MassiveLookupTable | unsupported | MassiveLookupTable | SKIP | Discovered in MASSIVETABLE.BIN. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | AttribSysVault | unsupported | AttribSysVault | SKIP | Discovered in SURFACELIST.BIN. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ICETakeDictionary | unsupported | ICETakeDictionary | SKIP | Discovered in CAMERAS.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | FlaptFile | unsupported | FlaptFile | SKIP | Discovered in FLAPTHUD.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ParticleDescription | unsupported | ParticleDescription | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | TextureNameMap | unsupported | TextureNameMap | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | VFXMeshCollection | unsupported | VFXMeshCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | VFXPropCollection | unsupported | VFXPropCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ParticleDescriptionCollection | unsupported | ParticleDescriptionCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | Registry | unsupported | Registry | SKIP | Discovered in PLAYBACKREGISTRY.BUNDLE. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ZoneList | unsupported | ZoneList | SKIP | Discovered in PVS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ChallengeList | unsupported | ChallengeList | SKIP | Discovered in ONLINECHALLENGES.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | RwShaderProgramBuffer | unsupported | RwShaderProgramBuffer | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ShaderTechnique | unsupported | ShaderTechnique | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | StaticSoundMap | unsupported | StaticSoundMap | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | PropInstanceData | unsupported | PropInstanceData | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | PropGraphicsList | unsupported | PropGraphicsList | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | WorldPainter2D | unsupported | WorldPainter2D | SKIP | Discovered in DISTRICTS.DAT. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | ProfileUpgrade | unsupported | ProfileUpgrade | SKIP | Discovered in PROFILEUPG.BIN. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | PolygonSoupList | unsupported | PolygonSoupList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | IdList | unsupported | IdList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | -| Burnout_tcartwright | GuiPopup | binaryparity | GuiPopup:2718168B | PASS | Binary files are identical. | -| Burnout_tcartwright | GuiPopup | roundtrip | GuiPopup:2718168B | PASS | | -| Burnout_tcartwright | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone272.BackDropScene?ID=409963_LOD0 | PASS | | -| Burnout_tcartwright | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | Binary files are identical. | -| Burnout_tcartwright | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | | -| Burnout_tcartwright | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone136.BackDropScene?ID=558369_LOD0 | PASS | | -| Burnout_tcartwright | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | Binary files are identical. | -| Burnout_tcartwright | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | | -| Burnout_tcartwright | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:dds | PASS | | -| Burnout_tcartwright | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:X360->TUB | PASS | | -| Burnout_tcartwright | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | Binary files are identical. | -| Burnout_tcartwright | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | | -| Burnout_tcartwright | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:dds | PASS | | -| Burnout_tcartwright | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:X360->TUB | PASS | | -| Burnout_tcartwright | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | Binary files are identical. | -| Burnout_tcartwright | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | | -| Burnout_tcartwright | InstanceList | binaryparity | InstanceList:TRK_UNIT0_list | PASS | Binary files are identical. | -| Burnout_tcartwright | InstanceList | roundtrip | InstanceList:TRK_UNIT0_list | PASS | | -| Burnout_tcartwright | InstanceList | binaryparity | InstanceList:TRK_UNIT100_list | PASS | Binary files are identical. | -| Burnout_tcartwright | InstanceList | roundtrip | InstanceList:TRK_UNIT100_list | PASS | | -| BurnoutPR | AISections | unsupported | AISections | SKIP | Discovered in AI.DAT. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ProgressionData | unsupported | ProgressionData | SKIP | Discovered in PROGRESSION.DAT. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | StreetData | unsupported | StreetData | SKIP | Discovered in STREETDATA.DAT. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | TriggerData | unsupported | TriggerData | SKIP | Discovered in TRIGGERS.DAT. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | HudMessage | unsupported | HudMessage | SKIP | Discovered in HUDMESSAGES.HM. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | HudMessageSequence | unsupported | HudMessageSequence | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | HudMessageSequenceDictionary | unsupported | HudMessageSequenceDictionary | SKIP | Discovered in HUDMESSAGESEQUENCES.HMSC. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | TrafficData | unsupported | TrafficData | SKIP | Discovered in B5TRAFFIC.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | TextureState | unsupported | TextureState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | Material | unsupported | Material | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | MaterialState | unsupported | MaterialState | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | VertexDescriptor | unsupported | VertexDescriptor | SKIP | Discovered in GLOBALBACKDROPS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | MassiveLookupTable | unsupported | MassiveLookupTable | SKIP | Discovered in MASSIVETABLE.BIN. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | AttribSysVault | unsupported | AttribSysVault | SKIP | Discovered in SURFACELIST.BIN. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ICETakeDictionary | unsupported | ICETakeDictionary | SKIP | Discovered in CAMERAS.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | FlaptFile | unsupported | FlaptFile | SKIP | Discovered in FLAPTHUD.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ParticleDescription | unsupported | ParticleDescription | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | TextureNameMap | unsupported | TextureNameMap | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | VFXMeshCollection | unsupported | VFXMeshCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | VFXPropCollection | unsupported | VFXPropCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ParticleDescriptionCollection | unsupported | ParticleDescriptionCollection | SKIP | Discovered in PARTICLES.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | Registry | unsupported | Registry | SKIP | Discovered in PLAYBACKREGISTRY.BUNDLE. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ZoneList | unsupported | ZoneList | SKIP | Discovered in PVS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ChallengeList | unsupported | ChallengeList | SKIP | Discovered in ONLINECHALLENGES.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | RwShaderProgramBuffer | unsupported | RwShaderProgramBuffer | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | ShaderTechnique | unsupported | ShaderTechnique | SKIP | Discovered in SHADERS.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | StaticSoundMap | unsupported | StaticSoundMap | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | PropInstanceData | unsupported | PropInstanceData | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | PropGraphicsList | unsupported | PropGraphicsList | SKIP | Discovered in TRK_UNIT0_GR.BNDL. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | WorldPainter2D | unsupported | WorldPainter2D | SKIP | Discovered in DISTRICTS.DAT. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | PolygonSoupList | unsupported | PolygonSoupList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | IdList | unsupported | IdList | SKIP | Discovered in WORLDCOL.BIN. No Volatility autotest handler exists for this resource type. | -| BurnoutPR | - | bundleextract | TRK_UNIT0_GR.BNDL | SKIP | Process 'C:\\Users\\adri1\\Documents\\Github\\volatility\\tools\\libbndl-extractor\\build\\volatility_libbndl_extract.exe --bundle "C:\\Program Files (x86)\\Steam\\steamapps\\common\\BurnoutPR\\TRK_UNIT0_GR.BNDL" --output "C:\\Users\\adri1\\Documents\\Github\\volatility\\.tmp\\game-autotest\\20260417_121900\\BurnoutPR_TUB\\bundles\\TRK_UNIT0_GR.BNDL" --manifest "C:\\Users\\adri1\\Documents\\Github\\volatility\\.tmp\\game-autotest\\20260417_121900\\BurnoutPR_TUB\\bundles\\TRK_UNIT0_GR.BNDL\\manifest.tsv"' failed with exit code 3.
Assertion failed: m_flags & Compressed, file C:\\Users\\adri1\\Documents\\Github\\volatility\\tools\\libbndl-extractor\\third_party\\libbndl\\src\\bundle.cpp, line 892
| -| BurnoutPR | InstanceList | candidate | InstanceList | SKIP | No fully extractable bundle candidate was available for this supported resource type. | -| BurnoutPR | GuiPopup | binaryparity | GuiPopup:POPUPS.pup | PASS | Binary files are identical. | -| BurnoutPR | GuiPopup | roundtrip | GuiPopup:POPUPS.pup | PASS | | -| BurnoutPR | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone272.BackDropScene?ID=409963_LOD0 | PASS | | -| BurnoutPR | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | Binary files are identical. | -| BurnoutPR | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone99.BackDropScene?ID=508161 | PASS | | -| BurnoutPR | Renderable | import | Renderable:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/PvsZone/BdZone136.BackDropScene?ID=558369_LOD0 | PASS | | -| BurnoutPR | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | Binary files are identical. | -| BurnoutPR | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298 | PASS | | -| BurnoutPR | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:dds | SKIP | DDS export is not supported for TUB texture format 'D3DFMT_UNKNOWN'. | -| BurnoutPR | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images/Backdrops/Striped_Glass_Building.TextureConfig2d?ID=388298:TUB->BPR | PASS | | -| BurnoutPR | Texture | binaryparity | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | Binary files are identical. | -| BurnoutPR | Texture | roundtrip | Texture:gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076 | PASS | | -| BurnoutPR | Texture | texturetodds | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:dds | SKIP | DDS export is not supported for TUB texture format 'D3DFMT_UNKNOWN'. | -| BurnoutPR | Texture | porttexture | gamedb://burnout5/Burnout/Content_World/Images_Final/cladding08_window03.TextureConfig2d?ID=331076:TUB->BPR | PASS | | -| BurnoutPR | Scene | binaryparity | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | Binary files are identical. | -| BurnoutPR | Scene | roundtrip | Scene:gamedb://burnout5/Burnout/Content_World/Scenes/Backdrops/BD_Mountains_03.RoadScene?ID=197487 | PASS | | From 61d0e049d0568b3f6a4ffd6c4690e29fc674dcdd Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Tue, 21 Apr 2026 00:28:02 -0400 Subject: [PATCH 12/17] exportresource externalimports refactor --- .../CLI/Commands/ExportResourceCommand.cs | 20 +++- .../Resources/ExportResourceOperation.cs | 97 ++++++++++++--- .../Resources/InstanceList/InstanceList.cs | 2 +- Volatility/Resources/Model/Model.cs | 2 +- Volatility/Resources/Resource.cs | 1 + Volatility/Resources/ResourceImport.cs | 113 ++++++++++++++++-- 6 files changed, 205 insertions(+), 30 deletions(-) diff --git a/Volatility/CLI/Commands/ExportResourceCommand.cs b/Volatility/CLI/Commands/ExportResourceCommand.cs index 8d3547e..04e1387 100644 --- a/Volatility/CLI/Commands/ExportResourceCommand.cs +++ b/Volatility/CLI/Commands/ExportResourceCommand.cs @@ -10,11 +10,13 @@ internal class ExportResourceCommand : ICommand { public static string CommandToken => "ExportResource"; public static string CommandDescription => "Exports information and relevant data from an imported/created resource into a platform's format."; - public static string CommandParameters => "--recurse --overwrite --type= --format= --respath= --outpath="; + public static string CommandParameters => "--recurse --overwrite --type= --format= --respath= --outpath= [--imports=] [--importsfile]"; public string? Format { get; set; } public string? ResourcePath { get; set; } public string? OutputPath { get; set; } + public string? Imports { get; set; } + public bool ImportsFile { get; set; } public bool Overwrite { get; set; } public bool Recursive { get; set; } @@ -57,6 +59,18 @@ public async Task Execute() throw new InvalidPlatformException("Error: Invalid file format specified!"); } + Unpacker? importUnpackerOverride = null; + if (!string.IsNullOrEmpty(Imports) && !string.Equals(Imports, "DEFAULT", StringComparison.OrdinalIgnoreCase)) + { + if (!TypeUtilities.TryParseEnum(Imports, out Unpacker parsedUnpacker)) + { + Console.WriteLine("Error: Invalid imports export mode specified!"); + return; + } + + importUnpackerOverride = parsedUnpacker; + } + var loadOperation = new LoadResourceOperation(); var exportOperation = new ExportResourceOperation(); @@ -104,7 +118,7 @@ public async Task Execute() return; } - await exportOperation.ExecuteAsync(resource, OutputPath, platform); + await exportOperation.ExecuteAsync(resource, OutputPath, platform, importUnpackerOverride, ImportsFile); Console.WriteLine($"Exported {Path.GetFileName(ResourcePath)} as {Path.GetFullPath(OutputPath)}."); })); @@ -117,6 +131,8 @@ public void SetArgs(Dictionary args) Format = (args.TryGetValue("format", out object? format) ? format as string : "")?.ToUpper(); ResourcePath = args.TryGetValue("respath", out object? respath) ? respath as string : ""; OutputPath = args.TryGetValue("outpath", out object? outpath) ? outpath as string : ""; + Imports = args.TryGetValue("imports", out object? imports) ? imports as string : ""; + ImportsFile = args.TryGetValue("importsfile", out var importsfile) && (bool)importsfile; Overwrite = args.TryGetValue("overwrite", out var ow) && (bool)ow; Recursive = args.TryGetValue("recurse", out var re) && (bool)re; } diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index d148b40..a18c7a3 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -4,7 +4,12 @@ namespace Volatility.Operations.Resources; internal class ExportResourceOperation { - public Task ExecuteAsync(Resource resource, string outputPath, Platform platform) + public Task ExecuteAsync( + Resource resource, + string outputPath, + Platform platform, + Unpacker? importUnpackerOverride = null, + bool writeImportsToSeparateFile = false) { string? directoryPath = Path.GetDirectoryName(outputPath); @@ -31,7 +36,13 @@ public Task ExecuteAsync(Resource resource, string outputPath, Platform platform break; } - WriteExternalImportsYaml(resource, outputPath); + WriteExternalImports( + resource, + outputPath, + writer, + endian, + ResolveImportExportUnpacker(resource, importUnpackerOverride), + writeImportsToSeparateFile); if (resource is ShaderBase shader) { @@ -71,29 +82,55 @@ public Task ExecuteAsync(Resource resource, string outputPath, Platform platform return Task.CompletedTask; } - private static void WriteExternalImportsYaml(Resource resource, string outputPath) + private static Unpacker ResolveImportExportUnpacker( + Resource resource, + Unpacker? importUnpackerOverride) { - List> imports = resource switch - { - Model model => model.GetExternalImports().ToList(), - InstanceList instanceList => instanceList.GetExternalImports().ToList(), - _ => [] - }; + return importUnpackerOverride ?? resource.Unpacker; + } + + private static void WriteExternalImports( + Resource resource, + string outputPath, + ResourceBinaryWriter writer, + Endian endian, + Unpacker importUnpacker, + bool forceExternalImportsFile) + { + List> imports = resource.GetExternalImports().ToList(); - string importsPath = Path.Combine( - Path.GetDirectoryName(outputPath) ?? string.Empty, - Path.GetFileNameWithoutExtension(outputPath) + "_imports.yaml"); + string yamlImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.YAP); + string datImportsPath = ResourceImport.GetImportsPath(outputPath, Unpacker.Raw); if (imports.Count == 0) { - if (File.Exists(importsPath)) - { - File.Delete(importsPath); - } + ResourceImport.DeleteImportsSidecarFiles(outputPath); + return; + } + if (importUnpacker == Unpacker.YAP) + { + ResourceImport.DeleteImportsSidecarFiles(outputPath); + WriteExternalImportsYaml(imports, yamlImportsPath); return; } + if (forceExternalImportsFile) + { + ResourceImport.DeleteImportsSidecarFiles(outputPath); + WriteExternalImportsDat(datImportsPath, endian, imports); + return; + } + + writer.BaseStream.Seek(0, SeekOrigin.End); + WriteBinaryImports(writer, imports); + ResourceImport.DeleteImportsSidecarFiles(outputPath); + } + + private static void WriteExternalImportsYaml( + List> imports, + string importsPath) + { List lines = new(imports.Count); foreach (KeyValuePair entry in imports) { @@ -104,6 +141,34 @@ private static void WriteExternalImportsYaml(Resource resource, string outputPat File.WriteAllLines(importsPath, lines); } + private static void WriteExternalImportsDat( + string importsPath, + Endian endianness, + List> imports) + { + using FileStream fs = new(importsPath, FileMode.Create, FileAccess.Write); + using EndianAwareBinaryWriter writer = new(fs, endianness); + WriteBinaryImports(writer, imports); + } + + private static void WriteBinaryImports( + EndianAwareBinaryWriter writer, + List> imports) + { + foreach (KeyValuePair entry in imports) + { + if (entry.Key < 0 || entry.Key > uint.MaxValue) + { + throw new InvalidDataException( + $"Import offset 0x{entry.Key:X} cannot be stored in a binary imports block."); + } + + writer.Write(ResourceUtilities.ResolveResourceID(entry.Value)); + writer.Write((uint)entry.Key); + writer.Write(0u); + } + } + private static string GetShaderProgramBufferPath( string shaderOutputPath, ShaderStageCompile stage, diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index 6e5d460..4880eda 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -137,7 +137,7 @@ private static void WriteInstanceBlock(ResourceBinaryWriter writer, Instance ins } } - public IEnumerable> GetExternalImports() + public override IEnumerable> GetExternalImports() { int instanceBlockSize = GetInstanceBlockSize(ResourceArch); for (int i = 0; i < Instances.Count; i++) diff --git a/Volatility/Resources/Model/Model.cs b/Volatility/Resources/Model/Model.cs index 3cd6f89..cab8551 100644 --- a/Volatility/Resources/Model/Model.cs +++ b/Volatility/Resources/Model/Model.cs @@ -144,7 +144,7 @@ private static ModelData ReadModelData( return modelData; } - public IEnumerable> GetExternalImports() + public override IEnumerable> GetExternalImports() { int renderablePointerSize = ResourceUtilities.GetPointerSize(ResourceArch); long renderablesOffset = GetHeaderSize(ResourceArch); diff --git a/Volatility/Resources/Resource.cs b/Volatility/Resources/Resource.cs index d5178d9..68d2335 100644 --- a/Volatility/Resources/Resource.cs +++ b/Volatility/Resources/Resource.cs @@ -24,6 +24,7 @@ public abstract class Resource public virtual Platform ResourcePlatform => Platform.Agnostic; public virtual Arch ResourceArch => Arch; public virtual void SetResourceArch(Arch newArch) { Arch = newArch; } + public virtual IEnumerable> GetExternalImports() { yield break; } public virtual void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { diff --git a/Volatility/Resources/ResourceImport.cs b/Volatility/Resources/ResourceImport.cs index 5b1e050..621af6d 100644 --- a/Volatility/Resources/ResourceImport.cs +++ b/Volatility/Resources/ResourceImport.cs @@ -42,6 +42,25 @@ public ResourceImport(string name, bool externalImport = false) ExternalImport = externalImport; } + public static string GetImportsPath(string resourcePath, Unpacker unpacker) + { + string suffix = unpacker switch + { + Unpacker.YAP => "_imports.yaml", + _ => "_imports.dat", + }; + + return Path.Combine( + Path.GetDirectoryName(resourcePath) ?? string.Empty, + Path.GetFileNameWithoutExtension(resourcePath) + suffix); + } + + public static void DeleteImportsSidecarFiles(string resourcePath) + { + DeleteFileIfExists(GetImportsPath(resourcePath, Unpacker.YAP)); + DeleteFileIfExists(GetImportsPath(resourcePath, Unpacker.Raw)); + } + public static bool ReadExternalImport(int index, EndianAwareBinaryReader reader, long importBlockOffset, out ResourceImport resourceImport) { long originalPosition = reader.BaseStream.Position; @@ -60,16 +79,21 @@ public static bool ReadExternalImport(int index, EndianAwareBinaryReader reader, reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); - // YAP imports yaml if (reader.BaseStream is FileStream fs) { - string baseName = Path.GetFileNameWithoutExtension(fs.Name); - string directory = Path.GetDirectoryName(fs.Name); - string yamlPath = Path.Combine(directory, baseName + "_imports.yaml"); + if (TryReadBinaryImportAt(GetImportsPath(fs.Name, Unpacker.Raw), reader.Endianness, index, out ResourceID binaryImport)) + { + resourceImport = new ResourceImport(binaryImport, externalImport: true); + return true; + } - resourceImport = new ResourceImport(GetYAMLImportValueAt(yamlPath, index), externalImport: true); + string yamlPath = GetImportsPath(fs.Name, Unpacker.YAP); + if (File.Exists(yamlPath)) + { + resourceImport = new ResourceImport(GetYAMLImportValueAt(yamlPath, index), externalImport: true); - return true; + return true; + } } resourceImport = default; @@ -97,13 +121,15 @@ public static bool ReadExternalImport(long fileOffset, EndianAwareBinaryReader r reader.BaseStream.Seek(originalPosition, SeekOrigin.Begin); - // YAP imports yaml if (reader.BaseStream is FileStream fs) { - string baseName = Path.GetFileNameWithoutExtension(fs.Name); - string directory = Path.GetDirectoryName(fs.Name); - string yamlPath = Path.Combine(directory, baseName + "_imports.yaml"); + if (TryReadBinaryImportByKey(GetImportsPath(fs.Name, Unpacker.Raw), reader.Endianness, fileOffset, out ResourceID binaryImport)) + { + resourceImport = new ResourceImport(binaryImport, externalImport: true); + return true; + } + string yamlPath = GetImportsPath(fs.Name, Unpacker.YAP); if (File.Exists(yamlPath)) { ulong? yamlValue = GetYAMLImportValueByKey(yamlPath, fileOffset); @@ -119,6 +145,73 @@ public static bool ReadExternalImport(long fileOffset, EndianAwareBinaryReader r return false; } + private static bool TryReadBinaryImportAt( + string binaryPath, + Endian endianness, + int index, + out ResourceID referenceId) + { + referenceId = ResourceID.Default; + + if (index < 0 || !File.Exists(binaryPath)) + { + return false; + } + + long entryOffset = (long)ImportEntrySize * index; + using EndianAwareBinaryReader importReader = new(new FileStream(binaryPath, FileMode.Open, FileAccess.Read, FileShare.Read), endianness); + + if (importReader.BaseStream.Length < entryOffset + ImportEntrySize) + { + return false; + } + + importReader.BaseStream.Seek(entryOffset, SeekOrigin.Begin); + referenceId = importReader.ReadUInt64(); + return true; + } + + private static bool TryReadBinaryImportByKey( + string binaryPath, + Endian endianness, + long fileOffset, + out ResourceID referenceId) + { + referenceId = ResourceID.Default; + + if (fileOffset < 0 || fileOffset > uint.MaxValue || !File.Exists(binaryPath)) + { + return false; + } + + using EndianAwareBinaryReader importReader = new(new FileStream(binaryPath, FileMode.Open, FileAccess.Read, FileShare.Read), endianness); + + while (importReader.BaseStream.Position + ImportEntrySize <= importReader.BaseStream.Length) + { + ulong resourceValue = importReader.ReadUInt64(); + uint entryKey = importReader.ReadUInt32(); + importReader.BaseStream.Seek(sizeof(uint), SeekOrigin.Current); + + if (entryKey != (uint)fileOffset) + { + continue; + } + + referenceId = resourceValue; + return true; + } + + return false; + } + + private static void DeleteFileIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + public static ResourceID GetYAMLImportValueAt(string yamlPath, int index) { var yaml = File.ReadAllText(yamlPath); From b99ae04bedfa1fccbd87cfbb4d8f5a0969a08391 Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Tue, 21 Apr 2026 13:03:25 -0400 Subject: [PATCH 13/17] overkill --- .../Operations/Resources/ExportResourceOperation.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Volatility/Operations/Resources/ExportResourceOperation.cs b/Volatility/Operations/Resources/ExportResourceOperation.cs index a18c7a3..508df0c 100644 --- a/Volatility/Operations/Resources/ExportResourceOperation.cs +++ b/Volatility/Operations/Resources/ExportResourceOperation.cs @@ -41,7 +41,7 @@ public Task ExecuteAsync( outputPath, writer, endian, - ResolveImportExportUnpacker(resource, importUnpackerOverride), + ResolveExternalImportsUnpackerFormat(resource, importUnpackerOverride), writeImportsToSeparateFile); if (resource is ShaderBase shader) @@ -82,7 +82,7 @@ public Task ExecuteAsync( return Task.CompletedTask; } - private static Unpacker ResolveImportExportUnpacker( + private static Unpacker ResolveExternalImportsUnpackerFormat( Resource resource, Unpacker? importUnpackerOverride) { @@ -163,9 +163,10 @@ private static void WriteBinaryImports( $"Import offset 0x{entry.Key:X} cannot be stored in a binary imports block."); } - writer.Write(ResourceUtilities.ResolveResourceID(entry.Value)); + // Probably overkill but I just want to make sure we always use the correct writer overloads + writer.Write((ulong)ResourceUtilities.ResolveResourceID(entry.Value)); writer.Write((uint)entry.Key); - writer.Write(0u); + writer.Write(0x00000000); } } From a1b13bd084723dddfc35a4f74b6ef44037be66ca Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Tue, 21 Apr 2026 13:51:32 -0400 Subject: [PATCH 14/17] run external processes through ProcessUtilities --- .../Resources/ImportResourceOperation.cs | 30 +----- Volatility/Utilities/DxcShaderCompiler.cs | 27 +---- Volatility/Utilities/PS3TextureUtilities.cs | 34 +------ Volatility/Utilities/ProcessUtilities.cs | 99 ++++++++++++++++--- 4 files changed, 96 insertions(+), 94 deletions(-) diff --git a/Volatility/Operations/Resources/ImportResourceOperation.cs b/Volatility/Operations/Resources/ImportResourceOperation.cs index 67af050..6d93b07 100644 --- a/Volatility/Operations/Resources/ImportResourceOperation.cs +++ b/Volatility/Operations/Resources/ImportResourceOperation.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.RegularExpressions; using Volatility.Resources; @@ -105,33 +104,10 @@ public async Task ExecuteAsync(ResourceType resourceType, if (!File.Exists(convertedSamplePathName) || overwrite) { - ProcessStartInfo start = new ProcessStartInfo - { - FileName = sxPath, - Arguments = $"-wave -s16l_int -v0 \"{samplePathName}.snr\" -=\"{convertedSamplePathName}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using Process process = new Process(); - process.StartInfo = start; - process.OutputDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) Console.WriteLine(e.Data); - }; - - process.ErrorDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) Console.WriteLine(e.Data); - }; - Console.WriteLine($"Converting extracted sample {sampleName}.snr to wave..."); - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - process.WaitForExit(); + ProcessUtilities.RunAndRelayOutput( + sxPath, + $"-wave -s16l_int -v0 \"{samplePathName}.snr\" -=\"{convertedSamplePathName}\""); } else { diff --git a/Volatility/Utilities/DxcShaderCompiler.cs b/Volatility/Utilities/DxcShaderCompiler.cs index ab232c2..8ed7fb2 100644 --- a/Volatility/Utilities/DxcShaderCompiler.cs +++ b/Volatility/Utilities/DxcShaderCompiler.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using System.Runtime.InteropServices; -using System.Text; - using Volatility.Resources; using static Volatility.Utilities.EnvironmentUtilities; @@ -46,30 +44,7 @@ public static void CompileToCSO(ShaderBase shader, ShaderStageCompile stage, str string sourcePath = ResolveSourcePath(shader); ProcessStartInfo startInfo = BuildStartInfo(dxcPath, sourcePath, shader, stage, entryPoint, targetProfile, outputPath); - using Process process = new() { StartInfo = startInfo }; - - StringBuilder output = new(); - process.OutputDataReceived += (_, e) => - { - if (!string.IsNullOrWhiteSpace(e.Data)) - output.AppendLine(e.Data); - }; - process.ErrorDataReceived += (_, e) => - { - if (!string.IsNullOrWhiteSpace(e.Data)) - output.AppendLine(e.Data); - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - process.WaitForExit(); - - if (process.ExitCode != 0) - { - string message = output.Length > 0 ? output.ToString() : "No compiler output."; - throw new InvalidOperationException($"dxc failed (exit {process.ExitCode}).\n{message}"); - } + ProcessUtilities.RunAndCapture(startInfo); } private static ProcessStartInfo BuildStartInfo( diff --git a/Volatility/Utilities/PS3TextureUtilities.cs b/Volatility/Utilities/PS3TextureUtilities.cs index 0f225c4..b7db1fb 100644 --- a/Volatility/Utilities/PS3TextureUtilities.cs +++ b/Volatility/Utilities/PS3TextureUtilities.cs @@ -165,38 +165,12 @@ public static void PS3GTFToDDS(byte[] ps3Header, string sourceBitmapPath, string throw new FileNotFoundException("Unable to find external tool gtf2dds.exe!"); } - ProcessStartInfo start = new ProcessStartInfo - { - FileName = gtf2ddsPath, - Arguments = $"-o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - if (verbose) Console.WriteLine($"Running: {gtf2ddsPath} -o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); + if (verbose) Console.WriteLine("Converting PS3 GTF texture to DDS..."); - using (Process process = new Process()) - { - if (verbose) Console.WriteLine("Converting PS3 GTF texture to DDS..."); - - process.StartInfo = start; - process.OutputDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) Console.WriteLine(e.Data); - }; - - process.ErrorDataReceived += (sender, e) => - { - if (!string.IsNullOrEmpty(e.Data)) Console.WriteLine(e.Data); - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - process.WaitForExit(); - } + ProcessUtilities.RunAndRelayOutput( + gtf2ddsPath, + $"-o \"{destinationBitmapPath}.dds\" \"{destinationBitmapPath}.gtf\""); fileBytes = File.ReadAllBytes($"{destinationBitmapPath}.dds"); diff --git a/Volatility/Utilities/ProcessUtilities.cs b/Volatility/Utilities/ProcessUtilities.cs index 0a3769e..824d3ea 100644 --- a/Volatility/Utilities/ProcessUtilities.cs +++ b/Volatility/Utilities/ProcessUtilities.cs @@ -7,17 +7,12 @@ internal static class ProcessUtilities { public static string RunAndCapture(string fileName, string arguments, string? workingDirectory = null) { - ProcessStartInfo startInfo = new() - { - FileName = fileName, - Arguments = arguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Directory.GetCurrentDirectory() : workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + ProcessStartInfo startInfo = CreateStartInfo(fileName, arguments, workingDirectory); + return RunAndCapture(startInfo); + } + public static string RunAndCapture(ProcessStartInfo startInfo) + { using Process process = new() { StartInfo = startInfo }; StringBuilder output = new(); @@ -29,9 +24,91 @@ public static string RunAndCapture(string fileName, string arguments, string? wo if (process.ExitCode != 0) { throw new InvalidOperationException( - $"Process '{fileName} {arguments}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); + $"Process '{GetProcessDisplayName(startInfo)}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); } return output.ToString(); } + + public static void RunAndRelayOutput( + string fileName, + string arguments, + string? workingDirectory = null, + Action? stdoutHandler = null, + Action? stderrHandler = null) + { + ProcessStartInfo startInfo = CreateStartInfo(fileName, arguments, workingDirectory); + RunAndRelayOutput(startInfo, stdoutHandler, stderrHandler); + } + + public static void RunAndRelayOutput( + ProcessStartInfo startInfo, + Action? stdoutHandler = null, + Action? stderrHandler = null) + { + using Process process = new() { StartInfo = startInfo }; + StringBuilder output = new(); + + process.OutputDataReceived += (_, e) => + { + if (string.IsNullOrEmpty(e.Data)) + { + return; + } + + output.AppendLine(e.Data); + (stdoutHandler ?? Console.WriteLine)(e.Data); + }; + + process.ErrorDataReceived += (_, e) => + { + if (string.IsNullOrEmpty(e.Data)) + { + return; + } + + output.AppendLine(e.Data); + (stderrHandler ?? Console.WriteLine)(e.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Process '{GetProcessDisplayName(startInfo)}' failed with exit code {process.ExitCode}.{Environment.NewLine}{output}"); + } + } + + private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string? workingDirectory) + { + return new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? Directory.GetCurrentDirectory() : workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + } + + private static string GetProcessDisplayName(ProcessStartInfo startInfo) + { + if (!string.IsNullOrWhiteSpace(startInfo.Arguments)) + { + return $"{startInfo.FileName} {startInfo.Arguments}"; + } + + if (startInfo.ArgumentList.Count > 0) + { + return $"{startInfo.FileName} {string.Join(' ', startInfo.ArgumentList)}"; + } + + return startInfo.FileName; + } } From d9d2849eb5d94401e9790917c30ac8ddf23d8fcb Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Tue, 21 Apr 2026 14:17:38 -0400 Subject: [PATCH 15/17] instancelist hygiene --- .../Resources/InstanceList/InstanceList.cs | 80 ++++++++++++++----- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/Volatility/Resources/InstanceList/InstanceList.cs b/Volatility/Resources/InstanceList/InstanceList.cs index 4880eda..95f4928 100644 --- a/Volatility/Resources/InstanceList/InstanceList.cs +++ b/Volatility/Resources/InstanceList/InstanceList.cs @@ -15,7 +15,6 @@ namespace Volatility.Resources; [ResourceRegistration(RegistrationPlatforms.All, EndianMapped = true)] public class InstanceList : Resource { - private const int HeaderSize = 0x10; private const int SectionAlignment = 0x10; [EditorLabel("Number of instances"), EditorCategory("Instance List"), EditorReadOnly, EditorTooltip("The amount of instances that have a model assigned, but NOT the size of the entire instance array.")] @@ -26,8 +25,7 @@ public class InstanceList : Resource public InstanceList() : base() { } - public InstanceList(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) { } + public InstanceList(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { } public override void WriteToStream(ResourceBinaryWriter writer, Endian endianness = Endian.Agnostic) { @@ -35,14 +33,19 @@ public override void WriteToStream(ResourceBinaryWriter writer, Endian endiannes int instanceBlockSize = GetInstanceBlockSize(ResourceArch); uint entryCount = (uint)Instances.Count; + if (NumInstances > entryCount) + { + throw new InvalidDataException( + $"NumInstances ({NumInstances}) cannot exceed the total array size ({entryCount})."); + } - long currentOffset = HeaderSize; + long currentOffset = GetHeaderSize(ResourceArch); long instanceListOffset = ResourceUtilities.GetSectionOffset( ref currentOffset, checked((int)(entryCount * instanceBlockSize)), SectionAlignment); - writer.Write((int)instanceListOffset); + writer.WritePointer((ulong)instanceListOffset, ResourceArch); writer.Write(entryCount); writer.Write(NumInstances); writer.Write(1u); @@ -54,23 +57,48 @@ public override void ParseFromStream(ResourceBinaryReader reader, Endian endiann { base.ParseFromStream(reader, endianness); - long instanceListPtr = reader.ReadInt32(); + ulong instanceListPtr = reader.ReadPointer(ResourceArch); uint entries = reader.ReadUInt32(); NumInstances = reader.ReadUInt32(); - if (reader.ReadUInt32() != 1) + uint version = reader.ReadUInt32(); + if (version != 1) { - throw new InvalidDataException("Version mismatch!"); + throw new InvalidDataException($"Version mismatch! Version should be 1. (Found version {version})"); } Instances.Clear(); long instanceBlockSize = GetInstanceBlockSize(ResourceArch); - long importBlockOffset = instanceListPtr + (instanceBlockSize * entries); + if (entries > 0 && instanceListPtr == 0) + { + throw new InvalidDataException("Instance list pointer is null, but the resource declares instance entries."); + } + + if (NumInstances > entries) + { + throw new InvalidDataException( + $"Invalid InstanceList header: NumInstances ({NumInstances}) cannot exceed array size ({entries})."); + } + + if (instanceListPtr != 0 && instanceListPtr < (ulong)GetHeaderSize(ResourceArch)) + { + throw new InvalidDataException( + $"Invalid InstanceList pointer 0x{instanceListPtr:X}. Instance data overlaps the resource header."); + } + + long instanceDataLength = checked(instanceBlockSize * (long)entries); + if (instanceListPtr != 0 && ((long)instanceListPtr + instanceDataLength) > reader.BaseStream.Length) + { + throw new InvalidDataException( + $"Instance data range 0x{instanceListPtr:X}-0x{((long)instanceListPtr + instanceDataLength):X} exceeds stream length 0x{reader.BaseStream.Length:X}."); + } + + long importBlockOffset = (long)instanceListPtr + instanceDataLength; for (int i = 0; i < entries; i++) { - long instanceOffset = instanceListPtr + (instanceBlockSize * i); + long instanceOffset = (long)instanceListPtr + (instanceBlockSize * i); reader.ParseSection(instanceOffset, r => ReadInstance(r, ResourceArch, importBlockOffset), out Instance instance); Instances.Add(instance); @@ -82,6 +110,11 @@ private static int GetInstanceBlockSize(Arch arch) return arch == Arch.x64 ? 0x60 : 0x50; } + private static int GetHeaderSize(Arch arch) + { + return ResourceUtilities.GetPointerSize(arch) + (sizeof(uint) * 3); + } + private static Instance ReadInstance( ResourceBinaryReader reader, Arch arch, @@ -93,9 +126,13 @@ private static Instance ReadInstance( reader.BaseStream.Seek(blockStart + ResourceUtilities.GetPointerSize(arch), SeekOrigin.Begin); short backdropZoneId = reader.ReadInt16(); - reader.BaseStream.Seek(0x2, SeekOrigin.Current); + reader.BaseStream.Seek(0x6, SeekOrigin.Current); float maxVisibleDistanceSquared = reader.ReadSingle(); - reader.BaseStream.Seek(0x4, SeekOrigin.Current); + if (arch == Arch.x64) + { + reader.BaseStream.Seek(0xC, SeekOrigin.Current); + } + Matrix44Affine transformMatrix = ReadMatrix44Affine(reader); Transform transform = Matrix44AffineToTransform(transformMatrix); @@ -115,9 +152,12 @@ private static void WriteInstanceBlock(ResourceBinaryWriter writer, Instance ins writer.WritePointer(0, arch); writer.Write(instance.BackdropZoneID); - writer.Write(new byte[0x2]); + writer.WriteFixedBytes(null, 0x6); writer.Write(instance.MaxVisibleDistanceSquared); - writer.Write(new byte[0x4]); + if (arch == Arch.x64) + { + writer.WriteFixedBytes(null, 0xC); + } Matrix44Affine transformMatrix = instance.TransformMatrix != default ? instance.TransformMatrix @@ -140,6 +180,7 @@ private static void WriteInstanceBlock(ResourceBinaryWriter writer, Instance ins public override IEnumerable> GetExternalImports() { int instanceBlockSize = GetInstanceBlockSize(ResourceArch); + long instanceListOffset = ResourceUtilities.AlignOffset(GetHeaderSize(ResourceArch), SectionAlignment); for (int i = 0; i < Instances.Count; i++) { ResourceImport modelReference = Instances[i].ModelReference; @@ -149,7 +190,7 @@ public override IEnumerable> GetExternalImpor } yield return new KeyValuePair( - HeaderSize + (i * instanceBlockSize), + instanceListOffset + (i * instanceBlockSize), modelReference); } } @@ -157,8 +198,8 @@ public override IEnumerable> GetExternalImpor public struct Instance { - [EditorLabel("Resource ID"), EditorCategory("InstanceList/Instances"), EditorTooltip("The reference to the resource placed by this instance.")] - public ResourceImport ResourceId; + [EditorLabel("Model Reference"), EditorCategory("InstanceList/Instances"), EditorTooltip("The external model import referenced by this instance.")] + public ResourceImport ModelReference; [EditorLabel("Transform"), EditorCategory("InstanceList/Instances"), EditorTooltip("The location, rotation, and scale of this instance.")] public Transform Transform; @@ -169,9 +210,6 @@ public struct Instance [EditorLabel("Transform"), EditorCategory("InstanceList/Instances"), EditorTooltip("If this is a backdrop, the PVS Zone ID that this backdrop represents.")] public short BackdropZoneID; - [EditorLabel("Max Visible Distance Squared"), EditorCategory("InstanceList/Instances"), EditorTooltip("The maximum distance that this instance can be seen (in meters), squared.")] - public float MaxVisibleDistanceSquared; - [EditorHidden] - public ResourceImport ModelReference; + public float MaxVisibleDistanceSquared; } From 9477294854c326d459f9a00dadc0d2b8084191fe Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Tue, 21 Apr 2026 14:49:06 -0400 Subject: [PATCH 16/17] move GameAutotest out of this PR --- .gitmodules | 4 - README.md | 6 - Volatility/CLI/Commands/AutotestCommand.cs | 220 +---- .../Autotest/GameAutotestOperation.cs | 894 ------------------ 4 files changed, 3 insertions(+), 1121 deletions(-) delete mode 100644 .gitmodules delete mode 100644 Volatility/Operations/Autotest/GameAutotestOperation.cs diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index c4e539b..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "tools/libbndl-extractor"] - path = tools/libbndl-extractor - url = https://github.com/Adriwin06/libbndl-extractor.git - branch = main diff --git a/README.md b/README.md index d3476e9..8d76c07 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,8 @@ Download and extract the latest version of the application from the [Releases pa ### Developers Ensure you have the necessary prerequisites to develop .NET 9.0 applications on your machine. -After cloning, initialize the extractor submodule with `git submodule update --init --recursive`. -This must stay recursive because `tools/libbndl-extractor` itself pins `Bo98/libbndl` as a nested submodule. - Compiling the application is as simple as opening the project within your IDE of choice (Such as Rider or Visual Studio 2022), or by running `dotnet build`. -The autotest workflow uses the `tools/libbndl-extractor` submodule to unpack bundle files without committing its generated CMake state and fetched dependencies into the main repo. - ## Commands NOTE: This may not be entirely comprehensive. Run "help" for a full list of commands within the application. @@ -30,4 +25,3 @@ NOTE: This may not be entirely comprehensive. Run "help" for a full list of comm #### Autotest - Runs automatic tests to ensure the application is working. - When provided a path & format, will import, export, then reimport specified file to ensure IO parity. -- When provided game path(s), roundtrip tests now include exact binary parity checks between original and exported files. diff --git a/Volatility/CLI/Commands/AutotestCommand.cs b/Volatility/CLI/Commands/AutotestCommand.cs index 33bd012..8182809 100644 --- a/Volatility/CLI/Commands/AutotestCommand.cs +++ b/Volatility/CLI/Commands/AutotestCommand.cs @@ -1,7 +1,5 @@ using System.Reflection; -using System.Text; -using Volatility.Operations.Autotest; using Volatility.Resources; using static Volatility.Utilities.TypeUtilities; @@ -13,48 +11,14 @@ internal class AutotestCommand : ICommand { public static string CommandToken => "autotest"; public static string CommandDescription => "Runs automatic tests to ensure the application is working." + - " When provided a path & format, will import, export, then reimport specified file to ensure IO parity." + - " When provided one or more game paths, will probe all bundle-like root files through libbndl, run automated resource operations on supported resource types, and verify exact binary parity for roundtrip exports."; - public static string CommandParameters => "[--format=] [--path=] [--game=] [--games=] [--bundletool=] [--workdir=] [--bundlelimit=] [--resourcelimit=] [--keepartifacts] [--recap=]"; + " When provided a path & format, will import, export, then reimport specified file to ensure IO parity."; + public static string CommandParameters => "[--format=] [--path=]"; public string? Format { get; set; } public string? Path { get; set; } - public string? GamePath { get; set; } - public string? GamePaths { get; set; } - public string? BundleToolPath { get; set; } - public string? WorkingDirectory { get; set; } - public string? RecapPath { get; set; } - public int BundleLimit { get; set; } - public int ResourceLimit { get; set; } = 2; - public bool KeepArtifacts { get; set; } public async Task Execute() { - IReadOnlyList gamePaths = ParseGamePaths(); - if (gamePaths.Count > 0) - { - GameAutotestOperation operation = new(); - GameAutotestSummary summary = await operation.ExecuteAsync(new GameAutotestOptions - { - GamePaths = gamePaths, - BundleToolPath = BundleToolPath, - WorkingDirectory = WorkingDirectory, - BundleLimitPerGame = BundleLimit, - ResourcesPerType = ResourceLimit, - KeepArtifacts = KeepArtifacts - }); - - Console.WriteLine( - $"AUTOTEST - Completed. Passed={summary.Passed}, Failed={summary.Failed}, Skipped={summary.Skipped}"); - - if (!string.IsNullOrWhiteSpace(RecapPath)) - { - string recapFilePath = WriteDetailedRecap(gamePaths, summary, RecapPath); - Console.WriteLine($"AUTOTEST - Detailed recap written to: {recapFilePath}"); - } - return; - } - if (!string.IsNullOrEmpty(Path)) { TextureBase? header = Format switch @@ -163,24 +127,6 @@ public void SetArgs(Dictionary args) { Format = (args.TryGetValue("format", out object? format) ? format as string : "auto").ToUpper(); Path = args.TryGetValue("path", out object? path) ? path as string : ""; - GamePath = args.TryGetValue("game", out object? game) ? game as string : ""; - GamePaths = args.TryGetValue("games", out object? games) ? games as string : ""; - BundleToolPath = args.TryGetValue("bundletool", out object? bundleTool) ? bundleTool as string : ""; - WorkingDirectory = args.TryGetValue("workdir", out object? workdir) ? workdir as string : ""; - RecapPath = args.TryGetValue("recap", out object? recap) ? recap as string : ""; - KeepArtifacts = args.TryGetValue("keepartifacts", out var keepArtifacts) && (bool)keepArtifacts; - - if (args.TryGetValue("bundlelimit", out object? bundleLimitValue) && - int.TryParse(bundleLimitValue?.ToString(), out int bundleLimit)) - { - BundleLimit = Math.Max(0, bundleLimit); - } - - if (args.TryGetValue("resourcelimit", out object? resourceLimitValue) && - int.TryParse(resourceLimitValue?.ToString(), out int resourceLimit)) - { - ResourceLimit = Math.Max(1, resourceLimit); - } } public void TestHeaderRW(string name, TextureBase header, bool skipImport = false) @@ -288,165 +234,5 @@ public static void TestCompareHeaders(object exported, object imported) Console.ResetColor(); } - private IReadOnlyList ParseGamePaths() - { - List paths = []; - - if (!string.IsNullOrWhiteSpace(GamePath)) - { - paths.Add(GamePath); - } - - if (!string.IsNullOrWhiteSpace(GamePaths)) - { - paths.AddRange( - GamePaths - .Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); - } - - return paths - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static string WriteDetailedRecap(IReadOnlyList gamePaths, GameAutotestSummary summary, string outputPath) - { - string recapPath = ResolveRecapPath(outputPath); - StringBuilder builder = new(); - int binaryParityPassed = summary.Cases.Count(result => - string.Equals(result.Outcome, "PASS", StringComparison.Ordinal) && - string.Equals(result.Operation, "binaryparity", StringComparison.OrdinalIgnoreCase)); - int semiPassed = Math.Max(0, summary.Passed - binaryParityPassed); - DateTime generatedAt = DateTime.Now; - - builder.AppendLine("# Volatility Autotest Recap"); - builder.AppendLine(); - builder.AppendLine($"Generated ({GetLocalTimeZoneLabel(generatedAt)}): {generatedAt:yyyy-MM-dd HH:mm:ss}"); - builder.AppendLine($"Games: `{string.Join("` | `", gamePaths)}`"); - builder.AppendLine($"* Failed: {summary.Failed}"); - builder.AppendLine($"* Passed with binary parity: {binaryParityPassed}"); - builder.AppendLine($"* Semi-passed (without binary parity): {semiPassed}"); - builder.AppendLine($"* Skipped: {summary.Skipped}"); - builder.AppendLine(); - - builder.AppendLine("## Test Operation Summary"); - builder.AppendLine(); - - List> byOperation = summary.Cases - .GroupBy(result => result.Operation, StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (byOperation.Count == 0) - { - builder.AppendLine("No test operations were recorded."); - } - else - { - builder.AppendLine("| Operation | Passed | Failed | Skipped |"); - builder.AppendLine("| --- | ---: | ---: | ---: |"); - - foreach (IGrouping group in byOperation) - { - int passed = group.Count(result => string.Equals(result.Outcome, "PASS", StringComparison.Ordinal)); - int failed = group.Count(result => string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); - int skipped = group.Count(result => !string.Equals(result.Outcome, "PASS", StringComparison.Ordinal) && !string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); - - builder.AppendLine($"| {group.Key} | {passed} | {failed} | {skipped} |"); - } - } - - builder.AppendLine(); - - List> byResourceType = summary.Cases - .Where(result => result.TestedResourceType.HasValue) - .GroupBy(result => result.TestedResourceType!.Value) - .OrderBy(group => group.Key.ToString(), StringComparer.OrdinalIgnoreCase) - .ToList(); - - builder.AppendLine("## Resource Type Outcomes"); - builder.AppendLine(); - - if (byResourceType.Count == 0) - { - builder.AppendLine("No resource-type specific cases were recorded."); - } - else - { - builder.AppendLine("| Resource Type | Passed | Failed | Skipped | Overall |"); - builder.AppendLine("| --- | ---: | ---: | ---: | --- |"); - - foreach (IGrouping group in byResourceType) - { - int passed = group.Count(result => string.Equals(result.Outcome, "PASS", StringComparison.Ordinal)); - int failed = group.Count(result => string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); - int skipped = group.Count(result => !string.Equals(result.Outcome, "PASS", StringComparison.Ordinal) && !string.Equals(result.Outcome, "FAIL", StringComparison.Ordinal)); - string overall = failed > 0 ? "FAIL" : passed > 0 ? "PASS" : "SKIP"; - - builder.AppendLine($"| {group.Key} | {passed} | {failed} | {skipped} | {overall} |"); - } - } - - builder.AppendLine(); - builder.AppendLine("## Case Details"); - builder.AppendLine(); - builder.AppendLine("| Game | Resource Type | Operation | Name | Outcome | Details |"); - builder.AppendLine("| --- | --- | --- | --- | --- | --- |"); - - foreach (GameAutotestCaseResult result in summary.Cases) - { - string resourceType = result.TestedResourceType?.ToString() ?? "-"; - builder.AppendLine($"| {EscapeMarkdownCell(result.Game)} | {EscapeMarkdownCell(resourceType)} | {EscapeMarkdownCell(result.Operation)} | {EscapeMarkdownCell(result.Name)} | {EscapeMarkdownCell(result.Outcome)} | {EscapeMarkdownCell(result.Details ?? string.Empty)} |"); - } - - File.WriteAllText(recapPath, builder.ToString()); - return recapPath; - } - - private static string ResolveRecapPath(string outputPath) - { - string fullPath = System.IO.Path.GetFullPath(outputPath); - bool looksLikeDirectory = - outputPath.EndsWith(System.IO.Path.DirectorySeparatorChar) || - outputPath.EndsWith(System.IO.Path.AltDirectorySeparatorChar) || - string.IsNullOrWhiteSpace(System.IO.Path.GetExtension(fullPath)); - - if (Directory.Exists(fullPath) || looksLikeDirectory) - { - Directory.CreateDirectory(fullPath); - string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); - return System.IO.Path.Combine(fullPath, $"autotest_recap_{timestamp}.md"); - } - - string? directory = System.IO.Path.GetDirectoryName(fullPath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - return fullPath; - } - - private static string GetLocalTimeZoneLabel(DateTime localTime) - { - TimeZoneInfo localTimeZone = TimeZoneInfo.Local; - TimeSpan offset = localTimeZone.GetUtcOffset(localTime); - string sign = offset < TimeSpan.Zero ? "-" : "+"; - TimeSpan absoluteOffset = offset.Duration(); - - return $"UTC{sign}{absoluteOffset:hh\\:mm}"; - } - - private static string EscapeMarkdownCell(string value) - { - return value - .Replace("\\", "\\\\", StringComparison.Ordinal) - .Replace("|", "\\|", StringComparison.Ordinal) - .Replace("\r", " ", StringComparison.Ordinal) - .Replace("\n", "
", StringComparison.Ordinal) - .Trim(); - } - public AutotestCommand() { } -} +} \ No newline at end of file diff --git a/Volatility/Operations/Autotest/GameAutotestOperation.cs b/Volatility/Operations/Autotest/GameAutotestOperation.cs deleted file mode 100644 index 512d2e2..0000000 --- a/Volatility/Operations/Autotest/GameAutotestOperation.cs +++ /dev/null @@ -1,894 +0,0 @@ -using System.Globalization; - -using Volatility.Operations.Resources; -using Volatility.Resources; -using Volatility.Utilities; - -namespace Volatility.Operations.Autotest; - -internal sealed class GameAutotestOptions -{ - public required IReadOnlyList GamePaths { get; init; } - public string? BundleToolPath { get; init; } - public string? WorkingDirectory { get; init; } - public int BundleLimitPerGame { get; init; } - public int ResourcesPerType { get; init; } = 2; - public bool KeepArtifacts { get; init; } -} - -internal sealed class GameAutotestSummary -{ - public int Passed { get; set; } - public int Failed { get; set; } - public int Skipped { get; set; } - public List Cases { get; } = []; -} - -internal sealed record GameAutotestCaseResult( - string Game, - string Name, - string Operation, - string Outcome, - string? Details = null, - ResourceType? TestedResourceType = null); - -internal sealed class GameAutotestOperation -{ - private static readonly HashSet RoundTripTypes = - [ - ResourceType.Texture, - ResourceType.GuiPopup, - ResourceType.InstanceList, - ResourceType.Model, - ResourceType.EnvironmentKeyframe, - ResourceType.EnvironmentTimeLine, - ResourceType.SnapshotData, - ResourceType.StreamedDeformationSpec, - ]; - - private static readonly HashSet ImportOnlyTypes = - [ - ResourceType.Renderable, - ResourceType.Splicer, - ResourceType.AptData, - ]; - - private static readonly string[] PreferredBundleNames = - [ - "POPUPS.PUP", - "AI.DAT", - "PROGRESSION.DAT", - "BTTPROGRESSION.DAT", - "STREETDATA.DAT", - "TRIGGERS.DAT", - "HUDMESSAGES.HM", - "HUDMESSAGESEQUENCES.HMSC", - "B5TRAFFIC.BNDL", - "BTTB5TRAFFIC.BNDL", - "GLOBALBACKDROPS.BNDL", - "GLOBALMODELDICTIONARY.BIN", - "GLOBALPROPS.BIN", - "GLOBALTEXTUREDICTIONARY.BIN", - "GUITEXTURES.BIN", - "MASSIVETABLE.BIN", - "MASSIVETEXTUREDICTIONARY.BIN", - "SURFACELIST.BIN", - "WORLDVAULT.BIN", - "CAMERAS.BUNDLE", - "FLAPTHUD.BUNDLE", - "PARTICLES.BUNDLE", - "PLAYBACKREGISTRY.BUNDLE", - "PVS.BNDL", - "ONLINECHALLENGES.BNDL", - "RWACFEATUREREGISTRY.BUNDLE", - "SHADERS.BNDL", - "TRK_UNIT0_GR.BNDL", - ]; - - public async Task ExecuteAsync(GameAutotestOptions options) - { - if (options.GamePaths.Count == 0) - { - throw new InvalidOperationException("At least one game path must be provided."); - } - - string repoRoot = WorkspaceUtilities.FindRepositoryRoot(); - string bundleToolPath = ResolveBundleTool(repoRoot, options.BundleToolPath); - string sessionRoot = ResolveSessionRoot(repoRoot, options.WorkingDirectory); - - Directory.CreateDirectory(sessionRoot); - - GameAutotestSummary summary = new(); - foreach (string gamePath in options.GamePaths) - { - GameInstall game = DetectGameInstall(gamePath); - await RunGameAsync(game, bundleToolPath, sessionRoot, options, summary); - } - - return summary; - } - - private async Task RunGameAsync( - GameInstall game, - string bundleToolPath, - string sessionRoot, - GameAutotestOptions options, - GameAutotestSummary summary) - { - string gameWorkRoot = Path.Combine(sessionRoot, $"{SanitizePathSegment(game.Name)}_{game.Platform}"); - Directory.CreateDirectory(gameWorkRoot); - - Console.WriteLine($"AUTOTEST - Game: {game.Name} ({game.Platform})"); - Console.WriteLine($"AUTOTEST - Working directory: {gameWorkRoot}"); - - int failuresBefore = summary.Failed; - - List candidates = []; - candidates.AddRange(GetDirectCandidates(game)); - - List probedBundles = ProbeBundleCandidates(game, bundleToolPath, gameWorkRoot, options, summary); - candidates.AddRange(ExtractSupportedBundleCandidates(game, bundleToolPath, gameWorkRoot, options, probedBundles, summary)); - - if (candidates.Count == 0) - { - AddCase(summary, new GameAutotestCaseResult( - game.Name, - "No candidates", - "discover", - "SKIP", - "No supported resources were discovered after probing bundle-like root files.")); - - if (!options.KeepArtifacts && failuresBefore == summary.Failed) - { - Directory.Delete(gameWorkRoot, recursive: true); - } - - return; - } - - string pass1Resources = Path.Combine(gameWorkRoot, "import_pass1", "Resources"); - string pass2Resources = Path.Combine(gameWorkRoot, "import_pass2", "Resources"); - string splicerPass1 = Path.Combine(gameWorkRoot, "import_pass1", "Splicer"); - string splicerPass2 = Path.Combine(gameWorkRoot, "import_pass2", "Splicer"); - string exportsRoot = Path.Combine(gameWorkRoot, "exports"); - string ddsRoot = Path.Combine(gameWorkRoot, "dds"); - string portRoot = Path.Combine(gameWorkRoot, "port"); - string toolsRoot = EnvironmentUtilities.GetEnvironmentDirectory(EnvironmentUtilities.EnvironmentDirectory.Tools); - - Directory.CreateDirectory(pass1Resources); - Directory.CreateDirectory(pass2Resources); - Directory.CreateDirectory(splicerPass1); - Directory.CreateDirectory(splicerPass2); - Directory.CreateDirectory(exportsRoot); - Directory.CreateDirectory(ddsRoot); - Directory.CreateDirectory(portRoot); - - ImportResourceOperation importPass1 = new(pass1Resources, toolsRoot, splicerPass1, overwrite: true); - ImportResourceOperation importPass2 = new(pass2Resources, toolsRoot, splicerPass2, overwrite: true); - SaveResourceOperation saveOperation = new(); - LoadResourceOperation loadOperation = new(); - ExportResourceOperation exportOperation = new(); - TextureToDDSOperation textureToDdsOperation = new(); - PortTextureOperation portTextureOperation = new(); - - foreach (ResourceTestCandidate candidate in candidates) - { - if (RoundTripTypes.Contains(candidate.ResourceType)) - { - await RunRoundTripAsync( - game, - candidate, - importPass1, - importPass2, - saveOperation, - loadOperation, - exportOperation, - exportsRoot, - summary); - } - else if (ImportOnlyTypes.Contains(candidate.ResourceType)) - { - await RunImportOnlyAsync(game, candidate, importPass1, saveOperation, summary); - } - - if (candidate.ResourceType == ResourceType.Texture) - { - await RunTextureOperationsAsync(game, candidate, textureToDdsOperation, portTextureOperation, ddsRoot, portRoot, summary); - } - } - - if (!options.KeepArtifacts && failuresBefore == summary.Failed) - { - Directory.Delete(gameWorkRoot, recursive: true); - } - } - - private static async Task RunRoundTripAsync( - GameInstall game, - ResourceTestCandidate candidate, - ImportResourceOperation importPass1, - ImportResourceOperation importPass2, - SaveResourceOperation saveOperation, - LoadResourceOperation loadOperation, - ExportResourceOperation exportOperation, - string exportsRoot, - GameAutotestSummary summary) - { - string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; - string? exportPath = null; - bool binaryParityRecorded = false; - - try - { - ImportResourceResult firstImport = await importPass1.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); - await saveOperation.ExecuteAsync(firstImport.Resource, firstImport.ResourcePath); - - Resource loaded = await loadOperation.ExecuteAsync(firstImport.ResourcePath, candidate.ResourceType, game.Platform); - exportPath = Path.Combine(exportsRoot, Path.GetFileName(candidate.SourcePath)); - await exportOperation.ExecuteAsync(loaded, exportPath, game.Platform); - - BinaryComparisonResult binaryComparison = CompareFilesExactly(candidate.SourcePath, exportPath); - AddCase(summary, new GameAutotestCaseResult( - game.Name, - caseName, - "binaryparity", - binaryComparison.Matches ? "PASS" : "FAIL", - binaryComparison.Details, - TestedResourceType: candidate.ResourceType)); - binaryParityRecorded = true; - - ImportResourceResult secondImport = await importPass2.ExecuteAsync(candidate.ResourceType, game.Platform, exportPath, isX64: false); - await saveOperation.ExecuteAsync(secondImport.Resource, secondImport.ResourcePath); - - string firstYaml = NormalizeYamlForComparison(await File.ReadAllTextAsync(firstImport.ResourcePath)); - string secondYaml = NormalizeYamlForComparison(await File.ReadAllTextAsync(secondImport.ResourcePath)); - - if (string.Equals(firstYaml, secondYaml, StringComparison.Ordinal)) - { - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "PASS", TestedResourceType: candidate.ResourceType)); - return; - } - - AddCase(summary, new GameAutotestCaseResult( - game.Name, - caseName, - "roundtrip", - "FAIL", - $"YAML mismatch after reimport. Pass1={firstImport.ResourcePath}, Pass2={secondImport.ResourcePath}", - TestedResourceType: candidate.ResourceType)); - } - catch (Exception ex) - { - if (!binaryParityRecorded) - { - string binaryOutcome = string.IsNullOrWhiteSpace(exportPath) ? "SKIP" : "FAIL"; - string binaryDetails = string.IsNullOrWhiteSpace(exportPath) - ? $"Roundtrip failed before binary parity comparison: {ex.Message}" - : $"Binary parity comparison failed: {ex.Message}"; - - AddCase(summary, new GameAutotestCaseResult( - game.Name, - caseName, - "binaryparity", - binaryOutcome, - binaryDetails, - TestedResourceType: candidate.ResourceType)); - } - - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "roundtrip", "FAIL", ex.Message, candidate.ResourceType)); - } - } - - private static async Task RunImportOnlyAsync( - GameInstall game, - ResourceTestCandidate candidate, - ImportResourceOperation importOperation, - SaveResourceOperation saveOperation, - GameAutotestSummary summary) - { - string caseName = $"{candidate.ResourceType}:{candidate.DisplayName}"; - - try - { - ImportResourceResult importResult = await importOperation.ExecuteAsync(candidate.ResourceType, game.Platform, candidate.SourcePath, isX64: false); - await saveOperation.ExecuteAsync(importResult.Resource, importResult.ResourcePath); - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "PASS", TestedResourceType: candidate.ResourceType)); - } - catch (Exception ex) - { - AddCase(summary, new GameAutotestCaseResult(game.Name, caseName, "import", "FAIL", ex.Message, candidate.ResourceType)); - } - } - - private static async Task RunTextureOperationsAsync( - GameInstall game, - ResourceTestCandidate candidate, - TextureToDDSOperation textureToDdsOperation, - PortTextureOperation portTextureOperation, - string ddsRoot, - string portRoot, - GameAutotestSummary summary) - { - string ddsCaseName = $"{candidate.DisplayName}:dds"; - try - { - await textureToDdsOperation.ExecuteAsync([candidate.SourcePath], game.Platform, isX64: false, ddsRoot, overwrite: true, verbose: false); - AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", "PASS", TestedResourceType: ResourceType.Texture)); - } - catch (Exception ex) - { - string outcome = IsSkippableTextureOperation(ex) ? "SKIP" : "FAIL"; - AddCase(summary, new GameAutotestCaseResult(game.Name, ddsCaseName, "texturetodds", outcome, ex.Message, ResourceType.Texture)); - } - - Platform destinationPlatform = GetTexturePortDestination(game.Platform); - if (destinationPlatform == Platform.Agnostic) - { - AddCase(summary, new GameAutotestCaseResult(game.Name, $"{candidate.DisplayName}:port", "porttexture", "SKIP", "No supported destination platform.", ResourceType.Texture)); - return; - } - - string portCaseName = $"{candidate.DisplayName}:{game.Platform}->{destinationPlatform}"; - try - { - string destinationFormat = destinationPlatform == Platform.TUB ? "TUB" : destinationPlatform.ToString().ToUpperInvariant(); - string sourceFormat = game.Platform == Platform.TUB ? "TUB" : game.Platform.ToString().ToUpperInvariant(); - string destinationPath = Path.Combine(portRoot, destinationPlatform.ToString()); - Directory.CreateDirectory(destinationPath); - - await portTextureOperation.ExecuteAsync( - [candidate.SourcePath], - sourceFormat, - candidate.SourcePath, - destinationFormat, - destinationPath, - verbose: false, - useGtf: false); - - AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "PASS", TestedResourceType: ResourceType.Texture)); - } - catch (Exception ex) - { - AddCase(summary, new GameAutotestCaseResult(game.Name, portCaseName, "porttexture", "FAIL", ex.Message, ResourceType.Texture)); - } - } - - private static IEnumerable GetDirectCandidates(GameInstall game) - { - _ = game; - yield break; - } - - private static List ProbeBundleCandidates( - GameInstall game, - string bundleToolPath, - string gameWorkRoot, - GameAutotestOptions options, - GameAutotestSummary summary) - { - string probeRoot = Path.Combine(gameWorkRoot, "bundle_probes"); - Directory.CreateDirectory(probeRoot); - - HashSet reportedUnsupportedTypes = []; - List probes = []; - - foreach (string bundlePath in ApplyBundleLimit(GetBundleCandidates(game.RootPath), options.BundleLimitPerGame)) - { - string bundleName = Path.GetFileName(bundlePath); - string outputDirectory = Path.Combine(probeRoot, SanitizePathSegment(bundleName)); - string manifestPath = Path.Combine(outputDirectory, "manifest.tsv"); - - RecreateDirectory(outputDirectory); - - try - { - ProcessUtilities.RunAndCapture( - bundleToolPath, - $"--bundle \"{bundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\" --metadataonly", - Path.GetDirectoryName(bundleToolPath)); - } - catch (Exception ex) - { - AddCase(summary, new GameAutotestCaseResult(game.Name, bundleName, "bundleprobe", "FAIL", ex.Message)); - continue; - } - - List entries = ParseManifest(bundlePath, outputDirectory, manifestPath).ToList(); - int supportedCount = entries.Count(entry => IsSupportedResourceType(entry.ResourceType)); - - Console.WriteLine( - $"AUTOTEST - Probed {bundleName}: Resources={entries.Count}, Supported={supportedCount}, Types={FormatTypeSummary(entries.Select(entry => entry.ResourceType))}"); - - foreach (ResourceType unsupportedType in entries - .Select(entry => entry.ResourceType) - .Where(type => !IsSupportedResourceType(type)) - .Distinct()) - { - if (reportedUnsupportedTypes.Add(unsupportedType)) - { - AddCase(summary, new GameAutotestCaseResult( - game.Name, - GetResourceTypeLabel(unsupportedType), - "unsupported", - "SKIP", - $"Discovered in {bundleName}. No Volatility autotest handler exists for this resource type.", - TestedResourceType: unsupportedType)); - } - } - - probes.Add(new ProbedBundle(bundlePath, entries)); - } - - return probes; - } - - private static List ExtractSupportedBundleCandidates( - GameInstall game, - string bundleToolPath, - string gameWorkRoot, - GameAutotestOptions options, - IReadOnlyList probedBundles, - GameAutotestSummary summary) - { - string extractedRoot = Path.Combine(gameWorkRoot, "bundles"); - Directory.CreateDirectory(extractedRoot); - - HashSet blockedTypes = []; - Dictionary selectedCounts = new(); - List candidates = []; - foreach (ProbedBundle probedBundle in probedBundles) - { - Dictionary pendingCounts = new(); - List selectedEntries = []; - - foreach (BundleManifestEntry entry in probedBundle.Entries.DistinctBy(entry => entry.ResourceIdHex, StringComparer.OrdinalIgnoreCase)) - { - if (!IsSupportedResourceType(entry.ResourceType) || blockedTypes.Contains(entry.ResourceType)) - { - continue; - } - - int currentCount = selectedCounts.GetValueOrDefault(entry.ResourceType); - int pendingCount = pendingCounts.GetValueOrDefault(entry.ResourceType); - if (currentCount + pendingCount >= options.ResourcesPerType) - { - continue; - } - - selectedEntries.Add(entry); - pendingCounts[entry.ResourceType] = pendingCount + 1; - } - - if (selectedEntries.Count == 0) - { - continue; - } - - string bundleName = Path.GetFileName(probedBundle.BundlePath); - string outputDirectory = Path.Combine(extractedRoot, SanitizePathSegment(bundleName)); - string manifestPath = Path.Combine(outputDirectory, "manifest.tsv"); - - RecreateDirectory(outputDirectory); - - try - { - ProcessUtilities.RunAndCapture( - bundleToolPath, - $"--bundle \"{probedBundle.BundlePath}\" --output \"{outputDirectory}\" --manifest \"{manifestPath}\"", - Path.GetDirectoryName(bundleToolPath)); - } - catch (Exception ex) - { - string outcome = IsSkippableBundleExtractionFailure(ex) ? "SKIP" : "FAIL"; - - if (outcome == "SKIP") - { - foreach (ResourceType blockedType in selectedEntries.Select(entry => entry.ResourceType).Distinct()) - { - blockedTypes.Add(blockedType); - } - } - - AddCase(summary, new GameAutotestCaseResult(game.Name, bundleName, "bundleextract", outcome, ex.Message)); - continue; - } - - List extractedEntries = ParseManifest(probedBundle.BundlePath, outputDirectory, manifestPath) - .Where(entry => !string.IsNullOrWhiteSpace(entry.PrimaryPath)) - .ToList(); - - Console.WriteLine( - $"AUTOTEST - Extracted {bundleName}: Resources={extractedEntries.Count}, Selected={selectedEntries.Count}"); - - Dictionary extractedById = extractedEntries - .GroupBy(entry => entry.ResourceIdHex, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); - - foreach (BundleManifestEntry selectedEntry in selectedEntries.DistinctBy(entry => entry.ResourceIdHex, StringComparer.OrdinalIgnoreCase)) - { - if (selectedCounts.GetValueOrDefault(selectedEntry.ResourceType) >= options.ResourcesPerType) - { - continue; - } - - if (!extractedById.TryGetValue(selectedEntry.ResourceIdHex, out BundleManifestEntry? extractedEntry) || - string.IsNullOrWhiteSpace(extractedEntry.PrimaryPath)) - { - AddCase(summary, new GameAutotestCaseResult( - game.Name, - $"{selectedEntry.ResourceType}:{selectedEntry.DisplayName}", - "candidate", - "FAIL", - $"Failed to resolve extracted primary data from {bundleName}.", - TestedResourceType: selectedEntry.ResourceType)); - continue; - } - - candidates.Add(new ResourceTestCandidate(extractedEntry.DisplayName, extractedEntry.PrimaryPath, extractedEntry.ResourceType)); - selectedCounts[extractedEntry.ResourceType] = selectedCounts.GetValueOrDefault(extractedEntry.ResourceType) + 1; - } - } - - foreach (ResourceType blockedType in blockedTypes.Where(type => selectedCounts.GetValueOrDefault(type) == 0)) - { - AddCase(summary, new GameAutotestCaseResult( - game.Name, - GetResourceTypeLabel(blockedType), - "candidate", - "SKIP", - "No fully extractable bundle candidate was available for this supported resource type.", - TestedResourceType: blockedType)); - } - - return candidates; - } - - private static IEnumerable GetBundleCandidates(string rootPath) - { - List candidates = Directory - .EnumerateFiles(rootPath, "*", SearchOption.TopDirectoryOnly) - .Where(IsBundleLikeFile) - .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) - .ToList(); - - List ordered = []; - foreach (string preferredName in PreferredBundleNames) - { - string? match = candidates.FirstOrDefault(path => - string.Equals(Path.GetFileName(path), preferredName, StringComparison.OrdinalIgnoreCase)); - - if (match != null) - { - ordered.Add(match); - } - } - - ordered.AddRange(candidates.Where(path => !ordered.Contains(path, StringComparer.OrdinalIgnoreCase))); - return ordered; - } - - private static IEnumerable ApplyBundleLimit(IEnumerable candidates, int bundleLimitPerGame) - { - return bundleLimitPerGame > 0 ? candidates.Take(bundleLimitPerGame) : candidates; - } - - private static bool IsBundleLikeFile(string path) - { - try - { - using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - if (stream.Length < 4) - { - return false; - } - - byte[] magic = new byte[4]; - if (stream.Read(magic, 0, magic.Length) != magic.Length) - { - return false; - } - - return magic[0] == (byte)'b' && - magic[1] == (byte)'n' && - magic[2] == (byte)'d' && - (magic[3] == (byte)'2' || magic[3] == (byte)'l'); - } - catch - { - return false; - } - } - - private static void RecreateDirectory(string path) - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - - Directory.CreateDirectory(path); - } - - private static IEnumerable ParseManifest(string bundlePath, string outputDirectory, string manifestPath) - { - foreach (string line in File.ReadLines(manifestPath).Skip(1)) - { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - string[] parts = line.Split('\t'); - if (parts.Length < 4) - { - continue; - } - - if (!uint.TryParse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint typeValue)) - { - continue; - } - - if (!Enum.IsDefined(typeof(ResourceType), (int)typeValue)) - { - continue; - } - - string resourceIdHex = parts[0].Trim(); - string displayName = !string.IsNullOrWhiteSpace(parts[2]) - ? parts[2] - : parts.Length > 3 && !string.IsNullOrWhiteSpace(parts[3]) - ? parts[3] - : resourceIdHex; - - string? primaryPath = null; - if (parts.Length > 4 && !string.IsNullOrWhiteSpace(parts[4])) - { - primaryPath = Path.Combine(outputDirectory, parts[4]); - } - - yield return new BundleManifestEntry( - bundlePath, - resourceIdHex, - displayName, - (ResourceType)typeValue, - primaryPath); - } - } - - private static string ResolveBundleTool(string repoRoot, string? bundleToolPath) - { - if (!string.IsNullOrWhiteSpace(bundleToolPath)) - { - string explicitPath = Path.GetFullPath(bundleToolPath); - if (!File.Exists(explicitPath)) - { - throw new FileNotFoundException($"Bundle extractor not found: {explicitPath}"); - } - - return explicitPath; - } - - string defaultTool = Path.Combine(repoRoot, "tools", "libbndl-extractor", "build", "volatility_libbndl_extract.exe"); - if (File.Exists(defaultTool)) - { - return defaultTool; - } - - string buildScript = Path.Combine(repoRoot, "tools", "libbndl-extractor", "build.ps1"); - ProcessUtilities.RunAndCapture("powershell", $"-ExecutionPolicy Bypass -File \"{buildScript}\"", repoRoot); - - if (!File.Exists(defaultTool)) - { - throw new FileNotFoundException($"Failed to build bundle extractor at {defaultTool}"); - } - - return defaultTool; - } - - private static string ResolveSessionRoot(string repoRoot, string? workingDirectory) - { - if (!string.IsNullOrWhiteSpace(workingDirectory)) - { - return Path.GetFullPath(workingDirectory); - } - - string stamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); - return Path.Combine(repoRoot, ".tmp", "game-autotest", stamp); - } - - private static GameInstall DetectGameInstall(string gamePath) - { - string fullPath = Path.GetFullPath(gamePath); - if (!Directory.Exists(fullPath)) - { - throw new DirectoryNotFoundException($"Game directory not found: {fullPath}"); - } - - if (File.Exists(Path.Combine(fullPath, "BurnoutPR.exe")) || - File.Exists(Path.Combine(fullPath, "BurnoutPR_trial.exe"))) - { - return new GameInstall(Path.GetFileName(fullPath), fullPath, Platform.TUB); - } - - if (Directory.EnumerateFiles(fullPath, "*.xex", SearchOption.TopDirectoryOnly).Any()) - { - return new GameInstall(Path.GetFileName(fullPath), fullPath, Platform.X360); - } - - throw new InvalidOperationException($"Unable to infer platform for game directory: {fullPath}"); - } - - private static bool IsSupportedResourceType(ResourceType resourceType) - { - return RoundTripTypes.Contains(resourceType) || ImportOnlyTypes.Contains(resourceType); - } - - private static string FormatTypeSummary(IEnumerable resourceTypes) - { - List labels = resourceTypes - .Distinct() - .Select(GetResourceTypeLabel) - .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (labels.Count == 0) - { - return "None"; - } - - const int maxDisplayedTypes = 6; - if (labels.Count <= maxDisplayedTypes) - { - return string.Join(", ", labels); - } - - return $"{string.Join(", ", labels.Take(maxDisplayedTypes))}, +{labels.Count - maxDisplayedTypes} more"; - } - - private static string GetResourceTypeLabel(ResourceType resourceType) - { - return Enum.IsDefined(typeof(ResourceType), resourceType) - ? resourceType.ToString() - : $"0x{(uint)resourceType:X8}"; - } - - private static Platform GetTexturePortDestination(Platform sourcePlatform) - { - return sourcePlatform switch - { - Platform.TUB => Platform.BPR, - Platform.X360 => Platform.TUB, - Platform.PS3 => Platform.TUB, - Platform.BPR => Platform.TUB, - _ => Platform.Agnostic, - }; - } - - private static string NormalizeYamlForComparison(string yaml) - { - IEnumerable lines = yaml - .Replace("\r\n", "\n", StringComparison.Ordinal) - .Split('\n') - .Where(line => !line.TrimStart().StartsWith("ImportedFileName:", StringComparison.Ordinal)); - - return string.Join('\n', lines).Trim(); - } - - private static BinaryComparisonResult CompareFilesExactly(string originalPath, string exportedPath) - { - FileInfo originalInfo = new(originalPath); - FileInfo exportedInfo = new(exportedPath); - - if (originalInfo.Length != exportedInfo.Length) - { - return new BinaryComparisonResult( - Matches: false, - Details: $"Binary size mismatch. Original={originalInfo.Length} bytes, Exported={exportedInfo.Length} bytes."); - } - - using FileStream originalStream = new(originalPath, FileMode.Open, FileAccess.Read, FileShare.Read); - using FileStream exportedStream = new(exportedPath, FileMode.Open, FileAccess.Read, FileShare.Read); - - const int bufferSize = 81920; - byte[] originalBuffer = new byte[bufferSize]; - byte[] exportedBuffer = new byte[bufferSize]; - long offset = 0; - - while (true) - { - int originalRead = originalStream.Read(originalBuffer, 0, originalBuffer.Length); - int exportedRead = exportedStream.Read(exportedBuffer, 0, exportedBuffer.Length); - - if (originalRead != exportedRead) - { - return new BinaryComparisonResult( - Matches: false, - Details: $"Binary read mismatch at offset 0x{offset:X}. OriginalRead={originalRead}, ExportedRead={exportedRead}."); - } - - if (originalRead == 0) - { - break; - } - - for (int i = 0; i < originalRead; i++) - { - if (originalBuffer[i] != exportedBuffer[i]) - { - long mismatchOffset = offset + i; - return new BinaryComparisonResult( - Matches: false, - Details: $"Binary mismatch at offset 0x{mismatchOffset:X}. Original=0x{originalBuffer[i]:X2}, Exported=0x{exportedBuffer[i]:X2}."); - } - } - - offset += originalRead; - } - - return new BinaryComparisonResult( - Matches: true, - Details: "Binary files are identical."); - } - - private static bool IsSkippableTextureOperation(Exception ex) - { - return ex.Message.Contains("DDS export is not supported", StringComparison.OrdinalIgnoreCase) || - ex.Message.Contains("Failed to find associated bitmap data", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsSkippableBundleExtractionFailure(Exception ex) - { - return ex.Message.Contains("Assertion failed: m_flags & Compressed", StringComparison.OrdinalIgnoreCase); - } - - private static string SanitizePathSegment(string value) - { - foreach (char invalidChar in Path.GetInvalidFileNameChars()) - { - value = value.Replace(invalidChar, '_'); - } - - return string.IsNullOrWhiteSpace(value) ? "game" : value; - } - - private static void AddCase(GameAutotestSummary summary, GameAutotestCaseResult result) - { - summary.Cases.Add(result); - - switch (result.Outcome) - { - case "PASS": - summary.Passed++; - Console.ForegroundColor = ConsoleColor.Green; - break; - case "FAIL": - summary.Failed++; - Console.ForegroundColor = ConsoleColor.Red; - break; - default: - summary.Skipped++; - Console.ForegroundColor = ConsoleColor.DarkYellow; - break; - } - - Console.WriteLine($"[{result.Outcome}] {result.Game} {result.Operation} {result.Name}" + - (string.IsNullOrWhiteSpace(result.Details) ? string.Empty : $" - {result.Details}")); - Console.ResetColor(); - } - - private sealed record GameInstall(string Name, string RootPath, Platform Platform); - - private sealed record ResourceTestCandidate(string DisplayName, string SourcePath, ResourceType ResourceType); - - private sealed record BinaryComparisonResult(bool Matches, string Details); - - private sealed record ProbedBundle(string BundlePath, List Entries); - - private sealed record BundleManifestEntry( - string BundlePath, - string ResourceIdHex, - string DisplayName, - ResourceType ResourceType, - string? PrimaryPath); -} From f5f1413a7154d03bc355f10b4c80b2f6fb352658 Mon Sep 17 00:00:00 2001 From: "Nathan V." Date: Tue, 21 Apr 2026 15:28:12 -0400 Subject: [PATCH 17/17] minor formatting --- Volatility/Resources/BinaryResource.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Volatility/Resources/BinaryResource.cs b/Volatility/Resources/BinaryResource.cs index 2220362..8479db3 100644 --- a/Volatility/Resources/BinaryResource.cs +++ b/Volatility/Resources/BinaryResource.cs @@ -14,8 +14,7 @@ public class BinaryResource : Resource public uint DataSize { get; set; } public uint DataOffset { get; set; } - public BinaryResource(uint dataOffset, uint dataSize) - : this() + public BinaryResource(uint dataOffset, uint dataSize) : this() { DataSize = dataSize; DataOffset = dataOffset == 0 ? 0x10u : dataOffset; @@ -26,8 +25,7 @@ public BinaryResource() : base() DataOffset = 0x10; } - public BinaryResource(string path, Endian endianness = Endian.Agnostic) - : base(path, endianness) + public BinaryResource(string path, Endian endianness = Endian.Agnostic) : base(path, endianness) { if (DataOffset == 0) {