From c407104ab937a4c037f228b8efe2570358f9d9d7 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:08:27 +0200 Subject: [PATCH] docs(lighting): A7 Fix D investigation RESOLVED + implementation spec (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the Fix D contradiction with decomp (workflow wf_f660eb88 + adversarial verify) + 4 live cdb captures. The D3D-FF model was the WRONG oracle: retail has TWO light systems — STATIC torches BAKE into wall vertices (calc_point_light, triple-clamped: range gate + per-channel min(scale*color,color) + per-vertex [0,1] from black), DYNAMIC lights go D3D hardware. The captured intensity=100 is the purple PORTAL (magenta, dynamic), not a wall torch. Ground truth: 38 static warm torches (orange (1,0.588,0.314)/cream, intensity=100, falloff 3-5) + 2 dynamic. acdream over-brightness = two confirmed bugs: D-1 mesh_modern.vert folds ambient+sun+torches into one UNCLAMPED accumulator (single frag clamp) -> warm blowout; D-2 EnvCellRenderer never binds SSBO 4/5 so the cell shell reads a leaked light set. Spec: D-1 in-shader clamp-split (clamp the torch sum on its own before ambient/sun); D-2 bind the shell's own per-cell light set (mirror WbDrawDispatcher); LightBake.cs is the C# conformance oracle. Adds the 4 reusable cdb capture scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lighting-a7-fixABC-shipped-fixD-handoff.md | 118 ++++++---- ...6-06-18-a7-fixd-torch-overbright-design.md | 211 ++++++++++++++++++ tools/cdb/a7-fixd-golden-probe.cdb | 15 ++ tools/cdb/a7-fixd-golden2-probe.cdb | 17 ++ tools/cdb/a7-fixd-lights-v2.cdb | 36 +++ tools/cdb/a7-fixd-lights.cdb | 50 +++++ tools/cdb/a7-fixd-numstatic-probe.cdb | 18 ++ 7 files changed, 419 insertions(+), 46 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md create mode 100644 tools/cdb/a7-fixd-golden-probe.cdb create mode 100644 tools/cdb/a7-fixd-golden2-probe.cdb create mode 100644 tools/cdb/a7-fixd-lights-v2.cdb create mode 100644 tools/cdb/a7-fixd-lights.cdb create mode 100644 tools/cdb/a7-fixd-numstatic-probe.cdb diff --git a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md index 5af718f8..381860de 100644 --- a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md +++ b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md @@ -45,57 +45,83 @@ was wrong (Fix C). Don't re-investigate the purple. --- -## OPEN — Fix D: outdoor OBJECTS too bright near torches +## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18) -**Symptom (user, 2026-06-18):** the Holtburg meeting-hall walls blow out warm/bright -in acdream vs dim in retail. Fix A/B/C did NOT touch this. It's the per-object -point-light **contribution on objects**. +**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. -### Grounded (cdb + decomp) — retail's object point-light path -`Render::config_hardware_light` (0x0059ad30) builds the `D3DLIGHT9`: -- `Diffuse = color × intensity` -- `Attenuation = (0, 1, 0)` ⇒ **1/d** (inverse-LINEAR; acdream's `calc_point_light` - is `~1/d²` via norm = distsq·d) -- `Range = falloff × rangeAdjust`, **`rangeAdjust = 1.5`** (0x00820cc4) ⇒ torch Range - = 6×1.5 = **9 m** (LARGER than acdream's falloff×1.3 = 7.8 m — range is NOT why - we're brighter) -- live `LIGHTINFO` captured: torch `type=0 intensity=100 falloff=6`; a 2nd light - `intensity=2.25 falloff=10` -- `d3d_material.Diffuse = (1,1,1)` white (decomp 0x00539774) +### 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. -### THE CONTRADICTION (resolve this FIRST next session) -By `mat(1)×color×100×(N·L)×(1/d)`, a torch 3 m away = `color×33` ⇒ retail's walls -SHOULD blow to **WHITE** — but they're **DIM**. Material diffuse, range, and -intensity are all captured and ruled out. So the scaling lives in the building's -**RENDER PATH**, which is unknown. **⚠ DO NOT port the D3D-FF model — by this math it -would make objects BRIGHTER (white), the opposite of the fix.** +### 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 = (1−d/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. -### The decisive next capture -Determine the static building's ACTUAL render path: -- **Hypothesis (a) — MOST LIKELY:** static buildings DON'T use D3D hardware lighting. - They use the `D3DPolyRender::SetStaticLightingVertexColors` BAKE (0x0059cfe0 → - `calc_point_light`), like EnvCells. The `config_hardware_light` lights I captured - were for a DIFFERENT object (player / creature / the purple PORTAL — note the - `intensity=100` could be the portal, not the wall torch). If (a) holds, acdream's - `calc_point_light` is the RIGHT model and the over-brightness is the **per-channel - cap** (`min(scale×col,col)` lets several torches each reach full colour and sum to - white) and/or **too many torches selected** per object and/or a missing clamp step. -- **Hypothesis (b):** `D3DRS_LIGHTING` off / lights not `LightEnable`'d for the - building draw. -- **How to capture:** break at `SetStaticLightingVertexColors` (0x0059cfe0) and see - whether it's called for the building's mesh (confirms the bake path); and/or - inspect the render state around the static-object `DrawIndexedPrimitive` - (`D3DRS_LIGHTING`, which lights are enabled). Also: at `config_hardware_light`, - dump WHICH object/owner the light is being configured for to identify whether the - `intensity=100` light is the torch or the portal. +### 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 1–2): 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 3–5 + (→ bake range ~3.9–6.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 side — where the fix lands -- acdream runs `calc_point_light` (wrap/norm + per-channel cap) for ALL meshes via - `mesh_modern.vert` `pointContribution` (objects AND cells — Fix A). -- If buildings use the bake, the likely fix is in the **cap / sum / count**, not the - attenuation model. Files: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` - (`pointContribution` + `accumulateLights`), `src/AcDream.Core/Lighting/LightManager.cs` - (`SelectForObject`), `LightBake.cs` (verbatim calc_point_light, still unwired). +### 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. --- diff --git a/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md new file mode 100644 index 00000000..1ad1a645 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md @@ -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 | 3–5 | **(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. diff --git a/tools/cdb/a7-fixd-golden-probe.cdb b/tools/cdb/a7-fixd-golden-probe.cdb new file mode 100644 index 00000000..07627206 --- /dev/null +++ b/tools/cdb/a7-fixd-golden-probe.cdb @@ -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 diff --git a/tools/cdb/a7-fixd-golden2-probe.cdb b/tools/cdb/a7-fixd-golden2-probe.cdb new file mode 100644 index 00000000..e4ed7d0e --- /dev/null +++ b/tools/cdb/a7-fixd-golden2-probe.cdb @@ -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 diff --git a/tools/cdb/a7-fixd-lights-v2.cdb b/tools/cdb/a7-fixd-lights-v2.cdb new file mode 100644 index 00000000..03345800 --- /dev/null +++ b/tools/cdb/a7-fixd-lights-v2.cdb @@ -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 diff --git a/tools/cdb/a7-fixd-lights.cdb b/tools/cdb/a7-fixd-lights.cdb new file mode 100644 index 00000000..34d2558b --- /dev/null +++ b/tools/cdb/a7-fixd-lights.cdb @@ -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 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 diff --git a/tools/cdb/a7-fixd-numstatic-probe.cdb b/tools/cdb/a7-fixd-numstatic-probe.cdb new file mode 100644 index 00000000..155bbbce --- /dev/null +++ b/tools/cdb/a7-fixd-numstatic-probe.cdb @@ -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