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.
45 KiB
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*.cthroughchunk_006A*.c. Symbols are mostly stripped (noparticle_emitterstrings survive in symbol form); references confirm via UI hook ("Particles Rendered", "Particle Systems" inchunk_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/*.csis the direct ACE-to-retail port of the simulation loop. Nearly identical shape to decompiled code. - ACViewer —
Physics/Particles/*.csplus the MonoGame render pipeline inRender/ParticleBatch.cs,Render/ParticleBatchDraw.cs,Render/ParticleTextureFormat.cs,Model/ParticleDeclaration.cs, and the HLSLContent/texture.fxPointSpritetechnique. - WorldBuilder — our exact Silk.NET stack. Complete GL port at
Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs,Lib/ParticleEmitterRenderer.cs, shaders atChorizite.OpenGLSDLBackend/Shaders/Particle.vertandParticle.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:
StartScale→FinalScale— linear interpolation over lifespanStartTrans→FinalTrans— linear interpolation (1 = invisible)- Texture UV flipbook if the
GfxObjcontains 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:
PhysicsScriptemits three sequential bursts with differentGfxObjtextures atStartTime=0.0, 0.15, 0.35. Each frame is a new emitter withTotalParticles=1and a shortLifespan. - Spell burst: 4–6
CreateParticlehooks 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:
- 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. - 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. - 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 readsParent.PartArray.Parts[PartIndex].Pos.Frameeach 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):
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 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:
- 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; theCvector = 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. - A two-part GfxObj emitter: one non-billboard mesh (a flat textured
disk rotating via
ParabolicLVLALR'sCangular-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:
- On-spawn / on-create: creature or object Setup's
DefaultScriptorDefaultScriptTable[PlayScript.Create]fires automatically when the PhysicsObj enters the world. ACE:WorldObject.EnterWorld→ApplyVisualEffects(PlayScript.Create)(WorldObject.cs:726-727). - Permanent / ambient: Setup's
DefaultScriptTablewith 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. - Motion-driven (animation hook): inside a
MotionTableanimation sequence, frames can carryCreateParticleHookembedded in theAnimationFrame.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 matchingDestroyParticleHookin later frames. - Network-driven (
GameMessageScript): server sends{ Guid, PlayScript, Mod }. Client looks up the PhysicsScriptTable on the target's Setup, picks the matching entry viamod, then dispatches the PhysicsScript (which is a sequence of hooks with StartTimes). The timer system fires each hook when its StartTime is reached. - Collision-driven: physics-collision resolver fires
PlayScript.ProjectileCollisionor a directionalSplatter*/Spark*at the hit point. The emitter is parented to the victim withPartIndex = -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:
- Sort all live particle instances by distance (back-to-front).
- Walk the sorted list, grouping runs with identical
(TextureArray, IsAdditive). - For each group: upload the instance range to the instance VBO with
glBufferSubData, bind thetexture2DArray(with the per-particle texture slice indexed byiTextureIndexattribute), set the blend mode, issue oneglDrawElementsInstanced(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:
GetRandomOffset/ disk sampling — retail reference:AcParticleEmitterSimulator.GetRandomOffset(WorldBuilder.Shared/Lib/AcParticleEmitterSimulator.cs:428-447). ACE mirror atParticleEmitterInfo.cs:173-187. Confirmed match.Particle.Initswitch on ParticleType — retail: ACEParticle.cs:47-108; ACMEAcParticleEmitterSimulator.cs:219-273. Both have the seven vector-space conventions. Use ACME's (it's the more recent, more-testably-correct form).Particle.Updatemotion math — retail: ACEParticle.cs:123-180; ACMEAcParticleEmitterSimulator.cs:285-348. Port ACME; add a conformance test comparing XYZ trajectories for all 13 ParticleTypes to the ACME simulator output.ShouldEmitParticlecadence — ACEParticleEmitterInfo.cs:155-171; ACMEAcParticleEmitterSimulator.cs:142-151.- Parenting /
set_parent— ACEParticleEmitter.cs:40-48; resolve via our ownIEntityHandleabstraction. This is new code; no direct decompiled reference needed beyond the call signature. - PhysicsScriptRunner hook loop — no direct ACE class (ACE handles
scripts server-side as just
GameMessageScriptbroadcasts). Model on ACViewer'sParticleViewer.InitEmitter+ time-based hook dispatch. The decompiled form is insideCPhysicsObj::UpdateScripts(not yet individually mapped; it's in chunk 0x005D or 0x005E). - 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
PhysicsScriptRunnerinstances, 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:
-
Frame update timing. Every frame the animation sequencer must run FIRST (populating
Parent.PartArray.Parts[PartIndex].Pos.Frame), THEN theParticleManager.UpdateAllmust 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). -
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, reuseBoth 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. -
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: theLiveEmittersubscribes toParentEntity.OnCellChangedevents 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. -
PartIndex = -1 vs 0.
-1means "attach to entity origin" (root frame, no part offset).0means "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. -
The
PartStoragevsPartssplit. ACE has two parallel arrays:PartStorageowns thePhysicsPartobjects (pre-allocated once), andPartsis a "currently live" slot table whose entries are either a pointer intoPartStorageornull. On emit,Parts[i] = PartStorage[i]; on kill,Parts[i] = nullbutPartStorage[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 aList<Particle>— keep the fixed-length slot table pattern.
15. Summary — the six pillars of acdream's VFX port
- DatReaderWriter supplies
ParticleEmitter,PhysicsScript,PhysicsScriptTable, and the four animation hooks. No dat-format work needed; just wire them up. - Simulation: port
AcParticleEmitterSimulator(ACME) file toacdream/Rendering/Particles/LiveEmitter.cs. Validate with conformance tests across all 13 ParticleTypes. - Rendering: port WorldBuilder's
Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.csand its shader pair toacdream/Rendering/Particles/. This is direct Silk.NET code that will compile in our build with minimal edits. - Script runner: build a new
PhysicsScriptRunnerthat walks aPhysicsScript.ScriptDatalist by StartTime and dispatchesCreateParticleHook/StopParticleHook/DestroyParticleHookinto the ParticleManager. This is new code; model onACViewer.ParticleViewer.InitEmitter. - Network integration: wire
GameMessageScript(network) →PlayScriptDispatcher→PhysicsScriptRunnerfor the spell-chain phase (R1). Until R1, drive effects from landblock-load and local test commands. - Plugin API: expose
IParticleEffectsinacdream.PluginApi.Particlesso 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
ParticleEmitterDID 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.