From 0886573d34e469e0da1b110618cb24de31205692 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 14:00:06 -0300 Subject: [PATCH] fix(switch): cut launcher pad-cursor lag on NX Skip per-frame mouse warps and FlexLove perf sampling on Switch, feed pad coords through a getPosition bridge, and park that shim before the save editor so desktop paths stay unchanged. Co-authored-by: Cursor --- main.lua | 5 + src/import/LauncherView.lua | 27 ++- src/import/RomImporter.lua | 59 +++++- tests/engine/launcher_nx_pad_cursor_test.lua | 206 +++++++++++++++++++ 4 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 tests/engine/launcher_nx_pad_cursor_test.lua diff --git a/main.lua b/main.lua index f8eed60a..ca4c0751 100644 --- a/main.lua +++ b/main.lua @@ -119,6 +119,11 @@ local function openEditor(version, slotId) require("src.import.CacheFs").mountVersion(version) editorVersion = version editorHost = Importer + -- NX: drop the launcher getPosition shim so the save editor sees the real + -- pointer / its own pad cursor. Desktop has no shim — no-op. + if Importer and Importer.parkNxPointerForHost then + Importer:parkNxPointerForHost() + end Importer = nil editorMode = true resizeForEditor() diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index c41fda75..50a2cfbf 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -97,6 +97,21 @@ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end -- ------- lifecycle +-- NX-only: FlexLove's init maps `performanceMonitoring = false` to true +-- (`false or true`), which leaves layout/render timers + memory sampling on +-- every immediate-mode frame and makes the pad cursor feel lagged. Force +-- them off after init. Desktop keeps the library default. Exported so the +-- engine tier can assert the Switch guards without drawing the full tree. +function LauncherView.applyNxPerfGuards(imp) + if not (imp and imp.isNX and FlexLove.isReady() and FlexLove._Performance) then + return false + end + FlexLove._Performance.enabled = false + local mp = FlexLove._Performance._memoryProfiler + if mp then mp.enabled = false end + return true +end + local function ensureFlex(imp) if not FlexLove.isReady() then FlexLove.init({ @@ -105,6 +120,9 @@ local function ensureFlex(imp) keyboardNavigation = false, }) end + -- Re-apply on every ensure: FlexLove may already be ready from a prior + -- init (hot reload / editor round-trip). No-op when not NX. + LauncherView.applyNxPerfGuards(imp) if not imp._flex then imp._flex = true imp._hot = imp._hot or {} @@ -123,7 +141,14 @@ end -- engine draws with raw love.graphics and must not share canvases or input -- polling with a live UI toolkit. function LauncherView.detach(imp) - if not imp._flex then return end + -- Restore the NX mouse shim even if _flex was never set (bridge can + -- install on the first update before the first draw). + if imp and imp.parkNxPointerForHost then + pcall(imp.parkNxPointerForHost, imp) + elseif imp and imp._restoreNxPointerBridge then + pcall(imp._restoreNxPointerBridge, imp) + end + if not imp or not imp._flex then return end imp._flex = nil if love.keyboard and love.keyboard.setKeyRepeat then pcall(love.keyboard.setKeyRepeat, false) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0a1d7b88..5660f4f8 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1937,6 +1937,41 @@ function RomImporter:_activatePadCursor() self._padCursorActive = true end +-- NX: FlexLove hover/hit-test polls love.mouse.getPosition every interactive +-- element. Warping via setPosition every stick frame is expensive on love-nx +-- and makes the virtual cursor lag. Expose the pad pointer through a getPosition +-- shim instead; desktop keeps the setPosition path unchanged. +function RomImporter:_ensureNxPointerBridge() + if not self.isNX or self._nxPointerBridge then return end + if not (love and love.mouse and love.mouse.getPosition) then return end + self._nxRealGetPosition = love.mouse.getPosition + local importer = self + love.mouse.getPosition = function() + if importer._padCursorActive then + return importer._padCursor.x, importer._padCursor.y + end + return importer._nxRealGetPosition() + end + self._nxPointerBridge = true +end + +function RomImporter:_restoreNxPointerBridge() + if not self._nxPointerBridge then return end + if love and love.mouse and self._nxRealGetPosition then + love.mouse.getPosition = self._nxRealGetPosition + end + self._nxPointerBridge = false + self._nxRealGetPosition = nil +end + +-- NX only: drop the getPosition shim + hide the virtual cursor before a host +-- takes over input (embedded save editor). Desktop is a no-op. +function RomImporter:parkNxPointerForHost() + if not self.isNX then return end + self._padCursorActive = false + self:_restoreNxPointerBridge() +end + function RomImporter:_cycleTab(delta) local order = { "red", "blue", "yellow", "mods", "find" } local idx = 1 @@ -1947,9 +1982,20 @@ function RomImporter:_cycleTab(delta) end function RomImporter:_updatePadCursor(dt) + if self.isNX then + self:_ensureNxPointerBridge() + end + -- Real mouse motion yields the pad cursor so desktop users keep a normal - -- pointer after bumping a stick once. - local mx, my = love.mouse.getPosition() + -- pointer after bumping a stick once. On NX the bridged getPosition returns + -- pad coords while active, so yield must sample the *real* mouse or an A + -- press after idle falsely drops the cursor (pad vs last real position). + local mx, my + if self.isNX and self._nxRealGetPosition then + mx, my = self._nxRealGetPosition() + else + mx, my = love.mouse.getPosition() + end if self._lastMouseX and self._padCursorActive then if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then self._padCursorActive = false @@ -1978,11 +2024,10 @@ function RomImporter:_updatePadCursor(dt) local ny = self._padCursor.y + dy * speed * dt self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.y = math.max(oy, math.min(oy + h, ny)) - -- The FlexLove view polls the real mouse for hover and wheel targeting, - -- so the pad pointer warps it along. The self-caused motion is recorded - -- as the last seen position, or the yield check above would read the warp - -- as real mouse movement and drop the pad cursor immediately. - if love.mouse.setPosition then + -- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer. + -- NX: the getPosition bridge already returns pad coords — skip setPosition + -- and leave _lastMouse* on the real pointer baseline (yield above). + if not self.isNX and love.mouse.setPosition then pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y end diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua new file mode 100644 index 00000000..771a3c1c --- /dev/null +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -0,0 +1,206 @@ +-- NX launcher pad-cursor lag guards (Switch-only). +-- Proves the virtual mouse no longer warps love.mouse via setPosition on NX, +-- that FlexLove still sees pad coords through the getPosition bridge, that +-- desktop keeps setPosition, and that LauncherView wires NX perf guards. +-- Also prints a small metric block (setPosition counts + update cost). +-- +-- FlexLove itself is not loaded here: the engine tier runs under plain luajit +-- without luautf8, which FlexLove requires. RomImporter owns the pointer +-- bridge; LauncherView wiring is asserted via source seams. +-- luajit tests/engine/launcher_nx_pad_cursor_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- Instrument setPosition so we can count warps (love_stub has none). +local setPositionCalls = 0 +local mouseX, mouseY = 0, 0 +love.mouse.getPosition = function() return mouseX, mouseY end +love.mouse.setPosition = function(x, y) + setPositionCalls = setPositionCalls + 1 + mouseX, mouseY = x, y +end + +local RomImporter = require("src.import.RomImporter") + +local function freshImporter(isNX) + return setmetatable({ + isNX = isNX and true or false, + _padCursor = { x = 100, y = 100 }, + _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, + _rawHatDirs = {}, + _padInited = true, + _flex = true, + tab = "red", + }, RomImporter) +end + +local function stickRight(imp, frames, dt) + dt = dt or (1 / 60) + imp._padAxis.leftx = 1 + for _ = 1, frames do + imp:_updatePadCursor(dt) + end +end + +local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src +end + +-- ------- NX: no setPosition warps; getPosition bridge tracks the pad + +do + setPositionCalls = 0 + mouseX, mouseY = 0, 0 + local imp = freshImporter(true) + local x0 = imp._padCursor.x + stickRight(imp, 30) + check(imp._padCursorActive, "NX stick activates pad cursor") + check(imp._padCursor.x > x0, "NX stick moves pad cursor right") + eq(setPositionCalls, 0, "NX pad move never calls love.mouse.setPosition") + check(imp._nxPointerBridge, "NX installs getPosition bridge") + local gx, gy = love.mouse.getPosition() + eq(gx, imp._padCursor.x, "NX getPosition X matches pad cursor") + eq(gy, imp._padCursor.y, "NX getPosition Y matches pad cursor") + -- Real (stored) mouse must stay where the stub left it — bridge only. + eq(mouseX, 0, "NX does not warp the underlying mouse X") + eq(mouseY, 0, "NX does not warp the underlying mouse Y") + imp:_restoreNxPointerBridge() + check(not imp._nxPointerBridge, "restore clears NX mouse bridge") + local rx, ry = love.mouse.getPosition() + eq(rx, 0, "after restore getPosition is the real stub again") + eq(ry, 0, "after restore getPosition Y is the real stub again") +end + +-- ------- NX: A after idle must not false-yield the pad cursor + +do + mouseX, mouseY = 10, 20 + local imp = freshImporter(true) + imp._padCursor.x, imp._padCursor.y = 400, 300 + -- Idle frames pin _lastMouse* to the real pointer. + imp:_updatePadCursor(1 / 60) + eq(imp._lastMouseX, 10, "idle samples real mouse X") + eq(imp._lastMouseY, 20, "idle samples real mouse Y") + -- A / activate without stick motion (clickAt path). + imp:_activatePadCursor() + imp:_updatePadCursor(1 / 60) + check(imp._padCursorActive, + "NX A after idle keeps pad cursor (no false yield via bridged getPosition)") + local gx, gy = love.mouse.getPosition() + eq(gx, 400, "bridged getPosition still reports pad X after A") + eq(gy, 300, "bridged getPosition still reports pad Y after A") + -- Real USB-ish motion still yields. + mouseX, mouseY = 80, 90 + imp:_updatePadCursor(1 / 60) + check(not imp._padCursorActive, "NX real mouse motion still yields pad cursor") + imp:parkNxPointerForHost() +end + +-- ------- NX: parkNxPointerForHost restores mouse for embedded editor + +do + mouseX, mouseY = 5, 6 + local imp = freshImporter(true) + stickRight(imp, 5) + check(imp._nxPointerBridge, "bridge on before park") + check(imp._padCursorActive, "pad active before park") + imp:parkNxPointerForHost() + check(not imp._nxPointerBridge, "park clears bridge") + check(not imp._padCursorActive, "park clears pad active") + local gx, gy = love.mouse.getPosition() + eq(gx, 5, "after park getPosition is real mouse X") + eq(gy, 6, "after park getPosition is real mouse Y") + -- Desktop no-op. + local desk = freshImporter(false) + desk._padCursorActive = true + desk:parkNxPointerForHost() + check(desk._padCursorActive, "desktop parkNxPointerForHost is a no-op") +end + +-- ------- Desktop: setPosition still warps (unchanged path) + +do + setPositionCalls = 0 + mouseX, mouseY = 0, 0 + local imp = freshImporter(false) + local x0 = imp._padCursor.x + stickRight(imp, 30) + check(imp._padCursorActive, "desktop stick activates pad cursor") + check(imp._padCursor.x > x0, "desktop stick moves pad cursor right") + eq(setPositionCalls, 30, "desktop pad move warps mouse every frame") + eq(mouseX, imp._padCursor.x, "desktop setPosition tracks pad X") + eq(mouseY, imp._padCursor.y, "desktop setPosition tracks pad Y") + check(not imp._nxPointerBridge, "desktop never installs NX bridge") +end + +-- ------- Metrics: setPosition counts + pad-update cost (NX vs desktop) + +do + local frames = 120 + local dt = 1 / 60 + + setPositionCalls = 0 + local nx = freshImporter(true) + local t0 = os.clock() + stickRight(nx, frames, dt) + local nxMs = (os.clock() - t0) * 1000 + local nxSet = setPositionCalls + nx:_restoreNxPointerBridge() + + setPositionCalls = 0 + local desk = freshImporter(false) + t0 = os.clock() + stickRight(desk, frames, dt) + local deskMs = (os.clock() - t0) * 1000 + local deskSet = setPositionCalls + + eq(nxSet, 0, "metric: NX setPosition count is 0 over 120 frames") + eq(deskSet, frames, "metric: desktop setPosition count equals frame count") + + print(string.format( + "METRICS nx_pad_cursor: frames=%d nx_setPosition=%d desk_setPosition=%d nx_update_ms=%.3f desk_update_ms=%.3f", + frames, nxSet, deskSet, nxMs, deskMs)) +end + +-- ------- Source seams: LauncherView NX perf + detach restore + +do + local view = read("src/import/LauncherView.lua") + check(view:find("function LauncherView.applyNxPerfGuards", 1, true) ~= nil, + "LauncherView exports applyNxPerfGuards") + check(view:find("LauncherView.applyNxPerfGuards(imp)", 1, true) ~= nil, + "ensureFlex calls applyNxPerfGuards") + check(view:find("FlexLove._Performance.enabled = false", 1, true) ~= nil, + "NX guard disables Performance.enabled") + check(view:find("mp.enabled = false", 1, true) ~= nil, + "NX guard disables memory profiling") + check(view:find("if not (imp and imp.isNX", 1, true) ~= nil, + "perf guard is gated on imp.isNX") + check(view:find("parkNxPointerForHost", 1, true) ~= nil, + "detach parks NX pointer before tearing down") + + local impSrc = read("src/import/RomImporter.lua") + check(impSrc:find("function RomImporter:_ensureNxPointerBridge", 1, true) ~= nil, + "RomImporter owns NX getPosition bridge") + check(impSrc:find("function RomImporter:parkNxPointerForHost", 1, true) ~= nil, + "RomImporter exports parkNxPointerForHost") + check(impSrc:find("_nxRealGetPosition()", 1, true) ~= nil, + "NX yield samples real mouse, not bridged getPosition") + check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil, + "RomImporter skips setPosition on NX") + + local mainSrc = read("main.lua") + check(mainSrc:find("parkNxPointerForHost", 1, true) ~= nil, + "openEditor parks NX pointer before save editor") +end + +T.finish("launcher_nx_pad_cursor")