Merge branch 'gamepad-apple-cleanup': cross-client + host gamepad review cleanup (G1–G25)
audit / bun-audit (push) Successful in 13s
ci / docs-site (push) Successful in 49s
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 21s
audit / cargo-audit (push) Successful in 2m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
apple / swift (push) Successful in 4m22s
ci / bench (push) Successful in 5m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
windows-host / package (push) Successful in 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m58s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m14s
arch / build-publish (push) Successful in 16m1s
android / android (push) Successful in 16m36s
deb / build-publish (push) Successful in 16m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
ci / rust (push) Successful in 22m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m19s
release / apple (push) Successful in 20m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m49s
apple / screenshots (push) Successful in 18m55s
audit / bun-audit (push) Successful in 13s
ci / docs-site (push) Successful in 49s
ci / web (push) Successful in 53s
decky / build-publish (push) Successful in 21s
audit / cargo-audit (push) Successful in 2m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
apple / swift (push) Successful in 4m22s
ci / bench (push) Successful in 5m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
windows-host / package (push) Successful in 9m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m5s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m58s
flatpak / build-publish (push) Successful in 6m18s
docker / deploy-docs (push) Successful in 27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m14s
arch / build-publish (push) Successful in 16m1s
android / android (push) Successful in 16m36s
deb / build-publish (push) Successful in 16m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m46s
ci / rust (push) Successful in 22m42s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m19s
release / apple (push) Successful in 20m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m8s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m49s
apple / screenshots (push) Successful in 18m55s
48-finding cross-client + host gamepad audit (2026-07-13). Apple/Android/SDL-core capture + feedback and the Linux/Windows host injectors: held-guide release, the permanent broken-latch cliff (PadGate), Steam Deck trackpad clicks, DualSense mute, Windows DS/DS4 paddle fold, uinput button re-sync, gamestream BTN_* dedup, the dead Windows shell fork, legacy-Deck rumble ceiling, XUSB arrival, ARM64 fences, the truncate-everywhere value convention, and more. See punktfunk-planning/design/gamepad-review-cleanup.md.
This commit is contained in:
Generated
-1
@@ -3046,7 +3046,6 @@ dependencies = [
|
||||
"mdns-sd",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Int, RumbleBind?>()
|
||||
private val lightBinds = HashMap<Int, LightBind?>()
|
||||
|
||||
@@ -122,6 +128,7 @@ 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.
|
||||
synchronized(bindsLock) {
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
@@ -130,17 +137,40 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
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() } }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
/** 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
|
||||
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? {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
@@ -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,11 +308,13 @@ 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
|
||||
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? {
|
||||
val lm = dev.lightsManager
|
||||
|
||||
@@ -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<Int, Slot>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
// `Arc<Mutex<…>>` (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<Mutex<Sender<Ctl>>>,
|
||||
}
|
||||
|
||||
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<PadInfo> {
|
||||
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<String>) {
|
||||
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<u32> {
|
||||
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<u32, sdl3::gamepad::Gamepad>,
|
||||
/// Connection order; the most recently connected is the auto selection.
|
||||
order: Vec<u32>,
|
||||
/// 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<String>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// 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<u32> {
|
||||
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<PadInfo> {
|
||||
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<Vec<PadInfo>>, ctl: &Receiver<Ctl>) -> 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<PadInfo> = 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<u32> {
|
||||
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<Instant>,
|
||||
/// 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<Instant>,
|
||||
/// 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
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DsState>,
|
||||
/// 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<HidoutDedup>,
|
||||
/// 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<Instant>,
|
||||
/// 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,8 +352,12 @@ impl DualSenseManager {
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Option<(u8, u8, u8)>>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i16, Effect>,
|
||||
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.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SteamState>,
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
last_write: Vec<Instant>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Instant>,
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<String> {
|
||||
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<Option<DsWinPad>>,
|
||||
state: Vec<DsState>,
|
||||
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<HidoutDedup>,
|
||||
last_write: Vec<Instant>,
|
||||
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,8 +519,12 @@ impl DualSenseWindowsManager {
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Option<(u8, u8, u8)>>,
|
||||
last_write: Vec<Instant>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SECURITY_ATTRIBUTES> {
|
||||
/// 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<SecAttr> {
|
||||
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<SECURITY_ATTRIBUTES> {
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
Ok(SECURITY_ATTRIBUTES {
|
||||
Ok(SecAttr {
|
||||
sa: SECURITY_ATTRIBUTES {
|
||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() 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<Shm> {
|
||||
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<Shm> {
|
||||
// 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<Shm> {
|
||||
// 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<String> {
|
||||
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);
|
||||
|
||||
@@ -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<String> {
|
||||
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_<index>` 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<Instant>,
|
||||
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,18 +295,23 @@ 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
|
||||
};
|
||||
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;
|
||||
@@ -344,6 +322,7 @@ impl GamepadManager {
|
||||
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 {
|
||||
@@ -362,6 +341,8 @@ impl GamepadManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user