# Writing a WASM AI Controller Audience: Rust developers who want to ship a custom AI for Magic Civilization. This document is the **interface contract** — the host engine promises to call your module exactly as described here, and your module promises to honour these exports. The reference implementation lives in `learned-duel-v1b` (see below). Status: contract is stable for Stage 5b onward. Items flagged **TBD** are subject to change before public release. ## What an AI controller does Once per AI turn, the engine serialises the visible tactical state, hands it to your WASM module, and asks for a plan. You return an ordered list of `Action`s; the engine applies them in sequence, validates each against the authoritative simulator, and discards any that fail validation. Your module never mutates game state directly — it only proposes actions. **Determinism is mandatory.** Given the same `(state, slot, seed)` triple, your controller MUST emit the same `Vec` byte-for-byte every time. The host uses this property for replay, netcode rollback, and save fingerprints. Use the supplied `seed` for any randomness; do not call host clocks or system RNG (none are exposed). ## Required WASM exports Your module must export three symbols. Rust pseudocode signatures: ```rust // Called once at load. Return non-zero to signal "ready"; zero rejects the module. // No state passed in; do lazy init on first decide_turn if needed. #[no_mangle] pub extern "C" fn init() -> u32; // The hot path. Called once per AI turn. // state_ptr..state_ptr+state_len : postcard-serialised TacticalState in linear memory // slot : which player slot you are deciding for // seed : deterministic PRNG seed for this turn // out_ptr..out_ptr+out_cap : scratch buffer the host allocated for your reply // Write a postcard-serialised Vec into out_ptr; return bytes written, or // a negative i32 on error. #[no_mangle] pub extern "C" fn decide_turn( state_ptr: i32, state_len: i32, slot: u32, seed: u64, out_ptr: i32, out_cap: i32, ) -> i32; ``` Plus an identity string. WASM has no native string return, so expose two globals at fixed names; the host reads `__ident_len` bytes starting at `__ident_ptr`: ```rust #[no_mangle] pub static __ident_ptr: i32 = /* address of a static b"name 1.2.3" */; #[no_mangle] pub static __ident_len: i32 = /* its length */; ``` The string format is `" "` (e.g. `"learned-duel 1.0.0-b"`). It feeds into `controller_id` mapping and save fingerprints. ## Marshalling: postcard The wire format on both sides is [postcard](https://docs.rs/postcard) — a no_std `serde` codec that is deterministic, compact, and schema-compatible with the Rust `TacticalState` / `Action` types. The host serialises once per turn, the guest deserialises once; same crate version on both sides means zero shape mismatch. Backwards compatibility: adding a field to `TacticalState` is safe **only** if the host annotates the new field with `#[serde(default)]`. This is a host-side guarantee — you, the mod author, don't have to do anything, but you should pin the version of the `mc-tactical-types` crate your mod was built against so a newer host doesn't silently drop fields you depend on. ### Fog of war The `TacticalState` you receive is **already vision-filtered** for your bound player. Enemy units and cities whose hex is not in your player's `visible` set are omitted from `players[other].units` / `.cities`; tile resources outside `explored` are stripped. Your own slot is always surfaced in full. The host computes vision via `mc_vision::compute_vision` and feeds the filtered projection through `project_tactical_with_vision` (`mc-player-api/src/projection.rs`). Plan around this — your controller does **not** see the whole map, and assuming otherwise will produce moves that target hexes you never explored. The `CP_OMNISCIENT=1` env flag on the host disables filtering for debug repros only; a shipped mod must never rely on it. ## `manifest.json` Ship your `.wasm` next to a `manifest.json`: ```json { "id": "learned-duel-v1b", "name": "Learned Duel (1v1 sparring AI)", "author": "your-handle", "version": "1.0.0-b", "kind": "ai_controller", "controller_id": "learned:duel-v1b", "sandbox": "wasm", "signature": null } ``` `sandbox: "native"` is permitted for `.so` / `.dylib` / `.dll` controllers but the `signature` field then becomes required — an ed25519 signature of the binary against the engine publisher pubkey. Unsigned native mods are refused at load time. **TBD:** the public key distribution channel is not yet finalised. ## Sandbox guarantees and limits The host runs WASM mods under [Wasmtime](https://wasmtime.dev/) with: - **Fuel budget per turn**: 100M instructions (**TBD — subject to change before release**). Exceeding the budget cancels the turn; the engine treats it as if your controller returned an empty `Vec`. - **Linear memory cap**: 16 MB (**TBD**). - **No host imports**: no clocks, no filesystem, no network, no host RNG. The `seed` argument is your only entropy source. - **Epoch-based interruption**: the host can cancel a stuck turn mid-execution without UB. Native mods get none of these protections, which is why they require a signature. ## Reference mod `public/games/age-of-dwarves/mods/learned-duel-v1b.wasm` is the canonical reference controller (lands in Stage 6). Open-source repo: **TBD — URL placeholder until the mod template repo is published.** A minimum viable controller — pass turns and do nothing — looks like: ```rust #[no_mangle] pub extern "C" fn init() -> u32 { 1 } #[no_mangle] pub extern "C" fn decide_turn( _sp: i32, _sl: i32, _slot: u32, _seed: u64, out_ptr: i32, _out_cap: i32, ) -> i32 { // postcard-encoded empty Vec is a single 0x00 byte (length prefix = 0). unsafe { *(out_ptr as *mut u8) = 0; } 1 } ``` ## Building your mod ``` cargo build --release --target wasm32-unknown-unknown wasm-opt -Oz target/wasm32-unknown-unknown/release/.wasm \ -o .opt.wasm ``` `wasm-opt` ships with [binaryen](https://github.com/WebAssembly/binaryen). Drop the optimised `.wasm` plus your `manifest.json` into: ``` ~/Documents/MagicCivilization/mods// manifest.json .opt.wasm ``` The engine scans that directory at startup and registers any well-formed `ai_controller` mods. For the FFI helpers many mod authors lean on (memory allocation hooks, panic handlers), [wasm-bindgen](https://docs.rs/wasm-bindgen) is supported but not required — the three exports above are the only contract. ## Open questions (TBD) - Final fuel + memory caps. - Native-mod signing key distribution. - Reference-mod public repo URL. - Versioning policy for `TacticalState` / `Action` (host promises `#[serde(default)]` on new fields; we still need a deprecation policy for removals).