docs(plans): Phase C.1 PES particle rendering — handoff spec

Self-contained spec for the next session: PES (Particle Effect
Schedule) renderer that produces retail's "aurora light play",
portal swirls, chimney smoke, fireplace flames in one
implementation. Rolls up ISSUES.md #28 (root-caused this session
to PES on CelestialPosition.pes_id) and likely #29 (residual
cloud density gap).

Picks up after sky/weather session (merged at f7c9e88). Phase
E.3 already shipped the data layer (ParticleSystem,
EmitterDescLoader, ParticleHookSink, PhysicsScriptRunner,
VfxModel in src/AcDream.Core/Vfx/). C.1 is the visual half:
SkyDescLoader PesObjectId capture, SkyRenderer emitter spawn,
billboarded-quad GL renderer following WorldBuilder's
ParticleBatcher pattern.

Spec includes Step 0 grep targets, references in priority order
(decomp first, ACME/WorldBuilder second), the Dereth Rainy
DayGroup PES enumeration from tools/StarsProbe (notably
0x3300042C active 0.27-0.91 = "render this and confirm" target),
implementation outline (C.1.0 through C.1.6), pitfalls from
prior sessions, and the worktree setup commands.

To kick off the next session, point it at this file.
This commit is contained in:
Erik 2026-04-28 10:11:44 +02:00
parent e4bc6de7ba
commit 1f82b7604e

View file

@ -0,0 +1,368 @@
# 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`.
---
## 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.030.19 | 0x02000BA6 | **0x33000453** | early morning rain |
| 8 | 0.910.98 | 0x02000BA6 | **0x33000453** | late evening rain |
| 11 | 0.0250.030 | 0x02000588 | **0x33000428** | dawn flash |
| 12 | 0.1900.200 | 0x02000588 | **0x33000428** | morning end |
| 13 | 0.0300.190 | 0x02000589 | **0x3300042C** | morning |
| 14 | 0.9050.910 | 0x02000588 | **0x33000428** | dusk start |
| 15 | 0.9800.990 | 0x02000588 | **0x33000428** | dusk end |
| 16 | 0.9100.980 | 0x02000589 | **0x3300042C** | evening |
| **17** | **0.2700.910** | 0x02000589 | **0x3300042C** | **most of daytime — pick this for first visual test** |
| 18 | 0.4000.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.