feat(gamepad): DualSense Edge backend — Linux UHID + Windows UMDF (N1)

The plain-DualSense transport + report codec under the Edge USB identity
(054C:0DF2, verbatim 389-byte real-device descriptor cross-checked against
the raw usbmon capture + hhd's production virtual Edge), so the wire back
grips (BTN_PADDLE1..4: Deck L4/L5/R4/R5, Elite P1-P4) land on the Edge's
NATIVE buttons[2] bits instead of the fold/drop policy: PADDLE1/2 -> the
right/left back buttons, PADDLE3/4 -> the right/left Fn buttons (kernel
BTN_TRIGGER_HAPPY1..4 on >= 7.2; SDL/Steam read hidraw on any kernel).

- proto: Edge descriptor + btn2 bits + edge_paddle_bits(), pinned against
  hid-playstation DS_EDGE_BUTTONS_* and SDL_hidapi_ps5 (tests).
- Linux: DsUhidIdentity parameterizes the UHID create; DsEdgeLinuxProto /
  DualSenseEdgeManager. Headless-validated on .21 (7.1): driver=playstation
  binds 0DF2, all 4 input devices created, probe lightbar/player-LED
  feedback round-trips; dualsense-test grew --edge (cycles all 4 paddles).
- Windows: UMDF driver serves device_type=2 (Edge descriptor/attrs/strings,
  DS feature blobs); WinDsIdentity parameterizes the SwDevice profile +
  devtype stamp; DsEdgeWinProto / DualSenseEdgeWindowsManager; INF gains
  pf_dualsenseedge. Driver change => resign + reinstall before on-glass.
- Router: DualSenseEdge arms in route_handle/apply_rich/pump/heartbeat;
  pick_gamepad folds Edge -> itself on linux||windows; degrade_if_no_uhid
  covers it.
- Client (SDL): 054C:0DF2 declares DualSenseEdge (no distinct SDL type);
  Edge physical pads take the raw DS5 effects path; console-UI glyphs =
  Shapes. Apple/Android pickers follow separately.

Verified: .21 clippy -D warnings + 292/0 host tests + on-box UHID bind
smoke; .133 clippy pending in this push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:49:31 +02:00
parent 45bde370e2
commit 1830e095f8
13 changed files with 618 additions and 119 deletions
+5
View File
@@ -482,6 +482,11 @@ pub mod dualsense_proto;
#[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows;
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
#[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_edge_windows.rs"]
pub mod dualsense_edge_windows;
#[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4;
@@ -13,9 +13,10 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING,
DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC,
DUALSENSE_RDESC,
};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
@@ -43,9 +44,45 @@ fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
}
/// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound
/// `hid-playstation` interface).
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
pub struct DsUhidIdentity {
product: u32,
rdesc: &'static [u8],
/// Device name prefix ("Punktfunk <name> <index>").
name: &'static str,
/// Path token for the phys string ("punktfunk/<phys>/<index>").
phys: &'static str,
/// Short slug for the uniq string ("punktfunk-<slug>-<index>").
slug: &'static str,
}
impl DsUhidIdentity {
pub const fn dualsense() -> DsUhidIdentity {
DsUhidIdentity {
product: DS_PRODUCT,
rdesc: DUALSENSE_RDESC,
name: "DualSense",
phys: "dualsense",
slug: "ds",
}
}
pub const fn dualsense_edge() -> DsUhidIdentity {
DsUhidIdentity {
product: DS_EDGE_PRODUCT,
rdesc: DUALSENSE_EDGE_RDESC,
name: "DualSense Edge",
phys: "dualsense-edge",
slug: "dsedge",
}
}
}
/// A virtual DualSense / DualSense Edge backed by `/dev/uhid` (hand-rolled codec — no bindgen,
/// mirroring the uinput pad's style). Dropping it destroys the device (the kernel tears down the
/// bound `hid-playstation` interface).
pub struct DualSensePad {
fd: File,
seq: u8,
@@ -53,8 +90,9 @@ pub struct DualSensePad {
}
impl DualSensePad {
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique).
pub fn open(index: u8) -> Result<DualSensePad> {
/// Create the UHID pad for wire index `index` under `id`'s identity (`index` is used only to
/// make the device name/uniq unique).
pub fn open(index: u8, id: &DsUhidIdentity) -> Result<DualSensePad> {
let fd = OpenOptions::new()
.read(true)
.write(true)
@@ -64,24 +102,24 @@ impl DualSensePad {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?;
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
ds.send_create2(index).context("UHID_CREATE2 DualSense")?;
ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?;
Ok(ds)
}
fn send_create2(&mut self, index: u8) -> Result<()> {
fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> 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 DualSense {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualsense/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(DUALSENSE_RDESC.len() as u16).to_ne_bytes()); // rd_size
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {} {index}", id.name)); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
ev[260..262].copy_from_slice(&(id.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(&DS_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&DS_PRODUCT.to_ne_bytes());
ev[268..272].copy_from_slice(&id.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 + DUALSENSE_RDESC.len()].copy_from_slice(DUALSENSE_RDESC); // rd_data
ev[280..280 + id.rdesc.len()].copy_from_slice(id.rdesc); // rd_data
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(())
}
@@ -186,7 +224,7 @@ impl PadProto for DsLinuxProto {
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
let p = DualSensePad::open(idx)?;
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
tracing::info!(
index = idx,
"virtual DualSense created (UHID hid-playstation)"
@@ -251,3 +289,83 @@ impl PadProto for DsLinuxProto {
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
pub type DualSenseManager = UhidManager<DsLinuxProto>;
/// The DualSense **Edge** half of the shared stateful manager: the plain-DualSense transport and
/// report codec under the Edge USB identity (`054C:0DF2` + the Edge descriptor), with the four
/// wire back-grip bits mapped onto the Edge's native `buttons[2]` slots instead of the
/// fold/drop policy — the whole point of this backend (a client's Deck grips / Elite paddles
/// stop vanishing). No remap config: every paddle has a native home.
///
/// Kernel note: `hid-playstation` binds the Edge PID since 6.1 (forced vibration-v2 output), but
/// only kernels ≥ 7.2 surface the Fn/back bits as evdev keys (`BTN_TRIGGER_HAPPY1..4`); SDL /
/// Steam Input read the report off hidraw and see them on any kernel.
#[derive(Default)]
pub struct DsEdgeLinuxProto;
impl PadProto for DsEdgeLinuxProto {
type Pad = DualSensePad;
type State = DsState;
const LABEL: &'static str = "DualSense Edge";
const DEVICE: &'static str = "DualSense Edge";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense_edge())?;
tracing::info!(
index = idx,
"virtual DualSense Edge created (UHID hid-playstation)"
);
Ok(p)
}
fn neutral(&self) -> DsState {
DsState::neutral()
}
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
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.buttons[2] |= edge_paddle_bits(f.buttons);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
s
}
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
/// split the one touchpad left/right, pad clicks ride touch_click.
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
let _ = pad.write_state(st);
}
/// Same kernel handshake + feedback parse as the plain DualSense — the Edge's GET_REPORT set
/// (calibration 0x05 / pairing 0x09 / firmware 0x20) and output report 0x02 are identical
/// (the Edge's rumble arrives via the vibration-v2 valid_flag2 bit, which
/// [`parse_ds_output`] already handles).
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
hidout: fb.hidout,
}
}
}
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
/// client declares for a paddle-bearing physical controller.
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
@@ -66,8 +66,45 @@ pub const DUALSENSE_RDESC: &[u8] = &[
0xC0,
];
/// Sony DualSense **Edge** USB HID report descriptor (389 bytes) — a verbatim real-device
/// capture (hid-recorder, hhd-dev/hwinfo `devices/ds5_edge`, cross-checked byte-for-byte against
/// the raw usbmon pcap in the same repo and the descriptor Handheld Daemon ships for ITS virtual
/// UHID Edge). vs the plain DS5 descriptor: output report `0x02` grows 47→63 bytes, feature
/// `0xF2` 15→52, and 19 vendor feature reports (`0x60..=0x7B`, the Edge profile slots) are
/// appended — input report `0x01` is bit-identical (the Edge's Fn/back buttons ride previously
/// reserved bits of `buttons[2]`, see [`btn2`]).
#[rustfmt::skip]
pub const DUALSENSE_EDGE_RDESC: &[u8] = &[
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 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, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
0x09, 0x53, 0xB1, 0x02, 0xC0,
];
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
pub const DS_EDGE_PRODUCT: u32 = 0x0DF2; // DualSense Edge Wireless Controller
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS_INPUT_REPORT_LEN: usize = 64;
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
@@ -92,12 +129,47 @@ pub mod btn1 {
pub const L3: u8 = 0x40;
pub const R3: u8 = 0x80;
}
/// `buttons[2]`: PS, touchpad click, mute (+ a rolling counter in the high bits).
/// `buttons[2]`: PS, touchpad click, mute — plus, on the DualSense **Edge**, the two Fn and two
/// back buttons in bits 47 (kernel `DS_EDGE_BUTTONS_*` / SDL `SDL_GAMEPAD_BUTTON_PS5_*`; the
/// plain DS5 leaves those bits reserved). The kernel maps them to `BTN_TRIGGER_HAPPY1..4`
/// (Fn-L, Fn-R, back-L, back-R) since 7.2; SDL/Steam read them off hidraw on any kernel.
pub mod btn2 {
pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02;
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
pub const MUTE: u8 = 0x04;
/// Edge left Fn button (below the left stick).
pub const EDGE_FN_LEFT: u8 = 0x10;
/// Edge right Fn button.
pub const EDGE_FN_RIGHT: u8 = 0x20;
/// Edge left back button (rear paddle).
pub const EDGE_BACK_LEFT: u8 = 0x40;
/// Edge right back button (rear paddle).
pub const EDGE_BACK_RIGHT: u8 = 0x80;
}
/// Map the wire back-grip bits onto the DualSense Edge's `buttons[2]` bits — the reason the Edge
/// backend exists: all four client paddles (Deck grips L4/L5/R4/R5, Elite P1P4) land on native
/// slots instead of the fold/drop policy. Wire PADDLE1/2 = R4/L4 (the primary pair, Steam
/// convention) → the Edge's right/left BACK buttons; PADDLE3/4 = R5/L5 → the right/left Fn
/// buttons (real-HW Fn is profile-switch chrome, but on a virtual pad the bits reach consumers
/// as ordinary buttons — kernel `BTN_TRIGGER_HAPPY1/2`, SDL `LEFT/RIGHT_FUNCTION`).
pub fn edge_paddle_bits(buttons: u32) -> u8 {
use punktfunk_core::input::gamepad as gs;
let mut b = 0;
if buttons & gs::BTN_PADDLE1 != 0 {
b |= btn2::EDGE_BACK_RIGHT; // R4
}
if buttons & gs::BTN_PADDLE2 != 0 {
b |= btn2::EDGE_BACK_LEFT; // L4
}
if buttons & gs::BTN_PADDLE3 != 0 {
b |= btn2::EDGE_FN_RIGHT; // R5
}
if buttons & gs::BTN_PADDLE4 != 0 {
b |= btn2::EDGE_FN_LEFT; // L5
}
b
}
/// One touchpad contact for the report.
@@ -798,6 +870,51 @@ mod tests {
assert_eq!(s.buttons[2], 0);
}
/// The Edge paddle map, pinned against hid-playstation's `DS_EDGE_BUTTONS_*` masks (bits
/// 47 of `buttons[2]`) and SDL's `SDL_GAMEPAD_BUTTON_PS5_*` (same byte off hidraw):
/// PADDLE1/2 (R4/L4) → right/left BACK, PADDLE3/4 (R5/L5) → right/left Fn — and the mapped
/// bits land in the serialized report's byte 10 next to the ordinary buttons[2] bits.
#[test]
fn edge_paddles_map_to_native_bits() {
use punktfunk_core::input::gamepad as gs;
assert_eq!(edge_paddle_bits(0), 0);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE1), btn2::EDGE_BACK_RIGHT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE2), btn2::EDGE_BACK_LEFT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE3), btn2::EDGE_FN_RIGHT);
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE4), btn2::EDGE_FN_LEFT);
// Exact kernel/SDL bit values (a one-bit slip ships dead paddles).
assert_eq!(btn2::EDGE_FN_LEFT, 0x10);
assert_eq!(btn2::EDGE_FN_RIGHT, 0x20);
assert_eq!(btn2::EDGE_BACK_LEFT, 0x40);
assert_eq!(btn2::EDGE_BACK_RIGHT, 0x80);
// All four + a non-paddle bit: paddles map, the rest is ignored here.
let all = gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_A;
assert_eq!(edge_paddle_bits(all), 0xF0);
// Serialized: the Edge merge ORs into buttons[2]; byte 10 carries both the paddles and
// the ordinary bits (e.g. a simultaneous PS press).
let mut s = DsState::from_gamepad(gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
s.buttons[2] |= edge_paddle_bits(gs::BTN_PADDLE2 | gs::BTN_PADDLE3);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
serialize_state(&mut r, &s, 0, 0);
assert_eq!(r[10], btn2::PS | btn2::EDGE_BACK_LEFT | btn2::EDGE_FN_RIGHT);
}
/// The Edge descriptor is the real-device capture: exact length, the three deltas vs the
/// plain DS5 descriptor (output 0x02 count 63, feature 0xF2 count 52, the appended profile
/// feature reports), and an unchanged input-report prefix (report 0x01 is bit-identical —
/// the serializer needs no Edge variant).
#[test]
fn edge_descriptor_shape() {
assert_eq!(DUALSENSE_RDESC.len(), 273);
assert_eq!(DUALSENSE_EDGE_RDESC.len(), 389);
// Identical through the input-report + output-report-id prefix; the first delta is the
// output report 0x02's Report Count at offset 109 (47 → 63 bytes of payload).
assert_eq!(DUALSENSE_EDGE_RDESC[..109], DUALSENSE_RDESC[..109]);
assert_eq!(DUALSENSE_RDESC[109], 0x2F);
assert_eq!(DUALSENSE_EDGE_RDESC[109], 0x3F);
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
@@ -0,0 +1,89 @@
//! Virtual Sony DualSense **Edge** on Windows via the UMDF minidriver — the Edge sibling of
//! [`super::dualsense_windows`]. Same transport ([`DsWinPad`]: a per-session `SwDeviceCreate`
//! devnode + the sealed shared-memory channel), same report codec ([`super::dualsense_proto`]);
//! the host stamps `device_type = 2` so the one UMDF driver serves the Edge descriptor /
//! `VID_054C&PID_0DF2` attributes, and the wire back-grip bits map onto the Edge's native
//! `buttons[2]` slots instead of the fold/drop policy — a client's Deck grips / Elite paddles
//! reach games as real buttons. Feedback is identical to the plain DualSense (rumble arrives with
//! the vibration-v2 flag, which [`parse_ds_output`](super::dualsense_proto::parse_ds_output)
//! already handles).
use super::dualsense_proto::{edge_paddle_bits, DsState, DS_TOUCH_H, DS_TOUCH_W};
use super::dualsense_windows::{DsWinPad, WinDsIdentity};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::Result;
use punktfunk_core::quic::RichInput;
/// The Windows-Edge half of the shared stateful manager (see [`PadProto`]): the shared
/// [`DsWinPad`] transport under the Edge identity, with the Edge paddle mapping in `merge_frame`.
/// No remap config — every wire paddle has a native slot.
#[derive(Default)]
pub struct DsEdgeWinProto;
impl PadProto for DsEdgeWinProto {
type Pad = DsWinPad;
type State = DsState;
const LABEL: &'static str = "DualSense Edge/Windows";
const DEVICE: &'static str = "DualSense Edge";
const CREATE_HINT: &'static str =
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense_edge())?;
tracing::info!(
index = idx,
"virtual DualSense Edge created (Windows UMDF shm channel)"
);
Ok(p)
}
fn neutral(&self) -> DsState {
DsState::neutral()
}
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
/// (rebuilt from every button frame, so no extra persistence).
fn merge_frame(&self, prev: &DsState, f: &crate::gamestream::gamepad::GamepadFrame) -> DsState {
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.buttons[2] |= edge_paddle_bits(f.buttons);
s.touch = prev.touch;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.touch_click = prev.touch_click;
s
}
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
/// split the one touchpad left/right, pad clicks ride touch_click.
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
}
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
pad.write_state(st);
}
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
/// lightbar/player-LED/trigger events on the 0xCD plane.
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
let fb = pad.service(idx);
PadFeedback {
rumble: fb.rumble,
hidout: fb.hidout,
}
}
}
/// All virtual DualSense Edge pads of a session — the Windows analogue of
/// [`DualSenseEdgeManager`](crate::inject::dualsense::DualSenseEdgeManager), with the same method
/// surface (via the shared [`UhidManager`]) as the other Windows pad managers.
pub type DualSenseEdgeWindowsManager = UhidManager<DsEdgeWinProto>;
@@ -55,6 +55,7 @@ pub(super) const OFF_DRIVER_PROTO: usize =
pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
@@ -228,20 +229,57 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
Ok((hsw, ctx.instance_id()))
}
/// The identity a [`DsWinPad`] enumerates with — the plain DualSense or the Edge share the whole
/// transport (section layout, input report shape, output parse); only the `device_type` stamp and
/// the PnP identity differ. The DS4 differs in report codec too, so it keeps its own pad type.
pub(super) struct WinDsIdentity {
/// `device_type` stamped into the section (the driver picks its HID identity off it).
pub devtype: u8,
/// PnP instance-id prefix (`pf_pad` / `pf_edge`) — distinct namespaces per type.
pub instance_prefix: &'static str,
/// The INF-matched hardware id.
pub hwid: &'static str,
/// The USB VID&PID token for the synthesized bus identity.
pub usb_vid_pid: &'static str,
/// Device Manager description.
pub description: &'static str,
}
impl WinDsIdentity {
pub(super) const fn dualsense() -> WinDsIdentity {
WinDsIdentity {
devtype: 0,
instance_prefix: "pf_pad",
hwid: "pf_dualsense",
usb_vid_pid: "VID_054C&PID_0CE6",
description: "punktfunk Virtual DualSense",
}
}
pub(super) const fn dualsense_edge() -> WinDsIdentity {
WinDsIdentity {
devtype: DEVTYPE_DUALSENSE_EDGE,
instance_prefix: "pf_edge",
hwid: "pf_dualsenseedge",
usb_vid_pid: "VID_054C&PID_0DF2",
description: "punktfunk Virtual DualSense Edge",
}
}
}
impl DsWinPad {
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
/// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_<index>` devnode (the
/// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
fn open(index: u8) -> Result<DsWinPad> {
/// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
/// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
/// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
/// removes it (`SwDeviceClose`).
pub(super) fn open(index: u8, id: &WinDsIdentity) -> Result<DsWinPad> {
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = channel.data_base();
// Stamp the pad index (the driver validates it on attach) + the neutral input report, then
// the magic LAST (the driver only accepts the section once magic is set). The device-type
// stays 0 (DualSense — the section arrives zeroed).
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX/OFF_INPUT are in range.
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
unsafe {
*base.add(OFF_DEVTYPE) = id.devtype;
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; DS_INPUT_REPORT_LEN];
@@ -251,19 +289,19 @@ impl DsWinPad {
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
}
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
// rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense`
// devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
let inst = format!("pf_pad_{index}");
// rare failure we keep the section + data plane and fall back to an out-of-band devnode
// (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
let inst = format!("{}_{index}", id.instance_prefix);
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst,
container_index: index,
hwid: "pf_dualsense",
usb_vid_pid: "VID_054C&PID_0CE6",
description: "punktfunk Virtual DualSense",
hwid: id.hwid,
usb_vid_pid: id.usb_vid_pid,
description: id.description,
}) {
Ok((h, id)) => (Some(h), id),
Ok((h, i)) => (Some(h), i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode");
tracing::warn!(error = %format!("{e:#}"), hwid = id.hwid, "SwDeviceCreate failed; falling back to an out-of-band devnode");
(None, None)
}
};
@@ -275,8 +313,8 @@ impl DsWinPad {
_sw,
channel,
attach: super::gamepad_raii::DriverAttach::new(
"pf_dualsense",
"pf_dualsense.inf",
id.hwid,
"pf_dualsense.inf", // one driver package serves every PS identity
"C:\\Users\\Public\\pfds-driver.log",
boot_name,
instance_id,
@@ -288,7 +326,7 @@ impl DsWinPad {
}
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
fn write_state(&mut self, st: &DsState) {
pub(super) fn write_state(&mut self, st: &DsState) {
self.seq = self.seq.wrapping_add(1);
self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN];
@@ -318,7 +356,7 @@ impl DsWinPad {
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
fn service(&mut self, pad: u8) -> DsFeedback {
pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
self.channel.pump();
let mut fb = DsFeedback::default();
// SAFETY: base points at SHM_SIZE bytes.
@@ -378,7 +416,7 @@ impl PadProto for DsWinProto {
" (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
let p = DsWinPad::open(idx)?;
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
tracing::info!(
index = idx,
"virtual DualSense created (Windows UMDF shm channel)"
+25 -8
View File
@@ -255,19 +255,28 @@ fn real_main() -> Result<()> {
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
// kernels verify the bind + `hidraw` byte 10 instead).
#[cfg(target_os = "linux")]
Some("dualsense-test") => {
use inject::dualsense::DualSensePad;
use inject::dualsense_proto::DsState;
use inject::dualsense::{DsUhidIdentity, DualSensePad};
use inject::dualsense_proto::{edge_paddle_bits, DsState};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let edge = args.iter().any(|a| a == "--edge");
let (identity, label) = if edge {
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
} else {
(DsUhidIdentity::dualsense(), "DualSense")
};
use std::time::{Duration, Instant};
let mut pad =
DualSensePad::open(0).context("create virtual DualSense via /dev/uhid")?;
let mut pad = DualSensePad::open(0, &identity)
.with_context(|| format!("create virtual {label} via /dev/uhid"))?;
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
// devices before we start streaming state.
let init = Instant::now() + Duration::from_millis(800);
@@ -276,7 +285,7 @@ fn real_main() -> Result<()> {
std::thread::sleep(Duration::from_millis(10));
}
println!(
"virtual DualSense created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
"virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
);
let deadline = Instant::now() + Duration::from_secs(secs);
@@ -292,14 +301,22 @@ fn real_main() -> Result<()> {
if last_write.elapsed() >= Duration::from_millis(300) {
last_write = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
let mut buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
0
};
if edge {
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
// are visible in evtest / hidraw.
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
}
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
pad.write_state(&st).context("write DualSense report")?;
let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
if edge {
st.buttons[2] |= edge_paddle_bits(buttons);
}
pad.write_state(&st).context("write report")?;
}
std::thread::sleep(Duration::from_millis(15));
}
+68 -12
View File
@@ -1752,7 +1752,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s
///
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver.
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
/// minidriver (device-type 0/2/1).
/// - Steam Deck — Linux UHID `hid-steam`.
///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
@@ -1771,12 +1772,16 @@ struct Pads {
#[cfg(target_os = "linux")]
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")]
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
#[cfg(target_os = "linux")]
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")]
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
}
@@ -1798,12 +1803,16 @@ impl Pads {
#[cfg(target_os = "linux")]
dualsense: None,
#[cfg(target_os = "linux")]
dualsense_edge: None,
#[cfg(target_os = "linux")]
dualshock4: None,
#[cfg(target_os = "linux")]
steamdeck: None,
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
dualsense_edge_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None,
}
}
@@ -1855,6 +1864,11 @@ impl Pads {
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => self
.dualsense_edge
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => self
.dualshock4
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
@@ -1879,6 +1893,13 @@ impl Pads {
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => self
.dualsense_edge_win
.get_or_insert_with(
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => self
.dualshock4_win
.get_or_insert_with(
@@ -1920,6 +1941,12 @@ impl Pads {
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4 {
m.apply_rich(rich)
@@ -1938,6 +1965,12 @@ impl Pads {
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4_win {
m.apply_rich(rich)
@@ -1967,6 +2000,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_edge {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4 {
m.pump(&mut rumble, &mut hidout);
}
@@ -1979,6 +2015,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_edge_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout);
}
@@ -1996,6 +2035,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualsense_edge {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4 {
m.heartbeat(gap);
}
@@ -2009,6 +2051,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualsense_edge_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap);
}
@@ -2700,11 +2745,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
// all of that silently.
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
// DualSense Edge: until its backend lands (N1), fold to the plain DualSense wherever
// that exists — it keeps the rich planes (touchpad/motion/lightbar) alive; only the
// back-button bits go through the paddle fold. NOT Xbox360 (that would drop the rich
// planes entirely, the SteamDeck-on-Windows lesson above).
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSense,
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// policy. Degrades to Xbox360 elsewhere like its siblings.
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
// Switch Pro: no backend yet (N2) — falls through to Xbox360 below.
_ => GamepadPref::Xbox360,
}
@@ -2718,7 +2762,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
let needs_uhid = matches!(
chosen,
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck
GamepadPref::DualSense
| GamepadPref::DualSenseEdge
| GamepadPref::DualShock4
| GamepadPref::SteamDeck
);
if needs_uhid
&& std::fs::OpenOptions::new()
@@ -5286,11 +5333,20 @@ mod tests {
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// DualSense Edge: folds to the plain DualSense (keeps the rich planes) until the Edge
// backend lands (gamepad-new-types N1); Xbox360 where no DualSense backend exists.
assert_eq!(pick_gamepad(DualSenseEdge, None, true, false), DualSense);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, true), DualSense);
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSense);
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
// elsewhere.
assert_eq!(
pick_gamepad(DualSenseEdge, None, true, false),
DualSenseEdge
);
assert_eq!(
pick_gamepad(DualSenseEdge, None, false, true),
DualSenseEdge
);
assert_eq!(
pick_gamepad(Auto, Some("edge"), true, false),
DualSenseEdge
);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: no backend yet (gamepad-new-types N2) — folds to Xbox360 everywhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), Xbox360);