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,