Merge pull request #1445 from AverageConsumer/codex/desktop-companion-display

This commit is contained in:
bryanthaboi
2026-08-16 16:40:10 -04:00
committed by GitHub
10 changed files with 490 additions and 8 deletions
+15 -2
View File
@@ -6,18 +6,30 @@ function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1" local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1" local developer = os.getenv("POKEPORT_DEV") == "1"
local companion = nil
if arg then if arg then
for _, a in ipairs(arg) do for _, a in ipairs(arg) do
if a == "--editor" then editor = true end if a == "--editor" then editor = true end
if a == "--developer" then developer = 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
end end
-- main.lua runs in the same Lua state right after conf.lua; stash the -- 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`. -- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer _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 -- 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 -- 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 -- folder. A private editor identity would point love.filesystem at an
@@ -51,7 +63,8 @@ function love.conf(t)
end end
t.version = love._os == "iOS" and "12.0" or "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 t.window.vsync = 1
t.modules.joystick = true t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false t.modules.physics = false
-- love.system is not loaded during love.conf; love._os is set by the -- love.system is not loaded during love.conf; love._os is set by the
+4
View File
@@ -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`. 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, 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. 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 `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 for mods that need the engine's normal composite rather than its separate
+5
View File
@@ -8,6 +8,11 @@
-- opens the editor on that slot's file, and restores the launcher when -- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below) -- 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 editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics") local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
+32
View File
@@ -272,6 +272,38 @@ function HostShell.quote(s)
return "'" .. s:gsub("'", "'\\''") .. "'" return "'" .. s:gsub("'", "'\\''") .. "'"
end 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 -- 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 -- `curl --version` process on every single fetch -- twice for a GET through
-- the Android-bridge fallback -- which doubled the number of spawns the lock -- the Android-bridge fallback -- which doubled the number of spawns the lock
+125
View File
@@ -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
+137
View File
@@ -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
+27 -6
View File
@@ -1,11 +1,11 @@
-- Bridge to native secondary-display output (Android Presentation). The C -- Shared secondary-display facade. Android uses its native Presentation
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp. -- bridge; process-capable desktop hosts fall back to a companion window.
-- Everything is guarded: off Android, or if the symbols cannot be resolved, -- Everything is guarded, so unsupported hosts keep the in-window layout.
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {} local SecondScreen = {}
local C = nil local C = nil
local ffi = nil local ffi = nil
local desktop = nil
local function log(msg) local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end) pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -37,17 +37,36 @@ do
end end
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() function SecondScreen.usable()
return C ~= nil return C ~= nil or desktop ~= nil
end end
function SecondScreen.available() function SecondScreen.available()
if desktop then return desktop.available() end
if not C then return false end if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready) local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0 return ok and r ~= 0
end 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 if not C or not imageData then return false end
return pcall(function() return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h) 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 -- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space. -- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch() function SecondScreen.pollTouch()
if desktop then return desktop.pollTouch() end
if not C then return nil end if not C then return nil end
local ok, event = pcall(function() local ok, event = pcall(function()
return C.love_android_poll_secondary_touch() return C.love_android_poll_secondary_touch()
@@ -66,6 +86,7 @@ function SecondScreen.pollTouch()
end end
function SecondScreen.setEnabled(on) function SecondScreen.setEnabled(on)
if desktop then return desktop.setEnabled(on) end
if not C then return end if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end) pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end end
+56
View File
@@ -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")
+57
View File
@@ -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")
+32
View File
@@ -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")