diff --git a/README.md b/README.md index 47a53307..3f2e1a15 100644 --- a/README.md +++ b/README.md @@ -1,173 +1,22 @@ # acdream -A modern open-source C# / .NET 10 Asheron's Call client. +Experimental modern open-source Asheron's Call client in C# / .NET 10. -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. +**Status:** pre-alpha, not playable. Phase 0 only — dat file asset inventory. -**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. +**Stack:** .NET 10, [Chorizite.DatReaderWriter](https://github.com/Chorizite/DatReaderWriter) for dat parsing. Silk.NET + Avalonia planned for rendering/UI (not yet wired up). -## Stack +**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. -- **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 +## Layout -## What works +- `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. -- 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): +## Run ``` dotnet run --project src/AcDream.Cli -- "C:\path\to\Asheron's Call" ``` -## 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. +Or set `ACDREAM_DAT_DIR` and run without args. diff --git a/docs/plans/2026-04-23-sky-weather-lightning-port.md b/docs/plans/2026-04-23-sky-weather-lightning-port.md deleted file mode 100644 index b4f885b4..00000000 --- a/docs/plans/2026-04-23-sky-weather-lightning-port.md +++ /dev/null @@ -1,136 +0,0 @@ -# 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-lightning-crossfade.md b/docs/research/2026-04-23-lightning-crossfade.md deleted file mode 100644 index 23eedd87..00000000 --- a/docs/research/2026-04-23-lightning-crossfade.md +++ /dev/null @@ -1,438 +0,0 @@ -# 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-lightning-real.md b/docs/research/2026-04-23-lightning-real.md deleted file mode 100644 index 8b5cc62a..00000000 --- a/docs/research/2026-04-23-lightning-real.md +++ /dev/null @@ -1,398 +0,0 @@ -# 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-physicsscript.md b/docs/research/2026-04-23-physicsscript.md deleted file mode 100644 index b258ec7e..00000000 --- a/docs/research/2026-04-23-physicsscript.md +++ /dev/null @@ -1,502 +0,0 @@ -# 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-decompile-hunt-B.md b/docs/research/2026-04-23-sky-decompile-hunt-B.md index cda6556d..890e743f 100644 --- a/docs/research/2026-04-23-sky-decompile-hunt-B.md +++ b/docs/research/2026-04-23-sky-decompile-hunt-B.md @@ -4,13 +4,6 @@ **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 c8792394..914e5ece 100644 --- a/docs/research/2026-04-23-sky-decompile-hunt-C.md +++ b/docs/research/2026-04-23-sky-decompile-hunt-C.md @@ -9,31 +9,6 @@ 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-23-sky-fog.md b/docs/research/2026-04-23-sky-fog.md deleted file mode 100644 index d1fd2345..00000000 --- a/docs/research/2026-04-23-sky-fog.md +++ /dev/null @@ -1,335 +0,0 @@ -# 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-material-state.md b/docs/research/2026-04-23-sky-material-state.md deleted file mode 100644 index 15b5fab0..00000000 --- a/docs/research/2026-04-23-sky-material-state.md +++ /dev/null @@ -1,441 +0,0 @@ -# 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) diff --git a/docs/research/2026-04-23-sky-pes-wiring.md b/docs/research/2026-04-23-sky-pes-wiring.md deleted file mode 100644 index a422f55b..00000000 --- a/docs/research/2026-04-23-sky-pes-wiring.md +++ /dev/null @@ -1,184 +0,0 @@ -# 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/docs/research/2026-04-24-lambert-brightness-split.md b/docs/research/2026-04-24-lambert-brightness-split.md deleted file mode 100644 index dc4f5625..00000000 --- a/docs/research/2026-04-24-lambert-brightness-split.md +++ /dev/null @@ -1,166 +0,0 @@ -# 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/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 44359db0..99dd2bdb 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -140,10 +140,6 @@ 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 @@ -831,12 +827,6 @@ 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. @@ -1074,24 +1064,6 @@ 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 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. @@ -2250,93 +2222,6 @@ 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 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 @@ -3717,11 +3602,6 @@ 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.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index 4ddfbded..37e74014 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -1,33 +1,29 @@ #version 430 core -// Sky mesh fragment shader — final composite matching retail's -// D3D fixed-function: +// Sky mesh fragment shader — UNLIT texture passthrough modulated by the +// per-keyframe SkyObjectReplace.Luminosity and .Transparent overrides. // -// fragment.rgb = texture.rgb × vTint × uLuminosity + lightning_flash -// fragment.a = texture.a × (1 - uTransparency) +// fragment.rgb = texture.rgb * uLuminosity + lightning_flash +// fragment.a = texture.a * (1 - uTransparency) // -// 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. +// 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). // -// 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`. +// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 + Phase 3b +// rationale in sky.vert. -in vec2 vTex; -in vec3 vTint; -in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far) +in vec2 vTex; out vec4 fragColor; uniform sampler2D uDiffuse; -uniform float uTransparency; // 0 = fully visible, 1 = fully transparent -uniform float uLuminosity; // SkyObjectReplace.Luminosity override (0..1) +uniform float uTransparency; +uniform float uLuminosity; -// Shared SceneLighting UBO — fog params drive the mix, flash channel -// bumps sky brightness during lightning strikes. Matches sky.vert's -// declaration exactly. +// Shared SceneLighting UBO — only fog-flash channel used (lightning). struct Light { vec4 posAndKind; vec4 dirAndRange; @@ -45,38 +41,14 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // 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; + // Unlit passthrough with per-keyframe dim. + vec3 rgb = sampled.rgb * uLuminosity; - // 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 - // 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). + // Lightning additive bump (client-driven during storm keyframes). float flash = uFogParams.z; rgb += flash * vec3(1.5, 1.5, 1.8); - // 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)); + float cap = mix(1.2, 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 1a2427f7..1d26ffd2 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -1,35 +1,26 @@ #version 430 core -// Sky mesh vertex shader — retail-verbatim D3D fixed-function lighting -// ported to per-vertex GLSL. Evidence trail: +// Sky mesh vertex shader — UNLIT texture passthrough. // -// 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) +// 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. // -// 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. +// 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. // -// 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. +// See `docs/research/2026-04-23-sky-retail-verbatim.md` §6 for the +// surviving hypotheses and the Phase 3b decision rationale. // -// 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. +// 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. layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; @@ -40,76 +31,16 @@ uniform mat4 uSkyView; uniform mat4 uSkyProjection; uniform vec2 uUvScroll; -// 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): +// 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; 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; 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). - // 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); - 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); - - // 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); } diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index 11e691d9..433ab87b 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -39,19 +39,10 @@ out vec4 vRoad0; out vec4 vRoad1; flat out float vBaseTexIdx; -// 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; +// 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; // 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 86c8d7f3..31ae73b2 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -205,18 +205,14 @@ public sealed unsafe class SkyRenderer : IDisposable else _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - // 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); + // 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); 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 e59a2559..c176fc8f 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -46,51 +46,11 @@ 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/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 9e854564..e4d96494 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -109,56 +109,6 @@ 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 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 @@ -598,35 +548,6 @@ 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]` - // 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 diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs deleted file mode 100644 index f50f740b..00000000 --- a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs +++ /dev/null @@ -1,279 +0,0 @@ -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/src/AcDream.Core/World/WeatherState.cs b/src/AcDream.Core/World/WeatherState.cs index 51219fd9..5f421fd6 100644 --- a/src/AcDream.Core/World/WeatherState.cs +++ b/src/AcDream.Core/World/WeatherState.cs @@ -96,42 +96,47 @@ 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 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 + // 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 private const float FlashPeakHoldS = 0.05f; - private WeatherKind _kind = WeatherKind.Clear; + // 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 _previousKind = WeatherKind.Clear; + private float _transitionT; // 0..1 through the cross-fade private float _flashLevel; - private float _flashAge; + private float _flashAge; // seconds since last strike + private float _nextStrikeInS; private EnvironOverride _override; - private int _rolledDayIndex = int.MinValue; + private int _rolledDayIndex = int.MinValue; // unrolled == "pick one" - // Phase 3e — when GameWindow pushes the retail DayGroup name via - // SetKindFromDayGroupName, the internal RollKind hash is disabled. + // 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. private bool _externallyDriven; + private readonly Random _strikeJitter; + public WeatherSystem(Random? rng = null) { - // 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; + _strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u)); + _nextStrikeInS = 12f; } /// Current active weather. @@ -184,35 +189,11 @@ public sealed class WeatherSystem { if (string.IsNullOrWhiteSpace(name)) return WeatherKind.Clear; string lc = name.ToLowerInvariant(); - // 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") + // 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") || lc.Contains("overcast") || lc.Contains("dark") || lc.Contains("fog")) return WeatherKind.Overcast; @@ -227,19 +208,15 @@ public sealed class WeatherSystem /// public void Tick(double nowSeconds, int dayIndex, float dtSeconds) { - // 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. + // Cross-fade progression: transitionT advances toward 1 over + // TransitionSeconds. Capped; no further rollover. + if (_transitionT < 1f) + _transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds); - // Day changed → re-roll (fallback only — disabled when externally driven). + // 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. if (!_externallyDriven && dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue) @@ -249,9 +226,19 @@ public sealed class WeatherSystem if (newKind != _kind) BeginTransition(newKind); } - // Flash decay — 50ms hold then exponential decay (~200ms τ). - // Production never TriggerFlashes; this exists for tests that - // exercise the UBO channel. + // 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. if (_flashLevel > 0f) { _flashAge += dtSeconds; @@ -273,45 +260,40 @@ public sealed class WeatherSystem } /// - /// 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. - /// + /// 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. /// public AtmosphereSnapshot Snapshot(in SkyKeyframe kf) { - // Fog passthrough from the keyframe (retail semantics). - Vector3 fogColor = kf.FogColor; - float fogStart = kf.FogStart; - float fogEnd = kf.FogEnd; + // Cross-fade fog distance + color from previous-kind to new-kind. + var prev = FogForKind(_previousKind, kf); + var curr = FogForKind(_kind, kf); - // 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). + 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. 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, // informational - Intensity: 1f, // no per-Kind easing in retail + Kind: _kind, + Intensity: Math.Clamp(intensity, 0f, 1f), FogColor: fogColor, FogStart: fogStart, FogEnd: fogEnd, FogMode: kf.FogMode, - LightningFlash: _flashLevel, // 0 in production; TriggerFlash hook for tests + LightningFlash: _flashLevel, Override: _override); } @@ -323,6 +305,7 @@ public sealed class WeatherSystem { _previousKind = _kind; _kind = newKind; + _transitionT = 0f; } /// @@ -347,6 +330,23 @@ 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/Vfx/PhysicsScriptRunnerTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs deleted file mode 100644 index 0eafa2e7..00000000 --- a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs +++ /dev/null @@ -1,210 +0,0 @@ -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); - } -} diff --git a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs index 20d490b3..9623f4f0 100644 --- a/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs +++ b/tests/AcDream.Core.Tests/World/WeatherSystemTests.cs @@ -38,30 +38,19 @@ public sealed class WeatherSystemTests } [Fact] - public void Snapshot_AlwaysPassesKeyframeFog_RegardlessOfKind() + public void Transition_EasesAcrossTenSeconds() { - // 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. + // Force Storm, then Clear, sample snapshot fog distance mid-transition. + var sys = new WeatherSystem(); + sys.ForceWeather(WeatherKind.Storm); + sys.Tick(0, 1, 100f); // finalize + var kf = SkyStateProvider.Default().Interpolate(0.5f); + var stormFog = sys.Snapshot(in kf); + Assert.Equal(WeatherKind.Storm, stormFog.Kind); - 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); - } + // Snapshot should have a small fog end (storm fog is dense). + Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}"); } [Fact] @@ -112,26 +101,21 @@ public sealed class WeatherSystemTests } [Theory] - [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)] + [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)] public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected) { var sys = new WeatherSystem(); diff --git a/tools/RetailTimeProbe/Program.cs b/tools/RetailTimeProbe/Program.cs index ef792235..3259357c 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 + sky-lighting global block so we can compare -// against acdream's computed calendar / SkyKeyframe values. +// dump its TimeOfDay struct so we can compare against acdream's computed +// calendar values. // // Decompile provenance (docs/research/2026-04-23-sky-decompile-hunt-C.md // §4 and the daygroup-selection research): @@ -18,30 +18,6 @@ // 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 @@ -72,27 +48,6 @@ 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; @@ -100,51 +55,22 @@ internal static class Program private static int Main(string[] args) { // Retail's process name is "acclient" (.exe stripped by Process API). - // args[0] = process name OR "pid=NNNN" to target a specific pid. - string processName = "acclient"; - int? requestedPid = null; - foreach (var a in args) - { - if (a.StartsWith("pid=", StringComparison.OrdinalIgnoreCase) && - int.TryParse(a.Substring(4), out var pidParsed)) - requestedPid = pidParsed; - else - processName = a; - } + // Allow override from the command line just in case. + string processName = args.Length > 0 ? args[0] : "acclient"; - Process target; - if (requestedPid is int pid) + Console.WriteLine($"RetailTimeProbe — scanning for process \"{processName}\"..."); + Process[] procs = Process.GetProcessesByName(processName); + if (procs.Length == 0) { - 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.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 — probing the first)"); + + Process target = procs[0]; Console.WriteLine( $"pid={target.Id} name={target.ProcessName} start={target.StartTime:HH:mm:ss} " + $"mainmodule={target.MainModule?.FileName ?? ""}"); @@ -229,9 +155,6 @@ 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 @@ -240,103 +163,6 @@ 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 deleted file mode 100644 index b0cce69f..00000000 --- a/tools/SkyObjectInspect/Program.cs +++ /dev/null @@ -1,175 +0,0 @@ -// 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 deleted file mode 100644 index 54b88ca7..00000000 --- a/tools/SkyObjectInspect/SkyObjectInspect.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net10.0 - enable - enable - SkyObjectInspect - - - - - - -