acdream/tests/AcDream.Content.Tests/PakEquivalenceTests.cs
Erik 859cf5ec02 refactor(pipeline): MP1b review - unify DatCollectionAdapter + TOC/log test gaps
Adversarially-verified review findings 7 and 8:

(7) The DatCollection->IDatReaderWriter adapter existed as THREE
near-identical copies (App-internal original, Bake's copy, Content.
Tests' copy) — a structure where adapter drift is exactly what the
live-vs-pak equivalence suite cannot detect (both sides would only
drift together if they shared one implementation). Now ONE public
AcDream.Content.DatCollectionAdapter next to IDatReaderWriter (GL-free
home established in MP1a), carrying App's FULL behavior including the
[dat-miss] TryGet tripwire log (which now also covers the bake tool
and the equivalence suite) and the caching/locking. All three copies
deleted; WbMeshAdapter (App), BakeRunner (Bake), and
PakEquivalenceTests (Content.Tests) resolve the shared class.
Iteration properties return the REAL dat iterations — the App copy's
hardcoded 0 was a stub nothing read; the unification intentionally
keeps truth (noted in the doc comment). Verified post-move: no
Silk.NET anywhere in Content / Bake / Content.Tests / Bake.Tests
resolved dependency graphs.

(8) Two test gaps closed in PakRoundTripTests: (a) direct on-disk TOC
sortedness — blobs added in DESCENDING key order, then the raw file
bytes parsed (not through the reader) and every TOC entry asserted
strictly ascending; (b) corrupt-blob logging — five repeated reads
through both public paths (TryReadObjectMeshData + ContainsKey) with
stderr captured, asserting exactly ONE [pak-corrupt] line for the
victim key.

Full suite: 4120 tests, 0 failures (Content.Tests 56, Bake.Tests 1,
plus the pre-existing 4 skips).
2026-07-05 22:18:30 +02:00

161 lines
7.9 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.Content.Pak;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
/// <summary>
/// MP1b Task 6: dat-gated live-vs-pak equivalence suite. Runs MeshExtractor
/// LIVE and bakes the SAME ids to a temp pak, reads the pak back, and deep-
/// compares field-by-field via the Task 3 comparator. Because the bake tool
/// and the live client drive the identical MeshExtractor code (MP1a), this
/// test is proving the pak ROUND-TRIP (serialize/deserialize) preserves
/// what extraction actually produces on real content — not re-verifying the
/// extraction algorithm itself (that's the existing Conformance suite's job).
///
/// Skips cleanly when the real dats are absent (CI), matching
/// DatConcurrencyStressTests' convention.
/// </summary>
public sealed class PakEquivalenceTests {
// Known-tricky GfxObj ids reused from existing conformance fixtures:
// 0x010002B4 / 0x010008A8 — #119 "[up-null] upload returned null" dump
// targets (Issue119UpNullGfxObjDumpTests).
// 0x010014C3 — the #113-saga Holtburg meeting-hall shell
// (Issue119UpNullGfxObjDumpTests.ShellModel_NoTexturedPolyIsDropped).
private static readonly uint[] KnownTrickyGfxObjIds = { 0x010002B4u, 0x010008A8u, 0x010014C3u };
// Setup ids reused from existing physics/conformance fixtures:
// 0x020019FF — the door setup (DoorBugTrajectoryReplayTests,
// DoorSetupGfxObjInspectionTests, DoorCollisionApparatusTests).
// 0x020005D8 / 0x020003F2 — Issue119TowerDumpTests fixtures.
private static readonly uint[] SetupIds = { 0x020019FFu, 0x020005D8u, 0x020003F2u };
private const uint HoltburgLandblock = 0xA9B40000u; // ConformanceDats.HoltburgLandblock
[Fact]
public void LiveExtraction_MatchesPakRoundTrip_OnFixtureIdSet() {
var datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null) return; // dats absent (CI) — skip, matching suite convention
using var dats = new DatCollection(datDir, DatAccessType.Read);
// The unified AcDream.Content.DatCollectionAdapter — same class the
// client and the bake tool use (MP1b review finding 7).
using var datReaderWriter = new DatCollectionAdapter(dats);
var logger = new TestConsoleLogger();
var sideStaged = new List<ObjectMeshData>();
var extractor = new MeshExtractor(datReaderWriter, logger, data => sideStaged.Add(data));
var gfxObjIds = BuildGfxObjIdSet(dats);
var setupIds = SetupIds.ToList();
var envCellIds = BuildEnvCellIdSet(dats, HoltburgLandblock, minCount: 5);
Assert.True(gfxObjIds.Count >= 10, $"fixture GfxObj id set unexpectedly small ({gfxObjIds.Count})");
Assert.True(setupIds.Count >= 3, $"fixture Setup id set unexpectedly small ({setupIds.Count})");
Assert.True(envCellIds.Count >= 5, $"fixture EnvCell id set unexpectedly small ({envCellIds.Count})");
var work = new List<(PakAssetType Type, uint FileId, ulong ExtractorId, bool IsSetup)>();
work.AddRange(gfxObjIds.Select(id => (PakAssetType.GfxObjMesh, id, (ulong)id, false)));
work.AddRange(setupIds.Select(id => (PakAssetType.SetupMesh, id, (ulong)id, true)));
// isSetup: false for EnvCell — matches the runtime's own request sites
// (WbMeshAdapter.IncrementRefCount/EnsureLoaded pass isSetup: false for
// every MeshRef id, incl. cell-geometry ids) and BakeRunner's call site.
// (Review finding 10 — the two call sites previously disagreed.)
work.AddRange(envCellIds.Select(id => (PakAssetType.EnvCellMesh, id, id | 0x1_0000_0000UL, false)));
// ---- LIVE extraction (golden) ----
var golden = new Dictionary<(PakAssetType, uint), ObjectMeshData>();
var extractionFailures = new List<string>();
foreach (var (type, fileId, extractorId, isSetup) in work) {
var data = extractor.PrepareMeshData(extractorId, isSetup);
if (data is null) {
extractionFailures.Add($"{type} 0x{fileId:X8}: live extraction returned null");
continue;
}
golden[(type, fileId)] = data;
}
Assert.True(extractionFailures.Count == 0,
$"{extractionFailures.Count} fixture ids failed LIVE extraction (fixture assumption broke): " +
string.Join(" | ", extractionFailures));
// ---- bake the SAME ids to a temp pak ----
var pakPath = Path.Combine(Path.GetTempPath(), $"acdream-equivtest-{System.Guid.NewGuid():N}.pak");
try {
var header = new PakHeader {
FormatVersion = 1,
PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration,
CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration,
HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration,
LanguageIteration = (uint)dats.Local.Iteration!.CurrentIteration,
BakeToolVersion = 1,
};
using (var writer = new PakWriter(pakPath, header)) {
foreach (var ((type, fileId), data) in golden) {
writer.AddBlob(PakKey.Compose(type, fileId), data);
}
writer.Finish();
}
// ---- read back and deep-compare ----
using var reader = new PakReader(pakPath);
var mismatches = new List<string>();
foreach (var ((type, fileId), expected) in golden) {
var key = PakKey.Compose(type, fileId);
if (!reader.TryReadObjectMeshData(key, out var actual)) {
mismatches.Add($"{type} 0x{fileId:X8}: pak read failed (missing or CRC mismatch)");
continue;
}
try {
ObjectMeshDataEquality.AssertEqual(expected, actual);
}
catch (Xunit.Sdk.XunitException ex) {
mismatches.Add($"{type} 0x{fileId:X8}: {ex.Message}");
}
}
Assert.True(mismatches.Count == 0,
$"{mismatches.Count}/{golden.Count} fixture ids mismatched between live extraction and pak round-trip:\n" +
string.Join("\n", mismatches));
}
finally {
if (File.Exists(pakPath)) File.Delete(pakPath);
}
}
/// <summary>
/// Known-tricky ids (#119/#113 dump fixtures) plus enough additional
/// GfxObjs (deterministically, the first N ids in dat order after the
/// tricky set) to reach at least 10 total.
/// </summary>
private static List<uint> BuildGfxObjIdSet(DatCollection dats) {
var ids = new List<uint>(KnownTrickyGfxObjIds);
var seen = new HashSet<uint>(ids);
foreach (var id in dats.GetAllIdsOfType<GfxObj>()) {
if (ids.Count >= 10) break;
if (seen.Add(id)) ids.Add(id);
}
return ids;
}
/// <summary>Walks the Holtburg landblock's LandBlockInfo.NumCells range (mirrors StipplingSurfaceEquivalenceTests' enumeration) to get at least <paramref name="minCount"/> real EnvCell ids.</summary>
private static List<uint> BuildEnvCellIdSet(DatCollection dats, uint landblockId, int minCount) {
var ids = new List<uint>();
var lbInfo = dats.Get<LandBlockInfo>(landblockId | 0xFFFEu);
Assert.True(lbInfo is not null, $"LandBlockInfo for landblock 0x{landblockId:X8} not found — fixture assumption broke");
Assert.True(lbInfo!.NumCells >= minCount,
$"landblock 0x{landblockId:X8} has only {lbInfo.NumCells} cells — fixture assumption broke (need >= {minCount})");
uint firstCellId = landblockId | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells && ids.Count < minCount; offset++) {
uint envCellId = firstCellId + offset;
if (dats.Get<EnvCell>(envCellId) is not null) ids.Add(envCellId);
}
return ids;
}
}