# Sky Rendering — Retail-Verbatim Investigation (SYNTHESIS)

**Date:** 2026-04-23
**Status:** RESEARCH COMPLETE — decompile-verified. Ready to plan port.
**Inputs:** three parallel hunt agents (A: Region loader; B: D3D state; C: global state + keyframe interp) + references cross-ref + dat schema map.
**Deliverables:** retail function map, struct layouts, globals, pseudocode, port plan.

## 0. TL;DR — the retail sky pipeline in three sentences

1. Retail renders a small list of sky `GfxObj` meshes (sun, moon, stars, clouds, dome) through the **normal mesh render queue** — no bespoke sky shader, no camera-anchored sky projection, no D3DRS_AMBIENT writes.
2. The "sky colors" you see at dusk come from **per-vertex lighting on non-Luminous sky meshes** (ambient + diffuse × sun), plus fog (`D3DRS_FOGCOLOR/START/END` updated per keyframe), plus any `SkyObjReplace` that swaps a mesh for a time-of-day variant.
3. Keyframe interpolation gates on `LightTickSize` — lighting only updates every ~2 s, so the sky color marches in visible steps (consistent with live retail feel).

---

## 1. Full retail function map

All citations are relative to `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\`.

| Address | File | Name | Purpose |
|---|---|---|---|
| `FUN_004ff370` | chunk_004F0000.c:10610 | `Region::Load` | Dat loader. Calls `FUN_00415730(id, 0xb, 0x1c)` (type-index 0x1c = REGION). Stores result at `DAT_0084247c`. |
| `FUN_004ff420` | chunk_004F0000.c:10680 | `Region::UpdateSkyObjectsTrampoline` | Guards + delegates to `FUN_00501990`. |
| `FUN_004ff440` | chunk_004F0000.c:10692 | `Region::LerpSunAndAmbient` | Guards → `FUN_00501600`. |
| `FUN_004ff480` | chunk_004F0000.c:10708 | `Region::LerpFogAmbient` | Guards → `FUN_00501860`. |
| `FUN_004ff4b0` | chunk_004F0000.c:10724 | `Region::UpdateSkyObjectTable` | Guards → `FUN_00502a10`. |
| **`FUN_00501530`** | chunk_00500000.c:1097 | **Keyframe bracket-picker** | Walks sorted keyframes; returns `k1, k2, u = (t - k1.Begin) / (k2.Begin - k1.Begin)`. Last-slot wrap: `k2 = arr[0]`, denominator uses `1.0f` (`_DAT_007938b0`). |
| **`FUN_00501600`** | chunk_00500000.c:1155 | **Sun + ambient interpolator** | Lerps DirBright, DirColor, DirHeading, DirPitch, AmbColor; emits `sunVec = DirBright * (sin yaw cos pit, cos yaw cos pit, sin pit)`. |
| **`FUN_00501860`** | chunk_00500000.c:1238 | **Fog interpolator** | Lerps MinWorldFog, MaxWorldFog, WorldFogColor. |
| `FUN_00501990` | chunk_00500000.c:1276 | Deterministic PES roll | Uses `DAT_008ee9c8` hash to pick physics-script variant. |
| `FUN_00501b20` | chunk_00500000.c:1384 | `SkyObject::Unpack` | 0x2c-byte struct. See §3. |
| `FUN_00501cd0` | chunk_00500000.c:1500 | `SkyObjectReplace::Unpack` | 0x1c-byte struct. See §3. |
| `FUN_00501f50` | chunk_00500000.c:1669 | **Region preloader** | Iterates every SkyObject + SkyObjectReplace, issues `FUN_0041a4e0(type=8, id)` for each GfxObjId. |
| `FUN_00502100` | chunk_00500000.c:1784 | `SkyTimeOfDay::Unpack` | 0x38-byte struct. See §3. |
| `FUN_005025c0` | chunk_00500000.c:2124 | `DayGroup::Unpack` | Reads `ChanceOfOccur + DayName + SkyObjects[] + SkyTime[]`. |
| **`FUN_00502820`** | chunk_00500000.c:2279 | **`SkyDesc::Unpack`** | 2 doubles (TickSize, LightTickSize) + DayGroup list. The signature hit that confirmed the trail. |
| **`FUN_00502a10`** | chunk_00500000.c:2407 | **Per-frame sky-object table builder** | Iterates `DayGroup.SkyObjects`, checks visibility, lerps `BeginAngle→EndAngle` by `u`, applies `SkyObjReplace` overrides. Emits 0x2c-byte entries per visible object. |
| **`FUN_00505f30`** | chunk_00500000.c:6026 | **Apply-light-state** | Writes globals `DAT_00842778/7c/80/950..58`. Writes D3D light slots `_DAT_008682bc..d4`. Calls `FUN_004530e0` (set D3D directional light). **Loops landblock grid, runs `FUN_00532440` (AdjustPlanes) to re-light every cell.** |
| **`FUN_005062e0`** | chunk_00500000.c:6213 | **Per-frame sky tick** | Every frame: draws sky (`FUN_00508010`); every `TickSize` sec: runs light interp + publish; every `LightTickSize` sec: runs fog interp + publish. Also handles weather crossfade. |
| `FUN_00506d90` | chunk_00500000.c:6683 | Scene renderer (weather phase caller) | Calls `FUN_00507a50(0)` = phase-0 weather volume, `FUN_00507a50(1)` = phase-1 overlay. |
| `FUN_00507a50` | chunk_00500000.c:7250 | **Weather-volume draw** (Agent B's find) | Not sky per se — draws rain/snow/fog-shaft scene-graph objects with Zwrite OFF, far plane × `_DAT_007c6f14`. |
| **`FUN_00508010`** | chunk_00500000.c:7535 | **Sky-object render loop** | Refreshes per-frame table via `FUN_004ff4b0`, iterates sky objects, resets transform (`FUN_00535b30`), applies two-axis rotation (`FUN_005079e0`), queues mesh draw (`FUN_00514b90`), applies per-object Luminosity/MaxBright/Transparent overrides (`FUN_00512360/5124b0/5120c0`) scaled by `_DAT_007a1870`. |
| `FUN_005079e0` | chunk_00500000.c:7229 | Two-axis sky-object transform | `FUN_00536b80(angleA)` then `FUN_005364e0({0, -angleB × π/180, 0})`. |
| `FUN_00514b90` | chunk_00510000.c | Mesh-draw enqueue | Shared with ALL mesh rendering (sky, entity, static). Sky meshes use the same code path as everything else. |
| `FUN_004530e0` | (not fully read) | D3D SetDirectionalLight | Writes `(|sunVec| × _DAT_0079a1e8 + DirBright, DirColor)` into D3D. |
| `FUN_00532440` | (not fully read) | **AdjustPlanes** (per-cell relight) | Recomputes per-vertex lighting on every terrain cell after a lighting tick. |
| `FUN_00451a60` | chunk_00450000.c:608 | ARGB byte-unpack | `(R, G, B, A) bytes → 3 floats × (1/255)` into scratch slots then copied to `_DAT_008682bc/c0/c4` as per-mesh ambient. |

---

## 2. Per-frame call graph

```
Region::FrameTick (FUN_005062e0)                                [every frame]
  │
  ├── if skyEnabled: Region::RenderSkyObjects (FUN_00508010)    [draws sky]
  │     ├── Region::UpdateSkyObjectTable (FUN_004ff4b0 → FUN_00502a10)
  │     │     └── SkyTimeOfDayList::FindBracket (FUN_00501530)
  │     └── foreach visible sky-object:
  │           ├── reset transform (FUN_00535b30 = identity)
  │           ├── SkyObjectTransform::ApplyRotations (FUN_005079e0)
  │           │     ├── FUN_00536b80(headingFromReplaceOrZero)
  │           │     └── FUN_005364e0({0, -arcAngle·π/180, 0})
  │           ├── FUN_00514b90(transform)          # enqueue mesh draw
  │           ├── if rep.Luminosity  > 0: FUN_00512360(0, rep.Luminosity  × _DAT_007a1870, 0, 0)
  │           ├── if rep.MaxBright   > 0: FUN_005124b0(0, rep.MaxBright   × _DAT_007a1870, 0, 0)
  │           └── if rep.Transparent ≥ 0: FUN_005120c0(   rep.Transparent × _DAT_007a1870, 0, 0)
  │
  └── if (nextLightTick ≤ gameClock) AND Region is loaded:        [every TickSize seconds]
        ├── dayFraction = *(float *)(DAT_008ee9c8 + 0x48)          # from player/time global
        │
        ├── if (nextKeyframeTick < gameClock):                     [every LightTickSize seconds]
        │     ├── Region::LerpSunAndAmbient (FUN_004ff440 → FUN_00501600)
        │     │     # out: DirBright, DirColor(ARGB), sunVec[3], AmbColor(ARGB)
        │     │     # sunVec = DirBright * (sin yaw cos pit, cos yaw cos pit, sin pit)
        │     ├── clamp DirBright ≥ DAT_0084295c (MinWorldFog floor)
        │     ├── if (weatherCrossfade): blend toward weather-override values
        │     └── Region::ApplyLightState (FUN_00505f30)
        │           ├── DAT_00842780 = DirBright
        │           ├── DAT_0084277c = DirColor        # packed ARGB
        │           ├── DAT_00842950/54/58 = sunVec    # scaled by DirBright (NOT a unit vector)
        │           ├── DAT_00842778 = AmbColor        # packed ARGB
        │           ├── _DAT_008682bc/c0/c4 = unpack(AmbColor) / 255       # per-mesh ambient RGB floats
        │           ├── _DAT_008682c8/cc/d0 = sunVec                       # D3D light direction
        │           ├── FUN_004530e0(|sunVec| × 0079a1e8 + DirBright, DirColor)  # SetDirectionalLight
        │           └── foreach landblock cell: FUN_00532440(cell)          # AdjustPlanes per-cell
        │
        ├── fog gate: FUN_005a4010(!DAT_0081dbf8)                   # master fog enable
        └── if (DAT_0081dbf8): Region::LerpFogAmbient (FUN_004ff480 → FUN_00501860)
              # out: fogNear, fogFar, fogColor(ARGB)
              ├── if (weatherCrossfade): blend toward weather-override fog
              └── FUN_005a41b0(&fogColor, fogNear, fogFar)
                    └── FUN_005a4080: writes D3DRS_FOGCOLOR=34, FOGSTART=36, FOGEND=37
```

---

## 3. Struct layouts (decompile-verified)

### 3.1 Region

```
Region (DBObj, 0x13000000..0x1300FFFF, type-index 0x1c)
  +0x00  vtable                   (ref-count release at +0x14)
  +0x44  (unknown)                used in chunk_00530000.c:2521
  +0x50  SkyDesc*                 pointer to SkyDesc; 0 = no sky info
```

`DAT_0084247c = current Region*` (global; set by `FUN_004ff370`, cleared by `FUN_004ff3b0`).

### 3.2 SkyDesc (chunk_00500000.c:2279 FUN_00502820)

```
SkyDesc
  +0x00  vtable
  +0x08  TickSize       (double)   seconds per frame tick
  +0x10  LightTickSize  (double)   seconds per lighting interp step
  +0x18  DayGroup[]  ptr
  +0x1c  DayGroup     capacity
  +0x20  DayGroup     count
```

### 3.3 DayGroup (chunk_00500000.c:2124 FUN_005025c0) — 0x20 bytes

```
DayGroup
  +0x00  vtable
  +0x04  ChanceOfOccur (float)      PDF weight for weather roll
  +0x08  DayName (PString)
  +0x14  SkyObjects[] ptr           0x2c bytes each
  +0x18  SkyObjects capacity
  +0x1c  SkyObjects count
  ...    SkyTime[] lives in a trailing section (not fully mapped; see Agent A §7)
```

### 3.4 SkyObject (chunk_00500000.c:1384 FUN_00501b20) — 0x2c bytes

```
SkyObject
  +0x00  ref/runtime               init to 0xbf800000 (-1.0f "not set")
  +0x04  BeginTime       (float)   day-fraction window start [0..1]
  +0x08  EndTime         (float)   wraps when End < Begin
  +0x0c  BeginAngle      (float)   degrees arc-sweep start
  +0x10  EndAngle        (float)   degrees arc-sweep end
  +0x14  TexVelocityX    (float)   UV/sec scroll rate
  +0x18  TexVelocityY    (float)
  +0x1c  runtime GfxObjRef         NOT from stream; init 0
  +0x20  Properties      (uint)    flag bits (undecoded; possibly billboard)
  +0x24  DefaultGfxObjectId
  +0x28  DefaultPesObjectId
```

Read order from stream: `BeginTime, EndTime, BeginAngle, EndAngle, TexVelocityX, TexVelocityY, DefaultGfxObjectId, DefaultPesObjectId, Properties` — matches dats.xml 1:1.

### 3.5 SkyTimeOfDay (chunk_00500000.c:1784 FUN_00502100) — 0x38 bytes

Wire read order: `Begin, DirBright, DirHeading, DirPitch, DirColor, AmbBright, AmbColor, MinWorldFog, MaxWorldFog, WorldFogColor, WorldFog`. **Struct writes WorldFog LAST to offset 0x1c**, so the in-memory layout reorders:

```
SkyTimeOfDay
  +0x00  Begin           (float)
  +0x04  DirBright       (float)
  +0x08  DirHeading      (float, degrees)
  +0x0c  DirPitch        (float, degrees)
  +0x10  DirColor        (ColorARGB)
  +0x14  AmbBright       (float)
  +0x18  AmbColor        (ColorARGB)
  +0x1c  WorldFog        (uint, D3D fog mode: 0=off,1=linear,2=exp,3=exp2)
  +0x20  MinWorldFog     (float, meters)
  +0x24  MaxWorldFog     (float, meters)
  +0x28  WorldFogColor   (ColorARGB)
  +0x2c  SkyObjectReplace[] ptr
  +0x30  SkyObjReplace capacity
  +0x34  SkyObjReplace count
```

### 3.6 SkyObjectReplace (chunk_00500000.c:1500 FUN_00501cd0) — 0x1c bytes

```
SkyObjectReplace
  +0x00  ObjectIndex     (uint)     index into DayGroup.SkyObjects
  +0x04  runtime SkyObject*         NOT from stream; resolved in FUN_005025c0:2249
  +0x08  GfxObjId        (QualifiedDataId<GfxObj>)   0 = keep default
  +0x0c  Rotate          (float, degrees heading)
  +0x10  Transparent     (float)
  +0x14  Luminosity      (float)    (0xbf800000 = -1.0f init)
  +0x18  MaxBright       (float)
```

### 3.7 Per-frame SkyObject render entry (FUN_00502a10 output) — 0x2c bytes

```
Entry
  +0x00  GfxObjId            (default or SkyObjectReplace override)
  +0x04  PesObjectId
  +0x08  (reset marker / zero)
  +0x0c  CurrentArcAngle     (deg) = lerp(BeginAngle, EndAngle, u)
  +0x10  TexVelocityX
  +0x14  TexVelocityY
  +0x18  (runtime slot)
  +0x1c  Transparent         (from SkyObjectReplace iff Transparent > 0 sentinel)
  +0x20  Luminosity          (from SkyObjectReplace iff Luminosity > 0 sentinel)
  +0x24  MaxBright           (from SkyObjectReplace iff MaxBright > 0 sentinel)
  +0x28  Properties          (SkyObject.Properties)
```

---

## 4. Globals — the sky state block

Two clusters at `0x00842778..0x008427bf` (primary state + crossfade) and `0x00842950..0x0084295f` (sun vector + fog floor):

| Global | Role | Producer | Consumer(s) |
|---|---|---|---|
| `DAT_0084247c` | current Region* | `FUN_004ff370` | every sky accessor |
| `DAT_00842778` | current AmbColor (packed ARGB) | `FUN_00505f30` | per-vertex lighting in chunk_00530000.c:2100-2105 |
| `DAT_0084277c` | current DirColor (packed ARGB, also fog tint) | `FUN_00505f30` | per-vertex lighting, fog |
| `DAT_00842780` | current DirBright (float) | `FUN_00505f30` | per-vertex lighting (ambient scalar); fog-start offset |
| `DAT_00842790` | cloud/stars heightmap buffer ptr | `FUN_00508010` realloc | sky-object draw |
| `DAT_00842798` | next cloud/weather-tick deadline (double) | `FUN_005062e0` | `FUN_005062e0` |
| `DAT_008427a0` | next keyframe-tick deadline (double) | `FUN_005062e0` | `FUN_005062e0` |
| `DAT_008427a8` | FixedLight override enable (bool) | `FUN_00505d40` | `FUN_00505f30` |
| `DAT_008427a9` | weather crossfade active (bool) | weather code | `FUN_005062e0`, `FUN_00508010` |
| `DAT_008427b8` | crossfade u (0..1) | `FUN_005062e0` | `FUN_005062e0` |
| `DAT_00842950/54/58` | sun vector × DirBright (3 floats) | `FUN_00505f30` | per-vertex terrain `chunk_00530000.c:2094-2230`; landblock ambient apply `chunk_00450000.c:4337-4468` |
| `DAT_0084295c` | MinWorldFog clamp floor (float) | region config | `FUN_005062e0:6253-6254` |
| `_DAT_008682b0/b4/b8` | D3D directional-light RGB = `DirColor × DirBright` | `FUN_004530e0` | D3D pipeline |
| `_DAT_008682bc/c0/c4` | D3D per-mesh ambient RGB (unpacked from AmbColor/255) | `FUN_00451a60 → FUN_00505f30` | per-mesh lighting |
| `_DAT_008682c8/cc/d0` | D3D directional-light direction (copy of sun vec) | `FUN_00505f30` | D3D pipeline |
| `DAT_008ee9c8` | world/time global (dayFraction at +0x48, tick counters) | world-update (NOT FOUND) | `FUN_005062e0`, `FUN_00508010` |
| `_DAT_007938b0` | `1.0f` (day-fraction upper bound) | `.rdata` constant | keyframe wrap denominator |
| `_DAT_0079c6b0` | `π/180` (deg → rad) | `.rdata` constant | sun-direction math, mesh rotation |
| `_DAT_00799208` | `1/255f` | `.rdata` constant | ARGB byte → float |
| **`_DAT_007a1870`** | **Scale for T/L/MB overrides** (likely 1.0f, unconfirmed) | `.rdata` constant | `FUN_00508010:7588-7594` |
| `DAT_00796344` | **`0.0f` sentinel** ("don't override if ≤ 0") — confirmed via threshold comparisons in chunk_004F0000.c:9152, 9172 | `.rdata` | `FUN_00508010:7587,7590,7593` |

---

## 5. The sun-direction formula (decompile-verified, chunk_00500000.c:1192-1205)

```
yaw_rad   = DirHeading × (π/180)        # _DAT_0079c6b0
pitch_rad = DirPitch   × (π/180)
mag       = DirBright

x = mag × sin(yaw) × cos(pitch)
y = mag × cos(yaw) × cos(pitch)
z = mag ×                sin(pitch)
```

Coordinate frame: X=east, Y=north, Z=up (Dereth world). Matches our `SkyStateProvider.SunDirectionFromKeyframe` **except** retail scales by `DirBright` (so the vector magnitude ≠ 1). The magnitude is deliberately retained — downstream code uses `|sunVec|` as a fog-start offset in `FUN_00505f30:6067`.

---

## 6. The cloud-tint puzzle — RESOLVED

User's observation: retail clouds show a "purple haze" at dusk; acdream clouds stay white.

**Mechanism (cross-referenced via Agent A + B + cross-ref):**
1. Sky meshes are drawn through the **normal mesh render queue** (`FUN_00514b90`), same as terrain / entities / statics.
2. **D3DRS_AMBIENT is set to 0 once at init and never changes** (Agent B confirmed). The keyframe AmbColor does NOT drive D3DRS_AMBIENT.
3. Instead, the keyframe AmbColor (ARGB byte) is unpacked via `FUN_00451a60` into **three float slots** `_DAT_008682bc/c0/c4`, which are **per-mesh ambient RGB** used by the per-vertex lighting pipeline.
4. `FUN_00532440` (AdjustPlanes) re-lights EVERY CELL on every keyframe tick — so terrain and mesh vertex colors are baked in from `(DirColor × DirBright)` dot-normal + AmbColor.
5. **A mesh's Surface.Type flags determine whether it participates in lighting:**
   - `Luminous` (0x40) set → self-illuminated, texture passthrough (sun, moon, stars, likely dome)
   - `Luminous` clear → gets `(ambient + diffuse × sun) × texture`, like any other mesh
6. Clouds without Luminous get the ambient tint naturally. No special "sky shader" path exists in retail.

**Implication for our port:**
- Our sky shader multiplying by `uTint = AmbientColor` is architecturally close but too broad — it tints ALL non-additive sub-meshes including the dome.
- The correct split is **by Surface.Luminous flag**, not by blend mode.
- Dome is Luminous (preserves baked gradient); clouds are non-Luminous (get ambient tint).

**Caveat (Agent A §7 gap #5):** the actual Luminous status of each retail sky mesh's Surface is NOT dumped here. We need a runtime diagnostic to confirm dome is Luminous and clouds are not.

---

## 7. The Transparent / Luminosity / MaxBright unit question

`FUN_00508010:7588-7594` applies overrides as:

```c
FUN_00512360(0, rep.Luminosity  × _DAT_007a1870, 0, 0);
FUN_005124b0(0, rep.MaxBright   × _DAT_007a1870, 0, 0);
FUN_005120c0(   rep.Transparent × _DAT_007a1870, 0, 0);
```

**`_DAT_007a1870` appears elsewhere** (`chunk_00510000.c:7644,7660`) as the FALLBACK "default when no override" return value — this is strongly consistent with **`_DAT_007a1870 = 1.0f`** (identity for Luminosity / scale).

If `_DAT_007a1870 = 1.0f`:
- Retail applies `Luminosity × 1.0 = raw dat value`.
- Our `/100` fix assumes the dat is in percent (0..100) and wants a scale-back-down.
- If dat Luminosity is already a fraction (0..1) — like `Surface.Luminosity = 0.3f` in the test fixture — our `/100` is WRONG (turns 0.3 into 0.003).

If `_DAT_007a1870 = 0.01f`:
- Retail applies `Luminosity × 0.01 = raw dat value ÷ 100`.
- Our `/100` correctly matches retail behavior.

**Decision:** **Need a real binary inspection of `0x007a1870` in `acclient.exe` to pin this.** Our user-confirmed "much better" observation after `/100` weakly supports `_DAT_007a1870 = 0.01f` (or equivalently, dat-in-percent with an implicit ×0.01 somewhere). Until binary-confirmed, TREAT THE `/100` AS SPECULATIVE.

Action: add a `dump-sky-desc` diag that prints raw SkyObjectReplace floats from a live dat; if values are consistently 0..100, `/100` is right and `_DAT_007a1870 = 0.01f`.

---

## 8. Architecture contrast: our SkyRenderer vs retail

| Aspect | acdream (current) | Retail | Action |
|---|---|---|---|
| Sky shader | Dedicated `sky.vert/frag` with `uTint`, `uLuminosity`, `uTransparency` | None — reuses normal mesh pipeline | **Unify**: route sky meshes through InstancedMeshRenderer with the SceneLighting UBO. OR: keep the dedicated shader but make it read the UBO's ambient/sun. |
| Camera-anchored sky | Yes — view matrix zeroed, far plane 1e6 | NO camera anchor (Agent B confirmed). Sky meshes live in world space, follow the camera via scene-graph parent. | Decide: is our camera-anchor a visible improvement, or a deviation? Retail may stretch the sky as the camera moves — which is visible at speed. |
| Per-keyframe tint | `uTint = 1` (eeae83a) OR `uTint = AmbientColor` (current uncommitted) | Per-vertex lighting driven by Surface.Luminous flag — ambient multiply happens per-fragment on non-Luminous meshes | **Replace blind `uTint = AmbientColor` with per-surface conditional** — Luminous meshes untinted, non-Luminous meshes get `(ambient + diffuse·sun) × texture` |
| Blend mode per sub-mesh | `SrcAlpha/One` for Additive; `SrcAlpha/OneMinusSrcAlpha` otherwise | Same pattern expected (Additive sun/moon, alpha clouds), but retail uses per-Surface-flag routing, not per-submesh classification | Keep current classification; it matches retail's intent. |
| `Transparent/Luminosity/MaxBright` | `/100` in loader, applied as uniforms | Applied via `FUN_00512360/5124b0/5120c0` scaled by `_DAT_007a1870` (likely 1.0f or 0.01f) | **Unresolved — see §7.** Do NOT change until binary-confirmed. |
| Keyframe interpolation | Lerps between 2 keyframes with wrap | Same algorithm (`FUN_00501530` — identical logic, including the `1.0f` wrap denominator) | **Match**, no change. |
| Lighting tick rate | Every frame | Every `LightTickSize` seconds (~2s) | **Port**: throttle the UBO update to `LightTickSize`. This produces retail's visible "step" in sky color transitions. |
| Per-cell relight | Not done | `FUN_00532440` (AdjustPlanes) reruns per-vertex lighting on every cell each keyframe tick | **Port** for terrain only — matches the "sky gets darker → terrain actually gets darker too" behavior. |
| Fog | Shader uniform, not updated per keyframe for sky | D3DRS_FOGCOLOR/START/END updated per keyframe via `FUN_005a41b0` | **Port**: UBO-update fog per keyframe. |
| UV scroll (TexVelocityX/Y) | We apply it | WorldBuilder doesn't; retail does via the per-object table (inferred — scroll state is part of the 0x2c-byte render entry) | Keep; matches retail intent. |

---

## 9. Retail-verbatim pseudocode

### 9.1 Region frame tick (port of `FUN_005062e0`)

```
def RegionFrameTick(landblockGrid):
    if not skyEnabled: return

    if skyObjectsTableReady:
        RenderSkyObjects(skyTable)              # every frame — see 9.2

    now = gameClockSeconds
    if nextSkyTick <= now and currentRegion is not None:
        nextSkyTick = currentRegion.SkyDesc.TickSize + now
        dayFraction = worldTime.dayFraction     # *(float*)(DAT_008ee9c8 + 0x48)

        # Keyframe interp gate — every LightTickSize seconds
        if nextLightTick < now:
            ok = LerpSunAndAmbient(dayFraction, &DirBright, &DirColor, &sunVec, &AmbColor)
            if ok:
                if DirBright < MinWorldFogFloor:      # DAT_0084295c
                    DirBright = MinWorldFogFloor
                if weatherCrossfadeActive:
                    blend(DirBright, DirColor, toward = weather override)
                    advance crossfade u by step constant
                ApplyLightState(DirBright, DirColor, sunVec, AmbColor)   # 9.3

            nextLightTick = currentRegion.SkyDesc.LightTickSize + now

        # Fog interp
        SetFogEnable(fogMasterFlag)
        if fogMasterFlag:
            ok2 = LerpFogAmbient(dayFraction, &fogNear, &fogFar, &fogColor)
            if ok2:
                if weatherCrossfadeActive:
                    blend(fogNear, fogFar, fogColor, toward = weather override)
                SetFogState(fogColor, fogNear, fogFar)         # D3DRS_FOGCOLOR/START/END
```

### 9.2 Sky-object render loop (port of `FUN_00508010`)

```
def RenderSkyObjects(table):
    EnsureSkyDescLoaded()
    dayFraction = playerWorldTime.dayFraction
    ok = UpdateSkyObjectTable(dayFraction, table)    # FUN_004ff4b0 → FUN_00502a10
    if not ok: return

    FUN_00507e20()      # probably advances a counter

    for i in range(table.count):
        entry = table.entries[i]    # 0x2c-byte struct per 3.7
        if entry.GfxObjId == 0: continue

        # Reset local transform to identity
        transform = IDENTITY

        # Special "custom position" path (flag & 4 — first entry only?)
        if (entry.propertiesFlag & 4):
            transform.translation = (entry.customX, entry.customY, entry.customZ)

        # Apply two-axis rotation — see FUN_005079e0
        # axis1 = unknown quaternion from table[+0x08]
        # axis2 = current arc angle (scalar degrees)
        ApplyRotations(transform, entry.axis1Quat, entry.arcAngleDeg)

        # Enqueue mesh draw through the normal render queue
        EnqueueMeshDraw(entry.GfxObjId, transform)

        # Apply per-keyframe overrides scaled by _DAT_007a1870 (likely 1.0f)
        scale = _DAT_007a1870
        if entry.Luminosity  > 0: SetLuminosity( entry.Luminosity  × scale)
        if entry.MaxBright   > 0: SetMaxBright(  entry.MaxBright   × scale)
        if entry.Transparent >= 0: SetTransparency(entry.Transparent × scale)
```

### 9.3 Apply-light-state (port of `FUN_00505f30`)

```
def ApplyLightState(DirBright, DirColor, sunVec3, AmbColor):
    # Publish globals
    worldSkyState.DirBright  = DirBright
    worldSkyState.DirColor   = DirColor
    worldSkyState.sunVec     = sunVec3        # NOT unit — carries |sunVec| = DirBright
    worldSkyState.AmbColor   = AmbColor

    # Unpack ambient ARGB to 3 floats (× 1/255)
    worldSkyState.ambientRGB = ColorARGB_to_RGB_floats(AmbColor)

    # Copy sun vector into "D3D direction" slot (what we call sunDirection uniform)
    d3dLight.direction       = sunVec3

    # SetDirectionalLight with pre-scaled power
    SetDirectionalLight(
        power = |sunVec3| × _DAT_0079a1e8 + DirBright,
        color = DirColor)

    # Per-cell relight — this is the big one
    for cell in landblockGrid:
        AdjustPlanes(cell)   # FUN_00532440: recomputes per-vertex lit colors
```

### 9.4 Interp (port of `FUN_00501600`)

```
def LerpSunAndAmbient(u, out_DirBright, out_DirColor, out_sunVec, out_AmbColor):
    (k1, k2, t) = FindBracket(skyTimeOfDayList, u)     # FUN_00501530
    if not found:
        out_DirBright = 0.3
        out_DirColor  = 0xFFFFFFFF
        out_AmbColor  = 0xFFFFFFFF
        out_sunVec    = (0.5, 0, 0.8)
        return False

    # Lerp the keyframe scalars (NOTE: offsets per §3.5 — struct layout is
    # Begin@0, DirBright@4, DirHeading@8, DirPitch@0xc, DirColor@0x10,
    # AmbBright@0x14, AmbColor@0x18)
    DirBright     = lerp(k1.DirBright, k2.DirBright, t)
    DirHeadingDeg = lerp(k1.DirHeading, k2.DirHeading, t)          # shortest-arc lerp!
    DirPitchDeg   = lerp(k1.DirPitch,   k2.DirPitch,   t)

    # Sun vector — NOT a unit vector; scaled by DirBright
    yaw = DirHeadingDeg × π/180
    pit = DirPitchDeg   × π/180
    out_sunVec = (
        DirBright × sin(yaw) × cos(pit),     # X east
        DirBright × cos(yaw) × cos(pit),     # Y north
        DirBright ×              sin(pit),   # Z up
    )

    # Byte-lerp each ARGB channel
    out_DirColor = byte_lerp(k1.DirColor, k2.DirColor, t)
    out_AmbColor = byte_lerp(k1.AmbColor, k2.AmbColor, t)

    # NOTE: `FUN_00501600` decompile shows AmbBright (+0x14) is NOT used to
    # scale the interpolated AmbColor at this call site. The scale is applied
    # downstream, possibly in the D3D bridge that consumes _DAT_008682bc/c0/c4.
    # See Agent A §7 gap #6.

    out_DirBright = DirBright
    return True
```

### 9.5 Bracket-picker (port of `FUN_00501530` — verbatim)

```
def FindBracket(keyframes, t):
    count = keyframes.count
    if count == 0: return (None, None, 0)
    if count == 1: return (keyframes[0], keyframes[0], 0)

    i = 0
    while i < count - 1:
        if t < keyframes[i + 1].Begin: break
        i += 1

    k1 = keyframes[i]
    if i == count - 1:
        # Wrap — last keyframe to first
        k2 = keyframes[0]
        u = (t - k1.Begin) / (1.0 - k1.Begin)     # denominator is 1.0, NOT k2.Begin
    else:
        k2 = keyframes[i + 1]
        u = (t - k1.Begin) / (k2.Begin - k1.Begin)

    return (k1, k2, u)
```

---

## 10. Port plan

### Phase 1: Safe revert + diagnostics (1 commit)

1. Revert uncommitted `SkyRenderer.cs` tint change → baseline `uTint = 1` for every sub-mesh (eeae83a state).
2. Add a one-shot `ACDREAM_DUMP_SKY=1` diagnostic that on Region load prints:
   - Every SkyObject: index, GfxObjId, BeginTime/EndTime, BeginAngle/EndAngle, TexVel, Properties.
   - For each loaded sky GfxObj: its surface list + Surface.Type flags (Luminous, Additive, Alpha, etc) + texture id.
   - Every SkyTimeOfDay keyframe: Begin, DirBright, DirHeading/Pitch, AmbBright, colors as hex bytes.
   - Every SkyObjectReplace: raw float values for Transparent/Luminosity/MaxBright.
3. Launch, log, inspect. Answers all the Agent A §7 gap questions.

### Phase 2: Route sky meshes through per-vertex lighting

Based on Phase 1 findings:
- If DOME is Luminous: uTint stays 1 for it.
- If CLOUDS are non-Luminous: they should get `(ambient + diffuse × sun) × texture`, not a simple ambient multiply.

Implementation options:
- **A (shader tweak):** Pass `Surface.Luminous` as a per-submesh uniform. Shader branches: Luminous → texture passthrough; non-Luminous → compute `ambient + max(0, dot(N, -sunDir)) × sunColor`.
- **B (architecture unification):** Route sky through `InstancedMeshRenderer` with the same `SceneLighting` UBO terrain/meshes use. No separate `sky.frag`. Each surface's Luminous flag already routes it through the per-surface lighting in the generic mesh path (or should).

### Phase 3: LightTickSize throttling + per-cell relight

- Throttle lighting UBO update to `LightTickSize` seconds (retail's ~2s step).
- Port `FUN_00532440` (AdjustPlanes) — recompute per-vertex baked lighting on every terrain cell each light-tick. This is probably what makes retail terrain "follow" the sky so convincingly.

### Phase 4: Fog + weather crossfade + lightning (later)

- Port `FUN_00501860` (fog interp) and the `SetFogState` write per keyframe.
- Port the weather crossfade (`DAT_008427a9`/`b8`).
- Lightning — NOT found in the decompile. Either retail doesn't have it or it's a separate code path. Ours is client-driven; keep as an acdream extension.

---

## 11. Open questions (Agent A §7 gaps — need follow-up)

1. **`_DAT_007a1870` exact value** (§7 above) — requires binary disassembly of `.rdata` at 0x007a1870. Do via `objdump`, Ghidra, or a dat-dump of a live memory address.
2. **FUN_00501600 offset discrepancy** — Agent A says the field offsets in the interp don't match the SkyTimeOfDay unpack. One of two reconciliations:
   - The interp uses a SEPARATE in-memory light-keyframe struct (not SkyTimeOfDay), possibly a simplified subset stored inline in DayGroup. Requires reading `FUN_00502a10` more carefully to see what pointer it passes.
   - Agent C's offset map (DirBright@+0x04, AmbColor@+0x18) is correct and Agent A's decompile-reading was off-by-one. Agent C matches ACE's `SkyTimeOfDay.cs` layout 1:1 — lean toward C.
3. **DAT_00796344 confirmed `0.0f`** — multiple usage sites confirm (returns this on divide-by-zero fallback, threshold comparison for "has override").
4. **AmbBright scaling location** — not visible in `FUN_00505f30`. Probably happens inside `FUN_00451a60` or the D3D material bridge. Our loader pre-multiplies AmbColor × AmbBright, which may double-apply.
5. **Surface.Luminous status of Dereth sky GfxObjs** — requires a live dump (Phase 1 diagnostic above).
6. **Lightning flash** — not found in decompile. Confirms it's either absent in retail or a separate path not on this hunt.

---

## 12. Decision trees

### Immediate action (this conversation)

```
if uncommitted SkyRenderer.cs change is still present:
    → REVERT to eeae83a baseline (uTint = white for all).
       Rationale: current tint change dims the dome gradient (verified
       regression). Baseline is the least-wrong state while we plan the
       retail-faithful rebuild.

then:
    → implement Phase 1 diagnostic dump to fill the §11 gaps.
    → do NOT implement Phase 2+ until the dump resolves the unit +
      Luminous-flag questions.
```

### Phase 2 fork (after Phase 1 dump)

```
if DOME.Surface.Luminous == true AND CLOUDS.Surface.Luminous == false:
    → retail-faithful: tint clouds by per-vertex (ambient + diffuse × sun),
      keep dome untinted. Matches the retail pipeline exactly.

elif DOME.Surface.Luminous == true AND CLOUDS.Surface.Luminous == true:
    → retail renders both as texture passthrough; the "purple haze"
      must come from SkyObjReplace swapping cloud textures at dusk.
      Solution: confirm our SkyObjReplace path fires correctly, and
      dusk cloud mesh actually has purple baked in.

elif DOME.Surface.Luminous == false:
    → (unexpected) the dome IS tinted by ambient. My initial hypothesis
      was wrong. Route dome through the ambient-tint shader path.
```

### Phase 3 (per-cell relight)

```
if Phase 2 fix looks good but terrain doesn't match:
    → port FUN_00532440 AdjustPlanes per-cell relight triggered on
      LightTickSize boundary.
```

---

## 13. References

- `docs/research/2026-04-23-sky-decompile-hunt-A.md` — Region loader trail.
- `docs/research/2026-04-23-sky-decompile-hunt-B.md` — D3D state trail.
- `docs/research/2026-04-23-sky-decompile-hunt-C.md` — globals + interp trail.
- `docs/research/2026-04-23-sky-references-crossref.md` — WorldBuilder/ACE/ACViewer/holtburger.
- `docs/research/2026-04-23-sky-dat-schema.md` — dat schema.
- `docs/research/deepdives/r12-weather-daynight.md` — foundational doc.
- `docs/research/decompiled/chunk_00500000.c:1097-7535` — the retail sky pipeline source.
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` — nearest-stack reference (dead code but structurally close).
- `references/DatReaderWriter/DatReaderWriter/Generated/Types/*.cs` — canonical dat schemas.
