Adversarially-verified review findings 1 and 10: (1) The bake pipeline no longer accumulates every decoded ObjectMeshData in one ConcurrentBag before writing (multi-GB OOM risk on the full bake), and no longer writes blobs in thread-completion order (which violated the plan's "bakes must be byte-reproducible run-to-run"). New shape in BakeRunner: build the FULL id list, sort by PakKey, chunk into 512-id batches; Parallel.ForEach WITHIN each batch; sort each batch's results by key and AddBlob sequentially; release the batch. Batches are contiguous key ranges, so the blob region lands in global key order regardless of thread scheduling, and peak memory is one batch's output. Side-staged particle-preload meshes drain per batch into a key-deduped map (first instance wins — per-id extraction output is deterministic, so instance choice cannot affect bytes) and are written after all batches, sorted by key, skipping keys already written. Program.cs is now a thin arg-parsing shell over the public BakeRunner so the new dat-gated byte-reproducibility test can drive the REAL pipeline: tests/AcDream.Bake.Tests (new project, rule 6; registered in slnx; no Silk.NET in its resolved dependency graph — verified) bakes the same 9-id mixed fixture twice with DIFFERENT thread counts (8 vs 3 — thread scheduling was the nondeterminism source) and asserts the two pak files are byte-identical. Ran for real against the dats on this machine: green. (10) The isSetup argument for EnvCell extraction now matches at both call sites (BakeRunner and PakEquivalenceTests both pass false) and is documented at each: the runtime's own request sites (WbMeshAdapter.IncrementRefCount / EnsureLoaded) pass isSetup: false for every MeshRef id including cell-geometry ids. The parameter is currently dead in MeshExtractor.PrepareMeshData (dispatch is on the resolved dat type), but two disagreeing call sites were a latent trap. Header FormatVersion is no longer set by the bake (PakWriter stamps it per review finding 2, previous commit).
159 lines
7.8 KiB
C#
159 lines
7.8 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);
|
|
using var datReaderWriter = new ContentTestDatCollectionAdapter(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;
|
|
}
|
|
}
|