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); } }