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:

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

  1. scene+0x778 AI pre-tick 0x1F73DC, then the AI world tick 0x1F7304
  2. scene+0x788 0x1F7240
  3. scene+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 byte npc+0x2C21)
  4. scene+0x7D8 render restart 0x4E1538
  5. GM+0x6C68 the faction system's fort-assault composition 0x1E2BC4, then GM+0x6CC8 fort-assault setup 0x4F4BDC
  6. GM+0x6D30 loading progress 0x1E3E78 (it reports ENTER_WORLD_HANG_DETECTED)
  7. scene+0x7E8 bundle unloads and world UI 0x16D9CC, scene+0x868 the character bundle manager 0x4B4FFC, scene+0x7E0 presentation bundles 0x5211DC
  8. the collision global 0x2FD3B8, scene+0x7A8 slow motion 0x5CF55C, scene+0x7B0 dynamic object groups 0x5AC9AC, scene+0x7D0 resurrection and ambient areas 0x6978C8
  9. GM+0x6D28 gifts and world items 0x6EBE70, GM+0x6C60 the quest system 0x4A859C, GM+0x6D98 weather 0x43C574
  10. world reset 0x1789F0C, then GM+0x6D90 0x179F64 (it walks a vector of objects and calls 0x179F98 on each one whose byte +0x252 is set)
  11. GM+0x6D08 the 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

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.