magicciv/docs/modding/abi-decisions.md
2026-05-26 02:21:12 -07:00

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 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 "<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_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::<Action>::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 5c (carry-over from 5b): wire wasmtime::ResourceLimiter to enforce the 16 MiB memory cap. Constant MEMORY_LIMIT_BYTES already defined in mc-mod-host/src/abi.rs — limiter just needs hookup at Store construction.
  • Stage 5b follow-up: allocator-mode -2 BufferTooSmall retry. BufferLayout::Allocator already holds the alloc/dealloc typed funcs, so the retry-with-out_cap * 2 loop is a localized change. Static-mode cannot retry (globals are immutable) — its -2 will 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).