diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 303675f9..6b417a2f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -4077,23 +4077,50 @@ area:** tracked under #112; note the #120 ping-pong fired at exactly A9B3 0103↔010F, so re-check after the #120 fix (`dede7e4`). -**DECODED + LIKELY FIXED BY #120 (2026-06-11 evening):** the user's -logout position pinned the tower (cell 0xAAB30107, AAB3 building[1] -model 0x01001117). Dat truth (`Issue119TowerDumpTests`): the stairs are -ONE static — Setup 0x020003F2, a 43-part spiral staircase at the tower -center (placement frames perfect, all parts drawable); the "extraneous -barrel" = the four 0x020005D8 wall barrels on the landings — legit dat -statics orphaned by the missing staircase. Pipeline exonerated layer by -layer (extraction, hydration ParentCellId=envCellId, per-MeshRef -registration, dispatcher compose); clean WB_DIAG counters at the tower -spawn: meshMissing=0, entSeen==entDrawn. **A screenshot of the running -post-fix build shows the staircase RENDERING.** Mechanism (most -plausible): the tower's three threshold cells portal back to 0x0107 — -climbing the stairs walks the eye through those portal planes, exactly -the #120 ping-pong window; the corrupted/aborted floods broke the -statics' viewcone per frame. The original report predates the #120 fix; -re-gate #2 did not re-check the stairs. **Pending: user verdict on the -tower stairs + barrels in the current build** — if clean, close #119. +**DECODED (2026-06-11 evening):** the user's logout position pinned the +tower (cell 0xAAB30107, AAB3 building[1] model 0x01001117). Dat truth +(`Issue119TowerDumpTests`): the stairs are ONE static — Setup +0x020003F2, a 43-part spiral staircase at the tower center (placement +frames perfect, all parts drawable). Pipeline exonerated layer by layer +(extraction, hydration ParentCellId=envCellId, per-MeshRef registration, +dispatcher compose); clean WB_DIAG counters at the tower spawn: +meshMissing=0, entSeen==entDrawn. + +**⚠️ USER AXIOM (2026-06-11 late): the barrel is NOT in the tower in +retail.** The earlier "legit dat barrels on the landings" claim is +RETRACTED — what the user saw was itself a render artifact. Post-#120 +verdict: "Barrel is gone and more stairs exist" — both improved +together, consistent with the "barrel" being mis-drawn staircase +geometry under the corrupted floods. (What the four 0x020005D8 cell +statics actually render as remains UNVERIFIED — do not assume barrel.) + +**REMAINING (user, post-#120 build):** +1. Running UP the tower, the TOP stairs disappear visually but stay + walkable. +2. On top of the tower, the roof and edges FLAP into existence and + back. + +**PINNED 2026-06-11 late (`TowerAscentReplayTests`, gaze locked ON the +staircase — no gaze excuse):** in the roof-lip band (eye z ≈ 126.9–127.3, +between the main cell's ceiling at 126.8 and the roof aperture plane at +~127.2) the eye resolves OUTDOOR and the per-building exterior flood +admits NOTHING (flood=1 = the outdoor node alone): the eye sits above +every side aperture's useful view and ON/INSIDE the roof aperture's +plane, so `BuildFromExterior`'s seed side-test / in-plane reject refuses +every exit portal. The tower interior never floods → the staircase +(a 0x0107 static) culls while walkable; the roof-lip cell geometry flaps +as the live eye bobs across the band's edges. The pin is committed as a +skipped red test (`TowerAscent_StaircaseStaysConeVisible_EveryStep`, +skip reason carries the defect) — un-skip with the fix. + +**Fix needs the retail oracle (next):** which side diverges — +(a) the VIEWER-CELL resolution (retail's curr_cell may keep the eye +INTERIOR through this band: keep-curr above open-top cells / the cell +BSP classifying the parapet bowl as inside 0x010A, where our resolution +demotes to outdoor), or (b) the exterior seed admission (retail +ConstructView(CBldPortal) Sidedness on an in-plane eye). Grep +`ConstructView` CBldPortal + `SmartBox::update_viewer` above open-top +cells before touching either layer. --- diff --git a/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs b/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs index cdf7c00f..c1b86334 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue120ReciprocalPingPongTests.cs @@ -39,7 +39,7 @@ public class Issue120ReciprocalPingPongTests private readonly ITestOutputHelper _out; public Issue120ReciprocalPingPongTests(ITestOutputHelper output) => _out = output; - private static Dictionary LoadAllInteriorCells(DatCollection dats, uint landblock) + internal static Dictionary LoadAllInteriorCells(DatCollection dats, uint landblock) { var lbi = dats.Get(landblock | 0xFFFEu); Assert.NotNull(lbi); diff --git a/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs b/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs new file mode 100644 index 00000000..04cd3309 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #119 residual — the tower-ascent harness (the #118 exit-walk pattern, +/// vertical). User report (post-#120 build): running UP the AAB3 tower +/// (cell 0xAAB30107, building[1] model 0x01001117) the TOP stairs disappear +/// visually but stay walkable, and at the top the roof/edges FLAP in and +/// out. The tower's upper cells are thin landing/roof slabs with EXIT +/// portals (0x0108 z≈121–123, 0x0109 z≈112–114.5, 0x010A z≈126.8–127.2 — +/// the roof lip), so the ascent walks the eye across exit-portal planes — +/// the same decision-stack territory as #118/#120. +/// +/// Per step of a helix matching the spiral staircase, this drives the +/// production stack headlessly: viewer-cell resolution (smallest containing +/// AABB; outdoor when none) → PortalVisibilityBuilder.Build (+ the +/// per-building exterior floods on outdoor steps, mirroring +/// RetailPViewRenderer.MergeNearbyBuildingFloods) → ClipFrameAssembler → +/// ViewconeCuller → the DrawCellObjectLists predicate for the staircase +/// static (Setup 0x020003F2, ParentCellId 0x0107). The failing steps pin +/// the vanish; root/visibility instability across adjacent steps pins the +/// flap. +/// +public class TowerAscentReplayTests +{ + private readonly ITestOutputHelper _out; + public TowerAscentReplayTests(ITestOutputHelper output) => _out = output; + + private const uint Landblock = 0xAAB30000u; + private const uint MainCell = Landblock | 0x0107u; + + // Tower geometry (Issue119TowerDumpTests dat facts): origin (108,60,112); + // the staircase Setup spans local x,y ∈ [-4,4], z ∈ [0,15.5]. Production + // EntitySphere = AABB center + half-diagonal. + private static readonly Vector3 TowerCenter = new(108f, 60f, 112f); + private static readonly Vector3 StairSphereCenter = new(108f, 60f, 119.75f); + private const float StairSphereRadius = 9.45f; + + private sealed record AscentStep( + int Index, + Vector3 Eye, + uint RootCellId, // 0 = outdoor + bool FloodHasMainCell, + bool StairsConeVisible, + int OutsidePolys, + int FloodCells); + + 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; + } + + private static uint? ResolveInteriorCellByAabb( + Dictionary cells, Vector3 worldPoint, float margin = 0.05f) + { + uint? best = null; + float bestVolume = float.MaxValue; + foreach (var (id, cell) in cells) + { + var local = Vector3.Transform(worldPoint, cell.InverseWorldTransform); + var min = cell.LocalBoundsMin - new Vector3(margin); + var max = cell.LocalBoundsMax + new Vector3(margin); + if (local.X < min.X || local.Y < min.Y || local.Z < min.Z + || local.X > max.X || local.Y > max.Y || local.Z > max.Z) + continue; + var ext = cell.LocalBoundsMax - cell.LocalBoundsMin; + float volume = MathF.Max(ext.X, 1e-3f) * MathF.Max(ext.Y, 1e-3f) * MathF.Max(ext.Z, 1e-3f); + if (volume < bestVolume) { bestVolume = volume; best = id; } + } + return best; + } + + // Mirror of RetailPViewRenderer.MergeBuildingFrame (union semantics). + private static void MergeFrame(PortalVisibilityFrame target, PortalVisibilityFrame src) + { + foreach (uint cellId in src.OrderedVisibleCells) + { + if (!src.CellViews.TryGetValue(cellId, out var srcView)) continue; + if (target.CellViews.TryGetValue(cellId, out var existing)) + { + foreach (var p in srcView.Polygons) existing.Add(p); + continue; + } + target.CellViews[cellId] = srcView; + target.OrderedVisibleCells.Add(cellId); + } + } + + private List? RunAscent() + { + var datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return null; } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + Assert.True(cells.ContainsKey(MainCell), "tower main cell not loaded"); + + // The tower building's cell list (Issue119TowerDumpTests: building[1] + // stabs = 0x0107..0x010A) — the per-building flood candidates on + // outdoor-root steps, mirroring GameWindow's gather. + var towerCells = new List(); + for (uint low = 0x0107u; low <= 0x010Au; low++) + if (cells.TryGetValue(Landblock | low, out var c)) + towerCells.Add(c); + + // Eye helix matching the spiral: two turns, radius 2.5 m, z from head + // height at the base to above the roof lip (cell 0x010A tops at 127.2). + const int Steps = 220; + const float ZStart = 113.6f, ZEnd = 128.6f; + var clipFrame = ClipFrame.NoClip(); + var steps = new List(Steps + 1); + + for (int i = 0; i <= Steps; i++) + { + float t = i / (float)Steps; + float theta = t * 4f * MathF.PI; + float z = ZStart + t * (ZEnd - ZStart); + var eye = new Vector3( + TowerCenter.X + 2.5f * MathF.Cos(theta), + TowerCenter.Y + 2.5f * MathF.Sin(theta), + z); + // look AT the staircase (the user's framing while climbing: the + // camera tracks the player on the stairs). An unambiguous pin: + // if the staircase cone-culls while looked at directly, the + // visibility stack is wrong — no gaze excuse. + var gaze = StairSphereCenter - eye; + if (gaze.LengthSquared() < 1e-4f) gaze = new Vector3(0f, 0f, -1f); + var viewProj = ViewProjFor(eye, eye + Vector3.Normalize(gaze) * 3f); + + uint rootCellId = 0u; + PortalVisibilityFrame pv; + var contained = ResolveInteriorCellByAabb(cells, eye); + if (contained.HasValue) + { + rootCellId = contained.Value; + pv = PortalVisibilityBuilder.Build(cells[rootCellId], eye, lookup, viewProj); + } + else + { + // outdoor root: full-screen outside view + the per-building + // exterior floods (RetailPViewRenderer.MergeNearbyBuildingFloods) + var outdoor = OutdoorCellNode.Build(Landblock | 0x0001u); + pv = PortalVisibilityBuilder.Build(outdoor, eye, lookup, viewProj); + var bf = PortalVisibilityBuilder.ConstructViewBuilding( + towerCells, eye, lookup, viewProj); + MergeFrame(pv, bf); + } + + var asm = ClipFrameAssembler.Assemble(clipFrame, pv); + var cone = ViewconeCuller.Build(asm, viewProj); + + bool floodHasMain = pv.CellViews.ContainsKey(MainCell); + // DrawCellObjectLists predicate for a 0x0107 static: sphere vs the + // cell's views (RetailPViewRenderer.cs DrawCellObjectLists / T3). + bool stairsVisible = cone.SphereVisibleInCell(MainCell, StairSphereCenter, StairSphereRadius); + + steps.Add(new AscentStep( + i, eye, rootCellId, floodHasMain, stairsVisible, + pv.OutsideView.Polygons.Count, pv.OrderedVisibleCells.Count)); + } + + return steps; + } + + private void DumpSteps(IEnumerable steps, Func? filter = null) + { + foreach (var st in steps) + { + if (filter is not null && !filter(st)) continue; + string root = st.RootCellId == 0 ? "OUTDOOR " : FormattableString.Invariant($"0x{st.RootCellId:X8}"); + string stairs = st.StairsConeVisible ? "VIS " : "CULL"; + _out.WriteLine(FormattableString.Invariant( + $"step={st.Index,3} eye=({st.Eye.X:F2},{st.Eye.Y:F2},{st.Eye.Z:F2}) root={root} floodMain={st.FloodHasMainCell} stairs={stairs} outPolys={st.OutsidePolys} flood={st.FloodCells}")); + } + } + + /// + /// The vanish pin: the staircase static must stay cone-visible on every + /// ascent step (the gaze looks STRAIGHT AT it — no gaze excuse). + /// + /// PINNED RED 2026-06-11 (skip carries the defect; un-skip with the fix): + /// steps 195–201 (eye z 126.9–127.3, the roof-lip band between the main + /// cell's ceiling at 126.8 and the roof aperture plane at ~127.2) resolve + /// OUTDOOR and the per-building exterior flood admits NOTHING (flood=1 = + /// the outdoor node alone) — the eye is above every side aperture's + /// useful view and ON/INSIDE the roof aperture's plane, so + /// BuildFromExterior's seed side-test/in-plane reject refuses every exit + /// portal. The tower interior never floods → the staircase (a 0x0107 + /// static) has no views → culled while walkable, and the roof-lip cell + /// geometry flaps as the live eye bobs across the band's edges. Fix + /// needs the retail oracle: does retail keep the viewer cell INTERIOR + /// through this band (curr_cell keep-curr above open-top cells), or can + /// ConstructView(CBldPortal) seed with an in-plane eye? + /// + [Fact(Skip = "#119-residual: pins the roof-lip flood gap (steps 195-201) — un-skip with the fix; see the doc comment")] + public void TowerAscent_StaircaseStaysConeVisible_EveryStep() + { + var steps = RunAscent(); + if (steps is null) return; + + var failures = steps.FindAll(s => !s.StairsConeVisible); + if (failures.Count > 0) + { + _out.WriteLine($"--- {failures.Count} stairs-CULLED steps ---"); + DumpSteps(failures); + } + Assert.True(failures.Count == 0, + $"{failures.Count}/{steps.Count} ascent steps cone-cull the staircase (first at step {(failures.Count > 0 ? failures[0].Index : -1)}) — see output"); + } + + /// + /// The flap pin: along a smooth monotone ascent, the resolved root may + /// transition between cells/outdoor but must not PING-PONG (A→B→A within + /// three consecutive steps = a knife-edge decision the live camera jitter + /// turns into per-frame flapping). + /// + [Fact] + public void TowerAscent_RootDoesNotPingPong() + { + var steps = RunAscent(); + if (steps is null) return; + + var flips = new List(); + for (int i = 2; i < steps.Count; i++) + { + uint a = steps[i - 2].RootCellId, b = steps[i - 1].RootCellId, c = steps[i].RootCellId; + if (a != b && b != c && a == c) + flips.Add(i - 1); + } + if (flips.Count > 0) + { + _out.WriteLine($"--- {flips.Count} root ping-pong steps ---"); + DumpSteps(steps, s => flips.Contains(s.Index) || flips.Contains(s.Index - 1) || flips.Contains(s.Index + 1)); + } + Assert.True(flips.Count == 0, + $"{flips.Count} root ping-pongs along a monotone ascent — knife-edge root decisions (see output)"); + } + + /// Full per-step table for the investigation record. + [Fact] + public void Diagnostic_TowerAscent_PerStepTable() + { + var steps = RunAscent(); + if (steps is null) return; + DumpSteps(steps); + } +}