diff --git a/docs/research/2026-07-09-a7-townnet-ambient-verdict-lightcap-handoff.md b/docs/research/2026-07-09-a7-townnet-ambient-verdict-lightcap-handoff.md new file mode 100644 index 00000000..a9cd3751 --- /dev/null +++ b/docs/research/2026-07-09-a7-townnet-ambient-verdict-lightcap-handoff.md @@ -0,0 +1,141 @@ +# 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).