# Sky Decompile Hunt — Agent A

**Date:** 2026-04-23
**Status:** SUCCESSFUL HUNT. Found the sky render + unpack code that prior audits (`2026-04-21-sky-deep-audit.md`, `2026-04-22-sky-lighting-decompile.md`) declared missing.

**Entry point insight:** The Ghidra strip removed all class names. Prior audits searched for `sky`/`SkyDesc`/`SkyObject` strings and found nothing. The trail that worked: start from the `Region` dat-ID registration (`chunk_00410000.c:12952`, factory-name slot `DAT_00796a6c` → registry `DAT_0079653c`, ID range `0x13000000..0x1300FFFF`), find the Region loader entry (`FUN_00415730(..., 0x1c)` call, type-index 0x1c = REGION per the `"REGION"` string lookup at `chunk_00410000.c:11795`), then follow the global region pointer (`DAT_0084247c`) outward. Every hit is in `chunk_00500000.c` (0x00501530 – 0x00508010 range).

---

## 1. Function Map

All citations are `chunk_00500000.c` unless noted.

| FUN | Line | Signature (inferred class method) | Purpose |
|-----|------|------------------|---------|
| `FUN_004ff370` | chunk_004F0000.c:10610 | `Region::Load(uint dataId)` | Calls `FUN_00415730(param_1, 0xb, 0x1c)` to fetch Region from dat; stores in global `DAT_0084247c`. |
| `FUN_004ff3b0` | chunk_004F0000.c:10630 | `Region::Unload()` | Releases current Region. |
| `FUN_004ff420` | chunk_004F0000.c:10680 | `Region::UpdateSkyObjectsTrampoline()` | Delegates to `FUN_00501990` when Region and SkyDesc both non-null. |
| `FUN_004ff440` | chunk_004F0000.c:10692 | `Region::LerpSunAndAmbient(t,...)` | Guards and delegates to `FUN_00501600`. |
| `FUN_004ff480` | chunk_004F0000.c:10708 | `Region::LerpFogAmbient(t,...)` | Guards and delegates to `FUN_00501860`. |
| `FUN_004ff4b0` | chunk_004F0000.c:10724 | `Region::UpdateSkyObjectTable(t, table*)` | Guards and delegates to `FUN_00502a10`. |
| **`FUN_00501530`** | **:1097** | `SkyTimeOfDayList::FindBracket(t, k1*, k2*, u*)` | Walks the keyframe array. For `uVar3 < count-1` picks k1=arr[uVar3], k2=arr[uVar3+1]; if at the last slot, wraps to arr[0] and uses a 1.0 denominator. Returns `u = (t - k1.Begin) / (k2.Begin - k1.Begin)`. **This is the two-keyframe bracket picker.** |
| **`FUN_00501600`** | **:1155** | `Region::InterpolateSunLight(t, outDirBright*, outDirColor*, outSunVec*, outAmbColor*)` | The **sun/ambient keyframe interpolator** and spherical-to-cartesian sun-direction builder. See §5. |
| **`FUN_00501860`** | **:1238** | `Region::InterpolateFog(t, outMinFog*, outMaxFog*, outFogColor*)` | Lerps fog min/max and fog color between two SkyTimeOfDay keyframes. |
| `FUN_00501990` | :1276 | `SkyObjectTable::RollPhysicsScriptFrame(table*)` | Uses a global deterministic hash (from `DAT_008ee9c8`, the player-weenie?) to update an integer at `table[0]` from `table[8]` (count of physics scripts). Likely rolls which variant PES to show. |
| `FUN_00501b20` | :1384 | `SkyObject::Unpack(stream*, size*)` | Reads 6 floats (`+4,+8,+0xc,+0x10,+0x14,+0x18`) then 3 uints (`+0x24,+0x28,+0x20`). See §4 offsets. |
| `FUN_00501cd0` | :1500 | `SkyObjectReplace::Unpack(stream*, size*)` | Reads 6 dwords at struct indices 0,2,3,4,5,6 (skips index 1 which is runtime pointer). |
| `FUN_00501de0` | :1577 | `SkyTimeOfDay::Pack(stream*, size*)` | Writes 11 floats+uint and the SkyObjReplace list (mirror of `FUN_00502100`). |
| `FUN_00501f50` | :1669 | `Region::PreloadSkyAssets(region*)` | Iterates every SkyObject and SkyObjectReplace, issues `FUN_0041a4e0(type=8, id)` for every GfxObjId — **this is the sky-mesh preloader**. |
| **`FUN_00502100`** | **:1784** | `SkyTimeOfDay::Unpack(stream*, size*)` | Reads 11 dwords into struct indices 0,1,2,3,4,5,6,8,9,10,**7** (index 7 last — the WorldFog enum) and then the SkyObjectReplace list. See §4. |
| **`FUN_005025c0`** | **:2124** | `DayGroup::Unpack(stream*, size*)` | Reads `ChanceOfOccur` + PString `DayName` + SkyObject list + SkyTime list. |
| **`FUN_00502820`** | **:2279** | `SkyDesc::Unpack(stream*, size*)` | **THE CORE UNPACK.** Reads 2 doubles (TickSize `+8`, LightTickSize `+0x10`) + DayGroup list. See §5. |
| **`FUN_00502a10`** | **:2407** | `Region::BuildPerFrameSkyObjectTable(table*, t, weatherChance)` | Iterates DayGroup.SkyObjects, applies visibility check (`Begin==End OR Begin<t<End`), lerps BeginAngle→EndAngle by u, then overlays any SkyObjectReplace for the current keyframe. Fills per-object render entries (0x2c bytes each) in an output table. See §5. |
| **`FUN_005062e0`** | **:6217** | `Region::FrameTick(landblockGrid*)` | Per-frame tick. Calls `FUN_00508010` (draw sky), then every `TickSize` seconds calls `FUN_004ff440` (→ sun/ambient lerp) and `FUN_00505f30` (pushes sun+ambient into D3D light globals). |
| **`FUN_00505f30`** | **:6031** | `Region::ApplyLightState(landblockGrid, DirBright, DirColor, sunVec*, AmbColor)` | Writes global state `DAT_00842780=DirBright`, `DAT_0084277c=DirColor`, sun vector to `DAT_00842950..58`, `DAT_00842778=AmbColor`. Decodes packed colors → `_DAT_008682b0..d4` (sun-color+direction for D3D vertex lighting). Calls `FUN_004530e0(power, color)` to set the D3D directional light. Then loops the landblock grid at `param_1+8` and calls `FUN_00532440` (AdjustPlanes) to recompute per-cell vertex lighting. |
| **`FUN_00508010`** | **:7535** | `Region::RenderSkyObjects(table*)` | **THE SKY RENDERER.** Calls `FUN_004ff4b0` to refresh the per-frame table, then iterates each SkyObject slot. For each non-null GfxObjId: builds a transform `local_48` (quaternion+translation), sets entity id+flags, calls `FUN_005079e0(xform, arcAngle?, worldRot)` to apply rotation, `FUN_00514b90(xform)` to queue the mesh draw, then `FUN_00512360/5124b0/5120c0` for the Transparent / Luminosity / MaxBright overrides. |
| `FUN_005079e0` | :7229 | `SkyObjectTransform::ApplyRotations(xform, a, b)` | Two-axis rotation: calls `FUN_00536b80(a)` then `FUN_005364e0({0, -b * DEG_TO_RAD, 0})`. Matches WorldBuilder `scale * RotZ(-heading) * RotY(-rot)`. |
| `FUN_00512360` | ref in 7588 | `PhysicsPart::SetMaxBright(...)` | Scales `input * _DAT_007a1870` — the field-scale constant (`0.01`? cf. WorldBuilder code comment). |
| `FUN_005124b0` | ref in 7591 | `PhysicsPart::SetLuminosity(...)` | Same scale. |
| `FUN_005120c0` | ref in 7594 | `PhysicsPart::SetTransparency(...)` | Same scale. |

---

## 2. Struct Offset Map

### Region (global at `DAT_0084247c`)

- `*region + 0x14` : vtable Release (called via `(**(code **)*region + 0x14)` at chunk_004F0000.c:10620)
- `region + 0x44` : some dword (used in chunk_00530000.c:2521)
- **`region + 0x50` : pointer to SkyDesc** (checked `!=0` for "has sky info" at :10683, :10699, :10715, :10731)

### SkyDesc (target of `*(int*)(region + 0x50)`)

From `FUN_00502820` (unpack) reads and `FUN_005062e0` (reader):

| Offset | Field | Evidence |
|---|---|---|
| +0x00 | (vtable / ref-count head) | implied by alloc+delete pattern |
| **+0x08** | **TickSize (double)** | :2295–2299 reads 2×4 bytes; :6241 reads `*(double *)(skyDesc + 8)` |
| **+0x10** | **LightTickSize (double)** | :2302–2305 reads 2×4 bytes; :6287 reads `*(double *)(skyDesc + 0x10)` |
| +0x18 | DayGroup[] ptr | :2335 `FUN_005025c0(piVar4=...)` implies `*(+0x18)` |
| +0x1c | DayGroup capacity | :2339 `*(uint *)(param_1 + 0x1c)` |
| +0x20 | DayGroup count | :2340 `*(int *)(param_1 + 0x20)` — also guards `:6236` "has DayGroups to iterate" |

### DayGroup (0x28-byte struct, allocated via `FUN_005df0f5(0x20)` = 32 bytes, but later used with indices up to [10] = 44 = 0x2c, suggesting alloc mismatch; the real allocation size per DayGroup is 0x20 but usage extends). From `FUN_005025c0`:

| Offset | Field | Evidence |
|---|---|---|
| +0x00 | vtable ptr | writes to `DAT_008ef11c` at :2324 |
| +0x04 | ChanceOfOccur (float) | :2137 `param_1[1] = *(int*)*param_2` |
| +0x08 | DayName (PString ptr) | :2141 `FUN_004fd460(param_2, ...)` PString reader |
| +0x14 | SkyObjects[] ptr | :2190 `*(undefined4 **)(param_1[5] + ...)` i.e. `param_1+0x14` |
| +0x18 | SkyObjects capacity | :2187 `param_1[6]` |
| +0x1c | SkyObjects count | :2188 `param_1[7]` |
| +0x20 | SkyTime[] ptr | :2256 `param_1[5+4] = param_1[8]` (actually read differently below — this offset is SkyTime array) |

Re-examining the exact struct indices used in `FUN_00501f50` (preloader) at :1684,:1686,:1688,:1698,:1700,:1702,:1704:
- `param_1 + 0x18` is the **SkyObjects array pointer** (iVar1 = `*(+0x18)+local_20*4`). iVar1+0x14 = SkyObjects inner-array ptr? Actually iVar1 is a DayGroup ptr. So each DayGroup field is computed at `iVar1 + 0x14` (SkyObjects) or `iVar1 + 0x8` (SkyTime).

**Corrected DayGroup layout (from `FUN_00501f50`):**
- +0x00 vtable, +0x04 ChanceOfOccur, +0x08 SkyTime[] ptr?, +0x0C cap, +0x10 count?, +0x14 SkyObjects[] ptr, +0x18 cap, +0x1c count

The prior numbering collided — needs one more pass to separate "struct fields" from "accessors." Treat the above as tentative; see §7 Gaps.

### SkyObject (0x2c bytes, allocated via `FUN_005df0f5(0x2c)` at :2215). From `FUN_00501b20`:

| Offset | Field | Evidence |
|---|---|---|
| +0x00 | ref-count or runtime field | init to 0xbf800000 at :1879 (== -1.0f, a "not-set" marker) |
| **+0x04** | BeginTime (float) | :1392 `*(param_1 + 4) = *stream` |
| **+0x08** | EndTime (float) | :1397 |
| **+0x0c** | BeginAngle (float) | :1402 |
| **+0x10** | EndAngle (float) | :1407 |
| **+0x14** | TexVelocityX (float) | :1412 |
| **+0x18** | TexVelocityY (float) | :1417 |
| +0x1c | Runtime GfxObjRef (not read from stream) | :1426 `*(param_1 + 0x1c) = 0` |
| **+0x20** | Properties (uint) | :1438 (READ LAST per ordering 0x24 then 0x28 then 0x20) |
| **+0x24** | DefaultGfxObjectId | :1428 (READ FIRST of the 3 dwords) |
| **+0x28** | DefaultPesObjectId | :1433 (READ SECOND) |

### SkyTimeOfDay (0x38 bytes, allocated via `FUN_005df0f5(0x38)` at :2215 in DayGroup loop 2). From `FUN_00502100`:

Dat wire order (from the 11 consecutive 4-byte reads): `Begin, DirBright, DirHeading, DirPitch, DirColor, AmbBright, AmbColor, MinWorldFog, MaxWorldFog, WorldFogColor, WorldFog`.

Struct-index storage order: **indices 0,1,2,3,4,5,6,8,9,10,7** — i.e. WorldFog slot (index 7, at +0x1c) is written LAST while data ordering has it last. Conclusion: **struct offset layout reorders `WorldFog` to +0x1c, placing `MinWorldFog/MaxWorldFog/WorldFogColor` at +0x20/+0x24/+0x28**.

| Offset | Field | Evidence |
|---|---|---|
| **+0x00** | Begin (float) | :1794 |
| **+0x04** | DirBright (float) | :1799 |
| **+0x08** | DirHeading (float) | :1804 |
| **+0x0c** | DirPitch (float) | :1809 |
| **+0x10** | DirColor (ColorARGB packed dword) | :1814 |
| **+0x14** | AmbBright (float) | :1819 |
| **+0x18** | AmbColor (ColorARGB packed dword) | :1824 |
| **+0x1c** | WorldFog enum (uint) | :1844 (stored AT index 7 = +0x1c, read LAST) |
| **+0x20** | MinWorldFog (float) | :1829 (index 8) |
| **+0x24** | MaxWorldFog (float) | :1834 (index 9) |
| **+0x28** | WorldFogColor (ColorARGB) | :1839 (index 10) |
| +0x2c | SkyObjectReplace[] ptr | :1886 `*(param_1[0xb] + ...)` |
| +0x30 | SkyObjReplace capacity | :1883 `param_1[0xc]` |
| +0x34 | SkyObjReplace count | :1884 `param_1[0xd]` |

### SkyObjectReplace (0x1c bytes, allocated via `FUN_005df0f5(0x1c)` at :1870). From `FUN_00501cd0`:

Reads in order: struct indices 0, 2, 3, 4, 5, 6 (skips index 1 at +0x04). Init +0x10 (index 4) = `0xbf800000 = -1.0f` at :1879.

| Offset | Field | Evidence |
|---|---|---|
| **+0x00** | ObjectIndex (uint) | :1507 (READ FIRST) |
| +0x04 | Runtime SkyObject ptr (resolved from ObjectIndex) | NOT read; written in `FUN_005025c0` at :2249: `piVar1[1] = *(int *)(param_1[5] + *piVar1 * 4)` |
| **+0x08** | GfxObjId | :1512 |
| **+0x0c** | Rotate (float, degrees) | :1517 |
| **+0x10** | Transparent (float) | :1522 |
| **+0x14** | Luminosity (float) | :1527 |
| **+0x18** | MaxBright (float) | :1532 |

Cross-checked against `FUN_00502a10` write-through:
- output `+0x1c` = Transparent (src `+0x10`) at :2562–2566 → `DAT_00796344 <= *(...+0x10)`
- output `+0x20` = Luminosity (src `+0x14`) at :2550–2554 → `DAT_00796344 < *(...+0x14)`
- output `+0x24` = MaxBright (src `+0x18`) at :2556–2560 → `DAT_00796344 < *(...+0x18)`

The `DAT_00796344` sentinel is most likely `0.0f` (the "don't override" marker). So the check pattern is "only override if the field is > 0".

### Per-Frame SkyObject Render Entry (0x2c bytes, table produced by `FUN_00502a10`):

| Offset | Field |
|---|---|
| +0x00 | GfxObjId (from Default or overridden SkyObjectReplace.GfxObjId) |
| +0x04 | PesObjectId (DefaultPesObjectId) |
| +0x08 | **[zeroed]** or reset marker |
| +0x0c | **Current arc angle (float, degrees)** = BeginAngle + (EndAngle - BeginAngle) * u |
| +0x10, +0x14, +0x18 | TexVelocityX, TexVelocityY, (runtime slot) |
| +0x1c | Transparent (from SkyObjectReplace.Transparent if > 0) |
| +0x20 | Luminosity (from SkyObjectReplace.Luminosity if > 0) |
| +0x24 | MaxBright (from SkyObjectReplace.MaxBright if > 0) |
| +0x28 | Properties (from SkyObject.Properties) |

---

## 3. Globals Inventory

| Global | Use | Confidence |
|---|---|---|
| `DAT_0084247c` | Current `Region*` (set by `FUN_004ff370`, cleared by `FUN_004ff3b0`) | **HIGH** — guarded by all sky accessors (:10683,:10699,:10715,:10731) |
| `DAT_00842798` | Next frame-tick timestamp (next time FUN_00505f30 fires) | HIGH — :6241 `= TickSize + now` |
| `_DAT_008379a8` | Current game time (double) — "now" | HIGH — used as current time everywhere |
| `_DAT_008427a0` | Next light-tick timestamp (LightTickSize cadence) | HIGH — :6289 |
| `_DAT_00842780` | Current DirBright (float) | HIGH — :6041 |
| `_DAT_0084277c` | Current DirColor (packed BGRA uint) | HIGH — :6042 |
| `_DAT_00842950/4/8` | Current sun vector (x,y,z floats) | HIGH — :6044–6046 |
| `_DAT_00842778` | Current AmbColor (packed BGRA uint) | HIGH — :6047 |
| `_DAT_008682b0/b4/b8` | D3D directional-light COLOR (R,G,B floats = sun color × brightness) | HIGH — :2084–2086 in FUN_004530e0 |
| `_DAT_008682bc/c0/c4` | D3D directional-light DIRECTION (x,y,z floats) | HIGH — :6062–6064 writes sun vector to these |
| `_DAT_008682c8/cc/d0` | Second copy of sun vector (likely view-space transform target, or "to-sun" vector) | MEDIUM — :6058–6060 |
| `_DAT_0079c6b0` | Degrees-to-radians constant (π/180) | HIGH — used in both `FUN_00501600` (sun direction) and `FUN_005079e0` (mesh rotation) |
| `_DAT_00799208` | 1/255.0f constant (byte → float color) | HIGH — :618 in FUN_00451a60 |
| `_DAT_007a1870` | Scale for Transparent/Luminosity/MaxBright when overridden | MEDIUM — :7588,7591,7594 |
| `DAT_00796344` | Sentinel `0.0f` (likely) — "don't override" marker | MEDIUM — compared against T/L/MB overrides in FUN_00502a10 and FUN_00508010 |
| `DAT_008ee9c8` | PlayerWeenie pointer (used to drive PES roll) | MEDIUM |
| `DAT_0079653c` | Region dat registry entry (type-index 0x1c) | HIGH — :12964 |
| `DAT_00796a6c` | Literal string "REGION" or its dat-type-name slot | HIGH — :12952 |

---

## 4. Call Graph

```
  Region::FrameTick (FUN_005062e0)                               [per-frame]
    ├── Region::RenderSkyObjects (FUN_00508010)                  [always]
    │     ├── Region::UpdateSkyObjectTable (FUN_004ff4b0)
    │     │     └── Region::BuildPerFrameSkyObjectTable (FUN_00502a10)
    │     │           └── SkyTimeOfDayList::FindBracket (FUN_00501530)
    │     ├── per-object: SkyObjectTransform::ApplyRotations (FUN_005079e0)
    │     │     ├── FUN_00536b80(angleA)           // rotate axis 1
    │     │     └── FUN_005364e0({0,-angleB*π/180,0})  // rotate axis 2
    │     ├── per-object: FUN_00514b90(xform)     // enqueue mesh draw
    │     ├── per-object: FUN_00512360 (MaxBright override)
    │     ├── per-object: FUN_005124b0 (Luminosity override)
    │     └── per-object: FUN_005120c0 (Transparency override)
    │
    └── (every TickSize s) Region::ApplyLightState (FUN_00505f30)
          ├── Region::LerpSunAndAmbient (FUN_004ff440 → FUN_00501600)
          │     └── SkyTimeOfDayList::FindBracket (FUN_00501530)
          ├── FUN_00451a60 (packed color → float[3])
          ├── FUN_004530e0 (write DirColor × DirBright to _DAT_008682b0..b8)
          └── loop landblock grid → FUN_00532440 (AdjustPlanes per-cell lighting)

  Region::Load (FUN_004ff370)                                    [on entering world]
    └── FUN_00415730(dataId, 0xb, 0x1c)                          // dat loader

  SkyDesc::Unpack (FUN_00502820)                                 [called by dat loader]
    ├── read TickSize (double)
    ├── read LightTickSize (double)
    ├── PString (skip? no — seems to skip at line 2308 FUN_00500610)
    └── DayGroup list:
          FUN_005025c0 → DayGroup::Unpack
            ├── read ChanceOfOccur (float)
            ├── FUN_004fd460 → PString DayName
            ├── SkyObject list (0x2c-byte each):
            │     FUN_00501b20 → SkyObject::Unpack
            └── SkyTime list (0x38-byte each):
                  FUN_00502100 → SkyTimeOfDay::Unpack
                    └── SkyObjectReplace list (0x1c-byte each):
                          FUN_00501cd0 → SkyObjectReplace::Unpack
```

---

## 5. Quoted Decompile — Key Functions

### 5.1 Keyframe bracket picker (`FUN_00501530`)

```c
undefined4 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 ptr
    do {
      puVar4 = puVar4 + 1;
      if (param_2 < *(float *)*puVar4) break;  // found k1 such that next.Begin > t
      uVar3 = uVar3 + 1;
    } while (uVar3 < iVar1 - 1U);
  }
  *param_3 = *(undefined4 *)(*(int *)(param_1 + 8) + uVar3 * 4);  // k1
  if (uVar3 == iVar1 - 1U) {
    *param_4 = **(undefined4 **)(param_1 + 8);    // k2 = first (wrap)
    *param_5 = (param_2 - *(float *)*param_3) / (_DAT_007938b0 - *(float *)*param_3);
    return 1;
  }
  pfVar2 = (float *)(*(undefined4 **)(param_1 + 8))[uVar3 + 1];
  *param_4 = pfVar2;                                  // k2
  *param_5 = (param_2 - *(float *)*param_3) / (*pfVar2 - *(float *)*param_3);
  return 1;
}
```

Notes: `_DAT_007938b0` is `1.0f` (day-fraction upper bound). Walks while `t >= arr[idx+1].Begin`. When at last keyframe, wraps to arr[0].Begin but with normalizer `1.0` instead of `(arr[0].Begin - arr[last].Begin)`.

### 5.2 Sun + ambient color interpolation (`FUN_00501600`)

```c
void FUN_00501600(float t, float *outDirBright, undefined1 *outDirColor,
                 float *outSunVec, undefined1 *outAmbColor) {
  iVar5 = FUN_00501530(region + 0x50 + 0x..., &local_14 /*k1*/, &local_10 /*k2*/, &t /*u*/);
  if (iVar5 != 0) {
    // DirBright lerp: k1+0x14 → k2+0x14 ... WAIT, this is on a SkyTimeOfDay which
    // starts at +0x00 = Begin.  So the keyframe struct offsets here are OFFSET -0 from
    // the keyframe start. Actually this function uses +0x14 directly which would be
    // DirHeading in our struct mapping — BUT the two SkyTime structs in FUN_00501530
    // are identified via their Begin at offset +0.  And here FUN_00501600 indexes
    // from the same base.
    *outDirBright = (*(float *)(k2 + 0x14) - *(float *)(k1 + 0x14)) * u
                  + *(float *)(k1 + 0x14);
    // Wait — this is struct offset +0x14 = DirBright per our map. The field WAS read
    // FIRST into SkyTimeOfDay at +0x04, but FUN_00501530 may pass a DIFFERENT struct
    // (a SkyLightKeyframe, NOT the SkyTimeOfDay). The offsets +4,+8,+0xc,+0x14,+0x18
    // suggest this keyframe record reflects a DIFFERENT internal layout than the
    // SkyTimeOfDay we parse.  *** see Gap §7 ***

    // DirColor unpack (bytes at k2+0x18..0x1a)
    param_3[0] = byte@(k2 + 0x1a);                   // R? (per our map 0x1a is not set)
    param_3[1] = byte@(k2 + 0x19);
    param_3[2] = byte@(k2 + 0x18);
    param_3[3] = 0xff;

    // fVar9 = scale = lerp(k1+4, k2+4, u)                     // radius?
    // fVar1 = pitch = lerp(k1+0xc, k2+0xc, u) * DEG_TO_RAD
    // fVar6 = head  = lerp(k1+8, k2+8, u) * DEG_TO_RAD
    fVar7 = fcos(fVar1);              // cos(pitch)
    fVar8 = fsin(fVar6);              // sin(heading)
    local_c = fVar9 * fVar8 * fVar7;  // X = R * sin(head)*cos(pitch)
    fVar6 = fcos(fVar6);              // cos(heading)
    *outSunVec = local_c;
    local_8 = fVar9 * fVar6 * fVar7;  // Y = R * cos(head)*cos(pitch)
    fVar6 = fsin(fVar1);              // sin(pitch)
    outSunVec[1] = local_8;
    local_4 = fVar6 * fVar9;          // Z = R * sin(pitch)
    outSunVec[2] = local_4;

    // AmbColor bytes at k2+0x10..0x12 (our map: AmbColor at +0x18... DISCREPANCY)
    outAmbColor[1] = byte@(k2 + 0x11);
    outAmbColor[2] = byte@(k2 + 0x12);
    *outAmbColor = byte@(k2 + 0x10);
    outAmbColor[3] = 0xff;
  }
  // else fallback: brightness 0.3, white dir/amb, sun=(0.5,0,0.8)
}
```

**NOTE: THE FIELD OFFSETS HERE CONTRADICT OUR SkyTimeOfDay STRUCT MAP.** See §7 Gaps.

### 5.3 Sky mesh renderer (`FUN_00508010`)

```c
void FUN_00508010(int *param_1) {  // param_1 = sky-object table
  FUN_004ff420();                    // ensure SkyDesc loaded
  fVar1 = (DAT_008ee9c8 == 0) ? _DAT_00795610 : *(float *)(DAT_008ee9c8 + 0x48);
  iVar6 = FUN_004ff4b0(fVar1, param_1);    // refresh per-frame table
  if (iVar6 != 0) {
    FUN_00507e20();
    uVar2 = param_1[2];
    uVar7 = 0;
    if (uVar2 != 0) {
      iVar6 = 0;
      do {
        if (*(int *)(param_1[3] + uVar7 * 4) != 0) {          // slot has a GfxObjId?
          uVar3 = *(undefined4 *)(iVar6 + 8 + *param_1);      // (unknown field at +8)
          uVar4 = *(undefined4 *)(iVar6 + *param_1 + 0xc);    // arc angle at +0xc
          local_48 = 0x3f800000; local_44 = 0; local_40 = 0; local_3c = 0;
          local_14 = 0; local_10 = 0; local_c = 0;
          FUN_00535b30();    // reset current transform (possibly sets identity)
          if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) {
            iVar5 = *(int *)param_1[3];
            local_14 = *(undefined4 *)(iVar5 + 0x84);  // custom position override?
            local_10 = *(undefined4 *)(iVar5 + 0x88);
            local_c  = *(undefined4 *)(iVar5 + 0x8c);
          }
          FUN_005079e0(&local_48, uVar3, uVar4);  // apply rotations (axis1=uVar3, axis2=uVar4)
          FUN_00514b90(&local_48);                // queue mesh draw
          if (DAT_00796344 < *(float *)(iVar6 + 0x20 + *param_1))    // Luminosity override
            FUN_00512360(0, *(float *)(iVar6 + 0x20 + *param_1) * _DAT_007a1870, 0, 0);
          if (DAT_00796344 < *(float *)(iVar6 + 0x24 + *param_1))    // MaxBright override
            FUN_005124b0(0, *(float *)(iVar6 + 0x24 + *param_1) * _DAT_007a1870, 0, 0);
          if (DAT_00796344 <= *(float *)(iVar6 + 0x1c + *param_1))   // Transparent override
            FUN_005120c0(   *(float *)(iVar6 + 0x1c + *param_1) * _DAT_007a1870, 0, 0);
        }
        uVar7 = uVar7 + 1;
        iVar6 = iVar6 + 0x2c;
      } while (uVar7 < uVar2);
    }
  }
}
```

### 5.4 Region frame tick + D3D light setup (`FUN_005062e0` + `FUN_00505f30`)

Key excerpt from `FUN_005062e0` at :6235–6290:

```c
if (*(int *)(skyTable + 0x10) != 0) {
    if (*(int *)(skyTable + 0x20) != 0)
      FUN_00508010(skyTable);                              // draw sky every frame
    local_14 = _DAT_008379a8;                              // now
    if ((_DAT_00842798 <= _DAT_008379a8) && (DAT_0084247c != 0)) {
      // next TickSize-delay firing time
      _DAT_00842798 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 8) + _DAT_008379a8;
      // acquire current scene-light time "fVar1" from player weenie or default
      fVar1 = (DAT_008ee9c8 == 0) ? _DAT_00795610 : *(float *)(DAT_008ee9c8 + 0x48);
      if (_DAT_008427a0 < _DAT_008379a8) {
        iVar7 = FUN_004ff440(fVar1, &local_24 /*DirBright*/, &local_20 /*DirColor*/,
                             local_c /*sunVec[3]*/, &local_18 /*AmbColor*/);
        if (iVar7 != 0) {
          if (local_24 < DAT_0084295c) local_24 = DAT_0084295c;  // clamp min brightness
          // Optional: crossfade to a custom state (DAT_008427a9 flag)
          if (DAT_008427a9 != '\0') { … blend to DAT_008427ac/0xb0/0xb4 over _DAT_008427b8 … }
          FUN_00505f30(local_24, local_20, local_c, local_18);   // push to D3D + AdjustPlanes
        }
        _DAT_008427a0 = _DAT_007c7200;
        if (DAT_0084247c != 0)
          _DAT_008427a0 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10);  // LightTickSize
        _DAT_008427a0 = _DAT_008427a0 + local_14;
      }
    }
  }
```

### 5.5 Light state publisher (`FUN_00505f30`)

```c
void FUN_00505f30(int landblockGrid, float DirBright, uint DirColor,
                  float *sunVec, uint AmbColor) {
  DAT_00842780 = DirBright;
  DAT_0084277c = DirColor;
  if (DAT_008427a8 == '\0') {
    DAT_00842950 = sunVec[0];
    DAT_00842954 = sunVec[1];
    DAT_00842958 = sunVec[2];
    DAT_00842778 = AmbColor;
  } else {
    // special-case crossfade …
  }
  _DAT_008682c8 = DAT_00842950;     // sun vec copy 1 (to-sun?)
  _DAT_008682d0 = DAT_00842958;
  _DAT_008682cc = DAT_00842954;
  FUN_00451a60(DAT_00842778);       // unpack AmbColor bytes → _DAT_008682bc/c0/c4 (scratch)
  _DAT_008682c0 = fVar2;            // sun vec copy 2 (forward?)
  _DAT_008682bc = fVar1;
  _DAT_008682c4 = fVar3;
  DAT_008682d4 = 0;
  if (DAT_0083da58 != 0)
    FUN_004530e0(|sun| * _DAT_0079a1e8 + DirBright, DirColor);   // D3D SetLight
  // Recompute per-cell lighting
  iVar4 = *(int *)(landblockGrid + 4);   // grid width
  for each (x,y): if (cell[y*w+x] != 0) FUN_00532440(cell);
}
```

---

## 6. D3D calls — what retail actually does

The decompile shows **no direct `SetRenderState` / `SetMaterial` / `SetLight` constant numbers** (no `14`, no `0x20`, etc.). Retail uses a C++ wrapper where the D3D device is instance-pointer-based (probably at `_DAT_008682xx` family of globals). The actual D3D bridge is elsewhere — a per-frame "render" sweep reads `_DAT_008682b0..d4` and feeds them to whatever lighting model D3D is set to.

What we CAN say with high confidence:
- Sky meshes are drawn by `FUN_00514b90` with a per-object transform. This enqueues a mesh draw in the normal render queue.
- The directional light (sun) color is `DirColor × DirBright` and gets written to `_DAT_008682b0..b8` — these globals feed the D3D directional light.
- The ambient color is `AmbColor` (packed uint), but **NOT** scaled by `AmbBright` at this location. The AmbBright-multiply must happen somewhere downstream (maybe inside the D3D bridge's SetAmbient, or baked into material setup).
- Per-cell landblock lighting is recomputed AT THIS MOMENT (FUN_00532440 AdjustPlanes), so terrain + statics re-light each time the sky keyframe ticks.

**No matrix-identity-with-zero-translation pattern** was found for a camera-anchored sky. Retail appears to render sky objects in world-space with a per-frame transform built from `FUN_00535b30` (reset transform) + `FUN_005079e0` (apply rotations). That's consistent with r12 deepdive description of sky meshes being camera-follow via the transform system, not a traditional infinite-far skybox.

---

## 7. Gaps / What I could NOT resolve

1. **Keyframe struct offsets in `FUN_00501600` disagree with `SkyTimeOfDay` struct as parsed by `FUN_00502100`.** The unpack writes DirBright at +0x04, DirHeading at +0x08, DirPitch at +0x0c, DirColor at +0x10, AmbBright at +0x14, AmbColor at +0x18. But the interpolator reads DirBright at +0x14, ambient bytes at +0x10, and uses +0x18..+0x1a for the "sun color" bytes. These offsets map better to a **DIFFERENT keyframe layout** — possibly the code passes a keyframe-sublist or a different struct (e.g. the 0x20-byte scaled-color record). **Next hunt:** locate what `region + 0x50 + X` pointer is passed to `FUN_00501530` inside `FUN_00501600`. The `DayGroup` struct likely has a light-keyframe sublist separate from the time-of-day list. Worth checking: is there a SEPARATE light-keyframe list at DayGroup +0x20 that FUN_00501600 uses?

2. **Two-axis rotation semantics in `FUN_005079e0`.** FUN_00536b80 is "axis 1" and FUN_005364e0 rotates around axis 2 with `{0, -angleB*DEG_TO_RAD, 0}` (Z-axis or Y-axis depending on internal convention). Need to disassemble those two to confirm.

3. **"Activity flag" at param_1[6] bit 0x04 inside `FUN_00508010`.** Some sky objects apparently get a different translation from +0x84/+0x88/+0x8c of the first slot (camera origin?). Not yet decoded.

4. **`DAT_00796344` sentinel value.** I assumed `0.0f` but didn't confirm by grep. If it's `-1.0f` that changes the "only-if-positive" interpretation of override fields.

5. **Unit of Transparent/Luminosity/MaxBright.** Retail multiplies the 3 values by `_DAT_007a1870` before applying. If that constant is `0.01f`, the raw dat values ARE percentages and our `/100` fix is retail-correct. If it's `1.0f`, the raw values are already fractions and the recent commit `eeae83a` is wrong. **Next hunt:** a 4-byte grep for `_DAT_007a1870` to find its initializer. One way: find where `_DAT_007a1870` is written, look at any `0x3c23d70a` (=0.01f as IEEE float) or `0x3f800000` (=1.0f) constant in the same function.

6. **AmbBright handling.** FUN_00505f30 doesn't visibly scale AmbColor by AmbBright. The bright-multiply either happens inside `FUN_00451a60`'s caller chain or it's a different globally-set D3D register. Our acdream code pre-multiplies AmbColor by AmbBright in the loader which might DOUBLE-apply if the D3D bridge also does it.

7. **GfxObj preload list.** `FUN_00501f50` preloads every sky GfxObjId — that's the function acdream should call when entering a Region to prefetch all sky meshes.

8. **Sky mesh vertex format / shader setup.** Not searched; `FUN_00514b90` is the entry to the rendering queue and is shared with all meshes. Need to disassemble to see if sky meshes get any distinguishing material flag.

---

## 8. Actionable implications for acdream

- **Port `FUN_00508010` as the sky draw loop.** Our WorldBuilder port is mostly right but:
  - The current arc angle should come from a **per-frame recomputed table** (like FUN_00502a10), not recomputed every draw call.
  - Transparent/Luminosity/MaxBright overrides are applied **ONLY IF > 0** (sentinel `DAT_00796344`). Our code should replicate this "don't override" gate.
- **Port `FUN_00501530` keyframe bracketing VERBATIM** — WorldBuilder's equivalent may not match the retail wrap-around (where k2 = arr[0] and denominator = 1.0 in the last-slot case).
- **Resolve the keyframe-struct mystery in FUN_00501600 before trusting the sun-direction math.** The offsets disagree with our dat-unpack struct. Something is reordering fields, and we need to know what before the ambient tint fix locks in.
- **The scale constant `_DAT_007a1870` is load-bearing for the T/L/MB units question.** Find its value BEFORE shipping any fix that divides by 100.
- **Retail does re-light EVERY cell (`FUN_00532440`) when the sky keyframe ticks.** This is the per-vertex lighting update that our port currently doesn't do — we set a uniform tint but don't recompute per-cell baked lighting. For faithful look, we need to re-run AdjustPlanes per-cell on each lighting tick.

---

## 9. Summary

Prior audits said "no retail sky code found." That was wrong — the code is all in `chunk_00500000.c` between 0x00501530 and 0x00508010 plus the global accessors in `chunk_004F0000.c:10610-10740`. The trail starts with `DAT_0084247c` (current Region global) and field `+0x50` (SkyDesc pointer). The trail to find it: search for the Region-dat-type-index (0x1c) as a parameter to the dat loader, not for sky strings.

**Every question the prior audits left open can now be answered by reading the specific functions listed in §1.** The main remaining work is to disassemble the two axes in `FUN_005079e0` and resolve the keyframe-struct-offset discrepancy in `FUN_00501600`.
