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

@ -94,6 +94,13 @@ investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`).
## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down) ## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
**⚠️ UPDATE 2026-07-06 (visual gate) — this is NOT lighting.** The A7 visible-cell
light-scoping fix shipped + was probe-validated, but the user's gate showed the stairs
STILL not visible looking back from the corridor (zoom-out changes the last-step case).
Eye-position/flood behavior ⇒ a portal-VISIBILITY miss at the stair cells
(0178/0182/0183), NOT the "its LIGHTS went dark" attribution recorded below. Re-diagnose
as visibility. See the render digest banner.
**Status:** OPEN **Status:** OPEN
**Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon) **Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon)
**Filed:** 2026-07-06 **Filed:** 2026-07-06
@ -142,6 +149,14 @@ the connecting opening, and stays rendered through the full descent.
## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent ## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
**⚠️ UPDATE 2026-07-06 (visual gate) — the light-set/camera-cap theory is REFUTED.** The
A7 scoping fix shipped + was probe-validated (~285 through-floor lights dropped/frame),
yet the purple flash was UNCHANGED. `[light-detail]` names the real cause: a single
over-bright purple POINT light — `kind=Point range=9 intensity=100 color=(0.784,0,0.784)`
(200/255,0,200/255) — washing the floor. NEXT: identify its owning entity/Setup; decide
whether `intensity=100` is a dat mis-parse or a portal/effect light we render wrong. Not
set-composition, not through-floor. See the render digest banner.
**Status:** OPEN **Status:** OPEN
**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon) **Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
**Filed:** 2026-07-06 **Filed:** 2026-07-06

View file

@ -179,7 +179,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | | AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = (speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
| AP-85 | **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) | | AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 19). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |

View file

@ -0,0 +1,268 @@
# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode)
**Date:** 2026-07-06 (continuation of the #176/#177 arc)
**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for
the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as
read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can
match it line-for-line.
> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff
> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that
> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that
> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap."
> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL
> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally
> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is
> the *collection phase*: retail rebuilds that global pool **each frame from only
> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood
> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate
> pool is pre-scoped by visibility, not by camera distance over the whole dungeon.
> This is a *better* fit for acdream than the handoff's framing, because acdream
> already computes the visible-cell set every frame (the portal flood).
---
## 1. The retail model, as source-confirmed
### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`)
- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the
cell's own `light_list` (a `DArray<LIGHTOBJ const*>`), `num_lights` counter.
Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights`
(line ~310877) and the light list straight from the dat CellStruct; the outdoor
path feeds it from the landblock's static object lights (caller at line ~285976,
`CObjCell::add_light(cell, lights->lightobj + i)`).
- So a light is DATA owned by the cell it sits in — dungeon torches live in the
EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list.
### 1.2 A cell pushes its own lights to the global pool
```
CObjCell::add_static_to_global_lights(cell): # 0x0052b350
for lightobj in cell.light_list[0 .. cell.num_lights):
if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light
Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame)
CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390
for lightobj in cell.light_list[0 .. cell.num_lights):
if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light
Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame)
```
The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its
owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the
block-offset distance math).
### 1.3 Per frame, ONLY visible cells contribute (the crux)
```
CEnvCell::add_dynamic_lights(): # 0x0052d410
for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set
CObjCell::add_dynamic_to_global_lights(cell)
# static counterpart — same function that ends at 0x0052def0 (line ~311650):
for cell in CEnvCell::visible_cell_table: # SAME visible set
cell.init_static_objects()
CObjCell::init_objects(cell)
CObjCell::add_static_to_global_lights(cell)
```
`visible_cell_table` is the set of cells reached by the portal flood from the
viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A
dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells'
lights to the global pool.** This is the entire reason retail doesn't churn.
### 1.4 The global pool is small and player-sorted (`insert_light`)
```
Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0
distsq = 0
if info.type == 0: # point light
# squared distance from THIS light to the PLAYER, across the cell block offset
blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId)
distsq = |(frame.origin + blockOff) - player_pos.frame.origin|²
# ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq)
# insertion-sort into sorted[] ascending by distancesq (nearest player first),
# capped at maxCount; when full, evict the farthest-from-player.
Render::add_static_light(info, cellId, frame): # 0x0054d3e0
insert_light(max_static_lights, &world_lights.num_static_lights,
world_lights.static_lights, world_lights.sorted_static_lights,
info, cellId, frame, max_dynamic_lights + 1)
Render::add_dynamic_light(info, cellId, frame): # 0x0054d420
insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights,
world_lights.dynamic_lights, world_lights.sorted_dynamic_lights,
info, cellId, frame, 1)
```
**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`)
init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal`
(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) —
always small (tens of static, single-digit dynamic). Retail deliberately keeps the
global pool tiny; it can, because §1.3 pre-scopes the input by visibility.
### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject`
```
Render::minimize_object_lighting(): # 0x0054d480
reset_active_lights_state()
used = 0
# DYNAMIC lights first (priority), pre-sorted nearest-player:
for i in 0 .. num_dynamic_lights:
if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep:
add_active_light(i, 2); used += 1
else: dynamic_light_used[i] = 0
# STATIC lights fill remaining slots:
for i in 0 .. num_static_lights:
if used >= 8: static_light_used[i] = 0; continue
L = sorted_static_lights[i]
if L.info.type != 0: # non-point (directional): always use
add_active_light(i, 1); used += 1
else: # point: sphere-overlap test
reach = L.range + local_object_radius
if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap
add_active_light(i, 1); used += 1
else: static_light_used[i] = 0
enable_active_lights()
```
acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap.
The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two
separate player-sorted arrays; acdream selects from one camera-sorted snapshot.
Minor — parity item, not the #176/#177 cause.
### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference
`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for
static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure:
```
calc_point_light(vertex, &r, &g, &b, info):
d = |info.offset.origin - vertex.pos|
range = info.falloff * static_light_factor # static_light_factor ≈ 1.3
if d < range:
# N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0
if faces_light:
atten = <1/d-ish curve, x87 SEE WARNING>
f = atten * (1 - d/range) * info.intensity
r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour
g += clamp(f * info.color.g, ..)
b += clamp(f * info.color.b, ..)
```
> ⚠️ **Do NOT port the exact `atten` curve from this BN pseudo-C.** Lines
> 425331425341 are dense x87 FPU register juggling (`distsq/dist` vs
> `1.5/(distsq·dist)` branch on `distsq ≷ 1`), exactly the "x87 dropout / misread"
> class the project has been burned by twice (see `feedback_bn_decomp_field_names`,
> `feedback_retail_binary_dispatch`). When implementing fix #2, cross-reference a
> SECOND source (ACE / ACViewer static-light port, or the Ghidra decomp) and pin
> the curve with a conformance test before trusting it. The STRUCTURE above
> (range = falloff × static_light_factor, per-vertex N·L, intensity scale, colour
> clamp) is solid; the attenuation exponent is the part to verify.
---
## 2. Why #176/#177 happen in acdream (refined root cause)
acdream `LightManager` registers **every** fixture permanently into `_all` (server
weenie spawns + EnvCell static hydration), then `BuildPointLightSnapshot` caps at
`MaxGlobalLights=128` **nearest-CAMERA** over the WHOLE registered set. In the
Facility Hub (366 fixtures) that evicts 238/frame by camera distance; `SelectForObject`
can only choose from the surviving 128, so an in-range torch of a *visible* cell that
ranks past the cap drops from that cell's 8-set and the per-cell Gouraud lighting pops
as the camera moves (#176 seam flash / #177 stair-room pop-in).
**Retail never has 366 candidates.** It rebuilds `world_lights` each frame from ONLY
the visible cells' `light_list`s (§1.3), so the candidate pool is a handful of cells —
under the 40+7 cap — and nothing gets evicted. The camera-distance cap is a backstop
that essentially never fires because the input is already visibility-scoped.
This also explains the **through-floor purple wash** the cap-raise exposed: acdream's
flat world-space sphere-overlap of all 366 lights let an under-room portal light reach
up through a solid floor. Retail's under-room cell isn't in the corridor's
`visible_cell_table` (the flood doesn't pass through the solid floor), so its light
never enters the pool. Per-cell reach = *the light is only a candidate when its cell
is visibly flooded.*
---
## 3. The fix (materially different from "just uncap MaxGlobalLights")
**Port the visibility-scoped per-frame collection**, not a bigger cap:
1. **Tag each `LightSource` with its owning cell id** (add `CellId` to `LightSource`;
populate at every registration site from the cell/landblock in scope). Retail's
`add_*_light(info, cellId, frame)` carries exactly this.
2. **Build the per-frame point-light pool from ONLY the currently-visible cells**
the portal-flood set the renderer already computes — instead of the whole `_all`
set. This is retail's `add_*_lights over visible_cell_table`. The pool is then
naturally bounded; `MaxGlobalLights` stops biting (can keep 128 or adopt retail's
40+7 as a documented backstop). The Skip'd end-state pin
(`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`)
asserts exactly this: an in-range light of a visible cell is never camera-evicted.
3. **Fix #2 — static curve for stationary fixtures.** Decide `isDynamic` by whether
the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern
is stationary → static 1/d³ (range × 1.3), reserving `isDynamic` (range × 1.5, 1/d)
for genuinely moving lights (portal swirls, projectiles). See §1.6 warning.
4. **Fix #3 — hunt the striped floor artifact** with the full (now visibility-scoped)
pool on. Invisible at cap 128; see the handoff for the two leading guesses.
5. **THEN uncap / adopt the retail cap** and un-skip the end-state pin.
### 3.1 acdream integration surface — as SHIPPED (slice 1: visible-cell scoping)
The renderers already select per-cell (`EnvCellRenderer.cs:1088`) and per-object
(`WbDrawDispatcher.cs:2095`) from `LightManager.PointSnapshot`; the ONLY defect was
that `PointSnapshot` was built by capping the whole `_all` set at 128 nearest-CAMERA.
The fix scopes that pool to visible cells. Concretely:
1. **`LightSource.CellId`** (new `uint`, 0 = cell-less/global). Retail's per-light cell
(insert_light arg6 → RenderLight +0x6c).
2. **`LightInfoLoader.Load(..., uint cellId = 0)`** propagates it onto each light.
3. **Both registration sites tag the owning cell** from `entity.ParentCellId`:
- Site A live weenie fixtures — `GameWindow.cs:~3682` (`cellId: entity.ParentCellId ?? 0u`).
- Site B dat EnvCell statics — `GameWindow.cs:~7696` (same).
- Viewer fill light keeps `CellId == 0` (always in the pool — retail's per-frame
`add_dynamic_light(&viewer_light, objcell_id)` is unconditional).
4. **`LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet<uint>? visibleCells)`** —
a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR
`visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop.
5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot
(null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the
portal flood + all cell/entity draws happen LATER, INSIDE
`RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context
callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right
after `prepareCells` (every cell drawn this frame) is finalized and BEFORE
`PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it
to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`).
The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and
`EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices.
`SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is
unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login /
fly) never invokes the callback and keeps the legacy null-scope full pool.
6. **Validation apparatus**`ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited
`[indoor-light]` line per second with the scoped-pool SET COMPOSITION
(`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered /
droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't
give (#176/#177 lived in set membership).
Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices.
---
## 4. Source anchors (for the register + future sessions)
| Retail fn | Addr | Role |
|---|---|---|
| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list |
| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool |
| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool |
| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic |
| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static |
| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat |
| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped |
| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light |
| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) |
| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level |
| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) |
| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 |

View file

@ -3684,7 +3684,8 @@ public sealed class GameWindow : IDisposable
ownerId: entity.Id, ownerId: entity.Id,
entityPosition: entity.Position, entityPosition: entity.Position,
entityRotation: entity.Rotation, 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) foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls); _lightingSink.RegisterOwnedLight(ls);
} }
@ -7697,7 +7698,8 @@ public sealed class GameWindow : IDisposable
datSetup, datSetup,
ownerId: entity.Id, ownerId: entity.Id,
entityPosition: entity.Position, 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) foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls); _lightingSink.RegisterOwnedLight(ls);
} }
@ -9368,6 +9370,11 @@ public sealed class GameWindow : IDisposable
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
Camera = camera, Camera = camera,
CameraWorldPosition = camPos, 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, Frustum = frustum,
PlayerLandblockId = playerLb, PlayerLandblockId = playerLb,
AnimatedEntityIds = animatedIds, AnimatedEntityIds = animatedIds,

View file

@ -129,6 +129,12 @@ public sealed class RetailPViewRenderer
prepareCells = _lookInPrepareScratch; 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( _envCells.PrepareRenderBatches(
ctx.ViewProjection, ctx.ViewProjection,
ctx.CameraWorldPosition, ctx.CameraWorldPosition,
@ -1084,6 +1090,16 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public Action? DrawUnattachedSceneParticles { get; init; } public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; } public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { 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 public sealed class RetailPViewFrameResult

View file

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

View file

@ -218,11 +218,33 @@ public sealed class LightManager
/// per-object selection. /// per-object selection.
/// </summary> /// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos) 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(); _pointSnapshot.Clear();
foreach (var light in _all) foreach (var light in _all)
{ {
if (!light.IsLit || light.Kind == LightKind.Directional) continue; 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(); light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
_pointSnapshot.Add(light); _pointSnapshot.Add(light);
} }
@ -231,6 +253,11 @@ public sealed class LightManager
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights); _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) ────────────── // ── 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 Range = 10f; // metres, hard cutoff
public float ConeAngle = 0f; // radians, Spot only public float ConeAngle = 0f; // radians, Spot only
public uint OwnerId; // attached entity id; 0 = world-global 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 IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3) // 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; } = public static bool ProbeLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; 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 // 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 // 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 // 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 = private static bool _probeEnvCellEnabled =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";

View file

@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting;
public sealed class LightManagerTests 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 => new LightSource
{ {
Kind = LightKind.Point, Kind = LightKind.Point,
@ -14,6 +14,7 @@ public sealed class LightManagerTests
Range = range, Range = range,
IsLit = lit, IsLit = lit,
OwnerId = ownerId, OwnerId = ownerId,
CellId = cellId,
}; };
[Fact] [Fact]
@ -176,6 +177,55 @@ public sealed class LightManagerTests
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); 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] [Fact]
public void SelectForObject_EmptySnapshot_ReturnsZero() public void SelectForObject_EmptySnapshot_ReturnsZero()
{ {
@ -258,56 +308,59 @@ public sealed class LightManagerTests
} }
/// <summary> /// <summary>
/// #176/#177 (2026-07-06): SelectForObject is camera-independent (the test /// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell
/// above), but the SNAPSHOT it selects from was not — BuildPointLightSnapshot /// scoping, not "uncap"). Before: <c>BuildPointLightSnapshot</c> kept only the
/// kept only the MaxGlobalLights nearest THE CAMERA. In the Facility Hub /// <c>MaxGlobalLights</c> nearest THE CAMERA over the WHOLE registered set, so in
/// (366 registered fixtures vs the old cap of 128), an in-range torch of a /// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank
/// VISIBLE cell could rank past the cap and be evicted, so the cell's 8-set /// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting)
/// (and its Gouraud vertex lighting) flipped as the camera moved — per-cell /// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix
/// lighting pops at seam granularity (#176's flash), and a whole room's /// is retail's per-frame collection: the pool is built from ONLY the lights of the
/// torches vanishing until approach (#177's pop-in). Retail's /// currently-VISIBLE cells (<c>CObjCell::add_*_to_global_lights</c> over
/// minimize_object_lighting (0x0054d480) has NO global camera-nearest cap — /// <c>CEnvCell::visible_cell_table</c>), so the visible pool is a handful of cells,
/// every registered light reaching the object is a candidate. This pins the /// the cap never bites, and a visible cell's in-range light is never camera-evicted.
/// end-to-end property: a light in range of an object stays selected no /// The same scoping keeps a NON-visible cell's light out of the pool entirely
/// matter where the camera is, at Facility-Hub-scale light counts. /// (through-floor prevention). See <c>docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md</c>.
/// </summary> /// </summary>
[Fact(Skip = "#176/#177: the camera-invariant pool is the DESIRED retail end-state " + [Fact]
"(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.")]
public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant() public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant()
{ {
var mgr = new LightManager(); var mgr = new LightManager();
// 400 fixtures clustered near the origin (the "camera side" of the // 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not
// dungeon) — these fill every low camera-distance rank. // 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++) 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 // The target torch: far from the origin-side camera, in the VISIBLE room
// squarely in range of the target cell around (200, 0, 0). // cell, squarely in range of the target object around (200, 0, 0).
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du); const uint targetRoom = 0xAAAA0101u;
var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom);
mgr.Register(torch); 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]; Span<int> sel = stackalloc int[LightManager.MaxLightsPerObject];
// Camera parked at the origin end — the torch must still light the cell. // Camera parked at the origin end — the torch must still light the visible cell.
mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero); mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible);
int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel); int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch); 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. // Camera next to the cell — the reference behaviour.
mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0)); mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible);
int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel); int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel);
bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch); 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(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it");
Assert.True(torchSelectedFar, 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)"); "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( static bool SelectedContains(
System.Collections.Generic.IReadOnlyList<LightSource> snapshot, System.Collections.Generic.IReadOnlyList<LightSource> snapshot,