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>
This commit is contained in:
Erik 2026-07-09 10:34:15 +02:00
parent 388f3ed307
commit d275ed554e
5 changed files with 166 additions and 23 deletions

View file

@ -189,12 +189,15 @@ public sealed class LightManager
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
/// purples entered/left the pool as the camera turned — the seam-floor
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
/// (=resident) lit lights, dynamics-first then nearest-player, capped here.
/// 128 is wider than retail's 40+7 — a documented backstop that in Hub-scale
/// rooms only ever evicts far-out-of-range statics; adopting retail's exact
/// dual-pool caps + degrade levels is A7-arc work. The 1024 uncap remains
/// refuted (striped-floor artifact + the unported static 1/d³ fixture curve,
/// A7 fix #2). Register row AP-85.</summary>
/// (=resident) lit lights optionally FILTERED by last frame's rendered
/// visible-cell set (A7.L1, 2026-07-09 — <see cref="BuildPointLightSnapshot"/>'s
/// <c>visibleCells</c> param; fixes Town Network starvation without
/// reproducing c500912b — see that method's doc), then dynamics-first nearest-
/// player, capped here. 128 is wider than retail's 40+7 — a documented backstop
/// that in a properly cell-scoped room only ever evicts far-out-of-range
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new();
@ -239,22 +242,46 @@ public sealed class LightManager
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
/// set from <c>player-&gt;m_position</c>, SmartBox 0x00453d3a, with the
/// viewer-cell fallback 0x00455ab6). The pool is therefore a function of
/// PLAYER position and light registration ONLY — camera rotation/position
/// cannot change it. Both prior camera-coupled pools (nearest-camera cap;
/// frame-flood scoping <c>c500912b</c>) produced the #176 seam-floor purple
/// blink. Call once per frame before per-object selection.
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
/// of PLAYER position and light registration ONLY — camera rotation/position
/// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap;
/// <c>c500912b</c>'s camera-seeded re-flood — produced the #176 seam-floor
/// purple blink by making the SORT itself camera-dependent). The optional
/// <paramref name="visibleCells"/> candidacy FILTER (A7.L1) does not change
/// this: it narrows the input set before the player-anchored sort runs, using
/// a value the caller captured from last frame's already-rendered draw list,
/// not a fresh camera-seeded computation performed here. Call once per frame
/// before per-object selection.
/// </para>
/// </summary>
/// <param name="playerWorldPos">The player's world position (render position;
/// callers pass the camera position only when no player exists — retail's
/// player/viewer branch).</param>
public void BuildPointLightSnapshot(Vector3 playerWorldPos)
/// <param name="visibleCells">
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
/// is a candidate only if it is cell-less (<c>CellId == 0</c> — the viewer fill,
/// always in scope) or its <c>CellId</c> is in this set. Fixes the Town Network
/// starvation case (463 registered fixtures): the player-nearest cap sorts by
/// raw Euclidean distance, which is not a reliable proxy for "same room" in a
/// dense, maze-like hub — a fixture on the other side of a wall can be
/// geometrically closer than the player's own room's torches and win the cap,
/// leaving the visible room dark. Scoping candidacy to the frame's actual
/// visible cells (the render already computes this — callers pass last frame's
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
/// re-threading a mid-render callback) removes those from contention before the
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
/// parameter only narrows candidacy, it does not change the sort (the #176
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
/// seam-floor flicker regression, c500912b). Null (the default) preserves the
/// legacy unscoped behavior — outdoor / no-clipRoot callers pass null.
/// </param>
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet<uint>? visibleCells = null)
{
_pointSnapshot.Clear();
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) continue;
_pointSnapshot.Add(light);
}
if (_pointSnapshot.Count > MaxGlobalLights)

View file

@ -282,13 +282,20 @@ public static class RenderingDiagnostics
/// [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, same day): the pool is now retail's
/// RESIDENT-cell collection capped nearest-the-PLAYER — visible-cell scoping
/// (the gaze-coupled pool) was the #176 flicker mechanism and is deleted, so
/// the line no longer carries <c>visibleCells/droppedNonVisible</c>. The
/// diagnostic value that remains: <c>cellLess==pool</c> in a fixture-rich room
/// still means cell tagging FAILED (ParentCellId not flowing), and
/// <c>byCell</c> shows which cells' lights are pooled.
/// #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; } =
@ -576,8 +583,8 @@ public static class RenderingDiagnostics
/// 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> (the resident collection —
/// #176 correction removed the visible-cell scoping).
/// <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>