diff --git a/src/game/engine/src/autoloads/mod_loader.gd b/src/game/engine/src/autoloads/mod_loader.gd new file mode 100644 index 00000000..bb432e17 --- /dev/null +++ b/src/game/engine/src/autoloads/mod_loader.gd @@ -0,0 +1,105 @@ +extends Node +## ModLoader (Stage 5d) — at boot, walks the OS-standard user data +## directory (`OS.get_user_data_dir()/mods/`), parses each subdirectory's +## `manifest.json`, and registers the contained AI controller via +## [code]GdGameState.register_mod_from_path[/code]. +## +## Each mod sits in its own subdirectory: +## [codeblock] +## //manifest.json +## //controller.wasm (or .so/.dylib/.dll for native mods) +## [/codeblock] +## +## Resolved paths by platform (Godot's `OS.get_user_data_dir()` shape): +## * Linux: ~/.local/share/godot/app_userdata//mods +## * macOS: ~/Library/Application Support/Godot/app_userdata//mods +## * Windows: %APPDATA%/Godot/app_userdata//mods +## +## Failures are non-fatal: a broken mod logs a warning and is skipped, but +## boot completes. Successful registrations make the controller id appear +## in [code]GdGameState.registered_controller_ids()[/code], which the +## Stage 8 game-setup picker reads when its scene initialises — so mods +## are visible on the next game-setup screen after they are dropped in. + +const MODS_SUBPATH: String = "mods" +const MANIFEST_FILENAME: String = "manifest.json" + +## Number of mods successfully registered during the most recent +## [method _scan_and_register] pass. Diagnostic only; the registry +## itself is the source of truth (queried via +## [code]GdGameState.registered_controller_ids()[/code]). +var _mods_loaded: int = 0 + + +func _ready() -> void: + var mods_root: String = _resolve_mods_root() + if mods_root.is_empty(): + return + if not DirAccess.dir_exists_absolute(mods_root): + # No mods dir yet — first launch, or user hasn't installed any + # mods. Silent skip: this is the common case and must not log + # noise on every boot. + return + _scan_and_register(mods_root) + if _mods_loaded > 0: + print("ModLoader: registered %d mod(s) from %s" % [_mods_loaded, mods_root]) + + +## Returns the absolute path to the user mods directory, or an empty +## string when `OS.get_user_data_dir()` is unavailable (extremely rare +## — typically only headless edge cases without a project file). +func _resolve_mods_root() -> String: + var user_dir: String = OS.get_user_data_dir() + if user_dir.is_empty(): + return "" + return user_dir.path_join(MODS_SUBPATH) + + +## Iterate every immediate subdirectory of `root` looking for a +## `manifest.json`. Each match is handed to the gdext bridge which +## owns the actual parse + load + register pipeline; this function +## only does the filesystem walk and aggregates the diagnostic count. +func _scan_and_register(root: String) -> void: + _mods_loaded = 0 + var dir: DirAccess = DirAccess.open(root) + if dir == null: + push_warning("ModLoader: failed to open %s (err=%d)" % [root, DirAccess.get_open_error()]) + return + var gd_state: RefCounted = GameState.get_gd_state() + if gd_state == null: + push_warning( + "ModLoader: GameState.get_gd_state() returned null — GDExtension not loaded; " + + "skipping mod scan" + ) + return + if not gd_state.has_method("register_mod_from_path"): + push_warning( + "ModLoader: GdGameState missing register_mod_from_path — " + + "gdext rebuild predates Stage 5d; skipping mod scan" + ) + return + + dir.list_dir_begin() + while true: + var entry: String = dir.get_next() + if entry.is_empty(): + break + if not dir.current_is_dir(): + continue + if entry == "." or entry == "..": + continue + var mod_dir: String = root.path_join(entry) + var manifest_path: String = mod_dir.path_join(MANIFEST_FILENAME) + if not FileAccess.file_exists(manifest_path): + # Bare directory with no manifest — skip silently. Could be + # user notes, a download stub, anything; not our concern. + continue + var registered_id: String = String(gd_state.register_mod_from_path(manifest_path)) + if registered_id.is_empty(): + # Detailed reason already logged via godot_error! on the + # Rust side. Add a single GDScript-side warning so it + # surfaces in the editor's debugger panel too. + push_warning("ModLoader: failed to register mod at %s" % manifest_path) + continue + _mods_loaded += 1 + dir.list_dir_end() diff --git a/src/game/engine/tests/ffi/test_mod_loader.gd b/src/game/engine/tests/ffi/test_mod_loader.gd new file mode 100644 index 00000000..bcb354d8 --- /dev/null +++ b/src/game/engine/tests/ffi/test_mod_loader.gd @@ -0,0 +1,168 @@ +extends GutTest +## Stage 5d verification — `GdGameState.register_mod_from_path` and the +## `ModLoader` autoload boundary. +## +## Two assertions: +## 1. A manifest pointing at a non-existent payload returns the empty +## sentinel and does NOT crash the engine. +## 2. A valid WASM manifest + payload pair registers successfully, +## returns the controller id parsed from the .wat ident (NOT the +## manifest's `controller_id` — see `register_wasm_mod` in +## `mc-mod-host/src/lib.rs`), and that id then appears in +## `registered_controller_ids()`. +## +## Tests write into a per-suite scratch directory under +## `OS.get_user_data_dir()/mods_test_stage5d/` to avoid polluting the +## real user mods dir, and clean it up in `after_each`. + +const NOOP_WAT: String = """ +(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)) + (data (i32.const 0x100) \"noop 0.1.0\") + (func (export \"init\") (result i32) i32.const 1) + (func (export \"decide_turn\") (param i32 i32 i32 i64 i32 i32) (result i32) + local.get 4 i32.const 0 i32.store8 i32.const 1)) +""" + +const VALID_MANIFEST: String = """{ + "id": "stage5d-noop", + "name": "Stage 5d Noop", + "author": "gut-tests", + "version": "0.1.0", + "kind": "ai_controller", + "controller_id": "stage5d-noop", + "sandbox": "wasm", + "entrypoint": "controller.wat" +}""" + +const BROKEN_MANIFEST: String = """{ + "id": "stage5d-broken", + "name": "Stage 5d Broken", + "author": "gut-tests", + "version": "0.1.0", + "kind": "ai_controller", + "controller_id": "stage5d-broken", + "sandbox": "wasm", + "entrypoint": "missing.wasm" +}""" + +var _scratch_root: String = "" + + +func before_each() -> void: + _scratch_root = OS.get_user_data_dir().path_join("mods_test_stage5d") + _purge_scratch() + DirAccess.make_dir_recursive_absolute(_scratch_root) + + +func after_each() -> void: + _purge_scratch() + + +func _purge_scratch() -> void: + if _scratch_root.is_empty(): + return + if not DirAccess.dir_exists_absolute(_scratch_root): + return + # Remove every subdirectory's files, then the dir itself. + var top: DirAccess = DirAccess.open(_scratch_root) + if top == null: + return + top.list_dir_begin() + while true: + var entry: String = top.get_next() + if entry.is_empty(): + break + if entry == "." or entry == "..": + continue + var sub: String = _scratch_root.path_join(entry) + if top.current_is_dir(): + var inner: DirAccess = DirAccess.open(sub) + if inner != null: + inner.list_dir_begin() + while true: + var f: String = inner.get_next() + if f.is_empty(): + break + if f == "." or f == "..": + continue + DirAccess.remove_absolute(sub.path_join(f)) + inner.list_dir_end() + DirAccess.remove_absolute(sub) + else: + DirAccess.remove_absolute(sub) + top.list_dir_end() + DirAccess.remove_absolute(_scratch_root) + + +func _write_text(path: String, body: String) -> void: + var f: FileAccess = FileAccess.open(path, FileAccess.WRITE) + assert_not_null(f, "Could not open %s for writing" % path) + f.store_string(body) + f.close() + + +func _gd_state() -> RefCounted: + if not ClassDB.class_exists("GdGameState"): + return null + return ClassDB.instantiate("GdGameState") as RefCounted + + +func test_missing_payload_returns_empty_and_does_not_crash() -> void: + var gs: RefCounted = _gd_state() + if gs == null: + pending("GdGameState not registered — gdext build missing") + return + assert_true( + gs.has_method("register_mod_from_path"), + "gdext rebuild predates Stage 5d — missing register_mod_from_path" + ) + var mod_dir: String = _scratch_root.path_join("stage5d-broken") + DirAccess.make_dir_recursive_absolute(mod_dir) + var manifest_path: String = mod_dir.path_join("manifest.json") + _write_text(manifest_path, BROKEN_MANIFEST) + # Intentionally do NOT create missing.wasm. Loader must fail gracefully. + var result: String = String(gs.register_mod_from_path(manifest_path)) + assert_eq(result, "", "Missing payload must return empty sentinel, got %s" % result) + # The Rust side intentionally logs via godot_error! when a payload is + # unreachable. Mark the tracked error handled so GUT does not flag the + # test on unexpected errors — emitting the error IS the contract here. + for err: GutTrackedError in get_errors(): + err.handled = true + + +func test_valid_wasm_mod_registers_and_appears_in_ids() -> void: + var gs: RefCounted = _gd_state() + if gs == null: + pending("GdGameState not registered — gdext build missing") + return + assert_true(gs.has_method("register_mod_from_path")) + var mod_dir: String = _scratch_root.path_join("stage5d-noop") + DirAccess.make_dir_recursive_absolute(mod_dir) + var manifest_path: String = mod_dir.path_join("manifest.json") + _write_text(manifest_path, VALID_MANIFEST) + # Wasmtime's `Module::new` accepts .wat text bytes the same as .wasm + # binary bytes — see mc-mod-host's load_module(). Storing the .wat + # inline keeps the fixture self-contained. + _write_text(mod_dir.path_join("controller.wat"), NOOP_WAT) + + var result: String = String(gs.register_mod_from_path(manifest_path)) + # Registered id comes from the .wat's __ident_ptr ("noop 0.1.0" → + # name part "noop"), NOT the manifest's controller_id. This is + # intentional per the locked ABI in `docs/modding/abi-decisions.md`. + assert_eq(result, "noop", "Expected registered id 'noop', got %s" % result) + + var ids: PackedStringArray = gs.registered_controller_ids() as PackedStringArray + # Use `.has` rather than size assertions — the WasmHost singleton + # means a successful registration persists across the rest of the + # GUT session, so subsequent test runs see N+1 ids. + assert_true( + ids.has("noop"), + "registered_controller_ids() missing 'noop' after registration (got %s)" % ids + )