# Plan — Sky-PES dispatch port (Issue #36) **Filed:** 2026-04-30 from a live cdb trace of retail acclient.exe. **Owner:** next session. **Closes:** #28 (aurora), #29 (cloud density), partially #2 (lightning). ## What we know (from the live trace, 24,576 GameSky::Draw frames) Retail's sky-PES dispatch chain runs as follows. All counts are from the cdb trace summarized in `memory/project_retail_debugger.md`: ``` GameSky::Draw = 24,576 (60Hz render rate) GameSky::UseTime = 12,288 (30Hz, MinQuantum gate) GameSky::CreateDeletePhysicsObjects = 12,288 (30Hz) CPhysicsObj::CallPES = 372 (~150/min) CallPESHook::Execute = 372 (1:1 with CallPES) CreateParticleHook::Execute = 62 (15 initial + 47 burst) CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook) ``` Three concrete findings: 1. **Persistent particle emitters on celestial / sky objects.** 15 are created at cell load. More are spawned dynamically on region / weather / time-of-day transitions (the trace caught a +47 burst on one such transition). 2. **Periodic PES dispatch drives existing emitters.** `CallPESHook::Execute` runs script-scheduled actions which call `CPhysicsObj::CallPES` 1:1. ~150 dispatches/min on average. 3. **Earlier research said "GameSky doesn't read pes_id" — true but misleading.** GameSky doesn't read it directly; the script-hook system does. `CelestialPosition.pes_id` (struct offset +0x004) is populated by `SkyDesc::GetSky` and consumed downstream by `CallPESHook` / `CreateParticleHook` invocations scheduled from region/weather handlers. ## Decomp anchors All addresses verified live against `refs/acclient.pdb`: | Function | Address | Role | |---|---|---| | `CallPESHook::Execute` | `0x00526e20` | Script-hook action that fires CallPES | | `CreateParticleHook::Execute` | `0x00526ec0` | Particle-creation hook | | `CPhysicsObj::CallPES` | `0x00511af0` | Top-level PES dispatch | | `CPhysicsObj::CallPESInternal` | `0x00511ac0` | Inner dispatch (never fires alone in trace — likely inlined) | | `CPhysicsObj::create_particle_emitter` | `0x0050f360` | Creates a new emitter | | `CPhysicsObj::create_blocking_particle_emitter` | `0x0050f3b0` | Creates a blocking emitter | | `CPhysicsObj::stop_particle_emitter` | `0x0050f420` | | | `CPhysicsObj::destroy_particle_emitter` | `0x0050f400` | | | `CPhysicsObj::ShouldDrawParticles` | `0x0050fe60` | | | `CPhysicsObj::makeParticleObject` | `0x00512640` | | | `CPartArray::CreateParticle` | `0x005194f0` | | | `CSetup::makeParticleSetup` | `0x005201f0` | | | `LongNIHash::add` | `0x005198c0` | Emitter registry insertion | | `GameSky::CreateDeletePhysicsObjects` | `0x005073c0` | Already partially decoded; entry point for sky-object lifecycle | | `GameSky::UseTime` | `0x005075b0` | Per-frame sky update (30Hz) | | `GameSky::Draw` | `0x00506ff0` | Sky render (60Hz) | | `SkyDesc::GetSky` | `0x00501ec0` | Populates `CelestialPosition.pes_id` | `CelestialPosition` struct layout: ``` +0x000 gfx_id : 4 bytes +0x004 pes_id : 4 bytes ← THIS is what gets PES-driven +0x008 heading : float +0x00c rotation : float +0x010 tex_velocity : Vector3 +0x01c transparent : float +0x020 luminosity : float +0x024 max_bright : float +0x028 properties : 4 bytes ``` ## Phase plan ### Phase M.1 — Decomp dive (no code changes) Read these functions in order. Save findings to `docs/research/2026-04-30-sky-pes-pseudocode.md`: 1. **`CallPESHook::Execute`** at `0x00526e20`. What state does it read? What does it call? Answer: probably "look up the target CPhysicsObj by some ID, call CallPES on it." Confirm. 2. **`CPhysicsObj::CallPES`** at `0x00511af0`. What does it do? Probably: "look up or load the PES file referenced by `pes_id`, start running its script timeline." Find the script-evaluation loop and where it dispatches `CallPESHook` events. 3. **`CreateParticleHook::Execute`** at `0x00526ec0`. When this hook fires, what does it create? What CPhysicsObj does it attach the emitter to? Probably calls `CPhysicsObj::create_particle_emitter`. 4. **`CPhysicsObj::create_particle_emitter`** at `0x0050f360`. What does it instantiate? What goes into the `LongNIHash`? 5. **`GameSky::CreateDeletePhysicsObjects`** at `0x005073c0`. The prior research said this doesn't read pes_id. Confirm — but ALSO check: does it set up the script-hook timeline somewhere? Or does that happen in a separate caller? 6. **The dynamic-emitter-spawn trigger.** The trace caught a +47 burst — find what fires CreateParticleHook on region / weather / time-of-day transitions. Likely candidates: - `LScape` weather handler - `CDayCycle` / `CWorldFog` region handler - Cell-load or cell-cross handler ### Phase M.2 — Verify with detailed cdb trace (one focused session) After M.1 reveals the wiring, attach cdb to retail and capture: - `this` pointer + `pes_id` arg on every `CPhysicsObj::CallPES` - GfxObj being attached on every `create_particle_emitter` - Stack walk at `CallPESHook::Execute` to confirm the caller chain - Watch for the dynamic +N burst — what global state changed at that frame? The data should match the M.1 decomp predictions. If it diverges, the decomp interpretation needs another pass. ### Phase M.3 — Implementation (acdream port) 1. **Persistent emitter creation at cell load.** When a sky-bearing landblock loads, walk SkyDesc / CelestialPosition entries; for each entry with non-zero `pes_id`, instantiate a ParticleEmitter on the corresponding sky CPhysicsObj. 2. **Dynamic emitter spawn on transitions.** Hook into our region / weather / day-cycle change events; replicate retail's dispatch. 3. **PES script-timeline driver.** Port the scheduler that fires `CallPESHook` events at script-defined moments. May reuse `references/holtburger` if there's a Rust port. If not, port from decomp directly. 4. **Particle-system rendering wire-up.** acdream already has a particle system (R3 era). Verify it can accept emitter spawns from this path. If so, just wire. If not, identify the gap. 5. **Surface 0x08000023 / cloud GfxObjs.** Once dynamic emitters spawn, #29's "clouds too thin" should resolve naturally. ### Phase M.4 — Live verification 1. Retail + acdream side-by-side. Aurora moment (Rainy DayGroup, dusk/dawn). Compare visual. 2. Cloudy moment — clouds should look as dense as retail. 3. Storm moment — lightning flashes (covers part of #2). 4. Run another cdb trace; counts should match retail's counts within ~10%. ## Acceptance - Aurora visible in acdream at the same in-game moments retail shows it. - Cloud sheets look as dense / purple as retail. - Storm flash visible during Rainy storm windows (part of #2). - New cdb trace shows similar PES dispatch rate (~150/min) and similar emitter spawn pattern (initial population + transition bursts). - Closes #28, #29. Updates #2 with the storm-flash story. ## What this doesn't fix - **#4 horizon-glow** is a separate issue (fog parameters, not particles). Tackle that in a different session — different code path, different cdb trace. - **Lightning timing / thunder** (the audio half of #2) is separate; needs the audio system wired. ## How to start The next session, having read CLAUDE.md (auto-loaded) and `memory/project_retail_debugger.md` (auto-loaded), should: 1. Read this plan top to bottom (~5 minutes). 2. Begin Phase M.1 decomp dive — no code yet, just understand the wiring. Save findings to a research doc. 3. After M.1 lands, decide whether M.2 (verify with cdb) is needed before M.3 (implement). Often the decomp alone is enough; M.2 is for resolving ambiguity. The cdb tooling is ready (CLAUDE.md "Retail debugger toolchain"). The user can launch retail with `C:\Turbine\Asheron's Call\acclient.exe` on demand.