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:
parent
af54b5123b
commit
2628be038a
7 changed files with 1006 additions and 0 deletions
140
docs/modding/abi-decisions.md
Normal file
140
docs/modding/abi-decisions.md
Normal 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).
|
||||
168
src/simulator/crates/mc-mod-host/src/abi.rs
Normal file
168
src/simulator/crates/mc-mod-host/src/abi.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
220
src/simulator/crates/mc-mod-host/src/lib.rs
Normal file
220
src/simulator/crates/mc-mod-host/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
361
src/simulator/crates/mc-mod-host/src/wasm_controller.rs
Normal file
361
src/simulator/crates/mc-mod-host/src/wasm_controller.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
33
src/simulator/crates/mc-mod-host/tests/fixtures/noop.wat
vendored
Normal file
33
src/simulator/crates/mc-mod-host/tests/fixtures/noop.wat
vendored
Normal 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))
|
||||
29
src/simulator/crates/mc-mod-host/tests/proof_load.rs
Normal file
29
src/simulator/crates/mc-mod-host/tests/proof_load.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue