Compare commits

2 Commits

Author SHA1 Message Date
enricobuehler d8e8529cd7 feat(gamepad): Windows Steam Deck backend — Steam-Input-promoted UMDF virtual Deck
windows-drivers / probe-and-proto (push) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
release / apple (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / web (push) Successful in 51s
windows-drivers / driver-build (push) Successful in 1m51s
ci / docs-site (push) Successful in 54s
ci / bench (push) Successful in 5m20s
arch / build-publish (push) Successful in 13m32s
android / android (push) Successful in 15m7s
ci / rust (push) Successful in 21m11s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Successful in 20m53s
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<unit-id> 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 <noreply@anthropic.com>
2026-07-14 12:36:26 +02:00
enricobuehler 4201851c7f fix(fmt) + feat(gamepad): CI-matching rustfmt everywhere, enforced by repo git hooks; N4 spike flips to GO via MI_02
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-host / package (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
decky / build-publish (push) Has been cancelled
deb / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
arch / build-publish (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
Three things that belong together:

1. rustfmt the gamepad-new-types host files ci.yml's `cargo fmt --all
   --check` gate flags (the .21/.133 verify recipes ran clippy+tests
   but never fmt — the same class of miss as 69f30f30).

2. Enforce it at the source: scripts/git-hooks/{pre-commit,pre-push}
   run the exact CI fmt gates (main workspace + the shipped-driver
   crates of the UMDF workspace); CONTRIBUTING documents the one-time
   `git config core.hooksPath scripts/git-hooks`. pre-push is the
   enforcement point (plumbing commits bypass pre-commit).

3. N4 follow-up — the spike verdict FLIPS TO GO: SwDeviceProfile grows
   `usb_mi`, synthesizing `&MI_02` into the Deck spike's USB hardware
   ids. hidclass mirrors the parent's USB tokens into the HID child's
   hardware ids, and hidapi/SDL/Steam parse `MI_` as bInterfaceNumber
   (defaulting to 0 when absent — the exact gate the first run hit:
   Steam wants the Deck controller on interface 2). Re-run live on
   .173: Steam logs `Interface: 2`, then `!! Steam controller device
   opened`, `Steam Controller reserving XInput slot 0`, and activates
   a mapping — full Steam Input promotion of the software-devnode
   Deck, no driver change needed. The PS identities pass
   `usb_mi: None` (real single-interface devices carry no MI_ token).
   A proper Windows-Deck backend phase is now justified; planned
   separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:20:33 +02:00
20 changed files with 475 additions and 97 deletions
+10
View File
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Before you push
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
workspace + the UMDF driver workspace) on every commit and push, so a push can never fail CI
on formatting alone:
```sh
git config core.hooksPath scripts/git-hooks
```
Then the usual full pass:
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
+3 -3
View File
@@ -22,9 +22,9 @@ pub(crate) enum GlyphStyle {
impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref {
Some(
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4,
) => GlyphStyle::Shapes,
Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
GlyphStyle::Shapes
}
Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard,
}
+5 -4
View File
@@ -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).
+3 -3
View File
@@ -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.
+4 -3
View File
@@ -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,
+22 -16
View File
@@ -473,6 +473,11 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
#[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"]
pub mod dualsense;
/// 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;
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -482,11 +487,6 @@ 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;
@@ -527,15 +527,11 @@ pub mod pad_slots;
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller;
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
#[cfg(target_os = "linux")]
#[path = "inject/linux/switch_pro.rs"]
pub mod switch_pro;
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
/// replies, used by the Linux UHID backend ([`switch_pro`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto;
/// 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`).
@@ -543,8 +539,9 @@ pub mod switch_proto;
#[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.
@@ -559,6 +556,15 @@ pub mod steam_remap;
#[cfg(target_os = "linux")]
#[path = "inject/linux/steam_usbip.rs"]
pub mod steam_usbip;
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`).
#[cfg(target_os = "linux")]
#[path = "inject/linux/switch_pro.rs"]
pub mod switch_pro;
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake
/// replies, used by the Linux UHID backend ([`switch_pro`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto;
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
@@ -13,10 +13,9 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{
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,
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};
@@ -102,7 +101,8 @@ 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, id).context("UHID_CREATE2 DualSense")?;
ds.send_create2(index, id)
.context("UHID_CREATE2 DualSense")?;
Ok(ds)
}
@@ -84,7 +84,12 @@ impl SwitchProPad {
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 Switch Pro Controller {index}")); // name[128]
put_cstr(
&mut ev,
4,
128,
&format!("Punktfunk Switch Pro Controller {index}"),
); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(PROCON_RDESC.len() as u16).to_ne_bytes()); // rd_size
@@ -123,7 +128,13 @@ impl SwitchProPad {
let reply = match id {
// Device info — the fatal one (probe aborts without it): type = Pro Controller +
// this pad's virtual MAC. Real hardware acks it with 0x82.
0x02 => build_subcmd_reply(&st, self.timer, 0x82, id, &device_info_payload(&switch_mac(self.index))),
0x02 => build_subcmd_reply(
&st,
self.timer,
0x82,
id,
&device_info_payload(&switch_mac(self.index)),
),
// SPI flash read: echoed addr + len + the canned calibration bytes. An unmapped
// range answers zeroes (echoed header, zero data) — the driver then warns and uses
// its defaults instead of stalling through 2 × 1 s timeouts.
@@ -134,7 +145,11 @@ impl SwitchProPad {
.unwrap_or(0);
let len = args.get(4).copied().unwrap_or(0);
let payload = spi_flash_read(addr, len).unwrap_or_else(|| {
tracing::debug!(addr = format!("{addr:#x}"), len, "unmapped SPI read — zero fill");
tracing::debug!(
addr = format!("{addr:#x}"),
len,
"unmapped SPI read — zero fill"
);
let mut p = Vec::with_capacity(5 + len as usize);
p.extend_from_slice(&addr.to_le_bytes());
p.push(len);
@@ -404,7 +404,7 @@ pub fn sc_from_gamepad(
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
set(
&mut b,
@@ -444,7 +444,7 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq:
r[10] = ((buttons >> 16) & 0xFF) as u8;
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
(st.lpad_x, st.lpad_y)
} else {
@@ -824,11 +824,7 @@ mod tests {
fn sc_serialize_and_mapping() {
// Full mapping: face + grips + clicks + a deflected right stick.
let s = sc_from_gamepad(
gs::BTN_A
| gs::BTN_PADDLE1
| gs::BTN_PADDLE2
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK,
gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
1000,
-2000,
3000,
@@ -486,7 +486,7 @@ mod tests {
fn pack12_layout() {
assert_eq!(pack12(0x578, 0x578), [0x78, 0x85, 0x57]); // 1400/1400 (the cal pair)
assert_eq!(pack12(0x800, 0x800), [0x00, 0x08, 0x80]); // 2048/2048 (the center pair)
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
// Extract back: a = b0 | (b1 & 0xF) << 8; b = (b1 >> 4) | b2 << 4.
let p = pack12(0xABC, 0x123);
let a = p[0] as u16 | ((p[1] as u16 & 0xF) << 8);
let b = ((p[1] as u16) >> 4) | ((p[2] as u16) << 4);
@@ -88,6 +88,11 @@ pub(super) struct SwDeviceProfile<'a> {
pub hwid: &'a str,
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
pub usb_vid_pid: &'a str,
/// USB composite interface number to synthesize (`&MI_xx` appended to the USB hardware ids).
/// hidclass mirrors the parent's `USB\VID…` tokens into the HID child's hardware ids, and
/// hidapi/SDL/Steam parse the child's `MI_` token as `bInterfaceNumber` (defaulting to 0 when
/// absent) — the Steam Deck's controller lives on interface 2, the gate the N4 spike hit.
pub usb_mi: Option<u8>,
/// Device description shown in Device Manager.
pub description: &'a str,
}
@@ -126,8 +131,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
.chain(std::iter::once(0))
.collect()
};
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid);
let usb = format!("USB\\{}", p.usb_vid_pid);
let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
let usb_rev = format!("USB\\{}&REV_0100{mi}", p.usb_vid_pid);
let usb = format!("USB\\{}{mi}", p.usb_vid_pid);
let hwids = multi_sz(&[
p.hwid, // FIRST → the INF binds our UMDF driver on this id
usb_rev.as_str(),
@@ -297,6 +303,7 @@ impl DsWinPad {
container_index: index,
hwid: id.hwid,
usb_vid_pid: id.usb_vid_pid,
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
description: id.description,
}) {
Ok((h, i)) => (Some(h), i),
@@ -487,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);
@@ -498,6 +505,9 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
container_index: index,
hwid: "pf_steamdeck",
usb_vid_pid: "VID_28DE&PID_1205",
// The Deck's controller interface — the promotion gate the first spike run hit
// (hidapi parses MI_ from the child hwids; absent = interface 0, Steam wants 2).
usb_mi: Some(2),
description: "punktfunk Virtual Steam Deck (spike)",
})?;
let _sw = super::gamepad_raii::SwDevice::new(hsw);
@@ -515,9 +525,8 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
channel.pump();
// Log any feature/output traffic Steam sends — each one is spike evidence.
// SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
let seq = unsafe {
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32)
};
let seq =
unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
if seq != last_out_seq {
last_out_seq = seq;
let mut out = [0u8; 16];
@@ -64,6 +64,7 @@ impl Ds4WinPad {
container_index: index,
hwid: "pf_dualshock4",
usb_vid_pid: "VID_054C&PID_09CC",
usb_mi: None,
description: "punktfunk Virtual DualShock 4",
}) {
Ok((h, id)) => (Some(h), id),
@@ -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_<index>` 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<super::gamepad_raii::SwDevice>,
/// 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_<index>` devnode with the
/// `MI_02` USB identity Steam's promotion gate requires.
fn open(index: u8) -> Result<DeckWinPad> {
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<DeckWinPad> {
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<DeckWinProto>;
+11 -3
View File
@@ -429,10 +429,13 @@ 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 {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
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 {
0
};
@@ -534,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(),
+36 -15
View File
@@ -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<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
#[cfg(target_os = "windows")]
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
}
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!(
@@ -5400,14 +5421,14 @@ mod tests {
pick_gamepad(DualSenseEdge, None, false, true),
DualSenseEdge
);
assert_eq!(
pick_gamepad(Auto, Some("edge"), true, false),
DualSenseEdge
);
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro);
assert_eq!(
pick_gamepad(Auto, Some("switchpro"), true, false),
SwitchPro
);
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, 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 |
|---|---|---|
| `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
+5 -5
View File
@@ -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
@@ -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<u8> = 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
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Repo pre-commit hook: the same rustfmt gates as pre-push, but at commit time for faster
# feedback. Enable once per clone:
#
# git config core.hooksPath scripts/git-hooks
#
# NOTE this is the convenience layer only — plumbing commits (commit-tree) bypass pre-commit,
# which is why pre-push re-runs the identical checks and is the actual enforcement point.
exec "$(git rev-parse --show-toplevel)/scripts/git-hooks/pre-push"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Repo pre-push hook: run the exact rustfmt gates CI runs, so a push can never fail on
# formatting alone. Enable once per clone:
#
# git config core.hooksPath scripts/git-hooks
#
# Kept to the CHEAP checks on purpose (rustfmt only parses — a couple of seconds): clippy/tests
# stay in CI and the per-box verify recipes. Runs regardless of how the commits were made
# (plain `git commit`, amends, or plumbing like commit-tree — pre-push sees them all).
#
# Escape hatch for a genuine emergency: `git push --no-verify`.
set -eu
root=$(git rev-parse --show-toplevel)
fail() {
echo "pre-push: $1" >&2
echo "pre-push: fix with: $2" >&2
echo "pre-push: (bypass in an emergency with: git push --no-verify)" >&2
exit 1
}
# 1. Main workspace — mirrors ci.yml "Format": cargo fmt --all --check.
if ! (cd "$root" && cargo fmt --all --check >/dev/null 2>&1); then
fail "main workspace is not rustfmt-clean (ci.yml would fail)" \
"cargo fmt --all"
fi
# 2. UMDF driver workspace — mirrors windows-drivers.yml's gate (the shipped drivers + the
# audited safe layer; pf-vdisplay is deliberately NOT gated there, so not here either).
if ! (cd "$root/packaging/windows/drivers" \
&& cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense --check >/dev/null 2>&1); then
fail "driver workspace is not rustfmt-clean (windows-drivers.yml would fail)" \
"(cd packaging/windows/drivers && cargo fmt -p pf-umdf-util -p pf-xusb -p pf-dualsense)"
fi
exit 0