acdream/tests/AcDream.Content.Tests/PakRoundTripTests.cs
Erik 84d1956d84 fix(pipeline): MP1b review - pak format/reader hardening
Adversarially-verified review findings 2,3,4,5,6,9:

(2) PakFormat.CurrentFormatVersion=1 constant; PakWriter stamps it
unconditionally (caller header templates can no longer produce a
version-0 pak — the default-0 footgun); PakReader refuses any other
version with a message naming found/expected. Tests: versions 0 and 2
both rejected; writer stamps even when the template omits the field.

(3) Reader robustness, all under the documented corrupt-=-missing
CONTRACT (external-file input — surface loudly once, then behave as
absent; never garbage, never throw from a lookup): (a) per-TOC-entry
bounds validation at open (offset/length outside [header, toc) or
past EOF -> logged once, entry missing, siblings unaffected); (b)
TryReadObjectMeshData catches deserialization failures (malformed
structure behind a matching CRC) -> logged once per entry, false; (c)
structurally unopenable files throw at OPEN with a clear message:
unfinalized header (tocOffset < header size — the placeholder-header
crash signature) and TOC-past-EOF truncation. Tests: corrupt TOC
entry, truncated pak, half-written pak, malformed-blob-behind-valid-
CRC (tamper + CRC recompute) — each verifying sibling blobs still read.

(4) Single-pass read: TryReadObjectMeshData now does ONE ReadArray out
of the map; CRC and deserialization run over the same buffer (was: a
separate VerifyCrc traversal + a second ReadArray + a ToArray copy
inside Serializer.Read). New Serializer.Read(byte[]) overload avoids
the defensive copy. Verdict set stays a ConcurrentDictionary (MP1c
calls this from 4 decode workers). True span-over-mmap zero-copy is
deferred to MP1c profiling per the class doc comment.

(5) PakWriter.Dispose restored to try/finally: Finish() does real I/O
and can throw (disk full) — the stream must ALWAYS close so no file
handle leaks mid-unwind. (Reverts the a5926ebc simplification, which
was wrong about this.) Test: dispose after an AddBlob exception leaves
the file deletable.

(6) CRC-32 known-answer vectors: "123456789" -> 0xCBF43926, empty ->
0x00000000, single zero byte -> 0xD202EF8D. The suite was previously
blind to a self-consistent-but-wrong CRC.

(9) Equality comparator floats switched to bit-equality
(SingleToInt32Bits/DoubleToInt64Bits, incl. Vector2/3 + Matrix4x4
components): for byte-identity round-trip purposes `==` was both too
strict (NaN==NaN false — a surviving NaN payload would wrongly FAIL)
and too lax (-0.0==+0.0 true — a sign-bit flip would wrongly PASS).

Content.Tests: 54/54 green (was 43).
2026-07-05 22:08:59 +02:00

405 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Content.Pak;
namespace AcDream.Content.Tests;
public class PakRoundTripTests : IDisposable {
private readonly List<string> _tempFiles = new();
private string NewTempPakPath() {
var path = Path.Combine(Path.GetTempPath(), $"acdream-paktest-{Guid.NewGuid():N}.pak");
_tempFiles.Add(path);
return path;
}
public void Dispose() {
foreach (var f in _tempFiles) {
try { if (File.Exists(f)) File.Delete(f); } catch { /* best effort cleanup */ }
}
}
private static ObjectMeshData MakeSyntheticData(uint fileId, int vertexCount) {
var vertices = new VertexPositionNormalTexture[vertexCount];
for (int i = 0; i < vertexCount; i++) {
vertices[i] = new VertexPositionNormalTexture(
new Vector3(i, i * 2, i * 3),
new Vector3(0, 0, 1),
new Vector2(i * 0.1f, i * 0.2f));
}
return new ObjectMeshData {
ObjectId = fileId,
IsSetup = false,
Vertices = vertices,
DIDDegrade = fileId + 1,
};
}
private static (PakAssetType Type, uint FileId, ObjectMeshData Data)[] MakeBlobSet(int n) {
var result = new (PakAssetType, uint, ObjectMeshData)[n];
for (int i = 0; i < n; i++) {
var type = (PakAssetType)((i % 3) + 1);
uint fileId = 0x0100_0000u + (uint)i;
result[i] = (type, fileId, MakeSyntheticData(fileId, i + 1));
}
return result;
}
private static PakHeader WritePak(string path, (PakAssetType Type, uint FileId, ObjectMeshData Data)[] blobs) {
var header = new PakHeader {
FormatVersion = 1,
PortalIteration = 10,
CellIteration = 20,
HighResIteration = 30,
LanguageIteration = 40,
BakeToolVersion = 1,
};
using var writer = new PakWriter(path, header);
foreach (var (type, fileId, data) in blobs) {
writer.AddBlob(PakKey.Compose(type, fileId), data);
}
writer.Finish();
return header;
}
[Fact]
public void WriteThenOpen_HeaderFieldsMatch() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(5);
var written = WritePak(path, blobs);
using var reader = new PakReader(path);
Assert.Equal(written.FormatVersion, reader.Header.FormatVersion);
Assert.Equal(written.PortalIteration, reader.Header.PortalIteration);
Assert.Equal(written.CellIteration, reader.Header.CellIteration);
Assert.Equal(written.HighResIteration, reader.Header.HighResIteration);
Assert.Equal(written.LanguageIteration, reader.Header.LanguageIteration);
Assert.Equal(written.BakeToolVersion, reader.Header.BakeToolVersion);
Assert.Equal((uint)blobs.Length, reader.Header.TocCount);
}
[Fact]
public void WriteThenOpen_EveryKeyFound() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(20);
WritePak(path, blobs);
using var reader = new PakReader(path);
foreach (var (type, fileId, _) in blobs) {
Assert.True(reader.ContainsKey(PakKey.Compose(type, fileId)),
$"key for type={type} fileId=0x{fileId:X8} should be found");
}
}
[Fact]
public void WriteThenOpen_BlobsDeserializeToDeepEqualObjects() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(8);
WritePak(path, blobs);
using var reader = new PakReader(path);
foreach (var (type, fileId, expected) in blobs) {
var key = PakKey.Compose(type, fileId);
Assert.True(reader.TryReadObjectMeshData(key, out var actual), $"expected key 0x{key:X16} to read successfully");
ObjectMeshDataEquality.AssertEqual(expected, actual);
}
}
[Fact]
public void MissingKey_ReturnsFalse() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(3);
WritePak(path, blobs);
using var reader = new PakReader(path);
var missingKey = PakKey.Compose(PakAssetType.GfxObjMesh, 0xFFFF_FFFEu);
Assert.False(reader.ContainsKey(missingKey));
Assert.False(reader.TryReadObjectMeshData(missingKey, out var data));
Assert.Null(data);
}
[Fact]
public void CorruptedBlob_CrcMismatch_TreatedAsMissing() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(4);
WritePak(path, blobs);
// Flip one byte inside the FIRST blob's region (right after the 64-byte header).
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) {
fs.Position = PakHeader.Size + 4; // a few bytes into the first blob's payload
int b = fs.ReadByte();
fs.Position = PakHeader.Size + 4;
fs.WriteByte((byte)(b ^ 0xFF));
}
using var reader = new PakReader(path);
var key = PakKey.Compose(blobs[0].Type, blobs[0].FileId);
Assert.False(reader.TryReadObjectMeshData(key, out var data),
"a CRC-mismatched blob must be treated as missing");
Assert.Null(data);
}
[Fact]
public void CorruptedBlob_DoesNotAffectOtherBlobs() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(4);
WritePak(path, blobs);
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) {
fs.Position = PakHeader.Size + 4;
int b = fs.ReadByte();
fs.Position = PakHeader.Size + 4;
fs.WriteByte((byte)(b ^ 0xFF));
}
using var reader = new PakReader(path);
// blobs[1..] should still read fine.
for (int i = 1; i < blobs.Length; i++) {
var key = PakKey.Compose(blobs[i].Type, blobs[i].FileId);
Assert.True(reader.TryReadObjectMeshData(key, out var actual), $"blob {i} should be unaffected by corruption in blob 0");
ObjectMeshDataEquality.AssertEqual(blobs[i].Data, actual);
}
}
[Fact]
public void Blobs_Are64ByteAligned() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(6);
WritePak(path, blobs);
using var reader = new PakReader(path);
foreach (var (type, fileId, _) in blobs) {
var offset = reader.GetBlobOffsetForTest(PakKey.Compose(type, fileId));
Assert.True(offset % 64 == 0, $"blob offset {offset} for type={type} fileId=0x{fileId:X8} must be 64-byte aligned");
}
}
[Fact]
public void TocBinarySearch_MatchesLinearScan() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(50);
WritePak(path, blobs);
using var reader = new PakReader(path);
var allKeys = blobs.Select(b => PakKey.Compose(b.Type, b.FileId)).ToArray();
foreach (var key in allKeys) {
bool binarySearchFound = reader.ContainsKey(key);
bool linearScanFound = reader.DebugLinearScanContainsKey(key);
Assert.Equal(linearScanFound, binarySearchFound);
}
// Also verify a handful of keys NOT present agree between both paths.
var absentKeys = new[] {
PakKey.Compose(PakAssetType.GfxObjMesh, 0xDEAD_0000u),
PakKey.Compose(PakAssetType.SetupMesh, 0xDEAD_0001u),
PakKey.Compose(PakAssetType.EnvCellMesh, 0xDEAD_0002u),
};
foreach (var key in absentKeys) {
Assert.Equal(reader.DebugLinearScanContainsKey(key), reader.ContainsKey(key));
Assert.False(reader.ContainsKey(key));
}
}
[Fact]
public void EmptyPak_OpensCleanly_NoKeysFound() {
var path = NewTempPakPath();
WritePak(path, Array.Empty<(PakAssetType, uint, ObjectMeshData)>());
using var reader = new PakReader(path);
Assert.Equal(0u, reader.Header.TocCount);
Assert.False(reader.ContainsKey(PakKey.Compose(PakAssetType.GfxObjMesh, 1)));
}
// ---- format-version enforcement (review finding 2) ---------------------
[Fact]
public void Writer_StampsFormatVersion_IgnoringTemplate() {
// The default-0 footgun: a caller building a header template without
// setting FormatVersion must still produce a CURRENT-version pak.
var path = NewTempPakPath();
using (var writer = new PakWriter(path, new PakHeader { BakeToolVersion = 1 })) {
writer.AddBlob(PakKey.Compose(PakAssetType.GfxObjMesh, 1), MakeSyntheticData(1, 1));
writer.Finish();
}
using var reader = new PakReader(path);
Assert.Equal(PakFormat.CurrentFormatVersion, reader.Header.FormatVersion);
}
[Theory]
[InlineData(0u)]
[InlineData(2u)]
public void Reader_RejectsWrongFormatVersion(uint wrongVersion) {
var path = NewTempPakPath();
WritePak(path, MakeBlobSet(2));
// Patch the header's formatVersion dword (offset 4) in place.
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) {
fs.Position = 4;
Span<byte> buf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buf, wrongVersion);
fs.Write(buf);
}
var ex = Assert.Throws<InvalidDataException>(() => new PakReader(path));
Assert.Contains($"version {wrongVersion}", ex.Message);
Assert.Contains($"version {PakFormat.CurrentFormatVersion}", ex.Message);
}
// ---- reader robustness (review finding 3) -------------------------------
[Fact]
public void CorruptTocEntry_TreatedAsMissing_SiblingsUnaffected_NoThrow() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(4);
WritePak(path, blobs);
// Patch the FIRST victim entry's length field (entry offset +16) to a
// value that runs past the file end.
var victimKey = PakKey.Compose(blobs[0].Type, blobs[0].FileId);
long entryPos = FindTocEntryPosition(path, victimKey);
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) {
fs.Position = entryPos + 16;
Span<byte> buf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buf, 0x7FFF_FFF0u);
fs.Write(buf);
}
using var reader = new PakReader(path); // must NOT throw — corrupt entry = missing
Assert.False(reader.ContainsKey(victimKey));
Assert.False(reader.TryReadObjectMeshData(victimKey, out var data));
Assert.Null(data);
for (int i = 1; i < blobs.Length; i++) {
var key = PakKey.Compose(blobs[i].Type, blobs[i].FileId);
Assert.True(reader.TryReadObjectMeshData(key, out var sibling), $"sibling blob {i} should be unaffected");
ObjectMeshDataEquality.AssertEqual(blobs[i].Data, sibling);
}
}
[Fact]
public void TruncatedPak_RefusedAtOpen() {
var path = NewTempPakPath();
WritePak(path, MakeBlobSet(4));
// Chop the file mid-TOC: the header claims 4 entries the file no
// longer holds — a structural fault, refused at open.
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) {
fs.SetLength(fs.Length - PakTocEntry.Size - 4);
}
var ex = Assert.Throws<InvalidDataException>(() => new PakReader(path));
Assert.Contains("truncated", ex.Message);
}
[Fact]
public void HalfWrittenPak_UnfinalizedHeader_RefusedAtOpen() {
var path = NewTempPakPath();
// Simulate a bake crash between the placeholder-header write and
// Finish(): current-version header with TocOffset still 0, blob bytes
// behind it.
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write)) {
new PakHeader { FormatVersion = PakFormat.CurrentFormatVersion }.WriteTo(fs);
var junk = new byte[256];
new Random(42).NextBytes(junk);
fs.Write(junk);
}
var ex = Assert.Throws<InvalidDataException>(() => new PakReader(path));
Assert.Contains("unfinalized", ex.Message);
}
[Fact]
public void MalformedBlobBehindValidCrc_TreatedAsMissing_SiblingsUnaffected() {
var path = NewTempPakPath();
var blobs = MakeBlobSet(3);
WritePak(path, blobs);
// Corrupt the FIRST blob's STRUCTURE (vertices count:i32 at blob
// offset 9 -> -1) and then RE-COMPUTE the CRC over the tampered bytes
// so the CRC tripwire passes — only the deserialization catch can
// save the read now.
var victimKey = PakKey.Compose(blobs[0].Type, blobs[0].FileId);
long entryPos = FindTocEntryPosition(path, victimKey);
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) {
// Read the victim entry to get offset+length.
fs.Position = entryPos;
var entryBuf = new byte[PakTocEntry.Size];
fs.ReadExactly(entryBuf);
var entry = PakTocEntry.ReadFrom((ReadOnlySpan<byte>)entryBuf);
// Tamper: vertices count -> -1 (ObjectId u64 + IsSetup byte = offset 9).
fs.Position = (long)entry.Offset + 9;
Span<byte> negOne = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(negOne, -1);
fs.Write(negOne);
// Recompute CRC over the tampered blob region.
fs.Position = (long)entry.Offset;
var blobBytes = new byte[entry.Length];
fs.ReadExactly(blobBytes);
uint newCrc = Crc32.Compute(blobBytes);
// Patch the TOC entry's crc32 field (entry offset +20).
fs.Position = entryPos + 20;
Span<byte> crcBuf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(crcBuf, newCrc);
fs.Write(crcBuf);
}
using var reader = new PakReader(path);
Assert.False(reader.TryReadObjectMeshData(victimKey, out var data),
"a structurally-malformed blob behind a matching CRC must be treated as missing");
Assert.Null(data);
// Second read: still missing, no throw (verdict cached).
Assert.False(reader.TryReadObjectMeshData(victimKey, out _));
for (int i = 1; i < blobs.Length; i++) {
var key = PakKey.Compose(blobs[i].Type, blobs[i].FileId);
Assert.True(reader.TryReadObjectMeshData(key, out var sibling), $"sibling blob {i} should be unaffected");
ObjectMeshDataEquality.AssertEqual(blobs[i].Data, sibling);
}
}
// ---- writer disposal safety (review finding 5) ---------------------------
[Fact]
public void WriterDisposedAfterAddBlobException_ReleasesFileHandle() {
var path = NewTempPakPath();
var data = MakeSyntheticData(1, 1);
var key = PakKey.Compose(PakAssetType.GfxObjMesh, 1);
var writer = new PakWriter(path, new PakHeader { BakeToolVersion = 1 });
writer.AddBlob(key, data);
Assert.Throws<ArgumentException>(() => writer.AddBlob(key, data)); // duplicate key mid-AddBlob
writer.Dispose();
// The stream must be closed even after the AddBlob exception: the
// file is deletable (no leaked/locked handle).
File.Delete(path);
Assert.False(File.Exists(path));
}
// ---- helpers -------------------------------------------------------------
/// <summary>Locates the on-disk file position of the TOC entry for <paramref name="key"/> by raw parsing.</summary>
private static long FindTocEntryPosition(string path, ulong key) {
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
var headerBuf = new byte[PakHeader.Size];
fs.ReadExactly(headerBuf);
var header = PakHeader.ReadFrom((ReadOnlySpan<byte>)headerBuf);
var entryBuf = new byte[PakTocEntry.Size];
for (uint i = 0; i < header.TocCount; i++) {
long pos = (long)header.TocOffset + i * PakTocEntry.Size;
fs.Position = pos;
fs.ReadExactly(entryBuf);
var entry = PakTocEntry.ReadFrom((ReadOnlySpan<byte>)entryBuf);
if (entry.Key == key) return pos;
}
throw new InvalidOperationException($"TOC entry for key 0x{key:X16} not found");
}
}