diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index ebf70c94..e2c78f79 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -216,12 +216,15 @@ public static class PortalVisibilityBuilder bool sideAllowed = true; - // Portal-side test: only traverse a portal the camera is on the interior side of - // (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing - // portals so we never feed a degenerate/wrong-facing projection downstream. + // Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal + // the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just + // flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old + // `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the + // 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log: + // back portals show camInterior=False eyeIn=True). The forward-portal clip-empty void rescue + // (below, the `clippedRegion.Count == 0` branch) is a SEPARATE path and stays. if (i < cell.ClipPlanes.Count - && !CameraOnInteriorSide(cell, i, cameraPos) - && !eyeInsideOpening) + && !CameraOnInteriorSide(cell, i, cameraPos)) { sideAllowed = false; trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}"); @@ -483,9 +486,9 @@ public static class PortalVisibilityBuilder continue; // already outdoors; exterior terrain was drawn by the caller. bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos); + // R-A2b: cull back portals by the side test alone (no eye-in-opening bypass) — see Build(). if (i < cell.ClipPlanes.Count - && !CameraOnInteriorSide(cell, i, cameraPos) - && !eyeInsideOpening) + && !CameraOnInteriorSide(cell, i, cameraPos)) continue; var clippedRegion = ClipPortalAgainstView( diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 111b34ba..6ff081dd 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -249,6 +249,33 @@ public class PortalVisibilityBuilderTests 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() {