From 99d9908017c98365c07f2d810d1b8a457f8e50ee Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:54:04 +0200 Subject: [PATCH] render: add cross-platform desktop companion display --- conf.lua | 17 ++- docs/modding.md | 4 + main.lua | 5 + src/core/HostShell.lua | 32 ++++++ src/render/DesktopCompanion.lua | 125 ++++++++++++++++++++++ src/render/DesktopScreen.lua | 137 +++++++++++++++++++++++++ src/render/SecondScreen.lua | 33 ++++-- tests/engine/desktop_companion.lua | 56 ++++++++++ tests/engine/desktop_second_screen.lua | 57 ++++++++++ tests/engine/hostshell_spawn_self.lua | 32 ++++++ 10 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 src/render/DesktopCompanion.lua create mode 100644 src/render/DesktopScreen.lua create mode 100644 tests/engine/desktop_companion.lua create mode 100644 tests/engine/desktop_second_screen.lua create mode 100644 tests/engine/hostshell_spawn_self.lua diff --git a/conf.lua b/conf.lua index 2894ffe5..1fe4d5da 100644 --- a/conf.lua +++ b/conf.lua @@ -6,18 +6,30 @@ function love.conf(t) local editor = os.getenv("POKEPORT_EDITOR") == "1" local developer = os.getenv("POKEPORT_DEV") == "1" + local companion = nil if arg then for _, a in ipairs(arg) do if a == "--editor" then editor = true end if a == "--developer" then developer = true end + local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$") + if port then companion = { port = tonumber(port), token = token } end end end -- main.lua runs in the same Lua state right after conf.lua; stash the -- decision in a global so it doesn't need to reparse `arg`. _G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_DEV_MODE = developer + _G.POKEPORT_DISPLAY_COMPANION = companion - if editor then + if companion then + t.identity = "pokemon-love2d-companion" + t.window.title = "gen1recomp Secondary Display" + t.window.width = 640 + t.window.height = 576 + t.window.minwidth = 160 + t.window.minheight = 144 + t.window.resizable = true + elseif editor then -- Same identity as the game, deliberately: the editor edits the game's -- saves and reads the game's ROM cache, both of which live under this -- folder. A private editor identity would point love.filesystem at an @@ -51,7 +63,8 @@ function love.conf(t) end t.version = love._os == "iOS" and "12.0" or "11.5" t.window.vsync = 1 - t.modules.joystick = true + t.modules.audio = not companion + t.modules.joystick = not companion t.modules.physics = false -- love.system is not loaded during love.conf; love._os is set by the diff --git a/docs/modding.md b/docs/modding.md index 0180762d..de1726bd 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -738,6 +738,10 @@ palette-correct blit of either canvas into an arbitrary screen rect, and the 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. +On process-capable Windows, Linux and macOS hosts without a native display +bridge, enabling this facade opens a second resizable app window instead. It +uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled` +contract, so a mod does not need a desktop-specific rendering path. `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 diff --git a/main.lua b/main.lua index c890caf9..4fbd44f2 100644 --- a/main.lua +++ b/main.lua @@ -8,6 +8,11 @@ -- opens the editor on that slot's file, and restores the launcher when -- the editor's Close button is pressed (openEditor / closeEditor below) +if POKEPORT_DISPLAY_COMPANION then + return require("src.render.DesktopCompanion").install( + POKEPORT_DISPLAY_COMPANION) +end + local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index fe19021c..9939ef39 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -272,6 +272,38 @@ function HostShell.quote(s) return "'" .. s:gsub("'", "'\\''") .. "'" end +-- Launch another instance of this packaged app without waiting for it. The +-- same path works on all process-capable desktop hosts; only the shell's +-- background spelling differs. Source checkouts include their game folder, +-- while fused releases and AppImages already carry it in the executable. +function HostShell.spawnSelfDetached(args) + if not require("src.core.Platform").canSpawnProcess() then return false end + local fs = love and love.filesystem + if not (fs and fs.getExecutablePath) then return false end + local executable = os.getenv("APPIMAGE") or fs.getExecutablePath() + if type(executable) ~= "string" or executable == "" then return false end + + local argv = {} + local fused = fs.isFused and fs.isFused() + if not os.getenv("APPIMAGE") and not fused and fs.getSource then + argv[#argv + 1] = fs.getSource() + end + for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end + + local command = HostShell.quote(executable) + for _, value in ipairs(argv) do + command = command .. " " .. HostShell.quote(value) + end + local osName = love.system and love.system.getOS and love.system.getOS() + if osName == "Windows" then + command = 'start "" /b ' .. command .. " >NUL 2>&1" + else + command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &" + end + local ok, _, code = os.execute(command) + return ok == true or ok == 0 or code == 0 +end + -- MEMOISED per Lua state (so once per thread). This used to spawn a whole -- `curl --version` process on every single fetch -- twice for a GET through -- the Android-bridge fallback -- which doubled the number of spawns the lock diff --git a/src/render/DesktopCompanion.lua b/src/render/DesktopCompanion.lua new file mode 100644 index 00000000..0702040b --- /dev/null +++ b/src/render/DesktopCompanion.lua @@ -0,0 +1,125 @@ +-- Minimal second-window process for src/render/DesktopScreen.lua. + +local DesktopCompanion = {} + +function DesktopCompanion.install(config) + local enet = require("enet") + local host = assert(enet.host_create()) + local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2)) + local image, sourceW, sourceH, preference + local background = { 0, 0, 0, 1 } + local connected, commandedQuit = false, false + local pointerDown = false + local started = love.timer.getTime() + local lastContact = started + + local function send(kind, payload) + if not connected then return end + pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable") + end + + local function receiveFrame(data) + local prefix = "F" .. config.token .. "\n" + if data:sub(1, #prefix) ~= prefix then return end + local split = data:find("\n", #prefix + 1, true) + if not split then return end + local w, h, rgb, mode = data:sub(#prefix + 1, split - 1) + :match("^(%d+),(%d+),(%d+),([%w_:.-]+)$") + w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb) + if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end + local ok, raw = pcall(love.data.decompress, "string", "lz4", + data:sub(split + 1)) + if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end + local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw) + if not made then return end + if not image or sourceW ~= w or sourceH ~= h then + if image and image.release then image:release() end + image = love.graphics.newImage(pixels) + else + image:replacePixels(pixels) + end + sourceW, sourceH, preference = w, h, mode + image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest", + mode:find("cover", 1, true) and "linear" or "nearest") + background = { + math.floor(rgb / 0x10000) % 0x100 / 255, + math.floor(rgb / 0x100) % 0x100 / 255, + rgb % 0x100 / 255, 1, + } + end + + local function service() + while true do + local event = host:service(0) + if not event then break end + if event.type == "connect" then + connected, lastContact = true, love.timer.getTime() + send("H") + elseif event.type == "receive" then + lastContact = love.timer.getTime() + if event.data == "Q" .. config.token then + commandedQuit = true + love.event.quit() + elseif event.data ~= "P" .. config.token then + receiveFrame(event.data) + end + elseif event.type == "disconnect" then + love.event.quit() + end + end + end + + local function placement() + if not image then return 0, 0, 1 end + local ww, wh = love.graphics.getDimensions() + local cover = preference and preference:find("cover", 1, true) + local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH) + return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale + end + + local function input(action, x, y) + if not image then return false end + local dx, dy, scale = placement() + local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale) + if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end + send("I", ("\n%s,%d,%d"):format(action, sx, sy)) + return true + end + + function love.update() + service() + local t = love.timer.getTime() + if (not connected and t - started > 5) or t - lastContact > 5 then + love.event.quit() + end + end + + function love.draw() + love.graphics.clear(background[1], background[2], background[3], background[4]) + if not image then return end + local x, y, scale = placement() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, x, y, 0, scale, scale) + end + + function love.mousepressed(x, y, button) + if button == 1 then pointerDown = input("down", x, y) end + end + function love.mousereleased(x, y, button) + if button == 1 and pointerDown then + if not input("up", x, y) then send("I", "\ncancel,0,0") end + pointerDown = false + end + end + function love.touchpressed(_, x, y) input("down", x, y) end + function love.touchreleased(_, x, y) input("up", x, y) end + function love.keypressed(key) + if key == "escape" then love.event.quit() end + end + function love.quit() + if not commandedQuit then send("C") end + pcall(peer.disconnect_now, peer) + end +end + +return DesktopCompanion diff --git a/src/render/DesktopScreen.lua b/src/render/DesktopScreen.lua new file mode 100644 index 00000000..8026bb80 --- /dev/null +++ b/src/render/DesktopScreen.lua @@ -0,0 +1,137 @@ +-- Cross-platform desktop secondary display. LOVE owns one window, so a +-- second minimal instance of this same app owns the companion window. ENet +-- is bundled with LOVE; binding it to loopback keeps frames and input local. + +local Platform = require("src.core.Platform") +local HostShell = require("src.core.HostShell") +local okEnet, enet = pcall(require, "enet") + +local DesktopScreen = {} +local state = { + enabled = false, blocked = false, host = nil, peer = nil, + token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0, +} + +local function now() + return love and love.timer and love.timer.getTime and love.timer.getTime() + or os.clock() +end + +local function destroy(sendQuit) + if state.peer and sendQuit then + pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable") + end + if state.peer then pcall(state.peer.disconnect_now, state.peer) end + if state.host then pcall(state.host.destroy, state.host) end + state.host, state.peer, state.token, state.port = nil, nil, nil, nil + state.touches = {} +end + +local function token() + local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":") + local digest = love.data.hash("sha256", seed) + return love.data.encode("string", "hex", digest):sub(1, 24) +end + +local function start() + if state.host or state.blocked or now() < state.retryAt then + return state.host ~= nil + end + local base = 49152 + math.floor(now() * 1000) % 12000 + for attempt = 0, 31 do + local port = 49152 + (base - 49152 + attempt * 37) % 12000 + local ok, host = pcall(enet.host_create, + ("127.0.0.1:%d"):format(port), 1, 2) + if ok and host then + state.host, state.port, state.token = host, port, token() + local launched = HostShell.spawnSelfDetached({ + ("--display-companion=%d,%s"):format(port, state.token), + }) + if launched then return true end + destroy(false) + break + end + end + state.retryAt = now() + 1 + return false +end + +local function service() + if not state.enabled or state.blocked then return end + if not state.host and not start() then return end + while state.host do + local ok, event = pcall(state.host.service, state.host, 0) + if not ok then + destroy(false) + state.retryAt = now() + 1 + return + end + if not event then break end + if event.type == "receive" then + local data = event.data or "" + if data == "H" .. state.token then + state.peer = event.peer + elseif event.peer == state.peer + and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then + state.touches[#state.touches + 1] = data:sub(#state.token + 3) + elseif event.peer == state.peer and data == "C" .. state.token then + state.blocked = true + destroy(false) + return + end + elseif event.type == "disconnect" and event.peer == state.peer then + destroy(false) + state.retryAt = now() + 1 + return + end + end + if state.peer and now() >= state.heartbeatAt then + state.heartbeatAt = now() + 1 + pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable") + end +end + +function DesktopScreen.usable() + return okEnet and enet ~= nil and Platform.canSpawnProcess() +end + +function DesktopScreen.available() + return DesktopScreen.detected() +end + +function DesktopScreen.detected() + service() + return state.peer ~= nil +end + +function DesktopScreen.push(imageData, w, h, background, preference) + service() + if not state.peer or not imageData or not imageData.getString then return false end + w, h = tonumber(w), tonumber(h) + if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end + local ok, raw = pcall(imageData.getString, imageData) + if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end + local packed = love.data.compress("string", "lz4", raw, 1) + local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h, + tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", "")) + local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable") + return sent +end + +function DesktopScreen.pollTouch() + service() + return table.remove(state.touches, 1) +end + +function DesktopScreen.setEnabled(on) + on = on == true + if on == state.enabled then + if on then service() end + return + end + state.enabled = on + state.blocked = false + if on then start(); service() else destroy(true) end +end + +return DesktopScreen diff --git a/src/render/SecondScreen.lua b/src/render/SecondScreen.lua index 2e5fb3b5..d3b03796 100644 --- a/src/render/SecondScreen.lua +++ b/src/render/SecondScreen.lua @@ -1,11 +1,11 @@ --- Bridge to native secondary-display output (Android Presentation). The C --- functions live in mobile/android/love/src/jni/love/src/common/android.cpp. --- Everything is guarded: off Android, or if the symbols cannot be resolved, --- this stays inert and the renderer keeps the in-window stacked layout. +-- Shared secondary-display facade. Android uses its native Presentation +-- bridge; process-capable desktop hosts fall back to a companion window. +-- Everything is guarded, so unsupported hosts keep the in-window layout. local SecondScreen = {} local C = nil local ffi = nil +local desktop = nil local function log(msg) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) @@ -37,17 +37,36 @@ do end end +if not C then + local ok, backend = pcall(require, "src.render.DesktopScreen") + if ok and backend and backend.usable and backend.usable() then + desktop = backend + log("desktop companion backend ready") + end +end + function SecondScreen.usable() - return C ~= nil + return C ~= nil or desktop ~= nil end function SecondScreen.available() + if desktop then return desktop.available() end if not C then return false end local ok, r = pcall(C.love_android_secondary_ready) return ok and r ~= 0 end -function SecondScreen.push(imageData, w, h) +-- A connected display is not necessarily the current Presentation yet. This +-- distinction lets a companion retry its first frame after hotplug/re-target. +function SecondScreen.detected() + if desktop then return desktop.detected() end + return SecondScreen.available() +end + +function SecondScreen.push(imageData, w, h, background, preference) + if desktop then + return desktop.push(imageData, w, h, background, preference) + end if not C or not imageData then return false end return pcall(function() C.love_android_push_secondary(imageData:getFFIPointer(), w, h) @@ -57,6 +76,7 @@ end -- Returns the oldest queued secondary-display event as "action,x,y", where -- coordinates are in the submitted frame's pixel space. function SecondScreen.pollTouch() + if desktop then return desktop.pollTouch() end if not C then return nil end local ok, event = pcall(function() return C.love_android_poll_secondary_touch() @@ -66,6 +86,7 @@ function SecondScreen.pollTouch() end function SecondScreen.setEnabled(on) + if desktop then return desktop.setEnabled(on) end if not C then return end pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) end diff --git a/tests/engine/desktop_companion.lua b/tests/engine/desktop_companion.lua new file mode 100644 index 00000000..36d8c5e1 --- /dev/null +++ b/tests/engine/desktop_companion.lua @@ -0,0 +1,56 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local clock, quit = 1, false +local sent, queue, draws = {}, {}, 0 +local peer = { + send = function(_, data) sent[#sent + 1] = data end, + disconnect_now = function() end, +} +local host = { + connect = function() return peer end, + service = function() + if #queue == 0 then return nil end + return table.remove(queue, 1) + end, +} +local rendered = { + setFilter = function() end, replacePixels = function() end, + release = function() end, +} +love = { + timer = { getTime = function() return clock end }, + data = { decompress = function(_, _, value) return value end }, + image = { newImageData = function(w, h, format, raw) + assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba") + return {} + end }, + graphics = { + newImage = function() return rendered end, + getDimensions = function() return 100, 100 end, + clear = function() end, setColor = function() end, + draw = function() draws = draws + 1 end, + }, + event = { quit = function() quit = true end }, +} +package.preload.enet = function() + return { host_create = function() return host end } +end + +require("src.render.DesktopCompanion").install({ port = 50000, token = "token" }) +queue[#queue + 1] = { type = "connect" } +love.update() +assert(sent[#sent] == "Htoken", "companion authenticates after connecting") +queue[#queue + 1] = { + type = "receive", data = "Ftoken\n1,1,0,auto\nrgba", +} +love.update() +love.draw() +assert(draws == 1, "companion draws a received frame") +love.mousepressed(50, 50, 1) +love.mousereleased(50, 50, 1) +assert(sent[#sent - 1] == "Itoken\ndown,0,0" + and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels") +queue[#queue + 1] = { type = "receive", data = "Qtoken" } +love.update() +assert(quit, "parent can close the companion") +print("desktop companion: ok") diff --git a/tests/engine/desktop_second_screen.lua b/tests/engine/desktop_second_screen.lua new file mode 100644 index 00000000..77116a8b --- /dev/null +++ b/tests/engine/desktop_second_screen.lua @@ -0,0 +1,57 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local clock = 1 +love = { + timer = { getTime = function() return clock end }, + data = { + hash = function() return "digest" end, + encode = function() return "0123456789abcdef0123456789abcdef" end, + compress = function(_, _, value) return value end, + }, +} + +local sent, spawned, queue = {}, nil, {} +local peer = { + send = function(_, data, channel, flag) + sent[#sent + 1] = { data = data, channel = channel, flag = flag } + end, + disconnect_now = function() end, +} +local host = { + service = function() + if #queue == 0 then return nil end + return table.remove(queue, 1) + end, + destroy = function() end, +} + +package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end } +package.loaded["src.core.HostShell"] = { + spawnSelfDetached = function(args) spawned = args return true end, +} +package.preload.enet = function() + return { host_create = function() return host end } +end + +local Screen = require("src.render.SecondScreen") +assert(Screen.usable(), "the shared facade selects the desktop backend") +Screen.setEnabled(true) +assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"), + "enabling launches one companion of this app") +local token = spawned[1]:match(",([%w]+)$") +queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token } +assert(Screen.detected(), "a token-authenticated companion becomes detected") + +local pixels = { getString = function() return "rgba" end } +assert(Screen.push(pixels, 1, 1, 0x102030, "auto"), + "a connected companion accepts a frame") +assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"), + "frame metadata and pixels stay in one loopback packet") + +queue[#queue + 1] = { + type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4", +} +assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod") +Screen.setEnabled(false) +assert(sent[#sent].data == "Q" .. token, "disabling closes the companion") +print("desktop second screen: ok") diff --git a/tests/engine/hostshell_spawn_self.lua b/tests/engine/hostshell_spawn_self.lua new file mode 100644 index 00000000..e3f2b937 --- /dev/null +++ b/tests/engine/hostshell_spawn_self.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local osName, fused, command = "Windows", true, nil +love = { + system = { getOS = function() return osName end }, + filesystem = { + getExecutablePath = function() return "C:\\Game\\gen1recomp.exe" end, + getSource = function() return "C:\\Source\\gen1recomp" end, + isFused = function() return fused end, + }, +} +package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end } +local execute = os.execute +os.execute = function(value) command = value return 0 end + +local HostShell = require("src.core.HostShell") +assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) +assert(command:find('start "" /b ', 1, true) + and command:find('"C:\\Game\\gen1recomp.exe"', 1, true), + "Windows launches the fused app detached") + +osName, fused = "Linux", false +assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) +assert(command:find("'C:\\Source\\gen1recomp'", 1, true) + and command:sub(-1) == "&", "Linux source runs include the game folder") + +osName, fused = "OS X", true +assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" })) +assert(not command:find("start", 1, true) and command:sub(-1) == "&", + "macOS uses the same detached POSIX path") +os.execute = execute +print("spawn self detached: ok")