Skip to content

aowlspt/game

Source: aowl/src/aowlspt/game.nim — 914 lines.

The high-level client API — what writing a mod should actually feel like.

Underneath, everything reaches the game the same way: resolve a type by name, call a method by name, patch a method to be told when it runs. That is a complete interface and a miserable one to write against:

var h: Handle = 0 discard resolve("EFT.Player", h) var res = "" discard call("EFT.Player::Heal", "[50]", res)

This module is the same three things with the boilerplate gone:

var Player = gameType("EFT.Player") discard Player.invoke("Heal", 50)

proc onDead(target: string) = info "someone died"

discard hook("EFT.Player::OnDead", onDead)

Two things it does beyond tidying.

It caches. A GameType resolves once, on first use, and holds the handle. Resolving walks every loaded assembly, so doing it per call in a tick loop is the difference between a mod you can ship and one that hitches.

It waits. The game's own assemblies are not loaded when a mod starts, so a type resolved at load time is a false negative. A GameType is declared eagerly and resolved lazily, which means a mod can name its types at the top of the file the way it wants to and still be correct.

Types

ValueKind

nim
  ValueKind* = enum
    vkInt, vkFloat, vkBool, vkString, vkObject

aowl/src/aowlspt/game.nim:37

Value

nim
  Value* = object
    kind*: ValueKind
    i*: int64
    f*: float
    s*: string
    h*: Handle

One argument. A variant rather than a generic so that a call site can mix types: invoke("Move", 1, 2.5, true).

aowl/src/aowlspt/game.nim:40

CallResult

nim
  CallResult* = object
    ok*: bool
    raw*: string
    error*: string

Deliberately not a bare string. A call can fail, and a mod that reads the result without noticing gets "" -- which is a plausible value for plenty of methods, so the failure would look like data.

aowl/src/aowlspt/game.nim:91

GameType

nim
  GameType* = object
    name*: string
    handle*: Handle
    tried*: bool

A type in the running game, resolved on first use.

Declared with gameType at the top of a mod and used whenever; the resolution happens the first time it is needed, which is the only point at which the game is guaranteed to have loaded its assemblies.

aowl/src/aowlspt/game.nim:196

GameObj

nim
  GameObj* = object
    handle*: Handle
    typeName*: string
    ok*: bool

A live object in the game. ok is false for "the call did not return one", which is an ordinary answer -- get_Instance before the world exists returns null, and that is not a failure.

aowl/src/aowlspt/game.nim:316

HookHandler

nim
  HookHandler* = proc (target: string)

aowl/src/aowlspt/game.nim:434

ArgHookHandler

nim
  ArgHookHandler* = proc (target, args: string): HookResult

A hook that is told what the method was called with, and may answer.

args is a JSON array in the same shape call takes, so it is read with aowlspt/json exactly like a request body on the server side. A reference argument arrives as {"handle":n,...} -- the live object the game was about to act on, which is usually the reason to want the arguments at all.

aowl/src/aowlspt/game.nim:436

HookResult

nim
  HookResult* = object
    stop*: bool
    replaceWith*: string

What a hook decided. stop suppresses the original; replaceWith is the value the caller gets instead, as JSON, and must match the method's declared return type or the suppression is refused.

aowl/src/aowlspt/game.nim:445

WhenReady

nim
  WhenReady* = object
    probe*: GameType
    fired*: bool

A one-shot that fires the first tick after a type becomes resolvable.

This exists because "the game is up" is not an event the client host can observe -- assemblies arrive when they arrive. Polling for a type that only exists once the world does is the honest way to wait for it, and doing it once here beats every mod inventing its own counter.

aowl/src/aowlspt/game.nim:890

Routines

v

nim
proc v*(x: int): Value

No separate int64 overload: nimony's int is 64-bit, so the two would be the same signature and every call site would be ambiguous.

aowl/src/aowlspt/game.nim:49

v

nim
proc v*(x: float): Value

aowl/src/aowlspt/game.nim:53

v

nim
proc v*(x: bool): Value

aowl/src/aowlspt/game.nim:55

v

nim
proc v*(x: string): Value

aowl/src/aowlspt/game.nim:57

toJson

nim
proc toJson*(v: Value): string

aowl/src/aowlspt/game.nim:71

argsJson

nim
proc argsJson*(args: openArray[Value]): string

aowl/src/aowlspt/game.nim:79

failed

nim
proc failed*(r: CallResult): bool

aowl/src/aowlspt/game.nim:99

asText

nim
proc asText*(r: CallResult): string

The result as text, with the JSON quoting removed if it was a string.

aowl/src/aowlspt/game.nim:119

asInt

nim
proc asInt*(r: CallResult; default: int = 0): int

aowl/src/aowlspt/game.nim:123

asFloat

nim
proc asFloat*(r: CallResult; default: float = 0.0): float

aowl/src/aowlspt/game.nim:148

asBool

nim
proc asBool*(r: CallResult; default: bool = false): bool

aowl/src/aowlspt/game.nim:181

isNull

nim
proc isNull*(r: CallResult): bool

aowl/src/aowlspt/game.nim:188

gameType

nim
template gameType*(typeName: string): GameType

Names a type. Does not resolve it yet — see the note above.

A template rather than a proc, and that is not a style choice. In a nimony --app:lib build, a global initialised by a call is never initialised: literal initialisers are folded at compile time, anything needing runtime evaluation is skipped, and the global is left zeroed. A mod written the obvious way —

var Player = gameType("EFT.Player")

would therefore hold an empty name and fail every lookup, silently. As a template this expands to an object literal at the declaration, which does get folded, so the obvious way is also the correct one.

aowl/src/aowlspt/game.nim:206

resolveNow

nim
proc resolveNow*(t: var GameType): bool

Resolves if it has not been resolved. Returns whether the type is available; a failure is retried on the next call, because "not yet" and "never" are the same answer early in a run and only one of them is final.

aowl/src/aowlspt/game.nim:222

available

nim
proc available*(t: var GameType): bool

aowl/src/aowlspt/game.nim:242

invoke

nim
proc invoke*(t: var GameType; member: string; args: openArray[Value] = []): CallResult

Calls a static member.

aowl/src/aowlspt/game.nim:244

invoke

nim
proc invoke*(t: var GameType; member: string; a: Value): CallResult

aowl/src/aowlspt/game.nim:261

invoke

nim
proc invoke*(t: var GameType; member: string; a, b: Value): CallResult

aowl/src/aowlspt/game.nim:263

invoke

nim
proc invoke*(t: var GameType; member: string; a, b, c: Value): CallResult

aowl/src/aowlspt/game.nim:265

invoke

nim
proc invoke*(t: var GameType; member: string; a: int): CallResult

aowl/src/aowlspt/game.nim:268

invoke

nim
proc invoke*(t: var GameType; member: string; a: float): CallResult

aowl/src/aowlspt/game.nim:270

invoke

nim
proc invoke*(t: var GameType; member: string; a: string): CallResult

aowl/src/aowlspt/game.nim:272

invoke

nim
proc invoke*(t: var GameType; member: string; a: bool): CallResult

aowl/src/aowlspt/game.nim:274

invoke

nim
proc invoke*(t: var GameType; member: string; a, b: int): CallResult

aowl/src/aowlspt/game.nim:276

field

nim
proc field*(t: var GameType; name: string): CallResult

A static field.

aowl/src/aowlspt/game.nim:279

setField

nim
proc setField*(t: var GameType; name: string; value: Value): CallResult

aowl/src/aowlspt/game.nim:283

get

nim
proc get*(t: var GameType; property: string): CallResult

A property getter. In IL2CPP a C# property compiles to get_Name, so this is the same call with the convention applied — which is worth wrapping, because forgetting the prefix produces "no such method" and no hint as to why.

aowl/src/aowlspt/game.nim:286

set

nim
proc set*(t: var GameType; property: string; value: Value): CallResult

aowl/src/aowlspt/game.nim:293

noObject

nim
proc noObject*(): GameObj

aowl/src/aowlspt/game.nim:324

asObject

nim
proc asObject*(r: CallResult): GameObj

The object a call returned, if it returned one.

aowl/src/aowlspt/game.nim:326

isObject

nim
proc isObject*(r: CallResult): bool

aowl/src/aowlspt/game.nim:352

invoke

nim
proc invoke*(o: GameObj; member: string; args: openArray[Value] = []): CallResult

Calls a member on this object.

aowl/src/aowlspt/game.nim:356

invoke

nim
proc invoke*(o: GameObj; member: string; a: Value): CallResult

aowl/src/aowlspt/game.nim:371

invoke

nim
proc invoke*(o: GameObj; member: string; a, b: Value): CallResult

aowl/src/aowlspt/game.nim:373

invoke

nim
proc invoke*(o: GameObj; member: string; a, b, c: Value): CallResult

aowl/src/aowlspt/game.nim:375

invoke

nim
proc invoke*(o: GameObj; member: string; a: int): CallResult

aowl/src/aowlspt/game.nim:377

invoke

nim
proc invoke*(o: GameObj; member: string; a: float): CallResult

aowl/src/aowlspt/game.nim:379

invoke

nim
proc invoke*(o: GameObj; member: string; a: string): CallResult

aowl/src/aowlspt/game.nim:381

invoke

nim
proc invoke*(o: GameObj; member: string; a: bool): CallResult

aowl/src/aowlspt/game.nim:383

get

nim
proc get*(o: GameObj; property: string): CallResult

aowl/src/aowlspt/game.nim:386

set

nim
proc set*(o: GameObj; property: string; value: Value): CallResult

aowl/src/aowlspt/game.nim:389

field

nim
proc field*(o: GameObj; name: string): CallResult

A field, not a property.

get/set reach a C# property, which compiles to get_X/set_X. A field has no method behind it and is unreachable that way -- and a great deal of what a mod wants to read on this game is a field, often a private one. Reaching it is not a trick: reflection is what the runtime provides for it.

aowl/src/aowlspt/game.nim:392

setField

nim
proc setField*(o: GameObj; name: string; value: Value): CallResult

aowl/src/aowlspt/game.nim:401

child

nim
proc child*(o: GameObj; property: string): GameObj

The object a property returns -- player.child("Physical"). Walking a chain of these is how a mod gets from a world to a weapon.

aowl/src/aowlspt/game.nim:404

alive

nim
proc alive*(o: GameObj): bool

Whether the object is still there. A handle to something the collector has taken answers false rather than crashing when it is next used, and this is how a mod can ask before it tries.

aowl/src/aowlspt/game.nim:409

release

nim
proc release*(o: GameObj)

Lets go. A handle held for the session is a pinned object.

aowl/src/aowlspt/game.nim:418

instanceOf

nim
proc instanceOf*(t: var GameType; property: string = "Instance"): GameObj

The singleton behind a type -- GameWorld, CameraManager and most of the game's managers expose exactly this. Separated out because reaching for the singleton is the first thing almost every client mod does.

aowl/src/aowlspt/game.nim:423

carryOn

nim
func carryOn*(): HookResult

aowl/src/aowlspt/game.nim:452

stopWith

nim
func stopWith*(json: string): HookResult

Suppress the original and return this value instead.

aowl/src/aowlspt/game.nim:454

stopVoid

nim
func stopVoid*(): HookResult

Suppress a method that returns nothing.

aowl/src/aowlspt/game.nim:458

replaceResult

nim
func replaceResult*(json: string): HookResult

For a hookReturn handler: hand the caller this instead of what the original returned.

The same HookResult a prefix uses, and the same field, because it is the same single decision -- what the caller ends up with. Named separately because "stop" is the wrong word once the original has already run, and a postfix handler reading stopWith would reasonably wonder what it was stopping.

aowl/src/aowlspt/game.nim:462

keepResult

nim
func keepResult*(): HookResult

For a hookReturn handler: leave the original's answer alone.

Hooks are kept as two parallel sequences and dispatched by matching the target name the host hands back.

The obvious design — capture the index in a closure per hook — is not available: nimony will not let a nested proc touch its enclosing scope without being made a closure, and the ABI wants a plain function pointer. Matching on the name keeps every callback a top-level proc.

The scan is linear in the number of hooks, on the path inside a patched game method. That is fine for the handful of hooks a mod installs and would not be for hundreds; if it ever is, the fix is a sorted table here, not a change to the ABI.

aowl/src/aowlspt/game.nim:473

hookReturn

nim
proc hookReturn*(target: string; handler: ArgHookHandler; withArgs = true): Status

Be told what a game method returned, and be able to change it.

This is Harmony's postfix, and it is the shape a mod needs whenever the answer depends on the original's own answer -- scaling a sensitivity, clamping a speed, filtering a list the game just built. The prefix alternative is stopWith, which means reimplementing the method, and a reimplementation of a method you cannot read is a guess.

What the handler is given. The hookArgs payload with one more member:

{"this":{"handle":3,...},"args":[4.0],"result":6.0} {"result":6.0} -- withArgs = false

hookResult(payload) reads it as a CallResult, so asFloat(), asInt(), asBool() and asObject() all work on it. Return replaceResult(...) to change it, keepResult() to leave it alone.

result is always present, whether or not arguments were asked for: it is one register read and it is the reason the hook exists. The arguments stay opt-in, and they are the values the method was entered with -- the registers saved on the way in, which is the only place they still are once the original has run.

Two methods cannot carry one, and the host says which. A method returning a value type wider than eight bytes returns it through a buffer the host cannot read the layout of, so a postfix there could neither report the result nor replace it. And a method whose compiled call uses more than four register slots -- its declared arguments, this, and IL2CPP's trailing MethodInfo* -- has stack arguments, which a postfix cannot pass on, because it must call the original rather than jump to it. Both are refused at registration with a sentence naming which; check the status and read lastError().

An exception thrown out of the original does not reach the handler. A postfix means "after it returned", not "after it finished".

aowl/src/aowlspt/game.nim:545

hookArgs

nim
proc hookArgs*(target: string; handler: ArgHookHandler): Status

Be told whenever a game method runs, with its arguments, and be able to stop it.

This is the full Harmony prefix: read what the method was called with, and return stopWith(...) to keep it from running at all. The cost over hook is one JSON payload built per call, which is why it is a separate function rather than the default -- a hook on a method the game calls every frame for every bot should be a hook.

What the handler is given. An object, not an array:

{"this":{"handle":3,"type":"EFT.Player"},"argc":2,"stackArgs":0, "args":[1,2.5]} {"this":null,"argc":0,"stackArgs":0,"args":[]} -- a static method

this is Harmony's __instance, and it is separate from args on purpose: it is not a declared parameter, and folding it into the array would make every argument index depend on whether the method happens to be static -- a difference that shows up as reading the wrong argument, far from the hook. A static method's this is null rather than missing, so "there is no instance" cannot be read as "the host did not say".

this and any reference argument arrive as handles, and they are valid only until the handler returns. The host frees them at that point: a handler on a per-frame method would otherwise pin one object per call, and a contract that depends on a mod remembering to release on that path is a leak with extra steps. Read what you need inside the handler; do not store the handle.

An enum argument arrives as its integer, and a value type too large for a register arrives as {"valueType":"..."} -- named and refused, because it is passed by hidden pointer and there is nothing in the register to report. A type the runtime cannot classify is {"type":"..."}.

An argument past the register window is named, not dropped. Win64 passes four arguments in registers and the thunk saves those four; on an instance method this is one of them, so a method with four declared parameters has its fourth on the stack and a static one has its fifth. There is nothing in the frame to report for it and nothing here can invent it -- but the payload says so rather than falling silent:

sits in that slot, so argument n is always declared parameter n, and "the host did not report this" can never be read as "this was empty". argc is the declared parameter count and stackArgs is how many of them are in that state, so a handler that wants nothing to do with a truncated firing can check one number before it reads anything.

This was the last silent wrong answer on this path. The array used to stop early, so a handler reading argument 3 of a four-argument instance method got the same empty answer an empty argument would give it, and went on to decide something on a value the game never supplied.

aowl/src/aowlspt/game.nim:589

hookTyped

nim
proc hookTyped*(target: string; handler: TypedPatchHandler): Status

A prefix hook that is given the registers rather than a description of them, and may suppress the original.

The handler receives a PatchFrame:

nim
proc onTilt(f: PatchFrame): TypedResult =
let obj = cast[Il2CppPtr](f.selfPointer())   # `this`, no handle, no call
var ok = false
let a = f.argFloat(0, ok)                    # declared parameter 0
if not ok: return frameContinue()
...
if f.setResultVoid(): frameReplace() else: frameContinue()

selfPointer is the address thisPointer(payload) costs a JSON build, a GC handle and a pointerOf round trip to produce; here it is the register the method was entered with. The same lifetime rule applies to it and to every argPointer: they are addresses, the collector moves objects, use them inside the handler. The frame itself is checked -- reading one after the handler has returned is refused and frameWhyText() says so.

Arguments past the fourth register position report akStack and cannot be read; they are named rather than omitted, so argument n is always declared parameter n.

ErrUnsupported on a host older than revision 4 -- test typedPatchesReady() and fall back to hookArgs, which is the point of keeping both.

aowl/src/aowlspt/game.nim:675

hookReturnTyped

nim
proc hookReturnTyped*(target: string; handler: TypedPatchHandler): Status

The postfix half: the original has run, and resultFloat, resultInt and resultPointer read what it produced -- out of the register the declared return type says it is in. setResultFloat and friends change it, and frameReplace() makes the change stick.

Reading a result on a prefix frame is refused rather than answered, because the original has not run and scaling a number that does not exist yet is worse than not scaling one.

The same two method shapes the host refuses for a JSON postfix are refused here, for the same reasons and with the same sentences: a value-type return wider than a register, and a compiled call needing more than four register slots. See hookReturn.

aowl/src/aowlspt/game.nim:706

memberRaw

nim
proc memberRaw*(payload: string; key: string): string

The raw JSON of one top-level member of a hook payload, or "" if absent.

Only top-level members: the scan starts at depth 0 and a "result" that appeared inside a nested object would be skipped rather than mistaken for the one being asked for. Worth the extra state, because a game string in an argument can contain anything at all.

aowl/src/aowlspt/game.nim:732

handleIn

nim
proc handleIn*(json: string): Handle

The handle in a {"handle":n,"type":"..."}, or 0.

aowl/src/aowlspt/game.nim:806

thisHandle

nim
proc thisHandle*(payload: string): Handle

The handle for __instance, or 0 for a static method.

aowl/src/aowlspt/game.nim:822

thisPointer

nim
proc thisPointer*(payload: string): uint64

The address of the object a hook fired for, for the fast path.

This is the one line that turns a hook from a notification into a place to do work. hookArgs gives this as a handle, and everything reached through a handle goes through call -- roughly a microsecond, which on a method the game runs per entity per frame is a frame tax rather than a feature. The address is what aowlspt/fast's bindOnObject, callFloat and readFloat take, and those are tens of nanoseconds.

Valid only while the handler runs. The host reclaims the handle when the handler returns, and asking afterwards is refused rather than answered with a stale address -- but nothing can refuse an address the mod wrote down and used next frame, because by then it is just a number. Read what you need inside the handler. pinHandle is the way to keep an object, and says what pinning costs.

Zero means there is no address: a static method, a host without a managed heap, or a handler that has already returned. lastError() says which.

aowl/src/aowlspt/game.nim:826

hookResult

nim
proc hookResult*(payload: string): CallResult

What the original returned, for a postfix handler.

A CallResult rather than a bare string for the same reason invoke returns one: asFloat() on a missing member would answer 0.0, and 0.0 is a plausible sensitivity. ok is false when the payload carries no result, which is what a prefix handler's payload looks like -- so a handler registered the wrong way round finds out here rather than scaling a zero.

aowl/src/aowlspt/game.nim:853

hook

nim
proc hook*(target: string; handler: HookHandler): Status

Be told whenever a game method runs.

The cheap one: it costs a name comparison and nothing else, and the handler is told only that the method fired. Use hookArgs when you need to know what it was called with or want to stop it.

aowl/src/aowlspt/game.nim:869

whenReady

nim
template whenReady*(typeName: string): WhenReady

A template for the same reason gameType is one.

aowl/src/aowlspt/game.nim:900

ready

nim
proc ready*(w: var WhenReady): bool

Call from onUpdate. True exactly once, on the first tick where the type resolves.

aowl/src/aowlspt/game.nim:905

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