acdream/docs/research/2026-07-09-a7-townnet-ambient-verdict-lightcap-handoff.md
Erik 388f3ed307 docs(A7): Town Network "too dark" root-caused — ambient RULED OUT (live cdb 0.2 white == retail), cause is the 463>128 light cap
Investigation handoff for the deferred A7 per-cell light-scoping fix (#79/#93/#176/#177).
Ambient verified retail-faithful three ways (decomp + dat SeenOutside + live retail
cdb capture: SetWorldAmbientLight level 0x3e4ccccd == 0.2f bit-exact). No fix applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:18:05 +02:00

141 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).