From b9e8b00af0ce3560235fe0c0b4d757d475b09415 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:42:37 +0200 Subject: [PATCH] feat(mods): add screen render visibility hook --- docs/modding.md | 7 ++ docs/rfcs/0002-screen-render-visible.md | 54 ++++++++++ src/core/Game.lua | 8 +- src/core/StateStack.lua | 16 ++- tests/modkit/cases/screen_render_visible.lua | 101 +++++++++++++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 docs/rfcs/0002-screen-render-visible.md create mode 100644 tests/modkit/cases/screen_render_visible.lua diff --git a/docs/modding.md b/docs/modding.md index 69035af3..31721d9c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -226,5 +226,12 @@ for driving a second physical display. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. +`screen.render_visible` receives `(next, state)` while the main screen is being +composed. Return `false` to omit that state from drawing, opacity selection and +palette-zone ownership. The state remains on the stack and keeps its normal +update and input ownership, so a mod can mirror a native menu on another +display without reimplementing it. The default is `true`. Treat the wrapper as +a pure predicate: the renderer may ask it more than once per frame. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/rfcs/0002-screen-render-visible.md b/docs/rfcs/0002-screen-render-visible.md new file mode 100644 index 00000000..da546bde --- /dev/null +++ b/docs/rfcs/0002-screen-render-visible.md @@ -0,0 +1,54 @@ +# RFC 0002 — Let mods hide an active screen state from the main render + +## Status + +Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests: +`screen_render_visible.lua`. + +## Motivation + +A mod can render a native menu on a companion display through +`render.compose`, but it cannot remove that menu from the main display without +also popping it. Popping transfers update and input ownership and forces the +mod to reimplement native menu behavior. + +## The decision it extends + +No prior D-number. Extends the render-hook plan in `docs/modding.md` and the +state-stack rendering contract in `docs/architecture.md`. + +## The exact API delta + +Backward-compatible, additive-only. + +### `screen.render_visible` + +New hook called with `(state) -> boolean` through the public wrapper signature +`(next, state)`. Its vanilla result is `true`. + +Returning `false` excludes the state from the main draw, from opaque-base +selection and from palette-zone ownership. It does not remove the state or +change update, input, push or pop behavior. The call sites are +`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette +walks in `Game:draw`. + +The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates +nothing. It is a pure render predicate and may be evaluated more than once per +frame. + +## Migration note for existing mods + +**Nothing.** With no subscriber every state remains visible, and the existing +state-stack, event and hook behavior is unchanged. + +## Parity tests + +- **No-mod:** the topmost opaque state still owns drawing and palette zones, + and `Runtime.wantsHook("screen.render_visible")` stays false. +- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one + opaque state and proves the state beneath draws and owns the palette while + the hidden state remains topmost and continues updating. + +## Deprecation etiquette + +Nothing deprecated. This is one additive hook with a `true` vanilla default. diff --git a/src/core/Game.lua b/src/core/Game.lua index 11d86c5a..06856b91 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -16,6 +16,10 @@ local Screens = require("src.ui.Screens") local Game = {} +local function renderVisible(stack, state) + return state and (not stack.renderVisible or stack:renderVisible(state)) +end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -460,7 +464,7 @@ function Game:draw() local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() - if state and state.draw then + if renderVisible(self.stack, state) and state.draw then if classicOffset ~= 0 and not wideState then love.graphics.push() love.graphics.translate(classicOffset, 0) @@ -484,7 +488,7 @@ function Game:draw() local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] - if s.sgbPalettes then + if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index 898fd3dc..3a1995a7 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -39,17 +39,29 @@ function StateStack:update(dt) if top and top.update then top:update(dt) end end +local function visibleByDefault() return true end + +-- A mod may mirror a state elsewhere and hide only its main-screen render. +-- The state stays on the stack, so update and input ownership do not move. +function StateStack:renderVisible(state) + if not state then return false end + if not Runtime.wantsHook("screen.render_visible") then return true end + return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false +end + -- index of the lowest state drawn this frame (highest opaque, else 1) function StateStack:visibleBase() for i = #self.states, 1, -1 do - if self.states[i].isOpaque then return i end + local state = self.states[i] + if self:renderVisible(state) and state.isOpaque then return i end end return 1 end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + local state = self.states[i] + if self:renderVisible(state) and state.draw then state:draw() end end end diff --git a/tests/modkit/cases/screen_render_visible.lua b/tests/modkit/cases/screen_render_visible.lua new file mode 100644 index 00000000..eb8d246a --- /dev/null +++ b/tests/modkit/cases/screen_render_visible.lua @@ -0,0 +1,101 @@ +-- screen.render_visible through the public mod API: a mirrored native screen +-- may leave the main render without leaving the active state stack. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Game = require("src.core.Game") +local Runtime = require("src.mods.Runtime") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local TouchControls = require("src.core.TouchControls") + +local FIXTURE = { + ["mods/fix_screen_mirror/manifest.json"] = [[{ + "id": "fix_screen_mirror", + "name": "Fixture Screen Mirror", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_screen_mirror/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("screen.render_visible", function(nextFn, state) + if state.screenId == "BagMenu" then return false end + return nextFn(state) + end) + ]], +} + +local savedSetUISize, savedBegin, savedEnd, savedTouch = + Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame, + TouchControls.draw +local presentedZones +Renderer.setUISize = function() end +Renderer.beginFrame = function() end +Renderer.endFrame = function(_, zones) + presentedZones = zones + return {} +end +TouchControls.draw = function() end + +local function scene() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local base = { + isOpaque = true, + draws = 0, + draw = function(self) self.draws = self.draws + 1 end, + sgbPalettes = function() return "base zones" end, + } + local menu = { + screenId = "BagMenu", + isOpaque = true, + draws = 0, + updates = 0, + draw = function(self) self.draws = self.draws + 1 end, + update = function(self) self.updates = self.updates + 1 end, + sgbPalettes = function() return "menu zones" end, + } + stack:push(base) + stack:push(menu) + return { stack = stack, overworld = base, save = { options = {} } }, + base, menu +end + +-- no-mod parity +do + local run = T.sdk.loadNone({}) + local game, base, menu = scene() + T.eq(Runtime.wantsHook("screen.render_visible"), false, + "no subscriber leaves the render hook cold") + Game.draw(game) + T.eq(base.draws, 0, "the opaque menu still covers the state beneath") + T.eq(menu.draws, 1, "the opaque menu still draws") + T.eq(presentedZones, "menu zones", "the visible menu still owns palettes") + run.release() +end + +-- subscribed path, registered by a real fixture mod +do + local run = T.sdk.loadMods({ "mods/fix_screen_mirror" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + local game, base, menu = scene() + Game.draw(game) + T.eq(base.draws, 1, "the state beneath the hidden menu draws") + T.eq(menu.draws, 0, "the mirrored menu is omitted from the main draw") + T.eq(presentedZones, "base zones", + "a hidden state cannot own the main-screen palette") + T.check(game.stack:top() == menu, + "the hidden menu remains the active top state") + game.stack:update(1 / 60) + T.eq(menu.updates, 1, "the hidden menu keeps its update ownership") + run.release() +end + +Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame, + TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch + +T.finish("screen_render_visible")