diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index cd72c225..f3fbf629 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -264,6 +264,7 @@ impl PadInfo { pub fn kind_label(&self) -> &'static str { match self.pref { GamepadPref::DualSense => "DualSense", + GamepadPref::DualSenseEdge => "DualSense Edge", GamepadPref::DualShock4 => "DualShock 4", GamepadPref::XboxOne => "Xbox One", GamepadPref::SteamDeck => "Steam Deck", @@ -783,6 +784,12 @@ impl Worker { if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { 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 .subsystem .name_for_id(jid) @@ -1556,7 +1563,12 @@ impl Worker { let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else { 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 { HidOutput::Led { r, g, b, .. } if is_ds => { let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs index 809076f6..2073b399 100644 --- a/crates/pf-console-ui/src/glyphs.rs +++ b/crates/pf-console-ui/src/glyphs.rs @@ -22,7 +22,9 @@ pub(crate) enum GlyphStyle { impl GlyphStyle { pub(crate) fn from_pref(pref: Option) -> GlyphStyle { match pref { - Some(GamepadPref::DualSense | GamepadPref::DualShock4) => GlyphStyle::Shapes, + Some( + GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4, + ) => GlyphStyle::Shapes, Some(_) => GlyphStyle::Letters, None => GlyphStyle::Keyboard, } diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 1439c56c..af1eec20 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -531,10 +531,13 @@ pub mod gamepad { pub const PAD_MAGIC: u32 = 0x5046_4453; /// `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; /// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity). 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 host's positive "driver is alive on this section" signal (health check + version audit). diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 4f29fadc..d32b5046 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -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; diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 1ce183a5..7b79fb14 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -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: &'static str, + /// Path token for the phys string ("punktfunk//"). + phys: &'static str, + /// Short slug for the uniq string ("punktfunk--"). + 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 { + /// 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 { 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 { - 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; + +/// 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 { + 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; diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 9fc189d6..97020e2e 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -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 4–7 (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 P1–P4) 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 + /// 4–7 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() { diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs new file mode 100644 index 00000000..a4d0e914 --- /dev/null +++ b/crates/punktfunk-host/src/inject/windows/dualsense_edge_windows.rs @@ -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 { + 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; diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index 5fdf4246..7a562c35 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -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_` 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-` mailbox), stamp - /// the pad index + neutral report + the magic LAST, then spawn the `pf_pad_` 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 { + /// 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 { 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 { - let p = DsWinPad::open(idx)?; + let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?; tracing::info!( index = idx, "virtual DualSense created (Windows UMDF shm channel)" diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 65255dd5..9be4030c 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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)); } diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 9ed5b460..2f0ed588 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -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, #[cfg(target_os = "linux")] + dualsense_edge: Option, + #[cfg(target_os = "linux")] dualshock4: Option, #[cfg(target_os = "linux")] steamdeck: Option, #[cfg(target_os = "windows")] dualsense_win: Option, #[cfg(target_os = "windows")] + dualsense_edge_win: Option, + #[cfg(target_os = "windows")] dualshock4_win: Option, } @@ -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); diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 38718e06..31209162 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -95,7 +95,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t | 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. | ## Audio / microphone diff --git a/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx b/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx index 10bb2a49..df221f50 100644 --- a/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx +++ b/packaging/windows/drivers/pf-dualsense/pf_dualsense.inx @@ -27,10 +27,10 @@ pf_dualsense.dll=1 [pf.NT$ARCH$.10.0...22000] ; 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 -; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` for the host's virtual DualShock 4 — the -; same driver binds both and serves the DualSense or DS4 identity per the device_type byte the host -; stamps into shared memory. -%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4 +; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` for the host's +; virtual DualShock 4 / DualSense Edge — the same driver binds all of them and serves the matching +; identity per the device_type byte the host stamps into shared memory. +%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4, pf_dualsenseedge [pfDualSense.NT] CopyFiles=UMDriverCopy diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-dualsense/src/lib.rs index d4a1ad77..b3d4b32e 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-dualsense/src/lib.rs @@ -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 -// (VID 054C / PID 0CE6) or DualShock 4 (device_type=1) using the inputtino report descriptor + -// feature blobs punktfunk already ships in `inject/{dualsense,dualshock4}.rs`. Games see a genuine +// (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the +// 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. // // 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; /// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1. 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). // 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, ]; +// ---- 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 -// {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 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]. -// `ds4` selects the DualShock 4 product id (same VID/version). -fn hid_attrs(ds4: bool) -> [u8; 32] { +// `devtype` selects the product id (same VID/version for all three identities). +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]; a[0..4].copy_from_slice(&32u32.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 } @@ -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 }; -fn neutral_report(ds4: bool) -> [u8; 64] { - if ds4 { +fn neutral_report(devtype: u8) -> [u8; 64] { + if devtype == 1 { DS4_NEUTRAL_REPORT } 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 /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. 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. static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); /// 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 { - IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { - &DS4_HID_DESC - } else { - &HID_DESC + IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() { + 1 => &DS4_HID_DESC, + 2 => &EDGE_HID_DESC, + _ => &HID_DESC, }), - IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type() == 1)), - IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(if device_type() == 1 { - &DS4_RDESC[..] - } else { - &DUALSENSE_RDESC[..] + IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())), + IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() { + 1 => &DS4_RDESC[..], + 2 => &DS_EDGE_RDESC[..], + _ => &DUALSENSE_RDESC[..], }), IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { 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_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), _ => STATUS_NOT_IMPLEMENTED, @@ -554,14 +598,16 @@ fn on_get_feature(request: &Request) -> NTSTATUS { let Some(&report_id) = bytes.first() else { return STATUS_INVALID_PARAMETER; }; - // DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3. - let blob: &[u8] = match (device_type() == 1, report_id) { - (false, 0x05) => &DS_FEATURE_CALIBRATION, - (false, 0x09) => &DS_FEATURE_PAIRING, - (false, 0x20) => &DS_FEATURE_FIRMWARE, - (true, 0x02) => &DS4_FEATURE_CALIBRATION, - (true, 0x12) => &DS4_FEATURE_PAIRING, - (true, 0xA3) => &DS4_FEATURE_FIRMWARE, + // DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble + // for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses + // 0x02/0x12/0xa3. + let blob: &[u8] = match (device_type(), report_id) { + (0 | 2, 0x05) => &DS_FEATURE_CALIBRATION, + (0 | 2, 0x09) => &DS_FEATURE_PAIRING, + (0 | 2, 0x20) => &DS_FEATURE_FIRMWARE, + (1, 0x02) => &DS4_FEATURE_CALIBRATION, + (1, 0x12) => &DS4_FEATURE_PAIRING, + (1, 0xA3) => &DS4_FEATURE_FIRMWARE, (_, other) => { dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); return STATUS_INVALID_PARAMETER; @@ -586,30 +632,26 @@ fn on_get_string(request: &Request) -> NTSTATUS { 0 }; let string_id = id_val & 0xFFFF; - let ds4 = device_type() == 1; - dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) ds4={ds4}"); + let devtype = device_type(); + dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); let s: &str = match string_id { 0 | 0x000e => { - if ds4 { + if devtype == 1 { "Sony Computer Entertainment" } else { "Sony Interactive Entertainment" } } - 2 | 0x0010 => { - if ds4 { - "DEADBEEF0001" - } else { - "35533AD6E774" - } - } - _ => { - if ds4 { - "Wireless Controller" - } else { - "DualSense Wireless Controller" - } - } + 2 | 0x0010 => match devtype { + 1 => "DEADBEEF0001", + 2 => "35533AD6E775", + _ => "35533AD6E774", + }, + _ => match devtype { + 1 => "Wireless Controller", + 2 => "DualSense Edge Wireless Controller", + _ => "DualSense Wireless Controller", + }, }; let mut wide: Vec = Vec::with_capacity(s.len() * 2 + 2); 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 -/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap. If the channel hasn't -/// attached when hidclass first asks (the host stamps the section + eager-delivers before -/// `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel briefly — -/// ONCE — for the delivery: a DS4 pad must not enumerate with the default DualSense identity because -/// of a lost race. After that one bounded wait, fall back to the last observed type. +/// (default), 1 = DualShock 4, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If +/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers +/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel +/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense +/// identity because of a lost race. After that one bounded wait, fall back to the last observed type. fn device_type() -> u8 { if let Some(view) = CHANNEL.data() { 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, // which outlives host sessions). 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); } } }