fix(lighting): indoor interiors now lit — landblock-key bug + viewer light + weenie fixtures

The whole "indoor interiors read dark/flat vs retail" saga was ONE root-cause
bug: EnvCellRenderer.GetCellLightSet derived the landblock key as
`cellId & 0xFFFF0000` (0xXXYY0000), but landblocks are keyed by the streaming
id 0xXXYYFFFF. The lookup missed for EVERY cell, so SelectForObject never ran
and every EnvCell wall received ZERO point lights — torches, lanterns, the
viewer light, all of it. Confirmed by a [cell-light] probe: inBounds=False
selected=0 across 1M+ cell draws; after the fix inBounds=True selected=3-4.
User-confirmed the interiors now look like retail.

Three faithful additions that were blocked by the key bug (and only show now):
- Viewer light (LightManager.UpdateViewerLight): retail's SmartBox::set_viewer
  (0x00452c40) adds a white fill light at the player every frame via
  add_dynamic_light — the dominant interior fill (no sun indoors). acdream had
  NO dynamic lights at all. Params from the cdb capture: intensity 2.25,
  falloff 10, white, offset (0,0,2). Indoor-only via the AP-43 gate.
- Weenie fixture lights (OnLiveEntitySpawnedLocked): server-spawned lanterns/
  braziers carry Setup.Lights but the dat-static registration never saw
  CreateObject entities. Register on spawn; unregister on despawn
  (UnregisterOwner made unconditional). Register row AP-44.
- IndoorObjectReceivesTorches now excludes the 0xFFFF landblock marker (it is
  not an EnvCell) — fixes WbDrawDispatcherIndoorFlagTests.LandblockId_OutdoorFlag0
  (a #142 verification miss).

Divergence register: AP-44 (weenie light spawn-position, no movement tracking),
AP-47 (acdream's 128-light/camera-independent cap keeps interiors always-lit vs
retail's 40-nearest-to-player budget that pops in on approach — intentional,
user-preferred).

Investigation: docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md

Core 1505 / App 476 green. Visual gate: user-confirmed "looks like retail now."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 15:51:55 +02:00
parent ef5049fd58
commit 0d8b827721
6 changed files with 185 additions and 7 deletions

View file

@ -146,6 +146,8 @@ accepted-divergence entries (#96, #49, #50).
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
| AP-44 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) |
| AP-47 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
---

View file

@ -0,0 +1,75 @@
# A7 indoor torch/lantern lighting — why acdream interiors read dark (investigation)
**Date:** 2026-06-20 **Mode:** report-only investigation (no code changed)
**Trigger:** after #142 sun-gate (`ef5049f`) the Agent of Arcanum interior
(`0xA9B40171/0172`) still reads dark; user: "retail uses torches **and lanterns**
for lighting indoors."
## Retail oracle — the static-light bake (decomp, confirmed)
`SetStaticLightingVertexColors` (0x0059cfe0), per EnvCell mesh, per vertex:
```
for each vertex V:
acc = (0,0,0) // from BLACK; no ambient/sun here
for i in 0 .. world_lights.num_static_lights: // ALL lights — NO per-vertex/cell cap
L = sorted_static_lights[i]
if L.type==0: calc_point_light(V, acc, L) // 0x0059c8b0
else: acc += dot(N,dir)*scale*L.color
acc = clamp(acc, 0, 1) // per-channel, AFTER summing all
V.diffuse = encode(acc)
```
`calc_point_light` (0x0059c8b0): `range = falloff × static_light_factor(1.3)`;
back-face/range gated; `angular = dot_n/dist` (<1 m) else `dot_n/(dist²·dist)`;
`scale = angular·(1dist/range)·intensity`; per-channel cap `min(scale·c, c)`.
**Global cap:** `max_static_lights = 0x28 = 40` (`insert_light` 0x0054d1b0 keeps a
distance-sorted, replace-farthest list). So the bake sees up to **40 nearest static
lights — and sums ALL of them per vertex.** There is no 8-light cap on the static
wall bake. (The 8-light cap is the D3D *hardware* path for *dynamic objects*,
`minimize_object_lighting` 0x0054d480 — a different path.)
## acdream's torch path — three real divergences
| # | Divergence | Site | Effect |
|---|---|---|---|
| Bug A | **Weenie fixture lights never registered.** `RegisterOwnedLight` is called ONLY from `ApplyLoadedTerrainLocked` for dat-baked landblock + EnvCell statics (Setup `0x02xxxxxx`, `Lights.Count>0`). Server `CreateObject` weenies (`OnLiveEntitySpawnedLocked`) never register lights. | `GameWindow.cs:6733-6758` (registers); `GameWindow.cs:2696/3275` (weenie path — no register) | If a lantern/torch is a server weenie, its light is **entirely absent**. |
| Bug B | **Per-cell 8-light cap.** `GetCellLightSet``SelectForObject` keeps the **8 nearest-to-cell-center** lights (`MaxLightsPerObject=8`); 9th+ dropped. Retail sums up to 40. | `EnvCellRenderer.cs:1044`; `LightManager.cs:234-278` | Dense cells (dungeon corridors, fixture clusters) lose lights. **Not binding** for the Arcanum (only ~3 in range). |
| Bug C | **128 global camera pre-filter.** `BuildPointLightSnapshot` keeps the 128 nearest-**to-camera** lights (`MaxGlobalLights=128`), drops the rest before per-cell selection. Retail has no global per-frame cap on statics. | `LightManager.cs:179,208-211` | `registeredLights=129` ⇒ already at the cap; drops farthest-from-camera. Hurts far lights, not the near interior. |
Bug A·partial: the per-vertex bake is also approximated as **one 8-light set per
whole cell** (uniform across all the cell's vertices), vs retail's true per-vertex
bake — a fidelity gap (bright cluster smears across the cell), secondary to the
source-count gaps above.
The per-light shader formula (`pointContribution`) + the `min(pointAcc,1.0)` torch
clamp **match** retail's `calc_point_light` + final saturate — magnitude per light
is faithful. The gap is **missing / capped light SOURCES**, not per-light math.
## Ranked hypotheses (for the Agent of Arcanum specifically)
1. **H1 — the lanterns are server weenies ⇒ Bug A drops their light** (best fit
for the "lanterns" hint). The cell shows only ~3 *dat* torches; lanterns absent.
*Falsify:* pick a lantern in-game — guid `0x80…`/`0x7…` = weenie ⇒ confirmed.
2. **H4 — per-cell (not per-vertex) bake / magnitude** — if the Arcanum has no
weenie fixtures, the 3 dat torches are all there is and the per-cell-uniform bake
under-fills vs retail's per-vertex bake.
3. **H2/H3 (8-cap / 128-cap)** — real retail divergences, but **not binding** for
this building (≤3 lights in range, near camera). They matter for dense dungeons.
## Fix plan (faithful, per hypothesis)
- **H1 (likely):** register `Setup.Lights` for light-bearing weenies in
`OnLiveEntitySpawnedLocked`, mirroring `ApplyLoadedTerrainLocked:6742-6758`
(guard `setup.Lights.Count>0`); unregister on despawn (`UnregisterByOwner`).
Faithful: retail registers object lights regardless of static/dynamic origin.
- **H2:** raise the per-cell static bake cap from 8 toward retail's 40 (the cap is
retail-correct only for the *dynamic-object hardware* path, not the wall bake).
- **H3:** make the per-cell selection independent of the 128 camera pre-filter for
statics (retail's bake has no global count cap).
- **H4:** move the cell bake toward per-vertex (longer-horizon A7 LightBake work).
## What this is NOT
NOT a #142 sun-gate regression and NOT an ambient bug (ambient matches retail;
it's just dim rainy-twilight). It is missing / capped light **sources**.