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>
33 KiB
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
- Retail renders a small list of sky
GfxObjmeshes (sun, moon, stars, clouds, dome) through the normal mesh render queue — no bespoke sky shader, no camera-anchored sky projection, no D3DRS_AMBIENT writes. - 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/ENDupdated per keyframe), plus anySkyObjReplacethat swaps a mesh for a time-of-day variant. - 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 `( |
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):
- Sky meshes are drawn through the normal mesh render queue (
FUN_00514b90), same as terrain / entities / statics. - D3DRS_AMBIENT is set to 0 once at init and never changes (Agent B confirmed). The keyframe AmbColor does NOT drive D3DRS_AMBIENT.
- Instead, the keyframe AmbColor (ARGB byte) is unpacked via
FUN_00451a60into three float slots_DAT_008682bc/c0/c4, which are per-mesh ambient RGB used by the per-vertex lighting pipeline. 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.- A mesh's Surface.Type flags determine whether it participates in lighting:
Luminous(0x40) set → self-illuminated, texture passthrough (sun, moon, stars, likely dome)Luminousclear → gets(ambient + diffuse × sun) × texture, like any other mesh
- 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 = AmbientColoris 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:
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
/100fix 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.3fin the test fixture — our/100is WRONG (turns 0.3 into 0.003).
If _DAT_007a1870 = 0.01f:
- Retail applies
Luminosity × 0.01 = raw dat value ÷ 100. - Our
/100correctly 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)
- Revert uncommitted
SkyRenderer.cstint change → baselineuTint = 1for every sub-mesh (eeae83astate). - Add a one-shot
ACDREAM_DUMP_SKY=1diagnostic 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.
- 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.Luminousas a per-submesh uniform. Shader branches: Luminous → texture passthrough; non-Luminous → computeambient + max(0, dot(N, -sunDir)) × sunColor. - B (architecture unification): Route sky through
InstancedMeshRendererwith the sameSceneLightingUBO terrain/meshes use. No separatesky.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
LightTickSizeseconds (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 theSetFogStatewrite 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)
_DAT_007a1870exact value (§7 above) — requires binary disassembly of.rdataat 0x007a1870. Do viaobjdump, Ghidra, or a dat-dump of a live memory address.- 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_00502a10more 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.cslayout 1:1 — lean toward C.
- The interp uses a SEPARATE in-memory light-keyframe struct (not SkyTimeOfDay), possibly a simplified subset stored inline in DayGroup. Requires reading
- DAT_00796344 confirmed
0.0f— multiple usage sites confirm (returns this on divide-by-zero fallback, threshold comparison for "has override"). - AmbBright scaling location — not visible in
FUN_00505f30. Probably happens insideFUN_00451a60or the D3D material bridge. Our loader pre-multiplies AmbColor × AmbBright, which may double-apply. - Surface.Luminous status of Dereth sky GfxObjs — requires a live dump (Phase 1 diagnostic above).
- 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.