Add core.update/core.quit_to_launcher platform lifecycle hooks (RFC 0006)

A platform-specific launcher wrapper (a native shell embedding this engine,
owning its own UI around the game window) needs to pause the simulation
while its own UI is on top, live-reload options it wrote outside any Lua
UI, and veto main.lua's "closing the window returns to the Lua launcher"
behavior when it owns that job itself. 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. No existing hook covers "should the per-frame simulation
step run" or "should closing the window return to the Lua launcher."

Adds two generic, additive hooks (src/core/PlatformHooks.lua): core.update
and core.quit_to_launcher, replacing what would otherwise be inline
main.lua special-casing. Also adds Manifest.force_enable_env, letting 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) re-enable itself
regardless of a saved disable.

RFC 0006 status: Proposed.
This commit is contained in:
david
2026-08-09 20:05:06 -07:00
parent 943ba5dcbf
commit e79107c644
9 changed files with 312 additions and 3 deletions
+33
View File
@@ -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.
+107
View File
@@ -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`.
+16 -3
View File
@@ -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
+18
View File
@@ -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
+10
View File
@@ -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"
+1
View File
@@ -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,
}
+32
View File
@@ -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
+2
View File
@@ -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({
@@ -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")