Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks

Add lifecycle and HUD hooks for tool mods
This commit is contained in:
bryanthaboi
2026-07-31 10:29:04 -04:00
committed by GitHub
7 changed files with 246 additions and 4 deletions
+20
View File
@@ -167,5 +167,25 @@ 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.
`render.hud` receives `(next, game, viewport)` after the finished game frame is
composited and before touch controls draw. The window-space viewport contains
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+18 -2
View File
@@ -172,6 +172,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
@@ -339,7 +344,13 @@ function Game:draw()
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
local viewport = Renderer:endFrame(zones, worldZones)
-- Persistent tool status is screen-space UI: draw it over the completed
-- render pipeline with exact playfield/margin geometry, but below mobile
-- controls. It never becomes an updating game state.
if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport)
end
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
@@ -578,6 +589,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
@@ -588,7 +604,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
+7
View File
@@ -720,6 +720,13 @@ function Renderer:endFrame(zones, worldZones)
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp,
dpiX = dpiX, dpiY = dpiY,
}
end
return Renderer
+6 -2
View File
@@ -734,11 +734,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
@@ -300,6 +302,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.
@@ -375,6 +379,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
+138
View File
@@ -0,0 +1,138 @@
-- 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
-- A tool status indicator draws in window space after the renderer composites
-- the game. This gives it exact playfield/margin geometry and keeps it crisp
-- over compatible render pipelines without entering the game canvas.
do
local Renderer = require("src.render.Renderer")
local TouchControls = require("src.core.TouchControls")
local savedSetUISize, savedBegin, savedEnd, savedTouch =
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
TouchControls.draw
local order = {}
Renderer.setUISize = function() end
Renderer.beginFrame = function() end
Renderer.endFrame = function()
order[#order + 1] = "present"
return {
width = 1024, height = 768,
gameX = 112, gameY = 24,
gameWidth = 800, gameHeight = 720,
scale = 5,
}
end
TouchControls.draw = function() order[#order + 1] = "touch" end
local fake = { overworld = {}, stack = { states = {} } }
function fake.stack:visibleBase() return 1 end
function fake.stack:top() return {} end
function fake.stack:draw() order[#order + 1] = "states" end
hooks:wrap("render.hud", function(nextFn, game, viewport)
T.check(game == fake, "render.hud receives the live Game object")
T.eq(viewport.width, 1024, "render.hud receives the window width")
T.eq(viewport.height, 768, "render.hud receives the window height")
T.eq(viewport.gameX, 112, "render.hud receives the playfield origin")
T.eq(viewport.gameWidth, 800, "render.hud receives the playfield width")
order[#order + 1] = "hud"
return nextFn(game, viewport)
end, 0, "tool_fixture")
require("src.core.Game").draw(fake)
T.eq(table.concat(order, ","), "states,present,hud,touch",
"render.hud draws after frame composition and before touch controls")
hooks:removeOwner("tool_fixture")
Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame,
TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch
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")