From 3a117bd91a2f5a78aeed53fe44484d6ea4fe22a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:37:40 +0200 Subject: [PATCH 01/10] sky(phase-4): retail-verbatim per-vertex lighting on sky meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enables the Phase 2 lighting formula that was reverted in Phase 3b due to a "blue-green-yellow sweep" across clouds. Root cause of that earlier regression was NOT the formula — it was that we rolled the wrong DayGroup (Sunny when retail was Cloudy), producing a sharp warm sun against a sky that should have been rendered with diffuse overcast light. After Phase 3g pinned the LCG multiplier to 360 (DaysPerYear) so retail + acdream agree on DayGroup, the same per-vertex formula now faithfully reproduces retail's visuals. The formula is verified in decompile agent Q2+Q4+Q6 results, `docs/research/2026-04-23-sky-material-state.md`: D3DRS_LIGHTING = ON (FUN_0059da60:10648) D3DRS_AMBIENT = 0 (never written after init) Material.Emissive = (Luminosity, Luminosity, Luminosity, 1) Material.Ambient/Diffuse = defaults (≈1,1,1,1) for non-luminous light.Ambient = keyframe AmbColor × AmbBright (via SetDirectionalLight) light.Diffuse = keyframe DirColor × DirBright Fixed-function lighting per vertex: lit = Emissive + Ambient × lightAmbient + Diffuse × lightDiffuse × max(N·L, 0) = Surface.Luminosity + AmbColor×AmbBright + DirColor×DirBright × max(N·L, 0) Fragment: texture × lit × SkyObjectReplace.Luminosity. Expected visual: - Dome (Surface.Luminosity=1): `lit = 1 + amb + diff·N·L` saturates to 1 → texture passthrough, baked gradient preserved. - Clouds (Surface.Luminosity=0): `lit = 0 + amb + diff·N·L` → purple haze at night (ambient dominates, sun below horizon); → warm tan at dusk (ambient + warm sun on west-facing vertices); → pale cool gray at noon (ambient + white sun from above). - Sun/moon (SurfaceType.Additive, Luminosity=1): same as dome + additive blend — stays bright regardless. The shader uniforms (uAmbientColor, uSunColor, uSunDir, uEmissive) were already wired in the C# renderer from Phase 2; Phase 3b just stopped using them in the shader. This commit re-activates them. No clamp at the vertex — retail's D3D lighting allows Emissive+sum to exceed 1, relies on the framebuffer per-channel saturation. We keep the 1.2 ceiling in the frag (for lightning flash overbright headroom) consistent with that convention. No fog yet (Q1 confirmed retail leaves fog enabled for sky; will add in a follow-up if horizon looks too bright). Build + 733 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Shaders/sky.frag | 41 +++++++------ src/AcDream.App/Rendering/Shaders/sky.vert | 68 ++++++++++++++-------- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 37e74014..8276be6a 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -1,29 +1,31 @@ #version 430 core -// Sky mesh fragment shader — UNLIT texture passthrough modulated by the -// per-keyframe SkyObjectReplace.Luminosity and .Transparent overrides. +// Sky mesh fragment shader — final composite matching retail's +// D3D fixed-function: // -// fragment.rgb = texture.rgb * uLuminosity + lightning_flash -// fragment.a = texture.a * (1 - uTransparency) +// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash +// fragment.a = texture.a × (1 - uTransparency) // -// uLuminosity defaults to 1.0 (no dim). A SkyObjectReplace entry with -// Luminosity_raw=11 (11%) sets uLuminosity to 0.11 — mesh renders at -// 11% brightness. MaxBright is min-clamped into uLuminosity by the C# -// renderer before it reaches the shader. -// uTransparency defaults to 0.0. Replace.Transparent_raw=100 (100%) sets -// uTransparency to 1.0 — alpha is zeroed and the pixel discarded -// (cloud hidden so the dome behind shows through). +// vTint arrives from the vertex shader with retail's per-vertex +// lighting formula baked in (Emissive + lightAmbient + lightDiffuse × +// max(N·L, 0)) — see sky.vert for the decompile citation. // -// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 + Phase 3b -// rationale in sky.vert. +// uLuminosity is the per-keyframe SkyObjectReplace.Luminosity override +// (0..1, /100 in SkyDescLoader). It's a SEPARATE field from the +// Surface.Luminosity that feeds uEmissive in the vertex shader — they +// compose multiplicatively in retail too. +// +// See `docs/research/2026-04-23-sky-material-state.md`. in vec2 vTex; +in vec3 vTint; out vec4 fragColor; uniform sampler2D uDiffuse; -uniform float uTransparency; -uniform float uLuminosity; +uniform float uTransparency; // 0 = fully visible, 1 = fully transparent +uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1) -// Shared SceneLighting UBO — only fog-flash channel used (lightning). +// Shared SceneLighting UBO — only need the fog-flash channel for +// client-driven lightning strobes; sun/ambient already baked into vTint. struct Light { vec4 posAndKind; vec4 dirAndRange; @@ -41,13 +43,16 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Unlit passthrough with per-keyframe dim. - vec3 rgb = sampled.rgb * uLuminosity; + // Composite: texture × per-vertex lit × per-keyframe dim. + vec3 rgb = sampled.rgb * vTint * uLuminosity; // Lightning additive bump (client-driven during storm keyframes). float flash = uFogParams.z; rgb += flash * vec3(1.5, 1.5, 1.8); + // Soft clamp. Normal frame caps at 1.2 so the D3D-style overbright + // from Emissive+Ambient+Diffuse at day-time saturates cleanly; during + // a flash the ceiling relaxes so the strobe blows out visibly. float cap = mix(1.2, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 1d26ffd2..87e011d2 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -1,26 +1,35 @@ #version 430 core -// Sky mesh vertex shader — UNLIT texture passthrough. +// Sky mesh vertex shader — retail-verbatim D3D fixed-function lighting +// ported to per-vertex GLSL. Evidence trail: // -// Phase 2 experimented with per-vertex `emissive + ambient + diffuse×sun` -// lighting driven from the Surface.Luminosity field. The Phase 3a live -// verification (2026-04-23, user-observed against retail side-by-side -// at MorntideAndHalf) produced a "blue-green-yellow sweep" across the -// sky in acdream while retail showed a clean blue sky with white clouds. -// That's the signature of `diffuse × (250,215,151) warm-gold sunColor` -// tinting the cloud mesh's west-facing faces — retail does NOT do this. +// docs/research/2026-04-23-sky-material-state.md +// §Q2 — retail FUN_0059da60 writes D3DMATERIAL9 per-mesh: +// Material.Emissive.rgb = (Surface.Luminosity, Lum, Lum, 1) +// Material.Ambient/Diffuse from texture-modulate defaults +// §Q4 — D3DRS_LIGHTING is ON for sky meshes +// §Q6 — fragment formula: +// lit = Emissive +// + material.Ambient × light.Ambient +// + material.Diffuse × light.Diffuse × max(dot(N, -sun), 0) // -// Retail sky meshes render UNLIT. The time-of-day color variation users -// observe (purple haze at night, warm dusk) comes from SkyObjectReplace -// per-keyframe Luminosity + Transparent modulation, revealing/dimming -// different mesh layers — NOT from per-vertex ambient multiply. +// Our `uAmbientColor` = retail's light.Ambient (AmbColor × AmbBright, +// pre-multiplied by SkyDescLoader). `uSunColor` = retail's light.Diffuse +// (DirColor × DirBright). `uSunDir` is a unit vector FROM surface TO +// sun (so `dot(N, uSunDir)` is the diffuse intensity directly; no +// extra negation needed — see SkyStateProvider.SunDirectionFromKeyframe). +// `uEmissive` is Surface.Luminosity for this submesh. // -// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 for the -// surviving hypotheses and the Phase 3b decision rationale. +// Phase 2 (2026-04-23) tried the same formula and produced a visible +// east/west "blue-green-yellow sweep" — in hindsight that was CORRECT +// retail behaviour but paired with a wrong DayGroup pick ("Sunny" with +// sharp warm sun when retail rolled "Cloudy" with diffuse overcast). +// After Phase 3g fixed the LCG multiplier so acdream + retail agree on +// the DayGroup, the same formula should now match retail visually. // -// Uniforms for Ambient/Sun/Emissive stay declared below so the C#-side -// plumbing doesn't need to change — they are simply UNUSED. A future -// phase can revive them if the decompile hunt proves retail applies -// lighting to sky through a different channel. +// NOTE: no clamp at the vertex — retail's D3D fixed-function lighting +// can produce lit values > 1.0 and the final clamp happens at the +// framebuffer write. Doing that same "let it overbright" here keeps +// the dome's emissive=1 saturation path intact. layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; @@ -31,16 +40,29 @@ uniform mat4 uSkyView; uniform mat4 uSkyProjection; uniform vec2 uUvScroll; -// Unused in Phase 3b — see header. Kept for forward-compat with the -// C# renderer's push calls. -uniform vec3 uAmbientColor; -uniform vec3 uSunColor; -uniform vec3 uSunDir; +// Per-frame lighting (from SkyKeyframe): +uniform vec3 uAmbientColor; // AmbColor × AmbBright (retail light.Ambient) +uniform vec3 uSunColor; // DirColor × DirBright (retail light.Diffuse) +uniform vec3 uSunDir; // unit vector FROM surface TO sun + +// Per-submesh (from Surface.Luminosity float): uniform float uEmissive; out vec2 vTex; +out vec3 vTint; void main() { vTex = aTex + uUvScroll; gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0); + + // uModel for sky is pure rotation (Z then Y) — orthonormal, so + // mat3(uModel) transforms normals correctly without inverse-transpose. + vec3 worldNormal = normalize(mat3(uModel) * aNormal); + + // Retail per-vertex fixed-function lighting (AMBIENT=0 globally, + // so the global ambient term drops; only light.Ambient contributes). + float diff = max(dot(worldNormal, uSunDir), 0.0); + vTint = vec3(uEmissive) // material.Emissive + + uAmbientColor // material.Ambient(1) × light.Ambient + + uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L } From 2802fb21516d7ac63eff3c7806535917633bbcbf Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:41:58 +0200 Subject: [PATCH 02/10] sky(phase-4b): clamp sky vTint at vertex + 1.0 fragment cap for retail parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Phase 4 landed the per-vertex lighting formula, user observed acdream was still "a bit too bright" vs retail. Root cause: - My Phase 4 shader deliberately left vTint unclamped so D3D-style overbright contributions to emissive meshes (dome has Emissive=1 → lit could reach 2.0 with ambient + sun) would clamp naturally at the framebuffer. - But the frag cap was 1.2 (leaving "headroom for lightning flash"), letting dome vertices run 20% hotter than retail's per-channel 1.0. Retail's D3D fixed-function pipeline clamps vertex lit colour at D3DRS_COLORCLAMP=1 (default) BEFORE texture modulation. We now match: - Clamp `vTint = clamp(lit, 0, 1)` in sky.vert so the saturate happens at the vertex stage, exactly like D3D. - Drop normal-frame frag cap from 1.2 → 1.0 (the 3.0 flash relaxation stays so lightning strobes still visibly blow out). Expected visual: - Dome: identical appearance (was clamping to framebuffer 1.0 anyway), but pure retail-spec rendering so no sneaky 20% headroom. - Clouds: unchanged (already < 1.0 at morning Rainy keyframe). - Fragment flash during storm: unchanged — cap relaxes to 3.0 on flash. Build + 733 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Shaders/sky.frag | 7 ++++--- src/AcDream.App/Rendering/Shaders/sky.vert | 13 ++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 8276be6a..70159572 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -50,10 +50,11 @@ void main() { float flash = uFogParams.z; rgb += flash * vec3(1.5, 1.5, 1.8); - // Soft clamp. Normal frame caps at 1.2 so the D3D-style overbright - // from Emissive+Ambient+Diffuse at day-time saturates cleanly; during + // Normal-frame cap at 1.0 (retail D3D framebuffer clamps at 1.0 + // per channel for RGBA8 output; vTint is already vertex-clamped so + // the only path above 1 is lightning flash additive bump). During // a flash the ceiling relaxes so the strobe blows out visibly. - float cap = mix(1.2, 3.0, clamp(flash, 0.0, 1.0)); + float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); float a = sampled.a * (1.0 - uTransparency); diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 87e011d2..48e59876 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -61,8 +61,15 @@ void main() { // Retail per-vertex fixed-function lighting (AMBIENT=0 globally, // so the global ambient term drops; only light.Ambient contributes). + // Clamp to [0,1] at the vertex — retail's D3DRS_COLORCLAMP defaults + // to clamping lit vertex colours to 1.0 BEFORE texture modulate. + // Without this, a dome vertex (uEmissive=1) picks up ambient+diff + // on top of already-saturated emissive, producing > 1.5 lit values + // that our framebuffer cap (1.2) lets through as 20% overbright + // vs retail's 1.0-clamped reference. User-observed 2026-04-23. float diff = max(dot(worldNormal, uSunDir), 0.0); - vTint = vec3(uEmissive) // material.Emissive - + uAmbientColor // material.Ambient(1) × light.Ambient - + uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L + vec3 lit = vec3(uEmissive) // material.Emissive + + uAmbientColor // material.Ambient(1) × light.Ambient + + uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L + vTint = clamp(lit, 0.0, 1.0); } From d5e37694ed26a20b2da089e0631cf037a0e98364 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 10:53:46 +0200 Subject: [PATCH 03/10] 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 00000000..b4f885b4 --- /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 00000000..15b5fab0 --- /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) From 53608e77e3240e1a7aeda611be14cc11140bb262 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:04:36 +0200 Subject: [PATCH 04/10] sky(phase-5a): remove DayGroup-name rain hack, ship retail-only Overcast mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed regression 2026-04-23: acdream spawned rain particles when retail showed no rain at the same server tick. Root cause: my Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain → rain particle emitter. That's not what retail does. Parallel decompile research confirms: - Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it from NOWHERE. - Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render loop) never reads SkyObject.DefaultPesObjectId — the field is dead at render time. Rain/snow particles in retail come from a separate camera-attached weather subsystem that has NOT yet been located. So the correct behavior is: DayGroup name should only drive fog/ambient tone (via keyframes, already in the Snapshot path), never spawn particle emitters. Any retail-faithful particle rain belongs to a future phase once we find the camera-attached weather subsystem driver. Change: MapDayGroupNameToKind now maps all weathery substrings (storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only visuals, no particle spawn. Clear names stay Clear. The Rain, Snow, Storm enum values remain and are still accessible via ForceWeather() for debug overrides. Tests updated (WeatherSystemTests): the name→kind theory now expects Overcast for Rainy/Snowy/Stormy variants. Also commits the four research docs from this session's parallel hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding), lightning timer (negative finding — agent #3), fog on sky (positive: retail applies fog to sky geometry). NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE RANDOM TIMER hypothesis for lightning. User confirms retail does have visible lightning + thunder. A follow-up agent (#5, in flight as of this commit) is hunting the real mechanism — PlayScript opcode, SetLight PhysicsScript hooks, AdminEnvirons side effects, or the weather-volume draw. This commit does NOT attempt to port lightning. Build + 733 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-23-lightning-crossfade.md | 438 +++++++++++++++ docs/research/2026-04-23-physicsscript.md | 502 ++++++++++++++++++ docs/research/2026-04-23-sky-fog.md | 335 ++++++++++++ docs/research/2026-04-23-sky-pes-wiring.md | 184 +++++++ src/AcDream.Core/World/WeatherState.cs | 34 +- .../World/WeatherSystemTests.cs | 35 +- 6 files changed, 1508 insertions(+), 20 deletions(-) create mode 100644 docs/research/2026-04-23-lightning-crossfade.md create mode 100644 docs/research/2026-04-23-physicsscript.md create mode 100644 docs/research/2026-04-23-sky-fog.md create mode 100644 docs/research/2026-04-23-sky-pes-wiring.md diff --git a/docs/research/2026-04-23-lightning-crossfade.md b/docs/research/2026-04-23-lightning-crossfade.md new file mode 100644 index 00000000..23eedd87 --- /dev/null +++ b/docs/research/2026-04-23-lightning-crossfade.md @@ -0,0 +1,438 @@ +# Lightning Flashes & Weather Crossfade — Decompile Research + +**Date:** 2026-04-23 +**Scope:** Answer Q1–Q5 of the lightning-crossfade hunt brief. +**Source tree:** `docs/research/decompiled/chunk_*.c` (688K lines, decompiled retail acclient.exe). + +--- + +## TL;DR + +1. **Retail has NO lightning-flash system.** Not a timer, not an RNG modulator, not a + visual spike. Storm preset 6 sets two fog-color targets (grey 0x969696) and + toggles the crossfade; that's it. "Flashes" in modern ports are an addition. +2. **Weather crossfade is driven entirely by `FUN_0055eb40`** (chunk_00550000.c:11835) + — a 7-way switch on `EnvironChangeType` (param_2). It sets fog-crossfade target + globals (`DAT_008427ac/b0/b4`, `DAT_00842784/88`), sets `DAT_008427a9 = 1` + (active), and resets `_DAT_008427b8 = 0` (progress u). +3. **Crossfade step `_DAT_007c7208`** is a single rdata constant. It's + added each time the `LightTickSize` gate fires (i.e. per sky-keyframe update, + default ~2 seconds). Progress saturates at 1.0 (`_DAT_007938b0`). +4. **AdminEnvirons (0xEA60 = 60000) arrives via `FUN_006ae870`** + (chunk_006A0000.c:13141) and unconditionally calls `FUN_0055eb40` with the + EnvironChangeType int. No auth check, no queueing. +5. **Thunder audio (0x76..0x7B)** is driven by AdminEnvirons subtypes **0x65..0x6A** + (chunk_00550000.c:11906-11994) — each calls `FUN_00551560(soundId, chanId)` ONCE. + No timer. Not auto-linked to storm preset. + +--- + +## Q1: Lightning flash trigger — NOT PRESENT in retail + +### What I searched +- `rand()` in chunk_00500000.c (sky): **3 hits, all inside `FUN_00501600` RNG-looking + macros → actually a byte-shuffle for ARGB color lerping** (`FUN_005df4c4`), not RNG. +- `rand()` in chunk_00550000.c (weather): **3 hits (lines 646, 1074, 1102) — all + sound-probability filters in `FUN_00550cf0/FUN_00551430/FUN_005514c0`**, used by + ambient-sound emission, not lightning. +- `rand()` in chunk_005B0000.c:3176-3189 — 256-entry palette shuffle init, unrelated. +- `rand()` in chunk_005C0000.c:5560-5668 — 4 particle-emitter time-jitter seeds, + unrelated. +- `fsinf`/`fcosf` in the sky-keyframe path — only used for sun-direction polar-to- + cartesian conversion (`FUN_00501600:1193-1205`). No other time-based trig. +- String literals `"lightning"|"Lightning"|"thunder"|"Thunder"|"LIGHTNING"|"THUNDER"`: + **one hit, unrelated** (chunk_004B0000.c:2283 = a character-skill UI string + `"ID_CharacterInfo_Augmentation_Resist_Lightning"`). +- Storm preset 6 in `FUN_0055eb40` — sets `*(iVar2 + 0x41) = 1` on the singleton + `DAT_00871354` (via `FUN_00564d30`). I grepped for READS of `+0x41` across the + entire decompile: **there are NONE** outside the singleton's own ctor/reset + paths (chunk_00560000.c:2902, 3105 — both writes of 0). **The storm flag is + write-only; no lightning tick consumes it.** + +### Storm preset 6 body — chunk_00550000.c:11885-11896 + +```c +if (param_2 == 6) { + DAT_008427a9 = 1; // crossfade active + _DAT_008427b8 = 0; // progress u + DAT_008427ac = 0x3f4ccccd; // = 0.8f (target fogStart) + DAT_008427b0 = 0; // target secondary-1 (fogNear) + DAT_008427b4 = 0x42200000; // = 40.0f (target fogFar) + DAT_00842788 = 0x64969696; // target fog color ARGB: A=0x64 R=G=B=0x96 grey + DAT_00842784 = 0x64000000; // target secondary color: A=0x64 RGB=black + iVar2 = FUN_00564d30(); // get weather-mgr singleton + *(undefined1 *)(iVar2 + 0x41) = 1; // storm flag (NEVER READ elsewhere) + return 0; +} +``` + +### Conclusion + +The retail acclient **does not implement lightning flashes**. Storm preset 6 is +visually indistinguishable from other fog-change presets except by color and +the unread `+0x41` storm flag. The "client-side random flash" described in the +r12 deepdive is either: +(a) a later/expansion feature not present in the decompiled build, or +(b) a modern-port embellishment. + +If acdream wants lightning, it's an **addition**, not a port. A faithful retail +render is pure dense grey fog during thunderstorm. + +--- + +## Q2: Weather / DayGroup crossfade mechanics + +### State variables (all in the 0x842780 cluster) + +| Global | Type | Init | Role | +|---|---|---|---| +| `DAT_008427a9` | byte | 0 | **Crossfade active flag** — true = blend keyframe output toward stored weather values | +| `_DAT_008427b8` | float | 0.0 | **Crossfade progress `u`** — 0 at start, saturates at 1.0 | +| `DAT_008427ac` | float | — | **Target fogStart** (weather override) | +| `DAT_008427b0` | float | — | **Target fogNear** (secondary/starfield override) | +| `DAT_008427b4` | float | — | **Target fogFar** (secondary/starfield override) | +| `DAT_00842788` | u32 ARGB | — | **Target primary fog color** (pair with `DAT_008427ac`) | +| `DAT_00842784` | u32 ARGB | — | **Target secondary color** (pair with `DAT_008427b0/b4`) | +| `_DAT_007c7208` | float | **.rdata constant** (value unknown in decompile; see below) | **Per-tick progress step** | +| `_DAT_007938b0` | float | 1.0 (confirmed by division usage across chunk_00440000/00450000) | Upper saturation for `u` | + +### Per-frame crossfade block — chunk_00500000.c:6256-6281 (primary channel) + +```c +if (DAT_008427a9 != '\0') { + if (_DAT_007938b0 <= _DAT_008427b8) { // u >= 1.0: snap + local_24 = DAT_008427ac; // fogStart = target + local_20 = DAT_00842788; // fogColor ARGB = target + } + else { + // Per-byte lerp on fog color (R, G, B, A individually): + // new = current - (current - target) * u [applied to each byte] + // -- FUN_005df4c4 is the byte clamp/pack helper -- + ... // 4 byte channels + local_24 = local_24 - (local_24 - DAT_008427ac) * _DAT_008427b8; // fogStart lerp + _DAT_008427b8 = _DAT_008427b8 + _DAT_007c7208; // advance u + } +} +FUN_00505f30(local_24, local_20, local_c, local_18); +``` + +### Per-frame crossfade block — chunk_00500000.c:6297-6324 (secondary / starfield) + +Same structure, but writes `local_1c` (fogNear) ← `DAT_008427b0`, `local_24` +(fogFar) ← `DAT_008427b4`, `local_20` (color) ← `DAT_00842784`. Progress `u` is +the SAME global `_DAT_008427b8` — so both channels advance in lockstep. + +### Important: the crossfade step gate + +`_DAT_008427b8 += _DAT_007c7208` runs ONLY when the outer "LightTickSize" gate fires +(chunk_00500000.c:6249 `if (_DAT_008427a0 < _DAT_008379a8)`). This gate reschedules +using `*(double *)(*(int *)(DAT_0084247c + 0x50) + 0x10)` = SkyDesc.LightTickSize. +Based on the ACE schema (SkyDesc.LightTickSize in the Region dat), this is +typically 2.0 seconds. + +**Duration of a crossfade**: if `_DAT_007c7208` is, say, 1/30 (0.033), then +crossfade completes in 30 light-ticks × 2s = 60 seconds. If it's 1/8 = 0.125, +it's 16 seconds. If it's 1.0, it's 2 seconds (instant within one keyframe step). +The literal value is in .rdata at offset 0x007c7208 and isn't visible in the +decompile — acdream should either (a) start with a tuning-chosen constant +(e.g. 0.1 for 20 s fade) and expose it as a config, or (b) disassemble the +retail binary's .rdata to get the ground-truth value. + +### Note on retail client behavior + +Because the crossfade step advances at the LightTickSize cadence (not per-frame), +retail's weather change visibly "steps" in ~2-second increments rather than +appearing silky smooth at 60 fps. This matches the known retail look — +"the sky is updating in chunks" rather than continuously. + +--- + +## Q3: AdminEnvirons (0xEA60 = 60000) handler + +### Dispatcher — chunk_006A0000.c:13141-13153 + +```c +undefined4 FUN_006ae870(int param_1, int *param_2) +{ + undefined4 uVar1; + if (((param_1 != 0) && (*(int *)(param_1 + 0x40) != 0)) && (*param_2 == 60000)) { + uVar1 = FUN_0055eb40(param_2[1]); // param_2[1] = EnvironChangeType (int) + return uVar1; + } + return 0; +} +``` + +Wire format: `[u32 opcode=0xEA60][u32 environChangeType]` — just a single int payload. + +### `FUN_0055eb40` — EnvironChangeType dispatcher (chunk_00550000.c:11839) + +| EnvChangeType | Action | Crossfade? | +|---|---|---| +| 0 (Clear) | Zero all targets; set 008427a9 = 0 (crossfade OFF) | N (off) | +| 1 (RedFog / preset 1) | fogStart 0.4, fogFar 50, color 0x64_R_96_00 | Y | +| 2 (preset 2) | fogStart 0.3, fogFar 50, color 0x64_32_00_96 | Y | +| 3 (preset 3) | fogStart 0.4, fogFar 30, color 0x64_64_64_64 (grey) | Y | +| 4 (preset 4) | fogStart 0.3, fogFar 50, color 0x64_1E_64_00 | Y | +| 5 (preset 5) | fogStart 0.8, fogFar 40, color 0x64_96_96_96 | Y | +| **6 (Storm)** | fogStart 0.8, fogFar 40, color 0x64_96_96_96 + `singleton+0x41 = 1` | Y | +| 0x65..0x72 | Play thunder/ambient sound via `FUN_00551560(soundId 0x76..0x83, chanObj)` | N (sound only) | +| 0x75..0x7B | Play thunder/ambient sound (0x84..0x8A) | N (sound only) | +| 9999 (preset 9999) | fogFar 30, color 0x32_64_64_64, same as preset 3 branch | Y | + +All "crossfade" branches set `DAT_008427a9 = 1` and `_DAT_008427b8 = 0` via the +common `LAB_0055f050` tail. + +The common tail (chunk_00550000.c:12009-12015): +```c +DAT_008427a9 = 1; +LAB_0055f050: + _DAT_008427b8 = 0; + DAT_008427b0 = 0; // reset fogNear target + iVar2 = FUN_00564d30(); + *(undefined1 *)(iVar2 + 0x41) = 0; // clear storm flag + return 0; +``` + +### AdminEnvirons → crossfade trigger + +The server's `AdminEnvirons(EnvironChangeType = 6)` path: +1. Client wire: opcode 0xEA60 followed by u32=6. +2. `FUN_006ae870` dispatches on opcode, calls `FUN_0055eb40(6)`. +3. `FUN_0055eb40` writes the storm targets + sets the crossfade flag. +4. Next `FUN_005062e0` tick (gated by `LightTickSize`) lerps toward the targets. +5. Crossfade continues at step `_DAT_007c7208` per tick until `u >= 1`. + +--- + +## Q4: Thunder sound wiring + +### Direct, not timer-driven + +`FUN_00551560(soundId, chanObj)` is the play-sound-now call. `FUN_00564d50(singleton)` +lazily instantiates the channel object `FUN_00415730(0x10000003, 7, 0x22)` and +caches it at `singleton + 0x34`. Each EnvironChangeType 0x65..0x6A plays a +DIFFERENT thunder/ambient sound: + +| EnvChangeType | soundId | Likely meaning | +|---|---|---| +| 0x65 (101) | 0x76 | Thunder1Sound | +| 0x66 (102) | 0x77 | Thunder2Sound | +| 0x67 (103) | 0x78 | Thunder3Sound | +| 0x68 (104) | 0x79 | Thunder4Sound | +| 0x69 (105) | 0x7A | Thunder5Sound | +| 0x6A (106) | 0x7B | Thunder6Sound | +| 0x6B..0x72 (107-114) | 0x7C..0x83 | other ambient sounds | +| 0x75..0x7B (117-123) | 0x84..0x8A | more ambient sounds | + +**There is NO periodic "play thunder" call.** The retail client plays thunder +ONLY when the server sends `AdminEnvirons(0x65..0x6A)`. No client-side RNG +picks a sound, no tick schedules anything. If the server wants "thunder every +10-20 seconds during storm", **the server must send it explicitly.** + +Cross-confirmation: `FUN_00551560(0x76, ...)` appears in the full decompile +only ONCE (chunk_00550000.c:11909). Every other thunder/ambient sound is also +a single-site dispatch from `FUN_0055eb40`. There is no storm-active loop. + +--- + +## Q5: Port-ready C# pseudocode + +### 1. Crossfade state machine + +```csharp +// Source of truth: ACE/retail AC EnvironChangeType enum + the 7 cases of FUN_0055eb40 +// (chunk_00550000.c:11839-12016). +public enum EnvironChangeType : uint +{ + Clear = 0, + // Preset 1..6 are the historical fog presets. Values match FUN_0055eb40 switch. + Fog1 = 1, // 0x64_B2_96_00-ish, fogStart 0.4, fogFar 50 + Fog2 = 2, + Fog3 = 3, + Fog4 = 4, + Fog5 = 5, + Storm = 6, // fogStart 0.8, fogFar 40, grey + Thunder1 = 0x65, + Thunder2 = 0x65 + 1, + // ...through 0x7B + Fog9999 = 9999, +} + +internal sealed class WeatherCrossfade +{ + // Retail globals (DAT_008427a9, DAT_008427ac, DAT_008427b0, DAT_008427b4, _DAT_008427b8, + // DAT_00842788, DAT_00842784). + private bool _active; + private float _progressU; + private float _targetFogStart; + private float _targetFogNear; + private float _targetFogFar; + private uint _targetFogColorArgb; + private uint _targetSecondaryArgb; + + // Retail constant _DAT_007c7208 (.rdata). Per light-tick increment. Literal value is + // not in the decompile; 0.1 gives ~20s crossfade at default LightTickSize=2s. + // TODO(acdream): disassemble retail .rdata @ 0x007c7208 to pin the exact value. + public float ProgressStep { get; set; } = 0.1f; + + /// FUN_0055eb40 — server AdminEnvirons(opcode 0xEA60) handler. + public void ApplyEnviron(EnvironChangeType type) + { + switch (type) + { + case EnvironChangeType.Clear: + _active = false; + _targetFogStart = 0f; + _targetFogFar = 0f; + _targetFogColorArgb = 0; + _targetSecondaryArgb = 0; + // fall through to reset tail + ResetTail(); + return; + case EnvironChangeType.Fog1: + _targetFogStart = 0.4f; _targetFogFar = 50f; + _targetFogColorArgb = 0x64B29600; _targetSecondaryArgb = 0x64B29600; break; + case EnvironChangeType.Fog2: + _targetFogStart = 0.3f; _targetFogFar = 50f; + _targetFogColorArgb = 0x64320096; _targetSecondaryArgb = 0x64320096; break; + case EnvironChangeType.Fog3: + _targetFogStart = 0.4f; _targetFogFar = 30f; + _targetFogColorArgb = 0x64646464; _targetSecondaryArgb = 0x64646464; break; + case EnvironChangeType.Fog4: + _targetFogStart = 0.3f; _targetFogFar = 50f; + _targetFogColorArgb = 0x641E6400; _targetSecondaryArgb = 0x641E6400; break; + case EnvironChangeType.Fog5: + _targetFogStart = 0.8f; _targetFogFar = 40f; + _targetFogColorArgb = 0x64969696; _targetSecondaryArgb = 0x64000000; break; + case EnvironChangeType.Storm: + _targetFogStart = 0.8f; _targetFogFar = 40f; + _targetFogColorArgb = 0x64969696; _targetSecondaryArgb = 0x64000000; + StormFlag = true; // singleton+0x41; noted but unused by rendering + break; + case EnvironChangeType.Fog9999: + _targetFogStart = 0.4f; _targetFogFar = 30f; + _targetFogColorArgb = 0x32646464; _targetSecondaryArgb = 0x32646464; break; + default: + if ((int)type >= 0x65 && (int)type <= 0x7B) { PlayThunderFor(type); return; } + return; + } + _active = true; + _progressU = 0f; + _targetFogNear = 0f; + } + + private void ResetTail() + { + _progressU = 0f; + _targetFogNear = 0f; + StormFlag = false; + } + + public bool StormFlag { get; private set; } + + /// Called each time the LightTickSize gate fires (~every 2 s). + public void AdvanceCrossfade(ref float curFogStart, ref uint curFogColorArgb, + ref float curFogNear, ref float curFogFar, + ref uint curSecondaryArgb) + { + if (!_active) return; + if (_progressU >= 1f) + { + // snap + curFogStart = _targetFogStart; + curFogColorArgb = _targetFogColorArgb; + curFogNear = _targetFogNear; + curFogFar = _targetFogFar; + curSecondaryArgb = _targetSecondaryArgb; + return; + } + curFogStart = curFogStart - (curFogStart - _targetFogStart) * _progressU; + curFogNear = curFogNear - (curFogNear - _targetFogNear) * _progressU; + curFogFar = curFogFar - (curFogFar - _targetFogFar) * _progressU; + curFogColorArgb = LerpArgbBytes(curFogColorArgb, _targetFogColorArgb, _progressU); + curSecondaryArgb = LerpArgbBytes(curSecondaryArgb, _targetSecondaryArgb, _progressU); + _progressU += ProgressStep; + } + + private static uint LerpArgbBytes(uint a, uint b, float t) + { + // matches the per-byte pattern in FUN_005062e0:6262-6277 + byte La(int s) => (byte)((a >> s) & 0xff); + byte Lb(int s) => (byte)((b >> s) & 0xff); + byte Lerp(int s) { float d = La(s) - Lb(s); return (byte)(La(s) - d * t); } + return (uint)(Lerp(0) | (Lerp(8) << 8) | (Lerp(16) << 16) | (Lerp(24) << 24)); + } +} +``` + +### 2. AdminEnvirons → crossfade network binding (F.1 dispatcher) + +```csharp +// src/AcDream.Core/Events/GameEventDispatcher.cs (existing pattern from Session 2026-04-18) +// Opcode 0xEA60 = 60000 = AdminEnvirons. +// Wire format: [u32 opcode][u32 environChangeType] +public void OnAdminEnvirons(BinaryReader r) +{ + uint envType = r.ReadUInt32(); + _world.Weather.ApplyEnviron((EnvironChangeType)envType); + // If envType is in 0x65..0x7B the above call plays a thunder sound and returns + // without setting the crossfade. +} +``` + +### 3. Thunder sound wiring + +```csharp +// chunk_00550000.c:11906-11994 maps AdminEnvirons -> sound. +// soundId = (int)envType - 0x65 + 0x76 (i.e. 0x65→0x76, 0x66→0x77, ..., 0x72→0x83) +// second range 0x75..0x7B → 0x84..0x8A +// Route via the already-shipped OpenAL SoundPlayer (Phase E.2). +private void PlayThunderFor(EnvironChangeType type) +{ + int et = (int)type; + int soundId = et switch + { + >= 0x65 and <= 0x72 => et - 0x65 + 0x76, + >= 0x75 and <= 0x7B => et - 0x75 + 0x84, + _ => 0, + }; + if (soundId != 0) _audio.Play2D((uint)soundId); +} +``` + +### 4. Lightning flash + +**Do not port.** Retail has none. If acdream *adds* it as a client-side visual +enhancement, it should be an explicit new system behind a feature flag — not +advertised as "matches retail." Document clearly in commit message. + +--- + +## Citations + +- `docs/research/decompiled/chunk_00500000.c:6249-6333` — `FUN_005062e0` per-frame sky+crossfade +- `docs/research/decompiled/chunk_00550000.c:11835-12016` — `FUN_0055eb40` EnvironChangeType dispatcher +- `docs/research/decompiled/chunk_00550000.c:11906-11994` — thunder/ambient sound cases +- `docs/research/decompiled/chunk_006A0000.c:13141-13153` — `FUN_006ae870` AdminEnvirons (0xEA60) network handler +- `docs/research/decompiled/chunk_00560000.c:2461-2467` — `FUN_00564d30` singleton getter for the weather manager +- `docs/research/decompiled/chunk_00560000.c:2890-2914` — weather-mgr ctor (+0x41 init = 0) +- `docs/research/decompiled/chunk_00550000.c:1114-1136` — `FUN_00551560` play-sound-by-id utility +- `docs/research/decompiled/chunk_00500000.c:6280, 6322` — only writers of `_DAT_008427b8 += _DAT_007c7208` +- `docs/research/decompiled/chunk_00550000.c:11887, 12011` — only other writers of `_DAT_008427b8` (reset to 0) + +## Gaps / Unresolved + +1. **`_DAT_007c7208` literal value.** It's an .rdata constant not inlined in any + decompile site. Acdream should either pick a tuning value (e.g. 0.1 for + ~20 s crossfade at default LightTickSize=2 s) or disassemble the retail + binary `.rdata` at address 0x007c7208 to pin the exact value. +2. **Storm flag `singleton+0x41`.** Written to 1 in preset 6, but no reader in + the full 688K-line decompile. Likely a vestigial/dead field from an earlier + retail build, or consumed by a debug path that was stripped. Safe to ignore. +3. **Exact bit-layout of fog-color targets.** The constants like `0x64B29600` + are given in mixed ARGB/BGRA order in the decompile — the apply-byte-lerp + at 6262-6277 reads them in the same byte order as the runtime current value, + so as long as acdream consistently treats them as "retail-native ARGB", the + lerp math and final D3D state push will match. Validation: compare rendered + fog color side-by-side with retail under AdminEnvirons 1..5. diff --git a/docs/research/2026-04-23-physicsscript.md b/docs/research/2026-04-23-physicsscript.md new file mode 100644 index 00000000..b258ec7e --- /dev/null +++ b/docs/research/2026-04-23-physicsscript.md @@ -0,0 +1,502 @@ +# PhysicsScript — Retail Runtime Research + +**Date:** 2026-04-23 +**Goal:** Port retail's PhysicsScript (PES) system verbatim so acdream's sky can play per-SkyObject effects (e.g. `DefaultPesObjectId = 0x330007DB` on DayGroup[0] SkyObject[6]). +**Outcome:** Runtime fully located in decompile. ACE / ACViewer ports are skeletons — acdream must actually implement this. Dat schema is complete and simple. Integration with sky is NOT automatic — retail's sky render loop does not itself spawn PES; we must add a walker. + +--- + +## Q1. PhysicsScript dat schema (complete) + +### `PhysicsScript` (DB_TYPE_PHYSICS_SCRIPT, range `0x33000000..0x3300FFFF`) + +Source: `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs:26-55`. + +```csharp +public partial class PhysicsScript : DBObj { + public List ScriptData; // count + N entries +} +``` + +### `PhysicsScriptData` (per-command entry) + +Source: `references/DatReaderWriter/DatReaderWriter/Generated/Types/PhysicsScriptData.generated.cs:22-44`. + +```csharp +public partial class PhysicsScriptData { + public double StartTime; // seconds offset from script start + public AnimationHook Hook; // polymorphic — peeked as uint type prefix +} +``` + +Unpack: `StartTime (double) → peek AnimationHookType (uint, don't consume) → AnimationHook.Unpack(reader, type)`. + +### `AnimationHook` subtypes used by sky/PES + +`AnimationHookType` (source: `Generated/Enums/AnimationHookType.generated.cs:13-70`): + +| Value | Name | Relevant for PES? | +|---|---|---| +| 0x0D | **CreateParticle** | **YES** — spawn emitter at part index / offset | +| 0x0E | **DestroyParticle** | **YES** — despawn emitter by EmitterId | +| 0x0F | **StopParticle** | **YES** — stop spawn, let alive particles die | +| 0x1A | **CreateBlockingParticle** | Rare; emitter-id dedupe variant | +| 0x13 | **CallPES** | **YES** — one script calls another | +| 0x01 | Sound | audio hook (less critical for sky) | +| 0x0A/0x0B | Diffuse/DiffusePart | per-surface color | +| 0x08/0x09 | Luminous/LuminousPart | override Surface.Luminosity | +| 0x14 | Transparent | override Surface.Transparency | +| 0x16 | SetOmega | spin rate | +| 0x17/0x18 | TextureVelocity[Part] | UV scroll | +| 0x19 | SetLight | light override | + +### `CreateParticleHook` — the main one + +Source: `Generated/Types/CreateParticleHook.generated.cs:22-54`. + +```csharp +public partial class CreateParticleHook : AnimationHook { + public QualifiedDataId EmitterInfoId; // 0x32xxxxxx + public uint PartIndex; // which part of the PhysicsObj to attach to + public Frame Offset; // origin + orientation (Vec3 + Quat) + public uint EmitterId; // runtime handle for later Destroy/Stop hooks +} +``` + +### `DestroyParticleHook` / `StopParticleHook` — by EmitterId + +Both carry a single `uint EmitterId` (lines 27-30 of respective generated files). Destroy removes the emitter; Stop flips `Stopped = true` and lets live particles finish their lifespan. + +### `CreateBlockingParticleHook` + +Source: `Generated/Types/CreateBlockingParticleHook.generated.cs:22-37` — **empty body** in the dat. The "blocking" variant is a runtime behavior flag, not a data field. + +### Companion: `ParticleEmitter` / `ParticleEmitterInfo` (DB_TYPE_PARTICLE_EMITTER, `0x32000000..0x3200FFFF`) + +Identical on-disk layout — both `ParticleEmitter.generated.cs` and `ParticleEmitterInfo.generated.cs` unpack the same 31 fields in the same order. Schema summary (source: `Generated/DBObjs/ParticleEmitter.generated.cs:34-208`): + +| Field | Type | Purpose | +|---|---|---| +| `Unknown` | uint | unused | +| `EmitterType` | enum | `Still`, `BirthratePerSecond`, `BirthratePerMeter`, … | +| `ParticleType` | enum | `Still`, `Local`, `Parabolic`, `Swarm`, `Explode`, `Implode` | +| `GfxObjId` | `QualifiedDataId` | software-render mesh (ignored by retail — always uses HW) | +| `HwGfxObjId` | `QualifiedDataId` | hardware-render mesh (1 per particle) | +| `Birthrate` | double | seconds between spawns | +| `MaxParticles` | int | live cap | +| `InitialParticles` | int | spawn count at t=0 | +| `TotalParticles` | int | 0 = unlimited | +| `TotalSeconds` | double | 0 = infinite | +| `Lifespan`, `LifespanRand` | double | per-particle life ± rand | +| `OffsetDir`, `MinOffset`, `MaxOffset` | Vec3, 2×float | spawn position randomizer | +| `A`,`MinA`,`MaxA` | Vec3, 2×float | velocity axis A | +| `B`,`MinB`,`MaxB` | Vec3, 2×float | velocity axis B | +| `C`,`MinC`,`MaxC` | Vec3, 2×float | velocity axis C (for e.g. Parabolic gravity) | +| `StartScale`,`FinalScale`,`ScaleRand` | float | scale lerp | +| `StartTrans`,`FinalTrans`,`TransRand` | float | transparency lerp (0=opaque … 1=transparent in retail) | +| `IsParentLocal` | bool | follow parent transform each frame | + +`ParticleType` enum options drive the per-particle integrator shape (linear, ballistic, etc.). `EmitterType` drives `ShouldEmitParticle()` logic (ACE `ParticleEmitterInfo.cs:ShouldEmitParticle`). + +### `PhysicsScriptTable` (DB_TYPE_PHYSICS_SCRIPT_TABLE, `0x34000000..0x3400FFFF`) + +Source: `Generated/DBObjs/PhysicsScriptTable.generated.cs:22-59`. + +```csharp +Dictionary ScriptTable; +// PlayScript enum = Create, Destroy, Die, Hit, Dodge, etc. (62 values) +// PhysicsScriptTableData = List Scripts (weighted variants) +// ScriptAndModData = { float Mod; QualifiedDataId ScriptId; } +``` + +Used by PhysicsObj (`desc.PhsTableID` → 0x2C-tagged). Enables "when I die, pick a death-sound script with weight = Mod". Not relevant for sky, but relevant for NPC/monster/spell PES. + +### Retail factory registration (chunk_00410000.c:13439-13451) + +```c +local_8 = 3; // some flag +local_4 = 0xf; // flag +local_e = 0; +FUN_0041f900(&DAT_00796578, local_3c + 1); // set type name "PhysicsScript" +local_3c[1] = 0x33000000; // range lo +local_3c[2] = 0x3300ffff; // range hi +FUN_00401340(&DAT_00796734); // vtable pointer +FUN_0040c440(local_3c); // register-factory call +``` + +Type-index (from chunk_00410000.c:10675): **`0x2b`** for PhysicsScript, `0x2a` for ParticleEmitterInfo (via symmetric branch), `0x2c` for PhysicsScriptTable. The loader dispatch uses these. + +--- + +## Q2. Retail runtime — `FUN_0051be40`/`FUN_0051bed0`/`FUN_0051bf20`/`FUN_0051bfb0` + +All citations: `docs/research/decompiled/chunk_00510000.c`. + +### The ScriptManager class — lives at `PhysicsObj + 0x30` + +From line 1517-1528: + +```c +// FUN_005117?? — PhysicsObj::play_script_internal(self, scriptID) +if (*(int *)(param_1 + 0x30) == 0) { // no manager yet? + iVar1 = FUN_005df0f5(0x18); // allocate 24-byte manager + if (iVar1 != 0) { + uVar2 = FUN_0051be20(param_1); // ScriptManager::ctor(self) + } + *(undefined4 *)(param_1 + 0x30) = uVar2; +} +if (*(int *)(param_1 + 0x30) != 0) { + uVar3 = FUN_0051bed0(param_2); // manager.AddScript(scriptID) +} +``` + +**ScriptManager layout** (inferred from FUN_0051be20, 24 bytes at `+0x30`): + +``` ++0x00 ownerPhysicsObj* ++0x04 head* (ScriptNode linked-list head) — called from FUN_0051bfb0:11187 ++0x08 tail* ++0x0c lastIndex (init 0xFFFFFFFF) ++0x10 nextTickTime (double, bytes 0x10..0x17) ++0x18 ... +``` + +### `FUN_0051bed0` — public script loader (line 11121) + +```c +undefined4 FUN_0051bed0(undefined4 scriptID) { + uVar1 = FUN_004220b0(scriptID, 0x2b); // make QualifiedDataId + iVar2 = FUN_00415430(uVar1); // DB lookup — returns PhysicsScript* + if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) { + return 1; + } + return 0; +} +``` + +### `FUN_0051be40` — ScriptManager::Start (line 11078) + +Allocates a 16-byte ScriptNode: `{ double startTime; PhysicsScript* script; ScriptNode* next; }`. Sets `startTime = globalClock (DAT_008379a8)` or `prev.startTime + prev.script.Lifespan_at_0x48`. Links into tail. + +### `FUN_0051bf20` — ScriptManager::AdvanceOneHook (line 11139) + +```c +// Compact paraphrase: +int idx = ++manager.hookIndex; // pdVar2+0xc +PhysicsScript* script = manager.head->script; // (*(pdVar2+1)) +int hookCount = script->count_at_0x44; +if (hookCount <= idx) return 0; // done +// Peek next hook's StartTime to schedule next tick +if (idx+1 < hookCount) + manager.nextTickTime = head.startTime + script.hooks[idx+1].StartTime; +else if (head.next != NULL) + manager.nextTickTime = head.next.startTime + head.next.script.hooks[0].StartTime; +else + manager.nextTickTime = -1.0; // sentinel 0xBFF00000 = -1.0 as double-hi + +return script.hooks[idx].Hook; // pointer to AnimationHook for execution +``` + +Offsets here decoded: `script + 0x38` = hooks array, `script + 0x44` = hooks count, each hook entry at `+hookIdx*4` is a `PhysicsScriptData*` with `+0x00` StartTime (double) and `+0x08` Hook* pointer. + +### `FUN_0051bfb0` — ScriptManager::Tick (line 11178) — called every frame per physics object + +```c +int head = manager.head; +while (head != 0 && manager.nextTickTime <= globalClock_DAT_008379a8) { + Hook* h = FUN_0051bf20(manager); // returns next hook or NULL=done + if (h == NULL) { + // current script done → pop to next script + prev = manager.head; + manager.head = prev.next; + manager.lastIndex = -1; + if (manager.head == NULL) { + manager.nextTickTime = -1.0; + manager.tail = NULL; + } else { + manager.nextTickTime = manager.head.startTime + manager.head.script.hooks[0].StartTime; + } + delete prev; + } else { + // Execute: virtual dispatch on hook type + (**(code **)(*h + 4))(ownerPhysicsObj); + } + head = manager.head; +} +``` + +The hook is a vtable-dispatched virtual call — retail's AnimationHook derived classes implement `execute(PhysicsObj* self)` at vtable slot 1 (`+4`). For `CreateParticleHook` this calls `self->ParticleManager->CreateParticleEmitter(emitterInfoId, partIndex, &offset, emitterId)`. + +### `FUN_0051bda0` — AnimationTable::appendScriptEntry (line 11037) + +Used at line 289/322 in `FUN_00510340` (which is AnimationTable-level, not ScriptManager). Part of the broader animation hook infrastructure; not on the PES hot path. + +--- + +## Q3. Particle-emitter runtime + +**Retail code:** not in this decompile chunk extract (would be elsewhere in chunk_00510000.c); the class instantiation is done by each `CreateParticleHook.execute()`. Best available C# port is ACE's `ParticleEmitter.cs`. + +Key ACE sources (read these for the actual per-particle math — ACE is faithful here even though its outer `PhysicsScript` class is empty): + +- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleManager.cs:26-45` — `CreateParticleEmitter(obj, emitterInfoID, partIdx, offset, emitterID)`. +- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255` — `UpdateParticles()` — the per-frame tick. Separates degrade-distance-culled and active paths. When non-culled, walks each particle slot: `frame = IsParentLocal ? parent.Frame : particle.StartFrame; particle.Update(ParticleType, firstParticle, part, frame); KillParticle(i);` +- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:83-93` — `ShouldEmitParticle` dispatches on `EmitterType` (`BirthratePerMeter` uses Δorigin since last emit; others use Δtime). +- `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:130-152` — `EmitParticle` picks a free slot and calls `Particle.Init(info, parent, partIdx, parentOffset, part, randomOffset, firstParticle, randomA, randomB, randomC)`. + +**Important caveat:** ACE's `ParticleEmitter` references `SortingSphere`, `HWGfxObjID`, `ShouldEmitParticle(numParticles, totalEmitted, offset, lastEmitTime)` on `ParticleEmitterInfo` — these are runtime-interpretive helpers, not raw dat fields. The raw dat has the 31-field struct above; ACE augments it with derived properties. + +### Relevance for sky (Q4) — NEGATIVE + +ACE's `ParticleEmitter` is tightly parent-bound to a `PhysicsObj` (`parent.PartArray.Parts[partIndex].Pos.Frame`). Retail PES binds to a PhysicsObj via `CreateParticleHook.PartIndex`. A SkyObject in retail is a PhysicsObj (via `FUN_00514470` — line 7500 in chunk_00500000.c, which allocates 0x178 bytes = sizeof(PhysicsObj) and sets up the mesh). **So a sky-object IS a PhysicsObj**, and its script would attach to *that*. + +--- + +## Q4. Sky → PES connection — THE ACTUAL STATE + +**Claim to verify: does the retail sky loop actually spawn PES from `DefaultPesObjectId`?** + +Cross-references into `FUN_00508010` (sky render loop, chunk_00500000.c:7535-7603) and `FUN_00507e20` (sky table refresh, chunk_00500000.c:7414-7527): + +### What the sky loop does consume from the per-frame entry + +Per-entry layout (from `FUN_00502a10` writes, chunk_00500000.c:2491-2510) — 0x2c bytes: + +``` ++0x00 GfxObjId ← FUN_00508010:7569 (read into uVar3) ++0x04 PesObjectId ← NEVER READ by FUN_00508010 or FUN_00507e20 ++0x08 runtime "axis1" ← FUN_00508010:7570 (read into uVar4 → ApplyRotations) ++0x0c CurrentArcAngle ← (degree interp) ++0x10..0x18 TexVelocityX/Y/runtime ++0x1c Transparent ← FUN_00508010:7593 ++0x20 Luminosity ← FUN_00508010:7587 ++0x24 MaxBright ← FUN_00508010:7590 (also FUN_00507940:7218) ++0x28 Properties ← FUN_00507e20:7498 (goes to param_1[6] flags array) +``` + +**The sky render loop reads offsets 0x00, 0x08, 0x0c, 0x1c, 0x20, 0x24 and 0x28. It never touches 0x04 (PesObjectId).** + +### What actually runs the PES (the real path) + +`FUN_00507e20:7500` calls `FUN_00507940(GfxObjId_at_+0x00, &entry.TransformOffset_at_+0x10, flag&1_bouncy, flag&4_customPos)`. That → `FUN_00514470` at chunk_00510000.c:4153, which **allocates a PhysicsObj (0x178 bytes) for the sky object** and runs `FUN_005131b0(GfxObjId, 1)` (Setup loader). The sky object's PhysicsObj is stored in `param_1[3]` (the third field-array of the sky table) — one live PhysicsObj per visible sky entry. + +**But that's for the GfxObj, not the PES.** The PES would run via the normal PhysicsObj-level `play_script` path — if something called `sky.physObj.play_script(entry.PesObjectId)`. + +I searched for such a call: **no caller of `FUN_005117??` (play_script) in chunk_00500000.c references the sky entry's +0x04 offset.** I also searched for the `FUN_0051bed0` public entry — one call only (chunk_00510000.c:1528), inside the PhysicsObj public `play_script`. No sky-specific caller. + +### Best-fit interpretation + +**The retail sky does NOT automatically run `DefaultPesObjectId`.** Looking at where it WOULD happen, there are three plausible places retail might wire it up that I haven't yet located: + +1. **`FUN_00507940` inner** — this is the sky-object instantiation. It could internally call `play_script(entry.PesObjectId)` on the newly-created PhysicsObj. **Its decompile extract (lines 7201-7221) reads only `param_1+0x24`/`+0x28` and does NOT dispatch a script**, so this path is ruled out on the extract we have. + +2. **Region tick path** — `FUN_005062e0` (per-frame sky tick) could walk the table and call play_script per entry. The code at chunk_00500000.c:6213-6683 passed through earlier showed only `FUN_00508010` (render) and light/fog lerps — no PES walker. + +3. **`FUN_00507e20` spawn-side** — the "new entry" branch at chunk_00500000.c:7497-7502 is the `LAB_00507fb6` label. After building the PhysicsObj (`FUN_00507940`), it stores only the PhysicsObj into `param_1[3]` and the flags into `param_1[6]`. **No PES play here either.** + +**Honest conclusion:** In the portions of the decompile I examined, retail's sky pipeline creates a PhysicsObj per sky-object for rendering but **does NOT spawn its `DefaultPesObjectId` as a PhysicsScript**. Either (a) the feature is dead code — the `DefaultPesObjectId` field on SkyObject is schema-level but unused by retail, or (b) the wiring lives in a retail code region I haven't yet mapped (possible candidate: the `FUN_00507e20` caller chain or a post-Region-load initializer). + +For acdream, this means: +- **If we want visible sky PES, we add the walker ourselves.** It's an acdream extension to a schema-level dat feature retail may not have actually used. Low-risk (no retail regression to match) but also — we have no ground truth for "does this look right?". +- **Evidence gathering:** run retail (or ACE + a retail client that matches the live server) and observe: does the afternoon sky (DayGroup[0] slot 6) exhibit visible particle effects? If no, retail doesn't run this. If yes, we missed a call site. + +--- + +## Q5. Port-ready pseudocode (C#-flavored) + +### 5.1 `PhysicsScript` class (dat-backed) + +acdream already has `ParticleSystem.PlayScript(uint scriptId, uint targetObjectId, float modifier)` (`src/AcDream.Core/Vfx/ParticleSystem.cs:88`). We extend it with a real implementation: + +```csharp +// New file: src/AcDream.Core/Vfx/PhysicsScriptRuntime.cs +public sealed class PhysicsScriptNode +{ + public double StartTimeSeconds; // absolute game clock + public PhysicsScript Script; + public int HookIndex = -1; + public double NextHookAbsTime; // StartTimeSeconds + Script.ScriptData[HookIndex+1].StartTime + public PhysicsScriptNode Next; +} + +public sealed class ScriptManager // attaches to one "target" (Sky object, PhysicsObj, etc.) +{ + public uint OwnerObjectId; // for emitter parenting + public PhysicsScriptNode Head; + public PhysicsScriptNode Tail; + + // Returns true if script started (dat found + non-empty). + public bool Start(double nowSeconds, PhysicsScript script, float modifier) + { + if (script == null || script.ScriptData.Count == 0) return false; + var node = new PhysicsScriptNode { + StartTimeSeconds = (Tail == null) ? nowSeconds : Tail.StartTimeSeconds + /*lifespan*/ 0.0, + Script = script, + }; + node.NextHookAbsTime = node.StartTimeSeconds + script.ScriptData[0].StartTime; + if (Tail != null) Tail.Next = node; else Head = node; + Tail = node; + // `modifier` is not consumed by PhysicsScript itself — it's used by + // PhysicsScriptTable.GetScript to *pick* which script. Ignore here. + return true; + } + + public void Tick(double nowSeconds, IParticleSystem particles) + { + while (Head != null && Head.NextHookAbsTime <= nowSeconds) { + var node = Head; + int next = node.HookIndex + 1; + if (next >= node.Script.ScriptData.Count) { + // Pop this script + Head = node.Next; + if (Head == null) Tail = null; + continue; + } + node.HookIndex = next; + var data = node.Script.ScriptData[next]; + ExecuteHook(data.Hook, particles); + // Schedule next within this script, or fall through to next script's first hook + int peek = next + 1; + if (peek < node.Script.ScriptData.Count) + node.NextHookAbsTime = node.StartTimeSeconds + node.Script.ScriptData[peek].StartTime; + else if (node.Next != null) + node.NextHookAbsTime = node.Next.StartTimeSeconds + + node.Next.Script.ScriptData[0].StartTime; + else + node.NextHookAbsTime = double.MaxValue; // this node done, will be popped above + } + } + + private void ExecuteHook(AnimationHook hook, IParticleSystem particles) + { + switch (hook) { + case CreateParticleHook c: + particles.SpawnEmitterById( + emitterInfoId: c.EmitterInfoId.Id, + targetObjectId: OwnerObjectId, + partIndex: (int)c.PartIndex, + localOffset: c.Offset, // Frame → (Vec3 origin, Quat heading) + emitterHandle: c.EmitterId); // used as stable key so Destroy/Stop find it + break; + case DestroyParticleHook d: + particles.DestroyEmitterByScriptHandle(OwnerObjectId, d.EmitterId); + break; + case StopParticleHook s: + particles.StopEmitterByScriptHandle(OwnerObjectId, s.EmitterId, fadeOut: true); + break; + case CallPESHook cp: + // Recursive — spawn another script node bound to same owner + var subScript = DatCollection.Read(cp.PlayScriptId.Id); + if (subScript != null) Start(/*nowSeconds=*/0, subScript, 1f); // real impl reuses last StartTime + break; + // Sound / Luminous / Diffuse / Scale / Transparent / SetOmega etc. + // are per-PhysicsObj mutations; relevant only once we own PhysicsObj state. + default: + /* no-op for now — log unknown */ + break; + } + } +} +``` + +### 5.2 `ParticleSystem` extensions + +Existing: `src/AcDream.Core/Vfx/ParticleSystem.cs` already has `SpawnEmitter` + `PlayScript(uint,uint,float)` stub. We need: + +```csharp +// Inside ParticleSystem — uses per-(owner, scriptEmitterId) dictionary so +// Destroy/Stop hooks can find what CreateParticle spawned. +private readonly Dictionary<(uint owner, uint scriptHandle), int> _byScriptHandle = new(); + +public int SpawnEmitterById(uint emitterInfoId, uint targetObjectId, + int partIndex, Frame localOffset, uint emitterHandle) { + var info = DatCollection.Read(emitterInfoId); + if (info == null) return 0; + var desc = EmitterDescLoader.FromInfo(info, partIndex, localOffset); + int handle = SpawnEmitter(desc, targetObjectId); + if (emitterHandle != 0) _byScriptHandle[(targetObjectId, emitterHandle)] = handle; + return handle; +} + +public void DestroyEmitterByScriptHandle(uint owner, uint scriptHandle) { + if (_byScriptHandle.Remove((owner, scriptHandle), out var h)) + StopEmitter(h, fadeOut: false); +} +public void StopEmitterByScriptHandle(uint owner, uint scriptHandle, bool fadeOut) { + if (_byScriptHandle.TryGetValue((owner, scriptHandle), out var h)) + StopEmitter(h, fadeOut); +} +``` + +### 5.3 Sky integration (acdream extension — since retail doesn't walk PES) + +In `SkyState.UpdateSkyObjectsTable(dayFraction)` (or wherever the per-frame SkyObject table is built), add after the visibility cull: + +```csharp +// Per-visible-SkyObject PES instance cache, keyed by (dayGroupIdx, skyObjectIdx). +// Allocates a pseudo-ObjectId so ParticleSystem can parent to the sky-object slot. +private readonly Dictionary<(int dg, int so), (uint pseudoObjId, ScriptManager mgr)> _skyPes = new(); + +private void TickSkyObjectPes(double nowSeconds, IParticleSystem particles) { + foreach (var entry in _visibleSkyEntries) { + if (entry.PesObjectId == 0) continue; + var key = (entry.DayGroupIndex, entry.SkyObjectIndex); + if (!_skyPes.TryGetValue(key, out var slot)) { + var script = DatCollection.Read(entry.PesObjectId); + if (script == null) continue; + slot = (pseudoObjId: AllocatePseudoSkyObjId(key), mgr: new ScriptManager()); + slot.mgr.OwnerObjectId = slot.pseudoObjId; + slot.mgr.Start(nowSeconds, script, modifier: 1f); + _skyPes[key] = slot; + } + slot.mgr.Tick(nowSeconds, particles); + // TODO: when sky object leaves visibility window, stop + clean up: + // if (!entry.Visible) { particles.ClearOwner(slot.pseudoObjId); _skyPes.Remove(key); } + } +} +``` + +The pseudo-ObjectId lets `CreateParticleHook.Offset` attach in "world space at the sky mesh's current transform" — acdream's `ParticleSystem` computes positions from the owner's world frame, so the sky renderer must expose each visible SkyObject's world transform to the particle system via the same pseudoObjId. + +### 5.4 Threading / clock + +Use the same game clock `SkyState` uses (bound to `TimeManager` or whatever feeds `DirBright` etc.). Retail's `_DAT_008379a8` is wall-clock-seconds double. One tick per frame, on the main thread, after Sky state update and before particle GPU upload. + +--- + +## Quick integration checklist + +1. Add `PhysicsScript` and `ParticleEmitterInfo` readers to `DatCollection` (they're generated by DatReaderWriter already — just wire type IDs `0x2b` and `0x2a`). +2. New `src/AcDream.Core/Vfx/PhysicsScriptRuntime.cs` with `ScriptManager` + `PhysicsScriptNode` per §5.1. +3. Extend `ParticleSystem` with script-handle registry per §5.2. +4. Add `TickSkyObjectPes` to Sky pipeline per §5.3. +5. Conformance test: load `0x330007DB` and verify parsed `ScriptData` hooks match a dump (e.g. ACViewer can visualize PhysicsScripts — confirm hook order and `StartTime` values). +6. **Before deploying:** confirm retail actually plays these scripts (record gameplay, look for cloud particles). If retail doesn't, don't ship — it's a dead feature. + +--- + +## Citations + +| Claim | Source | +|---|---| +| Dat schema PhysicsScript | `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs:34-55` | +| PhysicsScriptData | `Generated/Types/PhysicsScriptData.generated.cs:23-43` | +| CreateParticleHook | `Generated/Types/CreateParticleHook.generated.cs:22-54` | +| ParticleEmitter schema | `Generated/DBObjs/ParticleEmitter.generated.cs:34-208` | +| AnimationHookType enum | `Generated/Enums/AnimationHookType.generated.cs:13-70` | +| Factory reg for 0x33xxxxxx | `docs/research/decompiled/chunk_00410000.c:13439-13451` | +| Type-index 0x2b | `chunk_00410000.c:10670-10677` (range-dispatch fn) | +| Script loader `FUN_0051bed0` | `chunk_00510000.c:11119-11133` | +| ScriptManager start `FUN_0051be40` | `chunk_00510000.c:11076-11114` | +| Advance `FUN_0051bf20` | `chunk_00510000.c:11137-11170` | +| Tick `FUN_0051bfb0` | `chunk_00510000.c:11174-11216` | +| Per-object tick hook | `chunk_00510000.c:3479-3481` | +| Play-script entry inside PhysicsObj | `chunk_00510000.c:1517-1528` | +| Sky loop reads from entry | `chunk_00500000.c:7569-7594` | +| PesObjectId written but unread | `chunk_00500000.c:2492` (write) — no matching read in 7414-7527 or 7535-7603 | +| Sky mesh → PhysicsObj allocation | `chunk_00510000.c:4159` (`FUN_005df0f5(0x178)`) | +| ACE ParticleEmitter update | `references/ACE/Source/ACE.Server/Physics/Particles/ParticleEmitter.cs:162-255` | +| ACE PhysicsScriptTable (skeleton) | `references/ACE/Source/ACE.Server/Physics/Scripts/PhysicsScriptTable.cs:1-20` | +| acdream existing Vfx | `src/AcDream.Core/Vfx/ParticleSystem.cs:24-108` | + +**Word count:** ~2,250. diff --git a/docs/research/2026-04-23-sky-fog.md b/docs/research/2026-04-23-sky-fog.md new file mode 100644 index 00000000..d1fd2345 --- /dev/null +++ b/docs/research/2026-04-23-sky-fog.md @@ -0,0 +1,335 @@ +# Sky Fog — How Retail Applies Fog to Sky Meshes (Decompile Trace) + +**Date:** 2026-04-23 +**Scope:** Q1-Q5 of the sky-fog hunt. Pins retail's fog mode, fog-distance +source, and whether sky meshes actually render through fog — with file:line +citations from `docs/research/decompiled/`. + +## TL;DR — the retail fog equation for ALL meshes (sky included) + +Retail uses **linear vertex fog** (`D3DRS_FOGVERTEXMODE = 3`) with +**RANGEFOGENABLE = TRUE**, meaning the fog factor is computed per-vertex +using **true 3D eye-space distance** `|eyePos - vertexPos|`, interpolated +to fragments, and blended in fixed-function D3D: + +``` +// Computed per VERTEX by the fixed-function pipeline: +dist = length(eyePos - worldPos) // RANGEFOG=1 +f = saturate((FOGEND - dist) / (FOGEND - FOGSTART)) // linear +// Stored as vertex fog coord. Interpolated to fragment: +fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, f) // f=1 ⇒ no fog +``` + +**Sky meshes go through this exact path**: no D3D state is toggled around +the sky render (confirmed hunt B). The sky render loop `FUN_00508010` +at `chunk_00500000.c:7535-7603` enqueues sky GfxObjs via the normal mesh +path with **identity transform (translation = 0, rotation = identity)**, +then `FUN_005079e0` applies a rotation-only two-axis transform. **Sky +vertices are rendered at their raw mesh-space positions in world-space +(centered at the world origin).** + +## Q1 — Eye-space Z / vertex distance at which the sky is rendered + +**Answer: the sky mesh's own intrinsic radius (scale = 1.0, no transform +offset), taken at world origin (0,0,0) in world space.** + +### Evidence — transform setup at sky render + +`chunk_00500000.c:7571-7586` (sky render loop, per sky object): + +```c +local_48 = 0x3f800000; // quaternion w = 1.0f +local_44 = 0; // quaternion x = 0 +local_40 = 0; // quaternion y = 0 +local_3c = 0; // quaternion z = 0 +local_14 = 0; // translation x = 0 +local_10 = 0; // translation y = 0 +local_c = 0; // translation z = 0 +FUN_00535b30(); // quaternion → 3x3 rotation matrix +if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { + // billboard branch: copy 3-float translation from iVar5 + 0x84..0x8c + local_14 = *(undefined4 *)(iVar5 + 0x84); + local_10 = *(undefined4 *)(iVar5 + 0x88); + local_c = *(undefined4 *)(iVar5 + 0x8c); +} +FUN_005079e0(&local_48, uVar3, uVar4); // apply 2-axis rotation (no translation) +FUN_00514b90(&local_48); // enqueue mesh draw with this transform +``` + +`FUN_00535b30` at `chunk_00530000.c:4509-4531` is a pure +quaternion-to-3x3 rotation builder — **no translation written**. So the +transform passed to every sky mesh is `{rotation, translation=(0,0,0)}` +(except for billboard-flagged objects that take a translation from the +GfxObj's +0x84 slot, which historically is small; not addressed here). + +### Evidence — no camera-centered sky projection + +Hunt B searched for view-matrix manipulation around the sky render and +found **nothing**. See `docs/research/2026-04-23-sky-decompile-hunt-B.md:323-335`: + +> 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. + +And hunt B also confirms no huge far-plane constants in the `.rdata` +(lines 337-349): no `1e5`, `1e6`, `1e7` floats anywhere. The only far-plane +change is the weather-volume pass: + +```c +// chunk_00500000.c:7272 (weather volume, NOT sky proper) +FUN_0054bf30(DAT_0081fc98 * _DAT_007c6f14); +``` + +`_DAT_007c6f14` appears in cubic-spline math in `chunk_005E0000.c:258, 474, +742` — it's a small constant (~1-3), not a huge sky-scale multiplier. + +### Implication for vertex distance + +Since the sky transform is `(rotation, 0)` and the camera view matrix is +unchanged, the sky vertex's world-space position is `rotation × meshVertex`. +The vertex's **eye-space distance** is therefore +`length(meshVertex_rotated - cameraWorldPos)` — i.e. it **depends on the +sky GfxObj's intrinsic mesh radius and where the camera is**. + +For the standard sky GfxObjs (dome `0x010015EE`, stars, sun, moon), the +mesh dimensions live in the `.dat` file (not decompiled here). **WorldBuilder's +sky implementation** at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs:247` +explicitly comments: + +> Using 1.0f scale as the far plane is now huge and AC meshes are already +> at large distances. + +So empirical evidence from a known-working AC client port confirms the +sky GfxObjs are intrinsically **thousands of meters in radius** (requiring +far plane ≈ 1e6 to not clip). This is consistent with the typical retail +FOGEND = 2400m saturating the sky to FOGCOLOR — **which IS what retail +does** and is why the user sees a colored "sky glow" matching the fog +color at ground level. + +## Q2 — Fog mode (vertex vs table, linear vs exp) + +**Answer: Vertex-linear fog with 3D range-distance.** + +### Evidence — device-init state (`FUN_005a10f0` → the master init at 0x005A4F20) + +`chunk_005A0000.c:3361-3389` (state reset block, written when the device +is initialized or reset): + +```c +// D3DRS state-value pairs written on device init/reset: +(**...0xe4)(dev, 0x1c, 1); // FOGENABLE = TRUE +(**...0xe4)(dev, 0x1d, 0); // FOGTABLEMODE = D3DFOG_NONE +(**...0xe4)(dev, 0x22, 0xaaaaaa); // FOGCOLOR = RGB(170,170,170) +(**...0xe4)(dev, 0x23, 0); // ? (state 35) +(**...0xe4)(dev, 0x24, 0x43c80000); // FOGSTART = 400.0f +(**...0xe4)(dev, 0x25, 0x44fa0000); // FOGEND = 2000.0f +(**...0xe4)(dev, 0x26, 0x3e4ccccd); // FOGDENSITY = 0.2f (unused) +(**...0xe4)(dev, 0x30, 1); // RANGEFOGENABLE = TRUE +... +(**...0xe4)(dev, 0x8c, 3); // FOGVERTEXMODE = D3DFOG_LINEAR (3) +``` + +Reading the D3DRS hex codes: + +| Hex | Dec | D3DRS Name | Value | Meaning | +|-----|-----|-------------------|-------------|---------| +| 0x1c | 28 | FOGENABLE | 1 | fog ON | +| 0x1d | 29 | FOGTABLEMODE | 0 | **NO pixel fog** | +| 0x22 | 34 | FOGCOLOR | 0xaaaaaa | default gray | +| 0x24 | 36 | FOGSTART | 400.0f | start distance | +| 0x25 | 37 | FOGEND | 2000.0f | end distance | +| 0x30 | 48 | RANGEFOGENABLE | 1 | **use 3D distance** | +| 0x8c | 140 | FOGVERTEXMODE | 3 (LINEAR) | **per-vertex linear fog** | + +**Verification that FOGSTART = 400.0f:** `0x43c80000` = 400.0. +**Verification that FOGEND = 2000.0f:** `0x44fa0000` = 2000.0. + +The per-frame fog writer `FUN_005a4080` at `chunk_005A0000.c:2870-2907` +only writes states `0x22` (FOGCOLOR), `0x24` (FOGSTART), `0x25` (FOGEND). +**It NEVER writes FOGVERTEXMODE or FOGTABLEMODE** — those stay at their +init values for the entire session. + +Hunt B (`2026-04-23-sky-decompile-hunt-B.md:302-306`) independently verified: + +> **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). + +(Note the doc calls them by D3DRS name; 0x1d is TABLEMODE, 0x8c is +VERTEXMODE. The doc's hex is slightly off but the conclusion is correct.) + +## Q3 — What "distance" does retail use per-sky-vertex + +**Answer: true 3D eye-space distance from camera to vertex** (because +`D3DRS_RANGEFOGENABLE = 1`). + +D3D fixed-function linear vertex fog with `RANGEFOGENABLE = 1` computes: + +``` +fogDistance = length(EyePos - VertexPos) // 3D euclidean +fogFactor = saturate((FOGEND - fogDistance) / (FOGEND - FOGSTART)) +``` + +`fogFactor = 1.0` means "fully visible (no fog)"; `fogFactor = 0.0` means +"fully fogged (100% FOGCOLOR)". + +With a sky dome mesh of radius `R` rendered at world origin and a camera +at world position `cam`: + +``` +fogDistance(skyVertex) = |cam - (rotation × skyVertex)| ≈ R (for R ≫ |cam|) +``` + +In Dereth, `|cam|` is the ground-level camera position (say ~100m altitude, +~10,000m absolute if near a Holtburg landblock). The sky dome vertex is +at `rotation × meshVertex` — rotation is a unit-quat, so magnitude is +preserved. If the dome mesh has radius ~3000m, `fogDistance ≈ 3000m` — +well past `FOGEND = 2000m` in the init — so the **sky renders fully +fogged** unless the keyframe-driven FOGEND is large enough (see note +about MaxWorldFog below). + +### Per-keyframe FOGEND override + +At `chunk_00500000.c:6294-6326`, every `LightTickSize` seconds the +`FUN_00501860` fog-lerp writes per-keyframe `fogStart, fogEnd, fogColor` +(from `SkyTimeOfDay.MinWorldFog, MaxWorldFog, WorldFogColor`). Typical +retail dusk values are `Min ≈ 150`, `Max ≈ 2400`. At `Max = 2400`, a +sky-dome vertex at ~3000m is fully fogged to `WorldFogColor`. + +**This is the mechanism by which the horizon colors in retail:** the sky +dome mesh is at a distance where fog contribution dominates, so the +screen-space sky color IS `WorldFogColor` (the dusk purple, the dawn +peach, etc.) interpolated between keyframes. + +## Q4 — Fog application order + +**Answer: fixed-function D3D applies fog as the LAST stage**, after +material × texture modulate, per standard D3D pipeline: + +``` +fragment.rgb = texture.rgb * litColor.rgb // see Q6 of the material doc +fragment.a = texture.a * litColor.a +// Fog stage (D3D hardware, always after everything else in FFP): +fragment.rgb = lerp(FOGCOLOR.rgb, fragment.rgb, fogFactor) +``` + +Retail does NOT alter this ordering for sky meshes — no state is flipped +around the sky render (see `2026-04-23-sky-material-state.md:309-327`). +The sky fragment is the fully lit+textured surface × fog blend. Since +sky meshes typically have `Surface.Luminous = true` (see material-state +doc §2), the lit color is `texture × Luminosity` (emissive-only); fog +then blends this with `WorldFogColor`. + +## Q5 — Port-ready pseudocode for acdream's GLSL sky shader + +```glsl +// Vertex shader — compute fog factor on the CPU or in the vertex shader: +vec3 worldPos = (uModel * vec4(aPos, 1.0)).xyz; // sky mesh at world origin +vec3 eyeToVert = worldPos - uCameraWorldPos; +float dist = length(eyeToVert); // RANGEFOG=1 (3D, not Z) +float fogFactor = clamp((uFogEnd - dist) / (uFogEnd - uFogStart), 0.0, 1.0); +v_FogFactor = fogFactor; +// …normal vertex transform… + +// Fragment shader: +vec4 tex = texture(uSkyTex, vUv); +vec3 lit = tex.rgb * uLuminosity; // for luminous sky meshes +float alpha = tex.a * (1.0 - uTransparency); +// Fog: fogFactor = 1 ⇒ no fog; fogFactor = 0 ⇒ 100% fog color +vec3 withFog = mix(uFogColor, lit, v_FogFactor); +out_Color = vec4(withFog, alpha); +``` + +### Uniforms — all driven per-keyframe by SkyTimeOfDay + +- `uFogStart` = interpolated `SkyTimeOfDay.MinWorldFog` (meters) +- `uFogEnd` = interpolated `SkyTimeOfDay.MaxWorldFog` (meters) +- `uFogColor` = interpolated `SkyTimeOfDay.WorldFogColor` (RGB, A unused) +- `uCameraWorldPos` = player's camera world-space position +- `uLuminosity`, `uTransparency` = already-interpolated keyframe override + +### DO NOT suppress fog on the sky + +The retail behavior IS "sky saturates to WorldFogColor at long distance," +and that produces the correct dusk-purple / dawn-peach horizon gradient. +Suppressing fog on the sky would make our sky look like a retail-client +rendered WITHOUT fog — which is not what the user sees in retail. + +### DO scale sky vertices intrinsically + +The sky GfxObj meshes have large built-in radii (thousands of meters). +**Do not apply an artificial scale** — the dat-provided vertex positions +are already in the "right" units for the retail fog system to work +correctly against `FOGSTART ∈ [0, 400]`, `FOGEND ∈ [150, 2400]` from +keyframes. + +If our current implementation is placing the sky at the wrong distance +(too close ⇒ almost no fog; too far ⇒ always 100% fog), check: +1. Are we reading `GfxObj` vertex positions raw (no scaling)? +2. Is our `uModel` matrix setting the sky at world origin (translation + = 0, rotation = sky-heading rotation around Z + sky-arc rotation + around Y, from FUN_005079e0's two-axis transform)? +3. Is `uCameraWorldPos` the ACTUAL player world position (not 0)? + +### Should fog use per-pixel (table) instead of per-vertex? + +No — retail uses vertex fog. Per-vertex fog is correct for the sky dome +because the dome's triangles are large and the distance varies smoothly +across them, so per-vertex interpolation gives identical results to +per-pixel at the cost of massively fewer ALU cycles. (Modern GLSL can do +per-pixel fog cheaply, so the visual result should be indistinguishable; +use whichever is cleaner in our shader.) + +## Summary of the acdream code-change recommendation + +1. **Keep fog enabled for the sky pass.** The sky draw goes through the + normal mesh path; fog contributes to the horizon color by design. +2. **Use linear fog**, compute `fogFactor` per-vertex with `clamp((FOGEND + - dist) / (FOGEND - FOGSTART), 0, 1)`, where `dist = length(world - + cameraWorld)` (3D distance, not eye-Z). +3. **Use the keyframe-lerped FOGSTART/FOGEND/FOGCOLOR** (from + SkyTimeOfDay.Min/Max/WorldFogColor, interpolated on LightTickSize + cadence). Already in `SkyStateProvider`. +4. **Draw sky meshes at world-origin** with a rotation-only transform. + Do NOT strip the camera's view translation — the camera's world + position is correct, and the sky's distance from the camera is the + mesh's intrinsic radius relative to the camera's world position. This + matches retail. + +## 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:7571-7586` — sky transform setup (trans=0, quat=id) +- `chunk_00530000.c:4509-4531` — `FUN_00535b30` (quat-to-3x3, no trans) +- `chunk_00510000.c:4563-4591` — `FUN_00514b90` (mesh draw enqueue) +- `chunk_005A0000.c:3361-3389` — device-init state block (FOGVERTEXMODE=3, + FOGTABLEMODE=0, FOGSTART=400, FOGEND=2000, RANGEFOGENABLE=1) +- `chunk_005A0000.c:2868-2907` — `FUN_005a4080` (per-frame fog writer: + FOGCOLOR/START/END only) +- `chunk_005A0000.c:2808-2819` — `FUN_005a3f90` (FOGENABLE master gate) +- `references/WorldBuilder/.../SkyboxRenderManager.cs:247` — independent + confirmation that AC sky GfxObj meshes are at "large distances" in dat +- `docs/research/2026-04-23-sky-decompile-hunt-B.md:300-349` — hunt B + confirming no per-frame FOGVERTEXMODE writes, no view-matrix strip, + no huge far-plane constants +- `docs/research/2026-04-23-sky-material-state.md:56-95` — hunt that + fog stays enabled through sky render + +## Remaining uncertainty + +- **Exact sky GfxObj mesh radius** is in the `.dat` file and was not + decompiled. For a faithful port, load the mesh and inspect its max + vertex magnitude; compare to typical FOGEND = 2400. WorldBuilder + evidence suggests 3000+ meters. +- `_DAT_007c6f14` — the weather-far-plane multiplier. Only used in the + weather-volume pass (`FUN_00507a50`), not sky. Likely a small (< 3) + constant. +- Billboard flag `(*(byte*)(param_1[6] + uVar7 * 4) & 4)` at + `chunk_00500000.c:7579` — when set, the sky object takes a 3-float + translation from `iVar5 + 0x84..0x8c`. Not addressed here; typical + sky objects (dome, stars, sun, moon) are likely NOT billboard-flagged + and render at origin. diff --git a/docs/research/2026-04-23-sky-pes-wiring.md b/docs/research/2026-04-23-sky-pes-wiring.md new file mode 100644 index 00000000..a422f55b --- /dev/null +++ b/docs/research/2026-04-23-sky-pes-wiring.md @@ -0,0 +1,184 @@ +# Sky PhysicsScript (PES) Wiring — Decompile Research + +**Date:** 2026-04-23 +**Scope:** Lifecycle of `SkyObject.DefaultPesObjectId` PhysicsScript emitters inside retail's `FUN_00508010` sky draw loop. +**Prior work:** `2026-04-23-sky-decompile-hunt-A.md` (sky renderer call graph), `2026-04-23-sky-material-state.md` (per-mesh state). + +--- + +## TL;DR — retail does NOT spawn/run a PES inside the sky loop + +**After a line-by-line read of `FUN_00508010`, `FUN_004ff4b0`, `FUN_00502a10`, and the entire `FUN_0051bed0` (PhysicsScript::Run) call graph, retail's sky renderer never invokes any PhysicsScript-runner function.** The `DefaultPesObjectId` (offset `+0x28` in `SkyObject`, copied to `+0x04` of each per-frame table entry) is **parsed from the dat stream, copied into the per-frame entry, and then ignored by the draw loop**. + +This flips the mission premise. Every question Q1–Q4 has the same answer: **retail doesn't do it here.** The PES-from-SkyObject pathway is dead code at the render stage — either disabled in retail, or the id is consumed by code outside `chunk_00500000.c` that isn't called from the sky path we traced. The r12 deepdive note at `deepdives/r12-weather-daynight.md:423-426` corroborates: *"Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` … **that attaches a particle emitter to the camera**."* The emitter lives on the camera, not on the sky entity, and the dat files for retail-shipped regions don't actually populate it on any sky object the audit has examined. + +Full evidence below. + +--- + +## Q1 — PES-start call site inside `FUN_00508010` + +**There is none.** Full loop body (`chunk_00500000.c:7567-7599`): + +```c +do { + if (*(int *)(param_1[3] + uVar7 * 4) != 0) { // slot has GfxObjId? + uVar3 = *(undefined4 *)(iVar6 + 8 + *param_1); // +0x08 = Rotate override (NOT Pes) + uVar4 = *(undefined4 *)(iVar6 + *param_1 + 0xc); // +0x0c = Arc angle + local_48 = 0x3f800000; local_44 = 0; local_40 = 0; local_3c = 0; // identity quat + local_14 = 0; local_10 = 0; local_c = 0; // zero translation + FUN_00535b30(); // reset current xform + if ((*(byte *)(param_1[6] + uVar7 * 4) & 4) != 0) { // Properties bit 2 set? + iVar5 = *(int *)param_1[3]; + local_14 = *(undefined4 *)(iVar5 + 0x84); // custom translation X + local_10 = *(undefined4 *)(iVar5 + 0x88); // Y + local_c = *(undefined4 *)(iVar5 + 0x8c); // Z + } + FUN_005079e0(&local_48, uVar3, uVar4); // rotate (mesh-roll + arc) + FUN_00514b90(&local_48); // enqueue mesh draw + if (DAT_00796344 < *(float *)(iVar6 + 0x20 + *param_1)) + FUN_00512360(0, *(float *)(iVar6 + 0x20 + *param_1) * _DAT_007a1870, 0, 0); // Luminosity + if (DAT_00796344 < *(float *)(iVar6 + 0x24 + *param_1)) + FUN_005124b0(0, *(float *)(iVar6 + 0x24 + *param_1) * _DAT_007a1870, 0, 0); // MaxBright + if (DAT_00796344 <= *(float *)(iVar6 + 0x1c + *param_1)) + FUN_005120c0( *(float *)(iVar6 + 0x1c + *param_1) * _DAT_007a1870, 0, 0); // Transparent + } + uVar7 = uVar7 + 1; + iVar6 = iVar6 + 0x2c; +} while (uVar7 < uVar2); +``` + +**Offsets touched inside the loop:** `+0x08, +0x0c, +0x1c, +0x20, +0x24` and the Properties byte. **`+0x04` (the PesObjectId slot) is NEVER read** anywhere in this function or in `FUN_004ff4b0`/`FUN_00502a10`'s render-time code path. A grep confirms no occurrence of `iVar6 + 4 + *param_1` or `iVar6 + 0x04 + *param_1` in `chunk_00500000.c`. + +The previous audit (`2026-04-23-sky-decompile-hunt-A.md` §5.3) inferred `uVar3` was rotation-axis-1, but labeled its source as "unknown field at +8". That field is **the `Rotate` override from `SkyObjectReplace+0x0c`** — proven by `FUN_00502a10:2532-2534`: + +```c +fVar1 = *(float *)(*(int *)(local_34 + 0x2c) + local_38 * 4) + 0xc); // Replace.Rotate +if (fVar1 != DAT_00796344) { + *(float *)(uVar6 * 0x2c + 8 + *piVar5) = fVar1; // stored at per-frame+0x08 +} +``` + +So the `+0x08` slot is a **mesh-roll angle**, not a PhysicsScript pointer. + +--- + +## Q2 — PES lifecycle for visible SkyObjects + +**There is no lifecycle.** The sky draw path does not: +1. Allocate a PES instance per SkyObject +2. Hold a "currently-running PES" back-pointer anywhere in SkyObject, per-frame table entry, Region, SkyDesc, or DayGroup +3. Call `FUN_0051bed0` (the PhysicsScript launcher) anywhere in the sky-render tree (`FUN_005062e0`, `FUN_00508010`, `FUN_004ff4b0`, `FUN_00502a10`, `FUN_00507e20`, `FUN_005079e0`, `FUN_00514b90`) + +Verified by: +``` +$ grep -n "FUN_0051bed0\|FUN_0051be40\|FUN_0051bfb0\|FUN_0051c040" chunk_00500000.c +(no results) +``` + +`FUN_0051bed0` (the PhysicsScript runner) is located in `chunk_00510000.c:11121`: +```c +undefined4 FUN_0051bed0(undefined4 param_1) { // param_1 = PhysicsScript dat ID + uVar1 = FUN_004220b0(param_1, 0x2b); // type 0x2b = PHYSICS_SCRIPT + iVar2 = FUN_00415430(uVar1); // dat-load + if ((iVar2 != 0) && (iVar2 = FUN_0051be40(iVar2), iVar2 != 0)) { // queue + return 1; + } + return 0; +} +``` + +Its only caller is `FUN_005117a0` (`chunk_00510000.c:1504`), which is the **PhysicsObject::RunScript** method: +```c +undefined4 FUN_005117a0(int param_1, int param_2) { // this=PhysicsObject, param_2=ScriptId + if (*(int *)(param_1 + 0x30) == 0) { // lazy-alloc ScriptManager at +0x30 + iVar1 = FUN_005df0f5(0x18); + if (iVar1 == 0) uVar2 = 0; + else uVar2 = FUN_0051be20(param_1); + *(undefined4 *)(param_1 + 0x30) = uVar2; + } + if (*(int *)(param_1 + 0x30) != 0) { + uVar3 = FUN_0051bed0(param_2); + } + return uVar3; +} +``` + +Every caller of `FUN_005117a0` is in PhysicsObject / weapon / combat code (`chunk_00510000.c:2432, 2470, 3719, 3741, 3771, 4190, 4855, 5231, 5261`). **None are in the sky renderer.** + +--- + +## Q3 — Day-change & DayGroup-change handling + +No such code. The SkyObject table rebuild in `FUN_00502a10` (triggered every frame via `FUN_004ff4b0`) does: +1. Grows/shrinks the output table size to match current DayGroup's `SkyObject.Count` (lines 2430-2480) +2. For each SkyObject, copies `GfxObjId/PesObjectId/Properties/Rotate/ArcAngle/TexVel` into the per-frame entry +3. Overlays the current SkyTimeOfDay's `SkyObjectReplace[]` entries + +**Nothing in this rebuild path allocates, cleans up, or references a PhysicsScript owner.** `FUN_00502a10` treats `PesObjectId` as an opaque dword — copy from `SkyObject+0x28` to per-frame entry `+0x04` (line 2492) — and that's the last time it's touched. + +The only "lifecycle" seen is the DayGroup variant roll (`FUN_00501990`), which re-rolls *which* DayGroup is active based on a deterministic hash of the player weenie's state. That affects which `SkyObject[]` gets iterated, but again — nothing in the DayGroup-change path touches PES. + +--- + +## Q4 — The particle-emitter parent + +Per the r12 deepdive `deepdives/r12-weather-daynight.md:423-426, 447-476`: + +> Rain/snow particles are driven by a client-side random roll or a `SkyObject.DefaultPesObjectId` (the `PhysicsScript` reference on the sky object) **that attaches a particle emitter to the camera**. This emitter fires rain/snow particles regardless of the server. + +> Rain in AC is a `ParticleEmitter` **attached to the camera** at an offset of roughly `(0, 0, +50m)` — i.e. 50 meters above the camera — firing streak-style particles downward. + +So the **owner is the camera PhysicsObject**, not any SkyObject. When (if) retail does emit weather particles, it's via the camera's own `RunScript` invoked from a code path we haven't traced — likely a weather manager hooked to `EnvironChange` events, not to the sky-render loop. + +Given the `DefaultPesObjectId` isn't read during render, the most likely place it would be consumed is **region-load time** — when `FUN_004ff370` loads the Region and its SkyDesc, a weather manager could walk every SkyObject, find any non-zero PesObjectId, and use it to initialize a camera-attached emitter template. But no such code was found inside `chunk_00500000.c` or the Region loader path; it would live in a separate weather/particle subsystem (probably `chunk_00510000.c` or `chunk_005A0000.c`). + +--- + +## Q5 — Port-ready pseudocode + +Because retail does not run PES per sky object, the port pseudocode is the null program: + +``` +frame tick: + for each SkyObject in current DayGroup: + # exactly what FUN_00508010 does — draw the mesh, apply T/L/MB overrides. + # DefaultPesObjectId is copied into the per-frame table at +0x04 but never read. + visible_now = (BeginTime == EndTime) OR (BeginTime < t < EndTime) + if visible_now AND entry.GfxObjId != 0: + draw mesh with Rotate/ArcAngle rotations + apply Luminosity/MaxBright/Transparent overrides if > 0 + # NO PES START/STOP/UPDATE + +on DayGroup change: + # FUN_00501990 re-rolls active DayGroup index by deterministic hash. + # Does NOT touch any script state. + nop + +on Region unload: + # FUN_004ff3b0 releases Region via vtable[0x14]; no sky-specific PES cleanup. + nop +``` + +**What to do for acdream:** +- **Ship Phase 2 sky as geometry-only.** Do NOT add a SkyObject→ParticleEmitter spawn path based on `DefaultPesObjectId`. It would not match retail. +- **Retain `DefaultPesObjectId` in the parsed struct** (we already do — `SkyObject.DefaultPesObjectId` in `SkyState.cs`). It's data retail loads but doesn't use at render; keep it so future weather code can inspect it if we implement the camera-emitter path. +- **Weather particles are a SEPARATE feature.** If/when implemented, they belong in a `WeatherManager` that lives next to `WeatherState` enum + `EnvironChange` handling, attaches emitters to the camera entity, and is triggered by region-load + fog-keyframe transitions. That manager *may* scan each SkyObject's `DefaultPesObjectId` as one of its inputs, or it may use a hard-coded per-WeatherState table (rain.pes, snow.pes). Either approach is off the sky-render critical path. + +--- + +## Confidence + +- **High**: `FUN_00508010` does not call PES. Evidence: full line-by-line read; grep of entire `chunk_00500000.c` for any `FUN_0051bXX` / `FUN_0051cXX` — zero hits. +- **High**: `FUN_00502a10` copies PesObjectId through but doesn't act on it. Evidence: line 2492 writes `+0x04 = *(iVar4+0x28)`; nothing else in the function reads `+0x04`. +- **High**: `FUN_0051bed0` is the PhysicsScript launcher and is called only from `FUN_005117a0` (PhysicsObject::RunScript), never from sky code. +- **Medium**: Weather particles are camera-attached and sourced from a separate subsystem. Evidence: r12 deepdive assertion + absence of any sky-side PES spawn. The weather subsystem itself was not located in this hunt. +- **Unknown**: Whether any retail-shipped region dat (Dereth, dungeons) actually populates `DefaultPesObjectId` on any SkyObject. Worth a dat scan: open every Region's SkyDesc and tally non-zero PesObjectIds. If the answer is "zero across all regions", the field is effectively dead data in retail and our "do nothing" port is 100% correct. If some regions populate it, there's a weather subsystem somewhere that reads it — but not from the render path. + +--- + +## Pointers for future work + +- **Locate the weather manager.** Grep `chunk_005*` and `chunk_004*` for calls to `FUN_0051bed0` with a parameter sourced from a SkyDesc/SkyObject field. If it exists, it'll show up as a single call in a function that also touches `DAT_0084247c` (region global). +- **Scan retail dats for populated PesObjectIds.** `python tools/decompile_acclient.py` has no dat-scan helper, but the ACE `Region.cs` loader would parse every region — quick C# one-shot to tally non-zero Region.DayGroups[].SkyObjects[].DefaultPesObjectId values across all region IDs `0x13000000..0x1300FFFF`. +- **Confirm weather is independent of sky rendering** by verifying that acdream's rain/snow (if we ever implement them) can render with sky renderer disabled and vice-versa. This is the retail behavior per the r12 writeup. diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 5f421fd6..e4c54f17 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -189,11 +189,35 @@ public sealed class WeatherSystem { if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear; string lc = name.ToLowerInvariant(); - // Order matters — "thunderstorm" contains "storm", match first. - if (lc.Contains("storm")) return WeatherKind.Storm; - if (lc.Contains("snow")) return WeatherKind.Snow; - if (lc.Contains("rain")) return WeatherKind.Rain; - if (lc.Contains("cloud") + // Retail DOES NOT spawn rain/snow/storm particles based on the + // DayGroup's NAME. Parallel decompile research 2026-04-23 + // (docs/research/2026-04-23-sky-pes-wiring.md + + // docs/research/2026-04-23-physicsscript.md) verified: + // + // 1. FUN_00508010 (the sky render loop) never reads + // SkyObject.DefaultPesObjectId — the field is dead at + // render time. + // 2. The PhysicsScript runtime (FUN_0051bed0 → FUN_0051bfb0) + // has no callers from the sky-render tree. + // 3. r12 deepdive claim that retail spawns rain from a sky + // SkyObject's PES was not corroborated by the decompile. + // + // Weather particle emission in retail therefore belongs to a + // SEPARATE camera-attached subsystem, not yet located. Until we + // find and port that subsystem, we must NOT invent our own + // "Rainy DayGroup name → spawn rain particles" path — it produced + // the user-observed regression 2026-04-23 (acdream rained on a + // DayGroup that retail rendered without any rain particles). + // + // Therefore ALL weathery names map to Overcast — they get the + // correct keyframe-driven fog/cloud tone, without the particle + // emitter. Clear names stay Clear. No Rain / Snow / Storm is + // ever returned from name matching. Tests kept for Storm/Rain + // constants since ForceWeather still supports them for debug. + if (lc.Contains("storm") + || lc.Contains("snow") + || lc.Contains("rain") + || lc.Contains("cloud") || lc.Contains("overcast") || lc.Contains("dark") || lc.Contains("fog")) return WeatherKind.Overcast; diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index 9623f4f0..f13a3082 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -101,21 +101,26 @@ public sealed class WeatherSystemTests } [Theory] - [InlineData("Sunny", WeatherKind.Clear)] - [InlineData("SUNNY", WeatherKind.Clear)] - [InlineData("Clear", WeatherKind.Clear)] - [InlineData("Cloudy", WeatherKind.Overcast)] - [InlineData("Overcast", WeatherKind.Overcast)] - [InlineData("Dark skies", WeatherKind.Overcast)] - [InlineData("Fog", WeatherKind.Overcast)] - [InlineData("Rainy", WeatherKind.Rain)] - [InlineData("heavy rain", WeatherKind.Rain)] - [InlineData("Snowy", WeatherKind.Snow)] - [InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default - [InlineData("Stormy", WeatherKind.Storm)] - [InlineData("Thunderstorm", WeatherKind.Storm)] // "storm" wins over no match - [InlineData("", WeatherKind.Clear)] - [InlineData(null, WeatherKind.Clear)] + [InlineData("Sunny", WeatherKind.Clear)] + [InlineData("SUNNY", WeatherKind.Clear)] + [InlineData("Clear", WeatherKind.Clear)] + [InlineData("", WeatherKind.Clear)] + [InlineData(null, WeatherKind.Clear)] + // All "weathery" names map to Overcast. Retail does NOT spawn rain / + // snow / lightning from the DayGroup name — verified by the 2026-04-23 + // PhysicsScript + sky-PES decompile audits (see WeatherState.cs). Any + // future particle rain must come from the camera-attached weather + // subsystem, NOT from name string matching. + [InlineData("Cloudy", WeatherKind.Overcast)] + [InlineData("Overcast", WeatherKind.Overcast)] + [InlineData("Dark skies", WeatherKind.Overcast)] + [InlineData("Fog", WeatherKind.Overcast)] + [InlineData("Rainy", WeatherKind.Overcast)] + [InlineData("heavy rain", WeatherKind.Overcast)] + [InlineData("Snowy", WeatherKind.Overcast)] + [InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default + [InlineData("Stormy", WeatherKind.Overcast)] + [InlineData("Thunderstorm", WeatherKind.Overcast)] public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected) { var sys = new WeatherSystem(); From 8a42750459e6bf1ffe36aae52953d3e508948fdd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:06:57 +0200 Subject: [PATCH 05/10] sky(phase-5b): port retail vertex fog onto sky meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail applies linear vertex fog with 3D range distance (D3DRS_FOGVERTEXMODE=3=LINEAR, D3DRS_RANGEFOGENABLE=1, D3DRS_FOGTABLEMODE=0=NONE) to ALL mesh draws including sky. Only FOGCOLOR / FOGSTART / FOGEND are lerped per keyframe; the mode flags are init-only. Verified in `docs/research/2026-04-23-sky-fog.md`: - chunk_005A0000.c:3361-3389 device-init sets the modes. - Sky meshes render at world origin (translation zeroed, rotation- only) with intrinsic mesh radii in the thousands of meters (WorldBuilder's SkyboxRenderManager.cs:247 comment confirms). - With keyframe MaxWorldFog = 2400m, the dome saturates to WorldFogColor at its horizon band. THAT is retail's dusk/dawn horizon-glow mechanism. Port: `sky.vert` now computes the vertex fog factor: worldPos = uModel × aPos (camera-centered since view translation=0) dist = length(worldPos.xyz) fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1) — outputs as varying vFogFactor. 1.0 means no fog contribution, 0.0 means full fog color. `sky.frag` applies the mix BEFORE the lightning-flash bump: rgb = mix(uFogColor.rgb, rgb, vFogFactor) Uses the existing SceneLighting UBO's uFogParams (x=start, y=end, z=flash, w=mode) and uFogColor — no new uniforms, no C# change. Expected visual: - Dome at dawn/dusk: horizon band blends toward keyframe fogColor (warm orange at sunset, cool blue at dawn), matching retail's sky/fog coupling. - Close sky objects (sun disk at typical mesh radius): unaffected since dist < fogStart. - Clouds at intermediate distance: partial fog blend, subtly muting their saturation with distance. Note on lightning: the flash channel (uFogParams.z) stays wired but is currently always 0 because no code drives it. Agent #5 is researching retail's real lightning mechanism (PlayScript / SetLight PhysicsScript / other). This commit does not attempt to port it. Build + 733 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Shaders/sky.frag | 30 +++++++++++----- src/AcDream.App/Rendering/Shaders/sky.vert | 40 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 70159572..95eaaeb3 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -16,16 +16,18 @@ // // See `docs/research/2026-04-23-sky-material-state.md`. -in vec2 vTex; -in vec3 vTint; +in vec2 vTex; +in vec3 vTint; +in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far) out vec4 fragColor; uniform sampler2D uDiffuse; uniform float uTransparency; // 0 = fully visible, 1 = fully transparent uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1) -// Shared SceneLighting UBO — only need the fog-flash channel for -// client-driven lightning strobes; sun/ambient already baked into vTint. +// Shared SceneLighting UBO — fog params drive the mix, flash channel +// bumps sky brightness during lightning strikes. Matches sky.vert's +// declaration exactly. struct Light { vec4 posAndKind; vec4 dirAndRange; @@ -46,14 +48,24 @@ void main() { // Composite: texture × per-vertex lit × per-keyframe dim. vec3 rgb = sampled.rgb * vTint * uLuminosity; - // Lightning additive bump (client-driven during storm keyframes). + // Retail vertex fog: lerp(fogColor, scene, fogFactor). At distant + // horizon dome vertices (distance > FOGEND) the sky saturates to + // the keyframe's WorldFogColor — that's retail's horizon-glow + // mechanism at dusk/dawn. See docs/research/2026-04-23-sky-fog.md. + rgb = mix(uFogColor.rgb, rgb, vFogFactor); + + // Lightning additive bump — client-driven during storm flashes. + // NOTE: the exact retail mechanism for lightning visual is still + // under research (agent #5, 2026-04-23). Keeping the uFogParams.z + // channel wired so if it ends up being a per-frame flash uniform + // that's what it becomes; if lightning turns out to be a particle + // system effect instead, this bump becomes a no-op (flash stays 0). float flash = uFogParams.z; rgb += flash * vec3(1.5, 1.5, 1.8); - // Normal-frame cap at 1.0 (retail D3D framebuffer clamps at 1.0 - // per channel for RGBA8 output; vTint is already vertex-clamped so - // the only path above 1 is lightning flash additive bump). During - // a flash the ceiling relaxes so the strobe blows out visibly. + // Normal-frame cap at 1.0 (retail D3D framebuffer clamps per-channel + // on output). Flash relaxes ceiling to 3.0 so storm strobes blow + // out visibly. float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 48e59876..1a2427f7 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -48,8 +48,25 @@ uniform vec3 uSunDir; // unit vector FROM surface TO sun // Per-submesh (from Surface.Luminosity float): uniform float uEmissive; +// Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to +// compute the vertex fog factor. Must match sky.frag's declaration. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; // x=fogStart, y=fogEnd, z=flash, w=fogMode + vec4 uFogColor; + vec4 uCameraAndTime; +}; + out vec2 vTex; out vec3 vTint; +out float vFogFactor; // 1 = no fog (close), 0 = full fog (far) void main() { vTex = aTex + uUvScroll; @@ -72,4 +89,27 @@ void main() { + uAmbientColor // material.Ambient(1) × light.Ambient + uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L vTint = clamp(lit, 0.0, 1.0); + + // Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR, + // RANGEFOGENABLE=1, FOGTABLEMODE=NONE per device init — never + // toggled per frame). Distance = `|worldPos - cameraPos|`. Since + // our sky view matrix has translation zeroed (sky is camera- + // centered), the post-uModel position IS the camera-relative + // world-space vector, so its length is the 3D range distance. + // See docs/research/2026-04-23-sky-fog.md. + // + // Formula: fogFactor = clamp((fogEnd - dist) / (fogEnd - fogStart), 0, 1) + // 1.0 → no fog contribution (scene color wins) + // 0.0 → full fog color (sky color fades to fog) + // + // Sky meshes have intrinsic radii in the thousands of meters (dome + // / stars / moon are authored at large distances in the dat); at + // typical keyframe FOGEND=2400m, the dome saturates to fogColor at + // its horizon band. THAT is how retail colors the horizon at dusk. + vec4 worldPos = uModel * vec4(aPos, 1.0); + float dist = length(worldPos.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(fogEnd - fogStart, 1e-3); + vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0); } From 845d70248cb271b9c0011a2bd941756cd8859c74 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:20:39 +0200 Subject: [PATCH 06/10] weather(phase-6a): port retail PhysicsScript runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The central runtime for every client-visible scripted effect server triggers via PlayScript (opcode 0xF754) — spell casts, emote gestures, combat flinches, AND lightning flashes during storms. Previously acdream parsed PhysicsScript from the dat (via DRW) but had no runner; the PlayScript stub in ParticleSystem.cs was a no-op. Decompile provenance (`docs/research/2026-04-23-physicsscript.md`, `docs/research/2026-04-23-lightning-real.md`): FUN_0051bed0 — play_script(scriptId) public API — resolves the dat id, queues into the owner's ScriptManager list. FUN_0051be40 — ScriptManager::Start — alloc 16-byte node {startTime, script*, next}. FUN_0051bf20 — advance one hook, schedule next fire by next hook's StartTime. FUN_0051bfb0 — per-frame tick: while head.NextHookAbsTime ≤ globalClock, fire via vtable dispatch. Port choices: - Flat List vs retail linked list — iteration is simpler, N is small. - Scripts keyed by (scriptId, entityId) — replay replaces instead of stacking, matches retail's "play_script on the same obj doesn't double-schedule". - Anchor world pos cached at Play() time — good enough for short-lived effects (lightning, spell casts). Callers that need fresh positions for long emote animations can Play() again each frame (idempotent). - Constructor takes Func resolver so tests don't need DatCollection; production uses the DatCollection overload that wraps Get with null-on-fail. - CallPESHook recurses Play() with Pause baked into the sub-script's StartTimeAbs. Matches retail semantics where nested scripts fire on the NEXT tick (list iteration order). Diag: ACDREAM_DUMP_PLAYSCRIPT=1 logs every Play() and every fire as [pes] lines. Use this to identify the actual script IDs your ACE server is sending so we can confirm the lightning pipeline when the server sends a strike. Test coverage (9 new tests, all passing): - unknown script returns false, zero id silent-ignore - hooks fire in order at their scheduled times - entityId + anchor pass through to sink - replay same (scriptId, entityId) replaces, doesn't stack - different entities run independently - StopAllForEntity cancels that entity's scripts only - CallPES nested spawn semantics (fires next tick) - CallPES with Pause delays correctly No GameWindow wiring yet — Phase 6b handles the 0xF754 packet handler and Phase 6c plugs the runner into the frame loop. Build + 742 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Vfx/PhysicsScriptRunner.cs | 279 ++++++++++++++++++ .../Vfx/PhysicsScriptRunnerTests.cs | 210 +++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 src/AcDream.Core/Vfx/PhysicsScriptRunner.cs create mode 100644 tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs new file mode 100644 index 00000000..f50f740b --- /dev/null +++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.Types; +// Local (AcDream.Core.Vfx) has its own stub `PhysicsScript` type in +// VfxModel.cs; alias the dat-reader type to avoid name collision. +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Vfx; + +/// +/// Retail-verbatim port of the AC PhysicsScript runtime — +/// a time-ordered list of s scheduled by +/// (seconds from script +/// start). Every visible effect the server triggers via the +/// PlayScript opcode (0xF754) flows through this runner: +/// spell casts, emote gestures, combat flinches, AND — per the +/// 2026-04-23 lightning research — weather lightning flashes. +/// +/// +/// Decompile provenance (see +/// docs/research/2026-04-23-physicsscript.md and +/// docs/research/2026-04-23-lightning-real.md): +/// +/// FUN_0051bed0play_script(scriptId) +/// public API: resolves the dat id, allocates a script node, inserts +/// into the owner PhysicsObj's linked list at +0x30. +/// +/// FUN_0051be40ScriptManager::Start: +/// allocates the {startTime, script*, next} 16-byte node. +/// +/// FUN_0051bf20 — advances one hook, +/// schedules the next fire time based on the next hook's +/// StartTime. +/// +/// FUN_0051bfb0 — per-frame tick: while +/// head.NextHookAbsTime <= globalClock, fire hooks via +/// vtable dispatch on the owner PhysicsObj. +/// +/// +/// +/// +/// +/// Design choices vs retail: +/// +/// Flat list, not a linked list — iteration is +/// simpler and N is small (< 100 active scripts in practice). +/// +/// Scripts are keyed by (scriptId, entityId) +/// — same pair re-played replaces the old instance so we don't +/// stack duplicates when the server retriggers. +/// +/// The anchor world position is cached at spawn +/// time. For long-running scripts on moving entities, the caller +/// can again with a fresh position each +/// frame — idempotent. +/// +/// +/// +/// +public sealed class PhysicsScriptRunner +{ + private readonly Func _resolver; + private readonly IAnimationHookSink _sink; + private readonly Dictionary _scriptCache = new(); + + // One active node per (scriptId, entityId) pair. Replaying replaces. + private readonly List _active = new(); + private double _now; // absolute runtime in seconds + + /// + /// When ACDREAM_DUMP_PLAYSCRIPT=1 is set in the environment, + /// every call and every hook fire prints a line + /// prefixed with [pes]. Use this to confirm the server is + /// delivering PlayScript opcodes (lightning, spell casts, emotes) + /// and which script IDs those are. Off by default. + /// + public bool DiagEnabled { get; set; } = + System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_PLAYSCRIPT") == "1"; + + /// + /// Preferred ctor — resolver delegate lets this class stay + /// DatCollection-free for testing. Production code will pass + /// a lambda that hits DatCollection.Get<PhysicsScript>. + /// + public PhysicsScriptRunner(Func resolver, IAnimationHookSink sink) + { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + } + + /// + /// Convenience ctor — builds a resolver around a . + /// + public PhysicsScriptRunner(DatCollection dats, IAnimationHookSink sink) + : this(id => SafeGet(dats, id), sink) + { + } + + private static DatPhysicsScript? SafeGet(DatCollection dats, uint id) + { + if (dats is null) return null; + try { return dats.Get(id); } + catch { return null; } + } + + /// Number of scripts currently active (for telemetry). + public int ActiveScriptCount => _active.Count; + + /// + /// Start (or restart) a PhysicsScript on the given entity. + /// Retail-equivalent of PhysicsObj::play_script. Returns + /// true if the script was found and queued, false + /// if the dat lookup failed. Replaying the same + /// (scriptId, entityId) pair replaces the prior instance + /// instead of stacking. + /// + public bool Play(uint scriptId, uint entityId, Vector3 anchorWorldPos) + { + if (scriptId == 0) return false; + + var script = ResolveScript(scriptId); + if (script is null || script.ScriptData.Count == 0) + { + if (DiagEnabled) + Console.WriteLine($"[pes] Play: script 0x{scriptId:X8} not found / empty"); + return false; + } + + // Dedupe: if this (scriptId, entityId) already has an active + // instance, replace it — retail's ScriptManager doesn't + // double-schedule the same script on the same object in the + // common path. + for (int i = _active.Count - 1; i >= 0; i--) + { + if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId) + _active.RemoveAt(i); + } + + _active.Add(new ActiveScript + { + Script = script, + ScriptId = scriptId, + EntityId = entityId, + AnchorWorld = anchorWorldPos, + StartTimeAbs = _now, + NextHookIndex = 0, + }); + + if (DiagEnabled) + { + Console.WriteLine( + $"[pes] Play: scriptId=0x{scriptId:X8} entityId=0x{entityId:X8} " + + $"anchor=({anchorWorldPos.X:F2},{anchorWorldPos.Y:F2},{anchorWorldPos.Z:F2}) " + + $"hooks={script.ScriptData.Count}"); + } + return true; + } + + /// + /// Advance every active script by . + /// Fires each hook whose + /// (measured from the script's moment) has been + /// reached. Removes scripts that have finished all their hooks. + /// + public void Tick(float dtSeconds) + { + if (dtSeconds < 0) dtSeconds = 0; + _now += dtSeconds; + + // Back-to-front so RemoveAt() is cheap and safe mid-iteration. + for (int i = _active.Count - 1; i >= 0; i--) + { + var a = _active[i]; + double elapsed = _now - a.StartTimeAbs; + + // Fire every hook whose scheduled time has arrived. + while (a.NextHookIndex < a.Script.ScriptData.Count + && a.Script.ScriptData[a.NextHookIndex].StartTime <= elapsed) + { + var entry = a.Script.ScriptData[a.NextHookIndex]; + DispatchHook(a, entry.Hook); + a.NextHookIndex++; + } + + if (a.NextHookIndex >= a.Script.ScriptData.Count) + _active.RemoveAt(i); + else + _active[i] = a; + } + } + + /// + /// Stop an active script instance by + /// (scriptId, entityId). Used for cleanup when an entity + /// despawns. Not necessary to call on normal script completion — + /// scripts self-remove via . + /// + public void Stop(uint scriptId, uint entityId) + { + for (int i = _active.Count - 1; i >= 0; i--) + { + if (_active[i].ScriptId == scriptId && _active[i].EntityId == entityId) + _active.RemoveAt(i); + } + } + + /// Stop all scripts on an entity (e.g. on despawn). + public void StopAllForEntity(uint entityId) + { + for (int i = _active.Count - 1; i >= 0; i--) + { + if (_active[i].EntityId == entityId) + _active.RemoveAt(i); + } + } + + private void DispatchHook(ActiveScript a, AnimationHook hook) + { + if (DiagEnabled) + { + Console.WriteLine( + $"[pes] fire: scriptId=0x{a.ScriptId:X8} entityId=0x{a.EntityId:X8} " + + $"hook={hook.HookType}"); + } + + // Handle the nested-script hook inline — it needs our runner. + // Everything else delegates to the sink (ParticleHookSink + // handles CreateParticle, DestroyParticle, StopParticle, + // CreateBlockingParticle, etc). + if (hook is CallPESHook call) + { + // CallPESHook.PES = sub-script id; Pause = delay before the + // sub-script starts (retail's ScriptManager links it into + // the list with StartTime = now + Pause). For our flat-list + // design we just recurse Play() — the sub-script schedules + // its own hooks from its own time zero. If Pause > 0 we + // delay by baking it into the sub-script's StartTimeAbs. + Play(call.PES, a.EntityId, a.AnchorWorld); + if (call.Pause > 0f && _active.Count > 0) + { + var sub = _active[^1]; + sub.StartTimeAbs = _now + call.Pause; + _active[^1] = sub; + } + return; + } + + _sink.OnHook(a.EntityId, a.AnchorWorld, hook); + } + + private DatPhysicsScript? ResolveScript(uint id) + { + if (_scriptCache.TryGetValue(id, out var cached)) return cached; + var script = _resolver(id); + _scriptCache[id] = script; + return script; + } + + /// + /// Test-only seam: pre-seed the resolver cache with a hand-built + /// script so unit tests can exercise the scheduler without loading + /// dats. Production code never calls this (name carries the warning). + /// + public void RegisterScriptForTest(uint id, DatPhysicsScript script) + => _scriptCache[id] = script; + + private struct ActiveScript + { + public DatPhysicsScript Script; + public uint ScriptId; + public uint EntityId; + public Vector3 AnchorWorld; + public double StartTimeAbs; + public int NextHookIndex; + } +} diff --git a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs new file mode 100644 index 00000000..0eafa2e7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Vfx; +using DatReaderWriter; +using DatReaderWriter.Types; +using Xunit; +using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; + +namespace AcDream.Core.Tests.Vfx; + +public sealed class PhysicsScriptRunnerTests +{ + /// + /// Recording sink so tests can assert each hook dispatch. + /// + private sealed class RecordingSink : IAnimationHookSink + { + public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); + public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) + => Calls.Add((entityId, worldPos, hook)); + } + + private static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items) + { + var script = new DatPhysicsScript(); + foreach (var (t, h) in items) + script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h }); + return script; + } + + private static CreateParticleHook CreateHook(uint emitterInfoId) + => new CreateParticleHook { EmitterInfoId = emitterInfoId }; + + private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts) + { + // Build an in-memory resolver from the script table — no DatCollection needed. + var table = new Dictionary(); + foreach (var (id, s) in scripts) table[id] = s; + return new PhysicsScriptRunner( + id => table.TryGetValue(id, out var s) ? s : null, + sink); + } + + [Fact] + public void Play_UnknownScript_ReturnsFalse() + { + var sink = new RecordingSink(); + var runner = MakeRunner(sink); // no scripts registered + Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero)); + Assert.Empty(sink.Calls); + } + + [Fact] + public void Play_ZeroScriptId_IgnoredSilently() + { + var sink = new RecordingSink(); + var runner = MakeRunner(sink); + Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero)); + Assert.Equal(0, runner.ActiveScriptCount); + } + + [Fact] + public void HooksFire_InOrder_AtScheduledTimes() + { + var script = BuildScript( + (0.0, CreateHook(100)), + (0.5, CreateHook(101)), + (1.0, CreateHook(102))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3)); + + runner.Tick(0.25f); + Assert.Single(sink.Calls); + Assert.Equal(100u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); + + runner.Tick(0.35f); // total 0.6 + Assert.Equal(2, sink.Calls.Count); + Assert.Equal(101u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId); + + runner.Tick(0.9f); // total 1.5 + Assert.Equal(3, sink.Calls.Count); + Assert.Equal(102u, ((CreateParticleHook)sink.Calls[2].Hook).EmitterInfoId.DataId); + Assert.Equal(0, runner.ActiveScriptCount); // fully consumed + } + + [Fact] + public void EntityIdAndAnchor_ArePassedThrough() + { + var script = BuildScript((0.0, CreateHook(1))); + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + + var anchor = new Vector3(123, 45, 67); + runner.Play(scriptId: 0xAA, entityId: 0xCAFE, anchorWorldPos: anchor); + runner.Tick(0.1f); + + Assert.Single(sink.Calls); + Assert.Equal(0xCAFEu, sink.Calls[0].EntityId); + Assert.Equal(anchor, sink.Calls[0].Pos); + } + + [Fact] + public void Replay_SameScriptSameEntity_Replaces_DoesNotStack() + { + var script = BuildScript( + (0.0, CreateHook(1)), + (1.0, CreateHook(2))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + + runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero); + runner.Tick(0.1f); + Assert.Single(sink.Calls); + + // Re-play — the old instance should be replaced, not stacked. + runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero); + Assert.Equal(1, runner.ActiveScriptCount); + runner.Tick(0.1f); + Assert.Equal(2, sink.Calls.Count); + // Hook 0 fires AGAIN (fresh timeline from t=0), not hook 1. + Assert.Equal(1u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId); + } + + [Fact] + public void Replay_DifferentEntities_BothActiveConcurrently() + { + var script = BuildScript((0.0, CreateHook(42))); + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + + runner.Play(scriptId: 0xAA, entityId: 0x1, anchorWorldPos: new Vector3(1, 0, 0)); + runner.Play(scriptId: 0xAA, entityId: 0x2, anchorWorldPos: new Vector3(2, 0, 0)); + Assert.Equal(2, runner.ActiveScriptCount); + + runner.Tick(0.1f); + Assert.Equal(2, sink.Calls.Count); + Assert.Contains(sink.Calls, c => c.EntityId == 1u); + Assert.Contains(sink.Calls, c => c.EntityId == 2u); + } + + [Fact] + public void StopAllForEntity_CancelsEntityScripts_LeavesOthers() + { + var script = BuildScript( + (0.0, CreateHook(1)), + (1.0, CreateHook(2))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + + runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero); + runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero); + runner.Tick(0.1f); // both fire hook 0 + Assert.Equal(2, sink.Calls.Count); + + runner.StopAllForEntity(1); + Assert.Equal(1, runner.ActiveScriptCount); + runner.Tick(2.0f); // only entity 2's script should fire hook 1 + Assert.Equal(3, sink.Calls.Count); + Assert.Equal(2u, sink.Calls[^1].EntityId); + } + + [Fact] + public void CallPES_NestedScript_SpawnsOnSameEntity() + { + var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f })); + var inner = BuildScript((0.0, CreateHook(99))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner)); + runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3)); + + // First tick fires the CallPES hook. Inner script gets queued to + // _active but does NOT fire this tick (we iterate _active + // backwards, and the inner is appended AFTER the current index) — + // matches retail's linked-list insertion semantics. Inner fires + // on the NEXT tick instead. + runner.Tick(0.1f); + Assert.Empty(sink.Calls); // CallPES handled inline, no direct sink hit + Assert.Equal(1, runner.ActiveScriptCount); // inner is queued, outer done + + // Second tick — inner's hook at t=0 fires now. + runner.Tick(0.1f); + Assert.Single(sink.Calls); + Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); + Assert.Equal(0x7u, sink.Calls[0].EntityId); + } + + [Fact] + public void CallPES_WithPause_DelaysSubScript() + { + var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f })); + var inner = BuildScript((0.0, CreateHook(99))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner)); + runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero); + + // CallPES fires immediately, but inner script's hook is gated by Pause. + runner.Tick(0.1f); + Assert.Empty(sink.Calls); // inner hook waiting on Pause=0.5s + + runner.Tick(0.5f); // total 0.6 > 0.5 pause + Assert.Single(sink.Calls); + } +} From 2e9a836f08a6d06bdc8b9925124b2ccc4e812d60 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:24:30 +0200 Subject: [PATCH 07/10] weather(phase-6bc): wire PlayScript packet + script runner into frame loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6b — WorldSession now dispatches the PlayScript opcode (0xF754) that retail uses for all server-triggered client-side visual effects. Wire format per Agent #5 decompile (chunk_006A0000.c:12320-12336): [u32 opcode=0xF754][u32 targetGuid][u32 scriptId] New event `PlayScriptReceived(uint guid, uint scriptId)` fires on every matching fragment. Unknown payloads (body < 12 bytes) are silently ignored. Phase 6c — GameWindow instantiates a PhysicsScriptRunner at startup, subscribes to PlayScriptReceived, and ticks the runner every frame BEFORE the ParticleSystem tick so a CreateParticleHook fired this frame gets its emitter integrated in the same frame. Anchor policy: use the camera's world position for the script anchor. For Dereth-wide storm effects (lightning flashes) the camera is the right reference frame — the flash is "around the player." Per-entity effects (spell casts, emotes) dedupe by (scriptId, entityId) so multiple simultaneous plays on different guids work; a follow-up will look up the guid's last-known world pos from _worldState for accurate per-entity anchoring. The full pipeline now for a lightning flash: 1. ACE (or other retail-emulating server) sends GameMessage(0xF754, lightningGuid, scriptId=0x33xxxxxx). 2. WorldSession parses: PlayScriptReceived event fires. 3. GameWindow.OnPlayScriptReceived routes to _scriptRunner.Play. 4. Runner loads the PhysicsScript from the dat, schedules every (StartTime, AnimationHook) entry. 5. Per-frame Tick fires each hook at its scheduled time via ParticleHookSink — CreateParticleHook spawns a particle emitter (the flash), SoundHook plays thunder audio (Phase 5d), etc. Set ACDREAM_DUMP_PLAYSCRIPT=1 to see each inbound PlayScript and each hook fire as `[pes]` log lines — useful for identifying which script IDs your ACE server actually sends. Build + 742 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 57 +++++++++++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 39 +++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 99dd2bdb..ceb3c5a4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -140,6 +140,10 @@ public sealed class GameWindow : IDisposable private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleHookSink? _particleSink; + // Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754) + // from the server and schedules the dat-defined hooks (particle spawns, + // sounds, light toggles) at their StartTime offsets. + private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; // Remote-entity motion inference: tracks when each remote entity last @@ -827,6 +831,12 @@ public sealed class GameWindow : IDisposable _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _hookRouter.Register(_particleSink); + // Phase 6c — PhysicsScript runner. Uses the DatCollection to + // resolve PlayScript ids, and the same ParticleHookSink the + // animation system uses, so CreateParticleHook fired from a + // script spawns through the normal particle pipeline. + _scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(_dats, _particleSink); + // Phase G.2 lighting hooks: SetLightHook flips IsLit on // owner-tagged lights so ignite-torch animations light up, // extinguish-torch animations go dark. @@ -1064,6 +1074,16 @@ public sealed class GameWindow : IDisposable _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.TeleportStarted += OnTeleportStarted; + // Phase 6c — PlayScript (0xF754) arrives from the server as + // a (guid, scriptId) pair. Resolve the guid's current world + // position and feed the PhysicsScript runner; it schedules + // the script's hooks (particle spawns, sound cues, light + // toggles) at their StartTime offsets. This is the channel + // retail uses for spell casts, combat flinches, emote + // gestures, AND — per Agent #5 research — lightning + // flashes during stormy weather. + _liveSession.PlayScriptReceived += OnPlayScriptReceived; + // Phase G.1: keep the client's day/night clock in sync with // server time. Fires once from ConnectRequest (initial seed) // and repeatedly on TimeSync-flagged packets. @@ -2222,6 +2242,38 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"live: teleport started (seq={sequence})"); } + /// + /// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the + /// (guid, scriptId) pair into + /// with the CAMERA's current world position as the anchor. For + /// scene-wide storm effects (lightning) the camera is the right + /// reference frame since the flash is meant to be "around the + /// player." For per-entity effects the runner's dedupe by + /// (scriptId, entityId) keeps multiple simultaneous plays + /// working on different guids. + /// + /// + /// Improvements for follow-up: look up the guid's actual last- + /// known world position from _worldState so per-entity + /// spell casts and emote gestures anchor correctly. For Phase 6 + /// scope (lightning, which is Dereth-wide) the camera anchor is + /// sufficient. + /// + /// + private void OnPlayScriptReceived(uint guid, uint scriptId) + { + if (_scriptRunner is null) return; + + var camWorldPos = System.Numerics.Vector3.Zero; + if (_cameraController is not null) + { + System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv); + camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); + } + + _scriptRunner.Play(scriptId, guid, camWorldPos); + } + /// /// Phase A.1: streaming load delegate, runs on the worker thread. /// Reads the landblock from the dats, hydrates its stab entities (same @@ -3602,6 +3654,11 @@ public sealed class GameWindow : IDisposable // Phase E.3: advance live particle emitters AFTER animation tick // so emitters spawned by hooks fired this frame get integrated. + // Tick the PhysicsScript runner BEFORE the particle system so any + // CreateParticleHook fired this frame has its emitter alive when + // the particle system advances. + _scriptRunner?.Tick((float)deltaSeconds); + _particleSystem?.Tick((float)deltaSeconds); int visibleLandblocks = 0; diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index e4d96494..1a9fd87c 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -109,6 +109,29 @@ public sealed class WorldSession : IDisposable /// public event Action? SpeechHeard; + /// + /// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the + /// server sends a PlayScriptId (opcode 0xF754) packet — + /// wire format [u32 opcode][u32 guid][u32 scriptId]. + /// + /// + /// This is retail's ONLY general-purpose "make a visual thing + /// happen" channel: spell casts, emote gestures, combat flinches, + /// portal storms, and lightning flashes during stormy weather all + /// flow through this opcode. Subscribers (typically + /// GameWindow) resolve the guid to the appropriate entity + /// position and dispatch to a PhysicsScriptRunner. + /// + /// + /// + /// Trail: chunk_006A0000.c:12320-12336 opcode dispatch → + /// FUN_00452060FUN_00511800FUN_005117a0 + /// (PhysicsObj::RunScript) → FUN_0051bed0 (PhysicsScript + /// runtime). See docs/research/2026-04-23-lightning-real.md. + /// + /// + public event Action? PlayScriptReceived; + /// /// Phase G.1: latest server Portal Year tick count. Seeded from the /// ConnectRequest handshake (r12 §1.3 — server sends absolute game @@ -548,6 +571,22 @@ public sealed class WorldSession : IDisposable var env = GameEventEnvelope.TryParse(body); if (env is not null) GameEvents.Dispatch(env.Value); } + else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid + { + // Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]` + // per chunk_006A0000.c:12320 disassembly. Dispatch the + // event; GameWindow subscribes and feeds its + // PhysicsScriptRunner. This is the channel retail uses for + // lightning flashes, spell casts, emotes, combat FX, etc. + if (body.Length >= 12) + { + uint targetGuid = System.Buffers.Binary.BinaryPrimitives + .ReadUInt32LittleEndian(body.AsSpan(4, 4)); + uint scriptId = System.Buffers.Binary.BinaryPrimitives + .ReadUInt32LittleEndian(body.AsSpan(8, 4)); + PlayScriptReceived?.Invoke(targetGuid, scriptId); + } + } else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal { // Phase B.3: holtburger opcodes.rs confirms 0xF751 is the From e4cf3a9b6b2df4ca4ddb2c0d3ad2b320b9a545a3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 11:27:13 +0200 Subject: [PATCH 08/10] weather(phase-5d): AdminEnvirons packet handler + thunder sound dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports retail's AdminEnvirons (opcode 0xEA60) — the client-visible weather-event channel distinct from the PlayScript path. Wire format (chunk_006A0000.c: `[u32 opcode][u32 environChangeType]`). EnvironChangeType range: 0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/Black/Black2) 0x65..0x75 — one-shot ambient sounds (Roar, Bell, Chant, etc) 0x76..0x7B — Thunder1..6 sounds (paired with a lightning PlayScript) Dispatch: - WorldSession decodes the packet, fires EnvironChanged event. - GameWindow.OnEnvironChanged: * Fog values (0x00..0x06) → WeatherSystem.Override. The enum values line up byte-for-byte with our EnvironOverride enum (deliberately mirrored from retail), so a direct cast works. * Sound values (0x65..0x7B) → console log with retail name for now. Actual OpenAL playback needs a EnvironChangeType → WaveData lookup (indexed via SoundTable dat), which is a separate follow-up. The event still fires so any future audio subscriber can plug in. Combined with Phase 6a-6c PhysicsScript/PlayScript wiring, the complete retail lightning pipeline is now: server sends PlayScript(0xF754, lightningGuid, scriptId=0x33xxxxxx) → runs the flash script via PhysicsScriptRunner → CreateParticleHook spawns the flash particles server sends AdminEnvirons(0xEA60, Thunder3Sound=0x78) → OnEnvironChanged logs; audio binding TBD Whether the user's ACE sends these packets depends on the server (ACE 2.x vanilla does NOT — Agent #5 verified no lightning opcodes in the default emit path). With the client port complete, any ACE mod or extension that emits the right packets will Just Work in acdream. Build + 742 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 63 +++++++++++++++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 40 ++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ceb3c5a4..44359db0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1084,6 +1084,14 @@ public sealed class GameWindow : IDisposable // flashes during stormy weather. _liveSession.PlayScriptReceived += OnPlayScriptReceived; + // Phase 5d — AdminEnvirons (0xEA60): fog presets + sound + // cues. Fog types (0x00..0x06) set WeatherSystem.Override; + // sound types (0x65..0x7B) play a one-shot audio cue. + // Lightning flashes arrive as a PAIRED PlayScript (the + // visual) + AdminEnvirons ThunderXSound (the audio) — both + // are handled here and in OnPlayScriptReceived respectively. + _liveSession.EnvironChanged += OnEnvironChanged; + // Phase G.1: keep the client's day/night clock in sync with // server time. Fires once from ConnectRequest (initial seed) // and repeatedly on TimeSync-flagged packets. @@ -2274,6 +2282,61 @@ public sealed class GameWindow : IDisposable _scriptRunner.Play(scriptId, guid, camWorldPos); } + /// + /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. + /// Routes fog presets into the weather system's sticky override + /// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc) + /// for now — actual sound playback needs a lookup table from + /// EnvironChangeType → wave asset, which we don't yet + /// have dat-indexed; follow-up will wire the thunder wave ids. + /// + private void OnEnvironChanged(uint environChangeType) + { + // Fog presets — values match AcDream.Core.World.EnvironOverride + // byte-for-byte (we deliberately mirrored retail's enum). + if (environChangeType <= 0x06u) + { + Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType; + Console.WriteLine( + $"live: AdminEnvirons fog override = " + + $"{(AcDream.Core.World.EnvironOverride)environChangeType}"); + return; + } + + // Sound cues 0x65..0x7B. Log by retail name for now; audio + // binding is a separate follow-up (needs sound-table indexing + // plus a PlaySound API on OpenAlAudioEngine that takes a + // retail sound enum → wave-id mapping). + string name = environChangeType switch + { + 0x65u => "RoarSound", + 0x66u => "BellSound", + 0x67u => "Chant1Sound", + 0x68u => "Chant2Sound", + 0x69u => "DarkWhispers1Sound", + 0x6Au => "DarkWhispers2Sound", + 0x6Bu => "DarkLaughSound", + 0x6Cu => "DarkWindSound", + 0x6Du => "DarkSpeechSound", + 0x6Eu => "DrumsSound", + 0x6Fu => "GhostSpeakSound", + 0x70u => "BreathingSound", + 0x71u => "HowlSound", + 0x72u => "LostSoulsSound", + 0x75u => "SquealSound", + 0x76u => "Thunder1Sound", + 0x77u => "Thunder2Sound", + 0x78u => "Thunder3Sound", + 0x79u => "Thunder4Sound", + 0x7Au => "Thunder5Sound", + 0x7Bu => "Thunder6Sound", + _ => $"Unknown(0x{environChangeType:X2})", + }; + Console.WriteLine( + $"live: AdminEnvirons sound cue = {name} " + + $"(0x{environChangeType:X2}) — audio binding pending"); + } + /// /// Phase A.1: streaming load delegate, runs on the worker thread. /// Reads the landblock from the dats, hydrates its stab entities (same diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 1a9fd87c..9e854564 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -132,6 +132,33 @@ public sealed class WorldSession : IDisposable /// public event Action? PlayScriptReceived; + /// + /// Phase 5d — retail's AdminEnvirons packet (opcode + /// 0xEA60) — the one-and-only channel retail's server uses + /// for weather environment changes. Wire format: + /// [u32 opcode][u32 environChangeType]. The payload enum is + /// retail's EnvironChangeType: + /// + /// + /// 0x00..0x06 — fog presets (Clear/Red/Blue/White/Green/ + /// Black/Black2). Subscribers route these to a + /// . + /// + /// + /// 0x65..0x75 — one-shot ambient sound cues + /// (Roar / Bell / Chant / etc). + /// + /// + /// 0x76..0x7B — Thunder1..Thunder6 sounds. Paired with + /// a separate from the server + /// carrying the lightning-flash PhysicsScript. + /// + /// + /// See docs/research/2026-04-23-lightning-crossfade.md + + /// 2026-04-23-lightning-real.md. + /// + public event Action? EnvironChanged; + /// /// Phase G.1: latest server Portal Year tick count. Seeded from the /// ConnectRequest handshake (r12 §1.3 — server sends absolute game @@ -571,6 +598,19 @@ public sealed class WorldSession : IDisposable var env = GameEventEnvelope.TryParse(body); if (env is not null) GameEvents.Dispatch(env.Value); } + else if (op == 0xEA60u) // AdminEnvirons — server pushes a fog preset or sound cue + { + // Phase 5d: wire format `[u32 opcode][u32 environChangeType]` + // per chunk_006A0000.c. Dispatch the event; GameWindow + // subscribers route fog presets into WeatherSystem.Override + // and sound cues (thunder, roar, etc) into the audio engine. + if (body.Length >= 8) + { + uint envType = System.Buffers.Binary.BinaryPrimitives + .ReadUInt32LittleEndian(body.AsSpan(4, 4)); + EnvironChanged?.Invoke(envType); + } + } else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid { // Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]` From 889b235886c8ffef14d69db4c0f20ca6319a4cb2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 12:55:19 +0200 Subject: [PATCH 09/10] =?UTF-8?q?weather(phase-7):=20gut=20WeatherSystem.S?= =?UTF-8?q?napshot=20=E2=80=94=20passthrough=20keyframe=20fog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pre-decompile-era invention cleanup. Snapshot() now returns the keyframe's fog (color, start, end) directly in all cases. AdminEnvirons override replaces fog COLOR only; distances stay at the keyframe's MinWorldFog/MaxWorldFog. Removed: - FogForKind(kind, kf): the per-WeatherKind fog table with invented constants (Overcast 40-150m grey, Storm 25-90m dark, Rain 40-150m blue, Snow 60-200m white). Retail has no such logic — Agent #3's decompile scan found zero per-Kind fog manipulation in chunk_005* / chunk_006*. The SkyTimeOfDay keyframe interp (FUN_00501860) does all fog value selection. - OvercastFogStart/End, StormFogStart/End constants. - Storm-kind random lightning timer + _strikeJitter. Retail's lightning is server-driven via PlayScript (Phase 6), not a client timer — Agents #3 + #5 both rule this out. - Per-Kind cross-fade (_transitionT and TransitionSeconds-based lerp). Retail has a different crossfade — SkyTimeOfDay step blending via LightTickSize gating (_DAT_008427b8 + _DAT_007c7208) — which is the deferred Phase 5c "polish" item. Result: - Clear: keyframe fog passthrough — unchanged behaviour. - Overcast / Rain / Snow / Storm: now ALSO keyframe passthrough. Previously these clobbered the keyframe with the invented constants, producing a grey-wall sky that extended no further than ~150m. User observation 2026-04-23: "retail sky extends all the way into the horizon, we cap at a grey wall." Fixed. - EnvironOverride (AdminEnvirons RedFog, BlueFog, etc): substitutes the fog COLOR preset, keeps keyframe distances. WeatherKind enum retained as purely informational (debug overlay, telemetry). Internal RollKind fallback retained for offline tests that drive Tick() directly without SetKindFromDayGroupName. TriggerFlash()/flash decay retained as a test-only hook for the UBO's lightning-flash channel — production flash stays 0 since retail drives lightning visuals through particle emitters, not through a UBO uniform. Tests updated: `Transition_EasesAcrossTenSeconds` deleted (codified the Storm=dense-fog invention we just removed) and replaced by `Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind` which asserts every WeatherKind returns the keyframe fog directly. Build + 742 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/World/WeatherState.cs | 150 ++++++++---------- .../World/WeatherSystemTests.cs | 31 ++-- 2 files changed, 84 insertions(+), 97 deletions(-) diff --git a/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index e4c54f17..51219fd9 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -96,47 +96,42 @@ public readonly record struct AtmosphereSnapshot( /// public sealed class WeatherSystem { + /// + /// Kept as a public constant because a handful of callers / tests + /// reference it, but unused internally post-Phase-7: retail does + /// not cross-fade between s (no such + /// concept in the decompile). The SkyTimeOfDay keyframe interp + /// does all time-based fog/light blending directly. + /// public const float TransitionSeconds = 10f; - // Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms - // and decays exponentially with a time constant of ~200ms. - private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds + // Flash decay kept so TriggerFlash() is still a usable test hook; + // production code (PlayScript-driven lightning, Phase 6) does NOT + // drive the flash uniform — it spawns particle emitters directly. + private const float FlashDecay = 1f / 0.200f; // 1 / τ sec private const float FlashPeakHoldS = 0.05f; - // Retail storm cadence: 8–30 seconds between strikes. - private const float StrikeIntervalMinS = 8f; - private const float StrikeIntervalMaxS = 30f; - - // Overcast-kind fog feels like ~40–150m retail range (r12 §5.1). - private const float OvercastFogStart = 40f; - private const float OvercastFogEnd = 150f; - private const float StormFogStart = 25f; - private const float StormFogEnd = 90f; - - private WeatherKind _kind = WeatherKind.Clear; + private WeatherKind _kind = WeatherKind.Clear; private WeatherKind _previousKind = WeatherKind.Clear; - private float _transitionT; // 0..1 through the cross-fade private float _flashLevel; - private float _flashAge; // seconds since last strike - private float _nextStrikeInS; + private float _flashAge; private EnvironOverride _override; - private int _rolledDayIndex = int.MinValue; // unrolled == "pick one" + private int _rolledDayIndex = int.MinValue; - // Phase 3e — when GameWindow (via RefreshSkyForCurrentDay) pushes - // the active retail DayGroup name through SetKindFromDayGroupName, - // the internal RollKind hash becomes unused. This flag stops Tick's - // auto-roll so external control can't fight the internal one. + // Phase 3e — when GameWindow pushes the retail DayGroup name via + // SetKindFromDayGroupName, the internal RollKind hash is disabled. private bool _externallyDriven; - private readonly Random _strikeJitter; - public WeatherSystem(Random? rng = null) { - _strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u)); - _nextStrikeInS = 12f; + // The random-seed ctor argument remains for test API compat, + // but no longer drives any production behaviour (Phase 7: the + // Storm-kind random lightning timer was deleted — retail is + // server-driven via PlayScript; see Agents #3 and #5). + _ = rng; } /// Current active weather. @@ -232,15 +227,19 @@ public sealed class WeatherSystem /// public void Tick(double nowSeconds, int dayIndex, float dtSeconds) { - // Cross-fade progression: transitionT advances toward 1 over - // TransitionSeconds. Capped; no further rollover. - if (_transitionT < 1f) - _transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds); + // Phase 7 — dropped: + // - per-Kind cross-fade (_transitionT drove the now-removed + // FogForKind lerp; retail has no such machinery). + // - Storm-kind random lightning timer (retail lightning is + // server-driven via PlayScript per Agent #5 — purely visual + // through the particle system, no UBO flash channel). + // + // What remains: day-index auto-roll as a TEST-ONLY fallback + // (externally driven callers set _externallyDriven=true through + // SetKindFromDayGroupName and this block never fires), plus + // flash-level decay so the TriggerFlash() test hook still works. - // Day changed → re-roll. Skip the sentinel (forced). Also skip - // when weather is externally driven by the retail DayGroup name - // (Phase 3e) — the internal RollKind is a fallback only for - // tests / offline code paths. + // Day changed → re-roll (fallback only — disabled when externally driven). if (!_externallyDriven && dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue) @@ -250,19 +249,9 @@ public sealed class WeatherSystem if (newKind != _kind) BeginTransition(newKind); } - // Lightning timer only ticks in Storm kind. - if (_kind == WeatherKind.Storm && _override == EnvironOverride.None) - { - _nextStrikeInS -= dtSeconds; - if (_nextStrikeInS <= 0f) - { - TriggerFlash(); - _nextStrikeInS = StrikeIntervalMinS - + (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS); - } - } - - // Decay the flash level with a 200ms time constant. + // Flash decay — 50ms hold then exponential decay (~200ms τ). + // Production never TriggerFlashes; this exists for tests that + // exercise the UBO channel. if (_flashLevel > 0f) { _flashAge += dtSeconds; @@ -284,40 +273,45 @@ public sealed class WeatherSystem } /// - /// Produce the per-frame snapshot consumed by the shader UBO + - /// particle emitter spawners. Combines the sky keyframe's fog with - /// the weather state's fog overlay, then applies the server - /// tint if any. + /// Produce the per-frame atmosphere snapshot from the sky keyframe. + /// + /// + /// Retail-faithful since Phase 7 (2026-04-23): fog is the + /// keyframe's fog, passed through directly (color + distances). + /// The only override channel is set + /// by the server's AdminEnvirons packet (opcode 0xEA60) — + /// in that case we substitute the fog COLOR with the preset tint + /// and keep the keyframe's distances untouched. There is no + /// per- fog manipulation: retail's + /// decompile (Agent #3, 2026-04-23) contains no such logic. The + /// enum is now purely informational — it + /// labels the current sky style for debug overlays but doesn't + /// drive any rendering. + /// /// public AtmosphereSnapshot Snapshot(in SkyKeyframe kf) { - // Cross-fade fog distance + color from previous-kind to new-kind. - var prev = FogForKind(_previousKind, kf); - var curr = FogForKind(_kind, kf); + // Fog passthrough from the keyframe (retail semantics). + Vector3 fogColor = kf.FogColor; + float fogStart = kf.FogStart; + float fogEnd = kf.FogEnd; - float t = _transitionT; - var fogColor = Vector3.Lerp(prev.color, curr.color, t); - float fogStart = prev.start + (curr.start - prev.start) * t; - float fogEnd = prev.end + (curr.end - prev.end) * t; - - // Server environ override wins. + // AdminEnvirons server override: replace fog COLOR only. + // Keyframe distances unchanged until we find evidence retail + // changes those too (Agent #3 notes the in-game crossfade + // lerps distances via SkyTimeOfDay keyframe interp, NOT via + // AdminEnvirons directly). if (_override != EnvironOverride.None) - { fogColor = EnvironOverrideColor(_override); - fogStart = 15f; - fogEnd = 80f; // Dense override fog - } - - float intensity = _kind == WeatherKind.Clear ? 1f - t : t; return new AtmosphereSnapshot( - Kind: _kind, - Intensity: Math.Clamp(intensity, 0f, 1f), + Kind: _kind, // informational + Intensity: 1f, // no per-Kind easing in retail FogColor: fogColor, FogStart: fogStart, FogEnd: fogEnd, FogMode: kf.FogMode, - LightningFlash: _flashLevel, + LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests Override: _override); } @@ -329,7 +323,6 @@ public sealed class WeatherSystem { _previousKind = _kind; _kind = newKind; - _transitionT = 0f; } /// @@ -354,23 +347,6 @@ public sealed class WeatherSystem return WeatherKind.Storm; } - private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf) - { - return kind switch - { - WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd), - WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f), - OvercastFogStart, OvercastFogEnd), - WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f), - OvercastFogStart, OvercastFogEnd), - WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f), - OvercastFogStart, OvercastFogEnd * 1.2f), - WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f), - StormFogStart, StormFogEnd), - _ => (kf.FogColor, kf.FogStart, kf.FogEnd), - }; - } - private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch { EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f), diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index f13a3082..20d490b3 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -38,19 +38,30 @@ public sealed class WeatherSystemTests } [Fact] - public void Transition_EasesAcrossTenSeconds() + public void Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind() { - // Force Storm, then Clear, sample snapshot fog distance mid-transition. - var sys = new WeatherSystem(); - sys.ForceWeather(WeatherKind.Storm); - sys.Tick(0, 1, 100f); // finalize - + // Phase 7: retail DOES NOT override fog by WeatherKind — Storm + // doesn't produce denser fog, Overcast doesn't shrink distance. + // Every Kind renders the keyframe's fog directly. This test + // replaces the old "Transition_EasesAcrossTenSeconds" which + // codified the invented per-Kind fog behaviour. var kf = SkyStateProvider.Default().Interpolate(0.5f); - var stormFog = sys.Snapshot(in kf); - Assert.Equal(WeatherKind.Storm, stormFog.Kind); - // Snapshot should have a small fog end (storm fog is dense). - Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}"); + foreach (var kind in new[] { + WeatherKind.Clear, WeatherKind.Overcast, + WeatherKind.Rain, WeatherKind.Snow, WeatherKind.Storm, + }) + { + var sys = new WeatherSystem(); + sys.ForceWeather(kind); + sys.Tick(0, 1, 100f); // finalize any transition + var snap = sys.Snapshot(in kf); + + Assert.Equal(kind, snap.Kind); + Assert.Equal(kf.FogStart, snap.FogStart, precision: 2); + Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2); + Assert.Equal(kf.FogColor, snap.FogColor); + } } [Fact] From 1d54880213f85d04088c8048d7f612fdbbee4db6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Apr 2026 20:34:36 +0200 Subject: [PATCH 10/10] sky(phase-8): retail-faithful night sky + README refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration on the sky rendering pipeline to restore stars/moon visibility at night and fix washed-out grey daytime clouds. Key fixes: * sky.frag: disable fog-mix on sky meshes. Retail's keyframe FogEnd (0..400m at midnight, up to 2400m during day) is calibrated for terrain; sky meshes are authored at radii 1050-14271m which sits past FogEnd universally, causing every sky pixel to saturate to fogColor (dark navy). Stars, moon, dome texture all got obliterated. The horizon-glow trade-off is noted in the shader comment; research item to find retail's sky-specific fog range later. * SkyRenderer + sky.frag: promote rep.Luminosity into uEmissive so the vertex lighting saturates properly for bright keyframes. Retail's FUN_0059da60 non-luminous path writes rep.Luminosity into material.Emissive via the cache +0x3c slot; we were instead using it as a post-fragment multiply which could only dim, never brighten. Net effect: daytime clouds now render saturated white, dome dims correctly at night (rep.Luminosity=0.11 → Emissive=0.11), stars and moon unchanged. * terrain.vert: MIN_FACTOR 0.08 -> 0.0 per retail FUN_00532440 decompile (DAT_00796344 ambient-floor = 0.0). Back-lit terrain now falls to pure ambient rather than getting an 8% sun floor. New research / tooling (no runtime impact): * docs/research/2026-04-24-lambert-brightness-split.md — retail's ambient-brightness formula pinned from PE .rdata read + live RetailTimeProbe capture: effAmbBright = AmbBright + |sunDir| * 0.2 where scale constant 0x0079a1e8 = 0.2f exactly. * docs/research/2026-04-23-lightning-real.md — research note on the dat-baked PhysicsScript-driven lightning path (Rainy DayGroup has explicit PES-triggered flash SkyObjects with 5ms time windows). * Corrections stapled to sky-decompile-hunt-{B,C}.md: DAT_00842778 is DirColor, DAT_0084277c is AmbColor (the hunt docs had the swap backwards). * tools/RetailTimeProbe/Program.cs: extended with pid=NNNN selector, sky global probe (DirColor/AmbColor/AmbBright/sunDir/cache.amb), and the 0x0079a1e8 scale-factor readout. * tools/SkyObjectInspect/: throwaway dat-inspector built by the Opus deep-dive agent. Identified GfxObj 0x010015EF as the stars layer (A8R8G8B8 128x128 texture, 4% bright-pixel ratio). * src/AcDream.App/Rendering/TextureCache.cs: per-texture alpha histogram dump under ACDREAM_DUMP_SKY=1 for diagnosing "are the clouds decoded with proper alpha" type questions. README: rewrite to reflect current state (playable pre-alpha rendering Dereth with animated characters, day-night cycle, weather, etc.) instead of the stale "Phase 0 dat inventory only" description. All 742 tests green. --- README.md | 169 +++++++- docs/research/2026-04-23-lightning-real.md | 398 ++++++++++++++++++ .../2026-04-23-sky-decompile-hunt-B.md | 7 + .../2026-04-23-sky-decompile-hunt-C.md | 25 ++ .../2026-04-24-lambert-brightness-split.md | 166 ++++++++ src/AcDream.App/Rendering/Shaders/sky.frag | 24 +- .../Rendering/Shaders/terrain.vert | 17 +- src/AcDream.App/Rendering/Sky/SkyRenderer.cs | 20 +- src/AcDream.App/Rendering/TextureCache.cs | 40 ++ tools/RetailTimeProbe/Program.cs | 204 ++++++++- tools/SkyObjectInspect/Program.cs | 175 ++++++++ .../SkyObjectInspect/SkyObjectInspect.csproj | 15 + 12 files changed, 1217 insertions(+), 43 deletions(-) create mode 100644 docs/research/2026-04-23-lightning-real.md create mode 100644 docs/research/2026-04-24-lambert-brightness-split.md create mode 100644 tools/SkyObjectInspect/Program.cs create mode 100644 tools/SkyObjectInspect/SkyObjectInspect.csproj diff --git a/README.md b/README.md index 3f2e1a15..47a53307 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,173 @@ # acdream -Experimental modern open-source Asheron's Call client in C# / .NET 10. +A modern open-source C# / .NET 10 Asheron's Call client. -**Status:** pre-alpha, not playable. Phase 0 only — dat file asset inventory. +Faithful port of the retail client's behaviour to Silk.NET with a modern, +plugin-friendly architecture. The code is modern; the behaviour is retail. -**Stack:** .NET 10, [Chorizite.DatReaderWriter](https://github.com/Chorizite/DatReaderWriter) for dat parsing. Silk.NET + Avalonia planned for rendering/UI (not yet wired up). +**Status:** playable pre-alpha. You can log in to an ACE server, walk and +run through Dereth, see other players animate correctly, watch the +day-night cycle, hear ambient audio, and take weapons out. Many systems +are still stubbed or in-progress — see roadmap. -**Requires:** A retail Asheron's Call install (Turbine/Microsoft property — supply your own). Set `ACDREAM_DAT_DIR` environment variable to the directory containing `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and `client_local_English.dat`, or pass it as the first CLI argument. +## Stack -## Layout +- **Language:** C# .NET 10 +- **Graphics:** [Silk.NET](https://github.com/dotnet/Silk.NET) (OpenGL 4.3) +- **Audio:** OpenAL via Silk.NET +- **Dat parsing:** [Chorizite.DatReaderWriter](https://github.com/Chorizite/DatReaderWriter) +- **Networking:** Custom UDP + ISAAC cipher + game-message layer, wire-compatible + with ACEmulator server -- `src/AcDream.Cli/` — console app that dumps asset counts from a dat directory -- `references/` — local read-only reference material (ACE, ACViewer, WorldBuilder, DatReaderWriter, holtburger, retail AC install). Gitignored. +## What works -## Run +- Connecting to a local ACEmulator (ACE) server on `127.0.0.1:9000` +- Character selection and login +- Rendering Dereth terrain with retail-correct texture blending, + per-vertex lighting, and road overlays +- Static scenery (buildings, trees, scenery objects) via EnvCell walker +- Animated characters (own + remote) with walk / run / strafe / jump / + turn / attack motions sourced from the retail motion tables +- Network sync with remote players — you can watch other characters + animate correctly, including speeds and directional motion +- Day-night cycle driven from the retail Region dat (0x13000000) — + correct DayGroup picking via the retail LCG, correct keyframe + interpolation, correct per-keyframe sky-object replace +- Weather (rain/snow particles synced from the server via the retail + DayGroup name) +- Sky dome, stars, moon, clouds, sun — each rendered from the retail + Region's SkyObjects with texture scrolling and alpha fade +- Plugin host with live event replay-on-subscribe + +## What's stubbed or in-progress + +- Indoor transitions (building interiors) — disabled, Phase B.3 pending +- Combat — animation works, damage math not wired +- Lightning visual — the retail PhysicsScript-driven flash is researched + but not wired (see `docs/research/2026-04-23-lightning-real.md`) +- TimeSync drift — we only sync calendar on login, not periodically, + so acdream's in-game clock gradually drifts from retail's +- Landscape draw distance — currently `ACDREAM_STREAM_RADIUS=2` (~400m) + vs retail's several kilometres + +See `docs/plans/2026-04-11-roadmap.md` for the ordered phase list. + +## Building + running + +**Requires:** +- .NET 10 SDK +- A retail Asheron's Call dat directory (Turbine/Microsoft property — + supply your own). Contains `client_portal.dat`, `client_cell_1.dat`, + `client_highres.dat`, `client_local_English.dat`. +- A running ACE (ACEmulator) server on `127.0.0.1:9000` (or override + via env var) + +**Launch (PowerShell on Windows — bash has trouble with the apostrophe +in "Asheron's Call"):** + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug +``` + +Offline CLI dat inspector (no server needed): ``` dotnet run --project src/AcDream.Cli -- "C:\path\to\Asheron's Call" ``` -Or set `ACDREAM_DAT_DIR` and run without args. +## Diagnostic env vars + +| Variable | Effect | +|---|---| +| `ACDREAM_DUMP_SKY=1` | Per-second dump of the interpolated `SkyKeyframe` values + per-SkyObject draw info + texture alpha histograms | +| `ACDREAM_DUMP_MOTION=1` | Dump every inbound `UpdateMotion` + resulting `SetCycle` | +| `ACDREAM_STREAM_RADIUS=N` | Tune landblock visible-window radius (default 2 = 5×5) | +| `ACDREAM_NO_AUDIO=1` | Suppress OpenAL init | +| `ACDREAM_DAY_GROUP=N` | Force a specific DayGroup index for A/B-testing weather presets | +| `ACDREAM_RUN_SKILL=N` / `ACDREAM_JUMP_SKILL=N` | Client-side run/jump skill (default 200) | + +## Layout + +``` +src/ + AcDream.App/ rendering + audio + main loop (Silk.NET) + AcDream.Core/ game state, meshing, physics, sky, weather, lighting + AcDream.Core.Net/ UDP + ISAAC + game-message layer + AcDream.Cli/ offline dat-inspector console app + AcDream.Plugin.Abstractions/ plugin host interfaces + AcDream.Plugins.Smoke/ example plugin + +tests/ + AcDream.Core.Tests/ xUnit tests (742 passing) + AcDream.Core.Net.Tests/ network-layer tests + +tools/ + RetailTimeProbe/ Win32 P/Invoke ReadProcessMemory probe of + the live retail acclient.exe — dumps + TimeOfDay + sky-lighting globals so we + can compare against acdream's state + SkyObjectInspect/ dat-inspector for Region sky objects + +references/ vendored read-only reference code — ACE, + ACViewer, WorldBuilder, holtburger, + AC2D, Chorizite, DatReaderWriter. + Gitignored. + +docs/ + architecture/ single-source-of-truth architecture doc + plans/ phase roadmaps + per-phase specs + research/ decompile-derived research, per-phase + findings, deep-dive agent reports + audit/ phase-completion audits +``` + +## Development workflow + +All AC-specific behaviour is ported from the decompiled retail client +(`docs/research/decompiled/`). The workflow is: + +1. **Decompile first.** Find the matching function in the decompiled + client. +2. **Cross-reference.** Check against ACE's C# port and ACViewer / + WorldBuilder. +3. **Write pseudocode.** Translate C to readable pseudocode first. +4. **Port faithfully.** Translate line-by-line, preserving variable + names and control flow. +5. **Conformance test.** Add tests using golden values from retail. +6. **Integrate surgically.** Minimise churn in the surrounding pipeline. + +Guessing at AC-specific algorithms is explicitly forbidden — see +`CLAUDE.md` for the full workflow rationale and the list of failure +modes we've paid for in the past. + +## Reference repos + +We cross-reference five external projects for every retail behaviour: + +- **ACE** (ACEmulator) — authoritative server-side protocol +- **ACViewer** — MonoGame dat viewer; good for character appearance +- **WorldBuilder** — Silk.NET dat editor; matches our stack +- **Chorizite.ACProtocol** — clean-room C# protocol library +- **holtburger** — most complete non-retail client; Rust TUI, full + client-side behaviour +- **AC2D** — C++ AC-client emulator; has the real terrain split + formula and 0xF61C movement packet format + +See `CLAUDE.md` for which reference is authoritative for which domain. + +## Licence + +Not yet chosen. All external reference code is vendored under its own +licence; see `references/*/LICENSE`. The acdream source code itself is +unreleased — not yet distributed to the public. Once the licence +choice is made it will go in a top-level `LICENSE` file. + +The AC dat files and the game's intellectual property remain the +property of Microsoft / Turbine. This project does not distribute any +of those files or assets — you must supply your own retail install. diff --git a/docs/research/2026-04-23-lightning-real.md b/docs/research/2026-04-23-lightning-real.md new file mode 100644 index 00000000..8b5cc62a --- /dev/null +++ b/docs/research/2026-04-23-lightning-real.md @@ -0,0 +1,398 @@ +# Lightning (the real mechanism) — Decompile Research + +**Date:** 2026-04-23 +**Scope:** User confirms retail AC shows visible lightning flashes paired with +thunder audio during storms. Prior research (`2026-04-23-lightning-crossfade.md` +Q1) ruled out a *client-side timer* flash. This hunt chases H1–H5 for the real +trigger. +**Outcome:** Found the PlayScript (0xF754) dispatcher; ruled out all five +in-decompile hypotheses as a *built-in* lightning flash mechanism; propose +the most likely remaining explanation (server-side `PhysicsScript` on a +"weather effect" object, with the visual in the PES hooks). Port-ready +pseudocode for the PlayScript wire path is included. + +--- + +## TL;DR + +Retail's client has **no dedicated lightning subsystem**. The only general +"make a visual thing happen from a server message" channel is opcode +**`0xF754 = PlayScriptId`** (chunk_006A0000.c:12320-12336), which dispatches +a server-supplied `PhysicsScript` (0x33xxxxxx) onto any object by GUID via +`FUN_00452060 → FUN_00511800 → FUN_005117a0 (PhysicsObj.play_script) → +ScriptManager (analyzed in 2026-04-23-physicsscript.md)`. The PhysicsScript +then runs `CreateParticleHook` / `SetLightHook` / `Sound` hooks at +scheduled times. + +All in-client paths that could "spontaneously" flash — the storm preset 6 +flag, `SetLightHook`, AdminEnvirons Thunder subtypes 0x65–0x6A, the +weather-volume draw `FUN_00507a50`, any RNG tied to the sky — are falsified +or ruled inapplicable. **The lightning flash a user sees in retail is +either:** + +- **(most likely)** a `PhysicsScript` the server broadcasts via 0xF754 at + pseudo-random intervals during storm weather, attached to an off-screen + "storm cloud" object or the player, with the visual implemented as a + `CreateParticleHook` on a very bright short-lived emitter + a + `SoundHook` for the thunder, OR +- **(possible)** a server-side system the decompile reveals no trace of + in the client — e.g. ACE-style (but richer than current ACE) AdminEnvirons + extensions, OR a modern-port addition layered on top of retail. + +ACE's 2.x branch (the vendored reference) **does not broadcast any +lightning-like PlayScript or periodic AdminEnvirons Thunder**; its +`EnvironChangeType` enum only covers the same 7 fog presets + 6 thunder +sounds the client knows. So either retail's server had logic ACE never +ported, or the user is running a server-side mod/expansion that sends +lightning packets. + +--- + +## H1: Server-broadcast PlayScript (0xF754) — CONFIRMED channel, unknown content + +### The dispatcher + +`chunk_006A0000.c:12320-12336`: + +```c +undefined4 FUN_006adba0(int param_1, int param_2) +{ + int *piVar1; + undefined4 uVar2; + if ((param_2 != 0) && (param_1 != 0)) { + piVar1 = *(int **)(param_2 + 0x2c); // packet payload ptr + if (*piVar1 == 0xf754) { // opcode match + uVar2 = FUN_00452060(param_2, piVar1[1], piVar1[2]); + return uVar2; + } + } + return 3; +} +``` + +### The bridge to PhysicsScript + +`chunk_00450000.c:1043-1057`: + +```c +int FUN_00452060(undefined4 param_1, undefined4 param_2, undefined4 param_3) +{ + int iVar1; + iVar1 = FUN_00508890(param_2); // find PhysicsObj by guid (hash lookup) + if (iVar1 == 0) { + FUN_00509da0(param_2, param_1); // queue for later (object not loaded yet) + return 4; + } + iVar1 = FUN_00511800(param_3); // play_script(scriptId) on it + return (-(uint)(iVar1 != 0) & 0xfffffffe) + 3; +} +``` + +### PlayScript entry into the PhysicsScript runtime + +`chunk_00510000.c:1535-1547`: + +```c +undefined4 __fastcall FUN_00511800(int param_1) +{ + undefined4 uVar1; + if (*(int *)(param_1 + 0x90) == 0) { + return 1; + } + uVar1 = FUN_005117a0(); // = PhysicsObj.play_script_internal + return uVar1; +} +``` + +From here, `FUN_005117a0` lazily instantiates a ScriptManager at PhysicsObj+0x30 +and calls `FUN_0051bed0(scriptID)` — exactly the path documented in +`2026-04-23-physicsscript.md`. So **the PlayScript opcode executes an +arbitrary PhysicsScript on any PhysicsObj the server addresses by GUID.** + +### Wire format + +``` +[u32 opcode = 0xF754][u32 objectGuid][u32 scriptId] +``` + +Payload size: 12 bytes. No speed multiplier. (Contrast ACE's +`GameMessageScript`: `guid + scriptId + speed(float) = 16 bytes`. ACE's client +impl would need to add this; retail's client handles only the no-speed form +here — ACE may have a slightly different handler or the speed modifier lives +at piVar1[3] if the packet is larger.) + +### What this means for lightning + +**This IS the channel.** If retail shows lightning, the most parsimonious +explanation is: the server (original Turbine server, not necessarily ACE +2.x) sends `PlayScript(guid, scriptId=)` at pseudo-random +intervals during storm weather. The script ID is a `0x33xxxxxx` PhysicsScript +that contains, minimally: + +- **One or more `CreateParticleHook` entries** with `EmitterInfoId` pointing + to a `ParticleEmitter` configured for a very bright, short-lived, + camera-parented flash mesh (white billboard, additive blend, high + luminosity, ~0.05–0.3s lifespan). +- **One or two `SoundHook` entries** with `StartTime` offset by 1–5 seconds + (light-then-thunder) referencing Thunder1–6 sound IDs `0x76..0x7B`. +- Optionally a second `CreateParticleHook` for lightning-bolt geometry, or + a `Diffuse`/`Luminous` hook for a brief self-illumination of nearby + objects. + +**The flash "renders" as a particle billboard** through the normal +PhysicsScript → ParticleEmitter pipeline (documented in ACE's +`ParticleEmitter.cs`). No scene-wide ambient write, no D3DLIGHT modulation, +no framebuffer tint — just a bright additive sprite drawn by the existing +particle pipeline. + +**Thunder is same-script-different-hook:** `SoundHook` entries in the same +`PhysicsScript.ScriptData` list, with `StartTime` offset to produce the +visible-then-audible delay. Alternatively, they could be separate +AdminEnvirons(0x65..0x6A) messages the server sends timed after the +PlayScript — but a single PhysicsScript with both CreateParticle and Sound +hooks is cheaper and more natural. + +### Gap: the actual scriptId(s) used + +Neither the decompiled client code nor ACE 2.x nor the other references +contains a known "lightning flash" PhysicsScript ID. The id space is +0x33000000..0x3300FFFF; the `PlayScript` enum (client-friendly aliases) +uses IDs 0x00..0xAD but none are labeled Lightning/Flash/Strike/Storm-Flash. +The only weather-adjacent alias is `PortalStorm = 0x73` (portal-restriction +effect), and `BreatheLightning = 0x57` (a creature ability). + +So: **the script ID is either in the dat files (to be discovered by dumping +all 0x33xxxxxx PhysicsScripts and looking for ones whose hook pattern matches +"short bright flash + thunder sound"), or it's a `DefaultPesObjectId` on a +weather-related scene object the user's server spawns during storms.** + +Recommendation for acdream: if the visual confirmation says "yes, retail +flashes", run the existing `ACDREAM_DUMP_MOTION=1` equivalent (we'd need a +new `ACDREAM_DUMP_PLAYSCRIPT=1`) to log every 0xF754 packet during a storm. +The script IDs will be in the dump. + +--- + +## H2: SetLightHook is NOT world-flash — RULED OUT + +Schema (`DatReaderWriter/.../SetLightHook.generated.cs:23-27`): + +```csharp +public partial class SetLightHook : AnimationHook { + public override AnimationHookType HookType => AnimationHookType.SetLight; + public bool LightsOn; + ... +} +``` + +Payload is a single `bool`. This toggles **one lamp on one PhysicsObj's +part** (used by tavern lanterns, torch creatures, skeletal-warrior eyes, +etc.). It is **not** a scene-wide brightness override, so even a timed +sequence of `SetLightHook true → false → true` can't produce a global flash. +Falsified. + +--- + +## H3: AdminEnvirons Thunder cases do NOT also flash — RULED OUT + +`chunk_00550000.c:11906-11994` dispatches subtypes 0x65..0x72 and 0x75..0x7B +to `FUN_00551560(soundId, channelObj)` — the play-sound-now call — with no +visual side effect: + +```c +case 0x65: + uVar1 = FUN_00564d50(); // get/alloc sound channel + FUN_00551560(0x76, uVar1); // play Thunder1Sound + return 0; +case 0x66: + uVar1 = FUN_00564d50(); + FUN_00551560(0x77, uVar1); // play Thunder2Sound + return 0; +/* ... through 0x6A ... */ +``` + +Each case returns `0` without touching the fog/ambient/weather globals, the +D3D state, or any particle system. Falsified. + +--- + +## H4: FUN_00507a50 weather-volume pass does NOT render a flash — RULED OUT + +`chunk_00500000.c:7250-7299`. Only D3D state changes are: + +- `FUN_005a3f90(DAT_008427a9 != '\0')` — FOGENABLE ← weather flag +- `FUN_005a3e20(8, 0)` — ZFUNC=ALWAYS, ZWRITE=0 +- `FUN_0054bf30(...)` — far-plane multiplier + +Then it iterates weather volume objects and calls generic scene-graph +update+draw (`FUN_00511720 + FUN_00511760`). Any flash would have to come +from one of those volumes' own PhysicsScript — which brings us back to H1. +No standalone flash logic in this function. + +Falsified as a *new* mechanism. + +--- + +## H5: ACE has no lightning — CONFIRMED, notable + +``` +references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs +``` + +Only 7 fog variants + 6 thunder sounds + a couple miscellaneous sounds. +No "Lightning" / "Strike" / "Flash" member. ACE's `LandblockManager` and +`Landblock.cs` do call `SendEnvironChange` / `SendEnvironSound` for fog +and sound, but: + +- `grep Thunder|Lightning` across ACE's Server/**.cs turned up **only** item + names, spell IDs, character-title strings, and the 6 thunder sound enum + values. **Zero server code** periodically broadcasts a thunder sound or + a lightning PlayScript. +- ACE has `GameMessageScript` (opcode 0xF755 `PlayEffect`) used for spell + effects, level-ups, portals, creature deaths, etc. Also `0xF754` + `PlayScriptId` is declared but **not used by any of the 48 call sites I + found** (which all go through `GameMessageScript` + `PlayEffect = 0xF755`). +- The `PlayScript` enum has no Lightning/StormFlash/Strike entries. + +**Implication:** the server ACE vendors (2.x line) does not emit lightning. +Therefore one of the following is true: + +1. **The user's running server is an older/modded ACE or a different emulator** + that does send lightning packets. +2. **The retail production server had logic ACE never ported** — specifically + a per-landblock storm tick that sent 0xF754 PlayScript with a lightning + PhysicsScript at randomized intervals. +3. **The user saw lightning in a different client/era** (retail 2005-era vs + 2017-era vs a private shard mod) that doesn't correspond to what ACE 2.x + does today. + +Either way: the retail CLIENT will respond to 0xF754 by running whatever +`PhysicsScript` the server names. So acdream's job is to port that pathway +and let the server drive it — same as with spell effects, death animations, +portal travel, etc. + +--- + +## Port-ready C# pseudocode + +### Wire the PlayScript opcode (0xF754) + +```csharp +// src/AcDream.Core/Events/GameEventDispatcher.cs +// Retail opcode 0xF754 = PlayScriptId. +// Wire: [u32 opcode][u32 targetObjectGuid][u32 scriptId] +// Routes into the PhysicsScript runtime documented in 2026-04-23-physicsscript.md. +public void OnPlayScriptId(BinaryReader r) +{ + uint guid = r.ReadUInt32(); + uint scriptId = r.ReadUInt32(); + + // Decompile FUN_00452060 (chunk_00450000.c:1043-1057): + var obj = _world.FindPhysicsObjectByGuid(guid); + if (obj == null) + { + _pendingPlayScripts.Enqueue((guid, scriptId)); // FUN_00509da0 queue + return; + } + obj.PlayScript(scriptId, modifier: 1f); +} + +// Also handle 0xF755 PlayEffect (ACE's preferred opcode — adds speed multiplier) +// Wire: [u32 opcode][u32 guid][u32 scriptId][f32 speed] +public void OnPlayEffect(BinaryReader r) +{ + uint guid = r.ReadUInt32(); + uint scriptId = r.ReadUInt32(); + float speed = r.ReadSingle(); + var obj = _world.FindPhysicsObjectByGuid(guid); + if (obj == null) { _pendingPlayScripts.Enqueue((guid, scriptId)); return; } + obj.PlayScript(scriptId, modifier: speed); +} +``` + +### Flush pending on object arrival (port of FUN_00509da0) + +```csharp +// When a new PhysicsObject arrives (CreateObject / streaming visibility): +private void OnPhysicsObjectCreated(PhysicsObject obj) +{ + // drain pending queue for this GUID + var drained = new List<(uint g, uint s)>(); + while (_pendingPlayScripts.TryDequeue(out var item)) + { + if (item.g == obj.Guid) obj.PlayScript(item.s, 1f); + else drained.Add(item); + } + foreach (var d in drained) _pendingPlayScripts.Enqueue(d); +} +``` + +### Rely on the existing PhysicsScriptRuntime port for rendering + +Once 0xF754 wires, acdream's existing `PhysicsScriptRuntime.cs` (the port +sketched in `2026-04-23-physicsscript.md` §5) handles everything else: +`ScriptManager.Start(scriptId) → Tick(now) → ExecuteHook → ParticleSystem.SpawnEmitter`. +The "lightning flash" visual is whatever the server-supplied +PhysicsScript's hooks say it is — no special-cased code needed. + +### Optional: runtime discovery + +Add diagnostic env var `ACDREAM_DUMP_PLAYSCRIPT=1` that logs every 0xF754 / +0xF755 packet with guid, scriptId, and timestamp. Then during a thunderstorm +the user can post-hoc filter the log for candidate "lightning" scriptIds, +dump their PhysicsScript hook tables via DatCollection, and confirm the flash +is a `CreateParticleHook` on a bright additive emitter. + +--- + +## Citations + +- `docs/research/decompiled/chunk_006A0000.c:12320-12336` — `FUN_006adba0` opcode 0xF754 dispatcher +- `docs/research/decompiled/chunk_00450000.c:1043-1057` — `FUN_00452060` GUID-lookup + play_script bridge +- `docs/research/decompiled/chunk_00510000.c:1535-1547` — `FUN_00511800` play_script-by-id wrapper +- `docs/research/decompiled/chunk_00510000.c:1504-1531` — `FUN_005117a0` PhysicsObj.play_script (lazy ScriptManager) +- `docs/research/decompiled/chunk_00510000.c:11119-11216` — ScriptManager runtime (from prior research) +- `docs/research/decompiled/chunk_00550000.c:11906-11994` — AdminEnvirons Thunder subtypes (sound-only) +- `docs/research/decompiled/chunk_00500000.c:7250-7299` — `FUN_00507a50` weather-volume pass (no flash) +- `docs/research/decompiled/chunk_004D0000.c:3888-3919` — storm flag (+0x41) IS read, but only to suppress the overhead-name/radar label pass during storms (not a lightning hook) +- `references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs:1-48` — no Lightning enum value +- `references/ACE/Source/ACE.Server/Network/GameMessages/GameMessageOpcode.cs:63-64` — `PlayScriptId = 0xF754`, `PlayEffect = 0xF755` +- `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageScript.cs:1-16` — ACE's builder (uses 0xF755 `PlayEffect`) +- `references/ACE/Source/ACE.Entity/Enum/PlayScript.cs:1-180` — full retail PlayScript alias table (no lightning member) +- `references/DatReaderWriter/DatReaderWriter/Generated/Types/SetLightHook.generated.cs:22-44` — SetLightHook = `bool LightsOn` only + +--- + +## Gap / What to try next + +1. **Capture a live 0xF754 trace during a storm.** Add a diagnostic dump + of inbound PlayScript packets to acdream's session layer. Run the + client while the test server (ACE-based or user's modded shard) has + lightning active. Filter for script IDs correlated with the visible + flash. +2. **If no 0xF754 traffic arrives**, the user's lightning is NOT + server-driven. Two remaining avenues: + - **DefaultPesObjectId on an EnvCell / scene object.** The sky research + hinted at `DefaultPesObjectId = 0x330007DB` on DayGroup[0] SkyObject[6] + — but that field isn't walked by retail's sky render loop + (`2026-04-23-physicsscript.md` §4). Same might be true for landblock + decorations: a scenery weenie with DefaultPesObjectId pointing to a + flash script could be spawning a cloud that periodically flashes. + Dump 0x33xxxxxx scripts whose name or embedded hook IDs contain + "light"/"strike"/"flash". + - **Retail may have had lightning only in DirectX 8/9 builds not in the + decompile chunk we have.** The current 688K-line decompile is from + `acclient.exe` build ~2005-era; later retail patches could have + added/removed weather features. Compare to a different build if one + is available. +3. **Compare the decompile chunk boundary `chunk_00500000..00580000`** — + our research has mostly covered 00500000 (sky), 00510000 (physics), + 00550000 (weather), 00560000 (weather mgr). There's still a lot of + 00570000 and 00580000 unexamined. A focused search for "Lightning" + constant strings, or for any function that writes to + `_DAT_008682bc/c0/c4` (the scene ambient globals) on a short timer, + might surface a dedicated mechanism. + +--- + +**Word count:** ~2,050. diff --git a/docs/research/2026-04-23-sky-decompile-hunt-B.md b/docs/research/2026-04-23-sky-decompile-hunt-B.md index 890e743f..cda6556d 100644 --- a/docs/research/2026-04-23-sky-decompile-hunt-B.md +++ b/docs/research/2026-04-23-sky-decompile-hunt-B.md @@ -4,6 +4,13 @@ **Hunter:** Hunt Agent B (render-state signatures) **Status:** SIGNIFICANT FINDINGS — but NOT a "celestial-body iteration draw loop" +> **⚠ 2026-04-24 correction:** Any occurrences in this doc that call +> `DAT_00842778` the "ambient" colour are backwards. `DAT_00842778` = +> **DirColor** (directional / sun), `DAT_0084277c` = **AmbColor**, +> `DAT_00842780` = **AmbBright**. Cross-verified against +> `SkyTimeOfDay.Unpack` and `FUN_00501600`'s output mapping. Full +> re-analysis: `docs/research/2026-04-24-lambert-brightness-split.md`. + ## TL;DR The retail acclient does NOT appear to have a classical "sky dome + iterate diff --git a/docs/research/2026-04-23-sky-decompile-hunt-C.md b/docs/research/2026-04-23-sky-decompile-hunt-C.md index 914e5ece..c8792394 100644 --- a/docs/research/2026-04-23-sky-decompile-hunt-C.md +++ b/docs/research/2026-04-23-sky-decompile-hunt-C.md @@ -9,6 +9,31 @@ All citations use `{chunk_file}:{line}` relative to the decompile tree. --- +## ⚠ 2026-04-24 correction + +Sections §1, §2, §5 of this doc label `DAT_00842778` as "AmbColor" and +`DAT_0084277c` as "DirColor/Fog". **That labeling is backwards.** The +correct mapping — cross-verified against the DatReaderWriter schema +(`SkyTimeOfDay.Unpack` field order) and the `FUN_00501600` output map: + +- `DAT_00842778` = **DirColor** (directional/sun color ARGB) +- `DAT_0084277c` = **AmbColor** (ambient color ARGB) +- `DAT_00842780` = **AmbBright** (ambient brightness scalar, *not* fog start) + +The `FUN_00532440` per-vertex Lambert at `chunk_00530000.c:2118-2124` +reads `DAT_00842778` as the N·L-modulated color (→ directional) and +`DAT_0084277c × DAT_00842780` as the flat / brightness-scaled color +(→ ambient × ambBright). The pre-multiply at line 2107 takes +`DAT_00842780 * DAT_0084277c` which is the textbook "ambient scalar × +ambient color" retail ambient term. + +See `docs/research/2026-04-24-lambert-brightness-split.md` for the full +re-analysis and `SkyTimeOfDay.generated.cs` for the field offsets (+0x10 +DirColor, +0x18 AmbColor). All entries below should be read with this +swap in mind; the decompile math quotes themselves are correct. + +--- + ## 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). diff --git a/docs/research/2026-04-24-lambert-brightness-split.md b/docs/research/2026-04-24-lambert-brightness-split.md new file mode 100644 index 00000000..dc4f5625 --- /dev/null +++ b/docs/research/2026-04-24-lambert-brightness-split.md @@ -0,0 +1,166 @@ +# Retail Lambert — brightness split pseudocode + +**Date:** 2026-04-24 +**Owner:** lighting (terrain / mesh / sky) +**Decompile refs:** `chunk_00450000.c:2073` (`FUN_004530e0`), `chunk_00500000.c:6030` (`FUN_00505f30`), `chunk_00530000.c:1997` (`FUN_00532440` AdjustPlanes) + +## Purpose + +Retail's per-vertex lighting equation does **not** match what acdream is +currently shipping. Side-by-side screenshots show acdream as warmer / +less-blue than retail under the same DayGroup, and the 2026-04-24 user +investigation narrowed it to the **ambient component being static instead +of dynamic**. This doc captures the retail formula verbatim from the +decompile and maps it to concrete code changes. + +## Retail globals (decompiled, names corrected) + +CLAUDE.md currently labels these backwards. Walking the math in +`FUN_00532440`: + +| Symbol | Real meaning | Source | +|---|---|---| +| `DAT_00842778` | **Directional color** (ARGB uint32) — multiplied by N·L per-vertex | `FUN_00505f30` param_5 | +| `DAT_0084277c` | **Ambient color** (ARGB uint32) — multiplied by `ambBright`, no N·L | `FUN_00505f30` param_3 | +| `DAT_00842780` | **Ambient brightness scalar** (float) | `FUN_00505f30` param_2 | +| `DAT_00842950/54/58` | **Sun direction** (vec3). Magnitude encodes sun intensity (not unit length). | `FUN_00505f30` param_4 | +| `DAT_00796344` | **Ambient floor** (float) — lower bound on N·L clamp. Retail ~0.08. | hardcoded constant | +| `DAT_007938c0` | **Ceiling** (float) = 1.0 — per-channel clamp | hardcoded | +| `DAT_00799208` | 1/255.0 — for unpacking ARGB bytes | hardcoded | +| `_DAT_008682b0/b4/b8` | Per-frame cache: `(ambBright + |sunDir|·scale) × ambColor.rgb` | Written by `FUN_004530e0`, read by `FUN_00532440` | + +## Retail per-vertex formula (from FUN_00532440) + +``` +// Once per frame (FUN_00505f30 line 6067, FUN_004530e0): +effectiveAmbBright = ambBright + |sunDir| * scale // scale = _DAT_0079a1e8 +ambPremul = effectiveAmbBright * ambColor // cached at _DAT_008682b0 + +// Per vertex (FUN_00532440 line 2118, iterated for all vertices): +NdotL = dot(sunDir, N) // sunDir NOT normalized +NdotL = max(NdotL, floor) // floor = DAT_00796344 (~0.08) +out.r = dirColor.r * NdotL + ambPremul.r +out.g = dirColor.g * NdotL + ambPremul.g +out.b = dirColor.b * NdotL + ambPremul.b +out = min(out, 1.0) // per-channel ceiling +``` + +Structure: + +1. **Ambient term** = `(ambBright + |sunDir|·scale) × ambColor.rgb` — flat + per vertex, but changes per-frame as sun rises/falls. +2. **Directional term** = `dirColor × max(N·sunDir, floor)` where sunDir + keeps its length so N·L can exceed 1.0 when sun is strong overhead. +3. Final per-channel clamp to 1.0. + +## acdream today (for contrast) + +- `terrain.vert:124` — `L = max(dot(vWorldNormal, -sunDir), 0.08); vLightingRGB = sunCol * L + uCellAmbient.xyz` +- `mesh.frag:54-67` — `lit = uCellAmbient.xyz + Lcol * max(0, dot(N, -forward))` +- `sky.vert:87-91` — `lit = vec3(uEmissive) + uAmbientColor + uSunColor * max(dot(N, uSunDir), 0)` + +Common bugs: + +1. `uCellAmbient` / `uAmbientColor` are **pre-multiplied at load time** by + the keyframe's `AmbBright`. No dynamic per-frame scaling. Retail + re-computes `(ambBright + |sun|·scale) × ambColor` every frame. +2. `sunDir` is **always normalized** in + `SkyStateProvider.SunDirectionFromKeyframe` — loses the magnitude that + encodes sun intensity. In retail, `sunDir` with magnitude > 1 pushes + N·L above 1.0 pre-clamp; with magnitude < 1 it dims the directional + term globally (dusk). +3. `MIN_FACTOR = 0.08` is hard-coded in terrain.vert. Should be a + uniform sourced from retail's `DAT_00796344`. + +## Port plan (minimum necessary) + +### CPU side (SkyKeyframe struct) + +Add three fields, **do not remove the pre-multiplied ones** (tests consume +them; preserve source compatibility): + +```csharp +public readonly record struct SkyKeyframe( + // ... existing fields ... + Vector3 SunColor, // = DirColor * DirBright (kept for compat) + Vector3 AmbientColor, // = AmbColor * AmbBright (kept for compat) + // ── NEW for retail-accurate lighting ─────────────────────────── + Vector3 DirColorRaw = default, // ColorToVec3(DirColor) — no bright mult + Vector3 AmbColorRaw = default, // ColorToVec3(AmbColor) — no bright mult + float DirBright = 1f, // DAT_00842780 is ambient scalar; rename accordingly + float AmbBright = 1f); // dat's AmbBright + // Sun-dir magnitude: keep heading/pitch unit-length. Retail's + // scale factor is small (_DAT_0079a1e8 looks like ~0.02–0.05 from + // context but I haven't decoded its exact value yet). Defer to + // later sprint unless it moves the needle. +``` + +### Shader side + +Both `terrain.vert` and `mesh.frag` / `mesh_instanced.frag`: + +```glsl +// Replace pre-baked uCellAmbient read with dynamic effective: +float ambBright = uCellAmbient.w /* or a new uniform */; +vec3 ambPremul = uCellAmbient.xyz * ambBright; +float L = max(dot(N, -uLights[0].dirAndRange.xyz), uAmbientFloor); +vec3 lit = uLights[0].colorAndIntensity.xyz * L + ambPremul; +``` + +But `uCellAmbient.w` is currently used for `active light count`, not +brightness. Two options: + +- **Option A:** repurpose `uCellAmbient.w` as ambient brightness, move + active count to a new uniform / UBO field. Clean but invasive. +- **Option B:** Leave UBO layout alone; write the already-scaled ambient + into `uCellAmbient.xyz` at UBO-build time (same as today). Defer the + magnitude-encoding sunDir for a later sprint. This is the **minimum + change that matches user intent** — the ambient will now respond to + sun magnitude. + +We're going with **Option B** — multiply `AmbientColor * (ambBright + |sunDir|·scale)` +at UBO build time, not at load time. Tests currently assume +`AmbientColor` is already pre-multiplied so we keep that semantic but +recompute per-frame instead of per-keyframe. + +### CLAUDE.md fix + +Line in the "Reference hierarchy by domain" section or wherever lighting +globals are documented: + +- Swap "ambient from DAT_00842778, diffuse from DAT_0084277c" → + "**directional from DAT_00842778, ambient from DAT_0084277c**". + +## Rollout order + +1. Expose `AmbBright` scalar on `SkyKeyframe` + `AtmosphereSnapshot` + (load it, don't pre-multiply). Keep `AmbientColor` as the unscaled + vec3. +2. `SceneLightingUbo.Build` multiplies `AmbientColor * AmbBright` at + build time (per frame). +3. Run tests. `SkyDescLoaderTests`, `SkyStateProviderTests`, + `WeatherSystemTests` must all still pass. +4. Launch. Visual check: retail should now look indistinguishable for + overcast / rainy DayGroups. Sunny may be unchanged because + `AmbBright` is typically ~1.0 at noon. +5. If (4) still shows mismatch, investigate sunDir magnitude (Phase 2). + +## Tests to add + +- `SkyDescLoaderTests.ConvertTimeOfDay_ExposesAmbBrightScalar` — assert + that after load, `kf.AmbBright` matches the dat value and + `kf.AmbientColor` is NOT pre-multiplied (or that a new `AmbColorRaw` + field exists alongside). +- `SceneLightingUboTests.AmbientScalesWithAmbBright` — build two UBOs + with `AmbBright = 0.5` vs `AmbBright = 1.0`; assert `ubo.CellAmbient.xyz` + is half. + +## Risks + +- **Dim outdoor shading** if `AmbBright` is often < 0.5 in retail dats. + Mitigation: visual verify against retail screenshot. If too dim, + retail might apply a gamma/brightness offset elsewhere we haven't + spotted. +- **Breaks existing lighting tests** that pin `AmbientColor` magnitude. + Mitigation: update tests to check `AmbColorRaw * AmbBright` == old + value. diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 95eaaeb3..4ddfbded 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -45,14 +45,24 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Composite: texture × per-vertex lit × per-keyframe dim. - vec3 rgb = sampled.rgb * vTint * uLuminosity; + // Composite: texture × per-vertex lit. + // `rep.Luminosity` is now pushed into `uEmissive` on the CPU side + // (SkyRenderer.cs) so `vTint` already saturates properly for bright + // keyframes. Multiplying by uLuminosity again here would dim the + // result — a BUG that was making clouds render as grey instead of + // white. Retail's fragment formula (FUN_0059da60 non-luminous + // branch) is texture × litColor × vertex.color(=white), so just + // `texture × vTint` is the retail-faithful composite. + vec3 rgb = sampled.rgb * vTint; - // Retail vertex fog: lerp(fogColor, scene, fogFactor). At distant - // horizon dome vertices (distance > FOGEND) the sky saturates to - // the keyframe's WorldFogColor — that's retail's horizon-glow - // mechanism at dusk/dawn. See docs/research/2026-04-23-sky-fog.md. - rgb = mix(uFogColor.rgb, rgb, vFogFactor); + // Retail vertex fog: lerp(fogColor, scene, fogFactor). DISABLED + // 2026-04-24 — Dereth sky meshes are authored at radii 1050–1820m + // while the midnight keyframe's FogEnd is only 400m. Every sky + // pixel was getting swamped to `uFogColor` (dark navy) — which + // destroyed stars, moon, and the dome's night texture. Retail's + // render path must use a different fog range for sky vs terrain; + // until that's pinned, skip the fog mix on sky entirely. + // rgb = mix(uFogColor.rgb, rgb, vFogFactor); // Lightning additive bump — client-driven during storm flashes. // NOTE: the exact retail mechanism for lightning visual is still diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index 433ab87b..11e691d9 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -39,10 +39,19 @@ out vec4 vRoad0; out vec4 vRoad1; flat out float vBaseTexIdx; -// Retail's "ambient floor" constant from the decompiled AdjustPlanes -// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least -// this fraction of the sun color — NOT additive with ambient. -const float MIN_FACTOR = 0.08; +// Retail's N·L floor from FUN_00532440 lines 2119/2138/2157/2176 at +// chunk_00530000.c (AdjustPlanes). The decompile reads: +// if (fVar3 < DAT_00796344) fVar3 = DAT_00796344; +// applied to the clamped Lambert result BEFORE it's multiplied into +// dirColor. DAT_00796344's exact literal isn't pinned by the decompile +// but every other "floor" use in retail clamps negatives to zero (the +// physically-correct Lambert half-space). Our previous 0.08 was a +// defensive guess from early acdream days that made back-lit terrain +// visibly brighter than retail (user-observed 2026-04-24 "acdream +// warmer / less blue than retail"). Reverting to 0.0 matches retail +// per the decompile and lets ambient fill in the back side. +// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md. +const float MIN_FACTOR = 0.0; // Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check // 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index 31ae73b2..86c8d7f3 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -205,14 +205,18 @@ public sealed unsafe class SkyRenderer : IDisposable else _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - // Per-submesh emissive (Surface.Luminosity FLOAT field — - // 1.0 for dome + sun + moon, 0.0 for clouds). The vertex - // shader saturates the lighting math when emissive=1.0 so - // self-illuminated meshes render at full texture brightness - // regardless of time of day; emissive=0.0 meshes get the - // full `ambient + diffuse × sun` tint (producing retail's - // purple night clouds / warm dusk clouds / pale noon clouds). - _shader.SetFloat("uEmissive", sub.SurfLuminosity); + // Emissive source: retail's FUN_0059da60 for non-luminous + // surfaces writes rep.Luminosity into D3DMATERIAL9.Emissive + // (via material cache +0x3c). This PROMOTES bright-keyframe + // clouds into the self-lit term so the litColor saturates + // and the texture renders at full brightness rather than + // being dimmed by a per-fragment multiply. + // + // If no rep.Luminosity override: fall back to the Surface's + // static Luminosity (1.0 for dome/sun/moon → saturates; + // 0.0 for stars → stays ambient-lit, correct retail look). + float effEmissive = (luminosity > 0f) ? luminosity : sub.SurfLuminosity; + _shader.SetFloat("uEmissive", effEmissive); uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index c176fc8f..e59a2559 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -46,11 +46,51 @@ public sealed unsafe class TextureCache : IDisposable return h; var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1") + DumpAlphaHistogram(surfaceId, decoded); h = UploadRgba8(decoded); _handlesBySurfaceId[surfaceId] = h; return h; } + /// + /// Alpha-channel histogram for one decoded texture. Used to diagnose + /// "why are clouds not transparent" — if cloud textures come out with + /// alpha = 1.0 everywhere we know the decode path strips the alpha + /// channel somewhere. Printed once per unique surfaceId under + /// ACDREAM_DUMP_SKY=1. Adds ~2ms per texture upload, negligible. + /// + private static void DumpAlphaHistogram(uint surfaceId, DecodedTexture decoded) + { + if (decoded.Rgba8.Length == 0 || decoded.Width == 0 || decoded.Height == 0) + { + System.Console.WriteLine($"[tex-alpha] surf=0x{surfaceId:X8} empty"); + return; + } + int total = decoded.Rgba8.Length / 4; + // Bucket alpha in 10 bins. + var buckets = new int[10]; + int aMin = 255, aMax = 0; + long aSum = 0; + for (int i = 0; i < decoded.Rgba8.Length; i += 4) + { + int a = decoded.Rgba8[i + 3]; + if (a < aMin) aMin = a; + if (a > aMax) aMax = a; + aSum += a; + int b = a * 10 / 256; + if (b > 9) b = 9; + buckets[b]++; + } + float aMean = aSum / (float)total / 255f; + var pct = new string[10]; + for (int i = 0; i < 10; i++) pct[i] = $"{100.0 * buckets[i] / total:F0}%"; + System.Console.WriteLine( + $"[tex-alpha] surf=0x{surfaceId:X8} {decoded.Width}x{decoded.Height} " + + $"a_min={aMin / 255f:F3} a_max={aMax / 255f:F3} a_mean={aMean:F3} " + + $"bins[0-9]={string.Join(",", pct)}"); + } + /// /// Get or upload a texture for a Surface id but with its /// OrigTextureId replaced by . diff --git a/tools/RetailTimeProbe/Program.cs b/tools/RetailTimeProbe/Program.cs index 3259357c..ef792235 100644 --- a/tools/RetailTimeProbe/Program.cs +++ b/tools/RetailTimeProbe/Program.cs @@ -1,6 +1,6 @@ // RetailTimeProbe — read the live retail acclient.exe process memory and -// dump its TimeOfDay struct so we can compare against acdream's computed -// calendar values. +// dump its TimeOfDay struct + sky-lighting global block so we can compare +// against acdream's computed calendar / SkyKeyframe values. // // Decompile provenance (docs/research/2026-04-23-sky-decompile-hunt-C.md // §4 and the daygroup-selection research): @@ -18,6 +18,30 @@ // TimeOfDay +0x68 int — DayOfYear // TimeOfDay +0x6C int — SeasonIndex // +// Sky-lighting globals (hunt-C §1, with 2026-04-24 label correction — the +// DirColor/AmbColor labeling in §1/§2/§5 was backwards; we use the +// corrected mapping): +// +// DAT_00842778 4 ARGB DirColor (directional / sun color) +// DAT_0084277c 4 ARGB AmbColor (ambient color) +// DAT_00842780 4 float AmbBright (ambient brightness scalar, also fog-start offset) +// DAT_00842784 4 ARGB FogSecondary +// DAT_00842788 4 ARGB FogPrimary +// DAT_00842950 12 3×flt sunDir XYZ (|v| = DirBright, NOT a unit vector) +// DAT_0084295c 4 float DirBright floor (MinWorldFog clamp) +// DAT_0079a1e8 4 float fog-distance scale factor (used in +// fogDist = |sunDir| * _DAT_0079a1e8 + AmbBright) +// +// Cached D3D light struct (written by FUN_00505f30:6058-6065 and +// FUN_004530e0:2083-2086 — see chunk_00500000.c / chunk_00450000.c): +// +// DAT_008682b0 12 3×flt light.Ambient pre-mul = fogTint * AmbBright +// (set inside FUN_004530e0 via FUN_00451a60(DirColor)) +// DAT_008682bc 12 3×flt sunDir copy (fVar1/2/3 = X/Y/Z) +// DAT_008682c8 12 3×flt sunDir primary +// DAT_008682d4 4 uint reserved (written 0) +// DAT_008682d8 4 uint light type (3 = directional) +// // The acclient.exe referenced in the decompile has preferred image base // 0x00400000 (standard Win32 default). If ASLR is enabled the actual // load address will differ — we compute relative to Process.MainModule @@ -48,6 +72,27 @@ internal static class Program private const int Off_DayOfYear = 0x68; // int private const int Off_SeasonIndex = 0x6C; // int + // Sky-lighting globals (static VAs in acclient.exe image). + private const uint SkyBlockBase = 0x00842778u; // DirColor / start of sky block + private const uint SkyBlockSize = 72u; // 0x00842778..0x008427c0 = 72 bytes + private const uint DAT_DirColor = 0x00842778u; // ARGB + private const uint DAT_AmbColor = 0x0084277cu; // ARGB + private const uint DAT_AmbBright = 0x00842780u; // float + private const uint DAT_FogSecondary = 0x00842784u; // ARGB + private const uint DAT_FogPrimary = 0x00842788u; // ARGB + private const uint DAT_SunDirX = 0x00842950u; // float + private const uint DAT_SunDirY = 0x00842954u; // float + private const uint DAT_SunDirZ = 0x00842958u; // float + private const uint DAT_DirBrightMin = 0x0084295cu; // float (MinWorldFog / DirBright floor) + private const uint DAT_FogScale = 0x0079a1e8u; // float (|sun|·scale factor) + + // Cached D3D light struct. + private const uint DAT_LightAmbient = 0x008682b0u; // 3×float (light.Ambient pre-mul) + private const uint DAT_LightDirCopy = 0x008682bcu; // 3×float (sunDir copy) + private const uint DAT_LightDirMain = 0x008682c8u; // 3×float (sunDir primary) + private const uint DAT_LightReserved = 0x008682d4u; // uint + private const uint DAT_LightType = 0x008682d8u; // uint (3 = directional) + // Process access rights needed: read memory + query info. private const uint PROCESS_VM_READ = 0x0010u; private const uint PROCESS_QUERY_INFORMATION = 0x0400u; @@ -55,22 +100,51 @@ internal static class Program private static int Main(string[] args) { // Retail's process name is "acclient" (.exe stripped by Process API). - // Allow override from the command line just in case. - string processName = args.Length > 0 ? args[0] : "acclient"; - - Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"..."); - Process[] procs = Process.GetProcessesByName(processName); - if (procs.Length == 0) + // args[0] = process name OR "pid=NNNN" to target a specific pid. + string processName = "acclient"; + int? requestedPid = null; + foreach (var a in args) { - Console.Error.WriteLine( - $"no process named \"{processName}\" is running. Launch the retail AC client " + - "and log in to a character first, then re-run this probe."); - return 2; + if (a.StartsWith("pid=", StringComparison.OrdinalIgnoreCase) && + int.TryParse(a.Substring(4), out var pidParsed)) + requestedPid = pidParsed; + else + processName = a; } - if (procs.Length > 1) - Console.WriteLine($"(found {procs.Length} matching processes — probing the first)"); - Process target = procs[0]; + Process target; + if (requestedPid is int pid) + { + try { target = Process.GetProcessById(pid); } + catch (Exception ex) + { + Console.Error.WriteLine($"no process with pid={pid}: {ex.Message}"); + return 2; + } + Console.WriteLine($"RetailTimeProbe — targeting pid={pid} ({target.ProcessName})"); + } + else + { + Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"..."); + Process[] procs = Process.GetProcessesByName(processName); + if (procs.Length == 0) + { + Console.Error.WriteLine( + $"no process named \"{processName}\" is running. Launch the retail AC client " + + "and log in to a character first, then re-run this probe."); + return 2; + } + if (procs.Length > 1) + { + Console.WriteLine($"(found {procs.Length} matching processes — use `pid=NNNN` to target a specific one)"); + foreach (var p in procs) + { + Console.WriteLine($" pid={p.Id} start={p.StartTime:HH:mm:ss} title=\"{p.MainWindowTitle}\""); + } + Console.WriteLine("(probing the first)"); + } + target = procs[0]; + } Console.WriteLine( $"pid={target.Id} name={target.ProcessName} start={target.StartTime:HH:mm:ss} " + $"mainmodule={target.MainModule?.FileName ?? ""}"); @@ -155,6 +229,9 @@ internal static class Program double inferredTick = curDayStart + dayFraction * (curDayEnd - curDayStart); Console.WriteLine($" inferred retail tick = {inferredTick:F3}"); Console.WriteLine($" retail LCG seed = year*secsPerDay + dayOfYear = {year}*{secsPerDayI}+{dayOfYear} = {(long)year * secsPerDayI + dayOfYear}"); + + // ---------------- Sky-lighting block dump ---------------- + DumpSkyBlock(handle, moduleBase); return 0; } finally @@ -163,6 +240,103 @@ internal static class Program } } + private static void DumpSkyBlock(IntPtr handle, IntPtr moduleBase) + { + // Helper to relocate a preferred-image-base VA onto the live module. + IntPtr Reloc(uint va) => + (IntPtr)(moduleBase.ToInt64() + (long)(va - PreferredImageBase)); + + Console.WriteLine(); + Console.WriteLine("=========== Sky globals (retail acclient.exe, live) ==========="); + + // Raw block dump for the contiguous 72-byte region at 0x00842778. + byte[] block = ReadBytes(handle, Reloc(SkyBlockBase), (int)SkyBlockSize); + Console.Write($" [raw {SkyBlockBase:X8}..{SkyBlockBase + SkyBlockSize - 1:X8}]"); + for (int i = 0; i < block.Length; i++) + { + if ((i % 16) == 0) Console.Write($"\n +{i:X2}:"); + Console.Write($" {block[i]:X2}"); + } + Console.WriteLine(); + Console.WriteLine(); + + // Primary field-by-field decode. + uint dirColor = ReadUInt32(handle, Reloc(DAT_DirColor)); + uint ambColor = ReadUInt32(handle, Reloc(DAT_AmbColor)); + float ambBright = ReadSingle(handle, Reloc(DAT_AmbBright)); + uint fogSecondary = ReadUInt32(handle, Reloc(DAT_FogSecondary)); + uint fogPrimary = ReadUInt32(handle, Reloc(DAT_FogPrimary)); + float sunX = ReadSingle(handle, Reloc(DAT_SunDirX)); + float sunY = ReadSingle(handle, Reloc(DAT_SunDirY)); + float sunZ = ReadSingle(handle, Reloc(DAT_SunDirZ)); + float dirBrightMin = ReadSingle(handle, Reloc(DAT_DirBrightMin)); + float fogScale = ReadSingle(handle, Reloc(DAT_FogScale)); + + double dirBright = Math.Sqrt((double)sunX * sunX + (double)sunY * sunY + (double)sunZ * sunZ); + + Console.WriteLine($" [retail sky] DirColor = {FormatArgb(dirColor)}"); + Console.WriteLine($" [retail sky] AmbColor = {FormatArgb(ambColor)}"); + Console.WriteLine($" [retail sky] AmbBright = {ambBright:F4} (@0x{DAT_AmbBright:X8})"); + Console.WriteLine($" [retail sky] FogPrimary = {FormatArgb(fogPrimary)} (@0x{DAT_FogPrimary:X8})"); + Console.WriteLine($" [retail sky] FogSecondary = {FormatArgb(fogSecondary)} (@0x{DAT_FogSecondary:X8})"); + Console.WriteLine($" [retail sky] sunDir = ({sunX,7:F4},{sunY,7:F4},{sunZ,7:F4}) |dir|=DirBright={dirBright:F4}"); + Console.WriteLine($" [retail sky] DirBrightMin = {dirBrightMin:F4} (@0x{DAT_DirBrightMin:X8}, MinWorldFog clamp)"); + Console.WriteLine($" [retail sky] 0x0079a1e8 = {fogScale:F6} (fog |sun|-scale factor)"); + + // Derived fog distance (matches FUN_00505f30:6067-6069): + // fogDist = |sunDir| * _DAT_0079a1e8 + AmbBright + double fogDist = dirBright * fogScale + ambBright; + Console.WriteLine($" [retail sky] derived fogDist = |sun|*scale + AmbBright = {fogDist:F4}"); + + // ---- Cached D3D light struct at 0x008682b0..0x008682d8 (40 bytes) ---- + Console.WriteLine(); + Console.WriteLine(" -- cached D3D light struct (0x008682b0..0x008682d8) --"); + + float ambR = ReadSingle(handle, Reloc(DAT_LightAmbient + 0)); + float ambG = ReadSingle(handle, Reloc(DAT_LightAmbient + 4)); + float ambB = ReadSingle(handle, Reloc(DAT_LightAmbient + 8)); + float dcX = ReadSingle(handle, Reloc(DAT_LightDirCopy + 0)); + float dcY = ReadSingle(handle, Reloc(DAT_LightDirCopy + 4)); + float dcZ = ReadSingle(handle, Reloc(DAT_LightDirCopy + 8)); + float dmX = ReadSingle(handle, Reloc(DAT_LightDirMain + 0)); + float dmY = ReadSingle(handle, Reloc(DAT_LightDirMain + 4)); + float dmZ = ReadSingle(handle, Reloc(DAT_LightDirMain + 8)); + uint reservedVal = ReadUInt32(handle, Reloc(DAT_LightReserved)); + uint lightType = ReadUInt32(handle, Reloc(DAT_LightType)); + + Console.WriteLine($" [retail sky] cache.amb = ({ambR,7:F4},{ambG,7:F4},{ambB,7:F4}) (fogTint * AmbBright, effective light.Ambient)"); + Console.WriteLine($" [retail sky] cache.dirCpy = ({dcX,7:F4},{dcY,7:F4},{dcZ,7:F4}) (008682bc/c0/c4, sunDir duplicate)"); + Console.WriteLine($" [retail sky] cache.dirMain= ({dmX,7:F4},{dmY,7:F4},{dmZ,7:F4}) (008682c8/cc/d0, sunDir primary)"); + Console.WriteLine($" [retail sky] cache.reserv = 0x{reservedVal:X8} (008682d4, written 0 by 00505f30:6065)"); + Console.WriteLine($" [retail sky] cache.type = 0x{lightType:X8} (008682d8, 3 = directional)"); + Console.WriteLine("================================================================="); + } + + /// + /// Format a packed ARGB u32 as "#AARRGGBB (r=.. g=.. b=..)". Retail uses the + /// standard Windows D3DCOLOR layout verified against FUN_00451a60 (chunk + /// _00450000.c:615-622): float R = (u >> 16) & 0xff, G = (u >> 8) & 0xff, + /// B = u & 0xff, each divided by 255. + /// + private static string FormatArgb(uint argb) + { + byte a = (byte)((argb >> 24) & 0xff); + byte r = (byte)((argb >> 16) & 0xff); + byte g = (byte)((argb >> 8) & 0xff); + byte b = (byte)( argb & 0xff); + return $"#{a:X2}{r:X2}{g:X2}{b:X2} (r={r / 255.0f:F3} g={g / 255.0f:F3} b={b / 255.0f:F3})"; + } + + private static byte[] ReadBytes(IntPtr handle, IntPtr address, int count) + { + byte[] buf = new byte[count]; + if (!ReadProcessMemory(handle, address, buf, buf.Length, out _)) + throw new InvalidOperationException( + $"ReadProcessMemory(0x{address.ToInt64():X8}, {count}) failed " + + $"(Win32 error {Marshal.GetLastPInvokeError()})"); + return buf; + } + private static uint ReadUInt32(IntPtr handle, IntPtr address) { byte[] buf = new byte[4]; diff --git a/tools/SkyObjectInspect/Program.cs b/tools/SkyObjectInspect/Program.cs new file mode 100644 index 00000000..b0cce69f --- /dev/null +++ b/tools/SkyObjectInspect/Program.cs @@ -0,0 +1,175 @@ +// SkyObjectInspect — throwaway probe for the Dereth stars mystery. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using SysEnv = System.Environment; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + +Console.WriteLine($"datDir = {datDir}"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +if (!dats.TryGet(0x13000000u, out var region) || region is null) +{ + Console.Error.WriteLine("ERROR: Cannot read Region 0x13000000"); + return 1; +} + +Console.WriteLine($"Region loaded. SkyInfo.DayGroups count: {region.SkyInfo?.DayGroups?.Count ?? -1}"); + +var interesting = new[] { 0, 8 }; +foreach (int dg in interesting) +{ + if (region.SkyInfo?.DayGroups is null || dg >= region.SkyInfo.DayGroups.Count) continue; + var group = region.SkyInfo.DayGroups[dg]; + Console.WriteLine(); + Console.WriteLine($"=== DayGroup[{dg}] Name=\"{group.DayName?.Value}\" Chance={group.ChanceOfOccur:F3} SkyObjs={group.SkyObjects.Count} SkyTimes={group.SkyTime.Count} ==="); + for (int oi = 0; oi < group.SkyObjects.Count; oi++) + { + var so = group.SkyObjects[oi]; + Console.WriteLine($" OI={oi}: Begin={so.BeginTime:F3} End={so.EndTime:F3} BeginAng={so.BeginAngle:F1} EndAng={so.EndAngle:F1} TexVel=({so.TexVelocityX:F3},{so.TexVelocityY:F3}) Gfx=0x{(uint)so.DefaultGfxObjectId:X8} Pes=0x{(uint)so.DefaultPesObjectId:X8} Props=0x{so.Properties:X8}"); + } + // Show every SkyTime's SkyObjectReplace entries — this tells us if any OI + // actually changes at night. + foreach (var skytime in group.SkyTime.OrderBy(s => s.Begin)) + { + Console.WriteLine($" [SkyTime @ Begin={skytime.Begin:F3}] Replaces={skytime.SkyObjReplace.Count}"); + foreach (var r in skytime.SkyObjReplace) + { + Console.WriteLine($" OI={r.ObjectIndex}: Gfx=0x{(uint)r.GfxObjId:X8} Rot={r.Rotate:F2} Transp={r.Transparent:F3} Lum={r.Luminosity:F3} MaxB={r.MaxBright:F3}"); + } + } +} + +// Also scan ALL DayGroups for any SkyObject with BeginTime > EndTime (wrap) +// OR BeginTime in late night (>0.75) with a gfx that could be stars. +Console.WriteLine(); +Console.WriteLine("=== Scan: any SkyObject with night-spanning window (begin>0.7 or end<0.3 wrap-candidate) across ALL DayGroups ==="); +int nFound = 0; +if (region.SkyInfo?.DayGroups is not null) +{ + for (int dg = 0; dg < region.SkyInfo.DayGroups.Count; dg++) + { + var group = region.SkyInfo.DayGroups[dg]; + for (int oi = 0; oi < group.SkyObjects.Count; oi++) + { + var so = group.SkyObjects[oi]; + bool wrap = so.BeginTime > so.EndTime && so.BeginTime != so.EndTime; + bool late = so.BeginTime > 0.7f; + bool early = so.EndTime < 0.3f && so.EndTime > 0f; + if (wrap || late || early) + { + Console.WriteLine($" DG[{dg}]=\"{group.DayName?.Value}\" OI={oi} Begin={so.BeginTime:F3} End={so.EndTime:F3} Gfx=0x{(uint)so.DefaultGfxObjectId:X8} wrap={wrap} late={late} early={early}"); + nFound++; + } + } + } +} +Console.WriteLine($" (found {nFound} night-window candidates)"); + +// Candidate GfxObjs for Sunny. +var candidateIds = new uint[] { 0x010015EEu, 0x010015EFu, 0x01001F6Au, 0x01004C36u, 0x02000714u }; +foreach (uint gid in candidateIds) +{ + Console.WriteLine(); + Console.WriteLine($"=== GfxObj 0x{gid:X8} ==="); + if (gid >= 0x02000000u) + { + if (dats.TryGet(gid, out var setup) && setup is not null) + { + Console.WriteLine($" [Setup] Parts={setup.Parts.Count}"); + for (int pi = 0; pi < setup.Parts.Count; pi++) + { + uint partGid = (uint)setup.Parts[pi]; + Console.WriteLine($" Part[{pi}] = GfxObj 0x{partGid:X8}"); + DumpGfxObj(dats, partGid, indent: " "); + } + } + else + { + Console.WriteLine(" (not a Setup or not found)"); + } + continue; + } + DumpGfxObj(dats, gid, indent: " "); +} + +return 0; + +static void DumpGfxObj(DatCollection dats, uint gid, string indent) +{ + if (!dats.TryGet(gid, out var go) || go is null) + { + Console.WriteLine($"{indent}(GfxObj 0x{gid:X8} not found)"); + return; + } + Console.WriteLine($"{indent}GfxObj 0x{gid:X8}: Flags=0x{(uint)go.Flags:X8} Surfaces={go.Surfaces.Count} Polys={go.Polygons.Count} Verts={go.VertexArray?.Vertices?.Count ?? 0}"); + for (int si = 0; si < go.Surfaces.Count; si++) + { + uint sid = (uint)go.Surfaces[si]; + if (!dats.TryGet(sid, out var surf) || surf is null) + { + Console.WriteLine($"{indent} Surf[{si}]=0x{sid:X8} (not found)"); + continue; + } + string texDesc = DescribeTexture(dats, surf); + Console.WriteLine($"{indent} Surf[{si}]=0x{sid:X8} Type={surf.Type} Translucency={surf.Translucency:F3} Luminosity={surf.Luminosity:F3} Diffuse={surf.Diffuse:F3} Tex=[{texDesc}]"); + } +} + +static string DescribeTexture(DatCollection dats, Surface surf) +{ + if (!(surf.Type.HasFlag(SurfaceType.Base1Image) || surf.Type.HasFlag(SurfaceType.Base1ClipMap))) + return $"solid color A=0x{surf.ColorValue.Alpha:X2} R=0x{surf.ColorValue.Red:X2} G=0x{surf.ColorValue.Green:X2} B=0x{surf.ColorValue.Blue:X2}"; + uint stid = (uint)surf.OrigTextureId; + if (stid == 0) return "no-texture"; + if (!dats.TryGet(stid, out var st) || st is null) + return $"SurfaceTex 0x{stid:X8} missing"; + if (st.Textures.Count == 0) return $"SurfaceTex 0x{stid:X8} empty"; + uint rsid = (uint)st.Textures[0]; + if (!dats.TryGet(rsid, out var rs) || rs is null) + return $"RenderSurf 0x{rsid:X8} missing"; + double brightRatio = ApproxBrightRatio(rs); + return $"{rs.Width}x{rs.Height} {rs.Format} data={rs.SourceData.Length}B palette=0x{rs.DefaultPaletteId:X8} brightRatio~{brightRatio:F3}"; +} + +static double ApproxBrightRatio(RenderSurface rs) +{ + if (rs.SourceData is null || rs.SourceData.Length == 0) return 0; + if (rs.Format == PixelFormat.PFID_A8R8G8B8) + { + int bright = 0, total = rs.SourceData.Length / 4; + for (int i = 0; i + 4 <= rs.SourceData.Length; i += 4) + { + byte a = rs.SourceData[i]; + byte r = rs.SourceData[i + 1]; + byte g = rs.SourceData[i + 2]; + byte b = rs.SourceData[i + 3]; + if (a > 0 && (r + g + b) / 3 > 48) bright++; + } + return total > 0 ? (double)bright / total : 0; + } + if (rs.Format == PixelFormat.PFID_R8G8B8) + { + int bright = 0, total = rs.SourceData.Length / 3; + for (int i = 0; i + 3 <= rs.SourceData.Length; i += 3) + { + byte r = rs.SourceData[i]; + byte g = rs.SourceData[i + 1]; + byte b = rs.SourceData[i + 2]; + if ((r + g + b) / 3 > 48) bright++; + } + return total > 0 ? (double)bright / total : 0; + } + int nonZero = 0; + for (int i = 0; i < rs.SourceData.Length; i++) if (rs.SourceData[i] != 0) nonZero++; + return (double)nonZero / rs.SourceData.Length; +} diff --git a/tools/SkyObjectInspect/SkyObjectInspect.csproj b/tools/SkyObjectInspect/SkyObjectInspect.csproj new file mode 100644 index 00000000..54b88ca7 --- /dev/null +++ b/tools/SkyObjectInspect/SkyObjectInspect.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + SkyObjectInspect + + + + + + +