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:
parent
e4bc6de7ba
commit
1f82b7604e
1 changed files with 368 additions and 0 deletions
368
docs/plans/2026-04-27-phase-c1-pes-particles.md
Normal file
368
docs/plans/2026-04-27-phase-c1-pes-particles.md
Normal 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.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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue