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

@ -3684,7 +3684,8 @@ public sealed class GameWindow : IDisposable
ownerId: entity.Id,
entityPosition: entity.Position,
entityRotation: entity.Rotation,
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
isDynamic: true, // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls);
}
@ -7697,7 +7698,8 @@ public sealed class GameWindow : IDisposable
datSetup,
ownerId: entity.Id,
entityPosition: entity.Position,
entityRotation: entity.Rotation);
entityRotation: entity.Rotation,
cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility
foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls);
}
@ -9368,6 +9370,11 @@ public sealed class GameWindow : IDisposable
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
Camera = camera,
CameraWorldPosition = camPos,
// A7 #176/#177: once DrawInside has resolved the visible-cell set,
// rebuild the point-light pool from ONLY those cells' lights (retail's
// per-frame add_*_lights over visible_cell_table). The renderers hold a
// reference to the same PointSnapshot list, rebuilt in place here.
RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible),
Frustum = frustum,
PlayerLandblockId = playerLb,
AnimatedEntityIds = animatedIds,

View file

@ -129,6 +129,12 @@ public sealed class RetailPViewRenderer
prepareCells = _lookInPrepareScratch;
}
// A7 #176/#177: scope this frame's point-light pool to the cells actually being
// drawn, NOW that the flood has resolved the visible set (retail collects lights
// per-frame over visible_cell_table). Must run before the cell/entity draws below
// that select from LightManager.PointSnapshot.
ctx.RebuildScopedLights?.Invoke(prepareCells);
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
@ -1084,6 +1090,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
/// <summary>A7 #176/#177: rebuild the point-light snapshot scoped to the cells
/// this frame actually draws — invoked AFTER the portal flood resolves the visible
/// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame
/// light collection: <c>CObjCell::add_*_to_global_lights</c> walked over
/// <c>CEnvCell::visible_cell_table</c>). The argument is every cell drawn this frame
/// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept
/// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the
/// legacy full-pool snapshot.</summary>
public Action<IReadOnlySet<uint>>? RebuildScopedLights { get; init; }
}
public sealed class RetailPViewFrameResult