acdream/docs/research/2026-04-23-sky-decompile-hunt-C.md
Erik 58afd4850f sky(phase-1): revert speculative tint, add ACDREAM_DUMP_SKY diagnostic
The uncommitted uTint=AmbientColor-for-alpha-submeshes experiment (from
the 2026-04-22 inference) dimmed the sky dome's baked gradient — a
user-verified visual regression. Reverting to the eeae83a baseline
(uTint=Vector4.One for every submesh) while we execute the proper
retail-verbatim port.

Research: three parallel decompile-hunt agents landed verifying
retail's ground-truth sky pipeline for the first time (prior audits
searched for stripped symbol names; the trail opened via the Region
dat-type-index 0x1c registration at chunk_00410000.c:12952). Key
retail functions now mapped in chunk_00500000.c:1097-7535:
  - FUN_00501530: keyframe bracket-picker (with 1.0f wrap denominator)
  - FUN_00501600: sun+ambient interpolator (sunVec = DirBright ×
                  (sin yaw·cos pit, cos yaw·cos pit, sin pit))
  - FUN_00501860: fog interpolator
  - FUN_00502820: SkyDesc::Unpack (2 doubles + DayGroup list)
  - FUN_00502a10: build per-frame sky-object table
  - FUN_00505f30: apply light state + per-cell AdjustPlanes relight
  - FUN_005062e0: per-frame sky tick (throttled by LightTickSize)
  - FUN_00508010: sky-object render loop (enqueues through the NORMAL
                  mesh pipeline via FUN_00514b90 — not a bespoke path)

Surprise findings:
  - D3DRS_AMBIENT is set to 0 once at init and NEVER changes per-frame
    (chunk_005A0000.c). The r12-inferred "clouds = texture × D3DRS_
    AMBIENT" formula is falsified. Retail instead routes keyframe
    AmbColor through per-vertex lighting on non-Luminous sky meshes
    via _DAT_008682bc/c0/c4.
  - Retail does NOT anchor the sky to the camera or use a separate
    sky projection. Sky meshes live in world space and follow the
    camera via scene-graph parent.
  - FUN_00532440 (AdjustPlanes) re-lights every terrain cell on every
    keyframe tick — the "terrain follows the sky" effect we don't yet
    reproduce.

Phase 1 code change (this commit):
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: revert uTint to white
    for all submeshes (the per-submesh blend split stays — sun gets
    additive, clouds get alpha). Keep the `keyframe` parameter in the
    signature for Phase 2 readiness. Comments now cite the retail
    functions and reference docs instead of the (disproven) r12 formula.
  - src/AcDream.Core/World/SkyDescLoader.cs: ACDREAM_DUMP_SKY=1 logs
    the entire Region SkyDesc on load — DayGroups, SkyObjects, every
    SkyTimeOfDay keyframe, and every SkyObjectReplace with RAW pre-/100
    Transparent/Luminosity/MaxBright values so we can settle the unit
    question empirically.
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: ACDREAM_DUMP_SKY=1
    additionally logs each sky GfxObj's Surfaces and their SurfaceType
    flags on first load, so we can identify which meshes carry the
    Luminous bit (dome? sun? moon? stars?) vs which are lit.
  - src/AcDream.App/Rendering/GameWindow.cs: passes the interpolated
    keyframe to the sky renderer (kept — needed for Phase 2).

Research docs (pushed as part of this commit):
  - docs/research/2026-04-23-sky-retail-verbatim.md: full synthesis
    with retail function map, struct layouts, globals, pseudocode, and
    a 4-phase port plan.
  - docs/research/2026-04-23-sky-decompile-hunt-{A,B,C}.md: raw hunt
    outputs.
  - docs/research/2026-04-23-sky-references-crossref.md: WorldBuilder/
    ACE/ACViewer/holtburger/Chorizite coverage.
  - docs/research/2026-04-23-sky-dat-schema.md: full dat schema + unit
    analysis.
  - docs/research/2026-04-22-sky-lighting-decompile.md: prior agent's
    (superseded) inference — kept for provenance.

Phase 2 will port Surface.Luminous-flag-aware per-vertex lighting for
sky submeshes once the dump resolves the open questions (Luminous-flag
distribution per Dereth sky mesh; _DAT_007a1870 scale constant value).

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:06:52 +02:00

30 KiB
Raw 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.


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.