diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bf4cfaa5..9f19098e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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) +**⚠️ 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 **Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon) **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 +**⚠️ 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 **Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon) **Filed:** 2026-07-06 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index baeab278..abb563ae 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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-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 1–9). 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-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` | diff --git a/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md b/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md new file mode 100644 index 00000000..abc4040a --- /dev/null +++ b/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md @@ -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`), `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 +> 425331–425341 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? 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 | diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f078741a..8153ed4c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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, diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 30db299f..5ac022b3 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -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>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } + + /// 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: CObjCell::add_*_to_global_lights walked over + /// CEnvCell::visible_cell_table). 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. + public Action>? RebuildScopedLights { get; init; } } public sealed class RetailPViewFrameResult diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index f3b02348..b3e9d4a8 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -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(); 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, }; diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 3ba78d16..c4e45451 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -218,11 +218,33 @@ public sealed class LightManager /// per-object selection. /// public void BuildPointLightSnapshot(Vector3 cameraWorldPos) + => BuildPointLightSnapshot(cameraWorldPos, visibleCells: null); + + /// + /// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame + /// light collection (CObjCell::add_*_to_global_lights 0x0052b350/0x0052b390 + /// walked over CEnvCell::visible_cell_table 0x0052d410). When + /// is non-null (an indoor root with a portal + /// flood), a cell-tagged light is a candidate ONLY when its + /// is in the visible set — a cell-less light (CellId == 0: 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 + /// never evicts a visible cell's in-range light (the #176/#177 mechanism). When + /// is null (outdoor root / no flood) the behaviour + /// is unchanged from the legacy full-pool path. + /// + public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet? 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) ────────────── diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index c07e79b9..3d0bd705 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -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) diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 872285e4..69369b9a 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -271,6 +271,33 @@ public static class RenderingDiagnostics public static bool ProbeLightEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; + /// + /// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the + /// [light] counts could not provide (the #176/#177 discriminator: the bug + /// lived in set MEMBERSHIP, not counts). When true, the scoped + /// LightManager.BuildPointLightSnapshot emits ONE rate-limited + /// [indoor-light] line describing the visible-cell-scoped point-light pool + /// (see ): + /// + /// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R> + /// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...] + /// + /// This validates the A7 fix's load-bearing assumption end-to-end: + /// + /// cellLess==pool (every pool light is CellId 0) ⇒ + /// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op. + /// pool==cellLess while registered is large in a + /// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the + /// room would go dark. + /// droppedNonVisible>0 with byCell tracking the + /// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the + /// dropped ones). + /// + /// Output-only, inert when off. Initial state from ACDREAM_PROBE_INDOOR_LIGHT=1. + /// + 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; + + /// + /// A7.L1 — emit ONE rate-limited [indoor-light] line describing the + /// visible-cell-scoped point-light pool: the SET COMPOSITION the [light] + /// counts can't show. Cheap no-op when is + /// false; otherwise fires at most once per second. Called from the scoped + /// LightManager.BuildPointLightSnapshot (visibleCells != null path). + /// + /// Size of the portal-flood visible-cell set this frame. + /// Every registered light (LightManager._all). + /// The visible-cell-scoped point-light pool just built. + public static void EmitIndoorLight(int visibleCellCount, + IReadOnlyList allRegistered, + IReadOnlyList 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(); + 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"; diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 801b50d7..139e0d7a 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -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 { 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 { 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 } /// - /// #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: BuildPointLightSnapshot kept only the + /// MaxGlobalLights 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 (CObjCell::add_*_to_global_lights over + /// CEnvCell::visible_cell_table), 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 docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. /// - [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 { targetRoom }; Span 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 snapshot,