From aab980b05ac3002142eac30fc5c768931187d7c0 Mon Sep 17 00:00:00 2001 From: Kevin Jacobson Date: Tue, 28 Jul 2026 13:33:53 -0700 Subject: [PATCH 1/3] Add lifecycle hooks for tool mods --- docs/modding.md | 14 +++++ src/core/Game.lua | 12 +++- src/script/Commands.lua | 8 ++- src/ui/TitleState.lua | 11 ++++ tests/engine/tool_mod_hooks.lua | 92 +++++++++++++++++++++++++++++++ tests/engine/tool_save_safety.lua | 46 ++++++++++++++++ 6 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 tests/engine/tool_mod_hooks.lua create mode 100644 tests/engine/tool_save_safety.lua diff --git a/docs/modding.md b/docs/modding.md index 18955af2..a9d0d38d 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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. diff --git a/src/core/Game.lua b/src/core/Game.lua index 1bad3b9f..a2709bb6 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 25022d57..1be48be4 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -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() diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 979d24d7..9564516f 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -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 diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua new file mode 100644 index 00000000..d53ffb2e --- /dev/null +++ b/tests/engine/tool_mod_hooks.lua @@ -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") diff --git a/tests/engine/tool_save_safety.lua b/tests/engine/tool_save_safety.lua new file mode 100644 index 00000000..31ae39a7 --- /dev/null +++ b/tests/engine/tool_save_safety.lua @@ -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") From d09cf80da597a82ec95bf75a63a2944fed72cea0 Mon Sep 17 00:00:00 2001 From: Kevin Jacobson Date: Tue, 28 Jul 2026 13:58:42 -0700 Subject: [PATCH 2/3] Add persistent HUD hook for tool mods --- docs/modding.md | 5 +++++ src/core/Game.lua | 8 ++++++++ tests/engine/tool_mod_hooks.lua | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/docs/modding.md b/docs/modding.md index a9d0d38d..7ffa7b13 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -164,5 +164,10 @@ 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 visible states draw and +before the UI canvas is presented. `viewport.width` and `viewport.height` are +the active UI dimensions, so status indicators can stay visible over normal +and widescreen screens without pushing an updating game state. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/src/core/Game.lua b/src/core/Game.lua index a2709bb6..9a447adb 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -255,6 +255,14 @@ function Game:draw() end Renderer:beginFrame(worldBelow) self.stack:draw() + -- Persistent tool status belongs above every game state but inside the + -- active UI canvas, so it composes with palette/render mods and survives + -- menus, battles, and transitions without becoming an updating state. + if ModRuntime.wantsHook("render.hud") then + local width, height = Renderer:uiSize() + ModRuntime.call("render.hud", function() end, self, + { width = width, height = height }) + end -- SGB colorization: the topmost state that knows its palette owns the -- screen (overlays like text boxes inherit from what's beneath them); -- the overworld's world pass colors each visible map area separately diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index d53ffb2e..4e0f5e52 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -86,6 +86,42 @@ do hooks:removeOwner("tool_fixture") end +-- A tool status indicator must draw after every visible game state but before +-- the renderer presents that frame. This keeps the HUD visible over the +-- overworld, menus, battles, and compatible render pipelines. +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" 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, 160, "render.hud receives the UI width") + T.eq(viewport.height, 144, "render.hud receives the UI height") + order[#order + 1] = "hud" + return nextFn(game, viewport) + end, 0, "tool_fixture") + + require("src.core.Game").draw(fake) + T.eq(table.concat(order, ","), "states,hud,present,touch", + "render.hud draws over states before frame presentation") + 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 From fb58fa788b77cf07c01c249a63f259b7d53b0707 Mon Sep 17 00:00:00 2001 From: Kevin Jacobson Date: Tue, 28 Jul 2026 14:09:46 -0700 Subject: [PATCH 3/3] Expose window margins to tool HUDs --- docs/modding.md | 9 +++++---- src/core/Game.lua | 16 +++++++--------- src/render/Renderer.lua | 7 +++++++ tests/engine/tool_mod_hooks.lua | 26 ++++++++++++++++++-------- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 7ffa7b13..972cb298 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -164,10 +164,11 @@ 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 visible states draw and -before the UI canvas is presented. `viewport.width` and `viewport.height` are -the active UI dimensions, so status indicators can stay visible over normal -and widescreen screens without pushing an updating game state. +`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. diff --git a/src/core/Game.lua b/src/core/Game.lua index 9a447adb..2df3e5ca 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -255,14 +255,6 @@ function Game:draw() end Renderer:beginFrame(worldBelow) self.stack:draw() - -- Persistent tool status belongs above every game state but inside the - -- active UI canvas, so it composes with palette/render mods and survives - -- menus, battles, and transitions without becoming an updating state. - if ModRuntime.wantsHook("render.hud") then - local width, height = Renderer:uiSize() - ModRuntime.call("render.hud", function() end, self, - { width = width, height = height }) - end -- SGB colorization: the topmost state that knows its palette owns the -- screen (overlays like text boxes inherit from what's beneath them); -- the overworld's world pass colors each visible map area separately @@ -282,7 +274,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 diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 099f4b56..87f60bcd 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -706,6 +706,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 diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index 4e0f5e52..5cb3d8a4 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -86,9 +86,9 @@ do hooks:removeOwner("tool_fixture") end --- A tool status indicator must draw after every visible game state but before --- the renderer presents that frame. This keeps the HUD visible over the --- overworld, menus, battles, and compatible render pipelines. +-- 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") @@ -98,7 +98,15 @@ do local order = {} Renderer.setUISize = function() end Renderer.beginFrame = function() end - Renderer.endFrame = function() order[#order + 1] = "present" 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 = {} } } @@ -108,15 +116,17 @@ do hooks:wrap("render.hud", function(nextFn, game, viewport) T.check(game == fake, "render.hud receives the live Game object") - T.eq(viewport.width, 160, "render.hud receives the UI width") - T.eq(viewport.height, 144, "render.hud receives the UI height") + 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,hud,present,touch", - "render.hud draws over states before frame presentation") + 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