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_00452060 → FUN_00511800 → FUN_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_0051bed0 — play_script(scriptId)
+/// public API: resolves the dat id, allocates a script node, inserts
+/// into the owner PhysicsObj's linked list at +0x30.
+///
+/// - FUN_0051be40 — ScriptManager::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
+
+
+
+
+
+
+