# Retail Lambert — brightness split pseudocode

**Date:** 2026-04-24
**Owner:** lighting (terrain / mesh / sky)
**Decompile refs:** `chunk_00450000.c:2073` (`FUN_004530e0`), `chunk_00500000.c:6030` (`FUN_00505f30`), `chunk_00530000.c:1997` (`FUN_00532440` AdjustPlanes)

## Purpose

Retail's per-vertex lighting equation does **not** match what acdream is
currently shipping. Side-by-side screenshots show acdream as warmer /
less-blue than retail under the same DayGroup, and the 2026-04-24 user
investigation narrowed it to the **ambient component being static instead
of dynamic**. This doc captures the retail formula verbatim from the
decompile and maps it to concrete code changes.

## Retail globals (decompiled, names corrected)

CLAUDE.md currently labels these backwards. Walking the math in
`FUN_00532440`:

| Symbol | Real meaning | Source |
|---|---|---|
| `DAT_00842778` | **Directional color** (ARGB uint32) — multiplied by N·L per-vertex | `FUN_00505f30` param_5 |
| `DAT_0084277c` | **Ambient color** (ARGB uint32) — multiplied by `ambBright`, no N·L | `FUN_00505f30` param_3 |
| `DAT_00842780` | **Ambient brightness scalar** (float) | `FUN_00505f30` param_2 |
| `DAT_00842950/54/58` | **Sun direction** (vec3). Magnitude encodes sun intensity (not unit length). | `FUN_00505f30` param_4 |
| `DAT_00796344` | **Ambient floor** (float) — lower bound on N·L clamp. Retail ~0.08. | hardcoded constant |
| `DAT_007938c0` | **Ceiling** (float) = 1.0 — per-channel clamp | hardcoded |
| `DAT_00799208` | 1/255.0 — for unpacking ARGB bytes | hardcoded |
| `_DAT_008682b0/b4/b8` | Per-frame cache: `(ambBright + |sunDir|·scale) × ambColor.rgb` | Written by `FUN_004530e0`, read by `FUN_00532440` |

## Retail per-vertex formula (from FUN_00532440)

```
// Once per frame (FUN_00505f30 line 6067, FUN_004530e0):
effectiveAmbBright = ambBright + |sunDir| * scale      // scale = _DAT_0079a1e8
ambPremul          = effectiveAmbBright * ambColor     // cached at _DAT_008682b0

// Per vertex (FUN_00532440 line 2118, iterated for all vertices):
NdotL       = dot(sunDir, N)                           // sunDir NOT normalized
NdotL       = max(NdotL, floor)                        // floor = DAT_00796344 (~0.08)
out.r       = dirColor.r * NdotL + ambPremul.r
out.g       = dirColor.g * NdotL + ambPremul.g
out.b       = dirColor.b * NdotL + ambPremul.b
out         = min(out, 1.0)                            // per-channel ceiling
```

Structure:

1. **Ambient term** = `(ambBright + |sunDir|·scale) × ambColor.rgb` — flat
   per vertex, but changes per-frame as sun rises/falls.
2. **Directional term** = `dirColor × max(N·sunDir, floor)` where sunDir
   keeps its length so N·L can exceed 1.0 when sun is strong overhead.
3. Final per-channel clamp to 1.0.

## acdream today (for contrast)

- `terrain.vert:124` — `L = max(dot(vWorldNormal, -sunDir), 0.08); vLightingRGB = sunCol * L + uCellAmbient.xyz`
- `mesh.frag:54-67` — `lit = uCellAmbient.xyz + Lcol * max(0, dot(N, -forward))`
- `sky.vert:87-91` — `lit = vec3(uEmissive) + uAmbientColor + uSunColor * max(dot(N, uSunDir), 0)`

Common bugs:

1. `uCellAmbient` / `uAmbientColor` are **pre-multiplied at load time** by
   the keyframe's `AmbBright`. No dynamic per-frame scaling. Retail
   re-computes `(ambBright + |sun|·scale) × ambColor` every frame.
2. `sunDir` is **always normalized** in
   `SkyStateProvider.SunDirectionFromKeyframe` — loses the magnitude that
   encodes sun intensity. In retail, `sunDir` with magnitude > 1 pushes
   N·L above 1.0 pre-clamp; with magnitude < 1 it dims the directional
   term globally (dusk).
3. `MIN_FACTOR = 0.08` is hard-coded in terrain.vert. Should be a
   uniform sourced from retail's `DAT_00796344`.

## Port plan (minimum necessary)

### CPU side (SkyKeyframe struct)

Add three fields, **do not remove the pre-multiplied ones** (tests consume
them; preserve source compatibility):

```csharp
public readonly record struct SkyKeyframe(
    // ... existing fields ...
    Vector3 SunColor,           // = DirColor * DirBright (kept for compat)
    Vector3 AmbientColor,       // = AmbColor * AmbBright (kept for compat)
    // ── NEW for retail-accurate lighting ───────────────────────────
    Vector3 DirColorRaw = default,   // ColorToVec3(DirColor) — no bright mult
    Vector3 AmbColorRaw = default,   // ColorToVec3(AmbColor) — no bright mult
    float   DirBright   = 1f,        // DAT_00842780 is ambient scalar; rename accordingly
    float   AmbBright   = 1f);       // dat's AmbBright
    // Sun-dir magnitude: keep heading/pitch unit-length. Retail's
    // scale factor is small (_DAT_0079a1e8 looks like ~0.02–0.05 from
    // context but I haven't decoded its exact value yet). Defer to
    // later sprint unless it moves the needle.
```

### Shader side

Both `terrain.vert` and `mesh.frag` / `mesh_instanced.frag`:

```glsl
// Replace pre-baked uCellAmbient read with dynamic effective:
float ambBright = uCellAmbient.w /* or a new uniform */;
vec3  ambPremul = uCellAmbient.xyz * ambBright;
float L         = max(dot(N, -uLights[0].dirAndRange.xyz), uAmbientFloor);
vec3  lit       = uLights[0].colorAndIntensity.xyz * L + ambPremul;
```

But `uCellAmbient.w` is currently used for `active light count`, not
brightness. Two options:

- **Option A:** repurpose `uCellAmbient.w` as ambient brightness, move
  active count to a new uniform / UBO field. Clean but invasive.
- **Option B:** Leave UBO layout alone; write the already-scaled ambient
  into `uCellAmbient.xyz` at UBO-build time (same as today). Defer the
  magnitude-encoding sunDir for a later sprint. This is the **minimum
  change that matches user intent** — the ambient will now respond to
  sun magnitude.

We're going with **Option B** — multiply `AmbientColor * (ambBright + |sunDir|·scale)`
at UBO build time, not at load time. Tests currently assume
`AmbientColor` is already pre-multiplied so we keep that semantic but
recompute per-frame instead of per-keyframe.

### CLAUDE.md fix

Line in the "Reference hierarchy by domain" section or wherever lighting
globals are documented:

- Swap "ambient from DAT_00842778, diffuse from DAT_0084277c" →
  "**directional from DAT_00842778, ambient from DAT_0084277c**".

## Rollout order

1. Expose `AmbBright` scalar on `SkyKeyframe` + `AtmosphereSnapshot`
   (load it, don't pre-multiply). Keep `AmbientColor` as the unscaled
   vec3.
2. `SceneLightingUbo.Build` multiplies `AmbientColor * AmbBright` at
   build time (per frame).
3. Run tests. `SkyDescLoaderTests`, `SkyStateProviderTests`,
   `WeatherSystemTests` must all still pass.
4. Launch. Visual check: retail should now look indistinguishable for
   overcast / rainy DayGroups. Sunny may be unchanged because
   `AmbBright` is typically ~1.0 at noon.
5. If (4) still shows mismatch, investigate sunDir magnitude (Phase 2).

## Tests to add

- `SkyDescLoaderTests.ConvertTimeOfDay_ExposesAmbBrightScalar` — assert
  that after load, `kf.AmbBright` matches the dat value and
  `kf.AmbientColor` is NOT pre-multiplied (or that a new `AmbColorRaw`
  field exists alongside).
- `SceneLightingUboTests.AmbientScalesWithAmbBright` — build two UBOs
  with `AmbBright = 0.5` vs `AmbBright = 1.0`; assert `ubo.CellAmbient.xyz`
  is half.

## Risks

- **Dim outdoor shading** if `AmbBright` is often < 0.5 in retail dats.
  Mitigation: visual verify against retail screenshot. If too dim,
  retail might apply a gamma/brightness offset elsewhere we haven't
  spotted.
- **Breaks existing lighting tests** that pin `AmbientColor` magnitude.
  Mitigation: update tests to check `AmbColorRaw * AmbBright` == old
  value.
