Appearance
aowlspt/botnav
Source: aowl/src/aowlspt/botnav.nim — 361 lines.
aowlspt/botnav — enumerate the bots in a raid and tell one where to go.
Bot AI runs entirely in the CLIENT, inside GameAssembly.dll. Post-1.0 EFT is IL2CPP, so there is no BepInEx and no Mono patching, and reflection is dead — il2cpp_object_get_class, il2cpp_class_get_name and field iteration all fault. What is left is raw static-offset field access and direct calls to methods at their static RVA, and that is exactly what the client host does on a mod's behalf here.
import aowlspt/botnav
proc onCensus(payload: string): string =
Fires whenever the client host reports a new bot registry.
for b in botsFromEvent(payload): if b.alive and b.navStatus != 2:
Walk every living bot ten metres east of wherever it is now.
discard sendBotTo(b.id, b.x + 10.0, b.y, b.z, reach = 2.0) result = ""
proc onLoad(): Status = discard onBotCensus(onCensus)
Or, without waiting for a census: send every bot to one spot,
sprinting, and keep them going there for 30 seconds.
sendBotsTo(BotNavAll, 213.5, 1.4, -58.2, reach = 2.0, sprint = true, holdMs = 30000)
proc onUnload(): Status = clearBotNav()
How it actually gets there
Commands go out, and the registry comes back, on the poll the client host already makes every modSyncMs — the same request menuModeText and inRaid ride. No new endpoint, no new poll, no new socket: the client host has exactly one route to the backend and a second one would only be a second thing that can be down.
mod --sendBotTo--> aowlspt.bot.nav --> mod manager manager --> GET /aowlspt/mods/client/<hostver> {"botNav":"7|213.5|..."} client host --> EFT.BotOwner::GoToPoint(<Vector3>, ...) [Unity thread]
client host --> GET .../client/<hostver>?bn=<census> --> manager manager --broadcast--> aowlspt.bot.census --> mod's onBotCensus
The census is PUSHED rather than offered for polling, because the event bus has no request/response — broadcast returns a Status, not a reply — and because the value only changes when a poll brings a new one, so a mod polling it would just be spinning. The same census is also on GET /aowlspt/mods/clientreport as botNav, for anything outside the process.
The host's side of this is a detour on EFT.BotOwner::UpdateManual, which the game calls once per live bot per frame with the BotOwner in RCX. That single hook is both the census (every bot announces itself; nothing is enumerated and no pointer is kept across frames) and the service tick (a bot's command is issued while that bot's own Update runs).
What to expect — read this before building on it
The player must opt in. The host side is gated on
botNavinaowlspt-host.json, defaultfalse. A mod calling this on an install that has not enabled it changes nothing, and that is not an error. UnlikebotDiag, this feature calls into and writes to live game objects, which is why it is off by default and why the host self-disables it after eight trapped faults.A nav command is ADVISORY, not authoritative. This is the important limitation and it is not going away without a much larger piece of work. The bot's brain (
BotOwner.Brain, a layered decision graph) re-evaluates its goal every tick and will happily re-target the mover a frame later. A single "go here" is therefore often overridden almost immediately. The host compensates by RE-ISSUING an active command every 500 ms until it expires or the bot arrives — which is why commands carry aholdMsrather than being fire-and-forget. Expect a bot to argue with you, and expect a bot that is in combat to ignore you entirely.Reachability is answered, not guessed.
EFT.BotOwner::GoToPointreturns aNavMeshPathStatus, and the host reports it back per bot asnavStatus:0complete,1partial,2invalid (no path to that point),-1nothing issued yet,-2the host refused to issue because the bot's mover was not ready. A command that comes back2is dropped rather than retried — saying it again will not change the navmesh's mind.A bot must have moved at least once before the host will command it. The game's mover throws a managed
NullReferenceExceptionif its state machine is not populated, and having seen the bot actually move is the only proof available that it is. A freshly spawned bot is therefore briefly uncommandable, and that is deliberate.The census is PARTIAL and it ROTATES. This is the constraint most likely to surprise you. The host's whole report path is capped at 191 characters — the overlay's sync worker copies it into a 192-byte field and truncates silently past that — which is nowhere near a full registry. So each census carries roughly the first five bots that fit, starting where the last one stopped, and works through the rest on later polls. Every bot is reported within a few polls and none can be starved, but a bot missing from one census is not a bot that has left the raid. Accumulate across censuses, keyed on
id, and age entries out rather than treating each payload as the complete picture.Positions are stale and rounded. They are as of the host's last report, rebuilt at most every 4 seconds, carried on a poll that runs every
modSyncMs(default 3 s), and rounded to whole metres to fit the budget above. This is a control channel, not a telemetry stream; do not build anything that needs a bot's position this frame or to sub-metre precision.Last writer wins. There is one command set. Two mods both steering the same bot is two mods fighting, and the manager holds whichever spoke last rather than interleaving them invisibly.
What is NOT here. Behaviour tuning (SAIN-style) is not reachable this way at all — that lives in the brain's decision layers, which cannot be subclassed without reflection. Multi-waypoint routes are not native either: the game's
GoToByWaytakes a managedVector3[], so build a route as a sequence ofsendBotTocalls and advance when the bot arrives.
Types
BotInfo
nim
BotInfo* = object
id*: int ## `EFT.BotOwner.Id` — the handle every command takes.
role*: int ## `WildSpawnType` as an int32. Raw on purpose: the enum's
difficulty*: int ## `BotDifficulty` as an int32, same reasoning.
alive*: bool
x*, y*, z*: float
navStatus*: int ## Last `NavMeshPathStatus` for this bot: 0 complete,One bot, as of the client host's last report.
aowl/src/aowlspt/botnav.nim:137
BotCommand
nim
BotCommand* = object
spec*: stringOne instruction, for sendBotCommands. Build these with goTo, stop or moveSpeed rather than by hand.
aowl/src/aowlspt/botnav.nim:149
Constants
BotNavAll
nim
BotNavAll* = -1Address a command to every registered bot rather than one id.
aowl/src/aowlspt/botnav.nim:125
BotNavMaxSpec
nim
BotNavMaxSpec* = 512The longest command string this will send. Matches BotNavMax in the client host's modcontrol.nim, and both exist so the limit is enforced at the end where it can still be reported rather than only at the end where it can only be dropped.
aowl/src/aowlspt/botnav.nim:127
BotNavMaxCommands
nim
BotNavMaxCommands* = 16The most commands one set may carry. The host's registry applies them in order; anything past this is dropped.
aowl/src/aowlspt/botnav.nim:132
Routines
goTo
nim
proc goTo*(id: int; x, y, z: float; reach: float = 1.0; sprint: bool = false; holdMs: int = 20000): BotCommand"Bot id, walk to (x, y, z)." Pass BotNavAll for every bot.
reach is how close counts as arrived, in metres, clamped by the host to 0.25..50. holdMs is how long the host keeps re-issuing this before giving up — see the note above on commands being advisory; a holdMs of zero would mean "say it once and let the brain overrule it", which is almost never what anyone wants.
sprint is honoured as "move at full speed" (the host sets the mover's speed to 1.0 once, when the command is first issued) rather than by calling the game's own BotOwner::Sprint. Same visible effect for a bot crossing a map; considerably less of the game's machinery touched.
aowl/src/aowlspt/botnav.nim:177
stop
nim
proc stop*(id: int): BotCommand"Bot id, stand still." Clears any active command and calls the game's own BotOwner::StopMove. The brain will pick its own goal again shortly — this stops the bot, it does not pin it.
aowl/src/aowlspt/botnav.nim:199
moveSpeed
nim
proc moveSpeed*(id: int; speed: float): BotCommand"Bot id, move at speed" — 0.0..1.0, written straight into BotMover.MoveSpeed. Independent of any active destination.
aowl/src/aowlspt/botnav.nim:205
botNavSpecAcceptable
nim
proc botNavSpecAcceptable*(s: string): boolWhether s is a command string this will carry. Exposed so a mod that builds a set by hand can check it and say something useful, instead of sending it and having to infer the refusal from bots that did not move.
aowl/src/aowlspt/botnav.nim:213
sendBotCommands
nim
proc sendBotCommands*(cmds: seq[BotCommand]): StatusPublish a whole command set, replacing whatever was in force.
Returns Ok when the request was broadcast, ErrBadArg when the set is too long or malformed. Ok means "asked", not "done": whether anything moves depends on the player having set botNav, on the manager being up, on the client host being in a raid, and on the bots' own brains. None of those are things this call can know, and none of them are failures worth an error — the honest answer to all of them is a bot that carries on as it was.
An empty cmds clears the set and hands every bot back to its own brain.
aowl/src/aowlspt/botnav.nim:229
sendBotTo
nim
proc sendBotTo*(id: int; x, y, z: float; reach: float = 1.0; sprint: bool = false; holdMs: int = 20000): StatusThe one-liner: send a single bot to a point, replacing the command set.
aowl/src/aowlspt/botnav.nim:253
sendBotsTo
nim
proc sendBotsTo*(id: int; x, y, z: float; reach: float = 1.0; sprint: bool = false; holdMs: int = 20000): StatusAlias for sendBotTo that reads better with BotNavAll.
aowl/src/aowlspt/botnav.nim:258
stopBot
nim
proc stopBot*(id: int): StatusStop one bot (or BotNavAll), replacing the command set.
aowl/src/aowlspt/botnav.nim:263
clearBotNav
nim
proc clearBotNav*(): StatusDrop every command and hand the bots back to their own brains. Worth calling from a mod's onUnload, so a mod that is switched off does not leave a squad marching at a wall for the rest of the session.
aowl/src/aowlspt/botnav.nim:267
parseBotCensus
nim
proc parseBotCensus*(census: string): seq[BotInfo]Decode the host's registry string into rows. Exposed because a mod that has already fetched /aowlspt/mods/clientreport for other reasons should not have to fetch it twice.
Format, per bot, !-separated: id,role,difficulty,alive,x,y,z,navStatus
A row with the wrong field count is skipped. The census is a state that is resent every poll, so a dropped row costs at most one poll of staleness — which is a far better outcome than refusing the whole census over one bad record.
aowl/src/aowlspt/botnav.nim:315
botsFromEvent
nim
proc botsFromEvent*(payload: string): seq[BotInfo]Decode an aowlspt.bot.census payload — {"count":<n>,"census":"..."} — into bot rows. This is what an onBotCensus handler wants.
aowl/src/aowlspt/botnav.nim:341
censusCountFromEvent
nim
proc censusCountFromEvent*(payload: string): intHow many censuses the manager has taken, out of the same payload. Zero means no client host has ever reported a registry — which is a different thing from an empty registry (a raid with no bots in it), and worth being able to tell apart before concluding the feature is broken.
aowl/src/aowlspt/botnav.nim:346
onBotCensus
nim
proc onBotCensus*(handler: EventHandler): StatusSubscribe to the client host's bot registry. The handler is called with an aowlspt.bot.census payload each time a NEW census arrives — roughly every modSyncMs while a raid with bots is running, and not at all otherwise.
Decode it with botsFromEvent. The handler's return value is ignored; an empty string is the convention.
aowl/src/aowlspt/botnav.nim:353

