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
21 changed files with 475 additions and 192 deletions
+10
View File
@@ -30,6 +30,16 @@ file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Before you push ## 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 ```sh
cargo fmt --all --check cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
+3 -3
View File
@@ -22,9 +22,9 @@ pub(crate) enum GlyphStyle {
impl GlyphStyle { impl GlyphStyle {
pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle { pub(crate) fn from_pref(pref: Option<GamepadPref>) -> GlyphStyle {
match pref { match pref {
Some( Some(GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4) => {
GamepadPref::DualSense | GamepadPref::DualSenseEdge | GamepadPref::DualShock4, GlyphStyle::Shapes
) => GlyphStyle::Shapes, }
Some(_) => GlyphStyle::Letters, Some(_) => GlyphStyle::Letters,
None => GlyphStyle::Keyboard, 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 /// `device_type` = DualSense Edge (`VID_054C&PID_0DF2` HID identity — the DualSense report
/// codec plus the four native back/Fn button bits). /// codec plus the four native back/Fn button bits).
pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2; pub const DEVTYPE_DUALSENSE_EDGE: u8 = 2;
/// `device_type` = **N4-spike** Steam Deck identity (`VID_28DE&PID_1205`). Exists only for /// `device_type` = Steam Deck controller (`VID_28DE&PID_1205` HID identity, the captured
/// the `deck-windows-spike` go/no-go probe (does Steam Input on Windows promote a /// controller-interface descriptor + the Steam `0x83`/`0xAE` feature contract). Promoted by
/// software-devnode HID Deck?) — never stamped by a session. /// Steam Input on Windows when the devnode's synthesized USB hardware ids carry `&MI_02`
pub const DEVTYPE_STEAMDECK_SPIKE: u8 = 3; /// (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 value a gamepad driver writes into its section's `driver_proto` field once it attaches —
/// the host's positive "driver is alive on this section" signal (health check + version audit). /// the host's positive "driver is alive on this section" signal (health check + version audit).
-95
View File
@@ -427,47 +427,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// events on desktop, and the door Steam's on-screen keyboard types through under // events on desktop, and the door Steam's on-screen keyboard types through under
// gamescope). Toggled edge-wise — start/stop are not free on Wayland. // gamescope). Toggled edge-wise — start/stop are not free on Wayland.
let mut text_input_on = false; let mut text_input_on = false;
// One-shot on-glass touch diagnostics. Under the Deck's game-mode gamescope, Steam Input
// owns the physical touchscreen and by default emulates it as a virtual trackpad/mouse —
// so the app may see MouseMotion/MouseButton instead of the Finger* events the touch-mode
// engine feeds on (which kills BOTH trackpad and passthrough at once). Set
// `PUNKTFUNK_TOUCH_DEBUG=1` to log every raw finger AND mouse event: one run tells us
// whether native wl_touch is being delivered (Finger* with direct=true) or intercepted.
let touch_debug = std::env::var_os("PUNKTFUNK_TOUCH_DEBUG").is_some();
// Under the Deck's game-mode gamescope the session binary's stderr is swallowed by Steam's
// reaper, so ALSO mirror the debug lines to a file in the app data dir (host-visible at
// ~/.var/app/io.unom.Punktfunk/…), pulled over SSH after a run.
let mut touch_log: Option<std::fs::File> = touch_debug
.then(|| {
let dir = std::env::var_os("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share"))
})
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = dir.join("punktfunk-touch-debug.log");
match std::fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => {
tracing::info!(path = %path.display(), "touch-debug: mirroring to file");
Some(f)
}
Err(e) => {
tracing::warn!(error = %e, "touch-debug: file sink open failed");
None
}
}
})
.flatten();
// Defined after `touch_log` so the literal identifier resolves to that local; a no-op when
// the sink is absent (env unset or open failed).
macro_rules! touch_file_log {
($($arg:tt)*) => {
if let Some(f) = touch_log.as_mut() {
use std::io::Write;
let _ = writeln!(f, $($arg)*);
}
};
}
let outcome = 'main: loop { let outcome = 'main: loop {
// --- SDL events (input, window, gamepads) --------------------------------------- // --- SDL events (input, window, gamepads) ---------------------------------------
@@ -599,19 +558,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
} }
} }
Event::MouseMotion { xrel, yrel, .. } => { Event::MouseMotion { xrel, yrel, .. } => {
if touch_debug {
tracing::info!(xrel, yrel, "touch-debug: MouseMotion");
touch_file_log!("MouseMotion xrel={xrel} yrel={yrel}");
}
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
cap.on_motion(xrel, yrel); cap.on_motion(xrel, yrel);
} }
} }
Event::MouseButtonDown { mouse_btn, .. } => { Event::MouseButtonDown { mouse_btn, .. } => {
if touch_debug {
tracing::info!(?mouse_btn, "touch-debug: MouseButtonDown");
touch_file_log!("MouseButtonDown mouse_btn={mouse_btn:?}");
}
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
if !cap.captured() { if !cap.captured() {
// The engaging click is suppressed toward the host. // The engaging click is suppressed toward the host.
@@ -623,10 +574,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
} }
} }
Event::MouseButtonUp { mouse_btn, .. } => { Event::MouseButtonUp { mouse_btn, .. } => {
if touch_debug {
tracing::info!(?mouse_btn, "touch-debug: MouseButtonUp");
touch_file_log!("MouseButtonUp mouse_btn={mouse_btn:?}");
}
if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) {
cap.on_button_up(mouse_btn); cap.on_button_up(mouse_btn);
} }
@@ -650,20 +597,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
timestamp, timestamp,
.. ..
} => { } => {
if touch_debug {
tracing::info!(
touch_id,
finger_id,
x,
y,
direct = is_direct_touch(touch_id),
"touch-debug: FingerDown"
);
touch_file_log!(
"FingerDown touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
is_direct_touch(touch_id)
);
}
if is_direct_touch(touch_id) if is_direct_touch(touch_id)
&& dispatch_finger( && dispatch_finger(
FingerPhase::Down, FingerPhase::Down,
@@ -686,20 +619,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
timestamp, timestamp,
.. ..
} => { } => {
if touch_debug {
tracing::info!(
touch_id,
finger_id,
x,
y,
direct = is_direct_touch(touch_id),
"touch-debug: FingerMotion"
);
touch_file_log!(
"FingerMotion touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
is_direct_touch(touch_id)
);
}
if is_direct_touch(touch_id) if is_direct_touch(touch_id)
&& dispatch_finger( && dispatch_finger(
FingerPhase::Move, FingerPhase::Move,
@@ -722,20 +641,6 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
timestamp, timestamp,
.. ..
} => { } => {
if touch_debug {
tracing::info!(
touch_id,
finger_id,
x,
y,
direct = is_direct_touch(touch_id),
"touch-debug: FingerUp"
);
touch_file_log!(
"FingerUp touch_id={touch_id} finger_id={finger_id} x={x} y={y} direct={}",
is_direct_touch(touch_id)
);
}
if is_direct_touch(touch_id) if is_direct_touch(touch_id)
&& dispatch_finger( && dispatch_finger(
FingerPhase::Up, FingerPhase::Up,
+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 /// 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. /// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5; pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the /// Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when /// trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. /// host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6; pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
/// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a /// 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. /// 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 /// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
/// shadows the stick (hardware multiplex). Needs Linux UHID. /// shadows the stick (hardware multiplex). Needs Linux UHID.
SteamController, SteamController,
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl. /// Steam Deck controller (Valve `28DE:1205`) — full Deck gamepad incl. the four back grips
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input /// (L4/L5/R4/R5), both trackpads, and the IMU; re-grabbed by Steam Input with native glyphs
/// with native glyphs when Steam runs on the host. Needs Linux UHID. /// when Steam runs on the host. Linux (kernel `hid-steam` via UHID/usbip/gadget) or Windows
/// (UMDF minidriver, Steam-Input-promoted).
SteamDeck, SteamDeck,
/// DualSense Edge (Sony `054C:0DF2`, kernel `hid-playstation` ≥ 6.3 / Windows UMDF) — the /// 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, /// 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")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualsense.rs"] #[path = "inject/linux/dualsense.rs"]
pub mod dualsense; 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`]) /// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
/// and the Windows UMDF-driver backend ([`dualsense_windows`]). /// and the Windows UMDF-driver backend ([`dualsense_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))] #[cfg(any(target_os = "linux", target_os = "windows"))]
@@ -482,11 +487,6 @@ pub mod dualsense_proto;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_windows.rs"] #[path = "inject/windows/dualsense_windows.rs"]
pub mod dualsense_windows; pub mod dualsense_windows;
/// Windows: virtual DualSense **Edge** via the same UMDF minidriver + shared-memory channel
/// (device-type 2) — the wire back grips land on the Edge's native back/Fn buttons.
#[cfg(target_os = "windows")]
#[path = "inject/windows/dualsense_edge_windows.rs"]
pub mod dualsense_edge_windows;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/dualshock4.rs"] #[path = "inject/linux/dualshock4.rs"]
pub mod dualshock4; pub mod dualshock4;
@@ -527,15 +527,11 @@ pub mod pad_slots;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_controller.rs"] #[path = "inject/linux/steam_controller.rs"]
pub mod steam_controller; pub mod steam_controller;
/// Linux: virtual Nintendo Switch Pro Controller via UHID (kernel `hid-nintendo`). /// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
#[cfg(target_os = "linux")] /// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
#[path = "inject/linux/switch_pro.rs"] #[cfg(target_os = "windows")]
pub mod switch_pro; #[path = "inject/windows/steam_deck_windows.rs"]
/// Transport-independent Switch Pro Controller codec + the canned `hid-nintendo` handshake pub mod steam_deck_windows;
/// replies, used by the Linux UHID backend ([`switch_pro`]).
#[cfg(target_os = "linux")]
#[path = "inject/proto/switch_proto.rs"]
pub mod switch_proto;
/// Linux: virtual Steam Deck via the USB gadget subsystem (`raw_gadget` + `dummy_hcd`) — the only /// 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). /// virtual-Deck transport Steam Input promotes (presents the controller on USB interface 2).
/// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`). /// SteamOS-host only (needs `dummy_hcd` + `raw_gadget`).
@@ -543,8 +539,9 @@ pub mod switch_proto;
#[path = "inject/linux/steam_gadget.rs"] #[path = "inject/linux/steam_gadget.rs"]
pub mod steam_gadget; pub mod steam_gadget;
/// Transport-independent Steam Controller / Steam Deck HID contract (descriptor, byte-exact Deck /// 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`]). /// serializer, XInput/rich mappers, rumble parser), used by the Linux UHID backend
#[cfg(target_os = "linux")] /// ([`steam_controller`]) and the Windows UMDF backend ([`steam_deck_windows`]).
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "inject/proto/steam_proto.rs"] #[path = "inject/proto/steam_proto.rs"]
pub mod steam_proto; pub mod steam_proto;
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale. /// 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")] #[cfg(target_os = "linux")]
#[path = "inject/linux/steam_usbip.rs"] #[path = "inject/linux/steam_usbip.rs"]
pub mod steam_usbip; 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 /// 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 /// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12). /// 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. //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{ use super::dualsense_proto::{
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, DS_EDGE_PRODUCT,
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN,
DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
DUALSENSE_RDESC,
}; };
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -102,7 +101,8 @@ impl DualSensePad {
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)") format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
})?; })?;
let mut ds = DualSensePad { fd, seq: 0, ts: 0 }; let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
ds.send_create2(index, id).context("UHID_CREATE2 DualSense")?; ds.send_create2(index, id)
.context("UHID_CREATE2 DualSense")?;
Ok(ds) Ok(ds)
} }
@@ -84,7 +84,12 @@ impl SwitchProPad {
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
// union (uhid_create2_req) starts at byte 4. // union (uhid_create2_req) starts at byte 4.
put_cstr(&mut ev, 4, 128, &format!("Punktfunk 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, 132, 64, &format!("punktfunk/switchpro/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-swpro-{index}")); // uniq[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 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 { let reply = match id {
// Device info — the fatal one (probe aborts without it): type = Pro Controller + // Device info — the fatal one (probe aborts without it): type = Pro Controller +
// this pad's virtual MAC. Real hardware acks it with 0x82. // 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 // 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 // range answers zeroes (echoed header, zero data) — the driver then warns and uses
// its defaults instead of stalling through 2 × 1 s timeouts. // its defaults instead of stalling through 2 × 1 s timeouts.
@@ -134,7 +145,11 @@ impl SwitchProPad {
.unwrap_or(0); .unwrap_or(0);
let len = args.get(4).copied().unwrap_or(0); let len = args.get(4).copied().unwrap_or(0);
let payload = spi_flash_read(addr, len).unwrap_or_else(|| { 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); let mut p = Vec::with_capacity(5 + len as usize);
p.extend_from_slice(&addr.to_le_bytes()); p.extend_from_slice(&addr.to_le_bytes());
p.push(len); p.push(len);
@@ -824,11 +824,7 @@ mod tests {
fn sc_serialize_and_mapping() { fn sc_serialize_and_mapping() {
// Full mapping: face + grips + clicks + a deflected right stick. // Full mapping: face + grips + clicks + a deflected right stick.
let s = sc_from_gamepad( let s = sc_from_gamepad(
gs::BTN_A gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK,
| gs::BTN_PADDLE1
| gs::BTN_PADDLE2
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK,
1000, 1000,
-2000, -2000,
3000, 3000,
@@ -88,6 +88,11 @@ pub(super) struct SwDeviceProfile<'a> {
pub hwid: &'a str, pub hwid: &'a str,
/// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids. /// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids.
pub usb_vid_pid: &'a str, 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. /// Device description shown in Device Manager.
pub description: &'a str, pub description: &'a str,
} }
@@ -126,8 +131,9 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<
.chain(std::iter::once(0)) .chain(std::iter::once(0))
.collect() .collect()
}; };
let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid); let mi = p.usb_mi.map(|n| format!("&MI_{n:02}")).unwrap_or_default();
let usb = format!("USB\\{}", p.usb_vid_pid); 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(&[ let hwids = multi_sz(&[
p.hwid, // FIRST → the INF binds our UMDF driver on this id p.hwid, // FIRST → the INF binds our UMDF driver on this id
usb_rev.as_str(), usb_rev.as_str(),
@@ -297,6 +303,7 @@ impl DsWinPad {
container_index: index, container_index: index,
hwid: id.hwid, hwid: id.hwid,
usb_vid_pid: id.usb_vid_pid, usb_vid_pid: id.usb_vid_pid,
usb_mi: None, // single-interface USB devices (real DS/Edge have no MI_ token)
description: id.description, description: id.description,
}) { }) {
Ok((h, i)) => (Some(h), i), 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 // 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. // FIRST, magic LAST — the same publish order the session pads use.
unsafe { 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_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.add(OFF_INPUT) as *mut [u8; 64], neutral);
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); 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, container_index: index,
hwid: "pf_steamdeck", hwid: "pf_steamdeck",
usb_vid_pid: "VID_28DE&PID_1205", 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)", description: "punktfunk Virtual Steam Deck (spike)",
})?; })?;
let _sw = super::gamepad_raii::SwDevice::new(hsw); let _sw = super::gamepad_raii::SwDevice::new(hsw);
@@ -515,9 +525,8 @@ pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
channel.pump(); channel.pump();
// Log any feature/output traffic Steam sends — each one is spike evidence. // 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. // SAFETY: base points at SHM_SIZE bytes; OFF_OUT_SEQ is in range.
let seq = unsafe { let seq =
std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) unsafe { std::ptr::read_unaligned(channel.data_base().add(OFF_OUT_SEQ) as *const u32) };
};
if seq != last_out_seq { if seq != last_out_seq {
last_out_seq = seq; last_out_seq = seq;
let mut out = [0u8; 16]; let mut out = [0u8; 16];
@@ -64,6 +64,7 @@ impl Ds4WinPad {
container_index: index, container_index: index,
hwid: "pf_dualshock4", hwid: "pf_dualshock4",
usb_vid_pid: "VID_054C&PID_09CC", usb_vid_pid: "VID_054C&PID_09CC",
usb_mi: None,
description: "punktfunk Virtual DualShock 4", description: "punktfunk Virtual DualShock 4",
}) { }) {
Ok((h, id)) => (Some(h), id), 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"); let xbox = args.iter().any(|a| a == "--xbox");
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // `--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 // 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 edge = args.iter().any(|a| a == "--edge");
let extra_buttons: u32 = if edge { let deck = args.iter().any(|a| a == "--deck");
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2 let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1
| punktfunk_core::input::gamepad::BTN_PADDLE2
} else { } else {
0 0
}; };
@@ -534,6 +537,11 @@ fn real_main() -> Result<()> {
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(), inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
"DualSense Edge" "DualSense Edge"
); );
} else if deck {
drive!(
inject::steam_deck_windows::SteamDeckWindowsManager::new(),
"Steam Deck"
);
} else { } else {
drive!( drive!(
inject::dualsense_windows::DualSenseWindowsManager::new(), 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. /// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF /// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
/// minidriver (device-type 0/2/1). /// 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 /// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
/// constructs a manager the build lacks. /// constructs a manager the build lacks.
@@ -1787,6 +1788,8 @@ struct Pads {
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>, dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>, dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
#[cfg(target_os = "windows")]
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
} }
impl Pads { impl Pads {
@@ -1822,6 +1825,8 @@ impl Pads {
dualsense_edge_win: None, dualsense_edge_win: None,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
dualshock4_win: None, dualshock4_win: None,
#[cfg(target_os = "windows")]
steamdeck_win: None,
} }
} }
@@ -1924,6 +1929,11 @@ impl Pads {
crate::inject::dualshock4_windows::DualShock4WindowsManager::new, crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
) )
.handle(ev), .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 _ => self
.xbox360 .xbox360
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new) .get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
@@ -2006,6 +2016,12 @@ impl Pads {
m.apply_rich(rich) 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 { if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout); 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 { if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap); 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). // are the N4 spike).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
GamepadPref::SteamController if linux => GamepadPref::SteamController, GamepadPref::SteamController if linux => GamepadPref::SteamController,
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its // Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the // MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped // gyro + back grips, replacing the old fold to DualSense.
// all of that silently. GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
GamepadPref::SteamDeck if windows => GamepadPref::DualSense,
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain // 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 // DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// policy. Degrades to Xbox360 elsewhere like its siblings. // 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(Auto, Some("series"), true, false), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
// Steam Deck: native on Linux; folds to DualSense on Windows (keeps gyro + trackpads // Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
// via the UMDF backend — Xbox360 would drop the whole rich plane); Xbox360 elsewhere. // 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, true, false), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense); assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense); assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360); assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere. // Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
assert_eq!( assert_eq!(
@@ -5400,14 +5421,14 @@ mod tests {
pick_gamepad(DualSenseEdge, None, false, true), pick_gamepad(DualSenseEdge, None, false, true),
DualSenseEdge DualSenseEdge
); );
assert_eq!( assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
pick_gamepad(Auto, Some("edge"), true, false),
DualSenseEdge
);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360); assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere. // 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(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(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360); assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, false), 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 | | 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. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. |
## Audio / microphone ## Audio / microphone
+5 -5
View File
@@ -112,13 +112,13 @@
// hosts); otherwise the host falls back to X-Box 360. // hosts); otherwise the host falls back to X-Box 360.
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4 #define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro, // UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands. // trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5 #define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 5
// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the // Steam Deck controller (Valve `28DE:1205`): full Deck gamepad incl. the four back grips, both
// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when // trackpads, and the IMU; re-grabbed by Steam Input with native glyphs when Steam runs on the
// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360. // host. Honored on Linux AND Windows hosts; else folds to X-Box 360.
#define PUNKTFUNK_GAMEPAD_STEAMDECK 6 #define PUNKTFUNK_GAMEPAD_STEAMDECK 6
// DualSense Edge (Sony `054C:0DF2`): the DualSense plus two back buttons + two Fn buttons, so a // 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 STATUS_SUCCESS
} }
/// N4 spike: the last SET_FEATURE payload (the Steam command byte + args, minus the report-id /// Deck identity: the last SET_FEATURE payload (the Steam command byte + args, minus the
/// prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE on the one /// report-id prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE
/// unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are fire-and- /// on the one unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are
/// forget) — acking them is all they need. /// fire-and-forget) — acking them is all they need.
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]); 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 // SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE
// GET_FEATURE answer. Per the UMDF marshalling convention the report data is the input buffer. // 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 { fn on_set_feature(request: &Request) -> NTSTATUS {
if let Ok((bytes, _)) = request.input_bytes(64) { if let Ok((bytes, _)) = request.input_bytes(64) {
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the // 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); let n = src.len().min(64);
g[..n].copy_from_slice(&src[..n]); 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)"); dbglog!("[pf-ds] SET_FEATURE (acked, latched for GET)");
STATUS_SUCCESS 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 /// 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 /// 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. /// truth this mirrors). Anything else echoes the latched command.
fn deck_feature_reply() -> [u8; 64] { fn deck_feature_reply() -> [u8; 64] {
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 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 // Per-pad unit id "PF" + the pad index the host stamped into the section (0 while the
let serial = b"PFDK50460003"; // 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]; let mut r = [0u8; 64];
match last[0] { match last[0] {
0x83 => { 0x83 => {
@@ -736,23 +762,32 @@ fn on_get_string(request: &Request) -> NTSTATUS {
let string_id = id_val & 0xFFFF; let string_id = id_val & 0xFFFF;
let devtype = device_type(); let devtype = device_type();
dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}"); 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 { 0 | 0x000e => match devtype {
1 => "Sony Computer Entertainment", 1 => "Sony Computer Entertainment".into(),
3 => "Valve Software", 3 => "Valve Software".into(),
_ => "Sony Interactive Entertainment", _ => "Sony Interactive Entertainment".into(),
}, },
2 | 0x0010 => match devtype { 2 | 0x0010 => match devtype {
1 => "DEADBEEF0001", 1 => "DEADBEEF0001".into(),
2 => "35533AD6E775", 2 => "35533AD6E775".into(),
3 => "PFDK50460003", // Per-pad Deck serial — must agree with deck_feature_reply's 0xAE answer (Steam
_ => "35533AD6E774", // 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 { _ => match devtype {
1 => "Wireless Controller", 1 => "Wireless Controller".into(),
2 => "DualSense Edge Wireless Controller", 2 => "DualSense Edge Wireless Controller".into(),
3 => "Steam Deck Controller", 3 => "Steam Deck Controller".into(),
_ => "DualSense Wireless Controller", _ => "DualSense Wireless Controller".into(),
}, },
}; };
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2); 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 /// 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 /// 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 /// 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 /// 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