From a5ba435839847682a5dd2fa66c72bb5552d742df Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 21:19:02 +0200 Subject: [PATCH] feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD: ObjectMeshDataSerializerTests + the shared ObjectMeshDataEquality field-by-field comparator (reused by Task 6's equivalence suite) written first, confirmed a compile failure against the not-yet-existing ObjectMeshDataSerializer type. Serializes EVERY field of the ObjectMeshData family per the plan's normative layout: primitives raw LE, arrays as count:i32+payload, blittable arrays (VertexPositionNormalTexture[], ushort[], byte[]) via MemoryMarshal.AsBytes bulk copy, TextureBatches written sorted by the (Width, Height, Format) key tuple for run-to-run determinism regardless of dictionary insertion order, nullable fields as present:byte+value. EnvCellGeometry nests recursively (MeshExtractor can populate one level today; the serializer supports arbitrary depth rather than assuming it). Namespace-trap finding: StagedEmitter.Emitter resolves to DatReaderWriter.DBObjs.ParticleEmitter (the dat DBObj, verified via reflection against the pinned Chorizite.DatReaderWriter 2.1.7 package and confirmed live by MeshExtractor's `emitter.HwGfxObjId.DataId` call site compiling), NOT AcDream.Core.Vfx.ParticleEmitter (the runtime particle-simulation type with a live Particle[] pool that would NOT be serializable asset data). All ~31 of its fields are written explicitly rather than delegating to its own Pack/Unpack, which require a live DatBinWriter/DatBinReader bound to a DatDatabase — coupling our pak's determinism to a third-party wire-format helper we don't control the versioning of. 33 tests green: 9 round-trip fixtures (empty/vertices+indices/multi texture-batch-groups/setup-parts/emitters/nullable-present/nullable- absent/edge-lines/nested-EnvCellGeometry), same-instance-twice byte- identity, and dictionary-insertion-order-independence (two orders -> identical bytes) plus a key-sort-order assertion on the raw bytes. --- .../Pak/ObjectMeshDataSerializer.cs | 516 ++++++++++++++++++ .../ObjectMeshDataEquality.cs | 196 +++++++ .../ObjectMeshDataSerializerTests.cs | 297 ++++++++++ 3 files changed, 1009 insertions(+) create mode 100644 src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs create mode 100644 tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs create mode 100644 tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs new file mode 100644 index 00000000..8236ca24 --- /dev/null +++ b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Runtime.InteropServices; +using Chorizite.Core.Render.Enums; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using BoundingBox = Chorizite.Core.Lib.BoundingBox; +using CullMode = DatReaderWriter.Enums.CullMode; + +namespace AcDream.Content.Pak; + +/// +/// Deterministic binary (de)serializer for the +/// family (, , +/// , , plus the DRW +/// / value types). +/// +/// Layout rules (normative, see +/// docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1"): +/// primitives raw little-endian; arrays as count:i32 + payload; blittable +/// arrays (VertexPositionNormalTexture[], ushort[] indices, byte[] texture +/// data) written via bulk +/// copy; is written sorted by the +/// key tuple (Width, Height, Format) so bakes are byte-reproducible run to +/// run regardless of dictionary insertion order; nullable fields as +/// present:byte + value. +/// +/// EVERY field of every type in the family is serialized — see the plan's +/// Task 3 checklist. nests +/// recursively (present:byte + nested block) since MeshExtractor can +/// populate it with another full ObjectMeshData. +/// +public static class ObjectMeshDataSerializer { + public static void Write(ObjectMeshData data, Stream stream) { + using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true); + WriteObjectMeshData(bw, data); + } + + public static ObjectMeshData Read(ReadOnlySpan bytes) { + using var ms = new MemoryStream(bytes.ToArray(), writable: false); + using var br = new BinaryReader(ms, System.Text.Encoding.UTF8, leaveOpen: true); + return ReadObjectMeshData(br); + } + + // ---- ObjectMeshData ----------------------------------------------------- + + private static void WriteObjectMeshData(BinaryWriter w, ObjectMeshData data) { + w.Write(data.ObjectId); + w.Write(data.IsSetup); + + WriteVertexArray(w, data.Vertices); + + w.Write(data.Batches.Count); + foreach (var batch in data.Batches) WriteMeshBatchData(w, batch); + + w.Write(data.UploadAttempts); + + // EnvCellGeometry: recursive nested block. + w.Write(data.EnvCellGeometry is not null); + if (data.EnvCellGeometry is not null) WriteObjectMeshData(w, data.EnvCellGeometry); + + w.Write(data.SetupParts.Count); + foreach (var (gfxObjId, transform) in data.SetupParts) { + w.Write(gfxObjId); + WriteMatrix4x4(w, transform); + } + + w.Write(data.ParticleEmitters.Count); + foreach (var emitter in data.ParticleEmitters) WriteStagedEmitter(w, emitter); + + WriteTextureBatches(w, data.TextureBatches); + + WriteBoundingBox(w, data.BoundingBox); + WriteVector3(w, data.SortCenter); + w.Write(data.DIDDegrade); + + w.Write(data.SelectionSphere is not null); + if (data.SelectionSphere is not null) WriteSphere(w, data.SelectionSphere); + + WriteVector3Array(w, data.EdgeLines); + } + + private static ObjectMeshData ReadObjectMeshData(BinaryReader r) { + var data = new ObjectMeshData { + ObjectId = r.ReadUInt64(), + IsSetup = r.ReadBoolean(), + }; + + data.Vertices = ReadVertexArray(r); + + int batchCount = r.ReadInt32(); + var batches = new List(batchCount); + for (int i = 0; i < batchCount; i++) batches.Add(ReadMeshBatchData(r)); + data.Batches = batches; + + data.UploadAttempts = r.ReadInt32(); + + bool hasEnvCellGeometry = r.ReadBoolean(); + data.EnvCellGeometry = hasEnvCellGeometry ? ReadObjectMeshData(r) : null; + + int setupPartCount = r.ReadInt32(); + var setupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(setupPartCount); + for (int i = 0; i < setupPartCount; i++) { + ulong gfxObjId = r.ReadUInt64(); + var transform = ReadMatrix4x4(r); + setupParts.Add((gfxObjId, transform)); + } + data.SetupParts = setupParts; + + int emitterCount = r.ReadInt32(); + var emitters = new List(emitterCount); + for (int i = 0; i < emitterCount; i++) emitters.Add(ReadStagedEmitter(r)); + data.ParticleEmitters = emitters; + + data.TextureBatches = ReadTextureBatches(r); + + data.BoundingBox = ReadBoundingBox(r); + data.SortCenter = ReadVector3(r); + data.DIDDegrade = r.ReadUInt32(); + + bool hasSelectionSphere = r.ReadBoolean(); + data.SelectionSphere = hasSelectionSphere ? ReadSphere(r) : null; + + data.EdgeLines = ReadVector3Array(r); + + return data; + } + + // ---- MeshBatchData ------------------------------------------------------- + + private static void WriteMeshBatchData(BinaryWriter w, MeshBatchData batch) { + WriteUInt16Array(w, batch.Indices); + WriteTextureFormatTuple(w, batch.TextureFormat); + WriteTextureKey(w, batch.TextureKey); + w.Write(batch.TextureIndex); + WriteByteArray(w, batch.TextureData); + WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0); + WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); + w.Write((int)batch.CullMode); + } + + private static MeshBatchData ReadMeshBatchData(BinaryReader r) { + var batch = new MeshBatchData { + Indices = ReadUInt16Array(r), + TextureFormat = ReadTextureFormatTuple(r), + TextureKey = ReadTextureKey(r), + TextureIndex = r.ReadInt32(), + TextureData = ReadByteArray(r), + }; + batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v); + batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); + batch.CullMode = (CullMode)r.ReadInt32(); + return batch; + } + + // ---- TextureBatchData / TextureBatches dictionary ------------------------- + + private static void WriteTextureBatchData(BinaryWriter w, TextureBatchData batch) { + WriteTextureKey(w, batch.Key); + WriteByteArray(w, batch.TextureData); + WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0); + WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); + WriteUInt16List(w, batch.Indices); + w.Write((int)batch.CullMode); + w.Write(batch.IsTransparent); + w.Write(batch.IsAdditive); + w.Write(batch.HasWrappingUVs); + } + + private static TextureBatchData ReadTextureBatchData(BinaryReader r) { + var batch = new TextureBatchData { + Key = ReadTextureKey(r), + TextureData = ReadByteArray(r), + }; + batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v); + batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); + batch.Indices = ReadUInt16List(r); + batch.CullMode = (CullMode)r.ReadInt32(); + batch.IsTransparent = r.ReadBoolean(); + batch.IsAdditive = r.ReadBoolean(); + batch.HasWrappingUVs = r.ReadBoolean(); + return batch; + } + + /// + /// Writes the TextureBatches dictionary sorted ascending by the key tuple + /// (Width, Height, Format) — REQUIRED for byte-reproducible bakes + /// independent of Dictionary iteration/insertion order. + /// + private static void WriteTextureBatches( + BinaryWriter w, + Dictionary<(int Width, int Height, TextureFormat Format), List> batches) { + var sortedKeys = batches.Keys + .OrderBy(k => k.Width) + .ThenBy(k => k.Height) + .ThenBy(k => (int)k.Format) + .ToList(); + + w.Write(sortedKeys.Count); + foreach (var key in sortedKeys) { + w.Write(key.Width); + w.Write(key.Height); + w.Write((int)key.Format); + + var list = batches[key]; + w.Write(list.Count); + foreach (var item in list) WriteTextureBatchData(w, item); + } + } + + private static Dictionary<(int Width, int Height, TextureFormat Format), List> ReadTextureBatches(BinaryReader r) { + int groupCount = r.ReadInt32(); + var result = new Dictionary<(int Width, int Height, TextureFormat Format), List>(groupCount); + for (int i = 0; i < groupCount; i++) { + int width = r.ReadInt32(); + int height = r.ReadInt32(); + var format = (TextureFormat)r.ReadInt32(); + + int listCount = r.ReadInt32(); + var list = new List(listCount); + for (int j = 0; j < listCount; j++) list.Add(ReadTextureBatchData(r)); + + result[(width, height, format)] = list; + } + return result; + } + + // ---- StagedEmitter / ParticleEmitter (DBObj) ------------------------------ + + private static void WriteStagedEmitter(BinaryWriter w, StagedEmitter emitter) { + w.Write(emitter.PartIndex); + WriteMatrix4x4(w, emitter.Offset); + w.Write(emitter.Emitter is not null); + if (emitter.Emitter is not null) WriteParticleEmitter(w, emitter.Emitter); + } + + private static StagedEmitter ReadStagedEmitter(BinaryReader r) { + uint partIndex = r.ReadUInt32(); + var offset = ReadMatrix4x4(r); + bool hasEmitter = r.ReadBoolean(); + var pe = hasEmitter ? ReadParticleEmitter(r) : null; + return new StagedEmitter { + PartIndex = partIndex, + Offset = offset, + Emitter = pe!, + }; + } + + /// + /// Field-by-field serialization of DatReaderWriter.DBObjs.ParticleEmitter + /// (the dat DBObj type — StagedEmitter.Emitter resolves to THIS type via + /// ObjectMeshData.cs's `using DatReaderWriter.DBObjs;`, NOT + /// AcDream.Core.Vfx.ParticleEmitter). Every public field/property on the + /// pinned Chorizite.DatReaderWriter 2.1.7 type is written explicitly + /// (verified via reflection against the exact installed package) rather + /// than delegating to the type's own Pack/Unpack: those require a live + /// DatBinWriter/DatBinReader bound to a DatDatabase, which would couple + /// our pak's determinism to a third-party wire-format helper we don't + /// control the versioning of. + /// + private static void WriteParticleEmitter(BinaryWriter w, ParticleEmitter pe) { + w.Write(pe.Id); + w.Write(pe.DataCategory); + w.Write(pe.Unknown); + w.Write((int)pe.EmitterType); + w.Write((int)pe.ParticleType); + w.Write(pe.GfxObjId.DataId); + w.Write(pe.HwGfxObjId.DataId); + w.Write(pe.Birthrate); + w.Write(pe.MaxParticles); + w.Write(pe.InitialParticles); + w.Write(pe.TotalParticles); + w.Write(pe.TotalSeconds); + w.Write(pe.Lifespan); + w.Write(pe.LifespanRand); + WriteVector3(w, pe.OffsetDir); + w.Write(pe.MinOffset); + w.Write(pe.MaxOffset); + WriteVector3(w, pe.A); + w.Write(pe.MinA); + w.Write(pe.MaxA); + WriteVector3(w, pe.B); + w.Write(pe.MinB); + w.Write(pe.MaxB); + WriteVector3(w, pe.C); + w.Write(pe.MinC); + w.Write(pe.MaxC); + w.Write(pe.StartScale); + w.Write(pe.FinalScale); + w.Write(pe.ScaleRand); + w.Write(pe.StartTrans); + w.Write(pe.FinalTrans); + w.Write(pe.TransRand); + w.Write(pe.IsParentLocal); + } + + private static ParticleEmitter ReadParticleEmitter(BinaryReader r) { + var pe = new ParticleEmitter { + Id = r.ReadUInt32(), + DataCategory = r.ReadUInt32(), + }; + pe.Unknown = r.ReadUInt32(); + pe.EmitterType = (DatReaderWriter.Enums.EmitterType)r.ReadInt32(); + pe.ParticleType = (DatReaderWriter.Enums.ParticleType)r.ReadInt32(); + pe.GfxObjId = new QualifiedDataId { DataId = r.ReadUInt32() }; + pe.HwGfxObjId = new QualifiedDataId { DataId = r.ReadUInt32() }; + pe.Birthrate = r.ReadDouble(); + pe.MaxParticles = r.ReadInt32(); + pe.InitialParticles = r.ReadInt32(); + pe.TotalParticles = r.ReadInt32(); + pe.TotalSeconds = r.ReadDouble(); + pe.Lifespan = r.ReadDouble(); + pe.LifespanRand = r.ReadDouble(); + pe.OffsetDir = ReadVector3(r); + pe.MinOffset = r.ReadSingle(); + pe.MaxOffset = r.ReadSingle(); + pe.A = ReadVector3(r); + pe.MinA = r.ReadSingle(); + pe.MaxA = r.ReadSingle(); + pe.B = ReadVector3(r); + pe.MinB = r.ReadSingle(); + pe.MaxB = r.ReadSingle(); + pe.C = ReadVector3(r); + pe.MinC = r.ReadSingle(); + pe.MaxC = r.ReadSingle(); + pe.StartScale = r.ReadSingle(); + pe.FinalScale = r.ReadSingle(); + pe.ScaleRand = r.ReadSingle(); + pe.StartTrans = r.ReadSingle(); + pe.FinalTrans = r.ReadSingle(); + pe.TransRand = r.ReadSingle(); + pe.IsParentLocal = r.ReadBoolean(); + return pe; + } + + // ---- TextureKey ------------------------------------------------------ + + private static void WriteTextureKey(BinaryWriter w, TextureKey key) { + w.Write(key.SurfaceId); + w.Write(key.PaletteId); + w.Write((byte)key.Stippling); + w.Write(key.IsSolid); + } + + private static TextureKey ReadTextureKey(BinaryReader r) { + return new TextureKey { + SurfaceId = r.ReadUInt32(), + PaletteId = r.ReadUInt32(), + Stippling = (DatReaderWriter.Enums.StipplingType)r.ReadByte(), + IsSolid = r.ReadBoolean(), + }; + } + + private static void WriteTextureFormatTuple(BinaryWriter w, (int Width, int Height, TextureFormat Format) tuple) { + w.Write(tuple.Width); + w.Write(tuple.Height); + w.Write((int)tuple.Format); + } + + private static (int Width, int Height, TextureFormat Format) ReadTextureFormatTuple(BinaryReader r) { + int width = r.ReadInt32(); + int height = r.ReadInt32(); + var format = (TextureFormat)r.ReadInt32(); + return (width, height, format); + } + + // ---- Sphere / BoundingBox (DRW / Chorizite value types) ------------------- + + private static void WriteSphere(BinaryWriter w, Sphere sphere) { + WriteVector3(w, sphere.Origin); + w.Write(sphere.Radius); + } + + private static Sphere ReadSphere(BinaryReader r) { + var origin = ReadVector3(r); + float radius = r.ReadSingle(); + return new Sphere { Origin = origin, Radius = radius }; + } + + private static void WriteBoundingBox(BinaryWriter w, BoundingBox box) { + WriteVector3(w, box.Min); + WriteVector3(w, box.Max); + } + + private static BoundingBox ReadBoundingBox(BinaryReader r) { + var min = ReadVector3(r); + var max = ReadVector3(r); + return new BoundingBox(min, max); + } + + // ---- primitive helpers ------------------------------------------------- + + private static void WriteVector3(BinaryWriter w, Vector3 v) { + w.Write(v.X); + w.Write(v.Y); + w.Write(v.Z); + } + + private static Vector3 ReadVector3(BinaryReader r) { + float x = r.ReadSingle(); + float y = r.ReadSingle(); + float z = r.ReadSingle(); + return new Vector3(x, y, z); + } + + private static void WriteMatrix4x4(BinaryWriter w, Matrix4x4 m) { + w.Write(m.M11); w.Write(m.M12); w.Write(m.M13); w.Write(m.M14); + w.Write(m.M21); w.Write(m.M22); w.Write(m.M23); w.Write(m.M24); + w.Write(m.M31); w.Write(m.M32); w.Write(m.M33); w.Write(m.M34); + w.Write(m.M41); w.Write(m.M42); w.Write(m.M43); w.Write(m.M44); + } + + private static Matrix4x4 ReadMatrix4x4(BinaryReader r) { + return new Matrix4x4( + r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), + r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), + r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), + r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); + } + + /// count:i32 + MemoryMarshal.AsBytes bulk copy (32 bytes/vertex). + private static void WriteVertexArray(BinaryWriter w, VertexPositionNormalTexture[] vertices) { + w.Write(vertices.Length); + if (vertices.Length == 0) return; + var bytes = MemoryMarshal.AsBytes(vertices.AsSpan()); + w.Write(bytes); + } + + private static VertexPositionNormalTexture[] ReadVertexArray(BinaryReader r) { + int count = r.ReadInt32(); + if (count == 0) return Array.Empty(); + var result = new VertexPositionNormalTexture[count]; + var bytes = r.ReadBytes(count * VertexPositionNormalTexture.Size); + bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan())); + return result; + } + + /// count:i32 + MemoryMarshal.AsBytes bulk copy (12 bytes/Vector3). + private static void WriteVector3Array(BinaryWriter w, Vector3[] array) { + w.Write(array.Length); + if (array.Length == 0) return; + var bytes = MemoryMarshal.AsBytes(array.AsSpan()); + w.Write(bytes); + } + + private static Vector3[] ReadVector3Array(BinaryReader r) { + int count = r.ReadInt32(); + if (count == 0) return Array.Empty(); + var result = new Vector3[count]; + var bytes = r.ReadBytes(count * 12); + bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan())); + return result; + } + + /// count:i32 + MemoryMarshal.AsBytes bulk copy (2 bytes/ushort). + private static void WriteUInt16Array(BinaryWriter w, ushort[] array) { + w.Write(array.Length); + if (array.Length == 0) return; + var bytes = MemoryMarshal.AsBytes(array.AsSpan()); + w.Write(bytes); + } + + private static ushort[] ReadUInt16Array(BinaryReader r) { + int count = r.ReadInt32(); + if (count == 0) return Array.Empty(); + var result = new ushort[count]; + var bytes = r.ReadBytes(count * sizeof(ushort)); + bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan())); + return result; + } + + private static void WriteUInt16List(BinaryWriter w, List list) { + w.Write(list.Count); + if (list.Count == 0) return; + var array = list.ToArray(); + var bytes = MemoryMarshal.AsBytes(array.AsSpan()); + w.Write(bytes); + } + + private static List ReadUInt16List(BinaryReader r) { + int count = r.ReadInt32(); + var list = new List(count); + if (count == 0) return list; + var array = new ushort[count]; + var bytes = r.ReadBytes(count * sizeof(ushort)); + bytes.CopyTo(MemoryMarshal.AsBytes(array.AsSpan())); + list.AddRange(array); + return list; + } + + /// count:i32 + raw byte payload. + private static void WriteByteArray(BinaryWriter w, byte[] array) { + w.Write(array.Length); + if (array.Length > 0) w.Write(array); + } + + private static byte[] ReadByteArray(BinaryReader r) { + int count = r.ReadInt32(); + return count == 0 ? Array.Empty() : r.ReadBytes(count); + } + + /// present:byte + value:i32 for a nullable enum-backed-by-int field. + private static void WriteNullableInt32Enum(BinaryWriter w, bool present, int value) { + w.Write(present); + w.Write(value); + } + + private static TEnum? ReadNullableInt32Enum(BinaryReader r, Func project) where TEnum : struct, Enum { + bool present = r.ReadBoolean(); + int value = r.ReadInt32(); + return present ? project(value) : null; + } +} diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs new file mode 100644 index 00000000..9879a323 --- /dev/null +++ b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs @@ -0,0 +1,196 @@ +using System.Linq; +using Chorizite.Core.Render.Enums; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Content.Tests; + +/// +/// Field-by-field deep-equality comparator for and +/// its whole family (, , +/// , ). Used by both the +/// round-trip tests (Task 3) and the dat-gated equivalence suite (Task 6) so a +/// mismatch always names the exact field that diverged instead of just +/// "objects not equal". +/// +public static class ObjectMeshDataEquality { + public static void AssertEqual(ObjectMeshData? expected, ObjectMeshData? actual, string path = "root") { + if (expected is null && actual is null) return; + Assert.True(expected is not null, $"{path}: expected null but actual was non-null"); + Assert.True(actual is not null, $"{path}: expected non-null but actual was null"); + + Assert.True(expected.ObjectId == actual.ObjectId, $"{path}.ObjectId: expected 0x{expected.ObjectId:X16}, got 0x{actual.ObjectId:X16}"); + Assert.True(expected.IsSetup == actual.IsSetup, $"{path}.IsSetup: expected {expected.IsSetup}, got {actual.IsSetup}"); + AssertVerticesEqual(expected.Vertices, actual.Vertices, $"{path}.Vertices"); + + Assert.True(expected.Batches.Count == actual.Batches.Count, + $"{path}.Batches.Count: expected {expected.Batches.Count}, got {actual.Batches.Count}"); + for (int i = 0; i < expected.Batches.Count; i++) + AssertBatchEqual(expected.Batches[i], actual.Batches[i], $"{path}.Batches[{i}]"); + + Assert.True(expected.UploadAttempts == actual.UploadAttempts, + $"{path}.UploadAttempts: expected {expected.UploadAttempts}, got {actual.UploadAttempts}"); + + AssertEqual(expected.EnvCellGeometry, actual.EnvCellGeometry, $"{path}.EnvCellGeometry"); + + Assert.True(expected.SetupParts.Count == actual.SetupParts.Count, + $"{path}.SetupParts.Count: expected {expected.SetupParts.Count}, got {actual.SetupParts.Count}"); + for (int i = 0; i < expected.SetupParts.Count; i++) { + var (eId, eT) = expected.SetupParts[i]; + var (aId, aT) = actual.SetupParts[i]; + Assert.True(eId == aId, $"{path}.SetupParts[{i}].GfxObjId: expected 0x{eId:X16}, got 0x{aId:X16}"); + AssertMatrixEqual(eT, aT, $"{path}.SetupParts[{i}].Transform"); + } + + Assert.True(expected.ParticleEmitters.Count == actual.ParticleEmitters.Count, + $"{path}.ParticleEmitters.Count: expected {expected.ParticleEmitters.Count}, got {actual.ParticleEmitters.Count}"); + for (int i = 0; i < expected.ParticleEmitters.Count; i++) + AssertStagedEmitterEqual(expected.ParticleEmitters[i], actual.ParticleEmitters[i], $"{path}.ParticleEmitters[{i}]"); + + AssertTextureBatchesEqual(expected.TextureBatches, actual.TextureBatches, $"{path}.TextureBatches"); + + AssertBoundingBoxEqual(expected.BoundingBox, actual.BoundingBox, $"{path}.BoundingBox"); + AssertVector3Equal(expected.SortCenter, actual.SortCenter, $"{path}.SortCenter"); + Assert.True(expected.DIDDegrade == actual.DIDDegrade, $"{path}.DIDDegrade: expected {expected.DIDDegrade}, got {actual.DIDDegrade}"); + + AssertSphereEqual(expected.SelectionSphere, actual.SelectionSphere, $"{path}.SelectionSphere"); + AssertVector3ArrayEqual(expected.EdgeLines, actual.EdgeLines, $"{path}.EdgeLines"); + } + + private static void AssertVerticesEqual(VertexPositionNormalTexture[] expected, VertexPositionNormalTexture[] actual, string path) { + Assert.True(expected.Length == actual.Length, $"{path}.Length: expected {expected.Length}, got {actual.Length}"); + for (int i = 0; i < expected.Length; i++) { + AssertVector3Equal(expected[i].Position, actual[i].Position, $"{path}[{i}].Position"); + AssertVector3Equal(expected[i].Normal, actual[i].Normal, $"{path}[{i}].Normal"); + Assert.True(expected[i].UV == actual[i].UV, $"{path}[{i}].UV: expected {expected[i].UV}, got {actual[i].UV}"); + } + } + + private static void AssertBatchEqual(MeshBatchData expected, MeshBatchData actual, string path) { + Assert.True(expected.Indices.SequenceEqual(actual.Indices), + $"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]"); + Assert.True(expected.TextureFormat == actual.TextureFormat, + $"{path}.TextureFormat: expected {expected.TextureFormat}, got {actual.TextureFormat}"); + AssertTextureKeyEqual(expected.TextureKey, actual.TextureKey, $"{path}.TextureKey"); + Assert.True(expected.TextureIndex == actual.TextureIndex, + $"{path}.TextureIndex: expected {expected.TextureIndex}, got {actual.TextureIndex}"); + Assert.True(expected.TextureData.SequenceEqual(actual.TextureData), + $"{path}.TextureData: length expected {expected.TextureData.Length}, got {actual.TextureData.Length}"); + Assert.True(expected.UploadPixelFormat == actual.UploadPixelFormat, + $"{path}.UploadPixelFormat: expected {expected.UploadPixelFormat}, got {actual.UploadPixelFormat}"); + Assert.True(expected.UploadPixelType == actual.UploadPixelType, + $"{path}.UploadPixelType: expected {expected.UploadPixelType}, got {actual.UploadPixelType}"); + Assert.True(expected.CullMode == actual.CullMode, + $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); + } + + private static void AssertTextureBatchDataEqual(TextureBatchData expected, TextureBatchData actual, string path) { + AssertTextureKeyEqual(expected.Key, actual.Key, $"{path}.Key"); + Assert.True(expected.TextureData.SequenceEqual(actual.TextureData), + $"{path}.TextureData: length expected {expected.TextureData.Length}, got {actual.TextureData.Length}"); + Assert.True(expected.UploadPixelFormat == actual.UploadPixelFormat, + $"{path}.UploadPixelFormat: expected {expected.UploadPixelFormat}, got {actual.UploadPixelFormat}"); + Assert.True(expected.UploadPixelType == actual.UploadPixelType, + $"{path}.UploadPixelType: expected {expected.UploadPixelType}, got {actual.UploadPixelType}"); + Assert.True(expected.Indices.SequenceEqual(actual.Indices), + $"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]"); + Assert.True(expected.CullMode == actual.CullMode, $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); + Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}"); + Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}"); + Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}"); + } + + private static void AssertTextureBatchesEqual( + System.Collections.Generic.Dictionary<(int Width, int Height, TextureFormat Format), System.Collections.Generic.List> expected, + System.Collections.Generic.Dictionary<(int Width, int Height, TextureFormat Format), System.Collections.Generic.List> actual, + string path) { + Assert.True(expected.Count == actual.Count, $"{path}.Count: expected {expected.Count}, got {actual.Count}"); + foreach (var key in expected.Keys) { + Assert.True(actual.ContainsKey(key), $"{path}: missing key {key}"); + var expList = expected[key]; + var actList = actual[key]; + Assert.True(expList.Count == actList.Count, $"{path}[{key}].Count: expected {expList.Count}, got {actList.Count}"); + for (int i = 0; i < expList.Count; i++) + AssertTextureBatchDataEqual(expList[i], actList[i], $"{path}[{key}][{i}]"); + } + } + + private static void AssertStagedEmitterEqual(StagedEmitter expected, StagedEmitter actual, string path) { + Assert.True(expected.PartIndex == actual.PartIndex, $"{path}.PartIndex: expected {expected.PartIndex}, got {actual.PartIndex}"); + AssertMatrixEqual(expected.Offset, actual.Offset, $"{path}.Offset"); + AssertParticleEmitterEqual(expected.Emitter, actual.Emitter, $"{path}.Emitter"); + } + + private static void AssertParticleEmitterEqual(ParticleEmitter? expected, ParticleEmitter? actual, string path) { + if (expected is null && actual is null) return; + Assert.True(expected is not null, $"{path}: expected null but actual was non-null"); + Assert.True(actual is not null, $"{path}: expected non-null but actual was null"); + + Assert.True(expected.Id == actual.Id, $"{path}.Id: expected 0x{expected.Id:X8}, got 0x{actual.Id:X8}"); + Assert.True(expected.DataCategory == actual.DataCategory, $"{path}.DataCategory: expected {expected.DataCategory}, got {actual.DataCategory}"); + Assert.True(expected.Unknown == actual.Unknown, $"{path}.Unknown: expected {expected.Unknown}, got {actual.Unknown}"); + Assert.True(expected.EmitterType == actual.EmitterType, $"{path}.EmitterType: expected {expected.EmitterType}, got {actual.EmitterType}"); + Assert.True(expected.ParticleType == actual.ParticleType, $"{path}.ParticleType: expected {expected.ParticleType}, got {actual.ParticleType}"); + Assert.True(expected.GfxObjId.DataId == actual.GfxObjId.DataId, $"{path}.GfxObjId: expected 0x{expected.GfxObjId.DataId:X8}, got 0x{actual.GfxObjId.DataId:X8}"); + Assert.True(expected.HwGfxObjId.DataId == actual.HwGfxObjId.DataId, $"{path}.HwGfxObjId: expected 0x{expected.HwGfxObjId.DataId:X8}, got 0x{actual.HwGfxObjId.DataId:X8}"); + Assert.True(expected.Birthrate == actual.Birthrate, $"{path}.Birthrate: expected {expected.Birthrate}, got {actual.Birthrate}"); + Assert.True(expected.MaxParticles == actual.MaxParticles, $"{path}.MaxParticles: expected {expected.MaxParticles}, got {actual.MaxParticles}"); + Assert.True(expected.InitialParticles == actual.InitialParticles, $"{path}.InitialParticles: expected {expected.InitialParticles}, got {actual.InitialParticles}"); + Assert.True(expected.TotalParticles == actual.TotalParticles, $"{path}.TotalParticles: expected {expected.TotalParticles}, got {actual.TotalParticles}"); + Assert.True(expected.TotalSeconds == actual.TotalSeconds, $"{path}.TotalSeconds: expected {expected.TotalSeconds}, got {actual.TotalSeconds}"); + Assert.True(expected.Lifespan == actual.Lifespan, $"{path}.Lifespan: expected {expected.Lifespan}, got {actual.Lifespan}"); + Assert.True(expected.LifespanRand == actual.LifespanRand, $"{path}.LifespanRand: expected {expected.LifespanRand}, got {actual.LifespanRand}"); + AssertVector3Equal(expected.OffsetDir, actual.OffsetDir, $"{path}.OffsetDir"); + Assert.True(expected.MinOffset == actual.MinOffset, $"{path}.MinOffset: expected {expected.MinOffset}, got {actual.MinOffset}"); + Assert.True(expected.MaxOffset == actual.MaxOffset, $"{path}.MaxOffset: expected {expected.MaxOffset}, got {actual.MaxOffset}"); + AssertVector3Equal(expected.A, actual.A, $"{path}.A"); + Assert.True(expected.MinA == actual.MinA, $"{path}.MinA: expected {expected.MinA}, got {actual.MinA}"); + Assert.True(expected.MaxA == actual.MaxA, $"{path}.MaxA: expected {expected.MaxA}, got {actual.MaxA}"); + AssertVector3Equal(expected.B, actual.B, $"{path}.B"); + Assert.True(expected.MinB == actual.MinB, $"{path}.MinB: expected {expected.MinB}, got {actual.MinB}"); + Assert.True(expected.MaxB == actual.MaxB, $"{path}.MaxB: expected {expected.MaxB}, got {actual.MaxB}"); + AssertVector3Equal(expected.C, actual.C, $"{path}.C"); + Assert.True(expected.MinC == actual.MinC, $"{path}.MinC: expected {expected.MinC}, got {actual.MinC}"); + Assert.True(expected.MaxC == actual.MaxC, $"{path}.MaxC: expected {expected.MaxC}, got {actual.MaxC}"); + Assert.True(expected.StartScale == actual.StartScale, $"{path}.StartScale: expected {expected.StartScale}, got {actual.StartScale}"); + Assert.True(expected.FinalScale == actual.FinalScale, $"{path}.FinalScale: expected {expected.FinalScale}, got {actual.FinalScale}"); + Assert.True(expected.ScaleRand == actual.ScaleRand, $"{path}.ScaleRand: expected {expected.ScaleRand}, got {actual.ScaleRand}"); + Assert.True(expected.StartTrans == actual.StartTrans, $"{path}.StartTrans: expected {expected.StartTrans}, got {actual.StartTrans}"); + Assert.True(expected.FinalTrans == actual.FinalTrans, $"{path}.FinalTrans: expected {expected.FinalTrans}, got {actual.FinalTrans}"); + Assert.True(expected.TransRand == actual.TransRand, $"{path}.TransRand: expected {expected.TransRand}, got {actual.TransRand}"); + Assert.True(expected.IsParentLocal == actual.IsParentLocal, $"{path}.IsParentLocal: expected {expected.IsParentLocal}, got {actual.IsParentLocal}"); + } + + private static void AssertTextureKeyEqual(TextureKey expected, TextureKey actual, string path) { + Assert.True(expected.Equals(actual), + $"{path}: expected SurfaceId=0x{expected.SurfaceId:X8} PaletteId=0x{expected.PaletteId:X8} Stippling={expected.Stippling} IsSolid={expected.IsSolid}, " + + $"got SurfaceId=0x{actual.SurfaceId:X8} PaletteId=0x{actual.PaletteId:X8} Stippling={actual.Stippling} IsSolid={actual.IsSolid}"); + } + + private static void AssertBoundingBoxEqual(Chorizite.Core.Lib.BoundingBox expected, Chorizite.Core.Lib.BoundingBox actual, string path) { + AssertVector3Equal(expected.Min, actual.Min, $"{path}.Min"); + AssertVector3Equal(expected.Max, actual.Max, $"{path}.Max"); + } + + private static void AssertSphereEqual(Sphere? expected, Sphere? actual, string path) { + if (expected is null && actual is null) return; + Assert.True(expected is not null, $"{path}: expected null but actual was non-null"); + Assert.True(actual is not null, $"{path}: expected non-null but actual was null"); + AssertVector3Equal(expected.Origin, actual.Origin, $"{path}.Origin"); + Assert.True(expected.Radius == actual.Radius, $"{path}.Radius: expected {expected.Radius}, got {actual.Radius}"); + } + + private static void AssertVector3Equal(System.Numerics.Vector3 expected, System.Numerics.Vector3 actual, string path) { + Assert.True(expected == actual, $"{path}: expected {expected}, got {actual}"); + } + + private static void AssertVector3ArrayEqual(System.Numerics.Vector3[] expected, System.Numerics.Vector3[] actual, string path) { + Assert.True(expected.Length == actual.Length, $"{path}.Length: expected {expected.Length}, got {actual.Length}"); + for (int i = 0; i < expected.Length; i++) + AssertVector3Equal(expected[i], actual[i], $"{path}[{i}]"); + } + + private static void AssertMatrixEqual(System.Numerics.Matrix4x4 expected, System.Numerics.Matrix4x4 actual, string path) { + Assert.True(expected == actual, $"{path}: expected {expected}, got {actual}"); + } +} diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs new file mode 100644 index 00000000..8df8a0c6 --- /dev/null +++ b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using AcDream.Content.Pak; +using Chorizite.Core.Lib; +using Chorizite.Core.Render.Enums; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using CullMode = DatReaderWriter.Enums.CullMode; +using StipplingType = DatReaderWriter.Enums.StipplingType; +using EmitterType = DatReaderWriter.Enums.EmitterType; +using ParticleType = DatReaderWriter.Enums.ParticleType; + +namespace AcDream.Content.Tests; + +public class ObjectMeshDataSerializerTests { + // ---- fixture builders --------------------------------------------------- + + private static ObjectMeshData EmptyObject() => new() { + ObjectId = 0x0100_0001u, + IsSetup = false, + }; + + private static ObjectMeshData VerticesAndIndicesOnly() { + var data = new ObjectMeshData { + ObjectId = 0x0100_0002u, + IsSetup = false, + Vertices = new[] { + new VertexPositionNormalTexture(new Vector3(1, 2, 3), new Vector3(0, 0, 1), new Vector2(0, 0)), + new VertexPositionNormalTexture(new Vector3(4, 5, 6), new Vector3(0, 1, 0), new Vector2(1, 0)), + new VertexPositionNormalTexture(new Vector3(7, 8, 9), new Vector3(1, 0, 0), new Vector2(1, 1)), + }, + BoundingBox = new BoundingBox(new Vector3(1, 2, 3), new Vector3(7, 8, 9)), + SortCenter = new Vector3(4, 5, 6), + DIDDegrade = 0x11223344, + }; + data.Batches.Add(new MeshBatchData { + Indices = new ushort[] { 0, 1, 2 }, + TextureFormat = (64, 64, TextureFormat.RGBA8), + TextureKey = new TextureKey { SurfaceId = 0x08000001, PaletteId = 0x04000001, Stippling = StipplingType.Both, IsSolid = true }, + TextureIndex = 0, + TextureData = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, + UploadPixelFormat = AcDream.Content.UploadPixelFormat.Rgba, + UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, + CullMode = CullMode.Clockwise, + }); + return data; + } + + private static ObjectMeshData MultipleTextureBatchGroups() { + var data = EmptyObject(); + data.ObjectId = 0x0100_0003u; + + TextureBatchData Batch(uint surfaceId, string tag) => new() { + Key = new TextureKey { SurfaceId = surfaceId, PaletteId = 1, Stippling = StipplingType.Positive, IsSolid = false }, + TextureData = System.Text.Encoding.ASCII.GetBytes(tag), + UploadPixelFormat = AcDream.Content.UploadPixelFormat.Rgba, + UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, + Indices = new List { 0, 1, 2, 2, 3, 0 }, + CullMode = CullMode.CounterClockwise, + IsTransparent = true, + IsAdditive = false, + HasWrappingUVs = true, + }; + + data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { Batch(1, "a"), Batch(2, "b") }; + data.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List { Batch(3, "c") }; + data.TextureBatches[(16, 16, TextureFormat.A8)] = new List { Batch(4, "d"), Batch(5, "e"), Batch(6, "f") }; + return data; + } + + private static ObjectMeshData SetupWithParts() { + var data = EmptyObject(); + data.ObjectId = 0x0200_0001u; + data.IsSetup = true; + data.SetupParts.Add((0x0100_0010u, Matrix4x4.CreateTranslation(1, 2, 3))); + data.SetupParts.Add((0x0100_0011u, Matrix4x4.CreateFromYawPitchRoll(0.1f, 0.2f, 0.3f))); + return data; + } + + private static ParticleEmitter BuildEmitter(uint id) => new() { + Id = id, + DataCategory = 0x2A, + Unknown = 7, + EmitterType = EmitterType.BirthratePerSec, + ParticleType = ParticleType.Explode, + GfxObjId = new QualifiedDataId { DataId = 0x0100_0099u }, + HwGfxObjId = new QualifiedDataId { DataId = 0x0100_009Au }, + Birthrate = 2.5, + MaxParticles = 40, + InitialParticles = 5, + TotalParticles = 100, + TotalSeconds = 3.0, + Lifespan = 1.5, + LifespanRand = 0.25, + OffsetDir = new Vector3(0, 0, 1), + MinOffset = 0.1f, + MaxOffset = 0.5f, + A = new Vector3(1, 0, 0), + MinA = 0.9f, + MaxA = 1.1f, + B = new Vector3(0, 1, 0), + MinB = 0.8f, + MaxB = 1.2f, + C = new Vector3(0, 0, 1), + MinC = 0.7f, + MaxC = 1.3f, + StartScale = 0.5f, + FinalScale = 1.5f, + ScaleRand = 0.05f, + StartTrans = 1f, + FinalTrans = 0f, + TransRand = 0.1f, + IsParentLocal = true, + }; + + private static ObjectMeshData WithEmitters() { + var data = EmptyObject(); + data.ObjectId = 0x0200_0002u; + data.IsSetup = true; + data.ParticleEmitters.Add(new StagedEmitter { + Emitter = BuildEmitter(0x2A00_0001u), + PartIndex = 3, + Offset = Matrix4x4.CreateTranslation(10, 20, 30), + }); + data.ParticleEmitters.Add(new StagedEmitter { + Emitter = BuildEmitter(0x2A00_0002u), + PartIndex = 0, + Offset = Matrix4x4.Identity, + }); + return data; + } + + private static ObjectMeshData WithNullableFieldsPresent() { + var data = EmptyObject(); + data.ObjectId = 0x0300_0001u; + data.SelectionSphere = new Sphere { Origin = new Vector3(1, 1, 1), Radius = 2.5f }; + data.Batches.Add(new MeshBatchData { + Indices = new ushort[] { 0 }, + UploadPixelFormat = AcDream.Content.UploadPixelFormat.Rgba, + UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, + }); + return data; + } + + private static ObjectMeshData WithNullableFieldsAbsent() { + var data = EmptyObject(); + data.ObjectId = 0x0300_0002u; + data.SelectionSphere = null; + data.Batches.Add(new MeshBatchData { + Indices = new ushort[] { 0 }, + UploadPixelFormat = null, + UploadPixelType = null, + }); + return data; + } + + private static ObjectMeshData WithEdgeLines() { + var data = EmptyObject(); + data.ObjectId = 0x0400_0001u; + data.EdgeLines = new[] { + new Vector3(0, 0, 0), new Vector3(1, 0, 0), + new Vector3(1, 0, 0), new Vector3(1, 1, 0), + }; + return data; + } + + private static ObjectMeshData WithNestedEnvCellGeometry() { + var data = EmptyObject(); + data.ObjectId = 0x0D00_0001_0000_0100u | (1UL << 32); + data.IsSetup = true; + data.EnvCellGeometry = VerticesAndIndicesOnly(); + return data; + } + + public static IEnumerable AllFixtures() { + yield return new object[] { EmptyObject() }; + yield return new object[] { VerticesAndIndicesOnly() }; + yield return new object[] { MultipleTextureBatchGroups() }; + yield return new object[] { SetupWithParts() }; + yield return new object[] { WithEmitters() }; + yield return new object[] { WithNullableFieldsPresent() }; + yield return new object[] { WithNullableFieldsAbsent() }; + yield return new object[] { WithEdgeLines() }; + yield return new object[] { WithNestedEnvCellGeometry() }; + } + + // ---- round-trip tests ---------------------------------------------------- + + [Theory] + [MemberData(nameof(AllFixtures))] + public void RoundTrip_PreservesEveryField(ObjectMeshData original) { + using var ms = new MemoryStream(); + ObjectMeshDataSerializer.Write(original, ms); + var bytes = ms.ToArray(); + + var readBack = ObjectMeshDataSerializer.Read(bytes); + + ObjectMeshDataEquality.AssertEqual(original, readBack); + } + + // ---- determinism ----------------------------------------------------- + + [Fact] + public void Serialize_SameInstanceTwice_ByteIdentical() { + var data = MultipleTextureBatchGroups(); + + using var ms1 = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, ms1); + + using var ms2 = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, ms2); + + Assert.Equal(ms1.ToArray(), ms2.ToArray()); + } + + [Fact] + public void Serialize_DictionaryInsertedInDifferentOrders_ByteIdentical() { + TextureBatchData Batch(uint surfaceId) => new() { + Key = new TextureKey { SurfaceId = surfaceId, PaletteId = 1, Stippling = StipplingType.None, IsSolid = false }, + TextureData = new byte[] { (byte)surfaceId }, + Indices = new List { 0, 1, 2 }, + CullMode = CullMode.None, + }; + + var a = EmptyObject(); + a.ObjectId = 0x0500_0001u; + a.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { Batch(1) }; + a.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List { Batch(2) }; + a.TextureBatches[(16, 16, TextureFormat.A8)] = new List { Batch(3) }; + + var b = EmptyObject(); + b.ObjectId = 0x0500_0001u; + // Insert in a completely different order. + b.TextureBatches[(16, 16, TextureFormat.A8)] = new List { Batch(3) }; + b.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { Batch(1) }; + b.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List { Batch(2) }; + + using var msA = new MemoryStream(); + ObjectMeshDataSerializer.Write(a, msA); + using var msB = new MemoryStream(); + ObjectMeshDataSerializer.Write(b, msB); + + Assert.Equal(msA.ToArray(), msB.ToArray()); + } + + [Fact] + public void Write_SortsTextureBatchesByWidthHeightFormatKeyTuple() { + // Insert in scrambled order; the serialized bytes must reflect the + // KEY-sorted order (Width, Height, Format), not insertion order. + var data = EmptyObject(); + data.ObjectId = 0x0600_0001u; + + // Each batch's TextureData is a distinctive multi-byte marker (not a + // single ambiguous byte value that could collide with unrelated + // length-prefix / width / height bytes elsewhere in the stream). + TextureBatchData Batch(byte[] marker) => new() { + Key = default, + TextureData = marker, + Indices = new List(), + CullMode = CullMode.None, + }; + + byte[] markerA = { 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA }; + byte[] markerB = { 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB }; + byte[] markerC = { 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC }; + + data.TextureBatches[(100, 1, TextureFormat.RGBA8)] = new List { Batch(markerC) }; + data.TextureBatches[(1, 1, TextureFormat.RGBA8)] = new List { Batch(markerA) }; + data.TextureBatches[(1, 100, TextureFormat.RGBA8)] = new List { Batch(markerB) }; + + using var ms = new MemoryStream(); + ObjectMeshDataSerializer.Write(data, ms); + var bytes = ms.ToArray(); + + // markerA (width=1,height=1) < markerB (width=1,height=100) < + // markerC (width=100,height=1) in ascending (Width, Height) order. + int iA = IndexOfSequence(bytes, markerA); + int iB = IndexOfSequence(bytes, markerB); + int iC = IndexOfSequence(bytes, markerC); + Assert.True(iA >= 0 && iB >= 0 && iC >= 0, "all three markers must appear in the stream"); + Assert.True(iA < iB, $"markerA (width=1,height=1) must precede markerB (width=1,height=100): iA={iA} iB={iB}"); + Assert.True(iB < iC, $"markerB (width=1,height=100) must precede markerC (width=100,height=1): iB={iB} iC={iC}"); + } + + private static int IndexOfSequence(byte[] haystack, byte[] needle) { + for (int i = 0; i <= haystack.Length - needle.Length; i++) { + bool match = true; + for (int j = 0; j < needle.Length; j++) { + if (haystack[i + j] != needle[j]) { match = false; break; } + } + if (match) return i; + } + return -1; + } +}