acdream/docs/plans/2026-04-30-sky-pes-port.md
Erik 3361641655 docs(plans): #36 sky-PES dispatch port plan + .gitignore for retail-debugger scratch
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>
2026-04-30 23:00:46 +02:00

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:

  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.