acdream/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md
Erik c407104ab9 docs(lighting): A7 Fix D investigation RESOLVED + implementation spec (#140)
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>
2026-06-18 17:08:27 +02:00

9.9 KiB
Raw Blame History

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_lightinsert_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.