Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49533ff90a | |||
| 1b890ae919 | |||
| f88d0ae4dc | |||
| 94802795e7 | |||
| 764b5d938b | |||
| 1af11cc64d | |||
| 2f214532d9 | |||
| 31bc863084 | |||
| 60af4de3ba | |||
| aedffc69dd | |||
| 26cac9ce20 | |||
| 48933dc405 | |||
| e5166c6e6e | |||
| 59fc820226 | |||
| d611645ffc | |||
| 17457cf4ba | |||
| 6263108e15 | |||
| 0c427cb3f1 | |||
| 5109a4c80a | |||
| 43e52437c0 | |||
| 2642ba6ad0 | |||
| 236c59754b | |||
| 68b9f108ab | |||
| 5cd66eca59 |
Generated
+14
-15
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "latency-probe"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
@@ -2277,7 +2277,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "loss-harness"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"punktfunk-core",
|
||||
]
|
||||
@@ -2756,7 +2756,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-client-core"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -2778,7 +2778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-console-ui"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2799,7 +2799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-ffvk"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"bindgen",
|
||||
@@ -2808,7 +2808,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pf-presenter"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ash",
|
||||
@@ -2992,7 +2992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-android"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"jni",
|
||||
@@ -3008,7 +3008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-linux"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3024,7 +3024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-session"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-client-core",
|
||||
@@ -3039,14 +3039,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-client-windows"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"ffmpeg-next",
|
||||
"mdns-sd",
|
||||
"pf-client-core",
|
||||
"punktfunk-core",
|
||||
"sdl3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
@@ -3059,7 +3058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-core"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3090,7 +3089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-host"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -3162,7 +3161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-probe"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mdns-sd",
|
||||
@@ -3176,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "punktfunk-tray"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ksni",
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ exclude = [
|
||||
ndk = { path = "clients/android/native/vendor/ndk" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -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,13 +128,35 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
// Threads are dead — drop any held rumble and close every lights session.
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
synchronized(bindsLock) {
|
||||
for (b in rumbleBinds.values) b?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||
rumbleBinds.clear()
|
||||
lightBinds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict and release the bindings for a controller that just disconnected — invoked from
|
||||
* [GamepadRouter]'s slot-close on the main thread (routed via `StreamScreen`). Closes its
|
||||
* `LightsSession` and cancels any held rumble, so a hot-unplug mid-session frees the session
|
||||
* immediately instead of leaking it until [stop]. A no-op for a device with no cached binding.
|
||||
* The next feedback for that pad index rebinds against whatever controller now holds it.
|
||||
*/
|
||||
// Same runtime-guarded cleanup as [stop] (VIBRATE is app-declared; the light bind only exists
|
||||
// under the SDK 33 guard) — suppress the module-isolation lint false positives it re-triggers.
|
||||
@Suppress("MissingPermission", "NewApi")
|
||||
fun onDeviceRemoved(deviceId: Int) {
|
||||
synchronized(bindsLock) {
|
||||
rumbleBinds.remove(deviceId)?.let {
|
||||
runCatching { it.vm?.cancel() }
|
||||
runCatching { it.legacy?.cancel() }
|
||||
}
|
||||
lightBinds.remove(deviceId)?.let { runCatching { it.session.close() } }
|
||||
}
|
||||
for (b in lightBinds.values) b?.let { runCatching { it.session.close() } }
|
||||
rumbleBinds.clear()
|
||||
lightBinds.clear()
|
||||
}
|
||||
|
||||
// ---- Rumble ----
|
||||
@@ -136,10 +164,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
/** The rumble binding for the controller on wire pad [pad], or null (no live pad / no vibrator). Cached by device id. */
|
||||
private fun rumbleBindFor(pad: Int): RumbleBind? {
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||
val bind = bindRumble(dev)
|
||||
rumbleBinds[dev.id] = bind
|
||||
return bind
|
||||
synchronized(bindsLock) {
|
||||
if (rumbleBinds.containsKey(dev.id)) return rumbleBinds[dev.id]
|
||||
val bind = bindRumble(dev)
|
||||
rumbleBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindRumble(dev: InputDevice): RumbleBind? {
|
||||
@@ -184,7 +214,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
val combo = CombinedVibration.startParallel()
|
||||
if (bind.amplitudeControlled && bind.ids.size >= 2) {
|
||||
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
|
||||
// Two-motor split — ASSUMPTION: ids[0] = light/right, ids[1] = heavy/left
|
||||
// (XInput/Moonlight convention). Android does not guarantee the order of
|
||||
// VibratorManager.getVibratorIds(), so a pad that enumerates heavy-first would
|
||||
// invert the feel: the stronger amplitude drives the physically-lighter motor.
|
||||
// Failure mode is tactile only — both motors still fire, nothing silences or
|
||||
// crashes — so this stays the default pending per-pad on-glass verification (G20).
|
||||
// ids beyond the first two (rare) are left alone here.
|
||||
if (hi != 0) combo.addVibrator(bind.ids[0], oneShot(hi, durationMs))
|
||||
if (lo != 0) combo.addVibrator(bind.ids[1], oneShot(lo, durationMs))
|
||||
} else {
|
||||
@@ -217,9 +253,13 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
}
|
||||
|
||||
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
|
||||
// self-terminates on a lost stop; cancel on zero.
|
||||
// self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot`
|
||||
// throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0
|
||||
// (e.g. the legacy-Deck ceiling) with a nonzero amplitude — which reaches here past the (0,0)
|
||||
// stop guard. On the VibratorManager path the effect is built OUTSIDE the vibrate() runCatching,
|
||||
// so an uncaught throw here would kill the whole rumble poll thread.
|
||||
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
|
||||
VibrationEffect.createOneShot(durationMs, amp)
|
||||
VibrationEffect.createOneShot(durationMs.coerceAtLeast(1), amp)
|
||||
|
||||
// ---- HID output ----
|
||||
|
||||
@@ -268,10 +308,12 @@ class GamepadFeedback(private val handle: Long, private val router: GamepadRoute
|
||||
private fun lightBindFor(pad: Int): LightBind? {
|
||||
if (Build.VERSION.SDK_INT < 33) return null
|
||||
val dev = router?.deviceForPad(pad) ?: return null
|
||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||
val bind = bindLights(dev)
|
||||
lightBinds[dev.id] = bind
|
||||
return bind
|
||||
synchronized(bindsLock) {
|
||||
if (lightBinds.containsKey(dev.id)) return lightBinds[dev.id]
|
||||
val bind = bindLights(dev)
|
||||
lightBinds[dev.id] = bind
|
||||
return bind
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLights(dev: InputDevice): LightBind? {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,6 +30,10 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
|
||||
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
|
||||
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
|
||||
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
|
||||
/// Touch-input model values (persisted) paired with their display labels below — the
|
||||
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
|
||||
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
|
||||
const TOUCH_MODE_LABELS: &[&str] = &["Trackpad", "Direct pointer", "Touch passthrough"];
|
||||
|
||||
/// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page.
|
||||
const APP_LICENSE: &str = concat!(
|
||||
@@ -420,12 +424,21 @@ pub fn show(
|
||||
"Steam Deck",
|
||||
],
|
||||
);
|
||||
let touch_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Touch input",
|
||||
"How the touchscreen drives the host — Trackpad nudges a cursor (tap to click); \
|
||||
Direct pointer jumps to your finger; Touch passthrough sends real touches",
|
||||
TOUCH_MODE_LABELS,
|
||||
);
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
.title("Capture system shortcuts")
|
||||
.subtitle("Forward Alt+Tab, Super, … to the host while input is captured")
|
||||
.build();
|
||||
input.add(forward_row.widget());
|
||||
input.add(pad_row.widget());
|
||||
input.add(touch_row.widget());
|
||||
input.add(&inhibit_row);
|
||||
|
||||
let audio = adw::PreferencesGroup::builder().title("Audio").build();
|
||||
@@ -488,6 +501,11 @@ pub fn show(
|
||||
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
|
||||
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
|
||||
pad_row.set_selected(pad_i as u32);
|
||||
let touch_i = TOUCH_MODES
|
||||
.iter()
|
||||
.position(|&t| t == s.touch_mode)
|
||||
.unwrap_or(0);
|
||||
touch_row.set_selected(touch_i as u32);
|
||||
let comp_i = COMPOSITORS
|
||||
.iter()
|
||||
.position(|&c| c == s.compositor)
|
||||
@@ -527,6 +545,8 @@ pub fn show(
|
||||
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
|
||||
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
|
||||
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
|
||||
s.touch_mode =
|
||||
TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string();
|
||||
s.forward_pad = chosen_pin.borrow().clone();
|
||||
s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)]
|
||||
.to_string();
|
||||
|
||||
@@ -140,6 +140,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings_at_start.touch_mode(),
|
||||
json_status,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
trust::touch_last_used(&trust::hex(&fingerprint));
|
||||
|
||||
@@ -358,6 +358,7 @@ mod session_main {
|
||||
trust::StatsVerbosity::Off if arg_flag("--stats") => trust::StatsVerbosity::Normal,
|
||||
v => v,
|
||||
},
|
||||
touch_mode: settings.touch_mode(),
|
||||
json_status: true,
|
||||
on_connected: Some(Box::new(|fingerprint: [u8; 32]| {
|
||||
// This host's card carries the accent bar in the desktop client now.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -55,6 +55,13 @@ const STATS_TIERS: &[(StatsVerbosity, &str)] = &[
|
||||
(StatsVerbosity::Normal, "Normal"),
|
||||
(StatsVerbosity::Detailed, "Detailed"),
|
||||
];
|
||||
/// Touch-input presets: `(stored value, display label)` — how a touchscreen's fingers drive
|
||||
/// the host. The cross-client set (Android/Apple); only meaningful on a touchscreen device.
|
||||
const TOUCH_MODES: &[(&str, &str)] = &[
|
||||
("trackpad", "Trackpad"),
|
||||
("pointer", "Direct pointer"),
|
||||
("touch", "Touch passthrough"),
|
||||
];
|
||||
/// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to
|
||||
/// auto-detect when the choice is unavailable. Only meaningful against a Linux host.
|
||||
const COMPOSITORS: &[(&str, &str)] = &[
|
||||
@@ -324,6 +331,14 @@ pub(crate) fn settings_page(
|
||||
"The virtual pad the host creates. \u{201C}Automatic\u{201D} matches your physical \
|
||||
controller.",
|
||||
);
|
||||
let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode);
|
||||
let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| {
|
||||
s.touch_mode = TOUCH_MODES[i].0.to_string();
|
||||
})
|
||||
.tooltip(
|
||||
"How a touchscreen drives the host: Trackpad nudges a cursor (tap to click), Direct \
|
||||
pointer jumps to your finger, Touch passthrough sends real touches.",
|
||||
);
|
||||
let shortcuts_toggle = setting_toggle(
|
||||
ctx,
|
||||
"Capture system shortcuts (Alt+Tab, Win, \u{2026})",
|
||||
@@ -405,6 +420,7 @@ pub(crate) fn settings_page(
|
||||
settings_card(vec![
|
||||
forward_combo.into(),
|
||||
pad_combo.into(),
|
||||
touch_combo.into(),
|
||||
shortcuts_toggle.into(),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -343,6 +343,53 @@ impl StatsVerbosity {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a touchscreen's fingers drive the host — the cross-client touch-input model (Android
|
||||
/// `TouchMode`, Apple `TouchInputMode`). Stored stringly in [`Settings::touch_mode`] so the
|
||||
/// file stays readable; parsed with [`TouchMode::from_name`].
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum TouchMode {
|
||||
/// Relative cursor like a laptop touchpad: the cursor stays put on touch-down and moves
|
||||
/// by the finger's delta (with mild acceleration), tap to click. The default — a cursor
|
||||
/// is the universally workable model on a screen the host isn't sized for.
|
||||
Trackpad,
|
||||
/// Direct pointing: the cursor jumps to the finger and follows it (absolute).
|
||||
Pointer,
|
||||
/// Real multi-touch passthrough: every finger is a host touchscreen contact, no gesture
|
||||
/// interpretation — only helps hosts/apps that actually understand touch.
|
||||
Touch,
|
||||
}
|
||||
|
||||
impl TouchMode {
|
||||
/// Cycle/picker order (also the settings pickers' option order).
|
||||
pub const ALL: [TouchMode; 3] = [TouchMode::Trackpad, TouchMode::Pointer, TouchMode::Touch];
|
||||
|
||||
/// Parse the persisted name, defaulting to `Trackpad` for unset/unknown values.
|
||||
pub fn from_name(s: &str) -> TouchMode {
|
||||
match s {
|
||||
"pointer" => TouchMode::Pointer,
|
||||
"touch" => TouchMode::Touch,
|
||||
_ => TouchMode::Trackpad,
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted name (the inverse of [`from_name`](Self::from_name)).
|
||||
pub fn as_name(self) -> &'static str {
|
||||
match self {
|
||||
TouchMode::Trackpad => "trackpad",
|
||||
TouchMode::Pointer => "pointer",
|
||||
TouchMode::Touch => "touch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TouchMode::Trackpad => "Trackpad",
|
||||
TouchMode::Pointer => "Direct pointer",
|
||||
TouchMode::Touch => "Touch passthrough",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file
|
||||
/// stays readable; parsed with `*Pref::from_name` at connect time.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
@@ -363,6 +410,12 @@ pub struct Settings {
|
||||
/// Which host compositor backend to request (advisory; the host falls back to
|
||||
/// auto-detect when unavailable).
|
||||
pub compositor: String,
|
||||
/// How a touchscreen's fingers drive the host (Deck/tablet): a [`TouchMode`] name —
|
||||
/// `"trackpad"` (default), `"pointer"`, or `"touch"`. Read at connect via
|
||||
/// [`Settings::touch_mode`]; irrelevant on a mouse-only client. `default` so pre-existing
|
||||
/// stores load as trackpad.
|
||||
#[serde(default = "default_touch_mode")]
|
||||
pub touch_mode: String,
|
||||
/// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured.
|
||||
pub inhibit_shortcuts: bool,
|
||||
/// Stream the default microphone to the host's virtual mic source.
|
||||
@@ -425,6 +478,10 @@ fn default_codec() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
fn default_touch_mode() -> String {
|
||||
"trackpad".into()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -447,6 +504,11 @@ impl Settings {
|
||||
self.show_stats = v != StatsVerbosity::Off;
|
||||
}
|
||||
|
||||
/// The touch-input model for this session (parsed from the stored name).
|
||||
pub fn touch_mode(&self) -> TouchMode {
|
||||
TouchMode::from_name(&self.touch_mode)
|
||||
}
|
||||
|
||||
/// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto).
|
||||
pub fn preferred_codec(&self) -> u8 {
|
||||
match self.codec.as_str() {
|
||||
@@ -468,6 +530,7 @@ impl Default for Settings {
|
||||
gamepad: "auto".into(),
|
||||
forward_pad: String::new(),
|
||||
compositor: "auto".into(),
|
||||
touch_mode: "trackpad".into(),
|
||||
inhibit_shortcuts: true,
|
||||
mic_enabled: false,
|
||||
audio_channels: 2,
|
||||
@@ -519,6 +582,23 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A settings file predating the touch-input model loads as `trackpad` (the shipped
|
||||
/// default), and the name round-trips through the enum both ways.
|
||||
#[test]
|
||||
fn settings_touch_mode_defaults_trackpad() {
|
||||
let old = r#"{"width":1280,"height":720,"gamepad":"auto","compositor":"auto"}"#;
|
||||
let s: Settings = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(s.touch_mode, "trackpad");
|
||||
assert_eq!(s.touch_mode(), TouchMode::Trackpad);
|
||||
// Explicit values parse; an unknown name falls back to trackpad.
|
||||
assert_eq!(TouchMode::from_name("pointer"), TouchMode::Pointer);
|
||||
assert_eq!(TouchMode::from_name("touch"), TouchMode::Touch);
|
||||
assert_eq!(TouchMode::from_name("bogus"), TouchMode::Trackpad);
|
||||
for m in TouchMode::ALL {
|
||||
assert_eq!(TouchMode::from_name(m.as_name()), m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic.
|
||||
#[test]
|
||||
fn settings_forward_pad_defaults_empty() {
|
||||
|
||||
@@ -16,11 +16,21 @@
|
||||
//! otherwise send a datagram per event).
|
||||
|
||||
use crate::keymap_sdl;
|
||||
use crate::touch::{Abs, Act, Gestures};
|
||||
use pf_client_core::trust::TouchMode;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Which transition a forwarded touchscreen finger is (SDL delivers one finger per event).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FingerPhase {
|
||||
Down,
|
||||
Move,
|
||||
Up,
|
||||
}
|
||||
|
||||
pub struct Capture {
|
||||
connector: Arc<NativeClient>,
|
||||
captured: bool,
|
||||
@@ -34,6 +44,16 @@ pub struct Capture {
|
||||
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
|
||||
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
|
||||
scroll_acc: (f64, f64),
|
||||
/// Active touchscreen contacts: SDL finger id → the small wire touch id (slot) we
|
||||
/// forward it under. SDL finger ids are opaque and large; the host wants compact,
|
||||
/// per-contact-unique ids reusable after up (input.rs::TouchDown). Slots are freed on
|
||||
/// up and flushed up on release so no contact stays pressed on the host. Only used in
|
||||
/// [`TouchMode::Touch`]; the other modes drive `gestures` instead.
|
||||
touch_slots: HashMap<u64, u32>,
|
||||
/// The touchscreen input model for this session, and — for trackpad/pointer — the
|
||||
/// gesture state machine finger events feed.
|
||||
touch_mode: TouchMode,
|
||||
gestures: Gestures,
|
||||
}
|
||||
|
||||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||
@@ -48,7 +68,7 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn new(connector: Arc<NativeClient>) -> Capture {
|
||||
pub fn new(connector: Arc<NativeClient>, touch_mode: TouchMode) -> Capture {
|
||||
Capture {
|
||||
connector,
|
||||
captured: false,
|
||||
@@ -57,6 +77,9 @@ impl Capture {
|
||||
held_buttons: HashSet::new(),
|
||||
pending_rel: (0, 0),
|
||||
scroll_acc: (0.0, 0.0),
|
||||
touch_slots: HashMap::new(),
|
||||
touch_mode,
|
||||
gestures: Gestures::new(touch_mode == TouchMode::Trackpad),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +116,12 @@ impl Capture {
|
||||
for b in self.held_buttons.drain() {
|
||||
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
|
||||
}
|
||||
for slot in self.touch_slots.drain().map(|(_, slot)| slot) {
|
||||
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
|
||||
}
|
||||
// The gesture engine's held left button (a tap-drag in progress) rides in
|
||||
// `held_buttons` above, so it was just flushed — here we only forget its state.
|
||||
self.gestures.reset();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -180,4 +209,136 @@ impl Capture {
|
||||
}
|
||||
self.scroll_acc = (ax, ay);
|
||||
}
|
||||
|
||||
/// The compact wire touch id for an SDL finger — its existing slot, or the lowest free
|
||||
/// one (contacts are few, so a linear scan is nothing). Held until the finger lifts.
|
||||
fn touch_slot(&mut self, finger_id: u64) -> u32 {
|
||||
if let Some(&slot) = self.touch_slots.get(&finger_id) {
|
||||
return slot;
|
||||
}
|
||||
let used: HashSet<u32> = self.touch_slots.values().copied().collect();
|
||||
let slot = (0u32..).find(|s| !used.contains(s)).unwrap_or(0);
|
||||
self.touch_slots.insert(finger_id, slot);
|
||||
slot
|
||||
}
|
||||
|
||||
/// Touch flags pack the client surface size the coordinates are relative to, so the
|
||||
/// host can rescale into its output — identical layout to Android's nativeSendTouch.
|
||||
fn touch_flags(w: u32, h: u32) -> u32 {
|
||||
((w & 0xffff) << 16) | (h & 0xffff)
|
||||
}
|
||||
|
||||
/// A new touchscreen contact — `x`/`y` are absolute in the `w`×`h` content surface.
|
||||
/// Ignored unless captured (the stream owns the glass; the menu is gamepad-driven).
|
||||
pub fn on_touch_down(&mut self, finger_id: u64, x: i32, y: i32, w: u32, h: u32) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
let slot = self.touch_slot(finger_id);
|
||||
send(
|
||||
&self.connector,
|
||||
InputKind::TouchDown,
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
Self::touch_flags(w, h),
|
||||
);
|
||||
}
|
||||
|
||||
/// A contact moved. Only forwarded for a finger we already sent a down for — a move
|
||||
/// with no live slot (capture engaged mid-touch) would have no matching host contact.
|
||||
pub fn on_touch_move(&mut self, finger_id: u64, x: i32, y: i32, w: u32, h: u32) {
|
||||
if !self.captured {
|
||||
return;
|
||||
}
|
||||
if let Some(&slot) = self.touch_slots.get(&finger_id) {
|
||||
send(
|
||||
&self.connector,
|
||||
InputKind::TouchMove,
|
||||
slot,
|
||||
x,
|
||||
y,
|
||||
Self::touch_flags(w, h),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A contact lifted — release its slot and the host contact. Forwarded even when not
|
||||
/// captured: a `release()` may have already flushed it (then the slot is gone and this
|
||||
/// no-ops), but a stray up must never strand a pressed contact on the host.
|
||||
pub fn on_touch_up(&mut self, finger_id: u64) {
|
||||
if let Some(slot) = self.touch_slots.remove(&finger_id) {
|
||||
send(&self.connector, InputKind::TouchUp, slot, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Route one forwarded touchscreen finger by the session's touch model. `wx`/`wy` are
|
||||
/// physical window pixels (the trackpad ballistics + gesture geometry); `abs` is the same
|
||||
/// finger mapped into the letterboxed content rect (pointer moves + raw passthrough). In
|
||||
/// `Touch` mode fingers go on the wire as real contacts; in `Trackpad`/`Pointer` they
|
||||
/// drive the gesture engine. Returns true when a three-finger tap asks to cycle the stats
|
||||
/// overlay — the only signal the run loop must act on.
|
||||
pub fn dispatch_finger(
|
||||
&mut self,
|
||||
phase: FingerPhase,
|
||||
id: u64,
|
||||
wx: f32,
|
||||
wy: f32,
|
||||
abs: Abs,
|
||||
t_ms: f64,
|
||||
) -> bool {
|
||||
match self.touch_mode {
|
||||
TouchMode::Touch => {
|
||||
match phase {
|
||||
FingerPhase::Down => self.on_touch_down(id, abs.x, abs.y, abs.w, abs.h),
|
||||
FingerPhase::Move => self.on_touch_move(id, abs.x, abs.y, abs.w, abs.h),
|
||||
FingerPhase::Up => self.on_touch_up(id),
|
||||
}
|
||||
false
|
||||
}
|
||||
TouchMode::Trackpad | TouchMode::Pointer => {
|
||||
// Down/Move only while captured (the stream owns the glass); an Up always runs
|
||||
// so a lift can conclude a gesture / release a held drag even if capture just
|
||||
// dropped (focus loss mid-touch).
|
||||
if !self.captured && phase != FingerPhase::Up {
|
||||
return false;
|
||||
}
|
||||
let acts = match phase {
|
||||
FingerPhase::Down => self.gestures.down(id, wx, wy, abs, t_ms),
|
||||
FingerPhase::Move => self.gestures.motion(id, wx, wy, abs, t_ms),
|
||||
FingerPhase::Up => self.gestures.up(id, t_ms),
|
||||
};
|
||||
let mut cycle_stats = false;
|
||||
for act in acts {
|
||||
cycle_stats |= self.apply_touch_act(act);
|
||||
}
|
||||
cycle_stats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one gesture [`Act`] on the wire, tracking button holds in `held_buttons` so a
|
||||
/// capture release flushes them (a tap-drag's left button never sticks down). Returns
|
||||
/// true for [`Act::CycleStats`], which is a run-loop signal, not a wire event.
|
||||
fn apply_touch_act(&mut self, act: Act) -> bool {
|
||||
match act {
|
||||
Act::CycleStats => return true,
|
||||
Act::Button { gs, down } => {
|
||||
if down {
|
||||
self.flush_motion(); // the press lands where the cursor now is
|
||||
self.held_buttons.insert(gs);
|
||||
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
|
||||
} else if self.held_buttons.remove(&gs) {
|
||||
self.flush_motion();
|
||||
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
if let Some((kind, code, x, y, flags)) = other.wire() {
|
||||
send(&self.connector, kind, code, x, y, flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub mod overlay;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
mod run;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod touch;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub mod vk;
|
||||
#[cfg(windows)]
|
||||
mod win32;
|
||||
|
||||
+242
-20
@@ -13,13 +13,14 @@
|
||||
//! the stdout line always carries the full Detailed text so parsers see a stable
|
||||
//! shape). Logs go to stderr (the binary configures tracing so).
|
||||
|
||||
use crate::input::Capture;
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
use anyhow::{Context as _, Result};
|
||||
use pf_client_core::gamepad::GamepadService;
|
||||
use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats};
|
||||
use pf_client_core::trust::StatsVerbosity;
|
||||
use pf_client_core::trust::{StatsVerbosity, TouchMode};
|
||||
use pf_client_core::video::VulkanDecodeDevice;
|
||||
use pf_client_core::video::{DecodedFrame, DecodedImage};
|
||||
use punktfunk_core::client::NativeClient;
|
||||
@@ -43,6 +44,10 @@ pub struct SessionOpts {
|
||||
/// Stats overlay tier at start — gates the OSD panel AND the stdout `stats:` lines
|
||||
/// (Ctrl+Alt+Shift+S cycles Off → Compact → Normal → Detailed live).
|
||||
pub stats_verbosity: StatsVerbosity,
|
||||
/// Touchscreen input model (Deck/tablet): `Trackpad` (relative cursor + gestures),
|
||||
/// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per
|
||||
/// session — a mouse-only client leaves this at the default and never sees a finger.
|
||||
pub touch_mode: TouchMode,
|
||||
/// Emit the `{"ready":true}` stdout line after the first presented frame.
|
||||
pub json_status: bool,
|
||||
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
|
||||
@@ -204,6 +209,11 @@ struct StreamState {
|
||||
/// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it
|
||||
/// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
|
||||
resize_overlay: ResizeIndicator,
|
||||
/// The last presented frame's video dimensions — the source rect touch passthrough
|
||||
/// maps a finger into (the video is letterboxed within the window, so a finger's
|
||||
/// window-normalized position must be re-based onto the content rect). `None` until
|
||||
/// the first frame; touches before then have nothing to map onto and are dropped.
|
||||
last_video: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -253,6 +263,7 @@ impl StreamState {
|
||||
resize_requested: None,
|
||||
shown_mode: None,
|
||||
resize_overlay: ResizeIndicator::default(),
|
||||
last_video: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +310,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
#[cfg(windows)]
|
||||
crate::win32::set_app_user_model_id();
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so
|
||||
// suppress SDL's default synthesis of mouse events from touch. Left on, every touch
|
||||
// ALSO warps a synthetic mouse to the touch point, which under the stream's relative
|
||||
// mouse lock becomes a large positive delta that walks the host cursor into the
|
||||
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
|
||||
// and consumes no mouse, so nothing wanted these synthetic events anyway.
|
||||
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
@@ -512,24 +530,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
continue;
|
||||
}
|
||||
if chord && sc == Scancode::S {
|
||||
stats_verbosity = stats_verbosity.next();
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
tracing::info!(tier = ?stats_verbosity, "chord: stats verbosity");
|
||||
// Re-render the OSD from the last window immediately — waiting
|
||||
// for the next Stats event would lag the keypress by up to 1 s.
|
||||
if let Some(st) = &mut stream {
|
||||
let text = match &st.last_stats {
|
||||
Some(s) => stats_text(
|
||||
stats_verbosity,
|
||||
&st.mode_line,
|
||||
s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
st.osd_text = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// F11 or Alt+Enter (some keyboards' Fn layer sends a media key for
|
||||
@@ -581,6 +583,78 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
cap.on_wheel(x, y);
|
||||
}
|
||||
}
|
||||
// Touchscreen fingers (the Deck's glass) → the session's touch model
|
||||
// (Trackpad/Pointer mouse, or real Touch passthrough), routed by `Capture`.
|
||||
// `x`/`y` are window-normalized (0..1); the dispatcher gets physical window
|
||||
// pixels AND the letterbox mapping. Only DIRECT devices (touchscreens) — an
|
||||
// INDIRECT trackpad drives the mouse and must not be mistaken for one. A
|
||||
// three-finger tap returns `cycle` → bump the stats tier, same as Ctrl+⌥+⇧+S.
|
||||
Event::FingerDown {
|
||||
touch_id,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
if is_direct_touch(touch_id)
|
||||
&& dispatch_finger(
|
||||
FingerPhase::Down,
|
||||
&window,
|
||||
&mut stream,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
)
|
||||
{
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
}
|
||||
}
|
||||
Event::FingerMotion {
|
||||
touch_id,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
if is_direct_touch(touch_id)
|
||||
&& dispatch_finger(
|
||||
FingerPhase::Move,
|
||||
&window,
|
||||
&mut stream,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
)
|
||||
{
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
}
|
||||
}
|
||||
Event::FingerUp {
|
||||
touch_id,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
if is_direct_touch(touch_id)
|
||||
&& dispatch_finger(
|
||||
FingerPhase::Up,
|
||||
&window,
|
||||
&mut stream,
|
||||
finger_id,
|
||||
x,
|
||||
y,
|
||||
timestamp,
|
||||
)
|
||||
{
|
||||
bump_stats_tier(&mut stats_verbosity, &mut stream, &presenter);
|
||||
}
|
||||
}
|
||||
// The wake forwarder's FrameWake (and any other user event): pure
|
||||
// wake-up — the frame drain below runs this iteration either way.
|
||||
Event::User { .. } => {}
|
||||
@@ -713,7 +787,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
.ok();
|
||||
gamepad.attach(c.clone());
|
||||
st.clock_offset = Some(c.clock_offset_shared());
|
||||
let mut cap = Capture::new(c.clone());
|
||||
let mut cap = Capture::new(c.clone(), opts.touch_mode);
|
||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||
apply_capture(&mut window, &mouse, true);
|
||||
st.capture = Some(cap);
|
||||
@@ -887,6 +961,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// picture is here — lift the scrim. A no-op unless a switch is in flight.
|
||||
let (fw, fh) = f.image.dimensions();
|
||||
st.resize_overlay.decoded(fw, fh);
|
||||
st.last_video = Some((fw, fh)); // touch passthrough's source rect
|
||||
let DecodedFrame {
|
||||
pts_ns,
|
||||
decoded_ns,
|
||||
@@ -1291,6 +1366,111 @@ fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUti
|
||||
window.set_keyboard_grab(on);
|
||||
}
|
||||
|
||||
/// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)?
|
||||
/// Trackpads report INDIRECT and drive the mouse — their finger events must not be
|
||||
/// forwarded as touch passthrough. An unknown/invalid id (INVALID) reads as not-direct.
|
||||
fn is_direct_touch(touch_id: u64) -> bool {
|
||||
use sdl3::sys::touch::{SDL_GetTouchDeviceType, SDL_TouchDeviceType, SDL_TouchID};
|
||||
unsafe { SDL_GetTouchDeviceType(SDL_TouchID(touch_id)) == SDL_TouchDeviceType::DIRECT }
|
||||
}
|
||||
|
||||
/// Route one SDL touchscreen finger into the active session's [`Capture`] per the touch
|
||||
/// model. SDL delivers window-normalized `x`/`y` (0..1) and a nanosecond `timestamp`; the
|
||||
/// dispatcher hands `Capture` physical window pixels (trackpad ballistics + gesture geometry)
|
||||
/// AND the finger mapped into the letterboxed content rect (pointer moves + raw passthrough).
|
||||
/// Returns whether a three-finger tap asked to cycle the stats tier. Down/Move before the
|
||||
/// first decoded frame have nothing to map onto and are dropped; an Up always dispatches so a
|
||||
/// lift can release a held contact/drag.
|
||||
fn dispatch_finger(
|
||||
phase: FingerPhase,
|
||||
window: &sdl3::video::Window,
|
||||
stream: &mut Option<StreamState>,
|
||||
finger_id: u64,
|
||||
x: f32,
|
||||
y: f32,
|
||||
timestamp: u64,
|
||||
) -> bool {
|
||||
let Some(st) = stream.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let (pw, ph) = window.size_in_pixels();
|
||||
let (wx, wy) = (x * pw as f32, y * ph as f32);
|
||||
let abs = match st.last_video {
|
||||
Some(video) => {
|
||||
let (ax, ay, aw, ah) = finger_to_content((pw, ph), video, x, y);
|
||||
Abs {
|
||||
x: ax,
|
||||
y: ay,
|
||||
w: aw,
|
||||
h: ah,
|
||||
}
|
||||
}
|
||||
None if phase == FingerPhase::Up => Abs {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 0,
|
||||
h: 0,
|
||||
},
|
||||
None => return false,
|
||||
};
|
||||
let Some(cap) = st.capture.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
cap.dispatch_finger(
|
||||
phase,
|
||||
finger_id,
|
||||
wx,
|
||||
wy,
|
||||
abs,
|
||||
timestamp as f64 / 1_000_000.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Advance the stats-overlay tier and re-render the OSD immediately from the last window
|
||||
/// (waiting for the next Stats event would lag the trigger by up to 1 s). Shared by the
|
||||
/// Ctrl+Alt+Shift+S chord and the three-finger touch tap.
|
||||
fn bump_stats_tier(
|
||||
verbosity: &mut StatsVerbosity,
|
||||
stream: &mut Option<StreamState>,
|
||||
presenter: &Presenter,
|
||||
) {
|
||||
*verbosity = verbosity.next();
|
||||
if let Some(st) = stream {
|
||||
st.osd_text = match &st.last_stats {
|
||||
Some(s) => stats_text(
|
||||
*verbosity,
|
||||
&st.mode_line,
|
||||
s,
|
||||
&st.presented,
|
||||
st.hdr,
|
||||
presenter.hdr_active(),
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The pure Contain-fit mapping (window pixels in, content pixels out) — split out so the
|
||||
/// letterbox math is testable without a live SDL window. Mirrors
|
||||
/// [`vk::letterbox`]; a finger in the letterbox bars clamps to the nearest content edge.
|
||||
fn finger_to_content(
|
||||
surface: (u32, u32),
|
||||
video: (u32, u32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> (i32, i32, u32, u32) {
|
||||
let (pw, ph) = (f64::from(surface.0), f64::from(surface.1));
|
||||
let (vw, vh) = video;
|
||||
let scale = (pw / f64::from(vw.max(1))).min(ph / f64::from(vh.max(1)));
|
||||
let dw = (f64::from(vw) * scale).max(1.0);
|
||||
let dh = (f64::from(vh) * scale).max(1.0);
|
||||
let ox = (pw - dw) / 2.0;
|
||||
let oy = (ph - dh) / 2.0;
|
||||
let cx = ((f64::from(x) * pw - ox) / dw).clamp(0.0, 1.0) * dw;
|
||||
let cy = ((f64::from(y) * ph - oy) / dh).clamp(0.0, 1.0) * dh;
|
||||
(cx.round() as i32, cy.round() as i32, dw as u32, dh as u32)
|
||||
}
|
||||
|
||||
/// The presenter's share of the unified stats window — folded into each printed line.
|
||||
#[derive(Default)]
|
||||
struct PresentedWindow {
|
||||
@@ -1614,4 +1794,46 @@ mod tests {
|
||||
"120 fps · 24 Mb/s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_maps_across_a_perfectly_filled_surface() {
|
||||
// Video exactly fills the window (no letterbox): normalized finger → content
|
||||
// corners/center map straight through, and the surface size is the video size.
|
||||
let video = (1920, 1080);
|
||||
assert_eq!(
|
||||
finger_to_content((1920, 1080), video, 0.0, 0.0),
|
||||
(0, 0, 1920, 1080)
|
||||
);
|
||||
assert_eq!(
|
||||
finger_to_content((1920, 1080), video, 1.0, 1.0),
|
||||
(1920, 1080, 1920, 1080)
|
||||
);
|
||||
assert_eq!(
|
||||
finger_to_content((1920, 1080), video, 0.5, 0.5),
|
||||
(960, 540, 1920, 1080)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_rebases_onto_the_letterboxed_content_rect() {
|
||||
// 16:9 video in the Deck's 16:10 glass (1280×800) letterboxes: content is
|
||||
// 1280×720, centered with 40px bars top/bottom. A finger at the window's vertical
|
||||
// center is the content's vertical center; a finger inside the top bar clamps to
|
||||
// the content's top edge (not a negative coordinate).
|
||||
let surface = (1280, 800);
|
||||
let video = (1920, 1080);
|
||||
let (_, cy, w, h) = finger_to_content(surface, video, 0.5, 0.5);
|
||||
assert_eq!((w, h), (1280, 720));
|
||||
assert_eq!(cy, 360);
|
||||
// y=0.01 → window pixel 8, above the 40px bar → clamps to content top (0).
|
||||
assert_eq!(
|
||||
finger_to_content(surface, video, 0.5, 0.01),
|
||||
(640, 0, 1280, 720)
|
||||
);
|
||||
// Bottom-right corner of the video content.
|
||||
assert_eq!(
|
||||
finger_to_content(surface, video, 1.0, 1.0),
|
||||
(1280, 720, 1280, 720)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Touchscreen fingers → host mouse for the `trackpad`/`pointer` touch-input models — an
|
||||
//! incremental port of the Android client's gesture engine (clients/android
|
||||
//! `TouchInput.kt`) and its Apple twin (`TouchMouse.swift`) so all three touch clients feel
|
||||
//! identical. The third model, `touch`, never reaches here: those fingers go on the wire as
|
||||
//! real multi-touch contacts (`Capture::on_touch_*`).
|
||||
//!
|
||||
//! Two mouse models share one gesture vocabulary:
|
||||
//! * **trackpad** (default): the cursor STAYS PUT on touch-down and moves by the finger's
|
||||
//! relative delta with mild acceleration — swipe to nudge, lift and re-swipe to walk it
|
||||
//! across, tap to click where it is. What makes a cursor reachable on a small screen.
|
||||
//! * **pointer**: the cursor jumps to the finger and follows it (absolute moves through the
|
||||
//! aspect-fit letterbox) — direct pointing.
|
||||
//!
|
||||
//! Shared gestures: tap = left click · two-finger tap = right click · two-finger drag =
|
||||
//! scroll · tap-then-press-and-drag = held left drag · three-finger tap = cycle the stats
|
||||
//! overlay tier.
|
||||
//!
|
||||
//! Unlike the Android/Apple hosts (which hand the engine a whole event's worth of changed
|
||||
//! touches at once), SDL delivers ONE finger transition per event, so this is a strictly
|
||||
//! incremental state machine: it keeps every live finger's position and recomputes the
|
||||
//! centroid itself. Positions are in physical window pixels (the caller multiplies SDL's
|
||||
//! normalized 0..1 finger coordinates by the window's pixel size) so the pixel-based
|
||||
//! ballistics constants port from Android 1:1; timestamps are milliseconds.
|
||||
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Gesture/ballistics tuning (physical px / ms), matching the Android reference exactly.
|
||||
/// Movement under this (px) still counts as a tap, not a drag.
|
||||
const TAP_SLOP: f32 = 12.0;
|
||||
/// A new touch this soon (ms) after a tap, near it, starts a held left-button drag.
|
||||
const TAP_DRAG_MS: f64 = 250.0;
|
||||
/// Two-finger pan distance (px) per 120-unit wheel notch (smaller = faster scroll).
|
||||
const SCROLL_DIV: f32 = 4.0;
|
||||
/// Base finger-px → host-px gain (~1:1, never twitchy).
|
||||
const POINTER_SENS: f32 = 1.3;
|
||||
/// Above `ACCEL_SPEED_FLOOR` px/ms the gain ramps by `ACCEL_GAIN` per px/ms, capped at
|
||||
/// `ACCEL_MAX` so a fast swipe can't fling the cursor uncontrollably.
|
||||
const ACCEL_GAIN: f32 = 0.6;
|
||||
const ACCEL_SPEED_FLOOR: f32 = 0.3;
|
||||
const ACCEL_MAX: f32 = 3.0;
|
||||
|
||||
/// GameStream mouse button ids.
|
||||
const BTN_LEFT: u32 = 1;
|
||||
const BTN_RIGHT: u32 = 3;
|
||||
|
||||
/// A finger's position in the letterboxed video content rect (absolute host pixels + the
|
||||
/// content surface size) — what `pointer` mode's absolute moves carry. Mirrors the
|
||||
/// `MouseMoveAbs` packing the host rescales into its output.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Abs {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
/// A wire intent the engine emits; the owner ([`Capture`](crate::input::Capture)) translates
|
||||
/// each into an actual `send_input`, and folds [`CycleStats`](Act::CycleStats) back to the
|
||||
/// run loop.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Act {
|
||||
/// Relative cursor motion (`MouseMove`).
|
||||
MoveRel { dx: i32, dy: i32 },
|
||||
/// Absolute cursor position through the letterbox (`MouseMoveAbs`).
|
||||
MoveAbs(Abs),
|
||||
/// A mouse button transition (`gs` = GameStream id; `down` = press/release).
|
||||
Button { gs: u32, down: bool },
|
||||
/// A wheel step: `axis` 0 = vertical, 1 = horizontal; `delta` in WHEEL(120) units.
|
||||
Scroll { axis: u32, delta: i32 },
|
||||
/// Three-finger tap: cycle the stats-overlay verbosity tier (the run loop owns it).
|
||||
CycleStats,
|
||||
}
|
||||
|
||||
impl Act {
|
||||
/// The `(InputKind, code, x, y, flags)` this intent sends. `Button`/`CycleStats` don't map
|
||||
/// to a single motion send, so callers special-case them; this covers the motion/scroll
|
||||
/// intents shared with the raw pointer path.
|
||||
pub fn wire(self) -> Option<(InputKind, u32, i32, i32, u32)> {
|
||||
match self {
|
||||
Act::MoveRel { dx, dy } => Some((InputKind::MouseMove, 0, dx, dy, 0)),
|
||||
Act::MoveAbs(a) => Some((
|
||||
InputKind::MouseMoveAbs,
|
||||
0,
|
||||
a.x,
|
||||
a.y,
|
||||
((a.w & 0xffff) << 16) | (a.h & 0xffff),
|
||||
)),
|
||||
Act::Scroll { axis, delta } => Some((InputKind::MouseScroll, axis, delta, 0, 0)),
|
||||
Act::Button { .. } | Act::CycleStats => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trackpad/pointer gesture state machine. One per session; `trackpad` picks the model
|
||||
/// (false = pointer). Fed only DIRECT touchscreen fingers.
|
||||
pub struct Gestures {
|
||||
trackpad: bool,
|
||||
/// Live fingers → current window-pixel position (the centroid needs every finger, but a
|
||||
/// move event only carries the one that changed).
|
||||
positions: HashMap<u64, (f32, f32)>,
|
||||
/// A gesture is in flight (≥ 1 finger down since the first touch).
|
||||
active: bool,
|
||||
start: (f32, f32),
|
||||
max_fingers: usize,
|
||||
moved: bool,
|
||||
scrolling: bool,
|
||||
/// Finger count the scroll centroid is anchored at — re-anchor on a count change so a
|
||||
/// 2→3 transition isn't read as a scroll notch.
|
||||
scroll_count: usize,
|
||||
scroll_anchor: (f32, f32),
|
||||
/// A tap-then-press-and-drag is holding the left button down for this whole gesture.
|
||||
drag_held: bool,
|
||||
// Trackpad relative-motion state: the tracked finger, its last position/time, and the
|
||||
// sub-pixel remainder so a slow drag isn't lost to integer truncation.
|
||||
track_id: Option<u64>,
|
||||
prev: (f32, f32),
|
||||
prev_t: f64,
|
||||
carry: (f32, f32),
|
||||
// Tap-drag arming: a quick tap leaves a window in which the next nearby touch drags.
|
||||
last_tap_up: f64,
|
||||
last_tap_pt: (f32, f32),
|
||||
}
|
||||
|
||||
impl Gestures {
|
||||
pub fn new(trackpad: bool) -> Gestures {
|
||||
Gestures {
|
||||
trackpad,
|
||||
positions: HashMap::new(),
|
||||
active: false,
|
||||
start: (0.0, 0.0),
|
||||
max_fingers: 0,
|
||||
moved: false,
|
||||
scrolling: false,
|
||||
scroll_count: 0,
|
||||
scroll_anchor: (0.0, 0.0),
|
||||
drag_held: false,
|
||||
track_id: None,
|
||||
prev: (0.0, 0.0),
|
||||
prev_t: 0.0,
|
||||
carry: (0.0, 0.0),
|
||||
last_tap_up: 0.0,
|
||||
last_tap_pt: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger touched down. `abs` is its letterbox mapping (pointer mode jumps the cursor
|
||||
/// there on the first finger). `t` is milliseconds.
|
||||
pub fn down(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
let first = self.positions.is_empty() && !self.active;
|
||||
self.positions.insert(id, (wx, wy));
|
||||
if first {
|
||||
self.active = true;
|
||||
self.start = (wx, wy);
|
||||
self.max_fingers = 0;
|
||||
self.moved = false;
|
||||
self.scrolling = false;
|
||||
self.scroll_count = 0;
|
||||
// A touch landing just after a quick tap nearby = tap-and-drag.
|
||||
self.drag_held = t - self.last_tap_up < TAP_DRAG_MS
|
||||
&& (wx - self.last_tap_pt.0).abs() < TAP_SLOP
|
||||
&& (wy - self.last_tap_pt.1).abs() < TAP_SLOP;
|
||||
self.last_tap_up = 0.0; // consume the arming either way
|
||||
if !self.trackpad {
|
||||
acts.push(Act::MoveAbs(abs)); // pointer: place the cursor before any press
|
||||
}
|
||||
if self.drag_held {
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true,
|
||||
});
|
||||
}
|
||||
self.track_id = Some(id);
|
||||
self.prev = (wx, wy);
|
||||
self.prev_t = t;
|
||||
self.carry = (0.0, 0.0);
|
||||
}
|
||||
self.max_fingers = self.max_fingers.max(self.positions.len());
|
||||
acts
|
||||
}
|
||||
|
||||
/// A finger moved.
|
||||
pub fn motion(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
|
||||
if !self.active || !self.positions.contains_key(&id) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.positions.insert(id, (wx, wy));
|
||||
if self.positions.len() >= 2 {
|
||||
self.scroll_by_centroid()
|
||||
} else if !self.scrolling {
|
||||
// One finger, and the gesture never became a scroll (dropping back from two
|
||||
// fingers to one must not jerk the cursor).
|
||||
self.single_finger(id, wx, wy, abs, t)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger lifted. Only when the LAST finger lifts does the gesture conclude (into a
|
||||
/// click / drag-end / stats cycle). `t` is the up-time in milliseconds.
|
||||
pub fn up(&mut self, id: u64, t: f64) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
self.positions.remove(&id);
|
||||
if self.track_id == Some(id) {
|
||||
self.track_id = None;
|
||||
}
|
||||
if !self.positions.is_empty() || !self.active {
|
||||
return acts; // other fingers still down (or no live gesture)
|
||||
}
|
||||
self.active = false;
|
||||
if self.drag_held {
|
||||
self.drag_held = false;
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false,
|
||||
}); // end the held drag
|
||||
} else if !self.moved {
|
||||
match self.max_fingers {
|
||||
n if n >= 3 => acts.push(Act::CycleStats),
|
||||
2 => {
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: true,
|
||||
});
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: false,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true,
|
||||
});
|
||||
acts.push(Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false,
|
||||
});
|
||||
self.last_tap_up = t; // arm tap-drag
|
||||
self.last_tap_pt = self.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// Forget all in-flight gesture state (capture release / session teardown). Any left
|
||||
/// button the engine is holding is released by the owner's held-button flush, so this
|
||||
/// only clears state — it never re-emits wire events.
|
||||
pub fn reset(&mut self) {
|
||||
self.positions.clear();
|
||||
self.track_id = None;
|
||||
self.active = false;
|
||||
self.scrolling = false;
|
||||
self.moved = false;
|
||||
self.drag_held = false;
|
||||
self.last_tap_up = 0.0;
|
||||
}
|
||||
|
||||
/// Two (or more) fingers → scroll by the centroid delta; never move the cursor. Fires a
|
||||
/// notch per `SCROLL_DIV` px of pan and re-anchors on fire; finger up scrolls up, finger
|
||||
/// right scrolls right (the host WHEEL(120) convention).
|
||||
fn scroll_by_centroid(&mut self) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
let n = self.positions.len() as f32;
|
||||
let (mut sx, mut sy) = (0.0f32, 0.0f32);
|
||||
for &(px, py) in self.positions.values() {
|
||||
sx += px;
|
||||
sy += py;
|
||||
}
|
||||
let (cx, cy) = (sx / n, sy / n);
|
||||
// (Re-)anchor on scroll start AND whenever the finger count changes.
|
||||
if !self.scrolling || self.positions.len() != self.scroll_count {
|
||||
self.scrolling = true;
|
||||
self.scroll_count = self.positions.len();
|
||||
self.scroll_anchor = (cx, cy);
|
||||
}
|
||||
let notches_y = ((self.scroll_anchor.1 - cy) / SCROLL_DIV) as i32;
|
||||
let notches_x = ((cx - self.scroll_anchor.0) / SCROLL_DIV) as i32;
|
||||
if notches_y != 0 {
|
||||
acts.push(Act::Scroll {
|
||||
axis: 0,
|
||||
delta: notches_y * 120,
|
||||
});
|
||||
self.scroll_anchor.1 = cy;
|
||||
self.moved = true;
|
||||
}
|
||||
if notches_x != 0 {
|
||||
acts.push(Act::Scroll {
|
||||
axis: 1,
|
||||
delta: notches_x * 120,
|
||||
});
|
||||
self.scroll_anchor.0 = cx;
|
||||
self.moved = true;
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// One finger, not scrolling: trackpad relative ballistics, or pointer absolute follow.
|
||||
fn single_finger(&mut self, id: u64, wx: f32, wy: f32, abs: Abs, t: f64) -> Vec<Act> {
|
||||
let mut acts = Vec::new();
|
||||
if (wx - self.start.0).abs() > TAP_SLOP || (wy - self.start.1).abs() > TAP_SLOP {
|
||||
self.moved = true;
|
||||
}
|
||||
if !self.trackpad {
|
||||
acts.push(Act::MoveAbs(abs)); // the cursor follows the finger
|
||||
return acts;
|
||||
}
|
||||
// Re-anchor (zero delta this frame) if the tracked finger changed, so lifting one of
|
||||
// several fingers never jumps the cursor.
|
||||
if self.track_id != Some(id) {
|
||||
self.track_id = Some(id);
|
||||
self.prev = (wx, wy);
|
||||
self.prev_t = t;
|
||||
return acts;
|
||||
}
|
||||
let dx = wx - self.prev.0;
|
||||
let dy = wy - self.prev.1;
|
||||
let dt_ms = (t - self.prev_t).max(1.0) as f32;
|
||||
self.prev = (wx, wy);
|
||||
self.prev_t = t;
|
||||
let speed = dx.hypot(dy) / dt_ms; // finger px per ms
|
||||
let accel = (1.0 + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).max(0.0)).min(ACCEL_MAX);
|
||||
let gain = POINTER_SENS * accel;
|
||||
self.carry.0 += dx * gain;
|
||||
self.carry.1 += dy * gain;
|
||||
let out_x = self.carry.0 as i32; // truncates toward zero → remainder kept with sign
|
||||
let out_y = self.carry.1 as i32;
|
||||
if out_x != 0 || out_y != 0 {
|
||||
acts.push(Act::MoveRel {
|
||||
dx: out_x,
|
||||
dy: out_y,
|
||||
});
|
||||
self.carry.0 -= out_x as f32;
|
||||
self.carry.1 -= out_y as f32;
|
||||
}
|
||||
acts
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ABS: Abs = Abs {
|
||||
x: 100,
|
||||
y: 200,
|
||||
w: 1280,
|
||||
h: 720,
|
||||
};
|
||||
|
||||
fn abs_at(x: i32, y: i32) -> Abs {
|
||||
Abs {
|
||||
x,
|
||||
y,
|
||||
w: 1280,
|
||||
h: 720,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_tap_is_a_left_click_with_no_motion() {
|
||||
let mut g = Gestures::new(true);
|
||||
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
acts.extend(g.up(1, 40.0));
|
||||
// A trackpad tap places no cursor and moves nothing — just a click.
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_tap_places_the_cursor_then_clicks() {
|
||||
let mut g = Gestures::new(false);
|
||||
let mut acts = g.down(1, 50.0, 50.0, abs_at(640, 360), 0.0);
|
||||
acts.extend(g.up(1, 40.0));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::MoveAbs(abs_at(640, 360)),
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_finger_tap_is_a_right_click() {
|
||||
let mut g = Gestures::new(true);
|
||||
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
acts.extend(g.down(2, 80.0, 52.0, ABS, 5.0));
|
||||
acts.extend(g.up(1, 40.0));
|
||||
acts.extend(g.up(2, 42.0));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_RIGHT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_finger_tap_cycles_stats() {
|
||||
let mut g = Gestures::new(true);
|
||||
let mut acts = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
acts.extend(g.down(2, 80.0, 50.0, ABS, 2.0));
|
||||
acts.extend(g.down(3, 110.0, 50.0, ABS, 4.0));
|
||||
acts.extend(g.up(1, 40.0));
|
||||
acts.extend(g.up(2, 41.0));
|
||||
acts.extend(g.up(3, 42.0));
|
||||
assert_eq!(acts, vec![Act::CycleStats]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trackpad_drag_emits_relative_motion() {
|
||||
let mut g = Gestures::new(true);
|
||||
assert!(g.down(1, 100.0, 100.0, ABS, 0.0).is_empty());
|
||||
// A big move over 16 ms — relative, with acceleration, so it should exceed 1:1.
|
||||
let acts = g.motion(1, 140.0, 100.0, ABS, 16.0);
|
||||
match acts.as_slice() {
|
||||
[Act::MoveRel { dx, dy }] => {
|
||||
assert!(*dx >= 40, "expected accelerated dx ≥ raw 40, got {dx}");
|
||||
assert_eq!(*dy, 0);
|
||||
}
|
||||
other => panic!("expected one MoveRel, got {other:?}"),
|
||||
}
|
||||
// The gesture moved, so the lift is not a tap (no click).
|
||||
assert!(g.up(1, 32.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_motion_follows_the_finger_absolutely() {
|
||||
let mut g = Gestures::new(false);
|
||||
let _ = g.down(1, 100.0, 100.0, abs_at(300, 300), 0.0);
|
||||
let acts = g.motion(1, 140.0, 120.0, abs_at(360, 340), 16.0);
|
||||
assert_eq!(acts, vec![Act::MoveAbs(abs_at(360, 340))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_finger_pan_scrolls_by_the_centroid() {
|
||||
let mut g = Gestures::new(true);
|
||||
let _ = g.down(1, 100.0, 200.0, ABS, 0.0);
|
||||
let _ = g.down(2, 120.0, 200.0, ABS, 2.0);
|
||||
// Both fingers slide up 40 px → the centroid rises 40 px → +ve (finger-up) notches.
|
||||
let a1 = g.motion(1, 100.0, 160.0, ABS, 10.0);
|
||||
let a2 = g.motion(2, 120.0, 160.0, ABS, 12.0);
|
||||
let scrolls: Vec<_> = a1.into_iter().chain(a2).collect();
|
||||
assert!(
|
||||
scrolls
|
||||
.iter()
|
||||
.any(|a| matches!(a, Act::Scroll { axis: 0, delta } if *delta > 0)),
|
||||
"expected an upward vertical scroll, got {scrolls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_then_press_drag_holds_the_left_button() {
|
||||
let mut g = Gestures::new(true);
|
||||
// Tap at (50,50), lifting at t=10.
|
||||
let _ = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
let click = g.up(1, 10.0);
|
||||
assert_eq!(
|
||||
click,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
// A new touch nearby within the window arms a held drag: button down on touch, and
|
||||
// the whole gesture holds it until the lift.
|
||||
let down2 = g.down(2, 52.0, 51.0, ABS, 120.0);
|
||||
assert_eq!(
|
||||
down2,
|
||||
vec![Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
}]
|
||||
);
|
||||
let _ = g.motion(2, 90.0, 51.0, ABS, 140.0); // drag
|
||||
let end = g.up(2, 160.0);
|
||||
assert_eq!(
|
||||
end,
|
||||
vec![Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_a_drag_without_re_emitting() {
|
||||
let mut g = Gestures::new(true);
|
||||
let _ = g.down(1, 50.0, 50.0, ABS, 0.0);
|
||||
let _ = g.up(1, 5.0); // arm
|
||||
let _ = g.down(2, 51.0, 50.0, ABS, 50.0); // drag begins (left held)
|
||||
g.reset();
|
||||
// After a reset a fresh tap is an ordinary click (no stuck drag state).
|
||||
let mut acts = g.down(3, 400.0, 400.0, ABS, 500.0);
|
||||
acts.extend(g.up(3, 510.0));
|
||||
assert_eq!(
|
||||
acts,
|
||||
vec![
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: true
|
||||
},
|
||||
Act::Button {
|
||||
gs: BTN_LEFT,
|
||||
down: false
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +352,11 @@ impl DualSenseManager {
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
hidout(h);
|
||||
// Skip rich feedback that repeats the last-forwarded value (the game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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};
|
||||
@@ -339,11 +340,18 @@ fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it).
|
||||
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it). This is a
|
||||
// DEGRADED outcome, not a normal one: a UHID device has no USB interface number (Interface: -1),
|
||||
// so Steam Input ignores it and the controller never appears in Game Mode / can't navigate.
|
||||
// Reaching here almost always means `vhci_hcd` isn't loaded (the host runs unprivileged and
|
||||
// can't modprobe it) — load it at boot (packaging ships modules-load.d/punktfunk.conf +
|
||||
// 60-punktfunk.rules; on a systemd-sysext host `punktfunk-sysext` mirrors both into /etc).
|
||||
let p = SteamDeckPad::open(idx)?;
|
||||
tracing::info!(
|
||||
tracing::warn!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (UHID hid-steam — not Steam-Input-promoted)"
|
||||
"virtual Steam Deck created as UHID hid-steam — Steam Input WON'T promote it (no USB \
|
||||
interface), so it won't appear in Game Mode. Load vhci_hcd (usbip) so the pad arrives as a \
|
||||
real USB device: `sudo modprobe vhci_hcd`, and ensure it loads at boot."
|
||||
);
|
||||
Ok(DeckTransport::Uhid(p))
|
||||
}
|
||||
@@ -353,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 {
|
||||
@@ -369,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,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);
|
||||
}
|
||||
@@ -459,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) {
|
||||
@@ -468,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,7 +519,11 @@ impl DualSenseWindowsManager {
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
hidout(h);
|
||||
// Skip rich feedback that repeats the last-forwarded value (the game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
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,44 +295,52 @@ impl GamepadManager {
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_active[idx] = Instant::now();
|
||||
self.gate.on_success();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled until the next client connect (install/repair: punktfunk-host.exe driver install --gamepad)");
|
||||
self.broken = true;
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — retrying with backoff (install/repair: punktfunk-host.exe driver install --gamepad)");
|
||||
self.gate.on_failure(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
let GamepadEvent::State(f) = ev else {
|
||||
return; // Arrival metadata — the pad is created lazily on the first State
|
||||
};
|
||||
let idx = f.index.max(0) as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
||||
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
|
||||
*slot = None;
|
||||
self.last_rumble[i] = (0, 0);
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index.max(0) as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
||||
tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)");
|
||||
*slot = None;
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
pad.write_state(
|
||||
(f.buttons & 0xffff) as u16,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
pad.write_state(
|
||||
(f.buttons & 0xffff) as u16,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -77,10 +77,26 @@ post_merge() {
|
||||
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
|
||||
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
|
||||
done
|
||||
# vhci-hcd now, no reboot (modules-load.d/punktfunk.conf covers boot): the usbip transport
|
||||
# that makes the virtual Steam Deck pad a real USB device Steam Input adopts. The udev add
|
||||
# event fires the 60-punktfunk.rules vhci rule, opening the attach files to the input group.
|
||||
[ -f /usr/lib/modules-load.d/punktfunk.conf ] && modprobe vhci-hcd 2>/dev/null || :
|
||||
# vhci-hcd: the usbip transport that makes the virtual Steam Deck pad a real USB device Steam
|
||||
# Input adopts. Without it the pad falls back to plain UHID hid-steam, which Steam Input won't
|
||||
# promote (Interface: -1) — so on a host in Game Mode the controller never appears and you can't
|
||||
# navigate. Two things must be true at boot: the module loaded, and the vhci `attach`/`detach`
|
||||
# sysfs files opened to the `input` group (the host runs unprivileged and can't modprobe/chown).
|
||||
#
|
||||
# A sysext CANNOT rely on its own /usr/lib/modules-load.d + /usr/lib/udev files for this: the
|
||||
# image merges (systemd-sysext.service) AFTER systemd-modules-load and early udev have already
|
||||
# run, so at a plain reboot vhci-hcd is never loaded and its rule never applied. Mirror BOTH into
|
||||
# real /etc (read at the normal early-boot time, and shadowing the /usr copies by filename) so the
|
||||
# module loads early and udev's coldplug trigger grants the group access. Refreshed every merge so
|
||||
# a rule/module change in a new image propagates (neither is user-editable config). Then load +
|
||||
# (re)apply now, no reboot, for this session.
|
||||
install -Dm0644 /usr/lib/modules-load.d/punktfunk.conf /etc/modules-load.d/punktfunk.conf 2>/dev/null || :
|
||||
install -Dm0644 /usr/lib/udev/rules.d/60-punktfunk.rules /etc/udev/rules.d/60-punktfunk.rules 2>/dev/null || :
|
||||
udevadm control --reload 2>/dev/null || :
|
||||
modprobe vhci-hcd 2>/dev/null || :
|
||||
# Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up
|
||||
# the input-group ownership even when the module's original add event predated the reloaded rule.
|
||||
udevadm trigger --subsystem-match=platform --sysname-match='vhci_hcd.*' 2>/dev/null || :
|
||||
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
|
||||
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
|
||||
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
|
||||
|
||||
Reference in New Issue
Block a user