feat(switch): switch handheld 720p / docked 1080p at runtime

Unlock love-nx SDL dock/undock resizing and sync via NxDisplay so
booting docked is not stuck on the conf 720p hint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-03 14:12:51 -03:00
parent 0886573d34
commit dd5d8afd82
5 changed files with 233 additions and 5 deletions
+5 -3
View File
@@ -60,11 +60,13 @@ function love.conf(t)
local mobile = osName == "Android" or osName == "iOS" local mobile = osName == "Android" or osName == "iOS"
local nx = osName == "NX" local nx = osName == "NX"
if nx then if nx then
-- Switch (love-nx): docked/handheld 720p surface; no desktop resize hints. -- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
-- (720p↔1080p) only when the window is resizable and not exclusive
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
t.window.width = 1280 t.window.width = 1280
t.window.height = 720 t.window.height = 720
t.window.fullscreen = true t.window.fullscreen = false
t.window.resizable = false t.window.resizable = true
t.window.highdpi = false t.window.highdpi = false
elseif mobile then elseif mobile then
-- resizable is what unlocks orientation. SDL's Android backend, given no -- resizable is what unlocks orientation. SDL's Android backend, given no
+3 -2
View File
@@ -49,10 +49,11 @@ the transfer runbook).
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md` - Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail - Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md` - Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
### Known gaps / welcome contributions ### Known gaps / welcome contributions
- Docked vs handheld soak, long-play soak (≥30 min) - Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
- Switch Lite and fuller Pro Controller / third-party pad matrices - Switch Lite and fuller Pro Controller / third-party pad matrices
- Applet Mode remains unsupported by design (title override required) - Applet Mode remains unsupported by design (title override required)
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md)) - `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
@@ -476,7 +477,7 @@ Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent p
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` | | P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) | | P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves | | P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
| P1-01 | Docked vs handheld spot-check | **deferred** | Not exercised on OLED dock yet | | P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated | | P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded | | P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
| P1-04 | Reboot persistence | **pass** | T19 | | P1-04 | Reboot persistence | **pass** | T19 |
+6
View File
@@ -11,6 +11,7 @@
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")
local NxDisplay = require("src.core.NxDisplay")
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
@@ -233,6 +234,9 @@ function love.load(args)
end end
end end
love.graphics.setDefaultFilter("nearest", "nearest") love.graphics.setDefaultFilter("nearest", "nearest")
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
-- editor, scripted); no-op on desktop/mobile.
NxDisplay.sync()
-- Standalone editor. A bare `--editor` run has no launcher behind it, so -- Standalone editor. A bare `--editor` run has no launcher behind it, so
-- Close quits; --save points it at a specific file, otherwise it opens the -- Close quits; --save points it at a specific file, otherwise it opens the
@@ -299,6 +303,8 @@ end
function love.update(dt) function love.update(dt)
SwitchDiagnostics.maybeFlush(false) SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end if Importer then return Importer:update(dt) end
+96
View File
@@ -0,0 +1,96 @@
-- Switch-only display size: handheld 1280x720, docked (TV) 1920x1080.
-- love-nx's SDL backend can auto-resize on dock/undock when the window is
-- resizable; this module also syncs on boot and every frame so a docked
-- launch is not stuck at the conf.lua 720p hint until the next mode change.
local Platform = require("src.core.Platform")
local NxDisplay = {}
NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H = 1280, 720
NxDisplay.DOCKED_W, NxDisplay.DOCKED_H = 1920, 1080
-- AppletOperationMode from libnx: Handheld = 0, Console (docked) = 1.
local MODE_HANDHELD = 0
local MODE_CONSOLE = 1
-- Test hooks (nil = use live Platform / FFI / love.window).
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
local ffiOk, ffiC
local function ensureFfi()
if ffiOk ~= nil then return ffiOk end
ffiOk = false
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then return false end
-- Redefinition is fine across hot reload / tests; we only need the symbol.
pcall(ffi.cdef, [[
unsigned char appletGetOperationMode(void);
]])
local probeOk = pcall(function()
return ffi.C.appletGetOperationMode
end)
if not probeOk then return false end
ffiC = ffi.C
ffiOk = true
return true
end
local function isNX()
if NxDisplay._forceNXForTests ~= nil then
return not not NxDisplay._forceNXForTests
end
return Platform.isNX()
end
-- Returns AppletOperationMode or nil when unavailable.
function NxDisplay.operationMode()
if NxDisplay._operationModeForTests ~= nil then
return NxDisplay._operationModeForTests
end
if not ensureFfi() or not ffiC then return nil end
local ok, mode = pcall(function()
return tonumber(ffiC.appletGetOperationMode())
end)
if not ok then return nil end
return mode
end
-- Map operation mode → framebuffer size. Unknown / nil → handheld 720p.
function NxDisplay.desiredSize(mode)
if mode == nil then mode = NxDisplay.operationMode() end
if mode == MODE_CONSOLE then
return NxDisplay.DOCKED_W, NxDisplay.DOCKED_H
end
return NxDisplay.HANDHELD_W, NxDisplay.HANDHELD_H
end
-- Apply handheld/dock size when on NX and the window differs. No-op elsewhere.
-- Returns true when setMode ran.
function NxDisplay.sync()
if not isNX() then return false end
if not (love and love.window and love.window.getMode and love.window.setMode) then
return false
end
local wantW, wantH = NxDisplay.desiredSize()
local curW, curH, flags = love.window.getMode()
flags = flags or {}
if curW == wantW and curH == wantH
and flags.fullscreen == false and flags.resizable == true then
return false
end
flags.fullscreen = false
flags.resizable = true
love.window.setMode(wantW, wantH, flags)
return true
end
function NxDisplay._resetForTests()
NxDisplay._forceNXForTests = nil
NxDisplay._operationModeForTests = nil
ffiOk, ffiC = nil, nil
end
return NxDisplay
+123
View File
@@ -0,0 +1,123 @@
-- NX handheld/dock display sync (portable 720p / docked 1080p).
-- Self-contained: luajit tests/engine/nx_display_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local savedLove = _G.love
package.loaded["src.core.Platform"] = nil
package.loaded["src.core.NxDisplay"] = nil
local NxDisplay = require("src.core.NxDisplay")
local function withWindow(state, fn)
local setCalls = {}
_G.love = {
system = {
getOS = function() return state.os or "NX" end,
},
window = {
getMode = function()
return state.w, state.h, {
fullscreen = state.fullscreen,
resizable = state.resizable,
vsync = 1,
}
end,
setMode = function(w, h, flags)
setCalls[#setCalls + 1] = { w = w, h = h, flags = flags }
state.w, state.h = w, h
state.fullscreen = flags and flags.fullscreen
state.resizable = flags and flags.resizable
end,
},
}
package.loaded["src.core.Platform"] = nil
require("src.core.Platform")._resetForTests()
NxDisplay._resetForTests()
package.loaded["src.core.NxDisplay"] = nil
NxDisplay = require("src.core.NxDisplay")
local ok, err = pcall(fn, setCalls)
_G.love = savedLove
package.loaded["src.core.Platform"] = nil
package.loaded["src.core.NxDisplay"] = nil
NxDisplay = require("src.core.NxDisplay")
NxDisplay._resetForTests()
if not ok then error(err) end
end
-- Size mapping
do
local w, h = NxDisplay.desiredSize(0)
eq(w, 1280, "handheld mode → 1280 wide")
eq(h, 720, "handheld mode → 720 tall")
w, h = NxDisplay.desiredSize(1)
eq(w, 1920, "console/docked mode → 1920 wide")
eq(h, 1080, "console/docked mode → 1080 tall")
w, h = NxDisplay.desiredSize(nil)
eq(w, 1280, "nil mode falls back to handheld width")
eq(h, 720, "nil mode falls back to handheld height")
w, h = NxDisplay.desiredSize(99)
eq(w, 1280, "unknown mode falls back to handheld width")
end
-- Non-NX: sync is a no-op
withWindow({ os = "Linux", w = 1024, h = 768, fullscreen = false, resizable = true }, function(setCalls)
NxDisplay._forceNXForTests = false
NxDisplay._operationModeForTests = 1
eq(NxDisplay.sync(), false, "sync returns false off NX")
eq(#setCalls, 0, "sync never calls setMode off NX")
end)
-- NX handheld already correct: no setMode
withWindow({
os = "NX", w = 1280, h = 720, fullscreen = false, resizable = true,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 0
eq(NxDisplay.sync(), false, "sync skips setMode when already handheld")
eq(#setCalls, 0, "no setMode calls when size+flags match handheld")
end)
-- NX docked boot from 720p hint → 1080p
withWindow({
os = "NX", w = 1280, h = 720, fullscreen = true, resizable = false,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 1
eq(NxDisplay.sync(), true, "sync upgrades docked boot to 1080p")
eq(#setCalls, 1, "one setMode on docked boot")
eq(setCalls[1].w, 1920, "docked setMode width")
eq(setCalls[1].h, 1080, "docked setMode height")
eq(setCalls[1].flags.fullscreen, false, "docked setMode clears exclusive fullscreen")
eq(setCalls[1].flags.resizable, true, "docked setMode enables resizable for SDL backup")
end)
-- NX undock: 1080p → 720p
withWindow({
os = "NX", w = 1920, h = 1080, fullscreen = false, resizable = true,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 0
eq(NxDisplay.sync(), true, "sync shrinks to handheld after undock")
eq(setCalls[1].w, 1280, "undock setMode width")
eq(setCalls[1].h, 720, "undock setMode height")
end)
-- Flags-only fix when size already matches
withWindow({
os = "NX", w = 1280, h = 720, fullscreen = true, resizable = false,
}, function(setCalls)
NxDisplay._forceNXForTests = true
NxDisplay._operationModeForTests = 0
eq(NxDisplay.sync(), true, "sync fixes fullscreen/resizable even if size matches")
eq(setCalls[1].w, 1280, "flags-only setMode keeps handheld width")
eq(setCalls[1].flags.fullscreen, false, "flags-only clears fullscreen")
eq(setCalls[1].flags.resizable, true, "flags-only sets resizable")
end)
T.finish("nx_display")