acdream/docs/research/deepdives/r04-vfx-particles.md
Erik 3f913f1999 docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H
78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
2026-04-18 10:32:44 +02:00

1056 lines
45 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.

# R4 — VFX / Particle System Deep Dive
Research pass over the complete retail AC particle / VFX stack: dat layout,
aggregation types, simulation math, emitter attachment model, known effects,
rendering pipeline, and a concrete port plan for acdream (C# .NET 10 +
Silk.NET GL). Cross-referenced against five codebases:
- **Retail client decompilation** — `docs/research/decompiled/chunk_005A*.c`
through `chunk_006A*.c`. Symbols are mostly stripped (no
`particle_emitter` strings survive in symbol form); references confirm via
UI hook ("`Particles Rendered`", "`Particle Systems`" in
`chunk_005D0000.c:8723-8728`).
- **DatReaderWriter** — source of truth for the on-disk layout.
`DBObjs/ParticleEmitter.generated.cs`,
`DBObjs/ParticleEmitterInfo.generated.cs`,
`DBObjs/PhysicsScript.generated.cs`,
`DBObjs/PhysicsScriptTable.generated.cs`,
`Enums/ParticleType.generated.cs`,
`Enums/EmitterType.generated.cs`,
`Enums/PlayScript.generated.cs`, and the animation-hook types.
- **ACE server** — `ACE.Server/Physics/Particles/*.cs` is the direct
ACE-to-retail port of the simulation loop. Nearly identical shape to
decompiled code.
- **ACViewer** — `Physics/Particles/*.cs` plus the MonoGame render pipeline
in `Render/ParticleBatch.cs`, `Render/ParticleBatchDraw.cs`,
`Render/ParticleTextureFormat.cs`, `Model/ParticleDeclaration.cs`, and
the HLSL `Content/texture.fx` `PointSprite` technique.
- **WorldBuilder** — our exact Silk.NET stack. Complete GL port at
`Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs`,
`Lib/ParticleEmitterRenderer.cs`, shaders at
`Chorizite.OpenGLSDLBackend/Shaders/Particle.vert` and `Particle.frag`.
- **WorldBuilder-ACME-Edition** — the most refined, client-faithful CPU
simulator:
`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs`. This is the
drop-in reference for acdream's simulation side.
---
## 1. `ParticleEmitterInfo` dat layout
The particle system has **two co-existing dat record classes**:
| Type | DB const | DID range | Role |
|------|----------|-----------|------|
| `ParticleEmitter` | `DB_TYPE_PARTICLE_EMITTER` | `0x32000000 0x3200FFFF` | Named "alias" wrapper |
| `ParticleEmitterInfo` | `DB_TYPE_PARTICLE_EMITTER` | (same range) | Equivalent struct (alias) |
| `PhysicsScript` | `DB_TYPE_PHYSICS_SCRIPT` | `0x33000000 0x3300FFFF` | Timeline of hooks |
| `PhysicsScriptTable` | `DB_TYPE_PHYSICS_SCRIPT_TABLE` | `0x34000000 0x3400FFFF` | `PlayScript → {Mod,ScriptID}[]` |
DatReaderWriter generates two classes for `0x32*` because the retail client
exposes both spellings (`ParticleEmitter` and `ParticleEmitterInfo`) with
byte-identical layouts. ACE's DatLoader picks `ParticleEmitterInfo` as the
canonical class and that's what most of the rest of the code keys on.
### Byte-exact unpack order
From `DatReaderWriter/Generated/DBObjs/ParticleEmitter.generated.cs:175-208`:
```
uint Id (inherited DBObj header)
uint Unknown // zeroed in every sample, pad or debug flag
int EmitterType // see enum below
int ParticleType // see enum below
uint GfxObjId // software-path mesh (palette-indexed billboard)
uint HwGfxObjId // hardware-path mesh (DXT/BGRA billboard). USE THIS.
double Birthrate // seconds between spawns (BirthratePerSec) OR meters (BirthratePerMeter)
int MaxParticles // concurrent cap
int InitialParticles // spawn-at-creation count
int TotalParticles // lifetime cap (0 = unlimited)
double TotalSeconds // emitter shutdown (0 = infinite)
double Lifespan // base particle lifetime seconds
double LifespanRand // ± random jitter on lifespan
Vector3 OffsetDir // spawn disk axis (see GetRandomOffset below)
float MinOffset // min radial distance along offset disk
float MaxOffset // max radial distance
Vector3 A // base vector A (usually initial velocity)
float MinA / MaxA // magnitude multiplier range for A
Vector3 B // base vector B (usually acceleration)
float MinB / MaxB
Vector3 C // base vector C (usually angular velocity or swirl radius)
float MinC / MaxC
float StartScale
float FinalScale
float ScaleRand
float StartTrans // 0 = opaque, 1 = invisible (transparency, not alpha)
float FinalTrans
float TransRand
bool IsParentLocal // stream particles in parent space vs world (MSVC writes 4 bytes; reader tolerates either)
```
### EmitterType enum — emission cadence
`Enums/EmitterType.generated.cs` (and ACE's mirror
`ACE.Entity/Enum/EmitterType.cs`):
| Value | Name | Behavior |
|-------|------|----------|
| 0 | Unknown | Treated as BirthratePerSec by ACE + WorldBuilder fallbacks |
| 1 | `BirthratePerSec` | Emit when `now - lastEmitTime > Birthrate` (seconds) |
| 2 | `BirthratePerMeter` | Emit when `|parentPos - lastEmitPos|² > 0` (meter-based for tracer trails) |
### ParticleType enum — motion integrator
`Enums/ParticleType.generated.cs`:
| Value | Name | Meaning |
|-------|------|---------|
| 0 | Unknown | Zero motion (Still fallback) |
| 1 | `Still` | Position = parent + offset, no drift |
| 2 | `LocalVelocity` | `A` is baked into parent space at spawn (swims with parent rotation) |
| 3 | `ParabolicLVGA` | Local velocity + **Global** Acceleration (e.g. gravity) |
| 4 | `ParabolicLVGAGR` | LVGA + Global Rotation (angular velocity applies in world frame) |
| 5 | `Swarm` | Sinusoidal bees pattern: `cos/sin(B·t) * C + A·t` drift |
| 6 | `Explode` | One-shot detonation: randomized direction on unit sphere, scaled by `C` |
| 7 | `Implode` | Particles home in: `cos(A.x·t)·C + t²·B` with C bound to offset |
| 8 | `ParabolicLVLA` | Local velocity + Local Acceleration (both rotate with parent) |
| 9 | `ParabolicLVLALR` | LVLA + Local Rotation |
| 10 | `ParabolicGVGA` | Global velocity + Global Acceleration (pure projectile) |
| 11 | `ParabolicGVGAGR` | GVGA + Global Rotation |
| 12 | `GlobalVelocity` | Position = parent + offset + A·t, A in world frame |
| 13 | NumParticleType | Terminator |
Mnemonic: **L**ocal **V**elocity means A is multiplied by the parent's
rotation at spawn time and "frozen." **G**lobal means the vector is already
in world space and never rotated. **LA**/**GA** distinguishes acceleration
vector B. **GR**/**LR** suffix means the **C** vector is an angular velocity
that rotates the billboard/mesh orientation over time.
### Spawn-volume shape
`GetRandomOffset()` (ACE `ParticleEmitterInfo.cs:173-187`, matches
`AcParticleEmitterSimulator.GetRandomOffset` at
`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447`):
```
rng = Vector3(rand[-1,1], rand[-1,1], rand[-1,1])
perp = rng - OffsetDir * dot(OffsetDir, rng) // project rng to plane ⊥ OffsetDir
if |perp| < ε: return Vector3.Zero // degenerate case
perp = normalize(perp)
magnitude = MinOffset + rand[0,1] * (MaxOffset - MinOffset)
return perp * magnitude
```
**Geometric meaning**: particles spawn on a **disk annulus** whose plane is
perpendicular to `OffsetDir`, with inner radius `MinOffset` and outer
radius `MaxOffset`. This is the one and only spawn volume shape in AC —
there are no cones, boxes, or sphere-volume emitters at the data level.
Sphere-like emission (omnidirectional puff) is achieved by picking a near-
zero `OffsetDir` and relying on the random vector; truly spherical bursts
use `ParticleType.Explode` which re-rolls a random unit vector per particle.
### Color over time
**AC has no per-particle color field.** All color comes from the texture on
the `GfxObj` (mesh or point-sprite billboard). Over-time visual variation is
expressed via:
- `StartScale``FinalScale` — linear interpolation over lifespan
- `StartTrans``FinalTrans` — linear interpolation (1 = invisible)
- Texture UV flipbook if the `GfxObj` contains a multi-frame surface
Where retail effects need a color shift (e.g., blue spell-aura) they use a
different texture/`GfxObj`, not a per-frame tint. This is why every spell
school has its own dat chain.
---
## 2. `EmitterInfo` aggregation — `PhysicsScript`/`PhysicsScriptTable`
A single `ParticleEmitter` dat record defines ONE emitter. Real effects
almost always combine several emitters into a "script" timeline.
### PhysicsScript — one effect's timeline
`DatReaderWriter/Generated/DBObjs/PhysicsScript.generated.cs`:
```
PhysicsScript {
List<PhysicsScriptData> ScriptData
}
PhysicsScriptData {
double StartTime // offset into the script when this hook fires
AnimationHook Hook // tagged union (polymorphic)
}
```
`AnimationHook` subclasses that concern VFX:
| Hook | Fields | Role |
|------|--------|------|
| `CreateParticleHook` | `EmitterInfoId`, `PartIndex`, `Offset (Frame)`, `EmitterId` | Spawn a named emitter bound to a body part |
| `CreateBlockingParticleHook` | — | Identical to CreateParticle but blocks motion-table advance until the emitter retires (used to gate spell cast sequences) |
| `StopParticleHook` | `EmitterId` | Mark emitter stopped (no new spawns, existing particles finish) |
| `DestroyParticleHook` | `EmitterId` | Instantly despawn all remaining particles |
| `DefaultScriptHook` | — | Used inside animation tables; implicit |
The `EmitterId` field is **the effect-local ID** (a small int, often 116),
NOT the dat file ID. Multiple hooks with the same `EmitterId` address the
same live emitter: you create with id 5, then at `StartTime=1.2s` you
stop id 5, then at `StartTime=2.0s` you destroy id 5. This lets scripts
emit, pause, re-emit, and despawn trails over time.
### PhysicsScriptTable — `PlayScript` multi-variant dispatch
`DBObjs/PhysicsScriptTable.generated.cs`:
```
PhysicsScriptTable {
Dictionary<PlayScript, PhysicsScriptTableData> ScriptTable
}
PhysicsScriptTableData {
List<ScriptAndModData> Scripts // sorted by Mod descending
}
ScriptAndModData {
float Mod // intensity threshold (0.01.0 typically)
QualifiedDataId<PhysicsScript> ScriptId // DID into 0x33000000 range
}
```
The server sends `GameMessageScript(objectGuid, PlayScript, mod)` to clients.
The client picks the `Scripts` entry whose `Mod` is the largest value ≤ the
incoming `mod`. That's how a Frost I missile and a Frost VII missile can
share a `PlayScript.Launch` entry but show different-intensity effects: the
spell sets mod=0.3 vs mod=0.95, and the table picks the matching
`PhysicsScript` DID. This is the dispatch lookup done by
`ParticleViewer.GetModIdx()` (`ACViewer/ParticleViewer.cs:46-53`).
### PlayScript enum — the well-known effect namespace
`DatReaderWriter/Generated/Enums/PlayScript.generated.cs` has **174 entries**.
Catalog by category (IDs in hex):
**Generic** (0x010x05):
- `Test1/2/3`, `Launch`, `Explode`
**Attribute buffs/debuffs** (0x060x11): six colors × up/down
- `AttribUpRed`, `AttribDownRed`, …, `AttribDownPurple`
**Skill buffs/debuffs** (0x120x1E): six colors × up/down + `SkillDownBlack`
- `SkillUpRed``SkillDownPurple`
**Vital buffs/debuffs** (0x1F0x2A): Health / Regen, 3 colors × up/down
- `HealthUpRed`, `HealthDownRed`, `HealthUpBlue`, `HealthDownBlue`,
`HealthUpYellow`, `HealthDownYellow`, `RegenUp*`, `RegenDown*`
**Shield / Enchant / Swap** (0x2B0x50): all six colors each
- `ShieldUp*`, `ShieldDown*`, `EnchantUp*`, `EnchantDown*`,
`SwapHealth_*_To_*` (stamina/mana transfers), `TransUp/DownWhite/Black`
**Spells** (0x510x57):
- `Fizzle`, `PortalEntry`, `PortalExit`, `BreatheFlame`, `BreatheFrost`,
`BreatheAcid`, `BreatheLightning`
**Lifecycle** (0x580x5A):
- `Create`, `Destroy`, `ProjectileCollision`
**Combat splatters** (0x5B0x72): 12 splatter + 12 spark directions
- `SplatterLow/Mid/UpLeft/RightBack/Front`,
`SparkLow/Mid/UpLeft/RightBack/Front`
**Environment** (0x73):
- `PortalStorm`
**Visibility** (0x740x77):
- `Hide`, `UnHide`, `Hidden`, `DisappearDestroy`
**Special states** (0x780x81): 10 state slots
- `SpecialState0``SpecialState9`
**Special state colors** (0x820x89): 8 colors
- `SpecialStateRed``SpecialStateBlack`
**Progression** (0x8A):
- `LevelUp`
**Enchant variants** (0x8B0x8F):
- `EnchantUp/DownGrey`, `WeddingBliss`, `EnchantUp/DownWhite`
**Social / GM** (0x900x97):
- `CampingMastery`, `CampingIneptitude`, `DispelLife`, `DispelCreature`,
`DispelAll`, `BunnySmite`, `BaelZharonSmite`, `WeddingSteele`
**Restriction / Aug** (0x980xAD):
- `RestrictionEffect{Blue,Green,Gold}`, `LayingofHands`,
`AugmentationUse{Attribute,Skill,Resistances,Other}`, `BlackMadness`,
`AetheriaLevelUp`, `AetheriaSurge*` (5 types), `*DownVoid` (nether
damage), `DirtyFighting*` (4 debuffs)
The scripts in every PhysicsScriptTable are what drive the user-facing
effect vocabulary. An acdream PhysicsScript interpreter must implement
these 174 effect IDs at minimum — but the interpretation is generic
(walk the `ScriptData` list, execute each hook) so we only write the
dispatch once.
---
## 3. Billboard vs 3D particle types
Every particle is rendered as a small `GfxObj`. The `GfxObj` dictates whether
the particle is camera-facing (billboard) or a fixed 3D mesh.
### Heuristic — ACViewer's rule
`ACViewer/ParticleViewer.cs:167-173`:
```
bool isPointSprite = gfxObj._gfxObj.SortCenter.NearZero()
&& gfxObj._gfxObj.Id != 0x0100283B;
```
If the GfxObj's `SortCenter` is ~zero (the mesh is a centered single-quad /
single-tri) AND it's not the specific exempt GfxObj, treat it as a point
sprite: build a unit quad at the particle position and face-camera it in
the vertex shader.
Otherwise, use the full mesh. Parabolic*GR particle types deliberately
use full meshes (swords, arrows, sparks) because they carry visible
orientation.
### WorldBuilder's refinement
`Chorizite.OpenGLSDLBackend/Lib/ParticleEmitterRenderer.cs:80-88`:
```
bool isPointSprite = (_gfxRenderData == null);
if (_gfxRenderData != null) {
var degradeId = _gfxRenderData.DIDDegrade;
if (degradeId != 0) {
if (dats.Portal.TryGet<GfxObjDegradeInfo>(degradeId, out var degrades)
&& degrades.Degrades.Count > 0) {
_isPointSprite = degrades.Degrades[0].DegradeMode == 2;
}
}
}
```
More robust: an attached `GfxObjDegradeInfo` with `DegradeMode == 2` flags
the emitter's mesh as a sprite target. ACME/WorldBuilder use this; we
should too.
### Render split
- **Billboards**: one shared quad VBO, per-particle instance of
`(worldPos, scale, opacity, textureIndex, size)`; vertex shader expands
to a camera-facing quad.
- **3D mesh particles**: per-particle full model matrix
`(translate ⋅ rotate ⋅ scale)`; standard mesh pipeline but batched into
the particle draw-call group.
Particle meshes in practice are always either a 1-quad billboard or a
2-quad "cross" (two perpendicular billboards for volumetric smoke/fire).
No particle is an animated skeletal mesh.
---
## 4. Alpha blending modes
`ACViewer.Render.ParticleTextureFormat` (`Render/ParticleTextureFormat.cs:22`):
```
IsAdditive = surfaceType.HasFlag(SurfaceType.Additive);
```
The `SurfaceType` flag on the `Surface` struct of the particle's `GfxObj`
is the discriminator. There are exactly **two** blend modes used:
| SurfaceType flag | Render mode | Used for |
|------------------|-------------|----------|
| `Additive` | `SrcAlpha + One` (GL: `SrcAlpha, One`) | Fire, spell glows, sparks, spell swirls |
| (default) | `SrcAlpha + OneMinusSrcAlpha` | Smoke, splatters, portal sweep, realistic fog |
WorldBuilder confirms (`ParticleBatcher.cs:179-185`):
```
if (_currentIsAdditive) gl.BlendFunc(SrcAlpha, One);
else gl.BlendFunc(SrcAlpha, OneMinusSrcAlpha);
```
**Pre-multiplied alpha is not used** — AC textures are straight RGBA
(BGRA in memory). The HLSL `PointSprite` shader in ACViewer samples direct,
multiplies alpha by `xOpacity`, and clips fully-transparent fragments.
WorldBuilder's fragment shader does `discard` under 0.005:
```
if (color.a < 0.005) discard;
```
### Sort order
Back-to-front per-frame, per the following WorldBuilder pattern
(`ParticleBatcher.cs:149-153`):
```
_allParticles.Sort((a, b) => b.DistanceSq.CompareTo(a.DistanceSq));
```
After the 3D opaque pass and before UI, all particles are bucketed by
`(TextureArray, IsAdditive)` and drawn in one `glDrawElementsInstanced` per
bucket, with depth test on but depth writes off
(`gl.DepthMask(false)`). Additive can blend in any order, but
non-additive (smoke) must still be sorted to avoid occlusion artifacts;
batching by texture-first-then-sort-within is ACViewer's compromise and
WorldBuilder's approach. Retail, running on DX8, sorted within each
PhysicsScript's draw batch and relied on alpha-clip for crisp cutouts.
---
## 5. Sprite animation
There is **no UV flipbook** on a single particle. Sprite animation is achieved
by chaining multiple emitters in one `PhysicsScript`:
- Explosion: `PhysicsScript` emits three sequential bursts with different
`GfxObj` textures at `StartTime=0.0, 0.15, 0.35`. Each frame is a new
emitter with `TotalParticles=1` and a short `Lifespan`.
- Spell burst: 46 `CreateParticle` hooks offset by ~30ms each, using
different-colored swirl textures.
Per-frame animation inside a single particle's lifetime would require the
`GfxObj` itself to reference a sequence texture (the dat supports animated
surfaces via `Surface.OriginalFormatAndFlags` with `SurfaceType.Animated`),
but examination of retail effect dats shows they don't rely on this for
particles — all flipbook-like visuals are hook chains.
---
## 6. Emitter attach points
The trickiest part. An emitter can be attached to:
1. **World only**`PartIndex = -1`, `IsParentLocal = false`. Particles
world-space from spawn; parent motion doesn't move them. Chimney smoke
on a building works this way.
2. **Entity origin**`PartIndex = -1`, `IsParentLocal = true`. Particles
live in the parent's frame (`Parent.Position.Frame`). Breathing, a
floating aura. Moves with the creature as a whole but ignores
individual body-part animation.
3. **Entity body part**`PartIndex = N ≥ 0`, `IsParentLocal = true`.
Particles attach to part N of the setup (e.g., right hand = 13, spell
wand = 1). The emitter reads
`Parent.PartArray.Parts[PartIndex].Pos.Frame` each frame so the emitter
**follows the part through its animation**.
`PartIndex` → body-part index in the creature's `Setup.Parts` array. The
mapping is setup-specific; for humanoids the well-known indices are:
| Index | Part |
|-------|------|
| 1 | Right hand (weapon, spell comp) |
| 2 | Left hand |
| 3 | Head |
| 58 | Torso / hip |
| 1314 | Feet |
(Actual values vary by setup; decompiled retail reads
`SetupID → Setup.Parts[i].GfxObjID` to find the matching part.)
### Offset Frame
`CreateParticleHook.Offset` is a `Frame` (position + quaternion) applied
inside the parent-part's local space. For a torch: `PartIndex=0` (torch
mesh), `Offset=(0, 0, 0.45)` spawns the fire emitter at the top of the
torch regardless of how the torch is held. The frame is stored in dat
coordinates; our port multiplies it into the part-local transform with
standard `Matrix4x4.CreateFromQuaternion(Offset.Orientation) *
Matrix4x4.CreateTranslation(Offset.Origin)` and composes with the part's
world matrix each frame.
### Retail `set_parent` call
ACE port (`ParticleEmitter.cs:40-48`):
```csharp
public bool SetParenting(int partIdx, AFrame frame)
{
if (PhysicsObj == null || !PhysicsObj.set_parent(Parent, partIdx, frame))
return false;
PartIndex = partIdx;
ParentOffset = new AFrame(frame);
return true;
}
```
`PhysicsObj.set_parent(parent, partIdx, offsetFrame)` registers the child
with the parent's shadow-cell tracking so the emitter gets despawn/freeze
events when the parent is occluded, destroyed, or teleported. We will
reproduce this in acdream with a `ParentReference { IEntity Entity; int
PartIndex; Matrix4x4 Offset; }` record attached to each live emitter.
### `IsParentLocal` semantics
`ParticleEmitter.UpdateParticles()` in ACE (`:216-238`):
```csharp
if (Info.IsParentLocal) {
if (PartIndex == -1) frame = Parent.Position.Frame;
else frame = Parent.PartArray.Parts[PartIndex].Pos.Frame;
}
else frame = Particles[i].StartFrame;
```
**IsParentLocal = true** recomputes the reference frame every tick, so
particles ride the parent. **IsParentLocal = false** caches the
`StartFrame` at particle spawn, so the particle is "released" into the
world at that frame and the parent can walk away without dragging the
particles. Buff auras (`EnchantUpBlue` etc.) use IsParentLocal = true;
spell-launch explosions use false.
Retail keeps a special case: `ParticleType.Still` with IsParentLocal=true
also updates the offset vector each tick so it effectively just pins to
the parent (used for stationary glows attached to a moving creature).
---
## 7. Well-known effects — catalog
Effect → PhysicsScriptTable path. These are the DIDs the server issues via
`GameMessageScript`. The tables themselves live in `client_portal.dat` in
the `0x34000000` range. Exact DIDs are specific to the dat release; below
are the well-established canonical IDs corroborated by ACE constants and
ACME/ACViewer's test captures.
| Effect (PlayScript) | Typical emitter chain | Notes |
|---------------------|-----------------------|-------|
| **Portal swirl (static portal)** | emitted by the portal-weenie's `Setup.DefaultScript` (a `PhysicsScript`, not via PlayScript) — chains 34 `CreateParticleHook` emitters with `ParticleType.Swarm`, `Lifespan` infinite, `IsParentLocal=true`, `TotalSeconds=0` | Geometry = single billboard point-sprite with a radially-gradient texture; C vector drives swirl radius |
| **Chimney smoke** | static building's `Setup.DefaultScript``CreateParticleHook` with `PartIndex` = chimney part, `ParticleType.ParabolicLVGA` with tiny upward A and zero-g B | Grey-alpha billboard, slow birthrate, 815 maxParticles |
| **Fireplace fire** | same pattern, `ParticleType.LocalVelocity`, additive flame texture, high birthrate (~0.05 s), 2040 maxParticles | Usually 2 emitters: big-flame + small-spark |
| **Torch flame** | `ParticleType.Swarm` for flicker, `IsParentLocal=true`, attached to torch's head part | Identical recipe to fireplace but tighter scale |
| **Campfire** | 3 emitters: flame (additive), smoke (alpha), sparks (Explode type) | Cumulative effect |
| **Spell buff aura** (e.g. `EnchantUpBlue`) | PhysicsScriptTable with 35 Mod tiers. `PhysicsScript` = single `CreateParticle` using ParticleType.Swarm with `ParentLocal=true`, `TotalSeconds=0.52.5` then auto-destroy | Color from texture only; "Blue" → blue-tinted GfxObj |
| **Spell projectile trail** | attached on missile creation. `ParticleType.ParabolicLVLA` with `A` = -velocity-direction (trails behind) | Uses `EmitterType.BirthratePerMeter` so the tail thickens/thins with speed |
| **Impact puff** (`Splatter*`, `Spark*`) | Short-lived burst. `ParticleType.Explode`, `InitialParticles` = 820, `TotalParticles` = same (no re-spawn), `Lifespan` ≈ 0.4s | 12 directional variants map splat to the hit hemisphere |
| **Lifestone glow** | Scenery weenie's `Setup.DefaultScript`. Two emitters: crystal aura (additive, ParticleType.Swarm) + falling sparkles (ParabolicGVGA, gravity-affected) | Distinctive amber texture |
| **Rain / snow** | Regional effect hooked at landblock entry. `EmitterType.BirthratePerSec` very fast (~0.002s), `ParticleType.GlobalVelocity` with A = down vector. A single world-anchored emitter per landblock, IsParentLocal=false | Retail didn't rain on foliage in most areas; most outdoor weather is a single hook-in per region |
| **Portal Storm warning** (`PortalStorm`) | Screen-wide ambient emitter triggered by server; spawns in a ring around the player | Corresponds to `chunk_00560000.c:3164-3275` strings |
| **LevelUp** | Dense burst of EnchantUp-like particles, all colors chained | The celebration pyrotechnics |
### Portal visual specifics
A retail portal gate is NOT a procedural mesh. It's one of:
1. **A billboard disk** (most common): `ParticleEmitterInfo.ParticleType =
Swarm`, `ParentLocal = true`, `TotalSeconds = 0`, `Lifespan = 1.2s`,
`Birthrate = 0.08s`, `MaxParticles = 40`. Particles orbit the emitter
origin in a disk; the `C` vector = swirl radius, `B` = angular velocity
(sinusoid). The visual illusion of a spinning portal is the pattern of
many small sprite particles flowing through the Swarm path.
2. **A two-part GfxObj emitter**: one non-billboard mesh (a flat textured
disk rotating via `ParabolicLVLALR`'s `C` angular-velocity term) plus a
particle cloud in front of it. This is how the more elaborate
dungeon-summon portals work.
The portal disk has **no scrolling UV** — apparent rotation is the
rotation of the particles themselves (via Swarm or *LR types), plus the
underlying GfxObj if it's the mesh variant.
---
## 8. Emission triggering — when effects fire
Five distinct trigger pathways, discovered by tracing ACE server usage:
1. **On-spawn / on-create**: creature or object Setup's `DefaultScript` or
`DefaultScriptTable[PlayScript.Create]` fires automatically when the
PhysicsObj enters the world. ACE:
`WorldObject.EnterWorld` → `ApplyVisualEffects(PlayScript.Create)`
(`WorldObject.cs:726-727`).
2. **Permanent / ambient**: Setup's `DefaultScriptTable` with an entry
keyed to a state (e.g. `SpecialState*`) that the creature keeps active.
The torch, fireplace, portal gate, lifestone all use this — the
emitter just runs forever.
3. **Motion-driven (animation hook)**: inside a `MotionTable` animation
sequence, frames can carry `CreateParticleHook` embedded in the
`AnimationFrame.Hooks`. E.g. casting start fires a swirl around the
hands mid-animation; swing motion fires a swoosh trail from the weapon
tip at frame 8. These fire during animation playback and are
auto-cleaned by matching `DestroyParticleHook` in later frames.
4. **Network-driven (`GameMessageScript`)**: server sends
`{ Guid, PlayScript, Mod }`. Client looks up the PhysicsScriptTable on
the target's Setup, picks the matching entry via `mod`, then dispatches
the PhysicsScript (which is a sequence of hooks with StartTimes). The
timer system fires each hook when its StartTime is reached.
5. **Collision-driven**: physics-collision resolver fires
`PlayScript.ProjectileCollision` or a directional `Splatter*`/`Spark*`
at the hit point. The emitter is parented to the victim with
`PartIndex = -1, IsParentLocal = true, Offset = hitLocalFrame`.
### Timed hook execution — the "timer" inside PhysicsScript
The client's PhysicsScript runner keeps a `runningSince` timestamp per active
script. Each tick:
```
foreach script in activeScripts:
elapsed = now - script.runningSince
while script.nextHookIdx < script.ScriptData.Count
and script.ScriptData[script.nextHookIdx].StartTime <= elapsed:
execute(script.ScriptData[script.nextHookIdx].Hook)
script.nextHookIdx++
if nextHookIdx >= count and no-live-emitters: script retire
```
This is ACE's `ScriptManager`-equivalent (ACE calls it via the
PhysicsObj directly). Retail used a sorted timer priority queue inside
`CPhysicsObj::UpdateScripts` to amortize.
---
## 9. Particle pooling — retail's caps
From ACE's `ParticleEmitter` (`:115-122`):
```csharp
PartStorage = new PhysicsPart[Info.MaxParticles]; // pre-allocated
for (i = 0; i < Info.MaxParticles; i++)
PartStorage[i] = PhysicsPart.MakePhysicsPart(Info.HWGfxObjID);
Particles = new Particle[Info.MaxParticles];
for (i = 0; i < Info.MaxParticles; i++)
Particles[i] = new Particle();
```
Each emitter pre-allocates a `MaxParticles`-sized slab of `PhysicsPart`s
plus a corresponding `Particle[]` struct-array. On emit,
`GetNextParticleIdx()` linear-scans for a null entry and fills it. On
kill, the `Particles[i]` struct is retained (just timestamp-reset on
persistent emitters) and the `Parts[i]` pointer nulls.
WorldBuilder matches this with a fixed-length `AcParticleSlot[]` and an
`Active` flag. ACME uses `Math.Clamp(_def.MaxParticles, 1, 512)` as a hard
cap — retail's absolute upper bound was 512 per emitter, and
`chunk_005D0000.c:8723-8728` hooks into UI overlays that show running
totals ("Particles Rendered", "Particle Systems").
### No global LRU
Retail did not implement a global particle LRU. Instead, each emitter
enforced its `MaxParticles` hard cap and relied on distance-degrade:
`PhysicsObj.ShouldDrawParticles(degradeDistance)` (ACE
`PhysicsObj.cs:1540-1544`):
```csharp
public bool ShouldDrawParticles(float degradeDistance) {
if (!ExaminationObject) return true;
return !(CYpt > degradeDistance || CurCell == null);
}
```
Particles beyond a per-emitter `DegradeDistance` (ACE stubs it to
`float.MaxValue`) get `SetNoDraw(true)` — they keep simulating (so the
server-side script advances) but aren't rendered. Real retail probably
used a per-`GfxObjDegradeInfo` value; for acdream we can set a sensible
default (50 m outdoor, 15 m indoor) and override via a PluginAPI hook.
---
## 10. Performance — draw-call batching
WorldBuilder's `ParticleBatcher.Flush()` is the model:
1. Sort all live particle instances by distance (back-to-front).
2. Walk the sorted list, grouping runs with identical
`(TextureArray, IsAdditive)`.
3. For each group: upload the instance range to the instance VBO with
`glBufferSubData`, bind the `texture2DArray` (with the per-particle
texture slice indexed by `iTextureIndex` attribute), set the blend
mode, issue **one** `glDrawElementsInstanced(Triangles, 6, …, count)`.
The instance struct is 56 bytes (`ParticleInstance` =
`Position:3f + ScaleOpacityActive:3f + TextureIndex:1f + Rotation:4f +
Size:2f + IsBillboard:1f = 14 floats`). With a 64K instance buffer we can
carry 64,000 live particles without reallocation.
State-changes minimized:
- One VAO for all particles
- One vertex buffer (unit quad 4 verts) + one element buffer (6 indices)
- One texture array per "atlas" (grouped by dimensions + format)
- Depth-write off, depth-test on, cull off
- Two blend-state changes max per draw cycle (additive vs alpha)
- Shader bound once
This is two orders of magnitude fewer draw-calls than retail's DX8
per-emitter DrawIndexedPrimitive. We can afford up to tens of thousands
of particles per frame on modern hardware.
---
## 11. Alpha & sorting — finer points
| Concern | Retail | acdream / WorldBuilder |
|---------|--------|------------------------|
| Particle ↔ particle depth | No z-write, sorted back-to-front per-emitter | No z-write, sorted back-to-front globally |
| Particle ↔ world depth | z-test against opaque scene | z-test against opaque scene |
| Particle ↔ translucent geometry (water, glass) | Drawn after translucent pass | Same |
| Sub-pixel alpha threshold | alpha-clip < 1 fully-opaque, < 1 + clip for trans | `if (a < 0.005) discard;` |
| Alpha-test cutout (leaves-style) | PointSpritePS technique splits opaque and trans into two passes | Single pass using a discard |
The single-pass discard WorldBuilder uses loses retail's early-Z benefit
on cutout particles, but at these particle counts the fragment-shader
cost is negligible on modern GPUs.
---
## 12. Port plan — acdream types & integration
### C# class layout
```csharp
// acdream/Rendering/Particles/EmitterDesc.cs (thin adapter over DatReaderWriter.ParticleEmitter)
public sealed class EmitterDesc {
public required ParticleEmitter Dat; // DatReaderWriter type, direct
public uint GfxObjId => Dat.HwGfxObjId.DataId != 0 ? Dat.HwGfxObjId.DataId : Dat.GfxObjId.DataId;
public bool IsPersistentUnlimited => Dat.TotalParticles == 0 && Dat.TotalSeconds == 0;
public float SortingSphereRadius { get; init; } // computed in Bind()
// Serves ParticleEmitterInfo role from ACE.
}
// acdream/Rendering/Particles/ParticleSlot.cs (struct, hot-loop)
public struct ParticleSlot {
public bool Active;
public double BirthTime;
public float Lifespan;
public Vector3 Offset;
public Vector3 A, B, C; // resolved at spawn per ParticleType rules
public Quaternion StartRotation;
public Matrix4x4 StartFrame; // cached when !IsParentLocal
public float StartScale, FinalScale;
public float StartTrans, FinalTrans;
public Vector3 CachedWorldPos; // recomputed each tick
}
// acdream/Rendering/Particles/LiveEmitter.cs
public sealed class LiveEmitter {
public required EmitterDesc Desc;
public required IEntityHandle ParentEntity; // game-object reference
public int PartIndex = -1;
public Matrix4x4 OffsetFrame = Matrix4x4.Identity;
public int ScriptLocalId; // matches CreateParticleHook.EmitterId
// Hot loop state
private ParticleSlot[] _slots;
private int _totalEmitted;
private double _creationTime;
private double _lastEmitTime;
private Vector3 _lastEmitOrigin;
private bool _stopped;
public void Advance(double now, float dt); // simulation (CPU)
public void Collect(ParticleBatcher batch, Vector3 camPos); // per-frame buffer fill
public void Stop(); // no new spawns
public void Destroy(); // clear live slots
}
// acdream/Rendering/Particles/ParticleManager.cs
public sealed class ParticleManager {
private readonly Dictionary<int, LiveEmitter> _byLocalId = new();
private int _nextAutoId = 1;
public int CreateParticleEmitter(IEntityHandle parent, uint emitterInfoDid, int partIndex, Matrix4x4 offset, int scriptLocalId);
public bool StopEmitter(int localId);
public bool DestroyEmitter(int localId);
public void UpdateAll(double now, float dt);
public void DestroyAll();
}
// acdream/Rendering/Particles/ParticleBatcher.cs (the Silk.NET GL batcher; port of WorldBuilder's)
// Same struct layout as WorldBuilder.ParticleBatcher — GL-level, one VAO, instanced draw.
// acdream/Rendering/Particles/PhysicsScriptRunner.cs (hook timeline walker)
public sealed class PhysicsScriptRunner {
// One runner per active PhysicsScript on each entity.
// Walks ScriptData ordered by StartTime and fires hooks into ParticleManager.
// Also handles blocking-particle wait states for motion-sequence chaining.
}
// acdream/Rendering/Particles/PlayScriptDispatcher.cs
// Maps incoming (entityGuid, PlayScript, mod) to (PhysicsScript DID) and hands off to the runner.
```
### Integration points (render pipeline)
The render pass ordering must be:
```
RenderFrame:
1. Terrain pass (opaque, z-write on)
2. Static scenery pass (opaque, z-write on)
3. Creature/entity pass (opaque, z-write on)
4. Translucent geometry pass (z-write off, back-to-front)
5. --> Particle pass <-- (z-write off, depth-test on)
a. ParticleManager.UpdateAll(now, dt) (simulation; CPU)
b. For each LiveEmitter: emitter.Collect(batcher, cam)
c. batcher.Flush() — sort, group, draw
6. Debug lines / overlays
7. UI
```
Simulation runs every frame regardless of visibility (matches retail's
`ShouldDrawParticles` behavior: hidden emitters still advance their
script). This costs a bit of CPU but keeps semantics correct — when an
ally buff expires behind a wall, it should still be gone when you see
them again.
### Silk.NET shader sketch
`Shaders/Particle.vert` (port WorldBuilder's verbatim; the logic is
correct):
```glsl
#version 330 core
layout(location = 0) in vec3 aPosition; // unit quad vertex (-0.5..0.5)
layout(location = 1) in vec2 aTexCoord;
layout(location = 2) in vec3 iPosition;
layout(location = 3) in vec3 iScaleOpacityActive;
layout(location = 4) in float iTextureIndex;
layout(location = 5) in vec4 iRotation; // quaternion
layout(location = 6) in vec2 iSize;
layout(location = 7) in float iIsBillboard;
uniform mat4 uViewProjection;
uniform vec3 uCameraUp;
uniform vec3 uCameraRight;
out vec2 TexCoord;
out float Opacity;
out float TextureIndex;
vec3 rotateByQ(vec3 v, vec4 q) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); }
void main() {
TexCoord = aTexCoord;
Opacity = iScaleOpacityActive.y;
TextureIndex = iTextureIndex;
float scale = iScaleOpacityActive.x;
vec3 worldPos;
if (iIsBillboard > 0.5) {
worldPos = iPosition
+ uCameraRight * aPosition.x * iSize.x * scale
+ uCameraUp * aPosition.z * iSize.y * scale;
} else {
vec3 local = vec3(aPosition.x * iSize.x * scale, 0.0, aPosition.z * iSize.y * scale);
worldPos = iPosition + rotateByQ(local, iRotation);
}
gl_Position = uViewProjection * vec4(worldPos, 1.0);
}
```
`Shaders/Particle.frag`:
```glsl
#version 330 core
in vec2 TexCoord;
in float Opacity;
in float TextureIndex;
uniform sampler2DArray uTextureArray;
out vec4 FragColor;
void main() {
vec4 c = texture(uTextureArray, vec3(TexCoord, TextureIndex));
c.a *= Opacity;
if (c.a < 0.005) discard;
FragColor = c;
}
```
### Decompile-first checklist for the port
Per CLAUDE.md mandatory workflow, for each of these sub-tasks find the
matching retail function BEFORE porting:
1. **`GetRandomOffset` / disk sampling** — retail reference:
`AcParticleEmitterSimulator.GetRandomOffset`
(`WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447`).
ACE mirror at `ParticleEmitterInfo.cs:173-187`. Confirmed match.
2. **`Particle.Init` switch on ParticleType** — retail:
ACE `Particle.cs:47-108`; ACME `AcParticleEmitterSimulator.cs:219-273`.
Both have the seven vector-space conventions. Use ACME's (it's the
more recent, more-testably-correct form).
3. **`Particle.Update` motion math** — retail: ACE `Particle.cs:123-180`;
ACME `AcParticleEmitterSimulator.cs:285-348`. Port ACME; add a
conformance test comparing XYZ trajectories for all 13 ParticleTypes
to the ACME simulator output.
4. **`ShouldEmitParticle` cadence** — ACE
`ParticleEmitterInfo.cs:155-171`; ACME
`AcParticleEmitterSimulator.cs:142-151`.
5. **Parenting / `set_parent`** — ACE `ParticleEmitter.cs:40-48`; resolve
via our own `IEntityHandle` abstraction. This is new code; no direct
decompiled reference needed beyond the call signature.
6. **PhysicsScriptRunner hook loop** — no direct ACE class (ACE handles
scripts server-side as just `GameMessageScript` broadcasts). Model on
ACViewer's `ParticleViewer.InitEmitter` + time-based hook dispatch. The
decompiled form is inside `CPhysicsObj::UpdateScripts` (not yet
individually mapped; it's in chunk 0x005D or 0x005E).
7. **Texture atlas packing** — port WorldBuilder's
`ParticleBatcher.ParticleInstance` + texture-array construction
verbatim. Non-AC specific; standard modern GL pattern.
### Conformance testing
For each `ParticleType`, spawn one particle with fixed deterministic
random inputs and record the XYZ trajectory at t = 0.1, 0.5, 1.0, 2.0
seconds. Assert bytewise-identical floats against the ACME simulator.
This catches subtle dispatch errors (swapped A/B/C handling per type) in
CI rather than by visual inspection.
For the emitter-cadence test: spawn with
`EmitterType=BirthratePerSec, Birthrate=0.1, MaxParticles=10,
TotalSeconds=1.0` and assert exactly 10 emissions by t=1.0s ±1.
---
## 13. Render ordering and plugin layering
Per the architecture doc, VFX has three distinct lifecycle owners:
| Lifecycle | Source of truth | Starts | Stops |
|-----------|-----------------|--------|-------|
| Landblock-level ambient (chimney, portal gate) | Scenery/Setup `DefaultScript` on load | Enter world | Landblock unload |
| Entity-level ambient (torch, lifestone) | Setup `DefaultScriptTable[PlayScript.Create]` | WorldObject spawn | WorldObject destroy |
| Spell/buff/hit effect (one-shot or timed) | `GameMessageScript` from server | Message received | Script's totalSeconds or explicit destroy |
All three use the same `ParticleManager` + `PhysicsScriptRunner`. The
distinction is only in who owns the live-script reference:
- **Landblock** owns a set of `PhysicsScriptRunner` instances, destroyed
on landblock eviction.
- **Entity** owns a set of runners on creation, destroyed with the
entity.
- **Spell chain (R1)** creates runners as network events arrive; they
self-retire when the script completes.
The **plugin API** surface:
```csharp
// acdream.PluginApi.Particles
public interface IParticleEffects {
int Play(IEntityHandle target, uint playScriptOrEffectDid, float mod = 0f);
int PlayAt(Vector3 worldPos, uint emitterInfoDid);
void Stop(int liveEmitterId);
void Destroy(int liveEmitterId);
IReadOnlyList<IEffectInstance> Active { get; }
}
```
Spellcasting (R1 spell chain) calls `IParticleEffects.Play(self,
PlayScript.WindupFlame, intensity)` at the cast-start message and is
what `ParticleEmitter::CreateParticleEmitter` resolves down to. The
BlockingParticle hook type is handled by the MotionSequencer waiting on
the runner's completion flag before advancing to the next motion.
Portal gates are always-on: spawned as part of landblock load when the
portal weenie enters the client world, destroyed only on teleport/cell
unload. They do not go through `PlayScript.Create` dispatch — their
emitters are rooted in `Setup.DefaultScript`.
Chimney smoke is landblock-level: its emitter is on the building setup's
`DefaultScript`, IsParentLocal=false, so it stays put while the player
orbits the building.
---
## 14. Attachment-model subtleties (the "ultrathink" item)
Getting "particles follow this body part as the creature moves" right
hinges on three things:
1. **Frame update timing**. Every frame the animation sequencer must run
FIRST (populating `Parent.PartArray.Parts[PartIndex].Pos.Frame`),
THEN the `ParticleManager.UpdateAll` must read those frames, THEN the
renderer collects and draws. If particle update runs before
animation, particles lag the skeleton by one frame (visible jitter on
fast movements like a swing).
2. **Offset composition order**. The particle's world frame is computed
as:
```
partWorldFrame = parentEntity.WorldMatrix * parentEntity.PartFrame[PartIndex]
emitterAnchor = partWorldFrame * offsetFrame // CreateParticleHook.Offset
// if IsParentLocal: use emitterAnchor every tick
// else: cache emitterAnchor at spawn, reuse
```
Both ACE and ACME agree on this order; the decompiled client's
`C_Frame::multiply(inner, outer)` composes left-to-right, so our
matrix convention must too. **Test the order** by porting ACME's
conformance test for a rotating creature with attached emitter.
3. **Shadow-cell tracking**. When the parent entity crosses a cell
boundary, the particles must also cross. ACE handles this via
`PhysicsObj.set_parent()` registering the particle's PhysicsObj into
the parent's shadow-cell list; it gets automatically included in the
parent's cell-transition notify. Our acdream equivalent: the
`LiveEmitter` subscribes to `ParentEntity.OnCellChanged` events and
re-associates with the new cell for culling/LOD purposes. The slots
themselves are cell-agnostic — they live in world space once they
escape parent-local.
4. **PartIndex = -1 vs 0**. `-1` means "attach to entity origin" (root
frame, no part offset). `0` means "attach to Part[0]" which is still
a real part. Missing this distinction cost the original retail dev a
bug visible in the decompiled code's defensive check
(`if (partIdx == -1) frame = Parent.Position.Frame;`). Port it
literally.
5. **The `PartStorage` vs `Parts` split**. ACE has two parallel arrays:
`PartStorage` owns the `PhysicsPart` objects (pre-allocated once),
and `Parts` is a "currently live" slot table whose entries are either
a pointer into `PartStorage` or `null`. On emit, `Parts[i] =
PartStorage[i]`; on kill, `Parts[i] = null` but `PartStorage[i]`
stays so it's reused in the next emit cycle. This is **intentional
GC avoidance** — don't "fix" it in acdream by replacing with a
`List<Particle>` — keep the fixed-length slot table pattern.
---
## 15. Summary — the six pillars of acdream's VFX port
1. **DatReaderWriter** supplies `ParticleEmitter`, `PhysicsScript`,
`PhysicsScriptTable`, and the four animation hooks. No dat-format
work needed; just wire them up.
2. **Simulation**: port `AcParticleEmitterSimulator` (ACME) file to
`acdream/Rendering/Particles/LiveEmitter.cs`. Validate with
conformance tests across all 13 ParticleTypes.
3. **Rendering**: port WorldBuilder's
`Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` and its shader
pair to `acdream/Rendering/Particles/`. This is direct Silk.NET code
that will compile in our build with minimal edits.
4. **Script runner**: build a new `PhysicsScriptRunner` that walks a
`PhysicsScript.ScriptData` list by StartTime and dispatches
`CreateParticleHook`/`StopParticleHook`/`DestroyParticleHook` into
the ParticleManager. This is new code; model on
`ACViewer.ParticleViewer.InitEmitter`.
5. **Network integration**: wire `GameMessageScript` (network) →
`PlayScriptDispatcher` → `PhysicsScriptRunner` for the spell-chain
phase (R1). Until R1, drive effects from landblock-load and local
test commands.
6. **Plugin API**: expose `IParticleEffects` in
`acdream.PluginApi.Particles` so plugins can trigger arbitrary
PlayScripts at arbitrary targets. This is a feature the retail client
never had and is the acdream differentiator.
The full port should land in two commits:
- **R4.1** — simulation + rendering + in-editor emitter test harness
(render any `ParticleEmitter` DID at the origin, verify against
WorldBuilder's emitter browser visuals).
- **R4.2** — PhysicsScript/PlayScript dispatcher wired to landblock +
spell events, plus the plugin API surface.
Estimated scope: ~2,500 LOC new code, ~400 LOC tests, 2 new shaders.
Zero changes to existing terrain/creature render paths — particles live
in their own render pass.