Appearance
sain
Source: mods/sain.
Settings
Defined in mods/sain/config.json. The values shown are the shipped defaults, not what is currently deployed.
enabled
- Default:
true
UNKNOWN — this key carries no doc comment in config.json.
difficulty
- Default:
"default"
UNKNOWN — this key carries no doc comment in config.json.
forcePersonality
- Default:
""
UNKNOWN — this key carries no doc comment in config.json.
logDecisions
- Default:
false
UNKNOWN — this key carries no doc comment in config.json.
driveFromHostThread
- Default:
true
Whether the five driving calls (GoToPoint, Sprint, LookToPoint, Shoot, and the self-actions) may be made from the host's own thread on a host whose onMainThread drain has never fired. When the drain has fired they are queued and executed on Unity's thread regardless, which is what they need. Set false to withhold them instead: bots then decide and do not move, which is the safe direction and the visibly broken one. Either way /sain/status and the log say which path the session took.
clientDrivesLocomotion
- Default:
false
Whether the CLIENT-side actuator may write locomotion (BotMover::Sprint @0x1A2D700). Default false, and that is the ONE-WRITER decision, not caution. Two things were writing a bot's movement on last-writer-wins: the server side (server/drive.nim -> ORBIT dispatch -> SetTargetMoveSpeed @0x1A2B4D0, plus the botnav GoToPoint thunk, host flag botNav) and this per-decide actuator. The server side wins because it is the only one that can issue a DESTINATION -- GoToPoint needs 8-9 register slots and the shape dispatcher tops out at 5, so this actuator could only ever set 'sprint' along a path somebody else chose. Setting this true restores two writers deliberately; either way the withheld count is printed in /sain/status so the loser is provably inert rather than silently off.
allowIl2cppReflection
- Default:
false
Whether this mod may resolve IL2CPP types and methods BY NAME (il2cpp_class_from_name / il2cpp_class_get_method_from_name). Default false, and that is a measured fact rather than caution: on this build those calls return NON-NIL handles into UNMAPPED memory, so the nil check passes and the first dereference kills the client. The mod was measured dying inside exactly that call while binding UnityEngine.Physics::Raycast. With it false the cover sensor, the navmesh filter, the aim postfix and the damage postfix all decline and say why in the log; suppression falls back to the health delta and bots fight without taking cover. Turn it on only to re-measure the fact on a future Tarkov build.
sainRvaTable
- Default:
false
Bind 32 of SAIN's members from the table in client/rvatable.nim: 23 as direct calls at a static RVA, and 9 as GUARDED FIELD READS (pPhysical, phStamina, mcSprint, boMemory, memGoalEnemy, medFirstAid, medStims, medSurgery, wmReady) whose members are not properties on this build at all. A field row has no function and therefore no prologue to compare; it rests instead on an offset taken verbatim from Il2CppMetadataRegistration.fieldOffsets, a VirtualQuery for the exact width at every hop, an m_CachedPtr liveness check wherever the receiver is a UnityEngine.Object (readability is NOT liveness on this build), and a STATED RECEIVER TYPE -- a field read off the wrong object ANSWERS rather than faults, which is why wGrenades and boAiming stay refused on receiver grounds even though their offsets are known. EVERY FIELD ROW IS A READ: the field mechanism has no invoke path at all, so no drive call can be expressed through it. Three sensors that were bound and UNREACHABLE (memUnderFire, surgHave, stNormal) gain a receiver and become readable. Per-sensor PASS/FAIL/INCONCLUSIVE verdicts are logged once against the first live bot. The 23 call rows' addresses were derived offline with tools/il2cpp_resolve.py against GameAssembly.dll 1.1.0.1.46777 -- addresses derived offline with tools/il2cpp_resolve.py against GameAssembly.dll 1.1.0.1.46777, each byte-verified 16/16 against its recorded prologue and checked to sit in committed EXECUTABLE memory before it is ever called. A direct RVA call bypasses the token-gated export ABI entirely, so the failure mode that kills the client (a random non-zero handle passing a nil check) is not on the path at all. The other ~27 members SAIN asks for are REFUSED by name in the log with the measurement that decided it -- absent from the image, declared at an arity this mod never calls, declared on a class this mod never holds, an RVA with 147 or 341 owners, or a List<T> instantiation whose layout is unreachable offline -- and are never retried reflectively. HONEST SCOPE: among the members this table covers, zero remain by-name; that is not the same as SAIN working. GoToPoint, Sprint, Stop, Shoot and the alive-players list are all refused, so no bot is driven by this mod. Default false.
selfTestRuntime
- Default:
""
A GameAssembly.dll for the --side sim self-test to bind against, so it reports real bindings offline. Empty -- which is what ships -- means the process, and under aowlspt-sim there is no runtime at all. It must be an ABSOLUTE path: a relative one resolves against whatever directory the host was started in, which in an install is the game's own directory, and it is refused with that reason. aowl test and aowl run set AOWLSPT_SELFTEST_RUNTIME to the stand-in runtime tests/mockil2cpp builds, which is why this key is empty here and nothing ships a fixture path into an install.
global
- Default:
{"engageDistance": 70.0, "timeBeforeSearch": 40.0, "holdGroundBaseTime": 1.0, "fightBackHealthThreshold": 0.55, "runAwayHealthThreshold": 0.28, "coverMinEnemyDistance": 8.0, "maxCoverPathLength": 60.0, "decisionHz": 10.0, "maxBotsPerTick": 8, "farFromPlayerDistance": 150.0}
UNKNOWN — this key carries no doc comment in config.json.
roles
- Default:
{"pmc": {"difficulty": "inherit", "engageDistance": 90.0, "timeBeforeSearch": 25.0, "willSearchForEnemy": true, "runAwayHealthThreshold": 0.28, "canShiftCoverPosition": true}, "scav": {"difficulty": "inherit", "engageDistance": 50.0, "timeBeforeSearch": 60.0, "willSearchForEnemy": false, "runAwayHealthThreshold": 0.28, "canShiftCoverPosition": true}, "boss": {"difficulty": "inherit", "engageDistance": 110.0, "timeBeforeSearch": 5.0, "willSearchForEnemy": true, "runAwayHealthThreshold": 0.1, "canShiftCoverPosition": true}, "zombie": {"difficulty": "inherit", "engageDistance": 200.0, "timeBeforeSearch": 1.0, "willSearchForEnemy": true, "runAwayHealthThreshold": 0.0, "canShiftCoverPosition": false}}
UNKNOWN — this key carries no doc comment in config.json.
server
- Default:
{"patchBrains": true, "neutraliseLocationModifiers": true}
UNKNOWN — this key carries no doc comment in config.json.
botLoot
- Default:
{"richnessMultiplier": 1.0, "realisticLoot": true, "weaponTier": "role", "ammoTier": "role", "armorTier": "role", "pocketLootMin": 0, "pocketLootMax": 4, "rigLootMin": 0, "rigLootMax": 6, "backpackLootMin": 0, "backpackLootMax": 6, "factionOverrides": {}}
Bot AI > Loadout. How much loose loot generated bots carry, and how their weapons are built. richnessMultiplier is live on this build: the server's own bot generator reads it, so changing it changes what a killed scav drops. The tier and min/max keys below are the config SCHEMA for a fuller loadout system and are NOT yet read by the generator -- they are declared so the keys exist and a later pass can wire them without a config migration; each is greyed on the settings page until it does.

