# Phase C.1 — PES particle rendering (sky aurora + portals + smoke) **Status:** specced, not started. **Filed:** 2026-04-27 (handoff from sky/weather session, branch merged at f7c9e88). **Worktree:** to be created at `.worktrees/phase-c1-particles` on branch `feature/phase-c1-particles`. **2026-04-28 correction:** named-retail decompile disproves the sky-PES premise in this spec. `SkyDesc::GetSky` copies `default_pes_object` into `CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects` (`0x005073c0`), `GameSky::MakeObject` (`0x00506ee0`), and `GameSky::UseTime` (`0x005075b0`) never read it. C.1 remains valid as the generic PhysicsScript/particle renderer for real hooks, portals, smoke, etc., but per-SkyObject PES playback is debug-only and disabled by default. --- ## What you're building A retail-faithful **PES (Particle Effect Schedule)** rendering system. PES is retail's name for "scripted vertex-sprite emitter". The dat carries a PES file per effect ID (e.g. `0x33000453`) describing how particles spawn, advect, color-cycle, and expire. Retail attaches a PES to sky objects, motion-hook events, and portal swirls. Without it, several visible-in-retail effects look flat or absent in acdream: - The dynamic "aurora-like light play" the user sees in retail's Rainy/Cloudy sky is **not** a separate aurora system. It's PES particles attached to SkyObjects via the `pes_id` field of `CelestialPosition` (verbatim retail header at `acclient.h:35451`). See ISSUES.md #28 + #29. - Portal swirls (rotating black disks today — see roadmap "Open visual defects" → "Portals render as a rotating black disk"). - Chimney smoke + fireplace flames (`References/ACViewer/Physics/Particles/` for the visual model). - Spell cast effects (some are PES, some are setup-mesh-based). Phase **E.3** already shipped the **data layer**: `ParticleSystem`, `EmitterDescLoader`, `ParticleHookSink`, `PhysicsScriptRunner`, `VfxModel` in `src/AcDream.Core/Vfx/`. **C.1** is the visual half — the GL renderer plus SkyRenderer integration. --- ## Phase 0 — worktree setup ```bash git -C C:/Users/erikn/source/repos/acdream worktree add .worktrees/phase-c1-particles -b feature/phase-c1-particles main cd C:/Users/erikn/source/repos/acdream/.worktrees/phase-c1-particles dotnet build && dotnet test ``` Expected: green baseline before touching code. If red, stop and investigate (do not assume merge artifact). --- ## Step 0 — GREP NAMED FIRST Per `CLAUDE.md`'s "Development workflow" rule, before any AC-specific implementation step, grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method` name. For PES specifically, search: ```bash grep -nE "PhysicsScript::|PhysicsObj::PlayScript|PartArray::CreateSetup" \ docs/research/named-retail/acclient_2013_pseudo_c.txt | head -40 grep -nE "ParticleEmitter::|EmitterInfo::|EmitterDesc::|PESystem" \ docs/research/named-retail/acclient_2013_pseudo_c.txt | head -40 grep -nE "CreateParticleEmitter|DestroyParticleEmitter|RemoveParticleEmitter" \ docs/research/named-retail/acclient_2013_pseudo_c.txt | head -20 ``` Then check `docs/research/named-retail/acclient.h` for verbatim retail structs: - `EmitterInfo` (per-emitter description) - `EmitterDesc` (per-particle description: lifetime, color curve, motion) - `ParticleEmitter` (runtime instance) - `PhysicsScript` (parsed `0x34xxxxxx` PES file: list of "spawn this emitter at this time" entries) Once you have the named functions/structs, write a brief `docs/research/2026-04-XX-pes-pseudocode.md` translating the C decomp into clean pseudocode before porting (per CLAUDE.md "WRITE PSEUDOCODE" step). The sky/weather session repeatedly proved this catches misinterpretations before they become bugs. --- ## What we already have (Phase E.3 data-layer) Read these files first — they encode the dat schema and runtime model: - `src/AcDream.Core/Vfx/EmitterDescLoader.cs` — loads `EmitterInfo` / `EmitterDesc` from the dat. Confirm field set matches retail's `acclient.h` struct definitions. - `src/AcDream.Core/Vfx/ParticleSystem.cs` — runtime stepper. **Verify** the 13 motion-type integrators match retail's `PhysicsScript::MotionType` enum exactly. The roadmap claims they do. - `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` — schedules emitters per the parsed PES. This is the timeline driver. - `src/AcDream.Core/Vfx/ParticleHookSink.cs` — receives `CreateParticle` hook calls from MotionInterpreter / AnimationSequencer (Phase E.1). - `src/AcDream.Core/Vfx/VfxModel.cs` — the per-entity particle state. Tests for these are in `tests/AcDream.Core.Tests/Vfx/`. Run them first to confirm the data-layer baseline still passes after any refactor. **What is NOT yet there** (and is C.1's job): - A GL renderer that consumes `ParticleSystem.LiveParticles` and draws billboarded quads. - SkyRenderer integration so `CelestialPosition.pes_id` actually spawns a PES — currently `SkyDescLoader` drops the field on the floor (see `SkyObjectData` at `src/AcDream.Core/World/SkyDescLoader.cs:28-54`). - Per-entity emitter spawn for non-sky cases (chimneys, portals, spell effects). Probably a wiring exercise once the renderer exists. --- ## References (priority order) Per `CLAUDE.md`'s "Reference hierarchy by domain" — particle systems are client-side visual, so the priority is: 1. **`docs/research/named-retail/acclient_2013_pseudo_c.txt`** — the actual retail client, fully named. Beats every other reference. 2. **`docs/research/named-retail/acclient.h`** — verbatim retail struct definitions for `EmitterInfo`, `EmitterDesc`, `PhysicsScript`, `ParticleEmitter`, `CelestialPosition`. 3. **`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`** — exact Silk.NET stack match, has the GL-side billboarded-quad batcher we want to port. Check `ParticleEmitter` and rendering glue around it. 4. **`references/ACViewer/ACViewer/Physics/Particles/`** — MonoGame, has the visual algorithms. Use as cross-check on color cycle + motion math. 5. **`references/ACE/Source/`** — server-side, mostly irrelevant for client particles, but ACE's `EmitterDesc` structs can confirm field types. 6. **`references/AC2D/`** — older C++ AC client. Smaller scope, useful cross-check. 7. **`references/Chorizite.ACProtocol/`** — clean-room protocol library; PES is dat-side not protocol-side, so probably not relevant. When two references disagree: decomp wins. Always. --- ## What we already learned (data points the next session shouldn't re-derive) ### CelestialPosition struct (verbatim retail header `acclient.h:35451`) ```c struct CelestialPosition { IDClass<_tagDataID,32,0> gfx_id; IDClass<_tagDataID,32,0> pes_id; // ← particle scheduler ID float heading; float rotation; AC1Legacy::Vector3 tex_velocity; float transparent; float luminosity; float max_bright; unsigned int properties; }; ``` ### Dispatch path (named retail decomp) - `GameSky::CreateDeletePhysicsObjects` at offset `0x005073c0` (decomp ~269036) iterates `sky_obj_pos` and calls `MakeObject(gfx_id, ...)`. The current acdream port mirrors this for `gfx_id`. **The `pes_id` field is silently dropped.** Wire it. - `CPhysicsObj::InitPartArrayObject` at decomp ~280484 dispatches by type prefix — type 6 → direct GfxObj, type 7 → `CPartArray::CreateSetup`. This is the existing path C.1 needs to extend with PES-emitter spawn. - `CPartArray::CreateSetup` at decomp ~287490 → `SetSetupID` → `InitDefaults` (loads animations + scripts + physics-script-table). Note: the **physics-script-table** mentioned here is the PES dispatcher. ### Properties bit semantics (decomp 268704+ = `GameSky::Draw`) - bit `0x01` — post-scene placement (`after_sky_cell`). Acdream now honours this (commit `034a684`). Foreground rain & particle emitters with bit 0x01 set should render in the post-scene weather pass. - bit `0x02` — hidden when fog override is active. - bit `0x04` — only render if `LScape::weather_enabled`. Most PES-bearing rows have this set. - bit `0x08` — purpose unknown; seen on every PES-bearing entry. Worth decoding in C.1 for completeness. Likely "this object owns a PES" or "this is a transient effect". ### Specific PES IDs in Dereth (probe-confirmed) `tools/StarsProbe/Program.cs` already enumerates these. For Rainy DG3: | OI | Active window | Gfx | **PES** | Notes | |----|--------------|-----|---------|-------| | 5 | always | 0x02000714 | **0x330007DB** | low-rate background | | 7 | 0.03–0.19 | 0x02000BA6 | **0x33000453** | early morning rain | | 8 | 0.91–0.98 | 0x02000BA6 | **0x33000453** | late evening rain | | 11 | 0.025–0.030 | 0x02000588 | **0x33000428** | dawn flash | | 12 | 0.190–0.200 | 0x02000588 | **0x33000428** | morning end | | 13 | 0.030–0.190 | 0x02000589 | **0x3300042C** | morning | | 14 | 0.905–0.910 | 0x02000588 | **0x33000428** | dusk start | | 15 | 0.980–0.990 | 0x02000588 | **0x33000428** | dusk end | | 16 | 0.910–0.980 | 0x02000589 | **0x3300042C** | evening | | **17** | **0.270–0.910** | 0x02000589 | **0x3300042C** | **most of daytime — pick this for first visual test** | | 18 | 0.400–0.500 | 0x02000BA6 | 0x33000453 | midday burst | Use **`0x3300042C`** as the canonical "render this and confirm it shows up" target. It's active during normal daytime in any Rainy DayGroup; the user can compare side-by-side with retail at the same in-game time. ### Already-working sky pass The sky/weather session (commits `97fc1b5`..`e4bc6de`, all merged at `f7c9e88`) shipped: - retail-faithful `SunColor` / `AmbientColor` magnitude (`|sunVec|` formula) - bit-0x01 post-scene partition - Translucent-flag override on `FromSurfaceType` (cloud blend mode) - Setup-backed (`0x020xxxxx`) sky object loading via `SetupMesh.Flatten` - Sky fog + additive-fog-skip So when you launch with `ACDREAM_DAT_DIR=...`, the static sky meshes already render correctly. PES is the missing dynamic layer — adding it should be strictly additive on top of the existing visuals. --- ## Implementation outline Skeleton; the next session should fill in details after Step 0 grep + reading the existing E.3 code. ### C.1.0 — Decomp pseudocode + verify E.3 matches - Grep + read `PhysicsScript`, `EmitterDesc`, `EmitterInfo`, `ParticleEmitter` in the named decomp. - Translate to pseudocode in a `docs/research/2026-04-XX-pes-pseudocode.md` file. Cite line numbers. - Diff E.3's `EmitterDescLoader` field-by-field against the decomp. If E.3 is wrong, add a regression test before fixing. ### C.1.1 — `PesObjectId` capture in `SkyDescLoader` - `src/AcDream.Core/World/SkyDescLoader.cs:28-54` — extend `SkyObjectData` with `uint PesObjectId` (currently dropped). Set it from `CelestialPosition.pes_id` in the dat. - Tests: golden-value test that `0x3300042C` flows from a fixture region through to `SkyObjectData.PesObjectId`. ### C.1.2 — PES file decode (if E.3 doesn't already do it) - DatReaderWriter probably has a generated `PhysicsScript` reader. Confirm. If so, no work here. If not, implement decode + tests. ### C.1.3 — Emitter spawn in SkyRenderer - `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — when a SkyObject has `PesObjectId != 0`, request the `ParticleSystem` to spawn that PES attached to the SkyObject's celestial position. - Schedule per the SkyObject's `BeginTime` / `EndTime` window — only emit while the day fraction is inside the window. Mirrors retail's `GameSky::CreateDeletePhysicsObjects` activation logic. ### C.1.4 — Particle GL renderer This is the bulk of C.1. Follow `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` as the structural template — same Silk.NET stack, mature pattern. Key elements: - **Vertex sprite (billboard)**: 4 verts per particle, oriented to face the camera. Quad UVs from the particle's texture id + animation phase. - **Batched draw**: dynamic VBO; rebuild each frame from `ParticleSystem.LiveParticles`. Sort by Z when blend mode is alpha-blend. - **Color cycle**: per particle, interpolate the `EmitterDesc.ColorCurve` (RGBA over normalized lifetime) and modulate. Verify the curve evaluator matches retail's `EmitterDesc::GetColor(t)` — likely linear-interp between curve nodes. - **Blend modes**: PES particles use the same `TranslucencyKind` resolution as static meshes. `Additive` is by far the most common for sky particles. - **Depth handling**: depth test on, depth write **off** (translucent sprites shouldn't occlude each other or solid geometry). - **Per-pass split**: PES on sky objects with bit 0x01 set render in the post-scene pass; bit 0x01 clear render in the pre-scene pass with the dome. Reference shader pair: probably a single VS+FS for billboarded textured quads. Look at WorldBuilder's `Shaders/particle.vert/.frag` for prior art. ### C.1.5 — Wire other PES sources After sky particles work, do a sweep: - Portals — `WorldEntity` weenies of class `WeenieClassId.Portal` should spawn a portal-swirl PES. Replace the placeholder rotating-black-disk rendering. - Chimneys / fireplaces — `EnvCell.StaticObjects` for inn/cottage cells reference PES IDs. Confirm via probe. - Animation-hooked particles — `MotionInterpreter` already invokes `CreateParticle` hooks via `ParticleHookSink` (Phase E.1). Verify the hook reaches the new renderer. ### C.1.6 — Visual verification Per `CLAUDE.md`'s "Visual verification workflow": 1. Build green, tests green. 2. Launch live client (`ACDREAM_DAT_DIR=...`, `ACDREAM_LIVE=1`, etc.). 3. Stand at Holtburg outdoors during a Rainy DayGroup at midday-ish (DayGroup selection is LCG-deterministic from year+day_of_year — control by forcing a specific date if needed). 4. User confirms aurora-like light play matches retail in dual-client side-by-side comparison. Acceptance: ISSUES.md #28 closes; #29 likely closes too (the residual cloud density gap is hypothesised to roll into #28). --- ## Pitfalls to avoid (lessons from prior sessions) 1. **Do NOT guess the PES file format.** It has a header, a list of frames, each with an emitter-desc-id and start time. The exact byte layout is in the decomp — read it before writing the decode. 2. **Do NOT integrate via subagent without context.** The animation sequencer integration cost a 4-fix marathon when a subagent rewrote the transform pipeline. PES → renderer is similarly central; the subagent must read the existing Vfx/ + SkyRenderer/ before editing. 3. **Do NOT re-derive things probes already answered.** `tools/StarsProbe` has the full SkyObject + PES enumeration; just read its log. 4. **Do NOT skip the pseudocode step.** Write a pseudocode doc per C.1.0 before porting. 5. **Decomp wins all ties.** WorldBuilder's `ParticleBatcher` is a fine template for Silk.NET idioms but its PES interpretation is a port — if it disagrees with the decomp, the decomp is right. 6. **Translucent ≠ Additive ≠ AlphaBlend at the surface level.** The sky/weather session learned this the hard way. PES particles will hit the same `TranslucencyKindExtensions.FromSurfaceType` resolution; the fixes already merged (`375065b`) handle the `Translucent + ClipMap` override. Don't undo that. 7. **Bit `0x08` in SkyObject.Properties** is undecoded as of the merge. Worth grepping for `& 0x08` or `& 8` in the named decomp during C.1.0 to determine if it gates PES specifically. If yes, only spawn a PES when the bit is set. --- ## Critical files - `docs/research/named-retail/acclient_2013_pseudo_c.txt` — primary oracle. - `docs/research/named-retail/acclient.h` — verbatim retail structs. - `docs/research/named-retail/symbols.json` — symbol → address lookup. - `docs/ISSUES.md` — issues #28 (aurora root cause) and #29 (residual gap). - `docs/plans/2026-04-11-roadmap.md` — phase identifier (C.1). - `src/AcDream.Core/Vfx/` — Phase E.3 data-layer scaffolding. - `src/AcDream.Core/World/SkyDescLoader.cs:28-54` — `SkyObjectData`, needs `PesObjectId` capture. - `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky pass; needs particle emit + draw call. - `tools/StarsProbe/Program.cs` — probe with PES IDs already enumerated. - `tools/RainMeshProbe/Program.cs` — sky surface flag dump. - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` — Silk.NET particle batcher template. - `references/ACViewer/ACViewer/Physics/Particles/` — visual algorithm cross-check. --- ## Rules of engagement (per CLAUDE.md) - **Step 0 grep named first** before any AC-specific implementation. - **One hypothesis at a time.** No bundled fixes. If a fix fails, return to Phase 1 of `superpowers:systematic-debugging`, not Fix #2. - **Three failed fixes ⇒ stop and question the architecture**, not Fix #4. - **Visual verification is the only acceptance test that requires user input.** Everything else proceeds without confirmation. - **Drive autonomously** through full phases and across commit boundaries. Don't stop mid-phase for routine progress check-ins. - **Subagent policy**: default Sonnet for implementation chunks; Opus only for load-bearing quality review at phase boundaries. Provide each subagent with the full file paths it needs to read and the acceptance criteria. - **Commits go to the feature branch** until the phase ships, then merge to main with a `--no-ff` merge commit summarising the full phase.