# Sky Decompile Hunt C — Globals & Keyframe Interpolator

**Date:** 2026-04-23
**Hunter:** Agent C (globals / keyframe math)
**Scope:** Find the runtime state block the sky renderer reads, the keyframe-interp math, and the per-frame entry point.
**Source tree:** `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_*.c` (55 chunks, 688K lines).

All citations use `{chunk_file}:{line}` relative to the decompile tree.

---

## ⚠ 2026-04-24 correction

Sections §1, §2, §5 of this doc label `DAT_00842778` as "AmbColor" and
`DAT_0084277c` as "DirColor/Fog". **That labeling is backwards.** The
correct mapping — cross-verified against the DatReaderWriter schema
(`SkyTimeOfDay.Unpack` field order) and the `FUN_00501600` output map:

- `DAT_00842778` = **DirColor** (directional/sun color ARGB)
- `DAT_0084277c` = **AmbColor** (ambient color ARGB)
- `DAT_00842780` = **AmbBright** (ambient brightness scalar, *not* fog start)

The `FUN_00532440` per-vertex Lambert at `chunk_00530000.c:2118-2124`
reads `DAT_00842778` as the N·L-modulated color (→ directional) and
`DAT_0084277c × DAT_00842780` as the flat / brightness-scaled color
(→ ambient × ambBright). The pre-multiply at line 2107 takes
`DAT_00842780 * DAT_0084277c` which is the textbook "ambient scalar ×
ambient color" retail ambient term.

See `docs/research/2026-04-24-lambert-brightness-split.md` for the full
re-analysis and `SkyTimeOfDay.generated.cs` for the field offsets (+0x10
DirColor, +0x18 AmbColor). All entries below should be read with this
swap in mind; the decompile math quotes themselves are correct.

---

## 1. Global Inventory — the sky state block

All globals live in a contiguous block at **`0x00842778..0x008427c0`** with a second cluster at **`0x00842950..0x00842960`**. Every field is read by landblock/draw code and written only by the per-frame updater `FUN_005062e0` via the interp delegate `FUN_00501600`. Initial values are set in `FUN_00505dd0` (the sky-system constructor).

| Address | Size | Inferred semantic | Initial value | Writers | Readers |
|---|---|---|---|---|---|
| **`DAT_00842778`** | 4 B (ARGB packed u32) | **AmbColor / SunLight color** — unpacked to 3 floats via `FUN_00451a60` into the active D3D material/light | `0` (default white via fallback) | `FUN_00505f30:6047`; `FUN_00501600` (via out-param `param_5`) | `FUN_00451a60(DAT_00842778)` in `chunk_00450000.c:4341, 4468`; `chunk_00500000.c:6061` — applied to D3D light state |
| **`DAT_0084277c`** | 4 B (ARGB packed u32) | **Fog color** — passed to `FUN_004530e0(fogStart, DAT_0084277c)` and also decomposed into 3 floats `(R,G,B)*1/255` for per-vertex terrain lighting | `0` | `FUN_00505f30:6042` (param_3); `FUN_00501600` (via `param_3`) | `chunk_00530000.c:2095,2097,2099` (terrain lighting); `chunk_00500000.c:6069`; `chunk_00450000.c:4347,4474` |
| **`DAT_00842780`** | 4 B (float) | **Fog start distance / luminosity offset** — added to `\|sun\|·_DAT_0079a1e8` and passed as first arg to fog setup `FUN_004530e0` | `0x3ecccccd` = **0.4f** | `FUN_00505f30:6041` (param_2); `FUN_00501600` (via out-param `param_2`) | `chunk_00500000.c:6067-6069`; `chunk_00530000.c:2094, 2107-2109` (terrain mul) |
| `DAT_00842784` | 4 B (ARGB u32) | Sky-fog secondary color (bucket pick helper — see §6) | `0` | `chunk_00550000.c:11851/61/67/80/92` (palette decision in render-sample path), `FUN_005062e0:6295,6301` | `chunk_00500000.c:6295,6301,6305,6311,6317` |
| `DAT_00842788` | 4 B (ARGB u32) | Sky-fog primary color (same role, paired with 0x842784) | `0` | `chunk_00550000.c:11850/62/67/80/91`, `FUN_005062e0:6251,6259` | `chunk_00500000.c:6251,6259,6263,6269,6275` |
| `DAT_00842790` | 4 B (ptr) | **Cloud / stars heightmap buffer** — `operator_delete__`'d on teardown, re-alloc'd to `(N+1)*0x100` bytes where N is cloud-layer count | `NULL` | `chunk_00500000.c:5994, 6543` | `chunk_00500000.c:6571, 6586, 6597, 6599, 6539` |
| `_DAT_00842798` | 8 B (double) | **Next cloud/weather update deadline** — compared to the global game-clock `_DAT_008379a8` each frame, advanced by `TickSize` (from `*(double *)(DAT_0084247c + 0x50) + 8`) | `0` | `FUN_00505d40:5923` (reset); `FUN_005062e0:6241` | `FUN_005062e0:6240` |
| `_DAT_0084279c` | (pairs with 0x842798 as high half of double) | — | `0` | `FUN_00505d40:5924` | (via 0x842798 as double) |
| **`_DAT_008427a0`** | 8 B (double) | **Next sky-keyframe update deadline** — compared to `_DAT_008379a8`, advanced by `LightTickSize` (from `*(double *)(DAT_0084247c + 0x50) + 0x10`) | `0` | `FUN_00505d40:5925`; `FUN_005062e0:6285,6289` | `FUN_005062e0:6249` |
| `_DAT_008427a4` | (pairs as high half of 0x8427a0) | — | `0` | `FUN_00505d40:5926` | — |
| **`DAT_008427a8`** | 1 B (bool) | **FixedLight override enable** — `true` means "run live keyframe interp each frame"; `false` means the caller of `FUN_00505f30` supplies the values directly | `0` | `FUN_00505d40:5922` (set from `param_1 != 0`) | `FUN_00505f30:6043` |
| `DAT_008427a9` | 1 B (bool) | **Crossfade in progress** flag — enables blending from the previous keyframe output into the new one over `_DAT_007c7208` per-frame step | — | `chunk_00500000.c:7270` | `FUN_005062e0:6256, 6297`; `chunk_00500000.c:7281` |
| `DAT_008427ac` | 4 B (float) | **Crossfade target: fog start** (previous frame's fog start, held for lerp) | — | — | `FUN_005062e0:6258, 6279` |
| `DAT_008427b0` | 4 B (float) | **Crossfade target: fog start/secondary-1** | — | — | `FUN_005062e0:6299, 6320` |
| `DAT_008427b4` | 4 B (float) | **Crossfade target: fog start/secondary-2** | — | — | `FUN_005062e0:6300, 6321` |
| `_DAT_008427b8` | 4 B (float) | **Crossfade u-parameter** — 0..1; advanced by `_DAT_007c7208` per sample | — | `FUN_005062e0:6280, 6322` | `FUN_005062e0:6257, 6279, 6298, 6320` |
| **`DAT_00842950`** | 4 B (float) | **Sun direction X** | `0x3f99999a` = **1.2f** (default before first interp) | `FUN_00505f30:6044`; `FUN_00501600` → `param_4[0]` | `chunk_00500000.c:6057,6067,6058`; `chunk_00450000.c:4086,4337,4464`; `chunk_00530000.c:2029,2118,2137,2156,2175,2206` |
| **`DAT_00842954`** | 4 B (float) | **Sun direction Y** | `0` | `FUN_00505f30:6045`; `FUN_00501600` → `param_4[1]` | same file set; appears as `local_44[0]` in terrain lighting |
| **`DAT_00842958`** | 4 B (float) | **Sun direction Z** | `0x3f000000` = **0.5f** | `FUN_00505f30:6046`; `FUN_00501600` → `param_4[2]` | same file set; appears as `local_44[1]` in terrain lighting |
| **`DAT_0084295c`** | 4 B (float) | **Minimum fog-start clamp** — if interp result < this, snap up | — | (loaded from region config, probably MinWorldFog) | `FUN_00505f30:6051-6052`; `FUN_005062e0:6253-6254` |
| `DAT_00842960` | 4 B (int) | Heightmap dim counter (used alongside DAT_00842790) | — | `FUN_00500000.c:6544, 6540` | — |

**D3D light/material slots written from the block:**

| D3D slot | Source | Written at |
|---|---|---|
| `_DAT_008682bc/c0/c4` | `fVar1/2/3` = copy of `DAT_00842950/54/58` (a second sun-dir-like slot — likely "light direction copy") | `FUN_00505f30:6062-6064` |
| `_DAT_008682c8/cc/d0` | `DAT_00842950/54/58` directly (primary sun direction vector3) | `FUN_00505f30:6058-6060` |
| `DAT_008682d4` | `0` (light enable flag) | `FUN_00505f30:6065` |

---

## 2. Keyframe Interpolator — `FUN_00501600` (0x00501600)

**Signature:**
```c
void FUN_00501600(float param_1,         // u = dayFraction (0..1)
                  float *param_2,        // out: interpolated fog start scalar
                  undefined1 *param_3,   // out: interpolated fog color (4 bytes ARGB)
                  float *param_4,        // out: interpolated sun direction vec3 (scaled by brightness)
                  undefined1 *param_5);  // out: interpolated ambient color (4 bytes ARGB)
```

The function first brackets `u` against the keyframe table (`FUN_00501530`, see §3), then interpolates.

**Full decompile** (`chunk_00500000.c:1151-1232`):

```c
void FUN_00501600(float param_1,float *param_2,undefined1 *param_3,float *param_4,
                 undefined1 *param_5)
{
  ...
  iVar5 = FUN_00501530(param_1,&local_14,&local_10,&param_1);
  uVar2 = local_10;                                      // next keyframe
  if (iVar5 != 0) {
    // fog-start scalar: lerp field +0x14
    *param_2 = (*(float *)(local_10 + 0x14) - *(float *)(local_14 + 0x14)) * param_1
             + *(float *)(local_14 + 0x14);

    // fog color ARGB (3 bytes) — each byte is (lerp between k_i[0x18..0x1a] and k_i+1[0x18..0x1a])
    // FUN_005df4c4 is a clamp-to-byte helper
    param_2 = (float *)(uint)*(byte *)(local_10 + 0x19);   // G
    uVar3 = FUN_005df4c4();
    param_2 = (float *)(uint)*(byte *)(local_10 + 0x18);   // R
    uVar4 = FUN_005df4c4();
    local_10 = (uint)*(byte *)(local_10 + 0x1a);           // B
    param_2 = (float *)CONCAT31(param_2._1_3_,uVar4);
    uVar4 = FUN_005df4c4();
    param_3[2] = uVar4;                                    // B
    param_3[1] = uVar3;                                    // G
    *param_3 = param_2._0_1_;                              // R
    param_3[3] = 0xff;                                     // A

    // Sun direction — polar (heading, pitch) w/ magnitude scalar (field +0x04)
    fVar9 = ((float10)*(float *)(uVar2 + 4) - (float10)*(float *)(local_14 + 4)) * (float10)param_1
          + (float10)*(float *)(local_14 + 4);             // fVar9 = magnitude (DirBright/length)
    fVar1 = ((*(float *)(uVar2 + 0xc) - *(float *)(local_14 + 0xc)) * param_1 +
            *(float *)(local_14 + 0xc)) * (float)_DAT_0079c6b0;    // pitch_rad
    fVar6 = (((float10)*(float *)(uVar2 + 8) - (float10)*(float *)(local_14 + 8)) * (float10)param_1
          + (float10)*(float *)(local_14 + 8)) * (float10)_DAT_0079c6b0;  // yaw_rad
    fVar7 = (float10)fcos((float10)fVar1);                 // cos(pitch)
    fVar8 = (float10)fsin(fVar6);                          // sin(yaw)
    local_c = (float)(fVar9 * fVar8 * fVar7);              // x = mag * sin(yaw)*cos(pitch)
    fVar6 = (float10)fcos(fVar6);                          // cos(yaw)
    *param_4 = local_c;
    local_8 = (float)(fVar9 * fVar6 * fVar7);              // y = mag * cos(yaw)*cos(pitch)
    fVar6 = (float10)fsin((float10)fVar1);                 // sin(pitch)
    param_4[1] = local_8;
    local_4 = (float)(fVar6 * fVar9);                      // z = mag * sin(pitch)
    param_4[2] = local_4;

    // Ambient color ARGB (3 bytes) — lerp field +0x10..0x12
    param_2 = (float *)(uint)*(byte *)(uVar2 + 0x11);      // G
    uVar3 = FUN_005df4c4();
    param_2 = (float *)(uint)*(byte *)(uVar2 + 0x10);      // R
    uVar4 = FUN_005df4c4();
    param_3 = (undefined1 *)(uint)*(byte *)(uVar2 + 0x12); // B
    ...
    param_5[2] = uVar4;                                    // B
    param_5[1] = uVar3;                                    // G
    *param_5 = param_2._0_1_;                              // R
    param_5[3] = 0xff;                                     // A
    return;
  }
  // Fallback: no keyframe → white ambient, white fog, sun (0.5, 0, 0.8), fog_start 0.3
  *param_2 = 0.3;
  param_3[2] = 0xff; param_3[1] = 0xff; *param_3 = 0xff; param_3[3] = 0xff;
  param_5[2] = 0xff; param_5[1] = 0xff; *param_5 = 0xff; param_5[3] = 0xff;
  *param_4 = 0.5; param_4[1] = 0.0; param_4[2] = 0.8;
  return;
}
```

**Keyframe struct layout** (inferred from FUN_00501600 + the loader/saver `FUN_00501a20`/`FUN_00501b20` at `chunk_00500000.c:1316-1446`):

| Offset | Type | Semantic |
|---|---|---|
| `+0x00` | `float *` (back-pointer to `this`) | self |
| `+0x04` | `float` | **t0 / Begin** — day-fraction at which this keyframe is active (compared in bracket search) **AND** reused as **DirBright (sun magnitude)** scalar inside the interp! Actually this is DirBright per ACE schema; bracket search at FUN_00501530:1115 uses `*(float *)*puVar4` which dereferences the back-pointer — so the actual `t0` lives at `*this` (offset 0), and the field at +0x04 is DirBright. **But** the save-order in FUN_00501a20 starts from +0x04, so the on-disk file does NOT store t0 explicitly here — it's elsewhere (the outer container holds t0; see §3). |
| `+0x08` | `float` | **DirHeading** (degrees, converted to radians via `_DAT_0079c6b0` = π/180) |
| `+0x0c` | `float` | **DirPitch** (degrees → radians) |
| `+0x10..+0x12` | 3×`byte` | **AmbColor** (R, G, B at +0x10, +0x11, +0x12) |
| `+0x13` | `byte` | AmbColor alpha (unused; always 0xff on write) |
| `+0x14` | `float` | **MinWorldFog / fog start scalar** (or AmbBright; see §7 gaps) |
| `+0x18..+0x1a` | 3×`byte` | **FogColor** (R, G, B) |
| `+0x1b` | `byte` | FogColor alpha |
| `+0x1c` | `ptr` | Optional per-keyframe mesh/sky-object chain (non-zero used by `FUN_00501860`) |
| `+0x20` | `float` | **Secondary field 1** (ACE calls this `DirColor.Luminosity` or `MaxWorldFog`; used in `FUN_00501860:1250`) |
| `+0x24` | `float` | **Secondary field 2** (used in `FUN_00501860:1252` — pairs with +0x20) |
| `+0x28..+0x2a` | 3×`byte` | **Secondary color** (used in `FUN_00501860:1254-1263`) |
| `+0x2b` | `byte` | Secondary color alpha |

Total struct size: **`0x2c` bytes** (44 B), which matches the `FUN_005df0f5(0x2c)` alloc in `chunk_00500000.c:5972`. This is the in-memory SkyTimeOfDay (one keyframe).

The outer keyframe-table header is at `param_1` in FUN_00501530:
- `*(int *)(param_1 + 8)` = pointer to array of keyframe pointers
- `*(int *)(param_1 + 0x10)` = keyframe count
- `*(float *)*puVar4` = keyframe's `t0` (reached via the back-pointer at struct +0x00)

---

## 3. Keyframe bracket search — `FUN_00501530` (0x00501530)

`chunk_00500000.c:1093-1129`:

```c
undefined4 __thiscall
FUN_00501530(int param_1,float param_2,undefined4 *param_3,undefined4 *param_4,float *param_5)
{
  iVar1 = *(int *)(param_1 + 0x10);                 // count
  if (iVar1 == 0) return 0;
  uVar3 = 0;
  if (iVar1 != 1) {
    puVar4 = *(undefined4 **)(param_1 + 8);         // array base
    do {
      puVar4 = puVar4 + 1;
      if (param_2 < *(float *)*puVar4) break;        // u < keyframe[i+1].t0 ?
      uVar3 = uVar3 + 1;
    } while (uVar3 < iVar1 - 1U);
  }
  *param_3 = *(undefined4 *)(*(int *)(param_1 + 8) + uVar3 * 4);   // lower keyframe
  if (uVar3 == *(int *)(param_1 + 0x10) - 1U) {
    *param_4 = **(undefined4 **)(param_1 + 8);                     // wrap to keyframe[0]
    *param_5 = (param_2 - *(float *)*param_3) / (_DAT_007938b0 - *(float *)*param_3);
    //            u01 = (t - lower.t0) / (1.0 - lower.t0)   — wrap-around case
    return 1;
  }
  pfVar2 = (float *)(*(undefined4 **)(param_1 + 8))[uVar3 + 1];
  *param_4 = pfVar2;                                                // upper keyframe
  *param_5 = (param_2 - *(float *)*param_3) / (*pfVar2 - *(float *)*param_3);
  //            u01 = (t - lower.t0) / (upper.t0 - lower.t0)
  return 1;
}
```

This is exactly the interpolator-finder pattern the hunt asked for. The `t0` for each keyframe is stored at the FIRST FIELD of each keyframe record (via the back-pointer layer). The last keyframe wraps to keyframe[0] using `1.0` as the upper bound (`_DAT_007938b0` = 1.0).

---

## 4. Sun direction formula — confirmed

`chunk_00500000.c:1192-1205` computes the sun unit vector from `(DirHeading, DirPitch)`:

```
yaw_rad   = DirHeading * π/180
pitch_rad = DirPitch   * π/180
mag       = DirBright  (interpolated, scales the output length)

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

This matches the hunt-brief formula exactly **except** it multiplies by the per-keyframe magnitude `fVar9` (DirBright). So `DAT_00842950/54/58` is **not a unit vector**; it is `DirBright * unit_direction`. The terrain lighting code at `chunk_00530000.c:2118` dot-products this with per-vertex normals, so the `DirBright` scaling baked into the vector directly modulates the diffuse lighting strength.

Coordinate frame: retail AC is **Y-up world** for sky azimuth but stores sun in world coords where **Z-axis is vertical** (common in retail AC: X=east, Y=north, Z=up). `sin(pitch)` → Z means pitch=+90° → straight up; pitch=0 → horizontal; that matches AC's "degrees above horizon" convention.

---

## 5. Day-fraction / tick source

`chunk_00500000.c:6239-6248` (inside `FUN_005062e0`):

```c
local_14 = _DAT_008379a8;                                  // game-clock (seconds, double)
if ((_DAT_00842798 <= _DAT_008379a8) && (DAT_0084247c != 0)) {   // cloud-deadline met & region loaded
  _DAT_00842798 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 8) + _DAT_008379a8;
  //                             ^--- SkyDesc.TickSize (from the Region DB object)
  if (DAT_008ee9c8 == 0) {
    fVar1 = (float)_DAT_00795610;                          // = 0.0f (no world loaded)
  }
  else {
    fVar1 = *(float *)(DAT_008ee9c8 + 0x48);               // <-- THIS is the dayFraction
  }
```

And at `chunk_00500000.c:6285-6289`:

```c
_DAT_008427a0 = _DAT_007c7200;                            // default LightTickSize
if (DAT_0084247c != 0) {
  _DAT_008427a0 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10);
  //                              ^--- SkyDesc.LightTickSize (from Region DB object)
}
_DAT_008427a0 = _DAT_008427a0 + local_14;                 // advance deadline
```

**Day-fraction source:** `*(float *)(DAT_008ee9c8 + 0x48)`.

`DAT_008ee9c8` is the **world/time global** (a CTimeStamp-like struct). Its fields used by the sky:
- `+0x10` : int — "tickCount A" component (used in `FUN_00501990:1293`, a PRNG seed hint)
- `+0x48` : **float dayFraction** (0..1) — directly consumed by FUN_00501600 as `param_1`
- `+0x64` : int — "tickCount B"
- `+0x68` : int — "tickCount C"

I did NOT locate the writer of `+0x48` (the conversion from wall-clock seconds to dayFraction) in this hunt — see §7 "Gaps".

**Region-config pointer:** `DAT_0084247c` points at the loaded `Region` object; `Region + 0x50` is a pointer into `SkyDesc`, and `SkyDesc + 8` = TickSize (double seconds) and `SkyDesc + 0x10` = LightTickSize (double seconds) — matching the dat schema (`docs/research/2026-04-23-sky-dat-schema.md:21-22`).

---

## 6. Per-frame sky update entry point — `FUN_005062e0` (0x005062E0)

**The function the hunt brief is asking for.** `chunk_00500000.c:6213-6333`.

Skeleton (key flow, comments added):

```c
void __fastcall FUN_005062e0(int param_1)
{
  ...
  if (*(int *)(param_1 + 0x10) != 0) {
    if (*(int *)(param_1 + 0x20) != 0) {
      FUN_00508010();                           // prepare cloud buffers
    }
    local_14 = _DAT_008379a8;                   // game-clock snapshot
    if ((_DAT_00842798 <= _DAT_008379a8) && (DAT_0084247c != 0)) {
      _DAT_00842798 = SkyDesc.TickSize + _DAT_008379a8;        // next tick deadline
      fVar1 = (DAT_008ee9c8 == 0) ? 0.0f : *(float *)(DAT_008ee9c8 + 0x48);  // dayFraction

      // ---- Sky-keyframe update (gated by LightTickSize) ----
      if (_DAT_008427a0 < _DAT_008379a8) {
        iVar7 = FUN_004ff440(fVar1,&local_24,&local_20,local_c,&local_18);
        //         ^ primary interp: fogStart, fogColorARGB, sunDirVec3, ambColorARGB
        if (iVar7 != 0) {
          if (local_24 < DAT_0084295c) local_24 = DAT_0084295c;   // clamp min fog start

          if (DAT_008427a9 != '\0') {    // crossfade active
            // blend towards previous frame's held values (DAT_008427ac, DAT_00842788)
            // step crossfade u by _DAT_007c7208 each tick
            ...
          }
          FUN_00505f30(local_24, local_20, local_c, local_18);
          //   ^ commits the interp result to the DAT_008427xx / DAT_008429xx globals
          //     AND pushes them to D3D light/material/fog state.
        }
        _DAT_008427a0 = SkyDesc.LightTickSize + local_14;     // advance light deadline
      }

      // ---- Secondary interp (starfield / alt palette — DAT_00842784/88) ----
      FUN_005a4010(DAT_0081dbf8 == '\0');
      if (DAT_0081dbf8 != '\0') {
        FUN_005a3f90(DAT_0081dbf8);
        iVar7 = FUN_004ff480(fVar1,&local_1c,&local_24,&local_20);
        //         ^ secondary interp (starfield): 2 floats + 1 color
        if (iVar7 != 0) {
          // same crossfade pattern targeting DAT_008427b0/b4 and DAT_00842784
          ...
          FUN_005a41b0(&local_20, local_1c, local_24);   // push to starfield state
        }
      }
    }
  }
}
```

**Entry-point contract:**
- Called once per world/frame tick from the top-level world update (see §7).
- Reads `DAT_008ee9c8 + 0x48` for the current dayFraction.
- Runs **two** independent interpolators against the same keyframe tables, throttled by `TickSize`/`LightTickSize` (from `SkyDesc`).
- `FUN_00505f30` (see below) writes the final values into the globals that the rest of the render pipeline samples.

---

## 6b. Commit-to-globals — `FUN_00505f30` (0x00505F30)

`chunk_00500000.c:6026-6092`. This is the function that *writes* the `DAT_00842xxx` globals. It is the public "set sky state" entry point — called with either (a) fresh interp output (from `FUN_005062e0:6283`) or (b) caller-supplied values (the FixedLight override path when `DAT_008427a8 == 0`).

```c
void FUN_00505f30(int param_1, float param_2, undefined4 param_3,
                  float *param_4, undefined4 param_5)
{
  DAT_00842780 = param_2;             // fog start
  DAT_0084277c = param_3;             // fog color ARGB
  if (DAT_008427a8 == '\0') {         // FixedLight mode: caller provides raw values
    DAT_00842950 = *param_4;          // sun X
    DAT_00842954 = param_4[1];        // sun Y
    DAT_00842958 = param_4[2];        // sun Z
    DAT_00842778 = param_5;           // ambient color ARGB
  }
  else {                              // dynamic mode: re-run interp against current dayFrac snapshot
    iVar4 = FUN_004ff440(0x3f000000,  // 0.5 placeholder (?)
                         &DAT_00842780, &DAT_0084277c,
                         &DAT_00842950, &DAT_00842778);
    if ((iVar4 != 0) && (DAT_00842780 < DAT_0084295c)) {
      DAT_00842780 = DAT_0084295c;    // min fog clamp
    }
  }

  // Push sun direction to the active D3D directional light
  fVar3 = DAT_00842958; fVar2 = DAT_00842954; fVar1 = DAT_00842950;
  _DAT_008682c8 = DAT_00842950;
  _DAT_008682cc = DAT_00842954;
  _DAT_008682d0 = DAT_00842958;

  // Unpack ambient color ARGB → 3 floats into the D3D material
  FUN_00451a60(DAT_00842778);
  _DAT_008682bc = fVar1; _DAT_008682c0 = fVar2; _DAT_008682c4 = fVar3;
  DAT_008682d4 = 0;

  // Fog: distance = length(sunDir) * 0079a1e8 + fogStart; color = fog color ARGB
  if (DAT_0083da58 != 0) {
    FUN_004530e0(SQRT(DAT_00842950 * DAT_00842950 +
                      DAT_00842954 * DAT_00842954 +
                      DAT_00842958 * DAT_00842958) * _DAT_0079a1e8 +
                 DAT_00842780,
                 DAT_0084277c);
  }

  // Touch each dungeon EnvCell (re-apply fog/lighting state to each)
  if (*(int *)(param_1 + 8) != 0) {
    ... FUN_00532440(); ...
  }
}
```

**Dispatch glue** — `FUN_004ff440` (at `chunk_004F0000.c:10692-10704`) is a null-check wrapper around `FUN_00501600`:

```c
undefined4 FUN_004ff440(param_1, param_2, param_3, param_4, param_5) {
  if ((DAT_0084247c != 0) && (*(int *)(DAT_0084247c + 0x50) != 0)) {
    FUN_00501600(param_1, param_2, param_3, param_4, param_5);
    return 1;
  }
  return 0;
}
```

---

## 7. Call graph

```
FUN_004554b0  (0x004554B0 — world update root)         chunk_00450000.c:3831
  └── FUN_005062e0  (0x005062E0 — per-frame sky update)   chunk_00500000.c:6213
        ├── reads:   _DAT_008379a8 (game-clock)
        │            DAT_008ee9c8 + 0x48 (dayFraction)
        │            DAT_0084247c + 0x50 (SkyDesc ptr)
        │            DAT_0084247c + 0x50 + 8 (TickSize double)
        │            DAT_0084247c + 0x50 + 0x10 (LightTickSize double)
        ├── writes:  _DAT_00842798 (next cloud tick)
        │            _DAT_008427a0 (next light tick)
        │            _DAT_008427b8 (crossfade u)
        │            DAT_008427a9 (crossfade active)
        ├── FUN_004ff440 → FUN_00501600  (primary interp)   chunk_00500000.c:1151
        │     └── FUN_00501530 (bracket search)             chunk_00500000.c:1093
        ├── FUN_00505f30  (commits globals + D3D state)     chunk_00500000.c:6026
        │     ├── writes:  DAT_00842778, DAT_0084277c, DAT_00842780
        │     │            DAT_00842950, DAT_00842954, DAT_00842958
        │     ├── writes:  _DAT_008682bc..d4 (D3D directional light)
        │     ├── FUN_00451a60 (unpack ambient ARGB → 3 floats)    chunk_00450000.c:608
        │     └── FUN_004530e0 (push fog state)
        └── FUN_004ff480 → FUN_00501860  (secondary interp: starfield)  chunk_00500000.c:1236
              └── FUN_00505f30-equivalent callers use DAT_00842784/88

--- Readers (consumers of sky globals per frame) ---
chunk_00450000.c:4086,4337-4341,4464-4468    // landblock draw re-applies to D3D
chunk_00450000.c:4347, 4474                   // landblock draw re-applies fog color
chunk_00530000.c:2094-2230                    // per-vertex terrain shading:
                                              //   diffuse   = max(dot(vertexNormal, sunDir), minAmbient)
                                              //   lit_color = ambRGB*fogStart + diffuse*fogRGB
                                              //   clamp to 1.0 per channel
```

Note: `chunk_00530000.c:2094-2230` is the canonical per-vertex lit-color calculation (retail's CPU lighting path). It consumes:
- `DAT_00842950/54/58` as the sun direction (NOT normalized — carries `DirBright` baked in).
- `DAT_00842780` as the "ambient base multiplier" (named `DirBright` in the schema but semantically `AmbBright` here: it's the scalar multiplying the **ambient** ARGB, not the sun).
- `DAT_00842778` ARGB → (R,G,B)/255 as **ambient color** (confirmed: line 2100,2104,2105).
- `DAT_0084277c` ARGB → (R,G,B)/255 as **diffuse color** (confirmed: line 2095,2097,2099).
- `DAT_00796344` = minimum-dot clamp (avoids fully-black back faces).
- `_DAT_007938c0` (probably 1.0 or slightly above) = per-channel saturation clamp.

This flips my §1/§2 preliminary labels: **`DAT_0084277c` is actually the DIFFUSE/sun color** (not fog) and **`DAT_00842778` is the AMBIENT color**. The `FUN_004530e0` call at `FUN_00505f30:6067-6069` passes `DAT_0084277c` to the fog setup, but fog in retail AC also takes the **diffuse/sun color** as its tint — that's the known design (distant fog matches sun tint). So the semantic naming that unifies both consumers is:

- **`DAT_0084277c`** = **DirColor** (sun/diffuse ARGB) — doubles as fog tint
- **`DAT_00842778`** = **AmbColor** (ambient ARGB)
- **`DAT_00842780`** = **scalar brightness mul** applied to the AMBIENT in the lit-color formula, AND as the fog-start offset (it's a dual-purpose "ambient base" scalar)

This matches the FUN_00501600 output mapping from §2:
- interp out `*param_2` ← keyframe `+0x14` → `DAT_00842780` → "ambient scalar / fog base"
- interp out `*param_3` ← keyframe `+0x18..+0x1a` → `DAT_0084277c` → **DirColor (sun + fog)**
- interp out `*param_4[0..2]` ← keyframe `+0x04,+0x08,+0x0c` via polar→cartesian → `DAT_00842950..58` → **sun direction * DirBright**
- interp out `*param_5` ← keyframe `+0x10..+0x12` → `DAT_00842778` → **AmbColor**

So the on-disk SkyTimeOfDay→in-memory field offsets are:

| Offset | Field (ACE schema name) | Matched consumer |
|---|---|---|
| `+0x04` | `DirBright` | sun-vector magnitude `fVar9` |
| `+0x08` | `DirHeading` (deg) | yaw → `sin/cos` |
| `+0x0c` | `DirPitch` (deg) | pitch → `cos/sin` |
| `+0x10/+0x11/+0x12` | `AmbColor` (R,G,B) | `DAT_00842778` |
| `+0x14` | `AmbBright` (!) | `DAT_00842780` — multiplies AmbColor in lit_color; also fog-start offset |
| `+0x18/+0x19/+0x1a` | `DirColor` (R,G,B) | `DAT_0084277c` — diffuse AND fog tint |
| `+0x20` | `MinWorldFog` | secondary interp `local_1c` via `FUN_00501860:1250` |
| `+0x24` | `MaxWorldFog` | secondary interp `local_24` via `FUN_00501860:1252` |
| `+0x28/+0x29/+0x2a` | `WorldFogColor` (R,G,B) | starfield ARGB in `FUN_005a41b0` |

This **matches exactly** the published ACE SkyTimeOfDay struct (`docs/research/2026-04-23-sky-dat-schema.md:42-54`) — every field has a decompile home now.

---

## 8. Gaps / next hunts

1. **Day-fraction writer.** I found `*(float *)(DAT_008ee9c8 + 0x48)` is READ as `dayFraction`. I did NOT locate the WRITER — i.e. which function computes `dayFraction = (gameClockSeconds mod dayLength) / dayLength` and stamps it to `+0x48`. Look in chunk_004F0000.c for `DAT_008ee9c8 + 0x48` write-sites, likely driven by a world-tick function using `SkyDesc.TickSize`.

2. **`DAT_00796344` and `_DAT_007938c0` constants.** In the per-vertex lighting (`chunk_00530000.c:2119`), `DAT_00796344` is the minimum-dot clamp. `_DAT_007938c0` is the per-channel saturation clamp. Both should be extracted to confirm they are `0.0f` and `1.0f` respectively (or something like `0.0` and `> 1.0` to allow HDR).

3. **FixedLight (static sky) path.** When `DAT_008427a8 == 0` (FixedLight mode), the caller supplies raw sun direction, ambient, fog color. This is likely the path used for dungeons (no day/night cycle). The caller of `FUN_00505d40(0)` (which sets `DAT_008427a8 = 0`) needs to be mapped — it's the dungeon-load entry.

4. **Starfield secondary keyframe.** `FUN_00501860` / `FUN_005a41b0` (starfield state) uses keyframe fields `+0x20, +0x24, +0x28..+0x2a`. If acdream renders stars, port `FUN_005a41b0` too.

5. **Crossfade parameters `_DAT_007c7208` and `DAT_008427ac`.** The crossfade (`DAT_008427a9`) is a smoothing blend when keyframes are force-swapped (e.g. weather change). `_DAT_007c7208` is the per-tick u-step; `DAT_008427ac` is the held previous value. If acdream wants faithful weather transitions, port this loop from `FUN_005062e0:6256-6281`.

6. **Fog calling convention `FUN_004530e0`.** I did not decompile this but its signature — `FUN_004530e0(fogDistance, fogColorARGB)` — is unambiguous. Port as `SetFog(distance, ARGB)`. The `distance` arg is `|sunDir| * _DAT_0079a1e8 + fogStart` — so distant fog grows with the "weighted sun-vector length". This is AC's recognizable fog-near-horizon effect.

7. **The already-known globals hint resolved.** The hunt brief cited `DAT_00842778/7c`, `DAT_00842950..58` — all mapped in §1. No other sky-specific globals exist in this address neighborhood.

---

## 9. Quick summary for the porter

**What the retail sky pipeline does per frame:**

1. Pull dayFraction from `world.time.dayFraction` (`DAT_008ee9c8 + 0x48`, a float 0..1).
2. **Gate** on `LightTickSize` elapsed — only interpolate every `LightTickSize` seconds; otherwise reuse last frame's values (hence the visible ~2-second "step" in AC's time-of-day tint).
3. **Bracket** dayFraction across the current DayGroup's `SkyTimeOfDay[]` by `t0`. Compute u01.
4. **Interpolate** each channel: fogStart, fogColorARGB (byte-lerp), sunDir via polar-to-cartesian of (DirHeading, DirPitch) scaled by DirBright, ambColorARGB (byte-lerp).
5. **Clamp** fogStart ≥ `DAT_0084295c` (the MinWorldFog floor from the Region).
6. **Crossfade** blend if a keyframe swap was forced (weather).
7. **Commit** to six floats (sunDir x3, fogStart x1) + two ARGB dwords (fog=sun, amb).
8. **Push** those values to D3D: `_DAT_008682bc..d0` = sun vec3 duplicated into "direction copy" and "direction primary" slots; `_DAT_008682c8..d0` = sun vec3 copy 2; a material ambient RGB (unpacked from the ARGB); and fog(distance, color).
9. **Re-apply** the state on every landblock/EnvCell draw via `FUN_00451a60` + `FUN_004530e0` (because D3D resets between passes).

All values to port live in `DAT_008427xx` / `DAT_00842950..58` — no secrets elsewhere.
