acdream/docs/research/2026-04-23-sky-decompile-hunt-A.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

32 KiB
Raw Blame History

Sky Decompile Hunt — Agent A

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

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


1. Function Map

All citations are chunk_00500000.c unless noted.

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

2. Struct Offset Map

Region (global at DAT_0084247c)

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

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

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

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

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

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

Re-examining the exact struct indices used in FUN_00501f50 (preloader) at :1684,:1686,:1688,:1698,:1700,:1702,:1704:

  • param_1 + 0x18 is the SkyObjects array pointer (iVar1 = *(+0x18)+local_20*4). iVar1+0x14 = SkyObjects inner-array ptr? Actually iVar1 is a DayGroup ptr. So each DayGroup field is computed at iVar1 + 0x14 (SkyObjects) or iVar1 + 0x8 (SkyTime).

Corrected DayGroup layout (from FUN_00501f50):

  • +0x00 vtable, +0x04 ChanceOfOccur, +0x08 SkyTime[] ptr?, +0x0C cap, +0x10 count?, +0x14 SkyObjects[] ptr, +0x18 cap, +0x1c count

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

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

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

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

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

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

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

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

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

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

Cross-checked against FUN_00502a10 write-through:

  • output +0x1c = Transparent (src +0x10) at :25622566 → DAT_00796344 <= *(...+0x10)
  • output +0x20 = Luminosity (src +0x14) at :25502554 → DAT_00796344 < *(...+0x14)
  • output +0x24 = MaxBright (src +0x18) at :25562560 → DAT_00796344 < *(...+0x18)

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

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

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

3. Globals Inventory

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

4. Call Graph

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

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

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

5. Quoted Decompile — Key Functions

5.1 Keyframe bracket picker (FUN_00501530)

undefined4 FUN_00501530(int param_1, float param_2, undefined4 *param_3,
                       undefined4 *param_4, float *param_5) {
  iVar1 = *(int *)(param_1 + 0x10);         // count
  if (iVar1 == 0) return 0;
  uVar3 = 0;
  if (iVar1 != 1) {
    puVar4 = *(undefined4 **)(param_1 + 8); // array ptr
    do {
      puVar4 = puVar4 + 1;
      if (param_2 < *(float *)*puVar4) break;  // found k1 such that next.Begin > t
      uVar3 = uVar3 + 1;
    } while (uVar3 < iVar1 - 1U);
  }
  *param_3 = *(undefined4 *)(*(int *)(param_1 + 8) + uVar3 * 4);  // k1
  if (uVar3 == iVar1 - 1U) {
    *param_4 = **(undefined4 **)(param_1 + 8);    // k2 = first (wrap)
    *param_5 = (param_2 - *(float *)*param_3) / (_DAT_007938b0 - *(float *)*param_3);
    return 1;
  }
  pfVar2 = (float *)(*(undefined4 **)(param_1 + 8))[uVar3 + 1];
  *param_4 = pfVar2;                                  // k2
  *param_5 = (param_2 - *(float *)*param_3) / (*pfVar2 - *(float *)*param_3);
  return 1;
}

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

5.2 Sun + ambient color interpolation (FUN_00501600)

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

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

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

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

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

5.3 Sky mesh renderer (FUN_00508010)

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

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

Key excerpt from FUN_005062e0 at :62356290:

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

5.5 Light state publisher (FUN_00505f30)

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

6. D3D calls — what retail actually does

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

What we CAN say with high confidence:

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

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


7. Gaps / What I could NOT resolve

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

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

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

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

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

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

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

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


8. Actionable implications for acdream

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

9. Summary

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

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