Visual effects: ClientFX data and runtime
The ClientFX effect system end to end: the .fxp format and its loader, the 36 node types and their property schema, the five effect managers that own live instances per time domain, the instance and resource layouts, the per-frame tick split between the main thread and the FX background thread, the request path, GPU particles and budgets.
Every visual effect - sparks, fire, blood, auras, light flares, screen effects, camera shakes and
effect sounds - is a ClientFX effect: an .fxp file holding a small GameDB database of nodes, played
by an effect manager as instances.
The overview
CGameClientShell [base+0x26FFA98]
+0xA98 Simulation +0xAA0 RealTime +0xAA8 PlayerTime +0xAB0 CompanionTime +0xAB8 WorldUI
| (unique_ptr -> manager wrapper, 0x18358 bytes, vt_0x22F3178; global list [base+0x26C6A98])
+-- +0x28 inner manager (vt_0x1F67400, 0x18330 bytes)
+0xB8 vector of 0x28-byte entries -- entry +0x18 --> effect instance (vt_0x1DAB360, pool 0x2A3E9A8)
+0x230 FxResource (0x60) -> keys -> node types
+0x220 KeyData block -> one object per node
+0x128 background work list --> FXBackgroundThread
GameManager +0x6DB0 = the FX thread object (routine 0x116B11C)
GameManager +0x6DB8 = the FX performance / budget manager (ClientFxPerfMgr.cpp)
The .fxp file and its loader
An .fxp starts with LTFX and version 8, followed by a GameDB database (FxGlobalProps and one
record per node). The magic occurs exactly once in the executable, in the loader 0x48FBB90: it reads 8
bytes from a stream, checks LTFX and version 8, allocates the 0x60-byte FxResource, reads the
database from the stream (0x74CF10) into FxResource+0x48, and fails with "Failed to load the
database out of a stream." The loader is vf3 and the processor vf4 0x3E4540 of the effect resource
handler (vt_0x2312410); path resolution happens upstream, so the loader only sees a stream.
The file ends with a u32: the count of the database's (u32, u32) pair table (db+0x68), 0 in all 968
shipped effects checked. A file without it makes the body reader 0x4B9B50 read 4 bytes past the end
(0x4B9C60), which stalls the resource loader (see Resource streaming). The database's categories are
sorted by the hash of their path, and the loader binary-searches them (0x115B4D4).
Running the engine's own load and processing on every shipped effect in an emulator, 968 of 968 read
to their last byte and 949 are accepted; the 19 others are placeholders with an empty key list, which
the processor refuses by design. Most effects ship as LTFX sections inside .embb bundles rather
than as loose files: a sweep of 190,407 bundle containers found 30,531 effect instances.
The node types and their properties
The registry is a static array of 36 pointers at 0x21DB460 (ParticleSystem first, VolumetricCloud last) to 36 separate 0x50-byte type entries in the data section (0x2681DD0 - 0x26829F0):
| Offset | Meaning |
|---|---|
| +0x00 | the name "ClientFX.<Type>" |
| +0x08 | the type hash (u32, filled at start-up) |
| +0x10 / +0x18 | create / destroy a pooled run-time object (16 types; per-type pools at 0x277CA08 + 0x10 * i, the tracer's at 0x2A3EA10) |
| +0x20 / +0x28 | create / delete the node's KEY object (bump-allocated from the resource's buffer) |
| +0x30 | destroy the key in place |
| +0x38 / +0x40 | measure / construct the node's per-instance KeyData object (20 types) |
Two families. The 20 keydata types keep one object per node inside each instance's KeyData block: ParticleSystem, GPUParticleSystem, StandardGPUParticleSystem, AnimatedModel, AnimatedPhysicsModel, AnimatedSkeleton, Beam, GPUCloth, MaterialController, Model, ParentedModel, PhysicsModel, PhysicsImpulseRadial, PolyTrail, DynaLight, DecalLight, Sprite, FlareSprite, LensFlare and VolumetricCloud. The 16 pooled types use run-time objects from per-type pools (base class vt_0x1DAA9D0): Sound, CameraShake, Create, CreateRay, Lightning, Tracer, DebrisSystem, Overlay, Rumble, Volumetric, SceneRender, ImpactDecal, Wind, VegetationDisp, PolyString and PolyCurve. Key objects derive from vt_0x1DB46A8 (sprites vt_0x1DCB558, GPU nodes vt_0x1DB46C8); KeyData objects from vt_0x1DAB2F0 (12 slots; vf6, vf7, vf8 and vf10 are the per-type overrides).
Classes confirmed by exact counts (node-type count in the live instances' resources = count of the class's vtable inside their KeyData blocks, two snapshots):
| Type | Key class | Key size | KeyData class | Snapshot 1 | Snapshot 2 |
|---|---|---|---|---|---|
| StandardGPUParticleSystem | vt_0x1DCD848 | 0x330 | vt_0x1DAB290 | 969 | 976 |
| ParticleSystem | vt_0x1DCDA20 | 0x2A0 | vt_0x1DAB3B0 | 481 | 562 |
| MaterialController | vt_0x1DCC0A0 | 0x5B8 | vt_0x1DC37A0 | 257 | 307 |
| Sprite | vt_0x1DCB538 | - | vt_0x1DC3CD8 | 159 | 283 |
| Model | vt_0x1DC8910 | 0x140 | vt_0x1DB4810 | 170 | 171 |
| DynaLight | vt_0x1DCD358 | 0x150 | vt_0x1DC6070 | 154 | 157 |
| GPUParticleSystem | vt_0x1DB4680 | 0x280 | vt_0x1DC5D40 | 5 | 103 |
| Beam | vt_0x200B328 | 0x138 | vt_0x1DCB9A0 | 80 | 80 |
| DecalLight | vt_0x1DCD1A8 | 0x100 | vt_0x1DC37C0 | 23 | - |
A key object belongs to the loaded effect, so it is shared by every instance of that effect. Counted over all resident effects: 9,100 ParticleSystem keys, 3,746 MaterialController, 1,874 StandardGPUParticleSystem, 1,246 Sound and 1,235 Model.
The property schema is built at start-up into the uninitialised data section by per-type
initialisers: each type's property array is cleared (memset, size = count x 0x48), then one
registration call per property: PropRegister(slot, "Name", type code, &default, annotation, 1, sub,
accessor), where the type code is the database's own field type (2 float, 5 string enum with an
annotation such as type=EImpulseFilter, and so on). The descriptor table at 0x263F800 (stride
0x40: +0x00 property array, +0x08 count, +0x10 short name, +0x20 "ClientFX.<Type>",
+0x28 parent, +0x38 flags) lists 50 classes with 2,228 properties (2,227 named): the 36 node
types plus their shared bases - BaseFX (15 properties, the common header of every visual node),
BaseSpriteFX (41), BaseCreateFX (38), LightningComponent (6), FlareComponent (4),
DebrisType (8), OcclusionQueryFX (19), ReferenceFX (2), FxGlobalProps (5) and five curve
classes (CurveFloat, Int, Vector, Color, File). Per-type counts include the inherited ones.
A node record carries only a subset of its type's properties, so the engine binds fields by hash,
not by position.
33 of the 36 node types are used by shipped effects. AnimatedPhysicsModel, Tracer and VolumetricCloud have full property schemas (76, 37 and 39 properties) but appear in no shipped effect.
The managers
CGameClientShell::InitializeSubsystems 0x1772E1C creates five managers, one per time domain
("Could not init SimulationClientFXMgr!", RealTime, PlayerTime, CompanionTime, WorldUI): factories
0x176DC70 (Simulation, a 1 MB arena) and 0x176DBF4 (the others, 512 KB); wrapper constructor
0x1779A64, inner constructor 0x1779B20, initialisation 0x177BB44(manager, time source, flag). Three
more managers exist outside the client shell (updated from 0x224430, vt_0x1FFAFF0 vf18 0x1C815B4,
0x179AF98 and the UI.Portrait path 0x335E9C); eight in all are in the global list.
The wrapper (vt_0x22F3178): +0x08 its link in the global list [base+0x26C6A98], +0x18 a
{vt_0x1DAA870, time source} pair, +0x28 the inner manager. Its vf1 0x482DE0 and vf2 0x482EB4
receive resource events: the wrapper is a resource listener, so an effect resource that unloads
removes its instances from every manager (0x482EC0 -> 0x177C824 under the manager's lock).
The inner manager (vt_0x1F67400, 32 slots): +0x08 the FX thread object, +0x11 threaded (queue
work to the FX thread, otherwise run it inline), +0xB8 the instance vector, +0x108
work-pending, +0x110 / +0x128 work lists (+0x128 = background), +0x148 the time-source pair,
+0x178 arena bytes, +0x19C / +0x1A0 cost per key (128 / 64), +0x1A4 budget left this frame,
+0x1B0 the per-frame arena, +0x18268 .. +0x18320 six deferred queues, +0x18328 the work-done
event. vf2 0x3E9E30 is CreateClientFX.
In an open-world scene the Simulation manager held 541-569 instances, RealTime 2, PlayerTime 1-2, CompanionTime 0 and WorldUI 4; with the world map open WorldUI rose to 85 (the map's forest-fire and smoke effects run in the WorldUI domain, as do the army screen's effects).
Effect instances
Enumerate through the manager, not the heap. The vector at inner+0xB8 holds 0x28-byte entries
whose +0x18 is the instance: 587 of 587 and 663 of 663 entries in two snapshots carried the
instance vtable. A heap scan counts more objects with that vtable (927), because freed slots of the
instance pool keep it.
The entry: +0x00 byte, +0x01 visible this frame, +0x02 admitted by the budget, +0x04 the
cull position (3 floats, cm), +0x10 / +0x14 squared radii, +0x18 the instance, +0x20 the
parent instance. Child effects live in the parent's own vector (+0x18), not in the manager's;
0x1BAD70 picks the container and stores it at instance +0x260 with the index at +0x25C.
The instance (vt_0x1DAB360, second vtable vt_0x1DAB350 at +0x08; constructor 0x1BB368,
destructor 0x1BB79C):
| Offset | Meaning |
|---|---|
| +0x18 | vector of child-effect entries |
| +0x40 | an intrusive list head |
| +0x58 | flag word |
| +0x5C | the transform, copied from the create structure |
| +0xB8 | the KeyData container (+0xBC time; flags 4 active, 1 looping, tested by 0x24EBB8) |
| +0xD0 | the owning inner manager |
| +0x150 / +0x1A8 | handles (vt_0x1DABE28) to the parent object |
| +0x180 / +0x188 | create structure +0x20 / the socket index |
| +0x220 | the KeyData block (one 64-byte-aligned allocation: +0x00 raw, +0x08 size, then the node objects) |
| +0x228 | the resource hash |
| +0x230 | the FxResource |
The most common effects in a desert scene: torch caps (89 instances), collectible arrows (76), attached flies (69), forge fires (51), nest idles (43), night torches (34), dust (26), thermal vents (20).
The FxResource is built once per loaded effect by the processor 0x3E4540:
| Offset | Meaning |
|---|---|
| +0x00 | length in seconds (ResourceLenMS / 1000) |
| +0x04 | MaxUpdateDistance |
| +0x08 / +0x10 | the key slot array / count (a slot is 0x18: +0x00 type entry, +0x08 key object, +0x10 KeyName) |
| +0x18 / +0x20 / +0x28 | the fx\...\x.fxp name, its hash, the short name |
| +0x38 / +0x40 | the aligned buffer holding slots and key objects / its size |
| +0x45 | PauseWhenNotVisible |
| +0x48 | the effect's database - freed after processing (0x3E4765) |
For each key, 0x3E477C finds the node type by the record's type hash (FindFxType 0x3E451C searches the
36 entries), builds the key object in the buffer through the entry's +0x20, lets the key's vf1 parse
the record, and destroys it through +0x30 on failure. An effect resource is resource type 48:
handle+0x52 = 0x30 and [[handle+8]+8] is the FxResource (0x1BBBD4).
The per-frame tick
- Main thread.
CGameClientShellvf1 0x1A864C (after the performance manager's 0x69909C) calls 0x1AA1F8 for the five managers in slot order; it takes dt from the manager's time source (0 when paused) and callsClientFXMgr::Update0x1AAAFC. - Update finishes last frame's work first when
+0x108is set (0x1A9F20, 0x1AC584, 0x1AA000), drains the deferred queues, resets the frame arena, reserves the work list at twice the instance count, sets the budget to a quarter of the arena, runs the instance loop 0x24E040, then queues itself to the FX thread (or runs 0x1AC638 inline) and sets+0x108. - The instance loop 0x24E040 computes visibility from the squared camera distance against the
entry's radii; charges the budget (keys x 128 the first time, keys x 64 for an admitted instance;
over budget goes to 0x177BFE8); advances KeyData timing (0x24F2F0); and pushes the container to the
background list (
+0x128) or the two others. - Dispatch.
CGameClientShellvf2 0x1F6928 (and vf3 0x177473C) -> 0x1F6258([GM+0x6DB0]) swaps the front and back manager vectors and signals the thread. - The FX background thread (routine 0x116B11C) waits on its work event, then for each queued manager runs 0x1AC638: under the manager's lock, every work item goes to 0x188CA4(KeyData container, 1, ...) - the per-node update of the instance's KeyData block - then it signals the manager's done event and its own.
The FX thread object (vt_0x22F3120, name "FXBackgroundThread"): +0x08 handle, +0x18 routine,
+0x20 argument (itself), +0x5C the owner thread (the main thread), +0x90 / +0x98 front / back
manager vectors, +0xA0 dispatched, +0xA1 quit, +0xA8 work event, +0xB0 done event. Creation,
culling, budgets, timing and the instance list belong to the main thread; the KeyData update of
queued instances runs on the FX thread.
Requesting an effect
StartEffectSequenceOnCharacter 0x19B7850 -> 0x19E16CC(_, target, sequence, manager wrapper)
-> a pooled sequence player (vt_0x1DB7540, +0x10 = the manager wrapper)
-> ... 0x15B408 -> 0x15AF18 (builds a CLIENTFX_CREATESTRUCT)
-> 0x1BB950(wrapper, link, cs) the general entry: 72 functions, 87 call sites
-> 0x1BB980(inner, link, cs, 1) = manager vf2: resource check (type 0x30), distance refusal
-> 0x1BD0D0 enable byte 0x266D499, performance manager
allocate from the pool -> constructor 0x1BB368 -> container 0x1BAD70
-> fields from the create structure 0x1AA5EC -> KeyData 0x1BC8A0 + add 0x1BB2E8
-> register 0x1BB5E0
The fourth argument of 0x19E16CC chooses the manager, and with it the time domain the effect runs in.
The create structure: +0x00 the resource handle, +0x08 flags (bit 0 = no distance refusal), +0x10
/ +0x18 / +0x20 three kinds of parent (the manager's vf24 - vf28 build the attachment from whichever is
set), +0x28 socket, +0x30 / +0x38 transform, +0x98 KeyData overrides, +0xB8 a parent-instance
link. Other callers include ranged-weapon hit effects (0x158308), projectile models (0x10F52C), the
player class and the combat area attack (0x4EA0A4).
Attach points use two vocabularies: creature and orc rigs name sockets L_Hand / R_Hand, the
player rig LeftHand / RightHand; the player skeleton's sockets include Torso, Head,
LeftHand, RightHand, LeftFoot, RightFoot, Pelvis, SocketHead, SocketAttach and
Socket_Ring. The string null means unattached.
Particles
ParticleSystemsimulates on the CPU.GPUParticleSystemis the "CustomGPUParticle" path andStandardGPUParticleSystemthe newer GPU path; in the desert the GPU nodes outnumber the CPU ones (976 + 103 against 562).- Each manager's constructor loads the GPU particle materials (0x1D2E440:
StandardGPUParticle_SimEmit.mat,_SimEmit_Weather.mat,_Sort.mat,_ClearIndirectBuf.mat,TextureCopyTo2DArray.mat) and the grouped particle data (0x1D2F1A0,Client/GroupedGPUParticleFX: one record,Fire, 16,384 particles per group). - GPU particles are emitted and simulated by compute shaders and drawn with indirect draws from
GPU-written argument buffers: vt_0x1DBC638 vf4 0x1B572C (
CustomGPUParticle.Compute,_ParticleSource,_ParticleDest,_DrawIndirect), warm-up 0x1D2DB08, sort 0x587AE0; vt_0x2009DA8 vf4 0x1F18D0 -> 0x103A960 (StandardGPUParticle_ParticleBuffer0 / 1); the curves texture array 0x1B722C; grouped particles vt_0x1DBC718 vf4 and vt_0x23125F0 vf4 0x1EFF0C. They run inside the render thread's scene passes (see Threads and the frame: the GPU particle branch 0x33783C), not as separate commands from the main thread.
Budgets
- Per manager: the budget is a quarter of the arena each frame (Simulation 262,144); each
instance costs keys x 128 on admission and keys x 64 afterwards. The console variable
ClientFXMgr.FXInstanceBudget(0x266D4A0) feeds the two costs. - Per effect group (
Client/ClientFXBudget, 7 records: MaxSimultaneousFX / MaxNewFX): OnFire 30 / 10, Blinded 25 / 10, BloodDecals 30 / 4, Poisoned 30 / 10, Footsteps 100 / 10, Easterling_SmokeScreen 2 / 2, OnFire_Balefire 30 / 10, with a budget-exceeded behaviour and pooling rules. They are copied once at start-up: 0x177BCE0 (from 0x1783DD0; skipped while the byte at 0x266D4F8 is set) builds one budget object per record - 0x177A3B4 copiesMaxSimultaneousFXto+0x0C,MaxNewFXto+0x10, the exceeded behaviour and the pooling rules (radius, capacity) - and registers every path in the record'sFXListby itsStringHash. An effect is therefore assigned to its group by the hash of its file path, and an edit to the records after start-up is not seen. - Distance:
MaxUpdateDistance; creation is refused beyond the cull radius; visibility is decided per frame. - Particle limits are node properties (MaxParticleCapacity, MaxParticleLifeSpan, MaxParticleMethod, MaxParticleVelocity), not a global pool.
Open questions
- Which object owns the real-time ClientFX manager outside the client shell, and what class
vt_0x22F3D48 is. In a loaded Lithlad the global list holds eight managers: the client shell's five
at
+0xA98 .. +0xAB8, created by 0x1772E1C on the world, real-time, player, companion and world-UI clocks, and three more. One runs on the UI clock (50 instances) and is created by 0x18CC440 (stringUIFXMgrParent). One runs on the clock[base+0x2702560](0 instances) and is embedded at+0x50of a vt_0x22F3D48 object built by 0x17997F8 during subsystem start-up:[wrapper-0x50]reads that vtable on this manager and on none of the other seven. The last runs on the real-time clock (10 instances), and its creator is either 0x17E1F0C or vt_0x1FFAFF0 vf16. - Which of the KeyData slots vf6, vf7, vf8 and vf10 is the background update and which submits to the renderer.
- How a pooled run-time object attaches to its instance, and what kinds of parent the instance
handles at
+0x150/+0x1A8point to. - Where the per-group counts are checked when an effect is created, and what each budget-exceeded behaviour does.
Shadow of War (Steam), game version 1.21. An unofficial fan research project, not affiliated with or endorsed by Warner Bros. Games, Monolith Productions or Middle-earth Enterprises. Middle-earth: Shadow of War belongs to its owners.