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:
parent
c52ec4c3f2
commit
27a1c73bf9
2 changed files with 273 additions and 0 deletions
105
src/game/engine/src/autoloads/mod_loader.gd
Normal file
105
src/game/engine/src/autoloads/mod_loader.gd
Normal 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()
|
||||
168
src/game/engine/tests/ffi/test_mod_loader.gd
Normal file
168
src/game/engine/tests/ffi/test_mod_loader.gd
Normal 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
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue