feat(lighting): A7 visible-cell light scoping + [indoor-light] probe (NOT the #176/#177 fix)

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>
This commit is contained in:
Erik 2026-07-06 00:35:01 +02:00
parent e1746ca10d
commit c500912bf8
10 changed files with 516 additions and 35 deletions

View file

@ -37,7 +37,8 @@ public static class LightInfoLoader
uint ownerId,
Vector3 entityPosition,
Quaternion entityRotation,
bool isDynamic = false)
bool isDynamic = false,
uint cellId = 0)
{
var results = new List<LightSource>();
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
@ -89,6 +90,7 @@ public static class LightInfoLoader
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
IsLit = true,
IsDynamic = isDynamic,
};

View file

@ -218,11 +218,33 @@ public sealed class LightManager
/// per-object selection.
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
=> BuildPointLightSnapshot(cameraWorldPos, visibleCells: null);
/// <summary>
/// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame
/// light collection (<c>CObjCell::add_*_to_global_lights</c> 0x0052b350/0x0052b390
/// walked over <c>CEnvCell::visible_cell_table</c> 0x0052d410). When
/// <paramref name="visibleCells"/> is non-null (an indoor root with a portal
/// flood), a cell-tagged light is a candidate ONLY when its <see cref="LightSource.CellId"/>
/// is in the visible set — a cell-less light (<c>CellId == 0</c>: the viewer fill,
/// global lights) is always included. This is what (a) stops an under-room light
/// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and
/// (b) bounds the pool to the handful of visible cells so <see cref="MaxGlobalLights"/>
/// never evicts a visible cell's in-range light (the #176/#177 mechanism). When
/// <paramref name="visibleCells"/> is null (outdoor root / no flood) the behaviour
/// is unchanged from the legacy full-pool path.
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet<uint>? visibleCells)
{
_pointSnapshot.Clear();
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
// Visible-cell scoping (retail add_*_lights over visible_cell_table). A
// cell-less light (CellId == 0: viewer fill / global) is always a candidate;
// a cell-tagged light joins the pool ONLY when its cell is visibly flooded.
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId))
continue;
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
_pointSnapshot.Add(light);
}
@ -231,6 +253,11 @@ public sealed class LightManager
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
}
// A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path.
// Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off.
if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot);
}
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────

View file

@ -46,6 +46,12 @@ public sealed class LightSource
public float Range = 10f; // metres, hard cutoff
public float ConeAngle = 0f; // radians, Spot only
public uint OwnerId; // attached entity id; 0 = world-global
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the
// per-frame pool can be built from only the VISIBLE cells' lights
// (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table).
// acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is
// only a candidate when its cell is visibly flooded (#176/#177 A7 fix).
public bool IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3)

View file

@ -271,6 +271,33 @@ public static class RenderingDiagnostics
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=&lt;N&gt; pool=&lt;M&gt; cellLess=&lt;K&gt; registered=&lt;R&gt;
/// droppedNonVisible=&lt;R-M&gt; byCell=[0x&lt;id&gt;:&lt;count&gt;,...]
/// </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&gt;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
@ -451,6 +478,66 @@ public static class RenderingDiagnostics
}
}
// 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";