diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index 84638cf..7dde694 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -142,6 +142,25 @@ public struct PortalClipPlane
public int InsideSide;
}
+///
+/// Phase U.4c flap probe (diagnostic): which branch of
+/// resolved the camera cell.
+///
+public enum CameraCellResolution
+{
+ /// No cell contains the eye (outdoors), or not yet resolved.
+ None,
+ /// The eye is inside the previously-cached cell (fast path).
+ Cache,
+ /// The eye is inside a one-hop portal neighbour of the cached cell.
+ Neighbour,
+ /// The eye is inside a cell found by the full brute-force scan.
+ BruteForce,
+ /// The eye is inside NO cell, but the previous cell is kept alive for a
+ /// few grace frames — the "stale root" case the flap probe watches for.
+ Grace,
+}
+
///
/// Result of a portal-based visibility BFS from the camera cell.
///
@@ -213,6 +232,14 @@ public sealed class CellVisibility
/// The last visibility result produced by .
public VisibilityResult? LastVisibilityResult { get; private set; }
+ ///
+ /// Phase U.4c flap probe (diagnostic): which branch
+ /// resolved the camera cell on the most recent call. A
+ /// (or ) result while the eye is NOT actually inside the
+ /// returned cell is the "stale root" signature the flap probe looks for.
+ ///
+ public CameraCellResolution LastCameraCellResolution { get; private set; } = CameraCellResolution.None;
+
// ------------------------------------------------------------------
// Registration
// ------------------------------------------------------------------
@@ -330,7 +357,10 @@ public sealed class CellVisibility
{
// 1. Fast path: cached cell.
if (_lastCameraCell != null && PointInCell(cameraPos, _lastCameraCell))
+ {
+ LastCameraCellResolution = CameraCellResolution.Cache;
return _lastCameraCell;
+ }
// 2. One-hop neighbours of the cached cell.
if (_lastCameraCell != null)
@@ -347,6 +377,7 @@ public sealed class CellVisibility
{
_lastCameraCell = neighbour;
_cellSwitchGraceFrames = CellSwitchGraceFrameCount;
+ LastCameraCellResolution = CameraCellResolution.Neighbour;
return neighbour;
}
}
@@ -361,6 +392,7 @@ public sealed class CellVisibility
{
_lastCameraCell = cell;
_cellSwitchGraceFrames = CellSwitchGraceFrameCount;
+ LastCameraCellResolution = CameraCellResolution.BruteForce;
return cell;
}
}
@@ -370,11 +402,13 @@ public sealed class CellVisibility
if (_lastCameraCell != null && _cellSwitchGraceFrames > 0)
{
_cellSwitchGraceFrames--;
+ LastCameraCellResolution = CameraCellResolution.Grace;
return _lastCameraCell;
}
// 5. Camera is outside all cells.
_lastCameraCell = null;
+ LastCameraCellResolution = CameraCellResolution.None;
return null;
}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index cf84efe..8cc0ce6 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -7315,6 +7315,22 @@ public sealed class GameWindow : IDisposable
clipAssembly.OutsidePlaneCount,
clipAssembly.PerCellPlaneCounts,
clipAssembly.ScissorFallbacks);
+
+ // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — paired with the builder's
+ // per-frame [flap] line. res = which FindCameraCell branch chose the root;
+ // eyeInRoot = is the EYE actually inside clipRoot's AABB (n ⇒ stale root via
+ // cache/grace, the leading flap hypothesis); terrain/outVisible = the frame's
+ // outcome (Skip/false ⇒ terrain+shells flapped off this frame).
+ if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
+ {
+ var flapPlayer = _playerController?.Position ?? camPos;
+ bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot);
+ Console.WriteLine(
+ $"[flap-cam] root=0x{clipRoot.CellId:X8} res={_cellVisibility.LastCameraCellResolution} " +
+ $"eyeInRoot={(eyeInRoot ? "Y" : "n")} eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " +
+ $"player=({flapPlayer.X:F2},{flapPlayer.Y:F2},{flapPlayer.Z:F2}) " +
+ $"terrain={clipAssembly.TerrainMode} outVisible={clipAssembly.OutdoorVisible}");
+ }
}
else
{
diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
index 5276196..e7f47d1 100644
--- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
+++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
@@ -230,9 +230,59 @@ public static class PortalVisibilityBuilder
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
+ // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
+ // root cell's per-portal side-test + projection + the frame's exit/visible counts.
+ if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
+ EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
+
return frame;
}
+ // Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
+ // signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
+ // vertex count, plus the frame's OutsideView polygon count + visible-cell count.
+ // `localEye` is the eye in root-local space — its component along an interior portal
+ // plane reveals when the eye has crossed past that plane (the stale-root region that
+ // makes the side test cull a still-needed portal). Read-only recompute; no effect on
+ // the returned frame. Throwaway apparatus — strip with the probe.
+ private static void EmitFlapProbe(
+ LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame)
+ {
+ var localEye = Vector3.Transform(cameraPos, cameraCell.InverseWorldTransform);
+ var sb = new System.Text.StringBuilder(220);
+ sb.Append("[flap] root=0x").Append(cameraCell.CellId.ToString("X8"));
+ sb.Append(" eye=(").Append(cameraPos.X.ToString("F2")).Append(',')
+ .Append(cameraPos.Y.ToString("F2")).Append(',').Append(cameraPos.Z.ToString("F2")).Append(')');
+ sb.Append(" localEye=(").Append(localEye.X.ToString("F2")).Append(',')
+ .Append(localEye.Y.ToString("F2")).Append(',').Append(localEye.Z.ToString("F2")).Append(')');
+ for (int i = 0; i < cameraCell.Portals.Count; i++)
+ {
+ var portal = cameraCell.Portals[i];
+ float d = float.NaN;
+ bool side = true;
+ if (i < cameraCell.ClipPlanes.Count && cameraCell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f)
+ {
+ var pl = cameraCell.ClipPlanes[i];
+ d = Vector3.Dot(pl.Normal, localEye) + pl.D;
+ side = CameraOnInteriorSide(cameraCell, i, cameraPos);
+ }
+ int projN = -1;
+ if (i < cameraCell.PortalPolygons.Count)
+ {
+ var poly = cameraCell.PortalPolygons[i];
+ if (poly != null && poly.Length >= 3)
+ projN = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj).Length;
+ }
+ sb.Append(" | p").Append(i).Append("->0x").Append(portal.OtherCellId.ToString("X4"));
+ sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2"));
+ sb.Append(side ? " TRV" : " CULL");
+ sb.Append(" proj=").Append(projN);
+ }
+ sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count);
+ sb.Append(" vis=").Append(frame.OrderedVisibleCells.Count);
+ Console.WriteLine(sb.ToString());
+ }
+
// Mirrors CellVisibility's portal-side test (InsideSide convention).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
index b373aac..14687b9 100644
--- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
@@ -96,6 +96,24 @@ public static class RenderingDiagnostics
public static bool ProbeVisibilityEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1";
+ ///
+ /// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal
+ /// visibility pass emits, EVERY frame the camera root is an indoor cell, a
+ /// [flap] line (root cell's per-portal side-test D + traverse/cull +
+ /// projection, plus the frame's OutsideView/visible counts) and the call site
+ /// emits a paired [flap-cam] line (FindCameraCell resolution reason,
+ /// camera EYE worldpos, player worldpos, eye-in-root-AABB flag). Unlike the
+ /// cell-change-throttled probe, this fires
+ /// per-frame so it captures the flicker (the exit cell dropping in/out at a
+ /// STABLE root). Pinpoints WHY the exit cell drops: side-test cull (eye past an
+ /// interior portal plane), empty projection, or a stale root (eye outside the
+ /// cell while FindCameraCell still reports it via cache/grace). Throwaway
+ /// apparatus — strip once the flap mechanism is confirmed.
+ /// Initial state from ACDREAM_PROBE_FLAP=1.
+ ///
+ public static bool ProbeFlapEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_FLAP") == "1";
+
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
// so launch.log stays readable under motion (the per-frame call is a no-op
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real