# 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<ParticleEmitter>::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<ParticleEmitter>`?

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.
