# 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 `TacticalState` for 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: i32` and `__output_cap_request: i32` read 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 `" "` 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::::new()`. | | `n > 0` | Bytes written to the output buffer. Host reads `out_ptr..out_ptr+n` and `postcard::from_bytes::>`. 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_000` instructions. ~10 ms wallclock on a modern laptop. Set via `wasmtime::Config::consume_fuel(true)`; host calls `store.set_fuel(100_000_000)` before each `decide_turn`. Fuel exhaustion surfaces as `wasmtime::Trap::OutOfFuel`; host treats it as an empty-plan turn and logs the mod's `ident` to telemetry. - **Epoch deadline**: 1 second wallclock hard cap. Host runs an `Engine::increment_epoch` ticker 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 = false` - `wasm_simd = false` (non-determinism across CPUs) - `wasm_relaxed_simd = false` (MUST disable alongside `wasm_simd`; wasmtime 26+ errors at `Config` build time if you disable the base proposal without disabling the relaxed extension) - `wasm_reference_types = false` - `wasm_bulk_memory = true` - `wasm_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 `.wat` module exporting all four static globals + `init` + `decide_turn`. `decide_turn` writes a single `0u8` byte (postcard-encoded `Vec::::new()`) to `__output_ptr` and returns `1`. Verifies the full host marshalling pipeline without needing the `wasm32-unknown-unknown` target installed. - **Stage 6 cross-compile fixture**: real Rust sub-crate in `src/simulator/crates/mc-mod-host/test-fixtures/noop/` targeting `wasm32-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 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).