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>
30 KiB
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 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:
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,¶m_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'st0(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 inFUN_00501990:1293, a PRNG seed hint)+0x48: float dayFraction (0..1) — directly consumed by FUN_00501600 asparam_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 + 0x48for the current dayFraction. - Runs two independent interpolators against the same keyframe tables, throttled by
TickSize/LightTickSize(fromSkyDesc). 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 glue — FUN_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/58as the sun direction (NOT normalized — carriesDirBrightbaked in).DAT_00842780as the "ambient base multiplier" (namedDirBrightin the schema but semanticallyAmbBrighthere: it's the scalar multiplying the ambient ARGB, not the sun).DAT_00842778ARGB → (R,G,B)/255 as ambient color (confirmed: line 2100,2104,2105).DAT_0084277cARGB → (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 tintDAT_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,+0x0cvia polar→cartesian →DAT_00842950..58→ sun direction * DirBright - interp out
*param_5← keyframe+0x10..+0x12→DAT_00842778→ AmbColor
So the on-disk SkyTimeOfDay→in-memory field offsets are:
| Offset | Field (ACE schema name) | Matched consumer |
|---|---|---|
+0x04 |
DirBright |
sun-vector magnitude fVar9 |
+0x08 |
DirHeading (deg) |
yaw → sin/cos |
+0x0c |
DirPitch (deg) |
pitch → cos/sin |
+0x10/+0x11/+0x12 |
AmbColor (R,G,B) |
DAT_00842778 |
+0x14 |
AmbBright (!) |
DAT_00842780 — multiplies AmbColor in lit_color; also fog-start offset |
+0x18/+0x19/+0x1a |
DirColor (R,G,B) |
DAT_0084277c — diffuse AND fog tint |
+0x20 |
MinWorldFog |
secondary interp local_1c via FUN_00501860:1250 |
+0x24 |
MaxWorldFog |
secondary interp local_24 via FUN_00501860:1252 |
+0x28/+0x29/+0x2a |
WorldFogColor (R,G,B) |
starfield ARGB in FUN_005a41b0 |
This matches exactly the published ACE SkyTimeOfDay struct (docs/research/2026-04-23-sky-dat-schema.md:42-54) — every field has a decompile home now.
8. Gaps / next hunts
-
Day-fraction writer. I found
*(float *)(DAT_008ee9c8 + 0x48)is READ asdayFraction. I did NOT locate the WRITER — i.e. which function computesdayFraction = (gameClockSeconds mod dayLength) / dayLengthand stamps it to+0x48. Look in chunk_004F0000.c forDAT_008ee9c8 + 0x48write-sites, likely driven by a world-tick function usingSkyDesc.TickSize. -
DAT_00796344and_DAT_007938c0constants. In the per-vertex lighting (chunk_00530000.c:2119),DAT_00796344is the minimum-dot clamp._DAT_007938c0is the per-channel saturation clamp. Both should be extracted to confirm they are0.0fand1.0frespectively (or something like0.0and> 1.0to allow HDR). -
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 ofFUN_00505d40(0)(which setsDAT_008427a8 = 0) needs to be mapped — it's the dungeon-load entry. -
Starfield secondary keyframe.
FUN_00501860/FUN_005a41b0(starfield state) uses keyframe fields+0x20, +0x24, +0x28..+0x2a. If acdream renders stars, portFUN_005a41b0too. -
Crossfade parameters
_DAT_007c7208andDAT_008427ac. The crossfade (DAT_008427a9) is a smoothing blend when keyframes are force-swapped (e.g. weather change)._DAT_007c7208is the per-tick u-step;DAT_008427acis the held previous value. If acdream wants faithful weather transitions, port this loop fromFUN_005062e0:6256-6281. -
Fog calling convention
FUN_004530e0. I did not decompile this but its signature —FUN_004530e0(fogDistance, fogColorARGB)— is unambiguous. Port asSetFog(distance, ARGB). Thedistancearg is|sunDir| * _DAT_0079a1e8 + fogStart— so distant fog grows with the "weighted sun-vector length". This is AC's recognizable fog-near-horizon effect. -
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:
- Pull dayFraction from
world.time.dayFraction(DAT_008ee9c8 + 0x48, a float 0..1). - Gate on
LightTickSizeelapsed — only interpolate everyLightTickSizeseconds; otherwise reuse last frame's values (hence the visible ~2-second "step" in AC's time-of-day tint). - Bracket dayFraction across the current DayGroup's
SkyTimeOfDay[]byt0. Compute u01. - Interpolate each channel: fogStart, fogColorARGB (byte-lerp), sunDir via polar-to-cartesian of (DirHeading, DirPitch) scaled by DirBright, ambColorARGB (byte-lerp).
- Clamp fogStart ≥
DAT_0084295c(the MinWorldFog floor from the Region). - Crossfade blend if a keyframe swap was forced (weather).
- Commit to six floats (sunDir x3, fogStart x1) + two ARGB dwords (fog=sun, amb).
- 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). - 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.