# A7 dungeon lighting — Town Network "too dark" ROOT-CAUSED to the light cap (ambient RULED OUT). Pickup handoff. **Date:** 2026-07-09 **Status:** investigation COMPLETE (report-only). Fix NOT started — deferred to a new implementation session (this doc + the separate pickup prompt). **Milestone:** M1.5 — Indoor world feels right. Critical path item **A7 dungeon lighting (#79/#93/#176/#177)**. **Read FIRST in the new session:** `claude-memory/project_render_pipeline_digest.md`, `claude-memory/reference_retail_ambient_values.md`, then the two 2026-07-06 A7 docs (`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` + `docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md` — the source-confirmed retail per-cell light model, **including its two CORRECTION banners**), then this file. --- ## The user's report > "We are still missing some lighting indoors. It's darker than retail… Not the > torches, the ambient lighting. I'm checking in the Town Network — we have a > fountain in the main room but it's much darker and not lit up as in retail." The user's *observation is correct* (the room IS too dark). The *attribution* ("ambient") is what this investigation tested — and disproved. ## VERDICT: the ambient is retail-faithful. The darkness is the LIGHT CAP. Confirmed **three independent ways** that acdream's indoor ambient is correct: 1. **Decomp.** `CellManager::ChangePosition` (0x004559B0) sets the world ambient on a two-way branch on the player cell's `seen_outside` flag: sealed (`seen_outside==0`) → `SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)` (flat 0.2 white); seen-outside/outdoor → `SetWorldAmbientLight(calc_object_light(), LScape::ambient_color)` (bright sky ambient). acdream mirrors this exactly in `GameWindow.UpdateSunFromSky` (flat 0.2 white when `playerInsideCell`). 2. **This cell's dat flag.** The `[light]` probe in the Town Network fountain room: `insideCell=True ambient=(0.2,0.2,0.2) sun=0 playerCell=0x00070144`. acdream reads `SeenOutside` faithfully off the player's physics cell (`CellGraph.CurrCell`), so `insideCell=True` ⇒ the dat flag for 0x00070144 is genuinely "sealed." 3. **Live retail cdb capture (2026-07-09).** Attached cdb to retail acclient.exe (v11.4186, MATCHES `refs/acclient.pdb`), bp on `SmartBox::SetWorldAmbientLight` (`?SetWorldAmbientLight@SmartBox@@QAEXMK@Z`; args thiscall: level=float `dwo(@esp+4)`, color32 `dwo(@esp+8)`). Portalling from outdoors INTO the Town Network: | phase | levelraw | level | color32 | | |---|---|---|---|---| | outdoor | `0x3ee210c9`/`0x3ee2237d` | ≈0.44 | `0xffc864ff` = (200,100,255) purple | sky ambient | | **Town Network interior** | **`0x3e4ccccd`** | **0.20** | `0xffffffff` white | **matches acdream exactly** | `0x3e4ccccd` == `0.2f` bit-exact. **Retail runs the Town Network on the same 0.2 flat white ambient acdream does.** Raising the ambient would make acdream *wrong*. ## The REAL cause (the deferred A7 issue, now with a bigger repro) The `[light]` probe headline: **`registeredLights=463`, cap `MaxGlobalLights=128`, `activeLights=8`.** The Town Network hub registers **463** fixtures; `LightManager.BuildPointLightSnapshot(playerWorldPos)` keeps only the **128 nearest the player** over the WHOLE resident set, evicting ~335/frame. `SelectForObject` (the faithful per-object ≤8 pick) can only choose from the surviving 128, so the hub's fill lighting is starved and the room falls back to mostly the (correct) 0.2 ambient → reads dark. Retail keeps the same 0.2 ambient but scopes its light pool to the portal-flood **visible cells** (tiny 40+7 cap never bites), so every visible cell keeps its own fixtures → the room reads bright. This is exactly **#79/#93/#176/#177**, now reproduced at 463 fixtures (bigger than the Facility Hub's 366 where it was found). ## Current code state (verify — this doc dates fast) - `src/AcDream.Core/Lighting/LightManager.cs`: `MaxGlobalLights = 128`; `BuildPointLightSnapshot(Vector3 playerWorldPos)` caps the whole `_all` resident set at 128 **nearest-player** (camera-invariant — the camera-coupling bug is already fixed). There is **no** visible-cell scoping right now. - The visible-cell scoping WAS shipped once (`c500912b`) and **reverted** — the first attempt scoped by the *camera*-flood and churned the pool as the camera turned (the #176 seam-floor blink). See the pseudocode doc's CORRECTION #2. - Renderers already select per-cell (`EnvCellRenderer.cs:~1088`) and per-object (`WbDrawDispatcher.cs:~2095`) from `LightManager.PointSnapshot`. The only defect is how `PointSnapshot` is *built*. ## The fix shape (A7.L1 — port the visibility-scoped per-frame collection) Retail's model (source-confirmed, pseudocode doc §1): each cell owns a light list; per frame ONLY the visible cells push their lights into a small global player-nearest pool (40 static + 7 dynamic). The candidate pool is pre-scoped by *visibility*, so the tiny cap never bites. Port THAT, not a bigger cap: 1. **Build the `[indoor-light]` per-cell composition probe FIRST** (the A7 handoff's A7.L1 apparatus — prints per-cell active-light SET MEMBERSHIP: cellId → {pos, color, attenuation, dir, isDynamic}, plus registered/pool/dropped counts). The current `[light]` probe prints COUNTS, not membership — it can't show which cell lost which light. Build the membership probe before touching the pool. 2. **Tag each `LightSource` with its owning cell id** (`CellId`, populated at every registration site from `entity.ParentCellId`; viewer light stays `CellId==0`). Retail's `add_*_light(info, cellId, frame)` carries exactly this. 3. **Feed the per-frame pool from the currently-visible cells** (the portal-flood set the renderer already computes), not the whole `_all` set — CORRECTLY this time: scope by the **player/visible-cell flood**, NOT the camera position (that was the reverted attempt's bug). The pool is then naturally bounded and the 128 cap stops biting. Un-skip `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`. 4. **(Fix #2, follow-on) static curve for stationary fixtures** — a server-spawned wall lantern is stationary → static 1/d³ (range×1.3), reserve `isDynamic` (1/d, range×1.5) for genuinely MOVING lights. This is over-BRIGHTENING, not the darkness — separate slice. 5. **(Fix #3) hunt the striped floor artifact** with the full pool on (see the 2026-07-06 handoff §3). ## DO-NOT-RETRY / gotchas - **Do NOT raise the ambient.** 0.2 white is retail-verified three ways (this doc). - **Do NOT re-scope the pool by CAMERA position** (the reverted `c500912b` bug → #176 seam blink). Scope by the visible-cell flood / player. - **Do NOT just raise `MaxGlobalLights`** (128→1024 was tried `4d25e04d`, reverted — it exposed the through-floor purple wash + stripe artifacts the cap was masking). - **cdb note for next time:** the `Position→objcell_id` offset used here (`dwo(dwo(@esp+4))` at `ChangePosition` entry) was WRONG — the cell id printed stuck at a constant. The `level`/`color32` capture on `SetWorldAmbientLight` was correct. Find the real `Position` layout (or read the `Render::player_pos` global set at 0x00455ab6) before trusting a cdb-printed cell id. ## Apparatus / evidence produced this session - Live probe (already in the tree): `ACDREAM_PROBE_LIGHT=1` → `[light]` counts; `ACDREAM_PROBE_CELL=1` → `[cell-transit]`. Fountain-room reading: `insideCell=True ambient=(0.2,0.2,0.2) registeredLights=463 activeLights=8 playerCell=0x00070144`. - Retail cdb scripts (throwaway, in the session scratchpad): captured retail interior ambient = 0.2 white. The `SetWorldAmbientLight` bp recipe above is reusable. ## References - `docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md` — the retail model (source-confirmed) + BOTH correction banners. **The spec for the port.** - `docs/research/2026-07-06-176-177-handoff-A7-lighting.md` — the #176/#177 root cause, the reverted cap-raise, the tooling built. - `claude-memory/reference_retail_ambient_values.md` — ambient SSOT (now with the 2026-07-09 live-verified 0.2 interior confirmation). - Retail anchors: `CellManager::ChangePosition` 0x004559B0; `SmartBox::SetWorldAmbientLight` 0x004530a0; `CObjCell::add_light` 0x0052b1d0; `CEnvCell::add_dynamic_lights` 0x0052d410; `Render::insert_light` 0x0054d1b0 (cap 40+7); `Render::minimize_object_lighting` 0x0054d480; `calc_point_light` 0x0059c8b0 (static 1/d³ curve).