Ports retail's ParticleEmitterInfo / Particle::Init / Particle::Update
(0x005170d0..0x0051d400) and PhysicsScript runtime to a C# data-layer
plus a Silk.NET billboard renderer. Sky-PES path is debug-only behind
ACDREAM_ENABLE_SKY_PES because named-retail decomp confirms GameSky
copies SkyObject.pes_id but never reads it (CreateDeletePhysicsObjects
0x005073c0, MakeObject 0x00506ee0, UseTime 0x005075b0).
Post-review fixes folded into this commit:
H1: AttachLocal (is_parent_local=1) follows live parent each frame.
ParticleSystem.UpdateEmitterAnchor + ParticleHookSink.UpdateEntityAnchor
let the owning subsystem refresh AnchorPos every tick — matches
ParticleEmitter::UpdateParticles 0x0051d2d4 which re-reads the live
parent frame when is_parent_local != 0. Drops the renderer-side
cameraOffset hack that only worked when the parent was the camera.
H3: Strip the long stale comment in GfxObjMesh.cs that contradicted the
retail-faithful (1 - translucency) opacity formula. The code was
right; the comment was a leftover from an earlier hypothesis and
would have invited a wrong "fix".
M1: SkyRenderer tracks textures whose wrap mode it set to ClampToEdge
and restores them to Repeat at end-of-pass, so non-sky renderers
that share the GL handle can't silently inherit clamped wrap state.
M2: Post-scene Z-offset (-120m) only fires when the SkyObject is
weather-flagged AND bit 0x08 is clear, matching retail
GameSky::UpdatePosition 0x00506dd0. The old code applied it to
every post-scene object — a no-op today (every Dereth post-scene
entry happens to be weather-flagged) but a future post-scene-only
sun rim would have been pushed below the camera.
M4: ParticleSystem.EmitterDied event lets ParticleHookSink prune dead
handles from the per-entity tracking dictionaries, fixing a slow
leak where naturally-expired emitters' handles stayed in the
ConcurrentBag forever during long sessions.
M5: SkyPesEntityId moves the post-scene flag bit to 0x08000000 so it
can't ever overlap the object-index range. Synthetic IDs stay in
the reserved 0xFxxxxxxx space.
New tests (ParticleSystemTests + ParticleHookSinkTests):
- UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor
- UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin
- EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire
- Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed (retail-faithful
single-emit-per-frame behavior)
- UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor
- EmitterDied_PrunesPerEntityHandleTracking
dotnet build green, dotnet test green: 695 / 393 / 243 = 1331 passed
(up from 1325).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
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_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.