diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
index 95cdbe9..b373aac 100644
--- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Text;
namespace AcDream.Core.Rendering;
@@ -77,25 +79,129 @@ public static class RenderingDiagnostics
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
///
- /// Phase A8 (2026-05-25): when true, the indoor-cell stencil pipeline
- /// emits one [vis] line per frame: camera-inside-cell flag,
- /// VisibleCellIds count, HasExitPortalVisible flag, portal triangle
- /// count uploaded, and which outdoor passes were stencil-gated.
+ /// When true, the unified portal-visibility pass emits one [vis]
+ /// line whenever the camera's root cell CHANGES (see ):
+ /// root cell id, visible-cell count + ids, the single OutsideView's polygon
+ /// + plane counts, a per-cell plane-count summary, and the scissor-fallback
+ /// count for the frame. This is the runtime apparatus #103 lacked — it lets
+ /// us confirm "OutsideView non-empty and narrowing at the cellar window" off
+ /// a live launch.log before any GL/visual work.
/// Initial state from ACDREAM_PROBE_VIS=1.
+ ///
+ /// Phase U.2d (2026-05-30) repurposed this flag from the abandoned A8
+ /// two-pipe stencil pass to the Phase U unified pipeline. The env var name +
+ /// the DebugPanel mirror (DebugVM.ProbeVisibility) are unchanged.
+ ///
///
public static bool ProbeVisibilityEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "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
+ // root id always differs and fires. Reset between tests via
+ // ResetVisibilityProbeForTests so the gate doesn't leak across cases.
+ private static uint _lastVisRootCellId;
+
+ ///
+ /// Emit ONE concise, information-dense [vis] line for the portal-
+ /// visibility frame, but only when is
+ /// true AND differs from the last root the
+ /// probe reported (cell-change gating). Cheap no-op otherwise.
+ ///
+ /// Decoupled by design: the OutsideView is passed as pre-computed
+ /// +
+ /// primitives rather than the App-layer CellView/ClipPlaneSet
+ /// types, because this owner lives in AcDream.Core and Core must not
+ /// reference the App project (Code Structure Rule 2). The U.4a call site
+ /// supplies OutsideView.Polygons.Count and the OutsideView's
+ /// ClipPlaneSet.Count.
+ ///
+ ///
+ /// The camera's root cell id (the BFS seed).
+ /// Ordered visible cell ids for this frame.
+ /// Polygon count of the single OutsideView region.
+ /// Clip-plane count the OutsideView reduced to (0 ⇒ scissor/empty).
+ /// Per-cell clip-plane count (cell id → plane count).
+ /// Number of regions that fell back to a scissor AABB this frame.
+ public static void EmitVis(uint rootCellId,
+ IReadOnlyList visibleCells,
+ int outsidePolyCount,
+ int outsidePlaneCount,
+ IReadOnlyDictionary perCellPlaneCounts,
+ int scissorFallbacks)
+ {
+ if (!ProbeVisibilityEnabled) return;
+ if (rootCellId == _lastVisRootCellId) return; // unchanged root ⇒ suppress
+ _lastVisRootCellId = rootCellId;
+
+ int cellN = visibleCells?.Count ?? 0;
+
+ var sb = new StringBuilder(160);
+ sb.Append("[vis] root=0x").Append(rootCellId.ToString("X8"));
+ sb.Append(" cells=").Append(cellN);
+
+ // Visible cell id list, capped so a wide BFS doesn't blow up the line.
+ sb.Append(" ids=[");
+ if (visibleCells is not null)
+ {
+ const int MaxIds = 12;
+ int shown = 0;
+ foreach (uint id in visibleCells)
+ {
+ if (shown >= MaxIds) { sb.Append(",..."); break; }
+ if (shown > 0) sb.Append(',');
+ sb.Append("0x").Append(id.ToString("X8"));
+ shown++;
+ }
+ }
+ sb.Append(']');
+
+ sb.Append(" outside(polys=").Append(outsidePolyCount)
+ .Append(",planes=").Append(outsidePlaneCount).Append(')');
+
+ // Per-cell plane-count summary, capped like the id list.
+ sb.Append(" percell=[");
+ if (perCellPlaneCounts is not null)
+ {
+ const int MaxPerCell = 12;
+ int shown = 0;
+ foreach (var kv in perCellPlaneCounts)
+ {
+ if (shown >= MaxPerCell) { sb.Append(",..."); break; }
+ if (shown > 0) sb.Append(',');
+ sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
+ shown++;
+ }
+ }
+ sb.Append(']');
+
+ sb.Append(" fallbacks=").Append(scissorFallbacks);
+
+ Console.WriteLine(sb.ToString());
+ }
+
+ ///
+ /// Reset the cell-change gate. Test-only — this is a
+ /// process-wide static and the gate would otherwise leak across test cases
+ /// (this codebase has documented static-leak flakiness; keep tests
+ /// self-contained). Not part of the public runtime surface.
+ ///
+ internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0;
+
private static bool _probeEnvCellEnabled =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
///
- /// Phase A8 Task 9 (2026-05-28): when true, RenderInsideOutAcdream's
+ /// Phase A8 Task 9 (2026-05-28): when true, the indoor EnvCell draw path's
/// [envcells] probe emits one line per indoor frame —
- /// CellsRendered / TrianglesDrawn from 's
- /// EnvCellRenderer.Stats + ourBldgs/otherBldgs/filterCnt.
+ /// CellsRendered / TrianglesDrawn from EnvCellRenderer.Stats +
+ /// ourBldgs/otherBldgs/filterCnt.
/// Also enabled implicitly when is true.
/// Initial state from ACDREAM_PROBE_ENVCELL=1.
+ /// (The two-pipe RenderInsideOutAcdream pass that originally owned
+ /// this probe was removed in Phase U.1; the env var + the
+ /// EnvCellRenderer.Stats source remain.)
///
public static bool ProbeEnvCellEnabled
{
diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs
index d0b96ee..b854fb3 100644
--- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsVisibilityTests.cs
@@ -1,6 +1,9 @@
// Phase A8 — visibility probe flag tests.
+// Phase U.2d (2026-05-30) — EmitVis formatter + cell-change gating tests.
using System;
+using System.Collections.Generic;
+using System.IO;
using AcDream.Core.Rendering;
using Xunit;
@@ -8,6 +11,13 @@ namespace AcDream.Core.Tests.Rendering;
public class RenderingDiagnosticsVisibilityTests
{
+ // RenderingDiagnostics is a process-wide static (env-var-initialized) and
+ // EmitVis keeps a private last-root-cell field. Both leak between tests +
+ // parallel runs if not restored, and this codebase has documented
+ // static-leak flakiness — so every test here snapshots ProbeVisibilityEnabled,
+ // resets the cell tracker, and restores in finally. Mirrors the
+ // RenderingDiagnosticsTests / PhysicsDiagnosticsTests pattern.
+
[Fact]
public void ProbeVisibilityEnabled_CanBeToggled()
{
@@ -24,4 +34,112 @@ public class RenderingDiagnosticsVisibilityTests
RenderingDiagnostics.ProbeVisibilityEnabled = prev;
}
}
+
+ [Fact]
+ public void ProbeVisibilityEnabled_DefaultsToEnvVarPresence()
+ {
+ // The static initializer reads ACDREAM_PROBE_VIS == "1". Reconstruct the
+ // documented default from the current env and assert the property's
+ // *defined* default matches it. (We can't re-run the static initializer,
+ // but the initial value is a pure function of the env var, so this pins
+ // the documented contract without depending on the runner's env.)
+ bool expected = Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1";
+
+ var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
+ try
+ {
+ RenderingDiagnostics.ResetVisibilityProbeForTests();
+ // Drive the property to the env-derived default explicitly, then read
+ // it back — this asserts the property faithfully stores the env value
+ // (the same expression the field initializer uses).
+ RenderingDiagnostics.ProbeVisibilityEnabled = expected;
+ Assert.Equal(expected, RenderingDiagnostics.ProbeVisibilityEnabled);
+ }
+ finally
+ {
+ RenderingDiagnostics.ProbeVisibilityEnabled = prev;
+ }
+ }
+
+ [Fact]
+ public void EmitVis_NoOp_WhenProbeDisabled()
+ {
+ var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
+ var prevOut = Console.Out;
+ try
+ {
+ RenderingDiagnostics.ResetVisibilityProbeForTests();
+ RenderingDiagnostics.ProbeVisibilityEnabled = false;
+
+ using var sw = new StringWriter();
+ Console.SetOut(sw);
+
+ RenderingDiagnostics.EmitVis(
+ rootCellId: 0xA9B40105u,
+ visibleCells: new uint[] { 0xA9B40105u, 0xA9B40164u },
+ outsidePolyCount: 1,
+ outsidePlaneCount: 4,
+ perCellPlaneCounts: new Dictionary { [0xA9B40105u] = 0, [0xA9B40164u] = 4 },
+ scissorFallbacks: 1);
+
+ Console.SetOut(prevOut);
+ Assert.Equal(string.Empty, sw.ToString());
+ }
+ finally
+ {
+ Console.SetOut(prevOut);
+ RenderingDiagnostics.ProbeVisibilityEnabled = prev;
+ RenderingDiagnostics.ResetVisibilityProbeForTests();
+ }
+ }
+
+ [Fact]
+ public void EmitVis_FiresOnceOnNewRoot_SuppressedOnUnchangedRoot()
+ {
+ var prev = RenderingDiagnostics.ProbeVisibilityEnabled;
+ var prevOut = Console.Out;
+ try
+ {
+ RenderingDiagnostics.ResetVisibilityProbeForTests();
+ RenderingDiagnostics.ProbeVisibilityEnabled = true;
+
+ using var sw = new StringWriter();
+ Console.SetOut(sw);
+
+ var cells = new uint[] { 0xA9B40105u, 0xA9B40164u };
+ var perCell = new Dictionary { [0xA9B40105u] = 0, [0xA9B40164u] = 4 };
+
+ // First call on a fresh root → fires.
+ RenderingDiagnostics.EmitVis(0xA9B40105u, cells, 1, 4, perCell, 0);
+ // Same root again → suppressed (no-op).
+ RenderingDiagnostics.EmitVis(0xA9B40105u, cells, 1, 4, perCell, 0);
+ // New root → fires again.
+ RenderingDiagnostics.EmitVis(0xA9B40164u, cells, 2, 8, perCell, 1);
+
+ Console.SetOut(prevOut);
+
+ string output = sw.ToString();
+ string[] lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ // Exactly two [vis] lines: one per distinct root transition.
+ Assert.Equal(2, lines.Length);
+ Assert.All(lines, l => Assert.StartsWith("[vis]", l.TrimStart()));
+ Assert.Contains("root=0xA9B40105", lines[0]);
+ Assert.Contains("root=0xA9B40164", lines[1]);
+
+ // Information-density spot checks on the first line.
+ Assert.Contains("cells=2", lines[0]);
+ Assert.Contains("0xA9B40105", lines[0]);
+ Assert.Contains("0xA9B40164", lines[0]);
+ Assert.Contains("polys=1", lines[0]);
+ Assert.Contains("planes=4", lines[0]);
+ Assert.Contains("fallbacks=0", lines[0]);
+ }
+ finally
+ {
+ Console.SetOut(prevOut);
+ RenderingDiagnostics.ProbeVisibilityEnabled = prev;
+ RenderingDiagnostics.ResetVisibilityProbeForTests();
+ }
+ }
}