feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip)

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.
This commit is contained in:
Erik 2026-07-05 21:19:02 +02:00
parent 8248abe9d4
commit a5ba435839
3 changed files with 1009 additions and 0 deletions

View file

@ -0,0 +1,196 @@
using System.Linq;
using Chorizite.Core.Render.Enums;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Content.Tests;
/// <summary>
/// Field-by-field deep-equality comparator for <see cref="ObjectMeshData"/> and
/// its whole family (<see cref="MeshBatchData"/>, <see cref="TextureBatchData"/>,
/// <see cref="StagedEmitter"/>, <see cref="TextureKey"/>). 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".
/// </summary>
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<TextureBatchData>> expected,
System.Collections.Generic.Dictionary<(int Width, int Height, TextureFormat Format), System.Collections.Generic.List<TextureBatchData>> 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}");
}
}

View file

@ -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<ushort> { 0, 1, 2, 2, 3, 0 },
CullMode = CullMode.CounterClockwise,
IsTransparent = true,
IsAdditive = false,
HasWrappingUVs = true,
};
data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List<TextureBatchData> { Batch(1, "a"), Batch(2, "b") };
data.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List<TextureBatchData> { Batch(3, "c") };
data.TextureBatches[(16, 16, TextureFormat.A8)] = new List<TextureBatchData> { 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<GfxObj> { DataId = 0x0100_0099u },
HwGfxObjId = new QualifiedDataId<GfxObj> { 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<object[]> 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<ushort> { 0, 1, 2 },
CullMode = CullMode.None,
};
var a = EmptyObject();
a.ObjectId = 0x0500_0001u;
a.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List<TextureBatchData> { Batch(1) };
a.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List<TextureBatchData> { Batch(2) };
a.TextureBatches[(16, 16, TextureFormat.A8)] = new List<TextureBatchData> { Batch(3) };
var b = EmptyObject();
b.ObjectId = 0x0500_0001u;
// Insert in a completely different order.
b.TextureBatches[(16, 16, TextureFormat.A8)] = new List<TextureBatchData> { Batch(3) };
b.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List<TextureBatchData> { Batch(1) };
b.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List<TextureBatchData> { 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<ushort>(),
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<TextureBatchData> { Batch(markerC) };
data.TextureBatches[(1, 1, TextureFormat.RGBA8)] = new List<TextureBatchData> { Batch(markerA) };
data.TextureBatches[(1, 100, TextureFormat.RGBA8)] = new List<TextureBatchData> { 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;
}
}