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>
32 KiB
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!=0for "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) | :2295–2299 reads 2×4 bytes; :6241 reads *(double *)(skyDesc + 8) |
| +0x10 | LightTickSize (double) | :2302–2305 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 + 0x18is 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 atiVar1 + 0x14(SkyObjects) oriVar1 + 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 :2562–2566 →DAT_00796344 <= *(...+0x10) - output
+0x20= Luminosity (src+0x14) at :2550–2554 →DAT_00796344 < *(...+0x14) - output
+0x24= MaxBright (src+0x18) at :2556–2560 →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 — :6044–6046 |
_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 — :2084–2086 in FUN_004530e0 |
_DAT_008682bc/c0/c4 |
D3D directional-light DIRECTION (x,y,z floats) | HIGH — :6062–6064 writes sun vector to these |
_DAT_008682c8/cc/d0 |
Second copy of sun vector (likely view-space transform target, or "to-sun" vector) | MEDIUM — :6058–6060 |
_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 :6235–6290:
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_00514b90with a per-object transform. This enqueues a mesh draw in the normal render queue. - The directional light (sun) color is
DirColor × DirBrightand gets written to_DAT_008682b0..b8— these globals feed the D3D directional light. - The ambient color is
AmbColor(packed uint), but NOT scaled byAmbBrightat 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
-
Keyframe struct offsets in
FUN_00501600disagree withSkyTimeOfDaystruct as parsed byFUN_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 whatregion + 0x50 + Xpointer is passed toFUN_00501530insideFUN_00501600. TheDayGroupstruct 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? -
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. -
"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. -
DAT_00796344sentinel value. I assumed0.0fbut didn't confirm by grep. If it's-1.0fthat changes the "only-if-positive" interpretation of override fields. -
Unit of Transparent/Luminosity/MaxBright. Retail multiplies the 3 values by
_DAT_007a1870before applying. If that constant is0.01f, the raw dat values ARE percentages and our/100fix is retail-correct. If it's1.0f, the raw values are already fractions and the recent commiteeae83ais wrong. Next hunt: a 4-byte grep for_DAT_007a1870to find its initializer. One way: find where_DAT_007a1870is written, look at any0x3c23d70a(=0.01f as IEEE float) or0x3f800000(=1.0f) constant in the same function. -
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. -
GfxObj preload list.
FUN_00501f50preloads every sky GfxObjId — that's the function acdream should call when entering a Region to prefetch all sky meshes. -
Sky mesh vertex format / shader setup. Not searched;
FUN_00514b90is 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_00508010as 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_00501530keyframe 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_007a1870is 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.