From cfe482e1fc9a622b8fed717cf70b18be22d6f5fe Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Mon, 3 Aug 2026 14:31:05 -0300 Subject: [PATCH] fix(switch): stop pad cursor flicker from SDL mouse drift Disable mouse-yield on NX where stick/touch moves the system pointer between sparse axis events, clamp pad dt, pixel-snap the overlay, and soften FlexLove GC so the launcher cursor stays steady. Co-authored-by: Cursor --- src/import/LauncherView.lua | 14 +++++ src/import/RomImporter.lua | 29 +++++----- tests/engine/launcher_nx_pad_cursor_test.lua | 58 +++++++++++++++++--- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 50a2cfbf..95e9edb0 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -109,6 +109,15 @@ function LauncherView.applyNxPerfGuards(imp) FlexLove._Performance.enabled = false local mp = FlexLove._Performance._memoryProfiler if mp then mp.enabled = false end + -- Immediate-mode rebuilds allocate a full tree every frame; the default + -- auto GC steps hitch the pad cursor on Switch. Less frequent steps, higher + -- threshold — desktop keeps FlexLove defaults. + if FlexLove._gcConfig then + FlexLove._gcConfig.strategy = "periodic" + FlexLove._gcConfig.interval = 90 + FlexLove._gcConfig.stepSize = 40 + FlexLove._gcConfig.memoryThreshold = 180 + end return true end @@ -1734,7 +1743,12 @@ end local function drawPadCursor(imp) if not imp._padCursorActive then return end + -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch + -- framebuffer when the stick advances by fractional pixels each frame. local x, y = imp._padCursor.x, imp._padCursor.y + if imp.isNX then + x, y = math.floor(x + 0.5), math.floor(y + 0.5) + end love.graphics.push("all") love.graphics.origin() love.graphics.setLineWidth(1) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5660f4f8..b9b97a54 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1984,24 +1984,24 @@ end function RomImporter:_updatePadCursor(dt) if self.isNX then self:_ensureNxPointerBridge() + -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling + -- the cursor; desktop keeps raw dt (setPosition path already smooth there). + if dt > 1 / 30 then dt = 1 / 30 end end -- Real mouse motion yields the pad cursor so desktop users keep a normal - -- 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 + -- pointer after bumping a stick once. On NX this must stay off: love-nx / + -- SDL often drifts the system mouse with the stick (or touch), and axis + -- events are not every frame, so yield+reactivate flickers the overlay. + if not self.isNX then + local mx, my = love.mouse.getPosition() + 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 + end end + self._lastMouseX, self._lastMouseY = mx, my end - self._lastMouseX, self._lastMouseY = mx, my local ax = self._padAxis.leftx or 0 local ay = self._padAxis.lefty or 0 @@ -2025,8 +2025,7 @@ function RomImporter:_updatePadCursor(dt) self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.y = math.max(oy, math.min(oy + h, ny)) -- 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). + -- NX: the getPosition bridge already returns pad coords — skip setPosition. 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 diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua index 771a3c1c..7e6ecef1 100644 --- a/tests/engine/launcher_nx_pad_cursor_test.lua +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -86,22 +86,52 @@ 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. + -- Idle frames (NX ignores mouse yield entirely). 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)") + "NX A after idle keeps pad cursor") 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. + -- System mouse drift must NOT yield on NX (SDL stick→mouse / touch noise). mouseX, mouseY = 80, 90 imp:_updatePadCursor(1 / 60) - check(not imp._padCursorActive, "NX real mouse motion still yields pad cursor") + check(imp._padCursorActive, "NX ignores real mouse drift for yield") + imp:parkNxPointerForHost() +end + +-- ------- NX: sparse stick + SDL mouse drift must not flicker the overlay + +do + mouseX, mouseY = 100, 100 + local imp = freshImporter(true) + imp._padCursor.x, imp._padCursor.y = 100, 100 + local flickers = 0 + for i = 1, 60 do + mouseX = mouseX + 8 -- simulated SDL stick→mouse drift + if i % 2 == 1 then + imp._padAxis.leftx = 1 + else + imp._padAxis.leftx = 0 -- axis events not every frame + end + local before = imp._padCursorActive + imp:_updatePadCursor(1 / 60) + if before and not imp._padCursorActive then + flickers = flickers + 1 + end + end + eq(flickers, 0, "NX sparse stick + mouse drift causes zero pad flickers") + check(imp._padCursorActive, "NX pad stays active after sparse stick run") + -- dt clamp: a 0.2s hitch must not move more than a 1/30 step + local xBefore = imp._padCursor.x + imp._padAxis.leftx = 1 + imp:_updatePadCursor(0.2) + local moved = imp._padCursor.x - xBefore + local maxStep = 560 * (1 / 30) + 0.01 + check(moved <= maxStep, "NX pad cursor dt is clamped at 1/30") imp:parkNxPointerForHost() end @@ -140,6 +170,11 @@ do 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") + -- Desktop yield still drops the pad when the real mouse moves (stick released). + imp._padAxis.leftx = 0 + mouseX, mouseY = mouseX + 20, mouseY + 20 + imp:_updatePadCursor(1 / 60) + check(not imp._padCursorActive, "desktop real mouse motion still yields pad") end -- ------- Metrics: setPosition counts + pad-update cost (NX vs desktop) @@ -193,14 +228,21 @@ do "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 then", 1, true) ~= nil, + "NX skips desktop mouse-yield path") check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil, "RomImporter skips setPosition on NX") + check(impSrc:find("dt > 1 / 30", 1, true) ~= nil, + "NX clamps pad cursor dt") local mainSrc = read("main.lua") check(mainSrc:find("parkNxPointerForHost", 1, true) ~= nil, "openEditor parks NX pointer before save editor") + + check(view:find("math.floor(x + 0.5)", 1, true) ~= nil, + "NX pad cursor draw is pixel-snapped") + check(view:find('strategy = "periodic"', 1, true) ~= nil, + "NX softens FlexLove GC strategy") end T.finish("launcher_nx_pad_cursor")