feat(wasm-controller): Implement WASM controller validation logic and enforce ABI contracts with test coverage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 02:40:53 -07:00
parent af54b5123b
commit 2628be038a
7 changed files with 1006 additions and 0 deletions

View file

@ -0,0 +1,140 @@
# 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 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).

View file

@ -0,0 +1,168 @@
//! Locked constants and parse helpers for the WASM AI controller ABI.
//!
//! Every value here is fixed by `docs/modding/abi-decisions.md`. Changes
//! to this file MUST come with a corresponding update to that memo and a
//! bump of [`STATE_VERSION_PREFIX`] if the wire payload shape moves.
use regex::Regex;
use std::sync::OnceLock;
use crate::ModError;
/// Negative discriminants returned by the guest's `decide_turn` export.
///
/// `0` is reserved for a legitimate empty plan; positive values are byte
/// counts. The mapping here mirrors the table in
/// `docs/modding/abi-decisions.md` §"`decide_turn` return codes".
#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecideErrorCode {
/// Unspecified guest error. Host substitutes empty actions.
Generic = -1,
/// Output buffer too small. In allocator mode the host retries once
/// with double capacity; in static mode the host logs and gives up.
BufferTooSmall = -2,
/// Guest could not postcard-decode the state payload (schema skew).
StateDecodeFailed = -3,
/// Invalid `slot` argument. Treated as a host bug.
InvalidSlot = -4,
}
impl DecideErrorCode {
/// Map any negative `i32` returned by the guest onto a known variant.
/// Unknown negatives collapse to [`Self::Generic`].
#[must_use]
pub fn from_raw(code: i32) -> Self {
match code {
-2 => Self::BufferTooSmall,
-3 => Self::StateDecodeFailed,
-4 => Self::InvalidSlot,
_ => Self::Generic,
}
}
}
/// Schema version of the postcard-serialised `TacticalState` payload.
///
/// Written as a 4-byte big-endian prefix immediately before the postcard
/// bytes. Mods compare this to their pinned expected value and return
/// [`DecideErrorCode::StateDecodeFailed`] on mismatch.
pub const STATE_VERSION_PREFIX: u32 = 1;
/// Default I/O buffer size (input + output) in static-buffer mode, in bytes.
pub const DEFAULT_BUFFER_CAP: u32 = 64 * 1024;
/// Hard ceiling on any buffer capacity (input or output), in bytes.
///
/// Guest-supplied `__input_cap_request` / `__output_cap_request` overrides
/// are clamped to this value.
pub const MAX_BUFFER_CAP: u32 = 4 * 1024 * 1024;
/// Fuel budget set on the [`wasmtime::Store`] before each `decide_turn` call.
///
/// ~10 ms wallclock on a modern laptop. Fuel exhaustion is caught as a
/// trap and the host substitutes an empty plan.
pub const FUEL_BUDGET: u64 = 100_000_000;
/// Number of epoch ticks the guest is allowed to live past the current
/// engine epoch. Paired with a 1 Hz background ticker, this implements a
/// 1-second wallclock hard cap on each call.
pub const EPOCH_DEADLINE_TICKS: u64 = 1;
/// Maximum linear memory the guest may grow to, in bytes (16 MiB).
///
/// Currently advisory — wired through `wasmtime::ResourceLimiter` in a
/// later patch (TODO Stage 5c).
pub const MEMORY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
/// Validated `"<name> <semver>"` identity string read from the guest's
/// `__ident_ptr` / `__ident_len` globals at load time.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentFormat {
/// Mod name (left of the space). Matches `[a-z0-9_-]+`.
pub name: String,
/// Semver string (right of the space). Matches
/// `\d+\.\d+\.\d+(-[a-z0-9.-]+)?`.
pub version: String,
}
fn ident_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
// Locked by `abi-decisions.md` §"__ident_ptr / __ident_len".
Regex::new(r"^([a-z0-9_-]+) (\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?)$")
.unwrap_or_else(|_| unreachable!("ident regex is a compile-time constant"))
})
}
/// Parse a guest-supplied ident string into `(name, version)`.
///
/// # Errors
/// Returns [`ModError::InvalidIdent`] if `s` does not match the format
/// `^[a-z0-9_-]+ \d+\.\d+\.\d+(-[a-z0-9.-]+)?$`.
pub fn parse_ident(s: &str) -> Result<IdentFormat, ModError> {
let caps = ident_regex()
.captures(s)
.ok_or_else(|| ModError::InvalidIdent(s.to_owned()))?;
// SAFETY: regex matched, capture groups 1 and 2 are present.
let name = caps
.get(1)
.ok_or_else(|| ModError::InvalidIdent(s.to_owned()))?
.as_str()
.to_owned();
let version = caps
.get(2)
.ok_or_else(|| ModError::InvalidIdent(s.to_owned()))?
.as_str()
.to_owned();
Ok(IdentFormat { name, version })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_basic_ident() {
let id = parse_ident("noop 0.1.0").expect("must parse");
assert_eq!(id.name, "noop");
assert_eq!(id.version, "0.1.0");
}
#[test]
fn parses_prerelease() {
let id = parse_ident("learned-duel 1.0.0-b").expect("must parse");
assert_eq!(id.name, "learned-duel");
assert_eq!(id.version, "1.0.0-b");
}
#[test]
fn rejects_uppercase_name() {
assert!(parse_ident("Noop 0.1.0").is_err());
}
#[test]
fn rejects_missing_version() {
assert!(parse_ident("noop").is_err());
}
#[test]
fn rejects_partial_semver() {
assert!(parse_ident("noop 0.1").is_err());
}
#[test]
fn decide_error_from_raw_maps_known() {
assert_eq!(DecideErrorCode::from_raw(-1), DecideErrorCode::Generic);
assert_eq!(
DecideErrorCode::from_raw(-2),
DecideErrorCode::BufferTooSmall
);
assert_eq!(
DecideErrorCode::from_raw(-3),
DecideErrorCode::StateDecodeFailed
);
assert_eq!(DecideErrorCode::from_raw(-4), DecideErrorCode::InvalidSlot);
assert_eq!(DecideErrorCode::from_raw(-99), DecideErrorCode::Generic);
}
}

View file

@ -0,0 +1,220 @@
//! WASM module host for the Magic Civilization simulator.
//!
//! Stage 5a established the proof-of-load pipeline (`call_proof`). Stage
//! 5b adds [`WasmAiController`], a [`mc_player_api::controllers::AiController`]
//! implementation that loads a guest WASM module and dispatches each AI
//! turn through it. See `docs/modding/abi-decisions.md` for the locked
//! contract this implements.
pub mod abi;
pub mod wasm_controller;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use mc_player_api::controllers::register_controller;
use thiserror::Error;
use wasmtime::{Config, Engine, Instance, Module, Store, TypedFunc};
pub use abi::{
parse_ident, DecideErrorCode, IdentFormat, DEFAULT_BUFFER_CAP, EPOCH_DEADLINE_TICKS,
FUEL_BUDGET, MAX_BUFFER_CAP, MEMORY_LIMIT_BYTES, STATE_VERSION_PREFIX,
};
pub use wasm_controller::WasmAiController;
/// Errors that can arise while loading or invoking a guest module.
#[derive(Debug, Error)]
pub enum ModError {
/// The guest bytes failed to compile into a [`wasmtime::Module`].
#[error("failed to compile wasm module: {0}")]
Compile(#[source] wasmtime::Error),
/// The compiled module could not be instantiated (e.g. missing import).
#[error("failed to instantiate wasm module: {0}")]
Instantiate(#[source] wasmtime::Error),
/// The module did not export a symbol the host requires.
#[error("wasm module is missing required export: {0}")]
MissingExport(String),
/// The exported function trapped or returned an error during the call.
#[error("wasm call failed: {0}")]
CallFailed(#[source] wasmtime::Error),
/// `init()` returned zero, signalling self-rejection.
#[error("wasm module init() returned 0 (rejected)")]
InitRejected,
/// The ident string read from `__ident_ptr` / `__ident_len` did not
/// match the locked `^[a-z0-9_-]+ \d+\.\d+\.\d+(-[a-z0-9.-]+)?$` format.
#[error("invalid ident: {0:?}")]
InvalidIdent(String),
/// The host's wasmtime configuration failed to construct (e.g.
/// requested a flag combination the build does not support).
#[error("failed to build wasmtime engine: {0}")]
EngineConfig(#[source] wasmtime::Error),
/// Native sandbox path is not implemented in Stage 5b (deferred to 5c).
#[error("native sandbox is not yet implemented")]
NativeSandboxNotYetImplemented,
}
/// Opaque handle to a loaded WASM module instance.
///
/// The handle owns its own [`Store`] so guest globals/memory are
/// isolated between modules. The store is wrapped in a [`Mutex`]
/// because `wasmtime` requires `&mut Store` for invocation, while we
/// want `&self` ergonomics on [`WasmHost`].
pub struct WasmModuleHandle {
pub(crate) store: Mutex<Store<()>>,
pub(crate) instance: Instance,
}
impl WasmModuleHandle {
/// Returns a reference to the underlying [`Instance`].
#[must_use]
pub fn instance(&self) -> &Instance {
&self.instance
}
}
/// Host owning a shared [`wasmtime::Engine`] used to compile guest modules.
///
/// One [`WasmHost`] is intended per simulator process; each loaded
/// module gets its own [`WasmModuleHandle`]. Construction spawns a
/// 1 Hz background daemon thread that calls
/// [`wasmtime::Engine::increment_epoch`] so per-call epoch deadlines
/// fire on schedule. The thread is intentionally a process-lifetime
/// leak — fine for the singleton-host use case the simulator actually
/// has. Tests that construct many `WasmHost`s will accumulate threads;
/// share one host instead.
pub struct WasmHost {
engine: Engine,
}
impl WasmHost {
/// Constructs a new host with the deterministic [`wasmtime::Config`]
/// locked in `docs/modding/abi-decisions.md`.
///
/// # Errors
/// Returns [`ModError::EngineConfig`] if the configured flag set
/// cannot be realised on the current wasmtime build.
pub fn new() -> Result<Self, ModError> {
let mut config = Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
// Determinism flags (abi-decisions.md §"Fuel budget & epoch interruption").
config.wasm_threads(false);
config.wasm_simd(false);
config.wasm_relaxed_simd(false);
config.wasm_reference_types(false);
config.wasm_bulk_memory(true);
config.wasm_multi_value(true);
let engine = Engine::new(&config).map_err(ModError::EngineConfig)?;
// 1 Hz epoch ticker. See struct doc for the daemon-leak caveat.
let engine_for_thread = engine.clone();
thread::Builder::new()
.name("mc-mod-host-epoch-ticker".into())
.spawn(move || loop {
thread::sleep(Duration::from_secs(1));
engine_for_thread.increment_epoch();
})
.map_err(|e| {
ModError::EngineConfig(wasmtime::Error::msg(format!(
"failed to spawn epoch ticker thread: {e}"
)))
})?;
Ok(Self { engine })
}
/// Returns a reference to the underlying [`Engine`].
#[must_use]
pub fn engine(&self) -> &Engine {
&self.engine
}
/// Compiles `wasm_bytes` (binary `.wasm` or text `.wat`) and instantiates
/// it with no imports.
///
/// # Errors
/// Returns [`ModError::Compile`] if the bytes are not a valid module, or
/// [`ModError::Instantiate`] if instantiation fails.
pub fn load_module(&self, wasm_bytes: &[u8]) -> Result<WasmModuleHandle, ModError> {
let module = Module::new(&self.engine, wasm_bytes).map_err(ModError::Compile)?;
let mut store = Store::new(&self.engine, ());
// Pre-arm fuel/epoch so probe calls like proof_double don't fault.
store
.set_fuel(FUEL_BUDGET)
.map_err(ModError::CallFailed)?;
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let instance =
Instance::new(&mut store, &module, &[]).map_err(ModError::Instantiate)?;
Ok(WasmModuleHandle {
store: Mutex::new(store),
instance,
})
}
/// Invokes the `proof_double(i32) -> i32` export on `handle` with `input`.
///
/// Stage 5a probe entry point. Retained for backwards-compatible
/// regression testing of the engine wiring; production paths use
/// [`WasmAiController`] instead.
///
/// # Errors
/// * [`ModError::MissingExport`] if the module does not export
/// `proof_double` with signature `(i32) -> i32`.
/// * [`ModError::CallFailed`] if the call traps.
pub fn call_proof(&self, handle: &WasmModuleHandle, input: i32) -> Result<i32, ModError> {
let mut store = handle
.store
.lock()
.map_err(|_| ModError::CallFailed(wasmtime::Error::msg("store mutex poisoned")))?;
let func: TypedFunc<i32, i32> = handle
.instance
.get_typed_func(&mut *store, "proof_double")
.map_err(|_| ModError::MissingExport("proof_double".to_owned()))?;
func.call(&mut *store, input).map_err(ModError::CallFailed)
}
}
/// Convenience: load `wasm_bytes` as an [`WasmAiController`] and register
/// it under its ident id in [`mc_player_api::controllers`]. Returns the
/// registered id on success so callers can verify / display.
///
/// # Errors
/// Propagates any [`ModError`] from compilation, instantiation, ABI
/// detection, or `init()` rejection.
pub fn register_wasm_mod(host: Arc<WasmHost>, wasm_bytes: &[u8]) -> Result<String, ModError> {
let handle = host.load_module(wasm_bytes)?;
let controller = WasmAiController::new(host, handle)?;
let id = controller.ident_id().to_owned();
register_controller(id.clone(), Box::new(controller));
Ok(id)
}
#[cfg(test)]
mod tests {
use super::*;
const PROOF_WAT: &str = r#"
(module
(func (export "proof_double") (param i32) (result i32)
local.get 0
i32.const 2
i32.mul))
"#;
#[test]
fn proof_double_loads_and_runs() {
let host = WasmHost::new().expect("engine builds");
let handle = host.load_module(PROOF_WAT.as_bytes()).expect("load");
let out = host.call_proof(&handle, 21).expect("call");
assert_eq!(out, 42);
}
}

View file

@ -0,0 +1,361 @@
//! [`WasmAiController`] — bridge between a guest `.wasm` mod and the
//! [`mc_player_api::controllers::AiController`] trait the simulator
//! dispatches through.
//!
//! All ABI choices come from `docs/modding/abi-decisions.md`. If you
//! find yourself needing to change one, update the memo first.
use std::sync::Arc;
use mc_ai::evaluator::ScoringWeights;
use mc_ai::tactical::{Action, TacticalState};
use mc_player_api::controllers::{AiController, AiControllerIdent, SandboxKind};
use wasmtime::{Memory, TypedFunc, Val};
use crate::abi::{
self, DecideErrorCode, EPOCH_DEADLINE_TICKS, FUEL_BUDGET, MAX_BUFFER_CAP, STATE_VERSION_PREFIX,
};
use crate::{ModError, WasmHost, WasmModuleHandle};
/// How the guest exposes its scratch buffers.
///
/// Allocator-mode wins when both `alloc` AND `dealloc` are exported, per
/// `abi-decisions.md` §"Memory allocation".
enum BufferLayout {
/// `alloc(size: u32) -> i32` + `dealloc(ptr: i32, size: u32)`. Buffers
/// are reserved once at construction and reused. Allocator-mode
/// supports retry-on-`-2` because the host can free the old buffer
/// and re-allocate at the new size.
Allocator {
in_ptr: i32,
in_cap: u32,
out_ptr: i32,
out_cap: u32,
// alloc/dealloc retained so we can resize on -2 retries (TODO).
#[allow(dead_code)]
alloc: TypedFunc<u32, i32>,
#[allow(dead_code)]
dealloc: TypedFunc<(i32, u32), ()>,
},
/// Four static globals (`__input_ptr`, `__input_cap`, `__output_ptr`,
/// `__output_cap`). Globals are immutable, so retry-on-`-2` is not
/// available in this mode; the host logs `out_cap` and falls back
/// to empty actions.
Static {
in_ptr: i32,
in_cap: u32,
out_ptr: i32,
out_cap: u32,
},
}
impl BufferLayout {
fn in_ptr(&self) -> i32 {
match *self {
Self::Allocator { in_ptr, .. } | Self::Static { in_ptr, .. } => in_ptr,
}
}
fn in_cap(&self) -> u32 {
match *self {
Self::Allocator { in_cap, .. } | Self::Static { in_cap, .. } => in_cap,
}
}
fn out_ptr(&self) -> i32 {
match *self {
Self::Allocator { out_ptr, .. } | Self::Static { out_ptr, .. } => out_ptr,
}
}
fn out_cap(&self) -> u32 {
match *self {
Self::Allocator { out_cap, .. } | Self::Static { out_cap, .. } => out_cap,
}
}
}
/// Guest-side `decide_turn` signature, per `docs/modding/ai-controller.md`:
/// `(state_ptr: i32, state_len: i32, slot: u32, seed: u64, out_ptr: i32, out_cap: i32) -> i32`.
type DecideTurnFunc = TypedFunc<(i32, i32, u32, u64, i32, i32), i32>;
/// An [`AiController`] backed by a guest WASM mod.
///
/// Send + Sync via the `Mutex<Store>` already inside [`WasmModuleHandle`].
pub struct WasmAiController {
// Arc'd host kept alive so the Engine outlives the Module/Instance.
#[allow(dead_code)]
host: Arc<WasmHost>,
handle: WasmModuleHandle,
memory: Memory,
buffers: BufferLayout,
decide: DecideTurnFunc,
ident: AiControllerIdent,
}
impl WasmAiController {
/// Inspect a freshly-loaded `handle`, validate the contract, call
/// `init()`, and return a controller ready for [`AiController::decide_turn`].
///
/// # Errors
/// * [`ModError::MissingExport`] — `init`, `decide_turn`, `memory`,
/// `__ident_ptr`/`__ident_len`, or the buffer exports are missing.
/// * [`ModError::InvalidIdent`] — ident string fails format validation.
/// * [`ModError::InitRejected`] — `init()` returned `0`.
/// * [`ModError::CallFailed`] — `init()` trapped.
pub fn new(host: Arc<WasmHost>, handle: WasmModuleHandle) -> Result<Self, ModError> {
let mut store = handle
.store
.lock()
.map_err(|_| ModError::CallFailed(wasmtime::Error::msg("store mutex poisoned")))?;
// Required: memory.
let memory = handle
.instance
.get_memory(&mut *store, "memory")
.ok_or_else(|| ModError::MissingExport("memory".to_owned()))?;
// Required: init() -> i32.
let init: TypedFunc<(), i32> = handle
.instance
.get_typed_func(&mut *store, "init")
.map_err(|_| ModError::MissingExport("init".to_owned()))?;
// Required: decide_turn(...) -> i32.
let decide: DecideTurnFunc = handle
.instance
.get_typed_func(&mut *store, "decide_turn")
.map_err(|_| ModError::MissingExport("decide_turn".to_owned()))?;
// Detect buffer layout.
let buffers = detect_buffer_layout(&handle, &mut *store)?;
// Read ident.
let ident_ptr = read_i32_global(&handle, &mut *store, "__ident_ptr")?;
let ident_len = read_i32_global(&handle, &mut *store, "__ident_len")?;
if ident_len < 0 {
return Err(ModError::InvalidIdent(format!(
"negative __ident_len: {ident_len}"
)));
}
let mut ident_buf = vec![0u8; ident_len as usize];
memory
.read(&mut *store, ident_ptr as usize, &mut ident_buf)
.map_err(|e| ModError::CallFailed(wasmtime::Error::from(e)))?;
let ident_str = std::str::from_utf8(&ident_buf)
.map_err(|_| ModError::InvalidIdent(format!("{ident_buf:?} (non-utf8)")))?;
let parsed = abi::parse_ident(ident_str)?;
let ident = AiControllerIdent {
id: parsed.name.clone(),
version: parsed.version.clone(),
sandbox: SandboxKind::Wasm,
};
// Pre-arm fuel/epoch for init().
store.set_fuel(FUEL_BUDGET).map_err(ModError::CallFailed)?;
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let init_rc = init.call(&mut *store, ()).map_err(ModError::CallFailed)?;
if init_rc == 0 {
return Err(ModError::InitRejected);
}
drop(store);
Ok(Self {
host,
handle,
memory,
buffers,
decide,
ident,
})
}
/// Registered id (without version), used as the registry key.
#[must_use]
pub fn ident_id(&self) -> &str {
&self.ident.id
}
}
fn read_i32_global(
handle: &WasmModuleHandle,
store: &mut wasmtime::Store<()>,
name: &str,
) -> Result<i32, ModError> {
let g = handle
.instance
.get_global(&mut *store, name)
.ok_or_else(|| ModError::MissingExport(name.to_owned()))?;
match g.get(&mut *store) {
Val::I32(v) => Ok(v),
other => Err(ModError::InvalidIdent(format!(
"global {name} is not i32: {other:?}"
))),
}
}
fn read_optional_u32_global(
handle: &WasmModuleHandle,
store: &mut wasmtime::Store<()>,
name: &str,
) -> Option<u32> {
let g = handle.instance.get_global(&mut *store, name)?;
match g.get(&mut *store) {
Val::I32(v) if v >= 0 => Some(v as u32),
_ => None,
}
}
fn detect_buffer_layout(
handle: &WasmModuleHandle,
store: &mut wasmtime::Store<()>,
) -> Result<BufferLayout, ModError> {
// Allocator mode requires BOTH alloc + dealloc.
let alloc: Option<TypedFunc<u32, i32>> = handle
.instance
.get_typed_func(&mut *store, "alloc")
.ok();
let dealloc: Option<TypedFunc<(i32, u32), ()>> = handle
.instance
.get_typed_func(&mut *store, "dealloc")
.ok();
if let (Some(alloc), Some(dealloc)) = (alloc, dealloc) {
let in_cap = read_optional_u32_global(handle, store, "__input_cap_request")
.unwrap_or(abi::DEFAULT_BUFFER_CAP)
.min(MAX_BUFFER_CAP);
let out_cap = read_optional_u32_global(handle, store, "__output_cap_request")
.unwrap_or(abi::DEFAULT_BUFFER_CAP)
.min(MAX_BUFFER_CAP);
// Pre-arm fuel/epoch for the alloc calls.
store.set_fuel(FUEL_BUDGET).map_err(ModError::CallFailed)?;
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let in_ptr = alloc.call(&mut *store, in_cap).map_err(ModError::CallFailed)?;
store.set_fuel(FUEL_BUDGET).map_err(ModError::CallFailed)?;
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let out_ptr = alloc
.call(&mut *store, out_cap)
.map_err(ModError::CallFailed)?;
return Ok(BufferLayout::Allocator {
in_ptr,
in_cap,
out_ptr,
out_cap,
alloc,
dealloc,
});
}
// Static mode requires all four globals.
let in_ptr = read_i32_global(handle, store, "__input_ptr")?;
let in_cap_raw = read_i32_global(handle, store, "__input_cap")?;
let out_ptr = read_i32_global(handle, store, "__output_ptr")?;
let out_cap_raw = read_i32_global(handle, store, "__output_cap")?;
if in_cap_raw < 0 || out_cap_raw < 0 {
return Err(ModError::MissingExport(
"negative __input_cap or __output_cap".to_owned(),
));
}
Ok(BufferLayout::Static {
in_ptr,
in_cap: (in_cap_raw as u32).min(MAX_BUFFER_CAP),
out_ptr,
out_cap: (out_cap_raw as u32).min(MAX_BUFFER_CAP),
})
}
impl AiController for WasmAiController {
fn decide_turn(
&self,
state: &TacticalState,
slot: u8,
_weights: &ScoringWeights,
seed: u64,
) -> Vec<Action> {
// Serialise state. postcard::to_allocvec is allocator-only;
// pulling it through our `postcard = { features = ["alloc"] }`
// dep keeps it on host side only.
let payload = match postcard::to_allocvec(state) {
Ok(p) => p,
Err(_e) => {
// Cannot serialise — empty plan, log when we wire telemetry.
return Vec::new();
}
};
let total_len = 4usize + payload.len();
let in_cap = self.buffers.in_cap() as usize;
if total_len > in_cap {
// Input doesn't fit; empty plan.
return Vec::new();
}
let mut store = match self.handle.store.lock() {
Ok(s) => s,
Err(_) => return Vec::new(),
};
// Write 4-byte BE version prefix + postcard payload into input buffer.
let mem = self.memory.data_mut(&mut *store);
let in_off = self.buffers.in_ptr() as usize;
if in_off
.checked_add(total_len)
.map_or(true, |end| end > mem.len())
{
return Vec::new();
}
mem[in_off..in_off + 4].copy_from_slice(&STATE_VERSION_PREFIX.to_be_bytes());
mem[in_off + 4..in_off + total_len].copy_from_slice(&payload);
// Deterministic per-call: reset fuel + epoch.
if store.set_fuel(FUEL_BUDGET).is_err() {
return Vec::new();
}
store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
let rc = match self.decide.call(
&mut *store,
(
self.buffers.in_ptr(),
total_len as i32,
u32::from(slot),
seed,
self.buffers.out_ptr(),
self.buffers.out_cap() as i32,
),
) {
Ok(rc) => rc,
Err(_trap) => {
// Trap (fuel/epoch/memory). Empty plan.
return Vec::new();
}
};
if rc == 0 {
return Vec::new();
}
if rc < 0 {
// Map for completeness even though we currently treat all
// negatives the same. Retry-on-BufferTooSmall in allocator
// mode is a future TODO; static mode cannot retry.
let _code = DecideErrorCode::from_raw(rc);
return Vec::new();
}
let written = rc as u32;
if written > self.buffers.out_cap() {
return Vec::new();
}
let out_off = self.buffers.out_ptr() as usize;
let mem = self.memory.data(&*store);
let Some(end) = out_off.checked_add(written as usize) else {
return Vec::new();
};
if end > mem.len() {
return Vec::new();
}
match postcard::from_bytes::<Vec<Action>>(&mem[out_off..end]) {
Ok(actions) => actions,
Err(_e) => Vec::new(),
}
}
fn ident(&self) -> AiControllerIdent {
self.ident.clone()
}
}

View file

@ -0,0 +1,33 @@
;; Hand-written WASM test fixture for Stage 5b of the mod system.
;;
;; Implements the static-buffer variant of the WASM AiController ABI:
;; * exports `memory` (1 page = 64 KiB linear memory)
;; * exports four buffer globals (__input_ptr/_cap, __output_ptr/_cap)
;; * exports __ident_ptr/__ident_len pointing at "noop 0.1.0" (10 bytes)
;; * exports init() returning 1 ("ready")
;; * exports decide_turn(...) writing a single 0x00 byte (postcard-encoded
;; `Vec::<Action>::new()`) at __output_ptr and returning 1.
(module
(memory (export "memory") 1)
(global (export "__input_ptr") i32 (i32.const 0x1000))
(global (export "__input_cap") i32 (i32.const 0x10000))
(global (export "__output_ptr") i32 (i32.const 0x11000))
(global (export "__output_cap") i32 (i32.const 0x10000))
(global (export "__ident_ptr") i32 (i32.const 0x100))
(global (export "__ident_len") i32 (i32.const 10))
;; "noop 0.1.0" — exactly 10 bytes, no trailing NUL.
(data (i32.const 0x100) "noop 0.1.0")
(func (export "init") (result i32)
i32.const 1)
;; (state_ptr i32, state_len i32, slot u32, seed u64, out_ptr i32, out_cap i32) -> i32
(func (export "decide_turn")
(param i32 i32 i32 i64 i32 i32) (result i32)
;; Write a single 0x00 byte to out_ptr (postcard's empty-Vec encoding).
local.get 4 ;; out_ptr
i32.const 0
i32.store8
i32.const 1))

View file

@ -0,0 +1,29 @@
//! End-to-end proof that a WASM module can be compiled, instantiated,
//! and invoked from the simulator host.
//!
//! The fixture is inlined as WebAssembly text (`.wat`). Wasmtime
//! auto-detects text vs. binary input, so no separate build step is
//! required to produce this artefact.
use mc_mod_host::WasmHost;
/// A minimal module exporting `proof_double(x: i32) -> i32` returning `x * 2`.
const PROOF_DOUBLE_WAT: &str = r#"
(module
(func (export "proof_double") (param $x i32) (result i32)
local.get $x
i32.const 2
i32.mul))
"#;
#[test]
fn proof_double_loads_and_runs() {
let host = WasmHost::new().expect("engine builds");
let handle = host
.load_module(PROOF_DOUBLE_WAT.as_bytes())
.expect("module compiles and instantiates");
let out = host
.call_proof(&handle, 21)
.expect("proof_double call succeeds");
assert_eq!(out, 42);
}

View file

@ -0,0 +1,55 @@
//! Stage 5b integration test: load a static-buffer no-op WASM mod via
//! [`WasmAiController`] and verify the full marshalling pipeline.
use std::sync::Arc;
use mc_ai::evaluator::ScoringWeights;
use mc_ai::tactical::{TacticalMap, TacticalState};
use mc_mod_host::{register_wasm_mod, WasmAiController, WasmHost};
use mc_player_api::controllers::{AiController, SandboxKind};
const NOOP_WAT: &[u8] = include_bytes!("fixtures/noop.wat");
fn empty_state() -> TacticalState {
TacticalState {
current_player: 0,
turn: 0,
map: TacticalMap {
width: 0,
height: 0,
tiles: Vec::new(),
},
players: Vec::new(),
unit_catalog: Vec::new(),
building_catalog: Vec::new(),
difficulty_threshold_mult: 1.0,
}
}
#[test]
fn wasm_controller_noop_returns_empty_actions() {
let host = Arc::new(WasmHost::new().expect("engine builds"));
let handle = host.load_module(NOOP_WAT).expect("noop.wat loads");
let controller =
WasmAiController::new(host, handle).expect("controller initialises");
let state = empty_state();
let weights = ScoringWeights::default();
let actions = controller.decide_turn(&state, 0, &weights, 42);
assert!(
actions.is_empty(),
"noop must return empty action chain, got {actions:?}"
);
let ident = controller.ident();
assert_eq!(ident.id, "noop");
assert_eq!(ident.version, "0.1.0");
assert_eq!(ident.sandbox, SandboxKind::Wasm);
}
#[test]
fn register_wasm_mod_returns_ident_id() {
let host = Arc::new(WasmHost::new().expect("engine builds"));
let id = register_wasm_mod(host, NOOP_WAT).expect("registers");
assert_eq!(id, "noop");
}