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
516
src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs
Normal file
516
src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs
Normal file
196
tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
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