Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,17 @@ Mods live in the **mod directory** you point `--mod-path` at. Lay it out as:

```
<mod-path>/
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>/ your mod(s)
```

- **DMF** (the Darktide Mod Framework) is the framework mods are built against;
place it at `<mod-path>/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

Expand Down
36 changes: 20 additions & 16 deletions docs/architecture/ENGINSEER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,9 +131,11 @@ exit. Sets `SteamAppId`/`SteamGameId`.
its own DLL path (`<dll-dir>\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
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
27 changes: 16 additions & 11 deletions docs/architecture/MOD_LOADER-DMF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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")`).
Expand Down Expand Up @@ -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)`;
Expand Down
3 changes: 2 additions & 1 deletion enginseer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion enginseer/mod_loader/file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions enginseer/mod_loader/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 6 additions & 6 deletions enginseer/mod_loader/lifecycle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 26 additions & 22 deletions enginseer/mod_loader/mod_manager.lua
Original file line number Diff line number Diff line change
@@ -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.
--
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
Expand Down
4 changes: 2 additions & 2 deletions enginseer/mod_loader/tests/mock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -177,7 +177,7 @@ end
-- every active module, keyed at <MOD_LOADER_ROOT>/<name>.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
Expand Down
Loading
Loading