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>
7.8 KiB
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:
-
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).
-
Periodic PES dispatch drives existing emitters.
CallPESHook::Executeruns script-scheduled actions which callCPhysicsObj::CallPES1:1. ~150 dispatches/min on average. -
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 bySkyDesc::GetSkyand consumed downstream byCallPESHook/CreateParticleHookinvocations 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:
-
CallPESHook::Executeat0x00526e20. What state does it read? What does it call? Answer: probably "look up the target CPhysicsObj by some ID, call CallPES on it." Confirm. -
CPhysicsObj::CallPESat0x00511af0. What does it do? Probably: "look up or load the PES file referenced bypes_id, start running its script timeline." Find the script-evaluation loop and where it dispatchesCallPESHookevents. -
CreateParticleHook::Executeat0x00526ec0. When this hook fires, what does it create? What CPhysicsObj does it attach the emitter to? Probably callsCPhysicsObj::create_particle_emitter. -
CPhysicsObj::create_particle_emitterat0x0050f360. What does it instantiate? What goes into theLongNIHash<ParticleEmitter>? -
GameSky::CreateDeletePhysicsObjectsat0x005073c0. 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? -
The dynamic-emitter-spawn trigger. The trace caught a +47 burst — find what fires CreateParticleHook on region / weather / time-of-day transitions. Likely candidates:
LScapeweather handlerCDayCycle/CWorldFogregion 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:
thispointer +pes_idarg on everyCPhysicsObj::CallPES- GfxObj being attached on every
create_particle_emitter - Stack walk at
CallPESHook::Executeto 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)
-
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. -
Dynamic emitter spawn on transitions. Hook into our region / weather / day-cycle change events; replicate retail's dispatch.
-
PES script-timeline driver. Port the scheduler that fires
CallPESHookevents at script-defined moments. May reusereferences/holtburgerif there's a Rust port. If not, port from decomp directly. -
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.
-
Surface 0x08000023 / cloud GfxObjs. Once dynamic emitters spawn, #29's "clouds too thin" should resolve naturally.
Phase M.4 — Live verification
- Retail + acdream side-by-side. Aurora moment (Rainy DayGroup, dusk/dawn). Compare visual.
- Cloudy moment — clouds should look as dense as retail.
- Storm moment — lightning flashes (covers part of #2).
- 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:
- Read this plan top to bottom (~5 minutes).
- Begin Phase M.1 decomp dive — no code yet, just understand the wiring. Save findings to a research doc.
- 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.