Skip to content

The mod API

The nimony surface a mod is written against. aowl/src/aowlspt.nim is 1,493 lines and is what import aowlspt gives you: the ABI, made ordinary. The submodules sit on top of it and none of them reaches around it.

modulelinesfor
aowlspt1,493everything — logging, config, routes, events, timers, call, patch, the store, the typed frame, exportMod
aowlspt/game913the client-side high-level API: types, live objects, hooks
aowlspt/server778the backend-side high-level API: routes, the database, config, JSON building
aowlspt/json690reading and editing a document without rebuilding it
aowlspt/fast1,370the per-frame path: bind once, then call
aowlspt/il2cpp754the binding to the IL2CPP runtime's own C API
aowlspt/abi294the raw types and status constants, re-exported by aowlspt
aowlspt/sync93the mod-side lock

The smallest whole mod

nim
import aowlspt

proc onLoad(): Status =
  success "hello from " & hostName()
  Ok

exportMod(guid = "you.hello", name = "Hello", author = "you",
          version = "1.0.0", sptRange = "*",
          sides = {sideServer, sideClient, sideSim},
          onLoad = onLoad)

exportMod goes last in the module — the handlers have to be declared before they are named. Unsupplied hooks default to no-ops rather than to nil, so the host never has a null function pointer to guard against.

Core types

symboldefinition
Statusint32Ok is 0; every failure is negative
Handleuint64an opaque host-side object; 0 is null
Sideenum sideServer = 1, sideClient, sideSimwhich host you are in
LogLevelenum llTrace, llDebug, llInfo, llSuccess, llWarn, llError
RouteKindenum rkStatic, rkDynamic
PatchKindenum pkPrefix, pkPostfix, pkFinalizer
ModFlagenum mfHotReloadable, mfThreadSafeModFlags = set[ModFlag]
ArgKindenum akNone, akInt, akFloat, akDouble, akObject, akValue, akBigValue, akVoid, akStack, akUnknownwhat one slot of a typed frame is, decided once at registration

Status constants: Ok, ErrGeneric, ErrAbi, ErrNotFound, ErrBadArg, ErrDecode, ErrUnsupported, ErrWrongThread, ErrDisposed, ErrModFault, ErrConfigParse.

aowlspt — the base module

Who am I, and where

symbolsignaturewhat it does
sideproc (): Sidewhich host loaded this mod. The one guard every portable mod uses.
hostNameproc (): stringe.g. "aowlspt-backend"
hostVersionproc (): string
sptVersionproc (): stringempty on the sim
gameVersionproc (): stringthe EFT build; empty server-side
modDirproc (): stringabsolute path to this mod's own directory
dataDirproc (): stringwritable scratch owned by this mod
nowMsproc (): int64
lastErrorproc (): stringtext of the last failure on this thread. Every non-OK status is paired with one.
hostReadyproc (): bool
hostApiSizeproc (): intthe watermark — what a capability test reads
expectedApiSizeproc (): intsizeof(HostApi) on the mod's side

Logging

symbolsignature
logproc (level: LogLevel; message: string)
trace debug info success warn errorproc (m: string)

Configuration

symbolsignaturewhat it does
configGetproc (key: string; outText: var string): StatusErrNotFound means "absent, use your default"; ErrConfigParse means the file is broken and no key in it is real
configSetproc (key: string; valueJson: string): Status

Routes, events, timers

symbolsignaturewhat it does
routeproc (url: string; kind: RouteKind; handler: RouteHandler): Statusbackend and sim only
onproc (event: string; handler: EventHandler): Statussubscribe
emitproc (event: string; payload: string = ""): Status
afterproc (delayMs: int; handler: TickHandler): Statusone shot
everyproc (intervalMs: int; handler: TickHandler): Statusrepeating
onMainThreadproc (handler: TickHandler): Statusrun once on the host's main thread
everyMainproc (intervalMs: int; handler: TickHandler): Status
stopMainRepeatsproc (): int
scheduledSlotsproc (): int
registeredRoutes / registeredEvents / registeredPatches / registeredTypedPatchesproc (): intwhat this mod currently holds

onMainThread on the client is conditional and says which it gave you. There is no API that hands a native DLL Unity's player loop, so the host detours a method Unity runs every frame and drains the queue from inside it. If none of its candidates binds, it warns at boot and falls back to its own thread. Ask with call("aowlspt.host::main_thread"); bound is true only once the hook has actually fired.

The database (backend / sim)

symbolsignaturewhat it does
dbGetproc (path: string; outText: var string): Statusdotted path
dbPatchproc (path: string; patchJson: string): Statusmerges, and creates a path that is not there

dbPatch merging rather than replacing is the difference between a mod system and a pile of mods that happen to coexist: two mods editing sibling fields of the same item do not clobber each other. And creating an absent path is what lets a content mod — a new location, a new bot type, a table of its own — express what it is doing instead of carrying a workaround.

Reflection (client)

symbolsignaturewhat it does
callproc (target, argsJson: string; outText: var string): Status"Namespace.Type::Method"
resolveproc (typeName: string; outHandle: var Handle): Status
releaseproc (h: Handle)

Patching (client)

symbolsignature
patchproc (target: string; kind: PatchKind; handler: PatchHandler; withArgs = false): Status
patchTypedproc (target: string; kind: PatchKind; handler: TypedPatchHandler): Status
patchContinuefunc (): PatchResult
patchReplacefunc (json: string): PatchResult
typedPatchesReadyproc (): bool — the revision-4 capability test

The store (all hosts, revision 2)

symbolsignature
storeReadyproc (): bool
storeGetproc (key: string; outText: var string): Status
storeSetproc (key, value: string): Status
storeListproc (prefix: string; outJson: var string): Status

Addresses and pins (client, revision 3)

symbolsignaturewhat it does
livePointersReadyproc (): bool
pointerOfproc (h: Handle; outAddress: var uint64): Status8 ns, against 1112 for the boxed property read it replaces
pinHandleproc (h: Handle; outPinned: var Handle): Statuskeeps an object alive at the stated cost

Notifications (backend, revision 5)

symbolsignature
notifyReadyproc (): bool
notifyPushproc (session: string; ...): Status — a push down that session's notifier websocket

The typed patch frame

The revision-4 hook is handed a borrowed view of the saved registers instead of a JSON payload. PatchFrame is read field-wise through these:

symbolsignaturewhat it does
frameLiveproc (f: PatchFrame): boolis this frame still the one you were called for
frameSerialproc (f: PatchFrame): uint32
frameSize / expectedFrameSizeproc (...): intthe same watermark discipline as HostApi
argCountproc (f: PatchFrame): int
kindOf / retKindOfproc (f: PatchFrame; i: int): ArgKinddecided once, at registration
framePostfix / frameStaticproc (f: PatchFrame): bool
selfPointerproc (f: PatchFrame): uint64
argInt / argFloat / argPointerproc (f: PatchFrame; i: int; ok: var bool): Tthe ok out-parameter is not optional — a slot that is not what you asked for says so
resultInt / resultFloat / resultPointerproc (f: PatchFrame; ok: var bool): Tpostfix only
setResultInt / setResultFloat / setResultPointer / setResultVoidproc (f: PatchFrame; v: T): bool
frameContinue / frameReplacefunc (): TypedResult
frameWhy / frameWhyTextproc (): int / proc (): stringwhy the last read refused

Exporting a mod

nim
template exportMod*(guid, name, author, version, sptRange: string;
                    sides: set[Side];
                    onLoad:    proc (): Status = noopLoad;
                    onUpdate:  proc (elapsedMs: int64): Status = noopUpdate;
                    onUnload:  proc (): Status = noopUnload;
                    stateSave: proc (): string = noopStateSave;
                    stateLoad: proc (state: string): Status = noopStateLoad;
                    flags: ModFlags = {})

It emits aowlspt_abi_version, aowlspt_describe and aowlspt_init — the three exports the ABI names — and nothing else.


aowlspt/game — the client-side high-level API

Reflection made ordinary: a GameType is a named type that resolves lazily, a GameObj is a live handle, and both take the same invoke/get/set.

nim
import aowlspt
import aowlspt/game

var Player = gameType("EFT.Player")
var world  = whenReady("EFT.GameWorld")

proc onUpdate(elapsedMs: int64): Status =
  if world.ready():                      # true once, when the game exists
    info "health " & $Player.get("Health").asFloat()
    discard Player.invoke("Heal", 50)
  Ok

Values and results

symbolsignaturewhat it does
vproc (x: int | float | bool | string): Valueone overload per scalar
argsJsonproc (args: openArray[Value]): string
CallResultobjectevery call answers one
failedproc (r: CallResult): bool
asText / asInt / asFloat / asBoolproc (r: CallResult; default = ...): Tdefaults rather than exceptions
isNullproc (r: CallResult): bool

Types

symbolsignaturewhat it does
gameTypetemplate (typeName: string): GameTypedeclare a named type; nothing is resolved yet
resolveNow / availableproc (t: var GameType): boolresolve, and say whether it worked
invokeproc (t: var GameType; member: string; ...): CallResultoverloads for 0–3 Values and for bare int / float / string / bool
get / setproc (t: var GameType; property: string; ...): CallResultproperties
field / setFieldproc (t: var GameType; name: string; ...): CallResultfields
instanceOfproc (t: var GameType; property = "Instance"): GameObjthe singleton pattern, once
whenReadytemplate (typeName: string): WhenReady
readyproc (w: var WhenReady): booltrue once, when the type appears

Live objects

symbolsignaturewhat it does
asObject / isObjectproc (r: CallResult): GameObj / boola reference return is a handle
noObjectproc (): GameObj
invoke / get / set / field / setFieldproc (o: GameObj; ...)the same shapes as GameType
childproc (o: GameObj; property: string): GameObjchain without unpacking
aliveproc (o: GameObj): bool
releaseproc (o: GameObj)

Hooks

symbolsignaturewhat it does
hookproc (target: string; handler: HookHandler): Statusthe plain prefix
hookArgsproc (target: string; handler: ArgHookHandler): Statusthe arguments too — up to four; the fifth onward is omitted rather than guessed
hookReturnproc (target: string; handler: ArgHookHandler; withArgs = ...): Statusthe postfix
hookTypedproc (target: string; handler: TypedPatchHandler): Statusthe per-frame path
hookReturnTypedproc (target: string; handler: TypedPatchHandler): Status
carryOnfunc (): HookResultlet the original run
stopWithfunc (json: string): HookResultsuppress it, and answer this instead
stopVoidfunc (): HookResultsuppress a void method
replaceResult / keepResultfunc (...): HookResultpostfix
thisHandle / thisPointer / hookResult / memberRaw / handleInproc (payload: string): ...reading the JSON payload without a parser

stopWith is checked against the method's declared return type; a replacement that does not match is refused rather than written into the game's registers.


aowlspt/server — the backend-side high-level API

nim
import aowlspt
import aowlspt/server

proc onStatus(url, body, session: string): string =
  var o = obj()
  put(o, "ok", true)
  put(o, "session", session)
  result = done(o).text

proc onItem(url, body, session: string): string =
  let id = pathAfter(url, "/aowlspt/item/")
  if id.len == 0: return errJson("no item id in " & url)
  let weight = dbRead("templates.items." & id & "._props.Weight")
  if not weight.ok: return errJson("no such item: " & id)
  var o = obj()
  put(o, "ok", true)
  put(o, "weight", weight.asFloat())
  result = done(o).text

proc onLoad(): Status =
  # `!= sideClient`, not `== sideServer`, and the difference is the whole
  # example working or silently not: the sim presents as `sim`.
  if side() == sideClient:
    info "this mod serves; there is no HTTP server inside the game"
    return Ok
  discard serve("/aowlspt/status", onStatus)
  discard servePrefix("/aowlspt/item/", onItem)
  Ok

That comment is copied verbatim from examples/gameserver, and it is there because the guard was written the other way round first: under aowlspt-sim the mod registered no routes and wrote nothing to the database, the gate exercised two lines and reported ok, because a run that does nothing exits zero. examples/hello had the identical bug, and the simulator now carries a bespoke "no route registered for…" message whose only job is to explain the resulting confusion — which is how you can tell it has happened to people.

Routing

symbolsignaturewhat it does
serveproc (url: string; handler: Handler): Statusexact match
servePrefixproc (prefix: string; handler: Handler): Statusmost of the client's real endpoints are shaped this way
pathAfterproc (url, prefix: string): stringthe id out of the path

The database

symbolsignaturewhat it does
dbReadproc (path: string): DbValue
asText / asInt / asFloatproc (v: DbValue; default = ...): T
dbWriteproc (path: string; patch: Json | JsonObject | JsonArray | string): Statusmerges
dbKeysproc (path: string; into: var seq[string]): Statusthe key list without the values — 230 bytes instead of 12.5 MB
dbKeysOrReadproc (path: string; into: var seq[string]; scanned: var bool): StatusdbKeys where the host has it, a scan where it does not, and it tells you which
dbKeysReadyproc (): bool

Configuration

symbolsignature
settingproc (key: string): ConfigValue
asText / asInt / asFloat / asBoolproc (c: ConfigValue; default = ...): T
configFaultedproc (): bool — the file is broken, not the key

Building JSON without a DOM

symbolsignature
obj / arrproc (): JsonObject / JsonArray
putproc (o: var JsonObject; key: string; value: Json | string | int | float | bool | JsonObject | JsonArray)
addproc (a: var JsonArray; value: ...) — the same set
doneproc (o: JsonObject): Json / proc (a: JsonArray): Json
jstr / jint / jfloat / jbool / jnull / rawproc (...): Json
objOfproc (key: string; value: ...): JsonObject — the one-field case
emptyArray / emptyObjectproc (): Json
escapeTextproc (s: string): string
okJson / errJsonproc (...): string

The client's envelope

Every route the emulator answers goes out as {"err":0,"errmsg":null,"data":…}.

symbolsignature
envelopeproc (data: Json | string | JsonObject | JsonArray): string
envelopeNullproc (): string
failureproc (code: int; message: string): string

Persistence, events, timers

symbolsignature
saveproc (key: string; value: Json | string | JsonObject): Status
loadproc (key: string): Stored
savedproc (key: string): bool
savedKeysproc (prefix = ""): seq[string]
broadcastproc (name: string; payload: ...): Status
onEventproc (name: string; handler: EventHandler): Status
afterMs / everyMsproc (ms: int; handler: TickHandler): Status

aowlspt/json — reading a document without rebuilding it

A cursor over the text, not a parse into objects. JsonRef is a found-or-not view; Doc and List are editable.

symbolsignaturewhat it does
wholeproc (text: string): JsonRef
fieldproc (j: JsonRef | text: string; path: string): JsonRefdotted path
child / atproc (j: JsonRef; key: string | index: int): JsonRef
exists / isNull / isText / isObject / isArrayproc (j: JsonRef): bool
asText / asInt / asFloat / asBoolproc (j: JsonRef; default = ...): T
count / keys / each / membersproc (j: JsonRef): ...
rawproc (j: JsonRef): stringthe untouched source text
notFoundfunc (): JsonRef
parseObject / newDocproc (...): Doc
has / get / getRawproc (d: Doc; name: string): ...
setRaw / setText / setNumber / setBool / removeproc (d: var Doc; ...)
textproc (d: Doc): string / proc (l: List): stringback to a document
parseArray / newListproc (...): List
len / at / add / replaceAt / removeAtproc (l: ...; ...)
quoted / escapeTextproc (s: string): string

aowlspt/fast — bind once, then call

The per-frame path. A binding costs 1–9 µs, almost all of it findClass walking every loaded assembly; a bound call then costs ~10 ns and a bound field read 1.87 ns. That is the whole argument for binding once: it costs about as much as a hundred bound calls, and then it costs nothing.

symbolsignaturewhat it does
bindMethodproc (rt: Il2Cpp; owner, member: string; argc: int): Bindingby name
bindMethodAsproc (rt: Il2Cpp; owner, member: string; ...): Bindingwith a declared shape
bindInClass / bindOnObjectproc (rt: Il2Cpp; ...): Bindingtakes the class off an instance — reaches a generic instantiation, which has no name to look up
bindRawproc (rt: Il2Cpp; cls: Il2CppClass; owner, member: string; ...): Bindingthe Vector3-shaped cases
callVoid / callInt / callInt32 / callBool / callPtr / callFloatproc (b: Binding; self: Il2CppPtr; a: var Args): T
callShapedPtr / callShapedVoid / callShapedFloatproc (b: Binding; a: var ShapedArgs): T
noArgs / reset / addInt / addBool / addFloat / addPtrproc (a: var Args; ...)MaxArgs* = 4, MaxSlots* = 5 (including this)
argsI / argsF / argsP / argsII / argsFFproc (...): Argsthe common shapes, inline
bindField / bindStaticFieldproc (rt: Il2Cpp; owner, fieldName: string): FieldBinding
readInt / readInt32 / readBool / readFloat / readPtrproc (f: FieldBinding; obj: Il2CppPtr): Tcached offset, direct load
writeInt / writeBool / writeFloat / writePtrproc (f: FieldBinding; obj: Il2CppPtr; v: T)writePtr routes reference stores through IL2CPP's write barrier
writePtrRawproc (f: FieldBinding; obj: Il2CppPtr; v: Il2CppPtr)the deliberate opt-out, and the only pointer store offered for statics
barrierReadyproc (f: FieldBinding): boolsays which one you are getting
classifyType / classifyClass / classifyDeclared / describeproc (...): FastKind
perfCounter / perfFreq / nanosBetweenproc (...): int64
allocationCount / allocatedBytes / liveBytes / allocProbeOkproc (): int64 / boola mod can assert its own per-frame path allocates nothing

bindMethod refuses doubles, five-or-more arguments and arrays, rather than binding them wrongly. It no longer refuses enums.


aowlspt/il2cpp — the runtime's own C API

openIl2Cpp resolves 62 named entries (NumEntries) out of GameAssembly.dll, of which 9 are Essential and a missing one is named rather than discovered later:

symbolsignature
openIl2Cppproc (path: string = ""): Il2Cpp
hasproc (rt: Il2Cpp; e: Entry): bool
missingEssentialproc (rt: Il2Cpp): seq[string]
findClass / classFromName / classFromTypeproc (...): Il2CppClass
findMethod / findField / findPropertyproc (...)
nextMethod / nextField / nextPropertyiterator-shaped enumeration
invokeproc (rt: Il2Cpp; m: Il2CppMethod; obj: Il2CppObject; ...)
methodPointer / methodFlags / methodIsStatic / methodParamCount
fieldOffset / fieldFlags / fieldIsStatic / staticFieldData
newString / readString / readCString
valueBox / objectUnbox / objectNew / objectClass
gcHandleNew / gcHandleTarget / gcHandleFree
threadAttach / threadDetach / threadCurrent
writeBarrier / hasWriteBarrier / writeBarrierFn
boxHeaderBytes / valueWidth / classInstanceSize / classIsValueType

The README reports 242 exported functions on the post-1.0 client tools/il2cppprobe.nim was run against. 62 is what aowlspt binds of them.

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.