-- 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 (NX ignores mouse yield entirely). imp:_updatePadCursor(1 / 60) -- A / activate without stick motion (clickAt path). imp:_activatePadCursor() imp:_updatePadCursor(1 / 60) check(imp._padCursorActive, "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") -- System mouse drift must NOT yield on NX (SDL stick→mouse / touch noise). mouseX, mouseY = 80, 90 imp:_updatePadCursor(1 / 60) 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 -- ------- 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 -- ------- Overlay handoff / resume (Edit Save + Touch Controls) do mouseX, mouseY = 9, 10 local imp = freshImporter(true) imp.launcher = true stickRight(imp, 3) check(imp._padCursorActive, "pad active before overlay handoff") check(imp._nxPointerBridge, "bridge on before overlay handoff") imp:prepareOverlayHandoff() check(not imp._padCursorActive, "prepareOverlayHandoff clears pad") check(not imp._nxPointerBridge, "prepareOverlayHandoff clears NX bridge") check(imp._flex == nil, "prepareOverlayHandoff clears flex without FlexLove") local gx, gy = love.mouse.getPosition() eq(gx, 9, "after overlay handoff getPosition is real mouse X") eq(gy, 10, "after overlay handoff getPosition is real mouse Y") local prevCount = love.joystick and love.joystick.getJoystickCount love.joystick = love.joystick or {} love.joystick.getJoystickCount = function() return 1 end imp:resumeAfterOverlay() check(imp._padCursorActive, "NX resumeAfterOverlay re-arms pad with a stick") love.joystick.getJoystickCount = function() return 0 end imp._padCursorActive = false imp:resumeAfterOverlay() check(not imp._padCursorActive, "resumeAfterOverlay stays latent with no stick") love.joystick.getJoystickCount = prevCount local desk = freshImporter(false) desk.launcher = true desk._padCursorActive = true desk:prepareOverlayHandoff() check(not desk._padCursorActive, "desktop prepareOverlayHandoff clears pad too") 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") -- 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) 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") -- The perf guards these lines used to assert were FlexLove's: turning its -- per-frame profiler off and softening its GC strategy, because the -- immediate-mode tree rebuild allocated a full element graph every frame. -- FlexLove is gone; the kit has no profiler and no GC strategy to tune -- because it does not allocate per frame. What is worth guarding now is -- that the dependency does not come back -- on NX it was the single -- largest frame cost in the launcher. -- Test the DEPENDENCY, not the word: the file's header comment explains -- what it replaced, and that history is worth keeping. check(view:find("libs.flexlove", 1, true) == nil, "the launcher view does not require FlexLove") check(view:find("FlexLove%.%a") == nil, "the launcher view makes no FlexLove calls") check(view:find('require("src.ui.kit.Kit")', 1, true) ~= nil, "the launcher view draws with the shared UI kit") local hasLib = io.open("libs/flexlove/FlexLove.lua", "r") if hasLib then hasLib:close() end check(hasLib == nil, "the FlexLove library is not vendored any more") 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("function RomImporter:prepareOverlayHandoff", 1, true) ~= nil, "RomImporter exports prepareOverlayHandoff") check(impSrc:find("function RomImporter:resumeAfterOverlay", 1, true) ~= nil, "RomImporter exports resumeAfterOverlay") 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("prepareOverlayHandoff", 1, true) ~= nil, "openEditor / Touch Controls use prepareOverlayHandoff") check(mainSrc:find("resumeAfterOverlay", 1, true) ~= nil, "close paths resume the launcher pad cursor") check(view:find("math.floor(x + 0.5)", 1, true) ~= nil, "NX pad cursor draw is pixel-snapped") -- Pagination replaced scrolling, which is what removes the per-frame cost -- that the old GC tuning was compensating for: a page's row count is -- bounded by the viewport, so a long list costs what a short one does. check(view:find("Kit.pager(", 1, true) ~= nil, "launcher lists paginate instead of scrolling") check(view:find("Kit.rowsThatFit(", 1, true) ~= nil, "page size is derived from the real viewport height") end T.finish("launcher_nx_pad_cursor")