Merge pull request #1242 from AverageConsumer/codex/final-frame-output-hooks

This commit is contained in:
bryanthaboi
2026-08-14 05:55:26 -04:00
committed by GitHub
7 changed files with 145 additions and 29 deletions
+4 -2
View File
@@ -547,11 +547,13 @@ gains a field instead of the name gaining a prefix.
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.letterbox`, `render.hud`. Each sits
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
letterbox, and the finished playfield rect -- and carries the same payload.
composed frame before GBCFX, the letterbox, and the finished playfield rect
-- and carries the same payload.
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
+14
View File
@@ -440,6 +440,20 @@ oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
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.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
layers. It runs after registered present pipelines and before GBCFX,
`render.hud`, and touch controls. A mod wraps both hooks: the first returns
`true` only while output ownership is needed, and the second receives
`(next, ctx)` with `canvas`, `width`, `height`, `gameX`, `gameY`, `gameWidth`,
`gameHeight`, `scale`, `dpiX`,
`dpiY`, and `generation`. Returning `true` from `render.output` takes over the
window; calling `next(ctx)` keeps the normal presentation. Both hooks default
to `false`. Enabling the seam requires a full-window canvas for that frame.
With no `render.output` subscriber, or while `render.output_enabled` is false,
the existing presentation path is unchanged. `render.compose` takes precedence
when it owns the frame.
`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
+26 -13
View File
@@ -73,7 +73,7 @@ end
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the six
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
-- hooks below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
@@ -83,6 +83,7 @@ end
-- input.pointer uncaptured pointer events (src/core/Game.lua:887)
-- render.zones the palette pass, pre-blit (src/core/Game.lua:505)
-- render.compose the whole-window composite (Renderer.lua:759)
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
--
@@ -1382,12 +1383,14 @@ function Game2:draw()
local zoned = type(zones) == "table" and zones[1] ~= nil
-- A present canvas is paid for only when something reads it: the zone pass,
-- GBC FX, a mod post-process, or a render.compose subscriber about to be
-- handed the finished frame. With none of them the frame draws straight to
-- the screen exactly as it always did.
-- GBC FX, a mod post-process, render.compose, or an enabled render.output
-- subscriber. With none of them the frame draws straight to the screen
-- exactly as it always did.
local composing = ModRuntime.wantsHook("render.compose")
local hasOutputHook = ModRuntime.wantsHook("render.output")
and ModRuntime.call("render.output_enabled", function() return false end) == true
local scene = nil
if zoned or fx or composing or Pipelines.wantsPresent() then
if zoned or fx or composing or Pipelines.wantsPresent() or hasOutputHook then
scene = self:presentCanvas(1, w, h)
end
if not scene then
@@ -1419,7 +1422,7 @@ function Game2:draw()
-- untinted one. On its own the tint rides the final blit and no second
-- canvas is paid for.
local source = scene
local reread = fx or Pipelines.wantsPresent()
local reread = fx or Pipelines.wantsPresent() or hasOutputHook
if zoned and reread then
local tinted = self:presentCanvas(2, w, h)
if tinted then
@@ -1439,15 +1442,25 @@ function Game2:draw()
-- Post-process pipelines run over the finished composite and before GBC
-- FX. Each hands back a canvas; with none registered this returns `source`
-- unchanged and the frame is byte-identical (Renderer.lua:1058).
local scale, _, _, dpi = self:frameFit(w, h)
local scale, ox, oy, dpi = self:frameFit(w, h)
source = Pipelines.present(source, { width = w, height = h, scale = scale,
dpi = dpi, dpiX = dpi, dpiY = dpi }) or source
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
local outputHandled = hasOutputHook
and ModRuntime.call("render.output", function() return false end, {
canvas = source, width = w, height = h,
gameX = ox, gameY = oy,
gameWidth = 160 * scale, gameHeight = 144 * scale,
scale = scale, dpiX = dpi, dpiY = dpi,
generation = 2,
}) == true
if not outputHandled then
if fx then
GBCFX.present(source, self:pixelScale(w, h))
else
G.setColor(1, 1, 1, 1)
G.draw(source, 0, 0)
G.setShader()
end
end
end
G.pop()
+25 -13
View File
@@ -768,11 +768,12 @@ function Renderer:endFrame(zones, worldZones)
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
-- Post-process pipelines, GBC FX and an enabled final-output owner need the
-- whole composite in a canvas. With none of them, the frame draws straight
-- to the screen exactly as it always did.
local hasOutputHook = Runtime.wantsHook("render.output")
and Runtime.call("render.output_enabled", function() return false end) == true
local needPresent = GBCFX.active() or Pipelines.wantsPresent() or hasOutputHook
local present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -1057,14 +1058,25 @@ function Renderer:endFrame(zones, worldZones)
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
local outputHandled = hasOutputHook
and Runtime.call("render.output", function() return false end, {
canvas = composed,
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
generation = 1,
}) == true
if not outputHandled then
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
end
end
self.worldActive = false
+2 -1
View File
@@ -435,7 +435,8 @@ local GEN2_HOOKS = {
-- pointer with the touch overlay given first refusal, the palette zone list
-- handed to the present pass, the letterbox and the HUD rect.
"input.step", "input.pointer",
"render.zones", "render.compose", "render.letterbox", "render.hud",
"render.zones", "render.compose", "render.output_enabled", "render.output",
"render.letterbox", "render.hud",
}
local function assertShared(name, sites, kind)
+47
View File
@@ -0,0 +1,47 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game2 = require("src.core.Game2")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local received
hooks:wrap("render.output", function(_, context)
received = context
return true
end, 0, "test")
hooks:wrap("render.output_enabled", function() return false end, 0, "test")
local canvas = love.graphics.newCanvas(640, 480)
local presentCalls = 0
local game = setmetatable({
frameFit = function() return 3, 80, 24, 1, 480, 432 end,
presentCanvas = function() presentCalls = presentCalls + 1 return canvas end,
drawScene = function() end,
drawHud = function() end,
}, Game2)
Game2.draw(game)
T.eq(received, nil, "a disabled Gold output hook does not run")
T.eq(presentCalls, 0, "a disabled Gold output hook keeps the direct path")
hooks:wrap("render.output_enabled", function() return true end, 10, "test")
Game2.draw(game)
T.check(received ~= nil, "Gold raises render.output for an enabled subscriber")
T.eq(received and received.canvas, canvas,
"Gold hands render.output the finished present canvas")
T.eq(received and received.generation, 2,
"Gold identifies the output context without changing Gen 1")
T.eq(received and received.gameX, 80, "Gold output carries the fitted game X")
T.eq(received and received.gameY, 24, "Gold output carries the fitted game Y")
T.eq(received and received.gameWidth, 480,
"Gold output carries the fitted game width")
T.eq(received and received.gameHeight, 432,
"Gold output carries the fitted game height")
Runtime.hooks = savedHooks
T.finish("gen2 render output seam")
+27
View File
@@ -987,6 +987,33 @@ check(fallback.style == "doublecircle",
"a hook naming an unregistered style falls back to the vanilla bits")
Runtime.install(Events.new(), Hooks.new(), {})
-- ------- gated final-output ownership
local outputHooks = Hooks.new()
Runtime.install(Events.new(), outputHooks, {})
local outputCalls, outputContext = 0, nil
outputHooks:wrap("render.output", function(nextLink, context)
outputCalls, outputContext = outputCalls + 1, context
return true
end, 0, "test")
outputHooks:wrap("render.output_enabled", function() return false end, 0, "test")
Renderer:init()
Renderer.presentCanvas = nil
Renderer:beginFrame(false)
Renderer:endFrame(nil, nil)
check(outputCalls == 0 and Renderer.presentCanvas == nil,
"a disabled output hook leaves the direct render path untouched")
outputHooks:wrap("render.output_enabled", function() return true end, 10, "test")
Renderer:init()
Renderer.presentCanvas = nil
Renderer:beginFrame(false)
Renderer:endFrame(nil, nil)
check(outputCalls == 1 and outputContext and outputContext.canvas,
"an enabled output hook receives the finished frame")
check(outputContext and outputContext.generation == 1,
"the output context identifies the active generation")
-- ------- asset transforms
local function seedTransform(id, source)