From 1f82b7604e8d49e7a2d8127fde3abac2c6614332 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 10:11:44 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20Phase=20C.1=20PES=20particle=20r?= =?UTF-8?q?endering=20=E2=80=94=20handoff=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../2026-04-27-phase-c1-pes-particles.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/plans/2026-04-27-phase-c1-pes-particles.md diff --git a/docs/plans/2026-04-27-phase-c1-pes-particles.md b/docs/plans/2026-04-27-phase-c1-pes-particles.md new file mode 100644 index 0000000..6251b57 --- /dev/null +++ b/docs/plans/2026-04-27-phase-c1-pes-particles.md @@ -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.