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