Axiom/SDK
SDK / Memory, symbols, hooks

Memory, symbols, hooks

The low-level primitives every backend declares. Packs are built on these; most mods should never need them.

These are the ten primitives every backend declares, which is what makes a backend a backend. Packs live here. Most mods never touch any of it, and a mod that reaches for memory when a question would do is a mod that breaks on the next patch.

process@1

What you are attached to.

Answered by: the backend
modules() -> [Module]

Name, base and size of every loaded module.

module(name: str) -> Result<Module>

One by name.

arch() -> Arch

x64, arm64, ppc64, jvm or clr. jvm or clr is the signal that pointer math is unavailable and the object APIs are what you want.

pointer_size() -> u8

4 or 8, where the concept applies.

memory.read@1

Reading the address space.

Answered by: the backend
read(addr: u64, len: u32) -> Result<bytes>

One span.

Errors
Failed{ReadFault}
Unmapped or protected; never a crash
bulk read_many(spans: [Span]) -> [Result<bytes>]BULK

Many spans in one crossing. This is how a pack walks a chunked pool without paying per chunk.

read_chain(base: u64, offsets: [i32]) -> Result<u64>

Follow a pointer chain host-side. Four deep is one call, and it fails at whichever link was bad rather than faulting.

valid(addr: u64, len: u32) -> bool

Mapped and readable, without reading it.

memory.write@1

Separate question, separately denied, and frequently absent. A pack can answer read without write.

Answered by: the backend
write(addr: u64, data: bytes) -> Result<>WRITE

One span.

bulk write_many(writes: [Write]) -> [Result<>]BULKWRITE

Many, one crossing.

memory.scan@1

Finding something when a symbol will not do. Slow: at init, never per frame.

Answered by: the backend
scan(pattern: str, in: Module, limit: u32) -> [u64]

IDA-style pattern with ?? wildcards.

scan_one(pattern: str, in: Module) -> Result<u64>

Exactly one match, or a failure.

Errors
Failed{Ambiguous}
More than one hit. A pattern with two matches is a pattern that will pick the wrong one after the next patch, so this refuses rather than returning the first.
symbols@1

The only address source anything above this layer should use.

Answered by: a pack
resolve(name: str) -> Result<u64>

A name from the pack’s symbol table, resolved against this build.

Errors
Failed{SymbolUnresolved}
Names the symbol and the build, which is what makes a patch report useful
resolve_all() -> [(str, Result<u64>)]

Everything the pack declares, with its state. This is what axiom symbols prints.

exports(m: Module) -> [(str, u64)]

Exported symbols of a module.

Nothing above this layer stores an address

A mod or pack holding an address across a session is holding something that was only true for one build. Ask for the name every time; resolution is cached host-side.

hooks@1

Intercepting a game function.

Answered by: the backend
attach(at: u64, kind: HookKind, cb) -> Result<HookId>WRITE

Install a hook.

Parameters
kind
Before, After, Replace or Around
detach(id: HookId) -> Result<>

Remove it. Unloading does this for you.

call_original(id: HookId, args: Args) -> Result<Value>

From inside an Around hook.

Rule
Behavior
Chaining
Hooks on one address run in mod load order
Replace is not exclusive
A later Before still observes the call
A throwing hook
Is detached rather than left to fire again, and logged with the mod id
Tier W cost
Every invocation is a crossing. Hook the coarse function called once a tick, not the one called per entity per tick.
invoke@1

Calling the game's own code. The tenth primitive, the most dangerous thing in the system, and the only one the sandbox cannot protect you from.

Answered by: the backend

Six of the seven game shapes can be read. The one that cannot is resolution that is computed rather than stored: a handle table behind a virtual dispatch, ids transformed per frame, managed resolution that needs GC cooperation. A pack can bind the concrete layout until the game swaps implementations, and then it must either reimplement the game’s code, which breaks every patch, or call it.

declare(name: str, sig: Signature) -> Result<CallId>PACKS ONLY

Register a call site. The signature comes from the pack’s symbols file and generates a typed stub, so calling convention and argument marshalling are the generator’s problem rather than yours.

call(id: CallId, args: Args) -> Result<Value>PACKS ONLYWRITE

Invoke it. Runs on the thread the backend designates, at a barrier, once.

Errors
Failed{NoBarrier}
Called outside a certified barrier. It refuses rather than deadlocking on a lock the game holds.
Failed{Reentrant}
Issued from inside a hook that is itself inside an invoke
Failed{Timeout}
Exceeded the pack's slice
Denied
This backend has no safe barrier. Common on emulated cores.
barriers() -> [Barrier]

What this backend certifies as safe.

Barriers, and the clock they fix

Barrier
Where
Means
FrameBegin
Native, emulated
Before the game builds the frame
PrePresent
Native, emulated
Frame built, nothing submitted
TickBoundary
Headless, sim
Between logic ticks, no render involved
Safepoint
JVM, CLR
GC-safe, the only point managed invoke is legal
This also retires an ambiguity

frame used to mean a render frame on a client, a 50 ms logic tick on a headless server, and a GC safepoint on a managed runtime. One word, three clocks, and a mod saw 5 ms of staleness on one game and 50 ms on another. staleness() now reports milliseconds, and clock() names the barrier your frame hook rides.

What it cannot be made safe against

A wrong signature is undefined behavior inside the game’s own code. WebAssembly contains the pack’s own memory; invoke by definition leaves it. A Tier W pack that calls a real function with the wrong argument types corrupts or crashes the game exactly as a native one would.

Consequence
Why
Marked in the registry
A pack declaring any invoke signature is flagged
Prompts on install even at Tier W
Nothing else sandboxed does. This one earns it.
Reviewed as native-equivalent
However it was compiled
Usually denied on emulated
Safely calling guest code often is not possible, and saying so is the honest answer rather than a gap
Prefer not to need it

A pack that reads its way to an answer cannot crash a game. invoke exists because some games leave no alternative, not because calling in is a reasonable default.