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
+13 -1
View File
@@ -264,6 +264,7 @@ impl PadInfo {
pub fn kind_label(&self) -> &'static str { pub fn kind_label(&self) -> &'static str {
match self.pref { match self.pref {
GamepadPref::DualSense => "DualSense", GamepadPref::DualSense => "DualSense",
GamepadPref::DualSenseEdge => "DualSense Edge",
GamepadPref::DualShock4 => "DualShock 4", GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One", GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck", GamepadPref::SteamDeck => "Steam Deck",
@@ -783,6 +784,12 @@ impl Worker {
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
pref = GamepadPref::SteamDeck; pref = GamepadPref::SteamDeck;
} }
// The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by
// VID/PID so the host builds the virtual Edge and this pad's back paddles land on native
// slots instead of the fold/drop policy.
if vid == 0x054C && pid == 0x0DF2 {
pref = GamepadPref::DualSenseEdge;
}
let name = self let name = self
.subsystem .subsystem
.name_for_id(jid) .name_for_id(jid)
@@ -1556,7 +1563,12 @@ impl Worker {
let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else { let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else {
continue; continue;
}; };
let is_ds = slot.pref == GamepadPref::DualSense; // A physical Edge takes the same raw DS5 effects packets (SDL's DS5EffectsState_t
// layout is shared; SDL keys the enhanced path off the Edge PID itself).
let is_ds = matches!(
slot.pref,
GamepadPref::DualSense | GamepadPref::DualSenseEdge
);
match hid { match hid {
HidOutput::Led { r, g, b, .. } if is_ds => { HidOutput::Led { r, g, b, .. } if is_ds => {
let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
+3 -1
View File
@@ -22,7 +22,9 @@ pub(crate) enum GlyphStyle {
impl GlyphStyle { impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle { pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref { match pref {
Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes, Some(
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4,
) => GlyphStyle::Shapes,
Some(_) => GlyphStyle::Letters, Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard, None => GlyphStyle::Keyboard,
} }
+4 -1
View File
@@ -531,10 +531,13 @@ pub mod gamepad {
pub const PAD_MAGIC: u32 = 0x5046_4453; pub const PAD_MAGIC: u32 = 0x5046_4453;
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is /// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
/// zeroed, so `0` = DualSense is the default; one driver serves either identity. /// zeroed, so `0` = DualSense is the default; one driver serves every identity.
pub const DEVTYPE_DUALSENSE: u8 = 0; pub const DEVTYPE_DUALSENSE: u8 = 0;
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity). /// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
pub const DEVTYPE_DUALSHOCK4: u8 = 1; pub const DEVTYPE_DUALSHOCK4: u8 = 1;
/// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
/// codec plus the four native back/Fn button bits).
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
/// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit). /// the host's positive "driver is alive on this section" signal (health check + version audit).
+5
View File
@@ -482,6 +482,11 @@ pub mod dualsense_proto;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"] #[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows; 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")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"] #[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4; pub mod dualshock4;
@@ -13,9 +13,10 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{ use super::dualsense_proto::{
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING,
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC, 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 crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; 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) 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 /// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound /// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
/// `hid-playstation` interface). /// `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 { pub struct DualSensePad {
fd: File, fd: File,
seq: u8, seq: u8,
@@ -53,8 +90,9 @@ pub struct DualSensePad {
} }
impl DualSensePad { impl DualSensePad {
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique). /// Create the UHID pad for wire index `index` under `id`'s identity (`index` is used only to
pub fn open(index: u8) -> Result<DualSensePad> { /// make the device name/uniq unique).
pub fn open(index: u8, id: &DsUhidIdentity) -> Result<DualSensePad> {
let fd = OpenOptions::new() let fd = OpenOptions::new()
.read(true) .read(true)
.write(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'?)") 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 }; 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) 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]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// union (uhid_create2_req) starts at byte 4. // union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualSense {index}")); // name[128] put_cstr(&mut ev, 4, 128, &format!("Punktfunk {} {index}", id.name)); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualsense/{index}")); // phys[64] put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds-{index}")); // uniq[64] put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
ev[260..262].copy_from_slice(&(DUALSENSE_RDESC.len() as u16).to_ne_bytes()); // rd_size 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[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes()); 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[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country 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")?; self.fd.write_all(&ev).context("write UHID_CREATE2")?;
Ok(()) Ok(())
} }
@@ -186,7 +224,7 @@ impl PadProto for DsLinuxProto {
const CREATE_HINT: &'static str = ""; const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<DualSensePad> { fn open(&mut self, idx: u8) -> Result<DualSensePad> {
let p = DualSensePad::open(idx)?; let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
tracing::info!( tracing::info!(
index = idx, index = idx,
"virtual DualSense created (UHID hid-playstation)" "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` /// 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). /// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
pub type DualSenseManager = UhidManager<DsLinuxProto>; 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, 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_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller 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). /// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
pub const DS_INPUT_REPORT_LEN: usize = 64; pub const DS_INPUT_REPORT_LEN: usize = 64;
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080). /// 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 L3: u8 = 0x40;
pub const R3: u8 = 0x80; 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 mod btn2 {
pub const PS: u8 = 0x01; pub const PS: u8 = 0x01;
pub const TOUCHPAD: u8 = 0x02; pub const TOUCHPAD: u8 = 0x02;
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`. /// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
pub const MUTE: u8 = 0x04; 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. /// One touchpad contact for the report.
@@ -798,6 +870,51 @@ mod tests {
assert_eq!(s.buttons[2], 0); 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. /// A short / wrong-id report yields nothing.
#[test] #[test]
fn parse_output_rejects_garbage() { 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 = pub(super) const OFF_PAD_INDEX: usize =
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index); 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_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 /// 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. /// 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())) 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 { impl DsWinPad {
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp /// 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 /// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
/// driver loads on it and receives the DATA handle over the bootstrap). The devnode lives for the /// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
/// pad's lifetime — dropping the pad removes it (`SwDeviceClose`). /// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
fn open(index: u8) -> Result<DsWinPad> { /// 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 boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
let base = channel.data_base(); let base = channel.data_base();
// Stamp the pad index (the driver validates it on attach) + the neutral input report, then // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
// 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.
unsafe { 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_PAD_INDEX) as *mut u32, index as u32);
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
let mut r = [0u8; 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); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
} }
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the // 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` // rare failure we keep the section + data plane and fall back to an out-of-band devnode
// devnode (installer / dev-box devgen) — its persistent driver polls the same mailbox name. // (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
let inst = format!("pf_pad_{index}"); let inst = format!("{}_{index}", id.instance_prefix);
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
instance: &inst, instance: &inst,
container_index: index, container_index: index,
hwid: "pf_dualsense", hwid: id.hwid,
usb_vid_pid: "VID_054C&PID_0CE6", usb_vid_pid: id.usb_vid_pid,
description: "punktfunk Virtual DualSense", description: id.description,
}) { }) {
Ok((h, id)) => (Some(h), id), Ok((h, i)) => (Some(h), i),
Err(e) => { 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) (None, None)
} }
}; };
@@ -275,8 +313,8 @@ impl DsWinPad {
_sw, _sw,
channel, channel,
attach: super::gamepad_raii::DriverAttach::new( attach: super::gamepad_raii::DriverAttach::new(
"pf_dualsense", id.hwid,
"pf_dualsense.inf", "pf_dualsense.inf", // one driver package serves every PS identity
"C:\\Users\\Public\\pfds-driver.log", "C:\\Users\\Public\\pfds-driver.log",
boot_name, boot_name,
instance_id, instance_id,
@@ -288,7 +326,7 @@ impl DsWinPad {
} }
/// Serialize `st` into report `0x01` and publish it to the section's input slot. /// 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.seq = self.seq.wrapping_add(1);
self.ts = self.ts.wrapping_add(1); self.ts = self.ts.wrapping_add(1);
let mut r = [0u8; DS_INPUT_REPORT_LEN]; 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 /// [`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 /// 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). /// 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(); self.channel.pump();
let mut fb = DsFeedback::default(); let mut fb = DsFeedback::default();
// SAFETY: base points at SHM_SIZE bytes. // SAFETY: base points at SHM_SIZE bytes.
@@ -378,7 +416,7 @@ impl PadProto for DsWinProto {
" (install/repair: punktfunk-host.exe driver install --gamepad)"; " (install/repair: punktfunk-host.exe driver install --gamepad)";
fn open(&mut self, idx: u8) -> Result<DsWinPad> { fn open(&mut self, idx: u8) -> Result<DsWinPad> {
let p = DsWinPad::open(idx)?; let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
tracing::info!( tracing::info!(
index = idx, index = idx,
"virtual DualSense created (Windows UMDF shm channel)" "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): // 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 // 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`. // 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")] #[cfg(target_os = "linux")]
Some("dualsense-test") => { Some("dualsense-test") => {
use inject::dualsense::DualSensePad; use inject::dualsense::{DsUhidIdentity, DualSensePad};
use inject::dualsense_proto::DsState; use inject::dualsense_proto::{edge_paddle_bits, DsState};
let secs: u64 = args let secs: u64 = args
.iter() .iter()
.skip_while(|a| *a != "--seconds") .skip_while(|a| *a != "--seconds")
.nth(1) .nth(1)
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(20); .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}; use std::time::{Duration, Instant};
let mut pad = let mut pad = DualSensePad::open(0, &identity)
DualSensePad::open(0).context("create virtual DualSense via /dev/uhid")?; .with_context(|| format!("create virtual {label} via /dev/uhid"))?;
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input // Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
// devices before we start streaming state. // devices before we start streaming state.
let init = Instant::now() + Duration::from_millis(800); let init = Instant::now() + Duration::from_millis(800);
@@ -276,7 +285,7 @@ fn real_main() -> Result<()> {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
println!( 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." `ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
); );
let deadline = Instant::now() + Duration::from_secs(secs); let deadline = Instant::now() + Duration::from_secs(secs);
@@ -292,14 +301,22 @@ fn real_main() -> Result<()> {
if last_write.elapsed() >= Duration::from_millis(300) { if last_write.elapsed() >= Duration::from_millis(300) {
last_write = Instant::now(); last_write = Instant::now();
i += 1; i += 1;
let buttons = if i % 2 == 0 { let mut buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A punktfunk_core::input::gamepad::BTN_A
} else { } else {
0 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 lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0); let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
pad.write_state(&st).context("write DualSense report")?; if edge {
st.buttons[2] |= edge_paddle_bits(buttons);
}
pad.write_state(&st).context("write report")?;
} }
std::thread::sleep(Duration::from_millis(15)); 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), /// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// two identities), the XUSB companion driver (classic XInput) on Windows. /// 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`. /// - Steam Deck — Linux UHID `hid-steam`.
/// ///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never /// [`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")] #[cfg(target_os = "linux")]
dualsense: Option<crate::inject::dualsense::DualSenseManager>, dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
#[cfg(target_os = "linux")]
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>, dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>, steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>, dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")] #[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>, dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
} }
@@ -1798,12 +1803,16 @@ impl Pads {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense: None, dualsense: None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
dualsense_edge: None,
#[cfg(target_os = "linux")]
dualshock4: None, dualshock4: None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
steamdeck: None, steamdeck: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_win: None, dualsense_win: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualsense_edge_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None, dualshock4_win: None,
} }
} }
@@ -1855,6 +1864,11 @@ impl Pads {
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new) .get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
.handle(ev), .handle(ev),
#[cfg(target_os = "linux")] #[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 GamepadPref::DualShock4 => self
.dualshock4 .dualshock4
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new) .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) .get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
.handle(ev), .handle(ev),
#[cfg(target_os = "windows")] #[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 GamepadPref::DualShock4 => self
.dualshock4_win .dualshock4_win
.get_or_insert_with( .get_or_insert_with(
@@ -1920,6 +1941,12 @@ impl Pads {
} }
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => { GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4 { if let Some(m) = &mut self.dualshock4 {
m.apply_rich(rich) m.apply_rich(rich)
@@ -1938,6 +1965,12 @@ impl Pads {
} }
} }
#[cfg(target_os = "windows")] #[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 => { GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.apply_rich(rich) m.apply_rich(rich)
@@ -1967,6 +2000,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense { if let Some(m) = &mut self.dualsense {
m.pump(&mut rumble, &mut hidout); 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 { if let Some(m) = &mut self.dualshock4 {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
@@ -1979,6 +2015,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense_win { if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout); 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 { if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout); m.pump(&mut rumble, &mut hidout);
} }
@@ -1996,6 +2035,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense { if let Some(m) = &mut self.dualsense {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.dualsense_edge {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4 { if let Some(m) = &mut self.dualshock4 {
m.heartbeat(gap); m.heartbeat(gap);
} }
@@ -2009,6 +2051,9 @@ impl Pads {
if let Some(m) = &mut self.dualsense_win { if let Some(m) = &mut self.dualsense_win {
m.heartbeat(gap); m.heartbeat(gap);
} }
if let Some(m) = &mut self.dualsense_edge_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4_win { if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap); 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 // DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
// all of that silently. // all of that silently.
GamepadPref::SteamDeck if windows => GamepadPref::DualSense, GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
// DualSense Edge: until its backend lands (N1), fold to the plain DualSense wherever // DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
// that exists — it keeps the rich planes (touchpad/motion/lightbar) alive; only the // DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// back-button bits go through the paddle fold. NOT Xbox360 (that would drop the rich // policy. Degrades to Xbox360 elsewhere like its siblings.
// planes entirely, the SteamDeck-on-Windows lesson above). GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSense,
// Switch Pro: no backend yet (N2) — falls through to Xbox360 below. // Switch Pro: no backend yet (N2) — falls through to Xbox360 below.
_ => GamepadPref::Xbox360, _ => 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 { fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
let needs_uhid = matches!( let needs_uhid = matches!(
chosen, chosen,
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck GamepadPref::DualSense
| GamepadPref::DualSenseEdge
| GamepadPref::DualShock4
| GamepadPref::SteamDeck
); );
if needs_uhid if needs_uhid
&& std::fs::OpenOptions::new() && std::fs::OpenOptions::new()
@@ -5286,11 +5333,20 @@ mod tests {
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense); assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360); assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// DualSense Edge: folds to the plain DualSense (keeps the rich planes) until the Edge // DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
// backend lands (gamepad-new-types N1); Xbox360 where no DualSense backend exists. // elsewhere.
assert_eq!(pick_gamepad(DualSenseEdge, None, true, false), DualSense); assert_eq!(
assert_eq!(pick_gamepad(DualSenseEdge, None, false, true), DualSense); pick_gamepad(DualSenseEdge, None, true, false),
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSense); 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); assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: no backend yet (gamepad-new-types N2) — folds to Xbox360 everywhere. // Switch Pro: no backend yet (gamepad-new-types N2) — folds to Xbox360 everywhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, true, false), Xbox360);
+1 -1
View File
@@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualshock4` · `steamdeck` · `steamcontroller` (aliases: `ps5`, `ps4`, `deck`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. DualSense/DualShock 4/Steam Deck need Linux UHID; unsupported choices fold to Xbox 360. | | `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons. DualSense (Edge)/DualShock 4/Steam Deck need Linux UHID; unsupported choices fold to Xbox 360. |
| `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
## Audio / microphone ## Audio / microphone
@@ -27,10 +27,10 @@ pf_dualsense.dll=1
[pf.NT$ARCH$.10.0...22000] [pf.NT$ARCH$.10.0...22000]
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense` ; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so ; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` for the host's virtual DualShock 4 — the ; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's
; same driver binds both and serves the DualSense or DS4 identity per the device_type byte the host ; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching
; stamps into shared memory. ; identity per the device_type byte the host stamps into shared memory.
%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4 %DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge
[pfDualSense.NT] [pfDualSense.NT]
CopyFiles=UMDriverCopy CopyFiles=UMDriverCopy
@@ -1,8 +1,8 @@
// punktfunk virtual DualSense / DualShock 4 — UMDF2 HID minidriver. // punktfunk virtual DualSense / DualShock 4 / DualSense Edge — UMDF2 HID minidriver.
// //
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
// (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor + // (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the
// feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine // report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back. // HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
// //
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics. // No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
@@ -63,6 +63,8 @@ const DS_PID: u16 = 0x0CE6;
const DS_VER: u16 = 0x0100; const DS_VER: u16 = 0x0100;
/// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1. /// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1.
const DS4_PID: u16 = 0x09CC; const DS4_PID: u16 = 0x09CC;
/// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2.
const DS_EDGE_PID: u16 = 0x0DF2;
// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs). // Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs).
// NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273. // NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273.
@@ -175,18 +177,59 @@ static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info
0x00, 0x00,
]; ];
// ---- DualSense Edge assets (served when the host stamps device_type=2) ----
// Sony DualSense Edge USB HID report descriptor (389 bytes), verbatim from
// inject/proto/dualsense_proto.rs (a real-device capture; see the provenance note there). Input
// report 0x01 is bit-identical to the plain DualSense — the Edge's Fn/back buttons ride reserved
// bits of buttons[2]; output report 0x02 grows to 63 bytes and 19 profile feature reports are added.
#[rustfmt::skip]
static DS_EDGE_RDESC: [u8; 389] = [
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,
];
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then // HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB). // {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
// DualSense Edge = 389 (0x0185).
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01]; static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
// `ds4` selects the DualShock 4 product id (same VID/version). // `devtype` selects the product id (same VID/version for all three identities).
fn hid_attrs(ds4: bool) -> [u8; 32] { fn hid_attrs(devtype: u8) -> [u8; 32] {
let pid = match devtype {
1 => DS4_PID,
2 => DS_EDGE_PID,
_ => DS_PID,
};
let mut a = [0u8; 32]; let mut a = [0u8; 32];
a[0..4].copy_from_slice(&32u32.to_le_bytes()); a[0..4].copy_from_slice(&32u32.to_le_bytes());
a[4..6].copy_from_slice(&DS_VID.to_le_bytes()); a[4..6].copy_from_slice(&DS_VID.to_le_bytes());
a[6..8].copy_from_slice(&(if ds4 { DS4_PID } else { DS_PID }).to_le_bytes()); a[6..8].copy_from_slice(&pid.to_le_bytes());
a[8..10].copy_from_slice(&DS_VER.to_le_bytes()); a[8..10].copy_from_slice(&DS_VER.to_le_bytes());
a a
} }
@@ -215,11 +258,11 @@ const DS4_NEUTRAL_REPORT: [u8; 64] = {
r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0) r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
r r
}; };
fn neutral_report(ds4: bool) -> [u8; 64] { fn neutral_report(devtype: u8) -> [u8; 64] {
if ds4 { if devtype == 1 {
DS4_NEUTRAL_REPORT DS4_NEUTRAL_REPORT
} else { } else {
NEUTRAL_REPORT NEUTRAL_REPORT // DualSense and Edge share the report 0x01 shape
} }
} }
@@ -251,7 +294,8 @@ const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so /// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
static CHANNEL: ChannelClient = ChannelClient::new(); static CHANNEL: ChannelClient = ChannelClient::new();
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4) — the neutral-report shape when /// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge) — the
/// neutral-report shape when
/// the channel detaches, and the fallback identity while unattached. /// the channel detaches, and the fallback identity while unattached.
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
/// device_type()'s bounded first-read wait fires at most once (see its docs). /// device_type()'s bounded first-read wait fires at most once (see its docs).
@@ -480,16 +524,16 @@ extern "C" fn evt_io_device_control(
} }
let status: NTSTATUS = match ioctl { let status: NTSTATUS = match ioctl {
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() {
&DS4_HID_DESC 1 => &DS4_HID_DESC,
} else { 2 => &EDGE_HID_DESC,
&HID_DESC _ => &HID_DESC,
}), }),
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)), IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
&DS4_RDESC[..] 1 => &DS4_RDESC[..],
} else { 2 => &DS_EDGE_RDESC[..],
&DUALSENSE_RDESC[..] _ => &DUALSENSE_RDESC[..],
}), }),
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
on_output_report(&request, ioctl) on_output_report(&request, ioctl)
@@ -500,7 +544,7 @@ extern "C" fn evt_io_device_control(
} }
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request), IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
IOCTL_UMDF_HID_GET_INPUT_REPORT => { IOCTL_UMDF_HID_GET_INPUT_REPORT => {
request.copy_to_output(&neutral_report(device_type() == 1)) request.copy_to_output(&neutral_report(device_type()))
} }
IOCTL_HID_GET_STRING => on_get_string(&request), IOCTL_HID_GET_STRING => on_get_string(&request),
_ => STATUS_NOT_IMPLEMENTED, _ => STATUS_NOT_IMPLEMENTED,
@@ -554,14 +598,16 @@ fn on_get_feature(request: &Request) -> NTSTATUS {
let Some(&report_id) = bytes.first() else { let Some(&report_id) = bytes.first() else {
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
}; };
// DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3. // DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
let blob: &[u8] = match (device_type() == 1, report_id) { // for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
(false, 0x05) => &DS_FEATURE_CALIBRATION, // 0x02/0x12/0xa3.
(false, 0x09) => &DS_FEATURE_PAIRING, let blob: &[u8] = match (device_type(), report_id) {
(false, 0x20) => &DS_FEATURE_FIRMWARE, (0 | 2, 0x05) => &DS_FEATURE_CALIBRATION,
(true, 0x02) => &DS4_FEATURE_CALIBRATION, (0 | 2, 0x09) => &DS_FEATURE_PAIRING,
(true, 0x12) => &DS4_FEATURE_PAIRING, (0 | 2, 0x20) => &DS_FEATURE_FIRMWARE,
(true, 0xA3) => &DS4_FEATURE_FIRMWARE, (1, 0x02) => &DS4_FEATURE_CALIBRATION,
(1, 0x12) => &DS4_FEATURE_PAIRING,
(1, 0xA3) => &DS4_FEATURE_FIRMWARE,
(_, other) => { (_, other) => {
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
return STATUS_INVALID_PARAMETER; return STATUS_INVALID_PARAMETER;
@@ -586,30 +632,26 @@ fn on_get_string(request: &Request) -> NTSTATUS {
0 0
}; };
let string_id = id_val & 0xFFFF; let string_id = id_val & 0xFFFF;
let ds4 = device_type() == 1; let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) ds4={ds4}"); dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
let s: &str = match string_id { let s: &str = match string_id {
0 | 0x000e => { 0 | 0x000e => {
if ds4 { if devtype == 1 {
"Sony Computer Entertainment" "Sony Computer Entertainment"
} else { } else {
"Sony Interactive Entertainment" "Sony Interactive Entertainment"
} }
} }
2 | 0x0010 => { 2 | 0x0010 => match devtype {
if ds4 { 1 => "DEADBEEF0001",
"DEADBEEF0001" 2 => "35533AD6E775",
} else { _ => "35533AD6E774",
"35533AD6E774" },
} _ => match devtype {
} 1 => "Wireless Controller",
_ => { 2 => "DualSense Edge Wireless Controller",
if ds4 { _ => "DualSense Wireless Controller",
"Wireless Controller" },
} else {
"DualSense Wireless Controller"
}
}
}; };
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2); let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
for u in s.encode_utf16() { for u in s.encode_utf16() {
@@ -620,11 +662,11 @@ fn on_get_string(request: &Request) -> NTSTATUS {
} }
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense /// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't /// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If
/// attached when hidclass first asks (the host stamps the section + eager-delivers before /// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
/// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly — /// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
/// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because /// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
/// of a lost race. After that one bounded wait, fall back to the last observed type. /// identity because of a lost race. After that one bounded wait, fall back to the last observed type.
fn device_type() -> u8 { fn device_type() -> u8 {
if let Some(view) = CHANNEL.data() { if let Some(view) = CHANNEL.data() {
let t = view.read_u8(OFF_DEVICE_TYPE); let t = view.read_u8(OFF_DEVICE_TYPE);
@@ -672,7 +714,7 @@ extern "C" fn evt_timer(timer: WDFTIMER) {
// report instead of a frozen last state (matters for the persistent out-of-band devnode, // report instead of a frozen last state (matters for the persistent out-of-band devnode,
// which outlives host sessions). // which outlives host sessions).
if let Ok(mut g) = INPUT_REPORT.lock() { if let Ok(mut g) = INPUT_REPORT.lock() {
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) == 1); *g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) as u8);
} }
} }
} }