From 95d9dab4bb245fb023fe3f2ba862bf6c5250daad Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 18:52:00 +0200 Subject: [PATCH] =?UTF-8?q?test(#95):=20headless=20dungeon-flood=20diagnos?= =?UTF-8?q?tic=20=E2=80=94=20measure=20visible-cell=20count=20on=200x0007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Issue95DungeonFloodDiagnosticTests.cs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs diff --git a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs new file mode 100644 index 00000000..5e5f1228 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; +using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes +/// WB-DIAG to ~9.1M instances/frame. Suspected cause: +/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding +/// (decomp:311878). A dungeon cell has seen_outside==0; retail's PVS for it is just the +/// cell's stab_list () — typically a small bounded +/// set. If our flood instead visits ~all cells of the landblock, that is the blowup. +/// +/// This is a DIAGNOSTIC, not a fix: it loads the real 0x0007 interior cells, runs the real +/// production flood from representative dungeon-cell roots, and PRINTS the ground-truth numbers — +/// flood visited-cell-set size () vs the +/// root's stab_list size (), plus how many visited cells +/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output. +/// +public class Issue95DungeonFloodDiagnosticTests +{ + private const uint TownNetwork = 0x00070000u; + + private readonly ITestOutputHelper _out; + public Issue95DungeonFloodDiagnosticTests(ITestOutputHelper output) => _out = output; + + // Production-ish projection (mirrors the sibling harnesses): FovY ~1.2, 1280x720, + // near 0.1, far 5000. The flood's clip is near-independent, so exactness is not + // load-bearing for cell-count measurement. + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) + { + var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); + return view * proj; + } + + [Fact] + public void Measure_DungeonFlood_VisibleCellCount() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dat dir did not resolve (ACDREAM_DAT_DIR unset and " + + "%USERPROFILE%\\Documents\\Asheron's Call absent). No numbers measured."); + // Diagnostic test: do not hard-fail when dats are absent (matches sibling harnesses). + return; + } + _out.WriteLine($"dat dir resolved: {datDir}"); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // 1) LandBlockInfo header — NumCells for 0x0007. + var lbi = dats.Get(TownNetwork | 0xFFFEu); + if (lbi is null) + { + _out.WriteLine($"SKIP: LandBlockInfo 0x{TownNetwork | 0xFFFEu:X8} not found in the dat " + + "(0x0007 may not exist in this client_cell_1.dat)."); + return; + } + _out.WriteLine($"=== 0x0007 (Town Network) LandBlockInfo ==="); + _out.WriteLine($"NumCells (DatLandBlockInfo.NumCells) = {lbi.NumCells}"); + + // 2) Load ALL interior cells (sparse ids tolerated — see LoadAllInteriorCells). + var loaded = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, TownNetwork); + _out.WriteLine($"cells actually loaded = {loaded.Count}"); + Assert.True(loaded.Count > 0, "no interior cells loaded for 0x0007 — cannot measure"); + + Func lookup = id => loaded.TryGetValue(id, out var c) ? c : null; + + // 3) Per-cell stab_list (VisibleCells) distribution across ALL loaded cells. + // This is the bounded retail PVS size we expect the flood to roughly match. + var stabSizes = loaded.Values.Select(c => c.VisibleCells.Count).ToList(); + int seenOutsideCount = loaded.Values.Count(c => c.SeenOutside); + int interiorCount = loaded.Count - seenOutsideCount; + _out.WriteLine(""); + _out.WriteLine("=== stab_list (LoadedCell.VisibleCells) distribution over ALL loaded cells ==="); + _out.WriteLine($"cells with SeenOutside==true (entrance/exterior-facing) = {seenOutsideCount}"); + _out.WriteLine($"cells with SeenOutside==false (interior dungeon) = {interiorCount}"); + if (stabSizes.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"VisibleCells.Count min={stabSizes.Min()} max={stabSizes.Max()} avg={stabSizes.Average():F1} sum={stabSizes.Sum()}")); + int emptyStab = stabSizes.Count(s => s == 0); + _out.WriteLine($"cells with EMPTY stab_list (no dat PVS) = {emptyStab}"); + + // 4) Pick representative DUNGEON roots: the first interior (SeenOutside==false) cells in + // ascending id order. If none exist, fall back to 0x00070100 and report that. + var interiorRoots = loaded + .Where(kv => !kv.Value.SeenOutside) + .OrderBy(kv => kv.Key) + .Select(kv => kv.Value) + .Take(5) + .ToList(); + + if (interiorRoots.Count == 0) + { + _out.WriteLine(""); + _out.WriteLine("NOTE: NO cell has SeenOutside==false (all cells see the exterior). " + + "Falling back to root 0x00070100 for the flood measurement."); + if (loaded.TryGetValue(TownNetwork | 0x0100u, out var fallback)) + interiorRoots.Add(fallback); + else + { + _out.WriteLine("WARN: 0x00070100 not loaded either; using the lowest-id loaded cell."); + interiorRoots.Add(loaded.OrderBy(kv => kv.Key).First().Value); + } + } + + _out.WriteLine(""); + _out.WriteLine("=== PER-ROOT FLOOD MEASUREMENT (PortalVisibilityBuilder.Build) ==="); + _out.WriteLine("property read for the visited-cell set: PortalVisibilityFrame.OrderedVisibleCells"); + _out.WriteLine("root | seenOut | stab(VisibleCells) | flood(OrderedVisibleCells) | crossLB | dir"); + + var floodSizes = new List(); + foreach (var root in interiorRoots) + { + // Eye at the root cell's world origin, looking toward its first portal (or +X if none), + // so the flood actually fires through an opening. Sweep all 6 axis directions and KEEP + // the maximum visited-set — the blowup is a worst-case-over-orientation quantity. + var eye = root.WorldPosition; + int bestFlood = -1; + string bestDir = "?"; + int bestCrossLb = -1; + List? bestVisited = null; + + // Direction candidates: toward each portal's polygon centroid (the natural look-through), + // plus the 6 cardinal axes as a fallback sweep. + var lookTargets = new List<(Vector3 target, string label)>(); + for (int pi = 0; pi < root.Portals.Count && pi < root.PortalPolygons.Count; pi++) + { + var poly = root.PortalPolygons[pi]; + if (poly is { Length: >= 1 }) + { + var cl = Vector3.Zero; + foreach (var v in poly) cl += v; + cl /= poly.Length; + lookTargets.Add((Vector3.Transform(cl, root.WorldTransform), + $"portal{pi}->0x{root.Portals[pi].OtherCellId:X4}")); + } + } + foreach (var (d, lbl) in new (Vector3, string)[] + { + (Vector3.UnitX, "+X"), (-Vector3.UnitX, "-X"), + (Vector3.UnitY, "+Y"), (-Vector3.UnitY, "-Y"), + (Vector3.UnitZ, "+Z"), (-Vector3.UnitZ, "-Z"), + }) + lookTargets.Add((eye + d * 5f, lbl)); + + foreach (var (target, label) in lookTargets) + { + if (Vector3.DistanceSquared(target, eye) < 1e-6f) continue; + var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, target)); + int floodN = frame.OrderedVisibleCells.Count; + if (floodN > bestFlood) + { + bestFlood = floodN; + bestDir = label; + bestVisited = frame.OrderedVisibleCells; + bestCrossLb = frame.OrderedVisibleCells.Count(id => (id & 0xFFFF0000u) != TownNetwork); + } + } + + floodSizes.Add(bestFlood); + _out.WriteLine(FormattableString.Invariant( + $"0x{root.CellId:X8} | {(root.SeenOutside ? "Y" : "N"),5} | {root.VisibleCells.Count,18} | {bestFlood,26} | {bestCrossLb,7} | {bestDir}")); + + // For the FIRST root, also print the actual visited set + stab set for eyeballing. + if (ReferenceEquals(root, interiorRoots[0]) && bestVisited is not null) + { + _out.WriteLine(" first-root visited (OrderedVisibleCells, low ids): " + + string.Join(" ", bestVisited.Select(id => $"{id & 0xFFFFu:X4}"))); + _out.WriteLine(" first-root stab_list (VisibleCells, low ids): " + + string.Join(" ", root.VisibleCells.Select(id => $"{id & 0xFFFFu:X4}"))); + } + } + + // 5) Aggregate flood-size stats across the sampled roots — the headline numbers. + _out.WriteLine(""); + _out.WriteLine("=== AGGREGATE over sampled roots ==="); + if (floodSizes.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"flood visited-set size (OrderedVisibleCells): min={floodSizes.Min()} max={floodSizes.Max()} avg={floodSizes.Average():F1} (NumCells={lbi.NumCells}, loaded={loaded.Count})")); + var sampledStab = interiorRoots.Select(r => r.VisibleCells.Count).ToList(); + if (sampledStab.Count > 0) + _out.WriteLine(FormattableString.Invariant( + $"sampled roots' stab_list size (VisibleCells): min={sampledStab.Min()} max={sampledStab.Max()} avg={sampledStab.Average():F1}")); + _out.WriteLine(""); + _out.WriteLine("INTERPRETATION: if flood max ~= loaded.Count (visits ~all cells) while stab " + + "is small, that is the #95 blowup — the flood is unbounded by the retail stab_list PVS."); + } +}