# 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 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 ScriptTable } PhysicsScriptTableData { List Scripts // sorted by Mod descending } ScriptAndModData { float Mod // intensity threshold (0.0–1.0 typically) QualifiedDataId 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(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 _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 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` — 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.