diff --git a/main.lua b/main.lua index 11a2c003..a4bce640 100644 --- a/main.lua +++ b/main.lua @@ -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 @@ -425,6 +426,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() @@ -472,11 +474,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 @@ -491,6 +509,7 @@ function love.draw() end end) end + HostDisplay.endFrame("game", Game) end function love.keypressed(key, scancode, isrepeat) diff --git a/src/core/HostDisplay.lua b/src/core/HostDisplay.lua new file mode 100644 index 00000000..d8ec5754 --- /dev/null +++ b/src/core/HostDisplay.lua @@ -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 diff --git a/tests/engine/host_display_test.lua b/tests/engine/host_display_test.lua new file mode 100644 index 00000000..51433cf4 --- /dev/null +++ b/tests/engine/host_display_test.lua @@ -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")