7.3 KiB
WASM AI Controller ABI — Stage 5b implementation decisions
Internal design memo. Locks in the choices that docs/modding/ai-controller.md
flagged as TBD, so the Stage 5b implementer (Rust side in mc-mod-host) and
the Stage 6 reference mod can both target a stable contract.
Pair-document with ai-controller.md. When a decision here contradicts the
public doc, the public doc is updated to match this memo before Stage 5b lands.
Memory allocation
Decision: dual-path. The guest module declares its capability via exports the host inspects at load time:
| Mode | Guest exports | When chosen |
|---|---|---|
| Allocator | alloc(size: u32) -> i32 + dealloc(ptr: i32, size: u32) |
Default for wasm-bindgen / standard cargo build. Host calls alloc once at module init to reserve the I/O buffers and reuses them every turn (no per-turn malloc thrash). |
| Static | __input_ptr: i32, __input_cap: i32, __output_ptr: i32, __output_cap: i32 globals |
For hand-written modules / .wat fixtures / no-allocator no_std mods. Host reads the four globals once at load and reuses the static buffers. |
Detection order: if alloc is exported, allocator mode wins; otherwise the
host requires all four static globals. Mods exporting neither are refused at
load time with ModError::MissingExport("alloc or __input_ptr").
The per-call out_ptr / out_cap arguments documented in ai-controller.md
are populated by the host from whichever path was chosen. From the mod
author's perspective the contract is unchanged.
Buffer sizes
- Default input capacity: 64 KiB. Enough to carry a postcard-encoded
TacticalStatefor a Game-1 duel map (largest observed: ~12 KiB). - Default output capacity: 64 KiB. Action chains never exceed a few KiB.
- Override: optional guest globals
__input_cap_request: i32and__output_cap_request: i32read at load. Hard ceiling: 4 MiB each. Requests above the ceiling are clamped and logged.
__ident_ptr / __ident_len
Decision: i32 globals (per the public doc). Not functions. Read once at
load, cached on the host side. Pointed bytes live in the guest's .rodata;
they MUST NOT be mutated after init() returns.
Format "<name> <semver>" is enforced on the host: load fails with
ModError::InvalidIdent if the string does not match ^[a-z0-9_-]+ \d+\.\d+\.\d+(-[a-z0-9.-]+)?$.
decide_turn return codes
Decision: negative discriminated, zero is legitimate, positive is byte count.
| Value | Meaning |
|---|---|
0 |
Mod elected to do nothing this turn (legitimate empty plan). Host applies Vec::<Action>::new(). |
n > 0 |
Bytes written to the output buffer. Host reads out_ptr..out_ptr+n and postcard::from_bytes::<Vec<Action>>. Capped at out_cap; greater is treated as -2. |
-1 |
Generic error. Host substitutes empty actions, logs to telemetry, surfaces a warning. |
-2 |
Output buffer too small. Host doubles the buffer (up to the 4 MiB ceiling) and retries once. If still too small, falls back to empty actions. |
-3 |
Guest could not postcard-decode the state payload. Indicates a host/guest schema skew — host logs the mod's ident and the schema-version prefix it sent (see below). |
-4 |
Invalid slot argument. Should never happen if the host is correct; treated as a host bug, not a mod bug. Surfaces a hard error. |
Any other negative value is treated as -1.
Schema-version prefix
Decision: 4-byte big-endian version prefix on the state payload, NOT on the
action output. Host writes state_version: u32 immediately before the
postcard bytes, so state_ptr points at a 4-byte version then the payload.
state_len includes the prefix.
state_version is bumped any time the host changes the TacticalState
serialisation surface in a way that's NOT just additive-with-#[serde(default)].
Mods read the prefix first, compare to their pinned expected version, return
-3 if mismatched. Current value: 1. Mods built against future incompatible
versions get a clean rejection rather than a silent miscall.
Output (Vec) carries no prefix — the host owns its own action enum's schema; any breakage there breaks built-in scripted AI too, so the host has a strong incentive not to let it drift silently.
Fuel budget & epoch interruption
Decision:
- Fuel per turn:
100_000_000instructions. ~10 ms wallclock on a modern laptop. Set viawasmtime::Config::consume_fuel(true); host callsstore.set_fuel(100_000_000)before eachdecide_turn. Fuel exhaustion surfaces aswasmtime::Trap::OutOfFuel; host treats it as an empty-plan turn and logs the mod'sidentto telemetry. - Epoch deadline: 1 second wallclock hard cap. Host runs an
Engine::increment_epochticker on a background thread at 1 Hz;store.set_epoch_deadline(1)before each call. Catches infinite loops that consume fuel slowly enough to feel legal. - Engine config — determinism flags:
wasm_threads = falsewasm_simd = false(non-determinism across CPUs)wasm_relaxed_simd = false(MUST disable alongsidewasm_simd; wasmtime 26+ errors atConfigbuild time if you disable the base proposal without disabling the relaxed extension)wasm_reference_types = falsewasm_bulk_memory = truewasm_multi_value = true
- Memory cap: 16 MiB linear memory. Set via
wasmtime::ResourceLimiter. Allocator-mode mods may grow their heap up to this ceiling.
Native sandbox (.so / .dylib / .dll)
Out of Stage 5b scope. Stage 5c will add the libloading-based loader, the
ed25519 signature verifier, and the pubkey distribution decision. For Stage
5b: mods declaring sandbox: "native" in manifest.json are accepted by
the manifest parser but rejected at load time with
ModError::NativeSandboxNotYetImplemented.
Testing strategy
- Stage 5b unit fixture: hand-written
.watmodule exporting all four static globals +init+decide_turn.decide_turnwrites a single0u8byte (postcard-encodedVec::<Action>::new()) to__output_ptrand returns1. Verifies the full host marshalling pipeline without needing thewasm32-unknown-unknowntarget installed. - Stage 6 cross-compile fixture: real Rust sub-crate in
src/simulator/crates/mc-mod-host/test-fixtures/noop/targetingwasm32-unknown-unknown. Built lazily — feature-gated so CI without the toolchain still passes.
Open items deferred to later stages
- Stage 5c: native sandbox, signature scheme, pubkey distribution.
- Stage 5c (carry-over from 5b): wire
wasmtime::ResourceLimiterto enforce the 16 MiB memory cap. ConstantMEMORY_LIMIT_BYTESalready defined inmc-mod-host/src/abi.rs— limiter just needs hookup at Store construction. - Stage 5b follow-up: allocator-mode
-2 BufferTooSmallretry.BufferLayout::Allocatoralready holds thealloc/dealloctyped funcs, so the retry-with-out_cap * 2loop is a localized change. Static-mode cannot retry (globals are immutable) — its-2will remain "log + empty actions" forever; this is acceptable since static-mode mods control their own buffer size at compile time. - Stage 5d: hot-reload (watch mod dir, re-instantiate on change).
- Stage 6: actual learned-mod build pipeline + the OSS reference repo URL.
- Stage 9: Steam Workshop integration (out of v1).