acdream/docs/plans/2026-04-27-phase-c1-pes-particles.md
Erik ec1bbb4f43 feat(vfx): Phase C.1 — PES particle renderer + post-review fixes
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>
2026-04-28 22:47:11 +02:00

17 KiB
Raw Permalink Blame History

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_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

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 (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)

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 → SetSetupIDInitDefaults (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-54SkyObjectData, 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.