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:
parent
8248abe9d4
commit
a5ba435839
3 changed files with 1009 additions and 0 deletions
297
tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs
Normal file
297
tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue