124 lines
6.1 KiB
Markdown
124 lines
6.1 KiB
Markdown
|
|
# 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<Action>` 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<Action> 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 `"<name> <semver>"` (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.
|
||
|
|
|
||
|
|
## `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<Action>`.
|
||
|
|
- **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<Action> 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/<name>.wasm \
|
||
|
|
-o <name>.opt.wasm
|
||
|
|
```
|
||
|
|
|
||
|
|
`wasm-opt` ships with [binaryen](https://github.com/WebAssembly/binaryen). Drop the optimised `.wasm` plus your `manifest.json` into:
|
||
|
|
|
||
|
|
```
|
||
|
|
~/Documents/MagicCivilization/mods/<id>/
|
||
|
|
manifest.json
|
||
|
|
<name>.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).
|