Threads and the frame
Every engine thread and its routine, how a thread is started, what the main thread does each frame and in what order, and how the render thread consumes the main thread's commands, renders the scene and presents.
The threads
The game runs about 94 threads in the world. 35 belong to the executable, about 33 to the graphics
driver, and the rest to the C runtime, the OS, Bink (5), XAudio2 and the input stack. The engine
names its threads (GetThreadDescription returns the name); 28 carry a name.
| Thread | Routine (RVA) | Role |
|---|---|---|
| MainThread | starts at 0x73D1310, loop 0xC24DB0 | the game frame |
| RenderThread | 0x1C8F158 | consumes the render command ring, renders, presents |
| FXBackgroundThread | 0x116B11C | effect work |
| GameAudioMgrThread | 0x181F638 | the engine's audio manager |
| HavokWorkerThread1-3 | 0x11AFE58 (shared) | Havok task workers |
| EngineAnimationJobThread1-3 | 0x114CBA8 (shared) | animation pose jobs |
| ResourceOpenThread / LoaderThread / ParserThread | 0x11AECC8 / 0x11AEE50 / 0x11AEF58 | the three resource stages (see Resource streaming) |
| ZLibDecompressTask x2, ZLibDecompressThread | 0x11AD438, 0x11AD440 | decompression |
| StorageRequestThread | 0x11C6DB0 | storage requests |
| VideoClipRequestThread | 0x11C7488 | video clip requests |
| DLCPackageMountingThread | 0x11ABCF8 | mounting DLC packages |
| PathSystemRegionThread, PathFindingThread | 0x114AEF0, 0x11E1EA8 | navigation |
| AIThread | 0x116A3BC | AI background work |
| FireSim | 0x1A3D998 | fire simulation |
| MotionSystem | 0x114B018 | motion |
| ContextActionBuilderThread | 0x116C430 | builds traversal context-action lines (see World) |
| GamepadBufferThread | 0x11537D8 | XInput polling |
| OnlineServiceThread, MovieIOThread | - | online services, movie I/O |
Seven engine threads carry no name; the strings next to their creation identify them: Wwise's
AK::EventManager (0x1157C74), AK::IOThread, AK::BankManager and AK Suspended, the
AsyncFunctionCallerThread of the online API layer (0xA02CB0), a start-up helper (0x116BBF0) and one
thread created from the protected code region.
In the world, in combat, the render thread uses about one full core (970-1000 ms of CPU per second), the main thread about half a core, and the two busiest driver threads about 360 and 200 ms per second. The FX background thread, the audio thread, Havok worker 3 and the Wwise event manager each use 16-80 ms per second; the rest are idle.
How a thread is started
An engine thread is an object of a small class with 11 virtual methods: vf0 the destructor, vf1 the
shared Start 0x529500, vf8 the thread's name, vf9 its start stub and vf10 its stack size (the AI
thread, vt_0x22FF668: "AIThread", 0x1AEA674, 0x10000). Start(this, routine, arg) stores the
routine at +0x18, the argument at +0x20 and the name at +0x28, creates the OS thread suspended
on the stub (handle at +0x08), applies priority and affinity settings and resumes it. Every stub
is a jump to the trampoline 0x797DD4, which names the OS thread and calls routine(arg). Every
engine thread's stack therefore ends at 0x797DD4.
The main thread
The main loop is 0xC24DB0. Each iteration either does the frame's work under 0x2D47E0 or waits in
0x3807E8 -> 0x380FAC -> 0x38105C (MsgWaitForMultipleObjectsEx).
The wait is for the render thread. 0x38105C takes the render command queue (0x272E8D8; see the
render thread below) and calls 0x1CF430 with its state block (+0x38). 0x1CF430 loops while the
render thread is behind: while the value at +0x198 is below the value at +0x1A0, or equal to it
and the ring's read cursor +0xD0 is still below +0x188. Each pass sets +0x1EC, signals the
render thread's event (+0x130) and waits on its own event (+0x128) with
MsgWaitForMultipleObjectsEx, so window messages are still dispatched while the main thread waits;
when the stop byte +0x120 is set it only pumps messages (PeekMessageW). The main thread can
therefore run ahead of the renderer only as far as the queue allows.
Where the time goes
Sampled during play (4,000 samples at 400 Hz, every walk unwound to the thread's root 0x8C3E5C):
| Share | Frame | What it is |
|---|---|---|
| 83.6% | 0x2D47E0 -> 0x1F68D8 | the frame's work |
| 51.4% | 0x1A864C | CGameClientShell vf1, the per-frame client update |
| 16.8% | 0x6B31DC -> 0x2FA064 -> 0x2F94F0 | the object update scheduler running each NPC's update (NPC vf18; below) |
| 14.9% | 0x1F6928 -> 0x1F6958 | MainScene vf10, the scene tick (below) |
| 12.0% | 0x3807E8 | waiting |
| 6.1% | 0x1F7304 | the AI world tick (inside the scene tick) |
| 5.4% | 0x3365A4 | MainScene vf1, render submission (the HUD's CUIMgr::Render inside it) |
| 5.0% | 0x6528B8 | Havok physics (TtPhysics) |
| 2.7% | 0x1B0EE4 | animation pose jobs drained on the main thread |
The children of CGameClientShell vf1 run in this order (775 frames, 601 with at least two
children sampled): 0x300CFC (vt_0x22C0BB0::vf1), 0x1A83AC -> vt_0x2301178::vf14 0x1A8490 ->
0x187A38 (a per-frame client update that fans out to many systems, the animation clip trackers among
them), 0x1776B30, 0x1609AC, 0x47C1D4, 0x51C40C, 0x427B74, then Havok memory upkeep 0x709FC8.
When the game window loses focus the simulation pauses: the world clock stops, the per-character update disappears, the scene tick drops to 5.8% of samples and the main thread spends 64-68% of its time waiting, while the renderer keeps drawing the paused scene at full frame rate.
The object update scheduler
Game objects are updated by a per-object countdown, not by a character loop. 0x2D47E0 passes
[base+0x26FF050] to 0x1F68D8, which skips the walk while the object server [base+0x26FF058]
reports itself busy, then calls 0x6B31DC on the update manager U = [base+0x26FF050]+0x3E0
(vt_0x22C0050). 0x6B31DC first drains a deferred-delivery buffer at U+0x690 (0x4CA9F8): each
entry names a target object (+0x20) and a 16-bit tag (+0x28), and 0x4CAD68 decodes the entry's
recorded payload in chunks of up to 512 bytes and hands each chunk to the target's vtable slot 19;
the buffer is then reset and time-stamped. Then it calls 0x2FA064:
- 0x2FA064 walks the object sets linked from
U+0x18(node atset+0x18, next atnode+0x08; 130 sets in a loaded region). For each set it takes the set's due listset+0xE8and hands every object on it to 0x2FA10C. - 0x2FA10C subtracts the object's time-domain step
[[obj+0x88]+0x14]from its countdownobj+0xD0. If the countdown is still above zero the object goes back on the list; otherwise it tail-jumps to the object's vf18, its Update (vtable+0x90), which is why vf18 appears directly under 0x2FA064 in an unwound stack. SetNextUpdate0x59AB3C(object, delay) stores the delay inobj+0xD0and links the object into its set's due list ([obj+0x58]+0xE8). It has 119 callers and 36 tail calls, many of them other classes' Update methods.
The NPC class is vt_0x1FB3640 (the engine's class descriptor [[obj+0x78]]+0 reads NPC on 98 of
98 objects; vt_0x1FB3AA8 is the same object's secondary vtable). Its Update 0x2F94F0 first re-arms
itself with a delay of 1e-7 s (the constant at 0x202EA30), meaning "again next frame", then calls
0x2F9478, shared with the player's Update 0x6E92F8. That runs one of: vf121 for a dead body (the
ragdoll and body-cleanup path), vf122 in a special client state, or vf119 0x2F79A4, the AI update.
One object set holds every character in the spawn manager (101 of 101, 98 of 98, 124 of 124 and 163
of 163 in four reads), and every one is re-armed at 1e-7 every frame, at distances from 0 to 467 m.
Nothing on the path reads a position or a distance: there is no distance-based update rate for
NPCs. Pausing works through the time source: while the game is paused its step is 0.0 (102 of 102
characters), so no countdown reaches zero and no NPC Update runs. The time source [obj+0x88] is
the state of one of the engine's scaled clocks: NPCs use the world clock and the player the player
clock (see Engine core), which is also how slow motion reaches their updates. The other objects on due lists (spawn closets, look-at and quest triggers,
scenario-override volumes, quests, birds, spiders, script timers) together take under 0.3% of the
main thread.
Cost in play (three runs of 15, 15 and 20 s at 400 Hz, 1,220-1,857 presented frames, each sample attributed to the object whose Update was on the stack): the scheduler takes 16.3-17.3% of the main thread and the NPC Update 1.52-1.89 ms per frame, 12-14 µs per NPC per frame. The cost does not fall with distance (beyond 200 m: 10.7-11.4 µs per NPC); it follows what the NPC is doing (idle 9.9 µs, following a route 12.7, attacking 26.4, recoiling 20.8). Inside vf119, in call order:
| Share of the NPC update | Call | What |
|---|---|---|
| 26% | vf113 -> 0x11B230 | target selection: combat-threat scoring 0x2FA99C (about 7%) and a scan of the 27 target-select pickers in the table [base+0x26FFCC0] (0x11BF60, 9-12%) |
| 10-12% | vf112 -> 0x11AEB8 | aim and look-at trackers |
| 4.5-7% | 0x2F5E6C | movement and path following |
| 33-38% | 0x1919C8 | the plan tree tick (see AI): selection 4-5%, running steps 29-33%, of which the Attack step's attack picking 0x4612D8 alone takes 5-12% |
| 2% | 0x2F5EB4 | collision avoidance radius |
| 3-4% | 0x2FD960 | the NPC's services, each on its own timer against the world time |
| 4% | 0x2F6420 (tail) | context actions, perception target overrides, post-update state |
Morale, talk, awareness markers and the rest take under 3% each.
The scene tick
MainScene is a static singleton [base+0x26FFAB0] (vt_0x1F68920). Its vf10 0x1F6958 is the scene
tick; it calls, in this order (each call with the object it runs on; scene = the MainScene object,
GM = the GameManager [base+0x26FFAA8]):
scene+0x778AI pre-tick 0x1F73DC, then the AI world tick 0x1F7304scene+0x7880x1F7240scene+0x798: game mode 0x11FA58, projectiles that stick or fly 0x1F721C, weapon-mode item givers 0x1F6EC4, and 0x4C6050, the list of synchronised / attached characters (it clears each NPC's AI-suspend bytenpc+0x2C21)scene+0x7D8render restart 0x4E1538GM+0x6C68the faction system's fort-assault composition 0x1E2BC4, thenGM+0x6CC8fort-assault setup 0x4F4BDCGM+0x6D30loading progress 0x1E3E78 (it reportsENTER_WORLD_HANG_DETECTED)scene+0x7E8bundle unloads and world UI 0x16D9CC,scene+0x868the character bundle manager 0x4B4FFC,scene+0x7E0presentation bundles 0x5211DC- the collision global 0x2FD3B8,
scene+0x7A8slow motion 0x5CF55C,scene+0x7B0dynamic object groups 0x5AC9AC,scene+0x7D0resurrection and ambient areas 0x6978C8 GM+0x6D28gifts and world items 0x6EBE70,GM+0x6C60the quest system 0x4A859C,GM+0x6D98weather 0x43C574- world reset 0x1789F0C, then
GM+0x6D900x179F64 (it walks a vector of objects and calls 0x179F98 on each one whose byte+0x252is set) GM+0x6D08the script system 0x6D19B4
The tick makes 52 calls in all, with QueryPerformanceCounter timing around the AI world.
The AI world tick 0x1F7304 runs on W = scene+0x778: battle-node capture W+0x88 0x6546E8,
smart objects W+0x10 0x6B5258, AI core W+0x18 0x6EE15C, NPC summon and dismiss W+0x08
0x1234EC, fight circles W+0x28 0x6DE538, transient alarms W+0x38 0x607838, AI core
W+0x40 / +0x48 0x48C7E0 / 0x6870FC, find-player W+0x70 0x404EEC, the buddy / follower system
W+0xB0 0x3608C8, AI core W+0xB8 0x6D5394, the spawn-scenario module W+0xA8 0x378404 and AI
core W+0x90 / +0x98 0x72ABE4 / 0x1F5C94. The AI world also holds the spawn manager (W+0x30)
and the alarm system (W+0x38); see AI.
The render thread
The render thread is a static object at 0x267E338 (thread class vt_0x1FFD4D0: vf8 name
0x7F9AE0, vf9 stub 0x7F9AF0). Its routine 0x1C8F158 runs a one-time initialisation 0x1C8ECB8 (it
registers the world-mesh, world-client, texture-animation and CPU-texture resource types and the fog
overlay material), signals that it is ready, then loops while (0x1C8EDE0(queue)) {}: it is a
command processor.
The command ring
The queue lives at 0x272E8D8; its state block at +0x38 holds:
| Offset | Meaning |
|---|---|
| +0x08 | ring begin |
| +0x18 | ring end (a 2 MB ring) |
| +0xD0 | read cursor |
| +0xD8 | write cursor |
| +0x120 | non-zero stops the loop |
| +0x128, +0x130 | counters |
A command is a bound call {+0x00 thunk, +0x08 object, +0x10 method, +0x18 arguments}. The
executor 0x188E08 calls the qword at the read cursor with rdx = &cursor; the thunk advances the
cursor by its own fixed size (mov r8,[rdx]; lea rax,[r8+N]; mov [rdx],rax, 35 sizes in use) and
tail-calls method(object, arguments); the executor then aligns the cursor to 8 bytes. The ring can
therefore be walked exactly from its start to the write cursor.
A frame is 360-371 commands from 44 distinct methods. Across two snapshots (24,944 commands / 68
frames and 18,169 / 49 frames) every frame issued its 22-23 once-per-frame commands in the same
order. In order: setup 0x485420; 0x258720 (28 per frame); 0x177D7C (53 per frame, shader-constant
uploads); three calls on the surface-overlay renderable (vt_0x1FFECD8: 0x70C0E0, 0x7146A0, 0x72FDE0;
the rain / fire overlay pass); 0x5E9448 (53); a once-per-frame block with terrain 0x484844; a long
interleave of per-view work on objects of vt_0x1DAADB8; weather material overrides 0x1B7A40 /
0x1B7A94; the scene command (below); light-volume instancing 0x6C74A8
(DrawIcosahedronInstancesIndirect); 13 tiled-lighting tile updates (0x1791CC BufferTileIndices
with 0x247108); texture bindings 0x33651C; exposure 0x1E8150 (Lith.ExposureAdjustSpeed,
Lith.BlowOutWeight); frame timing 0x6D5908; Present 0x38052C; Bink video 0x3322BC (it walks
the live-video list 0x267E400); 0x4C32E8 (a call into the engine interface's render service); and
high-resolution texture streaming 0x3807E4.
Where the render thread's time goes
Sampled during play (4,000 samples at 500 Hz; every stack unwound to the thread trampoline):
0x797DD4 trampoline -> 0x1C8F158 render routine -> 0x188DB0 -> 0x188E08 executor (99.8%)
0x1EE210 scene command 79-85%
0x6EC61C -> 0x116B694 -> 0x1EAA54 (k_fSampleScale) -> 0x1EABC4
0x1EAF30 (animation-side work), 0x255CF8, 0x1EB038 first, in this order
0x33BCB8 scene passes 69-78%
0x33CD60 main pass list 56-67%
0x255920, 0x185410 G-buffer prelude (target set 7)
0x66E93C -> 0x1EE4F8 -> 0x387858 mesh pass A: the G-buffer 24-42%
0x33783C GPU particles 2-4.5%
0x1F02E8 -> 0x1EE3E4 / 0x25619C mesh pass B: the shadow atlas 10-20%
0x384640 light accumulation (point-light compute 0x4D951C runs here) 3-4%
0x1021250 vegetation displacement and grouped GPU particles 3-4.5%
0x33740C post-processing (motion blur, depth of field, blur, vignette)
0x339D6C colour-grading LUT
0x1EDE58 object-colour compute 6-7%
0x38052C Present 7-10%
The order inside 0x33CD60 comes from consecutive samples (taken 1.4 ms apart): when two
consecutive samples sit in different children, the first child ran before the second. Over 8,370
samples every such move went one way only: 0x255920 to mesh pass A 380 times (0 the other way),
0x185410 to A 136 (0), A to the GPU particles 193 (0), the GPU particles to mesh pass B 89 (0), A to B
685 (0), B to the light accumulation 0x384640 214 (0), and the light accumulation to the next frame's
0x255920 18 times. The frame is therefore: G-buffer prelude, G-buffer (A), GPU particles, shadows (B),
lighting. Which targets each pass draws into is in Rendering. In 0x33BCB8, 0x33CD60 runs first,
0x1021250 next and 0x1EDE58 last (a forward pass writing scene colour and motion). In 0x1EABC4,
the three small children run before 0x33BCB8 in all 303 frames where two were sampled. The tree is
the same whether the world is playing or paused.
What identifies each branch: the GPU particle branch references
StandardGPUParticle.Compute, StandardGPUParticle_ParticleBuffer0/1 and
CustomGPUParticle_DrawIndirect / ParticleSource / ParticleDest; the vegetation branch
VegetationPatch_InstanceBuffer_DisplaceInstances and GroupedGPUParticle_Source / DestDrawIndirect;
the post-processing branch tNeighborVelocityBuffer, tMaxVelocityBuffer, k_fNearDOFParams,
k_fFarDOFParams, k_fBoxBlurParams and k_fVignetteParams. Both mesh passes draw through the
same indexed-draw path (input assembly 0x17F1B0, DrawIndexed 0x17F5A4, the indirect mesh draw
0x17DC48).
Present
0x38052C calls 0x38057C with the engine's static D3D block at 0x272D1C0 as this:
| Offset in the block | Meaning |
|---|---|
| +0x00 | the device (ID3D11Device1) |
| +0x28 | the immediate context as ID3D11DeviceContext1, the handle the engine draws through ([base+0x272D1E8]) |
| +0x88 | the IDXGISwapChain |
| +0xA8 | presented-frame counter ([base+0x272D268]) |
| +0xE0 | flags (bit 0x800: tearing allowed) |
| +0x7D0 | the display refresh rate |
| +0xA80 | set when the window is occluded |
It flushes the context (ID3D11DeviceContext::Flush, vtable slot 111), then calls
IDXGISwapChain::Present (slot 8). The sync interval is 0 unless the caller asks for vertical
sync, in which case it is the refresh rate divided by the frame-rate cap [base+0x2A371A4], clamped
to 1-4; the flags add DXGI_PRESENT_ALLOW_TEARING when the swap chain supports it.
DXGI_STATUS_OCCLUDED sets the occluded byte, device-removed and device-reset errors go to the error
reporter 0x1C8AD0C, and every successful present increments the counter at +0xA8. The byte
[base+0x267E006] enables presenting. On a 240 Hz display the counter advanced 86 times per second.
Open questions
- What kinds of payload the deferred-delivery buffer at
U+0x690carries, and who records them. - Whether a longer countdown written into
obj+0xD0after an NPC's Update would thin out distant NPCs safely: it depends on whether the skipped systems use the frame step or elapsed time.
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.