AI: planning, perception, spawning and group combat

How an NPC decides what to do: the planner and its plan tree of steps, target selection and threat, perception, the sighting matrix, alertness and forgetting; how NPCs come into the world through the spawn manager, ambient spawners, spawn channels, templates and summon requests, with gangs, bands and mounts; the AI budget and constants the engine copies at start-up; and the fight pits.

Every NPC runs one update per frame that selects a target, aims, moves and ticks a tree of planner steps whose priority order decides its behaviour. NPCs enter the world as requests queued on the spawn manager, made by ambient spawners, scripts and summons, and resolved from a spawn channel to a template, a model and a character type.

The AI world and the NPC

The AI world is the object at [[base+0x26FFAB0]+0x778] (the MainScene's AI slot; its members and the order in which the scene tick runs them are in Engine core and Threads and the frame). Of the 24 non-null qwords in its first 0xC8 bytes, only the object at +0x98 begins with an image vtable: it is the engine thread object of class vt_0x22FF668, the AIThread.

An AI character is an NPC (vt_0x1FB3640; the class descriptor gives 0x3180 bytes). Its AI components hang off the character:

Offset Component
+0x2128 the AI/CharacterTypes record (the brain lookup reads it)
+0x2410 the alignment (Character/Alignments, see Characters)
+0x2BF8 u32 index into the sighting matrix
+0x2C21 byte that suspends the plan (set while a queued teleport is pending; see Quests, story and scenes)
+0x2C70 the target tracker
+0x2CE8 the planner (below)
+0x2CF0 the perception component (its +0x00 points back at the NPC)
+0x2CF8 movement and path following
+0x2D00 the service set
+0x2D08 morale
+0x2D18 alertness (0x190F7C returns its current AI/Alertness/Level)
+0x2D20 talk
+0x2D28 the target selector (the perceiver; its current focus at +0xF0)
+0x2D38 the plan tree (below)
+0x2E71 AI-alive byte, cleared when the AI dies

The update. The object update scheduler runs every NPC's Update every frame; there is no distance-based rate (Threads and the frame has the scheduler and the cost of each part). Inside the AI update vt_0x1FB3640::vf119 (0x2F79A4) the order is fixed by the call sites: target selection (vf113 -> 0x11B230), aim and look-at (vf112 -> 0x11AEB8), movement (0x2F5E6C), the plan tree tick (0x1919C8), collision avoidance, the service set (0x2FD960), morale, talk, awareness markers, and last context actions and perception target overrides (tail 0x2F6420). Per NPC per frame the plan tree takes about 35% of the update and target selection about 26%; an NPC in Attack costs about 2.7 times an idle one.

The planner

The planner [npc+0x2CE8] is a 0xFF8-byte object with no vtable. The NPC class builds it in 0x49B858: 0x49CB20 allocates 0xFF8 bytes and runs the constructor 0x45F354, which builds seven 0x58-byte sub-objects at +0x118 .. +0x328. The character's destructor 0x49CEC0 tears down +0x2CE8, +0x2CE0 and +0x2CD8 in reverse order. 779 functions load the pointer, among them the script natives NPC::Search and NPC::CircleMidPoint and the character methods vf19, vf54, vf75 and vf111.

The planner holds no step. Members read by the code around it:

Offset Meaning
+0x430 when non-null, the target-select scan is skipped
+0x440 the NPC's bitset of allowed target-select pickers
+0x500 float, reset to 1.0 when a step ends
+0x697 byte, cleared when a step ends
+0x7A4 when set, the AI update first calls 0x2AD844 on the alertness object
+0x7D0 read by the Flee step's update
+0x7F8 an intrusive list of observer hooks from many objects
+0x810 the current target the running steps validate against
+0x910 despawn requested (NPC::DespawnOffscreen 0x1A8BC08)
+0xA60 non-null while the NPC is infighting (NPC::IsInfighting 0x1A8D1B8)
+0xB18 / +0xB1C / +0xB20 presentation state (0x190FF0 ORs the three)

Step types

A step type is a GameDB category under AI/Planner/Steps/; its records carry the tuning (FleeAmbush.TraitsRequired, FleeAmbush.SearchDistance ...) and a C++ class implements it. AI/Planner has 69 categories: Context, Steps (the base), Trees and 66 step types:

Animate, AssembleAtNode, Attack, AttackPreview, Attract, BaitAttract, BeckonWithTalk, CaptureBattleNode, CheerExitCombatSpaceSource, CircleAlly, CircleTarget, CombatRecoil, CombatState, CombatTaunt, CombatWeaponChange, Cower, DespawnOffscreen, ElevateTransientAlarm, ExitCombatSpace, Fall, FindAlertnessSource, FindPreceivedOpponent, Flee, FleeAmbush, FollowLeader, FollowRoute, FollowWaitForBand, GiveUpOnPlayer, GoTo, GoToAndMount, GoToCircleAlly, GoToPreferredAttackPosition, GoToRallyPoint, Idle, Investigate, Investigating, LeadPlayer, LeaveObstacle, MenaceTask, Mounted, MoveTowardSpawner, NodeAnimate, Patrol, PerformSyncAction, PresentationIdle, Priority, ReactToAlertnessChange, ReactToAreaAlarmLevelChange, ReactToDeadBodyInvestigation, ReactToGlimpsed, ReactToKnownDeadBody, ReactToMoraleModifier, Retreat, RetreatIdle, RetreatToDistance, ReturnToBandLeader, ReturnToBattleNode, ReturnToConfinementNode, ReturnToPursuitConstraint, TauntOpponentNonAttackable, TraitCatalystReaction, UseAttackNode, UseFixedArcherNode, UseWorkNode, WargChaseTarget, WargCircleTarget.

AI/Planner/Trees has 8 records: BasicEnemy, BasicEnemy_Archer, Goblin, Idle, Prisoner, Spider, Troll and Warg. Every tree but Idle lists ForcedAttack first and then, in this order, PerformSyncAction, Fall, CombatRecoil, CombatState, Animate, Mounted, NodeAnimate and PresentationIdle before its own entries; BasicEnemy inserts Attack_HighPriority right after ForcedAttack. Idle's only step is Idle.

Step classes

A step's GameDB record type selects its C++ class (below). Classes read from live plan stacks (the vtable at step+0 against the category of the record at step+0x10: 12 of 12 pairs one to one) and classes named by the categories their own methods read:

Step Class
Attack vt_0x1FBD028
BaitAttract vt_0x1DCAE08
CheerExitCombatSpaceSource vt_0x1DC62B8
CircleTarget vt_0x1DC60E8
CombatRecoil vt_0x1DC6420
Cower vt_0x1FBCF08
DespawnOffscreen vt_0x1FBCE78
FindAlertnessSource vt_0x1FBCDE8
FindPreceivedOpponent vt_0x1FBCD58
Flee vt_0x1DC5010
FleeAmbush vt_0x1FBCCC8
FollowLeader vt_0x1DAA638 (in 121 of 121 trees' step lists)
FollowRoute vt_0x1DB0C98
GiveUpOnPlayer vt_0x1DB0D38
GoToPreferredAttackPosition vt_0x1FBCB00
Idle vt_0x1DB57B0
Investigate vt_0x1DC4ED0
Investigating vt_0x1DB55F0
MenaceTask vt_0x1DC5F50
Mounted vt_0x1DC6BB0
Priority vt_0x1FBC5E8
ReactToAlertnessChange vt_0x1DB5720
ReactToAreaAlarmLevelChange vt_0x1DB5520
RetreatIdle vt_0x1FBC8C0
ReturnToBandLeader vt_0x1DC6540
UseFixedArcherNode vt_0x1DC4F80
UseWorkNode vt_0x1DC51E0
WargChaseTarget vt_0x1DCB0D8

Every step derives from vt_0x1FBD1D8; the steps that issue a move request (Flee, FleeAmbush, DespawnOffscreen) share the bases vt_0x1DAA7E8, vt_0x1DB5500 and vt_0x1DB1050. MenaceTask reads AI/SmartObjects fields, and the class vt_0x1FBAC38 that reads FollowLeader fields is not the step's class.

A step object holds +0x08 the NPC, +0x10 its definition record (AI/Planner/Steps/<Type>; the field accessors take it) and +0x19 a byte that vf1 reports. A Flee step is 0x5F0 bytes.

The step interface

Read from Flee (vt_0x1DC5010) and FleeAmbush (vt_0x1FBCCC8), and from the tree code that calls the slots:

Slot Role
vf0 scalar deleting destructor (Flee 0x72A3AC: destructor 0x72A3E0, then the sized delete 0x8C4370 with 0x5F0)
vf1 writes 2 bytes: a class constant (Flee 1, FleeAmbush 0) and this+0x19; every step class's vf1 lies in 0x780680-0x780710
vf3 PREPARE(context), called on each candidate right before vf5 at selection
vf4 RELEASE, after a rejected vf5 and after vf10 when the step leaves the stack
vf5 EVALUATE(context, &out) at selection: 0 = take this step, non-zero = reject. Flee returns 2 when NPC::IsFleeing is false; FleeAmbush also needs the player's bodyguard and its record's TraitsRequired and SearchDistance
vf7 called on the finished step during unwind; a non-null result is copied into the parent's context
vf8 return true in both classes read (0x4E7DD0 / 0x7F7DE0)
vf9 UPDATE(forced, context = tree+0x48, &out), every tick: 0 = keep running, 1 or 2 = finished. Flee and FleeAmbush return 2 when [planner+0x810] no longer equals the target they stored (this+0x5E0 / this+0x430)
vf10 END(reason): 1 when the step finished with status 1, else 2. Flee cancels its move request (0x44344C), stops fleeing (0x359988), clears planner+0x697 and sets planner+0x500 to 1.0
vf11 EVENT(message): 0x3F5B38 sends a message to every active step (from character vf19)
vf12 / vf13 CHILD FINISHED, on the parent: vf13 when the child returned 1, vf12 when it returned 2; a non-zero return ends the parent too (0x3763A8)
vf14 called on every active step by 0x3F566C (from 0x4428B0, which also reads AI/Count/Configuration.RunSimulation)
vf15 STILL VALID(context): 0x3F5AD0 (from character vf53) unwinds the stack from the first step answering false
vf16 debug description ("Approaching", "")
vf17 debug filter match (0x1B04B68, only while the byte 0x2A361F1 is set; filters at 0x2A361F8 / 0x2A36200)

A step's preconditions are code in vf5 and vf9 and its tuning is its record, so whether an NPC flees is decided by NPC::IsFleeing and the record's gates, not by the Flee step's existence.

The planner's debug output is compiled in but never switched on. The switch 0x2A361F1 is read by seven instructions (0x191030, vt_0x1DB55F0 vf5, vt_0x1FBC5E8 vf5 and vf6) and written by none; it lies in zero-initialised memory, so it stays 0 in a normal run. The two filters are string objects created at start-up (0xC16F0, 0xC1780) that the vf17 match 0x1B04B68 compares with an object's name.

Construction and the step factory

The plan tree is built once, at character set-up, from the NPC's AI/CharacterTypes.PlanTree record (accessor 0x7279C0): vt_0x1FB3640::vf12 0x3FA718 -> 0x45AFDC -> 0x6FC774 -> 0x6FC7B8 -> ... -> the step factory 0x45CEE8 (2,800 bytes). The factory reads the step record's category ([record+0x18]), compares it with a table of cached category pointers at 0x2A359D8 + n x 0x18 and jumps to that type's creator (Flee 0x6E71D8 -> constructor 0x6E7288, FleeAmbush 0x6EB52C -> 0x6EB5DC, Cower 0x1ADD54C -> 0x1B02E84; each creator is called only from the factory). All 64 branches resolve to a category and a class vtable. The factory recurses (0x45CEE8 -> 0x45C724 -> 0x45CEB0 -> 0x45CEE8), so composite steps build their children. 0x45AFDC stores the result at [npc+0x2D38] by move-assignment (0x75F2DC); the old tree is freed by 0x3EB630.

The services are built from the type's AI/CharacterTypes.ServiceSet records by the loop 0x45B1B8, which calls 0x45B290 with [npc+0x2D00] and each record. If the character type has no PlanTree, the brain lookup returns null and the creator frees the new object (see Spawning).

The plan tree

[npc+0x2D38] is a unique pointer to the plan tree:

Offset Holds
+0x00 the AI/Planner/Trees record
+0x08 the NPC
+0x10 the root step (AttackPreview record ForcedAttack)
+0x18 / +0x20 the active stack, 0x18-byte entries {u8 ticks, u32 status @+4, u64 @+8, step* @+0x10}, root first, the running leaf last
+0x30 / +0x38 every top-level step, 0x10-byte pairs {step*, u8 forced_ok @+8, u8 presentation_ok @+9}, in the order selection tries them
+0x48 the context passed to step slots
+0x50 / +0x51 / +0x52 replan request / presentation latch / reset request

A live read of 115 NPCs found a tree on every one: BasicEnemy 92 (53 steps in the list), BasicEnemy_Archer 20 (49 steps), Warg 2, Troll 1. A typical stack is Priority:Passive -> FollowRoute:FollowRoute or a single Idle. The leaves in that read: Idle 57, FollowRoute 25, Attack 13, CircleTarget 5, CombatRecoil 4, Mounted 4, UseWorkNode (record UseMenaceNode) 3, and one each of FindPreceivedOpponent, ReactToAlertnessChange, GoToPreferredAttackPosition and Flee. Child steps appear only on active stacks; the +0x30 list holds the top-level priority entries.

The tick

0x1919C8(tree, forced) is called by the AI update (vf119, forced = 0, skipped while 0x11548E0(npc), i.e. while npc+0x2C21 is set) and by 0x2FD850 (forced = 1, which checks the same byte). 0x2FD850 is reached from the batch 0xF41E30 right after Quest_AI_REPLAN_Requesting_replan...: a replan a quest requests ticks at once.

  1. Reset. With +0x52 set, 0x3EB9C4 ends every active step (vf10) and clears the stack.
  2. Presentation. The planner's presentation state (0x190FF0) is latched in +0x51; on its rising edge 0x4E7E04 ("presentation started") unwinds the plan, so a scene pre-empts whatever the NPC was doing.
  3. Select 0x191030, when +0x50 is set and the character's vf113(1) is true, or the stack is empty (through 0x3F65F4): walk +0x30 in order, skip the running step and every pair not allowed now (0x191374: a forced tick needs byte +8, a presentation byte +9), call vf3 then vf5; the first step whose vf5 returns 0 is pushed. List order is priority order.
  4. Update 0x191ADC: each stack entry with ticks != 0 and status 0 gets vf9; a non-zero result either parks in the entry (when a deeper entry follows) or finishes the top step.
  5. Unwind 0x376E28 / 0x3763A8: the finished step gets vf10(reason) and vf4 and is popped; its parent gets vf13 or vf12 with the child's status and ends too when that returns non-zero.

The pair bytes and the list order are per-NPC heap state rebuilt with the tree, so a change to them lasts until the NPC's tree is rebuilt; a step's record is read by vf5 and vf9 each time they run.

In play the tick takes 33-38% of the NPC update, 0.50-0.73 ms per frame for 121-159 NPCs: selection 4-5%, running steps 29-33%. The heaviest running steps are Attack (its attack picking 0x4612D8 over the combat tree's nodes and pools, 7-15%), FollowLeader (3-9%, band movement 0xF498C0), CircleTarget and FollowRoute.

Fights in the planner

During a fight against the player (73.5 s of world time, 1,215 samples, up to 17 NPCs engaged, one fight circle), the NPCs whose leaf step was Attack numbered 0 in 65 of 122 timeline samples, 1 in 32 and 2 in 25, never 3 or more; a typical snapshot had 10-12 in CircleTarget and 1-2 in Attack. The leaf transitions: CircleTarget -> Attack 30, Attack -> CircleTarget 25, GoToPreferredAttackPosition -> CircleTarget 25, CircleTarget -> CombatRecoil 19, CircleTarget -> GoToPreferredAttackPosition 17, CombatRecoil -> CircleTarget 16. The CircleTarget / Attack alternation is the fight circle handing out attack turns; the circle itself is in Combat.

Target selection

The first call in the AI update, vf113(5) -> 0x11B230, picks the NPC's target:

  1. Threat. The target selector [npc+0x2D28] (0x11BCC8 -> 0x11C9AC) scores candidates in 0x2FA99C from AI/CombatThreat (43 records, one per kind: Infantry_Orc, Archer_Orc, Troll_Cave, Balrog, Player, Nazgul ...): CombatThreatTargetWeight, DefaultInitialDetection, DefaultMax, DistanceInfluence, CombatThreatAttenuationRange, ally and opponent proximity influences and distances, CombatThreatInitialValues and CombatThreatThreshold, plus the fight circle's RingSettings.TargetWeightAdjustment. A character type names its picker in AI/CharacterTypes.CombatThreatPicker.
  2. Target selects. A tail jump reaches the target tracker 0x11B258 ([npc+0x2C70]; "Target just changed", "Selecting %s against %s") -> 0x11BE00 -> 0x11BF60, the scan of the target pickers.

The pickers live in a table at [base+0x26FFCC0]: +0x00 u32 count (27), +0x10 an array of picker pointers (0x11C018 returns entry i). 0x11BF60 returns at once when planner+0x430 is set or the bitset planner+0x440 is empty; otherwise, in table order, it skips every picker whose key (picker+0x08) is not in the NPC's bitset, returns the current one if it is reached, and otherwise calls the picker's vf3 and returns the first that passes. AI/TargetSelects has 27 records whose Class field names 22 classes, four records sharing AlertnessSource and three Disturbance; the 27 table objects have the same shape, 22 classes in the vtable range 0x22FF778 - 0x22FFD18 with vt_0x22FF778 four times and vt_0x22FF9B8 three times:

Record Class Cost
Mounted Mounted 0
Fleeing Fleeing 0.4
BuddyCommand BuddyCommand 0.9
BaitAttract, PresentationIdle same 1.0
AnimateTask, CombatScripted same 1.5
AlertnessSourceWhileSurprised AlertnessSource 1.9
CombatThreat CombatThreat 2.0
GoToAndMount GoToAndMount 3.0
OpponentNonAttackable OpponentNonAttackable 3.5
OpponentDamage Disturbance 3.9
OpponentPerceived OpponentPerceived 4.0
OpponentDisturbance Disturbance 5.0
ScriptedSearch ScriptedSearch 6.75
Disturbance Disturbance 7.0
CircleAlly, AlertnessSourceFromAlert, AlertnessSourceFromAlert_Monster, AlertnessSourceWhileAlert CircleAlly / AlertnessSource 8.0
Talk Talk 8.5
LeadPlayer LeadPlayer 8.9
Ally, ExitCombatSpaceSource same 9.0
OpponentGlimpsed, KnownDeadBody same 10.0
WaitForBandMember WaitForBandMember 11.0

The other fields are Delay and ChainDelay (pool ranges), MaxChainDelayCount, UseSuperJumpAgainstPlayer, UsePursuitConstraint, Stimulus (CombatThreat and CombatScripted list FactionThreatSound, AlertLookoutSound, AttackingTarget, BandMateThreatSound, AlertLookoutVisible) and Validation (Surprised, Alert, LostPlayerFromAlert, OpponentDamage ...). A character type chooses its set in AI/CharacterTypes.TargetSelectSet (Default, Default_Monster ...).

An AI's target comes from this pass, not from its attacks: AttackData.Target is null on all 34 Balrog and AI-Talion attacks sampled. NPC::SetCombatScriptedTarget 0x1A8DC60 refuses a dead NPC and hands a script's target to the target selector [npc+0x2D28] (0x44C644) and then the planner.

The character type

AI/CharacterTypes (81 records, 62 fields) gathers everything above for one kind of AI. For Troll_Cave: PlanTree Troll, TargetSelectSet Default_Monster, Alignment Troll_Cave, PlayerFollowerAlignment PlayerFollower_Beast, BrandedAlignment / ActivatedBrandAlignment WraithInfight_Beast, SensorSet Trolls, ServiceSet Troll, AlertnessProfile Troll, Perception CaveTroll, PerceptionTarget Trolls, CombatThreatPicker Troll_Cave, CharacterVisibleStimulus TrollVisible, CharacterAudibleStimulus CharacterAudibleLoud, SenseUpdateRate 0.1, GiveUpOnPlayerData Troll, ActAsFactionMember False, CanRetreat False, plus movement (rotation times, stopping distances, WalkRunRange), collision avoidance and mounted-collision shape fields.

Perception

The perception table

AI/Perception/Types (240 records) is compiled once, at start-up, into a table of the AI system [base+0x26FFBF8]: +0x300 the rows, +0x308 u32 count (240, all AI/Perception/Types), 0x58 bytes per row:

Row offset Value (from the record)
+0x00 the record
+0x10 PerceiveRadius
+0x14 RecognizeRadius
+0x18 SurpriseRadius
+0x1C PerceiveDelay
+0x20 RecognizeDelay
+0x34 cos(FOVHorizontal)
+0x38 sin(FOVVertical)
+0x3C NoFOVRadius
+0x48 HearDistance
+0x54 HearDelay

The FOV fields are half-angles in degrees: Alertness_Spider_Drone 70 / 25 is stored as 0.342 / 0.423, Biped_AlertSuspicious_TargetKnown_Trolls 120 / 60 as -0.5 / 0.866; the radii and delays in the rows equal the records' values (3 records checked field by field). The table survives loading screens. Each AI then copies its own values: the perception refresh 0x499724(component = [npc+0x2CF0]) reads the current alertness level (UseSneakingPerceptionDistanceFactor), the faction row's PerceptionOverride, AI/Perception.DefaultPerceptionList, the weather modifier (AI/Perception/PerceptionWeatherMod.WeatherModifier), AdditionalSensorSet, SimulatedRecognizeEnemyDistance (stored at +0x1A8) and PerceptionOverrides, and fills the component's two perception copies (+0xC8, +0x118). It is the only caller of the AI/Perception/Duration slot helpers.

Record edits are not seen. A write to AI/Perception/Types changes nothing in play (scaling every record by 0.5 left three AIs' copies unchanged): the radii, angles and delays an AI uses come from the start-up table through 0x499724.

Sensors and stimuli

A character type's SensorSet (13 records in AI/SensorSets) composes observers and interpreters, with includes: _BasicObservers = BandMate, Audible, SuspectsTarget, NearBandLeader, MountedCharacter, RecognizeRange, FOV, CloseTarget, Obscured, SightRange; _BipedObservers adds Gathering and HiddenCharacter; Default (bipeds) interprets Attract, EnemyDelayedDisturbance, MoraleVictimAudible, FactionDisturbance, MoraleAudible, Distract, FactionDamaged, FactionThreat, EnemyDisturbance and BrandedDefendPlayer; Monsters adds BaitAttract; Wargs, Trolls and Balrog add their own hidden-character observers.

Characters emit stimuli named by their type (CharacterVisibleStimulus, CharacterAudibleStimulus, CharacterSneakingAudibleStimulus, AssaultCharacterVisibleStimulus). The player's emitters are built from AI/CharacterTypes by 0x440F80, whose only caller is the player's stimulus update 0x7AA29C (player vtable slot 59): sight at player+0x2190, sound at player+0x21B8, each with the live emitter at +0x20.

The sighting matrix

[base+0x26FFC48] (also the AI world's +0x50) is a 4.6 MB block of 256 perceiver blocks of 0x4808 bytes: +0x00 the owning character, then 256 facts of 0x48 bytes. The fact a perceiver holds about character c is at block + 8 + idx * 0x48, idx = u32 c+0x2BF8 (below 256; the player is 0). 116 of the 256 blocks had an owner in one read.

Fact offset Meaning
+0x00 the target
+0x3C float
+0x40 awareness; 4 = engaged, which raises threat (BlockInitialThreatAgainstTarget)
+0x41 relationship, written once when the fact is created (0x222A44 <- 0x122340); 3 = hostile
+0x42 byte

NPC::CreateCharacterFact 0x1A8B9B4 -> 0x6063E4 creates facts.

Forgetting and invisibility to AI

Forget 0x6C79D8(_, character, filter) visits every perceiver block whose fact about character has relationship 3: 0x2E6EE4 makes the perceiver ([owner+0x2D28]) drop the character as its focus (+0xF0; 0x2E6EB0 -> 0x11C9AC), queues the player's combat object at [base+0x26FFB70]+0x1050 (0x442F84), and 0x2229C0 resets the fact (target 0, relationship 2). It wipes every hostile AI's knowledge of the character however it was gained, by sight, sound or a hit. Callers: the player's stimulus update, Combat/Buffs.ClearNPCAwareness (0x452ED8), ClearAIDetectionBrush volumes (0x2B3B74) and the attack path 0x4CDE54.

AI-spectator invisibility. CPlayerObj::SetAISpectatorInvisible(player, bool) 0x19BF378 writes the byte player+0x2F10 and runs the stimulus update 0x7AA29C. While the byte is set (or the spectator state 0x171A78 holds), the update releases both emitters (0x4410E0), runs the forget path and zeroes the player combat object's +0x596; otherwise it rebuilds the emitters from the character type. Since 0x440F80 has no other caller, nothing is emitted while the byte is set, whatever the GameDB says. The byte is written only by the setter and the constructor (0x19B8D8C, 0). Engine users: SetHidden (player vf32 0x19BF91C, which also sets SetSpectatorInvisible at +0x3882), hiding places (0x1A001E0) and the script binding CPlayerObj.SetAISpectatorInvisible (0x1BA7D48, unused by the shipped scripts). The engine clears the byte after those uses, and a save load builds a fresh player object.

Alertness and alarms

An NPC's alertness object ([npc+0x2D18]) moves between the 11 AI/Alertness/Level records: Inactive, ActiveBuddy, Surprised, Alert, BaseHighAlarm, AcquiringTarget, TargetDead, LostPlayer, Suspicious, BaseLowAlarm, Cower. A level sets BehaviorEditValue (Inactive 0, Suspicious 0.33, Surprised 0.67, Alert 1.0), UseSneakingPerceptionDistanceFactor (read by the perception refresh), CombatReady, PreserveCharacterFacts, DismissDisturbancesImmediately, map rendering and AllowRestrictedNodes. A character type's AlertnessProfile (16 records: Default, DefaultMonster, AlwaysAlert, Coward, Troll, Warg, FortAssault ...) sets the default, simulated, inactive and exit-forced levels, the awareness icons and the ranges.

The AI world's alarm system (+0x38) is ticked by 0x607838 as the transient alarms. Area alarm levels are AI/SpawnAmbient/AlarmLevels: Empty, Low, High, HighWave2, HighWave3, with IsElevated true for the three high ones. On the NPC side there is an AreaAlarm service (every 0.5 s) and the steps ElevateTransientAlarm and ReactToAreaAlarmLevelChange.

AI services

The service set [npc+0x2D00] is a list of 0x18-byte entries {service, f64 due time}; 0x2FD960 runs a service's vf4 only when the world time passes its due time, so services are time-sliced while the rest of the update runs every frame. AI/Services (20 records) gives each its class and interval in seconds:

Service Interval Simulated interval
HeadTracking 0 -
ManageCharacterFacts, Riding 0.1 -
MonitorCharacter 0.25 -
AreaAlarm, AlertnessManager, LeaderBrand, GiveUpOnPlayer 0.5 -
AttackStructures 0.75 1.0
Gather, Patrol, MoveTowardSpawner, SpawnPersonnelWaves, Pulse, WargCloseQuarters 1.0 -
VerifyTasks, FixedArcher 1.0 3.0
WargCanChaseTarget 1.5 -
TraitTracker, TraitTrackerCloseRange 5.0 -

Spawning

The spawn manager

[base+0x26FFBE8] (also the AI world's +0x30) is null at the main menu. Engine core gives its role as the in-world test and the entry layout; the rest of the object:

Offset Meaning
+0x60 / +0x68 the character array: 0x40-byte entries, the character at +0x20, the AI/SpawnAmbient/Removal record at +0x28 for ambient-owned characters
+0x78 / +0x80 / +0x88 the per-template override table, its count and the AI/Templates category (see Resource streaming)
+0x98 the request list: an intrusive list with a sentinel at +0x98 (empty = it points at itself)
+0x168 / +0x170 / +0x178 / +0x17C a second roster of ambient characters: a vector (data, end, u32 capacity, owns-buffer byte) of 0x28-byte entries, a polymorphic object at +0x00 and the character at +0x20
+0x180 the ambient budget: a copy of the active AI/Count/Configuration.MaxAmbientAI (0x5C133C)
+0x18C the cursor of a sliced pass over the container at +0x48 / +0x50: 0x2735EC resumes from it and resets it to 0 once it passes the container's count
+0x190 the number of ambient-owned characters (entries with a Removal record)
+0x194 byte set by AISpawnManager:Enable (0x1B81FB4)
+0x1D0 a block holding 200 AI slots of 0x148 bytes from +0x98
+0x1E0 the counter behind the names noname<N> given to characters spawned without a name (0x3FA718 formats the name, then adds 1)

The second roster's members stay in the ambient count +0x190 but are left out of the living-AI count: 0x1A93278 (used by ForceAISpots) counts the living entries of the global AI list that it does not find there. RemoveAllAmbientAI (0x1A92D54) despawns the main array's characters first (character slot 26), then pops this roster from the back and despawns each entry's character the same way. 0x4A2D84 erases a given character's entry (called from character slot 17), and 0x4A2DC4 finds the character there and lowers +0x190 by one (called from character slot 128 and from 0x360988). A spawn (0x1A932F8, which logs Spawned AI %s from Spawner %s) appends to the main array and raises +0x190. The roster was empty (begin equal to end) in every one of 219 reads through a same-region fast travel and the 20 s after it; the first qword of its buffer then read vt_0x1DAAA20, a base vtable that 1,309 constructors and destructors write, which is what an element's destructor leaves behind.

The same fast travel tore the ambient population down and rebuilt it: +0x190 fell to 0 and rose to 7 within 20 s of arrival, although the region did not change.

When 0x5C133C copies MaxAmbientAI it also walks the 200 slots and opens the first MaxAmbientAI of them (slot byte +0xE8 cleared), closing the rest, so a budget above 200 opens no more slots. Dead characters leave the array (0 corpses among 107 entries with a fresh corpse at the player's feet), so its length counts the living; the AI list [base+0x2728ED0] .. [base+0x2728ED8] (begin, end) keeps dead AIs too (60 of 111 entries in one read). The manager is reallocated during travel; its destructor 0x1A90C60 clears the global and deletes every queued request, so a fast travel discards pending spawns.

Script interface (AISpawnManager): Enable 0x1B81FB4, Disable 0x1B819D4, RemoveAllAmbientAI 0x1BA2AB8, ForceAISpawnerSet 0x1B830A8, AddScenarioOverrideRecord 0x7B91DC, SummonGrunts 0x1BB138C and SummonNPCs 0x1BB1468. The same table (registered by 0x1B91DC8, twelve functions) also holds ForceAISpots 0x7B9264, CalcNumFactionMembersForHijackRecord 0x1B76B58, AddScenarioOverrideRecordPersistent 0xF77760, RemoveScenarioOverrideRecord 0x1BA317C and Debug_ResetScenarioMgr 0x1B812F0. AISpawnerAmbientSet registers 44 functions (0x1B928EC: enable, restock, replenish, scenario, distance, predicate, removal and node-tag accessors, Create, Cast, Serialize), and DifficultyMgr five (0x1B96668): SetScriptDifficultyRange 0x1BAE47C, LockAITemplate 0x1B8D93C, UnlockAITemplate 0x1BB2188, ClearLockedAITemplates 0x1B7C2D8 and GetSpawnLimit 0x1B89BD8.

Ambient spawners

Live census. In one loaded desert world, walking the world contexts' object tables found 5,002 AISpawnerAmbientNormal objects (vt_0x1DC5800), packed at a stride of 0x1D0 (4,556 of the neighbour gaps). Their channels (+0x1C0): Owner_DifficultyMelee 915, Owner_Infantry 779, Goblin_Difficulty 480, Owner_Warg 475, Owner_DifficultyAny 378, Spider_AI 286 ... and 233 Enemy_*. On 400 of them +0x158 held an AI/SpawnAmbient/Areas record (400), +0x160 an AI/SpawnAmbient/AlarmAreas record (387), +0x170 an AutoNodeTag (4), +0x178 / +0x190 the FortAssault/Side / StageList records (5) and +0x198 an Inventory/Transform (30).

The placed spawners (see World for their placement and counts) are classes derived from AISpawnerAmbientBase (0x1C0 bytes):

Class Size Constructor
AISpawnerAmbientNormal 0x1C8 0x48CE4A0
AISpawnerAmbientLocation 0x1C8 0x1AC393C
AISpawnerAmbientSocial 0x1C8 0x1AC1374
AISpawnerAmbientHijack 0x1C8 0x1AC3028
AISpawnerAmbientQuestPreset 0x1C8 0x1AC1CF4
AISpawnerAmbientRow 0x1D0 0x1AC195C
AISpawnerAmbientQuestJobNormal 0x1D0 0x48CC5D0
AISpawnerAmbientQuestJob 0x1D8 0x6F1718
AISpawnerAmbientGangSimEvent 0x1E0 0x1AC4774
AISpawnerAmbientNemesis 0x1E8 0x6F008E0
AISpawnerAmbientSimEvent 0x1F0 0x6EFD590
AISpawnerAmbientBodyGuard 0x200 0x48CD3C0
AISpawnerAmbientGangMember 0x208 0x1AC50C8

Two set classes derive from GameBase instead: AISpawnerAmbientSet (0x300 bytes, constructor 0x1AB9C38) and AISpawnerVignetteSet (0x338, derived from the set). The set's script interface includes SetAIToSpawn (0x5EB1F8). AISpawnCloset (0x250 bytes, GameBase, constructor 0x1AB5E68) objects are updated by the object scheduler (80 on its due lists in a loaded region); three channels are named for closets (Owner_Closet_Gang, Owner_Closet_GangSmall, Owner_Closet_GangNoArcher) and AI/Constants.SpawnClosetIdle is Bip_SpCloset_Idle. ScenarioOverrideVolume objects (0x160 bytes, 16 on the due lists) also update there.

Can a spawner spawn? The base check vt_0x1DC5988::vf38 0x16588C refuses, in order: no player object; the flag +0x1B2 set ("Waiting to snap to nav-mesh"); evaluations not passed (+0x1B1 clear; the message reports +0x1AF as "updating" and +0x1B8 as "checked"); a spawn predicate that fails (0x165B68, below); and a full spawner. The count check uses signed bytes: MaxSpawn at +0x1AD (0 = no limit) and CurSpawned at +0x1AE, refusing when CurSpawned >= MaxSpawn (the message's arguments fix which byte is which). AISpawnerAmbientNormal's vf38 0x165844 runs that check first, then resolves its channel (0x114B1BC calls the channel resolver CreateEntityFromArchetype 0x1B3098 with the channel at +0x1C0 and the spawner's alarm area at +0x160) and the character-bundle gate 0x16652C (see Resource streaming). The resolver walks the channel's AI/SpawnAmbient/AITypes entries and keeps the templates of the best Priority (below 101). When the channel is DifficultyBased, the alarm area decides which table applies: 0x1B62F6C turns the area into its current alarm level (0x11FBC8: the live alarm state looked up through [base+0x26FFBF0], else the area's TransientAlarmSettings.RequiredDefaultAlarmLevel) and takes the first table in [GM+0x6CC8]+0x18 whose AlarmLevelList.AlarmLevels contains that level (0x465CE8). A spawner in an alarmed area therefore spawns from a different difficulty table than the same spawner at rest. Every refusal is formatted for [AI|SPAWNAMBIENT|REJECT]:, but the logging function 0x19C83AC is empty in the shipped build.

The predicate. 0x165B68 takes the given predicate or the spawner's own (+0x168, an AI/SpawnAmbient/Predicates record, 67 exist); with none it passes. When the bytes +0x1A8, +0x1A9 and +0x1AB are all set it computes the distance from the spawner (+0xAC) to the player and evaluates the predicate on it (0x165C4C); otherwise it passes both positions (0x166DF8). A predicate record has CanSpawnWithoutEntry, ProcessAllValidIntervals and Entries (_StructuresAISpawnAmbientPredicateEntry: Distance, RandomWeightRange, MustBeOffScreen, MustBeLineOfSight); AmbShort, AmbLong and AmbDesert are three of the 67.

The budget. 0x1A92F18 lets a request through when [SM+0x190] + requested <= [SM+0x180]; past the budget it passes only when the scenario's AI/SpawnAmbient/SpawnScenarioSetup.ForceAISpots is set and the caller's flag is clear.

Spawner sets are refused by 0x1AB9E0C for eleven reasons, each formatted for [AI|SPAWNAMBIENTSET]:: not enough AI slots, the set cooldown, the quest-finish cooldown, the time of day, a global-variable predicate, the area's alarm level (AI/SpawnAmbient/AlarmAreas), a disabled scenario, a scenario already active, a disabled optional category, a category that may not spawn more AI, and no spawnable hijack for the type. Like the per-spawner refusals, these messages go to the empty logging function 0x19C83AC.

Scenarios. AI/SpawnAmbient/SpawnScenarioSetup (282 records) gives Priority, ScenarioType, RequiredAITypes, global and instance cooldowns, ForceNextScenario, ForceAISpots, MaxConcurrent, MaxConcurrentAI, MinDistance, ScenarioCategories, StartDisabled, IgnoreMaxConcurrent, AutoCooldownChance and AllowFailedSpawn; AI/SpawnAmbient/SpawnScenario (53) gives Priority, FrequencyDecay, MaxConcurrent, MaxConcurrentAI, StartDisabled and ScenarioCategories. Removal rules are AI/SpawnAmbient/Removal (45 records: AmbShort_R, AmbLong_R, Offscreen, AwayFromPlayer, Never, Stronghold_Removal50M ...); areas are AI/SpawnAmbient/Areas (180).

Spawn channels

A spawn channel is an AI/SpawnAmbient/AITypes record (250; the scripts call the type DBAISpawnTemplate). Its fields are DifficultyBased, PlayerWeightOnly, EnsureClassUnlocked, GruntBrandStatus, GruntActivateBrand, GruntPlayerFollowerStatus, InfightingRecord, Buff, Entries and four item lists given to grunts on the player's side or against it.

A channel carries an allegiance, not a cast. Owner_Infantry, Invader_Infantry and Enemy_Infantry resolve to the same template (Orc_Infantry, priority 0) and differ only in GruntPlayerFollowerStatus (2, 3 and 0), which partitions all 250 channels:

Status Channels Family
0 129 enemies, wildlife and debug: every Enemy_*, Balrog, FellBeast, Nazgul_Generic_Solo, 22 of the 23 GangType_*
1 42 the player's side: 15 of the 17 Gondorian_*, DLC4_Easterling_*, DLC4_Torvin, Gollum, Spider_AI_Follower, Carnun_Warg_Friendly, 5 of the 6 Summon_*
2 24 the area's owner garrison: 23 Owner_* and AmbSpontaneousEvent
3 17 invaders: Invader_*
4 19 fort-assault defenders: 17 FortAssault_Owner_*, Owner_GraugapultRider, Owner_Troll_Graugapult
5 19 fort-assault attackers: 17 FortAssault_Invader_*, Invader_GraugapultRider, Invader_Troll_Graugapult

Entries. Live, every channel carries an Entries array of _StructuresAISpawnAmbientTypeEntry primaries {Priority, AITemplate} (250 of 250). The channel resolver 0x1B3098 reads EnsureClassUnlocked and PlayerWeightOnly, walks Entries (accessor 0x1B3DD0, priority 0x1B3D64, template 0x1B3CFC), collects the templates by priority, and takes a further path through the game manager for DifficultyBased channels. ResolveEntityArchetype 0x6FBEB8 finds a channel by name: it hashes the name and binary-searches the cached category [base+0x27022D0] (+0x28 count, +0x38 the hash-sorted records), the same category the summon bindings use as the script type of their channel argument.

From channel to character

The chain is plain data: channel Entries[].AITemplate -> AI/Templates (177 records; fields GlobalVarUnlock, CharacterModel, CombatProp, Alignment, DisplayInMenu, DefaultAISetMountData) -> Character/Models (281 records, 102 fields: CombatTree, SimpleModel, Skeleton, AIName ...) -> AIName = the AI/CharacterTypes record -> its PlanTree. Of the 177 templates, 167 reach a character type with a PlanTree and 125 one with PlayerFollowerAlignment filled (Balrog, every troll and graug, drakes, the Carnan beasts, GP_Bruz, GP_Ranger, Baranor, Idril, Prototype_Celebrimbor). All 13 Nazgul templates (the Witch-king, Isildur and both forms of Sauron among them) have a brain but an empty PlayerFollowerAlignment; Talion, Dark_Talion, Idril, Carnun and Shelob_FairForm are presentation types whose tree is Idle.

The model picker. Character/Models.CharacterModelPicker (+0x208 of the record instance) names a Character/Models/ModelPicker; at creation 0x3FAE4C walks its Entries and returns the first CharacterModel whose GlobalVarPredicate passes, or the model itself. Only AI_Talion carries a picker (ModelPicker::Talion), and its entries return the player's equipped skin. Every player skin's AIName is AI/CharacterTypes::Player, which has no PlanTree, so in the AI set-up (0x3FA718 -> 0x45AFDC) the brain lookup 0x7279C0([obj+0x2128]) returns null and the creator 0x1CD454 frees the object (0x4DBD18). Nothing is logged and the summon that asked for it has already returned true. With the picker cell empty the model stays AI_Talion (character type AI_Talion, tree BasicEnemy) and creation proceeds like any other.

Summons

Two script bindings queue spawn requests; neither creates anything itself.

The main tick drains the list first in, first out, within about 2 ms of a call in play; while the world is paused nothing drains, and requests queued meanwhile all spawn at the transform they captured. Both processors resolve the channel (0x1B3098) and create through 0x1036D0(world context, class 0x1FB3EC8, &position, template, 1, 1, 0, 0, 0). A summoned NPC is named SpawnReq... (for example SpawnReqGrunt80) and is already on the update scheduler's due list before it appears in the spawn manager's array. A summon made with the player as summoner and Summon_Grunts comes out PlayerFollower (npc+0x2410) with the follower byte npc+0x23B8 = 1, 0.3-2 m from the player about 3 s later.

Summon data (Combat/SummonData, 55 records; the scripts' DBAISummonData): AIBandType, RemovalRecord, NavMarker, ScriptTags, AudioKey, MatchSummonerPlayerFollower, ItemsToGive, GruntTrait, RemoteScript, Alignment, UseSuperJump, AINodeTags, AINodeTagsAreMandatory, GoToAndMount, Ambushes, SpawnAtCombatTeleportPoint and EntranceSmartObject. Summon_Grunts: band HenchmenMob, removal AmbLong_R, tag AmbSummonOrcTag, MatchSummonerPlayerFollower True, remote script AmbSummonedOrcs. Summon_Bait: removal AmbShort_R, tags BaitSummonNPC and BaitNPC, spawned at the AI_Bait_Generic combat teleport point.

NPC summons (Combat/NPCSummon, 107 records) are the trait- and ability-driven layer on top: AISpawnType (a channel) or AISpawnTypePicker, Count, TeleportSettings, SummonData, SummonPlayerBodyguards, MakeGangMember and SpawnFactionMemberMount.

Gangs, bands and followers

A captain's gang is a trait (Faction/Traits/Definitions, see Captain traits). For Quirk_HasGang_Normal_Hunters: GangLeader True, GangClassTypes -> the channel GangType_Hunters, GangSize 4, Catalyst Gang_SummonOnSpawn, Item Item_Quirk_HasGang, and GangNPCSummon -> Combat/NPCSummon::Gang_Normal_Hunters_SpawnIn, which summons GangType_Hunters with MakeGangMember True and summon data Summon_Gang. The 23 GangType_* channels (Archers, Defenders, Warriors, Hunters, Savages, Ologs, Graugs, Caragors, Ghuls, Spiders, FellBeast, Easterling squads, summon groups ...) are ordinary spawn channels.

Groups move as bands: AI/BandTypes (66 records: Mob, HenchmenMob, Bodyguard, FollowLeader, SingleFile, TwoColumns, March, Skirmish_5Man, DiamondSurround, ChainGang, Wargs, FlyingV, GP_Buddy ...) with BandData, MovementData, NewLeaderOnRemoval, FollowDelay and NativePredicates; summon data names a band (Summon_Grunts -> HenchmenMob). The band steps are FollowLeader, FollowWaitForBand and ReturnToBandLeader, with the target select WaitForBandMember; FollowLeader's update 0x48DB00 calls the band movement 0xF498C0. The AI world ticks the buddy and follower system at +0xB0 (0x3608C8), and buddies have their own target select (BuddyCommand) and alertness level (ActiveBuddy).

Leading the player

Story characters that guide Talion somewhere (Ratbag, Idril, Blade, Gollum, the Ranger, the third story DLC's Architect, Thrall and Flint, the fourth's Gondorians) run a lead behaviour configured by one of the 16 AI/LeadPlayer records (distances in cm, times in seconds):

Field DLC3_Architect Read by
LeadRange (2,000, 4,000) 0x1AE7794
SlotRadius (200, 400) 0x1AE7794, 0x1AFD62C
WaitWidth / WaitExtension 1,200 / 200 0x1AE7794
ReachRadius / ReachMovementSpeed 500 / Run 0x1AFD62C
BeckonDelay / BeckonSmartObjectPicker (4, 6) / Bip_Idle_Looping the step's vf9 0x1AFD1E0; the picker also 0x1AFC7FC (from vf5)
BeckonAudioInitialDelay / BeckonAudioRepeatDelay (2, 3) / (5, 8)
AllowRepathing, RepathDelay true, 5 0x1AE70D4, 0x1AE7488
UnreachableRepathDelay 1 0xF40F00
ReferenceAgentHorizontalRange / VerticalRange 300 / 500 0x1AE7488
PauseConversation (-1, -1) 0x1AE70D4, 0x1AE7B5C
MovementData LeadPlayer_Architect (an AI/BandTypes/BandMovementData record)

The script native NPC::LeadPlayer starts it (0x1AE70D4 reads AllowRepathing and PauseConversation). Every NPC update ticks the guide's lead state (0x1AE7338, through 0x2F6150 on the NPC's +0x2D30): it recomputes the lead geometry (0x1AE7794: LeadRange, SlotRadius, WaitWidth, WaitExtension), the reference agent and repath timer (0x1AE7488) and the conversation pause (0x1AE7B5C). The planner's LeadPlayer step (vt_0x1FBCA70) does the beckoning: its update vf9 0x1AFD1E0 waits BeckonDelay and then plays a smart object from BeckonSmartObjectPicker, and 0x1AFD62C tests arrival against ReachRadius.

Mounts

The creature a captain rides comes from the trait Quirk_HasMount: MountAITemplate -> AI/Templates::Warg_Tamed, FleeFindMount -> AI/FindMount::FleeOnWarg (attack type Mount_Warg, SearchRadius 2500, MountCharacterType Wargs, MountIsSummoned True), FleeSpawnMountChance 0.25 and FleeSummonMount -> Combat/NPCSummon::Flee_Mount_Warg (channel Enemy_Warg). A rider's gang of beasts walking alongside is a separate gang trait. A template's DefaultAISetMountData names its ride (Warg_Tamed -> AI_SetMount_Warg).

Combat/MountData (31 records, 24 fields) describes each ride: SetMount is 2 on the 24 SetMount_* / AI_SetMount_* records and 3 on the 7 Dismount* ones; CombatBehaviorState (RidingWarg, RidingTroll, RidingOlog, RidingMedic, GrabbingAI ...); AttachNode (Saddle on 20 of the 24 mounting records; SocketAttach for carrying an ally and for the medic, attach for the meat shield, Null for the spider); behaviour nodes for rider and mount, visibility, the attack types used when the mount or the attached target dies, MountedAlignment, buff and item lists. The planner steps for it are GoToAndMount and Mounted.

The AI budget and constants

The AI count configuration

AI/Count/Configuration (16 records) sets the budget: MaxAmbientAI, MaxHighPriority, MaxMedPriority, MaxLowPriority, MaxDeadBodies, RunSimulation, MaxSimulatedAnimations, PrioDefinition, PrioTokenDefinition and the LOD configurations. The active configuration is [base+0x2A63018] (+0x28 of the static object at 0x2A62FF0 that 0x201DE8 initialises once); free roam uses Default (shipped: MaxAmbientAI 100, priorities 10 / 10 / 30, MaxDeadBodies 10). Quests switch configurations through 0x5C1294 (MaxAmbientAI 10-200 and MaxDeadBodies 10-60 across the others: Assault 160 / 10, CarnanSideMissions 100 / 60, DLC3_MoreBodies 100 / 55 ...). The switch takes a configuration only if the validator 0x5C1638 passes: MaxAmbientAI + MaxDeadBodies <= 230 and MaxHighPriority + MaxMedPriority + MaxLowPriority + MaxDeadBodies <= 256; otherwise it silently keeps the old one. MaxAmbientAI reaches the spawn manager only through the copy 0x5C133C (re-run by the script command SetAICount and by quests), so a later edit of the record is not seen until that copy runs again.

Corpses. On a death (0x49FE20) the cull 0x40538C reads the active configuration's MaxDeadBodies and walks the dead-body list (head [base+0x2672CB8] {+0 last, +8 first}, node at char+0x2298, next at +8), removing every body past that index, off-screen ones first.

AI constants

AI/Constants has one record, Constants: PersonalBubbleDistance 50, ThreatUnseenTime 10 s, OnScreenCheckDelay 0.1 s, NodeClusterRadius 120, RetreatThreshold 3, RetreatCount -1, BrandedDefendPlayerMinimumRange 500, MinimumPlayerTargetedStimulusDuration 1.0, DamagedAtNodeAvoidanceTime 40-80, the unreachable-position delays (first 2.5-6, new 15-30, visible 1-2.5), BattleNodeMaxSelectionsPerFrame 255 and BattleNodeMaxChildSelectionsPerFrame 32, MaximumAllyGruntsInCombatSpace 10 and MaximumOpponentGruntsInCombatSpace 10, DefaultCharacterModel Orc_Infantry, NPCKnockBackAttack NPC_Knock_Back, DefaultIdle LoopingIdle, SpawnClosetIdle Bip_SpCloset_Idle, ObjectAlignmentName Player, the player- targeted stimuli PlayerBeingAttackedSound / PlayerBeingAttackedVisual, and the combat-circle directions.

The grunt caps are read through 0x6DD4B0(ally): an override in the quest system [[GM]+0x6C60]+0x588 (ally) or +0x58C (opponent) wins when it is 0 or more; otherwise the record is read (the integer read defaults to 3). Both overrides read -1 in free roam. Quest/Quests carries the same field (registered min=-1;max=10).

Crowd AI. AI/Simulation (15 categories) is the cheap AI of army battles: 12 Configurations (OrcDefender, Spiders, FellBeast ...) pick animation pools (idle, movement, off-mesh, activity, attack, recoil), CanTargetNonSimulated, potential riders and an attack budget AttackThrottling {AttackCapacity, AttackWeight}: Grunt 4 / 1 (four grunts on one target), Named 4 / 4 (one), Warg 8 / 4, Troll 20 / 20, FellBeast 40 / 4. This is a second attacker limit, separate from the fight circle. The count configuration also carries RunSimulation (read by 0x4428B0) and MaxSimulatedAnimations.

Values the engine copies

Data Copied What a later record edit does
AI/Perception/Types once at start-up into [[base+0x26FFBF8]+0x300], then per AI by 0x499724 nothing
AI/CharacterTypes.PlanTree and the step list order when the character is set up nothing for existing NPCs
AI/CharacterTypes.ServiceSet when the service set is built (0x45B290) nothing for existing NPCs
AI/Count/Configuration.MaxAmbientAI into the spawn manager by 0x5C133C waits for the next copy
AI/Count/Configuration.MaxDeadBodies read on every death seen at the next death
AI/Planner/Steps/* records read by the step's vf5 / vf9 each time they run seen at once
Combat/FightCircle/Stage attacker counts read at every stage start (see Combat) seen at the next stage
Character/Models.CharacterModelPicker read when a character is created seen by the next creation

GameDB instance data is deserialised again on some loading screens, so record edits do not survive them.

Fight pits and arenas

Sim events

Captain events that can be played or simulated (duels, pit fights ...) are sim events (Faction/Events/SimEvent, 431 records, 41 fields: Type, Participants, WinChance, WinChanceJobSubject / WinChanceJobOpposition, SubjectWinsSimulate / OtherWinsSimulate, Results, life spans, RequiredCooldown, UniqueSimEventGroup ...). 0x834F50 builds the engine's table of sim-event kind names (hashed, eight entries): 0 Automatic, 1 Story, 2 SimEventSingle, 3 SimEventDuel, 4 SimEventAugment, 5 FortAssault, 6 Conquest, 7 FortAssaultUnfinished.

Sim-event records use per-record descriptor runs (Arena has 56 cells, Gauntlet 51), so field offsets differ from record to record. Participants is an ordinary fixed-count RecordLink array: Arena 3 (Arena_01 .. Arena_03), ArenaFollower 2, ArenaTest 3, Duel 5, Duel_Origin 5, DuelG 2, Gauntlet 1 (Gauntlet_02), Gauntlet2 1 (Gauntlet_Other_Lvl_2), Gauntlet3 1 (Gauntlet_Other_Lvl_3). Participants (Faction/Events/SimEventParticipant, 572 records) carry JobRecord, JobGroup, Exclusive, CanBeBodyguard, Purposes, RewardList, ChanceToFill and the bodyguard builders.

Win chance decides only a simulated outcome, when the player walks away from a fight; a watched fight is decided by the fight. Faction/Events/SimEventChance (34 records):

Field Arena Duel Default_2orc_arena Default_1orc_general
StartChance 0.5 0.5 0.5 0.6
CharacterLevelDeltaScalar 0.05 0.02 0.02 0
HenchmanDeltaScalar 0.15 0.15 0.15 0.15
SupporterDeltaScalar 0 0.15 0.15 0.15
TotalInteractionScoreDeltaScalar / Max 0.008 / 1.0 0.003 / 1.0 0.003 / 1.0 0.003 / 1.0
PlayerFollowerTotalISDeltaScalar / Max 0.0025 / 1.1 0.0025 / 1.1 0.0025 / 1.1 0.0025 / 1.1
TraitPairs 20 31 20 none

The other fields are RPSPairs (empty on these four and on the two below) and ClampFinalChance (a pool range). SubjectAlwaysWins and SubjectAlwaysLoses differ only in StartChance (1.0 and 0.0; every scalar 0, both *Max -1.0). Arena, ArenaFollower, ArenaTest and Gauntlet to Gauntlet3 link WinChance to Arena; Duel and Duel_Origin to Duel; DuelG to Default_1orc_general.

The chance is computed by 0x1BEDA40, the only reader of StartChance: StartChance, plus the level difference times CharacterLevelDeltaScalar, plus the henchman difference times HenchmanDeltaScalar, plus two further terms (0x1BEF460 and 0x1BEDD84), plus the subject's supporter term minus the other's (SupporterDeltaScalar), plus an interaction-score difference built from TotalInteractionScoreDeltaScalar, PlayerFollowerTotalISDeltaScalar and their Max caps. The sum is clamped to the ClampFinalChance range.

The fight pits are quests

The pits in the Army menu are Quest/Quests records of type Quest/Types::Gauntlet (IsGauntlet, ActivationType 1, CanReplay and XPAwardOnReplay True, ItemAwardOnReplay False): <MI|MP|IS|DE|VO|SM>_Gauntlet, _Gauntlet2 and _Gauntlet3 (18) plus three PS_Gauntlet* records without a job set-up. Per tier only the record differs: JobSetupSimple -> <R>_Gauntlet_Lvl_N -> the job PS_Other_Cap_Gauntlet_<R>[_Lvl_N] -> the champion's builder Faction/FactionMemberBuilder::Gauntlet_<Region>_LvlN; the rewards (tier 1 SkillPoint, tier 2 PC_Mirian_Comb_Arena2_<Region>, 5,000 Mirian, tier 3 Gauntlet3_CitadelUnlock_<Region>, in Minas Ithil PS_Raise_Child1_UNLOCK); and Predicates (tier N needs <R>_Gauntlet_Lvl_{N-1}_Complete and a living follower captain of the region army through Faction/CountFilters::IS_Gauntlet). Every pit quest owns its predicate primary (27 of 27 unshared). Completion is the global variable <R>_Gauntlet_Lvl_N, set by the fight script.

The predicate Int PlayerMustOwnZone means 0 any region, 1 a region the player owns, 2 a region the player does not own (availability 0x1E00C4, see Quests, story and scenes). The three Minas Ithil pits ship 0, the other fifteen 1. An empty PlayerMustOwnZoneFactions falls back to the current region's faction (0x1B5C820(FS, 1)), and MinPlayerFollowerFactionMembersInZone 1 refuses a region whose army has no living follower captain. Inactive quests are not re-checked on their own: the quest system re-runs every quest's predicates only when its timer cell QuestSystem+0xE8 (u32) is non-zero and older than the millisecond clock, then zeroes it. Online pits (<R>_ArenaOnline) are evaluated only in a region whose Overlord is the player's follower, unless the byte 0x2A36439 is set (0xEA5590; see Saves, the store and the network layer).

The fight is the layer script. The prefab prefabs\gameplay\quests\powerstruggle\gauntlet.prefab06c is placed in <region>/gauntlet_00/01/02 (its quest relay names the tier's quest) and, as arena.prefab06c, in <region>/arena_00, the Arena power struggle between two enemy captains. Its layer properties define spawner sets: C1 (job PS_Subject, the player's follower), C1G (his gang, sized by his gang trait), C2 (PS_Other, the champion), C2G, and Door and Spectator (StartEnabled, 30 spawns, alignment Spectator). The sides are the alignments Duelist_A (C1), Duelist_B (C2) and Duelist_C (hostile to both, neutral to spectators).

The script's play-as branch (QR:GetPlayerFactionMemberIndex() 0x1A50ED8 -> 0x19F4D30, 1 or 2 when the player's play-as key [player+0x2708] matches the subject's or the other job's captain) makes the player captain 1 or 2, but MissionEnd decides the win on aiCaptain1:IsAlive().

Pit data and objects

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.