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

376 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```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.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-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.