Port retail's per-frame light collection: the point-light pool is built from ONLY the currently-visible cells' lights, matching CObjCell::add_*_to_global_lights (0x0052b350/0x0052b390) walked over CEnvCell::visible_cell_table (0x0052d410) — not a flat world-space set capped at 128-nearest-camera. - LightSource.CellId (retail insert_light arg6 -> RenderLight +0x6c); tagged at both registration sites from entity.ParentCellId (live weenie fixtures + dat EnvCell statics). - LightManager.BuildPointLightSnapshot(camPos, visibleCells): a light joins the pool iff CellId==0 (viewer/global) or its cell is in the flood. 128 cap kept as a now-non-biting backstop (retail's is 40 static + 7 dynamic, 0x0081ec94/8). - Threaded via RetailPViewDrawContext.RebuildScopedLights, invoked in DrawInside after the flood resolves prepareCells and before the draws (renderers select from the same in-place-rebuilt PointSnapshot; EnvCellRenderer clears its per-cell cache each pass). - [indoor-light] probe (ACDREAM_PROBE_INDOOR_LIGHT=1) dumps the scoped-pool SET COMPOSITION. Un-skips LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant. CORRECTION: the handoff called the camera-cap the "confirmed" #176/#177 mechanism. The probe PROVES scoping works (291 Hub fixtures -> pool of 1-9, ~285 through-floor lights dropped/frame, CellIds match the flood), but the user's VISUAL GATE showed BOTH symptoms unchanged. So pool composition is NOT the cause. #176 real cause = an over-bright purple point light (intensity=100, color 0.784,0,0.784 -- from [light-detail]); #177 = a portal-visibility miss (stairs not drawn looking back). Both stay OPEN. This change is retail-faithful and retires the camera-eviction latent bug; kept as such, not as the symptom fix. Register AP-85 corrected; ISSUES #176/#177 re-diagnosed; render digest banner updated. Decomp: insert_light 0x0054d1b0, minimize_object_lighting 0x0054d480, calc_point_light 0x0059c8b0; pseudocode docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. Suites green: Core 2595 + 2 skip, App 719 + 2 skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
657 lines
36 KiB
C#
657 lines
36 KiB
C#
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>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=<bool> ambient=(r,g,b) sun=<intensity>
|
||
/// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id>
|
||
/// </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) per-cell light 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, the scoped
|
||
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||
/// <c>[indoor-light]</c> line describing the visible-cell-scoped point-light pool
|
||
/// (see <see cref="EmitIndoorLight"/>):
|
||
/// <code>
|
||
/// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R>
|
||
/// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...]
|
||
/// </code>
|
||
/// This validates the A7 fix's load-bearing assumption end-to-end:
|
||
/// <list type="bullet">
|
||
/// <item><description><c>cellLess==pool</c> (every pool light is CellId 0) ⇒
|
||
/// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op.</description></item>
|
||
/// <item><description><c>pool==cellLess</c> while <c>registered</c> is large in a
|
||
/// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the
|
||
/// room would go dark.</description></item>
|
||
/// <item><description><c>droppedNonVisible>0</c> with <c>byCell</c> tracking the
|
||
/// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the
|
||
/// dropped ones).</description></item>
|
||
/// </list>
|
||
/// 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";
|
||
|
||
// 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=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m>
|
||
/// </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.###}",
|
||
ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle,
|
||
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist));
|
||
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
|
||
/// visible-cell-scoped 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 the scoped
|
||
/// <c>LightManager.BuildPointLightSnapshot</c> (visibleCells != null path).
|
||
/// </summary>
|
||
/// <param name="visibleCellCount">Size of the portal-flood visible-cell set this frame.</param>
|
||
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||
/// <param name="scopedSnapshot">The visible-cell-scoped point-light pool just built.</param>
|
||
public static void EmitIndoorLight(int visibleCellCount,
|
||
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] visibleCells=").Append(visibleCellCount);
|
||
sb.Append(" pool=").Append(pool);
|
||
sb.Append(" cellLess=").Append(cellLess);
|
||
sb.Append(" registered=").Append(registeredLitPoints);
|
||
// Lights excluded by visibility scoping (retail: cells not in visible_cell_table
|
||
// contribute nothing) — the through-floor/under-room lights kept out of the pool.
|
||
sb.Append(" droppedNonVisible=").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 (0x0001–0x0040).
|
||
/// </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->m_position.objcell_id & 0xFFFF) < 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);
|
||
}
|