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>
595 lines
33 KiB
Markdown
595 lines
33 KiB
Markdown
# 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<GfxObj>) 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.
|