Compare commits

...

4 Commits

Author SHA1 Message Date
bryanthaboi 9de4db8531 Merge pull request #882 from bryanthaboi/dev 2026-08-06 05:17:04 -04:00
bryanthaboi 1d77d42fe2 Merge pull request #881 from AverageConsumer/codex/upstream-screen-render-visible
Mod API: allow active screen states to be hidden from the main render
2026-08-05 17:13:14 -04:00
github-actions 683fee8028 chore(ios): update app-repo.json [skip ci] 2026-08-05 16:49:41 -04:00
AverageConsumer b9e8b00af0 feat(mods): add screen render visibility hook 2026-08-05 22:42:37 +02:00
6 changed files with 189 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.
+7
View File
@@ -12,6 +12,13 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.72",
"date": "2026-08-05",
"size": 9586678,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Wont Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.71",
"date": "2026-08-05",
+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")