diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index fbb61d6e..6e8b0293 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -546,11 +546,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 diff --git a/docs/modding.md b/docs/modding.md index e3a45fa2..f03517b0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 8e02f801..7532437d 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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() diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 3b69e2d0..54b5518c 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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 diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index 0cbcf0d7..8079b749 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -434,7 +434,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) diff --git a/tests/engine/gen2_render_output_seam.lua b/tests/engine/gen2_render_output_seam.lua new file mode 100644 index 00000000..3387e17e --- /dev/null +++ b/tests/engine/gen2_render_output_seam.lua @@ -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") diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 8e310e63..df7fcbb5 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -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)