magicciv/docs/modding/abi-decisions.md

131 lines
6.6 KiB
Markdown
Raw Permalink Normal View History

# 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<Action>) 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 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).