From 3e6c9f606023b3c97b888ceeff7d39cce6ef749d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 21 Jun 2026 13:34:44 +0000 Subject: [PATCH] feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a physical Xbox One or PS4 pad on the client gets a near-native matching virtual pad on the host, auto-resolved from the controller type. Protocol/core: - GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/ from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants (compile-time guard ties them to the enum). Single-byte wire form is unchanged, so it's forward-compatible (older peers degrade to Auto). Host (Linux): - New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation: lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers / player LEDs / mute. Reuses the DualSense pure state + button mapping; only the report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane, lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane. - Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise. - PadBackend dispatch + resolver handle both; off Linux the UHID pads and One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred. Clients (auto-resolve physical pad -> virtual type, plus manual settings): - Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE -> Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture + lightbar already type-agnostic. Linux settings combo + label updated. - Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4 touchpad capture, settings picker entries. - Android (Kotlin): InputDevice VID/PID auto-detect (matching the other clients) + settings entries. - probe: --gamepad help/aliases. Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in catch_unwind so a panic degrades to a logged no-op instead of aborting the app. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 +- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 7 +- .../main/kotlin/io/unom/punktfunk/Settings.kt | 4 +- .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 65 ++ .../io/unom/punktfunk/kit/GamepadFeedback.kt | 25 +- clients/android/native/src/feedback.rs | 126 ++-- clients/android/native/src/session.rs | 125 ++-- .../PunktfunkClient/SettingsView.swift | 9 +- .../Sources/PunktfunkKit/GamepadCapture.swift | 36 +- .../PunktfunkKit/GamepadFeedback.swift | 14 +- .../Sources/PunktfunkKit/GamepadManager.swift | 44 +- .../PunktfunkKit/PunktfunkConnection.swift | 24 +- clients/linux/src/gamepad.rs | 42 +- clients/linux/src/ui_settings.rs | 11 +- clients/probe/src/main.rs | 11 +- clients/windows/src/gamepad.rs | 33 +- crates/punktfunk-core/src/abi.rs | 20 + crates/punktfunk-core/src/config.rs | 32 +- crates/punktfunk-core/src/quic.rs | 12 + crates/punktfunk-host/src/inject.rs | 2 + .../punktfunk-host/src/inject/dualshock4.rs | 629 ++++++++++++++++++ crates/punktfunk-host/src/inject/gamepad.rs | 75 ++- crates/punktfunk-host/src/punktfunk1.rs | 92 ++- include/punktfunk_core.h | 12 + 24 files changed, 1246 insertions(+), 214 deletions(-) create mode 100644 crates/punktfunk-host/src/inject/dualshock4.rs diff --git a/CLAUDE.md b/CLAUDE.md index c07672b..d390b5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc `send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD` - env > uinput Xbox 360; DualSense (UHID) only on Linux hosts. + env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same + XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible + capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID + `hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and + **DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player + LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360. + Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4, + Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for + DS4 get Xbox 360 for now. - **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA** virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput + diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index fe23bb6..04db0ae 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -63,6 +63,7 @@ import androidx.core.content.ContextCompat import io.unom.punktfunk.components.EmptyHostsState import io.unom.punktfunk.components.HostCard import io.unom.punktfunk.components.SectionLabel +import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.discovery.DiscoveredHost import io.unom.punktfunk.kit.discovery.HostDiscovery @@ -143,11 +144,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) { // Advertise HDR only when this device's display can present it (else the host sends a // proper SDR stream rather than PQ the panel would mis-tone-map). val hdrEnabled = displaySupportsHdr(context) + // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID + // (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An + // explicit choice is passed through unchanged. + val gamepadPref = Gamepad.resolvePref(settings.gamepad) val handle = withContext(Dispatchers.IO) { NativeBridge.nativeConnect( targetHost, targetPort, w, h, hz, id.certPem, id.privateKeyPem, pinHex ?: "", - settings.bitrateKbps, settings.compositor, settings.gamepad, + settings.bitrateKbps, settings.compositor, gamepadPref, hdrEnabled, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index a904068..ea3223d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -142,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf( "gamescope", ) -/** index = GamepadPref wire byte. */ +/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */ val GAMEPAD_OPTIONS = listOf( "Automatic", "Xbox 360", "DualSense", + "Xbox One", + "DualShock 4", ) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 6e18bd3..de8e271 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -44,6 +44,71 @@ object Gamepad { const val AXIS_LT = 4 const val AXIS_RT = 5 + // GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`. + const val PREF_AUTO = 0 + const val PREF_XBOX360 = 1 + const val PREF_DUALSENSE = 2 + const val PREF_XBOXONE = 3 + const val PREF_DUALSHOCK4 = 4 + + // USB vendor ids of the controllers we can identify by VID/PID. + private const val VID_SONY = 0x054C + private const val VID_MICROSOFT = 0x045E + + // Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. + private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) + private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC) + + // Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All + // behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte. + private val PID_XBOXONE = setOf( + 0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20, + ) + + /** + * Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the + * Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type + * auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` / + * `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as + * is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the + * host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a + * physical pad we resolve it concretely, matching the other native clients. + */ + fun prefFor(dev: InputDevice?): Int { + if (dev == null) return PREF_XBOX360 + val vid = dev.vendorId + val pid = dev.productId + return when { + vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE + vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4 + vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE + else -> PREF_XBOX360 + } + } + + /** First connected gamepad/joystick [InputDevice], or null when none is attached. */ + fun firstPad(): InputDevice? { + for (id in InputDevice.getDeviceIds()) { + val d = InputDevice.getDevice(id) ?: continue + val s = d.sources + if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + ) { + return d + } + } + return null + } + + /** + * The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A + * non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete + * type from the first connected controller via [prefFor] (so the host gets the right pad even + * though Android can't tell it the controller type any other way). + */ + fun resolvePref(setting: Int): Int = + if (setting == PREF_AUTO) prefFor(firstPad()) else setting + /** * Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are * positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred). diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index ee0a038..bc42af8 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -81,8 +81,16 @@ class GamepadFeedback(private val handle: Long) { rumbleThread?.interrupt() hidoutThread?.interrupt() runCatching { vm?.cancel() } // drop any held rumble immediately - runCatching { rumbleThread?.join(200) } - runCatching { hidoutThread?.join(200) } + // Join WITHOUT a timeout. These poll threads dereference the native session handle on every + // pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's + // onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out + // would let a thread survive into the freed handle → use-after-free SIGSEGV (the + // back-while-streaming crash, on the one path the main-thread `closed` guard can't cover). + // Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms) + // and rendering is a quick best-effort binder call, so each thread observes running=false and + // exits within ~one timeout — the join returns promptly (well under any ANR threshold). + runCatching { rumbleThread?.join() } + runCatching { hidoutThread?.join() } rumbleThread = null hidoutThread = null runCatching { lightsSession?.close() } @@ -94,18 +102,7 @@ class GamepadFeedback(private val handle: Long) { } /** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */ - private fun resolvePad(): InputDevice? { - for (id in InputDevice.getDeviceIds()) { - val d = InputDevice.getDevice(id) ?: continue - val s = d.sources - if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || - s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK - ) { - return d - } - } - return null - } + private fun resolvePad(): InputDevice? = Gamepad.firstPad() // ---- Rumble ---- diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index e374199..88416c7 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -7,7 +7,7 @@ //! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these //! compile on the host build too (parity with the input shims in [`crate::session`]). -use crate::session::SessionHandle; +use crate::session::{jni_guard, SessionHandle}; use jni::objects::{JByteBuffer, JObject}; use jni::sys::{jint, jlong}; use jni::JNIEnv; @@ -32,17 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( _this: JObject, handle: jlong, ) -> jlong { - if handle == 0 { - return -1; - } - // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the - // Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll - // threads (and joins them) before nativeClose frees the handle. - let h = unsafe { &*(handle as *const SessionHandle) }; - match h.client.next_rumble(PULL_TIMEOUT) { - Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high), - Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag - } + // Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary. + jni_guard(-1, || { + if handle == 0 { + return -1; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the + // Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll + // threads (and joins them — unbounded) before nativeClose frees the handle. + let h = unsafe { &*(handle as *const SessionHandle) }; + match h.client.next_rumble(PULL_TIMEOUT) { + Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high), + Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag + } + }) } /// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense @@ -58,57 +61,60 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout( handle: jlong, buf: JByteBuffer, ) -> jint { - if handle == 0 { - return -1; - } - // SAFETY: live handle per the contract; next_hidout is &self on the Sync connector. - let h = unsafe { &*(handle as *const SessionHandle) }; - let ev = match h.client.next_hidout(PULL_TIMEOUT) { - Ok(ev) => ev, - Err(_) => return -1, // timeout or closed — Kotlin loops - }; + // Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary. + jni_guard(-1, || { + if handle == 0 { + return -1; + } + // SAFETY: live handle per the contract; next_hidout is &self on the Sync connector. + let h = unsafe { &*(handle as *const SessionHandle) }; + let ev = match h.client.next_hidout(PULL_TIMEOUT) { + Ok(ev) => ev, + Err(_) => return -1, // timeout or closed — Kotlin loops + }; - // The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly. - let cap = match env.get_direct_buffer_capacity(&buf) { - Ok(c) => c, - Err(_) => return -1, - }; - let ptr = match env.get_direct_buffer_address(&buf) { - Ok(p) if !p.is_null() => p, - _ => return -1, - }; - // SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call. - let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) }; + // The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly. + let cap = match env.get_direct_buffer_capacity(&buf) { + Ok(c) => c, + Err(_) => return -1, + }; + let ptr = match env.get_direct_buffer_address(&buf) { + Ok(p) if !p.is_null() => p, + _ => return -1, + }; + // SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call. + let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) }; - let n = match ev { - HidOutput::Led { r, g, b, .. } => { - if cap < 4 { - return -1; + let n = match ev { + HidOutput::Led { r, g, b, .. } => { + if cap < 4 { + return -1; + } + out[0] = TAG_LED; + out[1] = r; + out[2] = g; + out[3] = b; + 4 } - out[0] = TAG_LED; - out[1] = r; - out[2] = g; - out[3] = b; - 4 - } - HidOutput::PlayerLeds { bits, .. } => { - if cap < 2 { - return -1; + HidOutput::PlayerLeds { bits, .. } => { + if cap < 2 { + return -1; + } + out[0] = TAG_PLAYER_LEDS; + out[1] = bits; + 2 } - out[0] = TAG_PLAYER_LEDS; - out[1] = bits; - 2 - } - HidOutput::Trigger { which, effect, .. } => { - let n = 2 + effect.len(); - if cap < n { - return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64 + HidOutput::Trigger { which, effect, .. } => { + let n = 2 + effect.len(); + if cap < n { + return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64 + } + out[0] = TAG_TRIGGER; + out[1] = which; + out[2..n].copy_from_slice(&effect); + n } - out[0] = TAG_TRIGGER; - out[1] = which; - out[2..n].copy_from_slice(&effect); - n - } - }; - n as jint + }; + n as jint + }) } diff --git a/clients/android/native/src/session.rs b/clients/android/native/src/session.rs index 377f869..903ba3f 100644 --- a/clients/android/native/src/session.rs +++ b/clients/android/native/src/session.rs @@ -19,11 +19,28 @@ use jni::JNIEnv; use punktfunk_core::client::NativeClient; use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use punktfunk_core::input::{InputEvent, InputKind}; +use std::panic::AssertUnwindSafe; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Duration; +/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead. +/// +/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 — +/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C +/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the +/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work. +/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an +/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged +/// no-op rather than kill the app. +pub(crate) fn jni_guard(default: T, f: impl FnOnce() -> T) -> T { + std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| { + log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)"); + default + }) +} + /// A live session behind the `jlong` handle: the connector + the decode thread it feeds. pub(crate) struct SessionHandle { // Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host @@ -231,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose( _this: JObject, handle: jlong, ) { - if handle != 0 { - // SAFETY: per the contract, `handle` is a live `Box` pointer. - unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) }; - } + jni_guard((), || { + if handle != 0 { + // SAFETY: per the contract, `handle` is a live `Box` pointer. + unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) }; + } + }) } /// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host @@ -367,11 +386,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( _this: JObject, handle: jlong, ) { - if handle != 0 { - // SAFETY: live handle per the contract. - let h = unsafe { &*(handle as *const SessionHandle) }; - h.stop_video(); - } + jni_guard((), || { + if handle != 0 { + // SAFETY: live handle per the contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.stop_video(); + } + }) } /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD. @@ -386,36 +407,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( _this: JObject, handle: jlong, ) -> jdoubleArray { - if handle == 0 { - return std::ptr::null_mut(); - } - // SAFETY: live handle per the nativeConnect/nativeClose contract. - let h = unsafe { &*(handle as *const SessionHandle) }; - let snap = match h.video.lock().unwrap().as_ref() { - Some(vt) => vt.stats.drain(), - None => return std::ptr::null_mut(), // not streaming → no stats - }; - let mode = h.client.mode(); - let buf: [f64; 10] = [ - snap.fps, - snap.mbps, - snap.lat_p50_ms, - snap.lat_p95_ms, - if snap.lat_valid { 1.0 } else { 0.0 }, - if snap.skew_corrected { 1.0 } else { 0.0 }, - mode.width as f64, - mode.height as f64, - mode.refresh_hz as f64, - h.client.frames_dropped() as f64, - ]; - let arr = match env.new_double_array(buf.len() as jsize) { - Ok(a) => a, - Err(_) => return std::ptr::null_mut(), - }; - if env.set_double_array_region(&arr, 0, &buf).is_err() { - return std::ptr::null_mut(); - } - arr.into_raw() + jni_guard(std::ptr::null_mut(), || { + if handle == 0 { + return std::ptr::null_mut(); + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + let snap = match h.video.lock().unwrap().as_ref() { + Some(vt) => vt.stats.drain(), + None => return std::ptr::null_mut(), // not streaming → no stats + }; + let mode = h.client.mode(); + let buf: [f64; 10] = [ + snap.fps, + snap.mbps, + snap.lat_p50_ms, + snap.lat_p95_ms, + if snap.lat_valid { 1.0 } else { 0.0 }, + if snap.skew_corrected { 1.0 } else { 0.0 }, + mode.width as f64, + mode.height as f64, + mode.refresh_hz as f64, + h.client.frames_dropped() as f64, + ]; + let arr = match env.new_double_array(buf.len() as jsize) { + Ok(a) => a, + Err(_) => return std::ptr::null_mut(), + }; + if env.set_double_array_region(&arr, 0, &buf).is_err() { + return std::ptr::null_mut(); + } + arr.into_raw() + }) } /// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already @@ -451,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio( _this: JObject, handle: jlong, ) { - if handle != 0 { - // SAFETY: live handle per the contract. - let h = unsafe { &*(handle as *const SessionHandle) }; - h.stop_audio(); - } + jni_guard((), || { + if handle != 0 { + // SAFETY: live handle per the contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.stop_audio(); + } + }) } /// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`). @@ -492,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic( _this: JObject, handle: jlong, ) { - if handle != 0 { - // SAFETY: live handle per the contract. - let h = unsafe { &*(handle as *const SessionHandle) }; - h.stop_mic(); - } + jni_guard((), || { + if handle != 0 { + // SAFETY: live handle per the contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.stop_mic(); + } + }) } // ---- Input plane: Kotlin capture → NativeClient::send_input ---------------------------------- diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 60d450c..a1a1e70 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -511,15 +511,18 @@ struct SettingsView: View { private static let padTypes: [(label: String, tag: Int)] = [ ("Automatic", 0), ("Xbox 360", 1), + ("Xbox One", 3), ("DualSense", 2), + ("DualShock 4", 4), ] private static let controllersFooter = "One controller is forwarded to the host, as player 1 — Automatic picks the most " + "recently connected one. The type is the virtual pad the host creates: Automatic " + "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad " - + "and motion), and changes apply from the next session. Two identical controllers " - + "may swap a manual selection after reconnecting." + + "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply " + + "from the next session. Two identical controllers may swap a manual selection " + + "after reconnecting." /// "Use controller" choices: Automatic, every forwardable controller, and — so a stale /// pin stays visible instead of leaving the Picker selection tag-less — any pinned id @@ -537,7 +540,7 @@ struct SettingsView: View { private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View { HStack(spacing: 10) { - Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill") + Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill") .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text(controller.name) diff --git a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift index f3be164..e8cc190 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift @@ -6,12 +6,14 @@ // full GCExtendedGamepad state on every valueChanged and diff against the previous // snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. // -// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized +// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized // 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion // samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — // derived from the host's fixed calibration blob; the conversion lives in ONE place, // `Wire`, so a live sign/scale correction is a one-line change). The host ignores both -// unless the session's virtual pad is a DualSense. +// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad +// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same +// `touchpad*` surface as `GCDualSenseGamepad`). // // Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture // toggle — a controller can't click local UI, so it always drives the host while the app @@ -154,8 +156,9 @@ public final class GamepadCapture { releaseAll() if let ext = bound?.extendedGamepad { ext.valueChangedHandler = nil - (ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil - (ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil + let tp = Self.touchpad(ext) + tp?.primary.valueChangedHandler = nil + tp?.secondary.valueChangedHandler = nil } if let motion = bound?.motion { motion.valueChangedHandler = nil @@ -186,11 +189,11 @@ public final class GamepadCapture { connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0)) sync(ext) - if let ds = ext as? GCDualSenseGamepad { - ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in + if let tp = Self.touchpad(ext) { + tp.primary.valueChangedHandler = { [weak self] _, x, y in MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) } } - ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in + tp.secondary.valueChangedHandler = { [weak self] _, x, y in MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) } } } @@ -257,12 +260,29 @@ public final class GamepadCapture { if g.buttonB.isPressed { b |= GamepadWire.b } if g.buttonX.isPressed { b |= GamepadWire.x } if g.buttonY.isPressed { b |= GamepadWire.y } - if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true { + if Self.touchpad(g)?.button.isPressed == true { b |= GamepadWire.touchpadClick } return b } + /// The touchpad surface of a PlayStation pad — present on both `GCDualSenseGamepad` and + /// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we + /// downcast either and project the identical `touchpad*` properties. `nil` for any other + /// controller (Xbox, MFi). + private static func touchpad( + _ g: GCExtendedGamepad + ) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad, + button: GCControllerButtonInput)? { + if let ds = g as? GCDualSenseGamepad { + return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton) + } + if let ds4 = g as? GCDualShockGamepad { + return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton) + } + return nil + } + /// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on /// lift — treated as the lift signal (a real finger landing on the precise center /// momentarily reads as a lift; harmless for a 1-in-65k coincidence). diff --git a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift index 07ef4af..6cbdb0a 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift @@ -8,8 +8,9 @@ // trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger. // // Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists -// only on DualSense sessions — the drain always polls both planes with short timeouts and -// never spins, so an Xbox session just renders rumble. GameController profile mutation +// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the +// drain always polls both planes with short timeouts and never spins, so an Xbox session +// just renders rumble. GameController profile mutation // happens on main; CHHapticEngine work on its own serial queue; the drain thread itself // touches neither. When GamepadManager switches the active controller mid-session, the // old pad is reset (triggers off, player index unset) and the last known feedback state @@ -248,9 +249,12 @@ public final class GamepadFeedback { public func start() { guard !drainStarted else { return } drainStarted = true - // No hidout traffic can exist on a non-DualSense session — poll that plane - // nonblocking there and let rumble own the wait. - let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0 + // Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad + // session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and + // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking. + let hasHidout = connection.resolvedGamepad == .dualSense + || connection.resolvedGamepad == .dualShock4 + let hidTimeout: UInt32 = hasHidout ? 10 : 0 let thread = Thread { [connection, flag, drainDone, weak self] in while !flag.isStopped { do { diff --git a/clients/apple/Sources/PunktfunkKit/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/GamepadManager.swift index 4ca488c..a0406f0 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/GamepadManager.swift @@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject { public let productCategory: String /// The full extended profile exists — only these are forwardable. public let isExtended: Bool - public let isDualSense: Bool + /// The virtual-pad type a physical match resolves to under `.auto`: DualSense → + /// `.dualSense`, DualShock 4 → `.dualShock4`, an Xbox pad → `.xboxOne`, anything + /// else → `.xbox360`. (`.auto` is never stored here.) + public let kind: PunktfunkConnection.GamepadType public let hasLight: Bool public let hasHaptics: Bool public let hasMotion: Bool public let hasAdaptiveTriggers: Bool + /// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers, + /// player LEDs) and the PlayStation glyph in Settings. + public var isDualSense: Bool { kind == .dualSense } + /// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates + /// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC). + public var hasTouchpadAndMotion: Bool { + kind == .dualSense || kind == .dualShock4 + } /// 0...1, nil when the controller doesn't report a battery (e.g. wired). public let batteryLevel: Float? public let isCharging: Bool @@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject { /// Connect-time resolution of the user's controller-type setting: an explicit choice /// wins; `.auto` matches the virtual pad to the active physical controller (DualSense → - /// DualSense, anything else → Xbox 360); no controller at all defers to the host. + /// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox + /// 360); no controller at all defers to the host. public func resolveType( setting: PunktfunkConnection.GamepadType ) -> PunktfunkConnection.GamepadType { @@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject { // pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race. rebuild() guard let active else { return .auto } - return active.isDualSense ? .dualSense : .xbox360 + return active.kind } private func noteConnected(_ c: GCController) { @@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject { private static func describe(_ c: GCController, id: String) -> DiscoveredController { let extended = c.extendedGamepad - let ds = extended as? GCDualSenseGamepad + let kind = padKind(extended) return DiscoveredController( id: id, name: c.vendorName ?? c.productCategory, productCategory: c.productCategory, isExtended: extended != nil, - isDualSense: ds != nil, + kind: kind, hasLight: c.light != nil, hasHaptics: c.haptics != nil, hasMotion: c.motion != nil, - // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration. - hasAdaptiveTriggers: ds != nil, + // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the + // DualShock 4 has none. + hasAdaptiveTriggers: kind == .dualSense, batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil }, isCharging: c.battery?.batteryState == .charging, controller: c) } + + /// Resolve a physical controller's matching virtual-pad type from its GameController + /// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then + /// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent + /// profile also falls back to `.xbox360` (it's never forwarded anyway). + private static func padKind( + _ extended: GCExtendedGamepad? + ) -> PunktfunkConnection.GamepadType { + guard let extended else { return .xbox360 } + // Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version + // here, so no `@available` guard is needed — matching the unguarded + // `GCDualSenseGamepad` use elsewhere in the package. + if extended is GCDualSenseGamepad { return .dualSense } + if extended is GCDualShockGamepad { return .dualShock4 } + if extended is GCXboxGamepad { return .xboxOne } + return .xbox360 + } } diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index 8fe0d40..e44c01d 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -170,13 +170,18 @@ public final class PunktfunkConnection { /// Which virtual gamepad the host creates for this session's pads (the /// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else - /// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) — games then see - /// a real DualSense and their lightbar / adaptive-trigger writes come back on the - /// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`. + /// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux) — + /// games then see a real PlayStation pad and its lightbar (and, on a DualSense, + /// adaptive-trigger / player-LED) writes come back on the HID-output plane + /// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same + /// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual + /// choice is `resolvedGamepad`. public enum GamepadType: UInt32, CaseIterable, Sendable { case auto = 0 case xbox360 = 1 case dualSense = 2 + case xboxOne = 3 + case dualShock4 = 4 /// Loose name parsing for env/dev hooks, mirroring the host's /// `GamepadPref::from_name`. @@ -184,7 +189,9 @@ public final class PunktfunkConnection { switch name.lowercased() { case "auto", "default": self = .auto case "xbox", "xbox360", "x360", "uinput": self = .xbox360 - case "dualsense", "ds", "ps5": self = .dualSense + case "dualsense", "ds", "ds5", "ps5": self = .dualSense + case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne + case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 default: return nil } } @@ -497,10 +504,11 @@ public final class PunktfunkConnection { case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8]) } - /// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers); - /// nil on timeout, throws `.closed` once the session ended. Drain from the (single) - /// feedback thread, alongside `nextRumble`. Nothing ever arrives unless - /// `resolvedGamepad == .dualSense` — poll with a short timeout, never spin. + /// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive + /// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the + /// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's + /// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a + /// short timeout, never spin. public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? { feedbackLock.lock() defer { feedbackLock.unlock() } diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index ae5f485..f8121b0 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -39,7 +39,39 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir pub struct PadInfo { pub id: u32, pub name: String, - pub is_dualsense: bool, + /// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a + /// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything + /// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path. + 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 controller-kind label for the Settings list (`""` for a plain Xbox/standard pad). + pub fn kind_label(&self) -> &'static str { + match self.pref { + GamepadPref::DualSense => "DualSense", + GamepadPref::DualShock4 => "DualShock 4", + GamepadPref::XboxOne => "Xbox One", + _ => "", + } + } +} + +/// 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 { @@ -120,8 +152,7 @@ impl GamepadService { /// (Swift parity); no pad connected leaves the host's own default. pub fn auto_pref(&self) -> GamepadPref { match self.active() { - Some(p) if p.is_dualsense => GamepadPref::DualSense, - Some(_) => GamepadPref::Xbox360, + Some(p) => p.pref, None => GamepadPref::Auto, } } @@ -247,10 +278,9 @@ impl Worker { Some(PadInfo { id, name: pad.name().unwrap_or_else(|| "Controller".into()), - is_dualsense: matches!( + pref: pref_for_type( self.subsystem .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), - sdl3::gamepad::GamepadType::PS5 ), }) } @@ -552,7 +582,7 @@ fn run( } 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 is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense()); let Some(pad) = w.opened.get_mut(&id) else { continue; }; diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index 1e3d930..187e752 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -16,7 +16,7 @@ const RESOLUTIONS: &[(u32, u32)] = &[ ]; /// `0` = the monitor's native refresh, resolved at connect. const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; -const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"]; +const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; pub fn show( @@ -85,10 +85,11 @@ pub fn show( let pads = gamepads.pads(); let mut pad_names = vec!["Automatic (most recent)".to_string()]; pad_names.extend(pads.iter().map(|p| { - if p.is_dualsense { - format!("{} · DualSense", p.name) - } else { + let kind = p.kind_label(); + if kind.is_empty() { p.name.clone() + } else { + format!("{} · {kind}", p.name) } })); let forward_row = adw::ComboRow::builder() @@ -126,6 +127,8 @@ pub fn show( "Automatic", "Xbox 360", "DualSense", + "Xbox One", + "DualShock 4", ])) .build(); let inhibit_row = adw::SwitchRow::builder() diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 5d0cf0a..aec9b02 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -27,9 +27,10 @@ //! `gamescope`); the host honors it if available, else auto-detects and reports the resolved //! choice in its Welcome (logged as `session offer … compositor=…`). //! -//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the -//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360, -//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`). +//! `--gamepad NAME` requests a host virtual-pad backend +//! (`auto`|`xbox360`|`dualsense`|`xboxone`|`dualshock4`); the host honors it where available (the +//! UHID pads — DualSense, DualShock 4 — need Linux), else falls back to X-Box 360, and reports the +//! resolved choice in its Welcome (logged as `session offer … gamepad=…`). //! //! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises //! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and @@ -178,7 +179,9 @@ fn parse_args() -> Args { Some(s) => match GamepadPref::from_name(s) { Some(g) => g, None => { - eprintln!("--gamepad must be one of: auto, xbox360, dualsense"); + eprintln!( + "--gamepad must be one of: auto, xbox360, dualsense, xboxone, dualshock4" + ); std::process::exit(2); } }, diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs index de52e0c..6649793 100644 --- a/clients/windows/src/gamepad.rs +++ b/clients/windows/src/gamepad.rs @@ -32,12 +32,33 @@ const G: f32 = 9.80665; #[derive(Clone, Debug)] pub struct PadInfo { // `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only - // reads `is_dualsense` (via `auto_pref`), so they're unused in reachable code for now. + // reads `pref` (via `auto_pref`), so they're unused in reachable code for now. #[allow(dead_code)] pub id: u32, #[allow(dead_code)] pub name: String, - pub is_dualsense: bool, + /// 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 + } +} + +/// 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 { @@ -112,8 +133,7 @@ impl GamepadService { /// (Swift parity); no pad connected leaves the host's own default. pub fn auto_pref(&self) -> GamepadPref { match self.active() { - Some(p) if p.is_dualsense => GamepadPref::DualSense, - Some(_) => GamepadPref::Xbox360, + Some(p) => p.pref, None => GamepadPref::Auto, } } @@ -235,10 +255,9 @@ impl Worker { Some(PadInfo { id, name: pad.name().unwrap_or_else(|| "Controller".into()), - is_dualsense: matches!( + pref: pref_for_type( self.subsystem .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), - sdl3::gamepad::GamepadType::PS5 ), }) } @@ -515,7 +534,7 @@ fn run( } 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 is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense()); let Some(pad) = w.opened.get_mut(&id) else { continue; }; diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 7137049..9ece706 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -687,6 +687,16 @@ pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1; /// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored /// only where available (Linux hosts); otherwise the host falls back to X-Box 360. pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2; +/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so +/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain; +/// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a +/// physical X-Box One/Series controller on the client. +pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3; +/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the +/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like +/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux +/// hosts); otherwise the host falls back to X-Box 360. +pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4; /// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. /// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to @@ -706,6 +716,16 @@ const _: () = { assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR); }; +// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift). +const _: () = { + use crate::config::GamepadPref; + assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32); + assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32); + assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32); + assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32); + assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32); +}; + /// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's /// certificate — a mismatching host is rejected. NULL = trust on first use; persist the /// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index cb5ea3a..102c7af 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -135,10 +135,10 @@ impl CompositorPref { /// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the /// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the /// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is -/// honored only if that backend is available on the host (DualSense needs Linux UHID); otherwise -/// the host falls back and reports the real choice in `Welcome`. The wire form is a single byte -/// (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`), appended to `Hello`/`Welcome` — older peers -/// simply omit/ignore it. +/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID); +/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single +/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to +/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum GamepadPref { /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). @@ -148,15 +148,24 @@ pub enum GamepadPref { Xbox360, /// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion. DualSense, + /// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity + /// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger + /// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`). + XboxOne, + /// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like + /// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host. + DualShock4, } impl GamepadPref { - /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`. - pub fn to_u8(self) -> u8 { + /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`. + pub const fn to_u8(self) -> u8 { match self { GamepadPref::Auto => 0, GamepadPref::Xbox360 => 1, GamepadPref::DualSense => 2, + GamepadPref::XboxOne => 3, + GamepadPref::DualShock4 => 4, } } @@ -166,6 +175,8 @@ impl GamepadPref { match v { 1 => GamepadPref::Xbox360, 2 => GamepadPref::DualSense, + 3 => GamepadPref::XboxOne, + 4 => GamepadPref::DualShock4, _ => GamepadPref::Auto, } } @@ -177,16 +188,23 @@ impl GamepadPref { "auto" | "default" => GamepadPref::Auto, "xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360, "dualsense" | "ds" | "ps5" => GamepadPref::DualSense, + "xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => { + GamepadPref::XboxOne + } + "dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4, _ => return None, }) } - /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`). + /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, + /// `"dualshock4"`). pub fn as_str(self) -> &'static str { match self { GamepadPref::Auto => "auto", GamepadPref::Xbox360 => "xbox360", GamepadPref::DualSense => "dualsense", + GamepadPref::XboxOne => "xboxone", + GamepadPref::DualShock4 => "dualshock4", } } } diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 2be5f9a..aff6fba 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -1883,13 +1883,25 @@ mod tests { GamepadPref::Auto, GamepadPref::Xbox360, GamepadPref::DualSense, + GamepadPref::XboxOne, + GamepadPref::DualShock4, ] { assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); } + // Distinct wire bytes (forward-compat with peers that only know 0..=2). + assert_eq!(GamepadPref::XboxOne.to_u8(), 3); + assert_eq!(GamepadPref::DualShock4.to_u8(), 4); // Aliases + unknowns. assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); + assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4)); + assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4)); + assert_eq!( + GamepadPref::from_name("xbox-one"), + Some(GamepadPref::XboxOne) + ); + assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne)); assert_eq!(GamepadPref::from_name("nope"), None); // Unknown wire byte degrades to Auto (forward-compatible). assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto); diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index b76d582..877bb01 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -424,6 +424,8 @@ fn gs_button_to_evdev(b: u32) -> Option { #[cfg(target_os = "linux")] pub mod dualsense; #[cfg(target_os = "linux")] +pub mod dualshock4; +#[cfg(target_os = "linux")] pub mod gamepad; /// Windows: virtual Xbox 360 pads via ViGEmBus. #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/inject/dualshock4.rs b/crates/punktfunk-host/src/inject/dualshock4.rs new file mode 100644 index 0000000..4f3cef9 --- /dev/null +++ b/crates/punktfunk-host/src/inject/dualshock4.rs @@ -0,0 +1,629 @@ +//! Virtual Sony DualShock 4 (PS4) via UHID — the PS4 sibling of the DualSense backend +//! ([`super::dualsense`]). A UHID device presents a *real* DualShock 4 HID interface to the kernel: +//! `hid-playstation` binds it (matched by VID `054C`/PID `09CC`, since Linux 6.2) and exposes the +//! full controller — gamepad, motion sensors, touchpad, lightbar, rumble — to games. We write HID +//! **input** reports (report `0x01`, our controller state) and read HID **output** reports (report +//! `0x05`, a game's rumble/lightbar feedback) back, forwarding them to the client. +//! +//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute +//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal +//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is +//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the +//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad +//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout +//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse. + +use super::dualsense::{DsState, Touch}; +use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use anyhow::{Context, Result}; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::time::{Duration, Instant}; + +// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`. +const UHID_PATH: &str = "/dev/uhid"; +const UHID_DESTROY: u32 = 1; +const UHID_OUTPUT: u32 = 6; +const UHID_GET_REPORT: u32 = 9; +const UHID_GET_REPORT_REPLY: u32 = 10; +const UHID_CREATE2: u32 = 11; +const UHID_INPUT2: u32 = 12; +const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; +const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) +const BUS_USB: u16 = 0x03; + +// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is +// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the +// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are +// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for +// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it). +#[rustfmt::skip] +const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01) + 0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; +#[rustfmt::skip] +const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words) + 0x02, + 0x00, 0x00, // gyro_pitch_bias = 0 + 0x00, 0x00, // gyro_yaw_bias = 0 + 0x00, 0x00, // gyro_roll_bias = 0 + 0x10, 0x00, // gyro_pitch_plus = +16 + 0xF0, 0xFF, // gyro_pitch_minus = -16 + 0x10, 0x00, // gyro_yaw_plus = +16 + 0xF0, 0xFF, // gyro_yaw_minus = -16 + 0x10, 0x00, // gyro_roll_plus = +16 + 0xF0, 0xFF, // gyro_roll_minus = -16 + 0x20, 0x00, // gyro_speed_plus = +32 + 0x20, 0x00, // gyro_speed_minus = +32 + 0x00, 0x20, // acc_x_plus = +8192 + 0x00, 0xE0, // acc_x_minus = -8192 + 0x00, 0x20, // acc_y_plus = +8192 + 0x00, 0xE0, // acc_y_minus = -8192 + 0x00, 0x20, // acc_z_plus = +8192 + 0x00, 0xE0, // acc_z_minus = -8192 + 0x00, 0x00, // trailing pad (descriptor declares 36 data bytes) +]; +#[rustfmt::skip] +const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic) + 0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013" + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12" + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xA0, // hw_version = 0xA000 (buf[35]) + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, // fw_version = 0x0100 (buf[41]) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total +]; + +/// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture +/// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature +/// reports `0x02`/`0x12`/`0xa3` so the kernel's GET_REPORTs route. The kernel binds DS4 by VID/PID, +/// but HID core still needs these reports declared. +#[rustfmt::skip] +const DS4_RDESC: &[u8] = &[ + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, + 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, + 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, + 0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95, + 0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, + 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, + 0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09, + 0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1, + 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09, + 0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1, + 0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06, + 0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22, + 0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95, + 0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06, + 0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81, + 0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05, + 0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84, + 0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06, + 0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87, + 0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90, + 0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03, + 0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93, + 0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1, + 0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01, + 0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4, + 0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, + 0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01, + 0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9, + 0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01, + 0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC, + 0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B, + 0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF, + 0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3, + 0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0, + 0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F, + 0xB1, 0x02, 0xC0, +]; + +const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment +const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2) +/// USB input report `0x01` is 64 bytes total (report id + 63-byte body). +const DS4_INPUT_REPORT_LEN: usize = 64; +/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower +/// than the DualSense's 1920×1080. +pub const DS4_TOUCH_W: u16 = 1920; +pub const DS4_TOUCH_H: u16 = 942; + +/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's: +/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y). +fn pack_touch(dst: &mut [u8], t: &Touch) { + dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 }; + // Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1. + let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1)); + dst[1] = (x & 0xFF) as u8; + dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4); + dst[3] = ((y >> 4) & 0xFF) as u8; +} + +/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field +/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; +/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; +/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` +/// field at struct offset N sits at report byte N+1. +fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) { + r[0] = 0x01; // report id + r[1] = st.lx; + r[2] = st.ly; + r[3] = st.rx; + r[4] = st.ry; + r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high) + r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3 + r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter + r[8] = st.l2; // L2 analog (z) + r[9] = st.r2; // R2 analog (rz) + r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9) + // r[12] temperature stays 0 + for (i, v) in st.gyro.iter().enumerate() { + r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12 + } + for (i, v) in st.accel.iter().enumerate() { + r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18 + } + // r[25..30] reserved2. + // status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report + // wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad. + r[30] = 0x10 | 0x0B; + // r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0. + r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one) + r[34] = ts as u8; // touch_reports[0].timestamp + pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0 + pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1 + // remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero +} + +/// What one [`DualShock4Pad::service`] pass extracted from the device's HID output reports. Rumble +/// rides the universal 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player +/// LEDs or adaptive triggers, so those never appear). +#[derive(Default)] +pub struct Ds4Feedback { + pub hidout: Vec, + /// `(low, high)` motor levels (0..=0xFF00), if a report carried them. + pub rumble: Option<(u16, u16)>, + /// Lightbar RGB, if the report carried it (deduped by the manager). + pub led: Option<(u8, u8, u8)>, +} + +/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel +/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1], +/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5], +/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write +/// doesn't masquerade as a lightbar change. +fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) { + if data.first() != Some(&0x05) || data.len() < 11 { + return; // not the USB output report (BT 0x11 is shifted) / too short + } + let flag0 = data[1]; + if flag0 & 0x01 != 0 { + // motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4]; + // scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends. + let low = (data[5] as u16) << 8; + let high = (data[4] as u16) << 8; + fb.rumble = Some((low, high)); + } + if flag0 & 0x02 != 0 { + fb.led = Some((data[6], data[7], data[8])); + } +} + +/// Copy a NUL-padded C string field into the event buffer. +fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { + let n = s.len().min(cap - 1); + ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) +} + +/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's). +/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface). +pub struct DualShock4Pad { + fd: File, + counter: u8, + ts: u16, +} + +impl DualShock4Pad { + /// Create the UHID DualShock 4 for pad `index` (used only to make the device name/uniq unique). + pub fn open(index: u8) -> Result { + let fd = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(UHID_PATH) + .with_context(|| { + format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)") + })?; + let mut ds = DualShock4Pad { + fd, + counter: 0, + ts: 0, + }; + ds.send_create2(index).context("UHID_CREATE2 DualShock4")?; + Ok(ds) + } + + fn send_create2(&mut self, index: u8) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); + // union (uhid_create2_req) starts at byte 4. + put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128] + put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64] + // A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's + // duplicate-device check itself keys off the per-pad MAC in the pairing feature report). + put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64] + ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size + ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus + ev[264..268].copy_from_slice(&DS4_VENDOR.to_ne_bytes()); + ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes()); + ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version + ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country + ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data + self.fd.write_all(&ev).context("write UHID_CREATE2")?; + Ok(()) + } + + /// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2). + pub fn write_state(&mut self, st: &DsState) -> Result<()> { + self.counter = self.counter.wrapping_add(1); + self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units + let mut r = [0u8; DS4_INPUT_REPORT_LEN]; + serialize_state(&mut r, st, self.counter, self.ts); + + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes()); + ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size + ev[6..6 + r.len()].copy_from_slice(&r); // input2.data + self.fd.write_all(&ev).context("write UHID_INPUT2")?; + Ok(()) + } + + /// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing / + /// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no + /// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a + /// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake + /// completes. + pub fn service(&mut self) -> Ds4Feedback { + let mut fb = Ds4Feedback::default(); + let mut ev = [0u8; UHID_EVENT_SIZE]; + while let Ok(n) = self.fd.read(&mut ev) { + if n < UHID_EVENT_SIZE { + break; + } + match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) { + UHID_OUTPUT => { + // uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102]. + let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize; + let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE); + parse_ds4_output(&ev[4..end], &mut fb); + } + UHID_GET_REPORT => { + // uhid_get_report_req: id u32 [4..8], rnum u8 [8]. + let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + let data: &[u8] = match ev[8] { + 0x12 => DS4_FEATURE_PAIRING, + 0x02 => DS4_FEATURE_CALIBRATION, + 0xA3 => DS4_FEATURE_FIRMWARE, + _ => &[], + }; + let _ = self.reply_get_report(id, data); + } + _ => {} // Start/Stop/Open/Close/SetReport — ignore + } + } + fb + } + + fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes()); + // uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..]. + ev[4..8].copy_from_slice(&id.to_ne_bytes()); + let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report + ev[8..10].copy_from_slice(&err.to_ne_bytes()); + ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes()); + ev[12..12 + data.len()].copy_from_slice(data); + self.fd + .write_all(&ev) + .context("write UHID_GET_REPORT_REPLY")?; + Ok(()) + } +} + +impl Drop for DualShock4Pad { + fn drop(&mut self) { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes()); + let _ = self.fd.write_all(&ev); + } +} + +/// All virtual DualShock 4 pads of a session — the PS4 analog of +/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`. +/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever +/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich)) +/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back: +/// motor rumble on the universal plane, the lightbar on the HID-output plane. +pub struct DualShock4Manager { + pads: Vec>, + /// Each pad's current full report — buttons/sticks merged with persisted touch + motion. + state: Vec, + /// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it. + last_rumble: Vec<(u16, u16)>, + /// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output + /// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane. + last_led: Vec>, + /// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat). + last_write: Vec, + /// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events. + broken: bool, +} + +impl Default for DualShock4Manager { + fn default() -> DualShock4Manager { + DualShock4Manager::new() + } +} + +impl DualShock4Manager { + pub fn new() -> DualShock4Manager { + DualShock4Manager { + pads: (0..MAX_PADS).map(|_| None).collect(), + state: vec![DsState::neutral(); MAX_PADS], + last_rumble: vec![(0, 0); MAX_PADS], + last_led: vec![None; MAX_PADS], + last_write: vec![Instant::now(); MAX_PADS], + broken: false, + } + } + + /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). + pub fn handle(&mut self, ev: &GamepadEvent) { + match ev { + GamepadEvent::Arrival { index, kind, .. } => { + tracing::info!(index, kind, "controller arrival (DualShock 4)"); + self.ensure(*index as usize); + } + GamepadEvent::State(f) => { + let idx = f.index as usize; + if idx >= MAX_PADS { + return; + } + // Unplugs: drop any allocated pad whose mask bit cleared, resetting its state. + 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 (DualShock 4)"); + *slot = None; + self.state[i] = DsState::neutral(); + self.last_rumble[i] = (0, 0); + self.last_led[i] = None; + } + } + if f.active_mask & (1 << idx) == 0 { + return; // this event WAS the unplug + } + self.ensure(idx); + // Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the + // rich-input plane and must survive a button-only frame). + let prev = self.state[idx]; + let mut s = DsState::from_gamepad( + f.buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.touch = prev.touch; + s.gyro = prev.gyro; + s.accel = prev.accel; + self.state[idx] = s; + self.write(idx); + } + } + } + + /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad, + /// preserving its button/stick state. Rich events never create a pad; they're dropped if the + /// pad isn't present. + pub fn apply_rich(&mut self, rich: RichInput) { + let idx = match rich { + RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + }; + if idx >= MAX_PADS || self.pads[idx].is_none() { + return; + } + match rich { + RichInput::Touchpad { + finger, + active, + x, + y, + .. + } => { + // The DS4 touchpad carries two contacts; clamp to a valid slot and keep the + // reported contact id consistent (the wire `finger` is untrusted). + let slot = (finger as usize).min(1); + let t = &mut self.state[idx].touch[slot]; + t.active = active; + t.id = slot as u8; + // Normalized 0..=65535 → the DS4 touchpad range (0..=W-1 / 0..=H-1). + t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16; + t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16; + } + RichInput::Motion { gyro, accel, .. } => { + self.state[idx].gyro = gyro; + self.state[idx].accel = accel; + } + } + self.write(idx); + } + + fn write(&mut self, idx: usize) { + let st = self.state[idx]; + if let Some(pad) = self.pads[idx].as_mut() { + let _ = pad.write_state(&st); + } + self.last_write[idx] = Instant::now(); + } + + /// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams + /// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a + /// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame); + /// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report. + pub fn heartbeat(&mut self, max_gap: Duration) { + let now = Instant::now(); + for i in 0..self.pads.len() { + if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap { + self.write(i); + } + } + } + + fn ensure(&mut self, idx: usize) { + if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + return; + } + match DualShock4Pad::open(idx as u8) { + Ok(p) => { + tracing::info!( + index = idx, + "virtual DualShock 4 created (UHID hid-playstation)" + ); + self.pads[idx] = Some(p); + self.state[idx] = DsState::neutral(); + self.last_rumble[idx] = (0, 0); + self.last_led[idx] = None; + self.last_write[idx] = Instant::now(); + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled"); + self.broken = true; + } + } + } + + /// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble` + /// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane); + /// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks + /// `hid-playstation` init until its GET_REPORTs are answered. + pub fn pump( + &mut self, + mut rumble: impl FnMut(u16, u16, u16), + mut hidout: impl FnMut(HidOutput), + ) { + for i in 0..self.pads.len() { + let Some(pad) = self.pads[i].as_mut() else { + continue; + }; + let fb = pad.service(); + if let Some(r) = fb.rumble { + if self.last_rumble[i] != r { + self.last_rumble[i] = r; + rumble(i as u16, r.0, r.1); + } + } + if let Some(rgb) = fb.led { + if self.last_led[i] != Some(rgb) { + self.last_led[i] = Some(rgb); + hidout(HidOutput::Led { + pad: i as u8, + r: rgb.0, + g: rgb.1, + b: rgb.2, + }); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets. + #[test] + fn serialize_offsets() { + use punktfunk_core::input::gamepad as gs; + let mut st = DsState::from_gamepad( + gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB, + 16384, // lx (right) + 0, + 0, + -32768, // ry (down) — inverted to 0xFF + 200, // L2 + 0, + ); + st.gyro = [0x0102, 0x0304, 0x0506]; + st.accel = [0x1112, 0x1314, 0x1516]; + st.touch[0] = Touch { + active: true, + id: 0, + x: 100, + y: 200, + }; + let mut r = [0u8; DS4_INPUT_REPORT_LEN]; + serialize_state(&mut r, &st, 0, 0); + assert_eq!(r[0], 0x01); // report id + assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5) + assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up) + assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit + assert_eq!(r[6] & 0x01, 0x01); // L1 + // gyro le16 at 13..19, accel le16 at 19..25. + assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]); + assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]); + assert_eq!(r[33], 1); // one touch frame + assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear) + assert_eq!(r[35] & 0x7F, 0); // contact id 0 + assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set + } + + /// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a + /// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched. + #[test] + fn parse_output_rumble_and_lightbar() { + let mut report = [0u8; 32]; + report[0] = 0x05; + report[1] = 0x01 | 0x02; // MOTOR | LED + report[4] = 0x40; // motor_right (weak/high) + report[5] = 0x80; // motor_left (strong/low) + report[6] = 0x11; // R + report[7] = 0x22; // G + report[8] = 0x33; // B + let mut fb = Ds4Feedback::default(); + parse_ds4_output(&report, &mut fb); + assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak) + assert_eq!(fb.led, Some((0x11, 0x22, 0x33))); + + let mut motor_only = [0u8; 32]; + motor_only[0] = 0x05; + motor_only[1] = 0x01; // MOTOR only + motor_only[5] = 0x10; + let mut fb2 = Ds4Feedback::default(); + parse_ds4_output(&motor_only, &mut fb2); + assert!(fb2.rumble.is_some()); + assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change + } + + /// Feature-report arrays carry the right report id + length the kernel expects. + #[test] + fn feature_report_shapes() { + assert_eq!(DS4_FEATURE_PAIRING.len(), 16); + assert_eq!(DS4_FEATURE_PAIRING[0], 0x12); + assert_eq!(DS4_FEATURE_CALIBRATION.len(), 37); + assert_eq!(DS4_FEATURE_CALIBRATION[0], 0x02); + assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49); + assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3); + } +} diff --git a/crates/punktfunk-host/src/inject/gamepad.rs b/crates/punktfunk-host/src/inject/gamepad.rs index 0d24fd4..9376e8a 100644 --- a/crates/punktfunk-host/src/inject/gamepad.rs +++ b/crates/punktfunk-host/src/inject/gamepad.rs @@ -82,6 +82,53 @@ const BUTTON_MAP: [(u32, u16); 11] = [ (gamepad::BTN_RS_CLK, BTN_THUMBR), ]; +/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off +/// `bustype/vendor/product/version` (+ name), and games pick button glyphs from it. The button/axis +/// layout this backend emits is the same XInput one regardless — only the identity differs between an +/// X-Box 360 pad and an X-Box One/Series pad (which is why "Xbox One" buys glyphs, not new capability; +/// impulse-trigger rumble is unreachable through evdev FF either way). +#[derive(Clone, Copy)] +pub struct PadIdentity { + vendor: u16, + product: u16, + version: u16, + name: &'static [u8], + /// Short label for the creation log line. + log: &'static str, +} + +impl PadIdentity { + /// "Microsoft X-Box 360 pad" (`045e:028e`) — the universal default; matches the kernel `xpad` + /// table verbatim so SDL/Steam map it with zero config. + pub const fn xbox360() -> PadIdentity { + PadIdentity { + vendor: 0x045e, + product: 0x028e, + version: 0x0110, + name: b"Microsoft X-Box 360 pad", + log: "X-Box 360 pad", + } + } + + /// "Microsoft X-Box One S pad" (`045e:02ea`) — an `xpad`-table entry, so games show One/Series + /// glyphs. XInput-identical to the 360 pad otherwise. + pub const fn xbox_one() -> PadIdentity { + PadIdentity { + vendor: 0x045e, + product: 0x02ea, + version: 0x0408, + name: b"Microsoft X-Box One S pad", + log: "X-Box One S pad", + } + } +} + +impl Default for PadIdentity { + fn default() -> PadIdentity { + PadIdentity::xbox360() + } +} + #[repr(C)] struct InputId { bustype: u16, @@ -202,7 +249,7 @@ pub struct VirtualPad { } impl VirtualPad { - pub fn create(index: usize) -> Result { + pub fn create(index: usize, identity: PadIdentity) -> Result { use std::os::fd::FromRawFd; let raw = unsafe { libc::open( @@ -272,18 +319,22 @@ impl VirtualPad { let mut setup = UinputSetup { id: InputId { bustype: 0x0003, // BUS_USB - vendor: 0x045e, - product: 0x028e, - version: 0x0110, + vendor: identity.vendor, + product: identity.product, + version: identity.version, }, name: [0; 80], ff_effects_max: 16, // must be > 0 or FF uploads are never delivered }; - let name = b"Microsoft X-Box 360 pad"; + let name = identity.name; setup.name[..name.len()].copy_from_slice(name); ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?; ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?; - tracing::info!(index, "virtual gamepad created (X-Box 360 pad via uinput)"); + tracing::info!( + index, + pad = identity.log, + "virtual gamepad created (uinput)" + ); Ok(VirtualPad { fd, @@ -449,14 +500,24 @@ impl Drop for VirtualPad { #[derive(Default)] pub struct GamepadManager { pads: Vec>, + /// 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, } impl GamepadManager { + /// A manager that creates X-Box 360 pads (the universal default). pub fn new() -> GamepadManager { + GamepadManager::with_identity(PadIdentity::xbox360()) + } + + /// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]). + pub fn with_identity(identity: PadIdentity) -> GamepadManager { GamepadManager { pads: (0..MAX_PADS).map(|_| None).collect(), + identity, broken: false, } } @@ -496,7 +557,7 @@ impl GamepadManager { if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { return; } - match VirtualPad::create(idx) { + match VirtualPad::create(idx, self.identity) { Ok(p) => self.pads[idx] = Some(p), Err(e) => { tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled"); diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index f6cc8af..5aaa670 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1164,26 +1164,50 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver>) { tracing::debug!("mic service stopped (host shutting down)"); } -/// The session's virtual-gamepad backend. Default = uinput X-Box-360 pads -/// ([`GamepadManager`](crate::inject::gamepad::GamepadManager)); `PUNKTFUNK_GAMEPAD=dualsense` -/// switches to virtual DualSense pads (UHID + the kernel `hid-playstation` driver) so a game sees -/// a *real* DualSense — adaptive triggers, lightbar, touchpad, motion — and a game's feedback -/// flows back over the rich HID-output plane. Selected once per session (sessions run serially). +/// The session's virtual-gamepad backend, resolved once per session (sessions run serially). +/// +/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)), +/// ViGEm on Windows. Also the X-Box One/Series identity (`PUNKTFUNK_GAMEPAD=xboxone`): the same +/// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical +/// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity). +/// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`, +/// so a game sees a *real* DualSense (adaptive triggers, lightbar, touchpad, motion); feedback +/// flows back over the rich HID-output plane. +/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar, +/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute). +/// +/// The two UHID pads are Linux-only; off Linux the resolver already folds them (and One/Series) +/// into `Xbox360`, so a non-Linux build never constructs them. enum PadBackend { Xbox360(crate::inject::gamepad::GamepadManager), #[cfg(target_os = "linux")] DualSense(crate::inject::dualsense::DualSenseManager), + #[cfg(target_os = "linux")] + DualShock4(crate::inject::dualshock4::DualShock4Manager), } impl PadBackend { /// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference, - /// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only - /// ever construct the X-Box backend, whatever the resolution said. + /// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever + /// construct the X-Box backend, whatever the resolution said. fn select(kind: GamepadPref) -> PadBackend { #[cfg(target_os = "linux")] - if kind == GamepadPref::DualSense { - tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)"); - return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new()); + match kind { + GamepadPref::DualSense => { + tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)"); + return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new()); + } + GamepadPref::DualShock4 => { + tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)"); + return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new()); + } + GamepadPref::XboxOne => { + tracing::info!("gamepad backend: uinput X-Box One/Series pad"); + return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity( + crate::inject::gamepad::PadIdentity::xbox_one(), + )); + } + _ => {} } let _ = kind; PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) @@ -1194,21 +1218,26 @@ impl PadBackend { PadBackend::Xbox360(m) => m.handle(ev), #[cfg(target_os = "linux")] PadBackend::DualSense(m) => m.handle(ev), + #[cfg(target_os = "linux")] + PadBackend::DualShock4(m) => m.handle(ev), } } - /// Apply a rich client→host event (DualSense touchpad / motion). A no-op for the X-Box pad, - /// which has no equivalent. + /// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no + /// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors. fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) { #[cfg(target_os = "linux")] - if let PadBackend::DualSense(m) = self { - m.apply_rich(_rich); + match self { + PadBackend::DualSense(m) => m.apply_rich(_rich), + PadBackend::DualShock4(m) => m.apply_rich(_rich), + PadBackend::Xbox360(_) => {} } } /// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane - /// (both backends); `hidout` carries DualSense-only rich feedback (lightbar / player LEDs / - /// adaptive triggers — DualSense backend only). + /// (every backend); `hidout` carries rich feedback on the HID-output plane — lightbar (both + /// UHID pads), plus player LEDs / adaptive triggers (DualSense only). The X-Box pad has no + /// rich-feedback plane. fn pump( &mut self, rumble: impl FnMut(u16, u16, u16), @@ -1221,10 +1250,12 @@ impl PadBackend { } #[cfg(target_os = "linux")] PadBackend::DualSense(m) => m.pump(rumble, hidout), + #[cfg(target_os = "linux")] + PadBackend::DualShock4(m) => m.pump(rumble, hidout), } } - /// Keep a virtual DualSense alive during input silence: re-emit its current HID report if it's + /// Keep a virtual UHID pad alive during input silence: re-emit its current HID report if it's /// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as /// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev /// holds last-known state with no periodic-report requirement). Called every input-thread tick; @@ -1234,6 +1265,8 @@ impl PadBackend { PadBackend::Xbox360(_) => {} #[cfg(target_os = "linux")] PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), + #[cfg(target_os = "linux")] + PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), } } } @@ -1516,10 +1549,13 @@ fn synthetic_stream( } /// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins, -/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. The -/// DualSense backend needs Linux UHID — when unavailable any DualSense wish degrades to -/// X-Box 360 (never an error: a session without rich pads still streams). -fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool) -> GamepadPref { +/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. +/// +/// `linux` is whether this is a Linux host (uinput + UHID). The rich UHID pads (DualSense, DualShock +/// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich +/// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical +/// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there. +fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPref { let want = match pref { GamepadPref::Auto => env .and_then(GamepadPref::from_name) @@ -1527,7 +1563,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool) explicit => explicit, }; match want { - GamepadPref::DualSense if dualsense_available => GamepadPref::DualSense, + GamepadPref::DualSense if linux => GamepadPref::DualSense, + GamepadPref::DualShock4 if linux => GamepadPref::DualShock4, + // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on + // Windows (XInput can't tell them apart anyway). + GamepadPref::XboxOne if linux => GamepadPref::XboxOne, _ => GamepadPref::Xbox360, } } @@ -3012,6 +3052,14 @@ mod tests { // DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux). assert_eq!(pick_gamepad(DualSense, None, false), Xbox360); assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360); + // DualShock 4: honored on Linux (UHID), degrades to X-Box 360 off it. + assert_eq!(pick_gamepad(DualShock4, None, true), DualShock4); + assert_eq!(pick_gamepad(Auto, Some("ps4"), true), DualShock4); + assert_eq!(pick_gamepad(DualShock4, None, false), Xbox360); + // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. + assert_eq!(pick_gamepad(XboxOne, None, true), XboxOne); + assert_eq!(pick_gamepad(Auto, Some("series"), true), XboxOne); + assert_eq!(pick_gamepad(XboxOne, None, false), Xbox360); } #[test] diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 3bb8c05..09e7328 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -70,6 +70,18 @@ // only where available (Linux hosts); otherwise the host falls back to X-Box 360. #define PUNKTFUNK_GAMEPAD_DUALSENSE 2 +// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so +// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain; +// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a +// physical X-Box One/Series controller on the client. +#define PUNKTFUNK_GAMEPAD_XBOXONE 3 + +// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the +// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like +// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux +// hosts); otherwise the host falls back to X-Box 360. +#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4 + // Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. // Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to // [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.