acdream/tests/AcDream.Content.Tests/FlatCollisionAssetSerializerTests.cs
Erik 9cd42417a8 feat(content): bake and read flat collision assets
Append strict collision/topology payloads to the existing prepared package so later physics cutover can drop parsed DAT graphs without adding a second mapping or changing traversal behavior. The full 2,232,170-key catalog is deterministic across worker counts, exact-byte aliased, corruption-isolated, and cancellation-safe.
2026-07-25 15:22:08 +02:00

512 lines
18 KiB
C#

using System.Collections.Immutable;
using System.Numerics;
using AcDream.Content.Pak;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
namespace AcDream.Content.Tests;
public sealed class FlatCollisionAssetSerializerTests : IDisposable
{
private static readonly PreparedAssetCatalogIdentity Identity =
new(10, 20, 30, 40, PakFormat.CurrentBakeToolVersion);
private readonly List<string> _paths = [];
[Fact]
public void EveryPayload_RoundTripsToByteIdenticalRepresentation()
{
FlatGfxObjCollisionAsset gfx = GfxAsset();
FlatSetupCollision setup = SetupAsset();
FlatCellStructureCollisionAsset structure = CellStructureAsset();
FlatEnvCellTopology topology = TopologyAsset();
byte[] gfxBytes = FlatCollisionAssetSerializer.Serialize(gfx);
byte[] setupBytes = FlatCollisionAssetSerializer.Serialize(setup);
byte[] structureBytes =
FlatCollisionAssetSerializer.Serialize(structure);
byte[] topologyBytes =
FlatCollisionAssetSerializer.Serialize(topology);
FlatGfxObjCollisionAsset readGfx =
FlatCollisionAssetSerializer.DeserializeGfxObj(gfxBytes);
FlatSetupCollision readSetup =
FlatCollisionAssetSerializer.DeserializeSetup(setupBytes);
FlatCellStructureCollisionAsset readStructure =
FlatCollisionAssetSerializer.DeserializeCellStructure(
structureBytes);
FlatEnvCellTopology readTopology =
FlatCollisionAssetSerializer.DeserializeEnvCellTopology(
topologyBytes);
Assert.Equal(
gfxBytes,
FlatCollisionAssetSerializer.Serialize(readGfx));
Assert.Equal(
setupBytes,
FlatCollisionAssetSerializer.Serialize(readSetup));
Assert.Equal(
structureBytes,
FlatCollisionAssetSerializer.Serialize(readStructure));
Assert.Equal(
topologyBytes,
FlatCollisionAssetSerializer.Serialize(readTopology));
AssertFloatBits(
gfx.VisualBounds!.Value.Radius,
readGfx.VisualBounds!.Value.Radius);
AssertFloatBits(
setup.StepDownHeight,
readSetup.StepDownHeight);
AssertFloatBits(
structure.PhysicsBsp.Nodes[0].SplittingPlane.D,
readStructure.PhysicsBsp.Nodes[0].SplittingPlane.D);
Assert.Equal(
topology.VisibleCellIds.AsEnumerable(),
readTopology.VisibleCellIds.AsEnumerable());
}
[Fact]
public void Encoding_IsLittleEndianAndPreservesSpecialFloatBits()
{
float special = BitConverter.Int32BitsToSingle(
unchecked((int)0xFFC0_1234u));
var setup = new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
special,
-0f,
float.PositiveInfinity,
float.NegativeInfinity);
byte[] bytes = FlatCollisionAssetSerializer.Serialize(setup);
// Header is "ACCL", kind=Setup, schema=1, reserved=0. Counts follow,
// then the exact little-endian IEEE-754 bits for Height.
Assert.Equal(
new byte[]
{
0x41, 0x43, 0x43, 0x4C,
0x02, 0x01, 0x00, 0x00,
},
bytes[..8]);
Assert.Equal(
new byte[] { 0x34, 0x12, 0xC0, 0xFF },
bytes[16..20]);
FlatSetupCollision read =
FlatCollisionAssetSerializer.DeserializeSetup(bytes);
AssertFloatBits(special, read.Height);
AssertFloatBits(-0f, read.Radius);
AssertFloatBits(float.PositiveInfinity, read.StepUpHeight);
AssertFloatBits(float.NegativeInfinity, read.StepDownHeight);
}
[Fact]
public void Readers_RejectWrongKindTruncationTrailingAndInvalidCounts()
{
byte[] setup = FlatCollisionAssetSerializer.Serialize(SetupAsset());
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeGfxObj(setup));
for (int length = 0; length < setup.Length; length++)
{
Assert.ThrowsAny<Exception>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
setup.AsSpan(0, length)));
}
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
[.. setup, 0x00]));
byte[] negativeCount = (byte[])setup.Clone();
negativeCount[8] = 0xFF;
negativeCount[9] = 0xFF;
negativeCount[10] = 0xFF;
negativeCount[11] = 0xFF;
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
negativeCount));
byte[] impossibleCombinedCounts = (byte[])setup.Clone();
impossibleCombinedCounts[8] = 0x03;
impossibleCombinedCounts[12] = 0x03;
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
impossibleCombinedCounts));
}
[Fact]
public void SerializeAndDeserialize_PropagatePreCancellation()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(
() => FlatCollisionAssetSerializer.Serialize(
GfxAsset(),
cancellation.Token));
byte[] bytes =
FlatCollisionAssetSerializer.Serialize(GfxAsset());
Assert.Throws<OperationCanceledException>(
() => FlatCollisionAssetSerializer.DeserializeGfxObj(
bytes,
cancellation.Token));
}
[Fact]
public void PreparedSource_ReadsAllCollisionKindsThroughSamePackageOwner()
{
const uint gfxId = 0x0100_0001u;
const uint setupId = 0x0200_0001u;
const uint cellId = 0xA9B4_0101u;
string path = NewPath();
using (var writer = new PakWriter(path, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjCollision, gfxId),
FlatCollisionAssetSerializer.Serialize(GfxAsset()));
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.AddBlob(
PakKey.Compose(
PakAssetType.CellStructureCollision,
cellId),
FlatCollisionAssetSerializer.Serialize(CellStructureAsset()));
writer.AddBlob(
PakKey.Compose(PakAssetType.EnvCellTopology, cellId),
FlatCollisionAssetSerializer.Serialize(TopologyAsset()));
writer.Finish();
}
using var owner = new PakPreparedAssetSource(path, Identity);
IPreparedAssetSource renderSource = owner;
IPreparedCollisionSource collisionSource = owner;
Assert.Equal(
PreparedAssetPresence.Available,
collisionSource.ProbeCollision(
PakAssetType.GfxObjCollision,
gfxId));
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadGfxObjCollision(gfxId).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadSetupCollision(setupId).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadCellStructureCollision(cellId).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadEnvCellTopology(cellId).Status);
Assert.Equal(4, collisionSource.CollisionStats.Loaded);
Assert.Equal(0, renderSource.Stats.Reads);
Assert.Equal(new FileInfo(path).Length, renderSource.MappedVirtualBytes);
Assert.Throws<ArgumentOutOfRangeException>(
() => renderSource.Probe(
PakAssetType.GfxObjCollision,
gfxId));
Assert.Throws<ArgumentOutOfRangeException>(
() => collisionSource.ProbeCollision(
PakAssetType.GfxObjMesh,
gfxId));
}
[Fact]
public void PreparedSource_DistinguishesMissingCrcAndSchemaCorruption()
{
const uint setupId = 0x0200_0001u;
string missingPath = NewPath();
using (var writer = new PakWriter(missingPath, Header()))
writer.Finish();
using (var source = new PakPreparedAssetSource(
missingPath,
Identity))
{
Assert.Equal(
PreparedAssetReadStatus.Missing,
source.ReadSetupCollision(setupId).Status);
}
string schemaPath = NewPath();
byte[] malformed =
[.. FlatCollisionAssetSerializer.Serialize(SetupAsset()), 0x7F];
using (var writer = new PakWriter(schemaPath, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
malformed);
writer.Finish();
}
using (var source = new PakPreparedAssetSource(schemaPath, Identity))
{
Assert.Equal(
PreparedAssetReadStatus.Corrupt,
source.ReadSetupCollision(setupId).Status);
Assert.Equal(
PreparedAssetPresence.Corrupt,
source.ProbeCollision(
PakAssetType.SetupCollision,
setupId));
Assert.Equal(
PreparedAssetReadStatus.Corrupt,
source.ReadSetupCollision(setupId).Status);
}
string crcPath = NewPath();
using (var writer = new PakWriter(crcPath, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.Finish();
}
FlipFirstBlobByte(crcPath);
using var corruptSource =
new PakPreparedAssetSource(crcPath, Identity);
Assert.Equal(
PreparedAssetReadStatus.Corrupt,
corruptSource.ReadSetupCollision(setupId).Status);
}
[Fact]
public void PreparedSource_ConcurrentReadsRemainIndependentAndComplete()
{
const uint setupId = 0x0200_0001u;
string path = NewPath();
using (var writer = new PakWriter(path, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.Finish();
}
using var source = new PakPreparedAssetSource(path, Identity);
Parallel.For(
0,
64,
_ =>
{
PreparedCollisionReadResult<FlatSetupCollision> result =
source.ReadSetupCollision(setupId);
Assert.Equal(PreparedAssetReadStatus.Loaded, result.Status);
Assert.NotNull(result.Data);
});
Assert.Equal(64, source.CollisionStats.Reads);
Assert.Equal(64, source.CollisionStats.Loaded);
Assert.Equal(0, source.CollisionStats.Corrupt);
}
[Fact]
public void RenderDecoder_CannotPoisonCollisionTypedEntry()
{
const uint setupId = 0x0200_0001u;
string path = NewPath();
ulong key = PakKey.Compose(
PakAssetType.SetupCollision,
setupId);
using (var writer = new PakWriter(path, Header()))
{
writer.AddBlob(
key,
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.Finish();
}
using (var reader = new PakReader(path))
{
Assert.Equal(
PakObjectReadStatus.Missing,
reader.ReadObjectMeshData(key, out ObjectMeshData? mesh));
Assert.Null(mesh);
Assert.Equal(PakEntryState.Available, reader.ProbeEntry(key));
}
using var source = new PakPreparedAssetSource(path, Identity);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
source.ReadSetupCollision(setupId).Status);
}
private static FlatGfxObjCollisionAsset GfxAsset() =>
new(
PhysicsBsp(),
new FlatCollisionSphere(
new Vector3(Float(0x3F80_0001), -0f, 3f),
Float(0x7FC0_1234)),
new FlatGfxObjVisualBounds(
new Vector3(-1, -2, -3),
new Vector3(4, 5, 6),
new Vector3(1.5f),
Float(0x4123_4567),
new Vector3(2.5f, 3.5f, 4.5f)));
private static FlatSetupCollision SetupAsset() =>
new(
ImmutableArray.Create(
new FlatCollisionCylinder(
new Vector3(1, 2, 3),
Float(0x3F00_0001),
Float(0x4000_0001))),
ImmutableArray.Create(
new FlatCollisionSphere(
new Vector3(-1, -2, -3),
Float(0x4040_0001))),
Float(0x4080_0001),
Float(0x40A0_0001),
Float(0x40C0_0001),
Float(0x40E0_0001));
private static FlatCellStructureCollisionAsset CellStructureAsset() =>
new(
PhysicsBsp(),
new FlatCellContainmentBsp(
0,
ImmutableArray.Create(
new FlatCellBspNode(
BSPNodeType.BPIN,
new Plane(Vector3.UnitX, -1),
1,
2,
0),
new FlatCellBspNode(
BSPNodeType.Leaf,
new Plane(Vector3.UnitY, -2),
-1,
-1,
1),
new FlatCellBspNode(
BSPNodeType.Leaf,
new Plane(Vector3.UnitZ, -3),
-1,
-1,
2))),
PolygonTable());
private static FlatEnvCellTopology TopologyAsset() =>
new(
ImmutableArray.Create(
new FlatEnvCellPortal(0x0102, 3, 4, 0),
new FlatEnvCellPortal(0x0103, 8, 2, 1)),
ImmutableArray.Create(0xA9B4_0101u, 0xA9B4_0102u),
true);
private static FlatPhysicsBsp PhysicsBsp() =>
new(
0,
ImmutableArray.Create(
Node(BSPNodeType.BPIN, 1, 2, 0, 0, 0),
Node(BSPNodeType.Leaf, -1, -1, 1, 0, 1),
Node(BSPNodeType.Leaf, -1, -1, 2, 1, 1)),
ImmutableArray.Create(0, 1),
PolygonTable());
private static FlatPhysicsBspNode Node(
BSPNodeType type,
int positive,
int negative,
int leaf,
int polygonStart,
int polygonCount) =>
new(
type,
new Plane(
new Vector3(1 + leaf, 2 + leaf, 3 + leaf),
Float(0x3F80_0000u + (uint)leaf)),
positive,
negative,
leaf,
leaf & 1,
new FlatCollisionSphere(
new Vector3(4 + leaf, 5 + leaf, 6 + leaf),
7 + leaf),
new FlatIndexRange(polygonStart, polygonCount));
private static FlatPolygonTable PolygonTable() =>
new(
ImmutableArray.Create(
new FlatCollisionPolygon(
3,
new Plane(Vector3.UnitX, -1),
CullMode.Clockwise,
3,
new FlatIndexRange(0, 3)),
new FlatCollisionPolygon(
8,
new Plane(Vector3.UnitY, -2),
CullMode.CounterClockwise,
3,
new FlatIndexRange(3, 3))),
ImmutableArray.Create(
Vector3.Zero,
Vector3.UnitY,
Vector3.UnitZ,
Vector3.One,
Vector3.UnitX,
Vector3.UnitZ));
private string NewPath()
{
string path = Path.Combine(
Path.GetTempPath(),
$"acdream-flat-collision-{Guid.NewGuid():N}.pak");
_paths.Add(path);
return path;
}
private static PakHeader Header() =>
new()
{
PortalIteration = Identity.PortalIteration,
CellIteration = Identity.CellIteration,
HighResIteration = Identity.HighResIteration,
LanguageIteration = Identity.LanguageIteration,
BakeToolVersion = Identity.BakeToolVersion,
};
private static void FlipFirstBlobByte(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
stream.Position = PakHeader.Size + 4;
int value = stream.ReadByte();
Assert.NotEqual(-1, value);
stream.Position = PakHeader.Size + 4;
stream.WriteByte((byte)(value ^ 0xFF));
}
private static float Float(uint bits) =>
BitConverter.Int32BitsToSingle(unchecked((int)bits));
private static void AssertFloatBits(float expected, float actual) =>
Assert.Equal(
BitConverter.SingleToInt32Bits(expected),
BitConverter.SingleToInt32Bits(actual));
public void Dispose()
{
foreach (string path in _paths)
{
try
{
File.Delete(path);
}
catch
{
// Best-effort cleanup.
}
}
}
}