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

474 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`)
```c
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`)
```c
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`)
```c
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:
```c
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`)
```c
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`.