Plan doc `docs/plans/2026-04-30-sky-pes-port.md` captures the full porting plan for #36 (sky-PES dispatch chain). Three phases: M.1 — decomp dive (no code yet) for CallPESHook::Execute, CPhysicsObj::CallPES, CreateParticleHook::Execute, GameSky::CreateDeletePhysicsObjects, and the dynamic-spawn trigger (region/weather/time-of-day handler). M.2 — optional cdb verification with detailed args (this pointer + pes_id + caller stack walk). M.3 — implementation: persistent emitter creation at cell load, dynamic spawn on transitions, PES script-timeline driver, particle-system render wire-up. M.4 — live side-by-side verification. Acceptance: aurora visible at right moments, clouds dense like retail, storm flashes during Rainy storm windows, PES dispatch rate matches retail's ~150/min. .gitignore extended to suppress per-session retail-debugger scratch files (cdb scripts, launch logs, analysis ps1 helpers). The canonical workflow lives in CLAUDE.md "Retail debugger toolchain"; session-specific traces should not pollute the repo. Closes #36 plan stage. Implementation work begins next session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
7.8 KiB
Markdown
187 lines
7.8 KiB
Markdown
# 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.
|