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