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

31 KiB
Raw Permalink Blame History

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 bufferoperator_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 enabletrue 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_00501600param_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_00501600param_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_00501600param_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:

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):

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:

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):

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:

_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):

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).

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 glueFUN_004ff440 (at chunk_004F0000.c:10692-10704) is a null-check wrapper around FUN_00501600:

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 +0x14DAT_00842780 → "ambient scalar / fog base"
  • interp out *param_3 ← keyframe +0x18..+0x1aDAT_0084277cDirColor (sun + fog)
  • interp out *param_4[0..2] ← keyframe +0x04,+0x08,+0x0c via polar→cartesian → DAT_00842950..58sun direction * DirBright
  • interp out *param_5 ← keyframe +0x10..+0x12DAT_00842778AmbColor

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.