diff --git a/Cargo.lock b/Cargo.lock index 426e9c93..22bc2523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,7 +3046,6 @@ dependencies = [ "mdns-sd", "pf-client-core", "punktfunk-core", - "sdl3", "serde", "serde_json", "tracing", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index a9e8994b..dd98af48 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -241,7 +241,10 @@ private fun resolveDir(s: NavInputState): NavDir? { if (s.hatY >= 0.5f) return NavDir.DOWN if (s.hatX <= -0.5f) return NavDir.LEFT if (s.hatX >= 0.5f) return NavDir.RIGHT - return if (abs(s.stickY) >= abs(s.stickX)) { + // Horizontal wins an exact |x| == |y| diagonal tie (Y must be strictly greater to take the + // vertical branch), matching the SDL core and Apple nav so a perfect 45° push resolves the + // same on every client. + return if (abs(s.stickY) > abs(s.stickX)) { when { s.stickY <= -STICK_HIGH -> NavDir.UP s.stickY >= STICK_HIGH -> NavDir.DOWN diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index a98f48a2..70b8b63a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -127,12 +127,12 @@ class MainActivity : ComponentActivity() { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { val bit = Gamepad.buttonBit(event.keyCode) if (bit != 0) { - // The router forwards the bit on this device's own wire pad index, tracks held - // state per pad, and reports when the emergency-exit chord (Select + Start + L1 + - // R1) completed on any one pad (a couch user has no keyboard/Back). - if (gamepadRouter?.onButton(event, bit) == true) { - requestStreamExit?.let { exit -> window.decorView.post { exit() } } - } + // The router forwards the bit on this device's own wire pad index and tracks held + // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled + // inside the router: holding it for ~1.5 s fires router.onExitChord (wired in + // StreamScreen), so a couch user with no keyboard/Back can still leave — but an + // accidental brush of the four buttons no longer quits instantly. + gamepadRouter?.onButton(event, bit) return true // consumed } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 83444567..3fbcf4a7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -180,13 +180,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { val router = GamepadRouter(context, handle, initialSettings.gamepad) activity?.gamepadRouter = router // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips - // the keep-alive linger), unlike a host-ended / backgrounded drop. + // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it + // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream + // the same way the Back gesture does. activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + router.onExitChord = { activity?.requestStreamExit?.invoke() } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // index via the router; poll threads stopped + joined before the router is released and the // session closed. val feedback = GamepadFeedback(handle, router).also { it.start() } + // Free a disconnected controller's rumble/lights bindings promptly (else the open lights + // session leaks until the session ends). The router owns hot-plug; the feedback owns the binds. + router.onSlotClosed = feedback::onDeviceRemoved onDispose { closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 608016c6..08c0a431 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -219,14 +219,31 @@ object Gamepad { ), ) - // HAT → dpad button transitions (track previous, emit only the deltas). - val hx = sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)) + // HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad + // tap (press+release inside one batch window) lives only in the historical samples — the + // final getAxisValue would show the HAT already back at rest and miss the tap entirely. + // Feed every historical HAT sample (oldest→newest) through the same transition logic + // before the current one, so each edge is emitted. (Sticks/triggers stay latest-wins: + // only the final value matters for an analog axis.) + for (h in 0 until event.historySize) { + applyHat( + sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_X, h)), + sign(event.getHistoricalAxisValue(MotionEvent.AXIS_HAT_Y, h)), + ) + } + applyHat( + sign(event.getAxisValue(MotionEvent.AXIS_HAT_X)), + sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)), + ) + } + + /** Emit dpad button deltas for one HAT sample (`hx`/`hy` each −1/0/+1), tracking held state. */ + private fun applyHat(hx: Int, hy: Int) { if (hx != hatX) { if (hatX < 0) btn(BTN_DPAD_LEFT, false) else if (hatX > 0) btn(BTN_DPAD_RIGHT, false) if (hx < 0) btn(BTN_DPAD_LEFT, true) else if (hx > 0) btn(BTN_DPAD_RIGHT, true) hatX = hx } - val hy = sign(event.getAxisValue(MotionEvent.AXIS_HAT_Y)) if (hy != hatY) { if (hatY < 0) btn(BTN_DPAD_UP, false) else if (hatY > 0) btn(BTN_DPAD_DOWN, false) if (hy < 0) btn(BTN_DPAD_UP, true) else if (hy > 0) btn(BTN_DPAD_DOWN, true) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 3d731053..76583034 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -64,10 +64,16 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute private var rumbleThread: Thread? = null private var hidoutThread: Thread? = null - // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is touched ONLY by the - // rumble thread and lightBinds ONLY by the hidout thread while running; stop() reads both from the - // main thread AFTER joining those threads (join establishes the happens-before), so plain maps are - // race-free. A null value caches "this controller has no vibrator / no controllable lights". + // Per-controller bindings, keyed by device id, built lazily. rumbleBinds is written by the rumble + // thread and lightBinds by the hidout thread while running; [onDeviceRemoved] also evicts+closes + // from the MAIN thread on a hot-unplug, and stop() clears both from the main thread after joining + // the threads. That main-vs-poll concurrency is why every access goes through `bindsLock` (a plain + // HashMap can corrupt under a concurrent structural write, and ConcurrentHashMap can't hold the + // null value that caches "this controller has no vibrator / no controllable lights"). The lock + // guards only the map ops — rendering runs on the returned reference outside it; a stale reference + // is harmless (a closed LightsSession's requestLights and a cancelled Vibrator are runCatching'd + // no-ops). A null value caches the negative result so a pad with no hardware isn't re-probed. + private val bindsLock = Any() private val rumbleBinds = HashMap() private val lightBinds = HashMap() @@ -122,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute rumbleThread = null hidoutThread = null // Threads are dead — drop any held rumble and close every lights session. - for (b in rumbleBinds.values) b?.let { - runCatching { it.vm?.cancel() } - runCatching { it.legacy?.cancel() } + synchronized(bindsLock) { + for (b in rumbleBinds.values) b?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } + rumbleBinds.clear() + lightBinds.clear() + } + } + + /** + * Evict and release the bindings for a controller that just disconnected — invoked from + * [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its + * `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session + * immediately instead of leaking it until [stop]. A no-op for a device with no cached binding. + * The next feedback for that pad index rebinds against whatever controller now holds it. + */ + // Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists + // under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers. + @Suppress("MissingPermission", "NewApi") + fun onDeviceRemoved(deviceId: Int) { + synchronized(bindsLock) { + rumbleBinds.remove(deviceId)?.let { + runCatching { it.vm?.cancel() } + runCatching { it.legacy?.cancel() } + } + lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } } } - for (b in lightBinds.values) b?.let { runCatching { it.session.close() } } - rumbleBinds.clear() - lightBinds.clear() } // ---- Rumble ---- @@ -136,10 +164,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute /** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */ private fun rumbleBindFor(pad: Int): RumbleBind? { val dev = router?.deviceForPad(pad) ?: return null - if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] - val bind = bindRumble(dev) - rumbleBinds[dev.id] = bind - return bind + synchronized(bindsLock) { + if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id] + val bind = bindRumble(dev) + rumbleBinds[dev.id] = bind + return bind + } } private fun bindRumble(dev: InputDevice): RumbleBind? { @@ -184,7 +214,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute } val combo = CombinedVibration.startParallel() if (bind.amplitudeControlled && bind.ids.size >= 2) { - // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). + // Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left + // (XInput/Moonlight convention). Android does not guarantee the order of + // VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would + // invert the feel: the stronger amplitude drives the physically-lighter motor. + // Failure mode is tactile only — both motors still fire, nothing silences or + // crashes — so this stays the default pending per-pad on-glass verification (G20). + // ids beyond the first two (rare) are left alone here. if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs)) if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs)) } else { @@ -217,9 +253,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute } // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it - // self-terminates on a lost stop; cancel on zero. + // self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot` + // throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0 + // (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0) + // stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching, + // so an uncaught throw here would kill the whole rumble poll thread. private fun oneShot(amp: Int, durationMs: Long): VibrationEffect = - VibrationEffect.createOneShot(durationMs, amp) + VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp) // ---- HID output ---- @@ -268,10 +308,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute private fun lightBindFor(pad: Int): LightBind? { if (Build.VERSION.SDK_INT < 33) return null val dev = router?.deviceForPad(pad) ?: return null - if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] - val bind = bindLights(dev) - lightBinds[dev.id] = bind - return bind + synchronized(bindsLock) { + if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id] + val bind = bindLights(dev) + lightBinds[dev.id] = bind + return bind + } } private fun bindLights(dev: InputDevice): LightBind? { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 2e3dee95..ce80b1fe 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -44,6 +44,23 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */ private val slots = ConcurrentHashMap() + /** + * Invoked (main thread) with the deviceId whenever a slot closes — hot-unplug or session teardown. + * `StreamScreen` wires this to `GamepadFeedback.onDeviceRemoved` so a disconnected pad's rumble / + * lights bindings are released promptly instead of leaking until the feedback threads stop. + */ + var onSlotClosed: ((deviceId: Int) -> Unit)? = null + + /** + * Invoked (main thread) when the emergency-exit chord has been HELD for [EXIT_HOLD_MS] — the caller + * leaves the stream. `StreamScreen` wires this to the deliberate-quit exit. + */ + var onExitChord: (() -> Unit)? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ + private var pendingExit: Runnable? = null + private val inputManager = context.getSystemService(InputManager::class.java) private val listener = object : InputManager.InputDeviceListener { override fun onInputDeviceAdded(deviceId: Int) { @@ -55,7 +72,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett } init { - inputManager?.registerInputDeviceListener(listener, Handler(Looper.getMainLooper())) + inputManager?.registerInputDeviceListener(listener, mainHandler) // Open a slot for every controller already connected when the session starts — the pads that // will never fire onInputDeviceAdded during this session; their Arrival lands before any input. for (id in InputDevice.getDeviceIds()) { @@ -66,28 +83,55 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** * One gamepad button transition for the device that produced [event] (already resolved to BTN_* * bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the - * slot's pad index, tracks held state, and returns true when this press completed the emergency - * stream-exit chord (Select + Start + L1 + R1) on THIS pad — the caller then leaves the stream - * (mirrors the Linux client's escape chord: any one controller can leave). + * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select + + * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly; + * [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is + * ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave. */ - fun onButton(event: KeyEvent, bit: Int): Boolean { - val slot = slotFor(event.device) ?: return false + fun onButton(event: KeyEvent, bit: Int) { + val slot = slotFor(event.device) ?: return when (event.action) { KeyEvent.ACTION_DOWN -> { // repeatCount guard: don't re-send a held button as auto-repeat. if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) slot.held = slot.held or bit - if (slot.held and EXIT_CHORD == EXIT_CHORD) { - slot.held = 0 - return true - } + // Full chord now held on this pad → start the hold countdown (idempotent while held). + if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit() } KeyEvent.ACTION_UP -> { NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) slot.held = slot.held and bit.inv() + // A chord button lifted before the hold elapsed → cancel, unless another pad still + // holds the full chord. + if (bit and EXIT_CHORD != 0 && slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) { + disarmExit() + } } } - return false + } + + /** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */ + private fun armExit() { + if (pendingExit != null) return // already counting down + val r = Runnable { + pendingExit = null + // Fire only if the chord survived the full hold on some pad. + val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD } + if (held.isNotEmpty()) { + // Release the held buttons + zero the axes on every triggering pad so nothing sticks + // host-side once we leave, then signal the deliberate exit. + for (s in held) releaseHeld(s) + onExitChord?.invoke() + } + } + pendingExit = r + mainHandler.postDelayed(r, EXIT_HOLD_MS) + } + + /** Cancel a pending exit-chord hold timer. */ + private fun disarmExit() { + pendingExit?.let { mainHandler.removeCallbacks(it) } + pendingExit = null } /** @@ -124,6 +168,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett */ fun release() { inputManager?.unregisterInputDeviceListener(listener) + disarmExit() // drop any pending exit-chord timer so it can't fire after teardown // Snapshot the ids first — closeSlot mutates the map. for (id in slots.keys.toList()) closeSlot(id) } @@ -173,6 +218,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett val slot = slots.remove(deviceId) ?: return releaseHeld(slot) NativeBridge.nativeSendGamepadRemove(handle, slot.index) + // If this pad was mid-exit-chord, its removal may have left no pad holding it — drop the timer. + if (slots.values.none { it.held and EXIT_CHORD == EXIT_CHORD }) disarmExit() + // Release this controller's feedback bindings (close its lights session / cancel rumble). + onSlotClosed?.invoke(deviceId) } /** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */ @@ -200,5 +249,8 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett /** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */ const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB + + /** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */ + const val EXIT_HOLD_MS = 1500L } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index f15b02c2..b5224180 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -257,7 +257,12 @@ public final class GamepadCapture { /// tagged with the slot's wire pad index. private func sync(_ slot: Slot, _ g: GCExtendedGamepad) { guard !suspended else { return } - let newButtons = Self.buttonMask(g) + // guide is driven separately (`sendGuide`, off the Home handler) and deliberately kept out + // of `buttonMask`. Preserve its current held state here so the XOR diff below never sees it + // as "changed" — otherwise the first stick/button move after a guide press would emit a + // spurious guide-UP while the button is still physically held (and drop the bit from + // `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it. + let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide) let changed = newButtons ^ slot.buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { @@ -266,12 +271,12 @@ public final class GamepadCapture { slot.buttons = newButtons } let newAxes: [Int32] = [ - Int32((g.leftThumbstick.xAxis.value * 32767).rounded()), - Int32((g.leftThumbstick.yAxis.value * 32767).rounded()), - Int32((g.rightThumbstick.xAxis.value * 32767).rounded()), - Int32((g.rightThumbstick.yAxis.value * 32767).rounded()), - Int32((g.leftTrigger.value * 255).rounded()), - Int32((g.rightTrigger.value * 255).rounded()), + Int32(g.leftThumbstick.xAxis.value * 32767), + Int32(g.leftThumbstick.yAxis.value * 32767), + Int32(g.rightThumbstick.xAxis.value * 32767), + Int32(g.rightThumbstick.yAxis.value * 32767), + Int32(g.leftTrigger.value * 255), + Int32(g.rightTrigger.value * 255), ] for (i, v) in newAxes.enumerated() where v != slot.axes[i] { connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad)) @@ -300,11 +305,15 @@ public final class GamepadCapture { if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } - // The share/create/capture element (Xbox Series share, a clone pad's screenshot button — - // e.g. the GameSir G8's, below its d-pad) folds into back/select too. On pads that expose - // the create button BOTH as buttonOptions and as the share element this OR is harmless — - // same wire bit. - if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.back } + // The dedicated share/create/capture element (Xbox-Series Share, DualSense Create, a clone + // pad's screenshot button — e.g. the GameSir G8's, below its d-pad) → the wire's capture + // bit, matching the Rust client's `Button::Misc1 => wire::BTN_MISC1`. On an Xbox-Series pad + // this is a button physically DISTINCT from View (buttonOptions, above), so it must not + // collapse onto back — the host reads MISC1 as its own control (DualSense mute / Steam + // quick-access). Caveat: a pad that surfaces ONE physical button as both buttonOptions and + // this share element now emits back+misc1 for it — harmless on a plain xpad session (no + // misc button) and rare otherwise. NOTE: on-glass verify on a real Xbox-Series pad. + if g.buttons[GCInputButtonShare]?.isPressed == true { b |= GamepadWire.misc1 } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift index fb6d21e6..d162ebeb 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift @@ -140,7 +140,9 @@ public final class GamepadMenuInput { let stick = gamepad.leftThumbstick let x = stick.xAxis.value let y = stick.yAxis.value - if abs(x) > abs(y), abs(x) > deadzone { + // Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android + // nav so a perfect 45° push resolves to the same direction on every client. + if abs(x) >= abs(y), abs(x) > deadzone { return x > 0 ? .right : .left } else if abs(y) > deadzone { return y > 0 ? .up : .down diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift index 0d90c7cc..b2fae0d0 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -26,11 +26,27 @@ public enum GamepadWire { public static let y: UInt32 = 0x8000 /// DualSense touchpad click (Moonlight's extended-button bit position). public static let touchpadClick: UInt32 = 0x10_0000 + /// Misc / capture button — Xbox-Series Share, DualSense Create, Steam-Deck quick-access + /// (Moonlight's extended-button namespace; `input::gamepad::BTN_MISC1`). The host routes it to + /// the DualSense mute / Steam quick-access menu; a plain virtual xpad has no such button. + public static let misc1: UInt32 = 0x0020_0000 + /// Back-grip paddles (Xbox Elite P1–P4 / DualSense Edge / Steam-Deck L4-L5-R4-R5), in + /// Moonlight's extended-button namespace (`input::gamepad::BTN_PADDLE1..4`, R4/L4/R5/L5). + /// Defined for wire completeness and pinned by the tests; `GamepadCapture.buttonMask` does not + /// read them yet — the GameController `paddleButton1..4` ↔ BTN_PADDLE physical correspondence + /// needs confirming on a real Elite pad first (see the gamepad-review-cleanup plan, G22), so + /// they are intentionally absent from `allButtons` until that forwarding lands. + public static let paddle1: UInt32 = 0x0001_0000 + public static let paddle2: UInt32 = 0x0002_0000 + public static let paddle3: UInt32 = 0x0004_0000 + public static let paddle4: UInt32 = 0x0008_0000 + /// Every button `buttonMask`/`sendGuide` can set — walked by `sync`'s transition diff and by + /// `flush` on release. Paddles are excluded until their capture lands (see above). public static let allButtons: [UInt32] = [ dpadUp, dpadDown, dpadLeft, dpadRight, start, back, leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, - a, b, x, y, touchpadClick, + a, b, x, y, touchpadClick, misc1, ] public static let axisLSX: UInt32 = 0 diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift index eee4c8c8..f93e8f38 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift @@ -27,11 +27,16 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.x, 0x4000) XCTAssertEqual(GamepadWire.y, 0x8000) XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000) + XCTAssertEqual(GamepadWire.misc1, 0x0020_0000) // Every button is enumerated exactly once (releaseAll walks this list). let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 } - XCTAssertEqual(combined, 0x0010_F7FF) - XCTAssertEqual(GamepadWire.allButtons.count, 16) + XCTAssertEqual(combined, 0x0030_F7FF) + XCTAssertEqual(GamepadWire.allButtons.count, 17) XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count) + // Paddles are defined but not yet forwarded, so they stay out of allButtons for now. + for paddle in [GamepadWire.paddle1, GamepadWire.paddle2, GamepadWire.paddle3, GamepadWire.paddle4] { + XCTAssertFalse(GamepadWire.allButtons.contains(paddle)) + } // Axis ids. XCTAssertEqual(GamepadWire.axisLSX, 0) XCTAssertEqual(GamepadWire.axisLSY, 1) @@ -41,6 +46,42 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.axisRT, 5) } + func testButtonBitsMatchTheCABIVerbatim() { + // Assert EVERY wire constant against the generated C ABI header (punktfunk_core.h, the same + // source `punktfunk_core::input::gamepad` emits), so a Swift-side edit that drifts from the + // Rust contract fails CI — not just the handful spot-checked above. (Cross-cutting review + // finding G15: the button values were re-declared per client with only a 3-of-19 check.) + XCTAssertEqual(GamepadWire.dpadUp, UInt32(PUNKTFUNK_BTN_DPAD_UP)) + XCTAssertEqual(GamepadWire.dpadDown, UInt32(PUNKTFUNK_BTN_DPAD_DOWN)) + XCTAssertEqual(GamepadWire.dpadLeft, UInt32(PUNKTFUNK_BTN_DPAD_LEFT)) + XCTAssertEqual(GamepadWire.dpadRight, UInt32(PUNKTFUNK_BTN_DPAD_RIGHT)) + XCTAssertEqual(GamepadWire.start, UInt32(PUNKTFUNK_BTN_START)) + XCTAssertEqual(GamepadWire.back, UInt32(PUNKTFUNK_BTN_BACK)) + XCTAssertEqual(GamepadWire.leftStickClick, UInt32(PUNKTFUNK_BTN_LS_CLICK)) + XCTAssertEqual(GamepadWire.rightStickClick, UInt32(PUNKTFUNK_BTN_RS_CLICK)) + XCTAssertEqual(GamepadWire.leftShoulder, UInt32(PUNKTFUNK_BTN_LB)) + XCTAssertEqual(GamepadWire.rightShoulder, UInt32(PUNKTFUNK_BTN_RB)) + XCTAssertEqual(GamepadWire.guide, UInt32(PUNKTFUNK_BTN_GUIDE)) + XCTAssertEqual(GamepadWire.a, UInt32(PUNKTFUNK_BTN_A)) + XCTAssertEqual(GamepadWire.b, UInt32(PUNKTFUNK_BTN_B)) + XCTAssertEqual(GamepadWire.x, UInt32(PUNKTFUNK_BTN_X)) + XCTAssertEqual(GamepadWire.y, UInt32(PUNKTFUNK_BTN_Y)) + XCTAssertEqual(GamepadWire.touchpadClick, UInt32(PUNKTFUNK_BTN_TOUCHPAD)) + XCTAssertEqual(GamepadWire.misc1, UInt32(PUNKTFUNK_GAMEPAD_BTN_MISC1)) + XCTAssertEqual(GamepadWire.paddle1, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE1)) + XCTAssertEqual(GamepadWire.paddle2, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE2)) + XCTAssertEqual(GamepadWire.paddle3, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE3)) + XCTAssertEqual(GamepadWire.paddle4, UInt32(PUNKTFUNK_GAMEPAD_BTN_PADDLE4)) + // Axis ids and pad count share the same header. + XCTAssertEqual(GamepadWire.axisLSX, UInt32(PUNKTFUNK_AXIS_LS_X)) + XCTAssertEqual(GamepadWire.axisLSY, UInt32(PUNKTFUNK_AXIS_LS_Y)) + XCTAssertEqual(GamepadWire.axisRSX, UInt32(PUNKTFUNK_AXIS_RS_X)) + XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y)) + XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT)) + XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT)) + XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS)) + } + func testPadIndexRidesFlagsOnEveryPerPadEvent() { // The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis. let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3) diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 6db0d0de..87f1e626 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -62,10 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63 # decode + present live in the spawned punktfunk-session binary.) ffmpeg-next = "8" -# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform; -# built from source via the bundled CMake on Windows (no system SDL3). -sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } - +# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service +# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3 +# itself (built from source via the bundled CMake on Windows) is pulled transitively by +# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here. mdns-sd = "0.20" async-channel = "2" serde = { version = "1", features = ["derive"] } diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 716e2e4d..1dee48d9 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -32,9 +32,9 @@ mod stream; mod style; use crate::discovery::{self, DiscoveredHost}; -use crate::gamepad::GamepadService; use crate::trust::{KnownHosts, Settings}; use hosts::HostsProps; +use pf_client_core::gamepad::GamepadService; use punktfunk_core::client::NativeClient; use speed::{SpeedProps, SpeedState}; use std::collections::HashMap; diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs deleted file mode 100644 index 3a957c01..00000000 --- a/clients/windows/src/gamepad.rs +++ /dev/null @@ -1,629 +0,0 @@ -//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` + -//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform, -//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled -//! CMake, since there is no system SDL3). -//! -//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the -//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while -//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion -//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real -//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). -//! Held state is zeroed on the wire when the active pad switches or the session detaches, so -//! nothing sticks down. -//! -//! This thread is also the single consumer of the rumble and HID-output pull planes. - -use punktfunk_core::client::NativeClient; -use punktfunk_core::config::GamepadPref; -use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; -use punktfunk_core::quic::{HidOutput, RichInput}; -use std::collections::HashMap; -use std::sync::mpsc::{Receiver, Sender}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived -/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in -/// rad/s and accel in m/s²; the DualSense report wants raw LSBs. -const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI; -const ACCEL_LSB_PER_G: f32 = 10_000.0; -const G: f32 = 9.80665; - -#[derive(Clone, Debug)] -pub struct PadInfo { - /// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`) - /// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the - /// spawned session binary, whose own gamepad service applies the same key. - pub key: String, - pub name: String, - /// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense, - /// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360). - pub pref: GamepadPref, -} - -impl PadInfo { - /// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger - /// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`). - fn is_dualsense(&self) -> bool { - self.pref == GamepadPref::DualSense - } - - /// A short human label for the detected pad family, shown next to the name in the settings - /// GUI's controller list ("" for a generic pad the name already describes). - pub fn kind_label(&self) -> &'static str { - match self.pref { - GamepadPref::DualSense => "DualSense", - GamepadPref::DualShock4 => "DualShock 4", - GamepadPref::XboxOne => "Xbox One", - GamepadPref::SteamDeck => "Steam Deck", - GamepadPref::SteamController => "Steam Controller", - _ => "", - } - } -} - -/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. -fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { - use sdl3::gamepad::GamepadType as T; - match t { - T::PS5 => GamepadPref::DualSense, - T::PS4 => GamepadPref::DualShock4, - T::XboxOne => GamepadPref::XboxOne, - _ => GamepadPref::Xbox360, - } -} - -enum Ctl { - Pin(Option), -} - -#[derive(Clone)] -pub struct GamepadService { - pads: Arc>>, - // `Arc>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the - // WinUI app shares it across the UI thread and the settings-pin path. - ctl: Arc>>, -} - -impl GamepadService { - pub fn start() -> GamepadService { - let pads = Arc::new(Mutex::new(Vec::new())); - let (ctl, ctl_rx) = std::sync::mpsc::channel(); - let p = pads.clone(); - if let Err(e) = std::thread::Builder::new() - .name("punktfunk-gamepad".into()) - .spawn(move || { - if let Err(e) = run(&p, &ctl_rx) { - tracing::warn!(error = %e, "gamepad service ended — pads disabled"); - } - }) - { - tracing::warn!(error = %e, "gamepad service failed to start"); - } - GamepadService { - pads, - ctl: Arc::new(Mutex::new(ctl)), - } - } - - /// Connected controllers, most recently attached first (the settings GUI's list order). - pub fn pads(&self) -> Vec { - self.pads.lock().unwrap().clone() - } - - /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic. - /// The pin survives the pad disconnecting: it re-applies the moment a matching - /// controller shows up again (same semantics as `pf-client-core`'s service). The spawned - /// `punktfunk-session` binary owns the actual forwarding; this persists the selection. - pub fn set_pinned(&self, key: Option) { - let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key)); - } -} - -fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) { - let _ = connector.send_input(&InputEvent { - kind, - _pad: [0; 3], - code, - x, - y: 0, - flags: 0, // pad index 0 — single-pad model - }); -} - -fn button_bit(b: sdl3::gamepad::Button) -> Option { - use sdl3::gamepad::Button; - Some(match b { - Button::South => wire::BTN_A, - Button::East => wire::BTN_B, - Button::West => wire::BTN_X, - Button::North => wire::BTN_Y, - Button::Back => wire::BTN_BACK, - Button::Start => wire::BTN_START, - Button::Guide => wire::BTN_GUIDE, - Button::LeftStick => wire::BTN_LS_CLICK, - Button::RightStick => wire::BTN_RS_CLICK, - Button::LeftShoulder => wire::BTN_LB, - Button::RightShoulder => wire::BTN_RB, - Button::DPadUp => wire::BTN_DPAD_UP, - Button::DPadDown => wire::BTN_DPAD_DOWN, - Button::DPadLeft => wire::BTN_DPAD_LEFT, - Button::DPadRight => wire::BTN_DPAD_RIGHT, - Button::Touchpad => wire::BTN_TOUCHPAD, - // Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button. - // PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`). - Button::RightPaddle1 => wire::BTN_PADDLE1, - Button::LeftPaddle1 => wire::BTN_PADDLE2, - Button::RightPaddle2 => wire::BTN_PADDLE3, - Button::LeftPaddle2 => wire::BTN_PADDLE4, - Button::Misc1 => wire::BTN_MISC1, - _ => return None, - }) -} - -/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput -/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255. -fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { - use sdl3::gamepad::Axis; - match axis { - Axis::LeftX => (wire::AXIS_LS_X, v as i32), - Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)), - Axis::RightX => (wire::AXIS_RS_X, v as i32), - Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)), - Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7), - Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7), - } -} - -/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host -/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits -/// select only the fields each update touches, so rumble (driven separately through SDL) and -/// untouched fields keep their state. -#[derive(Default)] -struct Ds5Feedback; - -impl Ds5Feedback { - const RIGHT_TRIGGER: usize = 10; - const LEFT_TRIGGER: usize = 21; - const PAD_LIGHTS: usize = 43; - const LED_RGB: usize = 44; - - fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] { - let mut p = [0u8; 47]; - let (flag, off) = if which == 1 { - (0x04, Self::RIGHT_TRIGGER) - } else { - (0x08, Self::LEFT_TRIGGER) - }; - p[0] = flag; - let n = effect.len().min(11); - p[off..off + n].copy_from_slice(&effect[..n]); - p - } - - fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] { - let mut p = [0u8; 47]; - p[1] = 0x04; // lightbar enable - p[Self::LED_RGB] = r; - p[Self::LED_RGB + 1] = g; - p[Self::LED_RGB + 2] = b; - p - } - - fn player_packet(bits: u8) -> [u8; 47] { - let mut p = [0u8; 47]; - p[1] = 0x10; // player-LED enable - p[Self::PAD_LIGHTS] = bits & 0x1F; - p - } -} - -struct Worker { - subsystem: sdl3::GamepadSubsystem, - opened: HashMap, - /// Connection order; the most recently connected is the auto selection. - order: Vec, - /// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup - /// so it re-applies whenever a matching pad (re)connects. - pinned: Option, - attached: Option>, - /// Wire state of the active pad — zeroed on the wire at switch/detach. - last_axis: [i32; 6], - held_buttons: Vec, - /// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad - /// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad. - held_touches: std::collections::HashSet<(u8, u8)>, - last_accel: [i16; 3], -} - -impl Worker { - fn active_id(&self) -> Option { - self.pinned - .as_deref() - .and_then(|key| { - self.order - .iter() - .rev() // prefer the most recently connected pad with this identity - .find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key)) - .copied() - }) - .or_else(|| self.order.last().copied()) - } - - fn pad_info(&self, id: u32) -> Option { - let pad = self.opened.get(&id)?; - let mut pref = pref_for_type( - self.subsystem - .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), - ); - let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0)); - // No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205, - // SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad. - if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { - pref = GamepadPref::SteamDeck; - } - let name = pad.name().unwrap_or_else(|| "Controller".into()); - Some(PadInfo { - // Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted - // `forward_pad` is applied by BOTH services (this one and the session's). - key: format!("{vid:04x}:{pid:04x}:{name}"), - name, - pref, - }) - } - - /// Zero everything the host believes is held — on pad switch and detach. - fn flush_held(&mut self) { - if let Some(c) = &self.attached { - for b in self.held_buttons.drain(..) { - send(c, InputKind::GamepadButton, b, 0); - } - for (id, v) in self.last_axis.iter_mut().enumerate() { - if *v != 0 && *v != i32::MIN { - send(c, InputKind::GamepadAxis, id as u32, 0); - } - *v = i32::MIN; - } - for (surface, finger) in self.held_touches.drain() { - let rich = if surface == 0 { - RichInput::Touchpad { - pad: 0, - finger, - active: false, - x: 0, - y: 0, - } - } else { - RichInput::TouchpadEx { - pad: 0, - surface, - finger, - touch: false, - click: false, - x: 0, - y: 0, - pressure: 0, - } - }; - let _ = c.send_rich_input(rich); - } - } else { - self.held_buttons.clear(); - self.last_axis = [i32::MIN; 6]; - self.held_touches.clear(); - } - } - - /// Sensors stream only while a session wants them (they cost USB/BT bandwidth). - fn set_sensors(&mut self, enabled: bool) { - let Some(id) = self.active_id() else { return }; - if let Some(pad) = self.opened.get_mut(&id) { - use sdl3::sensor::SensorType; - for s in [SensorType::Gyroscope, SensorType::Accelerometer] { - if unsafe { pad.has_sensor(s) } { - let _ = pad.sensor_set_enabled(s, enabled); - } - } - } - } - - /// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam - /// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and - /// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned). - fn forward_touch( - &mut self, - which: u32, - touchpad: u32, - finger: u8, - x: f32, - y: f32, - active: bool, - ) { - let Some(c) = self.attached.as_ref() else { - return; - }; - let multi = self - .opened - .get(&which) - .map(|p| p.touchpads_count() >= 2) - .unwrap_or(false); - let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); - let surface = if multi { (touchpad as u8) + 1 } else { 0 }; - let rich = if multi { - RichInput::TouchpadEx { - pad: 0, - surface, - finger, - touch: active, - click: false, - x: (cx * 65535.0 - 32768.0) as i16, - y: (cy * 65535.0 - 32768.0) as i16, - pressure: 0, - } - } else { - RichInput::Touchpad { - pad: 0, - finger, - active, - x: (cx * 65535.0) as u16, - y: (cy * 65535.0) as u16, - } - }; - let _ = c.send_rich_input(rich); - if active { - self.held_touches.insert((surface, finger)); - } else { - self.held_touches.remove(&(surface, finger)); - } - } -} - -#[allow(clippy::too_many_lines)] -fn run(pads_out: &Mutex>, ctl: &Receiver) -> Result<(), String> { - // Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own - // thread. - sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); - sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); - // Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the - // paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. - sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); - sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1"); - let sdl = sdl3::init().map_err(|e| e.to_string())?; - let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; - let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; - - let mut w = Worker { - subsystem, - opened: HashMap::new(), - order: Vec::new(), - pinned: None, - attached: None, - last_axis: [i32::MIN; 6], - held_buttons: Vec::new(), - held_touches: std::collections::HashSet::new(), - last_accel: [0; 3], - }; - - let publish = |w: &Worker| { - let mut list: Vec = w.order.iter().filter_map(|&id| w.pad_info(id)).collect(); - list.reverse(); // most recent first — the Settings list order - *pads_out.lock().unwrap() = list; - }; - - loop { - // Control plane from the UI thread. - loop { - match ctl.try_recv() { - Ok(Ctl::Pin(key)) => { - let before = w.active_id(); - w.pinned = key; - if w.active_id() != before { - w.flush_held(); - if w.attached.is_some() { - w.set_sensors(true); - } - } - publish(&w); - } - Err(std::sync::mpsc::TryRecvError::Empty) => break, - Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone - } - } - - while let Some(event) = pump.poll_event() { - use sdl3::event::Event; - let active = w.active_id(); - match event { - Event::ControllerDeviceAdded { which, .. } => { - if !w.opened.contains_key(&which) { - match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) { - Ok(pad) => { - tracing::info!( - name = pad.name().unwrap_or_default(), - "gamepad attached" - ); - w.opened.insert(which, pad); - w.order.push(which); - if w.attached.is_some() && w.active_id() == Some(which) { - w.set_sensors(true); - } - publish(&w); - } - Err(e) => tracing::warn!(error = %e, "gamepad open failed"), - } - } - } - Event::ControllerDeviceRemoved { which, .. } => { - if w.opened.remove(&which).is_some() { - w.order.retain(|&id| id != which); - if active == Some(which) { - w.flush_held(); - } - tracing::info!("gamepad detached"); - publish(&w); - } - } - Event::ControllerButtonDown { which, button, .. } - if active == Some(which) && w.attached.is_some() => - { - if let Some(bit) = button_bit(button) { - w.held_buttons.push(bit); - send( - w.attached.as_ref().unwrap(), - InputKind::GamepadButton, - bit, - 1, - ); - } - } - Event::ControllerButtonUp { which, button, .. } - if active == Some(which) && w.attached.is_some() => - { - if let Some(bit) = button_bit(button) { - w.held_buttons.retain(|&b| b != bit); - send( - w.attached.as_ref().unwrap(), - InputKind::GamepadButton, - bit, - 0, - ); - } - } - Event::ControllerAxisMotion { - which, axis, value, .. - } if active == Some(which) && w.attached.is_some() => { - let (id, v) = axis_value(axis, value); - if w.last_axis[id as usize] != v { - w.last_axis[id as usize] = v; - send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v); - } - } - // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy - // `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface. - Event::ControllerTouchpadDown { - which, - touchpad, - finger, - x, - y, - .. - } - | Event::ControllerTouchpadMotion { - which, - touchpad, - finger, - x, - y, - .. - } if active == Some(which) && w.attached.is_some() => { - w.forward_touch(which, touchpad as u32, finger as u8, x, y, true); - } - Event::ControllerTouchpadUp { - which, - touchpad, - finger, - x, - y, - .. - } if active == Some(which) && w.attached.is_some() => { - w.forward_touch(which, touchpad as u32, finger as u8, x, y, false); - } - // Motion: accel events update the cache; each gyro event ships a sample (the - // DualSense reports both at ~250 Hz). Scale convention shared with the other - // clients — sign/scale derived, not yet live-verified. - Event::ControllerSensorUpdated { - which, - sensor, - data, - .. - } if active == Some(which) && w.attached.is_some() => { - use sdl3::sensor::SensorType; - match sensor { - SensorType::Accelerometer => { - for (i, v) in data.iter().enumerate() { - w.last_accel[i] = - (v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16; - } - } - SensorType::Gyroscope => { - let mut gyro = [0i16; 3]; - for (i, v) in data.iter().enumerate() { - gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; - } - let _ = - w.attached - .as_ref() - .unwrap() - .send_rich_input(RichInput::Motion { - pad: 0, - gyro, - accel: w.last_accel, - }); - } - _ => {} - } - } - _ => {} - } - } - - // Feedback planes (this thread is their single consumer). Rumble arrives as - // self-terminating v2 envelopes: the host renews an active level and lets an abandoned one - // lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences - // at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the - // proven 5 s duration and rely on its periodic re-send as before. - if let Some(connector) = w.attached.clone() { - while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) { - if pad == 0 { - // Floor the lease so a jittered renewal can't gap the actuator between writes. - let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240)); - if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) { - // Surface a failed SDL rumble write: a swallowed error here (DualSense not in - // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The - // host logs the send side on 0xCA, so the two together pinpoint host-game vs - // client-render. - if let Err(e) = p.set_rumble(low, high, dur_ms) { - tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); - } else { - tracing::debug!(low, high, "rumble: rendered"); - } - } else { - tracing::debug!(low, high, "rumble: received but no active pad to render"); - } - } - } - while let Ok(hid) = connector.next_hidout(Duration::ZERO) { - let Some(id) = w.active_id() else { continue }; - let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense()); - let Some(pad) = w.opened.get_mut(&id) else { - continue; - }; - match hid { - HidOutput::Led { pad: 0, r, g, b } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); - } - HidOutput::Led { pad: 0, r, g, b } => { - let _ = pad.set_led(r, g, b); - } - HidOutput::PlayerLeds { pad: 0, bits } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::player_packet(bits)); - } - HidOutput::Trigger { - pad: 0, - which, - ref effect, - } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect)); - } - _ => {} - } - } - } - - std::thread::sleep(Duration::from_millis(if w.attached.is_some() { - 2 - } else { - 30 - })); - } -} diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 4e28917c..c6340683 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -24,8 +24,6 @@ mod app; #[cfg(windows)] mod discovery; #[cfg(windows)] -mod gamepad; -#[cfg(windows)] mod gpu; #[cfg(windows)] mod probe; @@ -85,7 +83,11 @@ fn main() { tracing::error!(error = %e, "Windows App SDK bootstrap failed"); std::process::exit(1); } - let gamepad = gamepad::GamepadService::start(); + // The shared SDL gamepad service (pf-client-core). The shell only enumerates pads (Settings + // list) and persists the pin; the spawned punktfunk-session runs the SAME service and does the + // actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays + // hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off). + let gamepad = pf_client_core::gamepad::GamepadService::start(); if let Err(e) = app::run(identity, gamepad) { tracing::error!(error = %e, "WinUI app failed"); std::process::exit(1); diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 9ed548dd..cd72c225 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -71,6 +71,15 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); /// left untouched. const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40; +/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host +/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh, +/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every +/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely) +/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s, +/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its +/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]). +const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000; + /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple /// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). const MENU_DEADZONE: u16 = 16384; @@ -558,9 +567,9 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option { fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { use sdl3::gamepad::Axis; match axis { - Axis::LeftX => (wire::AXIS_LS_X, v as i32), + Axis::LeftX => (wire::AXIS_LS_X, (v as i32).max(-32767)), Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)), - Axis::RightX => (wire::AXIS_RS_X, v as i32), + Axis::RightX => (wire::AXIS_RS_X, (v as i32).max(-32767)), Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)), Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7), Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7), @@ -617,6 +626,12 @@ struct RumbleState { /// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`]. last: (u16, u16), last_at: Option, + /// When the last *real* host rumble datagram landed on this slot — set only in the feedback + /// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive + /// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what + /// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive + /// stops and issues one (0, 0). See [`Worker::render_feedback`]. + updated_at: Option, /// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]). jitter: bool, @@ -1492,6 +1507,10 @@ impl Worker { } _ => None, }; + // Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive + // re-kick refreshes every ~40 ms), this clock advances only here, so a legacy + // lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below. + slot.rumble.updated_at = Some(Instant::now()); Self::issue_rumble(slot, low, high, deck); } } @@ -1511,6 +1530,17 @@ impl Worker { slot.rumble.deadline = None; slot.rumble.ttl_ms = 0; Self::issue_rumble(slot, 0, 0, true); + } else if slot.rumble.ttl_ms == 0 + && slot + .rumble + .updated_at + .is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS)) + { + // Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so + // this only trips on a lost stop-frame the host never followed up — silence the + // actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble` + // sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks. + Self::issue_rumble(slot, 0, 0, true); } else if slot .rumble .last_at diff --git a/crates/punktfunk-host/src/gamestream/gamepad.rs b/crates/punktfunk-host/src/gamestream/gamepad.rs index 4479479b..535c2c3e 100644 --- a/crates/punktfunk-host/src/gamestream/gamepad.rs +++ b/crates/punktfunk-host/src/gamestream/gamepad.rs @@ -50,29 +50,38 @@ pub struct GamepadFrame { pub rs_y: i16, } -// buttonFlags bits (Limelight.h). -pub const BTN_DPAD_UP: u32 = 0x0001; -pub const BTN_DPAD_DOWN: u32 = 0x0002; -pub const BTN_DPAD_LEFT: u32 = 0x0004; -pub const BTN_DPAD_RIGHT: u32 = 0x0008; -pub const BTN_START: u32 = 0x0010; -pub const BTN_BACK: u32 = 0x0020; -pub const BTN_LS_CLK: u32 = 0x0040; -pub const BTN_RS_CLK: u32 = 0x0080; -pub const BTN_LB: u32 = 0x0100; -pub const BTN_RB: u32 = 0x0200; -pub const BTN_GUIDE: u32 = 0x0400; -pub const BTN_A: u32 = 0x1000; -pub const BTN_B: u32 = 0x2000; -pub const BTN_X: u32 = 0x4000; -pub const BTN_Y: u32 = 0x8000; -// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`): -// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the -// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd. -pub const BTN_PADDLE1: u32 = 0x0001_0000; -pub const BTN_PADDLE2: u32 = 0x0002_0000; -pub const BTN_PADDLE3: u32 = 0x0004_0000; -pub const BTN_PADDLE4: u32 = 0x0008_0000; +// GameStream's `buttonFlags | buttonFlags2 << 16` layout (Limelight.h) is bit-identical to +// punktfunk's native gamepad wire, so source these from the single point of truth in `punktfunk_core` +// instead of re-declaring the values (the two drifted while separately hand-typed: the click bits +// were named `BTN_LS_CLK`/`BTN_RS_CLK` here vs the core `…_CLICK`). `decode` merges the two 16-bit +// halves into `buttons` raw; these names exist for the uinput injector's button map + hat math. The +// extended touchpad-click / Share bits (`BTN_TOUCHPAD` / `BTN_MISC1`) ride `buttons` too but are +// consumed straight from `punktfunk_core` by the DualSense/DS4 protos, so they aren't re-named here. +// +// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole +// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there, +// whereas an unused `pub const` does not. The values still come only from core, so they can't drift; +// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`. +use punktfunk_core::input::gamepad as wire; +pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP; +pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN; +pub const BTN_DPAD_LEFT: u32 = wire::BTN_DPAD_LEFT; +pub const BTN_DPAD_RIGHT: u32 = wire::BTN_DPAD_RIGHT; +pub const BTN_START: u32 = wire::BTN_START; +pub const BTN_BACK: u32 = wire::BTN_BACK; +pub const BTN_LS_CLICK: u32 = wire::BTN_LS_CLICK; +pub const BTN_RS_CLICK: u32 = wire::BTN_RS_CLICK; +pub const BTN_LB: u32 = wire::BTN_LB; +pub const BTN_RB: u32 = wire::BTN_RB; +pub const BTN_GUIDE: u32 = wire::BTN_GUIDE; +pub const BTN_A: u32 = wire::BTN_A; +pub const BTN_B: u32 = wire::BTN_B; +pub const BTN_X: u32 = wire::BTN_X; +pub const BTN_Y: u32 = wire::BTN_Y; +pub const BTN_PADDLE1: u32 = wire::BTN_PADDLE1; +pub const BTN_PADDLE2: u32 = wire::BTN_PADDLE2; +pub const BTN_PADDLE3: u32 = wire::BTN_PADDLE3; +pub const BTN_PADDLE4: u32 = wire::BTN_PADDLE4; /// Decode one decrypted control plaintext into a controller event, if it is one. Mouse, /// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]). diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index b44a970b..0bf07aa6 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -506,6 +506,11 @@ pub mod gamepad; #[cfg(target_os = "windows")] #[path = "inject/windows/gamepad_raii.rs"] mod gamepad_raii; +/// Shared virtual-pad creation-retry policy ([`pad_gate::PadGate`]) used by every backend manager on +/// both platforms — replaces the per-backend permanent `broken` latch with capped-backoff retry. +#[cfg(any(target_os = "linux", target_os = "windows"))] +#[path = "inject/pad_gate.rs"] +pub mod pad_gate; /// Linux: virtual Steam Deck via UHID — the kernel `hid-steam` driver binds it as a real Deck. #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] @@ -522,7 +527,9 @@ pub mod steam_gadget; #[path = "inject/proto/steam_proto.rs"] pub mod steam_proto; /// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale. -#[cfg(target_os = "linux")] +/// Shared by the Linux and Windows DualSense/DS4 backends (the slot-less pads that must fold the +/// Steam back grips); the Deck motion rescale is Linux-only but harmless to compile on Windows. +#[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/proto/steam_remap.rs"] pub mod steam_remap; /// Linux: virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index d71fe414..16c05761 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -13,11 +13,12 @@ //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. use super::dualsense_proto::{ - parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION, + parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC, }; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; @@ -177,11 +178,15 @@ pub struct DualSenseManager { state: Vec, /// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it. last_rumble: Vec<(u16, u16)>, + /// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an + /// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback. + hidout_dedup: Vec, /// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which /// re-emits the current state during input silence so the kernel never sees the device go quiet. last_write: Vec, - /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. - broken: bool, + /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. remap: crate::inject::steam_remap::RemapConfig, @@ -199,8 +204,9 @@ impl DualSenseManager { pads: (0..MAX_PADS).map(|_| None).collect(), state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], + hidout_dedup: vec![HidoutDedup::default(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -224,6 +230,7 @@ impl DualSenseManager { *slot = None; self.state[i] = DsState::neutral(); self.last_rumble[i] = (0, 0); + self.hidout_dedup[i].clear(); } } if f.active_mask & (1 << idx) == 0 { @@ -300,7 +307,7 @@ impl DualSenseManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match DualSensePad::open(idx as u8) { @@ -312,11 +319,13 @@ impl DualSenseManager { self.pads[idx] = Some(p); self.state[idx] = DsState::neutral(); self.last_rumble[idx] = (0, 0); + self.hidout_dedup[idx].clear(); self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } @@ -343,7 +352,11 @@ impl DualSenseManager { } } for h in fb.hidout { - hidout(h); + // Skip rich feedback that repeats the last-forwarded value (the game's output report + // re-sends unchanged lightbar/LED/trigger state alongside every rumble update). + if self.hidout_dedup[i].should_forward(&h) { + hidout(h); + } } } } diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index f602aee8..53d24f58 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -15,6 +15,7 @@ use super::dualsense_proto::{DsState, Touch}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; @@ -365,8 +366,9 @@ pub struct DualShock4Manager { last_led: Vec>, /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). last_write: Vec, - /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. - broken: bool, + /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, /// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. remap: crate::inject::steam_remap::RemapConfig, @@ -386,7 +388,7 @@ impl DualShock4Manager { last_rumble: vec![(0, 0); MAX_PADS], last_led: vec![None; MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -522,7 +524,7 @@ impl DualShock4Manager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match DualShock4Pad::open(idx as u8) { @@ -536,10 +538,11 @@ impl DualShock4Manager { self.last_rumble[idx] = (0, 0); self.last_led[idx] = None; self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index edf83d55..2e85ce93 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -19,6 +19,7 @@ #![deny(clippy::undocumented_unsafe_blocks)] use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{bail, Result}; use std::collections::HashMap; use std::os::fd::{AsRawFd, OwnedFd}; @@ -88,8 +89,8 @@ const BUTTON_MAP: [(u32, u16); 15] = [ (gamepad::BTN_BACK, BTN_SELECT), (gamepad::BTN_START, BTN_START), (gamepad::BTN_GUIDE, BTN_MODE), - (gamepad::BTN_LS_CLK, BTN_THUMBL), - (gamepad::BTN_RS_CLK, BTN_THUMBR), + (gamepad::BTN_LS_CLICK, BTN_THUMBL), + (gamepad::BTN_RS_CLICK, BTN_THUMBR), (gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5), (gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6), (gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7), @@ -265,7 +266,6 @@ struct Effect { /// One virtual X-Box-360 pad backed by a uinput device. pub struct VirtualPad { fd: OwnedFd, - prev_buttons: u32, effects: HashMap, next_effect_id: i16, gain: u32, @@ -369,7 +369,6 @@ impl VirtualPad { Ok(VirtualPad { fd, - prev_buttons: 0, effects: HashMap::new(), next_effect_id: 0, gain: 0xFFFF, @@ -412,15 +411,17 @@ impl VirtualPad { }; } - /// Apply one decoded frame: button transitions, axes, D-pad hat, one SYN_REPORT. + /// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT. pub fn apply(&mut self, f: &GamepadFrame) { - let changed = self.prev_buttons ^ f.buttons; + // Re-assert every mapped button's absolute state each frame — exactly like the axes below — + // instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops + // the write), so an edge-only scheme would strand a dropped press/release until that button + // next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is + // free downstream: the kernel input core discards an EV_KEY whose value already matches the + // device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat). for (bit, key) in BUTTON_MAP { - if changed & bit != 0 { - self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32); - } + self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32); } - self.prev_buttons = f.buttons; // Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow). self.emit(EV_ABS, ABS_X, f.ls_x as i32); @@ -557,8 +558,9 @@ pub struct GamepadManager { /// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when /// the client asked for `XboxOne`). All pads in a session share one identity. identity: PadIdentity, - /// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events. - broken: bool, + /// Create-retry gate: a transient `/dev/uinput` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl GamepadManager { @@ -572,7 +574,7 @@ impl GamepadManager { GamepadManager { pads: (0..MAX_PADS).map(|_| None).collect(), identity, - broken: false, + gate: PadGate::new(), } } @@ -608,14 +610,17 @@ impl GamepadManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match VirtualPad::create(idx, self.identity) { - Ok(p) => self.pads[idx] = Some(p), + Ok(p) => { + self.pads[idx] = Some(p); + self.gate.on_success(); + } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/linux/steam_controller.rs b/crates/punktfunk-host/src/inject/linux/steam_controller.rs index b0f837eb..54dff73e 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_controller.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_controller.rs @@ -24,6 +24,7 @@ use super::steam_proto::{ STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, }; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::fs::{File, OpenOptions}; @@ -360,7 +361,9 @@ pub struct SteamControllerManager { state: Vec, last_rumble: Vec<(u16, u16)>, last_write: Vec, - broken: bool, + /// Create-retry gate: a transient `/dev/uhid` failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl Default for SteamControllerManager { @@ -376,7 +379,7 @@ impl SteamControllerManager { state: vec![SteamState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), } } @@ -422,6 +425,12 @@ impl SteamControllerManager { s.gyro = prev.gyro; s.accel = prev.accel; s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH); + // Trackpad CLICK arrives on the rich plane too and must survive a button-only frame, + // exactly like touch/coords/motion above. It lives in its own fields (not `buttons`, + // which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD + // wire-button's RPAD_CLICK — the two are OR'd only at serialize. + s.lpad_click = prev.lpad_click; + s.rpad_click = prev.rpad_click; self.state[idx] = s; self.write(idx); } @@ -466,7 +475,7 @@ impl SteamControllerManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match open_transport(idx as u8) { @@ -475,10 +484,11 @@ impl SteamControllerManager { self.state[idx] = SteamState::neutral(); self.last_rumble[idx] = (0, 0); self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — controller input disabled"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual Steam Deck creation failed — retrying with backoff"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/pad_gate.rs b/crates/punktfunk-host/src/inject/pad_gate.rs new file mode 100644 index 00000000..d4813861 --- /dev/null +++ b/crates/punktfunk-host/src/inject/pad_gate.rs @@ -0,0 +1,122 @@ +//! Shared virtual-pad creation-retry policy, used by every backend manager (Linux uinput/uhid, +//! Windows XUSB/UMDF). See [`PadGate`]. + +use std::time::{Duration, Instant}; + +/// Backoff after the first failed pad creation… +const FIRST_BACKOFF: Duration = Duration::from_secs(1); +/// …doubling on each consecutive failure, capped here so a persistently-broken host retries at most +/// this often (a negligible cost) while still self-healing within one window of the fix. +const MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// Create-retry gate shared by every virtual-pad manager. +/// +/// Each backend used to carry a `broken: bool` that latched permanently on the FIRST pad-creation +/// error, so a single transient failure — a startup race on `/dev/uinput`, a momentary `EBUSY`, the +/// Windows companion driver not yet ready — disabled EVERY controller for the rest of the session, +/// even after the underlying cause cleared. `PadGate` replaces that latch with capped exponential +/// backoff: +/// +/// * After a failure, creation is blocked only until the backoff elapses — so the manager does not +/// re-attempt (and re-log) on every one of the 60–240 input frames a second — then a single +/// retry is permitted. +/// * A success clears the backoff, so the next failure starts fresh from [`FIRST_BACKOFF`]. +/// * Consecutive failures widen the window, doubling up to [`MAX_BACKOFF`]. +/// +/// Even a genuinely broken setup (bad `/dev/uinput` permissions, missing Windows driver) therefore +/// self-heals within [`MAX_BACKOFF`] of the fix — a udev-rule reload, a driver install, the next +/// client connect — with no host restart, while costing at most one failed syscall plus one log +/// line per backoff window. The gate is manager-wide (not per slot), matching the old `broken` +/// flag: these failures are systemic (device-node permissions, absent driver), not per-controller. +#[derive(Debug, Default)] +pub struct PadGate { + /// When the current backoff ends. `None` = creation is allowed right now. + retry_at: Option, + /// Current backoff length: `ZERO` until the first failure, then [`FIRST_BACKOFF`] doubling + /// toward [`MAX_BACKOFF`]. + backoff: Duration, +} + +impl PadGate { + /// A gate that permits creation immediately (no failures recorded yet). + pub fn new() -> PadGate { + PadGate::default() + } + + /// May a pad be created at `now`? `true` unless a post-failure backoff is still in effect. + pub fn allow(&self, now: Instant) -> bool { + match self.retry_at { + None => true, + Some(t) => now >= t, + } + } + + /// Record a successful pad creation — clear the backoff so the next failure starts fresh. + pub fn on_success(&mut self) { + self.retry_at = None; + self.backoff = Duration::ZERO; + } + + /// Record a failed pad creation at `now` — arm the next retry a capped-exponential backoff out. + pub fn on_failure(&mut self, now: Instant) { + self.backoff = if self.backoff.is_zero() { + FIRST_BACKOFF + } else { + (self.backoff * 2).min(MAX_BACKOFF) + }; + self.retry_at = Some(now + self.backoff); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_gate_allows_creation() { + assert!(PadGate::new().allow(Instant::now())); + } + + #[test] + fn failure_blocks_until_backoff_elapses_then_allows_one_retry() { + let t0 = Instant::now(); + let mut g = PadGate::new(); + g.on_failure(t0); + // Blocked for the whole first-backoff window… + assert!(!g.allow(t0)); + assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1))); + // …then a single retry is permitted. + assert!(g.allow(t0 + FIRST_BACKOFF)); + } + + #[test] + fn consecutive_failures_double_the_backoff_up_to_the_cap() { + let t0 = Instant::now(); + let mut g = PadGate::new(); + g.on_failure(t0); // window = 1s + g.on_failure(t0); // window = 2s + assert!(!g.allow(t0 + FIRST_BACKOFF)); // still blocked at 1s — the window is now 2s + assert!(g.allow(t0 + 2 * FIRST_BACKOFF)); + // Drive well past the cap and confirm the window never exceeds MAX_BACKOFF. + for _ in 0..20 { + g.on_failure(t0); + } + assert!(!g.allow(t0 + MAX_BACKOFF - Duration::from_millis(1))); + assert!(g.allow(t0 + MAX_BACKOFF)); + } + + #[test] + fn success_resets_the_backoff() { + let t0 = Instant::now(); + let mut g = PadGate::new(); + g.on_failure(t0); + g.on_failure(t0); // window grown to 2s + g.on_success(); + // Success clears the backoff: creation is immediately allowed again. + assert!(g.allow(t0)); + // The next failure starts from FIRST_BACKOFF, not the grown value. + g.on_failure(t0); + assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1))); + assert!(g.allow(t0 + FIRST_BACKOFF)); + } +} diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 62889ae6..9fc189d6 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -96,7 +96,7 @@ pub mod btn1 { pub mod btn2 { pub const PS: u8 = 0x01; pub const TOUCHPAD: u8 = 0x02; - #[allow(dead_code)] + /// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`. pub const MUTE: u8 = 0x04; } @@ -223,6 +223,12 @@ impl DsState { if on(gs::BTN_TOUCHPAD) { s.buttons[2] |= btn2::TOUCHPAD; } + // The mic-mute / capture button (Deck '…' QAM on the Steam path). Clients send it as + // BTN_MISC1; without this the DualSense mute button was inert on every PlayStation-family + // virtual pad. Rebuilt from the wire bit each frame like PS/TOUCHPAD, so no persistence gap. + if on(gs::BTN_MISC1) { + s.buttons[2] |= btn2::MUTE; + } s } @@ -439,10 +445,119 @@ pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) { } } +/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report +/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is +/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report. +/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the +/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by +/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire). +#[derive(Clone, Default)] +pub struct HidoutDedup { + led: Option<(u8, u8, u8)>, + player_leds: Option, + /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. + trigger: [Option>; 2], +} + +impl HidoutDedup { + /// Forget all remembered state — call when a pad is created or unplugged so the first feedback + /// after a (re)connect is always forwarded. + pub fn clear(&mut self) { + *self = HidoutDedup::default(); + } + + /// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a + /// one-shot pulse; `false` if it repeats the last-forwarded value for its kind. + pub fn should_forward(&mut self, h: &HidOutput) -> bool { + match h { + HidOutput::Led { r, g, b, .. } => { + let v = Some((*r, *g, *b)); + if self.led == v { + false + } else { + self.led = v; + true + } + } + HidOutput::PlayerLeds { bits, .. } => { + let v = Some(*bits); + if self.player_leds == v { + false + } else { + self.player_leds = v; + true + } + } + HidOutput::Trigger { which, effect, .. } => { + let slot = (*which as usize).min(1); + if self.trigger[slot].as_deref() == Some(effect.as_slice()) { + false + } else { + self.trigger[slot] = Some(effect.clone()); + true + } + } + // One-shot haptic pulse (Steam voice-coil) — state-less, always fires. + HidOutput::TrackpadHaptic { .. } => true, + } + } +} + #[cfg(test)] mod tests { use super::*; + /// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two + /// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`. + #[test] + fn hidout_dedup_forwards_only_changes() { + let mut d = HidoutDedup::default(); + let led = |r| HidOutput::Led { + pad: 0, + r, + g: 0, + b: 0, + }; + // First value forwards; an exact repeat is dropped; a change forwards again. + assert!(d.should_forward(&led(10))); + assert!(!d.should_forward(&led(10))); + assert!(d.should_forward(&led(20))); + + // Player LEDs dedup on their own field, independent of the lightbar. + let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits }; + assert!(d.should_forward(&pl(0b101))); + assert!(!d.should_forward(&pl(0b101))); + assert!(!d.should_forward(&led(20))); // lightbar still unchanged + + // The two adaptive triggers (L2=0, R2=1) are tracked separately. + let trig = |which, byte| HidOutput::Trigger { + pad: 0, + which, + effect: vec![byte, 0, 0], + }; + assert!(d.should_forward(&trig(0, 1))); + assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards + assert!(!d.should_forward(&trig(0, 1))); + assert!(d.should_forward(&trig(0, 2))); // L2 effect changed + + // One-shot haptic pulses are never deduped. + let haptic = HidOutput::TrackpadHaptic { + pad: 0, + side: 0, + amplitude: 1, + period: 2, + count: 3, + }; + assert!(d.should_forward(&haptic)); + assert!(d.should_forward(&haptic)); + + // `clear` re-arms every kind. + d.clear(); + assert!(d.should_forward(&led(20))); + assert!(d.should_forward(&pl(0b101))); + assert!(d.should_forward(&trig(0, 2))); + } + /// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0 /// on the left half, right pad (surface 2) contact 1 on the right half; y follows the /// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click @@ -669,12 +784,16 @@ mod tests { assert_eq!(r[53], 0x0A); } - /// The wire touchpad-click bit (Moonlight's extended position) lands in `buttons[2]`. + /// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in + /// `buttons[2]`. #[test] fn from_gamepad_maps_touchpad_click() { use punktfunk_core::input::gamepad as gs; let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0); assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD); + // BTN_MISC1 → the mic-mute / capture button (G6: was previously dropped entirely). + let s = DsState::from_gamepad(gs::BTN_MISC1, 0, 0, 0, 0, 0, 0); + assert_eq!(s.buttons[2], btn2::MUTE); let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0); assert_eq!(s.buttons[2], 0); } diff --git a/crates/punktfunk-host/src/inject/proto/steam_proto.rs b/crates/punktfunk-host/src/inject/proto/steam_proto.rs index 7cb0ab94..2e18b0fb 100644 --- a/crates/punktfunk-host/src/inject/proto/steam_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/steam_proto.rs @@ -156,6 +156,15 @@ pub struct SteamState { /// (with Z/RZ negated) on the separate sensors evdev. pub accel: [i16; 3], pub gyro: [i16; 3], + /// Trackpad CLICK from the rich plane ([`apply_rich`]), kept OUTSIDE `buttons` because + /// [`SteamControllerManager::handle`](super::super::linux::steam_controller::SteamControllerManager) + /// rebuilds `buttons` from the gamepad frame every tick — exactly why DualSense keeps + /// `touch_click` separate. Merged into the report's click bits in [`serialize_deck_state`]. The + /// DualSense touchpad-click WIRE button still sets `RPAD_CLICK` in `buttons` via + /// [`from_gamepad`](Self::from_gamepad); the two sources are OR'd at serialize, so each releases + /// independently (a released `BTN_TOUCHPAD` can't strand a rich click, and vice-versa). + pub lpad_click: bool, + pub rpad_click: bool, } impl SteamState { @@ -273,12 +282,14 @@ impl SteamState { // left pad, anything else (0 single / 2 right) = right pad. if surface == 1 { self.press(btn::LPAD_TOUCH, touch); - self.press(btn::LPAD_CLICK, click); + // Click lives in its own field, NOT `buttons` — `handle()` rebuilds `buttons` + // every gamepad frame and would otherwise wipe a held click (the bug this fixes). + self.lpad_click = click; self.lpad_x = x; self.lpad_y = flip_y(y); } else { self.press(btn::RPAD_TOUCH, touch); - self.press(btn::RPAD_CLICK, click); + self.rpad_click = click; self.rpad_x = x; self.rpad_y = flip_y(y); } @@ -297,7 +308,18 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq r[2] = ID_CONTROLLER_DECK_STATE; r[3] = 0x3C; // payload length; the kernel ignores it r[4..8].copy_from_slice(&seq.to_le_bytes()); - r[8..16].copy_from_slice(&st.buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0) + // Rich-plane trackpad clicks live in their own fields (see `SteamState`) so a button-only frame + // can't wipe them; merge them into the report's click bits here. RPAD_CLICK may ALSO come from + // the DualSense touchpad-click wire button via `from_gamepad` — OR both, so either source lights + // it and each releases independently. + let mut buttons = st.buttons; + if st.lpad_click { + buttons |= btn::LPAD_CLICK; + } + if st.rpad_click { + buttons |= btn::RPAD_CLICK; + } + r[8..16].copy_from_slice(&buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0) r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes()); r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes()); r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes()); @@ -611,7 +633,9 @@ mod tests { pressure: 100, }); assert_ne!(s.buttons & btn::LPAD_TOUCH, 0); - assert_ne!(s.buttons & btn::LPAD_CLICK, 0); + // Click now rides its own field (kept OUT of `buttons`, which handle() rebuilds each frame). + assert!(s.lpad_click); + assert_eq!(s.buttons & btn::LPAD_CLICK, 0); assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000)); s.apply_rich(RichInput::TouchpadEx { pad: 0, @@ -624,6 +648,7 @@ mod tests { pressure: 0, }); assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); + assert!(!s.rpad_click); // click:false → field cleared assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000)); // The i16 edge: wire y = -32768 (top-most) must clamp, not overflow. @@ -640,6 +665,34 @@ mod tests { assert_eq!(s.rpad_y, 32767); } + /// Regression (G2): a held trackpad click set on the rich plane must survive the per-frame + /// `buttons` rebuild that `SteamControllerManager::handle` performs via `from_gamepad`. Before + /// the fix, click lived in `buttons` and the rebuild wiped it every gamepad frame. + #[test] + fn rich_click_survives_a_buttons_rebuild() { + let mut held = SteamState::neutral(); + held.apply_rich(RichInput::TouchpadEx { + pad: 0, + surface: 1, + finger: 0, + touch: true, + click: true, + x: 0, + y: 0, + pressure: 0, + }); + assert!(held.lpad_click); + // A following button-only frame: from_gamepad rebuilds buttons (dropping the click bit), + // then handle() carries the rich fields over — the click must still reach the report. + let mut merged = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0); + assert_eq!(merged.buttons & btn::LPAD_CLICK, 0); // the rebuild alone loses it (the old bug) + merged.lpad_click = held.lpad_click; // what handle() now preserves + let mut r = [0u8; STEAM_REPORT_LEN]; + serialize_deck_state(&mut r, &merged, 0); + let serialized = u64::from_le_bytes(r[8..16].try_into().unwrap()); + assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild + } + /// The serial reply carries the leading report-id byte the kernel strips, so the *stripped* /// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`. #[test] diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index cbca4599..29873c30 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -18,21 +18,23 @@ //! must already be installed; the installer stages it.) use super::dualsense_proto::{ - parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, - DS_TOUCH_W, + parse_ds_output, serialize_state, DsFeedback, DsState, HidoutDedup, DS_INPUT_REPORT_LEN, + DS_TOUCH_H, DS_TOUCH_W, }; -use super::gamepad_raii::PadChannel; +use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{anyhow, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::ffi::c_void; +use std::sync::atomic::{fence, AtomicU32, Ordering}; use std::time::{Duration, Instant}; -use windows::core::{w, GUID, HRESULT, PCWSTR}; +use windows::core::{w, GUID, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; -use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0}; -use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; +use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0}; +use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; /// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset /// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic @@ -71,50 +73,6 @@ struct DsWinPad { last_out_seq: u32, } -/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, -/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). -#[repr(C)] -struct SwCreateCtx { - event: HANDLE, - result: HRESULT, - instance_id: [u16; 128], -} - -/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the -/// creator, which blocks on the event (so there's no concurrent access to `*ctx`). -unsafe extern "system" fn sw_create_cb( - _dev: HSWDEVICE, - result: HRESULT, - ctx: *const c_void, - id: PCWSTR, -) { - if !ctx.is_null() { - // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the - // creator blocks on the event). `id` is a NUL-terminated string for the callback's duration. - unsafe { - let c = ctx as *mut SwCreateCtx; - (*c).result = result; - if !id.is_null() { - for i in 0..(*c).instance_id.len() - 1 { - let ch = *id.0.add(i); - (*c).instance_id[i] = ch; - if ch == 0 { - break; - } - } - } - let _ = SetEvent((*c).event); - } - } -} - -impl SwCreateCtx { - fn instance_id(&self) -> Option { - let len = self.instance_id.iter().position(|&c| c == 0)?; - (len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len])) - } -} - /// The PnP identity for a virtual controller devnode — varies by controller type so the same /// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4 /// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below. @@ -334,13 +292,24 @@ impl DsWinPad { self.ts = self.ts.wrapping_add(1); let mut r = [0u8; DS_INPUT_REPORT_LEN]; serialize_state(&mut r, st, self.seq, self.ts); - // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. + // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the + // XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect + // field to publish last: the `pf_dualsense` driver streams the whole `input` region to game + // READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report) + // is consumed by the game's HID stack, not the driver — so it cannot serve as a separable + // publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout + + // driver change, deferred). The `Release` fence after the copy orders the report-body stores + // ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`), + // giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a + // no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn + // single frame is still theoretically possible but self-heals on the next ~250 Hz write. unsafe { std::ptr::copy_nonoverlapping( r.as_ptr(), self.channel.data_base().add(OFF_INPUT), r.len(), - ) + ); + fence(Ordering::Release); }; } @@ -356,9 +325,14 @@ impl DsWinPad { std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); - // SAFETY: base points at SHM_SIZE bytes. + // SAFETY: base points at SHM_SIZE bytes; `OFF_OUT_SEQ` (== 72) is 4-aligned off the + // page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER + // writing the `output` report, so an `Acquire` load here orders the `output` copy below after + // it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core + // (ARM64). On x86-TSO it is a plain load. let seq = unsafe { - std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) + (*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32)) + .load(Ordering::Acquire) }; if seq != self.last_out_seq { self.last_out_seq = seq; @@ -384,8 +358,16 @@ pub struct DualSenseWindowsManager { pads: Vec>, state: Vec, last_rumble: Vec<(u16, u16)>, + /// Last rich feedback (lightbar / player LEDs / adaptive triggers) forwarded per pad, so an + /// output report that only changed the rumble doesn't re-send unchanged 0xCD feedback. + hidout_dedup: Vec, last_write: Vec, - broken: bool, + /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, + /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button + /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`. + remap: crate::inject::steam_remap::RemapConfig, } impl Default for DualSenseWindowsManager { @@ -400,8 +382,10 @@ impl DualSenseWindowsManager { pads: (0..MAX_PADS).map(|_| None).collect(), state: vec![DsState::neutral(); MAX_PADS], last_rumble: vec![(0, 0); MAX_PADS], + hidout_dedup: vec![HidoutDedup::default(); MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), + remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -423,6 +407,7 @@ impl DualSenseWindowsManager { *slot = None; self.state[i] = DsState::neutral(); self.last_rumble[i] = (0, 0); + self.hidout_dedup[i].clear(); } } if f.active_mask & (1 << idx) == 0 { @@ -430,8 +415,13 @@ impl DualSenseWindowsManager { } self.ensure(idx); let prev = self.state[idx]; + // Steam back grips have no DualSense slot — fold them onto standard buttons per the + // configured policy (default drop) so they aren't silently lost, exactly as + // `linux/dualsense.rs` does. + let buttons = + crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let mut s = DsState::from_gamepad( - f.buttons, + buttons, f.ls_x, f.ls_y, f.rs_x, @@ -486,7 +476,7 @@ impl DualSenseWindowsManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match DsWinPad::open(idx as u8) { @@ -498,11 +488,13 @@ impl DualSenseWindowsManager { self.pads[idx] = Some(p); self.state[idx] = DsState::neutral(); self.last_rumble[idx] = (0, 0); + self.hidout_dedup[idx].clear(); self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)"); + self.gate.on_failure(Instant::now()); } } } @@ -527,7 +519,11 @@ impl DualSenseWindowsManager { } } for h in fb.hidout { - hidout(h); + // Skip rich feedback that repeats the last-forwarded value (the game's output report + // re-sends unchanged lightbar/LED/trigger state alongside every rumble update). + if self.hidout_dedup[i].should_forward(&h) { + hidout(h); + } } } } diff --git a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs index 6f24a994..29ba5d1a 100644 --- a/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualshock4_windows.rs @@ -17,6 +17,7 @@ use super::dualshock4_proto::{ }; use super::gamepad_raii::PadChannel; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::Result; use punktfunk_core::quic::{HidOutput, RichInput}; use std::time::{Duration, Instant}; @@ -149,7 +150,12 @@ pub struct DualShock4WindowsManager { last_rumble: Vec<(u16, u16)>, last_led: Vec>, last_write: Vec, - broken: bool, + /// Create-retry gate: a transient UMDF-channel failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, + /// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID + /// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`. + remap: crate::inject::steam_remap::RemapConfig, } impl Default for DualShock4WindowsManager { @@ -166,7 +172,8 @@ impl DualShock4WindowsManager { last_rumble: vec![(0, 0); MAX_PADS], last_led: vec![None; MAX_PADS], last_write: vec![Instant::now(); MAX_PADS], - broken: false, + gate: PadGate::new(), + remap: crate::inject::steam_remap::RemapConfig::from_env(), } } @@ -196,8 +203,13 @@ impl DualShock4WindowsManager { } self.ensure(idx); let prev = self.state[idx]; + // Steam back grips have no DS4 slot — fold them onto standard buttons per the + // configured policy (default drop) so they aren't silently lost, exactly as + // `linux/dualshock4.rs` does. + let buttons = + crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles); let mut s = DsState::from_gamepad( - f.buttons, + buttons, f.ls_x, f.ls_y, f.rs_x, @@ -251,7 +263,7 @@ impl DualShock4WindowsManager { } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match Ds4WinPad::open(idx as u8) { @@ -265,10 +277,11 @@ impl DualShock4WindowsManager { self.last_rumble[idx] = (0, 0); self.last_led[idx] = None; self.last_write[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)"); + self.gate.on_failure(Instant::now()); } } } diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs index 5effe302..ef6280e8 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_raii.rs @@ -22,19 +22,20 @@ use anyhow::{anyhow, bail, Context, Result}; use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION}; +use std::ffi::c_void; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering}; use std::sync::OnceLock; use std::time::{Duration, Instant}; -use windows::core::{w, HSTRING, PCWSTR}; +use windows::core::{w, HRESULT, HSTRING, PCWSTR}; use windows::Win32::Devices::DeviceAndDriverInstallation::{ CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL, CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED, }; use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; use windows::Win32::Foundation::{ - DuplicateHandle, GetLastError, SetLastError, DUPLICATE_HANDLE_OPTIONS, ERROR_ALREADY_EXISTS, - HANDLE, INVALID_HANDLE_VALUE, WIN32_ERROR, + DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS, + ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR, }; use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, @@ -45,7 +46,7 @@ use windows::Win32::System::Memory::{ MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, }; use windows::Win32::System::Threading::{ - GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION, + GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION, }; /// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so @@ -65,11 +66,37 @@ pub(super) struct Shm { view: MEMORY_MAPPED_VIEW_ADDRESS, } -/// Build a `SECURITY_ATTRIBUTES` from an SDDL literal (`psd` is OS-allocated and leaked — acceptable -/// for the handful of pad channels a host creates; it must outlive the returned `SECURITY_ATTRIBUTES`). -fn sddl_sa(sddl: PCWSTR) -> Result { +/// Owns an SDDL-derived `SECURITY_ATTRIBUTES` **and** the OS-allocated security descriptor its +/// `lpSecurityDescriptor` points at (`ConvertStringSecurityDescriptorToSecurityDescriptorW` +/// `LocalAlloc`s the descriptor). Drop `LocalFree`s it, so a `SecAttr` must outlive every +/// `CreateFileMappingW` that borrows its `sa`: the section copies the security info at create time, so +/// freeing after the create returns is safe — hence [`Shm::create_named`] builds one `SecAttr` before +/// its squat-retry loop and reuses it across attempts instead of re-allocating (and re-leaking) per +/// attempt. +struct SecAttr { + sa: SECURITY_ATTRIBUTES, + psd: PSECURITY_DESCRIPTOR, +} + +impl Drop for SecAttr { + fn drop(&mut self) { + // SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW` + // allocated for us with `LocalAlloc`; release it with the matching `LocalFree`. Every + // `CreateFileMappingW` that borrowed `self.sa` has already returned (so has copied the + // security info into its section object), so no live `SECURITY_ATTRIBUTES` still points here. + unsafe { + let _ = LocalFree(Some(HLOCAL(self.psd.0))); + } + } +} + +/// Build a [`SecAttr`] from an SDDL literal — a `SECURITY_ATTRIBUTES` plus the descriptor it borrows, +/// freed together on drop. The returned owner must outlive every `CreateFileMappingW` that borrows +/// its `sa` (see [`SecAttr`]). +fn sddl_sa(sddl: PCWSTR) -> Result { let mut psd = PSECURITY_DESCRIPTOR::default(); - // SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (leaked — see above). + // SAFETY: the SDDL literal is valid; `psd` receives a `LocalAlloc`'d descriptor that `SecAttr`'s + // `Drop` `LocalFree`s once the section create that borrows it has returned. unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW( sddl, @@ -78,10 +105,13 @@ fn sddl_sa(sddl: PCWSTR) -> Result { None, )?; } - Ok(SECURITY_ATTRIBUTES { - nLength: core::mem::size_of::() as u32, - lpSecurityDescriptor: psd.0, - bInheritHandle: false.into(), + Ok(SecAttr { + sa: SECURITY_ATTRIBUTES { + nLength: core::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }, + psd, }) } @@ -93,7 +123,9 @@ impl Shm { /// validated on-glass — `design/idd-push-security.md`). pub(super) fn create_unnamed(size: usize) -> Result { let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?; - Self::create_inner(&sa, PCWSTR::null(), size).context("create unnamed gamepad DATA section") + // `sa` owns the descriptor and lives to the end of this fn, so it outlives the create. + Self::create_inner(&sa.sa, PCWSTR::null(), size) + .context("create unnamed gamepad DATA section") } /// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL @@ -106,6 +138,8 @@ impl Shm { /// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or /// another host instance's) mailbox. pub(super) fn create_named(name: &HSTRING, size: usize) -> Result { + // Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS + // allocation it owns) lives to the end of this fn, so it outlives every create below. let sa = sddl_sa(w!("D:(A;;GA;;;SY)(A;;GA;;;LS)"))?; for attempt in 0..5 { if attempt > 0 { @@ -113,7 +147,7 @@ impl Shm { } // SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous. unsafe { SetLastError(WIN32_ERROR(0)) }; - let shm = Self::create_inner(&sa, PCWSTR(name.as_ptr()), size) + let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size) .with_context(|| format!("create gamepad bootstrap mailbox {name}"))?; // SAFETY: read immediately after the create; windows-rs only touches the error slot on // failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal. @@ -131,7 +165,8 @@ impl Shm { fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result { // SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the - // descriptor behind `sa` outlives this call (leaked by `sddl_sa`). + // descriptor behind `sa` outlives this call (owned by the caller's `SecAttr`, freed only once + // every create that borrows it has returned). let map = unsafe { CreateFileMappingW( INVALID_HANDLE_VALUE, @@ -403,6 +438,53 @@ impl PadChannel { } } +/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, +/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). Shared by every +/// Windows companion backend (XUSB / DualSense / DS4): each `create_swdevice` builds one, hands it to +/// `SwDeviceCreate` alongside [`sw_create_cb`], and reads [`instance_id`](Self::instance_id) once the +/// callback has signalled. +#[repr(C)] +pub(super) struct SwCreateCtx { + pub(super) event: HANDLE, + pub(super) result: HRESULT, + pub(super) instance_id: [u16; 128], +} + +/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the +/// creator, which blocks on the event (so there's no concurrent access to `*ctx`). +pub(super) unsafe extern "system" fn sw_create_cb( + _dev: HSWDEVICE, + result: HRESULT, + ctx: *const c_void, + id: PCWSTR, +) { + if !ctx.is_null() { + // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the + // creator blocks on the event). `id` is a NUL-terminated string for the callback's duration. + unsafe { + let c = ctx as *mut SwCreateCtx; + (*c).result = result; + if !id.is_null() { + for i in 0..(*c).instance_id.len() - 1 { + let ch = *id.0.add(i); + (*c).instance_id[i] = ch; + if ch == 0 { + break; + } + } + } + let _ = SetEvent((*c).event); + } + } +} + +impl SwCreateCtx { + pub(super) fn instance_id(&self) -> Option { + let len = self.instance_id.iter().position(|&c| c == 0)?; + (len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len])) + } +} + /// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual /// `SwDeviceClose` each backend used to call in its `Drop`. pub(super) struct SwDevice(HSWDEVICE); diff --git a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs index dd245194..68e929b8 100644 --- a/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/gamepad_windows.rs @@ -12,17 +12,19 @@ //! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays //! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path. -use super::gamepad_raii::PadChannel; +use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx}; use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use crate::inject::pad_gate::PadGate; use anyhow::{anyhow, Result}; use std::ffi::c_void; +use std::sync::atomic::{fence, AtomicU32, Ordering}; use std::time::{Duration, Instant}; -use windows::core::{w, GUID, HRESULT, PCWSTR}; +use windows::core::{w, GUID, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; -use windows::Win32::Foundation::{CloseHandle, E_FAIL, HANDLE, WAIT_OBJECT_0}; -use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; +use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0}; +use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; // Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset // asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from @@ -43,49 +45,6 @@ const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto); const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index); -/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports, -/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). -#[repr(C)] -struct SwCreateCtx { - event: HANDLE, - result: HRESULT, - instance_id: [u16; 128], -} - -/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator. -unsafe extern "system" fn sw_create_cb( - _dev: HSWDEVICE, - result: HRESULT, - ctx: *const c_void, - id: PCWSTR, -) { - if !ctx.is_null() { - // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the - // creator blocks on the event). `id` is a NUL-terminated string for the callback's duration. - unsafe { - let c = ctx as *mut SwCreateCtx; - (*c).result = result; - if !id.is_null() { - for i in 0..(*c).instance_id.len() - 1 { - let ch = *id.0.add(i); - (*c).instance_id[i] = ch; - if ch == 0 { - break; - } - } - } - let _ = SetEvent((*c).event); - } - } -} - -impl SwCreateCtx { - fn instance_id(&self) -> Option { - let len = self.instance_id.iter().position(|&c| c == 0)?; - (len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len])) - } -} - /// Spawn the `pf_xusb_` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The /// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads, /// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but @@ -235,7 +194,13 @@ impl XusbWinPad { let base = self.channel.data_base(); // SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every // `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field - // writes. Single owner (`&mut self`), so no concurrent writer races these stores. + // writes. Single owner (`&mut self`), so no concurrent writer races these stores. `packet` (the + // field XInput reads to detect a new state) is published LAST: the `Release` fence orders the + // state-body stores above before the `Release` `AtomicU32` store of `packet`, so the driver — + // which `Acquire`-loads `packet` — never observes a bumped packet over a torn body on a + // weakly-ordered core (ARM64). On x86-TSO both are plain stores. `OFF_PACKET` (== 4) is + // 4-aligned off the page-aligned section base, so the `AtomicU32` view is valid (mirrors the + // seq-fenced publish in `gamepad_raii::PadChannel::create`). unsafe { std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons); *base.add(OFF_LT) = lt; @@ -244,7 +209,8 @@ impl XusbWinPad { std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly); std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx); std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry); - std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet); + fence(Ordering::Release); + (*(base.add(OFF_PACKET) as *const AtomicU32)).store(self.packet, Ordering::Release); } } @@ -258,8 +224,13 @@ impl XusbWinPad { // SAFETY: base points at SHM_SIZE bytes. let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) }; self.attach.observe(proto); - // SAFETY: base points at SHM_SIZE bytes. - let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) }; + // SAFETY: base points at SHM_SIZE bytes; `OFF_RUMBLE_SEQ` (== 24) is 4-aligned off the + // page-aligned base, so the `AtomicU32` view is valid. The driver bumps `rumble_seq` AFTER + // writing the rumble bytes, so an `Acquire` load here orders the `rumble_large`/`rumble_small` + // reads below after it — a fresh seq guarantees a coherent snapshot of the rumble bytes on a + // weakly-ordered core (ARM64). On x86-TSO it is a plain load. + let seq = + unsafe { (*(base.add(OFF_RUMBLE_SEQ) as *const AtomicU32)).load(Ordering::Acquire) }; if seq == self.last_rumble_seq { return None; } @@ -291,7 +262,9 @@ pub struct GamepadManager { /// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the /// const's docs. last_active: Vec, - broken: bool, + /// Create-retry gate: a transient XUSB-companion failure backs off and retries instead of + /// permanently disabling every pad for the session. + gate: PadGate, } impl Default for GamepadManager { @@ -306,12 +279,12 @@ impl GamepadManager { pads: (0..MAX_PADS).map(|_| None).collect(), last_rumble: vec![(0, 0); MAX_PADS], last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(), - broken: false, + gate: PadGate::new(), } } fn ensure(&mut self, idx: usize) { - if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) { return; } match XusbWinPad::open(idx as u8) { @@ -322,44 +295,52 @@ impl GamepadManager { ); self.pads[idx] = Some(p); self.last_rumble[idx] = (0, 0); + self.last_active[idx] = Instant::now(); + self.gate.on_success(); } Err(e) => { - tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)"); - self.broken = true; + tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)"); + self.gate.on_failure(Instant::now()); } } } pub fn handle(&mut self, ev: &GamepadEvent) { - let GamepadEvent::State(f) = ev else { - return; // Arrival metadata — the pad is created lazily on the first State - }; - let idx = f.index.max(0) as usize; - if idx >= MAX_PADS { - return; - } - // Unplugs: drop any allocated pad whose mask bit cleared. - for (i, slot) in self.pads.iter_mut().enumerate() { - if slot.is_some() && f.active_mask & (1 << i) == 0 { - tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)"); - *slot = None; - self.last_rumble[i] = (0, 0); + match ev { + GamepadEvent::Arrival { index, kind, .. } => { + tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)"); + self.ensure(*index as usize); + } + GamepadEvent::State(f) => { + let idx = f.index.max(0) as usize; + if idx >= MAX_PADS { + return; + } + // Unplugs: drop any allocated pad whose mask bit cleared. + for (i, slot) in self.pads.iter_mut().enumerate() { + if slot.is_some() && f.active_mask & (1 << i) == 0 { + tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)"); + *slot = None; + self.last_rumble[i] = (0, 0); + self.last_active[i] = Instant::now(); + } + } + if f.active_mask & (1 << idx) == 0 { + return; + } + self.ensure(idx); + if let Some(pad) = self.pads[idx].as_mut() { + pad.write_state( + (f.buttons & 0xffff) as u16, + f.left_trigger, + f.right_trigger, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + ); + } } - } - if f.active_mask & (1 << idx) == 0 { - return; - } - self.ensure(idx); - if let Some(pad) = self.pads[idx].as_mut() { - pad.write_state( - (f.buttons & 0xffff) as u16, - f.left_trigger, - f.right_trigger, - f.ls_x, - f.ls_y, - f.rs_x, - f.rs_y, - ); } } diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 04a8e48b..38147ed2 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -5335,11 +5335,54 @@ mod tests { assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0))); assert_eq!(s.left_trigger, 255); assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0))); + } - // The punktfunk/1 button bits are the GameStream bits — one wire contract end to end. - assert_eq!(BTN_A, crate::gamestream::gamepad::BTN_A); - assert_eq!(BTN_GUIDE, crate::gamestream::gamepad::BTN_GUIDE); - assert_eq!(BTN_DPAD_UP, crate::gamestream::gamepad::BTN_DPAD_UP); + /// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value, read + /// through the GameStream namespace (`crate::gamestream::gamepad`, which re-exports + /// `punktfunk_core::input::gamepad` — the punktfunk/1 native wire and the GameStream/Limelight + /// wire are one and the same). Renumbering a bit in core, or dropping one from that re-export, + /// silently breaks every already-shipped client, so it must fail here first. This is the host + /// counterpart to the client-side C-ABI cross-checks in the Apple/Android gamepad tests. + #[test] + fn gamepad_wire_bits_are_pinned() { + use crate::gamestream::gamepad as gm; + use punktfunk_core::input::gamepad as pf; + // buttonFlags — low 16 bits, named via the GameStream re-export the injectors use. + assert_eq!(gm::BTN_DPAD_UP, 0x0000_0001); + assert_eq!(gm::BTN_DPAD_DOWN, 0x0000_0002); + assert_eq!(gm::BTN_DPAD_LEFT, 0x0000_0004); + assert_eq!(gm::BTN_DPAD_RIGHT, 0x0000_0008); + assert_eq!(gm::BTN_START, 0x0000_0010); + assert_eq!(gm::BTN_BACK, 0x0000_0020); + assert_eq!(gm::BTN_LS_CLICK, 0x0000_0040); + assert_eq!(gm::BTN_RS_CLICK, 0x0000_0080); + assert_eq!(gm::BTN_LB, 0x0000_0100); + assert_eq!(gm::BTN_RB, 0x0000_0200); + assert_eq!(gm::BTN_GUIDE, 0x0000_0400); + assert_eq!(gm::BTN_A, 0x0000_1000); + assert_eq!(gm::BTN_B, 0x0000_2000); + assert_eq!(gm::BTN_X, 0x0000_4000); + assert_eq!(gm::BTN_Y, 0x0000_8000); + // buttonFlags2 — high 16 bits: back-grip paddles (re-exported), plus the touchpad-click / + // Share bits the DualSense/DS4 protos consume straight from core. + assert_eq!(gm::BTN_PADDLE1, 0x0001_0000); + assert_eq!(gm::BTN_PADDLE2, 0x0002_0000); + assert_eq!(gm::BTN_PADDLE3, 0x0004_0000); + assert_eq!(gm::BTN_PADDLE4, 0x0008_0000); + assert_eq!(pf::BTN_TOUCHPAD, 0x0010_0000); + assert_eq!(pf::BTN_MISC1, 0x0020_0000); + // Axis ids — dense, 0-based. + assert_eq!( + [ + pf::AXIS_LS_X, + pf::AXIS_LS_Y, + pf::AXIS_RS_X, + pf::AXIS_RS_Y, + pf::AXIS_LT, + pf::AXIS_RT, + ], + [0, 1, 2, 3, 4, 5] + ); } /// Pull and byte-verify `count` synthetic frames through the C ABI connection.