acdream/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs
Erik a5ba435839 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.
2026-07-05 21:19:02 +02:00

516 lines
20 KiB
C#

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