# 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 = (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. ### 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'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()` for floats, `dwo()` 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.