mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks
Add lifecycle and HUD hooks for tool mods
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user