Merge pull request #1200 from HimioneGranger/upstream-host-display-api

feat(host): add optional display lifecycle backend
This commit is contained in:
bryanthaboi
2026-08-13 06:06:12 -04:00
committed by GitHub
3 changed files with 136 additions and 3 deletions
+22 -3
View File
@@ -14,6 +14,7 @@ local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -430,6 +431,7 @@ function love.load(args)
end
function love.update(dt)
HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
@@ -477,11 +479,27 @@ function love.update(dt)
end
function love.draw()
if editorMode then return EditorApp.draw() end
if TouchEditor then return TouchEditor.draw() end
if Importer then return Importer:draw() end
if editorMode then
HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp)
return result
end
if TouchEditor then
HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Importer then
HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer)
return result
end
if not Game then return end
HostDisplay.beginFrame("game", Game)
Game:draw()
-- frame capture requested by a driver
if Game.capturePath then
@@ -496,6 +514,7 @@ function love.draw()
end
end)
end
HostDisplay.endFrame("game", Game)
end
function love.keypressed(key, scancode, isrepeat)
+41
View File
@@ -0,0 +1,41 @@
-- Optional native-host display lifecycle.
--
-- The engine always owns simulation and drawing. A packaged host may install
-- one backend to observe per-frame updates and prepare/finalize a render target
-- around an otherwise unchanged draw. With no backend installed every method
-- is a no-op, which is the normal desktop and mobile path.
--
-- Backend methods are optional:
-- backend:update(dt)
-- backend:beginFrame(kind, subject)
-- backend:endFrame(kind, subject)
--
-- `kind` is "editor", "touch_editor", "launcher", or "game". `subject` is
-- the object whose existing draw method runs between beginFrame and endFrame.
local HostDisplay = {}
local backend
function HostDisplay.setBackend(value)
if value ~= nil and type(value) ~= "table" then
error("host display backend must be a table or nil", 2)
end
backend = value
end
function HostDisplay.update(dt)
local fn = backend and backend.update
if fn then return fn(backend, dt) end
end
function HostDisplay.beginFrame(kind, subject)
local fn = backend and backend.beginFrame
if fn then return fn(backend, kind, subject) end
end
function HostDisplay.endFrame(kind, subject)
local fn = backend and backend.endFrame
if fn then return fn(backend, kind, subject) end
end
return HostDisplay
+73
View File
@@ -0,0 +1,73 @@
-- Optional native-host display lifecycle. The default path is inert; a fake
-- backend proves callback order and arguments without graphics or a ROM.
-- luajit tests/engine/host_display_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostDisplay = require("src.core.HostDisplay")
-- Vanilla: no backend, no state requirement, and no invented return value.
HostDisplay.setBackend(nil)
eq(HostDisplay.update(1 / 60), nil, "default update is a no-op")
eq(HostDisplay.beginFrame("game", {}), nil, "default beginFrame is a no-op")
eq(HostDisplay.endFrame("game", {}), nil, "default endFrame is a no-op")
-- Bad installation fails at the boundary instead of producing a later frame
-- error whose source is difficult for a native host to diagnose.
local ok, err = pcall(HostDisplay.setBackend, function() end)
check(not ok, "non-table backend is rejected")
check(tostring(err):find("table or nil", 1, true) ~= nil,
"backend type error explains the accepted shape")
local calls = {}
local subject = { tag = "launcher-instance" }
local fake = {}
function fake:update(dt)
calls[#calls + 1] = { "update", self, dt }
return "updated"
end
function fake:beginFrame(kind, gotSubject)
calls[#calls + 1] = { "begin", self, kind, gotSubject }
return "begun"
end
function fake:endFrame(kind, gotSubject)
calls[#calls + 1] = { "end", self, kind, gotSubject }
return "ended"
end
HostDisplay.setBackend(fake)
eq(HostDisplay.update(0.25), "updated", "update return is forwarded")
eq(HostDisplay.beginFrame("launcher", subject), "begun",
"beginFrame return is forwarded")
eq(HostDisplay.endFrame("launcher", subject), "ended",
"endFrame return is forwarded")
eq(#calls, 3, "each lifecycle callback fires exactly once")
eq(calls[1][1], "update", "update is first")
eq(calls[1][2], fake, "update receives the backend as self")
eq(calls[1][3], 0.25, "update receives dt")
eq(calls[2][1], "begin", "beginFrame precedes endFrame")
eq(calls[2][3], "launcher", "beginFrame receives the frame kind")
eq(calls[2][4], subject, "beginFrame receives the drawn subject")
eq(calls[3][1], "end", "endFrame is last")
eq(calls[3][3], "launcher", "endFrame receives the frame kind")
eq(calls[3][4], subject, "endFrame receives the drawn subject")
-- Every callback is optional. Replacing and clearing a backend must not retain
-- callbacks from the old host across a restart or test process.
local partialCalls = 0
HostDisplay.setBackend({
endFrame = function() partialCalls = partialCalls + 1 end,
})
eq(HostDisplay.update(1), nil, "missing optional update remains a no-op")
eq(HostDisplay.beginFrame("editor", {}), nil,
"missing optional beginFrame remains a no-op")
HostDisplay.endFrame("editor", {})
eq(partialCalls, 1, "present optional callback still runs")
HostDisplay.setBackend(nil)
HostDisplay.endFrame("game", {})
eq(partialCalls, 1, "clearing backend detaches old callbacks")
T.finish("host display")