acdream/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs
Erik f35cb8b164 #119-residual ROOT CAUSE: the +0.02 m render lift leaked into the portal-visibility graph - horizontal portals side-culled anyone standing on them
The live capture pinned it end to end. BuildInteriorEntitiesForStreaming
lifts the render-side cell transform +0.02 m Z (shell z-fighting vs
terrain - a DRAW concern) and passed that LIFTED transform to
BuildLoadedCell, so every plane in the visibility graph sat 2 cm high.
The portal side test's in-plane window is +-10 mm: an eye standing ON a
floor containing a HORIZONTAL portal (the tower's deck lip 010A->0107,
stair landings, cellar mouths) sits 0-10 mm above the TRUE plane = 10-20
mm BELOW the lifted plane -> outside the window -> the cell behind the
portal side-culled out of the flood. Captured live at the stair top:
root=0xAAB3010A eye z=126.803 vs the portal plane at 126.80, flood=1,
0x0107 (the whole tower interior incl. the staircase) dropped WHILE THE
GAZE LOOKED STRAIGHT AT IT - "stairs disappear and you can walk on
them", and the roof/edge flap as the gaze swung the marginal admissions.
Vertical doorways were immune (the lift slides their planes along
themselves) - exactly why this hit stairs/decks/floors and not doors.

Chase chain (the apparatus did all the work): [viewer] print-on-change
probe with eye@mm -> the user's climb capture -> [viewer-diff] naming
the dropped cells per flip -> headless replay of the exact captured
(eye,fwd) frame: healthy UNLIFTED, reproduces ONLY with the production
lift -> gate-by-gate diagnostic (side test dot=+0.003 unlifted vs
-0.017 lifted; clip + rescue exonerated; knife-edge z-sweep all-stable,
killing the float-chaos theory).

Fix: BuildLoadedCell receives the PHYSICS (unlifted) transform; the
drawn shells keep their lift. The seal/punch fans (which read the
visibility LoadedCell's WorldTransform) now stamp TRUE depth - MORE
consistent with the unlifted terrain they protect.

Pins: CapturedTopOfStairs_MainCellStaysInFlood - arm 1 (unlifted =
post-fix production) asserts the main cell admitted at the captured
frame; arm 2 (lifted) is the mechanism canary asserting the drop, with
instructions if it ever starts passing. Plus the gate-by-gate
diagnostic + knife-edge sweep as the investigation record.

Also this session: Issue127FloodFlipReplayTests (the captured 4 cm
outdoor flip pair replays STABLE across fovs/pre-gate arms - the
outdoor churn is NOT the flood math; remaining #127 = distant-building
admission churn, lower priority now that the tower-cell drops are
explained by the lift), and the [viewer-diff] probe (per-flip added/
removed cell naming - keep, it found this).

Suites: App 242+1skip, Core 1422+2skip, UI 420, Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:26:06 +02:00

428 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// #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≈121123, 0x0109 z≈112114.5, 0x010A z≈126.8127.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.
/// </summary>
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<uint, LoadedCell> 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<AscentStep>? 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<uint, LoadedCell?> 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<LoadedCell>();
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<AscentStep>(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<AscentStep> steps, Func<AscentStep, bool>? 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}"));
}
}
/// <summary>
/// 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 195201 (eye z 126.9127.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?
/// </summary>
[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");
}
/// <summary>
/// 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).
/// </summary>
[Fact]
public void TowerAscent_RootDoesNotPingPong()
{
var steps = RunAscent();
if (steps is null) return;
var flips = new List<int>();
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)");
}
/// <summary>
/// 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).
/// </summary>
[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<uint, LoadedCell?> 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<uint, LoadedCell>() : cells;
if (lift)
{
var datDir2 = CornerFloodReplayTests.ResolveDatDir()!;
using var dats2 = new DatCollection(datDir2, DatAccessType.Read);
var lbi = dats2.Get<DatReaderWriter.DBObjs.LandBlockInfo>(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<uint, LoadedCell?> 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");
}
}
}
/// <summary>
/// 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).
/// </summary>
[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<Vector2>();
_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<Vector2>();
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}");
}
}
/// <summary>Full per-step table for the investigation record.</summary>
[Fact]
public void Diagnostic_TowerAscent_PerStepTable()
{
var steps = RunAscent();
if (steps is null) return;
DumpSteps(steps);
}
}