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) <noreply@anthropic.com>
9.9 KiB
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_lighting0x0054D480 enables both, for free GfxObjs.) Soconfig_hardware_light— where last session'sintensity=100was 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 lightintensity=2.25 falloff=10 color=(1,1,1)white; PORTALintensity=100 falloff=6 color=(0.784,0,0.784)MAGENTA. → theintensity=100light 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.vertaccumulateLightsstartslit = uCellAmbient.xyz(:184), ADDS sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is onemin(lit,1.0)inmesh_modern.frag:92AFTER 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 sharedmesh_modern.vertreads at :204-206. OnlyWbDrawDispatcherbinds 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,saturateit to [0,1] BEFORE adding ambient + sun — mirrorsSetStaticLightingVertexColors(sum-from-black, clamp the point sum). Keep the per-lightmin(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.csRenderModernMDIInternal. - Stale doc to fix in the D-1 commit: divergence-register
AP-35still describes the point-light path as per-pixelmesh_modern.frag:52with the wrap "NOT ported"; Fix A (aa94ced) moved it to per-vertexmesh_modern.vert:163WITH 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_groupLScape::sunlightglobal @ 0x00841940 (Vector3);LScape::ambient_level@ 0x00841770bp acclient!PrimD3DRender::config_hardware_light(0x0059ad30) — arg4=LIGHTINFO[esp+0x10];dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff colorrangeAdjust = 1.5@ 0x00820cc4;D3DPolyRender::SetStaticLightingVertexColors@ 0x0059cfe0- Pattern:
.formats poi(<addr>)for floats,dwo(<addr>)for dwords,qdafter 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.