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.
- `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
that reach outside their permission set.
+11 -1
View File
@@ -169,6 +169,11 @@ function Game:returnToTitle()
end
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()
-- serviced unconditionally: a link battle's ENet transport must not
-- 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.
-- Options are flushed to options.lua as part of SaveData.save.
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
self.overworld:captureSave(self.save)
end
@@ -486,7 +496,7 @@ function Game:writeSave()
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
SaveData.save(self.save)
return SaveData.save(self.save)
end
-- 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
game.overworld.lastOutdoor = ctx.save.lastOutdoor
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;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
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)
runner:yield()
+11
View File
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local Runtime = require("src.mods.Runtime")
local Logger = require("src.core.Logger")
local TitleState = {}
TitleState.__index = TitleState
@@ -136,6 +138,8 @@ local function hasSave()
return ok and info ~= nil
end
local function sameItems(_, items) return items end
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
@@ -211,6 +215,13 @@ function TitleState:openMenu()
love.event.quit()
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 menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- 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")