acdream/src/AcDream.Core/Rendering/RenderingDiagnostics.cs
Erik d275ed554e feat(A7.L1): scope the point-light pool by last frame's visible cells (#79/#93/#176/#177)
The Town Network hub (498 registered fixtures) starved the player's own
room: BuildPointLightSnapshot's player-nearest-128 cap sorts by raw
Euclidean distance, which isn't a reliable proxy for "same room" in a
dense maze — a fixture on the other side of a wall can be geometrically
closer than the room's own torches and win the cap. LightSource.CellId
tagging and the [indoor-light] membership probe already existed from the
c500912b/#176 arc; the missing piece was a candidacy filter.

BuildPointLightSnapshot(playerWorldPos, visibleCells) now narrows
candidates to the frame's actual visible cells before the existing
dynamics-first player-nearest cap runs (cell-less lights, e.g. the viewer
fill, always included). GameWindow feeds LAST FRAME's already-rendered
RetailPViewFrameResult.DrawableCells — one frame of latency instead of
re-threading a mid-DrawInside callback, which was the exact mechanism
(c500912b) that caused the earlier #176 seam-floor flicker regression.
The distance-sort anchor stays the player, unchanged.

AP-85 updated in place (third revision) rather than adding a new row —
same underlying divergence, now with the render-visibility approximation
of retail's true DBObj-load/flush-bounded resident registry documented
alongside its residual risk (one unscoped frame on portal re-entry).

Core 2652+2skip / App 741+2skip / UI 425 / Net 385 green. Pending: user
visual gate at the Town Network fountain, and a #176 corridor-seam
non-regression recheck (Facility Hub, different landblock).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 10:34:15 +02:00

770 lines
43 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Text;
namespace AcDream.Core.Rendering;
/// <summary>
/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
/// rendering pipeline. Initialized from env vars at process start;
/// flippable at runtime via the DebugPanel mirror. Log call sites read
/// these statics so a checkbox toggle takes effect on the next frame
/// without relaunching.
///
/// <para>
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
/// pattern. The master <see cref="IndoorAll"/> toggle is the user's
/// common case — flipping it cascades to all five probe flags.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md</c>.
/// </para>
/// </summary>
public static class RenderingDiagnostics
{
/// <summary>
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
/// <c>[indoor-walk]</c> line per visible cell entity per second:
/// entity id, world position, parent cell id, landblock visible flag,
/// AABB-visible flag, "in visible cells" flag, drew flag.
/// Initial state from <c>ACDREAM_PROBE_INDOOR_WALK=1</c>.
/// </summary>
public static bool ProbeIndoorWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-lookup]</c>
/// line per visible cell entity per second: render-data hit/miss,
/// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
/// Initial state from <c>ACDREAM_PROBE_INDOOR_LOOKUP=1</c>.
/// </summary>
public static bool ProbeIndoorLookupEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbMeshAdapter</c> emits two lines per EnvCell id:
/// <c>[indoor-upload] requested</c> on first IncrementRefCount and
/// <c>[indoor-upload] completed</c> when WB's staged drain produces
/// its <c>ObjectMeshData</c>. Missing "completed" lines indicate WB
/// silently returned null (hypothesis H1).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_UPLOAD=1</c>.
/// </summary>
public static bool ProbeIndoorUploadEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-xform]</c>
/// line per visible cell entity per second: cell-geometry SetupPart's
/// composed world matrix translation. Disambiguates transform
/// double-apply (hypothesis H5).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_XFORM=1</c>.
/// </summary>
public static bool ProbeIndoorXformEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
/// <c>[indoor-cull]</c> line per cell entity that gets culled, with
/// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
/// cull bugs (hypothesis H3).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_CULL=1</c>.
/// </summary>
public static bool ProbeIndoorCullEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, the unified portal-visibility pass emits one <c>[vis]</c>
/// line whenever the camera's root cell CHANGES (see <see cref="EmitVis"/>):
/// 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 <c>ACDREAM_PROBE_VIS=1</c>.
/// <para>
/// 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 (<c>DebugVM.ProbeVisibility</c>) are unchanged.
/// </para>
/// </summary>
public static bool ProbeVisibilityEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1";
/// <summary>
/// #119-residual viewer/flood capture (2026-06-11): one <c>[viewer]</c>
/// line per CHANGE of (root cell, flood size, OutsideView poly count,
/// player cell), with the projection EYE at mm precision on every line —
/// the capture half of the tower-ascent capture→replay loop
/// (TowerAscentReplayTests replays the captured pairs deterministically).
/// Light: silent while the visibility state is stable; a tower climb
/// emits a few dozen lines. Initial state from
/// <c>ACDREAM_PROBE_VIEWER=1</c>.
/// </summary>
public static bool ProbeViewerEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIEWER") == "1";
/// <summary>
/// #131 (2026-06-12) outside-stage dynamics probe. When true, the renderer
/// emits one <c>[outstage]</c> line per CHANGE of the outside-stage
/// routing + per-slice cone verdict set under an interior root (which
/// outdoor dynamics were routed to the landscape slice, which survived the
/// slice viewcone), and GameWindow emits one <c>[outstage-pt]</c> line per
/// change of the slice Scene-particle id set + matched-emitter count.
/// Built for the portal-swirl-missing-through-doorway capture. Light:
/// silent while the set is stable. Initial state from
/// <c>ACDREAM_PROBE_OUTSTAGE=1</c>.
/// </summary>
public static bool ProbeOutStageEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUTSTAGE") == "1";
/// <summary>
/// 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
/// <c>[flap]</c> 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 <c>[flap-cam]</c> line (FindCameraCell resolution reason,
/// camera EYE worldpos, player worldpos, eye-in-root-AABB flag). Unlike the
/// cell-change-throttled <see cref="ProbeVisibilityEnabled"/> 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 <c>ACDREAM_PROBE_FLAP=1</c>.
/// </summary>
public static bool ProbeFlapEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_FLAP") == "1";
/// <summary>
/// Issue #78 (2026-05-31) cell-shell render probe. When true,
/// <c>EnvCellRenderer.Render</c> emits one <c>[shell]</c> line per opaque-pass
/// call: per visible (filtered) cell — is it present in the prepared snapshot,
/// how many gfxObjs + instances, and per-gfxObj batch count / index count /
/// translucent / zero-bindless-handle (missing texture) — plus the pass totals.
/// This directly answers WHY interior walls/ceilings don't appear: no geometry
/// prepared for the cell (cell absent / 0 instances), drawn-but-invisible
/// (zeroHandle / translucent against the clear color), or prepared+drawn (so the
/// fault is elsewhere — depth/occlusion). Throwaway apparatus — strip once the
/// indoor-enclosure render is fixed. Initial state from <c>ACDREAM_PROBE_SHELL=1</c>.
/// </summary>
public static bool ProbeShellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SHELL") == "1";
/// <summary>
/// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE
/// <c>[pv-input]</c> line per frame with the EXACT PortalVisibilityBuilder.Build inputs at HIGH
/// precision (camera eye + player position to 6 dp, plus orientation-sensitive view-projection
/// elements) alongside the resulting flood cell count. The live flap shows the flood set flipping
/// 2↔6 at an eye/player that is identical to cm; this probe answers whether the Build INPUTS differ
/// below cm precision (sub-cm view jitter → robustness fix) or are byte-identical while the output
/// still flips (nondeterminism → surgical bug). Runs WITHOUT the heavy <c>[flap]</c>/<c>[render-sig]</c>
/// spam so the log stays diffable. Throwaway apparatus — strip once the jitter source is pinned.
/// Initial state from <c>ACDREAM_PROBE_PVINPUT=1</c>.
/// </summary>
public static bool ProbePvInputEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PVINPUT") == "1";
/// <summary>
/// §4 outdoor full-world flap apparatus (2026-06-09). When true, GameWindow snapshots the
/// GL fixed-function state entering the world passes each frame (depth test/mask/func, blend
/// + factors, cull, front-face, scissor + box, viewport, draw-FBO, color mask, glGetError)
/// and emits one <c>[gl-state]</c> line whenever the snapshot CHANGES. Pins or refutes the
/// "leaked GL state" family for the flap (every CPU-side input — matrix, flood, clip planes,
/// scissor box, membership, eye-vs-terrain — is already probe-exonerated). Throwaway
/// apparatus — strip once §4 ships. Initial state from <c>ACDREAM_PROBE_GLSTATE=1</c>.
/// </summary>
public static bool ProbeGlStateEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_GLSTATE") == "1";
/// <summary>
/// §4 outdoor full-world flap apparatus (2026-06-10) — the decisive probe between the two
/// surviving suspects (handoff 2026-06-09 §1): (a) per-instance clip-slot routing under
/// outdoor roots, (b) terrain/sky UBO content at draw time — plus the landscape-pass scissor
/// box as a third ground truth. When true: RetailPViewRenderer.DrawLandscapeThroughOutsideView
/// emits one <c>[clip-route]</c> line (print-on-change) with the outside slice's slot + NDC
/// AABB + planes, the CellIdToSlot routing table, the region-SSBO bytes decoded at the routed
/// slot, and the terrain-UBO head as uploaded; WbDrawDispatcher.Draw emits one
/// <c>[clip-route-disp]</c> line (print-on-change, routed draws only) with the per-slot
/// instance histogram exactly as uploaded to binding=3 plus the culled-entity count; and
/// GameWindow.DrawRetailPViewLandscapeSlice emits one <c>[clip-route-scis]</c> line
/// (print-on-change) with the ACTUAL GL scissor enable + box the landscape pass draws under.
/// Throwaway apparatus — strip once §4 ships. Initial state from <c>ACDREAM_PROBE_CLIPROUTE=1</c>.
/// </summary>
public static bool ProbeClipRouteEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CLIPROUTE") == "1";
/// <summary>
/// #105 white-indoor-textures apparatus (2026-06-10). When true, <c>WbMeshAdapter.Tick</c>
/// emits one <c>[tex-flush]</c> line whenever the staged-texture-update picture changes:
/// pending layer updates across all shared atlases BEFORE and AFTER the per-frame
/// <c>ObjectMeshManager.GenerateMipmaps()</c> flush, plus arrays-with-pending / total-array
/// counts. The broken contract this pins: <c>TextureAtlasManager.AddTexture</c> only STAGES
/// pixel data (PBO + pending list); without the per-frame flush (WB GameScene.cs:975) the
/// data never reaches the GL texture and the batch samples undefined content behind a valid
/// bindless handle — the classic white walls. A healthy run shows <c>after=0</c> on every
/// line; a stuck <c>before==after&gt;0</c> at standstill is the #105 mechanism live.
/// Initial state from <c>ACDREAM_PROBE_TEXFLUSH=1</c>.
/// </summary>
public static bool ProbeTexFlushEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_TEXFLUSH") == "1";
/// <summary>
/// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits
/// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues,
/// max pop count, and — per re-enqueue — the reciprocal-clip pre→post region count + grew flag. Pins
/// whether the flap's churn is redundant reciprocal back-contributions producing non-empty drifted
/// slivers (the hypothesis) vs another source. Throwaway apparatus — strip once the bound ships.
/// Initial state from ACDREAM_PROBE_PORTAL_CHURN=1.
/// </summary>
public static bool ProbePortalChurnEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1";
/// <summary>
/// BR-2 phantom-site probe (2026-06-11; plan
/// <c>docs/plans/2026-06-11-building-render-port-plan.md</c> §BR-2 first
/// task). The BR-1 pre-check proved the #113 phantom residual cannot be
/// GfxObj portal fills (never extracted); the surviving suspects are
/// cell-side. When true, <c>RetailPViewRenderer</c> emits, print-on-change
/// per cell: <c>[phantom-shell]</c> — per shell-pass cell, the clip-enable
/// state and each drawn slice's slot + plane count, flagging the pass-all
/// cases (NoClipSlice fallback for slot-less cells; assembler slot-0
/// scissor fallback) — and <c>[phantom-objs]</c> — per object-list cell,
/// the entity-bucket size drawn unclipped/un-viewcone'd. Reproducing the
/// phantom with this on pins which mechanism draws it (shells → BR-2/BR-3;
/// statics → BR-5). Throwaway apparatus — strip when the phantom closes.
/// Initial state from <c>ACDREAM_PROBE_PHANTOM=1</c>.
/// </summary>
public static bool ProbePhantomEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1";
/// <summary>
/// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true,
/// the per-frame scene-lighting build emits ONE <c>[light]</c> line
/// roughly every second (wall-clock rate-limited like WB-DIAG) via
/// <see cref="EmitLight"/>:
/// <code>
/// [light] insideCell=&lt;bool&gt; ambient=(r,g,b) sun=&lt;intensity&gt;
/// registeredLights=&lt;N&gt; activeLights=&lt;uCellAmbient.w&gt; playerCell=0x&lt;id&gt;
/// </code>
/// This is the self-verification signal for the dungeon-dim question:
/// <list type="bullet">
/// <item><description><c>insideCell=true ambient=(0.20,0.20,0.20) sun=0</c>
/// confirms the indoor branch fired (retail flat ambient, sun killed).</description></item>
/// <item><description><c>registeredLights</c> is the count of dat-baked
/// point/spot lights (<c>Setup.Lights</c>) registered with the
/// <c>LightManager</c> — if this is 0 in a dungeon, the cell's static
/// objects carry no baked torches (so the only illumination IS the
/// 0.2 ambient → dim).</description></item>
/// <item><description><c>activeLights</c> is <c>uCellAmbient.w</c> — the
/// shader's active-slot count, which INCLUDES the (zeroed) sun slot
/// indoors. So <c>activeLights=1 registeredLights=0</c> = "only the dead
/// sun slot, no torches in range".</description></item>
/// </list>
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_LIGHT=1</c>.
/// </summary>
public static bool ProbeLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
/// <summary>
/// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
/// lived in set MEMBERSHIP, not counts). When true,
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
/// <c>[indoor-light]</c> line describing the point-light pool
/// (see <see cref="EmitIndoorLight"/>):
/// <code>
/// [indoor-light] pool=&lt;M&gt; cellLess=&lt;K&gt; registered=&lt;R&gt; capped=&lt;R-M&gt;
/// byCell=[0x&lt;id&gt;:&lt;count&gt;,...]
/// </code>
/// #176 correction (2026-07-06): the pool became retail's RESIDENT-cell
/// collection capped nearest-the-PLAYER — the earlier gaze-coupled scoping
/// (rebuilding the pool from a freshly re-flooded CAMERA-seeded set,
/// <c>c500912b</c>) was the #176 flicker mechanism and was deleted.
/// A7.L1 (2026-07-09): visible-cell scoping is BACK, but sourced differently —
/// <c>LightManager.BuildPointLightSnapshot</c> now takes an optional
/// <c>visibleCells</c> filter that <c>GameWindow</c> feeds from LAST FRAME's
/// already-rendered <c>RetailPViewFrameResult.DrawableCells</c> (one frame of
/// latency, no independent re-flood, no callback threaded into DrawInside) —
/// fixes the Town Network starvation case (463 fixtures, a wall-adjacent
/// corridor's fixtures out-ranking the player's own room in raw Euclidean
/// distance) without reproducing the #176 mechanism. <c>byCell</c> shows which
/// cells' lights are pooled; <c>cellLess==pool</c> in a fixture-rich room still
/// means cell tagging FAILED (ParentCellId not flowing).
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
/// </summary>
public static bool ProbeIndoorLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
/// <summary>
/// #176 seam-floor flicker decisive probe (2026-07-06). RenderDoc pixel-history
/// is infeasible on this pipeline (RenderDoc does not support
/// GL_ARB_bindless_texture and hides it from the app → our mandatory-modern
/// startup gate throws), so this is the in-engine equivalent at draw
/// granularity: every draw route that can put geometry at the corridor seam
/// floor reports itself, plus the per-cell light sets ACTUALLY applied.
/// When set, four line families emit (all change-deduped, Console):
/// <list type="bullet">
/// <item><description><c>[seam-cell]</c> — <c>EnvCellRenderer.Render</c>
/// (opaque pass): per target cell — in-filter flag, per-gfx instance count +
/// transform translation (shows the +0.02 shell lift), per-batch
/// cull/translucency, and the cell's 8-light set RESOLVED to identities
/// (owner cell + intensity — raw snapshot indices shuffle when the pool
/// rebuilds, so identities are the stable signature). Two instances of one
/// (cell,gfx) = the runtime double-draw; light identities flipping with the
/// flood = the snapshot-scope mechanism.</description></item>
/// <item><description><c>[seam-snap]</c> — the point-light snapshot's HOT
/// subset (intensity ≥ 50: the portal purples; fixtures are ~12, the viewer
/// fill 2.25) with owner cells, emitted with the block.</description></item>
/// <item><description><c>[seam-ent]</c> — <c>WbDrawDispatcher</c>: any entity
/// parented to a target cell — position, culled/slot, resolved light set. A
/// floor-coincident entity (plate/static) would be the z-fight's second draw;
/// the player entity doubles as the probe's positive control.</description></item>
/// <item><description><c>[seam-mask]</c> — <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>:
/// every portal depth fan drawn in a target cell. A sealed dungeon must show
/// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding.</description></item>
/// </list>
/// Value <c>1</c> = the default Facility Hub target set (corridor 0x8A020164 +
/// seam neighbors 0165/016E/017A + under-hall 011E + portal-light cells
/// 0118/0119); a comma-separated hex cell-id list overrides it. Throwaway
/// apparatus — strip when #176 closes. Initial state from
/// <c>ACDREAM_PROBE_SEAMDRAW</c>.
/// </summary>
public static bool ProbeSeamDrawEnabled { get; set; } =
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
/// <summary>Target cell ids for the #176 seam-draw probe (see
/// <see cref="ProbeSeamDrawEnabled"/>). Full 32-bit cell ids.</summary>
public static IReadOnlySet<uint> SeamDrawTargetCells { get; } =
ParseSeamDrawTargets(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
/// <summary>
/// #176 stripe-hunt shader isolation mode (<c>ACDREAM_LIGHT_DEBUG</c>) —
/// throwaway diagnostic, uploaded as <c>uLightDebug</c> by EnvCellRenderer +
/// WbDrawDispatcher each pass. 0 = off; 1 = ambient-only vertex lighting
/// (all point/sun contributions killed); 2 = DYNAMIC point lights killed
/// (the intensity-100 portal purples + the viewer fill off; statics stay);
/// 3 = raw vLit visualization in the fragment shader (texture ignored).
/// Discriminates lighting-driven stripes (gone at 1/2, visible in the field
/// at 3) from texture/per-pixel machinery (survive 1). Settable for a
/// future DebugPanel mirror.
/// </summary>
public static int LightDebugMode { get; set; } =
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"), out var ldm) ? ldm : 0;
/// <summary>
/// #176 stripe-hunt isolation (<c>ACDREAM_CLIP_DEBUG=1</c>) — throwaway
/// diagnostic. When true, the EnvCell SHELL pass maps every instance to clip
/// slot 0 (the reserved no-clip region) instead of its cell's portal-slice
/// region — i.e. shells draw WHOLE, retail's shape (retail never clips cell
/// geometry; aperture exactness comes from the seal/punch depth writes +
/// far→near order, PView::DrawCells 0x005a4840). Discriminator for the
/// camera-against-wall stripe/hatch pattern: a knife-edge slice region (eye
/// on a portal plane / inside a wall) yields a clip plane near-parallel to
/// large surfaces → interpolated gl_ClipDistance ≈ 0 across them → per-pixel
/// sign dither = the hatch. Stripes gone with this on = the shell trim is
/// the mechanism. Entity/dispatcher clip routing is NOT affected.
/// </summary>
public static bool ClipDebugNoShellTrim { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_CLIP_DEBUG") == "1";
/// <summary>Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176
/// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving
/// grammar as <see cref="ParseDumpEntityIds"/>). Internal for unit tests.</summary>
internal static IReadOnlySet<uint> ParseSeamDrawTargets(string? raw)
{
// Default: the corridor + the coplanar-sweep seed neighbors + the under-hall
// + the two portal-weenie cells whose intensity-100 purple lights are the
// wedge's source (handoff 2026-07-06-176-seam-floor-zfight-handoff.md).
var defaults = new HashSet<uint>
{
0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au,
0x8A02011Eu, 0x8A020118u, 0x8A020119u,
};
if (string.IsNullOrWhiteSpace(raw)) return defaults;
var trimmed = raw.Trim();
if (trimmed == "1" || trimmed.Equals("true", StringComparison.OrdinalIgnoreCase))
return defaults;
var parsed = ParseDumpEntityIds(raw);
return parsed.Count > 0 ? parsed : defaults;
}
// 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;
/// <summary>
/// Emit ONE concise, information-dense <c>[vis]</c> line for the portal-
/// visibility frame, but only when <see cref="ProbeVisibilityEnabled"/> is
/// true AND <paramref name="rootCellId"/> differs from the last root the
/// probe reported (cell-change gating). Cheap no-op otherwise.
/// <para>
/// Decoupled by design: the OutsideView is passed as pre-computed
/// <paramref name="outsidePolyCount"/> + <paramref name="outsidePlaneCount"/>
/// primitives rather than the App-layer <c>CellView</c>/<c>ClipPlaneSet</c>
/// types, because this owner lives in <c>AcDream.Core</c> and Core must not
/// reference the App project (Code Structure Rule 2). The U.4a call site
/// supplies <c>OutsideView.Polygons.Count</c> and the OutsideView's
/// <c>ClipPlaneSet.Count</c>.
/// </para>
/// </summary>
/// <param name="rootCellId">The camera's root cell id (the BFS seed).</param>
/// <param name="visibleCells">Ordered visible cell ids for this frame.</param>
/// <param name="outsidePolyCount">Polygon count of the single OutsideView region.</param>
/// <param name="outsidePlaneCount">Clip-plane count the OutsideView reduced to (0 ⇒ scissor/empty).</param>
/// <param name="perCellPlaneCounts">Per-cell clip-plane count (cell id → plane count).</param>
/// <param name="scissorFallbacks">Number of regions that fell back to a scissor AABB this frame.</param>
public static void EmitVis(uint rootCellId,
IReadOnlyList<uint> visibleCells,
int outsidePolyCount,
int outsidePlaneCount,
IReadOnlyDictionary<uint, int> 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());
}
/// <summary>
/// Reset the <see cref="EmitVis"/> 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.
/// </summary>
internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0;
// Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty —
// we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0
// = "never emitted" so the first call always fires.
private static long _lastLightEmitTicks;
private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks
/// <summary>
/// #133 A7 — emit ONE rate-limited <c>[light]</c> line describing the
/// current scene-lighting state, followed (when <paramref name="lights"/>
/// is supplied) by up to three <c>[light-detail]</c> lines for the nearest
/// ACTIVE point/spot lights. Cheap no-op when
/// <see cref="ProbeLightEnabled"/> is false; otherwise fires at most
/// once per second. Pull the values from the spot where
/// <c>GameWindow.UpdateSunFromSky</c> set <c>Lighting.CurrentAmbient</c>
/// / <c>Lighting.Sun</c> and where <c>SceneLightingUbo.Build</c> computed
/// the active-slot count.
/// <para>
/// The <c>[light-detail]</c> lines are the answer to the "candle-spotlight"
/// question — they expose each torch's REAL dat-derived runtime values
/// (<c>range=</c> Falloff metres, <c>intensity=</c>, <c>cone=</c> radians,
/// <c>color=</c>, <c>distToViewer=</c>) so it is visible in launch.log
/// whether dungeon torches are tiny-range points or wide cones and at what
/// intensity — without a screenshot:
/// <code>
/// [light-detail] kind=Point range=&lt;Falloff m&gt; intensity=&lt;I&gt; cone=&lt;rad&gt; color=(r,g,b) distToViewer=&lt;m&gt;
/// </code>
/// </para>
/// </summary>
/// <param name="insideCell">The <c>playerInsideCell</c> value driving the indoor branch.</param>
/// <param name="ambientR">Cell ambient red (xyz of <c>uCellAmbient</c>).</param>
/// <param name="ambientG">Cell ambient green.</param>
/// <param name="ambientB">Cell ambient blue.</param>
/// <param name="sunIntensity">The sun <c>LightSource.Intensity</c> (0 indoors).</param>
/// <param name="registeredLights">Total point/spot lights registered with the LightManager.</param>
/// <param name="activeLights"><c>uCellAmbient.w</c> — shader active-slot count (includes the zeroed sun slot indoors).</param>
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
/// <param name="lights">The ticked <c>LightManager</c> (its <c>Active</c> list, sorted nearest-first by the
/// just-completed Tick). When non-null, drives the <c>[light-detail]</c> lines. Optional so existing call
/// sites / tests that only want the aggregate line keep compiling.</param>
public static void EmitLight(bool insideCell,
float ambientR, float ambientG, float ambientB,
float sunIntensity,
int registeredLights,
int activeLights,
uint playerCellId,
AcDream.Core.Lighting.LightManager? lights = null)
{
if (!ProbeLightEnabled) return;
long now = DateTime.UtcNow.Ticks;
if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks)
return;
_lastLightEmitTicks = now;
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}",
insideCell, ambientR, ambientG, ambientB, sunIntensity,
registeredLights, activeLights, playerCellId));
// #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble"
// question. Dump the actual runtime dat-derived values of the nearest
// ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle
// are visible in launch.log (are torch ranges 1m or 10m? points or
// spots? what intensity?). The sun (Directional, slot 0) is skipped —
// it carries no Range/cone meaning. DistSq is already cached by
// LightManager.Tick this frame, so the active list is sorted nearest-
// first; we just take the first few non-directional entries.
if (lights is null) return;
var active = lights.Active;
int shown = 0;
const int MaxDetail = 3;
for (int i = 0; i < active.Length && shown < MaxDetail; i++)
{
var ls = active[i];
if (ls is null) continue;
if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue;
float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f;
Console.WriteLine(string.Format(ci,
"[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###} owner=0x{8:X8} cell=0x{9:X8} dyn={10}",
ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle,
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist,
ls.OwnerId, ls.CellId, ls.IsDynamic ? 1 : 0));
shown++;
}
}
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
private static long _lastIndoorLightEmitTicks;
/// <summary>
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
/// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show.
/// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise
/// fires at most once per second. Called from
/// <c>LightManager.BuildPointLightSnapshot</c> — <paramref name="scopedSnapshot"/>
/// already reflects any last-frame visible-cell scoping (A7.L1, 2026-07-09).
/// </summary>
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
/// <param name="scopedSnapshot">The point-light pool just built.</param>
public static void EmitIndoorLight(
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
{
if (!ProbeIndoorLightEnabled) return;
long now = DateTime.UtcNow.Ticks;
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
return;
_lastIndoorLightEmitTicks = now;
int registeredLitPoints = 0;
foreach (var l in allRegistered)
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
int pool = scopedSnapshot.Count;
int cellLess = 0;
var hist = new Dictionary<uint, int>();
foreach (var l in scopedSnapshot)
{
if (l.CellId == 0) cellLess++;
hist.TryGetValue(l.CellId, out var c);
hist[l.CellId] = c + 1;
}
var sb = new StringBuilder(220);
sb.Append("[indoor-light] pool=").Append(pool);
sb.Append(" cellLess=").Append(cellLess);
sb.Append(" registered=").Append(registeredLitPoints);
// Lights dropped by the MaxGlobalLights nearest-player cap (0 in Hub-scale
// rooms for dynamics — statics beyond the 128th-nearest are out of range).
sb.Append(" capped=").Append(registeredLitPoints - pool);
sb.Append(" byCell=[");
const int MaxCells = 12;
int shown = 0;
foreach (var kv in hist)
{
if (shown >= MaxCells) { sb.Append(",..."); break; }
if (shown > 0) sb.Append(',');
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
shown++;
}
sb.Append(']');
Console.WriteLine(sb.ToString());
}
private static bool _probeEnvCellEnabled =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
/// <summary>
/// Phase A8 Task 9 (2026-05-28): when true, the indoor EnvCell draw path's
/// <c>[envcells]</c> probe emits one line per indoor frame —
/// CellsRendered / TrianglesDrawn from <c>EnvCellRenderer.Stats</c> +
/// ourBldgs/otherBldgs/filterCnt.
/// Also enabled implicitly when <see cref="ProbeVisibilityEnabled"/> is true.
/// Initial state from <c>ACDREAM_PROBE_ENVCELL=1</c>.
/// (The two-pipe <c>RenderInsideOutAcdream</c> pass that originally owned
/// this probe was removed in Phase U.1; the env var + the
/// <c>EnvCellRenderer.Stats</c> source remain.)
/// </summary>
public static bool ProbeEnvCellEnabled
{
get => _probeEnvCellEnabled || ProbeVisibilityEnabled;
set => _probeEnvCellEnabled = value;
}
/// <summary>
/// Master toggle. Reading reflects the AND of all five flags
/// (true only when every probe is on). Writing cascades — setting
/// to <see langword="true"/> turns ALL five flags on; setting to
/// <see langword="false"/> turns ALL five off.
/// </summary>
public static bool IndoorAll
{
get => ProbeIndoorWalkEnabled
&& ProbeIndoorLookupEnabled
&& ProbeIndoorUploadEnabled
&& ProbeIndoorXformEnabled
&& ProbeIndoorCullEnabled;
set
{
ProbeIndoorWalkEnabled = value;
ProbeIndoorLookupEnabled = value;
ProbeIndoorUploadEnabled = value;
ProbeIndoorXformEnabled = value;
ProbeIndoorCullEnabled = value;
}
}
/// <summary>
/// Helper for probe call sites. Returns <see langword="true"/> when
/// the low 16 bits of <paramref name="id"/> are ≥ 0x0100 — the AC
/// convention for EnvCell (indoor) cells, as opposed to outdoor cells
/// in the 8×8 landblock grid (0x00010x0040).
/// </summary>
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
/// <summary>
/// #119 tower-staircase decisive probe (2026-06-11). Comma-separated
/// Setup / GfxObj source ids (hex, optional 0x prefix) from
/// <c>ACDREAM_DUMP_ENTITY</c>. Any <c>WorldEntity</c> whose
/// <c>SourceGfxObjOrSetupId</c> is in this set emits:
/// (a) a <c>[dump-entity] HYDRATE</c> dump at MeshRef construction time
/// (<c>GameWindow.BuildInteriorEntitiesForStreaming</c>) — per-part
/// placement-frame translations + dropped-part accounting — discriminating
/// hydration-time corruption (H-A: SetupMesh.Flatten identity fallback /
/// silent gfx-null part drops under degraded dat reads);
/// (b) a <c>[dump-entity] DRAW</c> dump in <c>WbDrawDispatcher</c> at first
/// draw — live MeshRefs translations + Tier-1 classification cache state —
/// re-emitted compactly whenever that state changes (H-B: stale/partial
/// cached batch set); and
/// (c) rate-limited <c>[dump-entity] WALK-REJECT</c> lines when the
/// dispatcher's walk filters the entity out (absence-of-draw attribution).
/// Empty set = probe off; every call site early-outs on <c>Count == 0</c>.
/// </summary>
public static IReadOnlySet<uint> DumpEntitySourceIds { get; } =
ParseDumpEntityIds(Environment.GetEnvironmentVariable("ACDREAM_DUMP_ENTITY"));
/// <summary>
/// Parse the <c>ACDREAM_DUMP_ENTITY</c> value: comma-separated hex ids,
/// optional 0x prefix, whitespace tolerated, malformed segments ignored
/// (probes are forgiving — a typo'd segment must not take the launch down).
/// Internal for unit tests.
/// </summary>
internal static IReadOnlySet<uint> ParseDumpEntityIds(string? raw)
{
var set = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw)) return set;
foreach (var seg in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var s = seg.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? seg[2..] : seg;
if (uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var id))
set.Add(id);
}
return set;
}
/// <summary>
/// The top-level render branch: should this frame run the indoor (DrawInside) path?
///
/// <para>Retail <c>SmartBox::RenderNormalMode</c> (0x453aa0, pc:92665) branches
/// DrawInside vs the outdoor <c>LScape::draw</c> on <c>is_player_outside</c> — the
/// <b>PLAYER's</b> cell (<c>(player-&gt;m_position.objcell_id &amp; 0xFFFF) &lt; 0x100</c>,
/// <c>SmartBox::is_player_outside</c> 0x451e80) — NOT the camera/viewer cell. When the
/// player is inside, acdream roots the portal flood at the player's transition-owned
/// physics cell and projects from the camera eye, so the shell around the player remains
/// sealed during chase-camera cell transitions.</para>
///
/// <para>acdream historically branched on the camera cell (a non-null
/// <c>visibility.CameraCell</c>). A 3rd-person chase camera lags the player, so when the
/// player had already stepped outside but the camera still sat in the doorway, the camera
/// branch wrongly chose DrawInside rooted at the doorway cell, where the exit-portal flood
/// degenerates → the whole static world (terrain + shells) gated off → grey screen with
/// only entities (which bypass the gate) showing through. Branching on the player removes it.</para>
///
/// <param name="playerCellId">The player's current cell id (0 if unresolved → outside).</param>
/// <param name="renderRootResolved">Whether the player's indoor render root is loaded and
/// available to DrawInside.</param>
/// </summary>
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
=> renderRootResolved && IsEnvCellId(playerCellId);
/// <summary>
/// MP0 (2026-07-05) — master toggle for the permanent frame profiler
/// (<c>AcDream.App.Diagnostics.FrameProfiler</c>): CPU frame time
/// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
/// per-frame allocation counters, reported as one <c>[frame-prof]</c>
/// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
/// do NOT strip with session probes. The whole-frame GPU query is
/// self-disabled while <c>ACDREAM_WB_DIAG=1</c> (GL forbids nested
/// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under
/// that flag — the 2026-06-23 "separate flags" measurement lesson).
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
/// via the DebugPanel mirror (<c>DebugVM.FrameProf</c>).
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public static bool FrameProfEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
}