feat(autoloads): Enhance mod loader autoload scripts and add/update FFI tests for mod loading functionality

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

View file

@ -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]
## <mods_root>/<id>/manifest.json
## <mods_root>/<id>/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/<project>/mods
## * macOS: ~/Library/Application Support/Godot/app_userdata/<project>/mods
## * Windows: %APPDATA%/Godot/app_userdata/<project>/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()

View file

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