# 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 1–16),
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.0–1.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** (0x01–0x05):
- `Test1/2/3`, `Launch`, `Explode`

**Attribute buffs/debuffs** (0x06–0x11): six colors × up/down
- `AttribUpRed`, `AttribDownRed`, …, `AttribDownPurple`

**Skill buffs/debuffs** (0x12–0x1E): six colors × up/down + `SkillDownBlack`
- `SkillUpRed` … `SkillDownPurple`

**Vital buffs/debuffs** (0x1F–0x2A): Health / Regen, 3 colors × up/down
- `HealthUpRed`, `HealthDownRed`, `HealthUpBlue`, `HealthDownBlue`,
  `HealthUpYellow`, `HealthDownYellow`, `RegenUp*`, `RegenDown*`

**Shield / Enchant / Swap** (0x2B–0x50): all six colors each
- `ShieldUp*`, `ShieldDown*`, `EnchantUp*`, `EnchantDown*`,
  `SwapHealth_*_To_*` (stamina/mana transfers), `TransUp/DownWhite/Black`

**Spells** (0x51–0x57):
- `Fizzle`, `PortalEntry`, `PortalExit`, `BreatheFlame`, `BreatheFrost`,
  `BreatheAcid`, `BreatheLightning`

**Lifecycle** (0x58–0x5A):
- `Create`, `Destroy`, `ProjectileCollision`

**Combat splatters** (0x5B–0x72): 12 splatter + 12 spark directions
- `SplatterLow/Mid/UpLeft/RightBack/Front`,
  `SparkLow/Mid/UpLeft/RightBack/Front`

**Environment** (0x73):
- `PortalStorm`

**Visibility** (0x74–0x77):
- `Hide`, `UnHide`, `Hidden`, `DisappearDestroy`

**Special states** (0x78–0x81): 10 state slots
- `SpecialState0` … `SpecialState9`

**Special state colors** (0x82–0x89): 8 colors
- `SpecialStateRed` … `SpecialStateBlack`

**Progression** (0x8A):
- `LevelUp`

**Enchant variants** (0x8B–0x8F):
- `EnchantUp/DownGrey`, `WeddingBliss`, `EnchantUp/DownWhite`

**Social / GM** (0x90–0x97):
- `CampingMastery`, `CampingIneptitude`, `DispelLife`, `DispelCreature`,
  `DispelAll`, `BunnySmite`, `BaelZharonSmite`, `WeddingSteele`

**Restriction / Aug** (0x98–0xAD):
- `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: 4–6 `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 |
| 5–8 | Torso / hip |
| 13–14 | 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 3–4 `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, 8–15 maxParticles |
| **Fireplace fire** | same pattern, `ParticleType.LocalVelocity`, additive flame texture, high birthrate (~0.05 s), 20–40 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 3–5 Mod tiers. `PhysicsScript` = single `CreateParticle` using ParticleType.Swarm with `ParentLocal=true`, `TotalSeconds=0.5–2.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` = 8–20, `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.
