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

45 KiB
Raw Blame History

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 decompilationdocs/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 serverACE.Server/Physics/Particles/*.cs is the direct ACE-to-retail port of the simulation loop. Nearly identical shape to decompiled code.
  • ACViewerPhysics/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 `

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: Local Velocity means A is multiplied by the parent's rotation at spawn time and "frozen." Global 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:

  • StartScaleFinalScale — linear interpolation over lifespan
  • StartTransFinalTrans — 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

  • SkillUpRedSkillDownPurple

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

  • SpecialState0SpecialState9

Special state colors (0x820x89): 8 colors

  • SpecialStateRedSpecialStateBlack

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 onlyPartIndex = -1, IsParentLocal = false. Particles world-space from spawn; parent motion doesn't move them. Chimney smoke on a building works this way.
  2. Entity originPartIndex = -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 partPartIndex = 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):

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

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.DefaultScriptCreateParticleHook 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.EnterWorldApplyVisualEffects(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):

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 PhysicsParts 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):

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

// 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):

#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:

#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:

// 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) → PlayScriptDispatcherPhysicsScriptRunner 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.