# Sky Rendering — Retail-Verbatim Investigation (SYNTHESIS) **Date:** 2026-04-23 **Status:** RESEARCH COMPLETE — decompile-verified. Ready to plan port. **Inputs:** three parallel hunt agents (A: Region loader; B: D3D state; C: global state + keyframe interp) + references cross-ref + dat schema map. **Deliverables:** retail function map, struct layouts, globals, pseudocode, port plan. ## 0. TL;DR — the retail sky pipeline in three sentences 1. Retail renders a small list of sky `GfxObj` meshes (sun, moon, stars, clouds, dome) through the **normal mesh render queue** — no bespoke sky shader, no camera-anchored sky projection, no D3DRS_AMBIENT writes. 2. The "sky colors" you see at dusk come from **per-vertex lighting on non-Luminous sky meshes** (ambient + diffuse × sun), plus fog (`D3DRS_FOGCOLOR/START/END` updated per keyframe), plus any `SkyObjReplace` that swaps a mesh for a time-of-day variant. 3. Keyframe interpolation gates on `LightTickSize` — lighting only updates every ~2 s, so the sky color marches in visible steps (consistent with live retail feel). --- ## 1. Full retail function map All citations are relative to `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\`. | Address | File | Name | Purpose | |---|---|---|---| | `FUN_004ff370` | chunk_004F0000.c:10610 | `Region::Load` | Dat loader. Calls `FUN_00415730(id, 0xb, 0x1c)` (type-index 0x1c = REGION). Stores result at `DAT_0084247c`. | | `FUN_004ff420` | chunk_004F0000.c:10680 | `Region::UpdateSkyObjectsTrampoline` | Guards + delegates to `FUN_00501990`. | | `FUN_004ff440` | chunk_004F0000.c:10692 | `Region::LerpSunAndAmbient` | Guards → `FUN_00501600`. | | `FUN_004ff480` | chunk_004F0000.c:10708 | `Region::LerpFogAmbient` | Guards → `FUN_00501860`. | | `FUN_004ff4b0` | chunk_004F0000.c:10724 | `Region::UpdateSkyObjectTable` | Guards → `FUN_00502a10`. | | **`FUN_00501530`** | chunk_00500000.c:1097 | **Keyframe bracket-picker** | Walks sorted keyframes; returns `k1, k2, u = (t - k1.Begin) / (k2.Begin - k1.Begin)`. Last-slot wrap: `k2 = arr[0]`, denominator uses `1.0f` (`_DAT_007938b0`). | | **`FUN_00501600`** | chunk_00500000.c:1155 | **Sun + ambient interpolator** | Lerps DirBright, DirColor, DirHeading, DirPitch, AmbColor; emits `sunVec = DirBright * (sin yaw cos pit, cos yaw cos pit, sin pit)`. | | **`FUN_00501860`** | chunk_00500000.c:1238 | **Fog interpolator** | Lerps MinWorldFog, MaxWorldFog, WorldFogColor. | | `FUN_00501990` | chunk_00500000.c:1276 | Deterministic PES roll | Uses `DAT_008ee9c8` hash to pick physics-script variant. | | `FUN_00501b20` | chunk_00500000.c:1384 | `SkyObject::Unpack` | 0x2c-byte struct. See §3. | | `FUN_00501cd0` | chunk_00500000.c:1500 | `SkyObjectReplace::Unpack` | 0x1c-byte struct. See §3. | | `FUN_00501f50` | chunk_00500000.c:1669 | **Region preloader** | Iterates every SkyObject + SkyObjectReplace, issues `FUN_0041a4e0(type=8, id)` for each GfxObjId. | | `FUN_00502100` | chunk_00500000.c:1784 | `SkyTimeOfDay::Unpack` | 0x38-byte struct. See §3. | | `FUN_005025c0` | chunk_00500000.c:2124 | `DayGroup::Unpack` | Reads `ChanceOfOccur + DayName + SkyObjects[] + SkyTime[]`. | | **`FUN_00502820`** | chunk_00500000.c:2279 | **`SkyDesc::Unpack`** | 2 doubles (TickSize, LightTickSize) + DayGroup list. The signature hit that confirmed the trail. | | **`FUN_00502a10`** | chunk_00500000.c:2407 | **Per-frame sky-object table builder** | Iterates `DayGroup.SkyObjects`, checks visibility, lerps `BeginAngle→EndAngle` by `u`, applies `SkyObjReplace` overrides. Emits 0x2c-byte entries per visible object. | | **`FUN_00505f30`** | chunk_00500000.c:6026 | **Apply-light-state** | Writes globals `DAT_00842778/7c/80/950..58`. Writes D3D light slots `_DAT_008682bc..d4`. Calls `FUN_004530e0` (set D3D directional light). **Loops landblock grid, runs `FUN_00532440` (AdjustPlanes) to re-light every cell.** | | **`FUN_005062e0`** | chunk_00500000.c:6213 | **Per-frame sky tick** | Every frame: draws sky (`FUN_00508010`); every `TickSize` sec: runs light interp + publish; every `LightTickSize` sec: runs fog interp + publish. Also handles weather crossfade. | | `FUN_00506d90` | chunk_00500000.c:6683 | Scene renderer (weather phase caller) | Calls `FUN_00507a50(0)` = phase-0 weather volume, `FUN_00507a50(1)` = phase-1 overlay. | | `FUN_00507a50` | chunk_00500000.c:7250 | **Weather-volume draw** (Agent B's find) | Not sky per se — draws rain/snow/fog-shaft scene-graph objects with Zwrite OFF, far plane × `_DAT_007c6f14`. | | **`FUN_00508010`** | chunk_00500000.c:7535 | **Sky-object render loop** | Refreshes per-frame table via `FUN_004ff4b0`, iterates sky objects, resets transform (`FUN_00535b30`), applies two-axis rotation (`FUN_005079e0`), queues mesh draw (`FUN_00514b90`), applies per-object Luminosity/MaxBright/Transparent overrides (`FUN_00512360/5124b0/5120c0`) scaled by `_DAT_007a1870`. | | `FUN_005079e0` | chunk_00500000.c:7229 | Two-axis sky-object transform | `FUN_00536b80(angleA)` then `FUN_005364e0({0, -angleB × π/180, 0})`. | | `FUN_00514b90` | chunk_00510000.c | Mesh-draw enqueue | Shared with ALL mesh rendering (sky, entity, static). Sky meshes use the same code path as everything else. | | `FUN_004530e0` | (not fully read) | D3D SetDirectionalLight | Writes `(|sunVec| × _DAT_0079a1e8 + DirBright, DirColor)` into D3D. | | `FUN_00532440` | (not fully read) | **AdjustPlanes** (per-cell relight) | Recomputes per-vertex lighting on every terrain cell after a lighting tick. | | `FUN_00451a60` | chunk_00450000.c:608 | ARGB byte-unpack | `(R, G, B, A) bytes → 3 floats × (1/255)` into scratch slots then copied to `_DAT_008682bc/c0/c4` as per-mesh ambient. | --- ## 2. Per-frame call graph ``` Region::FrameTick (FUN_005062e0) [every frame] │ ├── if skyEnabled: Region::RenderSkyObjects (FUN_00508010) [draws sky] │ ├── Region::UpdateSkyObjectTable (FUN_004ff4b0 → FUN_00502a10) │ │ └── SkyTimeOfDayList::FindBracket (FUN_00501530) │ └── foreach visible sky-object: │ ├── reset transform (FUN_00535b30 = identity) │ ├── SkyObjectTransform::ApplyRotations (FUN_005079e0) │ │ ├── FUN_00536b80(headingFromReplaceOrZero) │ │ └── FUN_005364e0({0, -arcAngle·π/180, 0}) │ ├── FUN_00514b90(transform) # enqueue mesh draw │ ├── if rep.Luminosity > 0: FUN_00512360(0, rep.Luminosity × _DAT_007a1870, 0, 0) │ ├── if rep.MaxBright > 0: FUN_005124b0(0, rep.MaxBright × _DAT_007a1870, 0, 0) │ └── if rep.Transparent ≥ 0: FUN_005120c0( rep.Transparent × _DAT_007a1870, 0, 0) │ └── if (nextLightTick ≤ gameClock) AND Region is loaded: [every TickSize seconds] ├── dayFraction = *(float *)(DAT_008ee9c8 + 0x48) # from player/time global │ ├── if (nextKeyframeTick < gameClock): [every LightTickSize seconds] │ ├── Region::LerpSunAndAmbient (FUN_004ff440 → FUN_00501600) │ │ # out: DirBright, DirColor(ARGB), sunVec[3], AmbColor(ARGB) │ │ # sunVec = DirBright * (sin yaw cos pit, cos yaw cos pit, sin pit) │ ├── clamp DirBright ≥ DAT_0084295c (MinWorldFog floor) │ ├── if (weatherCrossfade): blend toward weather-override values │ └── Region::ApplyLightState (FUN_00505f30) │ ├── DAT_00842780 = DirBright │ ├── DAT_0084277c = DirColor # packed ARGB │ ├── DAT_00842950/54/58 = sunVec # scaled by DirBright (NOT a unit vector) │ ├── DAT_00842778 = AmbColor # packed ARGB │ ├── _DAT_008682bc/c0/c4 = unpack(AmbColor) / 255 # per-mesh ambient RGB floats │ ├── _DAT_008682c8/cc/d0 = sunVec # D3D light direction │ ├── FUN_004530e0(|sunVec| × 0079a1e8 + DirBright, DirColor) # SetDirectionalLight │ └── foreach landblock cell: FUN_00532440(cell) # AdjustPlanes per-cell │ ├── fog gate: FUN_005a4010(!DAT_0081dbf8) # master fog enable └── if (DAT_0081dbf8): Region::LerpFogAmbient (FUN_004ff480 → FUN_00501860) # out: fogNear, fogFar, fogColor(ARGB) ├── if (weatherCrossfade): blend toward weather-override fog └── FUN_005a41b0(&fogColor, fogNear, fogFar) └── FUN_005a4080: writes D3DRS_FOGCOLOR=34, FOGSTART=36, FOGEND=37 ``` --- ## 3. Struct layouts (decompile-verified) ### 3.1 Region ``` Region (DBObj, 0x13000000..0x1300FFFF, type-index 0x1c) +0x00 vtable (ref-count release at +0x14) +0x44 (unknown) used in chunk_00530000.c:2521 +0x50 SkyDesc* pointer to SkyDesc; 0 = no sky info ``` `DAT_0084247c = current Region*` (global; set by `FUN_004ff370`, cleared by `FUN_004ff3b0`). ### 3.2 SkyDesc (chunk_00500000.c:2279 FUN_00502820) ``` SkyDesc +0x00 vtable +0x08 TickSize (double) seconds per frame tick +0x10 LightTickSize (double) seconds per lighting interp step +0x18 DayGroup[] ptr +0x1c DayGroup capacity +0x20 DayGroup count ``` ### 3.3 DayGroup (chunk_00500000.c:2124 FUN_005025c0) — 0x20 bytes ``` DayGroup +0x00 vtable +0x04 ChanceOfOccur (float) PDF weight for weather roll +0x08 DayName (PString) +0x14 SkyObjects[] ptr 0x2c bytes each +0x18 SkyObjects capacity +0x1c SkyObjects count ... SkyTime[] lives in a trailing section (not fully mapped; see Agent A §7) ``` ### 3.4 SkyObject (chunk_00500000.c:1384 FUN_00501b20) — 0x2c bytes ``` SkyObject +0x00 ref/runtime init to 0xbf800000 (-1.0f "not set") +0x04 BeginTime (float) day-fraction window start [0..1] +0x08 EndTime (float) wraps when End < Begin +0x0c BeginAngle (float) degrees arc-sweep start +0x10 EndAngle (float) degrees arc-sweep end +0x14 TexVelocityX (float) UV/sec scroll rate +0x18 TexVelocityY (float) +0x1c runtime GfxObjRef NOT from stream; init 0 +0x20 Properties (uint) flag bits (undecoded; possibly billboard) +0x24 DefaultGfxObjectId +0x28 DefaultPesObjectId ``` Read order from stream: `BeginTime, EndTime, BeginAngle, EndAngle, TexVelocityX, TexVelocityY, DefaultGfxObjectId, DefaultPesObjectId, Properties` — matches dats.xml 1:1. ### 3.5 SkyTimeOfDay (chunk_00500000.c:1784 FUN_00502100) — 0x38 bytes Wire read order: `Begin, DirBright, DirHeading, DirPitch, DirColor, AmbBright, AmbColor, MinWorldFog, MaxWorldFog, WorldFogColor, WorldFog`. **Struct writes WorldFog LAST to offset 0x1c**, so the in-memory layout reorders: ``` SkyTimeOfDay +0x00 Begin (float) +0x04 DirBright (float) +0x08 DirHeading (float, degrees) +0x0c DirPitch (float, degrees) +0x10 DirColor (ColorARGB) +0x14 AmbBright (float) +0x18 AmbColor (ColorARGB) +0x1c WorldFog (uint, D3D fog mode: 0=off,1=linear,2=exp,3=exp2) +0x20 MinWorldFog (float, meters) +0x24 MaxWorldFog (float, meters) +0x28 WorldFogColor (ColorARGB) +0x2c SkyObjectReplace[] ptr +0x30 SkyObjReplace capacity +0x34 SkyObjReplace count ``` ### 3.6 SkyObjectReplace (chunk_00500000.c:1500 FUN_00501cd0) — 0x1c bytes ``` SkyObjectReplace +0x00 ObjectIndex (uint) index into DayGroup.SkyObjects +0x04 runtime SkyObject* NOT from stream; resolved in FUN_005025c0:2249 +0x08 GfxObjId (QualifiedDataId) 0 = keep default +0x0c Rotate (float, degrees heading) +0x10 Transparent (float) +0x14 Luminosity (float) (0xbf800000 = -1.0f init) +0x18 MaxBright (float) ``` ### 3.7 Per-frame SkyObject render entry (FUN_00502a10 output) — 0x2c bytes ``` Entry +0x00 GfxObjId (default or SkyObjectReplace override) +0x04 PesObjectId +0x08 (reset marker / zero) +0x0c CurrentArcAngle (deg) = lerp(BeginAngle, EndAngle, u) +0x10 TexVelocityX +0x14 TexVelocityY +0x18 (runtime slot) +0x1c Transparent (from SkyObjectReplace iff Transparent > 0 sentinel) +0x20 Luminosity (from SkyObjectReplace iff Luminosity > 0 sentinel) +0x24 MaxBright (from SkyObjectReplace iff MaxBright > 0 sentinel) +0x28 Properties (SkyObject.Properties) ``` --- ## 4. Globals — the sky state block Two clusters at `0x00842778..0x008427bf` (primary state + crossfade) and `0x00842950..0x0084295f` (sun vector + fog floor): | Global | Role | Producer | Consumer(s) | |---|---|---|---| | `DAT_0084247c` | current Region* | `FUN_004ff370` | every sky accessor | | `DAT_00842778` | current AmbColor (packed ARGB) | `FUN_00505f30` | per-vertex lighting in chunk_00530000.c:2100-2105 | | `DAT_0084277c` | current DirColor (packed ARGB, also fog tint) | `FUN_00505f30` | per-vertex lighting, fog | | `DAT_00842780` | current DirBright (float) | `FUN_00505f30` | per-vertex lighting (ambient scalar); fog-start offset | | `DAT_00842790` | cloud/stars heightmap buffer ptr | `FUN_00508010` realloc | sky-object draw | | `DAT_00842798` | next cloud/weather-tick deadline (double) | `FUN_005062e0` | `FUN_005062e0` | | `DAT_008427a0` | next keyframe-tick deadline (double) | `FUN_005062e0` | `FUN_005062e0` | | `DAT_008427a8` | FixedLight override enable (bool) | `FUN_00505d40` | `FUN_00505f30` | | `DAT_008427a9` | weather crossfade active (bool) | weather code | `FUN_005062e0`, `FUN_00508010` | | `DAT_008427b8` | crossfade u (0..1) | `FUN_005062e0` | `FUN_005062e0` | | `DAT_00842950/54/58` | sun vector × DirBright (3 floats) | `FUN_00505f30` | per-vertex terrain `chunk_00530000.c:2094-2230`; landblock ambient apply `chunk_00450000.c:4337-4468` | | `DAT_0084295c` | MinWorldFog clamp floor (float) | region config | `FUN_005062e0:6253-6254` | | `_DAT_008682b0/b4/b8` | D3D directional-light RGB = `DirColor × DirBright` | `FUN_004530e0` | D3D pipeline | | `_DAT_008682bc/c0/c4` | D3D per-mesh ambient RGB (unpacked from AmbColor/255) | `FUN_00451a60 → FUN_00505f30` | per-mesh lighting | | `_DAT_008682c8/cc/d0` | D3D directional-light direction (copy of sun vec) | `FUN_00505f30` | D3D pipeline | | `DAT_008ee9c8` | world/time global (dayFraction at +0x48, tick counters) | world-update (NOT FOUND) | `FUN_005062e0`, `FUN_00508010` | | `_DAT_007938b0` | `1.0f` (day-fraction upper bound) | `.rdata` constant | keyframe wrap denominator | | `_DAT_0079c6b0` | `π/180` (deg → rad) | `.rdata` constant | sun-direction math, mesh rotation | | `_DAT_00799208` | `1/255f` | `.rdata` constant | ARGB byte → float | | **`_DAT_007a1870`** | **Scale for T/L/MB overrides** (likely 1.0f, unconfirmed) | `.rdata` constant | `FUN_00508010:7588-7594` | | `DAT_00796344` | **`0.0f` sentinel** ("don't override if ≤ 0") — confirmed via threshold comparisons in chunk_004F0000.c:9152, 9172 | `.rdata` | `FUN_00508010:7587,7590,7593` | --- ## 5. The sun-direction formula (decompile-verified, chunk_00500000.c:1192-1205) ``` yaw_rad = DirHeading × (π/180) # _DAT_0079c6b0 pitch_rad = DirPitch × (π/180) mag = DirBright x = mag × sin(yaw) × cos(pitch) y = mag × cos(yaw) × cos(pitch) z = mag × sin(pitch) ``` Coordinate frame: X=east, Y=north, Z=up (Dereth world). Matches our `SkyStateProvider.SunDirectionFromKeyframe` **except** retail scales by `DirBright` (so the vector magnitude ≠ 1). The magnitude is deliberately retained — downstream code uses `|sunVec|` as a fog-start offset in `FUN_00505f30:6067`. --- ## 6. The cloud-tint puzzle — RESOLVED User's observation: retail clouds show a "purple haze" at dusk; acdream clouds stay white. **Mechanism (cross-referenced via Agent A + B + cross-ref):** 1. Sky meshes are drawn through the **normal mesh render queue** (`FUN_00514b90`), same as terrain / entities / statics. 2. **D3DRS_AMBIENT is set to 0 once at init and never changes** (Agent B confirmed). The keyframe AmbColor does NOT drive D3DRS_AMBIENT. 3. Instead, the keyframe AmbColor (ARGB byte) is unpacked via `FUN_00451a60` into **three float slots** `_DAT_008682bc/c0/c4`, which are **per-mesh ambient RGB** used by the per-vertex lighting pipeline. 4. `FUN_00532440` (AdjustPlanes) re-lights EVERY CELL on every keyframe tick — so terrain and mesh vertex colors are baked in from `(DirColor × DirBright)` dot-normal + AmbColor. 5. **A mesh's Surface.Type flags determine whether it participates in lighting:** - `Luminous` (0x40) set → self-illuminated, texture passthrough (sun, moon, stars, likely dome) - `Luminous` clear → gets `(ambient + diffuse × sun) × texture`, like any other mesh 6. Clouds without Luminous get the ambient tint naturally. No special "sky shader" path exists in retail. **Implication for our port:** - Our sky shader multiplying by `uTint = AmbientColor` is architecturally close but too broad — it tints ALL non-additive sub-meshes including the dome. - The correct split is **by Surface.Luminous flag**, not by blend mode. - Dome is Luminous (preserves baked gradient); clouds are non-Luminous (get ambient tint). **Caveat (Agent A §7 gap #5):** the actual Luminous status of each retail sky mesh's Surface is NOT dumped here. We need a runtime diagnostic to confirm dome is Luminous and clouds are not. --- ## 7. The Transparent / Luminosity / MaxBright unit question `FUN_00508010:7588-7594` applies overrides as: ```c FUN_00512360(0, rep.Luminosity × _DAT_007a1870, 0, 0); FUN_005124b0(0, rep.MaxBright × _DAT_007a1870, 0, 0); FUN_005120c0( rep.Transparent × _DAT_007a1870, 0, 0); ``` **`_DAT_007a1870` appears elsewhere** (`chunk_00510000.c:7644,7660`) as the FALLBACK "default when no override" return value — this is strongly consistent with **`_DAT_007a1870 = 1.0f`** (identity for Luminosity / scale). If `_DAT_007a1870 = 1.0f`: - Retail applies `Luminosity × 1.0 = raw dat value`. - Our `/100` fix assumes the dat is in percent (0..100) and wants a scale-back-down. - If dat Luminosity is already a fraction (0..1) — like `Surface.Luminosity = 0.3f` in the test fixture — our `/100` is WRONG (turns 0.3 into 0.003). If `_DAT_007a1870 = 0.01f`: - Retail applies `Luminosity × 0.01 = raw dat value ÷ 100`. - Our `/100` correctly matches retail behavior. **Decision:** **Need a real binary inspection of `0x007a1870` in `acclient.exe` to pin this.** Our user-confirmed "much better" observation after `/100` weakly supports `_DAT_007a1870 = 0.01f` (or equivalently, dat-in-percent with an implicit ×0.01 somewhere). Until binary-confirmed, TREAT THE `/100` AS SPECULATIVE. Action: add a `dump-sky-desc` diag that prints raw SkyObjectReplace floats from a live dat; if values are consistently 0..100, `/100` is right and `_DAT_007a1870 = 0.01f`. --- ## 8. Architecture contrast: our SkyRenderer vs retail | Aspect | acdream (current) | Retail | Action | |---|---|---|---| | Sky shader | Dedicated `sky.vert/frag` with `uTint`, `uLuminosity`, `uTransparency` | None — reuses normal mesh pipeline | **Unify**: route sky meshes through InstancedMeshRenderer with the SceneLighting UBO. OR: keep the dedicated shader but make it read the UBO's ambient/sun. | | Camera-anchored sky | Yes — view matrix zeroed, far plane 1e6 | NO camera anchor (Agent B confirmed). Sky meshes live in world space, follow the camera via scene-graph parent. | Decide: is our camera-anchor a visible improvement, or a deviation? Retail may stretch the sky as the camera moves — which is visible at speed. | | Per-keyframe tint | `uTint = 1` (eeae83a) OR `uTint = AmbientColor` (current uncommitted) | Per-vertex lighting driven by Surface.Luminous flag — ambient multiply happens per-fragment on non-Luminous meshes | **Replace blind `uTint = AmbientColor` with per-surface conditional** — Luminous meshes untinted, non-Luminous meshes get `(ambient + diffuse·sun) × texture` | | Blend mode per sub-mesh | `SrcAlpha/One` for Additive; `SrcAlpha/OneMinusSrcAlpha` otherwise | Same pattern expected (Additive sun/moon, alpha clouds), but retail uses per-Surface-flag routing, not per-submesh classification | Keep current classification; it matches retail's intent. | | `Transparent/Luminosity/MaxBright` | `/100` in loader, applied as uniforms | Applied via `FUN_00512360/5124b0/5120c0` scaled by `_DAT_007a1870` (likely 1.0f or 0.01f) | **Unresolved — see §7.** Do NOT change until binary-confirmed. | | Keyframe interpolation | Lerps between 2 keyframes with wrap | Same algorithm (`FUN_00501530` — identical logic, including the `1.0f` wrap denominator) | **Match**, no change. | | Lighting tick rate | Every frame | Every `LightTickSize` seconds (~2s) | **Port**: throttle the UBO update to `LightTickSize`. This produces retail's visible "step" in sky color transitions. | | Per-cell relight | Not done | `FUN_00532440` (AdjustPlanes) reruns per-vertex lighting on every cell each keyframe tick | **Port** for terrain only — matches the "sky gets darker → terrain actually gets darker too" behavior. | | Fog | Shader uniform, not updated per keyframe for sky | D3DRS_FOGCOLOR/START/END updated per keyframe via `FUN_005a41b0` | **Port**: UBO-update fog per keyframe. | | UV scroll (TexVelocityX/Y) | We apply it | WorldBuilder doesn't; retail does via the per-object table (inferred — scroll state is part of the 0x2c-byte render entry) | Keep; matches retail intent. | --- ## 9. Retail-verbatim pseudocode ### 9.1 Region frame tick (port of `FUN_005062e0`) ``` def RegionFrameTick(landblockGrid): if not skyEnabled: return if skyObjectsTableReady: RenderSkyObjects(skyTable) # every frame — see 9.2 now = gameClockSeconds if nextSkyTick <= now and currentRegion is not None: nextSkyTick = currentRegion.SkyDesc.TickSize + now dayFraction = worldTime.dayFraction # *(float*)(DAT_008ee9c8 + 0x48) # Keyframe interp gate — every LightTickSize seconds if nextLightTick < now: ok = LerpSunAndAmbient(dayFraction, &DirBright, &DirColor, &sunVec, &AmbColor) if ok: if DirBright < MinWorldFogFloor: # DAT_0084295c DirBright = MinWorldFogFloor if weatherCrossfadeActive: blend(DirBright, DirColor, toward = weather override) advance crossfade u by step constant ApplyLightState(DirBright, DirColor, sunVec, AmbColor) # 9.3 nextLightTick = currentRegion.SkyDesc.LightTickSize + now # Fog interp SetFogEnable(fogMasterFlag) if fogMasterFlag: ok2 = LerpFogAmbient(dayFraction, &fogNear, &fogFar, &fogColor) if ok2: if weatherCrossfadeActive: blend(fogNear, fogFar, fogColor, toward = weather override) SetFogState(fogColor, fogNear, fogFar) # D3DRS_FOGCOLOR/START/END ``` ### 9.2 Sky-object render loop (port of `FUN_00508010`) ``` def RenderSkyObjects(table): EnsureSkyDescLoaded() dayFraction = playerWorldTime.dayFraction ok = UpdateSkyObjectTable(dayFraction, table) # FUN_004ff4b0 → FUN_00502a10 if not ok: return FUN_00507e20() # probably advances a counter for i in range(table.count): entry = table.entries[i] # 0x2c-byte struct per 3.7 if entry.GfxObjId == 0: continue # Reset local transform to identity transform = IDENTITY # Special "custom position" path (flag & 4 — first entry only?) if (entry.propertiesFlag & 4): transform.translation = (entry.customX, entry.customY, entry.customZ) # Apply two-axis rotation — see FUN_005079e0 # axis1 = unknown quaternion from table[+0x08] # axis2 = current arc angle (scalar degrees) ApplyRotations(transform, entry.axis1Quat, entry.arcAngleDeg) # Enqueue mesh draw through the normal render queue EnqueueMeshDraw(entry.GfxObjId, transform) # Apply per-keyframe overrides scaled by _DAT_007a1870 (likely 1.0f) scale = _DAT_007a1870 if entry.Luminosity > 0: SetLuminosity( entry.Luminosity × scale) if entry.MaxBright > 0: SetMaxBright( entry.MaxBright × scale) if entry.Transparent >= 0: SetTransparency(entry.Transparent × scale) ``` ### 9.3 Apply-light-state (port of `FUN_00505f30`) ``` def ApplyLightState(DirBright, DirColor, sunVec3, AmbColor): # Publish globals worldSkyState.DirBright = DirBright worldSkyState.DirColor = DirColor worldSkyState.sunVec = sunVec3 # NOT unit — carries |sunVec| = DirBright worldSkyState.AmbColor = AmbColor # Unpack ambient ARGB to 3 floats (× 1/255) worldSkyState.ambientRGB = ColorARGB_to_RGB_floats(AmbColor) # Copy sun vector into "D3D direction" slot (what we call sunDirection uniform) d3dLight.direction = sunVec3 # SetDirectionalLight with pre-scaled power SetDirectionalLight( power = |sunVec3| × _DAT_0079a1e8 + DirBright, color = DirColor) # Per-cell relight — this is the big one for cell in landblockGrid: AdjustPlanes(cell) # FUN_00532440: recomputes per-vertex lit colors ``` ### 9.4 Interp (port of `FUN_00501600`) ``` def LerpSunAndAmbient(u, out_DirBright, out_DirColor, out_sunVec, out_AmbColor): (k1, k2, t) = FindBracket(skyTimeOfDayList, u) # FUN_00501530 if not found: out_DirBright = 0.3 out_DirColor = 0xFFFFFFFF out_AmbColor = 0xFFFFFFFF out_sunVec = (0.5, 0, 0.8) return False # Lerp the keyframe scalars (NOTE: offsets per §3.5 — struct layout is # Begin@0, DirBright@4, DirHeading@8, DirPitch@0xc, DirColor@0x10, # AmbBright@0x14, AmbColor@0x18) DirBright = lerp(k1.DirBright, k2.DirBright, t) DirHeadingDeg = lerp(k1.DirHeading, k2.DirHeading, t) # shortest-arc lerp! DirPitchDeg = lerp(k1.DirPitch, k2.DirPitch, t) # Sun vector — NOT a unit vector; scaled by DirBright yaw = DirHeadingDeg × π/180 pit = DirPitchDeg × π/180 out_sunVec = ( DirBright × sin(yaw) × cos(pit), # X east DirBright × cos(yaw) × cos(pit), # Y north DirBright × sin(pit), # Z up ) # Byte-lerp each ARGB channel out_DirColor = byte_lerp(k1.DirColor, k2.DirColor, t) out_AmbColor = byte_lerp(k1.AmbColor, k2.AmbColor, t) # NOTE: `FUN_00501600` decompile shows AmbBright (+0x14) is NOT used to # scale the interpolated AmbColor at this call site. The scale is applied # downstream, possibly in the D3D bridge that consumes _DAT_008682bc/c0/c4. # See Agent A §7 gap #6. out_DirBright = DirBright return True ``` ### 9.5 Bracket-picker (port of `FUN_00501530` — verbatim) ``` def FindBracket(keyframes, t): count = keyframes.count if count == 0: return (None, None, 0) if count == 1: return (keyframes[0], keyframes[0], 0) i = 0 while i < count - 1: if t < keyframes[i + 1].Begin: break i += 1 k1 = keyframes[i] if i == count - 1: # Wrap — last keyframe to first k2 = keyframes[0] u = (t - k1.Begin) / (1.0 - k1.Begin) # denominator is 1.0, NOT k2.Begin else: k2 = keyframes[i + 1] u = (t - k1.Begin) / (k2.Begin - k1.Begin) return (k1, k2, u) ``` --- ## 10. Port plan ### Phase 1: Safe revert + diagnostics (1 commit) 1. Revert uncommitted `SkyRenderer.cs` tint change → baseline `uTint = 1` for every sub-mesh (eeae83a state). 2. Add a one-shot `ACDREAM_DUMP_SKY=1` diagnostic that on Region load prints: - Every SkyObject: index, GfxObjId, BeginTime/EndTime, BeginAngle/EndAngle, TexVel, Properties. - For each loaded sky GfxObj: its surface list + Surface.Type flags (Luminous, Additive, Alpha, etc) + texture id. - Every SkyTimeOfDay keyframe: Begin, DirBright, DirHeading/Pitch, AmbBright, colors as hex bytes. - Every SkyObjectReplace: raw float values for Transparent/Luminosity/MaxBright. 3. Launch, log, inspect. Answers all the Agent A §7 gap questions. ### Phase 2: Route sky meshes through per-vertex lighting Based on Phase 1 findings: - If DOME is Luminous: uTint stays 1 for it. - If CLOUDS are non-Luminous: they should get `(ambient + diffuse × sun) × texture`, not a simple ambient multiply. Implementation options: - **A (shader tweak):** Pass `Surface.Luminous` as a per-submesh uniform. Shader branches: Luminous → texture passthrough; non-Luminous → compute `ambient + max(0, dot(N, -sunDir)) × sunColor`. - **B (architecture unification):** Route sky through `InstancedMeshRenderer` with the same `SceneLighting` UBO terrain/meshes use. No separate `sky.frag`. Each surface's Luminous flag already routes it through the per-surface lighting in the generic mesh path (or should). ### Phase 3: LightTickSize throttling + per-cell relight - Throttle lighting UBO update to `LightTickSize` seconds (retail's ~2s step). - Port `FUN_00532440` (AdjustPlanes) — recompute per-vertex baked lighting on every terrain cell each light-tick. This is probably what makes retail terrain "follow" the sky so convincingly. ### Phase 4: Fog + weather crossfade + lightning (later) - Port `FUN_00501860` (fog interp) and the `SetFogState` write per keyframe. - Port the weather crossfade (`DAT_008427a9`/`b8`). - Lightning — NOT found in the decompile. Either retail doesn't have it or it's a separate code path. Ours is client-driven; keep as an acdream extension. --- ## 11. Open questions (Agent A §7 gaps — need follow-up) 1. **`_DAT_007a1870` exact value** (§7 above) — requires binary disassembly of `.rdata` at 0x007a1870. Do via `objdump`, Ghidra, or a dat-dump of a live memory address. 2. **FUN_00501600 offset discrepancy** — Agent A says the field offsets in the interp don't match the SkyTimeOfDay unpack. One of two reconciliations: - The interp uses a SEPARATE in-memory light-keyframe struct (not SkyTimeOfDay), possibly a simplified subset stored inline in DayGroup. Requires reading `FUN_00502a10` more carefully to see what pointer it passes. - Agent C's offset map (DirBright@+0x04, AmbColor@+0x18) is correct and Agent A's decompile-reading was off-by-one. Agent C matches ACE's `SkyTimeOfDay.cs` layout 1:1 — lean toward C. 3. **DAT_00796344 confirmed `0.0f`** — multiple usage sites confirm (returns this on divide-by-zero fallback, threshold comparison for "has override"). 4. **AmbBright scaling location** — not visible in `FUN_00505f30`. Probably happens inside `FUN_00451a60` or the D3D material bridge. Our loader pre-multiplies AmbColor × AmbBright, which may double-apply. 5. **Surface.Luminous status of Dereth sky GfxObjs** — requires a live dump (Phase 1 diagnostic above). 6. **Lightning flash** — not found in decompile. Confirms it's either absent in retail or a separate path not on this hunt. --- ## 12. Decision trees ### Immediate action (this conversation) ``` if uncommitted SkyRenderer.cs change is still present: → REVERT to eeae83a baseline (uTint = white for all). Rationale: current tint change dims the dome gradient (verified regression). Baseline is the least-wrong state while we plan the retail-faithful rebuild. then: → implement Phase 1 diagnostic dump to fill the §11 gaps. → do NOT implement Phase 2+ until the dump resolves the unit + Luminous-flag questions. ``` ### Phase 2 fork (after Phase 1 dump) ``` if DOME.Surface.Luminous == true AND CLOUDS.Surface.Luminous == false: → retail-faithful: tint clouds by per-vertex (ambient + diffuse × sun), keep dome untinted. Matches the retail pipeline exactly. elif DOME.Surface.Luminous == true AND CLOUDS.Surface.Luminous == true: → retail renders both as texture passthrough; the "purple haze" must come from SkyObjReplace swapping cloud textures at dusk. Solution: confirm our SkyObjReplace path fires correctly, and dusk cloud mesh actually has purple baked in. elif DOME.Surface.Luminous == false: → (unexpected) the dome IS tinted by ambient. My initial hypothesis was wrong. Route dome through the ambient-tint shader path. ``` ### Phase 3 (per-cell relight) ``` if Phase 2 fix looks good but terrain doesn't match: → port FUN_00532440 AdjustPlanes per-cell relight triggered on LightTickSize boundary. ``` --- ## 13. References - `docs/research/2026-04-23-sky-decompile-hunt-A.md` — Region loader trail. - `docs/research/2026-04-23-sky-decompile-hunt-B.md` — D3D state trail. - `docs/research/2026-04-23-sky-decompile-hunt-C.md` — globals + interp trail. - `docs/research/2026-04-23-sky-references-crossref.md` — WorldBuilder/ACE/ACViewer/holtburger. - `docs/research/2026-04-23-sky-dat-schema.md` — dat schema. - `docs/research/deepdives/r12-weather-daynight.md` — foundational doc. - `docs/research/decompiled/chunk_00500000.c:1097-7535` — the retail sky pipeline source. - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` — nearest-stack reference (dead code but structurally close). - `references/DatReaderWriter/DatReaderWriter/Generated/Types/*.cs` — canonical dat schemas.