From 237f7b5fc9042c0552d7b873bef944251bf4b2ec Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 11:54:54 -0400 Subject: [PATCH] Re-prime Joy-Con optical motion after lost surface tracking An initial lifted report or rejected surface sample must not seed the next valid delta. Drop the optical counter baseline and fractional residue when tracking is unusable, then use the first valid report only to rearm. Ordinary tracked counter wrapping and button ownership remain unchanged. Add regressions for both Joy-Con halves, initial contact, lift, out-of-range distance, poor quality, counter jumps/wrap and residual clearing. They fail on the preceding MouseController and pass with the correction. All existing assertions are retained. This affects the opt-in dashboard pointer adapter only; no core, native SDL, protocol, permissions or packaging change. Locally verified on Linux with Swift 6.2.1 and faked CGEvent posting; native Apple-SDK CI and physical acceptance remain distinct. --- .../Output/MouseController.swift | 5 ++- tests/mouse/MouseTests.swift | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/Switch2KitApp/Output/MouseController.swift b/Sources/Switch2KitApp/Output/MouseController.swift index fb9f8bc..68f1e11 100644 --- a/Sources/Switch2KitApp/Output/MouseController.swift +++ b/Sources/Switch2KitApp/Output/MouseController.swift @@ -50,11 +50,14 @@ final class MouseController: @unchecked Sendable { defer { units[serial] = unit } let dx = Self.wrapDiff(state.mouseX, unit.lastX), dy = Self.wrapDiff(state.mouseY, unit.lastY) unit.lastX = state.mouseX; unit.lastY = state.mouseY - guard unit.primed else { unit.primed = true; return false } guard state.liftDistance != 0 && state.liftDistance < 1000 && state.surfaceQuality < 4000 else { + // Lost tracking breaks the counter baseline as well as fractional motion. + // Reacquisition may jump/wrap counters; its first usable report is not a delta. + unit.primed = false unit.residualX = 0; unit.residualY = 0 return false } + guard unit.primed else { unit.primed = true; return false } let scale = 0.35 * configuration.mouseSensitivity let x = Double(dx) * scale + unit.residualX, y = Double(dy) * scale + unit.residualY let moveX = x.rounded(.towardZero), moveY = y.rounded(.towardZero) diff --git a/tests/mouse/MouseTests.swift b/tests/mouse/MouseTests.swift index 0b48ebf..3f3c6c8 100644 --- a/tests/mouse/MouseTests.swift +++ b/tests/mouse/MouseTests.swift @@ -53,6 +53,48 @@ final class CGEvent { state.mouseX -= 1; precondition(!input()) } precondition(!mouse.handle(serial: "pro", model: .proController2, state: state, configuration: config)) + surfaceReacquisition() print("PASS accepted pointer motion, noise, lift/quality, wrap, disabled/denied, edge clamping and event failure") } + static func surfaceReacquisition() { + for model in [Switch2.Model.joyCon2Left, .joyCon2Right] { + let mouse = MouseController() + mouse.updateContext(permission: true, screens: []) + var config = ControllerConfiguration(); config.mouseEnabled = true + var state = ControllerState() + func input() -> Bool { + mouse.handle(serial: "surface-test", model: model, state: state, configuration: config) + } + CGEvent.cursor = CGPoint(x: 100, y: 100) + state.mouseX = 1000; state.mouseY = 2000 + // An initial no-surface report must not prime a usable counter baseline. + precondition(!input()) + state.liftDistance = 10; state.mouseX = 40000; state.mouseY = 100 + precondition(!input() && CGEvent.cursor == CGPoint(x: 100, y: 100), + "First contact after lifted reports only establishes the optical baseline") + state.mouseX += 10 + precondition(input() && CGEvent.cursor.x == 103) + for invalid in 0..<3 { + let before = CGEvent.cursor + state.liftDistance = invalid == 0 ? 0 : invalid == 1 ? 1000 : 10 + state.surfaceQuality = invalid == 2 ? 4000 : 0 + state.mouseX &+= 30000; state.mouseY &+= 30000 + precondition(!input() && CGEvent.cursor == before) + // Counters may jump, including wrapping, while tracking was unusable. + state.liftDistance = 10; state.surfaceQuality = 0 + state.mouseX = UInt16.max; state.mouseY = 500 + precondition(!input() && CGEvent.cursor == before, + "Reacquisition cannot turn an untracked interval into pointer movement") + state.mouseX = 3 + precondition(input() && CGEvent.cursor.x == before.x + 1, + "A following tracked report retains modulo counter movement") + // No fractional residue from before loss or the discarded interval survives. + state.mouseX += 1 + precondition(!input()) + } + mouse.reset() + } + print("PASS both Joy-Con halves re-prime after lift, out-of-range surface and low quality") + } + }