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:
parent
27a1c73bf9
commit
ae97860c14
2 changed files with 166 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
46
src/simulator/api-gdext/src/mod_host.rs
Normal file
46
src/simulator/api-gdext/src/mod_host.rs
Normal 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"))),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue