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

  1. Main thread. CGameClientShell vf1 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 calls ClientFXMgr::Update 0x1AAAFC.
  2. Update finishes last frame's work first when +0x108 is 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.
  3. 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.
  4. Dispatch. CGameClientShell vf2 0x1F6928 (and vf3 0x177473C) -> 0x1F6258([GM+0x6DB0]) swaps the front and back manager vectors and signals the thread.
  5. 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

Budgets

Open questions

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.