diff --git a/conf.lua b/conf.lua index 56a89919..ea6e8384 100644 --- a/conf.lua +++ b/conf.lua @@ -60,11 +60,13 @@ function love.conf(t) local mobile = osName == "Android" or osName == "iOS" local nx = osName == "NX" 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.height = 720 - t.window.fullscreen = true - t.window.resizable = false + t.window.fullscreen = false + t.window.resizable = true t.window.highdpi = false elseif mobile then -- resizable is what unlocks orientation. SDL's Android backend, given no diff --git a/docs/switch-development.md b/docs/switch-development.md index e16db6b9..87272d6a 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -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` - 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` +- 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 -- 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 - Applet Mode remains unsupported by design (title override required) - `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-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 | -| 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-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded | | P1-04 | Reboot persistence | **pass** | T19 | diff --git a/main.lua b/main.lua index ca4c0751..ad7d7183 100644 --- a/main.lua +++ b/main.lua @@ -11,6 +11,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true 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. do @@ -233,6 +234,9 @@ function love.load(args) end end 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 -- Close quits; --save points it at a specific file, otherwise it opens the @@ -299,6 +303,8 @@ end function love.update(dt) 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 TouchEditor then return TouchEditor.update(dt) end if Importer then return Importer:update(dt) end diff --git a/src/core/NxDisplay.lua b/src/core/NxDisplay.lua new file mode 100644 index 00000000..65b815b6 --- /dev/null +++ b/src/core/NxDisplay.lua @@ -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 diff --git a/tests/engine/nx_display_test.lua b/tests/engine/nx_display_test.lua new file mode 100644 index 00000000..23d7284f --- /dev/null +++ b/tests/engine/nx_display_test.lua @@ -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")