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.
17 KiB
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_idfield ofCelestialPosition(verbatim retail header atacclient.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
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:
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(parsed0x34xxxxxxPES 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— loadsEmitterInfo/EmitterDescfrom the dat. Confirm field set matches retail'sacclient.hstruct definitions.src/AcDream.Core/Vfx/ParticleSystem.cs— runtime stepper. Verify the 13 motion-type integrators match retail'sPhysicsScript::MotionTypeenum 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— receivesCreateParticlehook 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.LiveParticlesand draws billboarded quads. - SkyRenderer integration so
CelestialPosition.pes_idactually spawns a PES — currentlySkyDescLoaderdrops the field on the floor (seeSkyObjectDataatsrc/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:
docs/research/named-retail/acclient_2013_pseudo_c.txt— the actual retail client, fully named. Beats every other reference.docs/research/named-retail/acclient.h— verbatim retail struct definitions forEmitterInfo,EmitterDesc,PhysicsScript,ParticleEmitter,CelestialPosition.references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs— exact Silk.NET stack match, has the GL-side billboarded-quad batcher we want to port. CheckParticleEmitterand rendering glue around it.references/ACViewer/ACViewer/Physics/Particles/— MonoGame, has the visual algorithms. Use as cross-check on color cycle + motion math.references/ACE/Source/— server-side, mostly irrelevant for client particles, but ACE'sEmitterDescstructs can confirm field types.references/AC2D/— older C++ AC client. Smaller scope, useful cross-check.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)
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::CreateDeletePhysicsObjectsat offset0x005073c0(decomp ~269036) iteratessky_obj_posand callsMakeObject(gfx_id, ...). The current acdream port mirrors this forgfx_id. Thepes_idfield is silently dropped. Wire it.CPhysicsObj::InitPartArrayObjectat 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::CreateSetupat 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 (commit034a684). 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 ifLScape::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/AmbientColormagnitude (|sunVec|formula) - bit-0x01 post-scene partition
- Translucent-flag override on
FromSurfaceType(cloud blend mode) - Setup-backed (
0x020xxxxx) sky object loading viaSetupMesh.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,ParticleEmitterin the named decomp. - Translate to pseudocode in a
docs/research/2026-04-XX-pes-pseudocode.mdfile. Cite line numbers. - Diff E.3's
EmitterDescLoaderfield-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— extendSkyObjectDatawithuint PesObjectId(currently dropped). Set it fromCelestialPosition.pes_idin the dat.- Tests: golden-value test that
0x3300042Cflows from a fixture region through toSkyObjectData.PesObjectId.
C.1.2 — PES file decode (if E.3 doesn't already do it)
- DatReaderWriter probably has a generated
PhysicsScriptreader. 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 hasPesObjectId != 0, request theParticleSystemto spawn that PES attached to the SkyObject's celestial position.- Schedule per the SkyObject's
BeginTime/EndTimewindow — only emit while the day fraction is inside the window. Mirrors retail'sGameSky::CreateDeletePhysicsObjectsactivation 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'sEmitterDesc::GetColor(t)— likely linear-interp between curve nodes. - Blend modes: PES particles use the same
TranslucencyKindresolution as static meshes.Additiveis 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 —
WorldEntityweenies of classWeenieClassId.Portalshould spawn a portal-swirl PES. Replace the placeholder rotating-black-disk rendering. - Chimneys / fireplaces —
EnvCell.StaticObjectsfor inn/cottage cells reference PES IDs. Confirm via probe. - Animation-hooked particles —
MotionInterpreteralready invokesCreateParticlehooks viaParticleHookSink(Phase E.1). Verify the hook reaches the new renderer.
C.1.6 — Visual verification
Per CLAUDE.md's "Visual verification workflow":
- Build green, tests green.
- Launch live client (
ACDREAM_DAT_DIR=...,ACDREAM_LIVE=1, etc.). - 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).
- 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)
- 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.
- 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.
- Do NOT re-derive things probes already answered.
tools/StarsProbehas the full SkyObject + PES enumeration; just read its log. - Do NOT skip the pseudocode step. Write a pseudocode doc per C.1.0 before porting.
- Decomp wins all ties. WorldBuilder's
ParticleBatcheris a fine template for Silk.NET idioms but its PES interpretation is a port — if it disagrees with the decomp, the decomp is right. - Translucent ≠ Additive ≠ AlphaBlend at the surface level. The
sky/weather session learned this the hard way. PES particles will hit
the same
TranslucencyKindExtensions.FromSurfaceTyperesolution; the fixes already merged (375065b) handle theTranslucent + ClipMapoverride. Don't undo that. - Bit
0x08in SkyObject.Properties is undecoded as of the merge. Worth grepping for& 0x08or& 8in 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, needsPesObjectIdcapture.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-ffmerge commit summarising the full phase.