merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch

Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:01:20 +02:00
commit 31d7ffd253
27 changed files with 2327 additions and 103 deletions

View file

@ -46,9 +46,9 @@ Copy this block when adding a new issue:
--- ---
## #140 — Toolbar interactivity — selected-object display ## #141 — Toolbar interactivity — selected-object display
**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred) **Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-06-17 **Filed:** 2026-06-17
**Component:** ui — D.5 toolbar / selection **Component:** ui — D.5 toolbar / selection
@ -57,7 +57,7 @@ Copy this block when adding a new issue:
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port. **Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46. - **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46.
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #140:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4). - **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4).
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`). **Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
@ -67,6 +67,27 @@ Copy this block when adding a new issue:
--- ---
## #140 — A7 "Fix D": outdoor objects too bright near torches
**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C)
**Filed:** 2026-06-18
**Component:** render — point lighting on outdoor objects
**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*.
**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.**
**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired).
**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`.
**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches).
---
## #139 — D.2b retail UI polish: chat text colors + buttons ## #139 — D.2b retail UI polish: chat text colors + buttons
**Status:** OPEN **Status:** OPEN

View file

@ -116,7 +116,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint | | AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E | | AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) | | AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
@ -136,7 +136,8 @@ accepted-divergence entries (#96, #49, #50).
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ 0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 | | AP-43 | Per-object torch (point/spot) lighting is gated on the OBJECT's own cell: an object selects the static wall-torches ONLY when its `ParentCellId` is an EnvCell (`(id & 0xFFFF) >= 0x0100`); outdoor objects (building exterior shells with null ParentCellId, outdoor scenery, outdoor creatures) get the SUN + ambient and NO torches. This is the faithful port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and the outdoor landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, before `LScape::draw`) so outdoor objects are never torch-lit (closes the Holtburg meeting-hall facade torch-flood — A7 Fix D round 2, the dat's intensity-100 `falloff 6` orange torches were washing the exterior shell). **Residual approximation:** the sun/no-sun half is a per-FRAME global keyed on the PLAYER being inside a cell (`UpdateSunFromSky` zeroes the sun when `playerInsideCell`), NOT retail's per-draw-STAGE `useSunlight` toggle. So a mixed-stage frame (standing in a doorway / look-in) lights through-aperture objects with the player's regime, not the object's stage regime | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches` + `ComputeEntityLightSet`); sun half `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`) | The visible case — player OUTSIDE, looking at an outdoor building/scenery — is now exactly faithful (sun+ambient, no torches); player INSIDE a cell gets torches with the sun globally killed = faithful indoor regime. Cross-aperture mismatch only affects objects seen THROUGH a doorway from the other lighting context | A through-aperture interior object viewed from outside gets the sun (player outside) instead of retail's indoor torches-no-sun; an outdoor object viewed from inside gets no sun (player inside) instead of retail's sunlit outside stage — doorway/look-in frames only, not the standalone outdoor or indoor case | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; outside stage `PView::DrawCells` 0x005a485a (`useSunlightSet(1)`); `minimize_object_lighting` 0x0054d480; `config_hardware_light` Range=falloff×`rangeAdjust`(1.5) 0x0059ad30 |
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | | AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` |
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | | AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | | AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
@ -200,6 +201,7 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) | | UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
--- ---

View file

@ -0,0 +1,140 @@
# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF
**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main)
**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured
values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method).
This session made acdream's outdoor + ambient lighting retail-faithful by grounding
everything in **live cdb on the retail client** (no guessing). Three fixes shipped;
a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but
**deliberately NOT implemented** because the math contradicts the observed result —
one more capture is needed first.
## SHIPPED this session (all on `main`)
| Fix | Commit | What | Result |
|---|---|---|---|
| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" |
| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 |
| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" |
**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4
GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int
light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` +
`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's
light set once per entity (like `_currentEntitySlot`), threads it parallel to the
matrices.
**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the
PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before
`LScape::set_sky_position`'s world transform. cdb read retail's actual
`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the
world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`,
`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it
dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky
tests green (old tests pinned the inflated magnitude — updated to cdb-verified).
## KEY LESSON: the "too purple" was NEVER a bug
The user's side-by-side ("acdream too purple, retail neutral") was a comparison
**across different times of day**. Live cdb at the SAME game time + DayGroup proved
acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail
exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the
sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness*
was wrong (Fix C). Don't re-investigate the purple.
---
## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18)
**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream
vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE,
yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these
walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify +
4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** —
not because it'd be too bright, but because it's the wrong path entirely.
### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems)
- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) →
`D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light`
(0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex
colours. AC town buildings are EnvCell structures, so their walls take this path.
- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light``insert_light` (0x0054D1B0)
`config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170)
enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware-
enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free
GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen —
carries DYNAMIC lights for cells, not the wall torches.
### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`)
Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`;
`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1d/range)·intensity·(wrap/norm)`;
then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds
**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from
**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the
sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range
lights stacking past 1.0; a hall has a handful, each warm-capped.
### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`)
`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104,
`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights`
@ +0x3588. Captured standing in Holtburg:
- **num_static_lights = 38**, **num_dynamic_lights = 2.**
- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 12): viewer light `intensity=2.25 falloff=10
color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA.
→ **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.**
- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange
`(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 35
(→ bake range ~3.96.5 m). Torches DO carry intensity=100, but the per-channel cap
pins each to its warm colour ⇒ retail walls go warm, not white.
### acdream's actual bug — TWO real causes (both verified in source)
- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.**
`mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS
sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is
one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light
cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches
into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the
torch sum on its OWN (from black); ambient/sun are a separate term.
- **D-2 (state, compounding): EnvCell shell SSBO binding leak.**
`EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER
4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at
:204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a
cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound —
a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls.
- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is
the in-shader version missing the clamp shape.
### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule)
- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE
adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the
point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files:
`mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the
single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch
bakes warm-but-≤[0,1], NOT white.
- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set)
for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet`
(LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP
(needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal.
- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the
point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A
(`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap.
- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's
color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the
baked walls.
---
## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb)
- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]`
- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group`
- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770
- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color`
- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0
- Pattern: `.formats poi(<addr>)` for floats, `dwo(<addr>)` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first.
- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup).
## Build / run
`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard
`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks
the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green.

View file

@ -0,0 +1,152 @@
# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT
**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate)
**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`
**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed +
correct but **did NOT fix the visible symptom** — they were the wrong subsystem.
---
## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all
**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT
"shorten torch reach" — it is "outdoor objects receive NO torches."**
**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg
neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance
torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads
this **faithfully**`LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO
Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not
be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT
inflated. So "acdream 6 vs retail 4" was a red herring.
**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow
`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is
**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`.
The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right
before `LScape::draw`), so when the building EXTERIOR shell is drawn
(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 →
CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the
**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The
static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller
`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the
wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights
OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the
non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30
uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100`
and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never
enables it outdoors.)
**The three retail lighting regimes (now all mapped):**
1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun.
→ acdream mode 1 (EnvCell). ✓ already correct.
2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**.
3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**.
acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3.
**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object
torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`)
via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId
null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail.
The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell
(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual:
acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's
per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests:
`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green;
App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.**
**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach /
change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells.
The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach
to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any
reach is the bug.
---
## TL;DR — what the visible bug actually is (and is NOT)
The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs
lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak.
Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is:
1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail
~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool.
**CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match
retail ("looks much better", user 2026-06-19).
2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0
runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual
is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches).
## Render-path facts (source-verified, workflow `wf_c4ad8cf8`)
- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`,
built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher**
which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so
**D-4 (EnvCell walls get no sun) never touched it**.
- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by
WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT
(mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works).
- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells
from the dat EnvCell list — never `info.Buildings`, never characters.
- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare
`WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the
ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one
shared uniform; each renderer re-sets it before its draws.
## Ground truth (live cdb retail + acdream probe, SAME-INSTANT)
- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`.
→ same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream
purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it).
- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude
**0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky
state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects.
- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm
`(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT
pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs).
- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5).
acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5.
## The OPEN question to resolve FIRST on resume (don't guess)
acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at
**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different
torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in
acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch
Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff`
for the same one. Then:
- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion
(the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's.
- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is
the issue; tighten how building shells receive torches (the per-vertex range gate already
localises, so this is unlikely — favour the Falloff hypothesis).
## Proposed fix (after the falloff is confirmed)
Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows
a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs`
(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register
row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause:
object point lights OFF == retail-match).
## State of the committed work (KEEP — all correct, just off-target for the visible bug)
| Commit | What | Verdict |
|---|---|---|
| `180b4af` | D-1 clamp point sum on its own | faithful; keep |
| `39c70f0` | D-2 prep — LightBake conformance test | keep |
| `cf62793` | D-1 shader clamp | keep |
| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep |
| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep |
| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) |
`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted**
(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge.
## DO-NOT-RETRY (cost a lot this session)
- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the
building is a mode-0 OBJECT, none of that path lights it.
- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446).
- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725).
- The visible building bug is the **torch REACH** (confirmed by isolation); start there.

View file

@ -0,0 +1,603 @@
# A7 Fix D — torch over-brightness on indoor walls — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white.
**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`.
**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md)
**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 35 → Range 3.96.5 m), warm colours `(1.0, 0.588, 0.314)` orange and `(0.980, 0.843, 0.612)` cream. The per-channel cap pins each torch to its colour ⇒ warm, never white.
**Pre-flight (every task):** worktree is `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` (cwd). Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. The retail client locks the DLLs — it must be closed before a build.
---
## Task 1: Extract `GlobalLightPacker` (shared, pure) + refactor `WbDrawDispatcher`
Pull the global-light SSBO float packing out of `WbDrawDispatcher.UploadGlobalLights` into a pure Core helper so `EnvCellRenderer` (Task 4) reuses the exact same layout. No behaviour change.
**Files:**
- Create: `src/AcDream.Core/Lighting/GlobalLightPacker.cs`
- Create: `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1813-1848` (`UploadGlobalLights`)
- [ ] **Step 1: Write the failing test**
Create `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`:
```csharp
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
public class GlobalLightPackerTests
{
[Fact]
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
{
var light = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(10f, 20f, 30f),
WorldForward = new Vector3(0f, 0f, 1f),
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
Intensity = 100f,
Range = 5.2f,
ConeAngle = 0f,
};
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
Assert.Equal(1, count);
Assert.True(buffer.Length >= 16);
// posAndKind
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
// dirAndRange
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
Assert.Equal(5.2f, buffer[7]);
// colorAndIntensity
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
Assert.Equal(100f, buffer[11]);
// coneAngleEtc
Assert.Equal(0f, buffer[12]);
}
[Fact]
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
{
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(null, ref buffer);
Assert.Equal(0, count);
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
Expected: FAIL — `GlobalLightPacker` does not exist (compile error).
- [ ] **Step 3: Implement `GlobalLightPacker`**
Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`:
```csharp
using System;
using System.Collections.Generic;
namespace AcDream.Core.Lighting;
/// <summary>
/// Packs a point-light snapshot into the flat float layout the bindless mesh
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
/// </summary>
public static class GlobalLightPacker
{
public const int FloatsPerLight = 16;
/// <summary>
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
/// </summary>
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
{
int n = snapshot?.Count ?? 0;
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
if (buffer.Length < floatsNeeded)
buffer = new float[floatsNeeded + FloatsPerLight * 16];
Array.Clear(buffer, 0, floatsNeeded);
for (int i = 0; i < n; i++)
{
var L = snapshot![i];
int o = i * FloatsPerLight;
buffer[o + 0] = L.WorldPosition.X;
buffer[o + 1] = L.WorldPosition.Y;
buffer[o + 2] = L.WorldPosition.Z;
buffer[o + 3] = (int)L.Kind;
buffer[o + 4] = L.WorldForward.X;
buffer[o + 5] = L.WorldForward.Y;
buffer[o + 6] = L.WorldForward.Z;
buffer[o + 7] = L.Range;
buffer[o + 8] = L.ColorLinear.X;
buffer[o + 9] = L.ColorLinear.Y;
buffer[o + 10] = L.ColorLinear.Z;
buffer[o + 11] = L.Intensity;
buffer[o + 12] = L.ConeAngle;
}
return n;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer**
In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with:
```csharp
private unsafe void UploadGlobalLights()
{
int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int count = n > 0 ? n : 1; // never zero-size
fixed (float* gp = _globalLightData)
UploadSsbo(_globalLightsSsbo, 4, gp,
count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float));
}
```
Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it.
- [ ] **Step 6: Build and run the full Core test suite**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
Expected: build green; all tests pass (no regression — the packing is byte-identical).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches
`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check).
**Files:**
- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`
- [ ] **Step 1: Write the conformance test**
Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
/// <summary>
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
/// </summary>
public class LightBakeConformanceTests
{
private static LightSource OrangeTorch(Vector3 pos) => new()
{
Kind = LightKind.Point,
WorldPosition = pos,
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
Intensity = 100f,
Range = 4f * 1.3f, // falloff 4 × static_light_factor
IsLit = true,
};
[Theory]
[InlineData(1f)]
[InlineData(2f)]
[InlineData(3f)]
[InlineData(4f)]
[InlineData(5f)]
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
{
// Wall vertex at the origin, normal facing the torch (+X). Torch out along +X.
var vtx = Vector3.Zero;
var normal = Vector3.UnitX;
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
// Every channel bounded to [0,1] — intensity=100 must NOT blow to white.
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
// Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering.
if (c.X > 0f)
{
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
}
}
[Fact]
public void BeyondRange_ContributesNothing()
{
var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
Assert.Equal(Vector3.Zero, c);
}
[Fact]
public void ManyOverlappingIntenseTorches_StillClampToOne()
{
// Eight near-white intensity-100 torches all 1.5 m from the vertex: the
// [0,1] saturate must hold (no overflow past 1.0 per channel).
var lights = new List<LightSource>();
for (int i = 0; i < 8; i++)
lights.Add(new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
Intensity = 100f,
Range = 5.2f,
IsLit = true,
});
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
}
}
```
- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests`
Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.)
- [ ] **Step 3: Commit**
```bash
git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs
git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert`
Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check.
**Files:**
- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`)
- [ ] **Step 1: Apply the clamp split**
Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`):
```glsl
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
vec3 lit = uCellAmbient.xyz;
// SUN / directional — material-lit term (added with ambient, NOT into the
// torch sum), unchanged from before.
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
// SetStaticLightingVertexColors sums the static point lights from BLACK and
// clamps the SUM to [0,1] before anything else (it is a baked emissive term),
// so a few warm intensity-100 torches can't push the whole pixel to white the
// way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor
// (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap
// inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);
}
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
return lit; // frag still does the final min(lit, 1.0)
}
```
(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.)
- [ ] **Step 2: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.)
- [ ] **Step 3: Review the math against the oracle**
Confirm by reading both side-by-side that the shader's point path now matches `LightBake`:
- `mesh_modern.vert` `pointContribution``LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal.
- new `min(pointAcc, vec3(1.0))``LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum.
No code change expected here — this is the verification step the commit message cites.
- [ ] **Step 4: Commit**
```bash
git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert
git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140)
accumulateLights folded ambient+sun+torches into one accumulator clamped only
in the frag, so a few warm intensity-100 torches blew walls/objects to white.
Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp
to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches
LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5)
Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern.
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot)
- [ ] **Step 1: Add fields + the per-frame snapshot setter**
In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add:
```csharp
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
// left bound. binding=4 = global point-light snapshot (same data/indices as the
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
private uint _globalLightsSsbo; // binding=4
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private uint _instLightSetSsbo; // binding=5
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
```
Near `SetClipRouting` (~262) add the per-frame setter:
```csharp
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
/// reference this snapshot, which is also uploaded to binding=4 here, so the
/// pass is self-contained. Null/empty ⇒ shells receive no point lights.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
=> _pointSnapshot = snapshot;
```
- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`**
In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add:
```csharp
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
_gl.GenBuffers(1, out _globalLightsSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
_gl.GenBuffers(1, out _instLightSetSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
null, GLEnum.DynamicDraw);
```
- [ ] **Step 3: Add the per-cell light-set helper**
Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`:
```csharp
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
private int[] GetCellLightSet(uint cellId)
{
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
}
```
(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.)
- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`**
(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache:
```csharp
_cellLightSetCache.Clear();
```
(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it:
```csharp
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
for (int i = 0; i < uniqueInstanceCount; i++)
{
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
```
(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads:
```csharp
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
null, GLEnum.DynamicDraw);
fixed (float* gp = _globalLightData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
fixed (int* lp = _lightSetData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
```
(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add:
```csharp
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo);
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo);
```
- [ ] **Step 5: Wire the snapshot from GameWindow**
In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add:
```csharp
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
```
- [ ] **Step 6: Dispose the new buffers**
In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add:
```csharp
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo);
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo);
```
- [ ] **Step 7: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler.
- [ ] **Step 8: Commit**
```bash
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140)
The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left
bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own
binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5
per-instance set, computed per cell by LightManager.SelectForObject over the
cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Divergence register — correct AP-35, reconcile the Fix B row
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row)
- [ ] **Step 1: Correct AP-35**
Find the `AP-35` row. It currently describes the point-light path as per-pixel
`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to
reflect reality after Fix A + Fix D D-1:
- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`.
- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`).
- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`.
- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle.
- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row**
Find the row describing Fix B (per-object 8-light selection by sphere overlap vs
retail's per-vertex sum over the full static list — `minimize_object_lighting`
0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per
cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the
"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a
new contradicting row.
- [ ] **Step 3: Commit**
```bash
git add docs/architecture/retail-divergence-register.md
git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Final verification (after all tasks)
- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green.
- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions).
- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires.
- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually).
## Notes for the implementer
- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning).
- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach).
- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug.
- **Don't touch the purple portal** — confirmed correct.

View file

@ -0,0 +1,211 @@
# A7 Fix D — warm torch over-brightness on indoor walls (#140)
**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting
**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan.
**Investigation source of truth:**
[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md)
(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`.
## Problem
The Holtburg meeting-hall walls (and outdoor objects near torches) blow out
**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this.
The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE,
yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle**
for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous):
- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) →
`SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its
SOLE caller). Wall torches are STATIC objects → baked into vertex colours.
- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light``config_hardware_light`
(0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic
subset for a cell. The previously-captured `intensity=100` light is on THIS path.
`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the
decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own
sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to
`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights;
a hall has a handful, each warm-capped.
### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0)
Holtburg: **38 static + 2 dynamic** lights.
| Light | path | type | intensity | falloff | colour (r,g,b) |
|---|---|---|---|---|---|
| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white |
| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch |
| 38× wall torch | static / **bake** | point | 100 | 35 | **(1.0, 0.588, 0.314) orange** / (0.980, 0.843, 0.612) cream |
Torches carry `intensity=100` too, but the per-channel cap pins each to its warm
colour ⇒ retail walls go warm, never white.
## Root cause in acdream (both verified in source)
Two independent bugs, both touching the meeting-hall walls; this spec fixes both.
**D-1 (math, primary): unclamped accumulator folding ambient + sun + torches.**
[`mesh_modern.vert`](../../../src/AcDream.App/Rendering/Shaders/mesh_modern.vert)
`accumulateLights` starts `lit = uCellAmbient.xyz` (:184), adds sun (:196), adds each
capped torch (:206), returns UNCLAMPED (:208); the only clamp is `min(lit,1.0)` in
`mesh_modern.frag:92` after a lightning bump. The per-light cap (vert:180) is faithful.
But pouring ambient + sun + up-to-8 intensity-100 WARM torches into ONE bucket and
trimming only at the end overflows to warm-white. Retail clamps the torch sum on its
OWN (from black); ambient/sun are a separate material-lit term.
**D-2 (state, compounding): EnvCell shell SSBO binding leak.**
[`EnvCellRenderer.RenderModernMDIInternal`](../../../src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs)
binds SSBO 0/1/2/3 only, NEVER **4** (`gLights`) or **5** (`instanceLightIdx`) — which
the shared `mesh_modern.vert` reads unconditionally (:204-206). Only `WbDrawDispatcher`
binds 4/5. Indoor `DrawInside` interleaves the two, so a cell shell reads whatever
LEAKED light set the last `WbDrawDispatcher` draw left bound (a different entity's
torches, wrong per-instance indices) ⇒ wrong/over-bright walls.
`LightBake.cs` (verbatim CPU port of the bake) exists but is UNWIRED (zero callers).
## Design
Decisions (user, 2026-06-18): **D-1 = small in-shader clamp split** (not a CPU bake);
**D-1 + D-2 land together**, single visual verification.
### D-1 — clamp the torch sum on its own (mirrors `SetStaticLightingVertexColors`)
In `mesh_modern.vert` `accumulateLights`, give point/spot lights their own accumulator,
saturate it to `[0,1]` BEFORE it joins ambient + sun. The per-light cap and
`pointContribution` are unchanged; the only new operation is one `min(pointAcc, 1.0)`.
```glsl
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
// ambient + sun = retail's material-lit term
vec3 lit = uCellAmbient.xyz;
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
// point/spot torches: their OWN accumulator, clamped to [0,1] (retail baked emissive)
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]); // per-light cap unchanged
}
lit += min(pointAcc, vec3(1.0)); // <-- THE FIX
return lit; // frag still does final min(lit, 1.0)
}
```
Behaviour change is confined to surfaces whose torch sum currently exceeds 1.0 —
normally-lit surfaces are byte-identical (no regression). Shared by every mesh using
this shader (outdoor objects AND cell walls), matching the issue's scope.
`mesh_modern.frag:92`'s final `min(lit, 1.0)` stays as-is (it clamps the total to the
retail FF pixel clamp). The lightning bump (frag:89) is unaffected.
### D-2 — the EnvCell shell binds its OWN light set
`EnvCellRenderer` must own its lighting like `WbDrawDispatcher` does, instead of reading
leaked SSBO state. Mirror `WbDrawDispatcher`'s proven pattern
(`ComputeEntityLightSet`/`AppendCurrentLightSet`/`UploadGlobalLights`):
1. **Wire `LightManager` in** via `Initialize(...)` (alongside `_shader`). Self-contained
pass — per `feedback_render_self_contained_gl_state`, EnvCellRenderer already
re-uploads its own `uViewProjection`; it now also uploads/binds its own lights.
2. **Binding 4 (global lights):** upload `LightManager.PointSnapshot` itself, packed
identically to `WbDrawDispatcher.UploadGlobalLights` (the `GlobalLight` SSBO layout:
`posAndKind`, `dirAndRange`, `colorAndIntensity`, `coneAngleEtc`). Same snapshot →
same indices both renderers reference. `BuildPointLightSnapshot` is already called
once per frame before rendering. **Extract the packing into a shared helper** so the
two renderers cannot drift (a `GlobalLightPacker` in `AcDream.App/Rendering/Wb/` or a
static on the snapshot type) — do not copy-paste the struct layout.
3. **Binding 5 (per-instance light set):** per **cell** (keyed on `allInstances[i].CellId`),
compute the set ONCE with `LightManager.SelectForObject(snapshot, cellCenter,
cellRadius, set)` (camera-independent; cache per CellId, reuse for all that cell's
part-instances — like `WbDrawDispatcher` reuses one set per entity). Write the 8-int
set per instance into a new buffer parallel to `_gpuInstanceTransforms` (same shape
as `_clipSlotData`); bind at binding 5. On a no-lights frame, fill -1 (shader adds no
point light) and still bind a ≥1-element buffer so the SSBO is never unbound.
4. **Cell centre/radius:** world-space bounding sphere of the cell geometry — reuse the
cell's existing visibility bound (the BSP/AABB sphere already computed for culling).
The exact field is pinned during planning by reading the cell-storage structs in
`EnvCellRenderer` / `EnvCellLandblock`; fallback = centre from the cell-part transform
translation, radius from the cell vertex AABB. **This is the one detail to confirm
against code in the plan.**
Order independence: D-1 and D-2 are orthogonal (shader math vs buffer binding) and can
be implemented in either order, but ship together.
## Testing (TDD)
`LightBake.cs` already encodes the correct math: `PointContribution` = per-light capped
(matches `mesh_modern.vert` pointContribution line-for-line), `ComputeVertexColor` = sum
reaching point lights → clamp `[0,1]`, skip directional. The new shader `pointAcc` clamp
mirrors `ComputeVertexColor`'s final clamp exactly.
New conformance test in `tests/AcDream.Core.Tests/` (e.g. `LightBakeConformanceTests`):
- **Golden warm torch, bounded:** an orange `(1, 0.588, 0.314)` `intensity=100`
`falloff=4` (Range = 4×1.3 = 5.2 m) torch lighting a wall vertex (facing it) at
d = 1, 2, 3, 4, 5 m → result is warm (R ≥ G ≥ B, hue preserved) and **every channel
≤ 1.0** (never white); at d ≥ Range the contribution is 0 (range gate).
- **No-blowout under stacking:** 8 overlapping `intensity=100` near-white torches summed
via `ComputeVertexColor` → each channel clamps to ≤ 1.0 (the `[0,1]` saturate holds).
- **Hue preserved:** a single orange torch's bounded result keeps B < G < R (warm), not
desaturated toward white.
These pin the contract the shader must match. GLSL is not unit-testable in-process
(standard for this project per the render digest); the shader `pointContribution` +
`pointAcc` clamp are matched to `LightBake` by **line-for-line review** with the C#
oracle as the pinned reference (call it out in the implementation commit).
## Bookkeeping — divergence register
- **Correct stale row AP-35** (`docs/architecture/retail-divergence-register.md`): it
describes the point-light path as per-pixel `mesh_modern.frag:52` with the half-Lambert
wrap "NOT ported". Reality since Fix A (`aa94ced`): per-vertex Gouraud in
`mesh_modern.vert:163` WITH the wrap ported. Update the row to match; the D-1 clamp
makes the accumulator MORE faithful (no new deviation introduced).
- **EnvCell shell per-cell 8-light selection** (D-2) inherits Fix B's existing
per-object approximation (retail bakes per-VERTEX over the full static list; acdream
selects up to 8 per cell-sphere then gates per-vertex in-shader). Confirm Fix B's
register row covers EnvCell shells; extend that row if needed — do NOT add a
contradicting row.
## Files
- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — D-1 clamp split.
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — verify final clamp stays correct
(expected no change).
- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — D-2: `LightManager` ref, per-cell
light sets, bind SSBO 4 + 5, per-instance light-set buffer.
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (+ a shared `GlobalLightPacker`) —
extract the binding-4 global-lights packing so both renderers share it.
- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LightManager` into
`EnvCellRenderer.Initialize` (minimal).
- `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` — new.
- `docs/architecture/retail-divergence-register.md` — AP-35 update.
## Acceptance criteria
- `dotnet build` green; `dotnet test` green including the new conformance test.
- Conformance test passes on the captured golden torch values (warm, bounded, hue-preserved).
- Shader `pointContribution` + new `pointAcc` clamp reviewed line-for-line against
`LightBake` (cited in the commit).
- AP-35 corrected; any D-2 register note reconciled with Fix B's row.
- **Visual (user):** outdoor objects near torches no longer blow out warm-white, and the
Holtburg meeting-hall walls render warm-but-dim like retail.
## Out of scope (explicit)
- **Do NOT port the D3D-FF hardware model** (`config_hardware_light`'s
`color×intensity`, `(0,1,0)=1/d`, `Range=falloff×1.5`) — it lights GfxObjs/dynamics,
not the baked walls. Wrong oracle (handoff warning stands).
- **Do NOT** wire the CPU vertex bake (`LightBake.cs` as the runtime path) — chosen
approach is the in-shader clamp split. `LightBake.cs` stays the test oracle.
- Sun handling on indoor walls is unchanged (kept in the material-lit term as today);
any "should indoor walls receive sun at all" refinement is a separate question.
- The purple portal is correct — do not touch it.

View file

@ -8121,6 +8121,17 @@ public sealed class GameWindow : IDisposable
// frame — terrain, static mesh, instanced mesh, sky. // frame — terrain, static mesh, instanced mesh, sky.
UpdateSunFromSky(kf, playerInsideCell); UpdateSunFromSky(kf, playerInsideCell);
Lighting.Tick(camPos); Lighting.Tick(camPos);
// Fix B (A7 #3): build this frame's point-light snapshot and hand it to
// the entity dispatcher for per-OBJECT light selection
// (minimize_object_lighting). Replaces the single global nearest-8-to-
// camera UBO set for point/spot lights so a wall's torches stay tied to
// the wall as the camera moves. The SUN + ambient still flow through the
// SceneLighting UBO built below (binding=1) — terrain/sky read those.
Lighting.BuildPointLightSnapshot(camPos);
_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction); Lighting, in atmo, camPos, (float)WorldTime.DayFraction);

View file

@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0; atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
} }
lit += Lcol * ndl * atten; // Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single
// point/spot light can't push a channel past its own colour, regardless of
// intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag.
lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz);
} }
} }
} }

View file

@ -4,6 +4,7 @@
in vec3 vNormal; in vec3 vNormal;
in vec2 vTexCoord; in vec2 vTexCoord;
in vec3 vWorldPos; in vec3 vWorldPos;
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
in flat uvec2 vTextureHandle; in flat uvec2 vTextureHandle;
in flat uint vTextureLayer; in flat uint vTextureLayer;
@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime; vec4 uCameraAndTime;
}; };
vec3 accumulateLights(vec3 N, vec3 worldPos) { // A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
vec3 lit = uCellAmbient.xyz; // retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
int activeLights = int(uCellAmbient.w); // pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
for (int i = 0; i < 8; ++i) { // uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
if (i >= activeLights) break; // in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
int kind = int(uLights[i].posAndKind.w);
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
if (kind == 0) {
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += Lcol * ndl;
} else {
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
float d = length(toL);
float range = uLights[i].dirAndRange.w;
if (d < range && range > 1e-3) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
// smooth glow that blends into the ambient instead of a flat disc with
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
// denominator is just Range and fades to 0 exactly at the cutoff.
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
if (kind == 2) {
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
}
lit += Lcol * ndl * atten;
}
}
}
return lit;
}
vec3 applyFog(vec3 lit, vec3 worldPos) { vec3 applyFog(vec3 lit, vec3 worldPos) {
int mode = int(uFogParams.w); int mode = int(uFogParams.w);
@ -114,8 +82,8 @@ void main() {
if (color.a < 0.05) discard; if (color.a < 0.05) discard;
} }
vec3 N = normalize(vNormal); // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
vec3 lit = accumulateLights(N, vWorldPos); vec3 lit = vLit;
// Lightning flash — additive scene bump (matches mesh_instanced.frag). // Lightning flash — additive scene bump (matches mesh_instanced.frag).
lit += uFogParams.z * vec3(0.6, 0.6, 0.75); lit += uFogParams.z * vec3(0.6, 0.6, 0.75);

View file

@ -69,6 +69,33 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[]; uint instanceClipSlot[];
}; };
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
// (its torches swapping into the global top-8). Two SSBOs replace that for
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
//
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
// it once per entity (the set is constant across the entity's parts/tuples).
struct GlobalLight {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
GlobalLight gLights[];
};
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget // alongside gl_Position. The array is sized 8 to match the CellClip plane budget
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
@ -95,10 +122,107 @@ uniform mat4 uViewProjection;
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's // _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset; uniform int uDrawIDOffset;
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
// above). IDENTICAL std140 layout to mesh_modern.frag.
//
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
// torch/point lights Gouraud-interpolate across each triangle the way retail's
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
// per-vertex spreads it into a soft, broad gradient with no hard edge.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
vec4 uFogColor;
vec4 uCameraAndTime;
};
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
// light vertex, used UN-normalised (length = dist); N is the unit vertex normal.
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
int kind = int(L.posAndKind.w);
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
float distsq = dot(toL, toL);
float d = sqrt(distsq);
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3
if (d >= range || range <= 1e-4) return vec3(0.0);
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
// nothing (retail's hardware path). toL is un-normalised (length d).
float angular = (uLightingMode == 1)
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
if (angular <= 0.0) return vec3(0.0);
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
float norm = (distsq > 1.0) ? (distsq * d) : d;
float intensity = L.colorAndIntensity.w;
float scale = (1.0 - d / range) * intensity * (angular / norm);
if (kind == 2) {
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
vec3 Ldir = toL / max(d, 1e-4);
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
if (cos_l <= cos_edge) scale = 0.0;
}
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
// a single light can't push a channel past its colour. Summed lit clamped in frag.
vec3 baseCol = L.colorAndIntensity.xyz;
return min(scale * baseCol, baseCol);
}
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
vec3 lit = uCellAmbient.xyz;
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
if (uLightingMode == 0) {
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
}
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
// SetStaticLightingVertexColors sums the static point lights from BLACK and
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
// few warm intensity-100 torches can't push the whole pixel to white the way
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);
}
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
return lit; // frag still does the final min(lit, 1.0)
}
out vec3 vNormal; out vec3 vNormal;
out vec2 vTexCoord; out vec2 vTexCoord;
out vec3 vWorldPos; out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle; out flat uvec2 vTextureHandle;
out flat uint vTextureLayer; out flat uint vTextureLayer;
@ -123,6 +247,7 @@ void main() {
vWorldPos = worldPos.xyz; vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal); vNormal = normalize(mat3(model) * aNormal);
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
vTexCoord = aTexCoord; vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];

View file

@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private uint _clipSlotBuffer; private uint _clipSlotBuffer;
private uint[] _clipSlotData = Array.Empty<uint>(); private uint[] _clipSlotData = Array.Empty<uint>();
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
// left bound. binding=4 = global point-light snapshot (same data/indices as the
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
private uint _globalLightsSsbo; // binding=4
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private uint _instLightSetSsbo; // binding=5
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO. // our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
@ -231,6 +242,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BufferData(GLEnum.ShaderStorageBuffer, _gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
_gl.GenBuffers(1, out _globalLightsSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
_gl.GenBuffers(1, out _instLightSetSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
null, GLEnum.DynamicDraw);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
} }
@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot) public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
=> _cellIdToSlot = cellIdToSlot; => _cellIdToSlot = cellIdToSlot;
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
/// reference this snapshot, which is also uploaded to binding=4 here, so the
/// pass is self-contained. Null/empty -> shells receive no point lights.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
=> _pointSnapshot = snapshot;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GetEnvCellGeomId // GetEnvCellGeomId
// Verbatim copy of WB EnvCellRenderManager.cs:94-103. // Verbatim copy of WB EnvCellRenderManager.cs:94-103.
@ -843,6 +877,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// WB EnvCellRenderManager.cs:406-409: uniform state setup. // WB EnvCellRenderManager.cs:406-409: uniform state setup.
_shader.SetInt("uRenderPass", (int)renderPass); _shader.SetInt("uRenderPass", (int)renderPass);
_shader.SetInt("uFilterByCell", 0); _shader.SetInt("uFilterByCell", 0);
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from // moving"): upload uViewProjection HERE rather than inheriting it from
@ -997,6 +1032,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
} }
} }
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
private int[] GetCellLightSet(uint cellId)
{
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RenderModernMDIInternal // RenderModernMDIInternal
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant). // Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
@ -1016,6 +1080,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
int passIdx = (int)renderPass; int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return; if (passIdx < 0 || passIdx > 2) return;
// A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in
// GetCellLightSet below). Clear once here so each cell gets a fresh lookup
// using this frame's _pointSnapshot. Called for EVERY pass (opaque AND
// transparent); the cache entries are stable within a frame since PointSnapshot
// doesn't change between Render calls, so clearing once (at the opaque pass)
// and leaving stale entries for the transparent pass would also be correct, but
// clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet.
_cellLightSetCache.Clear();
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads. // §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// Without the global VAO nothing can draw, and returning AFTER the pass state // Without the global VAO nothing can draw, and returning AFTER the pass state
// was established leaked it (same early-out shape as the totalDraws==0 leak — // was established leaked it (same early-out shape as the totalDraws==0 leak —
@ -1213,6 +1286,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr); (nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
} }
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
for (int i = 0; i < uniqueInstanceCount; i++)
{
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
null, GLEnum.DynamicDraw);
fixed (float* gp = _globalLightData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
fixed (int* lp = _lightSetData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
// (globalVao validated at the top of the method — a return here would leak the // (globalVao validated at the top of the method — a return here would leak the
// pass state established above.) // pass state established above.)
@ -1228,6 +1330,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback). // (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
BindClipRegionBinding2(); BindClipRegionBinding2();
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
@ -1443,5 +1547,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; } if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3 if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3 if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
} }
} }

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
@ -132,6 +133,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private uint _clipSlotSsbo; private uint _clipSlotSsbo;
private uint[] _clipSlotData = new uint[256]; private uint[] _clipSlotData = new uint[256];
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
// SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot
// lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4)
// holds the per-frame point-light snapshot (LightManager.PointSnapshot);
// _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per
// instance INTO it (-1 = unused), laid out parallel to _instanceSsbo.
private uint _globalLightsSsbo;
private uint _instLightSetSsbo;
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot;
// This entity's selected point/spot light set — computed ONCE per entity at
// the isNewEntity site (constant across the entity's parts/tuples), exactly
// like _currentEntitySlot. -1 = unused slot.
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the
// GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0 // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
// (not yet wired), we bind our OWN fallback no-clip region buffer below so the // (not yet wired), we bind our OWN fallback no-clip region buffer below so the
@ -329,8 +348,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_batchSsbo = _gl.GenBuffer(); _batchSsbo = _gl.GenBuffer();
_indirectBuffer = _gl.GenBuffer(); _indirectBuffer = _gl.GenBuffer();
_clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3 _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
} }
/// <summary>
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
/// by the object's bounding sphere — camera-independent. Pass null/empty to
/// disable per-object point lights (only ambient + sun render).
/// </summary>
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
=> _pointSnapshot = pointSnapshot;
/// <summary> /// <summary>
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The /// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The
@ -861,6 +893,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_indoorProbeFrameCounter++; _indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection; var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp); _shader.SetMatrix4("uViewProjection", vp);
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
_shader.SetInt("uLightingMode", 0);
// #128 self-heal: fresh re-request dedup per Draw pass. // #128 self-heal: fresh re-request dedup per Draw pass.
_missRequested.Clear(); _missRequested.Clear();
@ -888,7 +923,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
camPos = invView.Translation; camPos = invView.Translation;
// ── Phase 1: clear groups, walk entities, build groups ────────────── // ── Phase 1: clear groups, walk entities, build groups ──────────────
foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); } foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); }
var metaTable = _meshAdapter.MetadataTable; var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0; uint anyVao = 0;
@ -1053,6 +1088,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_currentEntityCulled) if (_currentEntityCulled)
probeCulledEntities++; probeCulledEntities++;
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
// #119 decisive probe: one-shot dump (+ change re-emission) for // #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state. // so a routed-out entity still reports its state.
@ -1350,6 +1390,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_clipSlotData.Length < totalInstances) if (_clipSlotData.Length < totalInstances)
_clipSlotData = new uint[totalInstances + 256]; _clipSlotData = new uint[totalInstances + 256];
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
// instance, laid out in the SAME group order / cursor as _instanceData
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
// Instances[instanceIndex] (binding=0).
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
_opaqueDraws.Clear(); _opaqueDraws.Clear();
_translucentDraws.Clear(); _translucentDraws.Clear();
@ -1375,6 +1422,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Slots[] is parallel to Matrices[] within the group; write the // Slots[] is parallel to Matrices[] within the group; write the
// slot at the same cursor so binding=3 stays aligned with binding=0. // slot at the same cursor so binding=3 stays aligned with binding=0.
_clipSlotData[cursor] = grp.Slots[i]; _clipSlotData[cursor] = grp.Slots[i];
// Fix B: LightSets[] holds 8 ints per instance, parallel to
// Matrices[]; copy this instance's block to the same cursor so
// binding=5 stays aligned with binding=0.
int lsDst = cursor * LightManager.MaxLightsPerObject;
int lsSrc = i * LightManager.MaxLightsPerObject;
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
cursor++; cursor++;
} }
@ -1460,6 +1514,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (uint* sp = _clipSlotData) fixed (uint* sp = _clipSlotData)
UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint));
// Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
// per-instance buffer holds 8 int indices into it per instance, laid out
// parallel to _instanceData in Phase 3. Both bound with ≥1 element so the
// shader never reads an unbound SSBO on a no-lights frame.
UploadGlobalLights();
fixed (int* lp = _lightSetData)
UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
fixed (DrawElementsIndirectCommand* cp = _indirectCommands) fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
{ {
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
@ -1743,6 +1806,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
} }
/// <summary>
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
/// matching mesh_modern.vert's <c>GlobalLight</c>). Always uploads ≥1 element
/// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is
/// a zeroed dummy that no instance set references (all sets are -1).
/// </summary>
private unsafe void UploadGlobalLights()
{
int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int count = n > 0 ? n : 1; // never zero-size
// Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats.
fixed (float* gp = _globalLightData)
UploadSsbo(_globalLightsSsbo, 4, gp,
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
}
/// <summary> /// <summary>
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>); /// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
@ -1936,6 +2016,75 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
} }
grp.Matrices.Add(model); grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
}
/// <summary>
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
/// reused by every part/instance of it), by the entity's world bounding
/// sphere. Camera-independent (<see cref="LightManager.SelectForObject"/>), so
/// a static building's torches stay constant as the viewer moves. Fills
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
///
/// <para>
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
/// ambient ONLY — never the static wall torches. The per-object torch step
/// (<c>minimize_object_lighting</c>, 0x0054d480) runs ONLY in the indoor stage:
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398) calls it under
/// <c>if (Render::useSunlight == 0)</c>, and the outdoor landscape stage runs
/// <c>Render::useSunlightSet(1)</c> (<c>PView::DrawCells</c> 0x005a485a, right
/// before <c>LScape::draw</c> which draws buildings/scenery). So a building
/// EXTERIOR shell (<see cref="WorldEntity.IsBuildingShell"/>,
/// <see cref="WorldEntity.ParentCellId"/> = null) and all outdoor scenery /
/// creatures get the sun, not torches. We mirror that: only objects parented to
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
/// facade wash out warm — the dat's intensity-100 wall torches (range
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
private void ComputeEntityLightSet(WorldEntity entity)
{
Array.Fill(_currentEntityLightSet, -1);
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
// Retail useSunlight gate: outdoor objects receive no per-object torches.
if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return;
if (entity.AabbDirty) entity.RefreshAabb();
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
}
/// <summary>
/// Retail's <c>useSunlight</c> gate for per-object torch lighting, as a pure
/// predicate. An object receives the static wall torches (the indoor
/// <c>minimize_object_lighting</c> pass) ONLY when it is parented to an EnvCell
/// — an interior cell, by the AC convention <c>(cellId &amp; 0xFFFF) &gt;= 0x0100</c>.
/// Outdoor objects (building shells with null <paramref name="parentCellId"/>,
/// outdoor scenery in a land sub-cell <c>0x0001..0x00FF</c>, outdoor creatures)
/// are sun-lit only and return false. Mirrors
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398): torches enabled iff
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
/// </summary>
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
=> parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u;
/// <summary>
/// Fix B: append the current entity's 8-slot light set to a group's
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
/// </summary>
private void AppendCurrentLightSet(InstanceGroup grp)
{
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
grp.LightSets.Add(_currentEntityLightSet[k]);
} }
private void ClassifyBatches( private void ClassifyBatches(
@ -1993,6 +2142,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
} }
grp.Matrices.Add(model); grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
collector?.Add(new CachedBatch(key, texHandle, restPose)); collector?.Add(new CachedBatch(key, texHandle, restPose));
} }
} }
@ -2072,6 +2222,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.DeleteBuffer(_indirectBuffer); _gl.DeleteBuffer(_indirectBuffer);
if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3 if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3
if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3 if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_gpuQueriesInitialized) if (_gpuQueriesInitialized)
{ {
for (int i = 0; i < GpuQueryRingDepth; i++) for (int i = 0; i < GpuQueryRingDepth; i++)
@ -2257,5 +2409,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData, // _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance. // so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List<uint> Slots = new(); public readonly List<uint> Slots = new();
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
// light index block for the instance whose matrix is Matrices[i]). At
// layout time the dispatcher copies each block into _lightSetData at the
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
// instance. -1 = unused slot.
public readonly List<int> LightSets = new();
} }
} }

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Lighting;
/// <summary>
/// Packs a point-light snapshot into the flat float layout the bindless mesh
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
/// </summary>
public static class GlobalLightPacker
{
public const int FloatsPerLight = 16;
/// <summary>
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
/// </summary>
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
{
int n = snapshot?.Count ?? 0;
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
if (buffer.Length < floatsNeeded)
buffer = new float[floatsNeeded + FloatsPerLight * 16];
Array.Clear(buffer, 0, floatsNeeded);
for (int i = 0; i < n; i++)
{
var L = snapshot![i];
int o = i * FloatsPerLight;
// posAndKind (xyz world pos, w kind)
buffer[o + 0] = L.WorldPosition.X;
buffer[o + 1] = L.WorldPosition.Y;
buffer[o + 2] = L.WorldPosition.Z;
buffer[o + 3] = (int)L.Kind;
// dirAndRange (xyz forward, w range)
buffer[o + 4] = L.WorldForward.X;
buffer[o + 5] = L.WorldForward.Y;
buffer[o + 6] = L.WorldForward.Z;
buffer[o + 7] = L.Range; // w = Range = Falloff × static_light_factor (1.3), pre-multiplied by LightInfoLoader — NOT the raw dat Falloff
// colorAndIntensity (xyz linear colour, w intensity)
buffer[o + 8] = L.ColorLinear.X;
buffer[o + 9] = L.ColorLinear.Y;
buffer[o + 10] = L.ColorLinear.Z;
buffer[o + 11] = L.Intensity;
// coneAngleEtc (x cone radians; yzw reserved)
buffer[o + 12] = L.ConeAngle;
}
return n;
}
}

View file

@ -157,4 +157,125 @@ public sealed class LightManager
_activeCount = baseSlot + filled; _activeCount = baseSlot + filled;
} }
// ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ──
//
// The single global nearest-8-to-VIEWER set above (Tick) is camera-relative:
// a wall's brightness changes as the camera moves because the wall's torches
// swap in/out of that global top-8. Retail instead picks up-to-8 lights PER
// OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480),
// so a torch always lights the wall it sits on, camera-independent. The two
// members below feed the per-instance light path in WbDrawDispatcher; Tick
// remains the source of the legacy single-UBO path + the sun slot.
/// <summary>Max point/spot lights any one object can be lit by — retail's
/// D3D fixed-function 8-light cap (<c>minimize_object_lighting</c>). The sun
/// is global, not part of an object's per-object set, so all 8 are point/spot.</summary>
public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new();
/// <summary>
/// Per-frame snapshot of lit point/spot lights, stable-indexed for the global
/// shader light buffer and for per-object selection: the index of a light here
/// IS the index the per-instance light-set SSBO references. Built by
/// <see cref="BuildPointLightSnapshot"/>.
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
/// <summary>
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
/// path; unlit torches contribute nothing). When more than
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
/// most relevant lights survive the cap. Call once per frame before
/// per-object selection.
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
{
_pointSnapshot.Clear();
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
_pointSnapshot.Add(light);
}
if (_pointSnapshot.Count > MaxGlobalLights)
{
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
}
}
/// <summary>
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
/// <paramref name="snapshot"/> that reach the object sphere
/// (<paramref name="center"/>, <paramref name="radius"/>), nearest-first.
/// Faithful to retail's <c>minimize_object_lighting</c> (0x0054d480): a light
/// is a candidate iff its falloff sphere overlaps the object sphere —
/// <c>(light.pos center)² &lt; (light.Range + radius)²</c> — and when more
/// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the
/// farthest fall off). <paramref name="light.Range"/> already folds
/// <c>static_light_factor</c> (1.3), matching the per-vertex cutoff so a
/// selected light always actually contributes in the shader.
/// <para>
/// Writes indices INTO <paramref name="snapshot"/> to
/// <paramref name="outIndices"/> (ascending by distance) and returns the count.
/// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a
/// static object's set is stable and may be computed once. Unit-testable
/// without GL.
/// </para>
/// </summary>
public static int SelectForObject(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
int count = 0;
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object
if (count < cap)
{
int j = count;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
return count;
}
} }

View file

@ -74,22 +74,15 @@ public readonly record struct SkyKeyframe(
/// (see <see cref="SkyStateProvider.RetailSunVector"/>). /// (see <see cref="SkyStateProvider.RetailSunVector"/>).
/// ///
/// <para> /// <para>
/// Why <c>|sunVec|</c> instead of <c>DirBright</c> directly: retail's /// <c>|sunVec|</c> is retail's <c>D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²)</c>
/// <c>PrimD3DRender::UpdateLightsInternal</c> at <c>0x0059b57c</c> /// scaling (<c>PrimD3DRender::UpdateLightsInternal</c> 0x0059b57c, decomp
/// (decomp line 424118-424119) computes /// 424118-424119) of the WORLD-space sun vector (<c>LScape::sunlight</c>).
/// <code>D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)</code> /// Because <see cref="SkyStateProvider.RetailSunVector"/> is now the
/// from the sun vector <c>SkyDesc::GetLighting</c> built at /// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified —
/// <c>0x00500ac9</c> (decomp lines 261343-261353): /// see that method), <c>|sunVec| == DirBright</c>, so this is effectively
/// <code> /// <c>SunColor = DirColor × DirBright</c>. (A prior bug used the un-transformed
/// sunVec.x = sin(H) × DirBright × cos(P) /// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~45× too bright at dawn/dusk;
/// sunVec.y = cos(P) // NOT scaled by DirBright /// [[reference-retail-ambient-values]].)
/// sunVec.z = DirBright × sin(P)
/// </code>
/// Because Y is unscaled by <c>DirBright</c>, <c>|sunVec|</c> ≠
/// <c>DirBright</c> in general — it varies with sun pitch and heading.
/// Using <c>DirBright</c> alone underweighted the warm directional
/// term, letting the cool ambient/fog dominate ⇒ acdream rendered
/// blue-white at keyframes where retail looked warm-gray.
/// </para> /// </para>
/// </summary> /// </summary>
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length(); public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
@ -301,21 +294,35 @@ public sealed class SkyStateProvider
} }
/// <summary> /// <summary>
/// Retail's raw sun vector (NOT normalized) — the same vector /// Retail's world-space sun vector (NOT normalized): the standard
/// <c>SkyDesc::GetLighting</c> writes at <c>0x00500ac9</c> /// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by
/// (decomp lines 261343, 261352, 261353): /// <c>DirBright</c>:
/// <code> /// <code>
/// sunVec.x = sin(H_rad) × DirBright × cos(P_rad) /// sunVec.x = DirBright × cos(P) × sin(H)
/// sunVec.y = cos(P_rad) // NOT scaled by DirBright /// sunVec.y = DirBright × cos(P) × cos(H)
/// sunVec.z = DirBright × sin(P_rad) /// sunVec.z = DirBright × sin(P)
/// </code> /// </code>
/// Y is unscaled by brightness on purpose — that's what makes /// so <c>|sunVec| == DirBright</c> exactly (cos²P·(sin²H+cos²H)+sin²P = 1).
/// <c>|sunVec|</c> ≠ <c>DirBright</c> in general (the magnitude varies ///
/// with pitch/heading, which is the basis for retail's "sun is brighter /// <para>
/// in some configurations than others" lighting behavior). The shader's /// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]):
/// <c>uSunDir</c> uniform uses the NORMALIZED vector for N·L; the /// retail's <c>LScape::sunlight</c> read at a dawn keyframe (H=90°, P=0.9°,
/// magnitude feeds <see cref="SkyKeyframe.SunColor"/> intensity and /// DirBright≈0.224) = <c>(0.2238, ~0, 0.00352)</c> — y≈0, magnitude 0.224 =
/// the ambient brightness boost in <see cref="SkyKeyframe.AmbientColor"/>. /// DirBright. That fed <c>level = 0.2·|sunlight| + ambient_level = 0.2·0.224 +
/// 0.40 = 0.445</c>, matching the captured <c>SetWorldAmbientLight</c> level.
/// </para>
/// <para>
/// PRIOR BUG: an earlier version returned <c>y = cos(P)</c> (≈1) — the raw
/// PRE-transform value the decomp's <c>SkyDesc::GetLighting</c> writes to its
/// <c>arg5</c> (0x00500ac9, before <c>LScape::set_sky_position</c>'s world
/// transform). Porting that un-transformed vector inflated <c>|sunVec|</c> to
/// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost
/// (<see cref="SkyKeyframe.AmbientColor"/>) AND the sun colour
/// (<see cref="SkyKeyframe.SunColor"/>) by ~30% vs retail. The world-space
/// form above is what <c>LScape::sunlight</c> actually holds at runtime.
/// </para>
/// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright)
/// feeds the sun-colour intensity and the ambient brightness boost.
/// </summary> /// </summary>
public static Vector3 RetailSunVector(SkyKeyframe kf) public static Vector3 RetailSunVector(SkyKeyframe kf)
{ {
@ -325,9 +332,9 @@ public sealed class SkyStateProvider
float sinP = MathF.Sin(p); float sinP = MathF.Sin(p);
float B = kf.DirBright; float B = kf.DirBright;
return new Vector3( return new Vector3(
MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P) B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H)
cosP, // y = cos(P) ← unscaled by B B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H)
B * sinP); // z = B × sin(P) B * sinP); // z = DirBright × sin(P)
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,42 @@
using AcDream.App.Rendering.Wb;
using Xunit;
namespace AcDream.App.Tests.Rendering.Wb;
/// <summary>
/// A7 Fix D round 2 — pins retail's <c>useSunlight</c> gate for per-object torch
/// lighting (<c>WbDrawDispatcher.IndoorObjectReceivesTorches</c>). Retail enables
/// the static wall-torches on an object ONLY in the indoor stage
/// (<c>DrawMeshInternal</c> 0x0059f398: <c>if (useSunlight == 0) minimize_object_lighting()</c>),
/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor
/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only
/// EnvCell-parented (indoor, low word &gt;= 0x0100) objects receive torches.
/// </summary>
public sealed class WbDrawDispatcherTorchGateTests
{
[Fact]
public void BuildingShell_NullParent_IsOutdoor_NoTorches()
{
// Building exterior shells are top-level landblock stabs with no
// ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent).
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null));
}
[Theory]
[InlineData(0xA9B4_0001u)] // outdoor land sub-cell
[InlineData(0xA9B4_0020u)] // outdoor land sub-cell
[InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40)
public void OutdoorLandCell_NoTorches(uint parentCellId)
{
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
}
[Theory]
[InlineData(0xA9B4_0100u)] // first EnvCell
[InlineData(0xA9B4_0164u)] // interior EnvCell
[InlineData(0x0007_0143u)] // dungeon EnvCell
public void IndoorEnvCell_GetsTorches(uint parentCellId)
{
Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
}
}

View file

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Lighting;
using DatReaderWriter;
using DatReaderWriter.Options;
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
using DatSetup = DatReaderWriter.DBObjs.Setup;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
/// <summary>
/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without
/// guessing or a live launch: dump the RAW dat <c>LightInfo.Falloff</c> for every
/// static light in the Holtburg landblocks, via the EXACT production load path
/// (<see cref="LightInfoLoader.Load"/>). The dat is the SAME file retail reads, so
/// these falloffs ARE what retail reads (modulo any load-time transform, settled
/// separately in the decomp). Output-only — no assertions; read the log.
/// </summary>
public sealed class HoltburgTorchFalloffProbeTests
{
private readonly ITestOutputHelper _out;
public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output;
[Fact]
public void Dump_Holtburg_StaticLight_Falloffs()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
// The meeting hall sits in the Holtburg town landblocks. Sweep a small
// neighbourhood so we catch every entrance torch the streaming window
// would load around the player at the hall.
uint[] landblocks =
{
0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u,
};
// Tally every distinct raw Falloff seen (the headline number).
var falloffTally = new SortedDictionary<float, int>();
int totalLights = 0;
foreach (uint lb in landblocks)
{
uint infoId = (lb << 16) | 0xFFFEu;
var info = dats.Get<DatLandBlockInfo>(infoId);
if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; }
int buildings = info.Buildings?.Count ?? 0;
int objects = info.Objects?.Count ?? 0;
_out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ===");
// Record building-shell origins so we can rank torches by proximity.
var shells = new List<(uint model, Vector3 pos)>();
if (info.Buildings is not null)
{
foreach (var b in info.Buildings)
{
var o = b.Frame.Origin;
shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z)));
_out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}");
}
}
if (info.Objects is null) continue;
foreach (var stab in info.Objects)
{
// Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary —
// identical gate to GameWindow.cs:6399.
if ((stab.Id & 0xFF000000u) != 0x02000000u) continue;
var setup = dats.Get<DatSetup>(stab.Id);
if (setup?.Lights is null || setup.Lights.Count == 0) continue;
var loaded = LightInfoLoader.Load(
setup,
ownerId: 0,
entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z),
entityRotation: new Quaternion(
stab.Frame.Orientation.X, stab.Frame.Orientation.Y,
stab.Frame.Orientation.Z, stab.Frame.Orientation.W));
foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l)))
{
float rawFalloff = kvp.Value.Falloff;
totalLights++;
falloffTally.TryGetValue(rawFalloff, out int c);
falloffTally[rawFalloff] = c + 1;
// Nearest building shell, for "is this an entrance torch on the hall?".
float nearest = float.MaxValue;
uint nearestModel = 0;
foreach (var (model, spos) in shells)
{
float dd = Vector3.Distance(ls.WorldPosition, spos);
if (dd < nearest) { nearest = dd; nearestModel = model; }
}
_out.WriteLine(
$" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " +
$"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " +
$"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " +
$"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " +
$"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m");
}
}
}
_out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ===");
foreach (var kv in falloffTally)
_out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}");
}
}

View file

@ -0,0 +1,45 @@
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
public class GlobalLightPackerTests
{
[Fact]
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
{
var light = new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(10f, 20f, 30f),
WorldForward = new Vector3(0f, 0f, 1f),
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
Intensity = 100f,
Range = 5.2f,
ConeAngle = 0f,
};
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
Assert.Equal(1, count);
Assert.True(buffer.Length >= 16);
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
Assert.Equal(5.2f, buffer[7]);
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
Assert.Equal(100f, buffer[11]);
Assert.Equal(0f, buffer[12]);
}
[Fact]
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
{
float[] buffer = System.Array.Empty<float>();
int count = GlobalLightPacker.Pack(null, ref buffer);
Assert.Equal(0, count);
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
}
}

View file

@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Lighting;
using Xunit;
namespace AcDream.Core.Tests.Lighting;
/// <summary>
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
/// </summary>
public class LightBakeConformanceTests
{
private static LightSource OrangeTorch(Vector3 pos) => new()
{
Kind = LightKind.Point,
WorldPosition = pos,
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
Intensity = 100f,
Range = 4f * 1.3f, // falloff 4 × static_light_factor
IsLit = true,
};
[Theory]
[InlineData(1f)]
[InlineData(2f)]
[InlineData(3f)]
[InlineData(4f)]
[InlineData(5f)]
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
{
var vtx = Vector3.Zero;
var normal = Vector3.UnitX;
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
if (c.X > 0f)
{
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
}
}
[Fact]
public void BeyondRange_ContributesNothing()
{
var torch = OrangeTorch(new Vector3(100f, 0f, 0f));
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
Assert.Equal(Vector3.Zero, c);
}
[Fact]
public void ManyOverlappingIntenseTorches_StillClampToOne()
{
var lights = new List<LightSource>();
for (int i = 0; i < 8; i++)
lights.Add(new LightSource
{
Kind = LightKind.Point,
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
Intensity = 100f,
Range = 5.2f,
IsLit = true,
});
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
Assert.InRange(c.X, 0f, 1f);
Assert.InRange(c.Y, 0f, 1f);
Assert.InRange(c.Z, 0f, 1f);
}
}

View file

@ -144,4 +144,116 @@ public sealed class LightManagerTests
mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4
Assert.Equal(16f, light.DistSq, 2); Assert.Equal(16f, light.DistSq, 2);
} }
// ── Fix B: per-object selection (minimize_object_lighting) ────────────────
[Fact]
public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit()
{
var mgr = new LightManager();
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out
mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out
mgr.BuildPointLightSnapshot(Vector3.Zero);
Assert.Single(mgr.PointSnapshot);
Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3);
}
[Fact]
public void BuildPointLightSnapshot_IndexStable_InBudget()
{
var mgr = new LightManager();
// Registration order preserved when under MaxGlobalLights (no sort).
mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near
mgr.BuildPointLightSnapshot(Vector3.Zero);
Assert.Equal(2, mgr.PointSnapshot.Count);
Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
}
[Fact]
public void SelectForObject_EmptySnapshot_ReturnsZero()
{
Span<int> idx = stackalloc int[8];
int n = LightManager.SelectForObject(System.Array.Empty<LightSource>(), Vector3.Zero, 1f, idx);
Assert.Equal(0, n);
}
[Fact]
public void SelectForObject_InRange_Selected()
{
var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5
Span<int> idx = stackalloc int[8];
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
Assert.Equal(1, n);
Assert.Equal(0, idx[0]);
}
[Fact]
public void SelectForObject_OutOfRange_Excluded()
{
// dist 10, range 5, radius 0 → 10 >= 5 → excluded.
var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) };
Span<int> idx = stackalloc int[8];
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
Assert.Equal(0, n);
}
[Fact]
public void SelectForObject_ObjectRadiusExtendsReach()
{
// dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere
// overlaps (7 < 5+3). The whole object catches the light — retail uses the
// object's bounding sphere, not its centre point.
var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) };
Span<int> idx = stackalloc int[8];
Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx));
Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx));
}
[Fact]
public void SelectForObject_MoreThan8_KeepsNearest8()
{
// 10 candidate lights all in range; expect the 8 nearest the object centre,
// ascending by distance, with the two farthest dropped.
var snapshot = new LightSource[10];
for (int i = 0; i < 10; i++)
snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range
Span<int> idx = stackalloc int[8];
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
Assert.Equal(8, n);
// Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest
// (indices 8,9 / dist 9,10) are evicted.
for (int k = 0; k < 8; k++)
Assert.Equal(k, idx[k]);
}
[Fact]
public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre()
{
// Same snapshot, same object centre → identical selection regardless of
// where any "camera" is (the method takes no camera). This is the property
// that kills the "lights up as I approach" popping.
var snapshot = new[]
{
MakePoint(new Vector3(2, 0, 0), range: 10f),
MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0
};
Span<int> a = stackalloc int[8];
Span<int> b = stackalloc int[8];
int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a);
int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b);
Assert.Equal(1, na);
Assert.Equal(na, nb);
Assert.Equal(a[0], b[0]);
}
} }

View file

@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests
{ {
// The loader stores DirColor and DirBright RAW. The SunColor property // The loader stores DirColor and DirBright RAW. The SunColor property
// composes them via |sunVec| per retail's UpdateLightsInternal at // composes them via |sunVec| per retail's UpdateLightsInternal at
// 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²) // 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|.
// where the sun vector is built from heading/pitch/brightness with // cdb-verified (reference-retail-ambient-values): |LScape::sunlight| ==
// Y unscaled by brightness (decomp 261352). // DirBright for every keyframe (world-space spherical vector, magnitude
// DirBright·sqrt(cos²P+sin²P) = DirBright).
// //
// For this region: H=180°, P=70°, B=1.5 // For this region: H=180°, P=70°, B=1.5
// sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70)) // sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70))
// = (0, 0.342, 1.410) // = (0, -0.513, 1.410)
// |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509 // |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright)
// DirColor.X = 200/255 = 0.7843 // DirColor.X = 200/255 = 0.7843
// SunColor.X = 0.7843 × 1.4509 = 1.138 // SunColor.X = 0.7843 × 1.500 = 1.1765
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200); var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region); var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded); Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe; var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); Assert.InRange(kf.SunColor.X, 1.17f, 1.18f);
} }
[Fact] [Fact]

View file

@ -66,24 +66,33 @@ public sealed class SkyStateTests
} }
[Fact] [Fact]
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne() public void RetailSunVector_MagnitudeAlwaysEqualsDirBright()
{ {
// Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0. // cdb-verified (2026-06-18, reference-retail-ambient-values): retail's
// sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0) // world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP),
// |sunVec| = 1 regardless of B (because Y is unscaled by B) // whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright
var kf = new SkyKeyframe( // for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the
Begin: 0f, // horizon — that was the ~30% over-bright bug.)
SunHeadingDeg: 0f, // Horizon north (H=0°, P=0°): (0, B, 0), |.| = B.
SunPitchDeg: 0f, var horizon = new SkyKeyframe(
DirColor: Vector3.One, Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f,
DirBright: 2.0f, // anything DirColor: Vector3.One, DirBright: 2.0f,
AmbColor: Vector3.One, AmbColor: Vector3.One, AmbBright: 1f,
AmbBright: 1f, FogColor: Vector3.One, FogDensity: 0f);
FogColor: Vector3.One, Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f);
FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(kf); // Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224
Assert.InRange(v.Length(), 0.99f, 1.01f); // → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright.
var dawn = new SkyKeyframe(
Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f,
DirColor: Vector3.One, DirBright: 0.224f,
AmbColor: Vector3.One, AmbBright: 0.40f,
FogColor: Vector3.One, FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(dawn);
Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224
Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1)
Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035
Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright
} }
[Fact] [Fact]

View file

@ -0,0 +1,15 @@
$$ A7 Fix D — GOLDEN: dump the nearest static lights (the meeting-hall wall torches)
$$ + the ambient/sun that acdream folds into its accumulator. Breakpoint-free, instant.
$$ Render::world_lights @ 0x008672a0; sorted_static_lights[] (RenderLight*) @ +0x3498
$$ (verified: num_static_lights@+0x104=38, num_dynamic_lights@+0x3588=2).
$$ Stand near the meeting-hall torches so the nearest sorted lights ARE them.
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden-probe.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
.echo === ambient_color / sunlight_color / sunlight (what acdream folds into the accumulator) ===
dt -r1 acclient!Render::world_lights ambient_color sunlight_color sunlight num_static_lights num_dynamic_lights
.echo === nearest 10 sorted static lights (RenderLight.d3dLightIndex + info: type/intensity/falloff/color) ===
.for (r $t0=0; @$t0 < 10; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RenderLight=%p ---\n", @$t0, @$t1; dt -r2 acclient!RenderLight @$t1 d3dLightIndex distancesq info }
.echo === END ===
qd

View file

@ -0,0 +1,17 @@
$$ A7 Fix D — GOLDEN v2: explicit LIGHTINFO/RGBColor dump of the nearest static
$$ lights. info @ RenderLight+0x70 (LIGHTINFO); within info: color@+0x50, intensity@+0x5C,
$$ falloff@+0x60 -> absolute color@RL+0xC0, intensity@RL+0xCC, falloff@RL+0xD0.
$$ Characterizes the 38-light static set (warm town torches?) + golden for the fix.
$$ Breakpoint-free, instant, uses current scene.
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden2-probe.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
.echo === ambient_color (r,g,b) ===
dt acclient!RGBColor acclient!Render::world_lights+0x0
.echo === sunlight_color (r,g,b) ===
dt acclient!RGBColor acclient!Render::world_lights+0xc
.echo === nearest 8 sorted static lights: type/intensity/falloff + color(r,g,b) + distsq ===
.for (r $t0=0; @$t0 < 8; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RL=%p d3dIdx=%d ---\n", @$t0, @$t1, dwo(@$t1+0x68); dt acclient!LIGHTINFO @$t1+0x70 type intensity falloff; .echo color(r,g,b):; dt acclient!RGBColor @$t1+0xc0; .echo distancesq:; dd @$t1+0xd8 L1 }
.echo === END ===
qd

View file

@ -0,0 +1,36 @@
$$
$$ A7 Fix D (#140) v2 — fills the two gaps v1 left:
$$ (1) light COLORS (v1's dt did not expand RGBColor); expanded here as a
$$ typed RGBColor dump + raw dd hex backup (reinterpret IEEE-754 if dt fails).
$$ (2) the STATIC wall torches (the lights that actually BAKE the walls) — these
$$ only re-register on a visible-cell-set change, so the player must MOVE
$$ (walk IN and OUT of the meeting hall, circle past the torches) to trigger
$$ Render::add_static_light.
$$
$$ v1 already proved: intensity=100/falloff=6 light is DYNAMIC (add_dynamic_light,
$$ d3dIdx=2) = the portal/effect on the hardware path, NOT a baked wall torch.
$$ viewer light = intensity 2.25 / falloff 10 (dynamic, d3dIdx=1).
$$
$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset):
$$ LIGHTINFO* = poi(@esp+4). color@+0x50 (r/g/b floats), origin@+0x38, intensity@+0x5C, falloff@+0x60.
$$
$$ Dynamic logging is limited to the first 8 hits (we already characterised them);
$$ ALL static hits log. qd when 12 static torches captured OR 1500 total hits (safety).
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-v2.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t2 = 0
r $t3 = 0
$$ STATIC wall torches (baked path) — MOVE to trigger. Color (typed + hex) + origin.
bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff cone_angle; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3; .echo origin_hex(x,y,z):; dd poi(@esp+4)+0x38 L3; .if (@$t2 >= 12) { qd } .elsif (@$t0 >= 1500) { qd } .else { gc }"
$$ DYNAMIC lights (portal/viewer) — log first 8 with color, then silent gc.
bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .if (@$t3 <= 8) { .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3 }; .if (@$t0 >= 1500) { qd } .else { gc }"
.printf "v2 armed: STATIC=wall torches (MOVE in/out of hall to trigger), DYNAMIC=portal/viewer; colors expanded. qd at 12 statics or 1500 total.\\n"
g

View file

@ -0,0 +1,50 @@
$$
$$ A7 Fix D (#140) — wall-torch vs portal light OWNERSHIP + the actual LIGHTINFO
$$ values that feed the EnvCell wall bake. 2026-06-18.
$$
$$ Decomp already settled the render path (workflow wf_f660eb88):
$$ STATIC lights -> CPU per-vertex bake (SetStaticLightingVertexColors ->
$$ calc_point_light), DOUBLE-clamped (per-light min(scale*color,color) +
$$ per-vertex [0,1]) -> walls stay DIM even at intensity=100.
$$ DYNAMIC lights -> D3D hardware FF (minimize_envcell_lighting).
$$ Render::insert_light copies intensity VERBATIM to BOTH paths, so the only
$$ open empirical question is: which light carries intensity=100, and what do
$$ the actual wall-torch LIGHTINFOs look like (intensity/falloff/color)?
$$
$$ CLASSIFICATION via config_hardware_light's d3dLightIndex (arg1 @ [esp+4]):
$$ add_dynamic_light base index = 1 -> dynamic idx in [1..10] (viewer light / teleport PORTAL)
$$ add_static_light base index = 11 -> static idx in [11..70] (WALL TORCHES, baked)
$$
$$ config_hardware_light(d3dIndex, _D3DLIGHT9* out, ulong cellID, LIGHTINFO* info):
$$ d3dIndex = dwo(@esp+4) ; LIGHTINFO* = poi(@esp+0x10) (PROVEN last session)
$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset):
$$ LIGHTINFO* = poi(@esp+4)
$$ `dt acclient!LIGHTINFO <ptr> type intensity falloff color` resolves the
$$ float fields symbolically (PDB types) -> readable values, no hex reinterp.
$$
$$ USAGE: with retail in-world standing in/near the Holtburg meeting hall by a
$$ wall torch, WALK around the hall (and past the teleport portal if present)
$$ for ~15 s so static torch sets re-register. Auto-detaches (qd) after 600
$$ total hits, leaving retail running.
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-capture.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
r $t0 = 0
r $t1 = 0
r $t2 = 0
r $t3 = 0
$$ BP1: config_hardware_light — EVERY light (static+dynamic); d3dIdx classifies.
bp acclient!PrimD3DRender::config_hardware_light "r $t0=@$t0+1; r $t1=@$t1+1; .printf /D \"[CHL] hit#%d d3dIdx=%d (1-10=DYNAMIC portal/viewer, 11+=STATIC torch)\\n\", @$t1, dwo(@esp+4); dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }"
$$ BP2: add_static_light — every hit is a WALL TORCH (baked path).
bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }"
$$ BP3: add_dynamic_light — viewer light + teleport PORTAL (hardware path).
bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }"
.printf "a7-fixd-lights armed: BP1 CHL (classify via d3dIdx), BP2 STATIC=torch, BP3 DYNAMIC=portal/viewer. qd after 600 total hits.\\n"
g

View file

@ -0,0 +1,18 @@
$$ A7 Fix D — instant (breakpoint-free) read of how many STATIC lights the
$$ current scene bakes with. Confirms whether the meeting hall has static torches
$$ (-> D-1 summed-torches matters) or near-zero (-> D-2 leaked-SSBO is the cause).
$$ Stand where the meeting-hall walls are visible. No movement / no breakpoints.
.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-numstatic-probe.log
.sympath C:\Users\erikn\source\repos\acdream\refs
.symopt+ 0x40
.reload /f acclient.exe
.echo === x acclient!*world_lights* ===
x acclient!*world_lights*
.echo === x acclient!Render::world_lights ===
x acclient!Render::world_lights
.echo === dt typed (num_static_lights / num_dynamic_lights / ambient_color) ===
dt acclient!Render::world_lights num_static_lights num_dynamic_lights ambient_color sunlight_color
.echo === dt LightParms at symbol (fallback by explicit type) ===
dt acclient!LightParms acclient!Render::world_lights num_static_lights num_dynamic_lights
.echo === END ===
qd