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)"); } /// /// THE captured top-of-stairs vanish (flap-diff-capture.log, live user /// pan): root=0xAAB3010A (the roof-lip cell), eye 3 mm above its floor /// portal plane, gazing DOWN at the staircase — and the flood admitted /// ONLY {010A}: 0x0107 (the whole tower interior, staircase included) /// dropped while being looked straight at. Exact production inputs: /// eye=(301.448,-128.448,126.803) fwd=(-0.9361,-0.2459,-0.2514) /// eye=(301.371,-128.401,126.807) fwd=(-0.8938,-0.3596,-0.2678) /// The 010A→0107 portal is HORIZONTAL at z≈126.8 — the eye rides within /// millimetres of its plane at the stair top, knife-edge projection /// territory (the #120 family's geometry, admission-side). /// [Theory] [InlineData(301.448f, -128.448f, 126.803f, -0.9361f, -0.2459f, -0.2514f)] [InlineData(301.371f, -128.401f, 126.807f, -0.8938f, -0.3596f, -0.2678f)] public void CapturedTopOfStairs_MainCellStaysInFlood( float ex, float ey, float ez, float fx, float fy, float fz) { var datDir = CornerFloodReplayTests.ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } 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; // The capture is A9B4-anchored world; the loaded cells are AAB3-local // (LoadCell without offset). AAB3 = A9B4 + (1,-1) blocks → local = // world - (192,-192,0). var eye = new Vector3(ex - 192f, ey + 192f, ez); var fwd = Vector3.Normalize(new Vector3(fx, fy, fz)); // ROOT CAUSE (found via this capture): production fed BuildLoadedCell // the +0.02 m RENDER-LIFTED transform ("keep the small render lift out // of physics", GameWindow:5567) — the visibility graph's HORIZONTAL // portal planes all sat 2 cm high, so an eye standing on the deck // (3 mm above the TRUE plane) was 17 mm BELOW the lifted plane — // outside the side test's ±10 mm in-plane window → 010A→0107 // side-culled → the staircase vanished while walkable. FIXED: the // visibility LoadedCell now gets the PHYSICS (unlifted) transform. // // Arm 1 (unlifted = post-fix production): the main cell must be // admitted. Arm 2 (lifted = the old bug, kept as the mechanism // canary): the main cell is dropped — if this arm ever starts // PASSING, the in-plane window has changed enough to absorb a 2 cm // lift; re-evaluate both this canary and the no-lift rule. foreach (bool lift in new[] { false, true }) { var armCells = lift ? new Dictionary() : cells; if (lift) { var datDir2 = CornerFloodReplayTests.ResolveDatDir()!; using var dats2 = new DatCollection(datDir2, DatAccessType.Read); var lbi = dats2.Get(Landblock | 0xFFFEu)!; for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++) { try { armCells[Landblock | low] = CornerFloodReplayTests.LoadCell(dats2, Landblock | low, new Vector3(0f, 0f, 0.02f)); } catch (InvalidOperationException) { } } } Func armLookup = id => armCells.TryGetValue(id, out var c) ? c : null; var root = armCells[Landblock | 0x010Au]; foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f }) { var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(fov, 1280f / 720f, 0.1f, 5000f); var pv = PortalVisibilityBuilder.Build(root, eye, armLookup, view * proj); bool hasMain = pv.CellViews.ContainsKey(MainCell); _out.WriteLine(FormattableString.Invariant( $"lift={lift} fov={fov:F2}: flood={pv.OrderedVisibleCells.Count} hasMain={hasMain}")); if (!lift) Assert.True(hasMain, $"fov={fov:F2}: the tower main cell 0x{MainCell:X8} is NOT in the flood from the roof-lip root (unlifted = post-fix production)"); else Assert.False(hasMain, $"fov={fov:F2}: the LIFTED canary unexpectedly admits the main cell — the in-plane window now absorbs a 2 cm lift; re-evaluate the canary + the no-lift rule"); } } } /// /// Gate-by-gate diagnosis of the captured drop: for the 010A→0107 portal /// at the exact captured eye/gaze, print each admission gate's verdict — /// side test (CameraOnInteriorSide replica), homogeneous clip vs the /// full-screen view (ProjectToClip + ClipToRegion, the forward-hop /// pipeline), and the eye-in-opening rescue inputs (perp distance + /// footprint containment). /// [Fact] public void Diagnostic_TopOfStairs_GateByGate() { var datDir = CornerFloodReplayTests.ResolveDatDir(); if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } using var dats = new DatCollection(datDir, DatAccessType.Read); var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock); var root = cells[Landblock | 0x010Au]; // capture eye converted to the cells' AAB3-local frame (world - (192,-192,0)) var eye = new Vector3(301.448f - 192f, -128.448f + 192f, 126.803f); var fwd = Vector3.Normalize(new Vector3(-0.9361f, -0.2459f, -0.2514f)); var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1280f / 720f, 0.1f, 5000f); var viewProj = view * proj; for (int i = 0; i < root.Portals.Count; i++) { var portal = root.Portals[i]; var poly = i < root.PortalPolygons.Count ? root.PortalPolygons[i] : null; _out.WriteLine($"portal[{i}] -> 0x{portal.OtherCellId:X4} polyLen={poly?.Length ?? -1}"); if (poly is null || poly.Length < 3) continue; // gate 1: side test (CameraOnInteriorSide replica, ε=0.01 in-plane-allowed) string side = "no-plane(allow)"; if (i < root.ClipPlanes.Count && root.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f) { var plane = root.ClipPlanes[i]; var localCam = Vector3.Transform(eye, root.InverseWorldTransform); float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; bool pass = plane.InsideSide == 0 ? dot >= -0.01f : dot <= 0.01f; side = FormattableString.Invariant($"dot={dot:F4} insideSide={plane.InsideSide} -> {(pass ? "TRAVERSE" : "CULL")}"); } _out.WriteLine($" side: {side}"); // gate 2: homogeneous clip vs the full-screen view var clip = PortalProjection.ProjectToClip(poly, root.WorldTransform, viewProj); var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) }; var region = clip.Length >= 3 ? PortalProjection.ClipToRegion(clip, fullScreen) : Array.Empty(); _out.WriteLine($" clip: projVerts={clip.Length} regionVerts={region.Length}"); // gate 3: rescue inputs — perp distance to the portal plane + the // perpendicular foot inside the opening's footprint var centroid = Vector3.Zero; foreach (var v in poly) centroid += Vector3.Transform(v, root.WorldTransform); centroid /= poly.Length; var n0 = Vector3.Transform(poly[0], root.WorldTransform); var n1 = Vector3.Transform(poly[1], root.WorldTransform); var n2 = Vector3.Transform(poly[2], root.WorldTransform); var planeN = Vector3.Normalize(Vector3.Cross(n1 - n0, n2 - n0)); float perp = MathF.Abs(Vector3.Dot(planeN, eye - centroid)); _out.WriteLine(FormattableString.Invariant( $" rescue: perpDist={perp:F4} centroid=({centroid.X:F2},{centroid.Y:F2},{centroid.Z:F2}) planeN=({planeN.X:F2},{planeN.Y:F2},{planeN.Z:F2})")); } // knife-edge sweep: the probe rounds the eye to mm — sweep z within the // rounding envelope and count clip flips for the 010A→0107 portal. A // chaotic 6↔0 region inside ±2 mm proves the live drop is the same // frame at sub-mm precision. { var poly = root.PortalPolygons[0]; var fullScreen = new[] { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) }; int flips = 0; bool? prev = null; var results = new System.Text.StringBuilder(); for (int i = -20; i <= 20; i++) { var e2 = eye with { Z = eye.Z + i * 0.0002f }; var v2 = Matrix4x4.CreateLookAt(e2, e2 + fwd * 3f, Vector3.UnitZ); var vp2 = v2 * proj; var clip2 = PortalProjection.ProjectToClip(poly, root.WorldTransform, vp2); var region2 = clip2.Length >= 3 ? PortalProjection.ClipToRegion(clip2, fullScreen) : Array.Empty(); bool ok = region2.Length >= 3; if (prev.HasValue && ok != prev.Value) flips++; prev = ok; results.Append(ok ? '1' : '0'); } _out.WriteLine($" knife-edge sweep (z ±4 mm, 0.2 mm steps): {results} flips={flips}"); } } /// Full per-step table for the investigation record. [Fact] public void Diagnostic_TowerAscent_PerStepTable() { var steps = RunAscent(); if (steps is null) return; DumpSteps(steps); } }