using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; public class PortalVisibilityBuilderTests { private static Matrix4x4 ViewProj() { var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY); var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f); return view * proj; } private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[] { new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z), new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z), }; // Full-height (y in [-0.9, 0.9]) quad spanning [minX, maxX] in X at plane z. // Used by the multi-back-portal fixture where the X span is the only thing // distinguishing the LEFT and RIGHT apertures. private static Vector3[] QuadX(float minX, float maxX, float z) => new[] { new Vector3(minX, -0.9f, z), new Vector3(maxX, -0.9f, z), new Vector3(maxX, 0.9f, z), new Vector3(minX, 0.9f, z), }; private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell { CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = new List(portals), }; private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary all) => PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); [Fact] public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow() { var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.1f, 1.0f, -3f)); // narrow stairwell var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); ground.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // wide window var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; var frame = Build(cam, all); Assert.False(frame.OutsideView.IsEmpty); float outsideWidth = frame.OutsideView.MaxX - frame.OutsideView.MinX; float windowOnlyWidth = PortalFrameTestHelper.ProjectedWidth( new[] { new Vector3(-1, 0, -6), new Vector3(1, 0, -6) }, ViewProj()); Assert.True(outsideWidth < windowOnlyWidth * 0.5f, $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); } [Fact] public void Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet() { // Flap root-cause apparatus (2026-06-07): the live threshold flap shows OrderedVisibleCells // flipping 2<->6 at an eye+player identical to cm. Build is a pure static function with // all-fresh per-call state, so identical inputs MUST yield an identical visible set. If this // FAILS, the flap is a determinism bug INSIDE Build; if it PASSES (expected), the live flip is // sub-cm INPUT jitter and the fix must make membership robust, not Build deterministic. // Exercises the re-enqueue fixpoint via a diamond: 0x0004 is reached from BOTH 0x0002 and 0x0003. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0), new CellPortalInfo(0x0003, 1, 0, 0)); cam.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -3f)); // left -> 0002 cam.PortalPolygons.Add(QuadX(0.05f, 0.6f, -3f)); // right -> 0003 var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0)); left.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -6f)); var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0)); right.PortalPolygons.Add(QuadX(0.05f, 0.6f, -6f)); var back = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0)); back.PortalPolygons.Add(QuadX(-0.6f, 0.6f, -9f)); var all = new Dictionary { [0x0001] = cam, [0x0002] = left, [0x0003] = right, [0x0004] = back }; var a = Build(cam, all); var b = Build(cam, all); Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells); Assert.Equal( a.CellViews.Keys.OrderBy(k => k).ToArray(), b.CellViews.Keys.OrderBy(k => k).ToArray()); } [Fact] public void Build_EyeStandingInInteriorPortal_FloodsNeighbour() { // R1 void fix (2026-06-05): when the chase camera roots in a thin doorway cell and the eye is // STANDING IN an interior portal opening, the live capture showed the vestibule->room portal at // D=0.16 m projecting to 0 verts (proj=0), so the neighbour was wrongly culled (cells=1) and // only the thin cell drew -> bluish void. Retail's 3D portal clip imposes no constraint for a // portal the eye is inside, so the neighbour is fully visible. The flood MUST reach the neighbour. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.3f, -0.03f)); // opening 3 cm in front — eye standing in it var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); var all = new Dictionary { [0x0001] = cam, [0x0002] = room }; var frame = Build(cam, all); Assert.True(frame.CellViews.ContainsKey(0x0002), "eye standing in the doorway must flood the neighbour (degenerate projection was culling it -> void)"); Assert.Contains(0x0002u, frame.OrderedVisibleCells); } [Fact] public void Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion() { // Guard against the fix over-flooding: a portal whose opening the eye is NOT standing in (3 cm // in front but 2 m to the SIDE) also projects degenerate, but the eye is OUTSIDE the opening, so // it must stay culled — otherwise the eye-in-doorway fix would blow up the visible set (#95) by // flooding every degenerate-projecting portal regardless of where the eye actually is. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(2.0f, 0f, 0.3f, 0.3f, -0.03f)); // 2 m to the side — eye NOT in it var room = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); room.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); var all = new Dictionary { [0x0001] = cam, [0x0002] = room }; var frame = Build(cam, all); Assert.False(frame.CellViews.ContainsKey(0x0002), "a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)"); } [Fact] public void Build_PortalFullyBehindEye_NotFlooded_RetailEmptyClipRule() { // W=0 port (2026-06-11): a portal ENTIRELY behind the eye clips to empty in retail // polyClipFinish (every vertex w < 0 -> <3 survivors -> reject), so the flood does not // reach its neighbour — the cell is off-screen and drawing nothing through it is correct. // // HISTORY: this test used to assert the OPPOSITE (rescue-era pin from a 2026-06-06 cellar // capture, "0174->0175 must flood at 1.4 m behind the camera"). That pinned the // EyeInsidePortalOpening rescue — the documented compensation for ProjectToClip's old // EyePlaneW=1e-4 divergence — not retail. The rescue is deleted with the polyClipFinish // W=0 port (docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md); the live // cellar behaviors are re-verified by the dat-backed replay harnesses + the visual gate. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // entirely behind the eye var stairs = Cell(0x0002); var all = new Dictionary { [0x0001] = cam, [0x0002] = stairs }; var vp = ViewProj(); Assert.True(PortalProjection.ProjectToClip(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3, "a fully-behind portal must clip to empty (polyClipFinish part 1)"); var frame = PortalVisibilityBuilder.Build( cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp); Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells); } [Fact] public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit() { // Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then // a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its // exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves // enough to discover B in the other order. const uint A = 0x0001, B = 0x0002, D = 0x0003; var a = Cell(A, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0)); a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D var b = Cell(B, new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0), new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0), new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0)); b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2)); d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B var all = new Dictionary { [A] = a, [B] = b, [D] = d }; var frame = Build(a, all); Assert.Contains(B, frame.OrderedVisibleCells); Assert.Contains(D, frame.OrderedVisibleCells); Assert.False(frame.OutsideView.IsEmpty); } [Fact] public void Build_ViewGrowthAfterDoneCell_RePopsGrownCell() { // Same A->B(near LEFT) + A->D(far RIGHT) + D->B(later) topology as // Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit: B is popped via LEFT, then D grows B // through RIGHT after B is done -> B re-pops. This is retail-faithful late growth (kept by the fix). const uint A = 0x0001, B = 0x0002, D = 0x0003; var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0), new CellPortalInfo((ushort)D, 1, 0, 0)); a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0), new CellPortalInfo(0xFFFF, 1, 0, 0), new CellPortalInfo((ushort)D, 2, 0, 0)); b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); var d = Cell(D, new CellPortalInfo((ushort)B, 0, 0, 2)); d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); var all = new Dictionary { [A] = a, [B] = b, [D] = d }; var frame = Build(a, all); // Membership (the flap-relevant output) is each cell once, regardless of re-pops. Assert.Equal(new[] { B }, frame.OrderedVisibleCells.Where(c => c == B).ToArray()); Assert.Contains(D, frame.OrderedVisibleCells); } [Fact] public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty() { var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0, 0, 0.1f, 1f, -3f)); var inner = Cell(0x0002); // no portals at all var all = new Dictionary { [0x0001] = cam, [0x0002] = inner }; Assert.True(Build(cam, all).OutsideView.IsEmpty); } [Fact] public void Builder_CameraCellWithDirectExit_OutsideViewIsFullWindow() { var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f)); var all = new Dictionary { [0x0001] = cam }; var frame = Build(cam, all); Assert.False(frame.OutsideView.IsEmpty); Assert.True(frame.OutsideView.MaxX - frame.OutsideView.MinX > 0.3f); } [Fact] public void Builder_BackFacingPortal_NotTraversed() { // Portal to 0x0002, but its clip plane puts the camera (origin) on the OUTSIDE. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0, 0, 0.5f, 0.5f, -3f)); cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = -1f, InsideSide = 0 }); // dot = (0,0,1)·origin + (-1) = -1 < 0; InsideSide==0 requires dot >= -eps → camera OUTSIDE → skip. var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); ground.PortalPolygons.Add(Quad(0, 0, 1f, 1f, -6f)); var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; var frame = Build(cam, all); Assert.False(frame.CellViews.ContainsKey(0x0002)); // neighbour never reached Assert.True(frame.OutsideView.IsEmpty); // its window never marked } [Fact] public void Build_BackFacingPortal_EyeStandingInOpening_StillCulled() { // R-A2b (Option B1, the flap fix): retail PView::InitCell (:432962) culls a back-facing portal by // the side test REGARDLESS of eye proximity — there is NO eye-in-opening bypass. The live pin // (flap-sidechk.log, 2026-06-09) showed the back portal 0173->0171 with camInterior=False but // eyeIn=True (eye within 1.75 m of the shared doorway); the OLD `&& !eyeInsideOpening` side-cull // bypass let it through -> the 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap. // A back-facing portal the eye is STANDING IN must stay culled (the forward-portal clip-empty void // rescue, tested by Build_EyeStandingInInteriorPortal_FloodsNeighbour, is a separate path and stays). var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -1f)); // 1 m in front -> eyeInsideOpening = True // ClipPlane puts the eye on the EXIT side (camInterior = False), like the back portal of a doorway // just crossed: Normal·origin + D = 1 > 0, InsideSide==1 wants dot<=eps -> not interior. cam.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 1f, InsideSide = 1 }); var ground = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); ground.PortalPolygons.Add(Quad(0f, 0f, 1f, 1f, -6f)); var all = new Dictionary { [0x0001] = cam, [0x0002] = ground }; var frame = Build(cam, all); Assert.False(frame.CellViews.ContainsKey(0x0002), "a back-facing portal (camInterior=False) must stay culled even when the eye is standing in its " + "opening (eyeInsideOpening=True) — retail's side test has no bypass; the bypass WAS the flap cycle"); Assert.DoesNotContain(0x0002u, frame.OrderedVisibleCells); } [Fact] public void Builder_CwWoundExitPortal_OutsideRegionIsCcw() { // Exit portal authored CLOCKWISE — the builder must normalize to CCW so downstream stays valid. var cwQuad = new[] { new Vector3(-1, -1, -6), new Vector3(-1, 1, -6), new Vector3(1, 1, -6), new Vector3(1, -1, -6), }; var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); cam.PortalPolygons.Add(cwQuad); var all = new Dictionary { [0x0001] = cam }; var frame = Build(cam, all); Assert.False(frame.OutsideView.IsEmpty); var p = frame.OutsideView.Polygons[0].Vertices; float area2 = 0f; for (int i = 0; i < p.Length; i++) { var a = p[i]; var b = p[(i + 1) % p.Length]; area2 += a.X * b.Y - b.X * a.Y; } Assert.True(area2 > 0f, "clipped OutsideView region should be CCW after winding normalization"); } [Fact] public void Builder_CyclicGraph_TerminatesWithBoundedPolys() { // A <-> B cycle; B also has an exit window. Must terminate and not blow up. var a = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); a.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f)); var b = Cell(0x0002, new CellPortalInfo(0x0001, 0, 0, 0), new CellPortalInfo(0xFFFF, 1, 0, 0)); b.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // back to A b.PortalPolygons.Add(Quad(0f, 0f, 1.0f, 1.0f, -6f)); // exit window var all = new Dictionary { [0x0001] = a, [0x0002] = b }; var frame = Build(a, all); // must return (no infinite loop) Assert.False(frame.OutsideView.IsEmpty); Assert.True(frame.OutsideView.Polygons.Count < 256, $"OutsideView poly count {frame.OutsideView.Polygons.Count} — termination/dedup regression guard"); } // ----------------------------------------------------------------------- // Phase U.2a: ordered visible-cell list (closest-first) + grow-watermark // fixpoint termination (replaces MaxReprocessPerCell hard cap). // ----------------------------------------------------------------------- // Straight chain A -> B -> C, camera in A looking down -Z. Each onward portal // is progressively farther in -Z so the camera-to-portal distance is monotonic, // forcing the priority queue to dequeue A, then B, then C in that order. private static (LoadedCell[] cells, Dictionary lookup) SyntheticChain() { const uint A = 0x0001, B = 0x0002, C = 0x0003; var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0)); a.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f)); // portal A->B at z=-2 (nearer) var b = Cell(B, new CellPortalInfo((ushort)C, 0, 0, 0)); b.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -5f)); // portal B->C at z=-5 (farther) var c = Cell(C, new CellPortalInfo(0xFFFF, 0, 0, 0)); c.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -8f)); // exit window var all = new Dictionary { [A] = a, [B] = b, [C] = c }; return (new[] { a, b, c }, all); } [Fact] // closest-first ordering public void Build_OrdersVisibleCells_ClosestFirst() { var (cells, lookup) = SyntheticChain(); var f = PortalVisibilityBuilder.Build( cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj()); Assert.Equal(new uint[] { 0x0001, 0x0002, 0x0003 }, f.OrderedVisibleCells.ToArray()); } // Hub cell with 4 rooms, each room portal-linked BACK to the hub (a cycle on // every spoke). A naive FIFO with no real fixpoint re-enqueues the hub once per // returning spoke and the rooms once per hub re-process — the watermark must // converge instead, bounding the visible set to {hub + 4 rooms} with no dupes. private static (LoadedCell hub, Dictionary lookup) SyntheticCyclicHub() { const uint HUB = 0x0010; uint[] rooms = { 0x0011, 0x0012, 0x0013, 0x0014 }; // Hub has one portal to each room; rooms sit at distinct depths so ordering is deterministic. var hub = Cell(HUB, new CellPortalInfo((ushort)rooms[0], 0, 0, 0), new CellPortalInfo((ushort)rooms[1], 1, 0, 0), new CellPortalInfo((ushort)rooms[2], 2, 0, 0), new CellPortalInfo((ushort)rooms[3], 3, 0, 0)); for (int i = 0; i < 4; i++) hub.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); // -2,-3,-4,-5 var all = new Dictionary { [HUB] = hub }; for (int i = 0; i < 4; i++) { var room = Cell(rooms[i], new CellPortalInfo((ushort)HUB, 0, 0, 0)); // links back to hub → cycle room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -2f - i)); all[rooms[i]] = room; } return (hub, all); } [Fact] // cyclic graph terminates and bounds the visible set public void Build_CyclicHub_TerminatesAndBounds() { var (hub, lookup) = SyntheticCyclicHub(); var f = PortalVisibilityBuilder.Build( hub, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj()); Assert.True(f.OrderedVisibleCells.Count <= 5, $"hub + 4 rooms expected, got {f.OrderedVisibleCells.Count} — fixpoint failed to converge"); Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells } // ----------------------------------------------------------------------- // Phase U.2b: reciprocal OtherPortalClip (retail PView::OtherPortalClip // decomp:433524). When a portal leads to a loaded neighbour, the // propagated region must ALSO be clipped against the neighbour's matching // (reciprocal) back-portal polygon — the result is the intersection of the // opening seen from BOTH sides. This can only tighten, never widen. // ----------------------------------------------------------------------- // Camera in A looking down -Z through a WIDE near-side portal (A->B). B's // matching back-portal (B->A) is a NARROW opening at the same plane, so the // reciprocal opening projects to a strictly smaller NDC region. Without the // reciprocal clip, B's CellView equals the wide near-side projection; with // it, B's CellView is bounded by the narrow reciprocal opening. private static (LoadedCell camCell, LoadedCell neighbour, Dictionary lookup) SyntheticReciprocalPair() { const uint A = 0x0001, B = 0x0002; // A's portal into B: wide opening (half-width 0.9) at z = -3. Its // reciprocal back-portal lives at index 0 in B (OtherPortalId = 0). var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0)); a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f)); // B's reciprocal portal back to A: NARROW opening (half-width 0.3), // same height and plane, so it projects fully inside the near-side rect // but covers only ~1/3 of its width. var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0)); b.PortalPolygons.Add(Quad(0f, 0f, 0.3f, 0.9f, -3f)); var all = new Dictionary { [A] = a, [B] = b }; return (a, b, all); } [Fact] public void Build_AppliesReciprocalOtherPortalClip() { var (camCell, neighbour, lookup) = SyntheticReciprocalPair(); var vp = ViewProj(); var f = PortalVisibilityBuilder.Build( camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp); Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated"); // The reciprocal opening's area in NDC: project B's narrow back-portal // polygon and take its (CCW-magnitude) shoelace area. This is the // tightest the neighbour region can be — the propagated region must not // exceed it once both-sides clipping is applied. float reciprocalArea = ProjectedPolygonArea(neighbour.PortalPolygons[0], vp); float area = CellViewArea(f.CellViews[0x0002]); const float eps = 1e-4f; Assert.True(area <= reciprocalArea + eps, $"neighbour CellView area {area} must be clipped to the narrower reciprocal opening " + $"{reciprocalArea} (without OtherPortalClip it equals the WIDE near-side projection)"); // Falsifiability guard: the near-side projection is genuinely WIDER than // the reciprocal, so a no-op clip would have failed the assertion above. float nearSideArea = ProjectedPolygonArea(camCell.PortalPolygons[0], vp); Assert.True(nearSideArea > reciprocalArea * 1.5f, $"fixture sanity: near-side area {nearSideArea} must dominate reciprocal {reciprocalArea}"); } [Fact] public void Build_ReciprocalClip_DegradesGracefully_WhenNoBackPortal() { // Same wide A->B opening, but B has NO portal pointing back to A (data gap). // The reciprocal clip must no-op, so B's CellView equals the WIDE near-side // projection — proving degradation never under-includes (and never throws). const uint A = 0x0001, B = 0x0002; var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0)); a.PortalPolygons.Add(Quad(0f, 0f, 0.9f, 0.9f, -3f)); var b = Cell(B); // no portals at all → no back-portal to match var all = new Dictionary { [A] = a, [B] = b }; var vp = ViewProj(); var f = PortalVisibilityBuilder.Build( a, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp); Assert.True(f.CellViews.ContainsKey(B), "neighbour must still be reached when no back-portal exists"); float nearSideArea = ProjectedPolygonArea(a.PortalPolygons[0], vp); float area = CellViewArea(f.CellViews[B]); Assert.True(System.MathF.Abs(area - nearSideArea) < 1e-3f, $"with no reciprocal portal the region must equal the near-side projection " + $"(got {area}, near-side {nearSideArea}) — degrade must not tighten or expand"); } // ----------------------------------------------------------------------- // Phase U.2b CRITICAL — multiple portals to the SAME neighbour must each // resolve their OWN reciprocal back-portal (retail arg2->other_portal_id, // decomp:433557), NOT the first OtherCellId match. This is the real // Holtburg-cellar shape: cell 0x148 has two portals to 0x149 (poly 40, 41) // and 0x149 has two reciprocals back to 0x148. A scan-by-first-match clips // BOTH near-side openings against the FIRST reciprocal — if the apertures // are disjoint, the second opening's intersection underflows to empty and // its geometry is HIDDEN (under-inclusion). Direct-index resolution clips // each opening against the matching reciprocal, so both survive. // ----------------------------------------------------------------------- // Camera in A looking down -Z. A has TWO portals to the SAME neighbour B, // with DISJOINT openings: a LEFT aperture (x in [-0.9,-0.3]) and a RIGHT // aperture (x in [0.3,0.9]). B has two reciprocals back to A, one matching // each side. A.Portals[0].OtherPortalId = 0 (B's LEFT reciprocal), // A.Portals[1].OtherPortalId = 1 (B's RIGHT reciprocal). // // Scan-by-first-match resolves BOTH A-portals to B.Portals[0] (LEFT), // so the RIGHT near-side region (x>0) intersects an x<0 reciprocal → empty // → B's CellView never reaches the RIGHT aperture. Direct-index keeps it. private static (LoadedCell camCell, LoadedCell neighbour, Dictionary lookup) SyntheticMultiBackPortalPair() { const uint A = 0x0001, B = 0x0002; // A: portal[0] → B through the LEFT opening, portal[1] → B through the RIGHT opening. // The OtherPortalId back-links pick the matching reciprocal in B (0 = LEFT, 1 = RIGHT). var a = Cell(A, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0), // LEFT → B recip[0] new CellPortalInfo((ushort)B, PolygonId: 1, Flags: 0, OtherPortalId: 1)); // RIGHT → B recip[1] a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // LEFT near-side aperture a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT near-side aperture // B: two reciprocals back to A. Index 0 covers the LEFT opening, index 1 // the RIGHT opening — disjoint, same plane (z=-3) as the near side so each // reciprocal exactly overlaps its matching A aperture. var b = Cell(B, new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0), new CellPortalInfo((ushort)A, PolygonId: 1, Flags: 0, OtherPortalId: 1)); b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -3f)); // reciprocal[0] = LEFT b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // reciprocal[1] = RIGHT var all = new Dictionary { [A] = a, [B] = b }; return (a, b, all); } [Fact] public void Build_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal() { var (camCell, neighbour, lookup) = SyntheticMultiBackPortalPair(); var vp = ViewProj(); var f = PortalVisibilityBuilder.Build( camCell, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, vp); Assert.True(f.CellViews.ContainsKey(0x0002), "neighbour cell view should be populated"); // The RIGHT reciprocal opening (B.PortalPolygons[1]) projects to a region // with strictly positive NDC X. Geometry visible through the SECOND opening // survives ONLY if A.Portals[1] was clipped against B's RIGHT reciprocal // (index 1) rather than the LEFT one (index 0). var rightNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[1], Matrix4x4.Identity, vp); float rightMinX = float.MaxValue, rightMaxX = float.MinValue; foreach (var v in rightNdc) { if (v.X < rightMinX) rightMinX = v.X; if (v.X > rightMaxX) rightMaxX = v.X; } Assert.True(rightMinX > 0f, $"fixture sanity: RIGHT reciprocal must project to positive NDC X (got minX {rightMinX})"); // The neighbour CellView must extend into the RIGHT aperture. With the // scan-by-first-match bug both near-side openings clip against the LEFT // reciprocal (x<0), so the CellView's MaxX stays well left of rightMinX // and this assertion FAILS (the RIGHT opening's geometry is hidden). var bView = f.CellViews[0x0002]; Assert.True(bView.MaxX >= rightMinX - 1e-4f, $"neighbour CellView MaxX {bView.MaxX} must reach the RIGHT reciprocal opening " + $"[{rightMinX}, {rightMaxX}] — under scan-by-first-match the second opening is clipped " + $"against the LEFT reciprocal and HIDDEN (under-inclusion bug #102 M-4)."); // And the LEFT aperture must still be present (the first opening was never // in question) — guards against a fix that accidentally drops the LEFT side. var leftNdc = PortalProjection.ProjectToNdc(neighbour.PortalPolygons[0], Matrix4x4.Identity, vp); float leftMinX = float.MaxValue; foreach (var v in leftNdc) if (v.X < leftMinX) leftMinX = v.X; Assert.True(bView.MinX <= leftMinX + 1e-4f, $"neighbour CellView MinX {bView.MinX} must still cover the LEFT reciprocal opening (minX {leftMinX})"); } // ----------------------------------------------------------------------- // Phase U.4c: an earlier synthetic flap test (Build_NearBoundaryIntermediatePortal) // lived here. It modeled the doorway flap as a BUILDER side-test cull dropping the // exit cell. The live ACDREAM_PROBE_FLAP capture (2026-05-31) DISPROVED that model: // the real cause is the visibility ROOT being driven by the 3rd-person camera EYE — // the eye drifts out of the player's cell, FindCameraCell returns a STALE cell for // its grace frames, and the doorway is then culled as "behind" the eye. The fix is // at the GameWindow integration level (root visibility at the PLAYER's cell: // visRootPos), NOT in the builder — the builder's side test is correct and unchanged, // so the old test asserted a non-bug and was removed rather than left red. The fix is // validated by the visual gate + the [flap] probe (RenderingDiagnostics.ProbeFlapEnabled); // see docs/research/2026-05-31-u4c-flap-characterization.md. // ----------------------------------------------------------------------- // ----------------------------------------------------------------------- // Phase W Stage 4/5: BFS exit-portal + root-cell ordering guarantees // ----------------------------------------------------------------------- [Fact] public void Build_ExitPortalVisible_OutsideViewNonEmpty() { // A cell with a single exit portal (OtherCellId == 0xFFFF) whose polygon // projects in front of the camera. The BFS must union the clipped region // into OutsideView, making it non-empty. var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); // in front, in frustum var all = new Dictionary { [0x0001] = cam }; var frame = Build(cam, all); Assert.True(frame.OutsideView.Polygons.Count > 0, "An exit portal (OtherCellId=0xFFFF) in front of the camera must populate OutsideView"); } [Fact] public void Build_NoExitPortal_OutsideViewEmpty() { // A cell whose only portal leads to another interior cell (OtherCellId != 0xFFFF). // With no exit portal in any reachable cell the OutsideView must remain empty. var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0)); cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f)); var inner = Cell(0x0002); // no portals — no exit var all = new Dictionary { [0x0001] = cam, [0x0002] = inner }; var frame = Build(cam, all); Assert.True(frame.OutsideView.Polygons.Count == 0, "No exit portal in any reachable cell must leave OutsideView empty"); } [Fact] public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal() { var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); room.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0f, 0f, 1f), D = 3f, InsideSide = 1, }); var frame = PortalVisibilityBuilder.BuildFromExterior( new[] { room }, Vector3.Zero, id => id == room.CellId ? room : null, ViewProj()); Assert.Contains(room.CellId, frame.OrderedVisibleCells); Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view)); Assert.False(view!.IsEmpty); Assert.True(view.MaxX - view.MinX < 1.0f, "exterior seed should be clipped to the door opening, not full-screen"); } [Fact] public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide() { var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); room.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0f, 0f, 1f), D = -1f, InsideSide = 1, }); var frame = PortalVisibilityBuilder.BuildFromExterior( new[] { room }, Vector3.Zero, id => id == room.CellId ? room : null, ViewProj()); Assert.Empty(frame.OrderedVisibleCells); Assert.Empty(frame.CellViews); } [Fact] public void BuildFromExterior_TraversesDeeperInteriorPortals() { var entry = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0), new CellPortalInfo(0x0002, 1, 0, 0)); entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f)); entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f)); entry.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0f, 0f, 1f), D = 2f, InsideSide = 1, }); var backRoom = Cell(0x0002); var all = new Dictionary { [entry.CellId] = entry, [backRoom.CellId] = backRoom, }; var frame = PortalVisibilityBuilder.BuildFromExterior( new[] { entry }, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj()); Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray()); Assert.True(frame.CellViews.ContainsKey(0x0002)); } [Fact] public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal() { var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0)); nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); nearby.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0f, 0f, 1f), D = 3f, InsideSide = 1, }); var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0)); distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f)); distant.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0f, 0f, 1f), D = 199f, InsideSide = 1, }); var frame = PortalVisibilityBuilder.BuildFromExterior( new[] { nearby, distant }, Vector3.Zero, id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null, ViewProj(), maxSeedDistance: 48f); Assert.Contains(nearby.CellId, frame.OrderedVisibleCells); Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells); } [Fact] public void Build_RootCellAlwaysFirstInOrderedVisibleCells() { // The camera cell seeds the BFS at distance 0 so it must always pop // first → OrderedVisibleCells[0] == cameraCell.CellId. // Use the three-cell chain: A→B→C with exit window on C. var (cells, lookup) = SyntheticChain(); var f = PortalVisibilityBuilder.Build( cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj()); Assert.NotEmpty(f.OrderedVisibleCells); Assert.Equal(cells[0].CellId, f.OrderedVisibleCells[0]); } // Note: Build_CyclicPortalGraph_Terminates is already covered by // Builder_CyclicGraph_TerminatesWithBoundedPolys and Build_CyclicHub_TerminatesAndBounds. // The dedup invariant (each cell appears at most once) is validated by // Build_CyclicHub_TerminatesAndBounds via OrderedVisibleCells.Distinct().Count(). // Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC. private static float CellViewArea(CellView view) { float total = 0f; foreach (var poly in view.Polygons) total += ShoelaceAbs(poly.Vertices); return total; } // Project a cell-local polygon (identity world transform in these fixtures) // to NDC via the same path the builder uses, then take its area magnitude. private static float ProjectedPolygonArea(Vector3[] localPoly, Matrix4x4 vp) { var ndc = PortalProjection.ProjectToNdc(localPoly, Matrix4x4.Identity, vp); return ShoelaceAbs(ndc); } private static float ShoelaceAbs(Vector2[] poly) { if (poly == null || poly.Length < 3) return 0f; float area2 = 0f; for (int i = 0; i < poly.Length; i++) { var p = poly[i]; var q = poly[(i + 1) % poly.Length]; area2 += p.X * q.Y - q.X * p.Y; } return System.MathF.Abs(area2) * 0.5f; } } internal static class PortalFrameTestHelper { public static float ProjectedWidth(Vector3[] worldSeg, Matrix4x4 vp) { var a = Vector4.Transform(new Vector4(worldSeg[0], 1f), vp); var b = Vector4.Transform(new Vector4(worldSeg[1], 1f), vp); return System.MathF.Abs(a.X / a.W - b.X / b.W); } }