Campaign OVERHAUL S1 chunk A. Retail's D3DPolyRender::ConstructMesh @0x0059DFA0 is ported as one pure Core descriptor plus the Content extraction that consumes it: - side candidates come only from sides_type (0/1/2); NoPos/NoNeg mean UV-array absence only and never suppress a side (CPolygon::UnPack @0x00538650); - ST_DOUBLE's second copy is reversed with a negative normal; ST_BOTH's negative side has a negative normal and forward fan order (reverse is on the copy ordinal, not the side ordinal); - an absent UV-index array is UV index 0 (ConstructMesh @0x0059E691 xor ebx,ebx, arbitrated on the PDB-paired binary); copyVert @0x0059C080 zeroes coordinates only for a negative or out-of-range index or a vertex without UVs, never by clamping to slot 0; - the subset owner is the source surface-array index, emitted in ascending slot order with retail's per-slot mask (2 > 8 > 4 precedence, positive surface OR on signed stippling > 0); - built-EnvCell admission is (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 after surface resolution (DrawEnvCell @0x0059F170 -> DrawMesh @0x0059D4A0 arg4=1); untextured slots are constructed but not emitted; - cell batches carry SourceSurfaceIndex, RetailSurfaceMask, RawSurfaceType, IsCellShell, and fixed clockwise raster cull (RenderMeshSubset @0x0059CA10); authored sides_type is no longer stored as GPU cull. Prepared-mesh serializer gains the four fields; bake recipe 7 -> 8 with a FullRebuild migration; pak format stays 2 (pinned). Ordinary GfxObj extraction is unchanged. AP-234's register row and CellMesh unification land in chunk B. Core: 32 descriptor tests. Content: 170/170. Bake: 18/18. Launcher.Core: 365/365 (Lane!=Linux). Solution Release build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
399 lines
16 KiB
C#
399 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Numerics;
|
|
using AcDream.Content.Pak;
|
|
using AcDream.Core.Meshing;
|
|
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,
|
|
Translucency = TranslucencyKind.InvAlpha,
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// OH2/S1 chunk-2: a cell-shell batch with non-default values for all
|
|
/// four new fields (SourceSurfaceIndex, RetailSurfaceMask,
|
|
/// RawSurfaceType, IsCellShell), proving the round-trip preserves them
|
|
/// and not just their zero/-1 neutral defaults.
|
|
/// </summary>
|
|
private static ObjectMeshData WithCellShellSubset() {
|
|
var data = EmptyObject();
|
|
data.ObjectId = 0x0700_0001u;
|
|
data.TextureBatches[(64, 64, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
|
new() {
|
|
Key = new TextureKey { SurfaceId = 0x08000BFFu, PaletteId = 0, Stippling = StipplingType.None, IsSolid = false },
|
|
TextureData = new byte[] { 1, 2, 3, 4 },
|
|
Indices = new List<ushort> { 0, 1, 2 },
|
|
CullMode = CullMode.Clockwise,
|
|
Translucency = TranslucencyKind.Opaque,
|
|
IsTransparent = false,
|
|
IsAdditive = false,
|
|
HasWrappingUVs = false,
|
|
SourceSurfaceIndex = 7,
|
|
RetailSurfaceMask = 0b0000_1101,
|
|
RawSurfaceType = 0x11u,
|
|
IsCellShell = true,
|
|
},
|
|
};
|
|
return data;
|
|
}
|
|
|
|
/// <summary>
|
|
/// An ordinary (non-cell) GfxObj-shaped batch — the four new fields are
|
|
/// left entirely unset, exercising their class-level neutral defaults
|
|
/// (SourceSurfaceIndex = -1, RetailSurfaceMask = 0, RawSurfaceType = 0,
|
|
/// IsCellShell = false) through the same round-trip path.
|
|
/// </summary>
|
|
private static ObjectMeshData WithGfxObjNeutralDefaults() {
|
|
var data = EmptyObject();
|
|
data.ObjectId = 0x0700_0002u;
|
|
data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List<TextureBatchData> {
|
|
new() {
|
|
Key = new TextureKey { SurfaceId = 0x08000001u, PaletteId = 0, Stippling = StipplingType.Positive, IsSolid = true },
|
|
TextureData = new byte[] { 9, 9, 9, 9 },
|
|
Indices = new List<ushort> { 0, 1, 2 },
|
|
CullMode = CullMode.None,
|
|
},
|
|
};
|
|
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() };
|
|
yield return new object[] { WithCellShellSubset() };
|
|
yield return new object[] { WithGfxObjNeutralDefaults() };
|
|
}
|
|
|
|
[Fact]
|
|
public void WithGfxObjNeutralDefaults_Fixture_HasClassLevelNeutralDefaults() {
|
|
// Sanity check on the fixture itself (not the serializer): proves
|
|
// the "neutral default" claim is a property of TextureBatchData's
|
|
// own field initializers, not something MeshExtractor's GfxObj path
|
|
// has to opt into.
|
|
TextureBatchData batch = Assert.Single(Assert.Single(WithGfxObjNeutralDefaults().TextureBatches).Value);
|
|
Assert.Equal(-1, batch.SourceSurfaceIndex);
|
|
Assert.Equal((byte)0, batch.RetailSurfaceMask);
|
|
Assert.Equal(0u, batch.RawSurfaceType);
|
|
Assert.False(batch.IsCellShell);
|
|
}
|
|
|
|
// ---- 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}");
|
|
}
|
|
|
|
// ---- truncated/corrupt new-field rejection (OH2/S1 chunk-2, contract §10.5) ----
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(5)]
|
|
[InlineData(10)]
|
|
[InlineData(20)]
|
|
[InlineData(46)]
|
|
[InlineData(60)]
|
|
public void Read_TruncatedTail_ThrowsDeterministically(int bytesRemoved) {
|
|
// WithCellShellSubset's one small batch is the object's only
|
|
// populated collection, so the LAST ~55 bytes of its serialized
|
|
// form span exactly: the four new OH2 fields (SourceSurfaceIndex
|
|
// int32, RetailSurfaceMask byte, RawSurfaceType uint32, IsCellShell
|
|
// bool -- 10 bytes) immediately followed by the fixed
|
|
// BoundingBox+SortCenter+DIDDegrade+SelectionSphere-flag+EdgeLines-
|
|
// count tail every ObjectMeshData writes (45 bytes for this
|
|
// fixture). Sweeping removal depths across that whole span proves
|
|
// every cut inside or around the new fields fails closed --
|
|
// EndOfStreamException from the underlying BinaryReader, never a
|
|
// silently substituted default or a misread of a later field as one
|
|
// of the new ones.
|
|
var data = WithCellShellSubset();
|
|
using var full = new MemoryStream();
|
|
ObjectMeshDataSerializer.Write(data, full);
|
|
byte[] fullBytes = full.ToArray();
|
|
|
|
byte[] truncated = fullBytes[..^bytesRemoved];
|
|
|
|
Assert.Throws<EndOfStreamException>(() => ObjectMeshDataSerializer.Read(truncated));
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_EmptyStream_ThrowsDeterministically() {
|
|
Assert.Throws<EndOfStreamException>(() => ObjectMeshDataSerializer.Read(Array.Empty<byte>()));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|