diff --git a/docs/modding/abi-decisions.md b/docs/modding/abi-decisions.md new file mode 100644 index 00000000..ae992153 --- /dev/null +++ b/docs/modding/abi-decisions.md @@ -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 `" "` 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::::new()`. | +| `n > 0` | Bytes written to the output buffer. Host reads `out_ptr..out_ptr+n` and `postcard::from_bytes::>`. 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::::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). diff --git a/src/simulator/crates/mc-mod-host/src/abi.rs b/src/simulator/crates/mc-mod-host/src/abi.rs new file mode 100644 index 00000000..3de010af --- /dev/null +++ b/src/simulator/crates/mc-mod-host/src/abi.rs @@ -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 `" "` 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 = 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 { + 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); + } +} diff --git a/src/simulator/crates/mc-mod-host/src/lib.rs b/src/simulator/crates/mc-mod-host/src/lib.rs new file mode 100644 index 00000000..e12a65d6 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/src/lib.rs @@ -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>, + 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 { + 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 { + 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 { + let mut store = handle + .store + .lock() + .map_err(|_| ModError::CallFailed(wasmtime::Error::msg("store mutex poisoned")))?; + let func: TypedFunc = 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, wasm_bytes: &[u8]) -> Result { + 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); + } +} diff --git a/src/simulator/crates/mc-mod-host/src/wasm_controller.rs b/src/simulator/crates/mc-mod-host/src/wasm_controller.rs new file mode 100644 index 00000000..3a6e64bf --- /dev/null +++ b/src/simulator/crates/mc-mod-host/src/wasm_controller.rs @@ -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, + #[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` already inside [`WasmModuleHandle`]. +pub struct WasmAiController { + // Arc'd host kept alive so the Engine outlives the Module/Instance. + #[allow(dead_code)] + host: Arc, + 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, handle: WasmModuleHandle) -> Result { + 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 { + 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 { + 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 { + // Allocator mode requires BOTH alloc + dealloc. + let alloc: Option> = handle + .instance + .get_typed_func(&mut *store, "alloc") + .ok(); + let dealloc: Option> = 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 { + // 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::>(&mem[out_off..end]) { + Ok(actions) => actions, + Err(_e) => Vec::new(), + } + } + + fn ident(&self) -> AiControllerIdent { + self.ident.clone() + } +} diff --git a/src/simulator/crates/mc-mod-host/tests/fixtures/noop.wat b/src/simulator/crates/mc-mod-host/tests/fixtures/noop.wat new file mode 100644 index 00000000..7d69e281 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/fixtures/noop.wat @@ -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::::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)) diff --git a/src/simulator/crates/mc-mod-host/tests/proof_load.rs b/src/simulator/crates/mc-mod-host/tests/proof_load.rs new file mode 100644 index 00000000..fba07d2e --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/proof_load.rs @@ -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); +} diff --git a/src/simulator/crates/mc-mod-host/tests/wasm_controller_noop.rs b/src/simulator/crates/mc-mod-host/tests/wasm_controller_noop.rs new file mode 100644 index 00000000..42cf12f9 --- /dev/null +++ b/src/simulator/crates/mc-mod-host/tests/wasm_controller_noop.rs @@ -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"); +}