From 55f16a02050ba72ed14823fa0db57ab69523e685 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 21:33:32 +0200 Subject: [PATCH] test(pipeline): MP1b - live-vs-pak equivalence suite (dat-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PakEquivalenceTests runs MeshExtractor LIVE and bakes the SAME fixture ids to a temp pak, reads it back via PakReader, and deep-compares every field via the Task 3 ObjectMeshDataEquality comparator. Since the bake tool and the live client drive the identical MeshExtractor code (MP1a), this proves the pak ROUND-TRIP preserves what extraction actually produces on real content — the serializer's job, not a re-verification of the extraction algorithm itself (the existing Conformance suite owns that). Fixture set (>= the plan's minimums): 10 GfxObjs (3 known-tricky ids reused from Issue119UpNullGfxObjDumpTests - 0x010002B4, 0x010008A8, 0x010014C3 - plus 7 more from dat order), 3 Setups reused from door/ tower conformance fixtures (0x020019FF, 0x020005D8, 0x020003F2), 5 EnvCells walked from the Holtburg landblock 0xA9B40000's LandBlockInfo.NumCells range (same idiom as StipplingSurfaceEquivalenceTests). Skips cleanly when dats are absent via ContentConformanceDats. ResolveDatDir(), a deliberate small duplicate of ConformanceDats.ResolveDatDir()'s exact pattern (env var then Documents/Asheron's Call fallback) since Content.Tests cannot reference the AcDream.Core.Tests project. ContentTestDatCollectionAdapter is a third small copy of the IDatReaderWriter dat-access glue (alongside AcDream.App's internal original and AcDream.Bake's copy) for the same layering reason (structure rule 6: tests live in the project matching the layer under test). Ran for real against the dats on this machine (not just CI-skip path): 1 test, all fixture ids extracted live with zero failures, baked, read back, and field-for-field identical. Full solution dotnet test: 4106 tests total (43 Content.Tests + 385 Core.Net.Tests + 425 UI.Abstractions.Tests + 722 App.Tests incl. 2 pre-existing skips + 2531 Core.Tests incl. 2 pre-existing skips) — 0 failures. --- .../AcDream.Content.Tests.csproj | 1 + .../ContentConformanceDats.cs | 38 +++++ .../ContentTestDatCollectionAdapter.cs | 145 ++++++++++++++++ .../PakEquivalenceTests.cs | 155 ++++++++++++++++++ 4 files changed, 339 insertions(+) create mode 100644 tests/AcDream.Content.Tests/ContentConformanceDats.cs create mode 100644 tests/AcDream.Content.Tests/ContentTestDatCollectionAdapter.cs create mode 100644 tests/AcDream.Content.Tests/PakEquivalenceTests.cs diff --git a/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj b/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj index fd037caf..dc64fa3c 100644 --- a/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj +++ b/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/AcDream.Content.Tests/ContentConformanceDats.cs b/tests/AcDream.Content.Tests/ContentConformanceDats.cs new file mode 100644 index 00000000..c8ce4e21 --- /dev/null +++ b/tests/AcDream.Content.Tests/ContentConformanceDats.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace AcDream.Content.Tests; + +/// +/// Dat-dir resolution for the Content.Tests dat-gated equivalence suite. +/// Mirrors the exact pattern used by +/// tests/AcDream.Core.Tests/Conformance/ConformanceDats.ResolveDatDir and +/// tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests +/// ("dats absent (CI) -- skip, matching suite convention"). Content.Tests +/// cannot reference AcDream.Core.Tests (a test project, not a library), so +/// this is a deliberate small duplication of the resolution logic — not a +/// new convention. +/// +public static class ContentConformanceDats { + public static string? ResolveDatDir() { + var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv; + + var def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } +} + +/// Minimal ILogger writing to Console — surfaces MeshExtractor failures during the equivalence sweep instead of silently swallowing them (NullLogger would hide the exact failure this suite exists to catch). +internal sealed class TestConsoleLogger : ILogger { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { + if (!IsEnabled(logLevel)) return; + Console.WriteLine($"[{logLevel}] MeshExtractor: {formatter(state, exception)}"); + if (exception is not null) Console.WriteLine(exception); + } +} diff --git a/tests/AcDream.Content.Tests/ContentTestDatCollectionAdapter.cs b/tests/AcDream.Content.Tests/ContentTestDatCollectionAdapter.cs new file mode 100644 index 00000000..2a04f58e --- /dev/null +++ b/tests/AcDream.Content.Tests/ContentTestDatCollectionAdapter.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; + +namespace AcDream.Content.Tests; + +/// +/// Adapts to for +/// MeshExtractor's constructor, scoped to the equivalence test suite. A +/// third copy alongside AcDream.App.Rendering.Wb.DatCollectionAdapter +/// (internal to AcDream.App) and AcDream.Bake.BakeDatCollectionAdapter +/// (internal to AcDream.Bake) — tests live in the project matching the +/// layer under test (structure rule 6), so Content.Tests cannot reference +/// AcDream.Bake just to borrow its copy. Plain dat-access glue, not an +/// AC-specific algorithm. +/// +internal sealed class ContentTestDatCollectionAdapter : IDatReaderWriter { + private readonly DatCollection _dats; + private readonly TestDatDatabaseWrapper _portal; + private readonly TestDatDatabaseWrapper _cell; + private readonly TestDatDatabaseWrapper _highRes; + private readonly TestDatDatabaseWrapper _language; + private readonly ReadOnlyDictionary _cellRegions; + + public ContentTestDatCollectionAdapter(DatCollection dats) { + ArgumentNullException.ThrowIfNull(dats); + _dats = dats; + _portal = new TestDatDatabaseWrapper(dats.Portal); + _cell = new TestDatDatabaseWrapper(dats.Cell); + _highRes = new TestDatDatabaseWrapper(dats.HighRes); + _language = new TestDatDatabaseWrapper(dats.Local); + + var regions = new Dictionary { [0u] = _cell }; + _cellRegions = new ReadOnlyDictionary(regions); + } + + public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty; + + public IDatDatabase Portal => _portal; + public ReadOnlyDictionary CellRegions => _cellRegions; + public IDatDatabase HighRes => _highRes; + public IDatDatabase Language => _language; + + public ReadOnlyDictionary RegionFileMap => + new ReadOnlyDictionary(new Dictionary()); + + public int PortalIteration => _portal.Iteration; + public int CellIteration => _cell.Iteration; + public int HighResIteration => _highRes.Iteration; + public int LanguageIteration => _language.Iteration; + + public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) { + return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead); + } + + public IEnumerable ResolveId(uint id) { + var results = new List(); + + void CheckDb(TestDatDatabaseWrapper wrapper) { + var rawDb = wrapper.RawDatabase; + if (rawDb.Tree.TryGetFile(id, out _)) { + var type = rawDb.TypeFromId(id); + if (type != DBObjType.Unknown) { + results.Add(new IDatReaderWriter.IdResolution(wrapper, type)); + } + } + } + + CheckDb(_highRes); + CheckDb(_portal); + CheckDb(_language); + CheckDb(_cell); + + return results; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException($"{nameof(ContentTestDatCollectionAdapter)} is read-only."); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException($"{nameof(ContentTestDatCollectionAdapter)} is read-only."); + + public void Dispose() { + // The underlying DatCollection is owned by the caller. + } +} + +internal sealed class TestDatDatabaseWrapper : IDatDatabase { + private readonly DatDatabase _db; + private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new(); + private readonly object _lock = new(); + + public TestDatDatabaseWrapper(DatDatabase db) { + ArgumentNullException.ThrowIfNull(db); + _db = db; + } + + internal DatDatabase RawDatabase => _db; + + public DatDatabase Db => _db; + public int Iteration => _db.Iteration?.CurrentIteration ?? 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => _db.GetAllIdsOfType(); + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { + if (_cache.TryGetValue((typeof(T), fileId), out var cached)) { + value = (T)cached; + return true; + } + + lock (_lock) { + if (_db.TryGet(fileId, out value)) { + _cache.TryAdd((typeof(T), fileId), value); + return true; + } + } + + return false; + } + + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) { + lock (_lock) { + return _db.TryGetFileBytes(fileId, out value); + } + } + + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) { + lock (_lock) { + return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead); + } + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException($"{nameof(TestDatDatabaseWrapper)} is read-only."); + + public void Dispose() { + // The underlying DatDatabase is owned by DatCollection. + } +} diff --git a/tests/AcDream.Content.Tests/PakEquivalenceTests.cs b/tests/AcDream.Content.Tests/PakEquivalenceTests.cs new file mode 100644 index 00000000..a9de7286 --- /dev/null +++ b/tests/AcDream.Content.Tests/PakEquivalenceTests.cs @@ -0,0 +1,155 @@ +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; + +/// +/// 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. +/// +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(); + 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))); + work.AddRange(envCellIds.Select(id => (PakAssetType.EnvCellMesh, id, id | 0x1_0000_0000UL, true))); + + // ---- LIVE extraction (golden) ---- + var golden = new Dictionary<(PakAssetType, uint), ObjectMeshData>(); + var extractionFailures = new List(); + 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(); + 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); + } + } + + /// + /// 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. + /// + private static List BuildGfxObjIdSet(DatCollection dats) { + var ids = new List(KnownTrickyGfxObjIds); + var seen = new HashSet(ids); + + foreach (var id in dats.GetAllIdsOfType()) { + if (ids.Count >= 10) break; + if (seen.Add(id)) ids.Add(id); + } + return ids; + } + + /// Walks the Holtburg landblock's LandBlockInfo.NumCells range (mirrors StipplingSurfaceEquivalenceTests' enumeration) to get at least real EnvCell ids. + private static List BuildEnvCellIdSet(DatCollection dats, uint landblockId, int minCount) { + var ids = new List(); + var lbInfo = dats.Get(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(envCellId) is not null) ids.Add(envCellId); + } + return ids; + } +}