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
+20
View File
@@ -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
+25 -7
View File
@@ -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",
}
}
}
+12
View File
@@ -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);
+2
View File
@@ -424,6 +424,8 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
#[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")]
@@ -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<HidOutput>,
/// `(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<DualShock4Pad> {
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<Option<DualShock4Pad>>,
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
state: Vec<DsState>,
/// 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<Option<(u8, u8, u8)>>,
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
last_write: Vec<Instant>,
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
broken: bool,
}
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);
}
}
+68 -7
View File
@@ -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<VirtualPad> {
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
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<Option<VirtualPad>>,
/// 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");
+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]