feat(api-gdext): Add mod hosting endpoints for discovery, validation, and deployment logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-18 06:32:16 -07:00
parent 27a1c73bf9
commit ae97860c14
2 changed files with 166 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,46 @@
//! Process-wide singleton `Arc<WasmHost>` 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<Arc<WasmHost>> = 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<Arc<WasmHost>, 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"))),
}
}