# 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.
