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,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;
/// <summary>
/// Deterministic binary (de)serializer for the <see cref="ObjectMeshData"/>
/// family (<see cref="MeshBatchData"/>, <see cref="TextureBatchData"/>,
/// <see cref="StagedEmitter"/>, <see cref="TextureKey"/>, plus the DRW
/// <see cref="Sphere"/> / <see cref="BoundingBox"/> 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 <see cref="MemoryMarshal.AsBytes{T}(Span{T})"/> bulk
/// copy; <see cref="ObjectMeshData.TextureBatches"/> 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. <see cref="ObjectMeshData.EnvCellGeometry"/> nests
/// recursively (present:byte + nested block) since MeshExtractor can
/// populate it with another full ObjectMeshData.
/// </summary>
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<byte> 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<MeshBatchData>(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<StagedEmitter>(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;
}
/// <summary>
/// Writes the TextureBatches dictionary sorted ascending by the key tuple
/// (Width, Height, Format) — REQUIRED for byte-reproducible bakes
/// independent of Dictionary iteration/insertion order.
/// </summary>
private static void WriteTextureBatches(
BinaryWriter w,
Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> 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<TextureBatchData>> ReadTextureBatches(BinaryReader r) {
int groupCount = r.ReadInt32();
var result = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>(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<TextureBatchData>(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!,
};
}
/// <summary>
/// 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.
/// </summary>
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<GfxObj> { DataId = r.ReadUInt32() };
pe.HwGfxObjId = new QualifiedDataId<GfxObj> { 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());
}
/// <summary>count:i32 + MemoryMarshal.AsBytes bulk copy (32 bytes/vertex).</summary>
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<VertexPositionNormalTexture>();
var result = new VertexPositionNormalTexture[count];
var bytes = r.ReadBytes(count * VertexPositionNormalTexture.Size);
bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan()));
return result;
}
/// <summary>count:i32 + MemoryMarshal.AsBytes bulk copy (12 bytes/Vector3).</summary>
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<Vector3>();
var result = new Vector3[count];
var bytes = r.ReadBytes(count * 12);
bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan()));
return result;
}
/// <summary>count:i32 + MemoryMarshal.AsBytes bulk copy (2 bytes/ushort).</summary>
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<ushort>();
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<ushort> 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<ushort> ReadUInt16List(BinaryReader r) {
int count = r.ReadInt32();
var list = new List<ushort>(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;
}
/// <summary>count:i32 + raw byte payload.</summary>
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<byte>() : r.ReadBytes(count);
}
/// <summary>present:byte + value:i32 for a nullable enum-backed-by-int field.</summary>
private static void WriteNullableInt32Enum(BinaryWriter w, bool present, int value) {
w.Write(present);
w.Write(value);
}
private static TEnum? ReadNullableInt32Enum<TEnum>(BinaryReader r, Func<int, TEnum> project) where TEnum : struct, Enum {
bool present = r.ReadBoolean();
int value = r.ReadInt32();
return present ? project(value) : null;
}
}

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;
}
}