From d8e8529cd7169215ab30e2aea3efafc386af920d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 14 Jul 2026 12:36:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(gamepad):=20Windows=20Steam=20Deck=20backe?= =?UTF-8?q?nd=20=E2=80=94=20Steam-Input-promoted=20UMDF=20virtual=20Deck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The N4 GO verdict, productized. GamepadPref::SteamDeck on a Windows host now builds a real virtual Deck instead of folding to DualSense: games get native Deck glyphs + both trackpads + gyro + all four back grips through Steam Input's own remapping. - steam_deck_windows.rs: DeckWinPad/DeckWinProto/SteamDeckWindowsManager over the sealed shm channel, sharing the whole Linux Deck codec (steam_proto now compiles on Windows too — it was already pure). The SwDevice identity carries usb_mi: Some(2): the &MI_02 hardware-id token hidclass mirrors into the HID child and Steam parses as the wired controller interface — the promotion gate. - Driver: DEVTYPE_STEAMDECK (3) graduates from the spike — SET_FEATURE 0xEB rumble / 0x8F haptic pulses are republished to the host through the output slot (report-id-0 prefixed, so parse_steam_output sees the Linux wire shape), and the 0xAE/GET_STRING serial + 0x83 unit id are per-pad (read from the section's pad_index; PFDK matches steam_proto::deck_serial). - Router: SteamDeck arms in the Windows Pads paths; pick_gamepad flips SteamDeck-if-windows -> SteamDeck (the DualSense fold retires); dualsense-windows-test grows --deck. ON-GLASS VALIDATED on .173 (rebuilt signed driver 9.9.0714.12xx installed, Steam live): the manager-created pad (index 1) enumerates with per-pad serial PFDK50460001, Steam logs Interface: 2 -> '!! Steam controller device opened' -> 'Steam Controller reserving XInput slot 0' -> PollState 2 (actively polling our cycling input frames) -> mapping activated; clean teardown on exit. Rumble round-trip through a real game remains an on-glass debt (nothing sent 0xEB during the idle hold). Known gap vs Linux: no physical-Steam-controller conflict degrade on Windows yet (degrade_steam_on_conflict is Linux-only — /sys scan); a Windows equivalent needs SetupDi enumeration and is deferred. Verified: .21 clippy -D warnings + 304/0 tests + fmt --all; .133 clippy -D warnings + the WDK driver-workspace check. Co-Authored-By: Claude Fable 5 --- crates/pf-driver-proto/src/lib.rs | 9 +- crates/punktfunk-core/src/abi.rs | 6 +- crates/punktfunk-core/src/config.rs | 7 +- crates/punktfunk-host/src/inject.rs | 10 +- .../src/inject/windows/dualsense_windows.rs | 2 +- .../src/inject/windows/steam_deck_windows.rs | 228 ++++++++++++++++++ crates/punktfunk-host/src/main.rs | 11 +- crates/punktfunk-host/src/punktfunk1.rs | 41 +++- docs-site/content/docs/configuration.md | 2 +- include/punktfunk_core.h | 10 +- .../windows/drivers/pf-dualsense/src/lib.rs | 80 ++++-- 11 files changed, 353 insertions(+), 53 deletions(-) create mode 100644 crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 9eb4403a..283f585f 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -538,10 +538,11 @@ pub mod gamepad { /// `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; - /// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for - /// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a - /// software-devnode HID Deck?) — never stamped by a session. - pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3; + /// `device_type` = Steam Deck controller (`VID_28DE&PID_1205` HID identity, the captured + /// controller-interface descriptor + the Steam `0x83`/`0xAE` feature contract). Promoted by + /// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02` + /// (the wired controller interface — the N4-spike finding). + pub const DEVTYPE_STEAMDECK: u8 = 3; /// 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-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 4ac86e0a..f13a341d 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -885,9 +885,9 @@ pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4; /// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual /// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360. pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5; -/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the -/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when -/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. +/// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both +/// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the +/// host. Honored on Linux AND Windows hosts; else folds to X-Box 360. pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6; /// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a /// client's back paddles land on native slots. Folds to `DUALSENSE` until its backend lands. diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 673f69f0..24eacd99 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -160,9 +160,10 @@ pub enum GamepadPref { /// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact /// shadows the stick (hardware multiplex). Needs Linux UHID. SteamController, - /// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl. - /// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input - /// with native glyphs when Steam runs on the host. Needs Linux UHID. + /// Steam Deck controller (Valve `28DE:1205`) — full Deck gamepad incl. the four back grips + /// (L4/L5/R4/R5), both trackpads, and the IMU; re-grabbed by Steam Input with native glyphs + /// when Steam runs on the host. Linux (kernel `hid-steam` via UHID/usbip/gadget) or Windows + /// (UMDF minidriver, Steam-Input-promoted). SteamDeck, /// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the /// DualSense plus two back buttons + two Fn buttons, so a client's back paddles (Deck grips, diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index bc1565e1..6841b378 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -527,6 +527,11 @@ pub mod pad_slots; #[cfg(target_os = "linux")] #[path = "inject/linux/steam_controller.rs"] pub mod steam_controller; +/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel +/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis. +#[cfg(target_os = "windows")] +#[path = "inject/windows/steam_deck_windows.rs"] +pub mod steam_deck_windows; /// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only /// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2). /// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`). @@ -534,8 +539,9 @@ pub mod steam_controller; #[path = "inject/linux/steam_gadget.rs"] pub mod steam_gadget; /// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck -/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend ([`steam_controller`]). -#[cfg(target_os = "linux")] +/// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend +/// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]). +#[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/proto/steam_proto.rs"] pub mod steam_proto; /// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale. diff --git a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs index a30a8551..7d01ec5b 100644 --- a/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/windows/dualsense_windows.rs @@ -494,7 +494,7 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> { // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. Device-type // FIRST, magic LAST — the same publish order the session pads use. unsafe { - *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK_SPIKE; + *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK; 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; 64], neutral); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); diff --git a/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs b/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs new file mode 100644 index 00000000..682f6e91 --- /dev/null +++ b/crates/punktfunk-host/src/inject/windows/steam_deck_windows.rs @@ -0,0 +1,228 @@ +//! Virtual Steam Deck controller on Windows via the UMDF minidriver — the Windows analogue of +//! the Linux UHID Deck ([`super::steam_controller`]'s `SteamProto`), sharing its whole codec +//! ([`super::steam_proto`]: the byte-exact `ID_CONTROLLER_DECK_STATE` serializer, the +//! `XInput`/rich mappers, the `0xEB` rumble parser). +//! +//! Transport = the sealed shared-memory channel + a `SwDeviceCreate` devnode (device-type 3), +//! like the PS pads — with the promotion lever the N4 spike proved: the synthesized USB +//! hardware ids carry **`&MI_02`** (the Deck's wired controller interface), which hidclass +//! mirrors into the HID child and hidapi/Steam parse as `bInterfaceNumber`. Steam Input then +//! claims the pad exactly like a physical wired Deck (`!! Steam controller device opened`, +//! XInput slot reserved — observed live on `.173`), so games get native Deck glyphs + +//! trackpads + gyro + back grips through Steam's own remapping. +//! +//! Feedback: Steam drives Deck rumble (`0xEB`) and trackpad haptic pulses (`0x8F`) via +//! SET_FEATURE on the unnumbered report; the driver republishes those into the section's +//! output slot (report-id-0 prefixed), where [`parse_steam_output`] reads the exact wire shape +//! the Linux path sees. No gamepad-mode entry pulse here — that gate lives in the Linux +//! kernel's evdev parser; Steam-on-Windows reads the raw reports directly. + +use super::dualsense_windows::{ + create_swdevice, SwDeviceProfile, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT, OFF_OUTPUT, + OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE, +}; +use super::gamepad_raii::PadChannel; +use super::steam_proto::{ + neutral_deck_report, parse_steam_output, serialize_deck_state, SteamState, STEAM_REPORT_LEN, +}; +use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; +use anyhow::Result; +use punktfunk_core::quic::RichInput; +use std::time::Duration; + +/// A single virtual Steam Deck: the `SwDeviceCreate`'d `pf_deck_` devnode plus the sealed +/// shared-memory channel. Dropping it removes the devnode and closes both sections. +/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait). +pub struct DeckWinPad { + /// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop). + _sw: Option, + /// The sealed channel: unnamed DATA section (`PadShm`) + bootstrap mailbox + handle delivery. + channel: PadChannel, + /// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis. + attach: super::gamepad_raii::DriverAttach, + seq: u32, + last_out_seq: u32, +} + +impl DeckWinPad { + /// Create the sealed channel, stamp `device_type = Steam Deck` FIRST + the pad index + the + /// neutral Deck frame + the magic LAST, then spawn the `pf_deck_` devnode with the + /// `MI_02` USB identity Steam's promotion gate requires. + fn open(index: u8) -> 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(); + // SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range. + unsafe { + *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_STEAMDECK; + 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; STEAM_REPORT_LEN], + neutral_deck_report(), + ); + std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); + } + let inst = format!("pf_deck_{index}"); + let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile { + instance: &inst, + container_index: index, + hwid: "pf_steamdeck", + usb_vid_pid: "VID_28DE&PID_1205", + // The wired Deck controller interface — WITHOUT this the HID child carries no MI_ + // token, hidapi reports interface 0, and Steam never claims the pad (the N4 + // spike's run-1 failure). + usb_mi: Some(2), + description: "punktfunk Virtual Steam Deck", + }) { + Ok((h, i)) => (Some(h), i), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; Steam Deck devnode unavailable"); + (None, None) + } + }; + let _sw = hsw.map(super::gamepad_raii::SwDevice::new); + // Bounded eager delivery — the driver must read `device_type = 3` before hidclass asks + // it for descriptors, or the pad would enumerate with the default DualSense identity. + channel.deliver_eager(Duration::from_millis(1500)); + Ok(DeckWinPad { + _sw, + channel, + attach: super::gamepad_raii::DriverAttach::new( + "pf_steamdeck", + "pf_dualsense.inf", // one driver package serves every identity + "C:\\Users\\Public\\pfds-driver.log", + boot_name, + instance_id, + ), + seq: 0, + last_out_seq: 0, + }) + } + + /// Serialize `st` into the Deck state frame and publish it to the section's input slot. + fn write_state(&mut self, st: &SteamState) { + self.seq = self.seq.wrapping_add(1); + let mut r = [0u8; STEAM_REPORT_LEN]; + serialize_deck_state(&mut r, st, self.seq); + // SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. + unsafe { + std::ptr::copy_nonoverlapping( + r.as_ptr(), + self.channel.data_base().add(OFF_INPUT), + r.len(), + ) + }; + } + + /// Poll the section's output slot; parse a newly-published Steam command (`0xEB` rumble / + /// `0x8F` haptic pulse — republished by the driver off SET_FEATURE) into feedback. Also + /// ticks the sealed-channel delivery and the driver-attach health watcher. + fn service(&mut self) -> Option<(u16, u16)> { + self.channel.pump(); + // SAFETY: base points at SHM_SIZE bytes. + let proto = unsafe { + std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32) + }; + self.attach.observe(proto); + // SAFETY: base points at SHM_SIZE bytes. + let seq = unsafe { + std::ptr::read_unaligned(self.channel.data_base().add(OFF_OUT_SEQ) as *const u32) + }; + if seq == self.last_out_seq { + return None; + } + self.last_out_seq = seq; + let mut out = [0u8; 64]; + // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. + unsafe { + std::ptr::copy_nonoverlapping( + self.channel.data_base().add(OFF_OUTPUT), + out.as_mut_ptr(), + 64, + ) + }; + parse_steam_output(&out).rumble + } +} + +/// The Windows-Deck half of the shared stateful manager (see [`PadProto`]): the sealed-channel +/// open under the promoted Deck identity, the same [`SteamState`] mappers as the Linux backend, +/// and the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, rumble dedup) +/// lives in [`UhidManager`]. +#[derive(Default)] +pub struct DeckWinProto; + +impl PadProto for DeckWinProto { + type Pad = DeckWinPad; + type State = SteamState; + const LABEL: &'static str = "Steam Deck/Windows"; + const DEVICE: &'static str = "Steam Deck"; + const CREATE_HINT: &'static str = + " (install/repair: punktfunk-host.exe driver install --gamepad)"; + + fn open(&mut self, idx: u8) -> Result { + let p = DeckWinPad::open(idx)?; + tracing::info!( + index = idx, + "virtual Steam Deck created (Windows UMDF shm channel, MI_02 promoted identity)" + ); + Ok(p) + } + + fn neutral(&self) -> SteamState { + SteamState::neutral() + } + + /// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpads + motion + + /// pad clicks arrive separately and must survive a button-only frame) — identical to the + /// Linux `SteamProto::merge_frame`. + fn merge_frame( + &self, + prev: &SteamState, + f: &crate::gamestream::gamepad::GamepadFrame, + ) -> SteamState { + use super::steam_proto::btn; + let mut s = SteamState::from_gamepad( + f.buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.rpad_x = prev.rpad_x; + s.rpad_y = prev.rpad_y; + s.lpad_x = prev.lpad_x; + s.lpad_y = prev.lpad_y; + s.gyro = prev.gyro; + s.accel = prev.accel; + s.buttons |= prev.buttons & (btn::RPAD_TOUCH | btn::LPAD_TOUCH); + s.lpad_click = prev.lpad_click; + s.rpad_click = prev.rpad_click; + s + } + + fn apply_rich(&self, st: &mut SteamState, rich: RichInput) { + st.apply_rich(rich); + } + + fn write_state(&self, pad: &mut DeckWinPad, st: &SteamState) { + pad.write_state(st); + } + + /// Poll the section for Steam's feedback: motor rumble on the universal 0xCA plane. The + /// Deck has no rich host→client feedback plane (no lightbar / adaptive triggers), so + /// `hidout` stays empty — parity with the Linux backend. + fn service(&self, pad: &mut DeckWinPad, _idx: u8) -> PadFeedback { + PadFeedback { + rumble: pad.service(), + hidout: Vec::new(), + } + } +} + +/// All virtual Steam Deck pads of a Windows session — the analogue of the Linux +/// `SteamControllerManager`, with the same method surface (via the shared [`UhidManager`]) as +/// the other Windows pad managers. +pub type SteamDeckWindowsManager = UhidManager; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index f14087af..c814f601 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -429,9 +429,11 @@ fn real_main() -> Result<()> { let xbox = args.iter().any(|a| a == "--xbox"); // `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in - // report byte 10 (0x80|0x40) next to Cross. + // report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend + // (device_type 3, the MI_02-promoted identity) — watch Steam claim it live. let edge = args.iter().any(|a| a == "--edge"); - let extra_buttons: u32 = if edge { + let deck = args.iter().any(|a| a == "--deck"); + let extra_buttons: u32 = if edge || deck { punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2 } else { @@ -535,6 +537,11 @@ fn real_main() -> Result<()> { inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(), "DualSense Edge" ); + } else if deck { + drive!( + inject::steam_deck_windows::SteamDeckWindowsManager::new(), + "Steam Deck" + ); } else { drive!( inject::dualsense_windows::DualSenseWindowsManager::new(), diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index fa61a44d..b9450d0c 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1754,7 +1754,8 @@ const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_s /// two identities), the XUSB companion driver (classic XInput) on Windows. /// - 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` (or usbip/gadget), or the Windows UMDF minidriver +/// (device-type 3, Steam-Input-promoted). /// /// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never /// constructs a manager the build lacks. @@ -1787,6 +1788,8 @@ struct Pads { dualsense_edge_win: Option, #[cfg(target_os = "windows")] dualshock4_win: Option, + #[cfg(target_os = "windows")] + steamdeck_win: Option, } impl Pads { @@ -1822,6 +1825,8 @@ impl Pads { dualsense_edge_win: None, #[cfg(target_os = "windows")] dualshock4_win: None, + #[cfg(target_os = "windows")] + steamdeck_win: None, } } @@ -1924,6 +1929,11 @@ impl Pads { crate::inject::dualshock4_windows::DualShock4WindowsManager::new, ) .handle(ev), + #[cfg(target_os = "windows")] + GamepadPref::SteamDeck => self + .steamdeck_win + .get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new) + .handle(ev), _ => self .xbox360 .get_or_insert_with(crate::inject::gamepad::GamepadManager::new) @@ -2006,6 +2016,12 @@ impl Pads { m.apply_rich(rich) } } + #[cfg(target_os = "windows")] + GamepadPref::SteamDeck => { + if let Some(m) = &mut self.steamdeck_win { + m.apply_rich(rich) + } + } _ => {} } } @@ -2057,6 +2073,9 @@ impl Pads { if let Some(m) = &mut self.dualshock4_win { m.pump(&mut rumble, &mut hidout); } + if let Some(m) = &mut self.steamdeck_win { + m.pump(&mut rumble, &mut hidout); + } } } @@ -2099,6 +2118,9 @@ impl Pads { if let Some(m) = &mut self.dualshock4_win { m.heartbeat(gap); } + if let Some(m) = &mut self.steamdeck_win { + m.heartbeat(gap); + } } } } @@ -2783,11 +2805,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // are the N4 spike). GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, GamepadPref::SteamController if linux => GamepadPref::SteamController, - // No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its - // backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the - // DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped - // all of that silently. - GamepadPref::SteamDeck if windows => GamepadPref::DualSense, + // Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the + // MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads + + // gyro + back grips, replacing the old fold to DualSense. + GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck, // 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. @@ -5373,11 +5394,11 @@ mod tests { assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); - // Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads - // via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere. + // Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3, + // Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere. assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck); - assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense); - assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense); + assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck); + assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck); assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360); // Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere. assert_eq!( diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 1b1cb61b..e626a413 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` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | 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; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4/Steam Deck/Switch Pro need Linux UHID; unsupported choices fold to Xbox 360. | +| `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, …) | 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; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller 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/include/punktfunk_core.h b/include/punktfunk_core.h index 838aa24d..b9a6fa0f 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -112,13 +112,13 @@ // hosts); otherwise the host falls back to X-Box 360. #define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4 -// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro, -// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands. +// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual +// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360. #define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5 -// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the -// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when -// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. +// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both +// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the +// host. Honored on Linux AND Windows hosts; else folds to X-Box 360. #define PUNKTFUNK_GAMEPAD_STEAMDECK 6 // DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a diff --git a/packaging/windows/drivers/pf-dualsense/src/lib.rs b/packaging/windows/drivers/pf-dualsense/src/lib.rs index 938c0a43..804611f8 100644 --- a/packaging/windows/drivers/pf-dualsense/src/lib.rs +++ b/packaging/windows/drivers/pf-dualsense/src/lib.rs @@ -614,14 +614,15 @@ fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS { STATUS_SUCCESS } -/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id -/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one -/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and- -/// forget) — acking them is all they need. +/// Deck identity: the last SET_FEATURE payload (the Steam command byte + args, minus the +/// report-id prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE +/// on the one unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are +/// fire-and-forget) — acking them is all they need. static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]); -// SET_FEATURE: ack (the PS identities' contract), and latch the payload for the Deck's -// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer. +// SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE +// answer, and — the Deck feedback path — publish Steam's rumble/haptic commands to the host. +// Per the UMDF marshalling convention the report data is the input buffer. fn on_set_feature(request: &Request) -> NTSTATUS { if let Ok((bytes, _)) = request.input_bytes(64) { // The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the @@ -636,19 +637,44 @@ fn on_set_feature(request: &Request) -> NTSTATUS { let n = src.len().min(64); g[..n].copy_from_slice(&src[..n]); } + // Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via + // SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT + // reports instead. Publish them to the host through the same output slot + seq the + // output path uses, re-prefixed with the report-id 0 byte so the host's + // `parse_steam_output` sees the exact wire shape the Linux UHID path delivers. + if device_type() == 3 + && matches!(src.first(), Some(&0xEB) | Some(&0x8F)) + && let Some(view) = CHANNEL.data() + { + let mut out = [0u8; 64]; + let n = src.len().min(63); + out[1..1 + n].copy_from_slice(&src[..n]); + view.write_bytes(OFF_OUTPUT, &out); + let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1); + view.write_u32(OFF_OUT_SEQ, seq); + } } dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)"); STATUS_SUCCESS } -/// N4 spike: build the Deck's GET_FEATURE reply from the latched SET_FEATURE command — the +/// Deck identity: build the GET_FEATURE reply from the latched SET_FEATURE command — the /// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both /// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of /// truth this mirrors). Anything else echoes the latched command. fn deck_feature_reply() -> [u8; 64] { let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]); - let unit_id: u32 = 0x5046_0003; // "PF" + the spike's scratch index - let serial = b"PFDK50460003"; + // Per-pad unit id "PF" + the pad index the host stamped into the section (0 while the + // channel hasn't attached yet) — matches steam_proto::deck_unit_id / deck_serial, so two + // virtual Decks never collide in Steam's eyes. + let idx = CHANNEL + .data() + .map(|v| v.read_u32(OFF_PAD_INDEX)) + .unwrap_or(0) + & 0xFF; + let unit_id: u32 = 0x5046_0000 | idx; + let serial = format!("PFDK{unit_id:08X}"); + let serial = serial.as_bytes(); let mut r = [0u8; 64]; match last[0] { 0x83 => { @@ -736,23 +762,32 @@ fn on_get_string(request: &Request) -> NTSTATUS { let string_id = id_val & 0xFFFF; 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 { + let s: String = match string_id { 0 | 0x000e => match devtype { - 1 => "Sony Computer Entertainment", - 3 => "Valve Software", - _ => "Sony Interactive Entertainment", + 1 => "Sony Computer Entertainment".into(), + 3 => "Valve Software".into(), + _ => "Sony Interactive Entertainment".into(), }, 2 | 0x0010 => match devtype { - 1 => "DEADBEEF0001", - 2 => "35533AD6E775", - 3 => "PFDK50460003", - _ => "35533AD6E774", + 1 => "DEADBEEF0001".into(), + 2 => "35533AD6E775".into(), + // Per-pad Deck serial — must agree with deck_feature_reply's 0xAE answer (Steam + // reads both and uses the serial to identify units). + 3 => { + let idx = CHANNEL + .data() + .map(|v| v.read_u32(OFF_PAD_INDEX)) + .unwrap_or(0) + & 0xFF; + format!("PFDK{:08X}", 0x5046_0000u32 | idx) + } + _ => "35533AD6E774".into(), }, _ => match devtype { - 1 => "Wireless Controller", - 2 => "DualSense Edge Wireless Controller", - 3 => "Steam Deck Controller", - _ => "DualSense Wireless Controller", + 1 => "Wireless Controller".into(), + 2 => "DualSense Edge Wireless Controller".into(), + 3 => "Steam Deck Controller".into(), + _ => "DualSense Wireless Controller".into(), }, }; let mut wide: Vec = Vec::with_capacity(s.len() * 2 + 2); @@ -764,7 +799,8 @@ 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, 2 = DualSense Edge. Read fresh on each enumeration query — cheap. If +/// (default), 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. 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