feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 13:34:44 +00:00
parent b3811ff72e
commit 3e6c9f6060
24 changed files with 1246 additions and 214 deletions
+70 -22
View File
@@ -1164,26 +1164,50 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
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]