TDD: PakRoundTripTests written first, confirmed a compile failure against the not-yet-existing PakWriter/PakReader types. PakWriter streams [header placeholder][64-byte-aligned blobs][TOC sorted by key], then seeks back and finalizes the header once TocOffset/TocCount are known (plan: "TOC last" so the writer doesn't need blob count up front). PakReader mmaps the whole file once, loads the TOC into a sorted array for O(log n) binary-search lookup, and verifies each blob's CRC-32 LAZILY on first access (cached per-index so a corrupt blob logs exactly once and is thereafter always treated as missing rather than handing back garbage bytes). No locks anywhere — the mapped memory is immutable for the reader's lifetime by construction. CRC-32 implemented directly (standard IEEE 802.3 table-driven variant) rather than adding a System.IO.Hashing package reference, keeping AcDream.Content's dependency surface minimal per its existing "no GL binaries" intent. 9 tests green: header round-trip, every-key-found, blob deep-equality via the Task 3 comparator, missing-key returns false, corrupted-blob (single flipped byte) is detected and treated as missing while leaving sibling blobs unaffected, 64-byte blob alignment, and TOC binary search cross-checked against a linear scan for both present and absent keys.
215 lines
7.9 KiB
C#
215 lines
7.9 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)));
|
|
}
|
|
}
|