diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index f1328462..8a1a9923 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -14,6 +14,7 @@ pub mod building_action; pub mod building_registry; pub mod capture; pub mod civics; +mod mod_host; pub mod observation; pub mod player_api; pub mod replay; @@ -3826,6 +3827,125 @@ impl GdGameState { true } + /// Stage 5d — load and register a single mod from its + /// `manifest.json` path. Used by the GDScript `ModLoader` autoload + /// at engine boot to walk the user mod directory and surface each + /// installed AI controller in [`registered_controller_ids`]. + /// + /// `manifest_path` must be an absolute filesystem path (NOT a + /// `res://` URI — the user mod dir lives outside the project tree). + /// The payload (`.wasm` or platform-native shared library) is + /// resolved relative to the manifest via `manifest.entrypoint`, + /// defaulting to `controller.wasm` / `controller.so` / `.dylib` / + /// `.dll`. + /// + /// Returns the registered `controller_id` on success, or an empty + /// [`GString`] on any failure (manifest unreadable, parse error, + /// validation error, payload missing, sandbox load failure, …). + /// Errors are logged once via [`godot_error!`]; the caller's GDScript + /// side decides whether to surface a user-visible warning. + /// + /// `&self` because the registry mutation lives behind the global + /// `RwLock` in `mc_player_api::controllers` — this method does not + /// touch `GdGameState`'s own state. + #[func] + fn register_mod_from_path(&self, manifest_path: GString) -> GString { + let path_str = manifest_path.to_string(); + let manifest_path = std::path::PathBuf::from(&path_str); + + // Read + parse + validate manifest. + let manifest_bytes = match std::fs::read(&manifest_path) { + Ok(b) => b, + Err(e) => { + godot_error!( + "ModLoader: cannot read manifest {path_str:?}: {e}" + ); + return GString::new(); + } + }; + let manifest = match mc_mod_host::Manifest::parse(&manifest_bytes) { + Ok(m) => m, + Err(e) => { + godot_error!( + "ModLoader: failed to parse {path_str:?}: {e}" + ); + return GString::new(); + } + }; + if let Err(e) = manifest.validate() { + godot_error!("ModLoader: invalid manifest {path_str:?}: {e}"); + return GString::new(); + } + + match manifest.sandbox { + mc_mod_host::SandboxKindWire::Wasm => { + let manifest_dir = match manifest_path.parent() { + Some(d) => d, + None => { + godot_error!( + "ModLoader: manifest path {path_str:?} has no parent dir" + ); + return GString::new(); + } + }; + let entry = manifest + .entrypoint + .as_deref() + .unwrap_or("controller.wasm"); + let wasm_path = manifest_dir.join(entry); + let wasm_bytes = match std::fs::read(&wasm_path) { + Ok(b) => b, + Err(e) => { + godot_error!( + "ModLoader: cannot read wasm payload {:?}: {e}", + wasm_path + ); + return GString::new(); + } + }; + let host = match crate::mod_host::shared_host() { + Ok(h) => h, + Err(e) => { + godot_error!( + "ModLoader: shared WasmHost init failed: {e}" + ); + return GString::new(); + } + }; + match mc_mod_host::register_wasm_mod(host, &wasm_bytes) { + Ok(id) => { + godot_print!( + "ModLoader: registered '{id}' from {path_str}" + ); + GString::from(id) + } + Err(e) => { + godot_error!( + "ModLoader: register_wasm_mod failed for {path_str:?}: {e}" + ); + GString::new() + } + } + } + mc_mod_host::SandboxKindWire::Native => { + match mc_mod_host::register_native_mod(&manifest_path) { + Ok(id) => { + godot_print!( + "ModLoader: registered '{id}' from {path_str}" + ); + GString::from(id) + } + Err(e) => { + godot_error!( + "ModLoader: register_native_mod failed for {path_str:?}: {e}" + ); + GString::new() + } + } + } + } + } + /// Stage 7 — after `load_from_json` succeeds, GDScript calls this /// to ask: "are all the controllers this save references currently /// registered with `mc_player_api`?" Returns an empty `GString` on diff --git a/src/simulator/api-gdext/src/mod_host.rs b/src/simulator/api-gdext/src/mod_host.rs new file mode 100644 index 00000000..9f080a12 --- /dev/null +++ b/src/simulator/api-gdext/src/mod_host.rs @@ -0,0 +1,46 @@ +//! Process-wide singleton `Arc` for Stage 5d mod loading. +//! +//! `mc_mod_host::WasmHost::new()` spawns a 1 Hz daemon thread that drives +//! `wasmtime::Engine::increment_epoch` so per-call deadlines fire on +//! schedule. The thread is intentionally a process-lifetime leak — fine +//! for the one-shot singleton this module exposes, but constructing +//! multiple hosts would accumulate threads. We therefore stash one host +//! behind a [`OnceLock`] and hand out `Arc` clones. +//! +//! Failures during initial construction (e.g. wasmtime can't realise the +//! configured flag set on this build) propagate to the caller; on +//! success every subsequent call returns the cached host without +//! re-initialising the engine or the ticker. +//! +//! Wired into the gdext bridge by `GdGameState::register_mod_from_path`. + +use std::sync::{Arc, OnceLock}; + +use mc_mod_host::{ModError, WasmHost}; + +static SHARED_HOST: OnceLock> = OnceLock::new(); + +/// Returns the process-wide [`WasmHost`], lazily constructing it on the +/// first call. +/// +/// # Errors +/// Propagates [`ModError::EngineConfig`] from the first +/// [`WasmHost::new`] call. Subsequent calls cannot fail — once the host +/// is cached they hand out clones unconditionally. The cache is *not* +/// populated on the failing first call, so a second attempt will retry +/// construction (useful when a transient resource constraint blocked +/// the daemon-thread spawn). +pub(crate) fn shared_host() -> Result, ModError> { + if let Some(existing) = SHARED_HOST.get() { + return Ok(Arc::clone(existing)); + } + let host = Arc::new(WasmHost::new()?); + // `set` returns Err if another thread populated the cell between our + // `get` and `set`. In that race we discard our freshly-built host + // (its daemon thread leaks for the process lifetime, but the cell + // races are limited to engine boot) and return the winner. + match SHARED_HOST.set(Arc::clone(&host)) { + Ok(()) => Ok(host), + Err(_) => Ok(Arc::clone(SHARED_HOST.get().expect("just set"))), + } +}