From d5e37694ed26a20b2da089e0631cf037a0e98364 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:53:46 +0200 Subject: [PATCH] docs(sky): port plan for PhysicsScript/fog/lightning/crossfade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures where we stand after Phase 4b and lays out the remaining retail-faithful port work across four phases (5-8): - Phase 5: PhysicsScript loader + runtime + sky lifecycle. Replaces WeatherSystem's crude "DayGroup name contains Rainy → spawn rain" shortcut with retail's actual PES-driven particle emission. - Phase 6: Fog on sky meshes. The sky frag currently ignores fog uniforms; retail's D3D fog applies to sky. - Phase 7: Lightning flash trigger + thunder audio for storm keyframes. - Phase 8: Weather / DayGroup crossfade (DAT_008427a9 / _DAT_008427b8 lerp) + AdminEnvirons override → fog crossfade. User observation 2026-04-23 during Phase 4b verification: "Now it is raining when it should not be." Root cause traced to the SetKindFromDayGroupName string match firing rain particles on a "Rainy" DayGroup regardless of whether that DayGroup actually has a visible rain-emitting SkyObject. Proper fix requires porting PhysicsScript. Also commits the earlier research from agent Q1-Q6: `docs/research/2026-04-23-sky-material-state.md`. Four parallel decompile agents are in flight as of this commit: - PhysicsScript dat + runtime - Sky↔PES wiring + emitter lifecycle - Lightning + weather crossfade - Fog on sky + vertex distance Phase 5 implementation starts once those land. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-23-sky-weather-lightning-port.md | 136 ++++++ .../research/2026-04-23-sky-material-state.md | 441 ++++++++++++++++++ 2 files changed, 577 insertions(+) create mode 100644 docs/plans/2026-04-23-sky-weather-lightning-port.md create mode 100644 docs/research/2026-04-23-sky-material-state.md diff --git a/docs/plans/2026-04-23-sky-weather-lightning-port.md b/docs/plans/2026-04-23-sky-weather-lightning-port.md new file mode 100644 index 0000000..b4f885b --- /dev/null +++ b/docs/plans/2026-04-23-sky-weather-lightning-port.md @@ -0,0 +1,136 @@ +# Phase 5+ Port Plan — Sky / Weather / Lightning, retail-verbatim + +**Date:** 2026-04-23 +**Scope:** Port the remaining retail-accurate pieces of the sky/weather/lightning +system so acdream visually matches a side-by-side retail client in all +day/night + weather states (clear, cloudy, rainy, stormy). + +## Where we are today (main, commit 2802fb2) + +Sky core, landed across Phases 1-4b: +- Region-dat SkyDesc loader with GameTime offsets ✓ +- Retail LCG DayGroup picker (seed = Year × DaysPerYear + DayOfYear, Phase 3g) ✓ +- Calendar tick extraction with `GameTime.ZeroTimeOfYear = 3600` (Phase 3f) ✓ +- Per-vertex D3D-fixed-function lighting formula (Phase 4, Phase 4b clamp) ✓ +- Sky objects drawn with visibility, arc sweep, UV scroll ✓ +- ACDREAM_DUMP_SKY diagnostic for retail-faithfulness verification ✓ +- RetailTimeProbe tool for live memory comparison ✓ + +Left to do: +1. **PhysicsScript** — no loader, no runtime, no sky-side integration. User-visible: + rain doesn't spawn when retail rolls a PES-carrying SkyObject. +2. **Fog on sky** — shader ignores fog uniforms; retail's D3D fog applies to sky. +3. **Lightning flash trigger** — storm timer + visual not ported. +4. **Weather / DayGroup crossfade** — retail's 10-second smooth blend between + keyframe sets not ported. +5. **AdminEnvirons override** — packet handler exists as a stub on the wire side; + not wired to our rendering. + +## Phases (execute in order) + +### Phase 5 — PhysicsScript loader + runtime + sky wiring + +Output of parallel research agents #1 + #2 (2026-04-23): +- `2026-04-23-physicsscript.md` — dat schema + runtime interpreter +- `2026-04-23-sky-pes-wiring.md` — sky → PES lifecycle + +Sub-phases: +- **5a** Port `PhysicsScript` dat type + any nested types. Add to `AcDream.Core/Dat/`. +- **5b** Port the runtime interpreter to C#. `AcDream.Core/Vfx/PhysicsScriptRunner.cs`. + Wire into existing `ParticleSystem` as the spawner — we do NOT build a new + emitter class, reuse what's there. +- **5c** Hook into `SkyRenderer` → on per-frame sky-object iteration, for each + visible SkyObject with non-zero `DefaultPesObjectId`, ensure its PES is running. + Despawn on visibility loss or DayGroup change. +- **5d** Replace `WeatherSystem.SetKindFromDayGroupName`'s crude + `"Rainy" → WeatherKind.Rain` string match with PES-driven spawning. The + `WeatherKind` enum becomes fog/tone info only; particle emission is + 100% PES-gated. + +Tests: PhysicsScript parser conformance (golden bytes → expected struct), +runtime determinism (same script + same seed → same particle stream). + +### Phase 6 — Fog on sky meshes + +Output of research agent #4: `2026-04-23-sky-fog.md`. + +Sub-phases: +- **6a** `sky.vert` computes fog factor per vertex. Formula from the agent's + findings (expected: linear per-vertex based on eye-space Z). +- **6b** `sky.frag` applies `mix(fragment, fogColor, fogFactor)` before the + lightning-flash bump. +- **6c** If sky meshes render at distances that saturate the keyframe's + FOGEND (sky would be pure fog color), either: + - Cap sky mesh eye-space Z at FOGEND - epsilon for fog purposes only, OR + - Use a separate "sky fog" distance parameter per retail's behavior. + +Tests: render-golden at 4 canonical times (dawn/noon/dusk/midnight) + 3 +DayGroups (Sunny / Cloudy / Stormy) — compare against retail screenshots. + +### Phase 7 — Lightning flash trigger + +Output of research agent #3: `2026-04-23-lightning-crossfade.md` (shared with +Phase 8 findings). + +Sub-phases: +- **7a** Port retail's storm-keyframe lightning timer. +- **7b** Wire to existing `uFogParams.z` lightning-flash uniform in the UBO + (sky.frag already consumes it). +- **7c** Wire thunder audio cue via `AdminEnvirons.Thunder1Sound..Thunder6Sound` + or a local per-flash delay (retail uses speed-of-sound distance). + +### Phase 8 — Weather / DayGroup crossfade + +Also from agent #3. + +Sub-phases: +- **8a** Port `DAT_008427a9` flag + `_DAT_008427b8` progress mechanics into + our SkyStateProvider or a new CrossfadeOrchestrator class. +- **8b** Trigger a crossfade when: + - DayGroup index changes (day rollover hits a new weather roll) — smooth + swap of keyframe set over retail's step constant `_DAT_007c7208`. + - `AdminEnvirons` override arrives — smooth fog transition to the override + color. +- **8c** AdminEnvirons wiring: the packet handler stub in `WeatherSystem.Override` + already exists; wire it to the crossfade trigger + our renderer. + +### Optional Phase 9 — Per-cell AdjustPlanes terrain relight + +From earlier research (`2026-04-23-sky-decompile-hunt-A.md` §1): retail reruns +`FUN_00532440` on every terrain cell whenever the sky keyframe advances. +We currently bake terrain vertex lighting once and don't refresh. Visible effect: +terrain doesn't darken smoothly as the sun sets. + +Deferred because it's higher effort and lower payoff than 5-8. + +## Success criteria + +1. A `+Acdream` character stationary in outdoor Holtburg for 30 real minutes + (about 15 Derethian minutes with our 1:1 tick rate) produces a sky that, + side-by-side with retail, is visually indistinguishable within lighting + equipment tolerances (color temperature, saturation). +2. Rolling a DayGroup that contains a rain-emitting SkyObject causes + acdream to spawn rain particles MATCHING retail's rain cadence (drop rate, + direction, lifetime). +3. During a Stormy DayGroup, acdream shows lightning flashes at the retail + cadence (8–30 sec between strikes, flash rises in ~50ms, decays in ~200ms). +4. An `AdminEnvirons RedFog` packet arriving mid-play crossfades acdream's fog + to the red tint within ~10 real seconds, same direction retail does. + +## Non-goals (for this plan) + +- **PhysicsScript author tools** — we parse + run; we don't edit. +- **Retail-accurate GPU particle rendering** — reuse our existing + `ParticleSystem` backend. PhysicsScript drives IT, not a new emitter. +- **Exotic EnvironChangeTypes** (the Thunder3Sound, DarkLaughSound, etc. + non-fog variants) — those are admin-only and we can stub-log them. +- **Per-landblock weather variation** — retail weather is Dereth-wide. + +## Open risks / unknowns (to be resolved by agents) + +- Will our `ParticleSystem.SpawnEmitter` API be sufficient, or does retail's + PhysicsScript need commands we don't expose? (agent #1). +- Does sky mesh vertex data need a 1e6-far-plane fog distance rescaling, or + is retail's FOGEND authored large enough to cover sky? (agent #4). +- Does retail sync the lightning random timer across all clients (so everyone + sees the same strike), or is it truly client-local? (agent #3). diff --git a/docs/research/2026-04-23-sky-material-state.md b/docs/research/2026-04-23-sky-material-state.md new file mode 100644 index 0000000..15b5fab --- /dev/null +++ b/docs/research/2026-04-23-sky-material-state.md @@ -0,0 +1,441 @@ +# Sky Material/D3D State — Retail Decompile Trace + +**Date:** 2026-04-23 +**Scope:** Q1–Q6 of the material/state hunt. Pins exactly what retail writes +per-mesh when rendering a sky GfxObj, and what stays inherited from scene state. + +## TL;DR — the retail sky fragment formula + +Retail D3D **fixed-function lighting** is the sky's colour source. Per-sky-mesh, +the retail client writes a `D3DMATERIAL9` with fields populated from the mesh's +`Surface` (the per-Surface luminosity/maxBright/transparency). Sky meshes do +NOT get a special state pass — they ride the normal mesh pipeline. + +Per-fragment (fixed-function pseudocode): + +``` +material.Diffuse = (0, 0, 0, 1) if Surface.Luminous else (from FUN_0059da60) +material.Ambient = (0, 0, 0, 1) if Surface.Luminous else (from FUN_0059da60) +material.Emissive = (Lum, Lum, Lum, 1) where Lum = Surface.Luminosity or 0 +vertex.diffuse.rgb = +vertex.diffuse.a = 1 - Surface.Transparency (for each of 4 corners) + +if D3DRS_LIGHTING: + # D3D fixed-function lighting: + litColor = material.Emissive + + material.Ambient * (D3DRS_AMBIENT + sum_of_light.ambient) + + material.Diffuse * sum_of_light.diffuse * dot(N, L) + + material.Specular * ... +else: + # Lighting OFF — vertex.diffuse is used directly. + litColor = vertex.diffuse + +fragment.rgb = texture.rgb * litColor.rgb +fragment.a = texture.a * litColor.a + +if D3DRS_FOGENABLE and z > FOGSTART: + fragment.rgb = lerp(fragment.rgb, D3DRS_FOGCOLOR, + clamp((z - FOGSTART)/(FOGEND - FOGSTART), 0, 1)) +``` + +Key facts: +1. **No sky-specific render-state toggles.** Sky meshes render with whatever + D3DRS_LIGHTING, D3DRS_FOGENABLE, D3DRS_AMBIENT were last set. The per-mesh + writer `FUN_0059da60` MAY flip LIGHTING on/off based on a global flag. +2. **Luminous flag (`piVar6[5] < 0`) zeroes Diffuse+Ambient**, effectively + making the mesh render as `Emissive-only * texture`. Non-luminous uses the + full lighting equation. +3. **Surface.Luminosity is written to `D3DMATERIAL9.Emissive.rgb`.** Confirmed + at `chunk_00590000.c:10669-10674`. +4. **Surface.Transparency is written to 4 per-vertex alpha slots** (one per + corner of a quad Surface), via `FUN_0053a430` at `chunk_00530000.c:7706-7715`. +5. **Fog stays ENABLED during the sky render.** The keyframe fog range + (MinWorldFog → MaxWorldFog) is likely tuned so sky geometry at its rendered + distance is not heavily fogged. + +## Q1 — Fog state during sky render + +**Answer: Fog stays ENABLED.** Retail does not toggle fog around the sky pass. + +Evidence: I searched every call to `FUN_005a3f90` (D3DRS_FOGENABLE writer). +All call sites: + +``` +chunk_00500000.c:6293 FUN_005a3f90(DAT_0081dbf8); # FUN_005062e0 per-frame master gate +chunk_00500000.c:7270 FUN_005a3f90(DAT_008427a9 != '\0'); # FUN_00507a50 weather-volume pass +chunk_00500000.c:7295 FUN_005a3f90(cVar4 != '\0'); # FUN_00507a50 restore +chunk_005A0000.c:707 FUN_005a3f90(0); # device-init default +chunk_005A0000.c:1344 FUN_005a3f90(DAT_008ee545); # device-reset +``` + +`FUN_00508010` (sky render) does NOT call `FUN_005a3f90`. The per-frame master +gate at `FUN_005062e0:6291` fires BEFORE the sky render inside the same function: + +```c +// chunk_00500000.c:6235-6333 FUN_005062e0 +if (*(int *)(param_1 + 0x10) != 0) { + if (*(int *)(param_1 + 0x20) != 0) { + FUN_00508010(); // sky render + } + ... + FUN_005a4010(DAT_0081dbf8 == '\0'); // master fog gate, NOT disable + if (DAT_0081dbf8 != '\0') { + FUN_005a3f90(DAT_0081dbf8); // FOG = ON if master flag set + ...lerp fog... + FUN_005a41b0(&fogColor, fogNear, fogFar); // write FOGCOLOR/START/END + } +} +``` + +The fog is master-controlled by `DAT_0081dbf8` (application-level toggle). When +outdoors it is typically ON. + +**The sky meshes render THROUGH fog.** If the sky GfxObj's far-placement +distance exceeds FOGEND, the fog color will dominate. This is why retail keys +MinWorldFog/MaxWorldFog per-SkyTimeOfDay — to tune how fog bleeds into the sky. + +## Q2 — What FUN_0059da60 writes per-mesh (the real per-Surface state setter) + +**FUN_00514b90 is only a transform-enqueue wrapper. The real per-Surface +material/D3D state writer is `FUN_0059da60` at `chunk_00590000.c:10586-10795`**, +called downstream by the scene-graph flush. Critical region: + +```c +// chunk_00590000.c:10641-10689 +FUN_005a3d80((DAT_008ee070 == 0) + '\x01'); // D3DRS_CULLMODE + +if ((DAT_008ee06c == 0) || (*(char *)(DAT_00870340 + 0x7e0) != '\0')) { + uVar12 = 1; +} else { + uVar12 = 0; +} +FUN_005a41f0(uVar12); // D3DRS_LIGHTING ← uVar12 + +if ((char)piVar6[5] < '\0') { // Surface.Luminous flag + FUN_005a4310(1); + if (*(int *)(DAT_00870340 + 0x7e4) == 0) { + _DAT_008ee03c = DAT_00821e38; // D3DMATERIAL9.Diffuse.A = 0 + _DAT_008ee044 = 0x3f800000; // D3DMATERIAL9.Ambient.A = 1.0f + _DAT_008ee038 = DAT_00821e38; // D3DMATERIAL9.Ambient.R = 0 + _DAT_008ee040 = DAT_00821e38; // D3DMATERIAL9.Ambient.B = 0 + _DAT_008ee02c = DAT_00821e38; // D3DMATERIAL9.Diffuse.G = 0 + _DAT_008ee028 = DAT_00821e38; // D3DMATERIAL9.Diffuse.R = 0 + _DAT_008ee030 = DAT_00821e38; // D3DMATERIAL9.Diffuse.B = 0 + _DAT_008ee034 = 0x3f800000; // D3DMATERIAL9.Diffuse.A = 1.0f (overwrite) + (**(code **)(**(int **)(DAT_00870340 + 0x468) + 0xc4)) + (*(int **)(DAT_00870340 + 0x468), &DAT_008ee028); // SetMaterial + FUN_005a3ef0(0); // D3DRS_COLORVERTEX = 0 (ignore vertex colour) + FUN_005a3f40(0); // (state 0x93) + } +} +else if (DAT_00796344 < *(float *)(param_2 + 0x78)) { // Surface.Luminosity > 0 + iVar8 = *(int *)(DAT_00870340 + 0x7e4); + if (iVar8 == 0) { + DAT_008ee058 = *(undefined4 *)(param_2 + 0x78); // Emissive.R = Luminosity + DAT_008ee064 = 0x3f800000; // Emissive.A = 1.0f + DAT_008ee05c = DAT_008ee058; // Emissive.G = Luminosity + DAT_008ee060 = DAT_008ee058; // Emissive.B = Luminosity + (**(code **)(**(int **)(DAT_00870340 + 0x468) + 0xc4)) + (*(int **)(DAT_00870340 + 0x468), &DAT_008ee028); // SetMaterial + } +} +``` + +**Material-block global at `DAT_008ee028` — mapped byte-for-byte to D3DMATERIAL9:** + +| Offset from 0x008ee028 | Global | D3DMATERIAL9 field | +|---|---|---| +| +0x00 | DAT_008ee028 | Diffuse.R | +| +0x04 | DAT_008ee02c | Diffuse.G | +| +0x08 | DAT_008ee030 | Diffuse.B | +| +0x0c | DAT_008ee034 | Diffuse.A | +| +0x10 | DAT_008ee038 | Ambient.R | +| +0x14 | DAT_008ee03c | Ambient.G | +| +0x18 | DAT_008ee040 | Ambient.B | +| +0x1c | DAT_008ee044 | Ambient.A | +| +0x20..0x2c | DAT_008ee048..054 | Specular.RGBA (not touched in this hunt) | +| +0x30 | DAT_008ee058 | **Emissive.R = Luminosity** | +| +0x34 | DAT_008ee05c | **Emissive.G = Luminosity** | +| +0x38 | DAT_008ee060 | **Emissive.B = Luminosity** | +| +0x3c | DAT_008ee064 | **Emissive.A = 1.0f** | + +**Verification of offsets:** luminous path sets +0x0c (Diffuse.A) to 0 via +`_DAT_008ee03c = DAT_00821e38`. Wait — that's at +0x0c from 0x008ee028 = 0x008ee034. +Let me re-read: line 10652 sets `_DAT_008ee03c`; line 10659 sets `_DAT_008ee034` +to 1.0f. The former is 0x14 bytes in (Ambient.G); the latter is 0x0c (Diffuse.A). + +Reconciling: the luminous path sets Diffuse R=G=B=0, A=1 (via DAT_008ee02c, 028, +030, 034 all at +0x00..0x0c), Ambient R=G=B=0, A=1 (DAT_008ee038, 03c, 040, 044 +at +0x10..0x1c). Then `SetMaterial` pushes the whole block — but crucially +**Emissive at +0x30..0x3c is UNCHANGED from whatever the previous caller left +it at** for luminous meshes. This is a subtle retail bug/feature: if the +preceding draw set Emissive to some value, the next luminous draw inherits it. + +For non-luminous with Luminosity > 0 (the "else if" branch, line 10666), only +Emissive is updated — Diffuse/Ambient are left from the prior `FUN_0059d520` +call or from some other writer. + +**Referenced writer `FUN_0059d520` at line 10636** is where Diffuse/Ambient +get set for normal rendering (texture-modulated). Not fully traced here — but +confirmed: Diffuse/Ambient are NOT zero for non-luminous meshes. + +## Q3 — FUN_00512360/124b0/120c0 + FUN_00518e70/ee0/f50 + FUN_0050f040/0c0/140 + +These are the **PhysicsPart per-part setters** called by the sky render loop. +Each is a "set or enqueue-animation" pair. Chain: + +``` +FUN_00508010 (sky object render loop) + → FUN_00512360(part, Luminosity, 0, 0) # "set or animate Luminosity" + ├── [animated] FUN_0051c580(3, ...) # animation keyframe schedule + └── [immediate] FUN_00518ee0(Luminosity) + → foreach Surface in part: FUN_0050f0c0(Surface, Luminosity) + → writes Surface.offset_0xd4 = Luminosity (PhysicsPart +0xd4) + → if active: FUN_0053a460(material_cache, Luminosity) + → writes cache +0x3c, +0x40, +0x44 = Luminosity, Luminosity, Luminosity + + → FUN_005124b0(part, MaxBright, 0, 0) # same pattern for MaxBright → +0xd0 → FUN_0053a490 → cache +0x0c, +0x10, +0x14 + → FUN_005120c0(part, Transparency, 0, 0) # same pattern for Transparency → +0xcc → FUN_0053a430 → cache +0x18, +0x28, +0x38, +0x48 (alpha for 4 verts, stored as 1-Transparency) +``` + +File:line evidence: + +```c +// chunk_00510000.c:2267-2298 FUN_00512360 (Luminosity set-or-animate) +if (_DAT_007c78bc <= (float)(double)CONCAT44(param_5,param_4)) { + // animation branch — enqueue keyframe + iVar3 = FUN_0051c580(3, ...); + ... +} +else if (*(int *)(param_1 + 0x10) != 0) { + FUN_00518ee0(param_3); // immediate apply +} + +// chunk_00510000.c:7901-7915 FUN_00518ee0 (Luminosity broadcast to Surfaces) +void FUN_00518ee0(int param_1, undefined4 param_2) { + if ((*(int *)(param_1 + 0x54) != 0) && (uVar1 = 0, *(int *)(param_1 + 0x58) != 0)) { + do { + if (*(int *)(*(int *)(param_1 + 0x5c) + uVar1 * 4) != 0) { + FUN_0050f0c0(param_2); // per-Surface Luminosity set + } + uVar1 = uVar1 + 1; + } while (uVar1 < *(uint *)(param_1 + 0x58)); + } +} + +// chunk_00500000.c:13557-13582 FUN_0050f0c0 (PhysicsPart.Luminosity write) +if (param_2 != *(float *)(param_1 + 0xd4)) { + *(float *)(param_1 + 0xd4) = param_2; // PhysicsPart +0xd4 = Luminosity + ... + iVar2 = FUN_0050e100(); + if (iVar2 != 0) { + FUN_0053a460(param_2); // material cache broadcast + } +} + +// chunk_00530000.c:7732-7741 FUN_0053a460 (material cache: 3-float slot) +void FUN_0053a460(int param_1, undefined4 param_2) { + *(undefined4 *)(param_1 + 0x3c) = param_2; + *(undefined4 *)(param_1 + 0x40) = param_2; + *(undefined4 *)(param_1 + 0x44) = param_2; +} +``` + +Same chain for MaxBright (`FUN_005124b0 → FUN_00518f50 → FUN_0050f040 → +0xd0 → +FUN_0053a490`) and Transparency (`FUN_005120c0 → FUN_00518e70 → FUN_0050f140 → ++0xcc → FUN_0053a430`). The Transparency writer applies `alpha = 1 - +Transparency` to FOUR alpha slots at `+0x18, +0x28, +0x38, +0x48` (one per +corner of a quad-Surface's 4 vertices). + +**Interpretation:** `FUN_0053a4b0` initializes this cache struct with eight +consecutive `1.0f` values at `param_1[3..10]` (offsets +0x0c..+0x28). This is a +**per-Surface fixed-function render cache** holding material-like data for 4 +vertices. The fields: + +| Offset | Field | Set by | +|---|---|---| +| +0x0c, +0x10, +0x14 | MaxBright R, G, B (3 floats) | FUN_0053a490 | +| +0x18, +0x28, +0x38, +0x48 | vertex alpha v0, v1, v2, v3 (1-Transparency) | FUN_0053a430 | +| +0x3c, +0x40, +0x44 | Luminosity R, G, B | FUN_0053a460 | + +**This is NOT a D3DMATERIAL9.** It's retail's bespoke per-Surface colour cache. +The Surface.Luminosity/MaxBright/Transparency set on PhysicsPart via +`FUN_00512360/124b0/120c0` gets stored in: +1. PhysicsPart struct (+0xcc, 0xd0, 0xd4) — persistent part state. +2. Per-Surface material cache (+0x3c.., +0x0c.., +0x18..) — render-time values. + +Then when `FUN_0059da60` builds the actual D3DMATERIAL9 to submit to D3D, it +reads `param_2 + 0x78` = Surface.Luminosity — this is the **Surface-level** +Luminosity (from the dat), NOT the animated PhysicsPart Luminosity. The cache +struct's Luminosity (+0x3c..) is for a different purpose — likely per-vertex +colour modulation when COLORVERTEX is on (see Q5). I did NOT find the exact +consumer of cache +0x3c within the 60-minute budget — it may flow into vertex +colour on the vertex-fill path. + +Plainly: **retail sky's per-mesh luminosity overrides are stored in two places +and consumed by two different stages (material push for non-luminous meshes, +per-vertex colour cache for others).** + +## Q4 — D3DRS_LIGHTING during sky pass + +**D3DRS_LIGHTING is ON for normal meshes (including sky), OFF for the +weather-volume overlay (rain/snow/fog cells).** + +Evidence: `FUN_0059da60` at `chunk_00590000.c:10642-10648` sets LIGHTING ON +unless a global override forces it off: + +```c +if ((DAT_008ee06c == 0) || (*(char *)(DAT_00870340 + 0x7e0) != '\0')) { + uVar12 = 1; // ← LIGHTING = ON +} else { + uVar12 = 0; // ← LIGHTING = OFF +} +FUN_005a41f0(uVar12); // D3DRS_LIGHTING ← uVar12 +``` + +`DAT_008ee06c` is the "rendering flag" set in various places — when its value +is 0 (default), LIGHTING = 1. The `DAT_00870340 + 0x7e0` flag is a secondary +override. Practically: lighting is ON for all visible mesh draws. + +**Corollary:** Since LIGHTING is ON, the material fields (Diffuse, Ambient, +Emissive) drive the output. With Diffuse=0 and Emissive=Luminosity (the luminous +branch), output = texture × Luminosity. With Diffuse!=0 and Emissive=Luminosity +(non-luminous branch with Surface.Luminosity), output = texture × (Emissive + +Diffuse × dot(N, L) × sunLight + Ambient × AMBIENT). + +Device-init default at `chunk_005A0000.c:709` sets `FUN_005a41f0(0)` (LIGHTING +OFF), but this is the startup state; scene render flips it per-mesh. + +## Q5 — Sky-pass vs terrain-pass render state diff + +Retail does NOT distinguish sky from terrain at the render-state level. Both +go through `FUN_0059da60` (per-mesh state setter). Per-draw state that CAN +differ, all driven by Surface flags or globals: + +| D3D state | Who flips it per draw | Varies per-sky-mesh? | +|---|---|---| +| CULLMODE (0x16) | `FUN_005a3d80` at 10641 | No — all meshes same (`DAT_008ee070` global) | +| LIGHTING (0x89) | `FUN_005a41f0` at 10648 | No — driven by `DAT_008ee06c` global | +| COLORVERTEX (0x91) | `FUN_005a3ef0` at 10662 (luminous path only) | **Yes** — luminous sky meshes set COLORVERTEX=0 | +| Material (SetMaterial, not a RS) | `(vtable+0xc4)` at 10660, 10673, 10686 | **Yes** — per-Surface Luminosity/flag | +| FOGENABLE (0x1c) | Only `FUN_005062e0` (per-frame gate) | No — set once per frame | +| AMBIENT (0x8b) | Only init (`FUN_005a3eb0(0)`) | No — always 0 | +| ZFUNC/ZWRITE (0x17/0x0e) | Only `FUN_00507a50` weather volume | No for sky proper | + +**Conclusion for Q5: sky and terrain share state.** The ONLY per-draw divergence +for sky is via `Surface.Luminous` flag, which (a) zeroes Diffuse+Ambient, +(b) sets COLORVERTEX=0. Non-luminous sky meshes render identically to terrain +except for the material Emissive field. + +**This means:** in retail, a cloud mesh (non-luminous) gets the same lighting +treatment as a grass vertex — `Emissive + Diffuse*dot(N,L)*sunColor + +Ambient*D3DRS_AMBIENT`. Since D3DRS_AMBIENT=0, the Ambient term drops; the +output is `Emissive + Diffuse × dot(N, L) × sunColor` — i.e. per-vertex +directional lighting. + +A dome mesh (luminous) with `Surface.Luminosity = X` renders as +`Emissive(X,X,X) * texture` (no diffuse, no ambient) — essentially a fade +between off (X=0) and full-texture (X=1). + +## Q6 — Verbatim formula for C# port + +The retail sky fragment equation, per GfxObj Surface: + +``` +# Stage 1: Material + vertex-colour build +if Surface.Luminous: + material.Diffuse = (0, 0, 0, 1) + material.Ambient = (0, 0, 0, 1) + material.Emissive = (Lum, Lum, Lum, 1) # Lum = Surface.Luminosity + vertexColour = white # COLORVERTEX = 0 +else: + material.Diffuse = surfaceBaseDiffuse # from Surface texture modulate + material.Ambient = surfaceBaseAmbient # likely (1,1,1,1) default + material.Emissive = (Lum, Lum, Lum, 1) # Lum = Surface.Luminosity (≥ 0) + vertexColour = # pre-lit per-vertex + +# Stage 2: D3D fixed-function lighting (LIGHTING = ON; AMBIENT = 0) +litColour = material.Emissive + + material.Diffuse * D3DLight.Diffuse * dot(N, -sunDir) # sunDir from FUN_00501600 + + material.Ambient * 0 # AMBIENT=0, drops out + # Specular ignored (0) + +# Stage 3: Texture modulate + vertex colour +fragment.rgb = texture.rgb * litColour.rgb * vertexColour.rgb +fragment.a = texture.a * litColour.a * vertexColour.a + +# Stage 4: Fog blend (FOGENABLE = ON per master) +if z > FOGSTART: + t = clamp((z - FOGSTART) / (FOGEND - FOGSTART), 0, 1) + fragment.rgb = lerp(fragment.rgb, FOGCOLOR, t) +``` + +**For the acdream sky shader, this reduces to:** + +```glsl +// For LUMINOUS sky sub-meshes (dome, sun, moon, stars if Luminous=true): +fragment = texture(uSky, uv) * vec4(uLuminosity, uLuminosity, uLuminosity, 1.0) * uTransparency; +// where uLuminosity = Surface.Luminosity (0..1 fraction) +// and uTransparency is the keyframe-override-animated 1-Transparency. +// NO ambient multiplication. NO sun-direction. No fog. + +// For NON-LUMINOUS sky sub-meshes (typical clouds): +vec3 diffuseTerm = diffuseColour * sunColour * max(0, dot(N, -sunDir)); +vec3 emissiveTerm = vec3(uLuminosity); // usually 0 for clouds +vec3 lit = emissiveTerm + diffuseTerm; // D3DRS_AMBIENT=0 drops that term +fragment.rgb = texture(uSky, uv).rgb * lit; +fragment.a = texture(uSky, uv).a * (1 - transparency); +// Optional fog: retail leaves fog ENABLED, but sky distance vs FOGEND +// determines whether fog contribution is visible. For acdream, first port +// assume sky is rendered NEAR clip so fog doesn't dominate. +``` + +**Immediate actionable change for acdream:** + +1. Our current `fragment = texture × uLuminosity × uTint` (uTint=white) matches + retail for **luminous** sub-meshes. Correct behaviour — the over-bright + observation is NOT from tinting. +2. **The over-bright problem is almost certainly that our Luminosity values are + wrong.** Previous fix scaled dat values / 100 (percent→fraction). Retail does + `Surface.Luminosity × _DAT_007a1870`. If `_DAT_007a1870 = 1.0f` (strong + evidence: it's used as the "default/identity" return in FUN_00518c00/c20), + AND the dat values are in [0..1], retail renders `texture × dat_luminosity` + with NO /100 scaling. Our /100 would then be UNDER-bright. But user says + we're OVER-bright — so the dat values ARE in percent, 0..100, and our /100 + scaling is correct. +3. **However, we may be applying Luminosity twice, or not applying it to the + right meshes.** Dome at dusk has Luminosity that INTERPOLATES (from the + SkyObjectReplace keyframe) — currently a constant 1.0 in our renderer + would render too bright. +4. **Non-luminous clouds** should get `texture × (Emissive + Diffuse × dot(N, + -sun) × sunColour)` — not `texture × 1`. Our clouds being "too bright" is + consistent with us skipping the diffuse-dot-sun shading entirely. + +## Remaining uncertainty + +- `_DAT_007a1870` exact value — evidence leans to 1.0f (identity), so our C# + port should treat dat Luminosity/Transparency/MaxBright as **already in the + right units** (no /100) and feed them directly. But user observation requires + a /100 to look less bright, so either (a) dat values are in percent and + `_DAT_007a1870 = 0.01f`, or (b) our shader is applying Luminosity in an + additional place it shouldn't. +- The role of the per-Surface material cache struct (`FUN_0053a4b0` constructed, + +0x0c..+0x48 fields) in the final fragment colour. It's written by the + PhysicsPart L/MB/T animation setters, but I didn't track its consumer to D3D. + Likely feeds COLORVERTEX-ON vertex alpha/RGB for non-luminous meshes. +- Whether `param_2 + 0x78` (Surface.Luminosity in FUN_0059da60) is the same + float as `PhysicsPart +0xd4` (Luminosity set by FUN_0050f0c0). The dual-path + suggests they're distinct — one is Surface-level (from the dat), one is + PhysicsPart-level (animated override). + +## Files cited + +- `chunk_00500000.c:6213-6333` FUN_005062e0 (per-frame sky + fog tick) +- `chunk_00500000.c:7535-7603` FUN_00508010 (sky render loop) +- `chunk_00500000.c:13524-13617` FUN_0050f040/0c0/140 (PhysicsPart T/L/MB fields) +- `chunk_00510000.c:2115-2376` FUN_005120c0/12360/124b0 (set-or-animate entry) +- `chunk_00510000.c:4563-4591` FUN_00514b90 (transform enqueue — NOT the material writer) +- `chunk_00510000.c:7865-7963` FUN_00518e70/ee0/f50 (Surface broadcast) +- `chunk_00530000.c:7702-7764` FUN_0053a430/460/490 (per-Surface material cache fill) +- `chunk_00590000.c:10586-10795` FUN_0059da60 (the real per-mesh D3DMATERIAL9 + LIGHTING + COLORVERTEX writer) +- `chunk_005A0000.c:687-740` FUN_005a10f0 (device-init default state: LIGHTING=0, AMBIENT=0, FOG=0)