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

@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
public sealed class LightManagerTests
{
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true)
private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0)
=> new LightSource
{
Kind = LightKind.Point,
@ -14,6 +14,7 @@ public sealed class LightManagerTests
Range = range,
IsLit = lit,
OwnerId = ownerId,
CellId = cellId,
};
[Fact]
@ -176,6 +177,55 @@ public sealed class LightManagerTests
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
}
// ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ────
// A7 #176/#177: the per-frame pool is built from ONLY the lights of currently-
// visible cells (plus cell-less globals), not a flat world-space set.
[Fact]
public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u };
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
// Only the visible cell's light survives — the under-room light can't wash
// through the floor (retail: its cell isn't in visible_cell_table).
Assert.Single(mgr.PointSnapshot);
Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId);
}
[Fact]
public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell
var visible = new System.Collections.Generic.HashSet<uint> { 0xAAAA0101u }; // does NOT contain 0102
mgr.BuildPointLightSnapshot(Vector3.Zero, visible);
// The cell-less light (viewer fill) is always a candidate; the non-visible
// cell's light is dropped.
Assert.Single(mgr.PointSnapshot);
Assert.Equal(0u, mgr.PointSnapshot[0].CellId);
}
[Fact]
public void BuildPointLightSnapshot_NullScope_KeepsFullPool()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u));
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u));
// Null visible set = outdoor root / no flood → legacy full-pool behaviour.
mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null);
Assert.Equal(2, mgr.PointSnapshot.Count);
}
[Fact]
public void SelectForObject_EmptySnapshot_ReturnsZero()
{
@ -258,56 +308,59 @@ public sealed class LightManagerTests
}
/// <summary>
/// #176/#177 (2026-07-06): SelectForObject is camera-independent (the test
/// above), but the SNAPSHOT it selects from was not — BuildPointLightSnapshot
/// kept only the MaxGlobalLights nearest THE CAMERA. In the Facility Hub
/// (366 registered fixtures vs the old cap of 128), an in-range torch of a
/// VISIBLE cell could rank past the cap and be evicted, so the cell's 8-set
/// (and its Gouraud vertex lighting) flipped as the camera moved — per-cell
/// lighting pops at seam granularity (#176's flash), and a whole room's
/// torches vanishing until approach (#177's pop-in). Retail's
/// minimize_object_lighting (0x0054d480) has NO global camera-nearest cap —
/// every registered light reaching the object is a candidate. This pins the
/// end-to-end property: a light in range of an object stays selected no
/// matter where the camera is, at Facility-Hub-scale light counts.
/// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
/// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
/// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
/// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
/// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
/// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
/// is retail's per-frame collection: the pool is built from ONLY the lights of the
/// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
/// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
/// the cap never bites, and a visible cell's in-range light is never camera-evicted.
/// The same scoping keeps a NON-visible cell's light out of the pool entirely
/// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
/// </summary>
[Fact(Skip = "#176/#177: the camera-invariant pool is the DESIRED retail end-state " +
"(minimize_object_lighting has no global cap), but uncapping was live-tested " +
"2026-07-06 and reverted — it exposes unported per-cell light-reach semantics " +
"(through-floor light), the dynamic-vs-static falloff misassignment for weenie " +
"fixtures, and an unexplained striped floor artifact. Un-skip when the A7 " +
"dungeon-lighting arc lands per-cell registration (insert_light 0x0054d1b0) " +
"and raises MaxGlobalLights. See ISSUES #176/#177 + register row AP-85.")]
[Fact]
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
{
var mgr = new LightManager();
// 400 fixtures clustered near the origin (the "camera side" of the
// dungeon) — these fill every low camera-distance rank.
// 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
// visible from the target room). These would have filled every low
// camera-distance rank under the old camera-nearest cap.
const uint underRoom = 0xAAAA0102u;
for (int i = 0; i < 400; i++)
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1)));
mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom));
// The target torch: far from the origin-side camera (rank ~401), but
// squarely in range of the target cell around (200, 0, 0).
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du);
// The target torch: far from the origin-side camera, in the VISIBLE room
// cell, squarely in range of the target object around (200, 0, 0).
const uint targetRoom = 0xAAAA0101u;
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
mgr.Register(torch);
// The portal flood says only the target room is visible.
var visible = new System.Collections.Generic.HashSet<uint> { targetRoom };
Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
// Camera parked at the origin end — the torch must still light the cell.
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero);
// Camera parked at the origin end — the torch must still light the visible cell.
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch);
// The 400 under-room lights are NOT in the pool (their cell isn't visible).
int underRoomInPool = 0;
foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++;
// Camera next to the cell — the reference behavior.
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0));
// Camera next to the cell — the reference behaviour.
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch);
Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
Assert.True(torchSelectedFar,
"an in-range light of a visible cell was evicted by the camera-nearest snapshot cap — " +
"an in-range light of a VISIBLE cell was evicted by the snapshot cap — " +
"per-cell lighting would pop with camera movement (the #176/#177 mechanism)");
Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded
static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot,