feat(mods): add screen render visibility hook

This commit is contained in:
AverageConsumer
2026-08-05 22:42:37 +02:00
parent e2820e02c5
commit b9e8b00af0
5 changed files with 182 additions and 4 deletions
+7
View File
@@ -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.
+54
View File
@@ -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.
+6 -2
View File
@@ -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
+14 -2
View File
@@ -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
@@ -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")