acdream/docs/research/2026-04-23-sky-retail-verbatim.md
Erik 58afd4850f sky(phase-1): revert speculative tint, add ACDREAM_DUMP_SKY diagnostic
The uncommitted uTint=AmbientColor-for-alpha-submeshes experiment (from
the 2026-04-22 inference) dimmed the sky dome's baked gradient — a
user-verified visual regression. Reverting to the eeae83a baseline
(uTint=Vector4.One for every submesh) while we execute the proper
retail-verbatim port.

Research: three parallel decompile-hunt agents landed verifying
retail's ground-truth sky pipeline for the first time (prior audits
searched for stripped symbol names; the trail opened via the Region
dat-type-index 0x1c registration at chunk_00410000.c:12952). Key
retail functions now mapped in chunk_00500000.c:1097-7535:
  - FUN_00501530: keyframe bracket-picker (with 1.0f wrap denominator)
  - FUN_00501600: sun+ambient interpolator (sunVec = DirBright ×
                  (sin yaw·cos pit, cos yaw·cos pit, sin pit))
  - FUN_00501860: fog interpolator
  - FUN_00502820: SkyDesc::Unpack (2 doubles + DayGroup list)
  - FUN_00502a10: build per-frame sky-object table
  - FUN_00505f30: apply light state + per-cell AdjustPlanes relight
  - FUN_005062e0: per-frame sky tick (throttled by LightTickSize)
  - FUN_00508010: sky-object render loop (enqueues through the NORMAL
                  mesh pipeline via FUN_00514b90 — not a bespoke path)

Surprise findings:
  - D3DRS_AMBIENT is set to 0 once at init and NEVER changes per-frame
    (chunk_005A0000.c). The r12-inferred "clouds = texture × D3DRS_
    AMBIENT" formula is falsified. Retail instead routes keyframe
    AmbColor through per-vertex lighting on non-Luminous sky meshes
    via _DAT_008682bc/c0/c4.
  - Retail does NOT anchor the sky to the camera or use a separate
    sky projection. Sky meshes live in world space and follow the
    camera via scene-graph parent.
  - FUN_00532440 (AdjustPlanes) re-lights every terrain cell on every
    keyframe tick — the "terrain follows the sky" effect we don't yet
    reproduce.

Phase 1 code change (this commit):
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: revert uTint to white
    for all submeshes (the per-submesh blend split stays — sun gets
    additive, clouds get alpha). Keep the `keyframe` parameter in the
    signature for Phase 2 readiness. Comments now cite the retail
    functions and reference docs instead of the (disproven) r12 formula.
  - src/AcDream.Core/World/SkyDescLoader.cs: ACDREAM_DUMP_SKY=1 logs
    the entire Region SkyDesc on load — DayGroups, SkyObjects, every
    SkyTimeOfDay keyframe, and every SkyObjectReplace with RAW pre-/100
    Transparent/Luminosity/MaxBright values so we can settle the unit
    question empirically.
  - src/AcDream.App/Rendering/Sky/SkyRenderer.cs: ACDREAM_DUMP_SKY=1
    additionally logs each sky GfxObj's Surfaces and their SurfaceType
    flags on first load, so we can identify which meshes carry the
    Luminous bit (dome? sun? moon? stars?) vs which are lit.
  - src/AcDream.App/Rendering/GameWindow.cs: passes the interpolated
    keyframe to the sky renderer (kept — needed for Phase 2).

Research docs (pushed as part of this commit):
  - docs/research/2026-04-23-sky-retail-verbatim.md: full synthesis
    with retail function map, struct layouts, globals, pseudocode, and
    a 4-phase port plan.
  - docs/research/2026-04-23-sky-decompile-hunt-{A,B,C}.md: raw hunt
    outputs.
  - docs/research/2026-04-23-sky-references-crossref.md: WorldBuilder/
    ACE/ACViewer/holtburger/Chorizite coverage.
  - docs/research/2026-04-23-sky-dat-schema.md: full dat schema + unit
    analysis.
  - docs/research/2026-04-22-sky-lighting-decompile.md: prior agent's
    (superseded) inference — kept for provenance.

Phase 2 will port Surface.Luminous-flag-aware per-vertex lighting for
sky submeshes once the dump resolves the open questions (Luminous-flag
distribution per Dereth sky mesh; _DAT_007a1870 scale constant value).

Build + 717 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:06:52 +02:00

33 KiB
Raw Permalink Blame History

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 `(
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:

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.