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.
297 lines
12 KiB
C#
297 lines
12 KiB
C#
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;
|
|
}
|
|
}
|