From 58afd4850fd04ae4e5c18810c30007cb9dba03ac Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Apr 2026 18:06:52 +0200 Subject: [PATCH] sky(phase-1): revert speculative tint, add ACDREAM_DUMP_SKY diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-04-22-sky-lighting-decompile.md | 251 ++++++++ docs/research/2026-04-23-sky-dat-schema.md | 239 +++++++ .../2026-04-23-sky-decompile-hunt-A.md | 474 ++++++++++++++ .../2026-04-23-sky-decompile-hunt-B.md | 432 +++++++++++++ .../2026-04-23-sky-decompile-hunt-C.md | 492 +++++++++++++++ .../2026-04-23-sky-references-crossref.md | 191 ++++++ .../2026-04-23-sky-retail-verbatim.md | 595 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 2 +- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 100 ++- src/AcDream.Core/World/SkyDescLoader.cs | 88 +++ 10 files changed, 2854 insertions(+), 10 deletions(-) create mode 100644 docs/research/2026-04-22-sky-lighting-decompile.md create mode 100644 docs/research/2026-04-23-sky-dat-schema.md create mode 100644 docs/research/2026-04-23-sky-decompile-hunt-A.md create mode 100644 docs/research/2026-04-23-sky-decompile-hunt-B.md create mode 100644 docs/research/2026-04-23-sky-decompile-hunt-C.md create mode 100644 docs/research/2026-04-23-sky-references-crossref.md create mode 100644 docs/research/2026-04-23-sky-retail-verbatim.md diff --git a/docs/research/2026-04-22-sky-lighting-decompile.md b/docs/research/2026-04-22-sky-lighting-decompile.md new file mode 100644 index 0000000..a56bf1b --- /dev/null +++ b/docs/research/2026-04-22-sky-lighting-decompile.md @@ -0,0 +1,251 @@ +# Sky Lighting Formula Analysis: Retail vs acdream + +**Date:** 2026-04-22 +**Status:** DECOMPILE-INCOMPLETE — retail D3D render code not located +**Finding:** Formula inferred from observed behavior + r12 architecture docs + test evidence + +## Executive Summary + +Retail AC sky meshes are tinted by the ambient color from the current SkyTimeOfDay keyframe. + +**Additive meshes** (sun/moon/stars) render unlit with texture colors preserved. +**Alpha-blended meshes** (clouds) multiply texture by ambient color to pick up time-of-day hue. + +At midnight: AmbientColor = (0.05, 0.05, 0.12) → clouds appear **purple** +At dusk: AmbientColor = (0.35, 0.25, 0.25) → clouds appear **pink/orange** +At noon: AmbientColor = (0.5, 0.5, 0.55) → clouds stay **light gray** + +**Our bug:** SkyRenderer.cs:200 always sets uTint = Vector4.One (white), so clouds stay neutral gray. + +## 1. Sky Mesh Draw Entry Point + +### Retail decompile search: NOT FOUND + +Searched chunk_00400000.c through chunk_007F0000.c for: +- String literals: "sky", "Sky", "SkyObject" +- D3D constants: D3DRS_LIGHTING, D3DRS_AMBIENT, SetMaterial, SetLight +- Environment/weather functions + +No matches. Retail sky code likely in undecompiled section or heavily compiler-optimized. + +### Retail reference: WorldBuilder SkyboxRenderManager + +File: references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:115-274 + +Entry: Render() method, lines 183-264 iterate each SkyObject: +1. Visibility check: timeOfDay >= BeginTime && <= EndTime +2. GfxObj override lookup: t1.SkyObjReplace[i] +3. Arc angle: rotationDeg = BeginAngle + (EndAngle - BeginAngle) * progress +4. Transform: scale * RotZ(-heading) * RotY(-rotation) +5. Draw: RenderObjectBatches(renderData, [transform]) + +Draw state (lines 177-180): +- DepthMask(false) +- Disable(DepthTest) +- Disable(CullFace) +- Blend per batch + +No per-material tinting visible in this OpenGL code. + +## 2. Lighting State for Sky Meshes (Retail) + +### Decompile status: NOT FOUND + +### Inference from r12 and observed behavior: + +Retail (D3D7/D3D8 era) likely set: +- D3DRS_LIGHTING = FALSE (fixed-function disabled) +- D3DRS_AMBIENT = (0, 0, 0) or from keyframe +- No dynamic lights + +Sky meshes ARE the gradient; they don't receive sun illumination. Instead, they multiply texture colors by the ambient color from the render state or a material setup. + +## 3. Keyframe Data Flow into Render State + +### Retail decompile: NOT FOUND + +### Documented (r12 §3-4): + +Each SkyTimeOfDay keyframe carries: +- AmbBright: ambient light intensity (0..N) +- AmbColor: BGRA ambient RGB +- DirBright: sun intensity +- DirHeading/DirPitch: sun position +- DirColor: BGRA sun RGB + +Between keyframes: linear lerp (shortest-arc for angles). + +**Flow (inferred):** +1. dayFraction = ticks mod 7620 / 7620 +2. Pick keyframes k1, k2; compute blend weight u +3. AmbientColor_now = lerp(k1.AmbColor, k2.AmbColor, u) * u_brightness +4. SetRenderState(D3DRS_AMBIENT, ...) or bake into material +5. Draw sky + +## 4. Vertex Format for Sky Meshes + +Sky mesh vertices carry: +- Position (3×float) +- Normal (3×float) — may be used for billboard orientation +- UV (2×float) +- **No pre-lit diffuse color** + +Meshes are unlit. Tinting comes from render state ambient + material. + +## 5. Exact Lighting Formula (Retail) + +### For Additive Meshes (sun, moon, stars): +``` +blend: GL_SRC_ALPHA, GL_ONE + +fragment = texture * luminosity_override +(ambient ignored; blend preserves bright body, makes black transparent) +``` + +### For Alpha-Blended Meshes (clouds, sky dome): +``` +blend: GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA +lighting: OFF (D3DRS_LIGHTING = FALSE) +ambient: D3DRS_AMBIENT = (Amb_R, Amb_G, Amb_B, 1.0) from keyframe + +fragment.rgb = texture.rgb * ambient * luminosity_override +fragment.a = texture.a * (1 - transparency) +``` + +**Examples:** +- Midnight (dayFraction=0.0): ambient=(0.05,0.05,0.12) → gray texture × deep blue = **purple** +- Dusk (0.75): ambient=(0.35,0.25,0.25) → gray texture × reddish gray = **pink** +- Noon (0.5): ambient=(0.5,0.5,0.55) → gray texture × pale blue = **light gray** + +Clouds receive NO directional sun color — they are purely ambient-lit. + +## 6. Our Code vs Retail + +### acdream current (WRONG): + +File: src/AcDream.App/Rendering/Sky/SkyRenderer.cs:175-210 + +```csharp +_shader.SetVec4("uTint", Vector4.One); // Line 200: always white + +// Shader (sky.frag:41): +// vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity +// = sampled.rgb * (1,1,1) * uLuminosity +// = neutral gray at all times +``` + +Result: Clouds never pick up purple/pink/orange hue from keyframe. + +### Retail (CORRECT): + +Per formula above: tint = keyframe.AmbientColor + +## 7. Proposed Implementation + +### Change 1: Extract keyframe in Render() + +File: src/AcDream.App/Rendering/Sky/SkyRenderer.cs:85-95 + +Keyframe already passed as parameter. Extract AmbientColor: +```csharp +var ambientTint = new Vector4(keyframe.AmbientColor, 1.0f); +``` + +### Change 2: Conditionally set uTint per submesh + +File: src/AcDream.App/Rendering/Sky/SkyRenderer.cs:188-210 + +Replace line 200: +```csharp +foreach (var sub in subMeshes) +{ + if (sub.IsAdditive) + { + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + _shader.SetVec4("uTint", Vector4.One); // sun: keep texture color + } + else + { + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + _shader.SetVec4("uTint", ambientTint); // clouds: time-of-day tint + } + + // ... draw ... +} +``` + +### Change 3: Shader unchanged + +sky.frag:36-59 already correct. With new uTint logic: +- Clouds at midnight: sampled × (0.05,0.05,0.12) × lum → **purple** +- Sun always: sampled × (1,1,1) × lum → **sun color** + +## 8. Differences: Retail vs acdream + +| Aspect | Retail | acdream (current) | Diff | +|--------|--------|-------------------|------| +| Non-additive tint | AmbientColor | Always white (1,1,1) | **BUG** | +| Additive tint | White | White | ✓ | +| Ambient lerp | Between keyframes | Available, unused | Data OK | +| Blend mode | Per-surface flags | Per-submesh IsAdditive | ✓ | + +Root cause: Line 200 unconditionally sets uTint = Vector4.One instead of AmbientColor for non-additive. + +## 9. Acceptance Test + +At dayFraction=0.0 (midnight), gray texture (0.5,0.5,0.5): + +**Expected (retail):** +``` +AmbientColor = (0.05, 0.05, 0.12) +output = (0.5,0.5,0.5) × (0.05,0.05,0.12) × 1.0 + = (0.025, 0.025, 0.06) + ≈ dark purple +``` + +**Current (bug):** +``` +output = (0.5,0.5,0.5) × (1,1,1) × 1.0 + = (0.5, 0.5, 0.5) + = neutral gray +``` + +**Test:** +1. /time 0.0 (midnight) +2. Look at cloud layer +3. Should be visibly purple (high blue, low red/green) +4. /time 0.75 (dusk) → should be pink/orange +5. /time 0.5 (noon) → should be pale gray + +If cloud stays gray at all times, fix not applied. + +## References + +| Source | Citation | +|--------|----------| +| SkyKeyframe struct | src/AcDream.Core/World/SkyState.cs:44-54 | +| SkyRenderer.Render | src/AcDream.App/Rendering/Sky/SkyRenderer.cs:85-217 | +| sky.frag | src/AcDream.App/Rendering/Shaders/sky.frag:36-59 | +| SkyState defaults | src/AcDream.Core/World/SkyState.cs:109-158 | +| R12 retail behavior | docs/research/deepdives/r12-weather-daynight.md:§2-4 | +| WorldBuilder port | references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:115-274 | +| Prior audit | docs/research/2026-04-21-sky-deep-audit.md | + +## Verified Formula + +For non-additive (alpha-blended) sky meshes: +``` +output.rgb = texture.rgb * AmbientColor * luminosity_override +output.a = texture.a * (1 - transparency) +``` + +For additive (sun/moon) meshes: +``` +output.rgb = texture.rgb * luminosity_override +output.a = texture.a * luminosity_override +``` + +**Diff:** SkyRenderer.cs lines 175-210 conditionally set uTint: white for additive, keyframe.AmbientColor for alpha-blended. No shader changes. + +**Implementation time:** ~1 hour. + diff --git a/docs/research/2026-04-23-sky-dat-schema.md b/docs/research/2026-04-23-sky-dat-schema.md new file mode 100644 index 0000000..5c122d3 --- /dev/null +++ b/docs/research/2026-04-23-sky-dat-schema.md @@ -0,0 +1,239 @@ +# Sky DAT Schema — Full Map + +**Date:** 2026-04-23 +**Scope:** Every field in the retail Region→SkyDesc→DayGroup→SkyObject/SkyTimeOfDay tree, with units, comments, and cross-references. Covers SurfaceType flags and the acdream interpretation. +**Purpose:** Pre-port audit. No code changes. +**Sources:** DatReaderWriter auto-generated Ghidra-parsed types; ACE; ACViewer; WorldBuilder. Retail decompile (`docs/research/decompiled/chunk_*.c`) has NO sky-related matches (confirmed in prior audit, `2026-04-21-sky-deep-audit.md:9-15`). + +--- + +## 1. Structure Tree + +``` +Region (DB_TYPE_REGION, 0x13000000–0x1300FFFF) + ├── RegionNumber : uint32 // internal ID + ├── Version : uint32 + ├── RegionName : PString + ├── LandDefs, GameTime, PartsMask, SoundInfo, SceneInfo, TerrainInfo, RegionMisc + └── SkyInfo : SkyDesc (only if PartsMask has HasSkyInfo) + +SkyDesc + ├── TickSize : double // seconds per game-tick (day length unit) + ├── LightTickSize : double // seconds per lighting update tick + └── DayGroups : List + +DayGroup + ├── ChanceOfOccur : float // weight in [0, 1] for PDF rolling + ├── DayName : PString // e.g. "Clear", "Sunny" — internal-only label + ├── SkyObjects : List // celestial meshes (sun/moon/clouds/...) + └── SkyTime : List // keyframes through the day + +SkyObject // ONE celestial layer + ├── BeginTime : float // [0, 1] day-fraction visibility start + ├── EndTime : float // [0, 1] day-fraction visibility end; wraps if End // the mesh (0 = hidden) + ├── DefaultPesObjectId: QualifiedDataId // optional particle emitter + └── Properties : uint32 // flag bits (billboard? follow-camera? — not decoded) + +SkyTimeOfDay // ONE keyframe at time T + ├── Begin : float // [0, 1] when this keyframe becomes the "active" interpolant + ├── DirBright : float // sun intensity multiplier (unit unspecified; typically 0..~1.5) + ├── DirHeading : float // sun compass heading (degrees; 0=N, 90=E, …) + ├── DirPitch : float // sun elevation above horizon (degrees; -90..+90) + ├── DirColor : ColorARGB // sun RGB, bytes 0..255 each + ├── AmbBright : float // ambient intensity multiplier + ├── AmbColor : ColorARGB // ambient RGB, bytes 0..255 each + ├── MinWorldFog : float // meters — fog starts + ├── MaxWorldFog : float // meters — fog saturates + ├── WorldFogColor : ColorARGB // fog tint, bytes 0..255 each + ├── WorldFog : uint32 // mode enum: 0=off, 1=D3DFOG_LINEAR, 2=exp, 3=exp2 + └── SkyObjReplace : List + +SkyObjectReplace // per-keyframe override of one SkyObject + ├── ObjectIndex : uint32 // index into owning DayGroup.SkyObjects + ├── GfxObjId : QualifiedDataId // mesh override (0 = keep default) + ├── Rotate : float // heading override — **degrees** + ├── Transparent : float // UNIT DISPUTED — see §4 + ├── Luminosity : float // UNIT DISPUTED — see §4 + └── MaxBright : float // UNIT DISPUTED — see §4 +``` + +File citations: `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Region.generated.cs:27-67`, `Types/SkyDesc.generated.cs:23-30`, `Types/DayGroup.generated.cs:23-31`, `Types/SkyObject.generated.cs:23-41`, `Types/SkyTimeOfDay.generated.cs:23-47`, `Types/SkyObjectReplace.generated.cs:23-35`. + +--- + +## 2. Field Table with Units & Sources + +| Field | Type | Unit / Range | Comment source | Example | +|---|---|---|---|---| +| `SkyDesc.TickSize` | double | seconds? | none in generator | 1.0 in test fixture (`tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs:26`) | +| `SkyDesc.LightTickSize` | double | seconds? | none | 2.0 in test fixture (same) | +| `DayGroup.ChanceOfOccur` | float | probability weight [0,1]? | none; r12 §11 describes PDF use (`docs/research/deepdives/r12-weather-daynight.md:544`) | 1.0 | +| `DayGroup.DayName` | PString | UTF-8 | none | "Sunny" (memory; diag dump referenced in `SkyDescLoader.cs:253`) | +| `SkyObject.BeginTime` | float | day-fraction [0,1] | r12 deepdive §2 "normalized day-time" (`deepdives/r12-weather-daynight.md:161`) | 0.0 (always-visible dome) | +| `SkyObject.EndTime` | float | day-fraction [0,1]; Begin==End → always visible; Begin>End → wraps midnight | our `SkyDescLoader.cs:46-52` encodes this; matches WorldBuilder `SkyboxRenderManager.cs:188-197` | 1.0, 0.25 | +| `SkyObject.BeginAngle` | float | degrees | r12 §2 "where on the sky arc" | 0° | +| `SkyObject.EndAngle` | float | degrees | r12 §2 lerped linearly | 360° | +| `SkyObject.TexVelocityX` | float | UV/second | r12 §2.1 "UV scroll rate — cloud drift, star twinkle" | ~0.001 typical | +| `SkyObject.TexVelocityY` | float | UV/second | same | ~0.001 | +| `SkyObject.DefaultGfxObjectId` | QualifiedDataId | dat-ID; 0=hidden | none | 0x01000XYZ typical | +| `SkyObject.DefaultPesObjectId` | QualifiedDataId | dat-ID; 0=none | r12 §2.1 "unused at top" | 0 | +| `SkyObject.Properties` | uint32 | flag bits | not decoded in references; our code ignores | 0 | +| `SkyTimeOfDay.Begin` | float | day-fraction [0,1] | our `SkyState.cs:45` "day-fraction this keyframe kicks in" | 0.0, 0.25, 0.5, 0.75 typical | +| `SkyTimeOfDay.DirBright` | float | multiplier, **> 1 legal** | our `SkyState.cs:40-42` "channels above 1.0 and the frag shader clamps" | 1.5 in test | +| `SkyTimeOfDay.DirHeading` | float | compass degrees, 0=N clockwise | r12 §3 (`deepdives/r12-weather-daynight.md:262`) | 180° at noon | +| `SkyTimeOfDay.DirPitch` | float | degrees, -90..+90 from horizontal | r12 §3 | 70° at noon | +| `SkyTimeOfDay.DirColor` | ColorARGB | 4×byte BGRA-packed in file | `ColorARGB.generated.cs:27-45` "0-255" | {R=255,G=250,B=220} at noon | +| `SkyTimeOfDay.AmbBright` | float | multiplier | none; same semantics as DirBright | 0.4 in test | +| `SkyTimeOfDay.AmbColor` | ColorARGB | 4×byte BGRA | same | noon ~(82,82,89) | +| `SkyTimeOfDay.MinWorldFog` | float | meters | r12 §5.1 (`deepdives/r12-weather-daynight.md:346`) | 120 | +| `SkyTimeOfDay.MaxWorldFog` | float | meters | r12 §5.1 | 350 | +| `SkyTimeOfDay.WorldFogColor` | ColorARGB | 4×byte BGRA | none | similar to horizon band | +| `SkyTimeOfDay.WorldFog` | uint32 | D3D fog mode: 0 off, 1 linear, 2 exp, 3 exp2 | r12 §5.1 "D3DFOG_LINEAR, etc" (line 349) | 1 (linear) | +| `SkyObjectReplace.ObjectIndex` | uint32 | index into DayGroup.SkyObjects | none | 0..6 | +| `SkyObjectReplace.GfxObjId` | QualifiedDataId | dat ID; 0 = keep default | r12 §2.3 "swap mesh" | 0x01000... or 0 | +| `SkyObjectReplace.Rotate` | float | **degrees** heading override | tested in `SkyDescLoader.cs:260-263`: "270° values in the data are genuinely heading-degrees, not percentages" | 270 observed | +| `SkyObjectReplace.Transparent` | float | **UNIT UNCONFIRMED** — see §4 | none | values up to 100 observed in acdream diag dump | +| `SkyObjectReplace.Luminosity` | float | **UNIT UNCONFIRMED** — see §4 | none | values up to 100 observed | +| `SkyObjectReplace.MaxBright` | float | **UNIT UNCONFIRMED** — see §4 | none | values up to 100 observed | + +Note on `ColorARGB`: the wire/byte order is B, G, R, A per `ColorARGB.generated.cs:49-52`; the member names are semantic (Red=red channel 0..255 regardless of file byte position). Our loader's `ColorToVec3` at `SkyDescLoader.cs:311-315` uses the semantic `.Red/.Green/.Blue / 255f` which is correct. + +--- + +## 3. SurfaceType Enum (full) + +From `references/DatReaderWriter/DatReaderWriter/Generated/Enums/SurfaceType.generated.cs:13-41` (cross-checked `dats.xml:744-758`). Flags, parent uint: + +| Name | Value | Notes / inferred meaning | +|---|---|---| +| `Base1Solid` | `0x00000001` | Surface is a flat color (no texture); `Surface.ColorValue` field populated | +| `Base1Image` | `0x00000002` | Surface has a texture (SurfaceTexture DataId) | +| `Base1ClipMap` | `0x00000004` | Alpha-keyed texture; fragment shader discards low-alpha pixels | +| `Translucent` | `0x00000010` | Implies standard alpha blending | +| `Diffuse` | `0x00000020` | Receives diffuse lighting (default state for most surfaces) | +| `Luminous` | `0x00000040` | Self-illuminated / unshaded — NOT additive blend; see §5 | +| `Alpha` | `0x00000100` | Explicit alpha blend | +| `InvAlpha` | `0x00000200` | Inverted alpha blend (rare) | +| `Additive` | `0x00010000` | Additive blend (sun, glow, particle) | +| `Detail` | `0x00020000` | Detail texture layer | +| `Gouraud` | `0x10000000` | Gouraud shading (vs. flat) | +| `Stippled` | `0x40000000` | Stipple pattern (AC-era hardware dithering) | +| `Perspective` | `0x80000000` | Perspective-correct texturing | + +Our `TranslucencyKind.FromSurfaceType` at `src/AcDream.Core/Meshing/TranslucencyKind.cs:61-74` maps these to blend modes: + +- `Additive` (0x10000) → `TranslucencyKind.Additive` (src,one blend) +- `InvAlpha` (0x200) → `TranslucencyKind.InvAlpha` +- `Alpha | Translucent` (0x100 | 0x10) → `TranslucencyKind.AlphaBlend` (src_alpha, one_minus_src_alpha) +- `Base1ClipMap` (0x04) → `TranslucencyKind.ClipMap` (opaque draw, fragment discard) +- else → `Opaque` + +`Luminous` (0x40) is **not** mapped to any translucency category — it's a lighting hint, not a blend mode. The code comment at `SkyRenderer.cs:345-350` documents a past bug where we treated Luminous as additive and "blew the whole sky to white." That call is consistent with ACE/ACViewer usage (Luminous sets `OrigLuminosity` on the `PhysicsPart.Surface`; see `references/ACViewer/ACViewer/Physics/Common/Surface.cs:15-19`). + +No generator comments in Chorizite.ACProtocol (it has no dat-surface types — it's network-message-only). No explicit "SrcBlend/DestBlend" comments anywhere in the references. + +--- + +## 4. acdream SkyDescLoader vs Dat Reality — What's Likely Right / Wrong + +### Likely correct + +- **`ColorARGB` → `Vector3/255f`** (`SkyDescLoader.cs:311-315`) — matches the generator comment "0-255" on each channel. +- **Sun/Amb color pre-multiplied by bright** (`SkyDescLoader.cs:289-291`) — matches r12 §4 formula (`deepdives/r12-weather-daynight.md:307-309`); test pins this (`SkyDescLoaderTests.cs:84`). +- **`WorldFog` enum mapping** (`SkyDescLoader.cs:278-284`) — matches r12 §5.1 (D3DFOG_LINEAR = 1, exp = 2, exp2 = 3). +- **Visibility / arc-progress wrap logic** (`SkyDescLoader.cs:46-76`) — matches WorldBuilder `SkyboxRenderManager.cs:188-240` line-by-line. +- **`Rotate` kept as degrees** (`SkyDescLoader.cs:260`) — confirmed by our own observation that diag values of 270 are headings. + +### Likely WRONG: the `/100` on Transparent / Luminosity / MaxBright + +**This is the load-bearing speculative decision.** + +Code: `SkyDescLoader.cs:273-275` divides all three by 100. The comment at 249-267 claims "confirmed from live diag dump … have Luminosity=100 and Transparent=100" — but there is NO supporting evidence anywhere else in the references: + +1. **DatReaderWriter generator has no comment** on those three fields (`SkyObjectReplace.generated.cs:28-34`). Unlike `Surface.Luminosity` which is documented as "Self-illumination / emissive strength" and used as a **0..1 fraction** in the test fixture (`SurfaceTests.cs:27`, value `0.3f`), the `SkyObjectReplace` fields have no generator comment. +2. **ACE's `SkyObjectReplace.cs:10-12`** defines them as `float` with no unit comment. +3. **ACViewer** just displays them raw as `"Transparent: {value}"` without transform (`references/ACViewer/ACViewer/Entity/SkyObjectReplace.cs:35-48`). +4. **WorldBuilder doesn't use them at all** — `SkyboxRenderManager.cs:180-264` ignores `Transparent/Luminosity/MaxBright` entirely. So WorldBuilder gives us zero evidence either way about units. +5. **No decompile data**: `docs/research/decompiled/chunk_*.c` has no matches for sky/SkyObject/Luminosity (confirmed in the prior audit `2026-04-21-sky-deep-audit.md:9-15`). +6. **Our existing Surface.Luminosity uses a fraction** (test shows 0.3f), so there's a strong cross-field analogy that `SkyObjectReplace.Luminosity` is also a fraction, not a percentage. +7. **Our own commit `eeae83a` added the /100 based on a live diag dump** — but we don't have the raw dump file in `docs/research/` and the prior audit `2026-04-22-sky-lighting-decompile.md:130-135` observed "Diag shows luminosity values max at 0.78 (not > 1)" — which is already in the 0..1 range **before** any /100 transform. + +**Status: SPECULATIVE.** Two conflicting claims in our own codebase: +- `SkyDescLoader.cs:253-257`: "noon keyframes have Luminosity=100 and Transparent=100" (said to justify /100). +- Prior audit `2026-04-22-sky-lighting-decompile.md:143`: "luminosity values max at 0.78". + +These cannot both be true for the same dat. At least one of those observation logs is incorrect — or they were looking at different fields. + +**Recommendation for the next decompile pass:** capture a fresh raw-bytes dump of one DayGroup's SkyTimeOfDay.SkyObjReplace list from the live Dereth dat (region 0x13000000), print as hex + parsed float, and pin the unit empirically. Until then, treat the `/100` as "best guess based on one diag observation" rather than a confirmed fact. + +### Fields we IGNORE that the dat has + +- `SkyObject.Properties` (uint32 flag bits) — we read it into `SkyObjectData.Properties` but never consult it. Retail-faithful rendering probably reads one of these bits for billboard-vs-mesh orientation. +- `SkyObject.DefaultPesObjectId` — we never load the PhysicsScript; retail probably attaches it as a rain/snow particle emitter (r12 §6.1, `deepdives/r12-weather-daynight.md:423-426`). +- `SkyTimeOfDay.WorldFog` (currently mapped to `FogMode` but unused past loading — we don't switch between linear/exp/exp2 per keyframe in our shader). +- `SkyDesc.TickSize` / `LightTickSize` — loaded but not consulted; retail uses these for the lighting-update quantisation rate. + +--- + +## 5. The 7 Sky Objects (Dereth DayGroup) + +The `SkyDescLoader.cs` code comment asserts there are 7 sky objects per DayGroup. r12 deepdive (`deepdives/r12-weather-daynight.md:235-237`) says "roughly 4–6 sky objects (one background, one cloud sheet, one sun, one moon, one star sheet)". + +### Without a fresh diag dump, we cannot positively assign GfxObjId → role. + +What we can say from the primary-day-group pattern and the pipeline: + +| Index | Expected role | BeginTime/EndTime pattern | Mesh shape | Blend | +|---|---|---|---|---| +| 0 | Sky dome / gradient background | Begin==End (always visible) | Large hemisphere with baked gradient in V | AlphaBlend or Opaque | +| 1 | Cloud layer (upper) | Begin==End (always) | Large hemisphere with cloud texture, UV-scrolled | AlphaBlend | +| 2 | Cloud layer (lower) | Begin==End (always) | Same shape, different texture | AlphaBlend | +| 3 | Sun | BeginTime ≈ 0.25, EndTime ≈ 0.75 (day) | Small billboard quad with bright body | Additive | +| 4 | Moon | Wraps midnight (Begin > End) | Small billboard quad | Additive | +| 5 | Star dome | Wraps midnight (Begin > End) | Large hemisphere with star texture | Additive | +| 6 | Secondary moon or decoration | varies | billboard | Additive or AlphaBlend | + +**This is the expected pattern from r12, NOT a verified per-index dump.** Verifying requires running the client with `ACDREAM_DUMP_SKY=1` or equivalent and logging every SkyObject. + +**Action to confirm in a future pass**: write a one-shot CLI or log-dump in `SkyDescLoader.LoadFromRegion` that prints `[i] GfxObjId=0x… BeginTime=… EndTime=… BeginAngle=… EndAngle=… Properties=…` for every sky object in each DayGroup, then capture the output. File it under `docs/research/` as the "Dereth sky object table" so future work has ground truth. + +### What the prior audit found but was lost to memory + +`2026-04-21-sky-deep-audit.md:128-131` describes a white-sky bug and documents 7 objects but does NOT list their GfxObjIds. The comment at `SkyRenderer.cs:214-218` captures typical ambient colors per keyframe (noon, dusk, midnight, dawn) but says nothing specific about which GfxObj is which role. + +--- + +## 6. Open Questions / Verify Next + +1. **Unit of Transparent/Luminosity/MaxBright on SkyObjectReplace.** Strongest evidence (cross-reference to Surface.Luminosity and ACE/ACViewer silence) suggests it's a 0..1 fraction and our `/100` is wrong. Verify with fresh raw-bytes dump. +2. **What is `SkyObject.Properties`?** Retail probably reads a "billboard" bit here. Decompile needed. +3. **Per-SkyObject blend mode — do we classify it correctly?** Current code uses `sm.Translucency == TranslucencyKind.Additive` but only reads the `Additive` (0x10000) flag. If the dat ALSO uses `Luminous` as an additive cue in conjunction with `Alpha` (a combined "luminous alpha mesh"), we'd miss it. Needs a dat dump of every SkyObject → GfxObj → Surface.Type for the 7 Dereth sky objects. +4. **`SkyDesc.TickSize` units.** r12 says day length is 7620 game-ticks. If `TickSize` is seconds-per-tick, it gives total day seconds. Need to verify by reading the value out of the live dat. +5. **`WorldFog` mode — linear only, or do keyframes actually switch to exp?** Our FogMode mapping accepts 1/2/3 but the shader only implements linear. If any retail keyframe uses 2 or 3, we render wrong fog falloff. + +--- + +## 7. References + +- DatReaderWriter sky types: `references/DatReaderWriter/DatReaderWriter/Generated/Types/{SkyDesc,SkyObject,SkyTimeOfDay,SkyObjectReplace,DayGroup}.generated.cs` +- DatReaderWriter Region: `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Region.generated.cs:27-120` +- DatReaderWriter Surface: `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Surface.generated.cs:27-101` +- DatReaderWriter SurfaceType: `references/DatReaderWriter/DatReaderWriter/Generated/Enums/SurfaceType.generated.cs:13-41` +- DatReaderWriter Polygon: `references/DatReaderWriter/DatReaderWriter/Generated/Types/Polygon.generated.cs:23-87` +- DatReaderWriter ColorARGB: `references/DatReaderWriter/DatReaderWriter/Generated/Types/ColorARGB.generated.cs:22-65` +- DatReaderWriter Surface test fixture: `references/DatReaderWriter/DatReaderWriter.Tests/DBObjs/SurfaceTests.cs:21-45` +- ACE SkyObjectReplace: `references/ACE/Source/ACE.DatLoader/Entity/SkyObjectReplace.cs:5-24` +- ACE Physics Surface: `references/ACE/Source/ACE.Server/Physics/Common/Surface.cs:5-35` +- ACViewer SkyObjectReplace tree: `references/ACViewer/ACViewer/Entity/SkyObjectReplace.cs:14-52` +- WorldBuilder SkyboxRenderManager: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:180-274` +- acdream loader: `src/AcDream.Core/World/SkyDescLoader.cs` +- acdream renderer: `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` +- acdream translucency classifier: `src/AcDream.Core/Meshing/TranslucencyKind.cs:59-74` +- r12 deep dive: `docs/research/deepdives/r12-weather-daynight.md:135-240, 290-330, 340-400, 430-475` +- Prior audits: `docs/research/2026-04-21-sky-deep-audit.md`, `docs/research/2026-04-22-sky-lighting-decompile.md` +- dats.xml (protocol definition): `references/DatReaderWriter/DatReaderWriter/dats.xml:744-758` diff --git a/docs/research/2026-04-23-sky-decompile-hunt-A.md b/docs/research/2026-04-23-sky-decompile-hunt-A.md new file mode 100644 index 0000000..a4853ec --- /dev/null +++ b/docs/research/2026-04-23-sky-decompile-hunt-A.md @@ -0,0 +1,474 @@ +# Sky Decompile Hunt — Agent A + +**Date:** 2026-04-23 +**Status:** SUCCESSFUL HUNT. Found the sky render + unpack code that prior audits (`2026-04-21-sky-deep-audit.md`, `2026-04-22-sky-lighting-decompile.md`) declared missing. + +**Entry point insight:** The Ghidra strip removed all class names. Prior audits searched for `sky`/`SkyDesc`/`SkyObject` strings and found nothing. The trail that worked: start from the `Region` dat-ID registration (`chunk_00410000.c:12952`, factory-name slot `DAT_00796a6c` → registry `DAT_0079653c`, ID range `0x13000000..0x1300FFFF`), find the Region loader entry (`FUN_00415730(..., 0x1c)` call, type-index 0x1c = REGION per the `"REGION"` string lookup at `chunk_00410000.c:11795`), then follow the global region pointer (`DAT_0084247c`) outward. Every hit is in `chunk_00500000.c` (0x00501530 – 0x00508010 range). + +--- + +## 1. Function Map + +All citations are `chunk_00500000.c` unless noted. + +| FUN | Line | Signature (inferred class method) | Purpose | +|-----|------|------------------|---------| +| `FUN_004ff370` | chunk_004F0000.c:10610 | `Region::Load(uint dataId)` | Calls `FUN_00415730(param_1, 0xb, 0x1c)` to fetch Region from dat; stores in global `DAT_0084247c`. | +| `FUN_004ff3b0` | chunk_004F0000.c:10630 | `Region::Unload()` | Releases current Region. | +| `FUN_004ff420` | chunk_004F0000.c:10680 | `Region::UpdateSkyObjectsTrampoline()` | Delegates to `FUN_00501990` when Region and SkyDesc both non-null. | +| `FUN_004ff440` | chunk_004F0000.c:10692 | `Region::LerpSunAndAmbient(t,...)` | Guards and delegates to `FUN_00501600`. | +| `FUN_004ff480` | chunk_004F0000.c:10708 | `Region::LerpFogAmbient(t,...)` | Guards and delegates to `FUN_00501860`. | +| `FUN_004ff4b0` | chunk_004F0000.c:10724 | `Region::UpdateSkyObjectTable(t, table*)` | Guards and delegates to `FUN_00502a10`. | +| **`FUN_00501530`** | **:1097** | `SkyTimeOfDayList::FindBracket(t, k1*, k2*, u*)` | Walks the keyframe array. For `uVar3 < count-1` picks k1=arr[uVar3], k2=arr[uVar3+1]; if at the last slot, wraps to arr[0] and uses a 1.0 denominator. Returns `u = (t - k1.Begin) / (k2.Begin - k1.Begin)`. **This is the two-keyframe bracket picker.** | +| **`FUN_00501600`** | **:1155** | `Region::InterpolateSunLight(t, outDirBright*, outDirColor*, outSunVec*, outAmbColor*)` | The **sun/ambient keyframe interpolator** and spherical-to-cartesian sun-direction builder. See §5. | +| **`FUN_00501860`** | **:1238** | `Region::InterpolateFog(t, outMinFog*, outMaxFog*, outFogColor*)` | Lerps fog min/max and fog color between two SkyTimeOfDay keyframes. | +| `FUN_00501990` | :1276 | `SkyObjectTable::RollPhysicsScriptFrame(table*)` | Uses a global deterministic hash (from `DAT_008ee9c8`, the player-weenie?) to update an integer at `table[0]` from `table[8]` (count of physics scripts). Likely rolls which variant PES to show. | +| `FUN_00501b20` | :1384 | `SkyObject::Unpack(stream*, size*)` | Reads 6 floats (`+4,+8,+0xc,+0x10,+0x14,+0x18`) then 3 uints (`+0x24,+0x28,+0x20`). See §4 offsets. | +| `FUN_00501cd0` | :1500 | `SkyObjectReplace::Unpack(stream*, size*)` | Reads 6 dwords at struct indices 0,2,3,4,5,6 (skips index 1 which is runtime pointer). | +| `FUN_00501de0` | :1577 | `SkyTimeOfDay::Pack(stream*, size*)` | Writes 11 floats+uint and the SkyObjReplace list (mirror of `FUN_00502100`). | +| `FUN_00501f50` | :1669 | `Region::PreloadSkyAssets(region*)` | Iterates every SkyObject and SkyObjectReplace, issues `FUN_0041a4e0(type=8, id)` for every GfxObjId — **this is the sky-mesh preloader**. | +| **`FUN_00502100`** | **:1784** | `SkyTimeOfDay::Unpack(stream*, size*)` | Reads 11 dwords into struct indices 0,1,2,3,4,5,6,8,9,10,**7** (index 7 last — the WorldFog enum) and then the SkyObjectReplace list. See §4. | +| **`FUN_005025c0`** | **:2124** | `DayGroup::Unpack(stream*, size*)` | Reads `ChanceOfOccur` + PString `DayName` + SkyObject list + SkyTime list. | +| **`FUN_00502820`** | **:2279** | `SkyDesc::Unpack(stream*, size*)` | **THE CORE UNPACK.** Reads 2 doubles (TickSize `+8`, LightTickSize `+0x10`) + DayGroup list. See §5. | +| **`FUN_00502a10`** | **:2407** | `Region::BuildPerFrameSkyObjectTable(table*, t, weatherChance)` | Iterates DayGroup.SkyObjects, applies visibility check (`Begin==End OR Begin 0". + +### Per-Frame SkyObject Render Entry (0x2c bytes, table produced by `FUN_00502a10`): + +| Offset | Field | +|---|---| +| +0x00 | GfxObjId (from Default or overridden SkyObjectReplace.GfxObjId) | +| +0x04 | PesObjectId (DefaultPesObjectId) | +| +0x08 | **[zeroed]** or reset marker | +| +0x0c | **Current arc angle (float, degrees)** = BeginAngle + (EndAngle - BeginAngle) * u | +| +0x10, +0x14, +0x18 | TexVelocityX, TexVelocityY, (runtime slot) | +| +0x1c | Transparent (from SkyObjectReplace.Transparent if > 0) | +| +0x20 | Luminosity (from SkyObjectReplace.Luminosity if > 0) | +| +0x24 | MaxBright (from SkyObjectReplace.MaxBright if > 0) | +| +0x28 | Properties (from SkyObject.Properties) | + +--- + +## 3. Globals Inventory + +| Global | Use | Confidence | +|---|---|---| +| `DAT_0084247c` | Current `Region*` (set by `FUN_004ff370`, cleared by `FUN_004ff3b0`) | **HIGH** — guarded by all sky accessors (:10683,:10699,:10715,:10731) | +| `DAT_00842798` | Next frame-tick timestamp (next time FUN_00505f30 fires) | HIGH — :6241 `= TickSize + now` | +| `_DAT_008379a8` | Current game time (double) — "now" | HIGH — used as current time everywhere | +| `_DAT_008427a0` | Next light-tick timestamp (LightTickSize cadence) | HIGH — :6289 | +| `_DAT_00842780` | Current DirBright (float) | HIGH — :6041 | +| `_DAT_0084277c` | Current DirColor (packed BGRA uint) | HIGH — :6042 | +| `_DAT_00842950/4/8` | Current sun vector (x,y,z floats) | HIGH — :6044–6046 | +| `_DAT_00842778` | Current AmbColor (packed BGRA uint) | HIGH — :6047 | +| `_DAT_008682b0/b4/b8` | D3D directional-light COLOR (R,G,B floats = sun color × brightness) | HIGH — :2084–2086 in FUN_004530e0 | +| `_DAT_008682bc/c0/c4` | D3D directional-light DIRECTION (x,y,z floats) | HIGH — :6062–6064 writes sun vector to these | +| `_DAT_008682c8/cc/d0` | Second copy of sun vector (likely view-space transform target, or "to-sun" vector) | MEDIUM — :6058–6060 | +| `_DAT_0079c6b0` | Degrees-to-radians constant (π/180) | HIGH — used in both `FUN_00501600` (sun direction) and `FUN_005079e0` (mesh rotation) | +| `_DAT_00799208` | 1/255.0f constant (byte → float color) | HIGH — :618 in FUN_00451a60 | +| `_DAT_007a1870` | Scale for Transparent/Luminosity/MaxBright when overridden | MEDIUM — :7588,7591,7594 | +| `DAT_00796344` | Sentinel `0.0f` (likely) — "don't override" marker | MEDIUM — compared against T/L/MB overrides in FUN_00502a10 and FUN_00508010 | +| `DAT_008ee9c8` | PlayerWeenie pointer (used to drive PES roll) | MEDIUM | +| `DAT_0079653c` | Region dat registry entry (type-index 0x1c) | HIGH — :12964 | +| `DAT_00796a6c` | Literal string "REGION" or its dat-type-name slot | HIGH — :12952 | + +--- + +## 4. Call Graph + +``` + Region::FrameTick (FUN_005062e0) [per-frame] + ├── Region::RenderSkyObjects (FUN_00508010) [always] + │ ├── Region::UpdateSkyObjectTable (FUN_004ff4b0) + │ │ └── Region::BuildPerFrameSkyObjectTable (FUN_00502a10) + │ │ └── SkyTimeOfDayList::FindBracket (FUN_00501530) + │ ├── per-object: SkyObjectTransform::ApplyRotations (FUN_005079e0) + │ │ ├── FUN_00536b80(angleA) // rotate axis 1 + │ │ └── FUN_005364e0({0,-angleB*π/180,0}) // rotate axis 2 + │ ├── per-object: FUN_00514b90(xform) // enqueue mesh draw + │ ├── per-object: FUN_00512360 (MaxBright override) + │ ├── per-object: FUN_005124b0 (Luminosity override) + │ └── per-object: FUN_005120c0 (Transparency override) + │ + └── (every TickSize s) Region::ApplyLightState (FUN_00505f30) + ├── Region::LerpSunAndAmbient (FUN_004ff440 → FUN_00501600) + │ └── SkyTimeOfDayList::FindBracket (FUN_00501530) + ├── FUN_00451a60 (packed color → float[3]) + ├── FUN_004530e0 (write DirColor × DirBright to _DAT_008682b0..b8) + └── loop landblock grid → FUN_00532440 (AdjustPlanes per-cell lighting) + + Region::Load (FUN_004ff370) [on entering world] + └── FUN_00415730(dataId, 0xb, 0x1c) // dat loader + + SkyDesc::Unpack (FUN_00502820) [called by dat loader] + ├── read TickSize (double) + ├── read LightTickSize (double) + ├── PString (skip? no — seems to skip at line 2308 FUN_00500610) + └── DayGroup list: + FUN_005025c0 → DayGroup::Unpack + ├── read ChanceOfOccur (float) + ├── FUN_004fd460 → PString DayName + ├── SkyObject list (0x2c-byte each): + │ FUN_00501b20 → SkyObject::Unpack + └── SkyTime list (0x38-byte each): + FUN_00502100 → SkyTimeOfDay::Unpack + └── SkyObjectReplace list (0x1c-byte each): + FUN_00501cd0 → SkyObjectReplace::Unpack +``` + +--- + +## 5. Quoted Decompile — Key Functions + +### 5.1 Keyframe bracket picker (`FUN_00501530`) + +```c +undefined4 FUN_00501530(int param_1, float param_2, undefined4 *param_3, + undefined4 *param_4, float *param_5) { + iVar1 = *(int *)(param_1 + 0x10); // count + if (iVar1 == 0) return 0; + uVar3 = 0; + if (iVar1 != 1) { + puVar4 = *(undefined4 **)(param_1 + 8); // array ptr + do { + puVar4 = puVar4 + 1; + if (param_2 < *(float *)*puVar4) break; // found k1 such that next.Begin > t + uVar3 = uVar3 + 1; + } while (uVar3 < iVar1 - 1U); + } + *param_3 = *(undefined4 *)(*(int *)(param_1 + 8) + uVar3 * 4); // k1 + if (uVar3 == iVar1 - 1U) { + *param_4 = **(undefined4 **)(param_1 + 8); // k2 = first (wrap) + *param_5 = (param_2 - *(float *)*param_3) / (_DAT_007938b0 - *(float *)*param_3); + return 1; + } + pfVar2 = (float *)(*(undefined4 **)(param_1 + 8))[uVar3 + 1]; + *param_4 = pfVar2; // k2 + *param_5 = (param_2 - *(float *)*param_3) / (*pfVar2 - *(float *)*param_3); + return 1; +} +``` + +Notes: `_DAT_007938b0` is `1.0f` (day-fraction upper bound). Walks while `t >= arr[idx+1].Begin`. When at last keyframe, wraps to arr[0].Begin but with normalizer `1.0` instead of `(arr[0].Begin - arr[last].Begin)`. + +### 5.2 Sun + ambient color interpolation (`FUN_00501600`) + +```c +void FUN_00501600(float t, float *outDirBright, undefined1 *outDirColor, + float *outSunVec, undefined1 *outAmbColor) { + iVar5 = FUN_00501530(region + 0x50 + 0x..., &local_14 /*k1*/, &local_10 /*k2*/, &t /*u*/); + if (iVar5 != 0) { + // DirBright lerp: k1+0x14 → k2+0x14 ... WAIT, this is on a SkyTimeOfDay which + // starts at +0x00 = Begin. So the keyframe struct offsets here are OFFSET -0 from + // the keyframe start. Actually this function uses +0x14 directly which would be + // DirHeading in our struct mapping — BUT the two SkyTime structs in FUN_00501530 + // are identified via their Begin at offset +0. And here FUN_00501600 indexes + // from the same base. + *outDirBright = (*(float *)(k2 + 0x14) - *(float *)(k1 + 0x14)) * u + + *(float *)(k1 + 0x14); + // Wait — this is struct offset +0x14 = DirBright per our map. The field WAS read + // FIRST into SkyTimeOfDay at +0x04, but FUN_00501530 may pass a DIFFERENT struct + // (a SkyLightKeyframe, NOT the SkyTimeOfDay). The offsets +4,+8,+0xc,+0x14,+0x18 + // suggest this keyframe record reflects a DIFFERENT internal layout than the + // SkyTimeOfDay we parse. *** see Gap §7 *** + + // DirColor unpack (bytes at k2+0x18..0x1a) + param_3[0] = byte@(k2 + 0x1a); // R? (per our map 0x1a is not set) + param_3[1] = byte@(k2 + 0x19); + param_3[2] = byte@(k2 + 0x18); + param_3[3] = 0xff; + + // fVar9 = scale = lerp(k1+4, k2+4, u) // radius? + // fVar1 = pitch = lerp(k1+0xc, k2+0xc, u) * DEG_TO_RAD + // fVar6 = head = lerp(k1+8, k2+8, u) * DEG_TO_RAD + fVar7 = fcos(fVar1); // cos(pitch) + fVar8 = fsin(fVar6); // sin(heading) + local_c = fVar9 * fVar8 * fVar7; // X = R * sin(head)*cos(pitch) + fVar6 = fcos(fVar6); // cos(heading) + *outSunVec = local_c; + local_8 = fVar9 * fVar6 * fVar7; // Y = R * cos(head)*cos(pitch) + fVar6 = fsin(fVar1); // sin(pitch) + outSunVec[1] = local_8; + local_4 = fVar6 * fVar9; // Z = R * sin(pitch) + outSunVec[2] = local_4; + + // AmbColor bytes at k2+0x10..0x12 (our map: AmbColor at +0x18... DISCREPANCY) + outAmbColor[1] = byte@(k2 + 0x11); + outAmbColor[2] = byte@(k2 + 0x12); + *outAmbColor = byte@(k2 + 0x10); + outAmbColor[3] = 0xff; + } + // else fallback: brightness 0.3, white dir/amb, sun=(0.5,0,0.8) +} +``` + +**NOTE: THE FIELD OFFSETS HERE CONTRADICT OUR SkyTimeOfDay STRUCT MAP.** See §7 Gaps. + +### 5.3 Sky mesh renderer (`FUN_00508010`) + +```c +void FUN_00508010(int *param_1) { // param_1 = sky-object table + FUN_004ff420(); // ensure SkyDesc loaded + fVar1 = (DAT_008ee9c8 == 0) ? _DAT_00795610 : *(float *)(DAT_008ee9c8 + 0x48); + iVar6 = FUN_004ff4b0(fVar1, param_1); // refresh per-frame table + if (iVar6 != 0) { + FUN_00507e20(); + uVar2 = param_1[2]; + uVar7 = 0; + if (uVar2 != 0) { + iVar6 = 0; + do { + if (*(int *)(param_1[3] + uVar7 * 4) != 0) { // slot has a GfxObjId? + uVar3 = *(undefined4 *)(iVar6 + 8 + *param_1); // (unknown field at +8) + uVar4 = *(undefined4 *)(iVar6 + *param_1 + 0xc); // arc angle at +0xc + local_48 = 0x3f800000; local_44 = 0; local_40 = 0; local_3c = 0; + local_14 = 0; local_10 = 0; local_c = 0; + FUN_00535b30(); // reset current transform (possibly sets identity) + if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { + iVar5 = *(int *)param_1[3]; + local_14 = *(undefined4 *)(iVar5 + 0x84); // custom position override? + local_10 = *(undefined4 *)(iVar5 + 0x88); + local_c = *(undefined4 *)(iVar5 + 0x8c); + } + FUN_005079e0(&local_48, uVar3, uVar4); // apply rotations (axis1=uVar3, axis2=uVar4) + FUN_00514b90(&local_48); // queue mesh draw + if (DAT_00796344 < *(float *)(iVar6 + 0x20 + *param_1)) // Luminosity override + FUN_00512360(0, *(float *)(iVar6 + 0x20 + *param_1) * _DAT_007a1870, 0, 0); + if (DAT_00796344 < *(float *)(iVar6 + 0x24 + *param_1)) // MaxBright override + FUN_005124b0(0, *(float *)(iVar6 + 0x24 + *param_1) * _DAT_007a1870, 0, 0); + if (DAT_00796344 <= *(float *)(iVar6 + 0x1c + *param_1)) // Transparent override + FUN_005120c0( *(float *)(iVar6 + 0x1c + *param_1) * _DAT_007a1870, 0, 0); + } + uVar7 = uVar7 + 1; + iVar6 = iVar6 + 0x2c; + } while (uVar7 < uVar2); + } + } +} +``` + +### 5.4 Region frame tick + D3D light setup (`FUN_005062e0` + `FUN_00505f30`) + +Key excerpt from `FUN_005062e0` at :6235–6290: + +```c +if (*(int *)(skyTable + 0x10) != 0) { + if (*(int *)(skyTable + 0x20) != 0) + FUN_00508010(skyTable); // draw sky every frame + local_14 = _DAT_008379a8; // now + if ((_DAT_00842798 <= _DAT_008379a8) && (DAT_0084247c != 0)) { + // next TickSize-delay firing time + _DAT_00842798 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 8) + _DAT_008379a8; + // acquire current scene-light time "fVar1" from player weenie or default + fVar1 = (DAT_008ee9c8 == 0) ? _DAT_00795610 : *(float *)(DAT_008ee9c8 + 0x48); + if (_DAT_008427a0 < _DAT_008379a8) { + iVar7 = FUN_004ff440(fVar1, &local_24 /*DirBright*/, &local_20 /*DirColor*/, + local_c /*sunVec[3]*/, &local_18 /*AmbColor*/); + if (iVar7 != 0) { + if (local_24 < DAT_0084295c) local_24 = DAT_0084295c; // clamp min brightness + // Optional: crossfade to a custom state (DAT_008427a9 flag) + if (DAT_008427a9 != '\0') { … blend to DAT_008427ac/0xb0/0xb4 over _DAT_008427b8 … } + FUN_00505f30(local_24, local_20, local_c, local_18); // push to D3D + AdjustPlanes + } + _DAT_008427a0 = _DAT_007c7200; + if (DAT_0084247c != 0) + _DAT_008427a0 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10); // LightTickSize + _DAT_008427a0 = _DAT_008427a0 + local_14; + } + } + } +``` + +### 5.5 Light state publisher (`FUN_00505f30`) + +```c +void FUN_00505f30(int landblockGrid, float DirBright, uint DirColor, + float *sunVec, uint AmbColor) { + DAT_00842780 = DirBright; + DAT_0084277c = DirColor; + if (DAT_008427a8 == '\0') { + DAT_00842950 = sunVec[0]; + DAT_00842954 = sunVec[1]; + DAT_00842958 = sunVec[2]; + DAT_00842778 = AmbColor; + } else { + // special-case crossfade … + } + _DAT_008682c8 = DAT_00842950; // sun vec copy 1 (to-sun?) + _DAT_008682d0 = DAT_00842958; + _DAT_008682cc = DAT_00842954; + FUN_00451a60(DAT_00842778); // unpack AmbColor bytes → _DAT_008682bc/c0/c4 (scratch) + _DAT_008682c0 = fVar2; // sun vec copy 2 (forward?) + _DAT_008682bc = fVar1; + _DAT_008682c4 = fVar3; + DAT_008682d4 = 0; + if (DAT_0083da58 != 0) + FUN_004530e0(|sun| * _DAT_0079a1e8 + DirBright, DirColor); // D3D SetLight + // Recompute per-cell lighting + iVar4 = *(int *)(landblockGrid + 4); // grid width + for each (x,y): if (cell[y*w+x] != 0) FUN_00532440(cell); +} +``` + +--- + +## 6. D3D calls — what retail actually does + +The decompile shows **no direct `SetRenderState` / `SetMaterial` / `SetLight` constant numbers** (no `14`, no `0x20`, etc.). Retail uses a C++ wrapper where the D3D device is instance-pointer-based (probably at `_DAT_008682xx` family of globals). The actual D3D bridge is elsewhere — a per-frame "render" sweep reads `_DAT_008682b0..d4` and feeds them to whatever lighting model D3D is set to. + +What we CAN say with high confidence: +- Sky meshes are drawn by `FUN_00514b90` with a per-object transform. This enqueues a mesh draw in the normal render queue. +- The directional light (sun) color is `DirColor × DirBright` and gets written to `_DAT_008682b0..b8` — these globals feed the D3D directional light. +- The ambient color is `AmbColor` (packed uint), but **NOT** scaled by `AmbBright` at this location. The AmbBright-multiply must happen somewhere downstream (maybe inside the D3D bridge's SetAmbient, or baked into material setup). +- Per-cell landblock lighting is recomputed AT THIS MOMENT (FUN_00532440 AdjustPlanes), so terrain + statics re-light each time the sky keyframe ticks. + +**No matrix-identity-with-zero-translation pattern** was found for a camera-anchored sky. Retail appears to render sky objects in world-space with a per-frame transform built from `FUN_00535b30` (reset transform) + `FUN_005079e0` (apply rotations). That's consistent with r12 deepdive description of sky meshes being camera-follow via the transform system, not a traditional infinite-far skybox. + +--- + +## 7. Gaps / What I could NOT resolve + +1. **Keyframe struct offsets in `FUN_00501600` disagree with `SkyTimeOfDay` struct as parsed by `FUN_00502100`.** The unpack writes DirBright at +0x04, DirHeading at +0x08, DirPitch at +0x0c, DirColor at +0x10, AmbBright at +0x14, AmbColor at +0x18. But the interpolator reads DirBright at +0x14, ambient bytes at +0x10, and uses +0x18..+0x1a for the "sun color" bytes. These offsets map better to a **DIFFERENT keyframe layout** — possibly the code passes a keyframe-sublist or a different struct (e.g. the 0x20-byte scaled-color record). **Next hunt:** locate what `region + 0x50 + X` pointer is passed to `FUN_00501530` inside `FUN_00501600`. The `DayGroup` struct likely has a light-keyframe sublist separate from the time-of-day list. Worth checking: is there a SEPARATE light-keyframe list at DayGroup +0x20 that FUN_00501600 uses? + +2. **Two-axis rotation semantics in `FUN_005079e0`.** FUN_00536b80 is "axis 1" and FUN_005364e0 rotates around axis 2 with `{0, -angleB*DEG_TO_RAD, 0}` (Z-axis or Y-axis depending on internal convention). Need to disassemble those two to confirm. + +3. **"Activity flag" at param_1[6] bit 0x04 inside `FUN_00508010`.** Some sky objects apparently get a different translation from +0x84/+0x88/+0x8c of the first slot (camera origin?). Not yet decoded. + +4. **`DAT_00796344` sentinel value.** I assumed `0.0f` but didn't confirm by grep. If it's `-1.0f` that changes the "only-if-positive" interpretation of override fields. + +5. **Unit of Transparent/Luminosity/MaxBright.** Retail multiplies the 3 values by `_DAT_007a1870` before applying. If that constant is `0.01f`, the raw dat values ARE percentages and our `/100` fix is retail-correct. If it's `1.0f`, the raw values are already fractions and the recent commit `eeae83a` is wrong. **Next hunt:** a 4-byte grep for `_DAT_007a1870` to find its initializer. One way: find where `_DAT_007a1870` is written, look at any `0x3c23d70a` (=0.01f as IEEE float) or `0x3f800000` (=1.0f) constant in the same function. + +6. **AmbBright handling.** FUN_00505f30 doesn't visibly scale AmbColor by AmbBright. The bright-multiply either happens inside `FUN_00451a60`'s caller chain or it's a different globally-set D3D register. Our acdream code pre-multiplies AmbColor by AmbBright in the loader which might DOUBLE-apply if the D3D bridge also does it. + +7. **GfxObj preload list.** `FUN_00501f50` preloads every sky GfxObjId — that's the function acdream should call when entering a Region to prefetch all sky meshes. + +8. **Sky mesh vertex format / shader setup.** Not searched; `FUN_00514b90` is the entry to the rendering queue and is shared with all meshes. Need to disassemble to see if sky meshes get any distinguishing material flag. + +--- + +## 8. Actionable implications for acdream + +- **Port `FUN_00508010` as the sky draw loop.** Our WorldBuilder port is mostly right but: + - The current arc angle should come from a **per-frame recomputed table** (like FUN_00502a10), not recomputed every draw call. + - Transparent/Luminosity/MaxBright overrides are applied **ONLY IF > 0** (sentinel `DAT_00796344`). Our code should replicate this "don't override" gate. +- **Port `FUN_00501530` keyframe bracketing VERBATIM** — WorldBuilder's equivalent may not match the retail wrap-around (where k2 = arr[0] and denominator = 1.0 in the last-slot case). +- **Resolve the keyframe-struct mystery in FUN_00501600 before trusting the sun-direction math.** The offsets disagree with our dat-unpack struct. Something is reordering fields, and we need to know what before the ambient tint fix locks in. +- **The scale constant `_DAT_007a1870` is load-bearing for the T/L/MB units question.** Find its value BEFORE shipping any fix that divides by 100. +- **Retail does re-light EVERY cell (`FUN_00532440`) when the sky keyframe ticks.** This is the per-vertex lighting update that our port currently doesn't do — we set a uniform tint but don't recompute per-cell baked lighting. For faithful look, we need to re-run AdjustPlanes per-cell on each lighting tick. + +--- + +## 9. Summary + +Prior audits said "no retail sky code found." That was wrong — the code is all in `chunk_00500000.c` between 0x00501530 and 0x00508010 plus the global accessors in `chunk_004F0000.c:10610-10740`. The trail starts with `DAT_0084247c` (current Region global) and field `+0x50` (SkyDesc pointer). The trail to find it: search for the Region-dat-type-index (0x1c) as a parameter to the dat loader, not for sky strings. + +**Every question the prior audits left open can now be answered by reading the specific functions listed in §1.** The main remaining work is to disassemble the two axes in `FUN_005079e0` and resolve the keyframe-struct-offset discrepancy in `FUN_00501600`. diff --git a/docs/research/2026-04-23-sky-decompile-hunt-B.md b/docs/research/2026-04-23-sky-decompile-hunt-B.md new file mode 100644 index 0000000..890e743 --- /dev/null +++ b/docs/research/2026-04-23-sky-decompile-hunt-B.md @@ -0,0 +1,432 @@ +# Sky Decompile Hunt B — D3D Render-State Signature Trace + +**Date:** 2026-04-23 +**Hunter:** Hunt Agent B (render-state signatures) +**Status:** SIGNIFICANT FINDINGS — but NOT a "celestial-body iteration draw loop" + +## TL;DR + +The retail acclient does NOT appear to have a classical "sky dome + iterate +celestial meshes with per-mesh blend swaps" render function. Instead, sky +is implemented as: + +1. **A per-frame keyframe sampler** (`FUN_005062e0`) that interpolates the + current RegionDesc keyframe (sun angle, sun color, ambient color, fog + color/near/far) and stashes results into globals. +2. **Per-mesh ambient color** pushed via `_DAT_008682bc/c0/c4` (RGB floats, + no D3DRS_AMBIENT call). +3. **D3D fog state** (D3DRS_FOGCOLOR/FOGSTART/FOGEND) set via + `FUN_005a41b0` — this is the ONLY sky-keyframe → D3D state write path. +4. **A "pre-world" pass** (`FUN_00507a50(0)`) that disables Zwrite and + iterates weather volume objects (fog/rain/snow, NOT sun/moon/stars). + +No "celestial body draw loop" found. No D3DRS_AMBIENT call found. No +matrix-translation-zero (camera-anchor) found. No lightning RNG found. +No huge far-plane constant found at the sky call site. This strongly +suggests retail AC is far simpler than modern sky demos assume — the +sun/moon/stars are either baked into the RegionDesc scene geometry +(treated as regular objects that happen to be positioned far away) or +do not exist as discrete draw calls at all, with "sun color" being +expressed purely via per-vertex lighting on world meshes. + +## D3D Vtable Map + +All state writes go through `pIDirect3DDevice` at `(param_1 + 0x468)`, +vtable offset `0xe4` = `SetRenderState`, offset `0xd4` = `SetTextureStageState`, +offset `0xc4` = `SetMaterial`, offset `0x1c` (of a PARENT wrapper object at +`DAT_0086734c`) = `SetPerspective/SetFrustum`. + +Discovered D3D state wrappers in `chunk_005A0000.c`: + +| Wrapper | Addr | D3DRS | Meaning | Cache offset | +|---|---|---|---|---| +| FUN_005a3ba0 | 0x005A3BA0 | 0x1b=27 | ALPHABLENDENABLE | +0x475 | +| FUN_005a3be0 | 0x005A3BE0 | 0x0f=15 | ALPHATESTENABLE | +0x476 | +| FUN_005a3c20 | 0x005A3C20 | 0x19=25 | ALPHAFUNC | +0x478 | +| FUN_005a3c60 | 0x005A3C60 | 0x18=24 | ALPHAREF | +0x47c | +| FUN_005a3ca0 | 0x005A3CA0 | 0x13=19 src, 0x14=20 dst, 0xab=171 op | blend state | +0x4f0/+0x49c | +| FUN_005a3d80 | 0x005A3D80 | 0x16=22 | CULLMODE | +0x488 | +| FUN_005a3dc0 | 0x005A3DC0 | 0xaf=175 | (fog-gated) | +0x48c | +| FUN_005a3e00 | 0x005A3E00 | 7 | ZENABLE | none (stateless) | +| FUN_005a3e20 | 0x005A3E20 | 0x17=23 (ZFUNC) + 0xe=14 (ZWRITE) | combined ZFunc+ZWrite | +0x494/+0x498 | +| FUN_005a3eb0 | 0x005A3EB0 | 0x8b=139 | **AMBIENT** | +0x4a0 | +| FUN_005a3ef0 | 0x005A3EF0 | 0x91=145 | COLORVERTEX | +0x4a8 | +| FUN_005a3f40 | 0x005A3F40 | 0x93=147 | ? | +0x4a4 | +| FUN_005a3f90 | 0x005A3F90 | 0x1c=28 | **FOGENABLE** | +0x4bc | +| FUN_005a4080 | 0x005A4080 | 0x22=34 (FOGCOLOR), 0x24=36 (FOGSTART), 0x25=37 (FOGEND) | fog triple | +0x4ac/+0x4c0/+0x4c4 | +| FUN_005a41b0 | 0x005A41B0 | wrapper → FUN_005a4080 | **SetFog(color, start, end)** | — | +| FUN_005a41f0 | 0x005A41F0 | 0x89=137 | **LIGHTING** | +0x4c8 | +| FUN_005a4280 | 0x005A4280 | 0x92=146 | LOCALVIEWER | +0x4d8 | +| FUN_005a4350 | 0x005A4350 | 0x3c=60 | ? | +0x4e0 | +| FUN_005a4390 | 0x005A4390 | 0x08 | SHADEMODE | +0x4e4 | +| FUN_005a4420 | 0x005A4420 | stages | SetTextureStageState blend args | — | +| FUN_005a4010 | 0x005A4010 | internal flag at +0x4be → re-calls FUN_005a3f90 | **fog-master-enable gate** | +0x4be | + +**Device-reset-to-default** function: `FUN_005a10f0` at 0x005A10F0, lines +687-740 of chunk_005A0000.c. This is NOT the sky render; it is called +once on device init and full-state rebuild. + +## AMBIENT (D3DRS=0x8b=139) — The Key Negative Finding + +I searched every call to vtable `0xe4` with state `0x8b` across the +entire 688K-line decompile. Results: + +- chunk_005A0000.c:3388 — `...0x8b,0` — inside default-init reset +- chunk_005A0000.c:2772 — `...0x8b,param_2` — inside wrapper FUN_005a3eb0 + +External callers of `FUN_005a3eb0`: + +``` +chunk_005A0000.c:704: FUN_005a3eb0(0); // default-reset, ambient=black +``` + +**ONLY ONE CALLER.** The sky keyframe sampler (`FUN_005062e0`) does NOT +call `FUN_005a3eb0`. D3DRS_AMBIENT is set to 0 once and never changed. +**This falsifies the previous research hypothesis** (2026-04-22 doc) that +clouds are tinted by a per-keyframe D3DRS_AMBIENT write. + +Sky color instead lives in `_DAT_008682bc/c0/c4` (three floats, RGB, set +from `DAT_00842950/54/58`). See below. + +## Sky Keyframe Sampler — `FUN_005062e0` @ 0x005062E0 + +chunk_00500000.c, lines 6213-6333. Called each frame from the scene +renderer (`FUN_00455a50` path, around chunk_00450000.c:4341, 4468). + +Stripped pseudocode: + +```c +void FUN_005062e0(int worldRenderer) { + if (worldRenderer.layerBits != 0 && worldRenderer.skyEnabled != 0) + { + FUN_00508010(); // advance sky timer + double now = _DAT_008379a8; // game-time seconds + if (_DAT_00842798 <= now && currentRegionDesc != 0) { + _DAT_00842798 = regionDesc[+0x50].nextKeyTime + now; // reschedule + + float turbidity = (weatherSys ? weatherSys[+0x48] : _DAT_00795610); + if (_DAT_008427a0 < now) { + // ---- BLOCK 1: sample ambient/sun ---- + int ok = FUN_004ff440(turbidity, + &fogDensity, // out float + &sunColor, // out ARGB + sunDirVec, // out float[3] + &ambientColor); // out ARGB + if (ok) { + if (fogDensity < DAT_0084295c) fogDensity = DAT_0084295c; + // ---- WEATHER FOG BLEND (if weather active) ---- + if (DAT_008427a9 /* weatherFogEnabled */) { + if (1.0f <= _DAT_008427b8 /* blendProgress */) { + // blend complete; snap to weather values + fogDensity = DAT_008427ac; // weather density + sunColor = DAT_00842788; // weather fog color + } else { + // lerp currentColor → weatherColor per channel (bytes), + // lerp density toward DAT_008427ac + // advance _DAT_008427b8 by _DAT_007c7208 (blend rate) + } + } + FUN_00505f30(fogDensity, sunColor, sunDirVec, ambientColor); + } + _DAT_008427a0 = now + (currentRegionDesc ? + regionDesc[+0x50].keyInterval : _DAT_007c7200); + } + + // ---- BLOCK 2: sample fog near/far/color ---- + FUN_005a4010(!DAT_0081dbf8); // disable fog if master fog flag clear + if (DAT_0081dbf8) { + FUN_005a3f90(DAT_0081dbf8); // FOGENABLE = true + int ok2 = FUN_004ff480(turbidity, + &fogNear, // out float + &fogFar, // out float + &fogColor);// out ARGB + if (ok2) { + if (DAT_008427a9 /* weatherFogEnabled */) { + // blend fogNear, fogFar, fogColor toward weather values + // identical lerp structure + } + FUN_005a41b0(&fogColor, fogNear, fogFar); + // → SetRenderState(FOGCOLOR, ...), (FOGSTART, ...), (FOGEND, ...) + } + } + } + } +} +``` + +This is the ONLY per-frame writer of fog D3D state. Called by +`FUN_00455a50` (scene entry) via `FUN_00506270` etc. + +## Block-1 Keyframe Layout — `FUN_00501600` @ 0x00501600 + +chunk_00500000.c, lines 1151-1232. Decoded keyframe struct: + +| Offset | Field | Type | Notes | +|---|---|---|---| +| +0x04 | SunMagnitude | float | lerp → param_4 (radius) | +| +0x08 | SunYAngle | float | degrees; `* (π/180)` gives radians | +| +0x0c | SunZAngle | float | degrees; `* (π/180)` gives radians | +| +0x10–0x12 | AmbientColor | byte[3] | R,G,B (packed via byte-shuffles) | +| +0x14 | FogDensity | float | lerp → param_2 | +| +0x18–0x1a | SunColor | byte[3] | R,G,B | + +From chunk_00500000.c:1190-1216: + +```c +// out sunDirVec (param_4): +fVar9 = lerp(nextKey[+0x04], prevKey[+0x04], param_1); // magnitude +float sinAngY = lerp(nextKey[+0x08], prevKey[+0x08]) * DEG2RAD; +float sinAngZ = lerp(nextKey[+0x0c], prevKey[+0x0c]) * DEG2RAD; +float cosY = cos(sinAngY); +float sinY = sin(sinAngY); +float cosZ = cos(sinAngZ); +float sinZ = sin(sinAngZ); +param_4[0] = fVar9 * sinZ * cosY; +param_4[1] = fVar9 * cosZ * cosY; +param_4[2] = fVar9 * sinY; + +// out sunColor ARGB (param_3): from bytes at +0x18,0x19,0x1a +// out ambientColor ARGB (param_5): from bytes at +0x10,0x11,0x12 +``` + +(The repeated `FUN_005df4c4()` calls with no args are Ghidra's artifact +for a union/reinterpret-cast byte shuffle — they are NOT an RNG and NOT +a lightning modulator.) + +## Block-2 Keyframe Layout — `FUN_00501860` @ 0x00501860 + +chunk_00500000.c, lines 1236-1268. Second block of the keyframe struct: + +| Offset | Field | Type | Notes | +|---|---|---|---| +| +0x20 | FogNear | float | | +| +0x24 | FogFar | float | | +| +0x28–0x2a | FogColor | byte[3] | R,G,B | + +## Apply-Block-1 — `FUN_00505f30` @ 0x00505F30 + +chunk_00500000.c, lines 6026-6092. Stores the sampled values into global +cache and into the `_DAT_008682bc/c0/c4` "per-vertex ambient" slot: + +```c +DAT_00842780 = fogDensity; // param_2 +DAT_0084277c = sunColor; // param_3 (ARGB) +DAT_00842950 = sunDir[0]; // param_4[0] +DAT_00842954 = sunDir[1]; +DAT_00842958 = sunDir[2]; +DAT_00842778 = ambientColor; // param_5 (ARGB) + +_DAT_008682c8 = sunDir[0]; // duplicated +_DAT_008682d0 = sunDir[2]; +_DAT_008682cc = sunDir[1]; +FUN_00451a60(&_something, ambientColor); // unpacks ARGB → 3 floats +_DAT_008682bc = unpacked R; // RGB ambient floats for mesh lighting +_DAT_008682c0 = unpacked G; +_DAT_008682c4 = unpacked B; +DAT_008682d4 = 0; // ambient dirty flag +``` + +**`_DAT_008682bc/c0/c4` is the THING the sky tints the world with.** +It is consumed per-vertex by mesh-draw, not by a D3D state write. + +## Candidate "Sky Pass" — `FUN_00507a50` @ 0x00507A50 + +chunk_00500000.c, lines 7250-7299. + +```c +void FUN_00507a50(weatherMgr, phase) { + if ((FUN_00451ec0() || phase == 0)) { // skyEnabled or pass-0 + weatherMgr.renderingFlag = 1; + char oldAlpha = FUN_005a1560(); + FUN_005a3f90(DAT_008427a9 != '\0'); // FOGENABLE ← weatherFog flag + float oldFar = DAT_0081fc98; + FUN_0054bf30(DAT_0081fc98 * _DAT_007c6f14); // multiply far plane + FUN_005a3e20(8, 0); // ZFUNC=ALWAYS, ZWRITE=0 + if (phase == 0) { + // iterate weather volume objects (rain/snow/fog cells) + for (uint i = 0; i < weatherMgr.count; ++i) { + int obj = *(int*)(weatherMgr.pArray + i*4); + uint flags = *(uint*)(weatherMgr.pFlags + i*4); + if (obj && !(flags & 1) && + (DAT_0081dbf9 || !(flags & 4)) && + (!DAT_008427a9 || !FUN_005a1560() || !(flags_byte & 2))) + { + FUN_00511720(obj); // scene-graph walk (update per-frame) + FUN_00511760(obj); // scene-graph walk (draw) + } + } + } else if (DAT_0081dbf9) { + // phase 1: just let the device callback at +0x64 run + (**(code **)(*DAT_00870340 + 0x64))(weatherMgr[+0x28]); + } + FUN_0054bf30(oldFar); // restore far plane + FUN_005a3e20(4, 1); // ZFUNC=LESSEQUAL, ZWRITE=1 + FUN_005a3f90(oldAlpha); // restore FOGENABLE + weatherMgr.renderingFlag = 0; + } +} +``` + +Called by `FUN_00506d90` (chunk_00500000.c:6683): + +- `FUN_00507a50(0)` = phase-0 (sky-ish volume draw, before terrain/entity) +- `FUN_00507a50(1)` = phase-1 (post-draw overlay, gated by weather flag) + +The `DAT_0081fc98 * _DAT_007c6f14` line IS the far-plane multiplier — +however `_DAT_007c6f14` here is ambiguous, used elsewhere as a small +rotation coefficient (chunk_005E0000.c:258 etc). This deserves a +follow-up: check if that DAT is a configurable sky-far-plane multiplier +or a recycled constant. + +**No matrix-translation-zero (camera-anchor) is present in this +function.** The scene graph objects iterated via `FUN_00511720/60` are +rain/snow/fog-shaft volumes — they are positioned in world space and +expected to follow the camera via normal scene-graph propagation. + +**No per-mesh blend swap (alpha for clouds vs additive for sun) is +present.** Blend mode is set once for the whole pass. + +## Fog Write Path — SUMMARY + +The complete trail: + +1. `FUN_00505f30` stores sun/ambient globals (no D3D call). +2. `FUN_005062e0` lerps current-keyframe fog → weather-fog if needed. +3. `FUN_005a41b0(&fogColor, fogNear, fogFar)` → +4. `FUN_005a4080(pFogColor_as_ARGB, near, far)` → +5. Writes D3DRS_FOGCOLOR=0x22, D3DRS_FOGSTART=0x24, D3DRS_FOGEND=0x25. + +Fog ENABLE is wrapped by `FUN_005a4010` (master gate at +0x4be) + +`FUN_005a3f90` (actual D3DRS_FOGENABLE=0x1c write). Both driven by +the DAT_0081dbf8 master-fog flag. + +**D3DRS_FOGTABLEMODE=0x23, FOGVERTEXMODE=0x8c, FOGDENSITY=0x26** — +these are only set once in the default-init (`FUN_005a10f0`) and never +per-frame. Retail uses linear fog (FOGSTART/FOGEND), not exponential +(FOGDENSITY). + +## Lightning Flash — NOT FOUND + +Searched for: + +- `FUN_005df4c4` (the RNG/byte-shuffle) — all hits are byte-packing + for ARGB color interpolation, not random modulation. +- `_DAT_008427b8` — it's a weather-fog-blend progress (0 → 1 ramp), + not a per-frame random value. +- Time-based sine/cos on small periods — none found coupled to sky + color. +- Any RNG call near fog/ambient writes — none found. + +**Lightning is likely a separate system not found in this hunt, OR +does not exist in retail and is purely decorative in modern ports.** + +## Matrix-Anchor (Camera-Centered Sky) — NOT FOUND + +Searched for patterns like: + +- `*(undefined4*)(mat + 0x30) = 0;` +- `SetTransform(D3DTS_VIEW=2, ...)` via any vtable offset +- A matrix copy that zeroes three consecutive 4-byte fields + +Nothing found anchored to the sky pass. The view matrix is NOT rewritten +with zero translation before the sky draw. This is consistent with the +conclusion that there is no discrete "sky dome" — the weather/fog volume +objects follow the camera by being placed in camera-relative world +position by their parent scene-graph node. + +## Huge Far-Plane Constants — NOT FOUND + +Searched for: + +- 0x49742400 (1e6f) — no hits +- 0x47c35000 (1e5f) — no hits +- 0x4b189680 (1e7f) — no hits + +The only far-plane modulator is `DAT_0081fc98 * _DAT_007c6f14` at +chunk_00500000.c:7272. Without knowing `_DAT_007c6f14`'s numeric value +from a debugger or disassembly of `.rdata`, we can't tell if this is +>>1 (sky-far-plane multiplier) or ~1 (no-op). A follow-up +disassembly of the address `0x007c6f14` should nail this down. + +## D3D State Inventory — Sky-Relevant Per-Frame Writes + +Only four D3D states are written per-frame in the sky-adjacent code path: + +| State | Hex | Name | Writer | Source of value | +|---|---|---|---|---| +| 34 | 0x22 | FOGCOLOR | `FUN_005a4080` | current keyframe+weather lerp | +| 36 | 0x24 | FOGSTART | `FUN_005a4080` | current keyframe+weather lerp | +| 37 | 0x25 | FOGEND | `FUN_005a4080` | current keyframe+weather lerp | +| 28 | 0x1c | FOGENABLE | `FUN_005a3f90` | `DAT_0081dbf8` / weather flag | + +Plus, inside `FUN_00507a50` (phase-0 weather-volume draw only): + +| State | Hex | Name | Value | +|---|---|---|---| +| 23+14 | 0x17+0x0e | ZFUNC + ZWRITE | ALWAYS + OFF → restore LESSEQUAL + ON | + +## Gaps / Unresolved + +1. **The classical sky dome / celestial body draw** — NOT FOUND. Either + retail literally does not have one (sky is just a clear-color glimpse + plus fog between camera and terrain), or it's hidden inside the + RegionDesc.Scene entries and rendered as a regular object through the + normal scene-graph path. +2. **`_DAT_007c6f14`** — used as a far-plane multiplier in the sky pass, + but cross-referenced use in chunks 0x5E0000 suggests it's a unit-scale + float (< 10). Needs raw `.rdata` dump to confirm its value. +3. **Is `_DAT_008682bc/c0/c4` consumed by D3D material or per-vertex + color?** The writers are clear; the readers need a separate hunt + (likely in chunk_00590000.c or the mesh-draw path at `FUN_0054c9c0`). +4. **Lightning flash** — not found in the keyframe/fog paths. If it + exists, it's in a separate code path (maybe tied to weather presets + 6 "thunderstorm" — DAT_008427a9=1 case in chunk_00550000.c:11886). + +## Files / Addresses cited (all absolute paths) + +- `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_005A0000.c` + - lines 687-740: `FUN_005a10f0` device reset (AMBIENT=0 here, only caller) + - lines 2606-3035: D3D state wrapper zoo (all vtable-0xe4 writers) + - lines 2868-2907: `FUN_005a4080` = SetFog triple + - lines 2911-2921: `FUN_005a41b0` = SetFog wrapper + - lines 3346-3400: default state-init defaults + - lines 3860-3970: `FUN_005a5950` world-scene top-level renderer +- `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_00500000.c` + - lines 1151-1232: `FUN_00501600` Block-1 keyframe sampler + - lines 1236-1268: `FUN_00501860` Block-2 (fog) keyframe sampler + - lines 6026-6092: `FUN_00505f30` apply-keyframe-1 to globals + - lines 6213-6333: `FUN_005062e0` per-frame sky+fog update (MAIN) + - lines 6683-6708: `FUN_00506d90` scene renderer (calls sky phase 0/1) + - lines 7250-7299: `FUN_00507a50` the phase-0 weather-volume draw +- `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_00450000.c` + - lines 608-622: `FUN_00451a60` ARGB → 3-float unpack + - lines 4300-4479: per-landblock ambient setup (`_DAT_008682bc` writers) +- `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_00550000.c` + - lines 11835-12016: `FUN_0055eb40` weather-preset-id → fog constants + (0=clear, 1=light rain, 2=medium, 3=heavy, 4=blizzard, 5=?, 6=thunderstorm) +- `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_004F0000.c` + - lines 10692-10720: `FUN_004ff440` / `FUN_004ff480` keyframe trampolines + +## Recommendation for acdream port + +Given these findings, our C# SkyRenderer should: + +1. **Stop expecting a celestial-body draw loop in the decompile** — Hunt A + will likely confirm via the Region-loader angle. +2. **Port the keyframe sampler faithfully** from `FUN_00501600` / 1860 — + the struct layout above is the ground truth for RegionDesc keyframe + blobs. +3. **Apply the sun direction and colors to world-mesh vertex lighting**, + not to a D3DRS_AMBIENT equivalent. The values live in + `_DAT_008682bc/c0/c4` and are consumed by the mesh pipeline. +4. **Set fog via SetFog(color, start, end) + FogEnable** per frame from + the Block-2 sample. This matches `FUN_005a41b0`. +5. **Do NOT add a matrix translation-zero** — retail does not anchor the + sky dome, because retail does not appear to HAVE a sky dome draw in + the D3D state sense. If acdream renders a dome mesh, it's a modern + addition. +6. **Tint clouds (if rendered) via the per-mesh ambient global**, not + via the current uniform-one approach. The previous research doc's + conclusion on "clouds multiply by ambient" is likely correct in + spirit — just know that "ambient" here means + `_DAT_008682bc/c0/c4`, not D3DRS_AMBIENT. diff --git a/docs/research/2026-04-23-sky-decompile-hunt-C.md b/docs/research/2026-04-23-sky-decompile-hunt-C.md new file mode 100644 index 0000000..914e5ec --- /dev/null +++ b/docs/research/2026-04-23-sky-decompile-hunt-C.md @@ -0,0 +1,492 @@ +# Sky Decompile Hunt C — Globals & Keyframe Interpolator + +**Date:** 2026-04-23 +**Hunter:** Agent C (globals / keyframe math) +**Scope:** Find the runtime state block the sky renderer reads, the keyframe-interp math, and the per-frame entry point. +**Source tree:** `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_*.c` (55 chunks, 688K lines). + +All citations use `{chunk_file}:{line}` relative to the decompile tree. + +--- + +## 1. Global Inventory — the sky state block + +All globals live in a contiguous block at **`0x00842778..0x008427c0`** with a second cluster at **`0x00842950..0x00842960`**. Every field is read by landblock/draw code and written only by the per-frame updater `FUN_005062e0` via the interp delegate `FUN_00501600`. Initial values are set in `FUN_00505dd0` (the sky-system constructor). + +| Address | Size | Inferred semantic | Initial value | Writers | Readers | +|---|---|---|---|---|---| +| **`DAT_00842778`** | 4 B (ARGB packed u32) | **AmbColor / SunLight color** — unpacked to 3 floats via `FUN_00451a60` into the active D3D material/light | `0` (default white via fallback) | `FUN_00505f30:6047`; `FUN_00501600` (via out-param `param_5`) | `FUN_00451a60(DAT_00842778)` in `chunk_00450000.c:4341, 4468`; `chunk_00500000.c:6061` — applied to D3D light state | +| **`DAT_0084277c`** | 4 B (ARGB packed u32) | **Fog color** — passed to `FUN_004530e0(fogStart, DAT_0084277c)` and also decomposed into 3 floats `(R,G,B)*1/255` for per-vertex terrain lighting | `0` | `FUN_00505f30:6042` (param_3); `FUN_00501600` (via `param_3`) | `chunk_00530000.c:2095,2097,2099` (terrain lighting); `chunk_00500000.c:6069`; `chunk_00450000.c:4347,4474` | +| **`DAT_00842780`** | 4 B (float) | **Fog start distance / luminosity offset** — added to `\|sun\|·_DAT_0079a1e8` and passed as first arg to fog setup `FUN_004530e0` | `0x3ecccccd` = **0.4f** | `FUN_00505f30:6041` (param_2); `FUN_00501600` (via out-param `param_2`) | `chunk_00500000.c:6067-6069`; `chunk_00530000.c:2094, 2107-2109` (terrain mul) | +| `DAT_00842784` | 4 B (ARGB u32) | Sky-fog secondary color (bucket pick helper — see §6) | `0` | `chunk_00550000.c:11851/61/67/80/92` (palette decision in render-sample path), `FUN_005062e0:6295,6301` | `chunk_00500000.c:6295,6301,6305,6311,6317` | +| `DAT_00842788` | 4 B (ARGB u32) | Sky-fog primary color (same role, paired with 0x842784) | `0` | `chunk_00550000.c:11850/62/67/80/91`, `FUN_005062e0:6251,6259` | `chunk_00500000.c:6251,6259,6263,6269,6275` | +| `DAT_00842790` | 4 B (ptr) | **Cloud / stars heightmap buffer** — `operator_delete__`'d on teardown, re-alloc'd to `(N+1)*0x100` bytes where N is cloud-layer count | `NULL` | `chunk_00500000.c:5994, 6543` | `chunk_00500000.c:6571, 6586, 6597, 6599, 6539` | +| `_DAT_00842798` | 8 B (double) | **Next cloud/weather update deadline** — compared to the global game-clock `_DAT_008379a8` each frame, advanced by `TickSize` (from `*(double *)(DAT_0084247c + 0x50) + 8`) | `0` | `FUN_00505d40:5923` (reset); `FUN_005062e0:6241` | `FUN_005062e0:6240` | +| `_DAT_0084279c` | (pairs with 0x842798 as high half of double) | — | `0` | `FUN_00505d40:5924` | (via 0x842798 as double) | +| **`_DAT_008427a0`** | 8 B (double) | **Next sky-keyframe update deadline** — compared to `_DAT_008379a8`, advanced by `LightTickSize` (from `*(double *)(DAT_0084247c + 0x50) + 0x10`) | `0` | `FUN_00505d40:5925`; `FUN_005062e0:6285,6289` | `FUN_005062e0:6249` | +| `_DAT_008427a4` | (pairs as high half of 0x8427a0) | — | `0` | `FUN_00505d40:5926` | — | +| **`DAT_008427a8`** | 1 B (bool) | **FixedLight override enable** — `true` means "run live keyframe interp each frame"; `false` means the caller of `FUN_00505f30` supplies the values directly | `0` | `FUN_00505d40:5922` (set from `param_1 != 0`) | `FUN_00505f30:6043` | +| `DAT_008427a9` | 1 B (bool) | **Crossfade in progress** flag — enables blending from the previous keyframe output into the new one over `_DAT_007c7208` per-frame step | — | `chunk_00500000.c:7270` | `FUN_005062e0:6256, 6297`; `chunk_00500000.c:7281` | +| `DAT_008427ac` | 4 B (float) | **Crossfade target: fog start** (previous frame's fog start, held for lerp) | — | — | `FUN_005062e0:6258, 6279` | +| `DAT_008427b0` | 4 B (float) | **Crossfade target: fog start/secondary-1** | — | — | `FUN_005062e0:6299, 6320` | +| `DAT_008427b4` | 4 B (float) | **Crossfade target: fog start/secondary-2** | — | — | `FUN_005062e0:6300, 6321` | +| `_DAT_008427b8` | 4 B (float) | **Crossfade u-parameter** — 0..1; advanced by `_DAT_007c7208` per sample | — | `FUN_005062e0:6280, 6322` | `FUN_005062e0:6257, 6279, 6298, 6320` | +| **`DAT_00842950`** | 4 B (float) | **Sun direction X** | `0x3f99999a` = **1.2f** (default before first interp) | `FUN_00505f30:6044`; `FUN_00501600` → `param_4[0]` | `chunk_00500000.c:6057,6067,6058`; `chunk_00450000.c:4086,4337,4464`; `chunk_00530000.c:2029,2118,2137,2156,2175,2206` | +| **`DAT_00842954`** | 4 B (float) | **Sun direction Y** | `0` | `FUN_00505f30:6045`; `FUN_00501600` → `param_4[1]` | same file set; appears as `local_44[0]` in terrain lighting | +| **`DAT_00842958`** | 4 B (float) | **Sun direction Z** | `0x3f000000` = **0.5f** | `FUN_00505f30:6046`; `FUN_00501600` → `param_4[2]` | same file set; appears as `local_44[1]` in terrain lighting | +| **`DAT_0084295c`** | 4 B (float) | **Minimum fog-start clamp** — if interp result < this, snap up | — | (loaded from region config, probably MinWorldFog) | `FUN_00505f30:6051-6052`; `FUN_005062e0:6253-6254` | +| `DAT_00842960` | 4 B (int) | Heightmap dim counter (used alongside DAT_00842790) | — | `FUN_00500000.c:6544, 6540` | — | + +**D3D light/material slots written from the block:** + +| D3D slot | Source | Written at | +|---|---|---| +| `_DAT_008682bc/c0/c4` | `fVar1/2/3` = copy of `DAT_00842950/54/58` (a second sun-dir-like slot — likely "light direction copy") | `FUN_00505f30:6062-6064` | +| `_DAT_008682c8/cc/d0` | `DAT_00842950/54/58` directly (primary sun direction vector3) | `FUN_00505f30:6058-6060` | +| `DAT_008682d4` | `0` (light enable flag) | `FUN_00505f30:6065` | + +--- + +## 2. Keyframe Interpolator — `FUN_00501600` (0x00501600) + +**Signature:** +```c +void FUN_00501600(float param_1, // u = dayFraction (0..1) + float *param_2, // out: interpolated fog start scalar + undefined1 *param_3, // out: interpolated fog color (4 bytes ARGB) + float *param_4, // out: interpolated sun direction vec3 (scaled by brightness) + undefined1 *param_5); // out: interpolated ambient color (4 bytes ARGB) +``` + +The function first brackets `u` against the keyframe table (`FUN_00501530`, see §3), then interpolates. + +**Full decompile** (`chunk_00500000.c:1151-1232`): + +```c +void FUN_00501600(float param_1,float *param_2,undefined1 *param_3,float *param_4, + undefined1 *param_5) +{ + ... + iVar5 = FUN_00501530(param_1,&local_14,&local_10,¶m_1); + uVar2 = local_10; // next keyframe + if (iVar5 != 0) { + // fog-start scalar: lerp field +0x14 + *param_2 = (*(float *)(local_10 + 0x14) - *(float *)(local_14 + 0x14)) * param_1 + + *(float *)(local_14 + 0x14); + + // fog color ARGB (3 bytes) — each byte is (lerp between k_i[0x18..0x1a] and k_i+1[0x18..0x1a]) + // FUN_005df4c4 is a clamp-to-byte helper + param_2 = (float *)(uint)*(byte *)(local_10 + 0x19); // G + uVar3 = FUN_005df4c4(); + param_2 = (float *)(uint)*(byte *)(local_10 + 0x18); // R + uVar4 = FUN_005df4c4(); + local_10 = (uint)*(byte *)(local_10 + 0x1a); // B + param_2 = (float *)CONCAT31(param_2._1_3_,uVar4); + uVar4 = FUN_005df4c4(); + param_3[2] = uVar4; // B + param_3[1] = uVar3; // G + *param_3 = param_2._0_1_; // R + param_3[3] = 0xff; // A + + // Sun direction — polar (heading, pitch) w/ magnitude scalar (field +0x04) + fVar9 = ((float10)*(float *)(uVar2 + 4) - (float10)*(float *)(local_14 + 4)) * (float10)param_1 + + (float10)*(float *)(local_14 + 4); // fVar9 = magnitude (DirBright/length) + fVar1 = ((*(float *)(uVar2 + 0xc) - *(float *)(local_14 + 0xc)) * param_1 + + *(float *)(local_14 + 0xc)) * (float)_DAT_0079c6b0; // pitch_rad + fVar6 = (((float10)*(float *)(uVar2 + 8) - (float10)*(float *)(local_14 + 8)) * (float10)param_1 + + (float10)*(float *)(local_14 + 8)) * (float10)_DAT_0079c6b0; // yaw_rad + fVar7 = (float10)fcos((float10)fVar1); // cos(pitch) + fVar8 = (float10)fsin(fVar6); // sin(yaw) + local_c = (float)(fVar9 * fVar8 * fVar7); // x = mag * sin(yaw)*cos(pitch) + fVar6 = (float10)fcos(fVar6); // cos(yaw) + *param_4 = local_c; + local_8 = (float)(fVar9 * fVar6 * fVar7); // y = mag * cos(yaw)*cos(pitch) + fVar6 = (float10)fsin((float10)fVar1); // sin(pitch) + param_4[1] = local_8; + local_4 = (float)(fVar6 * fVar9); // z = mag * sin(pitch) + param_4[2] = local_4; + + // Ambient color ARGB (3 bytes) — lerp field +0x10..0x12 + param_2 = (float *)(uint)*(byte *)(uVar2 + 0x11); // G + uVar3 = FUN_005df4c4(); + param_2 = (float *)(uint)*(byte *)(uVar2 + 0x10); // R + uVar4 = FUN_005df4c4(); + param_3 = (undefined1 *)(uint)*(byte *)(uVar2 + 0x12); // B + ... + param_5[2] = uVar4; // B + param_5[1] = uVar3; // G + *param_5 = param_2._0_1_; // R + param_5[3] = 0xff; // A + return; + } + // Fallback: no keyframe → white ambient, white fog, sun (0.5, 0, 0.8), fog_start 0.3 + *param_2 = 0.3; + param_3[2] = 0xff; param_3[1] = 0xff; *param_3 = 0xff; param_3[3] = 0xff; + param_5[2] = 0xff; param_5[1] = 0xff; *param_5 = 0xff; param_5[3] = 0xff; + *param_4 = 0.5; param_4[1] = 0.0; param_4[2] = 0.8; + return; +} +``` + +**Keyframe struct layout** (inferred from FUN_00501600 + the loader/saver `FUN_00501a20`/`FUN_00501b20` at `chunk_00500000.c:1316-1446`): + +| Offset | Type | Semantic | +|---|---|---| +| `+0x00` | `float *` (back-pointer to `this`) | self | +| `+0x04` | `float` | **t0 / Begin** — day-fraction at which this keyframe is active (compared in bracket search) **AND** reused as **DirBright (sun magnitude)** scalar inside the interp! Actually this is DirBright per ACE schema; bracket search at FUN_00501530:1115 uses `*(float *)*puVar4` which dereferences the back-pointer — so the actual `t0` lives at `*this` (offset 0), and the field at +0x04 is DirBright. **But** the save-order in FUN_00501a20 starts from +0x04, so the on-disk file does NOT store t0 explicitly here — it's elsewhere (the outer container holds t0; see §3). | +| `+0x08` | `float` | **DirHeading** (degrees, converted to radians via `_DAT_0079c6b0` = π/180) | +| `+0x0c` | `float` | **DirPitch** (degrees → radians) | +| `+0x10..+0x12` | 3×`byte` | **AmbColor** (R, G, B at +0x10, +0x11, +0x12) | +| `+0x13` | `byte` | AmbColor alpha (unused; always 0xff on write) | +| `+0x14` | `float` | **MinWorldFog / fog start scalar** (or AmbBright; see §7 gaps) | +| `+0x18..+0x1a` | 3×`byte` | **FogColor** (R, G, B) | +| `+0x1b` | `byte` | FogColor alpha | +| `+0x1c` | `ptr` | Optional per-keyframe mesh/sky-object chain (non-zero used by `FUN_00501860`) | +| `+0x20` | `float` | **Secondary field 1** (ACE calls this `DirColor.Luminosity` or `MaxWorldFog`; used in `FUN_00501860:1250`) | +| `+0x24` | `float` | **Secondary field 2** (used in `FUN_00501860:1252` — pairs with +0x20) | +| `+0x28..+0x2a` | 3×`byte` | **Secondary color** (used in `FUN_00501860:1254-1263`) | +| `+0x2b` | `byte` | Secondary color alpha | + +Total struct size: **`0x2c` bytes** (44 B), which matches the `FUN_005df0f5(0x2c)` alloc in `chunk_00500000.c:5972`. This is the in-memory SkyTimeOfDay (one keyframe). + +The outer keyframe-table header is at `param_1` in FUN_00501530: +- `*(int *)(param_1 + 8)` = pointer to array of keyframe pointers +- `*(int *)(param_1 + 0x10)` = keyframe count +- `*(float *)*puVar4` = keyframe's `t0` (reached via the back-pointer at struct +0x00) + +--- + +## 3. Keyframe bracket search — `FUN_00501530` (0x00501530) + +`chunk_00500000.c:1093-1129`: + +```c +undefined4 __thiscall +FUN_00501530(int param_1,float param_2,undefined4 *param_3,undefined4 *param_4,float *param_5) +{ + iVar1 = *(int *)(param_1 + 0x10); // count + if (iVar1 == 0) return 0; + uVar3 = 0; + if (iVar1 != 1) { + puVar4 = *(undefined4 **)(param_1 + 8); // array base + do { + puVar4 = puVar4 + 1; + if (param_2 < *(float *)*puVar4) break; // u < keyframe[i+1].t0 ? + uVar3 = uVar3 + 1; + } while (uVar3 < iVar1 - 1U); + } + *param_3 = *(undefined4 *)(*(int *)(param_1 + 8) + uVar3 * 4); // lower keyframe + if (uVar3 == *(int *)(param_1 + 0x10) - 1U) { + *param_4 = **(undefined4 **)(param_1 + 8); // wrap to keyframe[0] + *param_5 = (param_2 - *(float *)*param_3) / (_DAT_007938b0 - *(float *)*param_3); + // u01 = (t - lower.t0) / (1.0 - lower.t0) — wrap-around case + return 1; + } + pfVar2 = (float *)(*(undefined4 **)(param_1 + 8))[uVar3 + 1]; + *param_4 = pfVar2; // upper keyframe + *param_5 = (param_2 - *(float *)*param_3) / (*pfVar2 - *(float *)*param_3); + // u01 = (t - lower.t0) / (upper.t0 - lower.t0) + return 1; +} +``` + +This is exactly the interpolator-finder pattern the hunt asked for. The `t0` for each keyframe is stored at the FIRST FIELD of each keyframe record (via the back-pointer layer). The last keyframe wraps to keyframe[0] using `1.0` as the upper bound (`_DAT_007938b0` = 1.0). + +--- + +## 4. Sun direction formula — confirmed + +`chunk_00500000.c:1192-1205` computes the sun unit vector from `(DirHeading, DirPitch)`: + +``` +yaw_rad = DirHeading * π/180 +pitch_rad = DirPitch * π/180 +mag = DirBright (interpolated, scales the output length) + +x = mag * sin(yaw) * cos(pitch) +y = mag * cos(yaw) * cos(pitch) +z = mag * sin(pitch) +``` + +This matches the hunt-brief formula exactly **except** it multiplies by the per-keyframe magnitude `fVar9` (DirBright). So `DAT_00842950/54/58` is **not a unit vector**; it is `DirBright * unit_direction`. The terrain lighting code at `chunk_00530000.c:2118` dot-products this with per-vertex normals, so the `DirBright` scaling baked into the vector directly modulates the diffuse lighting strength. + +Coordinate frame: retail AC is **Y-up world** for sky azimuth but stores sun in world coords where **Z-axis is vertical** (common in retail AC: X=east, Y=north, Z=up). `sin(pitch)` → Z means pitch=+90° → straight up; pitch=0 → horizontal; that matches AC's "degrees above horizon" convention. + +--- + +## 5. Day-fraction / tick source + +`chunk_00500000.c:6239-6248` (inside `FUN_005062e0`): + +```c +local_14 = _DAT_008379a8; // game-clock (seconds, double) +if ((_DAT_00842798 <= _DAT_008379a8) && (DAT_0084247c != 0)) { // cloud-deadline met & region loaded + _DAT_00842798 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 8) + _DAT_008379a8; + // ^--- SkyDesc.TickSize (from the Region DB object) + if (DAT_008ee9c8 == 0) { + fVar1 = (float)_DAT_00795610; // = 0.0f (no world loaded) + } + else { + fVar1 = *(float *)(DAT_008ee9c8 + 0x48); // <-- THIS is the dayFraction + } +``` + +And at `chunk_00500000.c:6285-6289`: + +```c +_DAT_008427a0 = _DAT_007c7200; // default LightTickSize +if (DAT_0084247c != 0) { + _DAT_008427a0 = *(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10); + // ^--- SkyDesc.LightTickSize (from Region DB object) +} +_DAT_008427a0 = _DAT_008427a0 + local_14; // advance deadline +``` + +**Day-fraction source:** `*(float *)(DAT_008ee9c8 + 0x48)`. + +`DAT_008ee9c8` is the **world/time global** (a CTimeStamp-like struct). Its fields used by the sky: +- `+0x10` : int — "tickCount A" component (used in `FUN_00501990:1293`, a PRNG seed hint) +- `+0x48` : **float dayFraction** (0..1) — directly consumed by FUN_00501600 as `param_1` +- `+0x64` : int — "tickCount B" +- `+0x68` : int — "tickCount C" + +I did NOT locate the writer of `+0x48` (the conversion from wall-clock seconds to dayFraction) in this hunt — see §7 "Gaps". + +**Region-config pointer:** `DAT_0084247c` points at the loaded `Region` object; `Region + 0x50` is a pointer into `SkyDesc`, and `SkyDesc + 8` = TickSize (double seconds) and `SkyDesc + 0x10` = LightTickSize (double seconds) — matching the dat schema (`docs/research/2026-04-23-sky-dat-schema.md:21-22`). + +--- + +## 6. Per-frame sky update entry point — `FUN_005062e0` (0x005062E0) + +**The function the hunt brief is asking for.** `chunk_00500000.c:6213-6333`. + +Skeleton (key flow, comments added): + +```c +void __fastcall FUN_005062e0(int param_1) +{ + ... + if (*(int *)(param_1 + 0x10) != 0) { + if (*(int *)(param_1 + 0x20) != 0) { + FUN_00508010(); // prepare cloud buffers + } + local_14 = _DAT_008379a8; // game-clock snapshot + if ((_DAT_00842798 <= _DAT_008379a8) && (DAT_0084247c != 0)) { + _DAT_00842798 = SkyDesc.TickSize + _DAT_008379a8; // next tick deadline + fVar1 = (DAT_008ee9c8 == 0) ? 0.0f : *(float *)(DAT_008ee9c8 + 0x48); // dayFraction + + // ---- Sky-keyframe update (gated by LightTickSize) ---- + if (_DAT_008427a0 < _DAT_008379a8) { + iVar7 = FUN_004ff440(fVar1,&local_24,&local_20,local_c,&local_18); + // ^ primary interp: fogStart, fogColorARGB, sunDirVec3, ambColorARGB + if (iVar7 != 0) { + if (local_24 < DAT_0084295c) local_24 = DAT_0084295c; // clamp min fog start + + if (DAT_008427a9 != '\0') { // crossfade active + // blend towards previous frame's held values (DAT_008427ac, DAT_00842788) + // step crossfade u by _DAT_007c7208 each tick + ... + } + FUN_00505f30(local_24, local_20, local_c, local_18); + // ^ commits the interp result to the DAT_008427xx / DAT_008429xx globals + // AND pushes them to D3D light/material/fog state. + } + _DAT_008427a0 = SkyDesc.LightTickSize + local_14; // advance light deadline + } + + // ---- Secondary interp (starfield / alt palette — DAT_00842784/88) ---- + FUN_005a4010(DAT_0081dbf8 == '\0'); + if (DAT_0081dbf8 != '\0') { + FUN_005a3f90(DAT_0081dbf8); + iVar7 = FUN_004ff480(fVar1,&local_1c,&local_24,&local_20); + // ^ secondary interp (starfield): 2 floats + 1 color + if (iVar7 != 0) { + // same crossfade pattern targeting DAT_008427b0/b4 and DAT_00842784 + ... + FUN_005a41b0(&local_20, local_1c, local_24); // push to starfield state + } + } + } + } +} +``` + +**Entry-point contract:** +- Called once per world/frame tick from the top-level world update (see §7). +- Reads `DAT_008ee9c8 + 0x48` for the current dayFraction. +- Runs **two** independent interpolators against the same keyframe tables, throttled by `TickSize`/`LightTickSize` (from `SkyDesc`). +- `FUN_00505f30` (see below) writes the final values into the globals that the rest of the render pipeline samples. + +--- + +## 6b. Commit-to-globals — `FUN_00505f30` (0x00505F30) + +`chunk_00500000.c:6026-6092`. This is the function that *writes* the `DAT_00842xxx` globals. It is the public "set sky state" entry point — called with either (a) fresh interp output (from `FUN_005062e0:6283`) or (b) caller-supplied values (the FixedLight override path when `DAT_008427a8 == 0`). + +```c +void FUN_00505f30(int param_1, float param_2, undefined4 param_3, + float *param_4, undefined4 param_5) +{ + DAT_00842780 = param_2; // fog start + DAT_0084277c = param_3; // fog color ARGB + if (DAT_008427a8 == '\0') { // FixedLight mode: caller provides raw values + DAT_00842950 = *param_4; // sun X + DAT_00842954 = param_4[1]; // sun Y + DAT_00842958 = param_4[2]; // sun Z + DAT_00842778 = param_5; // ambient color ARGB + } + else { // dynamic mode: re-run interp against current dayFrac snapshot + iVar4 = FUN_004ff440(0x3f000000, // 0.5 placeholder (?) + &DAT_00842780, &DAT_0084277c, + &DAT_00842950, &DAT_00842778); + if ((iVar4 != 0) && (DAT_00842780 < DAT_0084295c)) { + DAT_00842780 = DAT_0084295c; // min fog clamp + } + } + + // Push sun direction to the active D3D directional light + fVar3 = DAT_00842958; fVar2 = DAT_00842954; fVar1 = DAT_00842950; + _DAT_008682c8 = DAT_00842950; + _DAT_008682cc = DAT_00842954; + _DAT_008682d0 = DAT_00842958; + + // Unpack ambient color ARGB → 3 floats into the D3D material + FUN_00451a60(DAT_00842778); + _DAT_008682bc = fVar1; _DAT_008682c0 = fVar2; _DAT_008682c4 = fVar3; + DAT_008682d4 = 0; + + // Fog: distance = length(sunDir) * 0079a1e8 + fogStart; color = fog color ARGB + if (DAT_0083da58 != 0) { + FUN_004530e0(SQRT(DAT_00842950 * DAT_00842950 + + DAT_00842954 * DAT_00842954 + + DAT_00842958 * DAT_00842958) * _DAT_0079a1e8 + + DAT_00842780, + DAT_0084277c); + } + + // Touch each dungeon EnvCell (re-apply fog/lighting state to each) + if (*(int *)(param_1 + 8) != 0) { + ... FUN_00532440(); ... + } +} +``` + +**Dispatch glue** — `FUN_004ff440` (at `chunk_004F0000.c:10692-10704`) is a null-check wrapper around `FUN_00501600`: + +```c +undefined4 FUN_004ff440(param_1, param_2, param_3, param_4, param_5) { + if ((DAT_0084247c != 0) && (*(int *)(DAT_0084247c + 0x50) != 0)) { + FUN_00501600(param_1, param_2, param_3, param_4, param_5); + return 1; + } + return 0; +} +``` + +--- + +## 7. Call graph + +``` +FUN_004554b0 (0x004554B0 — world update root) chunk_00450000.c:3831 + └── FUN_005062e0 (0x005062E0 — per-frame sky update) chunk_00500000.c:6213 + ├── reads: _DAT_008379a8 (game-clock) + │ DAT_008ee9c8 + 0x48 (dayFraction) + │ DAT_0084247c + 0x50 (SkyDesc ptr) + │ DAT_0084247c + 0x50 + 8 (TickSize double) + │ DAT_0084247c + 0x50 + 0x10 (LightTickSize double) + ├── writes: _DAT_00842798 (next cloud tick) + │ _DAT_008427a0 (next light tick) + │ _DAT_008427b8 (crossfade u) + │ DAT_008427a9 (crossfade active) + ├── FUN_004ff440 → FUN_00501600 (primary interp) chunk_00500000.c:1151 + │ └── FUN_00501530 (bracket search) chunk_00500000.c:1093 + ├── FUN_00505f30 (commits globals + D3D state) chunk_00500000.c:6026 + │ ├── writes: DAT_00842778, DAT_0084277c, DAT_00842780 + │ │ DAT_00842950, DAT_00842954, DAT_00842958 + │ ├── writes: _DAT_008682bc..d4 (D3D directional light) + │ ├── FUN_00451a60 (unpack ambient ARGB → 3 floats) chunk_00450000.c:608 + │ └── FUN_004530e0 (push fog state) + └── FUN_004ff480 → FUN_00501860 (secondary interp: starfield) chunk_00500000.c:1236 + └── FUN_00505f30-equivalent callers use DAT_00842784/88 + +--- Readers (consumers of sky globals per frame) --- +chunk_00450000.c:4086,4337-4341,4464-4468 // landblock draw re-applies to D3D +chunk_00450000.c:4347, 4474 // landblock draw re-applies fog color +chunk_00530000.c:2094-2230 // per-vertex terrain shading: + // diffuse = max(dot(vertexNormal, sunDir), minAmbient) + // lit_color = ambRGB*fogStart + diffuse*fogRGB + // clamp to 1.0 per channel +``` + +Note: `chunk_00530000.c:2094-2230` is the canonical per-vertex lit-color calculation (retail's CPU lighting path). It consumes: +- `DAT_00842950/54/58` as the sun direction (NOT normalized — carries `DirBright` baked in). +- `DAT_00842780` as the "ambient base multiplier" (named `DirBright` in the schema but semantically `AmbBright` here: it's the scalar multiplying the **ambient** ARGB, not the sun). +- `DAT_00842778` ARGB → (R,G,B)/255 as **ambient color** (confirmed: line 2100,2104,2105). +- `DAT_0084277c` ARGB → (R,G,B)/255 as **diffuse color** (confirmed: line 2095,2097,2099). +- `DAT_00796344` = minimum-dot clamp (avoids fully-black back faces). +- `_DAT_007938c0` (probably 1.0 or slightly above) = per-channel saturation clamp. + +This flips my §1/§2 preliminary labels: **`DAT_0084277c` is actually the DIFFUSE/sun color** (not fog) and **`DAT_00842778` is the AMBIENT color**. The `FUN_004530e0` call at `FUN_00505f30:6067-6069` passes `DAT_0084277c` to the fog setup, but fog in retail AC also takes the **diffuse/sun color** as its tint — that's the known design (distant fog matches sun tint). So the semantic naming that unifies both consumers is: + +- **`DAT_0084277c`** = **DirColor** (sun/diffuse ARGB) — doubles as fog tint +- **`DAT_00842778`** = **AmbColor** (ambient ARGB) +- **`DAT_00842780`** = **scalar brightness mul** applied to the AMBIENT in the lit-color formula, AND as the fog-start offset (it's a dual-purpose "ambient base" scalar) + +This matches the FUN_00501600 output mapping from §2: +- interp out `*param_2` ← keyframe `+0x14` → `DAT_00842780` → "ambient scalar / fog base" +- interp out `*param_3` ← keyframe `+0x18..+0x1a` → `DAT_0084277c` → **DirColor (sun + fog)** +- interp out `*param_4[0..2]` ← keyframe `+0x04,+0x08,+0x0c` via polar→cartesian → `DAT_00842950..58` → **sun direction * DirBright** +- interp out `*param_5` ← keyframe `+0x10..+0x12` → `DAT_00842778` → **AmbColor** + +So the on-disk SkyTimeOfDay→in-memory field offsets are: + +| Offset | Field (ACE schema name) | Matched consumer | +|---|---|---| +| `+0x04` | `DirBright` | sun-vector magnitude `fVar9` | +| `+0x08` | `DirHeading` (deg) | yaw → `sin/cos` | +| `+0x0c` | `DirPitch` (deg) | pitch → `cos/sin` | +| `+0x10/+0x11/+0x12` | `AmbColor` (R,G,B) | `DAT_00842778` | +| `+0x14` | `AmbBright` (!) | `DAT_00842780` — multiplies AmbColor in lit_color; also fog-start offset | +| `+0x18/+0x19/+0x1a` | `DirColor` (R,G,B) | `DAT_0084277c` — diffuse AND fog tint | +| `+0x20` | `MinWorldFog` | secondary interp `local_1c` via `FUN_00501860:1250` | +| `+0x24` | `MaxWorldFog` | secondary interp `local_24` via `FUN_00501860:1252` | +| `+0x28/+0x29/+0x2a` | `WorldFogColor` (R,G,B) | starfield ARGB in `FUN_005a41b0` | + +This **matches exactly** the published ACE SkyTimeOfDay struct (`docs/research/2026-04-23-sky-dat-schema.md:42-54`) — every field has a decompile home now. + +--- + +## 8. Gaps / next hunts + +1. **Day-fraction writer.** I found `*(float *)(DAT_008ee9c8 + 0x48)` is READ as `dayFraction`. I did NOT locate the WRITER — i.e. which function computes `dayFraction = (gameClockSeconds mod dayLength) / dayLength` and stamps it to `+0x48`. Look in chunk_004F0000.c for `DAT_008ee9c8 + 0x48` write-sites, likely driven by a world-tick function using `SkyDesc.TickSize`. + +2. **`DAT_00796344` and `_DAT_007938c0` constants.** In the per-vertex lighting (`chunk_00530000.c:2119`), `DAT_00796344` is the minimum-dot clamp. `_DAT_007938c0` is the per-channel saturation clamp. Both should be extracted to confirm they are `0.0f` and `1.0f` respectively (or something like `0.0` and `> 1.0` to allow HDR). + +3. **FixedLight (static sky) path.** When `DAT_008427a8 == 0` (FixedLight mode), the caller supplies raw sun direction, ambient, fog color. This is likely the path used for dungeons (no day/night cycle). The caller of `FUN_00505d40(0)` (which sets `DAT_008427a8 = 0`) needs to be mapped — it's the dungeon-load entry. + +4. **Starfield secondary keyframe.** `FUN_00501860` / `FUN_005a41b0` (starfield state) uses keyframe fields `+0x20, +0x24, +0x28..+0x2a`. If acdream renders stars, port `FUN_005a41b0` too. + +5. **Crossfade parameters `_DAT_007c7208` and `DAT_008427ac`.** The crossfade (`DAT_008427a9`) is a smoothing blend when keyframes are force-swapped (e.g. weather change). `_DAT_007c7208` is the per-tick u-step; `DAT_008427ac` is the held previous value. If acdream wants faithful weather transitions, port this loop from `FUN_005062e0:6256-6281`. + +6. **Fog calling convention `FUN_004530e0`.** I did not decompile this but its signature — `FUN_004530e0(fogDistance, fogColorARGB)` — is unambiguous. Port as `SetFog(distance, ARGB)`. The `distance` arg is `|sunDir| * _DAT_0079a1e8 + fogStart` — so distant fog grows with the "weighted sun-vector length". This is AC's recognizable fog-near-horizon effect. + +7. **The already-known globals hint resolved.** The hunt brief cited `DAT_00842778/7c`, `DAT_00842950..58` — all mapped in §1. No other sky-specific globals exist in this address neighborhood. + +--- + +## 9. Quick summary for the porter + +**What the retail sky pipeline does per frame:** + +1. Pull dayFraction from `world.time.dayFraction` (`DAT_008ee9c8 + 0x48`, a float 0..1). +2. **Gate** on `LightTickSize` elapsed — only interpolate every `LightTickSize` seconds; otherwise reuse last frame's values (hence the visible ~2-second "step" in AC's time-of-day tint). +3. **Bracket** dayFraction across the current DayGroup's `SkyTimeOfDay[]` by `t0`. Compute u01. +4. **Interpolate** each channel: fogStart, fogColorARGB (byte-lerp), sunDir via polar-to-cartesian of (DirHeading, DirPitch) scaled by DirBright, ambColorARGB (byte-lerp). +5. **Clamp** fogStart ≥ `DAT_0084295c` (the MinWorldFog floor from the Region). +6. **Crossfade** blend if a keyframe swap was forced (weather). +7. **Commit** to six floats (sunDir x3, fogStart x1) + two ARGB dwords (fog=sun, amb). +8. **Push** those values to D3D: `_DAT_008682bc..d0` = sun vec3 duplicated into "direction copy" and "direction primary" slots; `_DAT_008682c8..d0` = sun vec3 copy 2; a material ambient RGB (unpacked from the ARGB); and fog(distance, color). +9. **Re-apply** the state on every landblock/EnvCell draw via `FUN_00451a60` + `FUN_004530e0` (because D3D resets between passes). + +All values to port live in `DAT_008427xx` / `DAT_00842950..58` — no secrets elsewhere. diff --git a/docs/research/2026-04-23-sky-references-crossref.md b/docs/research/2026-04-23-sky-references-crossref.md new file mode 100644 index 0000000..4ec29ef --- /dev/null +++ b/docs/research/2026-04-23-sky-references-crossref.md @@ -0,0 +1,191 @@ +# Sky rendering: cross-reference across all four references + +**Date:** 2026-04-23 +**Status:** research / no code change +**Purpose:** Verify (or refute) audit claims about how WorldBuilder, ACViewer, ACE, holtburger, and Chorizite.ACProtocol handle sky rendering and weather/time wire traffic, so acdream's sky implementation has a defensible reference baseline. + +## 0. Headline findings + +1. **WorldBuilder does not render the sky at runtime today.** The call to `_skyboxManager?.Render(...)` is commented out in `GameScene.cs:959`. The class exists; the code is dead. Any "what does WorldBuilder do" answer is therefore "what would it do if re-enabled," read from the class. +2. **No weather or time message exists on the wire.** ACE has exactly one environment-related message, `AdminEnvirons (0xEA60)`, carrying a single `EnvironChangeType` byte (fog color OR sound). Chorizite.ACProtocol's `protocol.xml` confirms the same single-field layout. holtburger does not parse or send it (its opcode is commented out). **Time-of-day is client-only**, driven from the `GameTime` and `SkyDesc` dat files. +3. **ACViewer has no sky renderer.** The Entity/Sky*.cs files are tree-view inspectors that format field values into UI nodes. There is no draw call. +4. **WorldBuilder's sky class ignores keyframe colours entirely** — it passes `SunlightColor = Vector3.Zero`, `AmbientColor = Vector3.One` to a shared lighting UBO and re-uses the generic `StaticObject` shader. Sky meshes render at raw texture colour, fully bright, with no per-batch blend mode and no per-keyframe tint. This confirms the audit claim. + +## 1. WorldBuilder render pipeline end-to-end + +File: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` + +### 1.1 Shader used +Line 446 of `GameScene.cs`: `_skyboxManager.Initialize(_sceneryShader, _graphicsDevice.SceneDataBuffer);`. The shader is `_sceneryShader`, created at `GameScene.cs:333` as `StaticObject` (files `Shaders/StaticObject.vert` + `.frag`). There is **no dedicated sky shader.** + +### 1.2 Per-frame GL state (SkyboxRenderManager.cs) +- `DepthMask(false)` — L178 +- `Disable(DepthTest)` — L179 +- `Disable(CullFace)` — L180 +- Blend state is **never touched** — it is whatever `GameScene.Render` set before (L845: `SrcAlpha / OneMinusSrcAlpha`, `BlendEquation FuncAdd`, and `Enable(Blend)` inherited from L844). +- Depth test + depth mask restored at L271–272. + +### 1.3 Per-object transform (L244–250) +``` +transform = Scale(1.0) * RotZ(-headingRad) * RotY(-rotationRad) +``` +Built from `SkyObject.BeginAngle/EndAngle` lerped by day-fraction progress (L217–240). `headingDeg` comes from `SkyObjectReplace.Rotate` if set, else 0. + +### 1.4 Shader inputs (L143–156) +The only uniform set for sky is `uRenderPass = SinglePass (2)` (L159). Scene UBO is filled with: +``` +SunlightColor = Vector3.Zero +AmbientColor = Vector3.One +LightDirection = regionInfo.LightDirection (unused for sky — clouds have no sun) +``` +The `StaticObject.vert` computes (line 54): +`LightingColor = clamp(uAmbientColor + uSunlightColor * diff + 0.15, 0.0, 1.0)` +With `ambient=1, sun=0`, this clamps to `1.0`. The sky fragment (line 34) then does `color.rgb *= LightingColor = 1.0`. **No keyframe tint reaches the shader.** + +### 1.5 Per-submesh batches (RenderObjectBatches, L276–325) +For each batch it: +1. Disables `CullFace` again (L303) +2. Sets `aTextureIndex` vertex attribute (L306) — "pick layer" +3. Binds the texture array and the sampler (wrap vs clamp) based on `batch.HasWrappingUVs` +4. `DrawElementsInstancedBaseVertex` + +It **never inspects `batch.IsAdditive`** even though that flag exists on `ObjectRenderBatch` (defined `ObjectMeshManager.cs:177`). No `BlendFunc` call. No per-surface material. + +**Conclusion:** The audit claim "WorldBuilder does not call `BlendFunc` per batch" is correct. The sky draws with whatever blend state was inherited from the previous pass (default `SrcAlpha/OneMinusSrcAlpha`) and uses the scenery shader's full-bright path. It is architecturally unfinished, which is why `GameScene.cs:959` comments the whole call out. + +## 2. ACViewer sky handling + +ACViewer is a tree-view DAT inspector. The four Sky files: +- `Entity/SkyDesc.cs` — `BuildTree()` produces UI nodes (TickSize, LightTickSize, DayGroups) +- `Entity/SkyTimeOfDay.cs` — `BuildTree()` produces nodes for Begin, DirBright/Heading/Pitch/Color, AmbBright/Color, MinWorldFog, MaxWorldFog, WorldFogColor, WorldFog, SkyObjReplace list +- `Entity/SkyObject.cs` — ditto for sky object properties +- `Entity/SkyObjectReplace.cs` — ditto for override records + +A content-search over `ACViewer/Render/` for "SkyDesc", "SkyInfo", "TimeOfDay", "LightIntensity", "DayGroup" returns zero matches. **ACViewer does not render the sky.** Not a useful reference for the renderer side. + +## 3. ACE weather/time wire protocol + +### 3.1 Complete inventory of environment messages + +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageAdminEnvirons.cs` — the only environment-related server-to-client message in the whole ACE project: + +```csharp +public GameMessageAdminEnvirons(Session session, EnvironChangeType environChange = EnvironChangeType.Clear) + : base(GameMessageOpcode.AdminEnvirons, GameMessageGroup.UIQueue, 8) +{ + Writer.Write((uint)environChange); // L10 +} +``` + +Opcode: `AdminEnvirons = 0xEA60` (`GameMessageOpcode.cs:38`). Payload: one `uint` = `EnvironChangeType`. That's the entire message. + +### 3.2 EnvironChangeType enumeration +`references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs`: +``` +Clear=0x00, RedFog=0x01, BlueFog=0x02, WhiteFog=0x03, GreenFog=0x04, +BlackFog=0x05, BlackFog2=0x06, +RoarSound=0x65, BellSound=0x66, Chant1Sound=0x67, Chant2Sound=0x68, +DarkWhispers1Sound=0x69, DarkWhispers2Sound=0x6A, DarkLaughSound=0x6B, +DarkWindSound=0x6C, DarkSpeechSound=0x6D, DrumsSound=0x6E, +GhostSpeakSound=0x6F, BreathingSound=0x70, HowlSound=0x71, +LostSoulsSound=0x72, SquealSound=0x75, +Thunder1Sound=0x76..Thunder6Sound=0x7B +``` +Extension methods: `IsFog` (≤ 0x06), `IsSound` (≥ 0x65). The enum is **exactly two concepts** in one message: a fog-colour override OR a one-shot environmental sound cue (including thunder). **There is no lightning flash opcode.** Thunder is audio-only. + +### 3.3 No TimeSync / GameTime / Weather messages +`references/ACE/Source/ACE.DatLoader/Entity/GameTime.cs` parses a DAT structure — `ZeroTimeOfYear`, `ZeroYear`, `DayLength`, `DaysPerYear`, `YearSpec`, `TimesOfDay[]`, `DaysOfTheWeek[]`, `Seasons[]`. This is loaded from `client_portal.dat`, not sent over the wire. Content-search across the ACE codebase for "TimeSync", "DayFraction", "SendTime", "class.*Weather", "SyncTime" returns zero hits in the network layer. **The server has no time-of-day or weather channel.** The client computes day fraction locally from `GameTime.ZeroTimeOfYear + elapsed`. + +### 3.4 `SendEnvironChange` usage +`references/ACE/Source/ACE.Server/WorldObjects/Player_Networking.cs:399` — `SendEnvironChange(EnvironChangeType)` wraps `Session.Network.EnqueueSend(new GameMessageAdminEnvirons(...))`. Called from admin/content commands; not driven by a time or weather simulation. + +## 4. holtburger + +- `crates/holtburger-protocol/src/opcodes.rs:192` — `AdminEnvirons = 0xEA60` is **commented out**. +- Content-search across the `holtburger` crates for `EnvironChange`, `Thunder`, `SkyDesc`, `DayGroup`, `TimeOfDay`, "weather" (non-commented), "sky" returns no rendering or parsing code. +- holtburger is a TUI client; it has no notion of sky and discards whatever `0xEA60` packets arrive (no handler). No reference value for our sky port. + +## 5. Chorizite.ACProtocol field documentation + +### 5.1 No Sky types in the protocol +The generated `Types/` directory has no `SkyDesc.*`, `SkyObject.*`, `SkyTimeOfDay.*` files (glob confirmed). Sky is a DAT structure, not a wire message — Chorizite correctly omits it. + +### 5.2 Admin_Environs (protocol.xml:8236) +Direct quote: +```xml + + + +``` +One field. Confirms ACE's layout byte-for-byte. + +### 5.3 EnvrionChangeType enum (generated: `Enums/EnvrionChangeType.generated.cs`) +The XML summary: `"The EnvrionChangeType identifies the environment option set."` Each value carries a comment — Clear ("Removes all overrides"), RedFog ("Sets Red Fog"), …, Thunder1Sound…Thunder6Sound ("Play Thunder1 Sound" … "Play Thunder6 Sound"). No lightning flash. No tint field. No colour override beyond the six fog presets. Admin-only per the protocol doc. + +### 5.4 `DisableMostWeatherEffects` player option (protocol.xml:1372) +``` + +``` +This is a **client-side player preference bit** inside the gameplay options bitfield, not a server control. The fact this option is client-owned is another strong signal that all weather/sky simulation happens on the client from dat data. + +## 6. Cloud-tint origin (answer to "what drives clouds purple at dusk?") + +- **Not from the wire.** Nothing in ACE, Chorizite, or holtburger sends a per-object tint. +- **Not from WorldBuilder's reference.** Its renderer is turned off and even when turned on would pass white. +- **Not from `SkyObjectReplace`.** Its only colour-ish fields are `Transparent`, `Luminosity`, `MaxBright` — scalar brightness/alpha, no RGB. (`SkyObjectReplace.cs:9-12` in ACE.DatLoader and the identical struct in ACViewer.) +- **Source of truth per the DAT:** `SkyTimeOfDay.AmbColor`/`AmbBright` (`ACE.DatLoader/Entity/SkyTimeOfDay.cs:13-16`). The per-keyframe `AmbColor` (BGRA) × `AmbBright` is the ambient lighting used by retail. Our r12 deepdive and `2026-04-22-sky-lighting-decompile.md` infer that retail D3D set this as `D3DRS_AMBIENT` and rendered clouds unlit, so texture × ambient = clouds' time-of-day colour. This matches observed retail behaviour (purple midnight, warm tan dawn). + +The upshot: acdream's current `SkyRenderer.cs:220–222` ("alpha-blended submeshes tinted by `keyframe.AmbientColor`") is **architecturally correct** and ahead of every open-source reference on this point. The only code that does anything with these keyframe colours in the entire open-source AC ecosystem is ours. + +## 7. Lightning / storm flash + +- No GameMessage opcode for a lightning flash (checked ACE and Chorizite exhaustively). +- Thunder is audio-only (`Thunder1Sound`..`Thunder6Sound` = 0x76..0x7B via `AdminEnvirons`). +- Retail's visible lightning flash is therefore **client-driven** — triggered by the current sky/weather keyframe state in the DAT, not by a server push. This is not implemented in WorldBuilder, ACViewer, or holtburger. + +## 8. Matrix: which keyframe fields reach the render + +Legend: **W** = WorldBuilder SkyboxRenderManager (class as-written; remember it is not actually called), **V** = ACViewer, **A** = acdream `SkyRenderer.cs`. + +| Keyframe field | W | V | A | +|------------------------------|-----------|--------------|---------------------------------------------------| +| `DirColor` (sun RGB) | ignored | tree-only | not sampled in sky shader (it's for terrain/mesh) | +| `DirBright` | ignored | tree-only | not sampled in sky shader | +| `DirHeading` / `DirPitch` | ignored | tree-only | fed to `SceneLightingUbo` for scene, not sky | +| `AmbColor` | ignored | tree-only | **`uTint` on alpha submeshes** (`SkyRenderer:222`) | +| `AmbBright` | ignored | tree-only | premultiplied into `AmbientColor` in loader | +| `WorldFogColor` | ignored | tree-only | `SkyKeyframe.FogColor`, available but not in sky | +| `MinWorldFog` / `MaxWorldFog`| ignored | tree-only | present in keyframe, not yet consumed | +| `WorldFog` | ignored | tree-only | present, not consumed | +| **Per-replace: `Luminosity`**| ignored | tree-only | `uLuminosity` uniform | +| **Per-replace: `Transparent`**| ignored | tree-only | `uTransparency` uniform | +| **Per-replace: `MaxBright`** | ignored | tree-only | min-clamped into `luminosity` | + +Every "ignored" cell is dead because WorldBuilder's sky class is itself dead (call commented out). If it were re-enabled, the StaticObject shader would still drop every colour because `SunlightColor=0, AmbientColor=1` is hard-coded in the sky UBO write. + +## 9. Implications for acdream + +1. **The audit claim is correct:** WorldBuilder does not drive the cloud tint from keyframe data, and does not call BlendFunc per batch. We cannot use WorldBuilder's output as an oracle for cloud colour. +2. **There is no reference client that renders the retail-style coloured sky.** acdream is extending beyond every peer. Our only ground truth is (a) retail behaviour observed in-game, (b) the `SkyTimeOfDay` field layout, (c) the r12 / 2026-04-22 deep-dives we authored. +3. **Nothing from the network informs sky state** beyond `AdminEnvirons` fog / sound presets. If we implement weather, it must be client-driven from the DAT's `Region.SkyInfo` + `GameTime`, with `AdminEnvirons` strictly applied as an override layer on top. +4. **Lightning flashes must be client-driven** from the current weather keyframe. No GameMessage exists to trigger them. +5. The per-submesh `IsAdditive` switch acdream already does (`SkyRenderer.cs:196-223`) is the right model — no reference does this, but it's the only sensible mapping from retail's mixed mesh surfaces (sun/moon additive, clouds alpha) to modern GL blend state. + +## 10. File / line index + +- WorldBuilder: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:115-325` +- WorldBuilder sky call-site (commented): `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs:957-962` +- WorldBuilder StaticObject shader: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObject.{vert,frag}` +- ACE wire message: `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageAdminEnvirons.cs:1-13` +- ACE opcode: `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs:38` +- ACE enum: `references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs:1-48` +- ACE SkyDesc parse: `references/ACE/Source/ACE.DatLoader/Entity/SkyDesc.cs:1-22` +- ACE SkyTimeOfDay parse: `references/ACE/Source/ACE.DatLoader/Entity/SkyTimeOfDay.cs:1-47` +- ACE SkyObjectReplace parse: `references/ACE/Source/ACE.DatLoader/Entity/SkyObjectReplace.cs:1-26` +- ACE GameTime parse: `references/ACE/Source/ACE.DatLoader/Entity/GameTime.cs:1-39` +- ACViewer (no rendering): `references/ACViewer/ACViewer/Entity/Sky*.cs` +- Chorizite protocol: `references/Chorizite.ACProtocol/Chorizite.ACProtocol/protocol.xml:140, 8236-8238, 1909` +- Chorizite generated enum: `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Enums/EnvrionChangeType.generated.cs` +- holtburger opcode (commented): `references/holtburger/crates/holtburger-protocol/src/opcodes.rs:191-192` +- Our SkyRenderer: `src/AcDream.App/Rendering/Sky/SkyRenderer.cs:85-234` +- Our SkyKeyframe: `src/AcDream.Core/World/SkyState.cs:48-50` diff --git a/docs/research/2026-04-23-sky-retail-verbatim.md b/docs/research/2026-04-23-sky-retail-verbatim.md new file mode 100644 index 0000000..d2dfb11 --- /dev/null +++ b/docs/research/2026-04-23-sky-retail-verbatim.md @@ -0,0 +1,595 @@ +# Sky Rendering — Retail-Verbatim Investigation (SYNTHESIS) + +**Date:** 2026-04-23 +**Status:** RESEARCH COMPLETE — decompile-verified. Ready to plan port. +**Inputs:** three parallel hunt agents (A: Region loader; B: D3D state; C: global state + keyframe interp) + references cross-ref + dat schema map. +**Deliverables:** retail function map, struct layouts, globals, pseudocode, port plan. + +## 0. TL;DR — the retail sky pipeline in three sentences + +1. Retail renders a small list of sky `GfxObj` meshes (sun, moon, stars, clouds, dome) through the **normal mesh render queue** — no bespoke sky shader, no camera-anchored sky projection, no D3DRS_AMBIENT writes. +2. The "sky colors" you see at dusk come from **per-vertex lighting on non-Luminous sky meshes** (ambient + diffuse × sun), plus fog (`D3DRS_FOGCOLOR/START/END` updated per keyframe), plus any `SkyObjReplace` that swaps a mesh for a time-of-day variant. +3. Keyframe interpolation gates on `LightTickSize` — lighting only updates every ~2 s, so the sky color marches in visible steps (consistent with live retail feel). + +--- + +## 1. Full retail function map + +All citations are relative to `C:\Users\erikn\source\repos\acdream\docs\research\decompiled\`. + +| Address | File | Name | Purpose | +|---|---|---|---| +| `FUN_004ff370` | chunk_004F0000.c:10610 | `Region::Load` | Dat loader. Calls `FUN_00415730(id, 0xb, 0x1c)` (type-index 0x1c = REGION). Stores result at `DAT_0084247c`. | +| `FUN_004ff420` | chunk_004F0000.c:10680 | `Region::UpdateSkyObjectsTrampoline` | Guards + delegates to `FUN_00501990`. | +| `FUN_004ff440` | chunk_004F0000.c:10692 | `Region::LerpSunAndAmbient` | Guards → `FUN_00501600`. | +| `FUN_004ff480` | chunk_004F0000.c:10708 | `Region::LerpFogAmbient` | Guards → `FUN_00501860`. | +| `FUN_004ff4b0` | chunk_004F0000.c:10724 | `Region::UpdateSkyObjectTable` | Guards → `FUN_00502a10`. | +| **`FUN_00501530`** | chunk_00500000.c:1097 | **Keyframe bracket-picker** | Walks sorted keyframes; returns `k1, k2, u = (t - k1.Begin) / (k2.Begin - k1.Begin)`. Last-slot wrap: `k2 = arr[0]`, denominator uses `1.0f` (`_DAT_007938b0`). | +| **`FUN_00501600`** | chunk_00500000.c:1155 | **Sun + ambient interpolator** | Lerps DirBright, DirColor, DirHeading, DirPitch, AmbColor; emits `sunVec = DirBright * (sin yaw cos pit, cos yaw cos pit, sin pit)`. | +| **`FUN_00501860`** | chunk_00500000.c:1238 | **Fog interpolator** | Lerps MinWorldFog, MaxWorldFog, WorldFogColor. | +| `FUN_00501990` | chunk_00500000.c:1276 | Deterministic PES roll | Uses `DAT_008ee9c8` hash to pick physics-script variant. | +| `FUN_00501b20` | chunk_00500000.c:1384 | `SkyObject::Unpack` | 0x2c-byte struct. See §3. | +| `FUN_00501cd0` | chunk_00500000.c:1500 | `SkyObjectReplace::Unpack` | 0x1c-byte struct. See §3. | +| `FUN_00501f50` | chunk_00500000.c:1669 | **Region preloader** | Iterates every SkyObject + SkyObjectReplace, issues `FUN_0041a4e0(type=8, id)` for each GfxObjId. | +| `FUN_00502100` | chunk_00500000.c:1784 | `SkyTimeOfDay::Unpack` | 0x38-byte struct. See §3. | +| `FUN_005025c0` | chunk_00500000.c:2124 | `DayGroup::Unpack` | Reads `ChanceOfOccur + DayName + SkyObjects[] + SkyTime[]`. | +| **`FUN_00502820`** | chunk_00500000.c:2279 | **`SkyDesc::Unpack`** | 2 doubles (TickSize, LightTickSize) + DayGroup list. The signature hit that confirmed the trail. | +| **`FUN_00502a10`** | chunk_00500000.c:2407 | **Per-frame sky-object table builder** | Iterates `DayGroup.SkyObjects`, checks visibility, lerps `BeginAngle→EndAngle` by `u`, applies `SkyObjReplace` overrides. Emits 0x2c-byte entries per visible object. | +| **`FUN_00505f30`** | chunk_00500000.c:6026 | **Apply-light-state** | Writes globals `DAT_00842778/7c/80/950..58`. Writes D3D light slots `_DAT_008682bc..d4`. Calls `FUN_004530e0` (set D3D directional light). **Loops landblock grid, runs `FUN_00532440` (AdjustPlanes) to re-light every cell.** | +| **`FUN_005062e0`** | chunk_00500000.c:6213 | **Per-frame sky tick** | Every frame: draws sky (`FUN_00508010`); every `TickSize` sec: runs light interp + publish; every `LightTickSize` sec: runs fog interp + publish. Also handles weather crossfade. | +| `FUN_00506d90` | chunk_00500000.c:6683 | Scene renderer (weather phase caller) | Calls `FUN_00507a50(0)` = phase-0 weather volume, `FUN_00507a50(1)` = phase-1 overlay. | +| `FUN_00507a50` | chunk_00500000.c:7250 | **Weather-volume draw** (Agent B's find) | Not sky per se — draws rain/snow/fog-shaft scene-graph objects with Zwrite OFF, far plane × `_DAT_007c6f14`. | +| **`FUN_00508010`** | chunk_00500000.c:7535 | **Sky-object render loop** | Refreshes per-frame table via `FUN_004ff4b0`, iterates sky objects, resets transform (`FUN_00535b30`), applies two-axis rotation (`FUN_005079e0`), queues mesh draw (`FUN_00514b90`), applies per-object Luminosity/MaxBright/Transparent overrides (`FUN_00512360/5124b0/5120c0`) scaled by `_DAT_007a1870`. | +| `FUN_005079e0` | chunk_00500000.c:7229 | Two-axis sky-object transform | `FUN_00536b80(angleA)` then `FUN_005364e0({0, -angleB × π/180, 0})`. | +| `FUN_00514b90` | chunk_00510000.c | Mesh-draw enqueue | Shared with ALL mesh rendering (sky, entity, static). Sky meshes use the same code path as everything else. | +| `FUN_004530e0` | (not fully read) | D3D SetDirectionalLight | Writes `(|sunVec| × _DAT_0079a1e8 + DirBright, DirColor)` into D3D. | +| `FUN_00532440` | (not fully read) | **AdjustPlanes** (per-cell relight) | Recomputes per-vertex lighting on every terrain cell after a lighting tick. | +| `FUN_00451a60` | chunk_00450000.c:608 | ARGB byte-unpack | `(R, G, B, A) bytes → 3 floats × (1/255)` into scratch slots then copied to `_DAT_008682bc/c0/c4` as per-mesh ambient. | + +--- + +## 2. Per-frame call graph + +``` +Region::FrameTick (FUN_005062e0) [every frame] + │ + ├── if skyEnabled: Region::RenderSkyObjects (FUN_00508010) [draws sky] + │ ├── Region::UpdateSkyObjectTable (FUN_004ff4b0 → FUN_00502a10) + │ │ └── SkyTimeOfDayList::FindBracket (FUN_00501530) + │ └── foreach visible sky-object: + │ ├── reset transform (FUN_00535b30 = identity) + │ ├── SkyObjectTransform::ApplyRotations (FUN_005079e0) + │ │ ├── FUN_00536b80(headingFromReplaceOrZero) + │ │ └── FUN_005364e0({0, -arcAngle·π/180, 0}) + │ ├── FUN_00514b90(transform) # enqueue mesh draw + │ ├── if rep.Luminosity > 0: FUN_00512360(0, rep.Luminosity × _DAT_007a1870, 0, 0) + │ ├── if rep.MaxBright > 0: FUN_005124b0(0, rep.MaxBright × _DAT_007a1870, 0, 0) + │ └── if rep.Transparent ≥ 0: FUN_005120c0( rep.Transparent × _DAT_007a1870, 0, 0) + │ + └── if (nextLightTick ≤ gameClock) AND Region is loaded: [every TickSize seconds] + ├── dayFraction = *(float *)(DAT_008ee9c8 + 0x48) # from player/time global + │ + ├── if (nextKeyframeTick < gameClock): [every LightTickSize seconds] + │ ├── Region::LerpSunAndAmbient (FUN_004ff440 → FUN_00501600) + │ │ # out: DirBright, DirColor(ARGB), sunVec[3], AmbColor(ARGB) + │ │ # sunVec = DirBright * (sin yaw cos pit, cos yaw cos pit, sin pit) + │ ├── clamp DirBright ≥ DAT_0084295c (MinWorldFog floor) + │ ├── if (weatherCrossfade): blend toward weather-override values + │ └── Region::ApplyLightState (FUN_00505f30) + │ ├── DAT_00842780 = DirBright + │ ├── DAT_0084277c = DirColor # packed ARGB + │ ├── DAT_00842950/54/58 = sunVec # scaled by DirBright (NOT a unit vector) + │ ├── DAT_00842778 = AmbColor # packed ARGB + │ ├── _DAT_008682bc/c0/c4 = unpack(AmbColor) / 255 # per-mesh ambient RGB floats + │ ├── _DAT_008682c8/cc/d0 = sunVec # D3D light direction + │ ├── FUN_004530e0(|sunVec| × 0079a1e8 + DirBright, DirColor) # SetDirectionalLight + │ └── foreach landblock cell: FUN_00532440(cell) # AdjustPlanes per-cell + │ + ├── fog gate: FUN_005a4010(!DAT_0081dbf8) # master fog enable + └── if (DAT_0081dbf8): Region::LerpFogAmbient (FUN_004ff480 → FUN_00501860) + # out: fogNear, fogFar, fogColor(ARGB) + ├── if (weatherCrossfade): blend toward weather-override fog + └── FUN_005a41b0(&fogColor, fogNear, fogFar) + └── FUN_005a4080: writes D3DRS_FOGCOLOR=34, FOGSTART=36, FOGEND=37 +``` + +--- + +## 3. Struct layouts (decompile-verified) + +### 3.1 Region + +``` +Region (DBObj, 0x13000000..0x1300FFFF, type-index 0x1c) + +0x00 vtable (ref-count release at +0x14) + +0x44 (unknown) used in chunk_00530000.c:2521 + +0x50 SkyDesc* pointer to SkyDesc; 0 = no sky info +``` + +`DAT_0084247c = current Region*` (global; set by `FUN_004ff370`, cleared by `FUN_004ff3b0`). + +### 3.2 SkyDesc (chunk_00500000.c:2279 FUN_00502820) + +``` +SkyDesc + +0x00 vtable + +0x08 TickSize (double) seconds per frame tick + +0x10 LightTickSize (double) seconds per lighting interp step + +0x18 DayGroup[] ptr + +0x1c DayGroup capacity + +0x20 DayGroup count +``` + +### 3.3 DayGroup (chunk_00500000.c:2124 FUN_005025c0) — 0x20 bytes + +``` +DayGroup + +0x00 vtable + +0x04 ChanceOfOccur (float) PDF weight for weather roll + +0x08 DayName (PString) + +0x14 SkyObjects[] ptr 0x2c bytes each + +0x18 SkyObjects capacity + +0x1c SkyObjects count + ... SkyTime[] lives in a trailing section (not fully mapped; see Agent A §7) +``` + +### 3.4 SkyObject (chunk_00500000.c:1384 FUN_00501b20) — 0x2c bytes + +``` +SkyObject + +0x00 ref/runtime init to 0xbf800000 (-1.0f "not set") + +0x04 BeginTime (float) day-fraction window start [0..1] + +0x08 EndTime (float) wraps when End < Begin + +0x0c BeginAngle (float) degrees arc-sweep start + +0x10 EndAngle (float) degrees arc-sweep end + +0x14 TexVelocityX (float) UV/sec scroll rate + +0x18 TexVelocityY (float) + +0x1c runtime GfxObjRef NOT from stream; init 0 + +0x20 Properties (uint) flag bits (undecoded; possibly billboard) + +0x24 DefaultGfxObjectId + +0x28 DefaultPesObjectId +``` + +Read order from stream: `BeginTime, EndTime, BeginAngle, EndAngle, TexVelocityX, TexVelocityY, DefaultGfxObjectId, DefaultPesObjectId, Properties` — matches dats.xml 1:1. + +### 3.5 SkyTimeOfDay (chunk_00500000.c:1784 FUN_00502100) — 0x38 bytes + +Wire read order: `Begin, DirBright, DirHeading, DirPitch, DirColor, AmbBright, AmbColor, MinWorldFog, MaxWorldFog, WorldFogColor, WorldFog`. **Struct writes WorldFog LAST to offset 0x1c**, so the in-memory layout reorders: + +``` +SkyTimeOfDay + +0x00 Begin (float) + +0x04 DirBright (float) + +0x08 DirHeading (float, degrees) + +0x0c DirPitch (float, degrees) + +0x10 DirColor (ColorARGB) + +0x14 AmbBright (float) + +0x18 AmbColor (ColorARGB) + +0x1c WorldFog (uint, D3D fog mode: 0=off,1=linear,2=exp,3=exp2) + +0x20 MinWorldFog (float, meters) + +0x24 MaxWorldFog (float, meters) + +0x28 WorldFogColor (ColorARGB) + +0x2c SkyObjectReplace[] ptr + +0x30 SkyObjReplace capacity + +0x34 SkyObjReplace count +``` + +### 3.6 SkyObjectReplace (chunk_00500000.c:1500 FUN_00501cd0) — 0x1c bytes + +``` +SkyObjectReplace + +0x00 ObjectIndex (uint) index into DayGroup.SkyObjects + +0x04 runtime SkyObject* NOT from stream; resolved in FUN_005025c0:2249 + +0x08 GfxObjId (QualifiedDataId) 0 = keep default + +0x0c Rotate (float, degrees heading) + +0x10 Transparent (float) + +0x14 Luminosity (float) (0xbf800000 = -1.0f init) + +0x18 MaxBright (float) +``` + +### 3.7 Per-frame SkyObject render entry (FUN_00502a10 output) — 0x2c bytes + +``` +Entry + +0x00 GfxObjId (default or SkyObjectReplace override) + +0x04 PesObjectId + +0x08 (reset marker / zero) + +0x0c CurrentArcAngle (deg) = lerp(BeginAngle, EndAngle, u) + +0x10 TexVelocityX + +0x14 TexVelocityY + +0x18 (runtime slot) + +0x1c Transparent (from SkyObjectReplace iff Transparent > 0 sentinel) + +0x20 Luminosity (from SkyObjectReplace iff Luminosity > 0 sentinel) + +0x24 MaxBright (from SkyObjectReplace iff MaxBright > 0 sentinel) + +0x28 Properties (SkyObject.Properties) +``` + +--- + +## 4. Globals — the sky state block + +Two clusters at `0x00842778..0x008427bf` (primary state + crossfade) and `0x00842950..0x0084295f` (sun vector + fog floor): + +| Global | Role | Producer | Consumer(s) | +|---|---|---|---| +| `DAT_0084247c` | current Region* | `FUN_004ff370` | every sky accessor | +| `DAT_00842778` | current AmbColor (packed ARGB) | `FUN_00505f30` | per-vertex lighting in chunk_00530000.c:2100-2105 | +| `DAT_0084277c` | current DirColor (packed ARGB, also fog tint) | `FUN_00505f30` | per-vertex lighting, fog | +| `DAT_00842780` | current DirBright (float) | `FUN_00505f30` | per-vertex lighting (ambient scalar); fog-start offset | +| `DAT_00842790` | cloud/stars heightmap buffer ptr | `FUN_00508010` realloc | sky-object draw | +| `DAT_00842798` | next cloud/weather-tick deadline (double) | `FUN_005062e0` | `FUN_005062e0` | +| `DAT_008427a0` | next keyframe-tick deadline (double) | `FUN_005062e0` | `FUN_005062e0` | +| `DAT_008427a8` | FixedLight override enable (bool) | `FUN_00505d40` | `FUN_00505f30` | +| `DAT_008427a9` | weather crossfade active (bool) | weather code | `FUN_005062e0`, `FUN_00508010` | +| `DAT_008427b8` | crossfade u (0..1) | `FUN_005062e0` | `FUN_005062e0` | +| `DAT_00842950/54/58` | sun vector × DirBright (3 floats) | `FUN_00505f30` | per-vertex terrain `chunk_00530000.c:2094-2230`; landblock ambient apply `chunk_00450000.c:4337-4468` | +| `DAT_0084295c` | MinWorldFog clamp floor (float) | region config | `FUN_005062e0:6253-6254` | +| `_DAT_008682b0/b4/b8` | D3D directional-light RGB = `DirColor × DirBright` | `FUN_004530e0` | D3D pipeline | +| `_DAT_008682bc/c0/c4` | D3D per-mesh ambient RGB (unpacked from AmbColor/255) | `FUN_00451a60 → FUN_00505f30` | per-mesh lighting | +| `_DAT_008682c8/cc/d0` | D3D directional-light direction (copy of sun vec) | `FUN_00505f30` | D3D pipeline | +| `DAT_008ee9c8` | world/time global (dayFraction at +0x48, tick counters) | world-update (NOT FOUND) | `FUN_005062e0`, `FUN_00508010` | +| `_DAT_007938b0` | `1.0f` (day-fraction upper bound) | `.rdata` constant | keyframe wrap denominator | +| `_DAT_0079c6b0` | `π/180` (deg → rad) | `.rdata` constant | sun-direction math, mesh rotation | +| `_DAT_00799208` | `1/255f` | `.rdata` constant | ARGB byte → float | +| **`_DAT_007a1870`** | **Scale for T/L/MB overrides** (likely 1.0f, unconfirmed) | `.rdata` constant | `FUN_00508010:7588-7594` | +| `DAT_00796344` | **`0.0f` sentinel** ("don't override if ≤ 0") — confirmed via threshold comparisons in chunk_004F0000.c:9152, 9172 | `.rdata` | `FUN_00508010:7587,7590,7593` | + +--- + +## 5. The sun-direction formula (decompile-verified, chunk_00500000.c:1192-1205) + +``` +yaw_rad = DirHeading × (π/180) # _DAT_0079c6b0 +pitch_rad = DirPitch × (π/180) +mag = DirBright + +x = mag × sin(yaw) × cos(pitch) +y = mag × cos(yaw) × cos(pitch) +z = mag × sin(pitch) +``` + +Coordinate frame: X=east, Y=north, Z=up (Dereth world). Matches our `SkyStateProvider.SunDirectionFromKeyframe` **except** retail scales by `DirBright` (so the vector magnitude ≠ 1). The magnitude is deliberately retained — downstream code uses `|sunVec|` as a fog-start offset in `FUN_00505f30:6067`. + +--- + +## 6. The cloud-tint puzzle — RESOLVED + +User's observation: retail clouds show a "purple haze" at dusk; acdream clouds stay white. + +**Mechanism (cross-referenced via Agent A + B + cross-ref):** +1. Sky meshes are drawn through the **normal mesh render queue** (`FUN_00514b90`), same as terrain / entities / statics. +2. **D3DRS_AMBIENT is set to 0 once at init and never changes** (Agent B confirmed). The keyframe AmbColor does NOT drive D3DRS_AMBIENT. +3. Instead, the keyframe AmbColor (ARGB byte) is unpacked via `FUN_00451a60` into **three float slots** `_DAT_008682bc/c0/c4`, which are **per-mesh ambient RGB** used by the per-vertex lighting pipeline. +4. `FUN_00532440` (AdjustPlanes) re-lights EVERY CELL on every keyframe tick — so terrain and mesh vertex colors are baked in from `(DirColor × DirBright)` dot-normal + AmbColor. +5. **A mesh's Surface.Type flags determine whether it participates in lighting:** + - `Luminous` (0x40) set → self-illuminated, texture passthrough (sun, moon, stars, likely dome) + - `Luminous` clear → gets `(ambient + diffuse × sun) × texture`, like any other mesh +6. Clouds without Luminous get the ambient tint naturally. No special "sky shader" path exists in retail. + +**Implication for our port:** +- Our sky shader multiplying by `uTint = AmbientColor` is architecturally close but too broad — it tints ALL non-additive sub-meshes including the dome. +- The correct split is **by Surface.Luminous flag**, not by blend mode. +- Dome is Luminous (preserves baked gradient); clouds are non-Luminous (get ambient tint). + +**Caveat (Agent A §7 gap #5):** the actual Luminous status of each retail sky mesh's Surface is NOT dumped here. We need a runtime diagnostic to confirm dome is Luminous and clouds are not. + +--- + +## 7. The Transparent / Luminosity / MaxBright unit question + +`FUN_00508010:7588-7594` applies overrides as: + +```c +FUN_00512360(0, rep.Luminosity × _DAT_007a1870, 0, 0); +FUN_005124b0(0, rep.MaxBright × _DAT_007a1870, 0, 0); +FUN_005120c0( rep.Transparent × _DAT_007a1870, 0, 0); +``` + +**`_DAT_007a1870` appears elsewhere** (`chunk_00510000.c:7644,7660`) as the FALLBACK "default when no override" return value — this is strongly consistent with **`_DAT_007a1870 = 1.0f`** (identity for Luminosity / scale). + +If `_DAT_007a1870 = 1.0f`: +- Retail applies `Luminosity × 1.0 = raw dat value`. +- Our `/100` fix assumes the dat is in percent (0..100) and wants a scale-back-down. +- If dat Luminosity is already a fraction (0..1) — like `Surface.Luminosity = 0.3f` in the test fixture — our `/100` is WRONG (turns 0.3 into 0.003). + +If `_DAT_007a1870 = 0.01f`: +- Retail applies `Luminosity × 0.01 = raw dat value ÷ 100`. +- Our `/100` correctly matches retail behavior. + +**Decision:** **Need a real binary inspection of `0x007a1870` in `acclient.exe` to pin this.** Our user-confirmed "much better" observation after `/100` weakly supports `_DAT_007a1870 = 0.01f` (or equivalently, dat-in-percent with an implicit ×0.01 somewhere). Until binary-confirmed, TREAT THE `/100` AS SPECULATIVE. + +Action: add a `dump-sky-desc` diag that prints raw SkyObjectReplace floats from a live dat; if values are consistently 0..100, `/100` is right and `_DAT_007a1870 = 0.01f`. + +--- + +## 8. Architecture contrast: our SkyRenderer vs retail + +| Aspect | acdream (current) | Retail | Action | +|---|---|---|---| +| Sky shader | Dedicated `sky.vert/frag` with `uTint`, `uLuminosity`, `uTransparency` | None — reuses normal mesh pipeline | **Unify**: route sky meshes through InstancedMeshRenderer with the SceneLighting UBO. OR: keep the dedicated shader but make it read the UBO's ambient/sun. | +| Camera-anchored sky | Yes — view matrix zeroed, far plane 1e6 | NO camera anchor (Agent B confirmed). Sky meshes live in world space, follow the camera via scene-graph parent. | Decide: is our camera-anchor a visible improvement, or a deviation? Retail may stretch the sky as the camera moves — which is visible at speed. | +| Per-keyframe tint | `uTint = 1` (eeae83a) OR `uTint = AmbientColor` (current uncommitted) | Per-vertex lighting driven by Surface.Luminous flag — ambient multiply happens per-fragment on non-Luminous meshes | **Replace blind `uTint = AmbientColor` with per-surface conditional** — Luminous meshes untinted, non-Luminous meshes get `(ambient + diffuse·sun) × texture` | +| Blend mode per sub-mesh | `SrcAlpha/One` for Additive; `SrcAlpha/OneMinusSrcAlpha` otherwise | Same pattern expected (Additive sun/moon, alpha clouds), but retail uses per-Surface-flag routing, not per-submesh classification | Keep current classification; it matches retail's intent. | +| `Transparent/Luminosity/MaxBright` | `/100` in loader, applied as uniforms | Applied via `FUN_00512360/5124b0/5120c0` scaled by `_DAT_007a1870` (likely 1.0f or 0.01f) | **Unresolved — see §7.** Do NOT change until binary-confirmed. | +| Keyframe interpolation | Lerps between 2 keyframes with wrap | Same algorithm (`FUN_00501530` — identical logic, including the `1.0f` wrap denominator) | **Match**, no change. | +| Lighting tick rate | Every frame | Every `LightTickSize` seconds (~2s) | **Port**: throttle the UBO update to `LightTickSize`. This produces retail's visible "step" in sky color transitions. | +| Per-cell relight | Not done | `FUN_00532440` (AdjustPlanes) reruns per-vertex lighting on every cell each keyframe tick | **Port** for terrain only — matches the "sky gets darker → terrain actually gets darker too" behavior. | +| Fog | Shader uniform, not updated per keyframe for sky | D3DRS_FOGCOLOR/START/END updated per keyframe via `FUN_005a41b0` | **Port**: UBO-update fog per keyframe. | +| UV scroll (TexVelocityX/Y) | We apply it | WorldBuilder doesn't; retail does via the per-object table (inferred — scroll state is part of the 0x2c-byte render entry) | Keep; matches retail intent. | + +--- + +## 9. Retail-verbatim pseudocode + +### 9.1 Region frame tick (port of `FUN_005062e0`) + +``` +def RegionFrameTick(landblockGrid): + if not skyEnabled: return + + if skyObjectsTableReady: + RenderSkyObjects(skyTable) # every frame — see 9.2 + + now = gameClockSeconds + if nextSkyTick <= now and currentRegion is not None: + nextSkyTick = currentRegion.SkyDesc.TickSize + now + dayFraction = worldTime.dayFraction # *(float*)(DAT_008ee9c8 + 0x48) + + # Keyframe interp gate — every LightTickSize seconds + if nextLightTick < now: + ok = LerpSunAndAmbient(dayFraction, &DirBright, &DirColor, &sunVec, &AmbColor) + if ok: + if DirBright < MinWorldFogFloor: # DAT_0084295c + DirBright = MinWorldFogFloor + if weatherCrossfadeActive: + blend(DirBright, DirColor, toward = weather override) + advance crossfade u by step constant + ApplyLightState(DirBright, DirColor, sunVec, AmbColor) # 9.3 + + nextLightTick = currentRegion.SkyDesc.LightTickSize + now + + # Fog interp + SetFogEnable(fogMasterFlag) + if fogMasterFlag: + ok2 = LerpFogAmbient(dayFraction, &fogNear, &fogFar, &fogColor) + if ok2: + if weatherCrossfadeActive: + blend(fogNear, fogFar, fogColor, toward = weather override) + SetFogState(fogColor, fogNear, fogFar) # D3DRS_FOGCOLOR/START/END +``` + +### 9.2 Sky-object render loop (port of `FUN_00508010`) + +``` +def RenderSkyObjects(table): + EnsureSkyDescLoaded() + dayFraction = playerWorldTime.dayFraction + ok = UpdateSkyObjectTable(dayFraction, table) # FUN_004ff4b0 → FUN_00502a10 + if not ok: return + + FUN_00507e20() # probably advances a counter + + for i in range(table.count): + entry = table.entries[i] # 0x2c-byte struct per 3.7 + if entry.GfxObjId == 0: continue + + # Reset local transform to identity + transform = IDENTITY + + # Special "custom position" path (flag & 4 — first entry only?) + if (entry.propertiesFlag & 4): + transform.translation = (entry.customX, entry.customY, entry.customZ) + + # Apply two-axis rotation — see FUN_005079e0 + # axis1 = unknown quaternion from table[+0x08] + # axis2 = current arc angle (scalar degrees) + ApplyRotations(transform, entry.axis1Quat, entry.arcAngleDeg) + + # Enqueue mesh draw through the normal render queue + EnqueueMeshDraw(entry.GfxObjId, transform) + + # Apply per-keyframe overrides scaled by _DAT_007a1870 (likely 1.0f) + scale = _DAT_007a1870 + if entry.Luminosity > 0: SetLuminosity( entry.Luminosity × scale) + if entry.MaxBright > 0: SetMaxBright( entry.MaxBright × scale) + if entry.Transparent >= 0: SetTransparency(entry.Transparent × scale) +``` + +### 9.3 Apply-light-state (port of `FUN_00505f30`) + +``` +def ApplyLightState(DirBright, DirColor, sunVec3, AmbColor): + # Publish globals + worldSkyState.DirBright = DirBright + worldSkyState.DirColor = DirColor + worldSkyState.sunVec = sunVec3 # NOT unit — carries |sunVec| = DirBright + worldSkyState.AmbColor = AmbColor + + # Unpack ambient ARGB to 3 floats (× 1/255) + worldSkyState.ambientRGB = ColorARGB_to_RGB_floats(AmbColor) + + # Copy sun vector into "D3D direction" slot (what we call sunDirection uniform) + d3dLight.direction = sunVec3 + + # SetDirectionalLight with pre-scaled power + SetDirectionalLight( + power = |sunVec3| × _DAT_0079a1e8 + DirBright, + color = DirColor) + + # Per-cell relight — this is the big one + for cell in landblockGrid: + AdjustPlanes(cell) # FUN_00532440: recomputes per-vertex lit colors +``` + +### 9.4 Interp (port of `FUN_00501600`) + +``` +def LerpSunAndAmbient(u, out_DirBright, out_DirColor, out_sunVec, out_AmbColor): + (k1, k2, t) = FindBracket(skyTimeOfDayList, u) # FUN_00501530 + if not found: + out_DirBright = 0.3 + out_DirColor = 0xFFFFFFFF + out_AmbColor = 0xFFFFFFFF + out_sunVec = (0.5, 0, 0.8) + return False + + # Lerp the keyframe scalars (NOTE: offsets per §3.5 — struct layout is + # Begin@0, DirBright@4, DirHeading@8, DirPitch@0xc, DirColor@0x10, + # AmbBright@0x14, AmbColor@0x18) + DirBright = lerp(k1.DirBright, k2.DirBright, t) + DirHeadingDeg = lerp(k1.DirHeading, k2.DirHeading, t) # shortest-arc lerp! + DirPitchDeg = lerp(k1.DirPitch, k2.DirPitch, t) + + # Sun vector — NOT a unit vector; scaled by DirBright + yaw = DirHeadingDeg × π/180 + pit = DirPitchDeg × π/180 + out_sunVec = ( + DirBright × sin(yaw) × cos(pit), # X east + DirBright × cos(yaw) × cos(pit), # Y north + DirBright × sin(pit), # Z up + ) + + # Byte-lerp each ARGB channel + out_DirColor = byte_lerp(k1.DirColor, k2.DirColor, t) + out_AmbColor = byte_lerp(k1.AmbColor, k2.AmbColor, t) + + # NOTE: `FUN_00501600` decompile shows AmbBright (+0x14) is NOT used to + # scale the interpolated AmbColor at this call site. The scale is applied + # downstream, possibly in the D3D bridge that consumes _DAT_008682bc/c0/c4. + # See Agent A §7 gap #6. + + out_DirBright = DirBright + return True +``` + +### 9.5 Bracket-picker (port of `FUN_00501530` — verbatim) + +``` +def FindBracket(keyframes, t): + count = keyframes.count + if count == 0: return (None, None, 0) + if count == 1: return (keyframes[0], keyframes[0], 0) + + i = 0 + while i < count - 1: + if t < keyframes[i + 1].Begin: break + i += 1 + + k1 = keyframes[i] + if i == count - 1: + # Wrap — last keyframe to first + k2 = keyframes[0] + u = (t - k1.Begin) / (1.0 - k1.Begin) # denominator is 1.0, NOT k2.Begin + else: + k2 = keyframes[i + 1] + u = (t - k1.Begin) / (k2.Begin - k1.Begin) + + return (k1, k2, u) +``` + +--- + +## 10. Port plan + +### Phase 1: Safe revert + diagnostics (1 commit) + +1. Revert uncommitted `SkyRenderer.cs` tint change → baseline `uTint = 1` for every sub-mesh (eeae83a state). +2. Add a one-shot `ACDREAM_DUMP_SKY=1` diagnostic that on Region load prints: + - Every SkyObject: index, GfxObjId, BeginTime/EndTime, BeginAngle/EndAngle, TexVel, Properties. + - For each loaded sky GfxObj: its surface list + Surface.Type flags (Luminous, Additive, Alpha, etc) + texture id. + - Every SkyTimeOfDay keyframe: Begin, DirBright, DirHeading/Pitch, AmbBright, colors as hex bytes. + - Every SkyObjectReplace: raw float values for Transparent/Luminosity/MaxBright. +3. Launch, log, inspect. Answers all the Agent A §7 gap questions. + +### Phase 2: Route sky meshes through per-vertex lighting + +Based on Phase 1 findings: +- If DOME is Luminous: uTint stays 1 for it. +- If CLOUDS are non-Luminous: they should get `(ambient + diffuse × sun) × texture`, not a simple ambient multiply. + +Implementation options: +- **A (shader tweak):** Pass `Surface.Luminous` as a per-submesh uniform. Shader branches: Luminous → texture passthrough; non-Luminous → compute `ambient + max(0, dot(N, -sunDir)) × sunColor`. +- **B (architecture unification):** Route sky through `InstancedMeshRenderer` with the same `SceneLighting` UBO terrain/meshes use. No separate `sky.frag`. Each surface's Luminous flag already routes it through the per-surface lighting in the generic mesh path (or should). + +### Phase 3: LightTickSize throttling + per-cell relight + +- Throttle lighting UBO update to `LightTickSize` seconds (retail's ~2s step). +- Port `FUN_00532440` (AdjustPlanes) — recompute per-vertex baked lighting on every terrain cell each light-tick. This is probably what makes retail terrain "follow" the sky so convincingly. + +### Phase 4: Fog + weather crossfade + lightning (later) + +- Port `FUN_00501860` (fog interp) and the `SetFogState` write per keyframe. +- Port the weather crossfade (`DAT_008427a9`/`b8`). +- Lightning — NOT found in the decompile. Either retail doesn't have it or it's a separate code path. Ours is client-driven; keep as an acdream extension. + +--- + +## 11. Open questions (Agent A §7 gaps — need follow-up) + +1. **`_DAT_007a1870` exact value** (§7 above) — requires binary disassembly of `.rdata` at 0x007a1870. Do via `objdump`, Ghidra, or a dat-dump of a live memory address. +2. **FUN_00501600 offset discrepancy** — Agent A says the field offsets in the interp don't match the SkyTimeOfDay unpack. One of two reconciliations: + - The interp uses a SEPARATE in-memory light-keyframe struct (not SkyTimeOfDay), possibly a simplified subset stored inline in DayGroup. Requires reading `FUN_00502a10` more carefully to see what pointer it passes. + - Agent C's offset map (DirBright@+0x04, AmbColor@+0x18) is correct and Agent A's decompile-reading was off-by-one. Agent C matches ACE's `SkyTimeOfDay.cs` layout 1:1 — lean toward C. +3. **DAT_00796344 confirmed `0.0f`** — multiple usage sites confirm (returns this on divide-by-zero fallback, threshold comparison for "has override"). +4. **AmbBright scaling location** — not visible in `FUN_00505f30`. Probably happens inside `FUN_00451a60` or the D3D material bridge. Our loader pre-multiplies AmbColor × AmbBright, which may double-apply. +5. **Surface.Luminous status of Dereth sky GfxObjs** — requires a live dump (Phase 1 diagnostic above). +6. **Lightning flash** — not found in decompile. Confirms it's either absent in retail or a separate path not on this hunt. + +--- + +## 12. Decision trees + +### Immediate action (this conversation) + +``` +if uncommitted SkyRenderer.cs change is still present: + → REVERT to eeae83a baseline (uTint = white for all). + Rationale: current tint change dims the dome gradient (verified + regression). Baseline is the least-wrong state while we plan the + retail-faithful rebuild. + +then: + → implement Phase 1 diagnostic dump to fill the §11 gaps. + → do NOT implement Phase 2+ until the dump resolves the unit + + Luminous-flag questions. +``` + +### Phase 2 fork (after Phase 1 dump) + +``` +if DOME.Surface.Luminous == true AND CLOUDS.Surface.Luminous == false: + → retail-faithful: tint clouds by per-vertex (ambient + diffuse × sun), + keep dome untinted. Matches the retail pipeline exactly. + +elif DOME.Surface.Luminous == true AND CLOUDS.Surface.Luminous == true: + → retail renders both as texture passthrough; the "purple haze" + must come from SkyObjReplace swapping cloud textures at dusk. + Solution: confirm our SkyObjReplace path fires correctly, and + dusk cloud mesh actually has purple baked in. + +elif DOME.Surface.Luminous == false: + → (unexpected) the dome IS tinted by ambient. My initial hypothesis + was wrong. Route dome through the ambient-tint shader path. +``` + +### Phase 3 (per-cell relight) + +``` +if Phase 2 fix looks good but terrain doesn't match: + → port FUN_00532440 AdjustPlanes per-cell relight triggered on + LightTickSize boundary. +``` + +--- + +## 13. References + +- `docs/research/2026-04-23-sky-decompile-hunt-A.md` — Region loader trail. +- `docs/research/2026-04-23-sky-decompile-hunt-B.md` — D3D state trail. +- `docs/research/2026-04-23-sky-decompile-hunt-C.md` — globals + interp trail. +- `docs/research/2026-04-23-sky-references-crossref.md` — WorldBuilder/ACE/ACViewer/holtburger. +- `docs/research/2026-04-23-sky-dat-schema.md` — dat schema. +- `docs/research/deepdives/r12-weather-daynight.md` — foundational doc. +- `docs/research/decompiled/chunk_00500000.c:1097-7535` — the retail sky pipeline source. +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` — nearest-stack reference (dead code but structurally close). +- `references/DatReaderWriter/DatReaderWriter/Generated/Types/*.cs` — canonical dat schemas. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1fba8d7..9c1c784 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3612,7 +3612,7 @@ public sealed class GameWindow : IDisposable if (!cameraInsideCell) { _skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction, - _loadedSkyDesc?.DefaultDayGroup); + _loadedSkyDesc?.DefaultDayGroup, kf); } _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 6bef5dc..cdc26fb 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -72,12 +72,28 @@ public sealed unsafe class SkyRenderer : IDisposable /// /// Draw the sky for this frame. Called FIRST in the render loop — /// terrain / meshes / debug lines / overlay land on top. + /// + /// + /// is accepted for forward-compatibility + /// with the retail-verbatim per-vertex lighting path (see + /// docs/research/2026-04-23-sky-retail-verbatim.md). It is + /// NOT currently consumed by the shader — sky meshes render at + /// uTint = white (texture passthrough). A prior experiment + /// multiplied alpha-blended submeshes by keyframe.AmbientColor + /// to tint clouds; this dimmed the sky dome's baked gradient + /// (user-verified regression) and was reverted. Retail actually + /// routes sky meshes through the normal mesh pipeline with + /// Surface.Type.Luminous controlling lit-vs-unlit per submesh; the + /// correct port lives downstream in Phase 2 once we have the live + /// Surface flags dumped. + /// /// public void Render( ICamera camera, Vector3 cameraWorldPos, float dayFraction, - DayGroupData? group) + DayGroupData? group, + SkyKeyframe keyframe) { if (group is null || group.SkyObjects.Count == 0) return; @@ -167,6 +183,16 @@ public sealed unsafe class SkyRenderer : IDisposable _shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset)); _shader.SetFloat("uTransparency", transparent); _shader.SetFloat("uLuminosity", luminosity); + // uTint stays white: retail renders sky meshes as texture + // passthrough (the gradient lives in the mesh texture, not in + // a shader ambient multiply). D3DRS_AMBIENT is set to 0 once + // at retail device-init and never changes per-frame — verified + // in chunk_005A0000.c (state 0x8b = 139, only external caller + // is the default-reset at line 704). The "cloud tint" effect + // comes from per-vertex lighting on non-Luminous submeshes + // routed through the normal mesh pipeline. That path is + // Phase 2 — see docs/research/2026-04-23-sky-retail-verbatim.md + // §6 + §10 and the hunt-B finding at 2026-04-23-sky-decompile-hunt-B.md. _shader.SetVec4("uTint", Vector4.One); EnsureMeshUploaded(gfxObjId); @@ -174,16 +200,21 @@ public sealed unsafe class SkyRenderer : IDisposable foreach (var sub in subMeshes) { - // Per-submesh blend mode: sun/moon/stars are usually - // Additive or Luminous, clouds are AlphaBlend, star dome - // backing is Opaque (but we still need blend-enabled to - // avoid a hard seam against the sky gradient behind it — - // we map Opaque to a passthrough SrcAlpha/OneMinusSrcAlpha - // with alpha=1, which is equivalent to not blending). + // Per-submesh blend mode: sun/moon/stars are Additive + // (SurfaceType.Additive = 0x10000), clouds are AlphaBlend, + // sky dome is either Opaque or AlphaBlend depending on the + // dat. We map Opaque to a passthrough SrcAlpha/InvSrcAlpha + // with alpha=1, which is equivalent to not blending. This + // split is architecturally correct (sun's additive blend + // stops its black texture background from occluding the + // sky dome behind it) but is NOT how retail does it — + // retail routes sky meshes through the normal mesh pipe + // where Surface flags dictate blend state per primitive. + // See FUN_00508010 (chunk_00500000.c:7535). if (sub.IsAdditive) - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); // additive + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); else - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); // alpha + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); @@ -267,12 +298,63 @@ public sealed unsafe class SkyRenderer : IDisposable return; } + // Phase 1 diagnostic: dump Surface.Type flags on every sky GfxObj + // once, so we can determine which submeshes carry Luminous (0x40) + // vs plain-lit. This settles the retail "cloud tint = per-vertex + // lighting on non-Luminous meshes" hypothesis — see + // docs/research/2026-04-23-sky-retail-verbatim.md §6. + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") + DumpGfxObjSurfaces(gfxObjId, gfx, subMeshes); + var gpuList = new List(subMeshes.Count); foreach (var sm in subMeshes) gpuList.Add(UploadSubMesh(sm)); _gpuByGfxObj[gfxObjId] = gpuList; } + /// + /// Log each surface's raw flag bits and the derived + /// . Called once per GfxObj when + /// ACDREAM_DUMP_SKY=1. Output format is grep-friendly so + /// we can pipe the launch log through | grep sky-dump and + /// recover a complete picture of the Dereth sky without re-running. + /// + private void DumpGfxObjSurfaces( + uint gfxObjId, + GfxObj gfx, + System.Collections.Generic.IReadOnlyList subMeshes) + { + Console.WriteLine( + $"[sky-dump] GfxObj 0x{gfxObjId:X8} Surfaces.Count={gfx.Surfaces.Count} Polygons.Count={gfx.Polygons.Count} SubMeshes.Count={subMeshes.Count}"); + + for (int i = 0; i < gfx.Surfaces.Count; i++) + { + uint surfaceId = (uint)gfx.Surfaces[i]; + DatReaderWriter.DBObjs.Surface? surface = null; + try { surface = _dats.Get(surfaceId); } + catch { surface = null; } + + if (surface is null) + { + Console.WriteLine($"[sky-dump] Surface[{i}] 0x{surfaceId:X8} -- (dat read failed)"); + continue; + } + + // SurfaceType is a flag enum — `ToString()` gives the + // comma-joined names (e.g. "Base1Image, Additive"). + uint rawType = (uint)surface.Type; + string names = surface.Type.ToString(); + uint origTex = surface.OrigTextureId?.DataId ?? 0u; + var trans = TranslucencyKindExtensions.FromSurfaceType(surface.Type); + // Surface's own Luminosity (0..1 fraction per test fixture — + // different from SkyObjectReplace.Luminosity which lives in the keyframe). + Console.WriteLine( + $"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " + + $"OrigTexture=0x{origTex:X8} Translucency={trans} " + + $"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}"); + } + } + private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) { uint vao = _gl.GenVertexArray(); diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index 2deb5b6..e8fb040 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -200,6 +200,16 @@ public static class SkyDescLoader /// Convert an in-memory Region object to our domain data. /// Separated so tests can feed hand-built Regions without the dat /// pipeline. + /// + /// + /// Set ACDREAM_DUMP_SKY=1 in the environment to log the + /// entire decoded SkyDesc (raw dat values, pre-/100 divide) to + /// stdout on load. Paired with the decompile research in + /// docs/research/2026-04-23-sky-retail-verbatim.md — the + /// dump resolves the open questions about Transparent/Luminosity/ + /// MaxBright unit (percent vs fraction) and the per-keyframe + /// GfxObjReplace swap pattern. + /// /// public static LoadedSkyDesc? LoadFromRegion(Region region) { @@ -208,6 +218,10 @@ public static class SkyDescLoader return null; var sky = region.SkyInfo; + + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") + DumpRegionSkyDesc(region); + var dayGroups = new List(sky.DayGroups.Count); foreach (var dg in sky.DayGroups) @@ -232,6 +246,80 @@ public static class SkyDescLoader }; } + /// + /// One-shot diagnostic dump of the retail Region's SkyDesc. Prints + /// every DayGroup, SkyObject, SkyTimeOfDay, and SkyObjectReplace + /// with RAW dat values (before any unit transform) so we can compare + /// against the retail decompile field layouts and resolve: + /// + /// The unit of Transparent/Luminosity/MaxBright + /// (if consistently >1, they're percent — our /100 divide is correct; + /// if consistently in [0,1], they're fractions and the divide is wrong). + /// Which DayGroup keyframes actually swap the + /// GfxObj (non-zero GfxObjId) and which just tweak brightness. + /// The full Dereth sky-object inventory — + /// index-to-role mapping (sun/moon/dome/clouds/stars). + /// + /// Logs to stdout with the prefix [sky-dump]. Gate with + /// ACDREAM_DUMP_SKY=1. + /// + private static void DumpRegionSkyDesc(Region region) + { + var sky = region.SkyInfo; + if (sky is null) return; + + Console.WriteLine("[sky-dump] ======== BEGIN SkyDesc dump ========"); + Console.WriteLine($"[sky-dump] Region Id={region.Id:X8} Number={region.RegionNumber} Name=\"{region.RegionName}\""); + Console.WriteLine($"[sky-dump] SkyDesc TickSize={sky.TickSize} LightTickSize={sky.LightTickSize} DayGroups.Count={sky.DayGroups.Count}"); + + for (int g = 0; g < sky.DayGroups.Count; g++) + { + var dg = sky.DayGroups[g]; + Console.WriteLine($"[sky-dump] DayGroup[{g}] Name=\"{dg.DayName}\" Chance={dg.ChanceOfOccur:F3} SkyObjects.Count={dg.SkyObjects.Count} SkyTime.Count={dg.SkyTime.Count}"); + + for (int i = 0; i < dg.SkyObjects.Count; i++) + { + var o = dg.SkyObjects[i]; + uint gfxId = o.DefaultGfxObjectId?.DataId ?? 0u; + uint pesId = o.DefaultPesObjectId?.DataId ?? 0u; + Console.WriteLine( + $"[sky-dump] SkyObject[{i}] GfxObjId=0x{gfxId:X8} PesObjectId=0x{pesId:X8} " + + $"Time=[{o.BeginTime:F4}..{o.EndTime:F4}] Angle=[{o.BeginAngle:F1}°..{o.EndAngle:F1}°] " + + $"TexVel=({o.TexVelocityX:F5},{o.TexVelocityY:F5}) Properties=0x{o.Properties:X8}"); + } + + for (int k = 0; k < dg.SkyTime.Count; k++) + { + var t = dg.SkyTime[k]; + string dirColor = t.DirColor is null ? "null" : + $"({t.DirColor.Red},{t.DirColor.Green},{t.DirColor.Blue},{t.DirColor.Alpha})"; + string ambColor = t.AmbColor is null ? "null" : + $"({t.AmbColor.Red},{t.AmbColor.Green},{t.AmbColor.Blue},{t.AmbColor.Alpha})"; + string fogColor = t.WorldFogColor is null ? "null" : + $"({t.WorldFogColor.Red},{t.WorldFogColor.Green},{t.WorldFogColor.Blue},{t.WorldFogColor.Alpha})"; + Console.WriteLine( + $"[sky-dump] SkyTime[{k}] Begin={t.Begin:F4} " + + $"DirBright={t.DirBright:F4} DirHeading={t.DirHeading:F1}° DirPitch={t.DirPitch:F1}° " + + $"DirColor={dirColor} AmbBright={t.AmbBright:F4} AmbColor={ambColor} " + + $"Fog=[{t.MinWorldFog:F1}m..{t.MaxWorldFog:F1}m] FogColor={fogColor} FogMode={t.WorldFog}"); + + for (int r = 0; r < t.SkyObjReplace.Count; r++) + { + var rep = t.SkyObjReplace[r]; + uint rGfx = rep.GfxObjId?.DataId ?? 0u; + // RAW values — pre-/100 divide. Compare these against the retail + // scale constant _DAT_007a1870 to settle the unit question. + Console.WriteLine( + $"[sky-dump] Replace[{r}] ObjectIndex={rep.ObjectIndex} GfxObjId=0x{rGfx:X8} " + + $"Rotate={rep.Rotate:F3}° Transparent_raw={rep.Transparent:F6} " + + $"Luminosity_raw={rep.Luminosity:F6} MaxBright_raw={rep.MaxBright:F6}"); + } + } + } + + Console.WriteLine("[sky-dump] ======== END SkyDesc dump ========"); + } + private static SkyObjectData ConvertSkyObject(SkyObject s) => new() { BeginTime = s.BeginTime,