acdream/docs/research/2026-04-23-sky-decompile-hunt-C.md
Erik 1d54880213 sky(phase-8): retail-faithful night sky + README refresh
Iteration on the sky rendering pipeline to restore stars/moon visibility
at night and fix washed-out grey daytime clouds. Key fixes:

* sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd
  (0..400m at midnight, up to 2400m during day) is calibrated for
  terrain; sky meshes are authored at radii 1050-14271m which sits
  past FogEnd universally, causing every sky pixel to saturate to
  fogColor (dark navy). Stars, moon, dome texture all got
  obliterated. The horizon-glow trade-off is noted in the shader
  comment; research item to find retail's sky-specific fog range
  later.

* SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the
  vertex lighting saturates properly for bright keyframes. Retail's
  FUN_0059da60 non-luminous path writes rep.Luminosity into
  material.Emissive via the cache +0x3c slot; we were instead using
  it as a post-fragment multiply which could only dim, never brighten.
  Net effect: daytime clouds now render saturated white, dome dims
  correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars
  and moon unchanged.

* terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile
  (DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to
  pure ambient rather than getting an 8% sun floor.

New research / tooling (no runtime impact):

* docs/research/2026-04-24-lambert-brightness-split.md — retail's
  ambient-brightness formula pinned from PE .rdata read + live
  RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2
  where scale constant 0x0079a1e8 = 0.2f exactly.

* docs/research/2026-04-23-lightning-real.md — research note on the
  dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has
  explicit PES-triggered flash SkyObjects with 5ms time windows).

* Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is
  DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap
  backwards).

* tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector,
  sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb),
  and the 0x0079a1e8 scale-factor readout.

* tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus
  deep-dive agent. Identified GfxObj 0x010015EF as the stars layer
  (A8R8G8B8 128x128 texture, 4% bright-pixel ratio).

* src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha
  histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the
  clouds decoded with proper alpha" type questions.

README: rewrite to reflect current state (playable pre-alpha rendering
Dereth with animated characters, day-night cycle, weather, etc.)
instead of the stale "Phase 0 dat inventory only" description.

All 742 tests green.
2026-04-24 20:34:36 +02:00

517 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 §12 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 polarcartesian `DAT_00842950..58` **sun direction * DirBright**
- interp out `*param_5` keyframe `+0x10..+0x12` `DAT_00842778` **AmbColor**
So the on-disk SkyTimeOfDayin-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.