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