diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..cab56c47 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -290,5 +290,38 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +## Process-lifecycle hooks + +These exist so a platform-specific launcher integration (a native shell +that embeds this engine and wraps its window in platform UI) can live +entirely in a mod instead of hand-patching `main.lua`, which every other +engine change also touches. + +`core.update` receives `(next, game, dt)` once per frame from +`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A +mod may skip calling `next(game, dt)` to pause the simulation for that +frame (e.g. while a native settings sheet is on top), and may run +additional per-frame polling before or after that call regardless of +whether it calls `next` -- useful for one-shot flags that must be observed +every frame even while paused. + +`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()` +returns the engine's own decision for whether closing the window should +return to the Lua launcher instead of exiting; a mod may return `false` +outright, without ever calling `next`, to veto that and let the process +really quit -- for a platform host that owns its own "return to launcher" +UI and would otherwise get looped straight back into the game it just +quit. + +A manifest may also declare `force_enable_env`, an environment variable +name that re-enables the mod regardless of a saved disable in +`options.mods` when that variable is set to `"1"`. This is for a mod that +cannot function disabled on the one build where its env var is set (a +platform-bridge mod bundled only with that build's launcher, for example). + +Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` +already falls straight through to the vanilla function when no mod has +wrapped the name, at negligible cost. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/rfcs/0006-platform-lifecycle-hooks.md b/docs/rfcs/0006-platform-lifecycle-hooks.md new file mode 100644 index 00000000..9763fed1 --- /dev/null +++ b/docs/rfcs/0006-platform-lifecycle-hooks.md @@ -0,0 +1,107 @@ +# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations + +## Status + +Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`, +`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`, +`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`. + +## Motivation + +A platform-specific launcher wrapper -- a native shell that embeds this +engine and owns its own UI around the game window (a mobile app shell, +say, presenting its own settings/import/save screens and only handing +control to the LÖVE window once play starts) needs three things no +current hook covers: + +1. Pause the simulation while its own UI is on top of the game window. +2. Live-reload options it wrote from outside any Lua UI. +3. Veto `main.lua`'s "closing the window returns to the Lua launcher" + behavior when the platform shell owns that job itself -- without this, + a shell that re-fronts its own launcher UI on quit gets looped straight + back into `HostShell.restart()`'s in-process reboot instead. + +Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit` +directly ties every such integration to editing the one file every other +engine change also touches, guaranteeing merge conflicts for any second +platform integration (or any unrelated engine PR landing around the same +time). No existing hook covers "should the per-frame simulation step run" +or "should closing the window return to the Lua launcher." + +## The decision it extends + +No prior D-number. Extends the hook-contract section of `docs/modding.md` +alongside `input.step`, `render.hud`, `screen.render_visible`, etc. + +## The exact API delta + +Backward-compatible, additive-only. + +### `core.update` + +New hook, `(game, dt) -> nil` through the public wrapper signature +`(next, game, dt)`, called once per frame from `love.update` via +`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla +behavior (used when no mod claims the hook) is `game:update(dt)`, +unconditionally -- identical to `love.update`'s behavior before this hook +existed. A subscriber may skip calling `next(game, dt)` to pause the +simulation for that frame, or do additional per-frame work before/after +calling it regardless of whether it calls `next`. + +### `core.quit_to_launcher` + +New hook, `() -> boolean` through the public wrapper signature `(next)`, +called once from `love.quit()` via +`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing +non-platform-specific decision (`Game and not Importer and not +quitToLauncher and not scripted and not launchedIntoGame`). A subscriber +may return `false` outright to veto returning to the Lua launcher (without +ever calling `next`, so the vanilla condition is never evaluated), or call +`next()` and return its result to pass the vanilla decision through +unchanged. + +Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally +every call, matching the existing `input.step` precedent +(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare +`vanilla(...)` call when no mod has wrapped the name. + +### `Manifest.force_enable_env` + +New optional manifest field, a bare env-var name. `Loader:load` re-enables +a mod carrying this field whenever that variable is set to `"1"`, +regardless of a saved disable in `options.mods`. This exists for exactly +the mod class this RFC is for: a platform-bridge mod that ships only with +one build and cannot function disabled there, but must still behave like +every other mod (a manifest opt-in, not an engine special case) on every +build that doesn't set its variable. + +## Migration note for existing mods + +**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)` +unconditionally every frame and `love.quit()`'s restart-to-launcher +decision is exactly the pre-existing condition -- bit-identical to today's +behavior on every platform where no mod wraps either hook. A manifest with +no `force_enable_env` field behaves exactly as before. + +## Parity tests + +- **No-mod:** `core.update`'s vanilla runs exactly once per call with the + hook chain empty; `core.quit_to_launcher`'s vanilla return value passes + through unchanged. Both hooks are picked up automatically by the + catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans + for `Runtime.call("...")` call sites), so neither needs a dedicated + no-mod test file. +- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves, + through a fixture mod loaded via the public loader (not the engine's + internals), that a subscriber can skip the vanilla update call (pause), + run extra per-frame polling regardless of pause state, and veto the + quit-to-launcher decision without the vanilla condition ever running. +- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover + `force_enable_env`: a matching env var re-enables a mod saved as + disabled, and an unset one leaves the saved disable alone. + +## Deprecation etiquette + +Nothing deprecated. These are two additive hooks and one additive manifest +field; `main.lua`'s only footprint is one `require` and two call sites +into `src/core/PlatformHooks.lua`. diff --git a/main.lua b/main.lua index cb57bc37..f8c349e8 100644 --- a/main.lua +++ b/main.lua @@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE = local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") +local PlatformHooks = require("src.core.PlatformHooks") -- Lua errors: persist a redacted trace in the save dir and surface a hint. do @@ -426,7 +427,11 @@ function love.update(dt) end return end - Game:update(dt) + -- Mods may wrap or veto the per-frame simulation step (pause it, react + -- to external platform state, etc.) -- see docs/modding.md's core.update + -- entry. Vanilla behavior (used when no mod claims the hook) is just + -- Game:update(dt), unconditionally, exactly as before this hook existed. + PlatformHooks.update(Game, dt) end function love.draw() @@ -819,8 +824,16 @@ function love.quit() or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM") -- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go -- back to and the restart would re-read the shortcut, so it exits instead. - if Game and not Importer and not quitToLauncher and not scripted - and not launchedIntoGame then + -- + -- A platform launcher that owns "return to launcher" itself (see + -- docs/modding.md's core.quit_to_launcher entry) may veto returning to + -- this Lua launcher via that hook. Vanilla behavior (used when no mod + -- claims the hook) is exactly the condition below. + local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function() + return Game and not Importer and not quitToLauncher and not scripted + and not launchedIntoGame + end) + if wouldReturnToLauncher then quitToLauncher = true -- Tell the fresh boot to ignore any boot-straight-into-a-game option this -- once, so the restart really does land in the launcher (#887). A failed diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 00000000..77bf83f5 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -0,0 +1,18 @@ +-- Generic process-lifecycle mod hooks so a platform-specific launcher +-- integration (a native shell embedding this engine, e.g. wrapping the +-- window in a platform UI) can live entirely in a mod instead of +-- hand-patching main.lua, which every other engine change also touches. +-- See docs/modding.md's "Process-lifecycle hooks" section. +local ModRuntime = require("src.mods.Runtime") + +local PlatformHooks = {} + +function PlatformHooks.update(game, dt) + return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt) +end + +function PlatformHooks.quitToLauncher(vanilla) + return ModRuntime.call("core.quit_to_launcher", vanilla) +end + +return PlatformHooks diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index daca6548..fa6f6a34 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -951,6 +951,16 @@ function Loader:load(data) end end end + -- A manifest may name an env var that force-enables it regardless of a + -- saved disable in options.mods -- generic, not tied to any mod id, for + -- a mod (e.g. a native-launcher bridge) that cannot function disabled on + -- the one build where its env var is set. + for id, mod in pairs(self.mods) do + local envName = mod.manifest.force_enable_env + if envName and os.getenv(envName) == "1" then + self.disabled[id] = nil + end + end for id, mod in pairs(self.mods) do mod.enabled = not self.disabled[id] mod.state = mod.enabled and "pending" or "disabled" diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 97bed0ac..9d1378a6 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -229,6 +229,7 @@ function Manifest.validate(raw, path) permissionSet = permissionSet, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), + force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"), path = path, raw = raw, } diff --git a/tests/mod_loader_tests.lua b/tests/mod_loader_tests.lua index 4b4633a6..e14d2b78 100644 --- a/tests/mod_loader_tests.lua +++ b/tests/mod_loader_tests.lua @@ -271,6 +271,38 @@ do "and nothing is disabled off a failed migration") end +-- ------- force_enable_env: an env var can override a saved disable +-- (src/mods/Loader.lua's enable-resolution block, added for a mod that +-- cannot function disabled on the one build where its env var is set -- +-- e.g. a platform-launcher bridge mod). +do + local forceFiles = { + ["options.lua"] = "return { mods = { forced = false } }", + ["mods/forced/manifest.json"] = + [[{"id":"forced","name":"forced","version":"1.0.0","entry":"main.lua",]] + .. [["force_enable_env":"SOME_TEST_ENV"}]], + ["mods/forced/main.lua"] = "return function(mod) end", + } + + local realGetenv = os.getenv + os.getenv = function(name) + if name == "SOME_TEST_ENV" then return "1" end + return realGetenv(name) + end + local onLoader = Loader.new({ fs = memfs(forceFiles) }) + check(onLoader:load({ pokemon = {} }) == true, + "force_enable_env: load succeeds with the env var set") + check(onLoader.mods.forced.enabled == true, + "a matching force_enable_env re-enables a mod saved as disabled") + os.getenv = realGetenv + + local offLoader = Loader.new({ fs = memfs(forceFiles) }) + check(offLoader:load({ pokemon = {} }) == true, + "force_enable_env: load succeeds with the env var unset") + check(offLoader.mods.forced.enabled == false, + "with the env var unset, the saved disable is left alone") +end + -- leave shared singletons the way we found them for later chained tests local StateStack = require("src.core.StateStack") while StateStack:top() do StateStack:pop() end diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index d6c615e9..569479d2 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -105,6 +105,7 @@ local full = Manifest.validate({ api = 2, profile = "overhaul", permissions = { "network" }, dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" }, options_schema = "options.lua", assets_transforms = "transforms.lua", + force_enable_env = "SOME_ENV", }, "mods/full") check(full.api == 2 and full.profile == "overhaul", "api and profile parse") check(full.affects_link == true, "overhaul defaults to affecting link play") @@ -115,6 +116,7 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range "a bare conflict entry has no range") check(full.options_schema == "options.lua" and full.assets_transforms == "transforms.lua", "declared files are kept") +check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept") -- ------- github / experimental / incompatible local gh = Manifest.validate({ diff --git a/tests/modkit/cases/platform_lifecycle_hooks.lua b/tests/modkit/cases/platform_lifecycle_hooks.lua new file mode 100644 index 00000000..88d95336 --- /dev/null +++ b/tests/modkit/cases/platform_lifecycle_hooks.lua @@ -0,0 +1,93 @@ +-- core.update / core.quit_to_launcher through the public mod API: a +-- platform-launcher integration can pause the simulation and veto the +-- return-to-launcher decision from a mod, with no main.lua patch. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local PlatformHooks = require("src.core.PlatformHooks") + +local FIXTURE = { + ["mods/fix_platform_bridge/manifest.json"] = [[{ + "id": "fix_platform_bridge", + "name": "Fixture Platform Bridge", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_platform_bridge/main.lua"] = [[ + local mod = ... + local paused = false + local extraPolls = 0 + mod.hooks:wrap("core.update", function(nextFn, game, dt) + extraPolls = extraPolls + 1 + if not paused then nextFn(game, dt) end + end) + mod.hooks:wrap("core.quit_to_launcher", function(nextFn) + if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end + return nextFn() + end) + -- test-only knobs, read back through mod.storage-free globals since + -- this fixture never leaves the process + _G.__fixturePlatformBridge = { + setPaused = function(v) paused = v end, + extraPolls = function() return extraPolls end, + } + ]], +} + +-- core.update: a subscriber can pause (skip vanilla) and still run every frame +do + local run = T.sdk.loadMods({ "mods/fix_platform_bridge" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + + local calls = 0 + local fakeGame = { update = function(self, dt) calls = calls + 1 end } + + _G.__fixturePlatformBridge.setPaused(false) + PlatformHooks.update(fakeGame, 1 / 60) + T.eq(calls, 1, "unpaused: vanilla Game:update runs") + T.eq(_G.__fixturePlatformBridge.extraPolls(), 1, + "the subscriber's wrapper runs every frame") + + _G.__fixturePlatformBridge.setPaused(true) + PlatformHooks.update(fakeGame, 1 / 60) + T.eq(calls, 1, "paused: vanilla Game:update is skipped") + T.eq(_G.__fixturePlatformBridge.extraPolls(), 2, + "the subscriber keeps polling every frame while paused") + + run.release() + _G.__fixturePlatformBridge = nil +end + +-- core.quit_to_launcher: a subscriber can veto without the vanilla +-- condition ever running, or pass it through unchanged +do + local run = T.sdk.loadMods({ "mods/fix_platform_bridge" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + + local realGetenv = os.getenv + os.getenv = function(name) + if name == "FIXTURE_VETO_QUIT" then return "1" end + return realGetenv(name) + end + local vanillaCalls = 0 + local vetoed = PlatformHooks.quitToLauncher(function() + vanillaCalls = vanillaCalls + 1 + return true + end) + T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision") + T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition") + os.getenv = realGetenv + + local passed = PlatformHooks.quitToLauncher(function() return true end) + T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged") + + run.release() +end + +T.finish("platform_lifecycle_hooks")