From f35cb8b164148cf996a218a7feb8362bd8600486 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 19:26:06 +0200 Subject: [PATCH] #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 --- src/AcDream.App/Rendering/GameWindow.cs | 52 ++++- .../Rendering/CornerFloodReplayTests.cs | 9 +- .../Rendering/Issue127FloodFlipReplayTests.cs | 221 ++++++++++++++++++ .../Rendering/TowerAscentReplayTests.cs | 170 ++++++++++++++ 4 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 34434354..867b9c1c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5585,8 +5585,21 @@ public sealed class GameWindow : IDisposable cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); - // Step 4: build LoadedCell for portal visibility (UNCHANGED from pre-A8). - BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); + // Step 4: build LoadedCell for portal visibility — with the + // PHYSICS (unlifted) transform. The +0.02 m render lift above + // is a DRAW concern (shell z-fighting vs terrain); feeding it + // into the visibility graph shifted every HORIZONTAL portal + // plane 2 cm up, putting an eye standing on a deck/landing + // 10–20 mm BELOW the lifted plane — outside the side test's + // ±10 mm in-plane window — so the cell behind the portal was + // side-culled: the tower-top staircase vanish + roof flap + // (#119-residual; captured live at eye z=126.803 vs the + // 010A→0107 plane at 126.80, reproduced ONLY with the lift in + // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical + // doorways were immune (the lift slides their planes along + // themselves), which is why this hit exactly stairs, decks, + // and cellar mouths. + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); // Cache CellStruct physics BSP for indoor collision (UNCHANGED). _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); @@ -9672,6 +9685,10 @@ public sealed class GameWindow : IDisposable // #119-residual [viewer] probe state: print-on-change signature so a // stable climb is silent and every flood/root flip emits exactly one line. private string? _lastViewerProbeSig; + // #127: previous frame's admitted cell set — the [viewer-diff] line names + // exactly which cells entered/left the flood when the signature changes. + private readonly HashSet _lastViewerFloodCells = new(); + private readonly List _viewerDiffScratch = new(); private void EmitRetailPViewDiagnostics( AcDream.App.Rendering.RetailPViewFrameResult result, @@ -9698,6 +9715,37 @@ public sealed class GameWindow : IDisposable var v = _cameraController?.Active.View ?? System.Numerics.Matrix4x4.Identity; Console.WriteLine(System.FormattableString.Invariant( $"[viewer] {sig} eye=({camPos.X:F3},{camPos.Y:F3},{camPos.Z:F3}) fwd=({-v.M13:F4},{-v.M23:F4},{-v.M33:F4}) viewerCell=0x{viewerCellId:X8}")); + + // #127 [viewer-diff]: name the cells that entered/left since the + // last emitted signature — the bistable admission self-attributes. + _viewerDiffScratch.Clear(); + var cur = result.PortalFrame.OrderedVisibleCells; + var sb = new System.Text.StringBuilder(96); + sb.Append("[viewer-diff] added=["); + bool first = true; + foreach (uint c in cur) + { + if (_lastViewerFloodCells.Contains(c)) continue; + if (!first) sb.Append(','); + sb.Append("0x").Append(c.ToString("X8")); + first = false; + } + sb.Append("] removed=["); + first = true; + foreach (uint c in _lastViewerFloodCells) + { + bool present = false; + for (int ci = 0; ci < cur.Count; ci++) + if (cur[ci] == c) { present = true; break; } + if (present) continue; + if (!first) sb.Append(','); + sb.Append("0x").Append(c.ToString("X8")); + first = false; + } + sb.Append(']'); + Console.WriteLine(sb.ToString()); + _lastViewerFloodCells.Clear(); + foreach (uint c in cur) _lastViewerFloodCells.Add(c); } } if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs index 2212490a..6893db41 100644 --- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs @@ -56,6 +56,11 @@ public class CornerFloodReplayTests /// cell-local space, local AABB, world transform from EnvCell.Position. /// internal static LoadedCell LoadCell(DatCollection dats, uint cellId) + => LoadCell(dats, cellId, Vector3.Zero); + + /// worldOffset: block offset for multi-landblock fixtures (dat cell + /// positions are landblock-local; a neighbour block needs ±192 per axis). + internal static LoadedCell LoadCell(DatCollection dats, uint cellId, Vector3 worldOffset) { var envCell = dats.Get(cellId) ?? throw new InvalidOperationException($"EnvCell 0x{cellId:X8} not found"); @@ -66,7 +71,7 @@ public class CornerFloodReplayTests var cellTransform = Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - Matrix4x4.CreateTranslation(envCell.Position.Origin); + Matrix4x4.CreateTranslation(envCell.Position.Origin + worldOffset); Matrix4x4.Invert(cellTransform, out var inverse); var boundsMin = new Vector3(float.MaxValue); @@ -139,7 +144,7 @@ public class CornerFloodReplayTests return new LoadedCell { CellId = cellId, - WorldPosition = envCell.Position.Origin, + WorldPosition = envCell.Position.Origin + worldOffset, WorldTransform = cellTransform, InverseWorldTransform = inverse, LocalBoundsMin = boundsMin, diff --git a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs new file mode 100644 index 00000000..581b5a52 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs @@ -0,0 +1,221 @@ +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; + +/// +/// #127 — per-building flood admissions are BISTABLE per frame under the +/// outdoor root (the building-flap mechanism: tower roof/edges flap, #123 +/// buildings vanishing when running past). Captured live with the [viewer] +/// fwd= probe (flap-fwd-capture2.log): a ~4 cm eye wobble flips the merged +/// flood 24↔25 with a byte-identical gaze: +/// flood=25 eye=(129.051,-15.636,99.512) fwd=(-0.7565,0.5877,-0.2869) +/// flood=24 eye=(129.009,-15.610,99.503) fwd=(same) +/// flood=25 eye=(129.051,-15.642,99.514) fwd=(same) +/// (player parked at pCell=0xA9B40019, Holtburg town, A9B4-anchored world +/// frame — A9B4-local == world; A9B3 cells get a (0,-192,0) block offset.) +/// +/// This harness replays the captured (eye, fwd) pairs through the +/// production outdoor pipeline — Build(outdoorNode) + per-building +/// ConstructViewBuilding merged (RetailPViewRenderer.MergeNearbyBuildingFloods +/// shape) — and DIFFS the admitted cell sets: the flipping cell names the +/// bistable admission, and the bisect along the eye segment names the knife +/// edge. +/// +public class Issue127FloodFlipReplayTests +{ + private readonly ITestOutputHelper _out; + public Issue127FloodFlipReplayTests(ITestOutputHelper output) => _out = output; + + // The captured flip pair (A9B4-anchored world frame). + private static readonly Vector3 EyeA = new(129.051f, -15.636f, 99.512f); + private static readonly Vector3 EyeB = new(129.009f, -15.610f, 99.503f); + private static readonly Vector3 Fwd = Vector3.Normalize(new Vector3(-0.7565f, 0.5877f, -0.2869f)); + + private sealed record World( + Dictionary Cells, + List> BuildingGroups, + Func Lookup); + + private static void LoadLandblockBuildings( + DatCollection dats, uint landblock, Vector3 offset, + Dictionary cells, List> groups) + { + var lbi = dats.Get(landblock | 0xFFFEu); + if (lbi is null) return; + + // all interior cells (the lookup needs every portal-reachable cell) + for (uint low = 0x0100u; low < 0x0100u + lbi.NumCells; low++) + { + uint id = landblock | low; + try { cells[id] = CornerFloodReplayTests.LoadCell(dats, id, offset); } + catch (InvalidOperationException) { } + } + + // per-building groups from the bldPortal stab lists (the dat's own + // building→cells mapping; mirrors BuildingLoader's seed+walk result) + if (lbi.Buildings is null) return; + foreach (var bld in lbi.Buildings) + { + var set = new HashSet(); + if (bld.Portals is not null) + foreach (var bp in bld.Portals) + if (bp.StabList is not null) + foreach (ushort low in bp.StabList) + set.Add(landblock | low); + var group = new List(); + foreach (uint id in set) + if (cells.TryGetValue(id, out var c)) + group.Add(c); + if (group.Count > 0) + groups.Add(group); + } + } + + private World? LoadWorld() + { + 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 = new Dictionary(); + var groups = new List>(); + // A9B4 = the anchor (world == local); the live gather spans the + // streaming near window, so load a 5x5 block neighbourhood with + // block offsets relative to the anchor. + for (int dx = -2; dx <= 2; dx++) + for (int dy = -2; dy <= 2; dy++) + { + uint lbX = (uint)(0xA9 + dx); + uint lbY = (uint)(0xB4 + dy); + uint lb = (lbX << 24) | (lbY << 16); + LoadLandblockBuildings(dats, lb, new Vector3(dx * 192f, dy * 192f, 0f), cells, groups); + } + _out.WriteLine($"loaded {cells.Count} cells in {groups.Count} building groups"); + Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; + return new World(cells, groups, lookup); + } + + private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 fwd, float fovY) + { + var view = Matrix4x4.CreateLookAt(eye, eye + fwd * 3f, Vector3.UnitZ); + var proj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, 1280f / 720f, 0.1f, 5000f); + return view * proj; + } + + // PortalBounds per group: the union AABB of the group's exit-portal + // polygons in world space — exactly BuildingLoader's construction + // (BuildingLoader.cs:114-144). + private static (bool Has, Vector3 Min, Vector3 Max) PortalBoundsFor(List group) + { + bool has = false; + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var cell in group) + { + int n = Math.Min(cell.Portals.Count, cell.PortalPolygons.Count); + for (int i = 0; i < n; i++) + { + if (cell.Portals[i].OtherCellId != 0xFFFF) continue; + var poly = cell.PortalPolygons[i]; + if (poly is null) continue; + foreach (var v in poly) + { + var world = Vector3.Transform(v, cell.WorldTransform); + has = true; + min = Vector3.Min(min, world); + max = Vector3.Max(max, world); + } + } + } + return (has, min, max); + } + + // The production outdoor pipeline: full-screen outdoor root + per-building + // exterior floods merged as a union (RetailPViewRenderer.DrawInside + + // MergeNearbyBuildingFloods + MergeBuildingFrame), INCLUDING the gather's + // per-building frustum pre-gate on PortalBounds (GameWindow:7542-7545) — + // controlled by so the gate itself can be + // implicated or exonerated. + private static HashSet AdmittedCells(World w, Vector3 eye, Vector3 fwd, float fovY, bool withPreGate) + { + var viewProj = ViewProjFor(eye, fwd, fovY); + var frustum = FrustumPlanes.FromViewProjection(viewProj); + var outdoor = OutdoorCellNode.Build(0xA9B40019u); + var pv = PortalVisibilityBuilder.Build(outdoor, eye, w.Lookup, viewProj); + foreach (var group in w.BuildingGroups) + { + if (withPreGate) + { + var (has, min, max) = PortalBoundsFor(group); + if (has && !FrustumCuller.IsAabbVisible(frustum, min, max)) + continue; + } + var bf = PortalVisibilityBuilder.ConstructViewBuilding(group, eye, w.Lookup, viewProj); + foreach (uint cellId in bf.OrderedVisibleCells) + { + if (!bf.CellViews.TryGetValue(cellId, out var srcView)) continue; + if (pv.CellViews.TryGetValue(cellId, out var existing)) + { + foreach (var p in srcView.Polygons) existing.Add(p); + continue; + } + pv.CellViews[cellId] = srcView; + pv.OrderedVisibleCells.Add(cellId); + } + } + return new HashSet(pv.OrderedVisibleCells); + } + + /// + /// Reproduce + name: the captured 4 cm pair must admit the SAME cell set + /// (a stable world does not flap a building interior for a 4 cm eye + /// wobble at identical gaze). The diff names the bistable cell(s); the + /// bisect prints the knife-edge location and transition count. + /// + [Fact] + public void CapturedFlipPair_AdmissionIsStable() + { + var w = LoadWorld(); + if (w is null) return; + + foreach (bool preGate in new[] { false, true }) + foreach (float fov in new[] { MathF.PI / 3f, 1.2f, MathF.PI / 2f, 0.9f }) + { + var a = AdmittedCells(w, EyeA, Fwd, fov, preGate); + var b = AdmittedCells(w, EyeB, Fwd, fov, preGate); + var onlyA = a.Except(b).OrderBy(x => x).ToList(); + var onlyB = b.Except(a).OrderBy(x => x).ToList(); + _out.WriteLine(FormattableString.Invariant( + $"preGate={preGate} fov={fov:F2}: |A|={a.Count} |B|={b.Count} onlyA=[{string.Join(",", onlyA.Select(x => $"0x{x:X8}"))}] onlyB=[{string.Join(",", onlyB.Select(x => $"0x{x:X8}"))}]")); + + if (onlyA.Count == 0 && onlyB.Count == 0) continue; + + // bisect the segment: count admission transitions per flipping cell + foreach (uint cell in onlyA.Concat(onlyB)) + { + int transitions = 0; + bool? prev = null; + for (int i = 0; i <= 40; i++) + { + var eye = Vector3.Lerp(EyeA, EyeB, i / 40f); + bool admitted = AdmittedCells(w, eye, Fwd, fov, preGate).Contains(cell); + if (prev.HasValue && admitted != prev.Value) transitions++; + prev = admitted; + } + _out.WriteLine(FormattableString.Invariant( + $" cell 0x{cell:X8}: {transitions} admission transition(s) along the 4 cm segment")); + } + + Assert.Fail($"flood admission differs across the captured 4 cm pair (preGate={preGate}, fov={fov:F2}) — see output for the flipping cells"); + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs b/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs index 04cd3309..9c75b24c 100644 --- a/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/TowerAscentReplayTests.cs @@ -247,6 +247,176 @@ public class TowerAscentReplayTests $"{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()