diff --git a/AGENTS.md b/AGENTS.md index 0a1c624d..67e9bf9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ enginseer/ Enginseer (runtime) — the injected modding runtime + injec `make build` stages the entry + modules into bin/mod_loader/ (the Enginseer-controlled loader root, self-located by the shell from its own DLL path and set as MOD_LOADER_DIR). - Vendored DMF/test-mod/mod_load_order live in a repo-root mods/ + Vendored DMF/test-mod/mods.lst live in a repo-root mods/ dir (gitignored — the mod root, pointed at by --mod-path). tests/ C unit tests (run via wine) mod-manager/ Magos Modificus — the mod manager app (not yet built; placeholder) diff --git a/README.md b/README.md index 2ea203ef..bb1495d0 100644 --- a/README.md +++ b/README.md @@ -97,16 +97,17 @@ Mods live in the **mod directory** you point `--mod-path` at. Lay it out as: ``` / - mod_load_order.txt one mod name per line, in load order (dmf is always first) + mods.lst one mod name per line, in load order (list dmf first) dmf/ the Darktide Mod Framework (DMF) — the API mods are built against / your mod(s) ``` - **DMF** (the Darktide Mod Framework) is the framework mods are built against; place it at `/dmf/`. -- **`mod_load_order.txt`** lists the mods to load, one name per line, in the - order they load (DMF is loaded first automatically). When Mod Magos ships it - will manage this for you. +- **`mods.lst`** lists the mods to load, one name per line, in the order they + load (list `dmf` first). The loader loads exactly what's listed, in order — it + injects nothing. Magos Modificus regenerates this file each launch; until Magos + ships, create/edit it by hand. ## License diff --git a/docs/architecture/ENGINSEER.md b/docs/architecture/ENGINSEER.md index dbe6aec6..cbb849e7 100644 --- a/docs/architecture/ENGINSEER.md +++ b/docs/architecture/ENGINSEER.md @@ -70,16 +70,17 @@ in engine context. (`mod_manager.lua` — the mod loader's driver), and installs the per-frame (`CLASS.StateGame.update`) + state-change (`CLASS.GameStateMachine._change_state`) hooks that drive `Managers.mod`. - The loader splits load into two phases: `init()` SCANs (reads - `mod_load_order.txt`, prepends `dmf`, builds the `_mods` table, installs the - DMF IO watch — no mod loaded), and the first `StateGame.update` tick LOADs - (per-mod `run()` → object → `init()`, then `_state="done"`) — deferred so - boot-complete globals like `Managers.input` exist. The IO watch re-roots - DMF's mod-facing IO at the mod root mid-DMF-init. The loader exposes itself - as `Managers.mod`. The whole bootstrap is pcall-wrapped so a DMF/mod failure - degrades to vanilla + a log line, not a crash. **Live-validated to - `StateMainMenu`** (DMF loads, a test mod's hook fires); the scan/load split - + IO-watch re-root are offline-tested, live validation pending. See + The loader splits load into two phases: `init()` SCANs (reads `mods.lst`, + builds the `_mods` table — the order file is authoritative, the loader + injects nothing — and installs the DMF IO watch; no mod loaded), and the + first `StateGame.update` tick LOADs (per-mod `run()` → object → `init()`, + then `_state="done"`) — deferred so boot-complete globals like + `Managers.input` exist. The IO watch re-roots DMF's mod-facing IO at the mod + root mid-DMF-init. The loader exposes itself as `Managers.mod`. The whole + bootstrap is pcall-wrapped so a DMF/mod failure degrades to vanilla + a log + line, not a crash. **Live-validated to `StateMainMenu`** (DMF loads, a test + mod's hook fires); the scan/load split + IO-watch re-root are + offline-tested, live validation pending. See `docs/architecture/MOD_LOADER-DMF.md` for the DMF integration + the IO re-rooting + the load timing. - **Bootstrap-only C helpers.** C functions are acceptable only at the @@ -130,9 +131,11 @@ exit. Sets `SteamAppId`/`SteamGameId`. its own DLL path (`\mod_loader\`, set as the internal `MOD_LOADER_DIR` global — not an env var/flag). The **mod** root (`--mod-path` / `DARKTIDE_MOD_PATH`) is Darktide-Magos-controlled: it writes DMF, user mods, - and `mod_load_order.txt` there; the trampoline sets `MAGOS_MOD_PATH` from it - and the mod loader bootstraps DMF + mods from there. `mod_load_order.txt` is a - Magos Modificus artifact, but the **mod loader reads it**; DMF does not. The + and `mods.lst` there; the trampoline sets `MAGOS_MOD_PATH` from it + and the mod loader bootstraps DMF + mods from there. `mods.lst` is a + Magos Modificus artifact (regenerated each launch), and the **mod loader reads + it authoritatively** — it loads exactly the listed mods in order and injects + nothing (DMF is a normal first entry Magos writes); DMF does not read it. The Enginseer runtime is the conduit; it does not compute the load order or resolve dependencies (that's Magos Modificus's job). - **Platform:** Windows — Magos Modificus runs directly, Steam in the @@ -158,7 +161,7 @@ global, so no loader-path env var exists. | Env var | Set by | Read by | Meaning | | --- | --- | --- | --- | -| `DARKTIDE_MOD_PATH` | launcher (only when `--mod-path`/env configured) | shell trampoline + mod loader | mod dir — where DMF + user mods + `mod_load_order.txt` live. The trampoline sets `MAGOS_MOD_PATH` from it; the loader/DMF/mods root here (`Mods.file.*`). Unset ⇒ empty `MAGOS_MOD_PATH` (mods won't load; graceful). | +| `DARKTIDE_MOD_PATH` | launcher (only when `--mod-path`/env configured) | shell trampoline + mod loader | mod dir — where DMF + user mods + `mods.lst` live. The trampoline sets `MAGOS_MOD_PATH` from it; the loader/DMF/mods root here (`Mods.file.*`). Unset ⇒ empty `MAGOS_MOD_PATH` (mods won't load; graceful). | | `MAGOS_ENGINSEER_LOG_FILE` | launcher | shell | shell log file path | | `MAGOS_ENGINSEER_LOG_LEVEL` | launcher | shell | shell log level (`error`/`warn`/`info`/`debug`/`trace`) | | `SteamAppId` / `SteamGameId` | launcher | Steam | the real Darktide app id (`1361210`); without it `SteamAPI_Init` is denied under a non-Steam shortcut | @@ -266,8 +269,9 @@ replacement for Lua `io`. ## Out of scope for the Enginseer runtime - **Dependency resolution / load-order computation** — Magos Modificus's job - (it writes `mod_load_order.txt`); the Enginseer runtime bootstraps the staged - mod loader entry point, and the mod loader reads the load order (DMF does not). + (it writes `mods.lst`); the Enginseer runtime bootstraps the staged + mod loader entry point, and the mod loader reads the load order authoritatively + (DMF does not). - **The mod manager UI / staging-dir management** — Magos Modificus. ## Build + test diff --git a/docs/architecture/MOD_LOADER-DMF.md b/docs/architecture/MOD_LOADER-DMF.md index 00ab0fb0..37e77b18 100644 --- a/docs/architecture/MOD_LOADER-DMF.md +++ b/docs/architecture/MOD_LOADER-DMF.md @@ -32,8 +32,9 @@ DLL injection -> mod loader -> DMF (mod #1) -> user mods (loads mods) (a mod) ``` -Concretely, the loader — not DMF — reads `mod_load_order.txt`, decides the order -(always `dmf` first), loads each `.mod`, calls `run()` / `init()`, and exposes +Concretely, the loader — not DMF — reads `mods.lst` (authored by Magos +Modificus), loads each listed `.mod` in the listed order (it injects nothing — +DMF is first because Magos lists it first), calls `run()` / `init()`, and exposes itself as `Managers.mod` to drive the per-frame + state-change lifecycle. DMF's own `dmf_mod_object` is driven by the loader like any other top-level mod object; DMF then drives *its* registered user mods through its inner update loop (see @@ -51,7 +52,7 @@ by the C trampoline before the entry opens: `make build` stages these into `bin/mod_loader/`. The entry's `bootstrap_load` (exposed as `Mods.load_module`) roots here. - **Mod root** (`MAGOS_MOD_PATH`, from `--mod-path` / `DARKTIDE_MOD_PATH`; - user/mod-manager-controlled) — holds DMF + user mods + `mod_load_order.txt`. + user/mod-manager-controlled) — holds DMF + user mods + `mods.lst`. `Mods.file.*` roots here (via `Mods._staging_base`). DMF and the mods never live under the loader root. @@ -67,10 +68,13 @@ see `lifecycle.lua`; `mod_manager.lua` is a loader module, so it loads from the **loader root** `MOD_LOADER_DIR`, not the mod root). Loading is split across two entry points: -- **`ModManager:init()`** — **SCAN only.** Read `mod_load_order.txt` - (`Mods.file.read_content_to_table`), prepend `"dmf"`, and build the **entire** - `_mods` table up front. Each entry is shaped - `{ id, name, handle, enabled, state, object }`. **No mod is loaded here** — +- **`ModManager:init()`** — **SCAN only.** Read `mods.lst` + (`Mods.file.read_content_to_table`), and build the **entire** `_mods` table up + front. The order file is authoritative — the loader loads exactly the listed + mods in the listed order and injects nothing (no framework assumption; DMF is + a normal first entry Magos writes). Each entry is shaped + `{ id, name, handle, state, object }`. A missing/empty `mods.lst` → empty + `_mods` → no mod loads (graceful, no crash). **No mod is loaded here** — `init()` only scans. It also installs the one-shot DMF IO watch (see [IO re-rooting](#io-re-rooting)). - **`ModManager:update(dt)`** — **LOAD on the first call**, then drive per-frame @@ -181,8 +185,9 @@ until late in boot. ## The DMF load sequence -DMF is always the first entry in the load order (the loader prepends `"dmf"`). -The sequence for DMF is the same as for any mod, just first — and it runs on the +DMF is the first entry in the load order because Magos Modificus lists it first +in `mods.lst` (the loader injects nothing — it is framework-agnostic). The +sequence for DMF is the same as for any mod, just first — and it runs on the first `StateGame.update` tick (not at boot): 1. The loader loads `dmf.mod` (`Mods.file.exec_with_return("dmf", "dmf", "mod")`). @@ -351,8 +356,8 @@ shell/ subsection. The bootstrap hook body (in `lifecycle.lua`) is: 1. call the original `_state_update` (requires game scripts → `StateGame` created and registered in `CLASS`); 2. `Mods.load_module("mod_manager")` → - `Managers.mod = ModManager:new()` → `init()` SCANs (reads `mod_load_order`, - prepends `dmf`, builds `_mods`; installs the IO watch). **No mod loads here.** + `Managers.mod = ModManager:new()` → `init()` SCANs (reads `mods.lst`, builds + `_mods`; installs the IO watch). **No mod loads here.** 3. install `CLASS.StateGame.update` hook → drives `Managers.mod:update(dt)` — the first tick LOADs (DMF + every user mod), every tick pumps per-mod `update(dt)`; diff --git a/enginseer/README.md b/enginseer/README.md index 18264e85..2e5c2aec 100644 --- a/enginseer/README.md +++ b/enginseer/README.md @@ -108,7 +108,8 @@ values: (`make build` stages it); a DMF/mod update never requires an Enginseer rebuild. - **`MAGOS_MOD_PATH`** (from `--mod-path` / `DARKTIDE_MOD_PATH`) — **user/mod-manager-controlled**. Holds DMF + user mods + - `mod_load_order.txt`. The mod loader roots its mod-facing IO here. + `mods.lst` (the load-order file, regenerated by Magos Modificus each launch). + The mod loader roots its mod-facing IO here. The split keeps the loader's own code Enginseer-owned while the mods it loads are user-owned. Detail in diff --git a/enginseer/mod_loader/file.lua b/enginseer/mod_loader/file.lua index 705a9dd1..e2140aeb 100644 --- a/enginseer/mod_loader/file.lua +++ b/enginseer/mod_loader/file.lua @@ -127,7 +127,7 @@ local function read_or_execute(file_path, args, return_type) -- line-comment-only; Lua block comments ("--[[ ]]") are NOT -- recognized — a "--[[" opener is dropped as a comment but the -- lines inside the block are kept as content. Matches DML's - -- behavior (these files are mod_load_order.txt-style, no block + -- behavior (these files are mods.lst-style, no block -- comments). if line ~= "" and line:sub(1, 2) ~= "--" then table.insert(result, line) diff --git a/enginseer/mod_loader/init.lua b/enginseer/mod_loader/init.lua index e5d65101..b851a261 100644 --- a/enginseer/mod_loader/init.lua +++ b/enginseer/mod_loader/init.lua @@ -13,7 +13,7 @@ -- (file/hook/class_patch/require_wrap/lifecycle + mod_manager) live here. -- bootstrap_load roots here. -- - MAGOS_MOD_PATH — the mod dir (user/mod-manager-controlled). DMF + user --- mods + mod_load_order live here. Mods.file.* roots here (via +-- mods + mods.lst live here. Mods.file.* roots here (via -- Mods._staging_base, set below). -- -- Supersedes init.v1.lua (which only captured the stdlib into Mods). The v1 @@ -59,7 +59,7 @@ Mods.lua.table = table Mods.lua.string = string Mods.file = Mods.file or {} Mods._deferred_hooks = {} --- The MOD root (DMF + user mods + mod_load_order). Mods.file.* roots here +-- The MOD root (DMF + user mods + mods.lst). Mods.file.* roots here -- (file.lua reads Mods._staging_base, falling back to MAGOS_MOD_PATH). Kept -- distinct from the loader root (below) so the loader's own modules load -- from the runtime root regardless of where mods are staged. diff --git a/enginseer/mod_loader/lifecycle.lua b/enginseer/mod_loader/lifecycle.lua index 176b5ddd..263aa2db 100644 --- a/enginseer/mod_loader/lifecycle.lua +++ b/enginseer/mod_loader/lifecycle.lua @@ -15,7 +15,7 @@ -- CLASS.BootStateRequireGameScripts._state_update that runs AFTER the original -- (which requires game scripts -> StateGame created), then loads the mod loader's -- mod_manager (the loader driver), assigns Managers.mod (whose :init() reads --- mod_load_order, prepends "dmf", and builds the _mods table — the SCAN), and +-- mods.lst and builds the _mods table — the SCAN), and -- installs the per-frame + state-change hooks. The LOAD itself (per-mod -- run()/init()) does NOT happen here: it is deferred to the first -- StateGame.update tick (driven by the per-frame hook below), where @@ -115,11 +115,11 @@ Mods.install_lifecycle_hooks = function() -- not at the entry's bootstrap_load — because it calls -- class("ModManager"), which only exists after the class patch -- installs at boot (the require-wrap), not at the entry's - -- pcall#1. :init() reads mod_load_order, prepends "dmf", and - -- builds the _mods table (the SCAN); it loads NO mod. The LOAD - -- runs on the first StateGame.update tick (the per-frame hook - -- installed below), where Managers.input exists. DMF/mods/ - -- mod_load_order root at the MOD dir via Mods.file.* + -- pcall#1. :init() reads mods.lst and builds the _mods table + -- (the SCAN); it loads NO mod. The LOAD runs on the first + -- StateGame.update tick (the per-frame hook installed below), + -- where Managers.input exists. DMF/mods/mods.lst root at the + -- MOD dir via Mods.file.* -- (MAGOS_MOD_PATH); _state reaches "done" once the load -- completes. -- LIVE-VALIDATE: the full load end-to-end (DMF init loads all diff --git a/enginseer/mod_loader/mod_manager.lua b/enginseer/mod_loader/mod_manager.lua index bf462e2f..a668cb01 100644 --- a/enginseer/mod_loader/mod_manager.lua +++ b/enginseer/mod_loader/mod_manager.lua @@ -1,11 +1,13 @@ -- mod_manager.lua — the mod loader's driver (ModManager). -- -- This module is the mod loader's driver. In the --- deferred bootstrap hook (lifecycle.lua), it reads the user's mod_load_order, --- prepends "dmf" (DMF is just the first mod — a bag of helper APIs other mods --- opt into), and loads each mod: exec its `.mod` file, call its `run()`, and if --- it yields a mod object store it + call `object:init()`. DMF loads first; its --- `init()` defines `new_mod`/`get_mod` that the subsequent user mods' `run()` +-- deferred bootstrap hook (lifecycle.lua), it reads the load order (mods.lst — +-- authored by Magos Modificus) and loads each listed mod in order: exec its +-- `.mod` file, call its `run()`, and if it yields a mod object store it + call +-- `object:init()`. The loader is framework-agnostic: it makes NO assumption +-- about DMF (or any framework) and injects nothing — the order file is +-- authoritative for both WHAT loads and the ORDER. Magos lists `dmf` first so +-- its `init()` defines `new_mod`/`get_mod` that subsequent user mods' `run()` -- calls — so the per-mod ordering (run+init before the next mod loads) is what -- makes that dependency work. -- @@ -16,10 +18,10 @@ -- DMF's inner loop, not here, so entry.object stays nil and the outer update/ -- gsc loops skip it. Either way the scan-phase _mods entry is retained. -- --- SCAN vs LOAD split: init() SCANs ONLY — it reads mod_load_order, prepends --- "dmf", and builds the full _mods table, but loads NO mod. The LOAD (per-mod --- run()/init()) is deferred to the first StateGame.update tick (via --- Managers.mod:update), where boot-complete globals like Managers.input exist. +-- SCAN vs LOAD split: init() SCANs ONLY — it reads mods.lst and builds the +-- full _mods table, but loads NO mod. The LOAD (per-mod run()/init()) is +-- deferred to the first StateGame.update tick (via Managers.mod:update), +-- where boot-complete globals like Managers.input exist. -- Mods whose new_mod/init touch those globals (e.g. Power_DI's option/keybind -- validation) would otherwise hit nil mid-boot. The engine drives Lua -- single-threaded at fixed points; the loader hooks @@ -164,11 +166,11 @@ end -- ModManager:init — SCAN ONLY. -- --- Reads mod_load_order, prepends "dmf", and builds the full _mods table, but --- loads NO mod. The LOAD happens on the first StateGame.update tick (see --- update()), where boot-complete globals like Managers.input exist — loading --- here (inside BootStateRequireGameScripts._state_update) was too early and --- broke mods whose init reads Managers.input. +-- Reads mods.lst and builds the full _mods table, but loads NO mod. The LOAD +-- happens on the first StateGame.update tick (see update()), where +-- boot-complete globals like Managers.input exist — loading here (inside +-- BootStateRequireGameScripts._state_update) was too early and broke mods +-- whose init reads Managers.input. -- -- _state is DMF's contract field; it is NOT set here — it's written once -- ("done") when the load completes (see update). nil before that is fine (DMF @@ -190,19 +192,21 @@ function ModManager:init() self._mods_loaded = false self._dmf_io_adapted = false - -- SCAN: read the user's load order and build the full _mods table up front - -- (id/name/handle/enabled/state/object), so every entry exists before any - -- mod's run()/init() reads it. nil (missing file) -> empty, so a bare - -- DMF-only bootstrap still works. Mirrors DML's _build_mod_table entry shape - -- (id=i, name=mod_name, handle=mod_name); DMF only reads id/name/handle. - local order = Mods.file.read_content_to_table("mod_load_order", "txt") or {} - table.insert(order, 1, "dmf") + -- SCAN: read the load order (mods.lst — authored by Magos Modificus) and + -- build the full _mods table up front (id/name/handle/state/object), so + -- every entry exists before any mod's run()/init() reads it. The order file + -- is AUTHORITATIVE: the loader loads exactly the listed mods, in the listed + -- order, and injects nothing (no framework assumption — DMF is a normal + -- entry Magos writes first). A missing/empty mods.lst -> empty _mods -> no + -- mod loads (graceful, no crash; the `or {}` covers a missing file's false + -- return). Mirrors DML's _build_mod_table entry shape (id=i, name=mod_name, + -- handle=mod_name); DMF only reads id/name/handle. + local order = Mods.file.read_content_to_table("mods", "lst") or {} for i, name in ipairs(order) do self._mods[i] = { id = i, name = name, handle = name, - enabled = true, state = "not_loaded", object = nil, } diff --git a/enginseer/mod_loader/tests/mock.lua b/enginseer/mod_loader/tests/mock.lua index fae0a311..200c1614 100644 --- a/enginseer/mod_loader/tests/mock.lua +++ b/enginseer/mod_loader/tests/mock.lua @@ -14,7 +14,7 @@ -- The mock default is mock.MOD_LOADER_ROOT ("/mod_loader"); -- mock.stage_mod_loader() builds the files map a test io mock serves for it. -- - Mod root (MAGOS_MOD_PATH) — user/mod-manager-controlled; holds --- mod_load_order.txt + dmf/ + user mods. Mods.file.* roots here. The mock +-- mods.lst + dmf/ + user mods. Mods.file.* roots here. The mock -- default is mock.MOD_ROOT ("/mods"); each test stages its own mods/DMF. local _G = _G @@ -177,7 +177,7 @@ end -- every active module, keyed at /.lua. Mirrors the -- deployment contract (bin/mod_loader/) and what bootstrap_load expects to -- open. A test merges this into its io-mock files map (and adds its own --- mod-root files under MOD_ROOT for DMF/mods/mod_load_order). +-- mod-root files under MOD_ROOT for DMF/mods/mods.lst). function M.stage_mod_loader() local files = {} for _, name in ipairs(M.MOD_LOADER_MODULES) do diff --git a/enginseer/mod_loader/tests/test_entry.lua b/enginseer/mod_loader/tests/test_entry.lua index 20ad52a9..b1c06eb7 100644 --- a/enginseer/mod_loader/tests/test_entry.lua +++ b/enginseer/mod_loader/tests/test_entry.lua @@ -3,7 +3,7 @@ -- Stages all 5 helper modules + the entry in an in-memory io map under the -- loader root (mirroring the deployment contract: /{file,hook, -- class_patch,require_wrap,lifecycle}.lua + init.lua), with a SEPARATE mod root --- holding DMF/mods/mod_load_order. Runs the entry in a sandbox and verifies it +-- holding DMF/mods/mods.lst. Runs the entry in a sandbox and verifies it -- bootstraps every surface, wraps require, and queues the bootstrap lifecycle -- hook. The per-module contracts are covered by the other test files; this test -- covers the entry's assembly + ordering + the deferred-bridge end-to-end @@ -15,7 +15,7 @@ local mock = require("mock") return function(runner) -- Build a sandbox with the two-root staging layout populated from real -- module sources. The loader root (MOD_LOADER_DIR) holds the entry + - -- modules; the mod root (MAGOS_MOD_PATH) is where DMF/mods/mod_load_order + -- modules; the mod root (MAGOS_MOD_PATH) is where DMF/mods/mods.lst -- live (Mods.file.* roots there via Mods._staging_base). -- `engine_require` is the fake engine require used to simulate main.lua -- loading modules (class.lua, etc.). diff --git a/enginseer/mod_loader/tests/test_mod_manager.lua b/enginseer/mod_loader/tests/test_mod_manager.lua index b48b6407..d25d49e3 100644 --- a/enginseer/mod_loader/tests/test_mod_manager.lua +++ b/enginseer/mod_loader/tests/test_mod_manager.lua @@ -11,7 +11,8 @@ -- most tests drive `:new()` (scan) then `:update(dt)` (load + drive). -- -- Coverage (acceptance criteria from the spec): --- - reads the order, prepends "dmf", and loads mods in order (dmf first); +-- - reads the order (mods.lst) and loads exactly the listed mods in order +-- (no injection — DMF is a normal first entry Magos writes); -- - per mod: run() -> object stored -> object:init() called BEFORE the next -- mod loads (verified with a sequence tracker); -- - _state is nil until the load completes, then "done" (DMF's contract field, @@ -74,11 +75,13 @@ return function(runner) sb.Mods = { file = {} } - -- read_content_to_table returns the staged order (or nil to test the - -- missing-file path). Default: a 2-user-mod order. + -- read_content_to_table returns the staged order (or false to test the + -- missing-file path, which handle_io returns for an absent mods.lst). + -- Default: a 2-user-mod order. sb.Mods.file.read_content_to_table = function(path, ext) - runner.assert_eq("mod_load_order", path, "order read must target 'mod_load_order'") - runner.assert_eq("txt", ext, "order read must use 'txt' extension") + runner.assert_eq("mods", path, "order read must target 'mods'") + runner.assert_eq("lst", ext, "order read must use 'lst' extension") + if opts.missing_order then return false end -- simulate missing mods.lst return opts.order ~= nil and opts.order or { "alpha", "beta" } end @@ -167,29 +170,80 @@ return function(runner) runner.assert_eq({}, load_calls, "init must NOT call exec_with_return (scan only, no load)") runner.assert_eq(false, mm._mods_loaded, "_mods_loaded false after init") runner.assert_nil(mm._state, "_state must NOT be set by init") - runner.assert_eq(2, #mm._mods, "dmf + usermod scanned") + runner.assert_eq(1, #mm._mods, "exactly the listed mods scanned (no injection)") -- DMF-compat entry shape: id/name/handle match each entry's position + -- name (mirrors DML's _build_mod_table). DMF reads id/name/handle. runner.assert_eq(1, mm._mods[1].id) - runner.assert_eq("dmf", mm._mods[1].name) - runner.assert_eq("dmf", mm._mods[1].handle) + runner.assert_eq("usermod", mm._mods[1].name) + runner.assert_eq("usermod", mm._mods[1].handle) runner.assert_eq("not_loaded", mm._mods[1].state, "no mod loaded yet") runner.assert_nil(mm._mods[1].object) - runner.assert_eq(2, mm._mods[2].id) - runner.assert_eq("usermod", mm._mods[2].name) - runner.assert_eq("usermod", mm._mods[2].handle) - runner.assert_eq("not_loaded", mm._mods[2].state) - runner.assert_nil(mm._mods[2].object) runner.assert_eq(false, mm._settings.developer_mode, "_settings defaults (developer_mode)") runner.assert_eq(1, mm._settings.log_level, "_settings defaults (log_level)") end) + runner.register("mod_manager: missing mods.lst -> empty _mods, nothing loads, no crash", function() + -- The order file is AUTHORITATIVE; a missing mods.lst means load NOTHING + -- (the new contract). Previously "missing -> DMF-only" because of the + -- prepend; now the `or {}` in init() turns read_content_to_table's false + -- return (handle_io's missing-file path) into an empty order, so _mods + -- stays empty and update() reaches _state="done" without loading any mod. + -- This is the intended graceful no-op. (The empty-file path — file + -- exists, zero entries — collapses through the same `or {}`; covered by + -- the next test.) + local load_calls = {} + local sb = setup({ missing_order = true }) -- read_content_to_table returns false + sb.Mods.file.exec_with_return = function(local_path, file_name, ext) + table.insert(load_calls, local_path) + return nil + end + local ModManager = load_driver(sb) + local mm = ModManager:new() + + runner.assert_eq(0, #mm._mods, "missing mods.lst -> empty _mods (nothing to load)") + runner.assert_eq(false, mm._mods_loaded, "_mods_loaded false after init") + runner.assert_nil(mm._state, "_state unset after init") + + mm:update(0.016) -- first tick: load (of nothing) + + runner.assert_eq({}, load_calls, "no .mod load attempted with an empty order") + runner.assert_eq(true, mm._mods_loaded, "_mods_loaded still flips after the (empty) load") + runner.assert_eq("done", mm._state, "_state still reaches 'done' with no mods") + runner.assert_eq(0, #mm._mods, "_mods still empty after the load") + end) + + runner.register("mod_manager: empty mods.lst (zero entries) -> empty _mods, nothing loads, no crash", function() + -- Companion to the missing-file test above: here mods.lst EXISTS but + -- lists zero entries (read_content_to_table returns {}). The same `or {}` + -- line passes the empty table through unchanged, so behavior is identical + -- to the missing case — empty _mods, no load attempted, _state="done". + local load_calls = {} + local sb = setup({ order = {} }) -- read_content_to_table returns {} (empty file) + sb.Mods.file.exec_with_return = function(local_path, file_name, ext) + table.insert(load_calls, local_path) + return nil + end + local ModManager = load_driver(sb) + local mm = ModManager:new() + + runner.assert_eq(0, #mm._mods, "empty mods.lst -> empty _mods (nothing to load)") + runner.assert_eq(false, mm._mods_loaded, "_mods_loaded false after init") + runner.assert_nil(mm._state, "_state unset after init") + + mm:update(0.016) -- first tick: load (of nothing) + + runner.assert_eq({}, load_calls, "no .mod load attempted with an empty order") + runner.assert_eq(true, mm._mods_loaded, "_mods_loaded still flips after the (empty) load") + runner.assert_eq("done", mm._state, "_state still reaches 'done' with no mods") + runner.assert_eq(0, #mm._mods, "_mods still empty after the load") + end) + runner.register("mod_manager: _state nil after init, 'done' after first update (never set mid-load)", function() -- _state is DMF's contract field, written ONCE ("done") when the load -- completes. nil before/while loading is fine — DMF only reads "done". -- A DMF poll mid-load (e.g. during a mod's init) must NOT see "done". local state_done_during_load = false - local sb = setup({ order = {} }) -- dmf-only + local sb = setup({ order = { "dmf" } }) -- dmf loads (listed explicitly) sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return mod_file("dmf", { init = function() @@ -211,8 +265,11 @@ return function(runner) runner.assert_eq(true, mm._mods_loaded, "_mods_loaded true after the load") end) - runner.register("mod_manager: reads order, prepends 'dmf', loads dmf first", function() - local sb = setup({ order = { "usermod" } }) + runner.register("mod_manager: reads order verbatim — loads exactly the listed mods in order (no injection)", function() + -- The order file is authoritative: the loader loads EXACTLY what's + -- listed, in the listed order. No injection — DMF appears here only + -- because Magos wrote it first; the loader treats it like any entry. + local sb = setup({ order = { "dmf", "usermod" } }) local loaded = {} local dmf_object = { init = function() end } local user_object = { init = function() end } @@ -226,7 +283,7 @@ return function(runner) local mm = new_loaded(sb) runner.assert_eq({ "dmf", "usermod" }, loaded, - "dmf must be prepended and loaded first, then user mods in order") + "loader loads exactly the listed mods in the listed order (no prepend)") runner.assert_eq(2, #mm._mods) runner.assert_eq(dmf_object, mm._mods[1].object) runner.assert_eq("running", mm._mods[1].state) @@ -242,7 +299,6 @@ return function(runner) local sb = setup({ order = { "alpha", "beta" } }) sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", recording_mod("dmf", seq), seq), alpha = mod_file("alpha", recording_mod("alpha", seq), seq), beta = mod_file("beta", recording_mod("beta", seq), seq), })[local_path] @@ -250,7 +306,6 @@ return function(runner) new_loaded(sb) runner.assert_eq({ - "dmf:run", "dmf:init", "alpha:run", "alpha:init", "beta:run", "beta:init", }, seq, "each mod's run()+init() must complete before the next mod loads") @@ -263,7 +318,6 @@ return function(runner) local good_init = 0 sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end }), boom = mod_file("boom", nil, nil, true), -- run() raises good = mod_file("good", { init = function() good_init = good_init + 1 end }), })[local_path] @@ -273,12 +327,12 @@ return function(runner) runner.assert_eq("done", mm._state, "load still reaches done despite a run() failure") -- Scan built every ordered mod's entry; boom's failed entry is retained -- (object == nil) so the indices of later mods stay stable. - runner.assert_eq(3, #mm._mods, "all ordered mods have entries; boom's failed entry is retained") - runner.assert_eq("boom", mm._mods[2].name) - runner.assert_nil(mm._mods[2].object, "boom's run() failed -> no object stored") - runner.assert_eq("not_loaded", mm._mods[2].state, "boom's entry stays 'not_loaded'") - runner.assert_eq("good", mm._mods[3].name) - runner.assert_truthy(mm._mods[3].object ~= nil, "the good mod after the failing one still stores its object") + runner.assert_eq(2, #mm._mods, "all ordered mods have entries; boom's failed entry is retained") + runner.assert_eq("boom", mm._mods[1].name) + runner.assert_nil(mm._mods[1].object, "boom's run() failed -> no object stored") + runner.assert_eq("not_loaded", mm._mods[1].state, "boom's entry stays 'not_loaded'") + runner.assert_eq("good", mm._mods[2].name) + runner.assert_truthy(mm._mods[2].object ~= nil, "the good mod after the failing one still stores its object") runner.assert_eq(1, good_init, "the good mod after the failing one still inits") runner.assert_truthy(#logged >= 1, "the run() failure must be logged") runner.assert_truthy(logged[1]:find("mod 'boom' run failed") ~= nil, @@ -314,7 +368,6 @@ return function(runner) local later_init = 0 sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end }), dmfmod = { run = function() -- Realistic DMF-mod convention: call new_mod for its @@ -329,13 +382,13 @@ return function(runner) local mm = new_loaded(sb) runner.assert_eq("done", mm._state, "load reaches done despite the nil-return mod") - runner.assert_eq(3, #mm._mods, "all ordered mods have entries; dmfmod's entry retained") - runner.assert_eq("dmfmod", mm._mods[2].name) - runner.assert_nil(mm._mods[2].object, "DMF-driven mod has no top-level object") - runner.assert_eq("dmf_driven", mm._mods[2].state, + runner.assert_eq(2, #mm._mods, "all ordered mods have entries; dmfmod's entry retained") + runner.assert_eq("dmfmod", mm._mods[1].name) + runner.assert_nil(mm._mods[1].object, "DMF-driven mod has no top-level object") + runner.assert_eq("dmf_driven", mm._mods[1].state, "DMF-driven mod is marked 'dmf_driven' (loaded), distinct from a failed/missing 'not_loaded' entry") - runner.assert_eq("later", mm._mods[3].name) - runner.assert_truthy(mm._mods[3].object ~= nil, "a mod after the nil-return mod still loads") + runner.assert_eq("later", mm._mods[2].name) + runner.assert_truthy(mm._mods[2].object ~= nil, "a mod after the nil-return mod still loads") runner.assert_eq(1, later_init, "the later mod still inits") runner.assert_eq("dmfmod", handle_during_run, "DMF's new_mod must read _mods[_mod_load_index].handle during the nil-return mod's run()") @@ -357,7 +410,6 @@ return function(runner) sb.__print = function(msg) table.insert(logged, msg) end sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end }), boom = mod_file("boom", recording_mod("boom", {}, "init")), good = mod_file("good", { init = function() end }), })[local_path] @@ -367,11 +419,11 @@ return function(runner) -- boom's run() succeeded -> it's stored (object kept); only its init() -- failed. It stays in _mods (object retained) but the load continues. runner.assert_eq("done", mm._state, "load reaches done despite an init() failure") - runner.assert_eq(3, #mm._mods, "dmf + boom + good all stored (run succeeded)") - runner.assert_eq("boom", mm._mods[2].name) - runner.assert_truthy(mm._mods[2].object ~= nil, + runner.assert_eq(2, #mm._mods, "boom + good both stored (run succeeded)") + runner.assert_eq("boom", mm._mods[1].name) + runner.assert_truthy(mm._mods[1].object ~= nil, "boom's run() succeeded -> object retained (only init failed)") - runner.assert_eq("good", mm._mods[3].name, "the good mod after the failing one still loaded") + runner.assert_eq("good", mm._mods[2].name, "the good mod after the failing one still loaded") runner.assert_truthy(logged[1]:find("mod 'boom' init failed") ~= nil, "log line must name the failing mod + 'init' phase") end) @@ -383,25 +435,24 @@ return function(runner) sb.Mods.file.exec_with_return = function(local_path, file_name, ext) if local_path == "ghost" then return nil end return ({ - dmf = mod_file("dmf", { init = function() end }), real = mod_file("real", { init = function() end }), })[local_path] end local mm = new_loaded(sb) runner.assert_eq("done", mm._state) - runner.assert_eq(3, #mm._mods, "all ordered mods have entries; ghost's entry retained (no object)") - runner.assert_eq("ghost", mm._mods[2].name) - runner.assert_nil(mm._mods[2].object, "ghost has no .mod -> no object stored") - runner.assert_eq("real", mm._mods[3].name) - runner.assert_truthy(mm._mods[3].object ~= nil, "real's object stored") + runner.assert_eq(2, #mm._mods, "all ordered mods have entries; ghost's entry retained (no object)") + runner.assert_eq("ghost", mm._mods[1].name) + runner.assert_nil(mm._mods[1].object, "ghost has no .mod -> no object stored") + runner.assert_eq("real", mm._mods[2].name) + runner.assert_truthy(mm._mods[2].object ~= nil, "real's object stored") runner.assert_truthy(logged[1]:find("mod 'ghost'") ~= nil, "missing .mod must be logged naming the mod") end) runner.register("mod_manager: update(dt) calls each loaded mod's update with dt", function() local calls = {} - local sb = setup({ order = {} }) + local sb = setup({ order = { "dmf" } }) sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ dmf = mod_file("dmf", { update = function(self, dt) table.insert(calls, { "dmf", dt }) end }), @@ -420,7 +471,7 @@ return function(runner) runner.register("mod_manager: update skips mods without an update() (no error)", function() local logged = {} - local sb = setup({ order = {} }) + local sb = setup({ order = { "dmf" } }) sb.__print = function(msg) table.insert(logged, msg) end sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return mod_file("dmf", { init = function() end }) -- no update() @@ -440,7 +491,6 @@ return function(runner) local good_dt sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end, update = function() end }), boom = mod_file("boom", { init = function() end, update = function() error("update boom") end, @@ -458,7 +508,7 @@ return function(runner) end) runner.register("mod_manager: on_game_state_changed fans out to each mod's callback", function() - local sb = setup({ order = {} }) + local sb = setup({ order = { "dmf" } }) local events = {} sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ @@ -484,7 +534,6 @@ return function(runner) local good_received sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end }), boom = mod_file("boom", { init = function() end, on_game_state_changed = function() error("gsc boom") end, @@ -511,7 +560,7 @@ return function(runner) -- ModManager:destroy -> unload intent, minimal. runner.register("mod_manager: destroy() calls on_unload on each loaded mod that has one (in order)", function() local unloaded = {} - local sb = setup({ order = { "alpha", "beta" } }) + local sb = setup({ order = { "dmf", "alpha", "beta" } }) sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ dmf = mod_file("dmf", { @@ -536,7 +585,7 @@ return function(runner) runner.register("mod_manager: destroy() skips mods without on_unload (no error, not logged)", function() local logged = {} - local sb = setup({ order = { "noul" } }) + local sb = setup({ order = { "dmf", "noul" } }) sb.__print = function(msg) table.insert(logged, msg) end local dmf_unloaded = false sb.Mods.file.exec_with_return = function(local_path, file_name, ext) @@ -562,7 +611,6 @@ return function(runner) local good_unloaded = false sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end, on_unload = function() end }), boom = mod_file("boom", { init = function() end, on_unload = function() error("unload boom") end, @@ -590,7 +638,7 @@ return function(runner) -- with an init() so the loader's object:init() step is exercised. local created = {} - local sb = setup({ order = { "usermod" } }) + local sb = setup({ order = { "dmf", "usermod" } }) sb.__print = function() end -- DMF's init() installs new_mod/get_mod on _G (its real module does). @@ -658,7 +706,7 @@ return function(runner) -- This is the read that indexed nil and died before the shape fix. runner.register("mod_manager: DMF read — _mods[_mod_load_index].handle resolves per mod", function() local seen = {} -- phase tag -> handle read via Managers.mod - local sb = setup({ order = { "usermod" } }) + local sb = setup({ order = { "dmf", "usermod" } }) -- Record the handle Managers.mod._mods[_mod_load_index] points at, the -- same indexing DMF's DMFMod:init does. Returns a mod object whose @@ -753,7 +801,7 @@ return function(runner) -- "./../mods" (the wrong base / a stale game-dir DMF); with it, it resolves -- against the mod root. runner.register("mod_manager: wrapper re-roots DMFMod:io_dofile mid-init — Phase-2 loads hit the mod root", function() - local sb = setup({ order = {} }) -- dmf-only + local sb = setup({ order = { "dmf" } }) -- dmf loads (listed explicitly) local dofile, mod_root_reads, wrong_base_reads = make_dmf_io_mock(sb) sb.Mods.file.dofile = dofile @@ -804,7 +852,7 @@ return function(runner) -- the mod root. This exercises io_dofile_unsafe (the resource-load surface), -- distinct from the Phase-2 io_dofile path above. runner.register("mod_manager: after re-root, a user mod's resource load (io_dofile_unsafe) hits the mod root", function() - local sb = setup({ order = { "usermod" } }) + local sb = setup({ order = { "dmf", "usermod" } }) local dofile, mod_root_reads, wrong_base_reads = make_dmf_io_mock(sb) sb.Mods.file.dofile = dofile @@ -853,7 +901,7 @@ return function(runner) -- no permanent wrap. A second Phase-1 dofile (still going through the -- captured local) is a benign pass-through. runner.register("mod_manager: wrapper fires once + unwraps (no permanent wrap on Mods.file.dofile)", function() - local sb = setup({ order = {} }) + local sb = setup({ order = { "dmf" } }) local dofile, mod_root_reads = make_dmf_io_mock(sb) local original = dofile sb.Mods.file.dofile = original @@ -888,10 +936,11 @@ return function(runner) -- No-op when DMFMod never surfaces: if DMF isn't present or its core/io.lua -- never loads (so DMFMod.io_dofile never appears), the wrapper stays -- installed as a thin pass-through and never fires — flag stays false, the - -- load completes normally. (DMF is always prepended, so in practice the - -- wrapper always fires; this covers the degenerate case.) + -- load completes normally. (When DMF is listed first in mods.lst, the + -- wrapper fires during its init; this covers the degenerate case where + -- DMFMod never surfaces.) runner.register("mod_manager: wrapper is a no-op when DMFMod never surfaces", function() - local sb = setup({ order = {} }) + local sb = setup({ order = { "dmf" } }) sb.Mods.file.dofile = function(fp) return "STAGED:" .. fp end sb.Mods.file.exec_with_return = function(local_path, file_name, ext) -- dmf loads but its init never loads core/io / never defines DMFMod @@ -917,7 +966,6 @@ return function(runner) local sb = setup({ order = { "usermod" } }) sb.Mods.file.exec_with_return = function(local_path, file_name, ext) return ({ - dmf = mod_file("dmf", { init = function() end }), usermod = { run = function() run_called = true @@ -934,7 +982,7 @@ return function(runner) runner.assert_eq(false, init_called, "init() must NOT call a mod's init()") runner.assert_eq(false, mm._mods_loaded, "_mods_loaded false after init") runner.assert_nil(mm._state, "_state unset after init") - runner.assert_eq("not_loaded", mm._mods[2].state, "usermod still 'not_loaded' after init") + runner.assert_eq("not_loaded", mm._mods[1].state, "usermod still 'not_loaded' after init") mm:update(0.016) -- first tick: load @@ -942,6 +990,6 @@ return function(runner) runner.assert_eq(true, init_called, "the first update() must call the mod's init()") runner.assert_eq(true, mm._mods_loaded, "_mods_loaded true after the first update") runner.assert_eq("done", mm._state, "_state reaches 'done' after the first update") - runner.assert_eq("running", mm._mods[2].state, "usermod 'running' after the first update") + runner.assert_eq("running", mm._mods[1].state, "usermod 'running' after the first update") end) end diff --git a/enginseer/shell/include/trampoline.h b/enginseer/shell/include/trampoline.h index e59a9582..15a00f28 100644 --- a/enginseer/shell/include/trampoline.h +++ b/enginseer/shell/include/trampoline.h @@ -8,7 +8,7 @@ * - the mod loader root (`mod_loader_dir`) — where init.lua + its modules * live (runtime-controlled; self-located by the shell next to the DLL at * \mod_loader); - * - the mod root (`mod_path`) — where DMF + user mods + mod_load_order live + * - the mod root (`mod_path`) — where DMF + user mods + mods.lst live * (user/mod-manager-controlled; optional — mods just won't load if unset). * The entry path is `\init.lua`. trampoline_build_chunk bakes * all three into the chunk: it sets MOD_LOADER_DIR from the mod loader root, @@ -70,7 +70,7 @@ int trampoline_escape_path(const char *path, size_t path_len, * * The two globals hand the roots to the mod loader: MOD_LOADER_DIR roots its * own module loads (bootstrap_load); MAGOS_MOD_PATH roots Mods.file.* - * (DMF/mods/mod_load_order). MOD_LOADER_DIR is an INTERNAL global set by the + * (DMF/mods/mods.lst). MOD_LOADER_DIR is an INTERNAL global set by the * trampoline (not a user env var/flag). (In the production call site * `mod_loader_dir` is also the prefix of `entry_path`, so it appears twice in * the chunk — once as the global, once inside the io.open path. That is diff --git a/enginseer/shell/src/dllmain.c b/enginseer/shell/src/dllmain.c index cb3b7edf..4e8432d3 100644 --- a/enginseer/shell/src/dllmain.c +++ b/enginseer/shell/src/dllmain.c @@ -232,7 +232,7 @@ static volatile int g_chunk_done = 0; /* one-shot: chunk-injection test p * launcher). REQUIRED: if the DLL path can't be resolved, staging logs why * and the trampoline is SKIPPED. * - MOD_PATH_ENV (DARKTIDE_MOD_PATH): the mod dir — where DMF + user mods + - * mod_load_order live. User/mod-manager-controlled. OPTIONAL: if unset, the + * mods.lst live. User/mod-manager-controlled. OPTIONAL: if unset, the * chunk emits an empty MAGOS_MOD_PATH and mods just won't load. * The entry path = \mod_loader\init.lua (joined + baked below). */ #define MOD_PATH_ENV "DARKTIDE_MOD_PATH" /* mod root dir (user/mod-manager-controlled; optional) */