Add lifecycle hooks for tool mods

This commit is contained in:
Kevin Jacobson
2026-07-28 13:33:53 -07:00
parent b69f8c27c9
commit aab980b05a
6 changed files with 180 additions and 3 deletions
+14
View File
@@ -150,5 +150,19 @@ The console understands these verbs (anything else is evaluated as Lua):
- `trace PAT | trace off` — trace events/hooks matching a glob pattern. - `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs. - `help` — list the verbs.
## Tool input and title-menu hooks
Tool mods that need to act once per game logic tick can wrap `input.step`.
It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`.
`ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins.
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
progress write before world state is captured or any bytes reach disk.
Developer mode also arms the mod loader's dev tripwire, which flags mods Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set. that reach outside their permission set.
+11 -1
View File
@@ -169,6 +169,11 @@ function Game:returnToTitle()
end end
function Game:step(dt) function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
-- Input:step promotes queued edges so a button chosen here is visible to
-- this logic tick, not one tick later. With no wrapper this is a no-op.
ModRuntime.call("input.step", function() end, self, dt)
self.input:step() self.input:step()
-- serviced unconditionally: a link battle's ENet transport must not -- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily -- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
@@ -476,6 +481,11 @@ end
-- Capture the live world state into the save table and persist it. -- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save. -- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave() function Game:writeSave()
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
-- before captureSave mutates the snapshot or any progress bytes reach disk.
if ModRuntime.call("save.write", function() return true end, self) == false then
return false
end
if self.overworld and self.overworld.captureSave then if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save) self.overworld:captureSave(self.save)
end end
@@ -486,7 +496,7 @@ function Game:writeSave()
if ModRuntime.wants("save.writing") then if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta }) ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end end
SaveData.save(self.save) return SaveData.save(self.save)
end end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings -- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
+6 -2
View File
@@ -676,11 +676,15 @@ function Commands.record_hall_of_fame(ctx)
if game.overworld then if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor game.overworld.lastOutdoor = ctx.save.lastOutdoor
end end
if game.writeSave then game:writeSave() end local saveAllowed = true
if game.writeSave then saveAllowed = game:writeSave() ~= false end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords; -- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom. -- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot) SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save) -- A tool session may veto Game:writeSave to keep its in-memory run
-- isolated from the player's real save. The relocation write is part
-- of that same save operation and must honor the same decision.
if saveAllowed then SaveData.save(ctx.save) end
end) end)
end) end)
runner:yield() runner:yield()
+11
View File
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Runtime = require("src.mods.Runtime")
local Logger = require("src.core.Logger")
local TitleState = {} local TitleState = {}
TitleState.__index = TitleState TitleState.__index = TitleState
@@ -136,6 +138,8 @@ local function hasSave()
return ok and info ~= nil return ok and info ~= nil
end end
local function sameItems(_, items) return items end
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo): -- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing -- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
-- CONTINUE. A confirms and loads the game, B returns to the main menu. -- CONTINUE. A confirms and loads the game, B returns to the main menu.
@@ -211,6 +215,13 @@ function TitleState:openMenu()
love.event.quit() love.event.quit()
end end
end }) end })
local hooked = Runtime.call("ui.title_menu.items", sameItems, game, items)
if type(hooked) == "table" then
items = hooked
else
Logger.error("ui.title_menu.items returned %s; keeping the vanilla items",
type(hooked))
end
local th = #items * 2 + 2 local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes -- full-width title LOGO zones would recolor this box; see sgbPalettes
+92
View File
@@ -0,0 +1,92 @@
-- Public seams a tool mod needs: a fixed-step input hook and a
-- title-menu entry point. Both are no-ops without a mod (gate_hooks.lua
-- covers that parity); this case proves a real public wrapper can act at the
-- correct point and decorate the real title menu.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
local hooks = Hooks.new()
Runtime.hooks = hooks
-- If input.step disappears, or moves after Input:step, a bot's buttons land
-- one logic tick late. Exercise Game:step itself and pin the observable
-- ordering rather than merely checking that a hook was registered.
do
local order = {}
local fake = {
input = { step = function() order[#order + 1] = "input" end },
stack = { update = function(_, dt)
order[#order + 1] = "world"
T.eq(dt, 1 / 60, "the world receives the fixed-step dt")
end },
save = {},
}
hooks:wrap("input.step", function(nextFn, game, dt)
T.check(game == fake, "input.step receives the live Game object")
T.eq(dt, 1 / 60, "input.step receives the fixed-step dt")
order[#order + 1] = "hook"
return nextFn(game, dt)
end, 0, "tool_fixture")
require("src.core.Game").step(fake, 1 / 60)
T.eq(table.concat(order, ","), "hook,input,world",
"input.step runs before input edges are promoted and before gameplay")
hooks:removeOwner("tool_fixture")
end
-- If ui.title_menu.items disappears, a tool mod can load but has no
-- safe user-facing way to begin a fresh, non-destructive run.
do
local TitleState = require("src.ui.TitleState")
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:top() return self.states[#self.states] end
local game = {
data = { field = { title = { cycleSpecies = { "MEW" } } },
pokemon = { MEW = {} } },
stack = stack,
}
hooks:wrap("ui.title_menu.items", function(nextFn, liveGame, items)
T.check(liveGame == game, "ui.title_menu.items receives the live Game")
table.insert(items, #items, { label = "AUTOPLAY" })
return nextFn(liveGame, items)
end, 0, "tool_fixture")
TitleState.new(game, {}):openMenu()
local menu = stack:top()
T.eq(menu.items[#menu.items - 1].label, "AUTOPLAY",
"ui.title_menu.items can insert AUTOPLAY before EXIT GAME")
hooks:removeOwner("tool_fixture")
end
-- A viewer/AI session must be able to veto every normal progress-save path,
-- including the in-game SAVE menu and autosaves, before captureSave mutates
-- the live snapshot in preparation for disk IO.
do
local captured = false
local fake = {
save = {},
overworld = { captureSave = function() captured = true end },
}
hooks:wrap("save.write", function(nextFn, game)
T.check(game == fake, "save.write receives the live Game object")
return false
end, 0, "tool_fixture")
local saved = require("src.core.Game").writeSave(fake)
T.eq(saved, false, "save.write can veto progress persistence")
T.eq(captured, false, "a veto happens before save-state capture")
hooks:removeOwner("tool_fixture")
end
Runtime.events, Runtime.hooks, Runtime.errors =
savedEvents, savedHooks, savedErrors
T.finish("tool_mod_hooks")
+46
View File
@@ -0,0 +1,46 @@
-- End-game persistence must honor a Game:writeSave veto so an ephemeral tool
-- session cannot overwrite the player's real save while THE END is on screen.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local Commands = require("src.script.Commands")
local SaveData = require("src.core.SaveData")
local Screens = require("src.ui.Screens")
local originalPush, originalSave = Screens.push, SaveData.save
local screens = {}
local directWrites = 0
Screens.push = function(_, id, onDone, onTheEnd)
screens[id] = { onDone = onDone, onTheEnd = onTheEnd }
return screens[id]
end
SaveData.save = function()
directWrites = directWrites + 1
return true
end
local save = SaveData.newGame()
save.party = {}
local game = {
data = { field = { boot = {} } },
save = save,
writeSave = function() return false end,
}
local runner = { yield = function() coroutine.yield() end }
local ctx = { game = game, save = save, runner = runner }
local co = coroutine.create(function() Commands.record_hall_of_fame(ctx) end)
local ok, err = coroutine.resume(co)
T.check(ok, "Hall of Fame command reaches its UI yield: " .. tostring(err))
T.check(screens.HallOfFame ~= nil, "Hall of Fame induction was requested")
screens.HallOfFame.onDone()
T.check(screens.Credits ~= nil, "credits were requested after induction")
screens.Credits.onTheEnd()
T.eq(directWrites, 0,
"a vetoed Hall of Fame autosave performs no fallback disk write")
Screens.push, SaveData.save = originalPush, originalSave
T.finish("tool_save_safety")