refactor(host/W6.2): extract the input-injection backends into the pf-inject crate
inject.rs + inject/* (the per-OS injectors — wlroots virtual-input, KWin
fake_input, libei/reis, gamescope-EI on Linux; SendInput on Windows — plus the
virtual-gamepad HID stack: DualSense/DualShock4/Switch Pro/Steam Controller/Deck
over uhid/usbip and the Windows UMDF drivers, the proto codecs, the injector
service, and the uhid manager) move into crates/pf-inject behind the
InputInjector trait (plan §W6). It consumes punktfunk_core::input (the neutral
GamepadEvent/InputEvent vocabulary, moved to core in W5) + the pf-driver-proto
wire contract, and reaches pf-capture only for the Windows gamepad-channel
WUDFHost check + the resident-mouse compose-kick hook.
The one inject->vdisplay coupling (the libei gamescope-EI backend needs the EIS
relay socket path) is broken via a leaf: gamescope_ei_socket_file moves to
pf-paths as the shared contract — the gamescope producer (host vdisplay) keeps
its session-env-lock wrapper around it, the libei consumer (pf-inject) reads it
directly post-retarget. The host keeps a `mod inject { pub use pf_inject::* }`
shim so every crate::inject::* path (the native/gamestream input planes + devtest)
is unchanged; the heavy input deps (wayland/reis/xkbcommon/usbip + the KWin
fake-input protocol XML) moved with the crate.
Verified: Linux clippy -D warnings (pf-inject + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-inject 69/69 + host 230/230 tests; Windows clippy -D warnings
(pf-inject --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
//! Per-pad dedup for the rich HID-output feedback plane (0xCD), carved out of `dualsense_proto`
|
||||
//! (plan §W4 — it is device-agnostic, shared by the DualSense/DS4/Deck managers via
|
||||
//! [`crate::uhid_manager`], not DualSense-specific). A game bundles rumble + lightbar +
|
||||
//! LEDs + adaptive triggers into one output report, so a merely-rumbling pad re-sends unchanged
|
||||
//! rich state every report; this forwards only genuine changes (one-shot pulses always fire).
|
||||
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
|
||||
/// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report
|
||||
/// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is
|
||||
/// merely *rumbling* re-sends its (unchanged) lightbar / LED / trigger state on every output report.
|
||||
/// The managers already dedup rumble; this does the same for the rich [`HidOutput`] feedback so the
|
||||
/// 0xCD plane carries only genuine changes. State (`Led` / `PlayerLeds` / `Trigger`) is deduped by
|
||||
/// value; a one-shot `TrackpadHaptic` pulse is always forwarded (each pulse must fire).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct HidoutDedup {
|
||||
led: Option<(u8, u8, u8)>,
|
||||
player_leds: Option<u8>,
|
||||
/// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2.
|
||||
trigger: [Option<Vec<u8>>; 2],
|
||||
}
|
||||
|
||||
impl HidoutDedup {
|
||||
/// Forget all remembered state — call when a pad is created or unplugged so the first feedback
|
||||
/// after a (re)connect is always forwarded.
|
||||
pub fn clear(&mut self) {
|
||||
*self = HidoutDedup::default();
|
||||
}
|
||||
|
||||
/// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a
|
||||
/// one-shot pulse; `false` if it repeats the last-forwarded value for its kind.
|
||||
pub fn should_forward(&mut self, h: &HidOutput) -> bool {
|
||||
match h {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
let v = Some((*r, *g, *b));
|
||||
if self.led == v {
|
||||
false
|
||||
} else {
|
||||
self.led = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
let v = Some(*bits);
|
||||
if self.player_leds == v {
|
||||
false
|
||||
} else {
|
||||
self.player_leds = v;
|
||||
true
|
||||
}
|
||||
}
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let slot = (*which as usize).min(1);
|
||||
if self.trigger[slot].as_deref() == Some(effect.as_slice()) {
|
||||
false
|
||||
} else {
|
||||
self.trigger[slot] = Some(effect.clone());
|
||||
true
|
||||
}
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
// silence the motors / re-enable lizard mode on the real controller.
|
||||
HidOutput::HidRaw { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `HidoutDedup` forwards a value once, drops exact repeats, re-forwards a change, tracks the two
|
||||
/// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`.
|
||||
#[test]
|
||||
fn hidout_dedup_forwards_only_changes() {
|
||||
let mut d = HidoutDedup::default();
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
r,
|
||||
g: 0,
|
||||
b: 0,
|
||||
};
|
||||
// First value forwards; an exact repeat is dropped; a change forwards again.
|
||||
assert!(d.should_forward(&led(10)));
|
||||
assert!(!d.should_forward(&led(10)));
|
||||
assert!(d.should_forward(&led(20)));
|
||||
|
||||
// Player LEDs dedup on their own field, independent of the lightbar.
|
||||
let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits };
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&pl(0b101)));
|
||||
assert!(!d.should_forward(&led(20))); // lightbar still unchanged
|
||||
|
||||
// The two adaptive triggers (L2=0, R2=1) are tracked separately.
|
||||
let trig = |which, byte| HidOutput::Trigger {
|
||||
pad: 0,
|
||||
which,
|
||||
effect: vec![byte, 0, 0],
|
||||
};
|
||||
assert!(d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards
|
||||
assert!(!d.should_forward(&trig(0, 1)));
|
||||
assert!(d.should_forward(&trig(0, 2))); // L2 effect changed
|
||||
|
||||
// One-shot haptic pulses are never deduped.
|
||||
let haptic = HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 0,
|
||||
amplitude: 1,
|
||||
period: 2,
|
||||
count: 3,
|
||||
};
|
||||
assert!(d.should_forward(&haptic));
|
||||
assert!(d.should_forward(&haptic));
|
||||
|
||||
// `clear` re-arms every kind.
|
||||
d.clear();
|
||||
assert!(d.should_forward(&led(20)));
|
||||
assert!(d.should_forward(&pl(0b101)));
|
||||
assert!(d.should_forward(&trig(0, 2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Key/button mapping tables (plan §W4, carved out of the inject facade): the Windows Virtual-Key
|
||||
//! → Linux-evdev keyboard map (mirrored bit-for-bit by the Windows SendInput positional table), the
|
||||
//! GameStream mouse-button → evdev `BTN_*` map, and the in-process semantic-VK flag. Pure lookup
|
||||
//! tables — no state, no OS handles.
|
||||
|
||||
/// In-process tag on a key event's `flags`: the VK in `code` is **layout-semantic** (already
|
||||
/// resolved under the sending client's keyboard layout — the GameStream/Moonlight convention)
|
||||
/// rather than the punktfunk-native **US-positional** convention (the physical key's US-layout VK,
|
||||
/// which every first-party client sends — the client's local layout never touches the wire).
|
||||
/// The Windows injector maps semantic VKs through the foreground app's layout and positional VKs
|
||||
/// through a fixed table; conflating the two is exactly the German y↔z / ö→ü scramble.
|
||||
/// Set ONLY by `gamestream::input::decode`; the punktfunk/1 ingest strips it from wire events, so
|
||||
/// a network client can never flip the host's key-decoding convention.
|
||||
pub const KEY_FLAG_SEMANTIC_VK: u32 = 0x8000_0000;
|
||||
|
||||
/// Map a Windows Virtual-Key code (as sent by Moonlight/GameStream) to a Linux evdev key code.
|
||||
pub fn vk_to_evdev(vk: u8) -> Option<u16> {
|
||||
match vk {
|
||||
// --- Navigation / editing / whitespace ---
|
||||
0x08 => Some(14), // VK_BACK -> KEY_BACKSPACE
|
||||
0x09 => Some(15), // VK_TAB -> KEY_TAB
|
||||
0x0D => Some(28), // VK_RETURN -> KEY_ENTER
|
||||
0x13 => Some(119), // VK_PAUSE -> KEY_PAUSE
|
||||
0x14 => Some(58), // VK_CAPITAL -> KEY_CAPSLOCK
|
||||
0x1B => Some(1), // VK_ESCAPE -> KEY_ESC
|
||||
0x20 => Some(57), // VK_SPACE -> KEY_SPACE
|
||||
0x21 => Some(104), // VK_PRIOR -> KEY_PAGEUP
|
||||
0x22 => Some(109), // VK_NEXT -> KEY_PAGEDOWN
|
||||
0x23 => Some(107), // VK_END -> KEY_END
|
||||
0x24 => Some(102), // VK_HOME -> KEY_HOME
|
||||
0x25 => Some(105), // VK_LEFT -> KEY_LEFT
|
||||
0x26 => Some(103), // VK_UP -> KEY_UP
|
||||
0x27 => Some(106), // VK_RIGHT -> KEY_RIGHT
|
||||
0x28 => Some(108), // VK_DOWN -> KEY_DOWN
|
||||
0x2C => Some(99), // VK_SNAPSHOT -> KEY_SYSRQ
|
||||
0x2D => Some(110), // VK_INSERT -> KEY_INSERT
|
||||
0x2E => Some(111), // VK_DELETE -> KEY_DELETE
|
||||
|
||||
// --- Generic modifiers ---
|
||||
0x10 => Some(42), // VK_SHIFT -> KEY_LEFTSHIFT
|
||||
0x11 => Some(29), // VK_CONTROL -> KEY_LEFTCTRL
|
||||
0x12 => Some(56), // VK_MENU -> KEY_LEFTALT
|
||||
|
||||
// --- Digit row (KEY_0 is 11, KEY_1..KEY_9 are 2..10) ---
|
||||
0x30 => Some(11), // VK_0
|
||||
0x31 => Some(2), // VK_1
|
||||
0x32 => Some(3), // VK_2
|
||||
0x33 => Some(4), // VK_3
|
||||
0x34 => Some(5), // VK_4
|
||||
0x35 => Some(6), // VK_5
|
||||
0x36 => Some(7), // VK_6
|
||||
0x37 => Some(8), // VK_7
|
||||
0x38 => Some(9), // VK_8
|
||||
0x39 => Some(10), // VK_9
|
||||
|
||||
// --- Letters A-Z (NOT sequential in evdev) ---
|
||||
0x41 => Some(30), // A
|
||||
0x42 => Some(48), // B
|
||||
0x43 => Some(46), // C
|
||||
0x44 => Some(32), // D
|
||||
0x45 => Some(18), // E
|
||||
0x46 => Some(33), // F
|
||||
0x47 => Some(34), // G
|
||||
0x48 => Some(35), // H
|
||||
0x49 => Some(23), // I
|
||||
0x4A => Some(36), // J
|
||||
0x4B => Some(37), // K
|
||||
0x4C => Some(38), // L
|
||||
0x4D => Some(50), // M
|
||||
0x4E => Some(49), // N
|
||||
0x4F => Some(24), // O
|
||||
0x50 => Some(25), // P
|
||||
0x51 => Some(16), // Q
|
||||
0x52 => Some(19), // R
|
||||
0x53 => Some(31), // S
|
||||
0x54 => Some(20), // T
|
||||
0x55 => Some(22), // U
|
||||
0x56 => Some(47), // V
|
||||
0x57 => Some(17), // W
|
||||
0x58 => Some(45), // X
|
||||
0x59 => Some(21), // Y
|
||||
0x5A => Some(44), // Z
|
||||
|
||||
// --- Meta / context-menu ---
|
||||
0x5B => Some(125), // VK_LWIN -> KEY_LEFTMETA
|
||||
0x5C => Some(126), // VK_RWIN -> KEY_RIGHTMETA
|
||||
0x5D => Some(127), // VK_APPS -> KEY_COMPOSE
|
||||
|
||||
// --- Numpad ---
|
||||
0x60 => Some(82), // KP0
|
||||
0x61 => Some(79), // KP1
|
||||
0x62 => Some(80), // KP2
|
||||
0x63 => Some(81), // KP3
|
||||
0x64 => Some(75), // KP4
|
||||
0x65 => Some(76), // KP5
|
||||
0x66 => Some(77), // KP6
|
||||
0x67 => Some(71), // KP7
|
||||
0x68 => Some(72), // KP8
|
||||
0x69 => Some(73), // KP9
|
||||
0x6A => Some(55), // VK_MULTIPLY -> KEY_KPASTERISK
|
||||
0x6B => Some(78), // VK_ADD -> KEY_KPPLUS
|
||||
0x6C => Some(96), // VK_SEPARATOR -> KEY_KPENTER
|
||||
0x6D => Some(74), // VK_SUBTRACT -> KEY_KPMINUS
|
||||
0x6E => Some(83), // VK_DECIMAL -> KEY_KPDOT
|
||||
0x6F => Some(98), // VK_DIVIDE -> KEY_KPSLASH
|
||||
|
||||
// --- Function keys (F1..F10 = 59..68, F11/F12 = 87/88) ---
|
||||
0x70 => Some(59),
|
||||
0x71 => Some(60),
|
||||
0x72 => Some(61),
|
||||
0x73 => Some(62),
|
||||
0x74 => Some(63),
|
||||
0x75 => Some(64),
|
||||
0x76 => Some(65),
|
||||
0x77 => Some(66),
|
||||
0x78 => Some(67),
|
||||
0x79 => Some(68),
|
||||
0x7A => Some(87),
|
||||
0x7B => Some(88),
|
||||
|
||||
// --- Locks ---
|
||||
0x90 => Some(69), // VK_NUMLOCK -> KEY_NUMLOCK
|
||||
0x91 => Some(70), // VK_SCROLL -> KEY_SCROLLLOCK
|
||||
|
||||
// --- Left/right modifiers ---
|
||||
0xA0 => Some(42), // VK_LSHIFT -> KEY_LEFTSHIFT
|
||||
0xA1 => Some(54), // VK_RSHIFT -> KEY_RIGHTSHIFT
|
||||
0xA2 => Some(29), // VK_LCONTROL -> KEY_LEFTCTRL
|
||||
0xA3 => Some(97), // VK_RCONTROL -> KEY_RIGHTCTRL
|
||||
0xA4 => Some(56), // VK_LMENU -> KEY_LEFTALT
|
||||
0xA5 => Some(100), // VK_RMENU -> KEY_RIGHTALT
|
||||
|
||||
// --- OEM punctuation (US layout) ---
|
||||
0xBA => Some(39), // VK_OEM_1 -> KEY_SEMICOLON
|
||||
0xBB => Some(13), // VK_OEM_PLUS -> KEY_EQUAL
|
||||
0xBC => Some(51), // VK_OEM_COMMA -> KEY_COMMA
|
||||
0xBD => Some(12), // VK_OEM_MINUS -> KEY_MINUS
|
||||
0xBE => Some(52), // VK_OEM_PERIOD -> KEY_DOT
|
||||
0xBF => Some(53), // VK_OEM_2 -> KEY_SLASH
|
||||
0xC0 => Some(41), // VK_OEM_3 -> KEY_GRAVE
|
||||
0xDB => Some(26), // VK_OEM_4 -> KEY_LEFTBRACE
|
||||
0xDC => Some(43), // VK_OEM_5 -> KEY_BACKSLASH
|
||||
0xDD => Some(27), // VK_OEM_6 -> KEY_RIGHTBRACE
|
||||
0xDE => Some(40), // VK_OEM_7 -> KEY_APOSTROPHE
|
||||
0xE2 => Some(86), // VK_OEM_102 -> KEY_102ND
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a GameStream mouse button id (1=left … 5=X2) to a Linux evdev `BTN_*` code.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
Some(match b {
|
||||
1 => 0x110, // BTN_LEFT
|
||||
2 => 0x112, // BTN_MIDDLE
|
||||
3 => 0x111, // BTN_RIGHT
|
||||
4 => 0x113, // BTN_SIDE (X1)
|
||||
5 => 0x114, // BTN_EXTRA (X2)
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
//! Virtual Sony DualSense via UHID — the rich-controller path (roadmap §5).
|
||||
//!
|
||||
//! Unlike the uinput X-Box-360 pad ([`super::gamepad`]), which only carries buttons + axes + a
|
||||
//! rumble back-channel, a UHID device presents a *real* DualSense HID interface to the kernel:
|
||||
//! `hid-playstation` binds it (matched by VID `054C`/PID `0CE6`) and exposes the full controller
|
||||
//! — gamepad, motion sensors, touchpad, lightbar + player LEDs, and adaptive triggers — to games.
|
||||
//! The host writes HID **input** reports (report `0x01`, our controller state) and reads HID
|
||||
//! **output** reports (report `0x02`, a game's rumble/LED/trigger feedback) back, which it
|
||||
//! forwards to the client as [`punktfunk_core::quic::HidOutput`].
|
||||
//!
|
||||
//! The transport-independent contract (report descriptor, feature blobs, [`DsState`], the `0x01`
|
||||
//! serializer and `0x02` parser) lives in [`super::dualsense_proto`], shared with the Windows
|
||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||
|
||||
use super::dualsense_proto::{
|
||||
ds_pairing_reply, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
|
||||
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
||||
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
||||
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
||||
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
||||
pub struct DsUhidIdentity {
|
||||
product: u32,
|
||||
rdesc: &'static [u8],
|
||||
/// Device name prefix ("Punktfunk <name> <index>").
|
||||
name: &'static str,
|
||||
/// Path token for the phys string ("punktfunk/<phys>/<index>").
|
||||
phys: &'static str,
|
||||
/// Short slug for the uniq string ("punktfunk-<slug>-<index>").
|
||||
slug: &'static str,
|
||||
}
|
||||
|
||||
impl DsUhidIdentity {
|
||||
pub const fn dualsense() -> DsUhidIdentity {
|
||||
DsUhidIdentity {
|
||||
product: DS_PRODUCT,
|
||||
rdesc: DUALSENSE_RDESC,
|
||||
name: "DualSense",
|
||||
phys: "dualsense",
|
||||
slug: "ds",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn dualsense_edge() -> DsUhidIdentity {
|
||||
DsUhidIdentity {
|
||||
product: DS_EDGE_PRODUCT,
|
||||
rdesc: DUALSENSE_EDGE_RDESC,
|
||||
name: "DualSense Edge",
|
||||
phys: "dualsense-edge",
|
||||
slug: "dsedge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual DualSense / DualSense Edge backed by `/dev/uhid` (hand-rolled codec — no bindgen,
|
||||
/// mirroring the uinput pad's style). Dropping it destroys the device (the kernel tears down the
|
||||
/// bound `hid-playstation` interface).
|
||||
pub struct DualSensePad {
|
||||
fd: File,
|
||||
seq: u8,
|
||||
ts: u32,
|
||||
}
|
||||
|
||||
impl DualSensePad {
|
||||
/// Create the UHID pad for wire index `index` under `id`'s identity (`index` is used only to
|
||||
/// make the device name/uniq unique).
|
||||
pub fn open(index: u8, id: &DsUhidIdentity) -> Result<DualSensePad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
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")?;
|
||||
Ok(ds)
|
||||
}
|
||||
|
||||
/// Send UHID_CREATE2 under `id`'s identity. The uniq written here is cosmetic:
|
||||
/// `hid-playstation` replaces it with the MAC from the pairing feature report (see
|
||||
/// [`ds_pairing_reply`]) as soon as it binds.
|
||||
fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {} {index}", id.name)); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(id.rdesc.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&id.product.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + id.rdesc.len()].copy_from_slice(id.rdesc); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
|
||||
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (calibration
|
||||
/// / pairing / firmware — required during `hid-playstation` init, or no input devices appear)
|
||||
/// and parse any HID OUTPUT reports (rumble / lightbar / player LEDs / adaptive triggers) into
|
||||
/// a [`DsFeedback`] for pad `pad`. Call frequently — especially right after [`open`] so the
|
||||
/// init handshake completes. The fd is `O_NONBLOCK`, so once drained `read` returns `WouldBlock`.
|
||||
pub fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
let mut fb = DsFeedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
parse_ds_output(pad, &ev[4..end], &mut fb);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// Per-pad MAC: hid-playstation adopts it as the HID uniq, and SDL/Steam
|
||||
// dedup controllers by that serial (see `ds_pairing_reply`).
|
||||
let pairing = ds_pairing_reply(pad);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x05 => DS_FEATURE_CALIBRATION,
|
||||
0x09 => &pairing,
|
||||
0x20 => DS_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
// Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s
|
||||
// timeout. Nothing to parse: every known DualSense writer sends its feedback
|
||||
// as OUTPUT reports (handled above), never SET_REPORT.
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report
|
||||
ev[8..10].copy_from_slice(&err.to_ne_bytes());
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualSensePad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
|
||||
/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Everything lifecycle-
|
||||
/// shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in [`UhidManager`].
|
||||
pub struct DsLinuxProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
||||
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DsLinuxProto {
|
||||
fn default() -> DsLinuxProto {
|
||||
DsLinuxProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for DsLinuxProto {
|
||||
type Pad = DualSensePad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense";
|
||||
const DEVICE: &'static str = "DualSense";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
||||
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (UHID hid-playstation)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||
/// come on the rich-input plane and must survive a button-only frame).
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
|
||||
/// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
|
||||
/// 0xCA plane, the rich lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
|
||||
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the rich-controller analog of
|
||||
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
|
||||
///
|
||||
/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate*
|
||||
/// rich-input plane (`apply_rich`) from the button/stick frames (`handle`); the shared
|
||||
/// [`UhidManager`] keeps each pad's full [`DsState`], re-emits the merged report whenever either
|
||||
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
|
||||
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
|
||||
pub type DualSenseManager = UhidManager<DsLinuxProto>;
|
||||
|
||||
/// The DualSense **Edge** half of the shared stateful manager: the plain-DualSense transport and
|
||||
/// report codec under the Edge USB identity (`054C:0DF2` + the Edge descriptor), with the four
|
||||
/// wire back-grip bits mapped onto the Edge's native `buttons[2]` slots instead of the
|
||||
/// fold/drop policy — the whole point of this backend (a client's Deck grips / Elite paddles
|
||||
/// stop vanishing). No remap config: every paddle has a native home.
|
||||
///
|
||||
/// Kernel note: `hid-playstation` binds the Edge PID since 6.1 (forced vibration-v2 output), but
|
||||
/// only kernels ≥ 7.2 surface the Fn/back bits as evdev keys (`BTN_TRIGGER_HAPPY1..4`); SDL /
|
||||
/// Steam Input read the report off hidraw and see them on any kernel.
|
||||
#[derive(Default)]
|
||||
pub struct DsEdgeLinuxProto;
|
||||
|
||||
impl PadProto for DsEdgeLinuxProto {
|
||||
type Pad = DualSensePad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense Edge";
|
||||
const DEVICE: &'static str = "DualSense Edge";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
||||
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense_edge())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense Edge created (UHID hid-playstation)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
|
||||
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Same kernel handshake + feedback parse as the plain DualSense — the Edge's GET_REPORT set
|
||||
/// (calibration 0x05 / pairing 0x09 / firmware 0x20) and output report 0x02 are identical
|
||||
/// (the Edge's rumble arrives via the vibration-v2 valid_flag2 bit, which
|
||||
/// [`parse_ds_output`] already handles).
|
||||
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
|
||||
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
|
||||
/// client declares for a paddle-bearing physical controller.
|
||||
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::quic::HidOutput;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// evdev nodes whose input-device name contains `name`: (full name, /dev/input/eventN).
|
||||
fn find_nodes(name: &str) -> Vec<(String, String)> {
|
||||
let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for line in s.lines() {
|
||||
if let Some(n) = line.strip_prefix("N: Name=") {
|
||||
cur = n.trim_matches('"').to_string();
|
||||
} else if let Some(h) = line.strip_prefix("H: Handlers=") {
|
||||
if cur.contains(name) {
|
||||
if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) {
|
||||
out.push((cur.clone(), format!("/dev/input/{ev}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the evdev at `node` advertises EV_FF (0x15) — the rumble-capable gamepad node
|
||||
/// (the touchpad / motion / headset siblings don't).
|
||||
fn has_ff(node: &str) -> bool {
|
||||
let Ok(f) = std::fs::OpenOptions::new().read(true).open(node) else {
|
||||
return false;
|
||||
};
|
||||
let mut bits = [0u8; 8];
|
||||
// EVIOCGBIT(0, 8): the device's event-type bitmap.
|
||||
let req: libc::c_ulong = (2 << 30) | (8 << 16) | (0x45 << 8) | 0x20;
|
||||
// SAFETY: EVIOCGBIT(0) copies at most 8 bytes (EV_MAX/8 < 8) into the live `bits` buffer
|
||||
// behind the valid evdev fd `f`; the kernel never writes past the ioctl's size argument.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, bits.as_mut_ptr()) };
|
||||
rc >= 0 && (bits[0x15 / 8] >> (0x15 % 8)) & 1 == 1
|
||||
}
|
||||
|
||||
/// Upload an FF_RUMBLE effect on `node` and play it, exactly like SDL's evdev haptic backend.
|
||||
/// Returns the OPEN fd with the id — closing the fd erases the process's effects (stopping
|
||||
/// the rumble), so the caller must hold it while asserting.
|
||||
fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(node)?;
|
||||
// struct ff_effect (48 B): type u16, id s16, direction u16, trigger, replay{len,delay},
|
||||
// pad to 16, union (ff_rumble_effect { strong, weak }).
|
||||
let mut eff = [0u8; 48];
|
||||
eff[0..2].copy_from_slice(&0x50u16.to_ne_bytes()); // FF_RUMBLE
|
||||
eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns
|
||||
eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms
|
||||
eff[16..18].copy_from_slice(&strong.to_ne_bytes());
|
||||
eff[18..20].copy_from_slice(&weak.to_ne_bytes());
|
||||
// EVIOCSFF = _IOW('E', 0x80, struct ff_effect)
|
||||
let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80;
|
||||
// SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is
|
||||
// exactly sizeof(struct ff_effect) and outlives the synchronous call.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) };
|
||||
if rc < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let id = i16::from_ne_bytes([eff[2], eff[3]]);
|
||||
// struct input_event (24 B on 64-bit): timeval 16, type u16, code u16, value s32.
|
||||
let mut ev = [0u8; 24];
|
||||
ev[16..18].copy_from_slice(&0x15u16.to_ne_bytes()); // EV_FF
|
||||
ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes());
|
||||
ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play
|
||||
f.write_all(&ev)?;
|
||||
Ok((f, id))
|
||||
}
|
||||
|
||||
/// `(HID_NAME, HID_UNIQ, /dev/hidrawN)` for every hidraw class device.
|
||||
fn hidraw_devices() -> Vec<(String, String, String)> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(dir) = std::fs::read_dir("/sys/class/hidraw") else {
|
||||
return out;
|
||||
};
|
||||
for e in dir.flatten() {
|
||||
let ue = std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default();
|
||||
let field = |k: &str| {
|
||||
ue.lines()
|
||||
.find_map(|l| l.strip_prefix(k))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
out.push((
|
||||
field("HID_NAME="),
|
||||
field("HID_UNIQ="),
|
||||
format!("/dev/{}", e.file_name().to_string_lossy()),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Service `pad` for `ms`, accumulating every captured feedback pass (all rumble levels in
|
||||
/// order + all rich events) while keeping the input heartbeat going.
|
||||
fn collect(pad: &mut DualSensePad, st: &DsState, ms: u64) -> (Vec<(u16, u16)>, Vec<HidOutput>) {
|
||||
let start = Instant::now();
|
||||
let (mut levels, mut hidout) = (Vec::new(), Vec::<HidOutput>::new());
|
||||
while start.elapsed() < Duration::from_millis(ms) {
|
||||
let fb = pad.service(0);
|
||||
levels.extend(fb.rumble);
|
||||
hidout.extend(fb.hidout);
|
||||
let _ = pad.write_state(st);
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
(levels, hidout)
|
||||
}
|
||||
|
||||
/// On-box proof of the full Linux feedback surface, playing the GAME's role against a real
|
||||
/// kernel: chain A drives rumble through evdev force feedback (`hid-playstation`'s ff-memless
|
||||
/// → UHID_OUTPUT — what SDL/Steam fall back to without hidraw); chain B writes a raw DS5
|
||||
/// output report to the pad's hidraw node (SDL/Steam's real path, and the ONLY way adaptive
|
||||
/// triggers can arrive) and expects rumble + lightbar + player LEDs + both trigger blocks
|
||||
/// back verbatim. Also pins the per-pad pairing MAC: two pads must present distinct uniqs or
|
||||
/// SDL/Steam dedup them into one controller.
|
||||
#[test]
|
||||
#[ignore = "creates real /dev/uhid devices; needs hid-playstation, the input group, and the 60-punktfunk.rules hidraw rules"]
|
||||
fn feedback_flows_via_evdev_ff_and_hidraw() {
|
||||
let mut pad0 = DualSensePad::open(0, &DsUhidIdentity::dualsense()).expect("open pad 0");
|
||||
let mut pad1 = DualSensePad::open(1, &DsUhidIdentity::dualsense()).expect("open pad 1");
|
||||
let st = DsState::neutral();
|
||||
// Let hid-playstation complete its GET_REPORT handshakes and register input devices.
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(1500) {
|
||||
let _ = pad0.service(0);
|
||||
let _ = pad1.service(1);
|
||||
let _ = pad0.write_state(&st);
|
||||
let _ = pad1.write_state(&st);
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
let nodes = find_nodes("Punktfunk DualSense 0");
|
||||
assert!(
|
||||
!nodes.is_empty(),
|
||||
"hid-playstation did not bind the uhid device"
|
||||
);
|
||||
let ff_node = nodes
|
||||
.iter()
|
||||
.map(|(_, n)| n.as_str())
|
||||
.find(|n| has_ff(n))
|
||||
.expect("no FF-capable evdev among the pad's input devices");
|
||||
|
||||
// Per-pad MAC: hid-playstation adopts the pairing-report MAC as HID_UNIQ; the two pads
|
||||
// must differ (the SDL/Steam serial-dedup regression, see `ds_pairing_reply`).
|
||||
let hidraws = hidraw_devices();
|
||||
let uniq = |name: &str| {
|
||||
hidraws
|
||||
.iter()
|
||||
.find(|(n, _, _)| n == name)
|
||||
.map(|(_, u, _)| u.clone())
|
||||
.unwrap_or_else(|| panic!("no hidraw for {name} in {hidraws:?}"))
|
||||
};
|
||||
assert_ne!(
|
||||
uniq("Punktfunk DualSense 0"),
|
||||
uniq("Punktfunk DualSense 1"),
|
||||
"pads share one pairing MAC — SDL/Steam will dedup them into one controller"
|
||||
);
|
||||
|
||||
// ---- Chain A: evdev force feedback ----
|
||||
let (ff_fd, _) = evdev_rumble(ff_node, 0xC000, 0x4000).expect("EVIOCSFF/play");
|
||||
let (levels, _) = collect(&mut pad0, &st, 1000);
|
||||
assert!(
|
||||
levels.iter().any(|&(l, h)| l > 0 || h > 0),
|
||||
"evdev FF rumble never surfaced as UHID_OUTPUT: {levels:?}"
|
||||
);
|
||||
drop(ff_fd); // closing erases the effect: the stop must surface too
|
||||
let (levels, _) = collect(&mut pad0, &st, 800);
|
||||
assert!(
|
||||
levels.contains(&(0, 0)),
|
||||
"erase-on-close never produced a rumble stop: {levels:?}"
|
||||
);
|
||||
|
||||
// ---- Chain B: raw DS5 output report over hidraw ----
|
||||
let hr = hidraws
|
||||
.iter()
|
||||
.find(|(n, _, _)| n == "Punktfunk DualSense 0")
|
||||
.map(|(_, _, d)| d.clone())
|
||||
.unwrap();
|
||||
let mut rep = [0u8; 48];
|
||||
rep[0] = 0x02; // USB output report id
|
||||
rep[1] = 0x03 | 0x04 | 0x08; // flag0: compat vibration + haptics select + R2 + L2
|
||||
rep[2] = 0x04 | 0x10; // flag1: lightbar + player LEDs
|
||||
rep[3] = 0x60; // motor right (high)
|
||||
rep[4] = 0xA0; // motor left (low)
|
||||
rep[11] = 0x21; // R2 trigger block: weapon mode + params
|
||||
rep[12] = 0x04;
|
||||
rep[13] = 0x07;
|
||||
rep[22] = 0x26; // L2 trigger block: vibration mode + params
|
||||
rep[23] = 0x02;
|
||||
rep[44] = 0x04; // player LED middle
|
||||
rep[45] = 0x10;
|
||||
rep[46] = 0x20;
|
||||
rep[47] = 0x30;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&hr)
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, &rep))
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"cannot write {hr} as this user ({e}) — Steam/SDL would be equally blocked; \
|
||||
are the 60-punktfunk.rules hidraw rules installed?"
|
||||
)
|
||||
});
|
||||
let (levels, hidout) = collect(&mut pad0, &st, 1000);
|
||||
assert!(
|
||||
levels.contains(&(0xA000, 0x6000)),
|
||||
"hidraw rumble did not surface: {levels:?}"
|
||||
);
|
||||
let triggers: Vec<_> = hidout
|
||||
.iter()
|
||||
.filter_map(|h| match h {
|
||||
HidOutput::Trigger { which, effect, .. } => Some((*which, effect.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
triggers.len(),
|
||||
2,
|
||||
"expected both trigger blocks: {hidout:?}"
|
||||
);
|
||||
assert!(
|
||||
triggers.contains(&(1, rep[11..22].to_vec())),
|
||||
"R2 block not verbatim"
|
||||
);
|
||||
assert!(
|
||||
triggers.contains(&(0, rep[22..33].to_vec())),
|
||||
"L2 block not verbatim"
|
||||
);
|
||||
assert!(
|
||||
hidout.iter().any(|h| matches!(
|
||||
h,
|
||||
HidOutput::Led {
|
||||
r: 0x10,
|
||||
g: 0x20,
|
||||
b: 0x30,
|
||||
..
|
||||
}
|
||||
)),
|
||||
"lightbar not surfaced: {hidout:?}"
|
||||
);
|
||||
assert!(
|
||||
hidout
|
||||
.iter()
|
||||
.any(|h| matches!(h, HidOutput::PlayerLeds { bits: 0x04, .. })),
|
||||
"player LEDs not surfaced: {hidout:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Virtual Sony DualShock 4 (PS4) via UHID — the PS4 sibling of the DualSense backend
|
||||
//! ([`super::dualsense`]). A UHID device presents a *real* DualShock 4 HID interface to the kernel:
|
||||
//! `hid-playstation` binds it (matched by VID `054C`/PID `09CC`, since Linux 6.2) and exposes the
|
||||
//! full controller — gamepad, motion sensors, touchpad, lightbar, rumble — to games. We write HID
|
||||
//! **input** reports (report `0x01`, our controller state) and read HID **output** reports (report
|
||||
//! `0x05`, a game's rumble/lightbar feedback) back, forwarding them to the client.
|
||||
//!
|
||||
//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute
|
||||
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
|
||||
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
|
||||
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; the
|
||||
//! report codec (input `0x01` serializer, output `0x05` parser, touch dims) is the pure
|
||||
//! [`super::dualshock4_proto`], shared with the Windows UMDF backend — this module is only the
|
||||
//! `/dev/uhid` transport plus the report descriptor + feature-report handshake the kernel needs.
|
||||
|
||||
use super::dualsense_proto::DsState;
|
||||
use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H,
|
||||
DS4_TOUCH_W, DS4_VENDOR,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for
|
||||
// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it).
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
|
||||
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
/// The pairing reply for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low octet offset
|
||||
/// by the pad index — same per-pad-serial contract as the DualSense's
|
||||
/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as
|
||||
/// the HID uniq, and SDL/Steam dedup controllers by that serial.
|
||||
fn ds4_pairing_reply(pad: u8) -> [u8; 16] {
|
||||
let mut r = [0u8; 16];
|
||||
r.copy_from_slice(DS4_FEATURE_PAIRING);
|
||||
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
|
||||
r
|
||||
}
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
|
||||
0x02,
|
||||
0x00, 0x00, // gyro_pitch_bias = 0
|
||||
0x00, 0x00, // gyro_yaw_bias = 0
|
||||
0x00, 0x00, // gyro_roll_bias = 0
|
||||
0x10, 0x00, // gyro_pitch_plus = +16
|
||||
0xF0, 0xFF, // gyro_pitch_minus = -16
|
||||
0x10, 0x00, // gyro_yaw_plus = +16
|
||||
0xF0, 0xFF, // gyro_yaw_minus = -16
|
||||
0x10, 0x00, // gyro_roll_plus = +16
|
||||
0xF0, 0xFF, // gyro_roll_minus = -16
|
||||
0x20, 0x00, // gyro_speed_plus = +32
|
||||
0x20, 0x00, // gyro_speed_minus = +32
|
||||
0x00, 0x20, // acc_x_plus = +8192
|
||||
0x00, 0xE0, // acc_x_minus = -8192
|
||||
0x00, 0x20, // acc_y_plus = +8192
|
||||
0x00, 0xE0, // acc_y_minus = -8192
|
||||
0x00, 0x20, // acc_z_plus = +8192
|
||||
0x00, 0xE0, // acc_z_minus = -8192
|
||||
0x00, 0x00, // trailing pad (descriptor declares 36 data bytes)
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic)
|
||||
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013"
|
||||
0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12"
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xA0, // hw_version = 0xA000 (buf[35])
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x01, // fw_version = 0x0100 (buf[41])
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total
|
||||
];
|
||||
|
||||
/// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture
|
||||
/// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature
|
||||
/// reports `0x02`/`0x12`/`0xa3` so the kernel's GET_REPORTs route. The kernel binds DS4 by VID/PID,
|
||||
/// but HID core still needs these reports declared.
|
||||
#[rustfmt::skip]
|
||||
const DS4_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
|
||||
0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
|
||||
0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
|
||||
0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
|
||||
0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
|
||||
0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
|
||||
0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
|
||||
0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09,
|
||||
0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1,
|
||||
0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06,
|
||||
0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22,
|
||||
0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95,
|
||||
0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06,
|
||||
0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81,
|
||||
0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05,
|
||||
0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84,
|
||||
0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06,
|
||||
0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87,
|
||||
0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90,
|
||||
0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03,
|
||||
0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93,
|
||||
0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1,
|
||||
0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4,
|
||||
0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2,
|
||||
0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9,
|
||||
0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC,
|
||||
0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B,
|
||||
0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF,
|
||||
0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3,
|
||||
0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0,
|
||||
0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
fd: File,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
}
|
||||
|
||||
impl DualShock4Pad {
|
||||
/// Create the UHID DualShock 4 for pad `index` (used only to make the device name/uniq unique).
|
||||
pub fn open(index: u8) -> Result<DualShock4Pad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut ds = DualShock4Pad {
|
||||
fd,
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
};
|
||||
ds.send_create2(index).context("UHID_CREATE2 DualShock4")?;
|
||||
Ok(ds)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
|
||||
|
||||
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
|
||||
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&(DS4_VENDOR as u32).to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&(DS4_PRODUCT as u32).to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
|
||||
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing /
|
||||
/// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no
|
||||
/// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`] for pad `pad`. Call frequently — especially right after [`open`] so the
|
||||
/// init handshake completes.
|
||||
pub fn service(&mut self, pad: u8) -> Ds4Feedback {
|
||||
let mut fb = Ds4Feedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
parse_ds4_output(&ev[4..end], &mut fb);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let pairing = ds4_pairing_reply(pad);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x12 => &pairing,
|
||||
0x02 => DS4_FEATURE_CALIBRATION,
|
||||
0xA3 => DS4_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
// Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s
|
||||
// timeout; DS4 feedback arrives as OUTPUT reports (handled above).
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report
|
||||
ev[8..10].copy_from_slice(&err.to_ne_bytes());
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualShock4Pad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The DualShock-4-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
|
||||
/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Lifecycle (slot table,
|
||||
/// unplug sweep, heartbeat, dedup) lives in [`UhidManager`]; the lightbar dedup that used to be a
|
||||
/// bespoke `last_led` vec (the kernel bundles the lightbar into every output report, incl.
|
||||
/// rumble-only writes) now rides the shared `HidoutDedup` — identical semantics, `Led` compared
|
||||
/// against the last-forwarded value and re-armed on create/unplug.
|
||||
pub struct Ds4LinuxProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
||||
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for Ds4LinuxProto {
|
||||
fn default() -> Ds4LinuxProto {
|
||||
Ds4LinuxProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for Ds4LinuxProto {
|
||||
type Pad = DualShock4Pad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualShock 4";
|
||||
const DEVICE: &'static str = "DualShock 4";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DualShock4Pad> {
|
||||
let p = DualShock4Pad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (UHID hid-playstation)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
||||
/// arrive on the rich-input plane and must survive a button-only frame).
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
||||
// policy (default drop) so they aren't silently lost.
|
||||
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DualShock4Pad, st: &DsState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
|
||||
/// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
|
||||
/// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
|
||||
/// triggers).
|
||||
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
||||
/// Like the DualSense, the shared [`UhidManager`] keeps each pad's full [`DsState`], re-emits the
|
||||
/// merged report whenever buttons/sticks or touchpad/motion change, and heartbeats it through
|
||||
/// input silence (a real DS4 streams report `0x01` continuously — `hid-playstation`/SDL treat a
|
||||
/// multi-second gap as an unplug).
|
||||
pub type DualShock4Manager = UhidManager<Ds4LinuxProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The report 0x01 serializer + output 0x05 parser are covered in `dualshock4_proto` (the codec
|
||||
// is shared with the Windows backend); only the UHID-transport-specific pieces are tested here.
|
||||
|
||||
/// Feature-report arrays carry the right report id + length the kernel expects.
|
||||
#[test]
|
||||
fn feature_report_shapes() {
|
||||
assert_eq!(DS4_FEATURE_PAIRING.len(), 16);
|
||||
assert_eq!(DS4_FEATURE_PAIRING[0], 0x12);
|
||||
assert_eq!(DS4_FEATURE_CALIBRATION.len(), 37);
|
||||
assert_eq!(DS4_FEATURE_CALIBRATION[0], 0x02);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3);
|
||||
}
|
||||
|
||||
/// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet —
|
||||
/// distinct serials so SDL/Steam never dedup two virtual pads into one controller.
|
||||
#[test]
|
||||
fn pairing_reply_mac_is_per_pad() {
|
||||
assert_eq!(ds4_pairing_reply(0).as_slice(), DS4_FEATURE_PAIRING);
|
||||
let (a, b) = (ds4_pairing_reply(1), ds4_pairing_reply(2));
|
||||
assert_eq!(a[0], 0x12); // report id untouched
|
||||
assert_eq!(a[1], DS4_FEATURE_PAIRING[1].wrapping_add(1));
|
||||
assert_eq!(b[1], DS4_FEATURE_PAIRING[1].wrapping_add(2));
|
||||
assert_eq!(a[2..], b[2..]); // everything but the low octet identical
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
//! Virtual gamepads via `/dev/uinput`, cloning the kernel `xpad` identity ("Microsoft X-Box
|
||||
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
|
||||
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
|
||||
//! controller, managed by [`GamepadManager`] from decoded
|
||||
//! [`GamepadFrame`](punktfunk_core::input::GamepadFrame)s.
|
||||
//!
|
||||
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects
|
||||
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
|
||||
//! `EV_FF` writes; [`GamepadManager::pump_rumble`] services that protocol non-blockingly
|
||||
//! (the control thread calls it every tick) and reports mixed `(low, high)` motor levels for
|
||||
//! the host to send to the client. Note: a game's `EVIOCSFF` ioctl BLOCKS until we answer
|
||||
//! `UI_END_FF_UPLOAD`, so the pump must run regularly.
|
||||
//!
|
||||
//! All ioctl numbers/struct layouts below were verified against this generation's
|
||||
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
||||
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::pad_slots::PadSlots;
|
||||
use anyhow::{bail, Result};
|
||||
use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS};
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
use std::time::Instant;
|
||||
|
||||
// ioctls (x86_64).
|
||||
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
|
||||
const UI_DEV_DESTROY: libc::c_ulong = 0x5502;
|
||||
const UI_DEV_SETUP: libc::c_ulong = 0x405c_5503;
|
||||
const UI_ABS_SETUP: libc::c_ulong = 0x401c_5504;
|
||||
const UI_SET_EVBIT: libc::c_ulong = 0x4004_5564;
|
||||
const UI_SET_KEYBIT: libc::c_ulong = 0x4004_5565;
|
||||
const UI_SET_FFBIT: libc::c_ulong = 0x4004_556b;
|
||||
const UI_BEGIN_FF_UPLOAD: libc::c_ulong = 0xc068_55c8;
|
||||
const UI_END_FF_UPLOAD: libc::c_ulong = 0x4068_55c9;
|
||||
const UI_BEGIN_FF_ERASE: libc::c_ulong = 0xc00c_55ca;
|
||||
const UI_END_FF_ERASE: libc::c_ulong = 0x400c_55cb;
|
||||
|
||||
// Event types/codes.
|
||||
const EV_SYN: u16 = 0x00;
|
||||
const EV_KEY: u16 = 0x01;
|
||||
const EV_ABS: u16 = 0x03;
|
||||
const EV_FF: u16 = 0x15;
|
||||
const EV_UINPUT: u16 = 0x0101;
|
||||
const SYN_REPORT: u16 = 0;
|
||||
const UI_FF_UPLOAD: u16 = 1;
|
||||
const UI_FF_ERASE: u16 = 2;
|
||||
const FF_RUMBLE: u16 = 0x50;
|
||||
const FF_GAIN: u16 = 0x60;
|
||||
|
||||
const ABS_X: u16 = 0x00;
|
||||
const ABS_Y: u16 = 0x01;
|
||||
const ABS_Z: u16 = 0x02;
|
||||
const ABS_RX: u16 = 0x03;
|
||||
const ABS_RY: u16 = 0x04;
|
||||
const ABS_RZ: u16 = 0x05;
|
||||
const ABS_HAT0X: u16 = 0x10;
|
||||
const ABS_HAT0Y: u16 = 0x11;
|
||||
|
||||
const BTN_SOUTH: u16 = 0x130; // A
|
||||
const BTN_EAST: u16 = 0x131; // B
|
||||
const BTN_NORTH: u16 = 0x133; // X (kernel calls it BTN_NORTH/BTN_X)
|
||||
const BTN_WEST: u16 = 0x134; // Y
|
||||
const BTN_TL: u16 = 0x136;
|
||||
const BTN_TR: u16 = 0x137;
|
||||
const BTN_SELECT: u16 = 0x13a;
|
||||
const BTN_START: u16 = 0x13b;
|
||||
const BTN_MODE: u16 = 0x13c;
|
||||
const BTN_THUMBL: u16 = 0x13d;
|
||||
const BTN_THUMBR: u16 = 0x13e;
|
||||
// Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips —
|
||||
// and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so
|
||||
// the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
||||
const BTN_TRIGGER_HAPPY5: u16 = 0x2c4;
|
||||
const BTN_TRIGGER_HAPPY6: u16 = 0x2c5;
|
||||
const BTN_TRIGGER_HAPPY7: u16 = 0x2c6;
|
||||
const BTN_TRIGGER_HAPPY8: u16 = 0x2c7;
|
||||
|
||||
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
|
||||
const BUTTON_MAP: [(u32, u16); 15] = [
|
||||
(gamepad::BTN_A, BTN_SOUTH),
|
||||
(gamepad::BTN_B, BTN_EAST),
|
||||
(gamepad::BTN_X, BTN_NORTH),
|
||||
(gamepad::BTN_Y, BTN_WEST),
|
||||
(gamepad::BTN_LB, BTN_TL),
|
||||
(gamepad::BTN_RB, BTN_TR),
|
||||
(gamepad::BTN_BACK, BTN_SELECT),
|
||||
(gamepad::BTN_START, BTN_START),
|
||||
(gamepad::BTN_GUIDE, BTN_MODE),
|
||||
(gamepad::BTN_LS_CLICK, BTN_THUMBL),
|
||||
(gamepad::BTN_RS_CLICK, BTN_THUMBR),
|
||||
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
|
||||
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
|
||||
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
|
||||
(gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8),
|
||||
];
|
||||
|
||||
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
|
||||
/// `bustype/vendor/product/version` (+ name), and games pick button glyphs from it. The button/axis
|
||||
/// layout this backend emits is the same XInput one regardless — only the identity differs between an
|
||||
/// X-Box 360 pad and an X-Box One/Series pad (which is why "Xbox One" buys glyphs, not new capability;
|
||||
/// impulse-trigger rumble is unreachable through evdev FF either way).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PadIdentity {
|
||||
vendor: u16,
|
||||
product: u16,
|
||||
version: u16,
|
||||
name: &'static [u8],
|
||||
/// Short label for the creation log line.
|
||||
log: &'static str,
|
||||
}
|
||||
|
||||
impl PadIdentity {
|
||||
/// "Microsoft X-Box 360 pad" (`045e:028e`) — the universal default; matches the kernel `xpad`
|
||||
/// table verbatim so SDL/Steam map it with zero config.
|
||||
pub const fn xbox360() -> PadIdentity {
|
||||
PadIdentity {
|
||||
vendor: 0x045e,
|
||||
product: 0x028e,
|
||||
version: 0x0110,
|
||||
name: b"Microsoft X-Box 360 pad",
|
||||
log: "X-Box 360 pad",
|
||||
}
|
||||
}
|
||||
|
||||
/// "Microsoft X-Box One S pad" (`045e:02ea`) — an `xpad`-table entry, so games show One/Series
|
||||
/// glyphs. XInput-identical to the 360 pad otherwise.
|
||||
pub const fn xbox_one() -> PadIdentity {
|
||||
PadIdentity {
|
||||
vendor: 0x045e,
|
||||
product: 0x02ea,
|
||||
version: 0x0408,
|
||||
name: b"Microsoft X-Box One S pad",
|
||||
log: "X-Box One S pad",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PadIdentity {
|
||||
fn default() -> PadIdentity {
|
||||
PadIdentity::xbox360()
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct InputId {
|
||||
bustype: u16,
|
||||
vendor: u16,
|
||||
product: u16,
|
||||
version: u16,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct UinputSetup {
|
||||
id: InputId,
|
||||
name: [u8; 80],
|
||||
ff_effects_max: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct AbsInfo {
|
||||
value: i32,
|
||||
minimum: i32,
|
||||
maximum: i32,
|
||||
fuzz: i32,
|
||||
flat: i32,
|
||||
resolution: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct UinputAbsSetup {
|
||||
code: u16,
|
||||
_pad: u16,
|
||||
absinfo: AbsInfo,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct InputEventRaw {
|
||||
time: libc::timeval,
|
||||
type_: u16,
|
||||
code: u16,
|
||||
value: i32,
|
||||
}
|
||||
|
||||
/// `struct ff_effect` (48 bytes; the union starts 8-aligned at offset 16).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct FfEffect {
|
||||
type_: u16,
|
||||
id: i16,
|
||||
direction: u16,
|
||||
trigger_button: u16,
|
||||
trigger_interval: u16,
|
||||
replay_length: u16,
|
||||
replay_delay: u16,
|
||||
_pad: u16,
|
||||
/// Union; for `FF_RUMBLE`: `u16 strong_magnitude` at [0..2], `u16 weak_magnitude` at [2..4].
|
||||
u: [u8; 32],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct UinputFfUpload {
|
||||
request_id: u32,
|
||||
retval: i32,
|
||||
effect: FfEffect,
|
||||
old: FfEffect,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct UinputFfErase {
|
||||
request_id: u32,
|
||||
retval: i32,
|
||||
effect_id: u32,
|
||||
}
|
||||
|
||||
// Layouts verified by compiling a probe against this generation's <linux/uinput.h> (x86_64).
|
||||
const _: () = {
|
||||
assert!(std::mem::size_of::<UinputSetup>() == 92);
|
||||
assert!(std::mem::size_of::<UinputAbsSetup>() == 28);
|
||||
assert!(std::mem::size_of::<InputEventRaw>() == 24);
|
||||
assert!(std::mem::size_of::<FfEffect>() == 48);
|
||||
assert!(std::mem::size_of::<UinputFfUpload>() == 104);
|
||||
assert!(std::mem::size_of::<UinputFfErase>() == 12);
|
||||
};
|
||||
|
||||
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
|
||||
// SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as
|
||||
// `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is
|
||||
// dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd`
|
||||
// being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd
|
||||
// would merely return -1/EBADF (reported below), never UB.
|
||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
|
||||
// SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live,
|
||||
// uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes
|
||||
// (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::<UinputSetup>(); UI_ABS_SETUP → 0x1c=28; the FF
|
||||
// upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies
|
||||
// exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased
|
||||
// for the whole synchronous call.
|
||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
replay_ms: u16,
|
||||
}
|
||||
|
||||
/// One virtual X-Box-360 pad backed by a uinput device.
|
||||
pub struct VirtualPad {
|
||||
fd: OwnedFd,
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
/// Last `(low, high)` reported, to dedup.
|
||||
last_mix: (u16, u16),
|
||||
}
|
||||
|
||||
impl VirtualPad {
|
||||
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
|
||||
use std::os::fd::FromRawFd;
|
||||
// SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a
|
||||
// valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1)
|
||||
// and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path.
|
||||
let raw = unsafe {
|
||||
libc::open(
|
||||
c"/dev/uinput".as_ptr(),
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if raw < 0 {
|
||||
bail!(
|
||||
"open /dev/uinput: {} (install the udev rule granting the 'input' group access \
|
||||
— see scripts/60-punktfunk.rules — and add the user to the 'input' group)",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
// SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd
|
||||
// from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes
|
||||
// this the unique owner, which will `close` it exactly once on drop (no double-close, no leak).
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_ABS as i32, "UI_SET_EVBIT(EV_ABS)")?;
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_FF as i32, "UI_SET_EVBIT(EV_FF)")?;
|
||||
for (_, key) in BUTTON_MAP {
|
||||
ioctl_int(raw, UI_SET_KEYBIT, key as i32, "UI_SET_KEYBIT")?;
|
||||
}
|
||||
ioctl_int(
|
||||
raw,
|
||||
UI_SET_FFBIT,
|
||||
FF_RUMBLE as i32,
|
||||
"UI_SET_FFBIT(FF_RUMBLE)",
|
||||
)?;
|
||||
ioctl_int(raw, UI_SET_FFBIT, FF_GAIN as i32, "UI_SET_FFBIT(FF_GAIN)")?;
|
||||
|
||||
let stick = AbsInfo {
|
||||
minimum: -32768,
|
||||
maximum: 32767,
|
||||
fuzz: 16,
|
||||
flat: 128,
|
||||
..Default::default()
|
||||
};
|
||||
let trigger = AbsInfo {
|
||||
minimum: 0,
|
||||
maximum: 255,
|
||||
..Default::default()
|
||||
};
|
||||
let hat = AbsInfo {
|
||||
minimum: -1,
|
||||
maximum: 1,
|
||||
..Default::default()
|
||||
};
|
||||
for (code, info) in [
|
||||
(ABS_X, stick),
|
||||
(ABS_Y, stick),
|
||||
(ABS_RX, stick),
|
||||
(ABS_RY, stick),
|
||||
(ABS_Z, trigger),
|
||||
(ABS_RZ, trigger),
|
||||
(ABS_HAT0X, hat),
|
||||
(ABS_HAT0Y, hat),
|
||||
] {
|
||||
let mut a = UinputAbsSetup {
|
||||
code,
|
||||
_pad: 0,
|
||||
absinfo: info,
|
||||
};
|
||||
ioctl_ptr(raw, UI_ABS_SETUP, &mut a, "UI_ABS_SETUP")?;
|
||||
}
|
||||
|
||||
// The xpad identity: SDL keys its built-in mapping off bustype/vendor/product/version.
|
||||
let mut setup = UinputSetup {
|
||||
id: InputId {
|
||||
bustype: 0x0003, // BUS_USB
|
||||
vendor: identity.vendor,
|
||||
product: identity.product,
|
||||
version: identity.version,
|
||||
},
|
||||
name: [0; 80],
|
||||
ff_effects_max: 16, // must be > 0 or FF uploads are never delivered
|
||||
};
|
||||
let name = identity.name;
|
||||
setup.name[..name.len()].copy_from_slice(name);
|
||||
ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?;
|
||||
ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?;
|
||||
tracing::info!(
|
||||
index,
|
||||
pad = identity.log,
|
||||
"virtual gamepad created (uinput)"
|
||||
);
|
||||
|
||||
Ok(VirtualPad {
|
||||
fd,
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
last_mix: (0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&self, type_: u16, code: u16, value: i32) {
|
||||
let ev = InputEventRaw {
|
||||
time: libc::timeval {
|
||||
tv_sec: 0,
|
||||
tv_usec: 0,
|
||||
},
|
||||
type_,
|
||||
code,
|
||||
value,
|
||||
};
|
||||
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
|
||||
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
|
||||
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
|
||||
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
|
||||
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
&ev as *const _ as *const u8,
|
||||
std::mem::size_of::<InputEventRaw>(),
|
||||
)
|
||||
};
|
||||
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
||||
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
|
||||
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
|
||||
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
|
||||
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
|
||||
let _ = unsafe {
|
||||
libc::write(
|
||||
self.fd.as_raw_fd(),
|
||||
bytes.as_ptr() as *const libc::c_void,
|
||||
bytes.len(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT.
|
||||
pub fn apply(&mut self, f: &GamepadFrame) {
|
||||
// Re-assert every mapped button's absolute state each frame — exactly like the axes below —
|
||||
// instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops
|
||||
// the write), so an edge-only scheme would strand a dropped press/release until that button
|
||||
// next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is
|
||||
// free downstream: the kernel input core discards an EV_KEY whose value already matches the
|
||||
// device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat).
|
||||
for (bit, key) in BUTTON_MAP {
|
||||
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
|
||||
}
|
||||
|
||||
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
|
||||
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
|
||||
self.emit(EV_ABS, ABS_Y, -(f.ls_y as i32));
|
||||
self.emit(EV_ABS, ABS_RX, f.rs_x as i32);
|
||||
self.emit(EV_ABS, ABS_RY, -(f.rs_y as i32));
|
||||
self.emit(EV_ABS, ABS_Z, f.left_trigger as i32);
|
||||
self.emit(EV_ABS, ABS_RZ, f.right_trigger as i32);
|
||||
let hat_x = ((f.buttons & gamepad::BTN_DPAD_RIGHT != 0) as i32)
|
||||
- ((f.buttons & gamepad::BTN_DPAD_LEFT != 0) as i32);
|
||||
let hat_y = ((f.buttons & gamepad::BTN_DPAD_DOWN != 0) as i32)
|
||||
- ((f.buttons & gamepad::BTN_DPAD_UP != 0) as i32);
|
||||
self.emit(EV_ABS, ABS_HAT0X, hat_x);
|
||||
self.emit(EV_ABS, ABS_HAT0Y, hat_y);
|
||||
self.emit(EV_SYN, SYN_REPORT, 0);
|
||||
}
|
||||
|
||||
/// Service the FF protocol on this pad's fd (non-blocking). Returns the new mixed
|
||||
/// `(low, high)` motor levels if they changed since last call.
|
||||
fn pump_ff(&mut self) -> Option<(u16, u16)> {
|
||||
let raw = self.fd.as_raw_fd();
|
||||
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
|
||||
loop {
|
||||
// SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a
|
||||
// live local `[u8; size_of::<InputEventRaw>()]`; `buf.as_mut_ptr()` is a valid writable pointer
|
||||
// to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer
|
||||
// outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race).
|
||||
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
||||
if n != buf.len() as isize {
|
||||
break; // EAGAIN / short read — queue drained
|
||||
}
|
||||
// SAFETY: `buf` is exactly `size_of::<InputEventRaw>()` bytes and fully written by the
|
||||
// `read` above. `read_unaligned` (not `read`) because the `[u8]` buffer is 1-aligned but
|
||||
// `InputEventRaw` needs 8 (it holds a `timeval`) — a plain `ptr::read` would be UB.
|
||||
let ev: InputEventRaw =
|
||||
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
||||
match (ev.type_, ev.code) {
|
||||
(EV_UINPUT, UI_FF_UPLOAD) => {
|
||||
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
|
||||
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
|
||||
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
|
||||
// value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD.
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
let mut e = up.effect;
|
||||
if e.id == -1 {
|
||||
e.id = self.next_effect_id;
|
||||
self.next_effect_id = self.next_effect_id.wrapping_add(1);
|
||||
}
|
||||
if e.type_ == FF_RUMBLE {
|
||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||
let slot = self.effects.entry(e.id).or_insert(Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
let _ = ioctl_ptr(raw, UI_END_FF_UPLOAD, &mut up, "UI_END_FF_UPLOAD");
|
||||
}
|
||||
}
|
||||
(EV_UINPUT, UI_FF_ERASE) => {
|
||||
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
|
||||
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
|
||||
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
|
||||
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
||||
er.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
||||
self.effects.remove(&(er.effect_id as i16));
|
||||
er.retval = 0;
|
||||
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
|
||||
}
|
||||
}
|
||||
(EV_FF, FF_GAIN) => self.gain = (ev.value as u32).min(0xFFFF),
|
||||
(EV_FF, code) => {
|
||||
if let Some(e) = self.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Mix: sum playing effects (expiring finished ones), scale by gain.
|
||||
let now = Instant::now();
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
if let Some(deadline) = e.playing {
|
||||
if deadline.is_some_and(|d| now >= d) {
|
||||
e.playing = None;
|
||||
} else {
|
||||
strong = strong.saturating_add(e.strong as u32);
|
||||
weak = weak.saturating_add(e.weak as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
|
||||
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
|
||||
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
|
||||
(self.last_mix != (low, high)).then(|| {
|
||||
self.last_mix = (low, high);
|
||||
(low, high)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VirtualPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only
|
||||
// AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument
|
||||
// (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device.
|
||||
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual pads of a session, driven from decoded controller events. Stateless per frame
|
||||
/// (uinput/evdev holds last-known state kernel-side), so it rides [`PadSlots`] directly — no state
|
||||
/// vec, heartbeat, or rich plane like the UHID managers.
|
||||
pub struct GamepadManager {
|
||||
slots: PadSlots<VirtualPad>,
|
||||
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
|
||||
/// the client asked for `XboxOne`). All pads in a session share one identity.
|
||||
identity: PadIdentity,
|
||||
}
|
||||
|
||||
impl Default for GamepadManager {
|
||||
fn default() -> GamepadManager {
|
||||
GamepadManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
/// A manager that creates X-Box 360 pads (the universal default).
|
||||
pub fn new() -> GamepadManager {
|
||||
GamepadManager::with_identity(PadIdentity::xbox360())
|
||||
}
|
||||
|
||||
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
|
||||
pub fn with_identity(identity: PadIdentity) -> GamepadManager {
|
||||
GamepadManager {
|
||||
slots: PadSlots::new(identity.log, "gamepad", ""),
|
||||
identity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then apply state).
|
||||
pub fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
|
||||
use punktfunk_core::input::GamepadEvent;
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling
|
||||
// state to reset — the pads mix rumble internally).
|
||||
self.slots.sweep(f.active_mask);
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
pad.apply(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
let identity = self.identity;
|
||||
// `VirtualPad::create` logs its own success line (it knows the identity + transport).
|
||||
self.slots
|
||||
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
|
||||
}
|
||||
|
||||
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
||||
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The FF-capable evdev node whose input-device name contains `name`.
|
||||
fn find_ff_node(name: &str) -> Option<String> {
|
||||
let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
let mut cur = String::new();
|
||||
let mut node = None;
|
||||
for line in s.lines() {
|
||||
if let Some(n) = line.strip_prefix("N: Name=") {
|
||||
cur = n.trim_matches('"').to_string();
|
||||
} else if let Some(h) = line.strip_prefix("H: Handlers=") {
|
||||
if cur.contains(name) {
|
||||
node = h
|
||||
.split_whitespace()
|
||||
.find(|t| t.starts_with("event"))
|
||||
.map(|ev| format!("/dev/input/{ev}"));
|
||||
}
|
||||
} else if line.starts_with("B: FF=")
|
||||
&& cur.contains(name)
|
||||
&& node.is_some()
|
||||
&& !line.trim_end().ends_with("FF=0")
|
||||
{
|
||||
return node;
|
||||
}
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Upload + play an FF_RUMBLE like SDL's evdev haptic backend. Returns the OPEN fd (closing
|
||||
/// it erases the process's effects, stopping the rumble) with the kernel-assigned id.
|
||||
/// NOTE: EVIOCSFF BLOCKS until the uinput owner answers UI_FF_UPLOAD — the caller must be a
|
||||
/// separate thread from the one running [`VirtualPad::pump_ff`], exactly like a real game vs
|
||||
/// the host input loop.
|
||||
fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> {
|
||||
use std::io::Write as _;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(node)?;
|
||||
let mut eff = [0u8; 48]; // struct ff_effect; union (rumble magnitudes) at offset 16
|
||||
eff[0..2].copy_from_slice(&FF_RUMBLE.to_ne_bytes());
|
||||
eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns
|
||||
eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms
|
||||
eff[16..18].copy_from_slice(&strong.to_ne_bytes());
|
||||
eff[18..20].copy_from_slice(&weak.to_ne_bytes());
|
||||
// EVIOCSFF = _IOW('E', 0x80, struct ff_effect)
|
||||
let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80;
|
||||
// SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is
|
||||
// exactly sizeof(struct ff_effect) and outlives the synchronous call.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) };
|
||||
if rc < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
let id = i16::from_ne_bytes([eff[2], eff[3]]);
|
||||
let mut ev = [0u8; 24]; // struct input_event: timeval 16, type u16, code u16, value s32
|
||||
ev[16..18].copy_from_slice(&EV_FF.to_ne_bytes());
|
||||
ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes());
|
||||
ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play
|
||||
f.write_all(&ev)?;
|
||||
Ok((f, id))
|
||||
}
|
||||
|
||||
/// On-box proof of the uinput FF back-channel, playing the GAME's role: an evdev FF_RUMBLE
|
||||
/// upload+play against the virtual X-Box 360 pad must surface through `pump_ff` (the
|
||||
/// EV_UINPUT UI_FF_UPLOAD protocol) — the path every `auto`-kind session's rumble rides on
|
||||
/// Linux — and erasing the effect (fd close) must surface the stop.
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uinput device; needs the input group"]
|
||||
fn ff_upload_reaches_pump_and_stops_on_erase() {
|
||||
let mut pad = VirtualPad::create(0, PadIdentity::xbox360()).expect("create uinput pad");
|
||||
std::thread::sleep(Duration::from_millis(700)); // let udev settle the node
|
||||
let node = find_ff_node("Microsoft X-Box 360 pad").expect("no X-Box 360 evdev node");
|
||||
let game = std::thread::spawn(move || {
|
||||
let r = evdev_rumble(&node, 0xC000, 0x4000);
|
||||
std::thread::sleep(Duration::from_millis(1200)); // hold the effect, then erase
|
||||
r.expect("EVIOCSFF/play (fd held meanwhile)");
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut seen = Vec::new();
|
||||
while start.elapsed() < Duration::from_millis(2500) {
|
||||
if let Some(mix) = pad.pump_ff() {
|
||||
seen.push(mix);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
game.join().unwrap();
|
||||
// Requested magnitudes scaled by the 0xFFFF default gain (>> 16).
|
||||
assert!(
|
||||
seen.contains(&(0xBFFF, 0x3FFF)),
|
||||
"evdev FF rumble never surfaced through pump_ff: {seen:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
seen.last(),
|
||||
Some(&(0, 0)),
|
||||
"erase-on-close never produced a stop mix: {seen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
//! Headless input injection on KWin via the privileged `org_kde_kwin_fake_input` protocol — the
|
||||
//! exact path KDE's own headless RDP server (`krdpserver`) uses. KWin advertises this restricted
|
||||
//! global only to a client authorized through its installed `.desktop` `X-KDE-Wayland-Interfaces`
|
||||
//! (we ship `io.unom.Punktfunk.Host.desktop`, which lists `org_kde_kwin_fake_input` alongside
|
||||
//! `zkde_screencast_unstable_v1`). Binding the global IS the authorization, so injection needs **no
|
||||
//! RemoteDesktop portal and no "Allow remote control?" dialog** — it works with no user present,
|
||||
//! which the libei/portal path cannot. We connect as an ordinary Wayland client on the KWin session's
|
||||
//! `$WAYLAND_DISPLAY` and translate events into fake-input requests; keyboard keys are raw Linux
|
||||
//! evdev codes that KWin resolves through the session's own keymap (no keymap upload, unlike the wlr
|
||||
//! virtual-keyboard path), and absolute pointer/touch coordinates are global compositor space.
|
||||
//!
|
||||
//! Global compositor space is *logical* pixels (post display-scaling), which only equals the streamed
|
||||
//! output's physical pixels at scale 1. Under a fractional/integer scale the logical edge sits at
|
||||
//! `physical / scale`, so feeding the raw streamed pixel coordinate lands the cursor `scale×` too far
|
||||
//! toward the bottom-right (top-left stays put). We therefore track each output's logical geometry
|
||||
//! (position + size) via `xdg-output` and map the normalized client position into the matching
|
||||
//! output's logical rectangle — the same shape the libei backend uses with its EI region.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::time::{Duration, Instant};
|
||||
use wayland_client::protocol::wl_output::{self, WlOutput};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum};
|
||||
use wayland_protocols::xdg::xdg_output::zv1::client::{
|
||||
zxdg_output_manager_v1::ZxdgOutputManagerV1,
|
||||
zxdg_output_v1::{self, ZxdgOutputV1},
|
||||
};
|
||||
|
||||
// Generate the client bindings for the vendored protocol XML inline (no build.rs), exactly like the
|
||||
// KWin virtual-output backend. Path is relative to CARGO_MANIFEST_DIR.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod fake {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/fake-input.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/fake-input.xml");
|
||||
}
|
||||
|
||||
use fake::org_kde_kwin_fake_input::OrgKdeKwinFakeInput as FakeInput;
|
||||
|
||||
/// Highest interface version we drive. `keyboard_key` arrived at v4; KWin advertises ≥4.
|
||||
const MAX_VERSION: u32 = 4;
|
||||
|
||||
/// `wl_pointer.axis` values used by `axis`.
|
||||
const AXIS_VERTICAL: u32 = 0;
|
||||
const AXIS_HORIZONTAL: u32 = 1;
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input` / the wlr backend).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// One tracked output: its physical mode (to match the streamed resolution) and its logical geometry
|
||||
/// (the global-compositor-space rectangle absolute coordinates are addressed in). `logical_w == 0`
|
||||
/// means xdg-output hasn't reported its size yet.
|
||||
struct OutputTrack {
|
||||
/// Registry global id — also the dispatch user-data, so events route back to this entry.
|
||||
name: u32,
|
||||
wl_output: WlOutput,
|
||||
xdg_output: Option<ZxdgOutputV1>,
|
||||
/// Physical pixel mode from `wl_output.mode` (the `current` mode); matched against the streamed WxH.
|
||||
mode_w: i32,
|
||||
mode_h: i32,
|
||||
/// Logical (post-scale) geometry from `xdg-output`.
|
||||
logical_x: i32,
|
||||
logical_y: i32,
|
||||
logical_w: i32,
|
||||
logical_h: i32,
|
||||
}
|
||||
|
||||
/// Registry-bound globals (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
fake: Option<FakeInput>,
|
||||
xdg_mgr: Option<ZxdgOutputManagerV1>,
|
||||
outputs: Vec<OutputTrack>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Create the `xdg_output` for a tracked output once both it and the manager exist.
|
||||
fn ensure_xdg_output(o: &mut OutputTrack, mgr: &ZxdgOutputManagerV1, qh: &QueueHandle<State>) {
|
||||
if o.xdg_output.is_none() {
|
||||
o.xdg_output = Some(mgr.get_xdg_output(&o.wl_output, qh, o.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} => match interface.as_str() {
|
||||
"org_kde_kwin_fake_input" => {
|
||||
state.fake = Some(registry.bind(name, version.min(MAX_VERSION), qh, ()));
|
||||
}
|
||||
"wl_output" => {
|
||||
// v1 carries `mode` (all we need); bind no higher than the proxy's max (4).
|
||||
let wl_output: WlOutput = registry.bind(name, version.min(4), qh, name);
|
||||
let mut o = OutputTrack {
|
||||
name,
|
||||
wl_output,
|
||||
xdg_output: None,
|
||||
mode_w: 0,
|
||||
mode_h: 0,
|
||||
logical_x: 0,
|
||||
logical_y: 0,
|
||||
logical_w: 0,
|
||||
logical_h: 0,
|
||||
};
|
||||
if let Some(mgr) = state.xdg_mgr.clone() {
|
||||
State::ensure_xdg_output(&mut o, &mgr, qh);
|
||||
}
|
||||
state.outputs.push(o);
|
||||
}
|
||||
"zxdg_output_manager_v1" => {
|
||||
let mgr: ZxdgOutputManagerV1 = registry.bind(name, version.min(3), qh, ());
|
||||
// Outputs bound before the manager have no xdg_output yet — create them now.
|
||||
for o in state.outputs.iter_mut() {
|
||||
State::ensure_xdg_output(o, &mgr, qh);
|
||||
}
|
||||
state.xdg_mgr = Some(mgr);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
wl_registry::Event::GlobalRemove { name } => {
|
||||
state.outputs.retain(|o| {
|
||||
if o.name == name {
|
||||
if let Some(x) = &o.xdg_output {
|
||||
x.destroy();
|
||||
}
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fake_input emits no events.
|
||||
impl Dispatch<FakeInput, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &FakeInput,
|
||||
_: <FakeInput as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<WlOutput, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WlOutput,
|
||||
event: wl_output::Event,
|
||||
name: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Only the *current* mode matters — a real monitor also advertises its other supported modes.
|
||||
if let wl_output::Event::Mode {
|
||||
flags: WEnum::Value(flags),
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if flags.contains(wl_output::Mode::Current) {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||||
o.mode_w = width;
|
||||
o.mode_h = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgOutputV1, u32> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZxdgOutputV1,
|
||||
event: zxdg_output_v1::Event,
|
||||
name: &u32,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let Some(o) = state.outputs.iter_mut().find(|o| o.name == *name) {
|
||||
match event {
|
||||
zxdg_output_v1::Event::LogicalPosition { x, y } => {
|
||||
o.logical_x = x;
|
||||
o.logical_y = y;
|
||||
}
|
||||
zxdg_output_v1::Event::LogicalSize { width, height } => {
|
||||
o.logical_w = width;
|
||||
o.logical_h = height;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events.
|
||||
impl Dispatch<ZxdgOutputManagerV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ZxdgOutputManagerV1,
|
||||
_: <ZxdgOutputManagerV1 as Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KwinFakeInjector {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
fake: FakeInput,
|
||||
/// When output geometry was last re-read; throttles the per-event roundtrip (see `refresh_geometry`).
|
||||
last_refresh: Option<Instant>,
|
||||
}
|
||||
|
||||
/// How often the fake_input backend re-reads output geometry from the compositor. Output add/remove
|
||||
/// (a new session's virtual output) and live scale/resolution changes are infrequent, so a lazy
|
||||
/// poll on the injector's own thread is plenty and adds at most one local-socket roundtrip twice a
|
||||
/// second — versus a blocking roundtrip on every single mouse-move event.
|
||||
const GEO_REFRESH: Duration = Duration::from_millis(500);
|
||||
|
||||
impl KwinFakeInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut state = State::default();
|
||||
queue
|
||||
.roundtrip(&mut state)
|
||||
.context("Wayland registry roundtrip")?;
|
||||
|
||||
let fake = state.fake.clone().context(
|
||||
"KWin does not expose org_kde_kwin_fake_input to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it (the grant is cached per-exe on first connect), or this is not a \
|
||||
KWin session",
|
||||
)?;
|
||||
// Authenticate (the legacy handshake; for an interface-authorized client KWin accepts it
|
||||
// without a dialog — same as krdpserver/krfb headless).
|
||||
fake.authenticate("punktfunk".into(), "remote streaming input".into());
|
||||
queue
|
||||
.roundtrip(&mut state)
|
||||
.context("fake_input authenticate roundtrip")?;
|
||||
conn.flush().ok();
|
||||
|
||||
// Settle output geometry (wl_output + xdg-output were bound during the registry roundtrip
|
||||
// above; their logical_size arrives on a follow-up roundtrip). Best-effort — falls back to
|
||||
// scale-1 mapping if xdg-output is absent.
|
||||
let mut injector = Self {
|
||||
conn,
|
||||
queue,
|
||||
state,
|
||||
fake,
|
||||
last_refresh: None,
|
||||
};
|
||||
injector.refresh_geometry();
|
||||
tracing::info!(
|
||||
outputs = injector.state.outputs.len(),
|
||||
"KWin fake_input ready (headless keyboard/mouse/touch — no portal)"
|
||||
);
|
||||
Ok(injector)
|
||||
}
|
||||
|
||||
/// Re-read output geometry, throttled to [`GEO_REFRESH`]. A `roundtrip` both flushes any pending
|
||||
/// `get_xdg_output` requests and reads the geometry events back. A wl_output that *appeared* this
|
||||
/// round only gets its xdg_output created mid-dispatch, so its `logical_size` lands on a later
|
||||
/// roundtrip — keep going (bounded) until every output is settled.
|
||||
fn refresh_geometry(&mut self) {
|
||||
let now = Instant::now();
|
||||
if let Some(t) = self.last_refresh {
|
||||
if now.duration_since(t) < GEO_REFRESH {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.last_refresh = Some(now);
|
||||
for _ in 0..3 {
|
||||
if self.queue.roundtrip(&mut self.state).is_err() {
|
||||
return;
|
||||
}
|
||||
let pending =
|
||||
self.state.xdg_mgr.is_some() && self.state.outputs.iter().any(|o| o.logical_w == 0);
|
||||
if !pending {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the logical (global-compositor-space) rectangle to map a normalized client position
|
||||
/// into. Prefer the output whose physical mode matches the streamed `phys_w`×`phys_h` (the
|
||||
/// per-session virtual output); fall back to the sole output, then — if xdg-output is unavailable
|
||||
/// — to the streamed pixels at the origin (the pre-scaling behavior, correct at scale 1).
|
||||
fn logical_target(&self, phys_w: i32, phys_h: i32) -> (f64, f64, f64, f64) {
|
||||
let usable = || {
|
||||
self.state
|
||||
.outputs
|
||||
.iter()
|
||||
.filter(|o| o.logical_w > 0 && o.logical_h > 0)
|
||||
};
|
||||
let chosen = usable()
|
||||
.find(|o| o.mode_w == phys_w && o.mode_h == phys_h)
|
||||
.or_else(|| {
|
||||
let mut it = usable();
|
||||
match (it.next(), it.next()) {
|
||||
(Some(only), None) => Some(only),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
match chosen {
|
||||
Some(o) => (
|
||||
o.logical_x as f64,
|
||||
o.logical_y as f64,
|
||||
o.logical_w as f64,
|
||||
o.logical_h as f64,
|
||||
),
|
||||
None => (0.0, 0.0, phys_w as f64, phys_h as f64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for KwinFakeInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
match event.kind {
|
||||
InputKind::MouseMove => {
|
||||
self.fake.pointer_motion(event.x as f64, event.y as f64);
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||
let h = (event.flags & 0xffff) as i32;
|
||||
if w > 0 && h > 0 {
|
||||
self.refresh_geometry();
|
||||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||
// Normalize in the streamed (physical) pixel space, then place inside the output's
|
||||
// logical rectangle — so display scaling no longer offsets the cursor.
|
||||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||||
self.fake
|
||||
.pointer_motion_absolute(lx + nx * lw, ly + ny * lh);
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||||
let st = u32::from(event.kind == InputKind::MouseButtonDown);
|
||||
self.fake.button(btn, st);
|
||||
}
|
||||
}
|
||||
InputKind::MouseScroll => {
|
||||
// GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Vertical flips
|
||||
// sign on the Wayland axis, horizontal passes through — same as the wlr backend.
|
||||
let horizontal = event.code == SCROLL_HORIZONTAL;
|
||||
let axis = if horizontal {
|
||||
AXIS_HORIZONTAL
|
||||
} else {
|
||||
AXIS_VERTICAL
|
||||
};
|
||||
let notches = event.x as f64 / 120.0;
|
||||
let sign = if horizontal { 1.0 } else { -1.0 };
|
||||
self.fake.axis(axis, sign * notches * 15.0);
|
||||
}
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
// Raw evdev keycode; KWin resolves it through the session's own keymap (and tracks
|
||||
// modifier state itself, so no separate modifiers request is needed).
|
||||
if let Some(evdev) = vk_to_evdev(event.code as u8) {
|
||||
let st = u32::from(event.kind == InputKind::KeyDown);
|
||||
self.fake.keyboard_key(evdev as u32, st);
|
||||
} else {
|
||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
// Touch: id = event.code, coords in the client surface w×h packed into flags (same
|
||||
// absolute mapping as MouseMoveAbs). Each event is its own frame.
|
||||
InputKind::TouchDown | InputKind::TouchMove => {
|
||||
let w = ((event.flags >> 16) & 0xffff) as i32;
|
||||
let h = (event.flags & 0xffff) as i32;
|
||||
if w > 0 && h > 0 {
|
||||
self.refresh_geometry();
|
||||
let (lx, ly, lw, lh) = self.logical_target(w, h);
|
||||
let nx = (event.x as f64 / w as f64).clamp(0.0, 1.0);
|
||||
let ny = (event.y as f64 / h as f64).clamp(0.0, 1.0);
|
||||
let x = lx + nx * lw;
|
||||
let y = ly + ny * lh;
|
||||
if event.kind == InputKind::TouchDown {
|
||||
self.fake.touch_down(event.code, x, y);
|
||||
} else {
|
||||
self.fake.touch_motion(event.code, x, y);
|
||||
}
|
||||
self.fake.touch_frame();
|
||||
}
|
||||
}
|
||||
InputKind::TouchUp => {
|
||||
self.fake.touch_up(event.code);
|
||||
self.fake.touch_frame();
|
||||
}
|
||||
// Gamepads are injected through uinput, not the compositor.
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.state)
|
||||
.context("wayland dispatch")?;
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,740 @@
|
||||
//! libei input injection — the portable EI-sender path.
|
||||
//!
|
||||
//! Two ways to reach an EIS server ([`EiSource`]):
|
||||
//! * **Portal** — `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin, GNOME/Mutter),
|
||||
//! which hands us the EIS socket fd after the session grant.
|
||||
//! * **Socket** — connect directly to a compositor's own EIS socket. gamescope runs an EIS
|
||||
//! server and exports its path to its children as `LIBEI_SOCKET`; our gamescope backend
|
||||
//! relays that path through a file so the injector can connect (no portal involved).
|
||||
//!
|
||||
//! Either way, `reis` drives the connection as an EI *sender*: bind the seat's
|
||||
//! pointer/keyboard/scroll/button capabilities and, per device, `start_emulating` → emit →
|
||||
//! `frame`. The session and the EIS connection must stay alive and the event stream must be
|
||||
//! polled continuously (resume/pause/ping/modifier traffic), so the whole thing runs on a
|
||||
//! dedicated thread with its own tokio runtime; the synchronous control thread reaches it
|
||||
//! through an unbounded channel and [`LibeiInjector::inject`] merely enqueues.
|
||||
//!
|
||||
//! Keyboard codes are Linux evdev (the same space our VK→evdev table produces) and the
|
||||
//! compositor supplies the keymap, so — unlike the wlr path — there is no keymap to upload and
|
||||
//! no modifier mask to serialize: pressing the modifier *keys* (which Moonlight sends as normal
|
||||
//! key events) is enough.
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
|
||||
use anyhow::{anyhow, Result};
|
||||
use ashpd::desktop::{
|
||||
remote_desktop::{
|
||||
ConnectToEISOptions, DeviceType, RemoteDesktop, SelectDevicesOptions, StartOptions,
|
||||
},
|
||||
CreateSessionOptions, PersistMode,
|
||||
};
|
||||
use ashpd::zbus;
|
||||
use futures_util::StreamExt;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use reis::ei;
|
||||
use reis::event::{DeviceCapability, EiEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// Where to find the EIS server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum EiSource {
|
||||
/// `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin — a pre-seeded grant avoids the
|
||||
/// approval dialog).
|
||||
Portal,
|
||||
/// Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` EIS (GNOME). Unlike the xdg portal, this
|
||||
/// needs no interactive "Allow remote control?" approval — which a headless host can't answer,
|
||||
/// so the portal's `Start()` would just time out. Mirrors how the Mutter *video* backend uses
|
||||
/// the same direct API.
|
||||
MutterEis,
|
||||
/// A file containing the EIS socket path/name (gamescope's relayed `LIBEI_SOCKET`); polled
|
||||
/// until it appears, since the compositor may still be starting.
|
||||
SocketPathFile(std::path::PathBuf),
|
||||
}
|
||||
|
||||
/// Handle held by the control thread; forwards events to the libei worker thread.
|
||||
pub struct LibeiInjector {
|
||||
tx: UnboundedSender<InputEvent>,
|
||||
}
|
||||
|
||||
impl LibeiInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
Self::open_with(EiSource::Portal)
|
||||
}
|
||||
|
||||
pub fn open_with(source: EiSource) -> Result<Self> {
|
||||
let (tx, rx) = unbounded_channel::<InputEvent>();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-libei".into())
|
||||
.spawn(move || worker(rx, source))
|
||||
.map_err(|e| anyhow!("spawn libei worker thread: {e}"))?;
|
||||
// Return immediately — the portal/socket handshake must NOT run on the caller's
|
||||
// (control) thread, or a slow/denied setup would freeze the ENet control stream and
|
||||
// drop the client. The worker establishes the session asynchronously and logs its
|
||||
// status; events enqueue until devices resume (a few startup events may be dropped).
|
||||
Ok(Self { tx })
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for LibeiInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
self.tx
|
||||
.send(*event)
|
||||
.map_err(|_| anyhow!("libei worker thread has exited"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker thread entry: build a tokio runtime and run the session to completion.
|
||||
fn worker(rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "libei: build tokio runtime failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(session_main(rx, source));
|
||||
}
|
||||
|
||||
/// Open the portal/socket + EIS (bounded), then pump events until disconnect or shutdown.
|
||||
async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
|
||||
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
|
||||
// hang the worker forever.
|
||||
let (_keepalive, context, mut events) = match tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
connect(source),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "libei: portal/EIS setup failed");
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"libei: EIS setup timed out (headless approval needed / kde-authorized grant not seeded / gamescope socket never appeared)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!("libei: EIS connected — awaiting devices");
|
||||
|
||||
let mut state = EiState::new();
|
||||
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
||||
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
||||
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
||||
// inject() fails and InjectorService reopens against a fresh socket, instead of silently
|
||||
// swallowing every event for the whole session.
|
||||
let resume_deadline = tokio::time::sleep(Duration::from_secs(5));
|
||||
tokio::pin!(resume_deadline);
|
||||
let mut resumed_once = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
ei = events.next() => match ei {
|
||||
Some(Ok(ev)) => {
|
||||
state.handle_ei(ev, &context);
|
||||
if !resumed_once && state.devices.iter().any(|d| d.resumed) {
|
||||
resumed_once = true;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => { tracing::warn!(error = %e, "libei: event stream error"); break; }
|
||||
None => { tracing::info!("libei: EIS disconnected"); break; }
|
||||
},
|
||||
msg = rx.recv() => match msg {
|
||||
Some(input) => state.inject(&input, &context),
|
||||
None => { tracing::info!("libei: injector closed — ending session"); break; }
|
||||
},
|
||||
_ = &mut resume_deadline, if !resumed_once => {
|
||||
tracing::warn!(
|
||||
"libei: no input device resumed within 5s of connecting — treating the EIS \
|
||||
connection as dead and reopening (stale or half-ready compositor socket)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A client that vanished mid-press must not leave keys/buttons latched in the
|
||||
// compositor — Mutter keeps the implicit grab of a destroyed device's button and the
|
||||
// focused app stops taking clicks until it is restarted. Release everything still
|
||||
// held before the EIS connection (and its devices) go away.
|
||||
state.release_all(&context);
|
||||
}
|
||||
|
||||
/// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the
|
||||
/// whole session — dropping the portal/Mutter session closes the EIS connection; for the
|
||||
/// direct-socket path it's `Box::new(())`.
|
||||
type Connected = (
|
||||
Box<dyn Send>,
|
||||
ei::Context,
|
||||
reis::tokio::EiConvertEventStream,
|
||||
);
|
||||
|
||||
/// Reach an EIS server per `source` and run the EI sender handshake.
|
||||
async fn connect(source: EiSource) -> Result<Connected> {
|
||||
let (keepalive, stream): (Box<dyn Send>, UnixStream) = match source {
|
||||
EiSource::Portal => {
|
||||
let (rd, session, fd) = connect_portal().await?;
|
||||
(Box::new((rd, session)), UnixStream::from(fd))
|
||||
}
|
||||
EiSource::MutterEis => {
|
||||
let (keepalive, fd) = connect_mutter().await?;
|
||||
(keepalive, UnixStream::from(fd))
|
||||
}
|
||||
EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?),
|
||||
};
|
||||
let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?;
|
||||
// Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path
|
||||
// exists, but a stale/half-ready gamescope (its socket created early in startup, or left behind
|
||||
// by a SIGKILLed prior session) may never drive the EI handshake — which would otherwise hang
|
||||
// this worker forever. A bounded handshake lets the worker error out so InjectorService reopens.
|
||||
let (_conn, events) = tokio::time::timeout(
|
||||
Duration::from_secs(8),
|
||||
context.handshake_tokio("punktfunk-host", ei::handshake::ContextType::Sender),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)")
|
||||
})?
|
||||
.map_err(|e| anyhow!("EI handshake: {e}"))?;
|
||||
Ok((keepalive, context, events))
|
||||
}
|
||||
|
||||
/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd.
|
||||
async fn connect_portal() -> Result<(
|
||||
RemoteDesktop,
|
||||
ashpd::desktop::Session<RemoteDesktop>,
|
||||
std::os::fd::OwnedFd,
|
||||
)> {
|
||||
let rd = RemoteDesktop::new()
|
||||
.await
|
||||
.map_err(|e| anyhow!("open RemoteDesktop portal (is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?): {e}"))?;
|
||||
let session = rd
|
||||
.create_session(CreateSessionOptions::default())
|
||||
.await
|
||||
.map_err(|e| anyhow!("create RemoteDesktop session: {e}"))?;
|
||||
rd.select_devices(
|
||||
&session,
|
||||
SelectDevicesOptions::default()
|
||||
.set_devices(DeviceType::Keyboard | DeviceType::Pointer | DeviceType::Touchscreen)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("select_devices: {e}"))?
|
||||
.response()
|
||||
.map_err(|e| anyhow!("select_devices response: {e}"))?;
|
||||
let started = rd
|
||||
.start(&session, None, StartOptions::default())
|
||||
.await
|
||||
.map_err(|e| anyhow!("start RemoteDesktop session: {e}"))?;
|
||||
let granted = started
|
||||
.response()
|
||||
.map_err(|e| anyhow!("RemoteDesktop start denied: {e}"))?;
|
||||
tracing::info!(devices = ?granted.devices(), "libei: portal granted devices");
|
||||
|
||||
let fd = rd
|
||||
.connect_to_eis(&session, ConnectToEISOptions::default())
|
||||
.await
|
||||
.map_err(|e| anyhow!("connect_to_eis (RemoteDesktop portal version < 2?): {e}"))?;
|
||||
Ok((rd, session, fd))
|
||||
}
|
||||
|
||||
/// GNOME path: get the EIS socket fd from Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` API
|
||||
/// (`CreateSession` → `Start` → `ConnectToEIS`). No xdg portal is involved, so there is no
|
||||
/// interactive "Allow remote control?" approval to satisfy — exactly why [`connect_portal`] times
|
||||
/// out on a headless GNOME host. (Same direct API the Mutter *video* backend uses.) The returned
|
||||
/// keep-alive owns the D-Bus connection + session; dropping it tears the Mutter session down and
|
||||
/// closes the EIS connection (Mutter sessions die with their D-Bus connection).
|
||||
async fn connect_mutter() -> Result<(Box<dyn Send>, std::os::fd::OwnedFd)> {
|
||||
use zbus::zvariant::{OwnedObjectPath, Value};
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.map_err(|e| anyhow!("connect session D-Bus (Mutter RemoteDesktop): {e}"))?;
|
||||
let rd = zbus::Proxy::new(
|
||||
&conn,
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
"/org/gnome/Mutter/RemoteDesktop",
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop proxy (is gnome-shell running?): {e}"))?;
|
||||
let session_path: OwnedObjectPath = rd
|
||||
.call("CreateSession", &())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.CreateSession: {e}"))?;
|
||||
let session = zbus::Proxy::new(
|
||||
&conn,
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
session_path,
|
||||
"org.gnome.Mutter.RemoteDesktop.Session",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.Session proxy: {e}"))?;
|
||||
session
|
||||
.call_method("Start", &())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.Session.Start: {e}"))?;
|
||||
let options: HashMap<&str, Value> = HashMap::new();
|
||||
let fd: zbus::zvariant::OwnedFd = session
|
||||
.call("ConnectToEIS", &(options,))
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.Session.ConnectToEIS: {e}"))?;
|
||||
tracing::info!("libei: connected to Mutter's direct RemoteDesktop EIS (no portal approval)");
|
||||
Ok((Box::new((conn, session)), std::os::fd::OwnedFd::from(fd)))
|
||||
}
|
||||
|
||||
/// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once
|
||||
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
|
||||
/// mirroring libei's own `LIBEI_SOCKET` semantics.
|
||||
async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
||||
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
||||
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
||||
// sessions). So poll: RE-READ the file and RETRY the connect, treating "refused"/"missing" as
|
||||
// not-ready-yet (the exact "Connection refused" we saw when a stale socket lingered). Bounded so
|
||||
// a genuinely wedged setup still surfaces an error.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(15);
|
||||
let mut logged = String::new();
|
||||
loop {
|
||||
// Defense-in-depth: never follow a symlinked relay file. It lives under `$XDG_RUNTIME_DIR`
|
||||
// (per-user 0700) so a cross-user plant is already blocked, but refuse a symlink outright
|
||||
// rather than read through one to an attacker-chosen target (a rogue EIS server would
|
||||
// keylog/deny the session's input; security-review 2026-06-28 #6).
|
||||
if std::fs::symlink_metadata(file)
|
||||
.map(|m| m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"EIS relay file {} is a symlink — refusing to follow it",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
if let Ok(s) = std::fs::read_to_string(file) {
|
||||
let name = s.trim();
|
||||
if !name.is_empty() {
|
||||
let full = if name.starts_with('/') {
|
||||
std::path::PathBuf::from(name)
|
||||
} else {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").map_err(|_| {
|
||||
anyhow!("XDG_RUNTIME_DIR unset (needed to resolve EIS socket '{name}')")
|
||||
})?;
|
||||
std::path::Path::new(&runtime).join(name)
|
||||
};
|
||||
if logged != name {
|
||||
tracing::info!(socket = %full.display(), "libei: connecting to EIS socket");
|
||||
logged = name.to_string();
|
||||
}
|
||||
match UnixStream::connect(&full) {
|
||||
Ok(stream) => return Ok(stream),
|
||||
// Refused = socket file exists but no listener yet (or a dead session);
|
||||
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
||||
// up — retry. Anything else (e.g. permission) is a real failure.
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
) => {}
|
||||
Err(e) => return Err(anyhow!("connect EIS socket {}: {e}", full.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"EIS socket from {} never became connectable (gamescope not up, or its EIS crashed)",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One EI device and its emulation state.
|
||||
struct DeviceSlot {
|
||||
device: reis::event::Device,
|
||||
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
|
||||
resumed: bool,
|
||||
/// We have issued `start_emulating` since the last resume.
|
||||
emulating: bool,
|
||||
}
|
||||
|
||||
/// Tracks bound devices + the serial/sequence/timebase the EI protocol requires.
|
||||
struct EiState {
|
||||
devices: Vec<DeviceSlot>,
|
||||
last_serial: u32,
|
||||
sequence: u32,
|
||||
start: Instant,
|
||||
/// Total inject() calls — used only to throttle diagnostic logging.
|
||||
injected: u64,
|
||||
/// Bitmask of [`InputKind`]s already logged once (diagnostics: surface the FIRST of each
|
||||
/// kind a client sends + whether it emitted, so an unexpected client — e.g. a touch-only
|
||||
/// tablet hitting a compositor without ei_touchscreen — is immediately diagnosable).
|
||||
seen_kinds: u32,
|
||||
/// Wire codes currently held down (keys = VK, buttons = GameStream ids, touches = ids)
|
||||
/// — synthesized back up at session end ([`EiState::release_all`]). A client that
|
||||
/// vanishes mid-press must not leave the compositor with a latched key or an implicit
|
||||
/// pointer grab: observed on Mutter, a button held by a destroyed EIS device wedges
|
||||
/// click delivery to the focused app until that app is restarted.
|
||||
held_keys: Vec<u32>,
|
||||
held_buttons: Vec<u32>,
|
||||
held_touches: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
||||
fn kind_bit(kind: InputKind) -> u32 {
|
||||
let i = match kind {
|
||||
InputKind::MouseMove => 0,
|
||||
InputKind::MouseMoveAbs => 1,
|
||||
InputKind::MouseButtonDown => 2,
|
||||
InputKind::MouseButtonUp => 3,
|
||||
InputKind::MouseScroll => 4,
|
||||
InputKind::KeyDown => 5,
|
||||
InputKind::KeyUp => 6,
|
||||
InputKind::TouchDown => 7,
|
||||
InputKind::TouchMove => 8,
|
||||
InputKind::TouchUp => 9,
|
||||
InputKind::GamepadButton => 10,
|
||||
InputKind::GamepadAxis => 11,
|
||||
InputKind::GamepadState => 12,
|
||||
InputKind::GamepadRemove => 13,
|
||||
InputKind::GamepadArrival => 14,
|
||||
};
|
||||
1 << i
|
||||
}
|
||||
|
||||
impl EiState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
devices: Vec::new(),
|
||||
last_serial: 0,
|
||||
sequence: 0,
|
||||
start: Instant::now(),
|
||||
injected: 0,
|
||||
seen_kinds: 0,
|
||||
held_keys: Vec::new(),
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Release everything the remote client still holds — called when the session ends
|
||||
/// (client gone, EIS closing). Synthesizes wire-level release events through the
|
||||
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
|
||||
/// touch-up frames before the devices disappear.
|
||||
fn release_all(&mut self, ctx: &ei::Context) {
|
||||
let (keys, buttons, touches) = (
|
||||
std::mem::take(&mut self.held_keys),
|
||||
std::mem::take(&mut self.held_buttons),
|
||||
std::mem::take(&mut self.held_touches),
|
||||
);
|
||||
if keys.is_empty() && buttons.is_empty() && touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
keys = keys.len(),
|
||||
buttons = buttons.len(),
|
||||
touches = touches.len(),
|
||||
"libei: releasing input still held at session end"
|
||||
);
|
||||
let release = |kind: InputKind, code: u32| InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
for code in buttons {
|
||||
self.inject(&release(InputKind::MouseButtonUp, code), ctx);
|
||||
}
|
||||
for code in keys {
|
||||
self.inject(&release(InputKind::KeyUp, code), ctx);
|
||||
}
|
||||
for id in touches {
|
||||
self.inject(&release(InputKind::TouchUp, id), ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn now_us(&self) -> u64 {
|
||||
self.start.elapsed().as_micros() as u64
|
||||
}
|
||||
|
||||
/// Apply a server event: bind capabilities, track devices, and follow resume/pause.
|
||||
fn handle_ei(&mut self, ev: EiEvent, ctx: &ei::Context) {
|
||||
match ev {
|
||||
EiEvent::SeatAdded(e) => {
|
||||
e.seat.bind_capabilities(
|
||||
DeviceCapability::Pointer
|
||||
| DeviceCapability::PointerAbsolute
|
||||
| DeviceCapability::Keyboard
|
||||
| DeviceCapability::Scroll
|
||||
| DeviceCapability::Button
|
||||
| DeviceCapability::Touch,
|
||||
);
|
||||
let _ = ctx.flush();
|
||||
}
|
||||
EiEvent::DeviceAdded(e) => {
|
||||
tracing::info!(device = ?e.device.name(), ty = ?e.device.device_type(), "libei: device added");
|
||||
self.devices.push(DeviceSlot {
|
||||
device: e.device,
|
||||
resumed: false,
|
||||
emulating: false,
|
||||
});
|
||||
}
|
||||
EiEvent::DeviceRemoved(e) => {
|
||||
self.devices.retain(|d| d.device != e.device);
|
||||
}
|
||||
EiEvent::DeviceResumed(e) => {
|
||||
self.last_serial = e.serial;
|
||||
if let Some(d) = self.devices.iter_mut().find(|d| d.device == e.device) {
|
||||
d.resumed = true;
|
||||
d.emulating = false; // must re-issue start_emulating after a resume
|
||||
}
|
||||
let dev = &e.device;
|
||||
tracing::info!(
|
||||
name = ?dev.name(),
|
||||
pointer = dev.has_capability(DeviceCapability::Pointer),
|
||||
pointer_abs = dev.has_capability(DeviceCapability::PointerAbsolute),
|
||||
keyboard = dev.has_capability(DeviceCapability::Keyboard),
|
||||
button = dev.has_capability(DeviceCapability::Button),
|
||||
scroll = dev.has_capability(DeviceCapability::Scroll),
|
||||
"libei: device RESUMED (now emittable)"
|
||||
);
|
||||
}
|
||||
EiEvent::DevicePaused(e) => {
|
||||
if let Some(d) = self.devices.iter_mut().find(|d| d.device == e.device) {
|
||||
d.resumed = false;
|
||||
d.emulating = false;
|
||||
}
|
||||
}
|
||||
// Informational: the server reports resulting modifier/group state; we don't set it.
|
||||
EiEvent::KeyboardModifiers(e) => self.last_serial = e.serial,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of a resumed device exposing `cap`.
|
||||
fn device_for(&self, cap: DeviceCapability) -> Option<usize> {
|
||||
self.devices
|
||||
.iter()
|
||||
.position(|d| d.resumed && d.device.has_capability(cap))
|
||||
}
|
||||
|
||||
/// Ensure the device at `idx` is in `start_emulating` state before we emit on it.
|
||||
fn ensure_emulating(&mut self, idx: usize, dev: &ei::Device) {
|
||||
if !self.devices[idx].emulating {
|
||||
dev.start_emulating(self.last_serial, self.sequence);
|
||||
self.sequence = self.sequence.wrapping_add(1);
|
||||
self.devices[idx].emulating = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate and emit one client input event, committing it as a single `frame`.
|
||||
fn inject(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
||||
let cap = match ev.kind {
|
||||
InputKind::MouseMove => DeviceCapability::Pointer,
|
||||
InputKind::MouseMoveAbs => DeviceCapability::PointerAbsolute,
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => DeviceCapability::Button,
|
||||
InputKind::MouseScroll => DeviceCapability::Scroll,
|
||||
InputKind::KeyDown | InputKind::KeyUp => DeviceCapability::Keyboard,
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||
DeviceCapability::Touch
|
||||
}
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => return, // uinput path (later)
|
||||
};
|
||||
self.injected += 1;
|
||||
let n = self.injected;
|
||||
// Log the first of each kind always (diagnostics), then occasionally.
|
||||
let bit = kind_bit(ev.kind);
|
||||
let first = self.seen_kinds & bit == 0;
|
||||
self.seen_kinds |= bit;
|
||||
let loud = first || n <= 5 || n % 600 == 0;
|
||||
let Some(idx) = self.device_for(cap) else {
|
||||
if loud {
|
||||
tracing::warn!(
|
||||
n,
|
||||
kind = ?ev.kind,
|
||||
?cap,
|
||||
devices = self.devices.len(),
|
||||
resumed = self.devices.iter().filter(|d| d.resumed).count(),
|
||||
"libei: dropped event — no resumed device exposes this capability"
|
||||
);
|
||||
}
|
||||
// No resumed device with this capability yet. For touch this is usually permanent on
|
||||
// this compositor — the RemoteDesktop portal may grant the Touchscreen *device type*
|
||||
// while the EIS server never creates a touchscreen *device* (observed on headless
|
||||
// KWin). Surface it once so touch silently going nowhere is diagnosable.
|
||||
if matches!(
|
||||
ev.kind,
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp
|
||||
) {
|
||||
static WARNED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"touch received but the compositor's EIS exposed no touchscreen device — \
|
||||
touch is dropped (KWin's libei may not implement ei_touchscreen yet; \
|
||||
gamescope / a newer compositor may)"
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
let dev = self.devices[idx].device.device().clone();
|
||||
self.ensure_emulating(idx, &dev);
|
||||
|
||||
let mut emitted = true;
|
||||
let slot = &self.devices[idx].device;
|
||||
match ev.kind {
|
||||
InputKind::MouseMove => match slot.interface::<ei::Pointer>() {
|
||||
Some(p) => p.motion_relative(ev.x as f32, ev.y as f32),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||
let h = (ev.flags & 0xffff) as f32;
|
||||
match (
|
||||
slot.interface::<ei::PointerAbsolute>(),
|
||||
slot.regions().first(),
|
||||
) {
|
||||
(Some(p), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||
// Map the normalized client position into the device's first region.
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let x = region.x as f32 + nx * region.width as f32;
|
||||
let y = region.y as f32 + ny * region.height as f32;
|
||||
p.motion_absolute(x, y);
|
||||
}
|
||||
_ => emitted = false,
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
match (slot.interface::<ei::Button>(), gs_button_to_evdev(ev.code)) {
|
||||
(Some(b), Some(btn)) => {
|
||||
let st = if ev.kind == InputKind::MouseButtonDown {
|
||||
ei::button::ButtonState::Press
|
||||
} else {
|
||||
ei::button::ButtonState::Released
|
||||
};
|
||||
b.button(btn, st);
|
||||
}
|
||||
_ => emitted = false,
|
||||
}
|
||||
}
|
||||
InputKind::MouseScroll => match slot.interface::<ei::Scroll>() {
|
||||
Some(s) => {
|
||||
// Wire deltas are WHEEL_DELTA(120)-scaled in `x`. Emit BOTH ei scroll axes
|
||||
// from it: `scroll_discrete` (120-per-detent — drives line/page scrolling)
|
||||
// AND the continuous `scroll` axis in logical px (≈15 px/detent). Without
|
||||
// the continuous axis Mutter floors a sub-detent delta (trackpad / precise
|
||||
// wheel / fractional smooth scroll) to zero whole clicks, so small scrolls
|
||||
// never register and you have to spin the wheel a lot — emitting the pixel
|
||||
// axis too makes every delta move proportionally (matches the wlr backend's
|
||||
// 15 px/notch). Positive wire = up (vertical, negated on the ei axis) /
|
||||
// RIGHT (horizontal, already positive — moonlight-qt/Sunshine pass it
|
||||
// through unnegated); only the vertical axis flips.
|
||||
const PX_PER_DETENT: f32 = 15.0;
|
||||
let px = ev.x as f32 / 120.0 * PX_PER_DETENT;
|
||||
if ev.code == SCROLL_HORIZONTAL {
|
||||
s.scroll_discrete(ev.x, 0);
|
||||
s.scroll(px, 0.0);
|
||||
} else {
|
||||
s.scroll_discrete(0, -ev.x);
|
||||
s.scroll(0.0, -px);
|
||||
}
|
||||
}
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
match (slot.interface::<ei::Keyboard>(), vk_to_evdev(ev.code as u8)) {
|
||||
(Some(k), Some(evdev)) => {
|
||||
let st = if ev.kind == InputKind::KeyDown {
|
||||
ei::keyboard::KeyState::Press
|
||||
} else {
|
||||
ei::keyboard::KeyState::Released
|
||||
};
|
||||
k.key(evdev as u32, st);
|
||||
}
|
||||
_ => {
|
||||
emitted = false;
|
||||
tracing::debug!(vk = ev.code, "libei: unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Touch: `code` is the touch id, `x`/`y` are client pixels and `flags` packs the
|
||||
// client surface w/h — mapped into the device's region exactly like MouseMoveAbs.
|
||||
// One InputEvent = one frame, which satisfies the ei_touchscreen rule that a down /
|
||||
// motion / up must not share a frame.
|
||||
InputKind::TouchDown | InputKind::TouchMove => {
|
||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||
let h = (ev.flags & 0xffff) as f32;
|
||||
match (slot.interface::<ei::Touchscreen>(), slot.regions().first()) {
|
||||
(Some(t), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let x = region.x as f32 + nx * region.width as f32;
|
||||
let y = region.y as f32 + ny * region.height as f32;
|
||||
if ev.kind == InputKind::TouchDown {
|
||||
t.down(ev.code, x, y);
|
||||
} else {
|
||||
t.motion(ev.code, x, y);
|
||||
}
|
||||
}
|
||||
_ => emitted = false,
|
||||
}
|
||||
}
|
||||
InputKind::TouchUp => match slot.interface::<ei::Touchscreen>() {
|
||||
Some(t) => t.up(ev.code),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => emitted = false,
|
||||
}
|
||||
|
||||
if emitted {
|
||||
// Track held state on the wire codes so `release_all` can undo it at
|
||||
// session end (vanished clients must not leave anything latched).
|
||||
match ev.kind {
|
||||
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
|
||||
self.held_keys.push(ev.code);
|
||||
}
|
||||
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
|
||||
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
|
||||
self.held_buttons.push(ev.code);
|
||||
}
|
||||
InputKind::MouseButtonUp => self.held_buttons.retain(|&c| c != ev.code),
|
||||
InputKind::TouchDown if !self.held_touches.contains(&ev.code) => {
|
||||
self.held_touches.push(ev.code);
|
||||
}
|
||||
InputKind::TouchUp => self.held_touches.retain(|&c| c != ev.code),
|
||||
_ => {}
|
||||
}
|
||||
dev.frame(self.last_serial, self.now_us());
|
||||
}
|
||||
if let Err(e) = ctx.flush() {
|
||||
// In the per-input-event hot path: a dead EIS socket fails flush on every event
|
||||
// (mouse-move = 100s/s), so gate the warn behind the same `loud` sampler as its siblings.
|
||||
if loud {
|
||||
tracing::warn!(error = %e, "libei: ctx.flush failed");
|
||||
}
|
||||
}
|
||||
if loud {
|
||||
tracing::debug!(n, kind = ?ev.kind, idx, emitted, "libei: emitted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Virtual Steam Deck controller via UHID — the Steam analogue of the virtual DualSense
|
||||
//! ([`super::dualsense`]). A UHID device with Valve VID `28DE` / Deck PID `1205` is bound by the
|
||||
//! kernel `hid-steam` driver, which exposes a full Steam Deck gamepad evdev (incl. the four back
|
||||
//! grips) **plus** a separate IMU evdev, and — when Steam runs on the host — is re-grabbed by Steam
|
||||
//! Input with native glyphs + trackpad/gyro/back-button bindings.
|
||||
//!
|
||||
//! The transport-independent contract (descriptor, byte-exact serializer, the `XInput`/rich
|
||||
//! mappers, the rumble parser) lives in [`super::steam_proto`]; this module is the `/dev/uhid`
|
||||
//! plumbing + the two Steam-specific lifecycle quirks the DualSense path lacks:
|
||||
//!
|
||||
//! 1. **`gamepad_mode` entry.** `steam_do_deck_input_event` early-returns under the default
|
||||
//! `lizard_mode` until `gamepad_mode` is toggled on — which the kernel only does when the `b9.6`
|
||||
//! Steam/menu-right button is held ~450 ms with no hidraw client open. So on the first pad we
|
||||
//! best-effort clear `lizard_mode` via sysfs (needs root; bypasses the gate entirely) AND every
|
||||
//! pad pulses `b9.6` for [`MODE_ENTER`] at creation. After that an **anti-toggle guard** caps any
|
||||
//! continuous `b9.6` (a long in-game Start-hold) below the kernel's 450 ms threshold so play can
|
||||
//! never accidentally flip `gamepad_mode` back off.
|
||||
//! 2. **`UHID_SET_REPORT`.** Steam feedback (`0xEB` rumble) + the kernel's settings/serial writes
|
||||
//! arrive as FEATURE set-reports that MUST be answered `err = 0`, or the kernel stalls ~5 s per
|
||||
//! command (the DualSense backend only services GET_REPORT + OUTPUT).
|
||||
|
||||
use super::steam_proto::{
|
||||
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
|
||||
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI — same layout as the DualSense backend.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs
|
||||
/// ~450 ms continuous; give margin).
|
||||
const MODE_ENTER: Duration = Duration::from_millis(650);
|
||||
/// Cap continuous `b9.6` (Start) below the kernel's 450 ms mode-switch threshold: after this long
|
||||
/// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off.
|
||||
const MENU_HOLD_CAP: Duration = Duration::from_millis(350);
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event`
|
||||
/// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the
|
||||
/// per-pad `b9.6` pulse + guard handle it instead.
|
||||
fn try_clear_lizard_mode() {
|
||||
static TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if TRIED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match std::fs::write("/sys/module/hid_steam/parameters/lizard_mode", "N") {
|
||||
Ok(()) => {
|
||||
tracing::info!("cleared hid_steam lizard_mode (Steam Deck gamepad events always flow)")
|
||||
}
|
||||
Err(e) => tracing::debug!(
|
||||
error = %e,
|
||||
"could not clear hid_steam lizard_mode (no root?) — using the gamepad_mode pulse + guard"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck **or classic Steam Controller** backed by `/dev/uhid` (same driver, two
|
||||
/// identities/report layouts — see [`SteamModel`]). Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-steam` interface + its evdevs).
|
||||
pub struct SteamDeckPad {
|
||||
fd: File,
|
||||
model: SteamModel,
|
||||
seq: u32,
|
||||
created: Instant,
|
||||
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
|
||||
menu_hold_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SteamDeckPad {
|
||||
pub fn open(index: u8) -> Result<SteamDeckPad> {
|
||||
SteamDeckPad::open_model(index, SteamModel::Deck)
|
||||
}
|
||||
|
||||
/// Open under a specific Steam identity. The classic Controller's `ID_CONTROLLER_STATE` path
|
||||
/// has NO `gamepad_mode` gate in the kernel (only the Deck's parser early-returns under
|
||||
/// lizard mode), so the SC skips the whole mode-entry machinery.
|
||||
pub fn open_model(index: u8, model: SteamModel) -> Result<SteamDeckPad> {
|
||||
if model == SteamModel::Deck {
|
||||
try_clear_lizard_mode();
|
||||
}
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut pad = SteamDeckPad {
|
||||
fd,
|
||||
model,
|
||||
seq: 0,
|
||||
created: Instant::now(),
|
||||
menu_hold_since: None,
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Steam pad")?;
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let (name, phys, uniq) = match self.model {
|
||||
SteamModel::Deck => ("Steam Deck", "steam", "steam"),
|
||||
SteamModel::Controller => ("Steam Controller", "steamctrl", "steamctrl"),
|
||||
};
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {name} {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{phys}/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{uniq}-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&self.model.product().to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` under this pad's model (Deck reports get the gamepad-mode entry overlay +
|
||||
/// anti-toggle guard applied) and write it.
|
||||
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
match self.model {
|
||||
SteamModel::Deck => {
|
||||
let mut s = *st;
|
||||
s.buttons = self.effective_buttons(st.buttons);
|
||||
serialize_deck_state(&mut r, &s, self.seq);
|
||||
}
|
||||
SteamModel::Controller => serialize_sc_state(&mut r, st, self.seq),
|
||||
}
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
|
||||
/// Deck-only — the SC's kernel parser has no mode gate.
|
||||
fn in_mode_entry(&self) -> bool {
|
||||
self.model == SteamModel::Deck && self.created.elapsed() < MODE_ENTER
|
||||
}
|
||||
|
||||
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
|
||||
/// drop `b9.6` for one frame whenever it's been continuously held past [`MENU_HOLD_CAP`].
|
||||
fn effective_buttons(&mut self, mut buttons: u64) -> u64 {
|
||||
if self.in_mode_entry() {
|
||||
return btn::STEAM_MENU_RIGHT;
|
||||
}
|
||||
if buttons & btn::MENU != 0 {
|
||||
let now = Instant::now();
|
||||
match self.menu_hold_since {
|
||||
None => self.menu_hold_since = Some(now),
|
||||
Some(since) if now.duration_since(since) >= MENU_HOLD_CAP => {
|
||||
buttons &= !btn::MENU; // one-frame release resets the kernel's mode-switch timer
|
||||
self.menu_hold_since = None;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
} else {
|
||||
self.menu_hold_since = None;
|
||||
}
|
||||
buttons
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's GET_REPORT (serial) + SET_REPORT
|
||||
/// (settings / rumble — ack `err=0`) and parse any rumble feedback (`0xEB`, on either the
|
||||
/// SET_REPORT or OUTPUT path) into `(low, high)` for the universal rumble plane.
|
||||
pub fn service(&mut self) -> Option<(u16, u16)> {
|
||||
let mut rumble = None;
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[4..end]).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01"));
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack.
|
||||
let end = (12 + 16).min(UHID_EVENT_SIZE);
|
||||
if let Some(r) = parse_steam_output(&ev[12..end]).rumble {
|
||||
rumble = Some(r);
|
||||
}
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
rumble
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
||||
self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SteamDeckPad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport a manager pad drives. UHID is universal but Steam Input won't promote it (a UHID
|
||||
/// device has no USB interface number, `Interface: -1`); the USB **gadget** (`raw_gadget`, SteamOS)
|
||||
/// and **usbip** (`vhci_hcd`, universal) both present the controller on USB interface 2, which Steam
|
||||
/// Input *does* promote. Selected per-pad by [`open_transport`]. (`pub`: the type appears as
|
||||
/// `type Pad` in the `PadProto` impl, a public trait.)
|
||||
pub enum DeckTransport {
|
||||
Uhid(SteamDeckPad),
|
||||
Gadget(crate::steam_gadget::SteamDeckGadget),
|
||||
Usbip(crate::steam_usbip::SteamDeckUsbip),
|
||||
}
|
||||
|
||||
impl DeckTransport {
|
||||
fn write_state(&mut self, st: &SteamState) {
|
||||
match self {
|
||||
DeckTransport::Uhid(p) => {
|
||||
let _ = p.write_state(st);
|
||||
}
|
||||
DeckTransport::Gadget(g) => g.write_state(st),
|
||||
DeckTransport::Usbip(u) => u.write_state(st),
|
||||
}
|
||||
}
|
||||
fn service(&mut self) -> Option<(u16, u16)> {
|
||||
match self {
|
||||
DeckTransport::Uhid(p) => p.service(),
|
||||
DeckTransport::Gadget(g) => g.service().rumble,
|
||||
DeckTransport::Usbip(u) => u.service().rumble,
|
||||
}
|
||||
}
|
||||
fn in_mode_entry(&self) -> bool {
|
||||
match self {
|
||||
// Only the UHID pad needs the gamepad-mode entry pulse: the promoted transports are
|
||||
// read raw via hidraw by Steam Input, which bypasses the kernel's evdev mode gate.
|
||||
DeckTransport::Uhid(p) => p.in_mode_entry(),
|
||||
DeckTransport::Gadget(_) | DeckTransport::Usbip(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot diagnostic: InputPlumber (shipped and enabled by default on Bazzite) hidraw-grabs
|
||||
/// controllers it decides to manage and re-emits them under a different identity — historically
|
||||
/// the Deck config re-emitted an Xbox Elite pad with the trackpads routed to a mouse target. If
|
||||
/// it grabs our virtual Deck, everything downstream of hid-steam looks wrong (trackpads surface
|
||||
/// as a stick/mouse, gyro vanishes) while punktfunk's own logs stay clean — so name the suspect
|
||||
/// up front. Best-effort process-name scan; no dependency on its D-Bus API.
|
||||
fn warn_if_inputplumber() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||
if !ONCE.swap(false, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let running = std::fs::read_dir("/proc")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.any(|e| {
|
||||
std::fs::read_to_string(e.path().join("comm")).is_ok_and(|c| c.trim() == "inputplumber")
|
||||
});
|
||||
if running {
|
||||
tracing::warn!(
|
||||
"InputPlumber is running on this host — if it manages the virtual Steam Deck pad, \
|
||||
games see InputPlumber's re-emitted device instead (trackpads may arrive as a \
|
||||
stick/mouse, gyro may vanish). Check `inputplumber devices` and exclude the \
|
||||
virtual pad from management if inputs look remapped."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the best Steam-Input-promotable Deck transport available, in preference order:
|
||||
/// **`raw_gadget` (SteamOS validated fast-path) → `usbip`/`vhci_hcd` (universal, Secure-Boot-clean)
|
||||
/// → UHID (universal, but `Interface: -1` so Steam Input won't promote it).** Each rung degrades to
|
||||
/// the next on failure, so a host lacking the gadget modules still gets a *promotable* Deck via
|
||||
/// usbip, and one lacking both still gets a working (if non-promoted) UHID pad.
|
||||
fn open_transport(idx: u8) -> Result<DeckTransport> {
|
||||
warn_if_inputplumber();
|
||||
use crate::{steam_gadget, steam_usbip};
|
||||
// 1. raw_gadget — the validated SteamOS fast-path (default on there).
|
||||
if steam_gadget::gadget_preferred() {
|
||||
steam_gadget::ensure_modules();
|
||||
match steam_gadget::SteamDeckGadget::open(idx) {
|
||||
Ok(g) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created (USB gadget — Steam Input recognizes it)"
|
||||
);
|
||||
return Ok(DeckTransport::Gadget(g));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "USB-gadget Deck unavailable — trying usbip")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. usbip/vhci_hcd — the universal, in-tree, Secure-Boot-clean transport (default on elsewhere).
|
||||
if steam_usbip::usbip_preferred() {
|
||||
match steam_usbip::SteamDeckUsbip::open(idx) {
|
||||
Ok(u) => return Ok(DeckTransport::Usbip(u)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "usbip Deck unavailable — falling back to UHID")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. UHID — universal fallback (works everywhere; Steam Input won't promote it). This is a
|
||||
// DEGRADED outcome, not a normal one: a UHID device has no USB interface number (Interface: -1),
|
||||
// so Steam Input ignores it and the controller never appears in Game Mode / can't navigate.
|
||||
// Reaching here almost always means `vhci_hcd` isn't loaded (the host runs unprivileged and
|
||||
// can't modprobe it) — load it at boot (packaging ships modules-load.d/punktfunk.conf +
|
||||
// 60-punktfunk.rules; on a systemd-sysext host `punktfunk-sysext` mirrors both into /etc).
|
||||
let p = SteamDeckPad::open(idx)?;
|
||||
tracing::warn!(
|
||||
index = idx,
|
||||
"virtual Steam Deck created as UHID hid-steam — Steam Input WON'T promote it (no USB \
|
||||
interface), so it won't appear in Game Mode. Load vhci_hcd (usbip) so the pad arrives as a \
|
||||
real USB device: `sudo modprobe vhci_hcd`, and ensure it loads at boot."
|
||||
);
|
||||
Ok(DeckTransport::Uhid(p))
|
||||
}
|
||||
|
||||
/// The Steam-Deck-specific half of the shared stateful manager (see [`PadProto`]): the transport
|
||||
/// open (usbip → gadget → UHID fallback via [`open_transport`], which logs its own per-transport
|
||||
/// outcome), the [`SteamState`] mappers, and the kernel-handshake service pass. Lifecycle (slot
|
||||
/// table, unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`]; the gamepad-mode-entry
|
||||
/// pulse rides the [`force_heartbeat`](PadProto::force_heartbeat) hook.
|
||||
#[derive(Default)]
|
||||
pub struct SteamProto;
|
||||
|
||||
impl PadProto for SteamProto {
|
||||
type Pad = DeckTransport;
|
||||
type State = SteamState;
|
||||
const LABEL: &'static str = "Steam Deck";
|
||||
const DEVICE: &'static str = "Steam Deck";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DeckTransport> {
|
||||
open_transport(idx)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SteamState {
|
||||
SteamState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields (trackpad + motion arrive
|
||||
/// separately and must survive a button-only frame).
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SteamState,
|
||||
f: &punktfunk_core::input::GamepadFrame,
|
||||
) -> SteamState {
|
||||
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);
|
||||
// Trackpad CLICK arrives on the rich plane too and must survive a button-only frame,
|
||||
// exactly like touch/coords/motion above. It lives in its own fields (not `buttons`,
|
||||
// which `from_gamepad` just rebuilt) so preserving it can't strand the BTN_TOUCHPAD
|
||||
// wire-button's RPAD_CLICK — the two are OR'd only at serialize.
|
||||
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 DeckTransport, st: &SteamState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the kernel handshake and forward rumble on the universal plane. The Steam Deck has
|
||||
/// no rich host→client feedback plane (no lightbar / adaptive triggers), so `hidout` stays
|
||||
/// empty.
|
||||
fn service(&self, pad: &mut DeckTransport, _idx: u8) -> PadFeedback {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Force a steady stream while a pad is still pulsing its gamepad-mode entry (so the `b9.6`
|
||||
/// toggle completes even with no game input).
|
||||
fn force_heartbeat(&self, pad: &DeckTransport) -> bool {
|
||||
pad.in_mode_entry()
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Deck pads of a session — the Steam analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with
|
||||
/// `PUNKTFUNK_GAMEPAD=steamdeck`. Button/stick frames arrive via `handle`; the trackpads + motion
|
||||
/// via `apply_rich`; `pump` services the kernel handshake + routes rumble back; `heartbeat` keeps
|
||||
/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
|
||||
pub type SteamControllerManager = UhidManager<SteamProto>;
|
||||
|
||||
/// The **classic Steam Controller** half of the shared stateful manager: the same `hid-steam`
|
||||
/// driver under the wired-SC identity (`28DE:1102`, `ID_CONTROLLER_STATE`), UHID-only in v1 —
|
||||
/// the usbip/gadget transports present the Deck's captured 3-interface USB device, and the SC's
|
||||
/// wired interface layout hasn't been captured, so there is no Steam-Input promotion (the same
|
||||
/// degraded-but-working state the Deck had pre-usbip; acceptable for discontinued hardware).
|
||||
///
|
||||
/// Deltas vs the Deck (see [`sc_from_gamepad`]/[`serialize_sc_state`]): one stick + two pads +
|
||||
/// two grips — the wire right stick drives the right pad, a left-pad contact shadows the left
|
||||
/// stick, wire PADDLE1/2 land on the two grips (3/4 fold via the remap policy), and the kernel
|
||||
/// registers neither FF rumble nor a sensors evdev for this model (feedback stays empty).
|
||||
pub struct ScProto {
|
||||
/// Fallback policy for the wire paddles beyond the SC's two grips (PADDLE3/4).
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for ScProto {
|
||||
fn default() -> ScProto {
|
||||
ScProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for ScProto {
|
||||
type Pad = SteamDeckPad;
|
||||
type State = SteamState;
|
||||
const LABEL: &'static str = "Steam Controller";
|
||||
const DEVICE: &'static str = "Steam Controller";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<SteamDeckPad> {
|
||||
let p = SteamDeckPad::open_model(idx, SteamModel::Controller)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Controller created (UHID hid-steam)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SteamState {
|
||||
SteamState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers, preserving the rich-plane fields. PADDLE1/2 map natively to
|
||||
/// the SC's two grips inside [`sc_from_gamepad`]; only 3/4 go through the fold policy — mask
|
||||
/// the native pair out of the fold input so the policy can't double-fire them.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SteamState,
|
||||
f: &punktfunk_core::input::GamepadFrame,
|
||||
) -> SteamState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
|
||||
let folded = crate::steam_remap::fold_paddles(
|
||||
f.buttons & !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2),
|
||||
self.remap.paddles,
|
||||
);
|
||||
let mut s = sc_from_gamepad(
|
||||
folded | native,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
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::LPAD_TOUCH;
|
||||
s.lpad_click = prev.lpad_click;
|
||||
// The right pad carries the wire right stick each frame; a rich right-pad contact
|
||||
// (TouchpadEx surface 2) overrides it only while the stick is centered — the stick is
|
||||
// the primary camera surface on this mapping.
|
||||
if f.rs_x == 0 && f.rs_y == 0 {
|
||||
s.rpad_x = prev.rpad_x;
|
||||
s.rpad_y = prev.rpad_y;
|
||||
s.buttons |= prev.buttons & btn::RPAD_TOUCH;
|
||||
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 SteamDeckPad, st: &SteamState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the kernel handshake (serial GET_REPORT + settings SET_REPORTs). The kernel
|
||||
/// registers no FF device for the classic SC, so rumble feedback can only arrive from a
|
||||
/// hidraw client (`0xEB`) — surfaced if it ever does.
|
||||
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
|
||||
PadFeedback {
|
||||
rumble: pad.service(),
|
||||
hidout: Vec::new(),
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual classic Steam Controllers of a session — `PUNKTFUNK_GAMEPAD=steamcontroller`, or
|
||||
/// the per-pad kind a client declares for a physical SC.
|
||||
pub type SteamCtrlManager = UhidManager<ScProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Find the evdev node for a kernel input device by exact name (e.g. `"Steam Deck"`).
|
||||
fn find_node(name: &str) -> Option<String> {
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").ok()?;
|
||||
for block in devs.split("\n\n") {
|
||||
if !block
|
||||
.lines()
|
||||
.any(|l| l.trim() == format!("N: Name=\"{name}\""))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for l in block.lines() {
|
||||
if let Some(h) = l.strip_prefix("H: Handlers=") {
|
||||
if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) {
|
||||
return Some(format!("/dev/input/{ev}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the evdev's current key bitmap (`EVIOCGKEY`) and test whether `code` is down.
|
||||
fn key_is_down(node: &str, code: u16) -> bool {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let Ok(f) = std::fs::File::open(node) else {
|
||||
return false;
|
||||
};
|
||||
let mut bits = [0u8; 96];
|
||||
const EVIOCGKEY: libc::c_ulong = (2 << 30) | (96 << 16) | (0x45 << 8) | 0x18;
|
||||
// SAFETY: EVIOCGKEY copies the current key-state bitmap of the evdev behind the valid fd
|
||||
// `f` into `bits`; 96 bytes covers KEY_MAX/8, so the kernel never writes past the buffer.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), EVIOCGKEY, bits.as_mut_ptr()) };
|
||||
rc >= 0 && (bits[(code / 8) as usize] >> (code % 8)) & 1 == 1
|
||||
}
|
||||
|
||||
/// Read the current value of an absolute axis (`EVIOCGABS`) — the first `i32` of `input_absinfo`.
|
||||
fn abs_value(node: &str, abs: u16) -> Option<i32> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let f = std::fs::File::open(node).ok()?;
|
||||
let mut info = [0u8; 24]; // struct input_absinfo { value, min, max, fuzz, flat, resolution }
|
||||
let req: libc::c_ulong =
|
||||
(2 << 30) | (24 << 16) | (0x45 << 8) | (0x40 + abs as libc::c_ulong);
|
||||
// SAFETY: EVIOCGABS fills the 24-byte input_absinfo for the valid evdev fd `f`; we read only
|
||||
// the leading i32 `value`. The buffer is exactly sizeof(input_absinfo), so no overflow.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, info.as_mut_ptr()) };
|
||||
(rc >= 0).then(|| i32::from_ne_bytes([info[0], info[1], info[2], info[3]]))
|
||||
}
|
||||
|
||||
/// On-box smoke test for the real backend: a `SteamDeckPad` must bind `hid-steam` (creating both
|
||||
/// the gamepad + IMU evdevs), enter `gamepad_mode` via the creation pulse, and land a held button
|
||||
/// on the evdev (`BTN_A`, code 0x130) — proving the entry overlay + byte-exact serialize path —
|
||||
/// then tear the device down on drop. Touches `/dev/uhid`, so it is `#[ignore]`d in CI; run on a
|
||||
/// box with `hid-steam` + `input`-group access: `cargo test -p punktfunk-host -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
||||
fn backend_binds_and_input_flows() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
const BTN_A: u16 = 0x130;
|
||||
const ABS_HAT0X: u16 = 0x10; // left trackpad X
|
||||
let mut pad = SteamDeckPad::open(0).expect("open SteamDeckPad (/dev/uhid + input group?)");
|
||||
// Drive the full M3 wire path: build state through `from_gamepad` (BTN_A + the L4 back grip)
|
||||
// and `apply_rich` (a left-pad TouchpadEx contact), then hold it past MODE_ENTER (the b9.6
|
||||
// pulse), servicing the handshake.
|
||||
let mut st = SteamState::from_gamepad(gs::BTN_A | gs::BTN_PADDLE2, 0, 0, 0, 0, 0, 0);
|
||||
st.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: -8000,
|
||||
y: 9000,
|
||||
pressure: 0,
|
||||
});
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(1200) {
|
||||
let _ = pad.service();
|
||||
pad.write_state(&st).expect("write_state");
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(devs.contains("Steam Deck"), "gamepad evdev not created");
|
||||
assert!(
|
||||
devs.contains("Steam Deck Motion Sensors"),
|
||||
"IMU evdev not created"
|
||||
);
|
||||
let node = find_node("Steam Deck").expect("gamepad evdev node");
|
||||
assert!(
|
||||
key_is_down(&node, BTN_A),
|
||||
"BTN_A not down — gamepad_mode entry or serialize failed"
|
||||
);
|
||||
// The left trackpad contact (TouchpadEx surface 1, gated on LPAD_TOUCH) reaches ABS_HAT0X.
|
||||
assert_eq!(
|
||||
abs_value(&node, ABS_HAT0X),
|
||||
Some(-8000),
|
||||
"left trackpad (TouchpadEx surface 1) did not reach ABS_HAT0X"
|
||||
);
|
||||
drop(pad);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
!devs.contains("Steam Deck Motion Sensors"),
|
||||
"device not torn down on drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// On-box smoke for the classic-SC identity: binds `hid-steam` as `28DE:1102`, input flows
|
||||
/// with NO mode-entry pulse (the SC parser has no gamepad_mode gate), a held A + right-stick
|
||||
/// deflection land on the evdev (BTN_A + ABS_RX — the right PAD surface), and a grip lands
|
||||
/// on BTN_GRIPR (0x2c5? — kernel BTN_GRIPR = 0x2c5 on new kernels / check via bitmap).
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
||||
fn sc_backend_binds_and_input_flows() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
const BTN_A: u16 = 0x130;
|
||||
const ABS_RX: u16 = 0x03;
|
||||
let mut pad = SteamDeckPad::open_model(0, SteamModel::Controller)
|
||||
.expect("open SC pad (/dev/uhid + input group?)");
|
||||
let st = sc_from_gamepad(gs::BTN_A | gs::BTN_PADDLE1, 0, 0, 9000, 0, 0, 0);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(900) {
|
||||
let _ = pad.service();
|
||||
pad.write_state(&st).expect("write_state");
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
}
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
devs.contains("Steam Controller"),
|
||||
"SC gamepad evdev not created"
|
||||
);
|
||||
let node = find_node("Steam Controller").expect("SC evdev node");
|
||||
assert!(
|
||||
key_is_down(&node, BTN_A),
|
||||
"BTN_A not down — SC serialize failed (no mode gate should apply)"
|
||||
);
|
||||
assert_eq!(
|
||||
abs_value(&node, ABS_RX),
|
||||
Some(9000),
|
||||
"wire right stick did not land on the right pad (ABS_RX)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Virtual **Steam Controller 2** (Triton) via UHID — the as-is passthrough backend
|
||||
//! ([`GamepadPref::SteamController2`](punktfunk_core::config::GamepadPref)). The
|
||||
//! transport-independent contract (descriptor, report ids, the typed fallback serializer, the
|
||||
//! rumble parser) lives in [`super::triton_proto`]; this module is the `/dev/uhid` plumbing.
|
||||
//!
|
||||
//! Deltas vs the Deck backend ([`super::steam_controller`]):
|
||||
//!
|
||||
//! 1. **No kernel driver.** Mainline `hid-steam` doesn't bind `28DE:1302`, so the device gets
|
||||
//! `hid-generic` + a hidraw node and NO evdev — Steam Input (hidapi over hidraw) is the only
|
||||
//! consumer, exactly as it is for the physical pad. No `gamepad_mode` machinery applies.
|
||||
//! 2. **Raw mirroring.** Input reports arrive verbatim from the client
|
||||
//! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)) and are written unchanged;
|
||||
//! everything Steam writes back (SET_REPORT features, OUTPUT haptics) is acked and forwarded
|
||||
//! raw for replay on the physical controller.
|
||||
//! 3. **usbip first, UHID fallback.** Steam ignores UHID devices (`Interface: -1`) for the
|
||||
//! Triton exactly as it did for the Deck — CONFIRMED on-glass 2026-07-15 — so the preferred
|
||||
//! transport is [`super::triton_usbip`] (`vhci_hcd`), which presents a real USB device
|
||||
//! byte-matched to the physical wired pad's captured descriptors. UHID remains the degraded
|
||||
//! fallback (hidraw exists, Steam won't list it) for hosts without `vhci_hcd`/root.
|
||||
|
||||
use super::triton_proto::{
|
||||
parse_triton_rumble, serialize_triton_state, strip_report_prefix, triton_feature_reply,
|
||||
triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR,
|
||||
TRITON_WIRED_PRODUCT,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
|
||||
pub struct TritonPad {
|
||||
fd: File,
|
||||
/// Synth-mode sequence counter (the raw path carries the physical pad's own seq).
|
||||
seq: u8,
|
||||
/// Raw reports Steam wrote since the last service pass, kind-tagged for the 0xCD plane.
|
||||
pending_raw: Vec<(u8, Vec<u8>)>,
|
||||
/// The last feature SET_REPORT (id-first) — the query half of the Valve GET dance.
|
||||
last_set: Vec<u8>,
|
||||
serial: String,
|
||||
unit_id: u32,
|
||||
/// Last GET query command logged, so the tester-facing log line fires once per distinct cmd.
|
||||
last_get_logged: u8,
|
||||
}
|
||||
|
||||
impl TritonPad {
|
||||
pub fn open(index: u8) -> Result<TritonPad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut pad = TritonPad {
|
||||
fd,
|
||||
seq: 0,
|
||||
pending_raw: Vec::new(),
|
||||
last_set: Vec::new(),
|
||||
serial: triton_serial(index),
|
||||
unit_id: triton_unit_id(index),
|
||||
last_get_logged: 0,
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Triton pad")?;
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// The physical pad's USB product string is "Steam Controller"; keep the punktfunk prefix
|
||||
// convention every virtual pad uses (Steam matches on VID/PID, not the name).
|
||||
put_cstr(
|
||||
&mut ev,
|
||||
4,
|
||||
128,
|
||||
&format!("Punktfunk Steam Controller 2 {index}"),
|
||||
); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/triton/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-triton-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(TRITON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&TRITON_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&TRITON_WIRED_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + TRITON_RDESC.len()].copy_from_slice(TRITON_RDESC);
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mirror one report out: the client's raw bytes verbatim in as-is mode, else a synthesized
|
||||
/// minimal `0x42` state report from the typed fallback fields.
|
||||
pub fn write_state(&mut self, st: &TritonState) -> Result<()> {
|
||||
if st.raw_len > 0 {
|
||||
let len = (st.raw_len as usize).min(st.raw.len());
|
||||
return self.write_input(&st.raw[..len]);
|
||||
}
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut r, st, self.seq);
|
||||
self.write_input(&r)
|
||||
}
|
||||
|
||||
fn write_input(&mut self, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(data.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + data.len()].copy_from_slice(data); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: ack SET_REPORTs (a stalled ack blocks the writer ~5 s),
|
||||
/// answer GET_REPORTs (best-effort canned reply — the query/answer feature dance can't
|
||||
/// round-trip to the physical pad synchronously), and queue every report Steam wrote for raw
|
||||
/// forwarding. Returns the rumble level if a `0x80` output report was seen this pass.
|
||||
pub fn service(&mut self) -> Option<(u16, u16)> {
|
||||
let mut rumble = None;
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
let rep = strip_report_prefix(&ev[4..end]);
|
||||
if let Some(r) = parse_triton_rumble(rep) {
|
||||
rumble = Some(r);
|
||||
}
|
||||
self.queue_raw(HID_RAW_OUTPUT, rep);
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// uhid_set_report: id u32, rnum u8, rtype u8, size u16, data — data at ev[12..].
|
||||
let size = u16::from_ne_bytes([ev[10], ev[11]]) as usize;
|
||||
let end = (12 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(UHID_EVENT_SIZE);
|
||||
let rep = strip_report_prefix(&ev[12..end]).to_vec();
|
||||
if let Some(r) = parse_triton_rumble(&rep) {
|
||||
rumble = Some(r); // some stacks send haptics on the feature path
|
||||
}
|
||||
// Remember the command — it selects the NEXT GET_REPORT's answer (the Valve
|
||||
// query dance) — and forward it raw to the physical pad.
|
||||
self.queue_raw(HID_RAW_FEATURE, &rep);
|
||||
self.last_set = rep;
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// The answer half of the Valve query dance: echo the LAST SET's command with
|
||||
// a plausible payload (attributes / serial). Answering with the wrong command
|
||||
// type makes Steam drop the pad — confirmed on-glass 2026-07-15; the dance
|
||||
// can't round-trip to the physical pad synchronously.
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let reply = triton_feature_reply(&self.last_set, &self.serial, self.unit_id);
|
||||
if reply[1] != self.last_get_logged {
|
||||
self.last_get_logged = reply[1];
|
||||
tracing::debug!(
|
||||
cmd = %format_args!("{:#04x}", reply[1]),
|
||||
"virtual SC2: answering feature GET"
|
||||
);
|
||||
}
|
||||
let _ = self.reply_get_report(id, &reply);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
rumble
|
||||
}
|
||||
|
||||
/// Queue a raw report for the 0xCD plane, capped so a hidraw client gone haywire can't grow
|
||||
/// the queue unboundedly between pumps (newest wins — these are level-styled commands).
|
||||
fn queue_raw(&mut self, kind: u8, data: &[u8]) {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.pending_raw.len() >= 32 {
|
||||
self.pending_raw.remove(0);
|
||||
}
|
||||
self.pending_raw.push((kind, data.to_vec()));
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
||||
self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TritonPad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport a manager pad drives: usbip (`vhci_hcd`, a real USB device Steam lists) with
|
||||
/// UHID as the degraded fallback — the same ladder shape as the Deck's [`super::steam_controller`],
|
||||
/// minus the gadget rung (no captured gadget layout for the Triton, and usbip is universal).
|
||||
pub enum TritonTransport {
|
||||
Usbip(crate::triton_usbip::TritonUsbip),
|
||||
Uhid(TritonPad),
|
||||
}
|
||||
|
||||
/// One transport `service()` pass: Steam's latest rumble `(left, right)` plus the raw
|
||||
/// `(kind, payload)` reports it wrote since the last pass.
|
||||
type TritonServiced = (Option<(u16, u16)>, Vec<(u8, Vec<u8>)>);
|
||||
|
||||
impl TritonTransport {
|
||||
fn write_state(&mut self, st: &TritonState) {
|
||||
match self {
|
||||
TritonTransport::Usbip(u) => u.write_state(st),
|
||||
TritonTransport::Uhid(p) => {
|
||||
let _ = p.write_state(st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(rumble, raw reports)` Steam wrote since the last pass.
|
||||
fn service(&mut self) -> TritonServiced {
|
||||
match self {
|
||||
TritonTransport::Usbip(u) => {
|
||||
let fb = u.service();
|
||||
(fb.rumble, fb.raw)
|
||||
}
|
||||
TritonTransport::Uhid(p) => {
|
||||
let rumble = p.service();
|
||||
(rumble, std::mem::take(&mut p.pending_raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the best Steam-visible SC2 transport: **usbip (`vhci_hcd`) → UHID.** Steam is confirmed
|
||||
/// (on-glass 2026-07-15) to ignore the UHID leg, so reaching the fallback means the pad exists as
|
||||
/// hidraw only — flagged loudly, with the vhci_hcd remedy in the log.
|
||||
fn open_transport(idx: u8, puck: bool) -> Result<TritonTransport> {
|
||||
if crate::steam_usbip::usbip_preferred() {
|
||||
let opened = if puck {
|
||||
crate::triton_usbip::TritonUsbip::open_puck(idx)
|
||||
} else {
|
||||
crate::triton_usbip::TritonUsbip::open(idx)
|
||||
};
|
||||
match opened {
|
||||
Ok(u) => return Ok(TritonTransport::Usbip(u)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "usbip SC2 unavailable — falling back to UHID")
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = TritonPad::open(idx)?;
|
||||
tracing::warn!(
|
||||
index = idx,
|
||||
"virtual Steam Controller 2 created as UHID — Steam WON'T list it (no USB interface; \
|
||||
confirmed on-glass). Load vhci_hcd (usbip) so the pad arrives as a real USB device: \
|
||||
`sudo modprobe vhci_hcd`, and ensure it loads at boot."
|
||||
);
|
||||
Ok(TritonTransport::Uhid(p))
|
||||
}
|
||||
|
||||
/// The Triton-specific half of the shared stateful manager (see [`PadProto`]): raw mirroring
|
||||
/// with the typed fallback, and the raw-forwarding service pass.
|
||||
#[derive(Default)]
|
||||
pub struct TritonProto {
|
||||
puck: bool,
|
||||
}
|
||||
|
||||
impl TritonProto {
|
||||
pub fn puck() -> Self {
|
||||
Self { puck: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for TritonProto {
|
||||
type Pad = TritonTransport;
|
||||
type State = TritonState;
|
||||
const LABEL: &'static str = "Steam Controller 2";
|
||||
const DEVICE: &'static str = "Steam Controller 2";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<TritonTransport> {
|
||||
open_transport(idx, self.puck)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> TritonState {
|
||||
TritonState::neutral()
|
||||
}
|
||||
|
||||
/// Typed fallback merge. Once raw reports flow (`raw_len > 0`) the frame only refreshes the
|
||||
/// typed fields for diagnostics — `write_state` keeps mirroring the raw report.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &TritonState,
|
||||
f: &punktfunk_core::input::GamepadFrame,
|
||||
) -> TritonState {
|
||||
let mut s = TritonState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
// As-is mode is sticky: a typed frame between two raw reports must not flap the pad back
|
||||
// to synth mode (the client sends BOTH planes — typed keeps the degrade paths alive).
|
||||
s.raw = prev.raw;
|
||||
s.raw_len = prev.raw_len;
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut TritonState, rich: RichInput) {
|
||||
if let RichInput::HidReport { len, data, .. } = rich {
|
||||
let len = (len as usize).min(data.len()).min(st.raw.len());
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
st.raw[..len].copy_from_slice(&data[..len]);
|
||||
st.raw_len = len as u8;
|
||||
}
|
||||
// Touchpad/Motion/TouchpadEx: nothing to fold — the raw feed carries pads + IMU natively,
|
||||
// and the synth fallback has no surface for them.
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut TritonTransport, st: &TritonState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Ack + queue Steam's writes, then hand them to the pump as raw 0xCD events; rumble ALSO
|
||||
/// rides the universal 0xCA plane (deduped) so the client's phone-mirror path keeps working.
|
||||
fn service(&self, pad: &mut TritonTransport, idx: u8) -> PadFeedback {
|
||||
let (rumble, raw) = pad.service();
|
||||
let hidout = raw
|
||||
.into_iter()
|
||||
.map(|(kind, data)| HidOutput::HidRaw {
|
||||
pad: idx,
|
||||
kind,
|
||||
data,
|
||||
})
|
||||
.collect();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
hidout,
|
||||
game_drove: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Controller 2 pads of a session — `PUNKTFUNK_GAMEPAD=steamcontroller2`
|
||||
/// (aliases `sc2`/`ibex`), or the per-pad kind an Android client declares for a captured
|
||||
/// physical pad.
|
||||
pub type Triton2Manager = UhidManager<TritonProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// On-box smoke: the virtual SC2 must create a hidraw node under `hid-generic` (no evdev —
|
||||
/// nothing binds the PID) carrying the Valve identity, mirror a raw state report verbatim,
|
||||
/// and tear down on drop. `#[ignore]`d in CI (touches `/dev/uhid`); run on a Linux box:
|
||||
/// `cargo test -p punktfunk-host -- --ignored triton`.
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uhid device; needs the input group"]
|
||||
fn triton_backend_creates_hidraw_and_mirrors_raw() {
|
||||
let mut pad = TritonPad::open(0).expect("open TritonPad (/dev/uhid + input group?)");
|
||||
// Mirror one raw report (as the client would forward it).
|
||||
let mut st = TritonState::neutral();
|
||||
let raw: &[u8] = &[0x42, 1, 0x01, 0, 0, 0, 0xFF, 0x7F]; // A held, LT full — truncated is fine
|
||||
st.raw[..raw.len()].copy_from_slice(raw);
|
||||
st.raw_len = raw.len() as u8;
|
||||
for _ in 0..50 {
|
||||
let _ = pad.service();
|
||||
pad.write_state(&st).expect("write_state");
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
}
|
||||
// The device exists with the Valve identity (hidraw only; /proc/bus/input has no entry).
|
||||
let found = std::fs::read_dir("/sys/bus/hid/devices")
|
||||
.map(|d| {
|
||||
d.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().contains(":28DE:1302"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(found, "virtual 28DE:1302 HID device not created");
|
||||
drop(pad);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! Virtual Steam Deck via the USB **gadget** subsystem (`raw_gadget` + `dummy_hcd`) — the only
|
||||
//! virtual-Deck transport Steam Input recognizes.
|
||||
//!
|
||||
//! The UHID [`super::steam_controller::SteamDeckPad`] binds the kernel `hid-steam` driver, but Steam's
|
||||
//! own controller driver filters the Deck's controller to USB **interface 2**, and a UHID device has no
|
||||
//! USB interface number (`Interface: -1`), so Steam enumerates it but never promotes it. This backend
|
||||
//! instead presents a *real* 3-interface USB Deck (mouse = interface 0, keyboard = 1, **controller =
|
||||
//! 2**) on a `dummy_hcd` loopback UDC, driven from userspace via `/dev/raw-gadget` so we can answer
|
||||
//! every control transfer (including the HID feature reports `f_hid` can't). Proven on a real Deck:
|
||||
//! hid-steam binds it, Steam reserves an XInput slot and emits an X-Box pad. Descriptors are captured
|
||||
//! verbatim from a physical Deck; see `packaging/linux/steam-deck-gadget/` for the original PoC + the
|
||||
//! USB-stack gotchas. **SteamOS-host only** (needs `dummy_hcd` + `raw_gadget`, which SteamOS ships).
|
||||
//!
|
||||
//! The transport here is self-contained (libc + std); the report bytes it streams are produced by
|
||||
//! [`super::steam_proto`] in the wrapping backend.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::mem::size_of;
|
||||
use std::os::fd::RawFd;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
// ---- raw_gadget UAPI (mirrors linux/usb/raw_gadget.h; inlined like the C PoC) ----
|
||||
const UDC_NAME_MAX: usize = 128;
|
||||
|
||||
#[repr(C)]
|
||||
struct UsbRawInit {
|
||||
driver_name: [u8; UDC_NAME_MAX],
|
||||
device_name: [u8; UDC_NAME_MAX],
|
||||
speed: u8,
|
||||
}
|
||||
|
||||
// usb_raw_event { u32 type; u32 length; u8 data[]; } — we read it into a fixed buffer.
|
||||
const EVENT_HDR: usize = 8; // type + length
|
||||
const EVENT_BUF: usize = EVENT_HDR + 64; // setup packet (8) fits easily
|
||||
|
||||
// usb_raw_ep_io { u16 ep; u16 flags; u32 length; u8 data[]; }
|
||||
const EPIO_HDR: usize = 8;
|
||||
|
||||
// usb_endpoint_descriptor is 9 bytes in the kernel (audio bRefresh/bSynchAddress); EP_ENABLE wants it.
|
||||
#[repr(C, packed)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct UsbEndpointDescriptor {
|
||||
b_length: u8,
|
||||
b_descriptor_type: u8,
|
||||
b_endpoint_address: u8,
|
||||
bm_attributes: u8,
|
||||
w_max_packet_size: u16,
|
||||
b_interval: u8,
|
||||
b_refresh: u8,
|
||||
b_synch_address: u8,
|
||||
}
|
||||
|
||||
const fn ioc(dir: u64, nr: u64, size: usize) -> libc::c_ulong {
|
||||
((dir << 30) | ((size as u64) << 16) | ((b'U' as u64) << 8) | nr) as libc::c_ulong
|
||||
}
|
||||
const IOCTL_INIT: libc::c_ulong = ioc(1, 0, size_of::<UsbRawInit>());
|
||||
const IOCTL_RUN: libc::c_ulong = ioc(0, 1, 0);
|
||||
const IOCTL_EVENT_FETCH: libc::c_ulong = ioc(2, 2, EVENT_HDR); // size is the header; kernel copies more
|
||||
const IOCTL_EP0_WRITE: libc::c_ulong = ioc(1, 3, EPIO_HDR);
|
||||
const IOCTL_EP0_READ: libc::c_ulong = ioc(2 | 1, 4, EPIO_HDR); // _IOWR
|
||||
const IOCTL_EP_ENABLE: libc::c_ulong = ioc(1, 5, size_of::<UsbEndpointDescriptor>());
|
||||
const IOCTL_EP_WRITE: libc::c_ulong = ioc(1, 7, EPIO_HDR);
|
||||
const IOCTL_CONFIGURE: libc::c_ulong = ioc(0, 9, 0);
|
||||
const IOCTL_VBUS_DRAW: libc::c_ulong = ioc(1, 10, 4);
|
||||
const IOCTL_EP0_STALL: libc::c_ulong = ioc(0, 12, 0);
|
||||
|
||||
const USB_RAW_EVENT_CONNECT: u32 = 1;
|
||||
const USB_RAW_EVENT_CONTROL: u32 = 2;
|
||||
const USB_SPEED_HIGH: u8 = 3;
|
||||
|
||||
// Captured-from-hardware Deck descriptors + the `0x83`/`0xAE` feature contract live in the shared
|
||||
// [`super::steam_proto`] module (single source of truth, also used by the usbip transport).
|
||||
use super::steam_proto::{
|
||||
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, RDESC_DECK_CTRL as RDESC_CTRL,
|
||||
RDESC_DECK_KBD as RDESC_KBD, RDESC_DECK_MOUSE as RDESC_MOUSE,
|
||||
};
|
||||
|
||||
const DEV_DESC: [u8; 18] = [
|
||||
18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00
|
||||
0, 0, 0, 64, // class/sub/proto, bMaxPacketSize0
|
||||
0xDE, 0x28, 0x05, 0x12, // idVendor 28DE, idProduct 1205
|
||||
0x00, 0x03, // bcdDevice 3.00
|
||||
1, 2, 3, 1, // iManufacturer, iProduct, iSerial, bNumConfigurations
|
||||
];
|
||||
|
||||
const HID_DT: u8 = 0x21;
|
||||
const HID_RPT_DT: u8 = 0x22;
|
||||
|
||||
/// Assemble the 84-byte config descriptor: config + 3×(interface + HID + 7-byte endpoint).
|
||||
fn build_config() -> Vec<u8> {
|
||||
let mut c = Vec::with_capacity(84);
|
||||
// config descriptor (wTotalLength patched after)
|
||||
c.extend_from_slice(&[9, 2, 84, 0, 3, 1, 0, 0x80, 250]);
|
||||
// helper closures
|
||||
let iface = |n: u8, sub: u8, proto: u8| [9u8, 4, n, 0, 1, 3, sub, proto, 0];
|
||||
let hid = |rlen: u16, country: u8| {
|
||||
[
|
||||
9u8,
|
||||
HID_DT,
|
||||
0x10,
|
||||
0x01,
|
||||
country,
|
||||
1,
|
||||
HID_RPT_DT,
|
||||
(rlen & 0xff) as u8,
|
||||
(rlen >> 8) as u8,
|
||||
]
|
||||
};
|
||||
let ep = |addr: u8, mps: u16| [7u8, 5, addr, 0x03, (mps & 0xff) as u8, (mps >> 8) as u8, 4];
|
||||
// interface 0: mouse, EP 0x81
|
||||
c.extend_from_slice(&iface(0, 0, 2));
|
||||
c.extend_from_slice(&hid(RDESC_MOUSE.len() as u16, 0));
|
||||
c.extend_from_slice(&ep(0x81, 8));
|
||||
// interface 1: keyboard (boot), EP 0x82
|
||||
c.extend_from_slice(&iface(1, 1, 1));
|
||||
c.extend_from_slice(&hid(RDESC_KBD.len() as u16, 0));
|
||||
c.extend_from_slice(&ep(0x82, 8));
|
||||
// interface 2: controller, EP 0x83, bCountryCode 33
|
||||
c.extend_from_slice(&iface(2, 0, 0));
|
||||
c.extend_from_slice(&hid(RDESC_CTRL.len() as u16, 33));
|
||||
c.extend_from_slice(&ep(0x83, 64));
|
||||
debug_assert_eq!(c.len(), 84);
|
||||
c
|
||||
}
|
||||
|
||||
fn string_desc(idx: u8, serial: &str) -> Vec<u8> {
|
||||
if idx == 0 {
|
||||
return vec![4, 3, 0x09, 0x04]; // LANGID en-US
|
||||
}
|
||||
let s: &str = match idx {
|
||||
1 => "Valve Software",
|
||||
2 => "Steam Deck Controller",
|
||||
3 => serial,
|
||||
_ => "",
|
||||
};
|
||||
let mut v = vec![(2 + s.len() * 2) as u8, 3];
|
||||
for ch in s.encode_utf16() {
|
||||
v.push((ch & 0xff) as u8);
|
||||
v.push((ch >> 8) as u8);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ---- ioctl wrappers (the only unsafe surface for the raw_gadget UAPI; documented once) ----
|
||||
fn ioctl_ptr<T>(fd: RawFd, req: libc::c_ulong, arg: *const T) -> i32 {
|
||||
// SAFETY: `fd` is our open /dev/raw-gadget descriptor; `arg` points to a correctly-sized,
|
||||
// initialized argument for `req` (a raw_gadget UAPI struct or an owned usb_raw_ep_io buffer)
|
||||
// that lives for the duration of the call. `ioctl` is variadic, so passing a thin pointer is ABI-correct.
|
||||
unsafe { libc::ioctl(fd, req as _, arg) as i32 }
|
||||
}
|
||||
fn ioctl_mut<T>(fd: RawFd, req: libc::c_ulong, arg: *mut T) -> i32 {
|
||||
// SAFETY: as `ioctl_ptr`, but `arg` is a writable buffer the kernel fills for `req` (EVENT_FETCH / EP0_READ).
|
||||
unsafe { libc::ioctl(fd, req as _, arg) as i32 }
|
||||
}
|
||||
fn ioctl_val(fd: RawFd, req: libc::c_ulong, val: libc::c_ulong) -> i32 {
|
||||
// SAFETY: `req` (VBUS_DRAW) takes an integer argument by value; `fd` is our descriptor.
|
||||
unsafe { libc::ioctl(fd, req as _, val) as i32 }
|
||||
}
|
||||
fn ioctl_none(fd: RawFd, req: libc::c_ulong) -> i32 {
|
||||
// SAFETY: `req` (RUN / CONFIGURE / EP0_STALL) takes no argument, but raw_gadget rejects a non-zero
|
||||
// `value` with EINVAL — pass an explicit 0 (an omitted vararg would be an indeterminate register).
|
||||
unsafe { libc::ioctl(fd, req as _, 0) as i32 }
|
||||
}
|
||||
|
||||
// ---- low-level ep0 helpers (operate on the shared fd) ----
|
||||
fn ep0_write(fd: RawFd, data: &[u8]) -> i32 {
|
||||
let mut buf = vec![0u8; EPIO_HDR + data.len()];
|
||||
buf[0..2].copy_from_slice(&0u16.to_ne_bytes()); // ep 0
|
||||
buf[4..8].copy_from_slice(&(data.len() as u32).to_ne_bytes());
|
||||
buf[EPIO_HDR..].copy_from_slice(data);
|
||||
ioctl_ptr(fd, IOCTL_EP0_WRITE, buf.as_ptr())
|
||||
}
|
||||
fn ep0_read(fd: RawFd, len: usize) -> (i32, Vec<u8>) {
|
||||
let mut buf = vec![0u8; EPIO_HDR + len.max(1)];
|
||||
buf[4..8].copy_from_slice(&(len as u32).to_ne_bytes());
|
||||
let r = ioctl_mut(fd, IOCTL_EP0_READ, buf.as_mut_ptr());
|
||||
let n = if r > 0 { r as usize } else { 0 };
|
||||
(r, buf[EPIO_HDR..EPIO_HDR + n.min(len.max(1))].to_vec())
|
||||
}
|
||||
/// Complete a no-data OUT control (status stage is an IN, handled by a zero-length read).
|
||||
fn ep0_ack(fd: RawFd) {
|
||||
ep0_read(fd, 0);
|
||||
}
|
||||
fn ep0_stall(fd: RawFd) {
|
||||
ioctl_none(fd, IOCTL_EP0_STALL);
|
||||
}
|
||||
|
||||
/// Owns the `/dev/raw-gadget` fd; closing it tears the device down.
|
||||
struct GadgetFd(RawFd);
|
||||
impl Drop for GadgetFd {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the fd we opened in `SteamDeckGadget::open` and own uniquely here.
|
||||
unsafe { libc::close(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck presented over the USB gadget subsystem. Dropping it stops the threads and
|
||||
/// closes the gadget (the kernel tears down the device).
|
||||
pub struct SteamDeckGadget {
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||
running: Arc<AtomicBool>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
_fd: Arc<GadgetFd>,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl SteamDeckGadget {
|
||||
/// Bind a virtual Deck on a fresh `dummy_hcd` UDC. `index` only varies the serial. Requires
|
||||
/// `dummy_hcd` + `raw_gadget` loaded and write access to `/dev/raw-gadget` (root on SteamOS).
|
||||
pub fn open(index: u8) -> Result<SteamDeckGadget> {
|
||||
// SAFETY: opening a constant NUL-terminated device path with O_RDWR; returns a fd or -1.
|
||||
let fd = unsafe { libc::open(c"/dev/raw-gadget".as_ptr(), libc::O_RDWR) };
|
||||
if fd < 0 {
|
||||
bail!(
|
||||
"open /dev/raw-gadget ({}) — is raw_gadget+dummy_hcd loaded and are we root?",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
let fd = Arc::new(GadgetFd(fd));
|
||||
let raw = fd.0;
|
||||
|
||||
// INIT against the dummy UDC, then RUN.
|
||||
// SAFETY: `UsbRawInit` is a plain-old-data struct (byte arrays + u8); all-zero is a valid value.
|
||||
let mut init: UsbRawInit = unsafe { std::mem::zeroed() };
|
||||
copy_cstr(&mut init.driver_name, "dummy_udc");
|
||||
copy_cstr(&mut init.device_name, "dummy_udc.0");
|
||||
init.speed = USB_SPEED_HIGH;
|
||||
if ioctl_ptr(raw, IOCTL_INIT, &init as *const _) < 0 {
|
||||
bail!("raw_gadget INIT: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
if ioctl_none(raw, IOCTL_RUN) < 0 {
|
||||
bail!("raw_gadget RUN: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
let serial = deck_serial(index);
|
||||
let unit_id = deck_unit_id(index); // "PF" + index — a synthetic per-instance device id
|
||||
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||
let feedback = Arc::new(Mutex::new(Default::default()));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let ctrl_ep = Arc::new(std::sync::atomic::AtomicI32::new(-1));
|
||||
let configured = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Control thread: enumerate + answer every control transfer.
|
||||
let control = {
|
||||
let fd = fd.clone();
|
||||
let running = running.clone();
|
||||
let ctrl_ep = ctrl_ep.clone();
|
||||
let configured = configured.clone();
|
||||
let feedback = feedback.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-deck-gadget-ctrl".into())
|
||||
.spawn(move || {
|
||||
control_loop(fd, running, ctrl_ep, configured, feedback, serial, unit_id)
|
||||
})
|
||||
.context("spawn gadget control thread")?
|
||||
};
|
||||
// Stream thread: push the current report on the controller interrupt-IN endpoint.
|
||||
let stream = {
|
||||
let fd = fd.clone();
|
||||
let running = running.clone();
|
||||
let ctrl_ep = ctrl_ep.clone();
|
||||
let configured = configured.clone();
|
||||
let report = report.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pf-deck-gadget-stream".into())
|
||||
.spawn(move || stream_loop(fd, running, ctrl_ep, configured, report))
|
||||
.context("spawn gadget stream thread")?
|
||||
};
|
||||
|
||||
Ok(SteamDeckGadget {
|
||||
report,
|
||||
feedback,
|
||||
running,
|
||||
threads: vec![control, stream],
|
||||
_fd: fd,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into the 64-byte Deck state report streamed to the kernel.
|
||||
pub fn write_state(&mut self, st: &super::steam_proto::SteamState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; 64];
|
||||
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
|
||||
if let Ok(mut g) = self.report.lock() {
|
||||
*g = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any feedback (rumble) the kernel/Steam wrote to the device.
|
||||
pub fn service(&mut self) -> super::steam_proto::SteamFeedback {
|
||||
self.feedback
|
||||
.lock()
|
||||
.map(|mut f| std::mem::take(&mut *f))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SteamDeckGadget {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
for t in self.threads.drain(..) {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_cstr(dst: &mut [u8], s: &str) {
|
||||
let n = s.len().min(dst.len() - 1);
|
||||
dst[..n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
fn control_loop(
|
||||
fd: Arc<GadgetFd>,
|
||||
running: Arc<AtomicBool>,
|
||||
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
|
||||
configured: Arc<AtomicBool>,
|
||||
feedback: Arc<Mutex<super::steam_proto::SteamFeedback>>,
|
||||
serial: String,
|
||||
unit_id: u32,
|
||||
) {
|
||||
let raw = fd.0;
|
||||
let cfg = build_config();
|
||||
let mut last_set: Vec<u8> = Vec::new();
|
||||
let mut evbuf = [0u8; EVENT_BUF];
|
||||
while running.load(Ordering::SeqCst) {
|
||||
// EVENT_FETCH: type(4) length(4) data[].
|
||||
evbuf[4..8].copy_from_slice(&(8u32).to_ne_bytes()); // request setup-sized payload
|
||||
let r = ioctl_mut(raw, IOCTL_EVENT_FETCH, evbuf.as_mut_ptr());
|
||||
if r < 0 {
|
||||
if running.load(Ordering::SeqCst) {
|
||||
// transient; brief backoff
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let etype = u32::from_ne_bytes([evbuf[0], evbuf[1], evbuf[2], evbuf[3]]);
|
||||
match etype {
|
||||
USB_RAW_EVENT_CONNECT => {}
|
||||
USB_RAW_EVENT_CONTROL => {
|
||||
let s = &evbuf[EVENT_HDR..EVENT_HDR + 8];
|
||||
let ctrl = Setup {
|
||||
bm_request_type: s[0],
|
||||
b_request: s[1],
|
||||
w_value: u16::from_le_bytes([s[2], s[3]]),
|
||||
w_index: u16::from_le_bytes([s[4], s[5]]),
|
||||
w_length: u16::from_le_bytes([s[6], s[7]]),
|
||||
};
|
||||
handle_control(
|
||||
raw,
|
||||
&ctrl,
|
||||
&cfg,
|
||||
&serial,
|
||||
unit_id,
|
||||
&ctrl_ep,
|
||||
&configured,
|
||||
&mut last_set,
|
||||
&feedback,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Setup {
|
||||
bm_request_type: u8,
|
||||
b_request: u8,
|
||||
w_value: u16,
|
||||
w_index: u16,
|
||||
w_length: u16,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_control(
|
||||
raw: RawFd,
|
||||
ctrl: &Setup,
|
||||
cfg: &[u8],
|
||||
serial: &str,
|
||||
unit_id: u32,
|
||||
ctrl_ep: &std::sync::atomic::AtomicI32,
|
||||
configured: &AtomicBool,
|
||||
last_set: &mut Vec<u8>,
|
||||
feedback: &Mutex<super::steam_proto::SteamFeedback>,
|
||||
) {
|
||||
let idx = (ctrl.w_index & 0xff) as u8;
|
||||
let type_class = ctrl.bm_request_type & 0x60;
|
||||
let wl = ctrl.w_length as usize;
|
||||
if type_class == 0x00 {
|
||||
// standard
|
||||
match ctrl.b_request {
|
||||
0x06 => {
|
||||
// GET_DESCRIPTOR
|
||||
let dt = (ctrl.w_value >> 8) as u8;
|
||||
let di = (ctrl.w_value & 0xff) as u8;
|
||||
let resp: Vec<u8> = match dt {
|
||||
1 => DEV_DESC.to_vec(),
|
||||
2 => cfg.to_vec(),
|
||||
3 => string_desc(di, serial),
|
||||
HID_RPT_DT => match idx {
|
||||
0 => RDESC_MOUSE.to_vec(),
|
||||
1 => RDESC_KBD.to_vec(),
|
||||
_ => RDESC_CTRL.to_vec(),
|
||||
},
|
||||
HID_DT => {
|
||||
// re-emit the interface's HID descriptor from the config blob (best effort)
|
||||
hid_desc_for(cfg, idx)
|
||||
}
|
||||
_ => {
|
||||
ep0_stall(raw);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let n = resp.len().min(wl);
|
||||
ep0_write(raw, &resp[..n]);
|
||||
}
|
||||
0x09 => {
|
||||
// SET_CONFIGURATION
|
||||
ioctl_val(raw, IOCTL_VBUS_DRAW, 0x32);
|
||||
ioctl_none(raw, IOCTL_CONFIGURE);
|
||||
enable_endpoints(raw, ctrl_ep);
|
||||
ep0_ack(raw);
|
||||
configured.store(true, Ordering::SeqCst);
|
||||
}
|
||||
0x0b => ep0_ack(raw), // SET_INTERFACE
|
||||
0x00 => {
|
||||
let st = 0u16;
|
||||
ep0_write(raw, &st.to_le_bytes());
|
||||
}
|
||||
_ => ep0_stall(raw),
|
||||
}
|
||||
} else if type_class == 0x20 {
|
||||
// HID class
|
||||
match ctrl.b_request {
|
||||
0x01 => {
|
||||
// GET_REPORT — serve the Deck feature reply for the last requested command.
|
||||
let resp = feature_reply(last_set, serial, unit_id);
|
||||
let n = resp.len().min(wl);
|
||||
ep0_write(raw, &resp[..n]);
|
||||
}
|
||||
0x09 => {
|
||||
// SET_REPORT — read the host's data; remember it + extract feedback.
|
||||
let (r, data) = ep0_read(raw, wl);
|
||||
if r > 0 {
|
||||
*last_set = data.clone();
|
||||
// parse_steam_output expects [report-id(0), cmd, …]; EP0 OUT data is [cmd, …].
|
||||
let mut framed = Vec::with_capacity(data.len() + 1);
|
||||
framed.push(0);
|
||||
framed.extend_from_slice(&data);
|
||||
let fb = super::steam_proto::parse_steam_output(&framed);
|
||||
if fb.rumble.is_some() {
|
||||
if let Ok(mut g) = feedback.lock() {
|
||||
*g = fb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0x0a | 0x0b => ep0_ack(raw), // SET_IDLE / SET_PROTOCOL
|
||||
0x03 => {
|
||||
ep0_write(raw, &[0u8]);
|
||||
} // GET_PROTOCOL
|
||||
_ => ep0_stall(raw),
|
||||
}
|
||||
} else {
|
||||
ep0_stall(raw);
|
||||
}
|
||||
}
|
||||
|
||||
fn hid_desc_for(cfg: &[u8], idx: u8) -> Vec<u8> {
|
||||
// The HID descriptors live right after each interface descriptor in the config blob.
|
||||
// Offsets: cfg(9) | i0(9) h0(9) e0(7) | i1(9) h1(9) e1(7) | i2(9) h2(9) e2(7)
|
||||
let off = match idx {
|
||||
0 => 9 + 9,
|
||||
1 => 9 + 25 + 9,
|
||||
_ => 9 + 50 + 9,
|
||||
};
|
||||
cfg.get(off..off + 9)
|
||||
.map(|s| s.to_vec())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn enable_endpoints(raw: RawFd, ctrl_ep: &std::sync::atomic::AtomicI32) {
|
||||
let mk = |addr: u8, mps: u16| UsbEndpointDescriptor {
|
||||
b_length: 7,
|
||||
b_descriptor_type: 5,
|
||||
b_endpoint_address: addr,
|
||||
bm_attributes: 0x03,
|
||||
w_max_packet_size: mps,
|
||||
b_interval: 4,
|
||||
..Default::default()
|
||||
};
|
||||
let e0 = mk(0x81, 8);
|
||||
let e1 = mk(0x82, 8);
|
||||
let e2 = mk(0x83, 64);
|
||||
ioctl_ptr(raw, IOCTL_EP_ENABLE, &e0 as *const _);
|
||||
ioctl_ptr(raw, IOCTL_EP_ENABLE, &e1 as *const _);
|
||||
let h2 = ioctl_ptr(raw, IOCTL_EP_ENABLE, &e2 as *const _);
|
||||
ctrl_ep.store(h2, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn stream_loop(
|
||||
fd: Arc<GadgetFd>,
|
||||
running: Arc<AtomicBool>,
|
||||
ctrl_ep: Arc<std::sync::atomic::AtomicI32>,
|
||||
configured: Arc<AtomicBool>,
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
) {
|
||||
let raw = fd.0;
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let ep = ctrl_ep.load(Ordering::SeqCst);
|
||||
if configured.load(Ordering::SeqCst) && ep >= 0 {
|
||||
let r = report
|
||||
.lock()
|
||||
.map(|g| *g)
|
||||
.unwrap_or_else(|_| neutral_deck_report());
|
||||
let mut buf = [0u8; EPIO_HDR + 64];
|
||||
buf[0..2].copy_from_slice(&(ep as u16).to_ne_bytes());
|
||||
buf[4..8].copy_from_slice(&(64u32).to_ne_bytes());
|
||||
buf[EPIO_HDR..].copy_from_slice(&r);
|
||||
// Blocks until the host polls the interrupt-IN endpoint; that's fine on its own thread.
|
||||
ioctl_ptr(raw, IOCTL_EP_WRITE, buf.as_ptr());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(8));
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort load of the gadget modules (SteamOS ships `dummy_hcd` + `raw_gadget`). Failures are
|
||||
/// ignored — the caller falls back to UHID if `/dev/raw-gadget` is then still unusable.
|
||||
pub fn ensure_modules() {
|
||||
for m in ["dummy_hcd", "raw_gadget"] {
|
||||
let _ = std::process::Command::new("modprobe").arg(m).status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether to prefer the USB-gadget Deck over the UHID `SteamDeckPad` — the only transport Steam Input
|
||||
/// promotes (validated glass-to-glass on a Deck). Defaults **on for SteamOS** hosts (which ship the
|
||||
/// gadget modules + run Steam Input); off elsewhere, where the universal UHID path stays the default.
|
||||
/// `PUNKTFUNK_STEAM_GADGET=1`/`0` forces it on/off. A Deck-as-host with a *physical* Deck never reaches
|
||||
/// here: `resolve_gamepad`'s conflict gate degrades `SteamDeck` → DualSense before the manager is built.
|
||||
pub fn gadget_preferred() -> bool {
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_STEAM_GADGET") {
|
||||
return v == "1" || v.eq_ignore_ascii_case("true");
|
||||
}
|
||||
is_steamos()
|
||||
}
|
||||
|
||||
/// True on SteamOS-class hosts (`/etc/os-release` `ID=steamos`, or `ID_LIKE` naming it).
|
||||
fn is_steamos() -> bool {
|
||||
std::fs::read_to_string("/etc/os-release")
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.any(|l| l == "ID=steamos" || (l.starts_with("ID_LIKE=") && l.contains("steamos")))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
//! Virtual Steam Deck over **USB/IP** (`vhci_hcd`) — the shippable, Secure-Boot-clean, universal
|
||||
//! alternative to [`super::steam_gadget`] (`raw_gadget` + `dummy_hcd`, SteamOS-only).
|
||||
//!
|
||||
//! Like the gadget, this presents a *real* 3-interface USB Steam Deck (mouse = interface 0, keyboard
|
||||
//! = 1, **controller = 2**) — the interface-2 layout Steam's own driver filters on, so Steam Input
|
||||
//! promotes it (a UHID Deck, `Interface: -1`, never is). Unlike the gadget it needs no out-of-tree
|
||||
//! module: `vhci_hcd` is in-tree + signed on SteamOS, Bazzite, and ~every distro, loads under Secure
|
||||
//! Boot, and needs no MOK. A userspace [`usbip_sim`] server emulates the Deck; the local `vhci_hcd`
|
||||
//! attaches it. **Validated on Bazzite**: `vhci_hcd` enumerates the 3-interface Deck, `hid-steam`
|
||||
//! binds it, and Steam reserves an XInput slot — identical recognition to the gadget.
|
||||
//!
|
||||
//! The device model + the USB/IP protocol come from the vendored [`usbip_sim`] crate (the upstream
|
||||
//! `usbip` crate trimmed of its libusb host mode); the captured descriptors + the `0x83`/`0xAE`
|
||||
//! feature contract come from the shared [`super::steam_proto`] (one source of truth with the gadget).
|
||||
//!
|
||||
//! **Attach** is in-process by default (no external `usbip` CLI dependency — the production goal): we
|
||||
//! run the emulation server on a loopback TCP port, connect to it ourselves, perform the
|
||||
//! `OP_REQ_IMPORT` handshake, then hand the connected socket fd to `vhci_hcd` via its sysfs `attach`
|
||||
//! file. If anything in that path fails we fall back to the widely-packaged `usbip` CLI; if *that*
|
||||
//! also fails, [`open`](SteamDeckUsbip::open) returns `Err` and the caller degrades to UHID.
|
||||
|
||||
use super::steam_proto::{
|
||||
deck_serial, deck_unit_id, feature_reply, neutral_deck_report, parse_steam_output,
|
||||
SteamFeedback, SteamState, RDESC_DECK_CTRL, RDESC_DECK_KBD, RDESC_DECK_MOUSE,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::any::Any;
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use usbip_sim::{
|
||||
Direction, SetupPacket, UsbDevice, UsbEndpoint, UsbInterface, UsbInterfaceHandler, UsbIpServer,
|
||||
Version,
|
||||
};
|
||||
|
||||
const STEAM_VENDOR: u16 = 0x28DE;
|
||||
const STEAMDECK_PRODUCT: u16 = 0x1205;
|
||||
/// The single device's USB/IP bus id (one device per server, so the fixed default is fine).
|
||||
const BUS_ID: &str = "0-0-0";
|
||||
/// The usbip default TCP port — the server must listen here for the `usbip` CLI fallback to attach.
|
||||
const USBIP_TCP_PORT: u16 = 3240;
|
||||
|
||||
/// Build the 9-byte HID class descriptor inserted between the interface and endpoint descriptors.
|
||||
fn hid_desc(report_len: usize, country: u8) -> Vec<u8> {
|
||||
let l = report_len as u16;
|
||||
#[rustfmt::skip]
|
||||
let d = vec![0x09, 0x21, 0x10, 0x01, country, 1, 0x22, (l & 0xff) as u8, (l >> 8) as u8];
|
||||
d
|
||||
}
|
||||
|
||||
/// The Deck **controller** interface (vendor HID, interface 2): answers the HID feature reports
|
||||
/// (descriptor / `0x83` attributes / `0xAE` serial), streams the current 64-byte state on the
|
||||
/// interrupt-IN endpoint, and surfaces rumble written via SET_REPORT.
|
||||
#[derive(Debug)]
|
||||
struct ControllerHandler {
|
||||
/// The current 64-byte Deck input report, shared with [`SteamDeckUsbip::write_state`].
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
/// Rumble extracted from the kernel's SET_REPORTs, drained by [`SteamDeckUsbip::service`].
|
||||
feedback: Arc<Mutex<SteamFeedback>>,
|
||||
/// The host's last SET_REPORT command (drives [`feature_reply`]).
|
||||
last_set: Vec<u8>,
|
||||
serial: String,
|
||||
unit_id: u32,
|
||||
}
|
||||
|
||||
impl UsbInterfaceHandler for ControllerHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
hid_desc(RDESC_DECK_CTRL.len(), 33)
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_interface: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_len: u32,
|
||||
setup: SetupPacket,
|
||||
req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0() {
|
||||
Ok(match (setup.request_type, setup.request) {
|
||||
// GET report descriptor (standard, interface recipient).
|
||||
(0x81, 0x06) if (setup.value >> 8) == 0x22 => RDESC_DECK_CTRL.to_vec(),
|
||||
// HID GET_REPORT (feature) — the Deck `0x83`/`0xAE` contract.
|
||||
(0xA1, 0x01) => feature_reply(&self.last_set, &self.serial, self.unit_id).to_vec(),
|
||||
// HID SET_REPORT — remember the command (for the next feature reply) + surface rumble.
|
||||
(0x21, 0x09) => {
|
||||
self.last_set = req.to_vec();
|
||||
// `parse_steam_output` expects `[report-id(0), cmd, …]`; EP0 OUT data is `[cmd, …]`.
|
||||
let mut framed = Vec::with_capacity(req.len() + 1);
|
||||
framed.push(0);
|
||||
framed.extend_from_slice(req);
|
||||
let fb = parse_steam_output(&framed);
|
||||
if fb.rumble.is_some() {
|
||||
if let Ok(mut g) = self.feedback.lock() {
|
||||
*g = fb;
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
(0x21, 0x0A) | (0x21, 0x0B) => vec![], // SET_IDLE / SET_PROTOCOL
|
||||
_ => vec![],
|
||||
})
|
||||
} else if let Direction::In = ep.direction() {
|
||||
// Interrupt-IN poll: return the current report. The vendored sim paces interrupt-IN by
|
||||
// bInterval (vhci_hcd does NOT throttle the server side), so this isn't a busy spin.
|
||||
let r = self
|
||||
.report
|
||||
.lock()
|
||||
.map(|g| *g)
|
||||
.unwrap_or_else(|_| neutral_deck_report());
|
||||
Ok(r.to_vec())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal idle HID interface (mouse / keyboard) — serves only its report descriptor.
|
||||
#[derive(Debug)]
|
||||
struct IdleHidHandler {
|
||||
report_desc: Vec<u8>,
|
||||
}
|
||||
impl UsbInterfaceHandler for IdleHidHandler {
|
||||
fn get_class_specific_descriptor(&self) -> Vec<u8> {
|
||||
hid_desc(self.report_desc.len(), 0)
|
||||
}
|
||||
fn handle_urb(
|
||||
&mut self,
|
||||
_i: &UsbInterface,
|
||||
ep: UsbEndpoint,
|
||||
_l: u32,
|
||||
setup: SetupPacket,
|
||||
_req: &[u8],
|
||||
) -> std::io::Result<Vec<u8>> {
|
||||
if ep.is_ep0() && setup.request == 0x06 && (setup.value >> 8) == 0x22 {
|
||||
Ok(self.report_desc.clone())
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
fn as_any(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn boxed(
|
||||
h: impl UsbInterfaceHandler + Send + 'static,
|
||||
) -> Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>> {
|
||||
Arc::new(Mutex::new(Box::new(h)))
|
||||
}
|
||||
fn ep(addr: u8, mps: u16) -> UsbEndpoint {
|
||||
UsbEndpoint {
|
||||
address: addr,
|
||||
attributes: 0x03, // interrupt
|
||||
max_packet_size: mps,
|
||||
interval: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the simulated 3-interface USB Deck. The controller handler shares `report` + `feedback`
|
||||
/// with the owning [`SteamDeckUsbip`].
|
||||
fn build_device(
|
||||
index: u8,
|
||||
report: &Arc<Mutex<[u8; 64]>>,
|
||||
feedback: &Arc<Mutex<SteamFeedback>>,
|
||||
) -> UsbDevice {
|
||||
let mut dev = UsbDevice::new(0); // one device per server; bus_id stays the default "0-0-0".
|
||||
dev.vendor_id = STEAM_VENDOR;
|
||||
dev.product_id = STEAMDECK_PRODUCT;
|
||||
dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00
|
||||
dev.device_bcd = Version::from(0x0300u16); // bcdDevice 3.00 (matches the gadget)
|
||||
dev.set_manufacturer_name("Valve Software");
|
||||
dev.set_product_name("Steam Deck Controller");
|
||||
dev.set_serial_number(&deck_serial(index));
|
||||
dev.with_interface(
|
||||
0x03,
|
||||
0x00,
|
||||
0x02,
|
||||
Some("mouse"),
|
||||
vec![ep(0x81, 8)],
|
||||
boxed(IdleHidHandler {
|
||||
report_desc: RDESC_DECK_MOUSE.to_vec(),
|
||||
}),
|
||||
)
|
||||
.with_interface(
|
||||
0x03,
|
||||
0x01,
|
||||
0x01,
|
||||
Some("keyboard"),
|
||||
vec![ep(0x82, 8)],
|
||||
boxed(IdleHidHandler {
|
||||
report_desc: RDESC_DECK_KBD.to_vec(),
|
||||
}),
|
||||
)
|
||||
.with_interface(
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
Some("controller"),
|
||||
vec![ep(0x83, 64)],
|
||||
boxed(ControllerHandler {
|
||||
report: report.clone(),
|
||||
feedback: feedback.clone(),
|
||||
last_set: vec![],
|
||||
serial: deck_serial(index),
|
||||
unit_id: deck_unit_id(index),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Owns the emulation-server thread (a dedicated current-thread tokio runtime) and stops it on drop.
|
||||
/// Run on its own thread so `SteamDeckUsbip::open` works whether or not the caller is inside a tokio
|
||||
/// runtime (creating a runtime inside one would panic).
|
||||
struct ServerThread {
|
||||
stop: Arc<tokio::sync::Notify>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ServerThread {
|
||||
/// Spawn the server on `listener`, serving exactly the one simulated `dev`.
|
||||
fn spawn(listener: std::net::TcpListener, dev: UsbDevice) -> Result<ServerThread> {
|
||||
let stop = Arc::new(tokio::sync::Notify::new());
|
||||
let stop_t = stop.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("pf-deck-usbip".into())
|
||||
.spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "usbip server runtime build failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(run_server(
|
||||
listener,
|
||||
Arc::new(UsbIpServer::new_simulated(vec![dev])),
|
||||
stop_t,
|
||||
));
|
||||
})
|
||||
.context("spawn usbip server thread")?;
|
||||
Ok(ServerThread {
|
||||
stop,
|
||||
join: Some(join),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServerThread {
|
||||
fn drop(&mut self) {
|
||||
self.stop.notify_one();
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped.
|
||||
async fn run_server(
|
||||
listener: std::net::TcpListener,
|
||||
server: Arc<UsbIpServer>,
|
||||
stop: Arc<tokio::sync::Notify>,
|
||||
) {
|
||||
let listener = match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "usbip TcpListener::from_std failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = stop.notified() => break,
|
||||
r = listener.accept() => match r {
|
||||
Ok((mut sock, _)) => {
|
||||
// URB replies are small and interleave with the kernel's next SUBMITs; without
|
||||
// TCP_NODELAY the multi-interface request/response pattern collapses into
|
||||
// ~40 ms Nagle/delayed-ACK stalls (observed as ~22 reports/s on the Puck's
|
||||
// active hidraw against a 266 Hz source).
|
||||
sock.set_nodelay(true).ok();
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = usbip_sim::handler(&mut sock, server).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "usbip accept error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A usbip-attached simulated device: the `vhci_hcd` port plus the socket + emulation server
|
||||
/// keeping it alive. Dropping it detaches the port FIRST (the kernel closes its socket end and
|
||||
/// tears the device down — Steam releases its slot), then drops the socket and stops the server —
|
||||
/// the teardown order the Deck transport shipped with. Shared by every usbip-presented pad
|
||||
/// (the Deck here, the Steam Controller 2 in [`super::triton_usbip`]).
|
||||
pub(crate) struct UsbipAttachment {
|
||||
/// The `vhci_hcd` port we attached to — written to the sysfs `detach` file on drop.
|
||||
vhci_port: u16,
|
||||
/// Kept alive so the connected socket fd we handed to `vhci_hcd` stays valid (in-process attach
|
||||
/// only; the CLI hands its own fd to the kernel and exits, so this is `None` there).
|
||||
_client_sock: Option<TcpStream>,
|
||||
/// Emulation-server thread; dropped (stopped) after the detach.
|
||||
_server: ServerThread,
|
||||
}
|
||||
|
||||
impl Drop for UsbipAttachment {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = vhci_detach(self.vhci_port) {
|
||||
tracing::debug!(port = self.vhci_port, error = %e, "vhci detach failed (device may already be gone)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a simulated USB device locally via `vhci_hcd`. Requires `vhci_hcd` loaded and root
|
||||
/// (the sysfs attach / the CLI both need it). Tries the in-process sysfs attach first, then the
|
||||
/// `usbip` CLI; `PUNKTFUNK_USBIP_ATTACH=inproc|cli` pins one path (for debugging). `build` is
|
||||
/// invoked once per attempted path (a [`UsbDevice`] isn't reusable across servers); `label`
|
||||
/// names the device in the attach log lines.
|
||||
pub(crate) fn attach_device(build: impl Fn() -> UsbDevice, label: &str) -> Result<UsbipAttachment> {
|
||||
ensure_modules();
|
||||
if vhci_base().is_none() {
|
||||
bail!("vhci_hcd unavailable (no /sys/devices/platform/vhci_hcd*/status) — is it loaded?");
|
||||
}
|
||||
let mode = std::env::var("PUNKTFUNK_USBIP_ATTACH").ok();
|
||||
if mode.as_deref() != Some("cli") {
|
||||
match attach_in_process(build(), label) {
|
||||
Ok(a) => return Ok(a),
|
||||
Err(e) if mode.as_deref() == Some("inproc") => return Err(e),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "in-process vhci attach failed — trying the usbip CLI")
|
||||
}
|
||||
}
|
||||
}
|
||||
attach_via_cli(build(), label)
|
||||
}
|
||||
|
||||
/// In-process attach: emulate on a loopback port, do the import handshake ourselves, hand the
|
||||
/// connected socket to `vhci_hcd` via sysfs. No external dependency.
|
||||
fn attach_in_process(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
|
||||
// An ephemeral loopback port (avoids contending the usbip default with another pad).
|
||||
let listener =
|
||||
std::net::TcpListener::bind(("127.0.0.1", 0)).context("bind loopback usbip server")?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.context("usbip server local_addr")?
|
||||
.port();
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.context("usbip listener set_nonblocking")?;
|
||||
let server = ServerThread::spawn(listener, dev)?;
|
||||
|
||||
// Connect to our own server and run the OP_REQ_IMPORT handshake.
|
||||
let mut sock = connect_loopback(port).context("connect to usbip server")?;
|
||||
let (devid, speed) = import_handshake(&mut sock).context("usbip import handshake")?;
|
||||
|
||||
// Hand the connected socket to vhci_hcd. Clear BOTH timeouts first: the kernel's vhci rx/tx
|
||||
// threads honour SO_RCVTIMEO/SO_SNDTIMEO on this socket, so the 3s handshake timeouts would
|
||||
// otherwise tear the device down after 3s idle (rx) or a 3s-blocked send (tx).
|
||||
let vhci_port = vhci_find_free_port(speed).context("find a free vhci port")?;
|
||||
sock.set_read_timeout(None).ok();
|
||||
sock.set_write_timeout(None).ok();
|
||||
vhci_attach(vhci_port, sock.as_raw_fd(), devid, speed).context("write vhci_hcd attach")?;
|
||||
|
||||
tracing::info!(
|
||||
label,
|
||||
vhci_port,
|
||||
"attached via usbip (in-process — Steam Input recognizes it)"
|
||||
);
|
||||
Ok(UsbipAttachment {
|
||||
vhci_port,
|
||||
_client_sock: Some(sock),
|
||||
_server: server,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fallback: emulate on the usbip default port and let the `usbip` CLI attach (it picks the vhci
|
||||
/// port itself; we recover it by diffing the sysfs status).
|
||||
fn attach_via_cli(dev: UsbDevice, label: &str) -> Result<UsbipAttachment> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", USBIP_TCP_PORT))
|
||||
.with_context(|| format!("bind usbip default port {USBIP_TCP_PORT} for CLI attach"))?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.context("usbip listener set_nonblocking")?;
|
||||
let server = ServerThread::spawn(listener, dev)?;
|
||||
|
||||
let before = vhci_used_ports();
|
||||
usbip_attach_cli().context("usbip CLI attach")?;
|
||||
let vhci_port = wait_for_new_port(&before)
|
||||
.context("could not determine the vhci port the usbip CLI attached to")?;
|
||||
|
||||
tracing::info!(
|
||||
label,
|
||||
vhci_port,
|
||||
"attached via usbip (CLI — Steam Input recognizes it)"
|
||||
);
|
||||
Ok(UsbipAttachment {
|
||||
vhci_port,
|
||||
_client_sock: None,
|
||||
_server: server,
|
||||
})
|
||||
}
|
||||
|
||||
/// A virtual Steam Deck presented over USB/IP. Dropping it detaches the `vhci_hcd` port (the device
|
||||
/// disappears, Steam releases its slot) and stops the emulation server.
|
||||
pub struct SteamDeckUsbip {
|
||||
report: Arc<Mutex<[u8; 64]>>,
|
||||
feedback: Arc<Mutex<SteamFeedback>>,
|
||||
_attach: UsbipAttachment,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl SteamDeckUsbip {
|
||||
/// Bind a virtual Deck and attach it locally via `vhci_hcd`. `index` varies only the serial.
|
||||
pub fn open(index: u8) -> Result<SteamDeckUsbip> {
|
||||
let report = Arc::new(Mutex::new(neutral_deck_report()));
|
||||
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
|
||||
let attach = attach_device(
|
||||
|| build_device(index, &report, &feedback),
|
||||
&format!("virtual Steam Deck {index}"),
|
||||
)?;
|
||||
Ok(SteamDeckUsbip {
|
||||
report,
|
||||
feedback,
|
||||
_attach: attach,
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into the 64-byte Deck report streamed on the controller interrupt-IN endpoint.
|
||||
pub fn write_state(&mut self, st: &SteamState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; 64];
|
||||
super::steam_proto::serialize_deck_state(&mut r, st, self.seq);
|
||||
if let Ok(mut g) = self.report.lock() {
|
||||
*g = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any rumble feedback the kernel/Steam wrote to the device.
|
||||
pub fn service(&mut self) -> SteamFeedback {
|
||||
self.feedback
|
||||
.lock()
|
||||
.map(|mut f| std::mem::take(&mut *f))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- USB/IP import handshake (we act as the usbip *client* before handing the fd to the kernel) ----
|
||||
|
||||
const USBIP_VERSION: u16 = 0x0111;
|
||||
const OP_REQ_IMPORT: u16 = 0x8003;
|
||||
|
||||
/// Connect to our own loopback server, retrying briefly while the server thread comes up.
|
||||
fn connect_loopback(port: u16) -> Result<TcpStream> {
|
||||
let addr = ("127.0.0.1", port);
|
||||
let mut last = None;
|
||||
for _ in 0..50 {
|
||||
match TcpStream::connect(addr) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
return Ok(s);
|
||||
}
|
||||
Err(e) => {
|
||||
last = Some(e);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"connect 127.0.0.1:{port}: {}",
|
||||
last.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Send `OP_REQ_IMPORT` for [`BUS_ID`] and read `OP_REP_IMPORT`, returning `(devid, speed)` parsed
|
||||
/// from the device record (the same `devid = bus_num<<16 | dev_num` + speed `vhci_hcd` wants). The
|
||||
/// whole 320-byte reply MUST be consumed here so the socket starts clean at the kernel's first
|
||||
/// `USBIP_CMD_SUBMIT`.
|
||||
fn import_handshake(sock: &mut TcpStream) -> Result<(u32, u32)> {
|
||||
// Bounded so a non-responsive server can't head-block the per-session input thread (this talks
|
||||
// to our own in-process loopback server, so a working handshake completes in well under a ms).
|
||||
sock.set_read_timeout(Some(Duration::from_secs(1))).ok();
|
||||
sock.set_write_timeout(Some(Duration::from_secs(1))).ok();
|
||||
|
||||
let mut req = Vec::with_capacity(40);
|
||||
req.extend_from_slice(&USBIP_VERSION.to_be_bytes());
|
||||
req.extend_from_slice(&OP_REQ_IMPORT.to_be_bytes());
|
||||
req.extend_from_slice(&0u32.to_be_bytes()); // status
|
||||
let mut busid = [0u8; 32];
|
||||
let b = BUS_ID.as_bytes();
|
||||
busid[..b.len()].copy_from_slice(b);
|
||||
req.extend_from_slice(&busid);
|
||||
sock.write_all(&req).context("send OP_REQ_IMPORT")?;
|
||||
|
||||
// Reply: version(2) code(2) status(4), then the 312-byte device record on success.
|
||||
let mut header = [0u8; 8];
|
||||
sock.read_exact(&mut header)
|
||||
.context("read OP_REP_IMPORT header")?;
|
||||
let status = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);
|
||||
if status != 0 {
|
||||
bail!("OP_REP_IMPORT refused (status={status}) — device {BUS_ID} not exported?");
|
||||
}
|
||||
let mut dev = [0u8; 312];
|
||||
sock.read_exact(&mut dev)
|
||||
.context("read OP_REP_IMPORT device record")?;
|
||||
// Device record layout: path[256], bus_id[32], bus_num(4 BE)@288, dev_num(4 BE)@292, speed(4)@296.
|
||||
let be = |o: usize| u32::from_be_bytes([dev[o], dev[o + 1], dev[o + 2], dev[o + 3]]);
|
||||
let bus_num = be(288);
|
||||
let dev_num = be(292);
|
||||
let speed = be(296);
|
||||
Ok(((bus_num << 16) | dev_num, speed))
|
||||
}
|
||||
|
||||
// ---- vhci_hcd sysfs plumbing ----
|
||||
|
||||
/// Best-effort load of `vhci_hcd` (in-tree + signed on SteamOS/Bazzite/most distros).
|
||||
pub fn ensure_modules() {
|
||||
let _ = Command::new("modprobe").arg("vhci_hcd").status();
|
||||
}
|
||||
|
||||
/// Run `usbip attach -r 127.0.0.1 -b 0-0-0`, bounded by a deadline so a hung CLI can't head-block
|
||||
/// the per-session input thread indefinitely (the caller runs this inline on that thread).
|
||||
fn usbip_attach_cli() -> Result<()> {
|
||||
let mut child = Command::new("usbip")
|
||||
.args(["attach", "-r", "127.0.0.1", "-b", BUS_ID])
|
||||
.spawn()
|
||||
.context("spawn `usbip attach` (is usbip-utils installed?)")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
loop {
|
||||
match child.try_wait().context("wait on `usbip attach`")? {
|
||||
Some(st) if st.success() => return Ok(()),
|
||||
Some(st) => bail!("`usbip attach` exited with {st}"),
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
bail!("`usbip attach` timed out (>6s) — killed");
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a usbip attach should be attempted at all. Default on (the universal Steam-promotable
|
||||
/// transport on non-SteamOS hosts); `PUNKTFUNK_STEAM_USBIP=0` forces it off, `=1` forces it on.
|
||||
/// [`open`](SteamDeckUsbip::open) still degrades gracefully if `vhci_hcd` turns out to be absent.
|
||||
pub fn usbip_preferred() -> bool {
|
||||
!matches!(
|
||||
std::env::var("PUNKTFUNK_STEAM_USBIP").ok().as_deref(),
|
||||
Some("0") | Some("false")
|
||||
)
|
||||
}
|
||||
|
||||
/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present.
|
||||
fn vhci_base() -> Option<PathBuf> {
|
||||
for p in [
|
||||
"/sys/devices/platform/vhci_hcd.0",
|
||||
"/sys/devices/platform/vhci_hcd",
|
||||
] {
|
||||
let base = Path::new(p);
|
||||
if base.join("status").exists() {
|
||||
return Some(base.to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_status() -> Result<String> {
|
||||
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
||||
std::fs::read_to_string(base.join("status")).context("read vhci_hcd status")
|
||||
}
|
||||
|
||||
/// One parsed `status` row: `(port, hub_is_superspeed, sta)`. Handles both the modern
|
||||
/// `hub port sta …` and the legacy `port sta …` column layouts; returns `None` for header/blank rows.
|
||||
fn parse_status_row(line: &str) -> Option<(u16, bool, u32)> {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (hub_ss, port_str, sta_str) = if t[0] == "hs" || t[0] == "ss" {
|
||||
(Some(t[0] == "ss"), *t.get(1)?, *t.get(2)?)
|
||||
} else if t[0].chars().all(|c| c.is_ascii_digit()) {
|
||||
(None, t[0], *t.get(1)?) // legacy: port sta …
|
||||
} else {
|
||||
return None; // header ("hub"/"prt"/"port" …)
|
||||
};
|
||||
let port = port_str.parse::<u16>().ok()?;
|
||||
let sta = sta_str.parse::<u32>().ok()?;
|
||||
Some((port, hub_ss.unwrap_or(false), sta))
|
||||
}
|
||||
|
||||
/// `sta == 4` is `VDEV_ST_NULL` (a free port).
|
||||
const VDEV_ST_NULL: u32 = 4;
|
||||
|
||||
/// Pick a free `vhci_hcd` port matching the device speed (`usbip_speed >= 5` ⇒ SuperSpeed hub).
|
||||
fn vhci_find_free_port(usbip_speed: u32) -> Result<u16> {
|
||||
let want_ss = usbip_speed >= 5;
|
||||
let status = read_status()?;
|
||||
for line in status.lines() {
|
||||
if let Some((port, is_ss, sta)) = parse_status_row(line) {
|
||||
if sta == VDEV_ST_NULL && is_ss == want_ss {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Speed-class match failed (legacy single-hub status): take any free port.
|
||||
for line in status.lines() {
|
||||
if let Some((port, _, sta)) = parse_status_row(line) {
|
||||
if sta == VDEV_ST_NULL {
|
||||
return Ok(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("no free vhci_hcd port (all ports in use?)")
|
||||
}
|
||||
|
||||
/// Ports currently in use (`sta != VDEV_ST_NULL`) — snapshotted around a CLI attach to recover its port.
|
||||
fn vhci_used_ports() -> HashSet<u16> {
|
||||
read_status()
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(parse_status_row)
|
||||
.filter(|&(_, _, sta)| sta != VDEV_ST_NULL)
|
||||
.map(|(port, _, _)| port)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Poll the status file (briefly) for a port that became used since `before` — the one the CLI attached.
|
||||
fn wait_for_new_port(before: &HashSet<u16>) -> Result<u16> {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if let Some(p) = vhci_used_ports().difference(before).copied().min() {
|
||||
return Ok(p);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("no newly-attached vhci port appeared after `usbip attach`");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn vhci_attach(port: u16, sockfd: i32, devid: u32, speed: u32) -> Result<()> {
|
||||
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
||||
let line = format!("{port} {sockfd} {devid} {speed}");
|
||||
std::fs::write(base.join("attach"), line)
|
||||
.with_context(|| format!("write vhci_hcd attach (port {port}) — root?"))
|
||||
}
|
||||
|
||||
fn vhci_detach(port: u16) -> Result<()> {
|
||||
let base = vhci_base().context("vhci_hcd sysfs not present")?;
|
||||
std::fs::write(base.join("detach"), format!("{port}")).context("write vhci_hcd detach")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `status` parser handles the modern `hub port sta …` layout, the legacy `port sta …`
|
||||
/// layout, and skips header/blank lines — a slip here would mean attaching to a busy port.
|
||||
#[test]
|
||||
fn status_parser_handles_both_layouts() {
|
||||
// modern
|
||||
assert_eq!(
|
||||
parse_status_row("hs 0000 004 000 00000000 000000 0-0"),
|
||||
Some((0, false, 4))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_status_row("ss 0008 006 000 00000000 000000 0-0"),
|
||||
Some((8, true, 6))
|
||||
);
|
||||
// legacy (no hub column)
|
||||
assert_eq!(
|
||||
parse_status_row("0001 004 000 00000000 000000 0-0"),
|
||||
Some((1, false, 4))
|
||||
);
|
||||
// header / blank
|
||||
assert_eq!(
|
||||
parse_status_row("hub port sta spd dev sockfd local_busid"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_status_row(""), None);
|
||||
}
|
||||
|
||||
/// A free HS port is preferred for an HS device; a free SS port for an SS device.
|
||||
#[test]
|
||||
fn free_port_selection_matches_speed() {
|
||||
let status = "hub port sta spd dev sockfd local_busid\n\
|
||||
hs 0000 006 000 00000000 000000 0-0\n\
|
||||
hs 0001 004 000 00000000 000000 0-0\n\
|
||||
ss 0008 004 000 00000000 000000 0-0\n";
|
||||
// Reuse the parser directly (vhci_find_free_port reads sysfs; test the selection logic).
|
||||
let hs = status
|
||||
.lines()
|
||||
.filter_map(parse_status_row)
|
||||
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && !is_ss)
|
||||
.map(|(p, _, _)| p);
|
||||
let ss = status
|
||||
.lines()
|
||||
.filter_map(parse_status_row)
|
||||
.find(|&(_, is_ss, sta)| sta == VDEV_ST_NULL && is_ss)
|
||||
.map(|(p, _, _)| p);
|
||||
assert_eq!(hs, Some(1));
|
||||
assert_eq!(ss, Some(8));
|
||||
}
|
||||
|
||||
/// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds
|
||||
/// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI.
|
||||
#[test]
|
||||
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
|
||||
fn usbip_deck_binds_and_tears_down() {
|
||||
ensure_modules();
|
||||
let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)");
|
||||
let st = SteamState::from_gamepad(punktfunk_core::input::gamepad::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(800) {
|
||||
pad.write_state(&st);
|
||||
let _ = pad.service();
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
devs.contains("Steam Deck"),
|
||||
"hid-steam did not bind the usbip Deck"
|
||||
);
|
||||
drop(pad);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
||||
assert!(
|
||||
!devs.contains("Steam Deck Motion Sensors"),
|
||||
"device not torn down on drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// On-box smoke test (needs root + `vhci_hcd`): rumble the attached virtual Deck exactly like
|
||||
/// Steam does — a `0xEB` feature SET_REPORT on the hid-steam hidraw node — and confirm
|
||||
/// [`SteamDeckUsbip::service`] surfaces `(left, right)` for the 0xCA plane. The Deck presents
|
||||
/// 3 interfaces (0 mouse / 1 kbd / 2 controller); only the CONTROLLER interface's EP0 handler
|
||||
/// parses feedback (the idle interfaces ACK silently, like real hardware), and Steam filters
|
||||
/// on interface 2 — so the write must land there. `#[ignore]`d in CI.
|
||||
#[test]
|
||||
#[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"]
|
||||
fn usbip_deck_rumble_flows_via_controller_interface() {
|
||||
use super::super::steam_proto::ID_TRIGGER_RUMBLE_CMD;
|
||||
ensure_modules();
|
||||
let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)");
|
||||
let st = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(1500) {
|
||||
pad.write_state(&st);
|
||||
let _ = pad.service();
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
// The hid-steam hidraw node on USB interface 2 (bInterfaceNumber is the HID device's
|
||||
// parent attribute).
|
||||
let node = std::fs::read_dir("/sys/class/hidraw")
|
||||
.expect("/sys/class/hidraw")
|
||||
.flatten()
|
||||
.find_map(|e| {
|
||||
let ue =
|
||||
std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default();
|
||||
let iface = std::fs::read_to_string(e.path().join("device/../bInterfaceNumber"))
|
||||
.ok()
|
||||
.and_then(|s| u8::from_str_radix(s.trim(), 16).ok());
|
||||
(ue.lines().any(|l| l == "DRIVER=hid-steam") && iface == Some(2))
|
||||
.then(|| format!("/dev/{}", e.file_name().to_string_lossy()))
|
||||
})
|
||||
.expect("no hid-steam hidraw on interface 2");
|
||||
let f = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&node)
|
||||
.expect("open hidraw");
|
||||
// steam_haptic_rumble: [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gain(2)]
|
||||
let mut buf = [0u8; 12];
|
||||
buf[1] = ID_TRIGGER_RUMBLE_CMD;
|
||||
buf[2] = 0x09;
|
||||
buf[6..8].copy_from_slice(&0xC000u16.to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&0x4000u16.to_le_bytes());
|
||||
// HIDIOCSFEATURE(12)
|
||||
let req: libc::c_ulong =
|
||||
(3 << 30) | ((buf.len() as libc::c_ulong) << 16) | (0x48 << 8) | 0x06;
|
||||
// SAFETY: HIDIOCSFEATURE reads the 12-byte report from the live `buf` behind the valid
|
||||
// hidraw fd `f`; the length is encoded in the request, so nothing is written past it.
|
||||
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, buf.as_mut_ptr()) };
|
||||
assert!(
|
||||
rc >= 0,
|
||||
"HIDIOCSFEATURE: {}",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
let start = Instant::now();
|
||||
let mut got = None;
|
||||
while got.is_none() && start.elapsed() < Duration::from_millis(1500) {
|
||||
got = pad.service().rumble;
|
||||
pad.write_state(&st);
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
assert_eq!(
|
||||
got,
|
||||
Some((0xC000, 0x4000)),
|
||||
"Deck rumble never surfaced from the interface-2 SET_REPORT"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Virtual Nintendo Switch Pro Controller via UHID — bound by the kernel's `hid-nintendo`
|
||||
//! (≥ 5.16), so a Nintendo-family client pad gets correct glyphs + positional layout, live
|
||||
//! gyro/accel, and HD-rumble feedback, instead of folding to the Xbox 360 pad (mirrored A/B
|
||||
//! + X/Y, no motion).
|
||||
//!
|
||||
//! Unlike `hid-playstation` (whose init is three GET_REPORTs), `hid-nintendo` runs a real
|
||||
//! PROBE CONVERSATION against the device: the `0x80`-family USB commands, then ~a dozen
|
||||
//! subcommands (device info, SPI-flash calibration reads, IMU/vibration enable, input mode,
|
||||
//! player lights) — each a blocking send that must see its reply (input report `0x81`/`0x21`)
|
||||
//! within 1–2 s or probe aborts and NO input devices appear. The whole codec + the canned
|
||||
//! replies live in [`super::switch_proto`]; this module is the `/dev/uhid` plumbing that
|
||||
//! answers them from the [`UhidManager`]'s frequent `service` pass (the same cadence that
|
||||
//! already completes the DualSense handshake).
|
||||
//!
|
||||
//! Post-probe, the driver stalls every LED/rumble write for up to 250 ms unless input reports
|
||||
//! are flowing — the shared manager's 8 ms silence heartbeat provides exactly that steady
|
||||
//! `0x30` stream. On host suspend/resume the driver re-runs the whole init; the service pass
|
||||
//! answers it identically (nothing probe-specific is latched).
|
||||
|
||||
use super::switch_proto::{
|
||||
build_subcmd_reply, build_usb_ack, device_info_payload, parse_output, player_leds_bits,
|
||||
serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC,
|
||||
SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR,
|
||||
};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel
|
||||
/// tears down the bound `hid-nintendo` interface).
|
||||
pub struct SwitchProPad {
|
||||
fd: File,
|
||||
index: u8,
|
||||
/// Rolling report timer (byte 1 of every input report).
|
||||
timer: u8,
|
||||
/// The last written state — subcommand replies embed the current input-state header, so the
|
||||
/// probe conversation always reports coherent (neutral, at first) controller state.
|
||||
state: SwitchState,
|
||||
}
|
||||
|
||||
impl SwitchProPad {
|
||||
/// Create the UHID Pro Controller for pad `index` (used for the name/uniq + the virtual MAC).
|
||||
pub fn open(index: u8) -> Result<SwitchProPad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut pad = SwitchProPad {
|
||||
fd,
|
||||
index,
|
||||
timer: 0,
|
||||
state: SwitchState::neutral(),
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Switch Pro")?;
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(
|
||||
&mut ev,
|
||||
4,
|
||||
128,
|
||||
&format!("Punktfunk 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
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus (selects the driver's USB init path)
|
||||
ev[264..268].copy_from_slice(&SWITCH_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&SWITCH_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0200u32.to_ne_bytes()); // version (bcdDevice 2.00)
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + PROCON_RDESC.len()].copy_from_slice(PROCON_RDESC); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write one full input report to the kernel (UHID_INPUT2).
|
||||
fn write_report(&mut self, r: &[u8; SWITCH_REPORT_LEN]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize the state into the standard `0x30` report and stream it.
|
||||
pub fn write_state(&mut self, st: &SwitchState) -> Result<()> {
|
||||
self.state = *st;
|
||||
self.timer = self.timer.wrapping_add(1);
|
||||
let r = serialize_report_0x30(st, self.timer);
|
||||
self.write_report(&r)
|
||||
}
|
||||
|
||||
/// Answer one subcommand from the driver with its canned `0x21` reply.
|
||||
fn answer_subcmd(&mut self, id: u8, args: &[u8]) {
|
||||
self.timer = self.timer.wrapping_add(1);
|
||||
let st = self.state;
|
||||
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)),
|
||||
),
|
||||
// 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.
|
||||
0x10 => {
|
||||
let addr = args
|
||||
.get(..4)
|
||||
.map(|a| u32::from_le_bytes([a[0], a[1], a[2], a[3]]))
|
||||
.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"
|
||||
);
|
||||
let mut p = Vec::with_capacity(5 + len as usize);
|
||||
p.extend_from_slice(&addr.to_le_bytes());
|
||||
p.push(len);
|
||||
p.extend(std::iter::repeat_n(0u8, len as usize));
|
||||
p
|
||||
});
|
||||
build_subcmd_reply(&st, self.timer, 0x90, id, &payload)
|
||||
}
|
||||
// Everything else the driver sends (input mode 0x03, IMU 0x40, vibration 0x48,
|
||||
// player lights 0x30, home light 0x38, …) just needs the ack + echoed id.
|
||||
_ => build_subcmd_reply(&st, self.timer, 0x80, id, &[]),
|
||||
};
|
||||
let _ = self.write_report(&reply);
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the driver's probe conversation (USB commands +
|
||||
/// subcommands) and surface a game's rumble / player-lights feedback for pad `pad`. Call
|
||||
/// frequently — each probe step blocks the driver until answered.
|
||||
pub fn service(&mut self, pad: u8) -> PadFeedback {
|
||||
let mut fb = PadFeedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
match parse_output(&ev[4..end]) {
|
||||
Some(SwitchOutput::UsbCmd(cmd)) => {
|
||||
// Ack every 0x80 command, incl. no-timeout (0x04) — the driver
|
||||
// ignores that ack but replying skips its 2 × 100 ms wait.
|
||||
let _ = self.write_report(&build_usb_ack(cmd));
|
||||
}
|
||||
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||
fb.rumble = Some(rumble);
|
||||
if id == 0x30 {
|
||||
// Player lights ride the subcommand itself; still ack it.
|
||||
if let Some(&arg) = args.first() {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: player_leds_bits(arg),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.answer_subcmd(id, &args);
|
||||
}
|
||||
Some(SwitchOutput::Rumble(r)) => fb.rumble = Some(r),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// hid-nintendo never GET_REPORTs; answer EIO so nothing ever blocks on us.
|
||||
let req_id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let _ = self.reply_get_report_err(req_id);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report_err(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&5u16.to_ne_bytes()); // EIO
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwitchProPad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Switch-Pro-specific half of the shared stateful manager (see [`PadProto`]): UHID
|
||||
/// transport open, the [`SwitchState`] mappers, and the probe-conversation service pass.
|
||||
/// Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
|
||||
pub struct SwitchProProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (a Pro Controller has no
|
||||
/// back-button slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for SwitchProProto {
|
||||
fn default() -> SwitchProProto {
|
||||
SwitchProProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for SwitchProProto {
|
||||
type Pad = SwitchProPad;
|
||||
type State = SwitchState;
|
||||
const LABEL: &'static str = "Switch Pro";
|
||||
const DEVICE: &'static str = "Switch Pro Controller";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<SwitchProPad> {
|
||||
let p = SwitchProPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Switch Pro Controller created (UHID hid-nintendo)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> SwitchState {
|
||||
SwitchState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving motion (it arrives on the rich
|
||||
/// plane and must survive a button-only frame). Paddles fold via the configured policy.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &SwitchState,
|
||||
f: &punktfunk_core::input::GamepadFrame,
|
||||
) -> SwitchState {
|
||||
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = SwitchState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s
|
||||
}
|
||||
|
||||
/// Motion lands on the IMU sample frames; a Pro Controller has no touchpad, so touch events
|
||||
/// are dropped (the client folds trackpads into stick/mouse modes itself).
|
||||
fn apply_rich(&self, st: &mut SwitchState, rich: RichInput) {
|
||||
if let RichInput::Motion { gyro, accel, .. } = rich {
|
||||
st.apply_motion(gyro, accel);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut SwitchProPad, st: &SwitchState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Answer the driver's probe conversation (it blocks `hid-nintendo` init until every step is
|
||||
/// answered — call frequently) and surface a game's feedback: HD-rumble amplitude on the
|
||||
/// universal 0xCA plane, player lights on the 0xCD plane.
|
||||
fn service(&self, pad: &mut SwitchProPad, idx: u8) -> PadFeedback {
|
||||
pad.service(idx)
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Switch Pro Controllers of a session — `PUNKTFUNK_GAMEPAD=switchpro`, or the
|
||||
/// per-pad kind a client declares for a Nintendo-family physical pad.
|
||||
pub type SwitchProManager = UhidManager<SwitchProProto>;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
//! Input injection through the wlroots virtual-input Wayland protocols
|
||||
//! (`zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1`) — the headless-Sway
|
||||
//! path. We connect as an ordinary Wayland client (the host inherits Sway's
|
||||
//! `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, upload an xkb keymap for the
|
||||
//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting
|
||||
//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier
|
||||
//! state so the compositor resolves shifted keysyms correctly.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsFd, FromRawFd};
|
||||
use std::time::Instant;
|
||||
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
|
||||
};
|
||||
use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||
};
|
||||
use xkbcommon::xkb;
|
||||
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// Globals bound from the registry (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct Globals {
|
||||
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
|
||||
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
|
||||
seat: Option<WlSeat>,
|
||||
output: Option<WlOutput>,
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"zwlr_virtual_pointer_manager_v1" => {
|
||||
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
|
||||
}
|
||||
"zwp_virtual_keyboard_manager_v1" => {
|
||||
state.keyboard_mgr = Some(registry.bind(name, version.min(1), qh, ()));
|
||||
}
|
||||
"wl_seat" => {
|
||||
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||
}
|
||||
"wl_output" if state.output.is_none() => {
|
||||
state.output = Some(registry.bind(name, version.min(3), qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The managers, the two virtual devices, the seat and the output emit no events we use.
|
||||
macro_rules! ignore_events {
|
||||
($($t:ty),* $(,)?) => {$(
|
||||
impl Dispatch<$t, ()> for Globals {
|
||||
fn event(_: &mut Self, _: &$t, _: <$t as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
ignore_events!(
|
||||
WlSeat,
|
||||
WlOutput,
|
||||
ZwlrVirtualPointerManagerV1,
|
||||
ZwlrVirtualPointerV1,
|
||||
ZwpVirtualKeyboardManagerV1,
|
||||
ZwpVirtualKeyboardV1,
|
||||
);
|
||||
|
||||
pub struct WlrootsInjector {
|
||||
conn: Connection,
|
||||
queue: EventQueue<Globals>,
|
||||
globals: Globals,
|
||||
pointer: ZwlrVirtualPointerV1,
|
||||
keyboard: ZwpVirtualKeyboardV1,
|
||||
xkb_state: xkb::State,
|
||||
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl WlrootsInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to Wayland (is Sway up + WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut globals = Globals::default();
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("Wayland registry roundtrip")?;
|
||||
|
||||
let pointer_mgr = globals
|
||||
.pointer_mgr
|
||||
.clone()
|
||||
.context("compositor lacks zwlr_virtual_pointer_manager_v1")?;
|
||||
let keyboard_mgr = globals
|
||||
.keyboard_mgr
|
||||
.clone()
|
||||
.context("compositor lacks zwp_virtual_keyboard_manager_v1")?;
|
||||
let seat = globals
|
||||
.seat
|
||||
.clone()
|
||||
.context("compositor advertised no wl_seat")?;
|
||||
|
||||
let pointer = pointer_mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
globals.output.as_ref(),
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
|
||||
|
||||
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
|
||||
// the standard `XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS` env vars, then to
|
||||
// libxkbcommon's built-ins (evdev/pc105/us) — so a non-US host sets e.g.
|
||||
// `XKB_DEFAULT_LAYOUT=de` and the positional wire keys render as its layout (parity with
|
||||
// the libei path, where the session compositor's own keymap applies). Previously this
|
||||
// hardcoded "us", which forced US characters for the OEM/umlaut keys on every layout.
|
||||
let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||
let keymap =
|
||||
xkb::Keymap::new_from_names(&ctx, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS)
|
||||
.context("compile xkb keymap (check XKB_DEFAULT_LAYOUT/VARIANT/RULES if set)")?;
|
||||
tracing::info!(
|
||||
layout = %std::env::var("XKB_DEFAULT_LAYOUT").unwrap_or_else(|_| "us (default)".into()),
|
||||
"virtual keyboard keymap compiled"
|
||||
);
|
||||
let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1);
|
||||
let xkb_state = xkb::State::new(&keymap);
|
||||
|
||||
let file = memfd_with(&keymap_str)?;
|
||||
let size = keymap_str.len() as u32 + 1; // include the trailing NUL
|
||||
keyboard.keymap(1 /* XKB_V1 */, file.as_fd(), size);
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("keymap upload roundtrip")?;
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!(
|
||||
output = globals.output.is_some(),
|
||||
"wlroots virtual input ready (pointer + keyboard)"
|
||||
);
|
||||
Ok(Self {
|
||||
conn,
|
||||
queue,
|
||||
globals,
|
||||
pointer,
|
||||
keyboard,
|
||||
xkb_state,
|
||||
_keymap_file: file,
|
||||
start: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u32 {
|
||||
self.start.elapsed().as_millis() as u32
|
||||
}
|
||||
|
||||
/// Update xkb state for a key and tell the compositor the resulting modifier mask.
|
||||
fn send_modifiers(&mut self, evdev: u16, down: bool) {
|
||||
let kc = xkb::Keycode::new(evdev as u32 + 8); // evdev -> xkb keycode
|
||||
let dir = if down {
|
||||
xkb::KeyDirection::Down
|
||||
} else {
|
||||
xkb::KeyDirection::Up
|
||||
};
|
||||
self.xkb_state.update_key(kc, dir);
|
||||
let depressed = self.xkb_state.serialize_mods(xkb::STATE_MODS_DEPRESSED);
|
||||
let latched = self.xkb_state.serialize_mods(xkb::STATE_MODS_LATCHED);
|
||||
let locked = self.xkb_state.serialize_mods(xkb::STATE_MODS_LOCKED);
|
||||
let group = self.xkb_state.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE);
|
||||
self.keyboard.modifiers(depressed, latched, locked, group);
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for WlrootsInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
let t = self.now_ms();
|
||||
match event.kind {
|
||||
InputKind::MouseMove => {
|
||||
self.pointer.motion(t, event.x as f64, event.y as f64);
|
||||
self.pointer.frame();
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w > 0 && h > 0 {
|
||||
let x = event.x.clamp(0, w as i32) as u32;
|
||||
let y = event.y.clamp(0, h as i32) as u32;
|
||||
self.pointer.motion_absolute(t, x, y, w, h);
|
||||
self.pointer.frame();
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||||
let st = if event.kind == InputKind::MouseButtonDown {
|
||||
wl_pointer::ButtonState::Pressed
|
||||
} else {
|
||||
wl_pointer::ButtonState::Released
|
||||
};
|
||||
self.pointer.button(t, btn, st);
|
||||
self.pointer.frame();
|
||||
}
|
||||
}
|
||||
InputKind::MouseScroll => {
|
||||
let axis = if event.code == SCROLL_HORIZONTAL {
|
||||
wl_pointer::Axis::HorizontalScroll
|
||||
} else {
|
||||
wl_pointer::Axis::VerticalScroll
|
||||
};
|
||||
// GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Positive
|
||||
// GameStream = up (vertical), negative on the Wayland axis; but = RIGHT
|
||||
// (horizontal), already positive there (moonlight-qt/Sunshine pass
|
||||
// horizontal through unnegated) — only the vertical axis flips.
|
||||
let notches = event.x as f64 / 120.0;
|
||||
let sign = if event.code == SCROLL_HORIZONTAL {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
self.pointer.axis_source(wl_pointer::AxisSource::Wheel);
|
||||
self.pointer.axis(t, axis, sign * notches * 15.0);
|
||||
self.pointer.frame();
|
||||
}
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
let down = event.kind == InputKind::KeyDown;
|
||||
if let Some(evdev) = vk_to_evdev(event.code as u8) {
|
||||
self.keyboard.key(t, evdev as u32, if down { 1 } else { 0 });
|
||||
self.send_modifiers(evdev, down);
|
||||
} else {
|
||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
InputKind::GamepadState
|
||||
| InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival => {} // not yet injected
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
|
||||
fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||
let name = b"punktfunk-keymap\0";
|
||||
// SAFETY: `name` is a byte-string literal with an explicit trailing NUL, so `name.as_ptr()` is a
|
||||
// valid NUL-terminated C string; `memfd_create` only reads that name (copying it) and creates an
|
||||
// anonymous file, returning a fresh fd (or -1). `MFD_CLOEXEC` is a valid flag. The 'static literal
|
||||
// outlives the synchronous call and nothing aliases it. The result is checked `< 0` below.
|
||||
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
bail!("memfd_create failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: `fd` is the fresh memfd `memfd_create` just returned and checked `>= 0`; it is a unique
|
||||
// open fd nothing else owns, so `File` takes sole ownership and closes it exactly once on drop —
|
||||
// no alias, no double-close.
|
||||
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
f.write_all(s.as_bytes()).context("write keymap")?;
|
||||
f.write_all(&[0]).context("write keymap NUL")?;
|
||||
Ok(f)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Shared virtual-pad creation-retry policy, used by every backend manager (Linux uinput/uhid,
|
||||
//! Windows XUSB/UMDF). See [`PadGate`].
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Backoff after the first failed pad creation…
|
||||
const FIRST_BACKOFF: Duration = Duration::from_secs(1);
|
||||
/// …doubling on each consecutive failure, capped here so a persistently-broken host retries at most
|
||||
/// this often (a negligible cost) while still self-healing within one window of the fix.
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Create-retry gate shared by every virtual-pad manager.
|
||||
///
|
||||
/// Each backend used to carry a `broken: bool` that latched permanently on the FIRST pad-creation
|
||||
/// error, so a single transient failure — a startup race on `/dev/uinput`, a momentary `EBUSY`, the
|
||||
/// Windows companion driver not yet ready — disabled EVERY controller for the rest of the session,
|
||||
/// even after the underlying cause cleared. `PadGate` replaces that latch with capped exponential
|
||||
/// backoff:
|
||||
///
|
||||
/// * After a failure, creation is blocked only until the backoff elapses — so the manager does not
|
||||
/// re-attempt (and re-log) on every one of the 60–240 input frames a second — then a single
|
||||
/// retry is permitted.
|
||||
/// * A success clears the backoff, so the next failure starts fresh from [`FIRST_BACKOFF`].
|
||||
/// * Consecutive failures widen the window, doubling up to [`MAX_BACKOFF`].
|
||||
///
|
||||
/// Even a genuinely broken setup (bad `/dev/uinput` permissions, missing Windows driver) therefore
|
||||
/// self-heals within [`MAX_BACKOFF`] of the fix — a udev-rule reload, a driver install, the next
|
||||
/// client connect — with no host restart, while costing at most one failed syscall plus one log
|
||||
/// line per backoff window. The gate is manager-wide (not per slot), matching the old `broken`
|
||||
/// flag: these failures are systemic (device-node permissions, absent driver), not per-controller.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PadGate {
|
||||
/// When the current backoff ends. `None` = creation is allowed right now.
|
||||
retry_at: Option<Instant>,
|
||||
/// Current backoff length: `ZERO` until the first failure, then [`FIRST_BACKOFF`] doubling
|
||||
/// toward [`MAX_BACKOFF`].
|
||||
backoff: Duration,
|
||||
}
|
||||
|
||||
impl PadGate {
|
||||
/// A gate that permits creation immediately (no failures recorded yet).
|
||||
pub fn new() -> PadGate {
|
||||
PadGate::default()
|
||||
}
|
||||
|
||||
/// May a pad be created at `now`? `true` unless a post-failure backoff is still in effect.
|
||||
pub fn allow(&self, now: Instant) -> bool {
|
||||
match self.retry_at {
|
||||
None => true,
|
||||
Some(t) => now >= t,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a successful pad creation — clear the backoff so the next failure starts fresh.
|
||||
pub fn on_success(&mut self) {
|
||||
self.retry_at = None;
|
||||
self.backoff = Duration::ZERO;
|
||||
}
|
||||
|
||||
/// Record a failed pad creation at `now` — arm the next retry a capped-exponential backoff out.
|
||||
pub fn on_failure(&mut self, now: Instant) {
|
||||
self.backoff = if self.backoff.is_zero() {
|
||||
FIRST_BACKOFF
|
||||
} else {
|
||||
(self.backoff * 2).min(MAX_BACKOFF)
|
||||
};
|
||||
self.retry_at = Some(now + self.backoff);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresh_gate_allows_creation() {
|
||||
assert!(PadGate::new().allow(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_blocks_until_backoff_elapses_then_allows_one_retry() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PadGate::new();
|
||||
g.on_failure(t0);
|
||||
// Blocked for the whole first-backoff window…
|
||||
assert!(!g.allow(t0));
|
||||
assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1)));
|
||||
// …then a single retry is permitted.
|
||||
assert!(g.allow(t0 + FIRST_BACKOFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_failures_double_the_backoff_up_to_the_cap() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PadGate::new();
|
||||
g.on_failure(t0); // window = 1s
|
||||
g.on_failure(t0); // window = 2s
|
||||
assert!(!g.allow(t0 + FIRST_BACKOFF)); // still blocked at 1s — the window is now 2s
|
||||
assert!(g.allow(t0 + 2 * FIRST_BACKOFF));
|
||||
// Drive well past the cap and confirm the window never exceeds MAX_BACKOFF.
|
||||
for _ in 0..20 {
|
||||
g.on_failure(t0);
|
||||
}
|
||||
assert!(!g.allow(t0 + MAX_BACKOFF - Duration::from_millis(1)));
|
||||
assert!(g.allow(t0 + MAX_BACKOFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_resets_the_backoff() {
|
||||
let t0 = Instant::now();
|
||||
let mut g = PadGate::new();
|
||||
g.on_failure(t0);
|
||||
g.on_failure(t0); // window grown to 2s
|
||||
g.on_success();
|
||||
// Success clears the backoff: creation is immediately allowed again.
|
||||
assert!(g.allow(t0));
|
||||
// The next failure starts from FIRST_BACKOFF, not the grown value.
|
||||
g.on_failure(t0);
|
||||
assert!(!g.allow(t0 + FIRST_BACKOFF - Duration::from_millis(1)));
|
||||
assert!(g.allow(t0 + FIRST_BACKOFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Shared virtual-pad slot table + creation lifecycle, used by every backend manager (Linux
|
||||
//! uinput/uhid, Windows XUSB/UMDF). See [`PadSlots`].
|
||||
|
||||
use crate::pad_gate::PadGate;
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::MAX_PADS;
|
||||
use std::time::Instant;
|
||||
|
||||
// The unplug sweep walks a u16 `active_mask` (the wire type); every slot must have a bit.
|
||||
const _: () = assert!(MAX_PADS <= 16);
|
||||
|
||||
/// The slot table + lifecycle every virtual-pad manager repeats: `Vec<Option<P>>` keyed by wire pad
|
||||
/// index, the `active_mask` unplug sweep, and the [`PadGate`]-guarded create. Extracted verbatim
|
||||
/// from seven copy-pasted managers (G12) so a lifecycle fix lands once, not seven times.
|
||||
///
|
||||
/// Division of labor: `PadSlots` owns the pads' *existence* (create / sweep / lookup) and logs the
|
||||
/// shared lifecycle lines (unplug, create-failure); the backend keeps everything per-controller —
|
||||
/// its state model, feedback pump, and the success log inside `open` (which knows the transport
|
||||
/// detail worth printing). Per-index sibling state (`state` / `last_rumble` / dedup / clocks) stays
|
||||
/// in the manager, which resets it on the indices [`sweep`](Self::sweep) returns and on a `true`
|
||||
/// from [`ensure`](Self::ensure).
|
||||
pub struct PadSlots<P> {
|
||||
pads: Vec<Option<P>>,
|
||||
/// Create-retry gate: a transient backend failure backs off and retries instead of permanently
|
||||
/// disabling every pad for the session.
|
||||
gate: PadGate,
|
||||
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"` — keeps every
|
||||
/// existing per-backend line byte-identical (ops greps survive the extraction).
|
||||
label: &'static str,
|
||||
/// Device name in the create-failure line ("virtual `<device>` creation failed …").
|
||||
device: &'static str,
|
||||
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
|
||||
hint: &'static str,
|
||||
}
|
||||
|
||||
impl<P> PadSlots<P> {
|
||||
/// An empty table of [`MAX_PADS`] slots whose lifecycle log lines carry `label` / `device` /
|
||||
/// `hint` (see the field docs).
|
||||
pub fn new(label: &'static str, device: &'static str, hint: &'static str) -> PadSlots<P> {
|
||||
PadSlots {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
gate: PadGate::new(),
|
||||
label,
|
||||
device,
|
||||
hint,
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend tag this table logs with (for the manager's own arrival line).
|
||||
pub fn label(&self) -> &'static str {
|
||||
self.label
|
||||
}
|
||||
|
||||
/// Drop every allocated pad whose `active_mask` bit cleared (the unplug sweep run on each state
|
||||
/// frame), logging each. Returns the swept indices as a bitmask so the caller resets its
|
||||
/// per-index sibling state; an index another manager owns is `None` here, so it is never swept.
|
||||
pub fn sweep(&mut self, active_mask: u16) -> u16 {
|
||||
let mut swept = 0u16;
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if slot.is_some() && active_mask & (1 << i) == 0 {
|
||||
tracing::info!(index = i, "controller unplugged ({})", self.label);
|
||||
*slot = None;
|
||||
swept |= 1 << i;
|
||||
}
|
||||
}
|
||||
swept
|
||||
}
|
||||
|
||||
/// Create the pad at `idx` via `open` if the slot is empty and the create gate allows it.
|
||||
/// Returns `true` only on a fresh create (the caller resets its per-index sibling state);
|
||||
/// `open` logs its own success line (it knows the transport detail), failure is logged here.
|
||||
pub fn ensure(&mut self, idx: usize, open: impl FnOnce(u8) -> Result<P>) -> bool {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || !self.gate.allow(Instant::now()) {
|
||||
return false;
|
||||
}
|
||||
match open(idx as u8) {
|
||||
Ok(p) => {
|
||||
self.pads[idx] = Some(p);
|
||||
self.gate.on_success();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %format!("{e:#}"),
|
||||
"virtual {} creation failed — retrying with backoff{}",
|
||||
self.device,
|
||||
self.hint
|
||||
);
|
||||
self.gate.on_failure(Instant::now());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The live pad at `idx`, if any (out-of-range → `None`).
|
||||
pub fn get(&self, idx: usize) -> Option<&P> {
|
||||
self.pads.get(idx).and_then(|s| s.as_ref())
|
||||
}
|
||||
|
||||
/// The live pad at `idx`, mutably, if any (out-of-range → `None`).
|
||||
pub fn get_mut(&mut self, idx: usize) -> Option<&mut P> {
|
||||
self.pads.get_mut(idx).and_then(|s| s.as_mut())
|
||||
}
|
||||
|
||||
/// Iterate the live pads as `(index, &mut pad)` (the feedback-pump shape).
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (usize, &mut P)> {
|
||||
self.pads
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| s.as_mut().map(|p| (i, p)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::bail;
|
||||
|
||||
fn slots() -> PadSlots<u32> {
|
||||
PadSlots::new("Test", "test pad", "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_creates_once_and_reports_freshness() {
|
||||
let mut s = slots();
|
||||
// Fresh create → true; the pad is live.
|
||||
assert!(s.ensure(3, |i| Ok(i as u32 * 10)));
|
||||
assert_eq!(s.get(3), Some(&30));
|
||||
// Occupied slot → no re-open (the closure must not run), no reset signal.
|
||||
assert!(!s.ensure(3, |_| panic!("re-opened an occupied slot")));
|
||||
// Out of range → never opens.
|
||||
assert!(!s.ensure(MAX_PADS, |_| panic!("opened out of range")));
|
||||
assert_eq!(s.get(MAX_PADS), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_drops_only_cleared_bits_and_returns_them_once() {
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(0, |_| Ok(0)));
|
||||
assert!(s.ensure(2, |_| Ok(2)));
|
||||
assert!(s.ensure(5, |_| Ok(5)));
|
||||
// Mask keeps 2, clears 0 and 5; empty slots (1, 3, …) are untouched non-events.
|
||||
let swept = s.sweep(0b0000_0100);
|
||||
assert_eq!(swept, 0b0010_0001);
|
||||
assert_eq!(s.get(0), None);
|
||||
assert_eq!(s.get(2), Some(&2));
|
||||
assert_eq!(s.get(5), None);
|
||||
// A second identical sweep is a no-op: the indices were returned exactly once.
|
||||
assert_eq!(s.sweep(0b0000_0100), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_failure_arms_the_gate_and_success_heals_it() {
|
||||
let mut s = slots();
|
||||
assert!(!s.ensure(1, |_| bail!("transient")));
|
||||
// Backoff in effect: the next attempt is blocked without even calling `open`.
|
||||
assert!(!s.ensure(1, |_| panic!("open during backoff")));
|
||||
// The gate is manager-wide (create failures are systemic), so other indices block too.
|
||||
assert!(!s.ensure(2, |_| panic!("open during backoff")));
|
||||
// …and a sweep-then-recreate of a *different* live pad is equally gated, but the table
|
||||
// itself is intact: nothing was allocated.
|
||||
assert_eq!(s.get(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreate_after_sweep_resets_freshness() {
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(4, |_| Ok(1)));
|
||||
s.sweep(0);
|
||||
assert_eq!(s.get(4), None);
|
||||
// The slot is free again → a fresh create (true) with a new value.
|
||||
assert!(s.ensure(4, |_| Ok(2)));
|
||||
assert_eq!(s.get(4), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_mut_yields_live_pads_with_indices() {
|
||||
let mut s = slots();
|
||||
assert!(s.ensure(1, |_| Ok(10)));
|
||||
assert!(s.ensure(6, |_| Ok(60)));
|
||||
let seen: Vec<(usize, u32)> = s.iter_mut().map(|(i, p)| (i, *p)).collect();
|
||||
assert_eq!(seen, vec![(1, 10), (6, 60)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
//! Transport-independent DualSense HID contract — shared by the Linux UHID backend
|
||||
//! ([`super::dualsense`]) and the Windows UMDF-driver backend ([`super::dualsense_windows`]).
|
||||
//!
|
||||
//! This is the pure logic: the report descriptor, feature blobs, the [`DsState`] controller model
|
||||
//! and its `GameStream`/XInput mapper, the input-report serializer (report `0x01`) and the
|
||||
//! output-report parser (report `0x02`, a game's rumble / lightbar / player-LED / adaptive-trigger
|
||||
//! feedback). Neither half depends on a transport — the Linux backend writes `0x01` to `/dev/uhid`
|
||||
//! and reads `0x02` via `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and
|
||||
//! pulls `0x02` back over its control channel — but both build/parse the exact same bytes.
|
||||
//!
|
||||
//! The descriptor + field layout are the canonical inputtino ones (games-on-whales/inputtino
|
||||
//! `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` (Linux) and `hidclass` (Windows) bind the
|
||||
//! same as a real USB DualSense.
|
||||
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
|
||||
// Feature reports the host stack GET_REPORTs during init — without these replies the kernel
|
||||
// (`hid-playstation`) never finishes calibration and creates no input devices. Verbatim from
|
||||
// inputtino (each array's first byte is the report id). The pairing report carries a fixed
|
||||
// virtual MAC.
|
||||
#[rustfmt::skip]
|
||||
// FIXME(cal-len): the descriptor declares report 0x05 as a 40-byte feature (id + 40 = 41 total),
|
||||
// but this blob is 42 bytes (one trailing pad byte too many). Linux `hid-playstation` tolerates it
|
||||
// (the backend is live-validated), and `hidclass` truncates to the declared length, so it is not
|
||||
// currently blocking; trim the trailing 0x00 to 41 once a physical DualSense is available to
|
||||
// re-verify motion calibration on both backends.
|
||||
pub const DS_FEATURE_CALIBRATION: &[u8] = &[ // report 0x05 (motion calibration)
|
||||
0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
|
||||
0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10,
|
||||
0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
pub const DS_FEATURE_PAIRING: &[u8] = &[ // report 0x09 (pairing info: MAC at bytes 1..7)
|
||||
0x09, 0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35, 0x08, 0x25, 0x00, 0x1E, 0x00, 0xEE, 0x74, 0xD0, 0xBC,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build date)
|
||||
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||
0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
|
||||
/// The pairing reply (report `0x09`) for wire pad `pad`: [`DS_FEATURE_PAIRING`] with the MAC's low
|
||||
/// octet offset by the pad index. The MAC must be **unique per pad**: `hid-playstation` adopts it
|
||||
/// as the HID `uniq` (replacing whatever uniq the device was created with), and SDL/Steam dedup
|
||||
/// controllers by that serial — with identical MACs a second virtual pad reads as the *first* pad
|
||||
/// re-appearing over another transport and is merged/ignored.
|
||||
pub fn ds_pairing_reply(pad: u8) -> [u8; 20] {
|
||||
let mut r = [0u8; 20];
|
||||
r.copy_from_slice(DS_FEATURE_PAIRING);
|
||||
r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first
|
||||
r
|
||||
}
|
||||
|
||||
/// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
|
||||
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
|
||||
#[rustfmt::skip]
|
||||
pub const DUALSENSE_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
|
||||
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
|
||||
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
|
||||
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
|
||||
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
|
||||
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x2F, 0x91, 0x02,
|
||||
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
|
||||
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
|
||||
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
|
||||
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
|
||||
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
|
||||
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x0F, 0xB1, 0x02,
|
||||
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||
0xC0,
|
||||
];
|
||||
|
||||
/// Sony DualSense **Edge** USB HID report descriptor (389 bytes) — a verbatim real-device
|
||||
/// capture (hid-recorder, hhd-dev/hwinfo `devices/ds5_edge`, cross-checked byte-for-byte against
|
||||
/// the raw usbmon pcap in the same repo and the descriptor Handheld Daemon ships for ITS virtual
|
||||
/// UHID Edge). vs the plain DS5 descriptor: output report `0x02` grows 47→63 bytes, feature
|
||||
/// `0xF2` 15→52, and 19 vendor feature reports (`0x60..=0x7B`, the Edge profile slots) are
|
||||
/// appended — input report `0x01` is bit-identical (the Edge's Fn/back buttons ride previously
|
||||
/// reserved bits of `buttons[2]`, see [`btn2`]).
|
||||
#[rustfmt::skip]
|
||||
pub const DUALSENSE_EDGE_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
|
||||
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
|
||||
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
|
||||
0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05,
|
||||
0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
|
||||
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x3F, 0x91, 0x02,
|
||||
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
|
||||
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
|
||||
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
|
||||
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
|
||||
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
|
||||
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
|
||||
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x34, 0xB1, 0x02,
|
||||
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
|
||||
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
|
||||
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
|
||||
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
|
||||
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
|
||||
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
|
||||
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
|
||||
0x09, 0x53, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
pub const DS_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
||||
pub const DS_PRODUCT: u32 = 0x0CE6; // DualSense Wireless Controller
|
||||
pub const DS_EDGE_PRODUCT: u32 = 0x0DF2; // DualSense Edge Wireless Controller
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
pub const DS_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualSense touchpad's reported resolution (the kernel exposes it as ABS_MT 0..1920/1080).
|
||||
pub const DS_TOUCH_W: u16 = 1920;
|
||||
pub const DS_TOUCH_H: u16 = 1080;
|
||||
|
||||
/// Bit positions inside the DualSense face/dpad button byte (`buttons[0]`, low nibble = hat).
|
||||
pub mod btn0 {
|
||||
pub const SQUARE: u8 = 0x10;
|
||||
pub const CROSS: u8 = 0x20;
|
||||
pub const CIRCLE: u8 = 0x40;
|
||||
pub const TRIANGLE: u8 = 0x80;
|
||||
}
|
||||
/// `buttons[1]`: shoulders, triggers-as-buttons, create/options, stick clicks.
|
||||
pub mod btn1 {
|
||||
pub const L1: u8 = 0x01;
|
||||
pub const R1: u8 = 0x02;
|
||||
pub const L2: u8 = 0x04;
|
||||
pub const R2: u8 = 0x08;
|
||||
pub const CREATE: u8 = 0x10; // "Share"
|
||||
pub const OPTIONS: u8 = 0x20;
|
||||
pub const L3: u8 = 0x40;
|
||||
pub const R3: u8 = 0x80;
|
||||
}
|
||||
/// `buttons[2]`: PS, touchpad click, mute — plus, on the DualSense **Edge**, the two Fn and two
|
||||
/// back buttons in bits 4–7 (kernel `DS_EDGE_BUTTONS_*` / SDL `SDL_GAMEPAD_BUTTON_PS5_*`; the
|
||||
/// plain DS5 leaves those bits reserved). The kernel maps them to `BTN_TRIGGER_HAPPY1..4`
|
||||
/// (Fn-L, Fn-R, back-L, back-R) since 7.2; SDL/Steam read them off hidraw on any kernel.
|
||||
pub mod btn2 {
|
||||
pub const PS: u8 = 0x01;
|
||||
pub const TOUCHPAD: u8 = 0x02;
|
||||
/// Mic-mute / capture button — set from the wire `BTN_MISC1` in `DsState::from_gamepad`.
|
||||
pub const MUTE: u8 = 0x04;
|
||||
/// Edge left Fn button (below the left stick).
|
||||
pub const EDGE_FN_LEFT: u8 = 0x10;
|
||||
/// Edge right Fn button.
|
||||
pub const EDGE_FN_RIGHT: u8 = 0x20;
|
||||
/// Edge left back button (rear paddle).
|
||||
pub const EDGE_BACK_LEFT: u8 = 0x40;
|
||||
/// Edge right back button (rear paddle).
|
||||
pub const EDGE_BACK_RIGHT: u8 = 0x80;
|
||||
}
|
||||
|
||||
/// Map the wire back-grip bits onto the DualSense Edge's `buttons[2]` bits — the reason the Edge
|
||||
/// backend exists: all four client paddles (Deck grips L4/L5/R4/R5, Elite P1–P4) land on native
|
||||
/// slots instead of the fold/drop policy. Wire PADDLE1/2 = R4/L4 (the primary pair, Steam
|
||||
/// convention) → the Edge's right/left BACK buttons; PADDLE3/4 = R5/L5 → the right/left Fn
|
||||
/// buttons (real-HW Fn is profile-switch chrome, but on a virtual pad the bits reach consumers
|
||||
/// as ordinary buttons — kernel `BTN_TRIGGER_HAPPY1/2`, SDL `LEFT/RIGHT_FUNCTION`).
|
||||
pub fn edge_paddle_bits(buttons: u32) -> u8 {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut b = 0;
|
||||
if buttons & gs::BTN_PADDLE1 != 0 {
|
||||
b |= btn2::EDGE_BACK_RIGHT; // R4
|
||||
}
|
||||
if buttons & gs::BTN_PADDLE2 != 0 {
|
||||
b |= btn2::EDGE_BACK_LEFT; // L4
|
||||
}
|
||||
if buttons & gs::BTN_PADDLE3 != 0 {
|
||||
b |= btn2::EDGE_FN_RIGHT; // R5
|
||||
}
|
||||
if buttons & gs::BTN_PADDLE4 != 0 {
|
||||
b |= btn2::EDGE_FN_LEFT; // L5
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// One touchpad contact for the report.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Touch {
|
||||
pub active: bool,
|
||||
pub id: u8,
|
||||
pub x: u16, // 0..DS_TOUCH_W
|
||||
pub y: u16, // 0..DS_TOUCH_H
|
||||
}
|
||||
|
||||
/// Full DualSense controller state to serialize into report `0x01`. Sticks/triggers are 8-bit
|
||||
/// (`0x80` neutral for sticks, `0x00` released for triggers); `dpad` is the 8-way hat (`8` =
|
||||
/// centered); `buttons[0..3]` are the packed DualSense button bytes; gyro/accel are raw i16.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct DsState {
|
||||
pub lx: u8,
|
||||
pub ly: u8,
|
||||
pub rx: u8,
|
||||
pub ry: u8,
|
||||
pub l2: u8,
|
||||
pub r2: u8,
|
||||
pub dpad: u8, // 0..7 direction, 8 = neutral
|
||||
pub buttons: [u8; 4],
|
||||
pub gyro: [i16; 3],
|
||||
pub accel: [i16; 3],
|
||||
pub touch: [Touch; 2],
|
||||
/// Per-contact-slot click state from the rich plane (`TouchpadEx.click` — a Steam pad's
|
||||
/// physical pad-click). The serializers OR any held slot into the touchpad-click button
|
||||
/// bit: the DualSense has ONE clickable pad, so either Deck pad clicking counts. Lives
|
||||
/// outside `buttons` because `from_gamepad` rebuilds those from every button frame —
|
||||
/// managers must persist this across rebuilds like `touch`/`gyro`/`accel`.
|
||||
pub touch_click: [bool; 2],
|
||||
}
|
||||
|
||||
impl DsState {
|
||||
/// A centered, nothing-pressed state (sticks 0x80, dpad neutral).
|
||||
pub fn neutral() -> DsState {
|
||||
DsState {
|
||||
lx: 0x80,
|
||||
ly: 0x80,
|
||||
rx: 0x80,
|
||||
ry: 0x80,
|
||||
dpad: 8,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a GameStream/XInput pad frame (button bitmask + i16 sticks + u8 triggers) into the
|
||||
/// DualSense report fields. Sticks are recentred to `0x80`; the Y axes are inverted (XInput
|
||||
/// `+y = up`, DualSense `0 = up`). Triggers double as the L2/R2 buttons when pressed. Touchpad
|
||||
/// + motion are filled separately from rich-input events.
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> DsState {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8;
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let mut s = DsState {
|
||||
lx: to_u8(lx),
|
||||
ly: 255 - to_u8(ly),
|
||||
rx: to_u8(rx),
|
||||
ry: 255 - to_u8(ry),
|
||||
l2: lt,
|
||||
r2: rt,
|
||||
..DsState::neutral()
|
||||
};
|
||||
s.set_dpad(
|
||||
on(gs::BTN_DPAD_UP),
|
||||
on(gs::BTN_DPAD_DOWN),
|
||||
on(gs::BTN_DPAD_LEFT),
|
||||
on(gs::BTN_DPAD_RIGHT),
|
||||
);
|
||||
let mut b0 = 0;
|
||||
if on(gs::BTN_A) {
|
||||
b0 |= btn0::CROSS;
|
||||
}
|
||||
if on(gs::BTN_B) {
|
||||
b0 |= btn0::CIRCLE;
|
||||
}
|
||||
if on(gs::BTN_X) {
|
||||
b0 |= btn0::SQUARE;
|
||||
}
|
||||
if on(gs::BTN_Y) {
|
||||
b0 |= btn0::TRIANGLE;
|
||||
}
|
||||
s.buttons[0] = b0; // face buttons (high nibble); dpad merged in write_state
|
||||
let mut b1 = 0;
|
||||
if on(gs::BTN_LB) {
|
||||
b1 |= btn1::L1;
|
||||
}
|
||||
if on(gs::BTN_RB) {
|
||||
b1 |= btn1::R1;
|
||||
}
|
||||
if lt > 0 {
|
||||
b1 |= btn1::L2;
|
||||
}
|
||||
if rt > 0 {
|
||||
b1 |= btn1::R2;
|
||||
}
|
||||
if on(gs::BTN_BACK) {
|
||||
b1 |= btn1::CREATE;
|
||||
}
|
||||
if on(gs::BTN_START) {
|
||||
b1 |= btn1::OPTIONS;
|
||||
}
|
||||
if on(gs::BTN_LS_CLICK) {
|
||||
b1 |= btn1::L3;
|
||||
}
|
||||
if on(gs::BTN_RS_CLICK) {
|
||||
b1 |= btn1::R3;
|
||||
}
|
||||
s.buttons[1] = b1;
|
||||
if on(gs::BTN_GUIDE) {
|
||||
s.buttons[2] |= btn2::PS;
|
||||
}
|
||||
if on(gs::BTN_TOUCHPAD) {
|
||||
s.buttons[2] |= btn2::TOUCHPAD;
|
||||
}
|
||||
// The mic-mute / capture button (Deck '…' QAM on the Steam path). Clients send it as
|
||||
// BTN_MISC1; without this the DualSense mute button was inert on every PlayStation-family
|
||||
// virtual pad. Rebuilt from the wire bit each frame like PS/TOUCHPAD, so no persistence gap.
|
||||
if on(gs::BTN_MISC1) {
|
||||
s.buttons[2] |= btn2::MUTE;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Set the dpad hat from the four GameStream dpad booleans (up/down/left/right).
|
||||
pub fn set_dpad(&mut self, up: bool, down: bool, left: bool, right: bool) {
|
||||
// DualSense hat: 0=N,1=NE,2=E,3=SE,4=S,5=SW,6=W,7=NW,8=neutral.
|
||||
self.dpad = match (up, right, down, left) {
|
||||
(true, false, false, false) => 0,
|
||||
(true, true, false, false) => 1,
|
||||
(false, true, false, false) => 2,
|
||||
(false, true, true, false) => 3,
|
||||
(false, false, true, false) => 4,
|
||||
(false, false, true, true) => 5,
|
||||
(false, false, false, true) => 6,
|
||||
(true, false, false, true) => 7,
|
||||
_ => 8,
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) into this state —
|
||||
/// the ONE mapping shared by every DualSense-family backend (Linux UHID, Windows UMDF,
|
||||
/// DS4 both ways; `touch_w`/`touch_h` are the pad's advertised extents, 1920×1080 vs
|
||||
/// 1920×942).
|
||||
///
|
||||
/// Wire touch coordinates are screen convention (+x right, +y down) — same as the
|
||||
/// DualSense pad's own (top-left origin), so no flip here.
|
||||
///
|
||||
/// A Steam Deck / Steam Controller client sends TWO pads as `TouchpadEx` surfaces; the
|
||||
/// DualSense has one pad with two contact slots, so the surfaces SPLIT it — left pad →
|
||||
/// contact 0 on the left half, right pad → contact 1 on the right half. That mirrors the
|
||||
/// physical thumb layout and lands exactly on the split-pad zones games and Steam Input
|
||||
/// already use for the DS4/DualSense touchpad. Pad clicks ride `touch_click` (the
|
||||
/// serializer ORs them into the touchpad-click button — one clickable pad, either
|
||||
/// surface counts); dropping them was the "Deck pad click does nothing on a DualSense
|
||||
/// host" gap.
|
||||
pub fn apply_rich(&mut self, rich: RichInput, touch_w: u16, touch_h: u16) {
|
||||
// Normalized position → pad extents. The kernel/driver advertises 0..=W-1 / 0..=H-1.
|
||||
let scale = |n: u32, extent: u16| ((n * (extent - 1) as u32) / u16::MAX as u32) as u16;
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep
|
||||
// the reported contact id consistent with it (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
self.touch[slot] = Touch {
|
||||
active,
|
||||
id: slot as u8,
|
||||
x: scale(x as u32, touch_w),
|
||||
y: scale(y as u32, touch_h),
|
||||
};
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
// The wire is already DualSense-convention units (20 LSB/°·s, 10000 LSB/g).
|
||||
self.gyro = gyro;
|
||||
self.accel = accel;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
let n = |v: i16| ((v as i32) + 32768) as u32; // signed centre-0 → 0..=65535
|
||||
let half = touch_w / 2;
|
||||
let (slot, tx) = match surface {
|
||||
// The single / DualSense pad: full extent, slot by finger.
|
||||
0 => ((finger as usize).min(1), scale(n(x), touch_w)),
|
||||
// Steam LEFT pad → contact 0 on the left half.
|
||||
1 => (0, scale(n(x), half)),
|
||||
// Steam RIGHT pad (or anything newer) → contact 1 on the right half.
|
||||
_ => (1, half + scale(n(x), half)),
|
||||
};
|
||||
self.touch[slot] = Touch {
|
||||
active: touch,
|
||||
id: slot as u8,
|
||||
x: tx,
|
||||
y: scale(n(y), touch_h),
|
||||
};
|
||||
self.touch_click[slot] = click;
|
||||
}
|
||||
// Raw as-is passthrough reports belong to the Triton backend, never a DS state.
|
||||
RichInput::HidReport { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `buttons[2]` as serialized: the live button frame plus the touchpad-click bit when a
|
||||
/// rich-plane pad click is held (see [`DsState::touch_click`]).
|
||||
pub fn buttons2_with_click(&self) -> u8 {
|
||||
let mut b = self.buttons[2];
|
||||
if self.touch_click.iter().any(|c| *c) {
|
||||
b |= btn2::TOUCHPAD;
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a full input report `0x01` (pure — unit-testable without a transport). Field
|
||||
/// offsets per the kernel's `struct dualsense_input_report`, this report's one consumer:
|
||||
/// x..rz 0-5, seq 6, buttons[4] 7-10, reserved[4] 11-14, gyro[3] 15-20, accel[3] 21-26,
|
||||
/// sensor_timestamp 27-30, reserved2 31, points[2] 32-39 (static_assert(sizeof == 63)).
|
||||
/// The report id occupies r[0], so struct offset N = r[N + 1].
|
||||
pub fn serialize_state(r: &mut [u8; DS_INPUT_REPORT_LEN], st: &DsState, seq: u8, ts: u32) {
|
||||
r[0] = 0x01; // report id; the struct fields follow (struct offset 0 == r[1])
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = st.l2;
|
||||
r[6] = st.r2;
|
||||
r[7] = seq; // seq_number (struct off 6)
|
||||
r[8] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // off 7: dpad + face buttons
|
||||
r[9] = st.buttons[1]; // off 8
|
||||
r[10] = st.buttons2_with_click(); // off 9 (PS/touchpad-click/mute; rich pad clicks OR in)
|
||||
r[11] = st.buttons[3]; // off 10
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[16 + i * 2..18 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 15
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[22 + i * 2..24 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 21
|
||||
}
|
||||
r[28..32].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 27)
|
||||
pack_touch(&mut r[33..37], &st.touch[0]); // touch point 1 (struct off 32)
|
||||
pack_touch(&mut r[37..41], &st.touch[1]); // touch point 2
|
||||
// status byte (struct off 52 → r[53]) — hid-playstation reads battery here: low nibble =
|
||||
// capacity (×10+5 %), high nibble = charging state (0 = discharging). A virtual pad has no
|
||||
// real cell, so report "discharging, full" (0x0A → 100 %); leaving it 0 makes SteamOS / the
|
||||
// kernel see ~5 % and warn "low battery". (We don't forward the client pad's real charge yet.)
|
||||
r[53] = 0x0A;
|
||||
}
|
||||
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
// byte0: bit7 = NOT active (1 = no contact), bits0-6 = contact id.
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
// The kernel advertises ABS_MT ranges 0..=W-1 / 0..=H-1 — never emit the size itself.
|
||||
let (x, y) = (t.x.min(DS_TOUCH_W - 1), t.y.min(DS_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// What one service pass extracted from the device's HID output reports.
|
||||
/// Rich feedback (lightbar / player LEDs / adaptive triggers) rides the HID-output plane (0xCD);
|
||||
/// motor rumble rides the universal rumble plane (0xCA) so non-DualSense clients still feel it.
|
||||
#[derive(Default)]
|
||||
pub struct DsFeedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by
|
||||
/// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager)
|
||||
/// abandoned-rumble force-off keys on.
|
||||
pub fresh: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
||||
///
|
||||
/// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1`
|
||||
/// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed),
|
||||
/// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off
|
||||
/// broadcast.
|
||||
pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||
return;
|
||||
}
|
||||
let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2
|
||||
let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators
|
||||
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
|
||||
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
|
||||
// and route to the universal rumble plane (0xCA).
|
||||
// Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2
|
||||
// (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises 0x0154 so the
|
||||
// kernel and SDL stay on the flag0 convention, but a writer that hardcodes v2 would
|
||||
// otherwise have its rumble — including stops — silently ignored, and a missed stop
|
||||
// buzzes for the rest of the session (the 500 ms refresh re-sends stale state forever).
|
||||
if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 {
|
||||
let high = (data[3] as u16) << 8;
|
||||
let low = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||
if flag1 & 0x04 != 0 {
|
||||
let (r, g, b) = (data[45], data[46], data[47]);
|
||||
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||
}
|
||||
if flag1 & 0x10 != 0 {
|
||||
fb.hidout.push(HidOutput::PlayerLeds {
|
||||
pad,
|
||||
bits: data[44] & 0x1F,
|
||||
});
|
||||
}
|
||||
// Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the
|
||||
// report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's
|
||||
// ps5.hpp. Wire convention: which 0 = L2, 1 = R2.
|
||||
if data.len() >= 33 {
|
||||
if flag0 & 0x04 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 1,
|
||||
effect: data[11..22].to_vec(),
|
||||
});
|
||||
}
|
||||
if flag0 & 0x08 != 0 {
|
||||
fb.hidout.push(HidOutput::Trigger {
|
||||
pad,
|
||||
which: 0,
|
||||
effect: data[22..33].to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Steam dual-pad → DualSense touchpad SPLIT: left pad (surface 1) lands contact 0
|
||||
/// on the left half, right pad (surface 2) contact 1 on the right half; y follows the
|
||||
/// shared screen convention (top → 0) with no flip; pad clicks set the touchpad-click
|
||||
/// button bit in the serialized report.
|
||||
#[test]
|
||||
fn steam_surfaces_split_the_touchpad() {
|
||||
let mut s = DsState::neutral();
|
||||
// Left pad, centre → middle of the LEFT half.
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert!(s.touch[0].active);
|
||||
assert_eq!(s.touch[0].id, 0);
|
||||
assert_eq!(s.touch[0].x, (DS_TOUCH_W / 2 - 1) / 2); // centre of 0..=959
|
||||
assert_eq!(s.touch[0].y, (DS_TOUCH_H - 1) / 2);
|
||||
// Right pad, top-right corner → right edge of the RIGHT half, y = 0 (screen top).
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: i16::MAX,
|
||||
y: i16::MIN,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert!(s.touch[1].active);
|
||||
assert_eq!(s.touch[1].id, 1);
|
||||
assert_eq!(s.touch[1].x, DS_TOUCH_W - 1);
|
||||
assert_eq!(s.touch[1].y, 0);
|
||||
// The right pad's click reaches the (single) touchpad-click button bit.
|
||||
assert!(s.touch_click[1]);
|
||||
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &s, 0, 0);
|
||||
assert_eq!(r[10] & btn2::TOUCHPAD, btn2::TOUCHPAD);
|
||||
// Releasing the click clears the bit again.
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(s.buttons2_with_click() & btn2::TOUCHPAD, 0);
|
||||
}
|
||||
|
||||
/// The single-surface forms keep their full-pad mapping: unsigned `Touchpad` and
|
||||
/// `TouchpadEx` surface 0 both span the whole touchpad, slot picked by finger.
|
||||
#[test]
|
||||
fn single_surface_spans_full_pad() {
|
||||
let mut s = DsState::neutral();
|
||||
s.apply_rich(
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x: 65535,
|
||||
y: 65535,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.touch[0].x, s.touch[0].y),
|
||||
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||
);
|
||||
s.apply_rich(
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 0,
|
||||
finger: 1,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: i16::MAX,
|
||||
y: i16::MAX,
|
||||
pressure: 0,
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(
|
||||
(s.touch[1].x, s.touch[1].y),
|
||||
(DS_TOUCH_W - 1, DS_TOUCH_H - 1)
|
||||
);
|
||||
// Motion is unit-passthrough (wire is already DualSense convention).
|
||||
s.apply_rich(
|
||||
RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [100, -200, 300],
|
||||
accel: [-1000, 2000, -3000],
|
||||
},
|
||||
DS_TOUCH_W,
|
||||
DS_TOUCH_H,
|
||||
);
|
||||
assert_eq!(s.gyro, [100, -200, 300]);
|
||||
assert_eq!(s.accel, [-1000, 2000, -3000]);
|
||||
}
|
||||
|
||||
/// A DualSense USB output report (`0x02`) with all valid-flags set parses into motor
|
||||
/// rumble (0xCA), lightbar, player LEDs, and both adaptive-trigger blocks (0xCD) — with
|
||||
/// the report's right-trigger-first layout mapped onto the wire's `which` (0 = L2).
|
||||
#[test]
|
||||
fn parse_output_report() {
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02; // report id
|
||||
data[1] = 0x0F; // valid_flag0: vibration + haptics + R2 + L2 triggers
|
||||
data[2] = 0x14; // valid_flag1: lightbar + player indicators
|
||||
data[3] = 0x80; // right (high-freq) motor
|
||||
data[4] = 0x40; // left (low-freq) motor
|
||||
data[11] = 0x21; // right-trigger block mode byte (report bytes 11..22)
|
||||
data[22] = 0x26; // left-trigger block mode byte (report bytes 22..33)
|
||||
data[44] = 0x03; // player LEDs (low 5 bits)
|
||||
data[45] = 10; // R
|
||||
data[46] = 20; // G
|
||||
data[47] = 30; // B
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
// (low, high) = (left<<8, right<<8).
|
||||
assert_eq!(fb.rumble, Some((0x4000, 0x8000)));
|
||||
assert!(fb.hidout.contains(&HidOutput::Led {
|
||||
pad: 0,
|
||||
r: 10,
|
||||
g: 20,
|
||||
b: 30
|
||||
}));
|
||||
assert!(fb
|
||||
.hidout
|
||||
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
|
||||
// The report's FIRST block (bytes 11..22) is the RIGHT trigger → wire which = 1.
|
||||
let triggers: Vec<_> = fb
|
||||
.hidout
|
||||
.iter()
|
||||
.filter_map(|h| match h {
|
||||
HidOutput::Trigger { which, effect, .. } => Some((*which, effect[0])),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(triggers, vec![(1, 0x21), (0, 0x26)]);
|
||||
}
|
||||
|
||||
/// Writers set only the valid-flag bits for the fields they mean to change (the rest of the
|
||||
/// report is zeroed) — a plain rumble write must NOT blank the lightbar / player LEDs /
|
||||
/// triggers, and an LED-only write must not stop the motors.
|
||||
#[test]
|
||||
fn parse_output_respects_valid_flags() {
|
||||
// Rumble write: only the vibration flags set, everything else zero.
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[1] = 0x03; // compatible vibration + haptics select
|
||||
data[3] = 0xFF;
|
||||
data[4] = 0xFF;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0xFF00, 0xFF00)));
|
||||
assert!(fb.hidout.is_empty(), "rumble write must not emit hidout");
|
||||
|
||||
// Lightbar-only write: no rumble surfaced (would otherwise spam rumble-stops).
|
||||
let mut data = vec![0u8; 48];
|
||||
data[0] = 0x02;
|
||||
data[2] = 0x04; // lightbar control enable
|
||||
data[45] = 1;
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &data, &mut fb);
|
||||
assert!(fb.rumble.is_none());
|
||||
assert_eq!(fb.hidout.len(), 1);
|
||||
assert!(matches!(fb.hidout[0], HidOutput::Led { r: 1, .. }));
|
||||
}
|
||||
|
||||
/// The input report's sensor/touch bytes must land exactly where the kernel's
|
||||
/// `struct dualsense_input_report` reads them (gyro at struct offset 15, accel 21,
|
||||
/// timestamp 27, touch points 32 — report byte = struct offset + 1). A one-byte slip
|
||||
/// here turns client motion into noise and conjures phantom touch contacts.
|
||||
#[test]
|
||||
fn input_report_layout_matches_hid_playstation() {
|
||||
let mut st = DsState::neutral();
|
||||
st.gyro = [0x1122, 0x3344, 0x5566];
|
||||
st.accel = [0x778, 0x99A, 0xBBC];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 5,
|
||||
x: 0x123,
|
||||
y: 0x356,
|
||||
};
|
||||
// touch[1] stays inactive — its NOT-active bit must be set.
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 7, 0xAABBCCDD);
|
||||
assert_eq!(r[0], 0x01);
|
||||
assert_eq!(r[7], 7); // seq_number (struct off 6)
|
||||
assert_eq!(&r[16..22], &[0x22, 0x11, 0x44, 0x33, 0x66, 0x55]); // gyro LE
|
||||
assert_eq!(&r[22..28], &[0x78, 0x07, 0x9A, 0x09, 0xBC, 0x0B]); // accel LE
|
||||
assert_eq!(&r[28..32], &[0xDD, 0xCC, 0xBB, 0xAA]); // sensor_timestamp LE
|
||||
// Touch point 1 at struct off 32 = r[33..37]: contact byte (active → bit7 clear),
|
||||
// then 12-bit x / 12-bit y packed.
|
||||
assert_eq!(r[33], 5);
|
||||
assert_eq!(r[34], 0x23);
|
||||
assert_eq!(r[35], 0x61); // x_hi nibble 0x1 | (y & 0xF) << 4 (y=0x356 → 0x6 << 4)
|
||||
assert_eq!(r[36], 0x35); // y >> 4
|
||||
assert_eq!(r[37] & 0x80, 0x80); // touch point 2 inactive
|
||||
// status byte (struct off 52): discharging (high nibble 0) + full capacity (low nibble
|
||||
// 0xA → 100 %), so SteamOS/hid-playstation never reports a false "low battery".
|
||||
assert_eq!(r[53], 0x0A);
|
||||
}
|
||||
|
||||
/// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in
|
||||
/// `buttons[2]`.
|
||||
#[test]
|
||||
fn from_gamepad_maps_touchpad_click() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let s = DsState::from_gamepad(gs::BTN_TOUCHPAD | gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(s.buttons[2], btn2::PS | btn2::TOUCHPAD);
|
||||
// BTN_MISC1 → the mic-mute / capture button (G6: was previously dropped entirely).
|
||||
let s = DsState::from_gamepad(gs::BTN_MISC1, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(s.buttons[2], btn2::MUTE);
|
||||
let s = DsState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(s.buttons[2], 0);
|
||||
}
|
||||
|
||||
/// The Edge paddle map, pinned against hid-playstation's `DS_EDGE_BUTTONS_*` masks (bits
|
||||
/// 4–7 of `buttons[2]`) and SDL's `SDL_GAMEPAD_BUTTON_PS5_*` (same byte off hidraw):
|
||||
/// PADDLE1/2 (R4/L4) → right/left BACK, PADDLE3/4 (R5/L5) → right/left Fn — and the mapped
|
||||
/// bits land in the serialized report's byte 10 next to the ordinary buttons[2] bits.
|
||||
#[test]
|
||||
fn edge_paddles_map_to_native_bits() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
assert_eq!(edge_paddle_bits(0), 0);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE1), btn2::EDGE_BACK_RIGHT);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE2), btn2::EDGE_BACK_LEFT);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE3), btn2::EDGE_FN_RIGHT);
|
||||
assert_eq!(edge_paddle_bits(gs::BTN_PADDLE4), btn2::EDGE_FN_LEFT);
|
||||
// Exact kernel/SDL bit values (a one-bit slip ships dead paddles).
|
||||
assert_eq!(btn2::EDGE_FN_LEFT, 0x10);
|
||||
assert_eq!(btn2::EDGE_FN_RIGHT, 0x20);
|
||||
assert_eq!(btn2::EDGE_BACK_LEFT, 0x40);
|
||||
assert_eq!(btn2::EDGE_BACK_RIGHT, 0x80);
|
||||
// All four + a non-paddle bit: paddles map, the rest is ignored here.
|
||||
let all = gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_A;
|
||||
assert_eq!(edge_paddle_bits(all), 0xF0);
|
||||
// Serialized: the Edge merge ORs into buttons[2]; byte 10 carries both the paddles and
|
||||
// the ordinary bits (e.g. a simultaneous PS press).
|
||||
let mut s = DsState::from_gamepad(gs::BTN_GUIDE, 0, 0, 0, 0, 0, 0);
|
||||
s.buttons[2] |= edge_paddle_bits(gs::BTN_PADDLE2 | gs::BTN_PADDLE3);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &s, 0, 0);
|
||||
assert_eq!(r[10], btn2::PS | btn2::EDGE_BACK_LEFT | btn2::EDGE_FN_RIGHT);
|
||||
}
|
||||
|
||||
/// The Edge descriptor is the real-device capture: exact length, the three deltas vs the
|
||||
/// plain DS5 descriptor (output 0x02 count 63, feature 0xF2 count 52, the appended profile
|
||||
/// feature reports), and an unchanged input-report prefix (report 0x01 is bit-identical —
|
||||
/// the serializer needs no Edge variant).
|
||||
#[test]
|
||||
fn edge_descriptor_shape() {
|
||||
assert_eq!(DUALSENSE_RDESC.len(), 273);
|
||||
assert_eq!(DUALSENSE_EDGE_RDESC.len(), 389);
|
||||
// Identical through the input-report + output-report-id prefix; the first delta is the
|
||||
// output report 0x02's Report Count at offset 109 (47 → 63 bytes of payload).
|
||||
assert_eq!(DUALSENSE_EDGE_RDESC[..109], DUALSENSE_RDESC[..109]);
|
||||
assert_eq!(DUALSENSE_RDESC[109], 0x2F);
|
||||
assert_eq!(DUALSENSE_EDGE_RDESC[109], 0x3F);
|
||||
assert_eq!(*DUALSENSE_EDGE_RDESC.last().unwrap(), 0xC0);
|
||||
}
|
||||
|
||||
/// A short / wrong-id report yields nothing.
|
||||
#[test]
|
||||
fn parse_output_rejects_garbage() {
|
||||
let mut fb = DsFeedback::default();
|
||||
parse_ds_output(0, &[0x01, 0, 0], &mut fb); // wrong report id, too short
|
||||
assert!(fb.rumble.is_none());
|
||||
assert!(fb.hidout.is_empty());
|
||||
}
|
||||
|
||||
/// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet —
|
||||
/// distinct serials so SDL/Steam never dedup two virtual pads into one controller.
|
||||
#[test]
|
||||
fn pairing_reply_mac_is_per_pad() {
|
||||
assert_eq!(ds_pairing_reply(0).as_slice(), DS_FEATURE_PAIRING);
|
||||
let (a, b) = (ds_pairing_reply(1), ds_pairing_reply(2));
|
||||
assert_eq!(a[0], 0x09); // report id untouched
|
||||
assert_eq!(a[1], DS_FEATURE_PAIRING[1].wrapping_add(1));
|
||||
assert_eq!(b[1], DS_FEATURE_PAIRING[1].wrapping_add(2));
|
||||
assert_eq!(a[2..], b[2..]); // everything but the low octet identical
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Transport-independent DualShock 4 HID contract — the pure report codec shared by the Windows
|
||||
//! UMDF-driver backend ([`super::dualshock4_windows`]) and the Linux UHID backend
|
||||
//! ([`super::dualshock4`]).
|
||||
//!
|
||||
//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4
|
||||
//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper
|
||||
//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the
|
||||
//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via
|
||||
//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its
|
||||
//! shared-memory channel — both build/parse the exact same bytes here.
|
||||
//!
|
||||
//! Field offsets are the canonical real-DS4-USB layout the kernel `struct
|
||||
//! dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||
|
||||
use super::dualsense_proto::{DsState, Touch};
|
||||
|
||||
/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2).
|
||||
pub const DS4_VENDOR: u16 = 0x054C;
|
||||
pub const DS4_PRODUCT: u16 = 0x09CC;
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
pub const DS4_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
|
||||
/// than the DualSense's 1920×1080.
|
||||
pub const DS4_TOUCH_W: u16 = 1920;
|
||||
pub const DS4_TOUCH_H: u16 = 942;
|
||||
|
||||
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
|
||||
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
|
||||
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without a transport). Field offsets
|
||||
/// per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; touch[3];
|
||||
/// rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; gyro[3] le16;
|
||||
/// accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` field at struct
|
||||
/// offset N sits at report byte N+1.
|
||||
pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
|
||||
r[0] = 0x01; // report id
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons2_with_click() & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click (incl. rich pad clicks) + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
// r[12] temperature stays 0
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
|
||||
}
|
||||
// r[25..30] reserved2.
|
||||
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
|
||||
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
|
||||
r[30] = 0x10 | 0x0B;
|
||||
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
|
||||
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
|
||||
r[34] = ts as u8; // touch_reports[0].timestamp
|
||||
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
|
||||
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
|
||||
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
|
||||
}
|
||||
|
||||
/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal
|
||||
/// 0xCA plane; the lightbar rides the HID-output 0xCD plane as a `Led` event (DS4 has no player LEDs
|
||||
/// or adaptive triggers, so those never appear).
|
||||
#[derive(Default)]
|
||||
pub struct Ds4Feedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
pub led: Option<(u8, u8, u8)>,
|
||||
/// Whether a fresh output report was seen this poll (set by the backend's section poll, not by
|
||||
/// the parser) — the game-activity signal the [`UhidManager`](crate::uhid_manager)
|
||||
/// abandoned-rumble force-off keys on.
|
||||
pub fresh: bool,
|
||||
}
|
||||
|
||||
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
|
||||
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
|
||||
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
|
||||
/// doesn't masquerade as a lightbar change.
|
||||
pub fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
|
||||
if data.first() != Some(&0x05) || data.len() < 11 {
|
||||
return; // not the USB output report (BT 0x11 is shifted) / too short
|
||||
}
|
||||
let flag0 = data[1];
|
||||
if flag0 & 0x01 != 0 {
|
||||
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
|
||||
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
|
||||
let low = (data[5] as u16) << 8;
|
||||
let high = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
if flag0 & 0x02 != 0 {
|
||||
fb.led = Some((data[6], data[7], data[8]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
|
||||
#[test]
|
||||
fn serialize_offsets() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut st = DsState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
|
||||
16384, // lx (right)
|
||||
0,
|
||||
0,
|
||||
-32768, // ry (down) — inverted to 0xFF
|
||||
200, // L2
|
||||
0,
|
||||
);
|
||||
st.gyro = [0x0102, 0x0304, 0x0506];
|
||||
st.accel = [0x1112, 0x1314, 0x1516];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
};
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[0], 0x01); // report id
|
||||
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
|
||||
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
|
||||
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
|
||||
assert_eq!(r[6] & 0x01, 0x01); // L1
|
||||
// gyro le16 at 13..19, accel le16 at 19..25.
|
||||
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
|
||||
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
|
||||
assert_eq!(r[33], 1); // one touch frame
|
||||
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||
|
||||
// A rich-plane pad click (`touch_click`, no BTN_TOUCHPAD in the frame) rides the
|
||||
// touchpad-click bit at byte 7 bit 1 via `buttons2_with_click` — the Linux backend used to
|
||||
// serialize raw `buttons[2]` here and drop it.
|
||||
assert_eq!(r[7] & 0x02, 0); // no click yet
|
||||
st.touch_click[0] = true;
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[7] & 0x02, 0x02);
|
||||
}
|
||||
|
||||
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
|
||||
#[test]
|
||||
fn parse_output_rumble_and_lightbar() {
|
||||
let mut report = [0u8; 32];
|
||||
report[0] = 0x05;
|
||||
report[1] = 0x01 | 0x02; // MOTOR | LED
|
||||
report[4] = 0x40; // motor_right (weak/high)
|
||||
report[5] = 0x80; // motor_left (strong/low)
|
||||
report[6] = 0x11; // R
|
||||
report[7] = 0x22; // G
|
||||
report[8] = 0x33; // B
|
||||
let mut fb = Ds4Feedback::default();
|
||||
parse_ds4_output(&report, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
|
||||
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
|
||||
|
||||
let mut motor_only = [0u8; 32];
|
||||
motor_only[0] = 0x05;
|
||||
motor_only[1] = 0x01; // MOTOR only
|
||||
motor_only[5] = 0x10;
|
||||
let mut fb2 = Ds4Feedback::default();
|
||||
parse_ds4_output(&motor_only, &mut fb2);
|
||||
assert!(fb2.rumble.is_some());
|
||||
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
//! Transport-independent Steam Controller / Steam Deck HID contract — the Steam analogue of
|
||||
//! [`super::dualsense_proto`]. The report descriptor, the command/feature IDs, the byte-exact
|
||||
//! Deck input-report serializer, the `XInput`/rich-input → state mappers, and the rumble-feedback
|
||||
//! parser. Pure logic, shared by the Linux UHID backend and (later) a Windows UMDF backend.
|
||||
//!
|
||||
//! **Layout source of truth:** the kernel `drivers/hid/hid-steam.c` `steam_do_deck_input_event`
|
||||
//! (+ `steam_do_deck_sensors_event`) — every offset/bit/sign below is transcribed verbatim from
|
||||
//! it and on-box-validated against kernel 7.0 (see `design/steam-controller-deck-support.md`).
|
||||
//! M0 proved the device binds + parses; M1 (here) makes the serializer byte-exact.
|
||||
//!
|
||||
//! Three load-bearing details the DualSense path does NOT have:
|
||||
//! * **report id 0 / unnumbered**: input reports are the raw 64 bytes starting `[0x01,0x00,0x09]`
|
||||
//! (no report-id prefix); FEATURE get/set reports DO carry a leading `0x00` report-id byte
|
||||
//! (`steam_send_report` does `memcpy(buf+1, cmd, …)`, `steam_recv_report` strips `buf[0]`).
|
||||
//! * **`gamepad_mode` gate**: `steam_do_deck_input_event` early-returns when
|
||||
//! `!gamepad_mode && lizard_mode` (the module param, default on). `gamepad_mode` starts false
|
||||
//! and TOGGLES when [`btn::STEAM_MENU_RIGHT`] (`b9.6`, the mode-switch) is held ~450 ms while
|
||||
//! no hidraw client is open. The backend enters gamepad mode at session start (pulse that bit,
|
||||
//! or load `hid_steam lizard_mode=0`) — see the backend, not this module.
|
||||
//! * **the `UHID_SET_REPORT` handshake** must be answered (DualSense omits it).
|
||||
#![allow(dead_code)] // Some of the full model is consumed only once the M2 backend + M3 wire land.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
|
||||
/// Valve. `hid-steam` matches purely by VID/PID over `BUS_USB`.
|
||||
pub const STEAM_VENDOR: u32 = 0x28DE;
|
||||
/// Steam Deck built-in controller (same PID on LCD + OLED).
|
||||
pub const STEAMDECK_PRODUCT: u32 = 0x1205;
|
||||
/// Classic Steam Controller, wired (report id 1 / `ID_CONTROLLER_STATE`; a later model).
|
||||
pub const STEAMCTRL_WIRED_PRODUCT: u32 = 0x1102;
|
||||
|
||||
/// The Steam HID state/command report is a fixed 64-byte, **unnumbered** (report-id-0) frame.
|
||||
pub const STEAM_REPORT_LEN: usize = 64;
|
||||
|
||||
// Command IDs (drivers/hid/hid-steam.c), confirmed against the kernel source.
|
||||
pub const ID_CLEAR_DIGITAL_MAPPINGS: u8 = 0x81;
|
||||
pub const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
|
||||
pub const ID_SET_SETTINGS_VALUES: u8 = 0x87;
|
||||
pub const ID_LOAD_DEFAULT_SETTINGS: u8 = 0x8E;
|
||||
pub const ID_GET_DEVICE_INFO: u8 = 0xA1;
|
||||
pub const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
pub const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
/// Host→client feedback: `steam_haptic_rumble` emits report `[0xEB, 9, …]` (FF_RUMBLE → trackpad
|
||||
/// actuators / Deck motors). The Deck's rumble path; the classic SC also has `0x8F` pad pulses.
|
||||
pub const ID_TRIGGER_RUMBLE_CMD: u8 = 0xEB;
|
||||
pub const ID_TRIGGER_HAPTIC_PULSE: u8 = 0x8F;
|
||||
/// Input report message types: SC = `ID_CONTROLLER_STATE`, Deck = `ID_CONTROLLER_DECK_STATE`.
|
||||
pub const ID_CONTROLLER_STATE: u8 = 0x01;
|
||||
pub const ID_CONTROLLER_DECK_STATE: u8 = 0x09;
|
||||
|
||||
/// Which Steam device identity to present. M1 implements the Deck fully; the classic Controller
|
||||
/// (dual trackpads, report id 1, trackpad-only haptics) is a later identity behind the same path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SteamModel {
|
||||
Deck,
|
||||
Controller,
|
||||
}
|
||||
|
||||
impl SteamModel {
|
||||
pub fn product(self) -> u32 {
|
||||
match self {
|
||||
SteamModel::Deck => STEAMDECK_PRODUCT,
|
||||
SteamModel::Controller => STEAMCTRL_WIRED_PRODUCT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal vendor-defined HID report descriptor: one application collection with a 64-byte input
|
||||
/// report and a 64-byte feature report, both UNNUMBERED (report id 0). `hid-steam` is a raw-event
|
||||
/// driver, so the field layout is cosmetic — but `steam_probe` requires `hid_parse` to succeed AND
|
||||
/// a non-empty FEATURE report list (`steam_is_valve_interface`), so the feature item is mandatory.
|
||||
#[rustfmt::skip]
|
||||
pub const STEAMDECK_RDESC: &[u8] = &[
|
||||
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x75, 0x08, // Report Size (8 bits)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x81, 0x02, // Input (Data,Var,Abs) — the 64-byte state report
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0xB1, 0x02, // Feature (Data,Var,Abs) — makes steam_is_valve_interface() true
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Deck button bits, indexed in the `u64` packed across report bytes 8..16 — bit `(byte-8)*8 + bit`,
|
||||
/// transcribed verbatim from `steam_do_deck_input_event` (bytes 12 + 15 carry no buttons). Naming
|
||||
/// follows the physical Deck control; the trailing comment is the kernel `BTN_*` it maps to.
|
||||
pub mod btn {
|
||||
// byte 8
|
||||
pub const RT_FULL: u64 = 1 << 0; // BTN_TR2 — right trigger fully pressed
|
||||
pub const LT_FULL: u64 = 1 << 1; // BTN_TL2 — left trigger fully pressed
|
||||
pub const RB: u64 = 1 << 2; // BTN_TR — right shoulder
|
||||
pub const LB: u64 = 1 << 3; // BTN_TL — left shoulder
|
||||
pub const Y: u64 = 1 << 4;
|
||||
pub const B: u64 = 1 << 5;
|
||||
pub const X: u64 = 1 << 6;
|
||||
pub const A: u64 = 1 << 7;
|
||||
// byte 9
|
||||
pub const DPAD_UP: u64 = 1 << 8;
|
||||
pub const DPAD_RIGHT: u64 = 1 << 9;
|
||||
pub const DPAD_LEFT: u64 = 1 << 10;
|
||||
pub const DPAD_DOWN: u64 = 1 << 11;
|
||||
pub const VIEW: u64 = 1 << 12; // BTN_SELECT — "menu left" (View / Back)
|
||||
pub const STEAM: u64 = 1 << 13; // BTN_MODE — Steam logo button
|
||||
pub const MENU: u64 = 1 << 14; // BTN_START — "menu right" (Start / Options)
|
||||
pub const L5: u64 = 1 << 15; // BTN_GRIPL2 — left BOTTOM back grip
|
||||
// byte 10
|
||||
pub const R5: u64 = 1 << 16; // BTN_GRIPR2 — right BOTTOM back grip
|
||||
pub const LPAD_CLICK: u64 = 1 << 17; // BTN_THUMB — left pad pressed (click)
|
||||
pub const RPAD_CLICK: u64 = 1 << 18; // BTN_THUMB2 — right pad pressed (click)
|
||||
pub const LPAD_TOUCH: u64 = 1 << 19; // gates ABS_HAT0 (left pad coords)
|
||||
pub const RPAD_TOUCH: u64 = 1 << 20; // gates ABS_HAT1 (right pad coords)
|
||||
pub const L3: u64 = 1 << 22; // BTN_THUMBL — left joystick click
|
||||
// byte 11
|
||||
pub const R3: u64 = 1 << 26; // BTN_THUMBR — right joystick click
|
||||
// byte 13
|
||||
pub const L4: u64 = 1 << 41; // BTN_GRIPL — left TOP back grip
|
||||
pub const R4: u64 = 1 << 42; // BTN_GRIPR — right TOP back grip
|
||||
pub const LJOY_TOUCH: u64 = 1 << 46;
|
||||
pub const RJOY_TOUCH: u64 = 1 << 47;
|
||||
// byte 14
|
||||
pub const QAM: u64 = 1 << 50; // BTN_BASE — quick-access (…) button
|
||||
/// `b9.6` doubles as the mode-switch: held ~450 ms (no hidraw client) it toggles `gamepad_mode`.
|
||||
pub const STEAM_MENU_RIGHT: u64 = MENU;
|
||||
}
|
||||
|
||||
/// Full virtual Steam Deck controller state. All analog fields are stored as the RAW little-endian
|
||||
/// report values the kernel reads (so [`serialize_deck_state`] is a pure memcpy); the kernel applies
|
||||
/// its own sign conventions on top (`ABS_Y = -raw`, etc.) — see [`SteamState::from_gamepad`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SteamState {
|
||||
/// Packed button bits (see [`btn`]); occupies report bytes 8..16.
|
||||
pub buttons: u64,
|
||||
/// Left / right joystick, raw s16 (report 48/50/52/54). The kernel negates the Y axes.
|
||||
pub lx: i16,
|
||||
pub ly: i16,
|
||||
pub rx: i16,
|
||||
pub ry: i16,
|
||||
/// Left / right analog trigger, raw u16 (report 44/46 → ABS_HAT2Y/X).
|
||||
pub lt: u16,
|
||||
pub rt: u16,
|
||||
/// Left / right trackpad position, raw s16, centred 0 (report 16/18/20/22). Only surfaced by
|
||||
/// the kernel while the matching `*PAD_TOUCH` button bit is set.
|
||||
pub lpad_x: i16,
|
||||
pub lpad_y: i16,
|
||||
pub rpad_x: i16,
|
||||
pub rpad_y: i16,
|
||||
pub lpad_pressure: u16,
|
||||
pub rpad_pressure: u16,
|
||||
/// IMU, raw s16. `accel`/`gyro` are `[X, Y, Z]`; the kernel maps them to ABS_X/Z/Y + ABS_RX/RZ/RY
|
||||
/// (with Z/RZ negated) on the separate sensors evdev.
|
||||
pub accel: [i16; 3],
|
||||
pub gyro: [i16; 3],
|
||||
/// Trackpad CLICK from the rich plane ([`apply_rich`]), kept OUTSIDE `buttons` because
|
||||
/// [`SteamControllerManager::handle`](super::super::linux::steam_controller::SteamControllerManager)
|
||||
/// rebuilds `buttons` from the gamepad frame every tick — exactly why DualSense keeps
|
||||
/// `touch_click` separate. Merged into the report's click bits in [`serialize_deck_state`]. The
|
||||
/// DualSense touchpad-click WIRE button still sets `RPAD_CLICK` in `buttons` via
|
||||
/// [`from_gamepad`](Self::from_gamepad); the two sources are OR'd at serialize, so each releases
|
||||
/// independently (a released `BTN_TOUCHPAD` can't strand a rich click, and vice-versa).
|
||||
pub lpad_click: bool,
|
||||
pub rpad_click: bool,
|
||||
}
|
||||
|
||||
impl SteamState {
|
||||
pub fn neutral() -> SteamState {
|
||||
SteamState::default()
|
||||
}
|
||||
|
||||
/// Set/clear a button (or group) by its [`btn`] mask.
|
||||
pub fn press(&mut self, mask: u64, down: bool) {
|
||||
if down {
|
||||
self.buttons |= mask;
|
||||
} else {
|
||||
self.buttons &= !mask;
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck
|
||||
/// state. Sticks pass through (the kernel negates Y, which yields the conventional direction —
|
||||
/// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when
|
||||
/// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> SteamState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let mut s = SteamState {
|
||||
lx,
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
let set = |b: &mut u64, on: bool, m: u64| {
|
||||
if on {
|
||||
*b |= m;
|
||||
}
|
||||
};
|
||||
set(&mut b, on(gs::BTN_A), btn::A);
|
||||
set(&mut b, on(gs::BTN_B), btn::B);
|
||||
set(&mut b, on(gs::BTN_X), btn::X);
|
||||
set(&mut b, on(gs::BTN_Y), btn::Y);
|
||||
set(&mut b, on(gs::BTN_LB), btn::LB);
|
||||
set(&mut b, on(gs::BTN_RB), btn::RB);
|
||||
set(&mut b, lt > 0, btn::LT_FULL);
|
||||
set(&mut b, rt > 0, btn::RT_FULL);
|
||||
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
|
||||
set(&mut b, on(gs::BTN_START), btn::MENU);
|
||||
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
|
||||
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
||||
set(&mut b, on(gs::BTN_RS_CLICK), btn::R3);
|
||||
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
|
||||
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
|
||||
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
|
||||
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
|
||||
// The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that
|
||||
// stands in for the DualSense touchpad — see apply_rich).
|
||||
set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK);
|
||||
// Back grips (the whole reason for the Deck identity): the wire paddle bits map to the four
|
||||
// Deck grips — PADDLE1/2/3/4 = R4/L4/R5/L5 (see `input::gamepad`); MISC1 = the QAM '…' button.
|
||||
set(&mut b, on(gs::BTN_PADDLE1), btn::R4);
|
||||
set(&mut b, on(gs::BTN_PADDLE2), btn::L4);
|
||||
set(&mut b, on(gs::BTN_PADDLE3), btn::R5);
|
||||
set(&mut b, on(gs::BTN_PADDLE4), btn::L5);
|
||||
set(&mut b, on(gs::BTN_MISC1), btn::QAM);
|
||||
s.buttons = b;
|
||||
s
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event into this state, preserving everything else. The single-pad
|
||||
/// wire [`RichInput::Touchpad`] maps to the **right** trackpad (the Deck pad analogous to the
|
||||
/// DualSense touchpad); the left pad arrives via the M3 `TouchpadEx` surface. [`RichInput::Motion`]
|
||||
/// passes gyro/accel straight through (raw i16; cross-device unit scaling is M3).
|
||||
///
|
||||
/// The wire's touch coordinates are SCREEN convention — +y DOWN, what SDL/Windows/Android
|
||||
/// capture APIs all produce — but the Deck's raw trackpad fields are stick convention
|
||||
/// (+y UP, centre origin), and Steam Input parses our report as real Deck hardware. Y is
|
||||
/// therefore negated here, on the device boundary; leaving it through was the "both
|
||||
/// trackpads inverted" bug the first live Deck-to-Deck session surfaced (2026-07-08).
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
/// Screen-convention (+down) wire Y → Deck raw (+up), saturating (-32768 has no i16 negation).
|
||||
fn flip_y(y: i16) -> i16 {
|
||||
(y as i32).saturating_neg().clamp(-32768, 32767) as i16
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad { active, x, y, .. } => {
|
||||
self.press(btn::RPAD_TOUCH, active);
|
||||
// Normalized 0..=65535 (centre 32768, +y down) → the pad's centred s16 range (+y up).
|
||||
self.rpad_x = ((x as i32) - 32768) as i16;
|
||||
self.rpad_y = (32768 - (y as i32)).min(32767) as i16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
// The wire carries DualSense-convention units (what every client capture emits); the
|
||||
// Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so rescale here.
|
||||
let (g, a) = super::steam_remap::motion_wire_to_deck(gyro, accel);
|
||||
self.gyro = g;
|
||||
self.accel = a;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// Signed centre-0 x maps straight in; y flips to the Deck's +up. surface 1 =
|
||||
// left pad, anything else (0 single / 2 right) = right pad.
|
||||
if surface == 1 {
|
||||
self.press(btn::LPAD_TOUCH, touch);
|
||||
// Click lives in its own field, NOT `buttons` — `handle()` rebuilds `buttons`
|
||||
// every gamepad frame and would otherwise wipe a held click (the bug this fixes).
|
||||
self.lpad_click = click;
|
||||
self.lpad_x = x;
|
||||
self.lpad_y = flip_y(y);
|
||||
} else {
|
||||
self.press(btn::RPAD_TOUCH, touch);
|
||||
self.rpad_click = click;
|
||||
self.rpad_x = x;
|
||||
self.rpad_y = flip_y(y);
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports belong to the Triton backend, never a Deck/SC state.
|
||||
RichInput::HidReport { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the full Deck input report (`ID_CONTROLLER_DECK_STATE`) into the 64-byte unnumbered
|
||||
/// frame `hid-steam` parses. Pure + byte-exact against `steam_do_deck_input_event`; the report-id
|
||||
/// constant is `data[0]=0x01` (NOT a HID report id — this report is unnumbered).
|
||||
pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
|
||||
r.fill(0);
|
||||
r[0] = 0x01;
|
||||
r[1] = 0x00;
|
||||
r[2] = ID_CONTROLLER_DECK_STATE;
|
||||
r[3] = 0x3C; // payload length; the kernel ignores it
|
||||
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
||||
// Rich-plane trackpad clicks live in their own fields (see `SteamState`) so a button-only frame
|
||||
// can't wipe them; merge them into the report's click bits here. RPAD_CLICK may ALSO come from
|
||||
// the DualSense touchpad-click wire button via `from_gamepad` — OR both, so either source lights
|
||||
// it and each releases independently.
|
||||
let mut buttons = st.buttons;
|
||||
if st.lpad_click {
|
||||
buttons |= btn::LPAD_CLICK;
|
||||
}
|
||||
if st.rpad_click {
|
||||
buttons |= btn::RPAD_CLICK;
|
||||
}
|
||||
r[8..16].copy_from_slice(&buttons.to_le_bytes()); // bytes 8..16 (12+15 stay 0)
|
||||
r[16..18].copy_from_slice(&st.lpad_x.to_le_bytes());
|
||||
r[18..20].copy_from_slice(&st.lpad_y.to_le_bytes());
|
||||
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
|
||||
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
|
||||
r[24..26].copy_from_slice(&st.accel[0].to_le_bytes()); // accel X → IMU ABS_X
|
||||
r[26..28].copy_from_slice(&st.accel[1].to_le_bytes()); // accel Y → IMU ABS_Z (kernel negates)
|
||||
r[28..30].copy_from_slice(&st.accel[2].to_le_bytes()); // accel Z → IMU ABS_Y
|
||||
r[30..32].copy_from_slice(&st.gyro[0].to_le_bytes()); // gyro X → IMU ABS_RX
|
||||
r[32..34].copy_from_slice(&st.gyro[1].to_le_bytes()); // gyro Y → IMU ABS_RZ (kernel negates)
|
||||
r[34..36].copy_from_slice(&st.gyro[2].to_le_bytes()); // gyro Z → IMU ABS_RY
|
||||
// 36..44 quaternion — left 0 (optional; the kernel does not surface it)
|
||||
r[44..46].copy_from_slice(&st.lt.to_le_bytes()); // left trigger → ABS_HAT2Y
|
||||
r[46..48].copy_from_slice(&st.rt.to_le_bytes()); // right trigger → ABS_HAT2X
|
||||
r[48..50].copy_from_slice(&st.lx.to_le_bytes()); // left joystick X → ABS_X
|
||||
r[50..52].copy_from_slice(&st.ly.to_le_bytes()); // left joystick Y → ABS_Y (kernel negates)
|
||||
r[52..54].copy_from_slice(&st.rx.to_le_bytes()); // right joystick X → ABS_RX
|
||||
r[54..56].copy_from_slice(&st.ry.to_le_bytes()); // right joystick Y → ABS_RY (kernel negates)
|
||||
r[56..58].copy_from_slice(&st.lpad_pressure.to_le_bytes());
|
||||
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit
|
||||
/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/
|
||||
/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 0–6), so this reuses the [`btn`] masks —
|
||||
/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table:
|
||||
/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire
|
||||
/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via
|
||||
/// [`super::steam_remap`] BEFORE calling this.
|
||||
/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the
|
||||
/// DualSense `BTN_TOUCHPAD` click both land there.
|
||||
/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3).
|
||||
/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it).
|
||||
///
|
||||
/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit
|
||||
/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to
|
||||
/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact
|
||||
/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes
|
||||
/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag).
|
||||
pub fn sc_from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> SteamState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let mut s = SteamState {
|
||||
lx,
|
||||
ly,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
lt: (lt as u16) * 128,
|
||||
rt: (rt as u16) * 128,
|
||||
// The wire right stick becomes a right-pad contact (see the doc above).
|
||||
rpad_x: rx,
|
||||
rpad_y: ry,
|
||||
..SteamState::neutral()
|
||||
};
|
||||
let mut b = 0u64;
|
||||
let set = |b: &mut u64, on: bool, m: u64| {
|
||||
if on {
|
||||
*b |= m;
|
||||
}
|
||||
};
|
||||
set(&mut b, on(gs::BTN_A), btn::A);
|
||||
set(&mut b, on(gs::BTN_B), btn::B);
|
||||
set(&mut b, on(gs::BTN_X), btn::X);
|
||||
set(&mut b, on(gs::BTN_Y), btn::Y);
|
||||
set(&mut b, on(gs::BTN_LB), btn::LB);
|
||||
set(&mut b, on(gs::BTN_RB), btn::RB);
|
||||
set(&mut b, lt > 0, btn::LT_FULL);
|
||||
set(&mut b, rt > 0, btn::RT_FULL);
|
||||
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
|
||||
set(&mut b, on(gs::BTN_START), btn::MENU);
|
||||
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
|
||||
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
|
||||
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
|
||||
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
|
||||
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
|
||||
// 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).
|
||||
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
|
||||
set(
|
||||
&mut b,
|
||||
on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD),
|
||||
btn::RPAD_CLICK,
|
||||
);
|
||||
// Right-pad touched (10.4) while the wire stick is deflected — the coords are live then.
|
||||
set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH);
|
||||
s.buttons = b;
|
||||
s
|
||||
}
|
||||
|
||||
/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte
|
||||
/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message
|
||||
/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46),
|
||||
/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the
|
||||
/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible)
|
||||
/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values.
|
||||
pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
|
||||
r.fill(0);
|
||||
r[0] = 0x01;
|
||||
r[1] = 0x00;
|
||||
r[2] = ID_CONTROLLER_STATE;
|
||||
r[3] = 0x3C;
|
||||
r[4..8].copy_from_slice(&seq.to_le_bytes());
|
||||
// Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only —
|
||||
// the kernel maps no key to it), right-pad clicked = 10.2.
|
||||
let mut buttons = st.buttons;
|
||||
if st.lpad_click {
|
||||
buttons |= btn::LPAD_CLICK;
|
||||
}
|
||||
if st.rpad_click {
|
||||
buttons |= btn::RPAD_CLICK;
|
||||
}
|
||||
r[8] = (buttons & 0xFF) as u8;
|
||||
r[9] = ((buttons >> 8) & 0xFF) as u8;
|
||||
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.
|
||||
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
|
||||
(st.lpad_x, st.lpad_y)
|
||||
} else {
|
||||
(st.lx, st.ly)
|
||||
};
|
||||
r[16..18].copy_from_slice(&x.to_le_bytes());
|
||||
r[18..20].copy_from_slice(&y.to_le_bytes());
|
||||
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
|
||||
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
|
||||
// IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it
|
||||
// ("accelerator/gyro is disabled by default" — no sensors evdev for the SC).
|
||||
r[28..30].copy_from_slice(&st.accel[0].to_le_bytes());
|
||||
r[30..32].copy_from_slice(&st.accel[1].to_le_bytes());
|
||||
r[32..34].copy_from_slice(&st.accel[2].to_le_bytes());
|
||||
r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes());
|
||||
r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes());
|
||||
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
|
||||
}
|
||||
|
||||
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
|
||||
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
|
||||
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
|
||||
/// `1<=reply[1]<=21`, `reply[2]==0x01`. Non-fatal (a bad reply → the `"XXXXXXXXXX"` fallback).
|
||||
pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut buf = [0u8; STEAM_REPORT_LEN];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
buf[0] = 0x00; // report id 0 — stripped by steam_recv_report
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
buf[3] = ATTRIB_STR_UNIT_SERIAL;
|
||||
buf[4..4 + len].copy_from_slice(&bytes[..len]);
|
||||
buf
|
||||
}
|
||||
|
||||
/// One service pass's extracted feedback. Rumble rides the universal 0xCA plane (so any client
|
||||
/// feels it); the classic SC's trackpad-pulse haptics (`0x8F`) are a later, model-specific add.
|
||||
#[derive(Default, Debug, PartialEq, Eq)]
|
||||
pub struct SteamFeedback {
|
||||
/// `(low, high)` motor levels (left/strong, right/weak), if a rumble report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
/// Parse a feature/output report the kernel wrote to our device. The Steam feedback path is a
|
||||
/// FEATURE `SET_REPORT` whose wire data is `[0x00 report-id, cmd, len, …]`; `cmd == 0xEB`
|
||||
/// (`steam_haptic_rumble`) carries `[…, 0, intensity(2), left_speed(2), right_speed(2), gains(2)]`.
|
||||
/// We surface `(left_speed, right_speed)` as `(low, high)` for the 0xCA rumble plane.
|
||||
pub fn parse_steam_output(data: &[u8]) -> SteamFeedback {
|
||||
let mut fb = SteamFeedback::default();
|
||||
// data[0] is the stripped report-id byte (0); the command id follows.
|
||||
if data.len() >= 10 && data[1] == ID_TRIGGER_RUMBLE_CMD {
|
||||
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
|
||||
let left = le(6); // left_speed (report[5..7]) → low / strong motor
|
||||
let right = le(8); // right_speed (report[7..9]) → high / weak motor
|
||||
fb.rumble = Some((left, right));
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Real-USB Deck device contract (the gadget + usbip transports present a *real* 3-interface USB
|
||||
// Deck so Steam Input promotes it; the UHID path above uses the minimal [`STEAMDECK_RDESC`]).
|
||||
//
|
||||
// These descriptors are captured verbatim from a physical Steam Deck (28DE:1205): mouse =
|
||||
// interface 0, keyboard = interface 1, **controller = interface 2** (the interface number Steam's
|
||||
// own driver filters on — the reason a UHID Deck, `Interface: -1`, is never promoted). The
|
||||
// `0x83`/`0xAE` feature contract is what stops Steam re-probing (the gamepad-evdev churn). Shared
|
||||
// by [`super::super::steam_gadget`] (raw_gadget) and [`super::super::steam_usbip`] (usbip/vhci).
|
||||
// ===========================================================================================
|
||||
|
||||
/// Captured Deck **mouse** report descriptor (interface 0, EP 0x81).
|
||||
#[rustfmt::skip]
|
||||
pub const RDESC_DECK_MOUSE: &[u8] = &[
|
||||
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,0xa1,0x00,0x05,0x09,0x19,0x01,0x29,0x02,
|
||||
0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,
|
||||
0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,0x81,0x06,
|
||||
0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0c,0x0a,0x38,0x02,0x95,0x01,0x81,0x06,0xc0,0xc0];
|
||||
/// Captured Deck **keyboard** (boot) report descriptor (interface 1, EP 0x82).
|
||||
#[rustfmt::skip]
|
||||
pub const RDESC_DECK_KBD: &[u8] = &[
|
||||
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
|
||||
0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,0x65,0x15,0x00,0x25,0x65,
|
||||
0x75,0x08,0x95,0x06,0x81,0x00,0xc0];
|
||||
/// Captured Deck **controller** report descriptor (interface 2, EP 0x83; Usage Page `0xFFFF`,
|
||||
/// `bCountryCode 33`). The vendor-defined report the `hid-steam` driver binds.
|
||||
#[rustfmt::skip]
|
||||
pub const RDESC_DECK_CTRL: &[u8] = &[
|
||||
0x06,0xff,0xff,0x09,0x01,0xa1,0x01,0x09,0x02,0x09,0x03,0x15,0x00,0x26,0xff,0x00,
|
||||
0x75,0x08,0x95,0x40,0x81,0x02,0x09,0x06,0x09,0x07,0x15,0x00,0x26,0xff,0x00,0x75,
|
||||
0x08,0x95,0x40,0xb1,0x02,0xc0];
|
||||
|
||||
/// Per-instance Deck unit id stamped into the `0x83` GET_ATTRIBUTES device-id attrs (`0x0a`/`0x04`)
|
||||
/// so a virtual Deck never collides with a real one or another instance. `"PF"` high word + index.
|
||||
pub fn deck_unit_id(index: u8) -> u32 {
|
||||
0x5046_0000 | index as u32
|
||||
}
|
||||
|
||||
/// A Steam-accepted alphanumeric unit serial (a real Deck's is e.g. `"FVZZ4200469B"`). Steam
|
||||
/// validates the serial's FORMAT before accepting it: a `"PF"`-leading serial is REJECTED
|
||||
/// ("Invalid or missing unit serial number …") and Steam then substitutes a hash AND mangles the
|
||||
/// displayed controller name (observed as "Steam Deck Controllerggg" on Windows). An `'F'`-leading
|
||||
/// serial passes, so we keep the PunktFunk marker one slot in (`"FVPF"`) — still distinct from a
|
||||
/// real Deck's `"FVZZ"` for the self-detection below while satisfying Steam's format check.
|
||||
/// Derived from [`deck_unit_id`] so the `0xAE` serial reply and the `0x83` unit-id attrs stay
|
||||
/// consistent. (The Windows UMDF driver mirrors this exact format — see pf-dualsense lib.rs.)
|
||||
pub fn deck_serial(index: u8) -> String {
|
||||
format!("FVPF{:08X}", deck_unit_id(index))
|
||||
}
|
||||
|
||||
/// The neutral 64-byte Deck input report (header only, all controls released) — the report the
|
||||
/// real-USB transports stream until the first [`serialize_deck_state`] call updates it.
|
||||
pub fn neutral_deck_report() -> [u8; STEAM_REPORT_LEN] {
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
r[0] = 0x01;
|
||||
r[2] = ID_CONTROLLER_DECK_STATE;
|
||||
r[3] = 0x3C;
|
||||
r
|
||||
}
|
||||
|
||||
/// Build the HID feature GET_REPORT reply for the host's last SET_REPORT command, for the *real-USB*
|
||||
/// Deck (gadget + usbip). Steam's `GetControllerInfo` reads the `0x83` attributes + the `0xAE`
|
||||
/// serial; **serving the real `0x83` blob is what stops Steam re-probing** (the gamepad-evdev churn).
|
||||
/// The 9-attribute `0x83` layout + the `0xAE` string format were captured from a physical Deck via
|
||||
/// hidraw. `unit_id` (see [`deck_unit_id`]) stamps a per-instance value into the device-id attrs.
|
||||
///
|
||||
/// Note this is the raw 64-byte EP0 feature payload (command id first, no report-id prefix) — the USB
|
||||
/// control path, distinct from [`serial_reply`] which carries the UHID report-id byte the kernel
|
||||
/// strips.
|
||||
pub fn feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; STEAM_REPORT_LEN] {
|
||||
let cmd = last_set.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
match cmd {
|
||||
ID_GET_ATTRIBUTES_VALUES => {
|
||||
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9× (attr-id, value u32-LE)].
|
||||
r[0] = ID_GET_ATTRIBUTES_VALUES;
|
||||
r[1] = 0x2d;
|
||||
let attrs: [(u8, u32); 9] = [
|
||||
(0x01, 0x1205), // product id
|
||||
(0x02, 0),
|
||||
(0x0a, unit_id), // unit serial number (per-instance)
|
||||
(0x04, unit_id ^ 0x5555_5555),
|
||||
(0x09, 0x2e),
|
||||
(0x0b, 0x0fa0),
|
||||
(0x0d, 0),
|
||||
(0x0c, 0),
|
||||
(0x0e, 0),
|
||||
];
|
||||
let mut o = 2;
|
||||
for (id, val) in attrs {
|
||||
r[o] = id;
|
||||
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||
o += 5;
|
||||
}
|
||||
}
|
||||
ID_GET_STRING_ATTRIBUTE => {
|
||||
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. The kernel validates the serial (attr
|
||||
// 0x01) wants reply[2]==0x01 and 1<=len<=21; for other attrs we echo the requested id.
|
||||
let attr = last_set.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().clamp(1, 20);
|
||||
r[0] = ID_GET_STRING_ATTRIBUTE;
|
||||
r[1] = len as u8;
|
||||
r[2] = attr;
|
||||
r[3..3 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
_ => {
|
||||
// Settings read-back (e.g. 0x87): echo the host's last command + data.
|
||||
let n = last_set.len().min(STEAM_REPORT_LEN);
|
||||
r[..n].copy_from_slice(&last_set[..n]);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_input_and_feature_reports() {
|
||||
assert!(
|
||||
STEAMDECK_RDESC.contains(&0xB1),
|
||||
"missing Feature main item — steam_is_valve_interface() would fail"
|
||||
);
|
||||
assert!(STEAMDECK_RDESC.contains(&0x81), "missing Input main item");
|
||||
assert_eq!(
|
||||
*STEAMDECK_RDESC.last().unwrap(),
|
||||
0xC0,
|
||||
"unterminated collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every analog field lands at the exact offset `steam_do_deck_input_event` reads, the header is
|
||||
/// what `steam_raw_event` requires, and the buttons pack into bytes 8..16 (12+15 zero). A
|
||||
/// one-byte slip here turns the whole controller into noise.
|
||||
#[test]
|
||||
fn serialize_is_byte_exact() {
|
||||
let mut st = SteamState::neutral();
|
||||
st.buttons = btn::A | btn::L4 | btn::R5 | btn::QAM;
|
||||
st.lx = 0x1122;
|
||||
st.ly = 0x3344;
|
||||
st.rx = 0x5566;
|
||||
st.ry = 0x778;
|
||||
st.lt = 0xABCD;
|
||||
st.rt = 0xEF01;
|
||||
st.lpad_x = 0x0A0B;
|
||||
st.lpad_y = 0x0C0D;
|
||||
st.rpad_x = 0x0E0F;
|
||||
st.rpad_y = 0x1011;
|
||||
st.accel = [0x0102, 0x0304, 0x0506];
|
||||
st.gyro = [0x0708, 0x090A, 0x0B0C];
|
||||
st.lpad_pressure = 0x1314;
|
||||
st.rpad_pressure = 0x1516;
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_deck_state(&mut r, &st, 0xAABB_CCDD);
|
||||
assert_eq!(&r[0..4], &[0x01, 0x00, 0x09, 0x3C]);
|
||||
assert_eq!(&r[4..8], &[0xDD, 0xCC, 0xBB, 0xAA]); // seq LE
|
||||
// buttons: A=bit7 (byte8), L4=bit41 (byte13.1), R5=bit16 (byte10.0), QAM=bit50 (byte14.2).
|
||||
assert_eq!(r[8], 0x80); // A
|
||||
assert_eq!(r[10], 0x01); // R5
|
||||
assert_eq!(r[12], 0x00); // unused button byte
|
||||
assert_eq!(r[13], 0x02); // L4 (bit 1)
|
||||
assert_eq!(r[14], 0x04); // QAM (bit 2)
|
||||
assert_eq!(r[15], 0x00); // unused button byte
|
||||
assert_eq!(&r[16..18], &0x0A0Bi16.to_le_bytes()); // lpad X
|
||||
assert_eq!(&r[20..22], &0x0E0Fi16.to_le_bytes()); // rpad X
|
||||
assert_eq!(&r[24..26], &0x0102i16.to_le_bytes()); // accel X
|
||||
assert_eq!(&r[26..28], &0x0304i16.to_le_bytes()); // accel Y
|
||||
assert_eq!(&r[28..30], &0x0506i16.to_le_bytes()); // accel Z
|
||||
assert_eq!(&r[30..32], &0x0708i16.to_le_bytes()); // gyro X
|
||||
assert_eq!(&r[44..46], &0xABCDu16.to_le_bytes()); // left trigger
|
||||
assert_eq!(&r[46..48], &0xEF01u16.to_le_bytes()); // right trigger
|
||||
assert_eq!(&r[48..50], &0x1122i16.to_le_bytes()); // left joy X
|
||||
assert_eq!(&r[50..52], &0x3344i16.to_le_bytes()); // left joy Y
|
||||
assert_eq!(&r[52..54], &0x5566i16.to_le_bytes()); // right joy X
|
||||
assert_eq!(&r[56..58], &0x1314u16.to_le_bytes()); // left pad pressure
|
||||
assert_eq!(&r[58..60], &0x1516u16.to_le_bytes()); // right pad pressure
|
||||
}
|
||||
|
||||
/// `from_gamepad` sets the right Deck bits + scales triggers, and a touched flag is merged when
|
||||
/// a trackpad contact arrives via `apply_rich`.
|
||||
#[test]
|
||||
fn from_gamepad_and_rich_mapping() {
|
||||
let s = SteamState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_LB,
|
||||
1000,
|
||||
-2000,
|
||||
0,
|
||||
0,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_ne!(s.buttons & btn::A, 0);
|
||||
assert_ne!(s.buttons & btn::MENU, 0);
|
||||
assert_ne!(s.buttons & btn::STEAM, 0);
|
||||
assert_ne!(s.buttons & btn::LB, 0);
|
||||
assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit
|
||||
assert_eq!(s.lt, 255 * 128);
|
||||
assert_eq!(s.lx, 1000);
|
||||
assert_eq!(s.ly, -2000);
|
||||
|
||||
let mut s = SteamState::neutral();
|
||||
s.apply_rich(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x: 65535,
|
||||
y: 0,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert_eq!(s.rpad_x, 32767); // 65535-32768
|
||||
assert_eq!(s.rpad_y, 32767); // wire y=0 = TOP (screen conv) → Deck raw +up (clamped)
|
||||
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
|
||||
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
||||
s.apply_rich(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [1000, -2000, 0],
|
||||
accel: [10000, -5000, 0],
|
||||
});
|
||||
assert_eq!(s.gyro, [800, -1600, 0]);
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
/// left / right surfaces to the matching pad (x passes straight through; y flips from the
|
||||
/// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction).
|
||||
#[test]
|
||||
fn back_buttons_and_dual_trackpad_mapping() {
|
||||
let s = SteamState::from_gamepad(
|
||||
gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_MISC1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
assert_ne!(s.buttons & btn::R4, 0); // PADDLE1 = R4
|
||||
assert_ne!(s.buttons & btn::L4, 0); // PADDLE2 = L4
|
||||
assert_ne!(s.buttons & btn::R5, 0); // PADDLE3 = R5
|
||||
assert_ne!(s.buttons & btn::L5, 0); // PADDLE4 = L5
|
||||
assert_ne!(s.buttons & btn::QAM, 0); // MISC1 = QAM
|
||||
|
||||
let mut s = SteamState::neutral();
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: -5000,
|
||||
y: 6000,
|
||||
pressure: 100,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
|
||||
// Click now rides its own field (kept OUT of `buttons`, which handle() rebuilds each frame).
|
||||
assert!(s.lpad_click);
|
||||
assert_eq!(s.buttons & btn::LPAD_CLICK, 0);
|
||||
assert_eq!((s.lpad_x, s.lpad_y), (-5000, -6000));
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 7000,
|
||||
y: -8000,
|
||||
pressure: 0,
|
||||
});
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert!(!s.rpad_click); // click:false → field cleared
|
||||
assert_eq!((s.rpad_x, s.rpad_y), (7000, 8000));
|
||||
|
||||
// The i16 edge: wire y = -32768 (top-most) must clamp, not overflow.
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 2,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: -32768,
|
||||
pressure: 0,
|
||||
});
|
||||
assert_eq!(s.rpad_y, 32767);
|
||||
}
|
||||
|
||||
/// Regression (G2): a held trackpad click set on the rich plane must survive the per-frame
|
||||
/// `buttons` rebuild that `SteamControllerManager::handle` performs via `from_gamepad`. Before
|
||||
/// the fix, click lived in `buttons` and the rebuild wiped it every gamepad frame.
|
||||
#[test]
|
||||
fn rich_click_survives_a_buttons_rebuild() {
|
||||
let mut held = SteamState::neutral();
|
||||
held.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
});
|
||||
assert!(held.lpad_click);
|
||||
// A following button-only frame: from_gamepad rebuilds buttons (dropping the click bit),
|
||||
// then handle() carries the rich fields over — the click must still reach the report.
|
||||
let mut merged = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(merged.buttons & btn::LPAD_CLICK, 0); // the rebuild alone loses it (the old bug)
|
||||
merged.lpad_click = held.lpad_click; // what handle() now preserves
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_deck_state(&mut r, &merged, 0);
|
||||
let serialized = u64::from_le_bytes(r[8..16].try_into().unwrap());
|
||||
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
|
||||
}
|
||||
|
||||
/// The classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit
|
||||
/// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right
|
||||
/// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at
|
||||
/// 10.2, joystick click at 10.6).
|
||||
#[test]
|
||||
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,
|
||||
1000,
|
||||
-2000,
|
||||
3000,
|
||||
-4000,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_ne!(s.buttons & btn::A, 0);
|
||||
assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0)
|
||||
assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7)
|
||||
assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6)
|
||||
assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2)
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4)
|
||||
assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad
|
||||
assert_eq!((s.rx, s.ry), (0, 0));
|
||||
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_sc_state(&mut r, &s, 0x0102_0304);
|
||||
assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE
|
||||
assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]);
|
||||
assert_eq!(r[8] & 0x80, 0x80); // A = 8.7
|
||||
assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7
|
||||
assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0
|
||||
assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2
|
||||
assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6
|
||||
assert_eq!(r[11], 255); // left trigger u8
|
||||
assert_eq!(r[12], 0); // right trigger u8
|
||||
assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched)
|
||||
assert_eq!(&r[18..20], &(-2000i16).to_le_bytes());
|
||||
assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X
|
||||
assert_eq!(&r[22..24], &(-4000i16).to_le_bytes());
|
||||
|
||||
// Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20
|
||||
// and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field).
|
||||
let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0);
|
||||
s.apply_rich(RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface: 1,
|
||||
finger: 0,
|
||||
touch: true,
|
||||
click: true,
|
||||
x: -5000,
|
||||
y: 6000,
|
||||
pressure: 0,
|
||||
});
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_sc_state(&mut r, &s, 0);
|
||||
assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3
|
||||
assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged)
|
||||
assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick
|
||||
assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip)
|
||||
}
|
||||
|
||||
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
|
||||
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
||||
#[test]
|
||||
fn serial_reply_has_stripped_prefix() {
|
||||
let r = serial_reply("PUNKTFUNK01");
|
||||
assert_eq!(r[0], 0x00); // report id, stripped by steam_recv_report
|
||||
assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); // becomes reply[0] after strip
|
||||
assert!((1..=21).contains(&r[2]));
|
||||
assert_eq!(r[3], ATTRIB_STR_UNIT_SERIAL);
|
||||
assert_eq!(&r[4..4 + r[2] as usize], b"PUNKTFUNK01");
|
||||
}
|
||||
|
||||
/// A `0xEB` rumble feature report parses to `(left_speed, right_speed)`; other commands don't.
|
||||
#[test]
|
||||
fn parse_rumble_feedback() {
|
||||
// [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gains(2)]
|
||||
let mut d = vec![0u8; 12];
|
||||
d[1] = ID_TRIGGER_RUMBLE_CMD;
|
||||
d[2] = 9;
|
||||
d[6..8].copy_from_slice(&0x8000u16.to_le_bytes()); // left_speed
|
||||
d[8..10].copy_from_slice(&0x4000u16.to_le_bytes()); // right_speed
|
||||
assert_eq!(parse_steam_output(&d).rumble, Some((0x8000, 0x4000)));
|
||||
|
||||
let mut d = vec![0u8; 12];
|
||||
d[1] = ID_SET_SETTINGS_VALUES; // a settings write — no rumble
|
||||
assert_eq!(parse_steam_output(&d).rumble, None);
|
||||
}
|
||||
|
||||
/// The shared real-USB Deck feature contract (gadget + usbip): the `0x83` GET_ATTRIBUTES reply
|
||||
/// carries the 9-attribute blob with the per-instance unit id, and the `0xAE` reply carries the
|
||||
/// Steam-accepted serial — both keyed off the host's last SET_REPORT command. A slip here is the
|
||||
/// gamepad-evdev churn (Steam re-probing).
|
||||
#[test]
|
||||
fn deck_feature_reply_contract() {
|
||||
let serial = deck_serial(0);
|
||||
let unit_id = deck_unit_id(0);
|
||||
assert_eq!(serial, "FVPF50460000"); // 12-char alphanumeric, derived from the unit id
|
||||
assert_eq!(serial.len(), 12);
|
||||
|
||||
// 0x83 GET_ATTRIBUTES_VALUES: header + (0x0a, unit_id) at the 3rd attribute slot.
|
||||
let r = feature_reply(&[ID_GET_ATTRIBUTES_VALUES], &serial, unit_id);
|
||||
assert_eq!(r[0], ID_GET_ATTRIBUTES_VALUES);
|
||||
assert_eq!(r[1], 0x2d);
|
||||
assert_eq!(r[12], 0x0a); // 3rd attr id (slots at 2,7,12,…)
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([r[13], r[14], r[15], r[16]]),
|
||||
unit_id,
|
||||
"unit serial attribute must carry the per-instance unit id"
|
||||
);
|
||||
|
||||
// 0xAE GET_STRING_ATTRIBUTE: [0xAE, len, attr(0x01), ascii serial…].
|
||||
let r = feature_reply(
|
||||
&[ID_GET_STRING_ATTRIBUTE, 0, ATTRIB_STR_UNIT_SERIAL],
|
||||
&serial,
|
||||
unit_id,
|
||||
);
|
||||
assert_eq!(r[0], ID_GET_STRING_ATTRIBUTE);
|
||||
assert_eq!(r[1] as usize, serial.len());
|
||||
assert_eq!(r[2], ATTRIB_STR_UNIT_SERIAL);
|
||||
assert_eq!(&r[3..3 + serial.len()], serial.as_bytes());
|
||||
|
||||
// Distinct pad indices get distinct unit ids + serials (no collision between virtual Decks).
|
||||
assert_ne!(deck_unit_id(0), deck_unit_id(1));
|
||||
assert_ne!(deck_serial(0), deck_serial(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Pure fallback-remap policy for the Steam Controller / Steam Deck rich inputs when the resolved
|
||||
//! host backend is **not** the virtual `hid-steam` device (DualSense / DualShock 4 / Xbox), so a
|
||||
//! client's Steam-only inputs aren't silently dropped — plus the cross-device motion rescale the
|
||||
//! Deck backend itself needs.
|
||||
//!
|
||||
//! Driven by the host's `PUNKTFUNK_STEAM_REMAP` env (`key=value`, `,`/`;`-separated, e.g.
|
||||
//! `paddles=stickclicks`). No I/O beyond [`RemapConfig::from_env`]; everything else is pure +
|
||||
//! unit-testable. The uinput Xbox pad already exposes the back grips as Elite paddles
|
||||
//! (`BTN_TRIGGER_HAPPY5-8`), so only the slot-less DualSense / DS4 backends fold them.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
/// Where the four Steam back grips go on a backend with no native back-button HID slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PaddleFallback {
|
||||
/// Drop them — the back buttons are simply absent on this pad. The honest default: don't fire
|
||||
/// buttons the user didn't ask for. Set the env to map them instead.
|
||||
#[default]
|
||||
Drop,
|
||||
/// L4/L5 → left-stick click, R4/R5 → right-stick click.
|
||||
StickClicks,
|
||||
/// L4/L5 → left bumper, R4/R5 → right bumper.
|
||||
Shoulders,
|
||||
}
|
||||
|
||||
/// Fallback-remap knobs parsed from `PUNKTFUNK_STEAM_REMAP`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RemapConfig {
|
||||
pub paddles: PaddleFallback,
|
||||
}
|
||||
|
||||
impl RemapConfig {
|
||||
/// Parse the host's `PUNKTFUNK_STEAM_REMAP` env (absent / unrecognized → defaults).
|
||||
pub fn from_env() -> RemapConfig {
|
||||
std::env::var("PUNKTFUNK_STEAM_REMAP")
|
||||
.map(|s| RemapConfig::parse(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Pure parse of the `key=value[,key=value…]` string (the testable core of [`from_env`]).
|
||||
pub fn parse(s: &str) -> RemapConfig {
|
||||
let mut cfg = RemapConfig::default();
|
||||
for kv in s.split([',', ';']) {
|
||||
let mut it = kv.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (it.next(), it.next()) {
|
||||
if k.trim().eq_ignore_ascii_case("paddles") {
|
||||
cfg.paddles = match v.trim().to_ascii_lowercase().as_str() {
|
||||
"stickclicks" | "l3r3" | "sticks" => PaddleFallback::StickClicks,
|
||||
"shoulders" | "lbrb" | "bumpers" => PaddleFallback::Shoulders,
|
||||
_ => PaddleFallback::Drop,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the wire back-grip bits (`BTN_PADDLE1..4`) into standard buttons per `policy` for a pad with
|
||||
/// no native back-button slot, clearing the paddle bits. Pure. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
||||
pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 {
|
||||
let left = buttons & (gs::BTN_PADDLE2 | gs::BTN_PADDLE4) != 0; // L4 | L5
|
||||
let right = buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE3) != 0; // R4 | R5
|
||||
buttons &= !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4);
|
||||
let (lbit, rbit) = match policy {
|
||||
PaddleFallback::Drop => return buttons,
|
||||
PaddleFallback::StickClicks => (gs::BTN_LS_CLICK, gs::BTN_RS_CLICK),
|
||||
PaddleFallback::Shoulders => (gs::BTN_LB, gs::BTN_RB),
|
||||
};
|
||||
if left {
|
||||
buttons |= lbit;
|
||||
}
|
||||
if right {
|
||||
buttons |= rbit;
|
||||
}
|
||||
buttons
|
||||
}
|
||||
|
||||
// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the
|
||||
// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and
|
||||
// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1.
|
||||
const GYRO_NUM: i32 = 16;
|
||||
const GYRO_DEN: i32 = 20;
|
||||
const ACCEL_NUM: i32 = 16384;
|
||||
const ACCEL_DEN: i32 = 10000;
|
||||
|
||||
fn scale(v: i16, num: i32, den: i32) -> i16 {
|
||||
((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16
|
||||
}
|
||||
|
||||
/// Rescale a wire (DualSense-convention) motion sample into the Steam Deck's `hid-steam` units.
|
||||
pub fn motion_wire_to_deck(gyro: [i16; 3], accel: [i16; 3]) -> ([i16; 3], [i16; 3]) {
|
||||
(
|
||||
gyro.map(|g| scale(g, GYRO_NUM, GYRO_DEN)),
|
||||
accel.map(|a| scale(a, ACCEL_NUM, ACCEL_DEN)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_paddle_policy() {
|
||||
assert_eq!(RemapConfig::parse("").paddles, PaddleFallback::Drop);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("paddles=stickclicks").paddles,
|
||||
PaddleFallback::StickClicks
|
||||
);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("foo=bar; paddles = Shoulders").paddles,
|
||||
PaddleFallback::Shoulders
|
||||
);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("paddles=nonsense").paddles,
|
||||
PaddleFallback::Drop
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_paddles_maps_and_clears() {
|
||||
// All four grips set + a real A button.
|
||||
let b = gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4;
|
||||
// Drop: paddle bits cleared, A preserved, nothing added.
|
||||
assert_eq!(fold_paddles(b, PaddleFallback::Drop), gs::BTN_A);
|
||||
// StickClicks: left grips → L3, right grips → R3.
|
||||
assert_eq!(
|
||||
fold_paddles(b, PaddleFallback::StickClicks),
|
||||
gs::BTN_A | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK
|
||||
);
|
||||
// Only a left grip (L4 = PADDLE2) → only the left bumper under Shoulders.
|
||||
assert_eq!(
|
||||
fold_paddles(gs::BTN_PADDLE2, PaddleFallback::Shoulders),
|
||||
gs::BTN_LB
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_rescale_to_deck_units() {
|
||||
// gyro × 16/20 = 0.8; accel × 16384/10000 = 1.6384.
|
||||
let (g, a) = motion_wire_to_deck([1000, -2000, 0], [10000, -5000, 0]);
|
||||
assert_eq!(g, [800, -1600, 0]);
|
||||
assert_eq!(a, [16384, -8192, 0]);
|
||||
// Saturates rather than wraps.
|
||||
let (_, a) = motion_wire_to_deck([0; 3], [32767, i16::MIN, 0]);
|
||||
assert_eq!(a[0], i16::MAX);
|
||||
assert_eq!(a[1], i16::MIN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Transport-independent Nintendo Switch Pro Controller contract — the report codec + canned
|
||||
//! handshake replies the Linux UHID backend ([`super::switch_pro`]) drives `hid-nintendo` with.
|
||||
//!
|
||||
//! Everything here is pinned against the kernel driver source (drivers/hid/hid-nintendo.c —
|
||||
//! the ONE consumer of these bytes; a virtual pad must answer its probe exactly or no input
|
||||
//! devices appear):
|
||||
//!
|
||||
//! - **USB handshake**: 2-byte output reports `0x80 <cmd>` (handshake / baudrate / no-timeout),
|
||||
//! each ACKed with an input report `0x81 <cmd>` (`joycon_send_usb` matches only those two
|
||||
//! bytes).
|
||||
//! - **Subcommands**: output report `0x01` (packet counter + 8 rumble bytes + subcommand id +
|
||||
//! args), ACKed with input report `0x21` — a 12-byte input-state header, then ack byte /
|
||||
//! echoed subcommand id / payload. The driver matches ONLY the echoed id (byte 14) and
|
||||
//! requires ≥ 49 bytes; real hardware sends 64.
|
||||
//! - **SPI flash reads** (subcommand `0x10`): the driver reads the user-calibration magics
|
||||
//! (absent here → `0xFF 0xFF`, so it takes the factory path), the factory stick calibrations
|
||||
//! (9-byte packed 12-bit triples — max/center/min order DIFFERS left vs right), and the
|
||||
//! 24-byte factory IMU calibration. We serve blobs chosen so the math is clean: sticks
|
||||
//! centered at [`STICK_CENTER`] ± [`STICK_RANGE`], IMU offsets 0 with the driver's default
|
||||
//! scales (accel 16384, gyro 13371) so raw units pass through 1:1.
|
||||
//! - **Input report `0x30`**: 3 button bytes (bit layout per `JC_BTN_*`), two packed 12-bit
|
||||
//! stick triples, battery/connection, and 3 IMU sample frames (accel then gyro, i16 LE).
|
||||
//! - **Rumble**: 4 encoded bytes per side in every `0x01`/`0x10` output; we decode the
|
||||
//! amplitude through the driver's own `joycon_rumble_amplitudes` table (inverted) back to the
|
||||
//! 0..=0xFFFF wire magnitudes it was scaled from (left = strong/low, right = weak/high).
|
||||
//!
|
||||
//! Wire-mapping subtleties (see the plan doc, gamepad-new-types §4):
|
||||
//! - **Positional swap.** Wire `BTN_A` is the SOUTH button (GameStream convention); on a Switch
|
||||
//! pad SOUTH is `B`. `from_gamepad` maps wire-south → the report's B bit (and X/Y likewise),
|
||||
//! so the physical-position ↔ glyph relationship stays correct end-to-end.
|
||||
//! - **Units.** Wire motion is DualSense-convention (20 LSB/°·s, 10000 LSB/g); the report wants
|
||||
//! real-Pro-Controller raw units (≈14.247 LSB/°·s per `JC_IMU_GYRO_RES_PER_DPS`, 4096 LSB/g
|
||||
//! per `JC_IMU_ACCEL_RES_PER_G`), which our calibration blobs make the driver consume 1:1.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
pub const SWITCH_VENDOR: u32 = 0x057E; // Nintendo Co., Ltd
|
||||
pub const SWITCH_PRODUCT: u32 = 0x2009; // Pro Controller
|
||||
|
||||
/// Nintendo Switch Pro Controller **USB** HID report descriptor (203 bytes) — a verbatim
|
||||
/// real-device capture (usbhid-dump off a wired Pro Controller; three independent public
|
||||
/// captures agree byte-for-byte: mzyy94's usbhid-dump, ToadKing's full USB capture, and
|
||||
/// spacemeowx2's annotated dump). Declares exactly the report ids `hid-nintendo` exchanges
|
||||
/// wired (inputs 0x30/0x21/0x81, outputs 0x01/0x10/0x80/0x82); the driver reads raw events,
|
||||
/// so the descriptor only has to `hid_parse()` — but this is what real hardware presents.
|
||||
/// NOT the Bluetooth descriptor (that one is ~170 bytes with a different report set).
|
||||
#[rustfmt::skip]
|
||||
pub const PROCON_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01, 0x05, 0x09, 0x19, 0x01,
|
||||
0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02,
|
||||
0x05, 0x09, 0x19, 0x0B, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
|
||||
0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1, 0x00, 0x0B, 0x30, 0x00,
|
||||
0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B, 0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01,
|
||||
0x00, 0x15, 0x00, 0x27, 0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
|
||||
0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75,
|
||||
0x04, 0x95, 0x01, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75,
|
||||
0x01, 0x95, 0x04, 0x81, 0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
|
||||
0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02, 0x75, 0x08, 0x95, 0x3F,
|
||||
0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04,
|
||||
0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
|
||||
0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0,
|
||||
];
|
||||
/// Every input report we emit is the full USB size (the driver requires ≥ 49 for `0x21`).
|
||||
pub const SWITCH_REPORT_LEN: usize = 64;
|
||||
|
||||
/// Stick raw center + full-deflection range of OUR virtual pad's calibration (12-bit axis).
|
||||
/// The factory blobs below advertise exactly this, so the driver maps
|
||||
/// `center ± range → ∓/± 32767` — one clean linear scale from the wire values.
|
||||
pub const STICK_CENTER: u16 = 2048;
|
||||
pub const STICK_RANGE: u16 = 1400;
|
||||
|
||||
/// `battery and connection info` byte (report byte 2): high 3 bits = level (4 = full),
|
||||
/// BIT(4) = charging, BIT(0) = host powered — "full + charging + wired", so no low-battery
|
||||
/// warnings ever.
|
||||
pub const BAT_CON_FULL_WIRED: u8 = 0x91;
|
||||
/// `vibrator_report` (report byte 12): must be non-zero or the driver stops pumping its rumble
|
||||
/// queue (`joycon_ctlr_read_handler` gates on it). Real hardware sends 0x70-ish.
|
||||
pub const VIBRATOR_READY: u8 = 0x70;
|
||||
|
||||
// Button bits of the 24-bit little-endian button field (report bytes 3..6), per the kernel's
|
||||
// JC_BTN_* defines.
|
||||
pub mod btn {
|
||||
pub const Y: u32 = 1 << 0;
|
||||
pub const X: u32 = 1 << 1;
|
||||
pub const B: u32 = 1 << 2;
|
||||
pub const A: u32 = 1 << 3;
|
||||
pub const R: u32 = 1 << 6;
|
||||
pub const ZR: u32 = 1 << 7;
|
||||
pub const MINUS: u32 = 1 << 8;
|
||||
pub const PLUS: u32 = 1 << 9;
|
||||
pub const RSTICK: u32 = 1 << 10;
|
||||
pub const LSTICK: u32 = 1 << 11;
|
||||
pub const HOME: u32 = 1 << 12;
|
||||
pub const CAPTURE: u32 = 1 << 13;
|
||||
pub const DOWN: u32 = 1 << 16;
|
||||
pub const UP: u32 = 1 << 17;
|
||||
pub const RIGHT: u32 = 1 << 18;
|
||||
pub const LEFT: u32 = 1 << 19;
|
||||
pub const L: u32 = 1 << 22;
|
||||
pub const ZL: u32 = 1 << 23;
|
||||
}
|
||||
|
||||
/// Full Pro Controller state serialized into report `0x30` (and the `0x21` reply headers).
|
||||
/// Sticks are the RAW 12-bit values ([`STICK_CENTER`]-centered); motion is raw IMU units.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct SwitchState {
|
||||
/// 24-bit `JC_BTN_*` field.
|
||||
pub buttons: u32,
|
||||
pub lx: u16,
|
||||
pub ly: u16,
|
||||
pub rx: u16,
|
||||
pub ry: u16,
|
||||
/// Raw gyro (≈14.247 LSB/°·s) and accel (4096 LSB/g), driver axis order x/y/z.
|
||||
pub gyro: [i16; 3],
|
||||
pub accel: [i16; 3],
|
||||
}
|
||||
|
||||
impl SwitchState {
|
||||
/// Centered sticks, nothing pressed, flat at rest (1 g on +Z — a pad lying on the desk, so
|
||||
/// SDL/games don't see a free-falling controller).
|
||||
pub fn neutral() -> SwitchState {
|
||||
SwitchState {
|
||||
buttons: 0,
|
||||
lx: STICK_CENTER,
|
||||
ly: STICK_CENTER,
|
||||
rx: STICK_CENTER,
|
||||
ry: STICK_CENTER,
|
||||
gyro: [0; 3],
|
||||
accel: [0, 0, 4096],
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a GameStream/XInput pad frame into Pro Controller state. Face buttons are mapped
|
||||
/// **positionally** (wire A = south → Switch B, etc. — see the module doc); triggers are
|
||||
/// digital on a Pro Controller, so any analog pull presses ZL/ZR. The wire paddles have no
|
||||
/// Switch slot — fold them via [`super::steam_remap`] BEFORE calling this (like the
|
||||
/// DualSense-family backends do).
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> SwitchState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let mut b = 0u32;
|
||||
// Positional: wire south/east/west/north → the Switch button at that position.
|
||||
if on(gs::BTN_A) {
|
||||
b |= btn::B; // south
|
||||
}
|
||||
if on(gs::BTN_B) {
|
||||
b |= btn::A; // east
|
||||
}
|
||||
if on(gs::BTN_X) {
|
||||
b |= btn::Y; // west
|
||||
}
|
||||
if on(gs::BTN_Y) {
|
||||
b |= btn::X; // north
|
||||
}
|
||||
if on(gs::BTN_LB) {
|
||||
b |= btn::L;
|
||||
}
|
||||
if on(gs::BTN_RB) {
|
||||
b |= btn::R;
|
||||
}
|
||||
if lt > 0 {
|
||||
b |= btn::ZL;
|
||||
}
|
||||
if rt > 0 {
|
||||
b |= btn::ZR;
|
||||
}
|
||||
if on(gs::BTN_BACK) {
|
||||
b |= btn::MINUS;
|
||||
}
|
||||
if on(gs::BTN_START) {
|
||||
b |= btn::PLUS;
|
||||
}
|
||||
if on(gs::BTN_LS_CLICK) {
|
||||
b |= btn::LSTICK;
|
||||
}
|
||||
if on(gs::BTN_RS_CLICK) {
|
||||
b |= btn::RSTICK;
|
||||
}
|
||||
if on(gs::BTN_GUIDE) {
|
||||
b |= btn::HOME;
|
||||
}
|
||||
if on(gs::BTN_MISC1) {
|
||||
b |= btn::CAPTURE;
|
||||
}
|
||||
if on(gs::BTN_DPAD_UP) {
|
||||
b |= btn::UP;
|
||||
}
|
||||
if on(gs::BTN_DPAD_DOWN) {
|
||||
b |= btn::DOWN;
|
||||
}
|
||||
if on(gs::BTN_DPAD_LEFT) {
|
||||
b |= btn::LEFT;
|
||||
}
|
||||
if on(gs::BTN_DPAD_RIGHT) {
|
||||
b |= btn::RIGHT;
|
||||
}
|
||||
SwitchState {
|
||||
buttons: b,
|
||||
lx: stick_raw(lx),
|
||||
ly: stick_raw(ly),
|
||||
rx: stick_raw(rx),
|
||||
ry: stick_raw(ry),
|
||||
..SwitchState::neutral()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a wire motion sample (DualSense-convention units) as raw IMU values. No axis flip:
|
||||
/// both conventions are x-toward-triggers / z-up for a Pro Controller held like a DualSense,
|
||||
/// and the driver applies no negation for the Pro (only the right Joy-Con negates).
|
||||
pub fn apply_motion(&mut self, gyro: [i16; 3], accel: [i16; 3]) {
|
||||
// gyro: wire 20 LSB/°·s → raw 14.247 LSB/°·s; accel: wire 10000 LSB/g → raw 4096 LSB/g.
|
||||
self.gyro = gyro.map(|v| ((v as i32 * 14247) / 20000) as i16);
|
||||
self.accel = accel.map(|v| ((v as i32 * 4096) / 10000) as i16);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire stick value (i16, +32767 = right/up) → raw 12-bit axis. The driver Y-negates BOTH the
|
||||
/// wire's and evdev's conventions away: it computes `evdev_y = -scale(raw_y)`, and evdev's
|
||||
/// gamepad convention is negative-up — so wire +y (up) maps to raw above-center, exactly like x.
|
||||
pub fn stick_raw(v: i16) -> u16 {
|
||||
let raw = STICK_CENTER as i32 + (v as i32 * STICK_RANGE as i32) / 32767;
|
||||
raw.clamp(0, 0xFFF) as u16
|
||||
}
|
||||
|
||||
/// Pack two 12-bit values into the 3-byte stick / calibration wire form
|
||||
/// (`hid_field_extract` little-endian bitfield order).
|
||||
pub fn pack12(a: u16, b: u16) -> [u8; 3] {
|
||||
[
|
||||
(a & 0xFF) as u8,
|
||||
((a >> 8) & 0x0F) as u8 | ((b & 0x0F) << 4) as u8,
|
||||
((b >> 4) & 0xFF) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
/// Write the shared 13-byte input-state header (report id .. `vibrator_report`) that both the
|
||||
/// `0x30` stream and every `0x21` subcommand reply carry.
|
||||
fn write_header(r: &mut [u8; SWITCH_REPORT_LEN], id: u8, st: &SwitchState, timer: u8) {
|
||||
r[0] = id;
|
||||
r[1] = timer;
|
||||
r[2] = BAT_CON_FULL_WIRED;
|
||||
r[3] = (st.buttons & 0xFF) as u8;
|
||||
r[4] = ((st.buttons >> 8) & 0xFF) as u8;
|
||||
r[5] = ((st.buttons >> 16) & 0xFF) as u8;
|
||||
r[6..9].copy_from_slice(&pack12(st.lx, st.ly));
|
||||
r[9..12].copy_from_slice(&pack12(st.rx, st.ry));
|
||||
r[12] = VIBRATOR_READY;
|
||||
}
|
||||
|
||||
/// Serialize the full/standard input report `0x30`: state header + 3 IMU sample frames
|
||||
/// (accel x/y/z then gyro x/y/z, i16 LE — `struct joycon_imu_data`). We repeat the current
|
||||
/// sample across all three 5 ms sub-frames (we sample per report, not per sub-frame).
|
||||
pub fn serialize_report_0x30(st: &SwitchState, timer: u8) -> [u8; SWITCH_REPORT_LEN] {
|
||||
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||
write_header(&mut r, 0x30, st, timer);
|
||||
for frame in 0..3 {
|
||||
let off = 13 + frame * 12;
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[off + i * 2..off + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[off + 6 + i * 2..off + 6 + i * 2 + 2].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Build the `0x81 <cmd>` input report acknowledging a USB `0x80 <cmd>` command
|
||||
/// (`joycon_send_usb` matches exactly those two bytes).
|
||||
pub fn build_usb_ack(cmd: u8) -> [u8; SWITCH_REPORT_LEN] {
|
||||
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||
r[0] = 0x81;
|
||||
r[1] = cmd;
|
||||
r
|
||||
}
|
||||
|
||||
/// Build a `0x21` subcommand reply: state header, then ack / echoed subcommand id / payload.
|
||||
/// The driver matches on the echoed id only; the MSB-set ack byte mirrors real hardware
|
||||
/// (`0x80` plain ack, `0x80 | data-type` when a payload follows).
|
||||
pub fn build_subcmd_reply(
|
||||
st: &SwitchState,
|
||||
timer: u8,
|
||||
ack: u8,
|
||||
subcmd: u8,
|
||||
payload: &[u8],
|
||||
) -> [u8; SWITCH_REPORT_LEN] {
|
||||
let mut r = [0u8; SWITCH_REPORT_LEN];
|
||||
write_header(&mut r, 0x21, st, timer);
|
||||
r[13] = ack;
|
||||
r[14] = subcmd;
|
||||
let n = payload.len().min(SWITCH_REPORT_LEN - 15);
|
||||
r[15..15 + n].copy_from_slice(&payload[..n]);
|
||||
r
|
||||
}
|
||||
|
||||
/// The device-info payload (subcommand `0x02`): firmware 4.33, type `0x03` = **Pro Controller**
|
||||
/// (`ctlr_type` — the value that selects the Pro button/stick/IMU paths), `0x02`, the 6-byte
|
||||
/// MAC (parsed into `ctlr->mac_addr`, printed + used as the input devices' `uniq`), `0x01`,
|
||||
/// and `0x01` = "colors in SPI" (not read by the driver).
|
||||
pub fn device_info_payload(mac: &[u8; 6]) -> [u8; 12] {
|
||||
let mut p = [0u8; 12];
|
||||
p[0] = 0x04;
|
||||
p[1] = 0x21;
|
||||
p[2] = 0x03; // JOYCON_CTLR_TYPE_PRO
|
||||
p[3] = 0x02;
|
||||
p[4..10].copy_from_slice(mac);
|
||||
p[10] = 0x01;
|
||||
p[11] = 0x01;
|
||||
p
|
||||
}
|
||||
|
||||
/// A stable per-pad virtual MAC (Nintendo OUI + our index) — the driver requires one from
|
||||
/// device info and keys the input devices' `uniq` off it.
|
||||
pub fn switch_mac(index: u8) -> [u8; 6] {
|
||||
[0x7C, 0xBB, 0x8A, 0xDF, 0x00, index]
|
||||
}
|
||||
|
||||
/// The canned SPI-flash contents (subcommand `0x10`): reply payload = echoed LE address +
|
||||
/// echoed length + the flash bytes. `None` for an unmapped range (the caller then replies with
|
||||
/// zeroes — the driver falls back to defaults rather than aborting).
|
||||
///
|
||||
/// Served ranges:
|
||||
/// - `0x8010`/`0x801B`/`0x8026` (user-cal magics, 2 B): NOT `0xB2 0xA1` → user cal absent, the
|
||||
/// driver takes the factory path.
|
||||
/// - `0x603D`/`0x6046` (factory stick cal, 9 B): [`STICK_CENTER`] ± [`STICK_RANGE`] on every
|
||||
/// axis. **Byte order differs**: left = max-above ++ center ++ min-below; right = center ++
|
||||
/// min-below ++ max-above (`joycon_read_stick_calibration`).
|
||||
/// - `0x6020` (factory IMU cal, 24 B): offsets 0, accel scale 16384, gyro scale 13371 — the
|
||||
/// driver's own defaults, making its per-sample math the identity (accel) / ×1000 (gyro).
|
||||
pub fn spi_flash_read(addr: u32, len: u8) -> Option<Vec<u8>> {
|
||||
let cal_pair = pack12(STICK_RANGE, STICK_RANGE);
|
||||
let center_pair = pack12(STICK_CENTER, STICK_CENTER);
|
||||
let data: Vec<u8> = match (addr, len) {
|
||||
(0x8010 | 0x801B | 0x8026, 2) => vec![0xFF, 0xFF],
|
||||
(0x603D, 9) => [cal_pair, center_pair, cal_pair].concat(),
|
||||
(0x6046, 9) => [center_pair, cal_pair, cal_pair].concat(),
|
||||
(0x6020, 24) => {
|
||||
let mut v = Vec::with_capacity(24);
|
||||
v.extend_from_slice(&[0u8; 6]); // accel offsets = 0
|
||||
for _ in 0..3 {
|
||||
v.extend_from_slice(&16384u16.to_le_bytes()); // accel scale (driver default)
|
||||
}
|
||||
v.extend_from_slice(&[0u8; 6]); // gyro offsets = 0
|
||||
for _ in 0..3 {
|
||||
v.extend_from_slice(&13371u16.to_le_bytes()); // gyro scale (driver default)
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut payload = Vec::with_capacity(5 + data.len());
|
||||
payload.extend_from_slice(&addr.to_le_bytes());
|
||||
payload.push(len);
|
||||
payload.extend_from_slice(&data);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
/// One decoded host-bound output report from the driver.
|
||||
pub enum SwitchOutput {
|
||||
/// `0x80 <cmd>` USB command — answer with [`build_usb_ack`].
|
||||
UsbCmd(u8),
|
||||
/// `0x01` subcommand (with its rumble bytes) — answer with a `0x21` reply.
|
||||
Subcmd {
|
||||
id: u8,
|
||||
/// Subcommand argument bytes (report bytes 11..).
|
||||
args: Vec<u8>,
|
||||
/// Decoded rumble `(low, high)` magnitudes.
|
||||
rumble: (u16, u16),
|
||||
},
|
||||
/// `0x10` rumble-only report — no reply expected.
|
||||
Rumble((u16, u16)),
|
||||
}
|
||||
|
||||
/// Parse one output report from the driver. Returns `None` for anything unrecognized/short.
|
||||
pub fn parse_output(data: &[u8]) -> Option<SwitchOutput> {
|
||||
match *data.first()? {
|
||||
0x80 => Some(SwitchOutput::UsbCmd(*data.get(1)?)),
|
||||
0x01 if data.len() >= 11 => Some(SwitchOutput::Subcmd {
|
||||
id: data[10],
|
||||
args: data.get(11..).map(|s| s.to_vec()).unwrap_or_default(),
|
||||
rumble: decode_rumble(&data[2..10]),
|
||||
}),
|
||||
0x10 if data.len() >= 10 => Some(SwitchOutput::Rumble(decode_rumble(&data[2..10]))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver's `joycon_rumble_amplitudes` table, amplitude column only, indexed by
|
||||
/// `amp_high / 2` (the encoded high-band amplitude byte is always even). Copied verbatim from
|
||||
/// hid-nintendo.c; last entry = `joycon_max_rumble_amp` (1003).
|
||||
#[rustfmt::skip]
|
||||
const RUMBLE_AMPS: [u16; 101] = [
|
||||
0, 10, 12, 14, 17, 20, 24, 28, 33, 40,
|
||||
47, 56, 67, 80, 95, 112, 117, 123, 128, 134,
|
||||
140, 146, 152, 159, 166, 173, 181, 189, 198, 206,
|
||||
215, 225, 230, 235, 240, 245, 251, 256, 262, 268,
|
||||
273, 279, 286, 292, 298, 305, 311, 318, 325, 332,
|
||||
340, 347, 355, 362, 370, 378, 387, 395, 404, 413,
|
||||
422, 431, 440, 450, 460, 470, 480, 491, 501, 512,
|
||||
524, 535, 547, 559, 571, 584, 596, 609, 623, 636,
|
||||
650, 665, 679, 694, 709, 725, 741, 757, 773, 790,
|
||||
808, 825, 843, 862, 881, 900, 920, 940, 960, 981,
|
||||
1003,
|
||||
];
|
||||
|
||||
/// Invert the driver's per-side rumble encoding back to the 0..=0xFFFF magnitude it scaled
|
||||
/// from: byte1's even bits carry the amplitude-table index × 2 (`data[1] = freq_high_lo +
|
||||
/// amp.high`, where the freq contribution is only ever bit 0).
|
||||
fn side_amplitude(side: &[u8]) -> u16 {
|
||||
let idx = ((side[1] & 0xFE) / 2) as usize;
|
||||
let amp = RUMBLE_AMPS[idx.min(RUMBLE_AMPS.len() - 1)] as u32;
|
||||
// Driver: amp = magnitude * 1003 / 65535 — invert, saturating at full scale.
|
||||
((amp * 65535) / 1003).min(65535) as u16
|
||||
}
|
||||
|
||||
/// Decode the 8 rumble bytes (left side = strong → wire `low`, right side = weak → wire
|
||||
/// `high`, per `joycon_play_effect`).
|
||||
pub fn decode_rumble(bytes: &[u8]) -> (u16, u16) {
|
||||
if bytes.len() < 8 {
|
||||
return (0, 0);
|
||||
}
|
||||
(side_amplitude(&bytes[..4]), side_amplitude(&bytes[4..8]))
|
||||
}
|
||||
|
||||
/// Decode a player-lights subcommand payload (`(flash << 4) | on`, one bit per LED) into the
|
||||
/// wire `PlayerLeds` bits: a flashing LED counts as on.
|
||||
pub fn player_leds_bits(arg: u8) -> u8 {
|
||||
(arg & 0x0F) | (arg >> 4)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The positional swap, pinned: wire south/east/west/north land on the Switch B/A/Y/X bits
|
||||
/// (the driver then maps them back to BTN_SOUTH/EAST/WEST/NORTH — position-correct
|
||||
/// end-to-end), and the rest of the buttons land on their JC_BTN_* bits.
|
||||
#[test]
|
||||
fn positional_swap_and_button_bits() {
|
||||
let st = SwitchState::from_gamepad(gs::BTN_A, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::B);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_B, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::A);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_X, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::Y);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_Y, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::X);
|
||||
// Shoulders / sticks / meta / dpad / triggers-as-digital.
|
||||
let st = SwitchState::from_gamepad(
|
||||
gs::BTN_LB | gs::BTN_RB | gs::BTN_BACK | gs::BTN_START | gs::BTN_GUIDE | gs::BTN_MISC1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
255,
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
st.buttons,
|
||||
btn::L | btn::R | btn::MINUS | btn::PLUS | btn::HOME | btn::CAPTURE | btn::ZL | btn::ZR
|
||||
);
|
||||
let st = SwitchState::from_gamepad(gs::BTN_DPAD_UP | gs::BTN_DPAD_LEFT, 0, 0, 0, 0, 0, 0);
|
||||
assert_eq!(st.buttons, btn::UP | btn::LEFT);
|
||||
}
|
||||
|
||||
/// Sticks: wire full deflection → center ± range on the raw 12-bit axis, both axes the same
|
||||
/// direction (the driver's own Y negation restores evdev's negative-up).
|
||||
#[test]
|
||||
fn stick_scaling() {
|
||||
assert_eq!(stick_raw(0), STICK_CENTER);
|
||||
assert_eq!(stick_raw(32767), STICK_CENTER + STICK_RANGE);
|
||||
assert_eq!(stick_raw(-32767), STICK_CENTER - STICK_RANGE);
|
||||
// Extreme min doesn't underflow past the 12-bit range.
|
||||
assert!(stick_raw(i16::MIN) <= 0xFFF);
|
||||
}
|
||||
|
||||
/// The 3-byte 12-bit packing matches `hid_field_extract`'s little-endian bitfield order:
|
||||
/// value A at bit 0, value B at bit 12.
|
||||
#[test]
|
||||
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.
|
||||
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);
|
||||
assert_eq!((a, b), (0xABC, 0x123));
|
||||
}
|
||||
|
||||
/// Report 0x30 layout, pinned against `struct joycon_input_report` + `joycon_imu_data`:
|
||||
/// header bytes, packed sticks, and the 3 × 12-byte IMU frames (accel then gyro, LE).
|
||||
#[test]
|
||||
fn report_0x30_layout() {
|
||||
let mut st = SwitchState::neutral();
|
||||
st.buttons = btn::B | btn::MINUS | btn::ZL;
|
||||
st.gyro = [0x1122, -2, 3];
|
||||
st.accel = [-1, 0x3344, 5];
|
||||
let r = serialize_report_0x30(&st, 7);
|
||||
assert_eq!(r[0], 0x30);
|
||||
assert_eq!(r[1], 7);
|
||||
assert_eq!(r[2], BAT_CON_FULL_WIRED);
|
||||
assert_eq!(r[3], 0x04); // B = bit 2
|
||||
assert_eq!(r[4], 0x01); // MINUS = bit 8
|
||||
assert_eq!(r[5], 0x80); // ZL = bit 23
|
||||
assert_eq!(&r[6..9], &pack12(STICK_CENTER, STICK_CENTER));
|
||||
assert_eq!(&r[9..12], &pack12(STICK_CENTER, STICK_CENTER));
|
||||
assert_eq!(r[12], VIBRATOR_READY);
|
||||
// Frame 0 at byte 13: accel x/y/z then gyro x/y/z, i16 LE.
|
||||
assert_eq!(&r[13..15], &(-1i16).to_le_bytes());
|
||||
assert_eq!(&r[15..17], &0x3344u16.to_le_bytes());
|
||||
assert_eq!(&r[19..21], &0x1122u16.to_le_bytes());
|
||||
// Frames repeat identically at +12 and +24.
|
||||
assert_eq!(&r[13..25], &r[25..37]);
|
||||
assert_eq!(&r[13..25], &r[37..49]);
|
||||
}
|
||||
|
||||
/// Subcommand replies: ≥ 49 bytes (we send 64), ack at byte 13, echoed id at byte 14 (the
|
||||
/// ONLY byte the driver's matcher checks), payload from byte 15.
|
||||
#[test]
|
||||
fn subcmd_reply_layout() {
|
||||
let st = SwitchState::neutral();
|
||||
let r = build_subcmd_reply(&st, 3, 0x90, 0x10, &[0xAA, 0xBB]);
|
||||
assert_eq!(r.len(), SWITCH_REPORT_LEN);
|
||||
assert_eq!(r[0], 0x21);
|
||||
assert_eq!(r[13], 0x90);
|
||||
assert_eq!(r[14], 0x10);
|
||||
assert_eq!(&r[15..17], &[0xAA, 0xBB]);
|
||||
// USB ack: exactly the two bytes joycon_send_usb matches.
|
||||
let a = build_usb_ack(0x02);
|
||||
assert_eq!((a[0], a[1]), (0x81, 0x02));
|
||||
}
|
||||
|
||||
/// SPI blobs: magics read as ABSENT (≠ B2 A1); the stick blobs put center strictly between
|
||||
/// min and max on both axes in the driver's per-side byte order; the reply echoes addr+len.
|
||||
#[test]
|
||||
fn spi_blobs_valid() {
|
||||
for addr in [0x8010u32, 0x801B, 0x8026] {
|
||||
let p = spi_flash_read(addr, 2).unwrap();
|
||||
assert_eq!(&p[..4], &addr.to_le_bytes());
|
||||
assert_eq!(p[4], 2);
|
||||
assert!(!(p[5] == 0xB2 && p[6] == 0xA1));
|
||||
}
|
||||
let unpack = |b: &[u8]| -> (u16, u16) {
|
||||
let a = b[0] as u16 | ((b[1] as u16 & 0xF) << 8);
|
||||
let y = ((b[1] as u16) >> 4) | ((b[2] as u16) << 4);
|
||||
(a, y)
|
||||
};
|
||||
// Left: max-above ++ center ++ min-below.
|
||||
let l = spi_flash_read(0x603D, 9).unwrap();
|
||||
let (data, hdr) = (&l[5..], &l[..5]);
|
||||
assert_eq!(hdr, &[0x3D, 0x60, 0, 0, 9]);
|
||||
let (max_above, _) = unpack(&data[0..3]);
|
||||
let (center, _) = unpack(&data[3..6]);
|
||||
let (min_below, _) = unpack(&data[6..9]);
|
||||
assert_eq!(center, STICK_CENTER);
|
||||
assert!(center - min_below < center && center < center + max_above);
|
||||
// Right: center ++ min-below ++ max-above.
|
||||
let r = spi_flash_read(0x6046, 9).unwrap();
|
||||
let (rc, _) = unpack(&r[5..8]);
|
||||
assert_eq!(rc, STICK_CENTER);
|
||||
// IMU: offsets 0, driver-default scales — the identity calibration.
|
||||
let imu = spi_flash_read(0x6020, 24).unwrap();
|
||||
let d = &imu[5..];
|
||||
assert_eq!(&d[0..6], &[0; 6]);
|
||||
assert_eq!(&d[6..8], &16384u16.to_le_bytes());
|
||||
assert_eq!(&d[12..18], &[0; 6]);
|
||||
assert_eq!(&d[18..20], &13371u16.to_le_bytes());
|
||||
// Unmapped range → None.
|
||||
assert!(spi_flash_read(0x6050, 12).is_none());
|
||||
}
|
||||
|
||||
/// Motion unit conversion: wire (20 LSB/°·s, 10000 LSB/g) → raw (14.247 LSB/°·s, 4096 LSB/g).
|
||||
#[test]
|
||||
fn motion_units() {
|
||||
let mut st = SwitchState::neutral();
|
||||
// 100 °/s = wire 2000 → raw ≈ 1424; 1 g = wire 10000 → raw 4096.
|
||||
st.apply_motion([2000, 0, -2000], [10000, -10000, 0]);
|
||||
assert_eq!(st.gyro, [1424, 0, -1424]);
|
||||
assert_eq!(st.accel, [4096, -4096, 0]);
|
||||
}
|
||||
|
||||
/// Rumble decode inverts the driver's encoder: a neutral packet decodes to silence; the
|
||||
/// max-amplitude packet decodes to full scale; left = low/strong, right = high/weak.
|
||||
#[test]
|
||||
fn rumble_decode() {
|
||||
// Neutral per the driver's tables: freq defaults + amp 0.
|
||||
let neutral = [0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40];
|
||||
assert_eq!(decode_rumble(&neutral), (0, 0));
|
||||
// Max amp (0xC8 → index 100 → 1003 → 65535) on the LEFT only → (low=full, high=0).
|
||||
let left_max = [0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40];
|
||||
assert_eq!(decode_rumble(&left_max), (65535, 0));
|
||||
// Mid-table on the right: amp_high 0x20 → index 16 → 117 → 117*65535/1003 = 7644.
|
||||
let right_mid = [0x00, 0x01, 0x40, 0x40, 0x00, 0x20, 0x48, 0x40];
|
||||
assert_eq!(decode_rumble(&right_mid), (0, 7644));
|
||||
// The freq bit riding data[1] bit0 must not disturb the amplitude index.
|
||||
let with_freq_bit = [0x00, 0x21, 0x48, 0x40, 0x00, 0x01, 0x40, 0x40];
|
||||
assert_eq!(decode_rumble(&with_freq_bit).0, 7644);
|
||||
// Short slice → silence, not a panic.
|
||||
assert_eq!(decode_rumble(&[0x10; 4]), (0, 0));
|
||||
}
|
||||
|
||||
/// Output-report parse: the three shapes the driver sends.
|
||||
#[test]
|
||||
fn parse_output_shapes() {
|
||||
assert!(matches!(
|
||||
parse_output(&[0x80, 0x02]),
|
||||
Some(SwitchOutput::UsbCmd(0x02))
|
||||
));
|
||||
let mut sub = vec![0x01, 0x05];
|
||||
sub.extend_from_slice(&[0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40]);
|
||||
sub.push(0x10); // subcmd id
|
||||
sub.extend_from_slice(&[0x3D, 0x60, 0x00, 0x00, 0x09]); // SPI addr+len args
|
||||
match parse_output(&sub) {
|
||||
Some(SwitchOutput::Subcmd { id, args, rumble }) => {
|
||||
assert_eq!(id, 0x10);
|
||||
assert_eq!(&args[..5], &[0x3D, 0x60, 0x00, 0x00, 0x09]);
|
||||
assert_eq!(rumble, (0, 0));
|
||||
}
|
||||
_ => panic!("expected subcmd"),
|
||||
}
|
||||
let mut rum = vec![0x10, 0x06];
|
||||
rum.extend_from_slice(&[0x00, 0xC8, 0x40, 0x72, 0x00, 0x01, 0x40, 0x40]);
|
||||
assert!(matches!(
|
||||
parse_output(&rum),
|
||||
Some(SwitchOutput::Rumble((65535, 0)))
|
||||
));
|
||||
assert!(parse_output(&[0x21]).is_none());
|
||||
assert!(parse_output(&[]).is_none());
|
||||
}
|
||||
|
||||
/// Player lights: solid + flashing nibbles both count as lit.
|
||||
#[test]
|
||||
fn player_lights() {
|
||||
assert_eq!(player_leds_bits(0x01), 0b0001);
|
||||
assert_eq!(player_leds_bits(0x10), 0b0001); // flashing LED 1
|
||||
assert_eq!(player_leds_bits(0x23), 0b0011 | 0b0010);
|
||||
}
|
||||
|
||||
/// Device info: type byte 0x03 (Pro Controller) at payload[2], MAC at [4..10].
|
||||
#[test]
|
||||
fn device_info_shape() {
|
||||
let mac = switch_mac(3);
|
||||
let p = device_info_payload(&mac);
|
||||
assert_eq!(p[2], 0x03);
|
||||
assert_eq!(&p[4..10], &mac);
|
||||
assert_eq!(mac[5], 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Transport-independent contract for the virtual **Steam Controller 2** (2026, Valve "Ibex" /
|
||||
//! SDL "Triton", wired `28DE:1302`) — the as-is passthrough sibling of [`super::steam_proto`].
|
||||
//!
|
||||
//! Unlike the Deck/classic-SC backends, this device is NOT re-synthesized from typed wire state:
|
||||
//! the client captures the physical controller (USB Puck / wired / BLE) and forwards its raw
|
||||
//! input reports verbatim ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)); the host
|
||||
//! mirrors them out unchanged, and everything the host's hidraw consumer writes back (Steam's
|
||||
//! lizard-off / IMU-enable feature reports, `0x80` rumble output reports) is forwarded raw to the
|
||||
//! client for replay on the real controller ([`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)).
|
||||
//! Mainline `hid-steam` does not bind this PID, so no kernel evdev exists — **Steam Input is the
|
||||
//! consumer**, driving the hidraw node exactly as it drives the physical pad.
|
||||
//!
|
||||
//! Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||
//! (Valve-maintained). Input report ids `0x42`/`0x45` (`TritonMTUNoQuat_t`, 46 bytes with id),
|
||||
//! `0x47` (adds a trackpad timestamp), `0x43` battery, `0x46`/`0x79` wireless status. Feature
|
||||
//! reports are 64 bytes on report id `1`; haptics are OUTPUT reports `0x80..=0x85`.
|
||||
//!
|
||||
//! A typed **fallback synthesizer** is kept for the degraded case (a client that declared the
|
||||
//! kind but sends no raw feed): buttons/sticks/triggers from the ordinary gamepad plane are
|
||||
//! serialized into a minimal `0x42` state report. The first raw report permanently switches the
|
||||
//! pad to as-is mode.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
/// Valve vendor id (same as [`super::steam_proto::STEAM_VENDOR`], repeated to keep this module
|
||||
/// self-contained).
|
||||
pub const TRITON_VENDOR: u32 = 0x28DE;
|
||||
/// The wired Steam Controller 2 identity the virtual pad presents. The BLE (`0x1303`) and Puck
|
||||
/// dongle (`0x1304`/`0x1305`) identities are client-side transports only — Steam treats the wired
|
||||
/// PID as the canonical controller.
|
||||
pub const TRITON_WIRED_PRODUCT: u32 = 0x1302;
|
||||
|
||||
/// Triton input-report ids (`ETritonReportIDTypes`, SDL `controller_structs.h`).
|
||||
pub const ID_TRITON_CONTROLLER_STATE: u8 = 0x42;
|
||||
pub const ID_TRITON_BATTERY_STATUS: u8 = 0x43;
|
||||
pub const ID_TRITON_CONTROLLER_STATE_BLE: u8 = 0x45;
|
||||
pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
|
||||
|
||||
/// Haptic OUTPUT report ids (`ID_OUT_REPORT_*`). Only rumble is parsed host-side (for the
|
||||
/// universal 0xCA plane); every output report is forwarded raw regardless.
|
||||
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
|
||||
|
||||
/// Physical `0x42` state report size: one report-id byte plus 53 payload bytes.
|
||||
pub const TRITON_REPORT_LEN: usize = 64;
|
||||
pub const TRITON_STATE_LEN: usize = 54;
|
||||
|
||||
/// The physical Triton HID report descriptor, captured byte-for-byte from both wired `28DE:1302`
|
||||
/// and Puck `28DE:1304` controller interfaces. Its numbered reports are part of the protocol:
|
||||
/// inputs `0x40`–`0x45`/`0x79`/`0x7B`, outputs `0x80`–`0x89`, and feature channels `1` and `2`.
|
||||
/// In particular, Puck connection and bond queries use feature report 2; an unnumbered minimal
|
||||
/// descriptor makes hidraw frame those queries incorrectly and Steam eventually closes the device.
|
||||
#[rustfmt::skip]
|
||||
pub const TRITON_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
|
||||
0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
|
||||
0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
|
||||
0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
|
||||
0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
|
||||
0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
|
||||
0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
|
||||
0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
|
||||
0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
|
||||
0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
|
||||
0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
|
||||
0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
|
||||
0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
|
||||
0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
|
||||
0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
|
||||
0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
|
||||
0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
|
||||
0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
|
||||
0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
|
||||
/// `TritonButtons`. Only the bits the typed fallback synthesizes are named; the raw path carries
|
||||
/// whatever the physical pad set.
|
||||
pub mod tbtn {
|
||||
pub const A: u32 = 0x0000_0001;
|
||||
pub const B: u32 = 0x0000_0002;
|
||||
pub const X: u32 = 0x0000_0004;
|
||||
pub const Y: u32 = 0x0000_0008;
|
||||
pub const QAM: u32 = 0x0000_0010;
|
||||
pub const R3: u32 = 0x0000_0020;
|
||||
pub const VIEW: u32 = 0x0000_0040;
|
||||
pub const R4: u32 = 0x0000_0080;
|
||||
pub const R5: u32 = 0x0000_0100;
|
||||
pub const RB: u32 = 0x0000_0200;
|
||||
pub const DPAD_DOWN: u32 = 0x0000_0400;
|
||||
pub const DPAD_RIGHT: u32 = 0x0000_0800;
|
||||
pub const DPAD_LEFT: u32 = 0x0000_1000;
|
||||
pub const DPAD_UP: u32 = 0x0000_2000;
|
||||
pub const MENU: u32 = 0x0000_4000;
|
||||
pub const L3: u32 = 0x0000_8000;
|
||||
pub const STEAM: u32 = 0x0001_0000;
|
||||
pub const L4: u32 = 0x0002_0000;
|
||||
pub const L5: u32 = 0x0004_0000;
|
||||
pub const LB: u32 = 0x0008_0000;
|
||||
pub const RPAD_TOUCH: u32 = 0x0020_0000;
|
||||
pub const RPAD_CLICK: u32 = 0x0040_0000;
|
||||
pub const RT_CLICK: u32 = 0x0080_0000;
|
||||
pub const LPAD_TOUCH: u32 = 0x0200_0000;
|
||||
pub const LPAD_CLICK: u32 = 0x0400_0000;
|
||||
pub const LT_CLICK: u32 = 0x0800_0000;
|
||||
}
|
||||
|
||||
/// One virtual Triton pad's report state. In as-is mode (`raw_len > 0`) the raw report IS the
|
||||
/// state; the typed fields only feed the fallback synthesizer until the first raw report lands.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TritonState {
|
||||
/// The last raw report the client forwarded (report-id byte first); `raw_len == 0` until the
|
||||
/// first one arrives, after which the typed fields below stop mattering.
|
||||
pub raw: [u8; TRITON_REPORT_LEN],
|
||||
pub raw_len: u8,
|
||||
/// Typed fallback fields (Triton bit layout / raw axis units), from the ordinary wire plane.
|
||||
pub buttons: u32,
|
||||
pub lt: u16,
|
||||
pub rt: u16,
|
||||
pub lx: i16,
|
||||
pub ly: i16,
|
||||
pub rx: i16,
|
||||
pub ry: i16,
|
||||
}
|
||||
|
||||
impl TritonState {
|
||||
pub fn neutral() -> TritonState {
|
||||
TritonState {
|
||||
raw: [0u8; TRITON_REPORT_LEN],
|
||||
raw_len: 0,
|
||||
buttons: 0,
|
||||
lt: 0,
|
||||
rt: 0,
|
||||
lx: 0,
|
||||
ly: 0,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed fallback: fold one wire button/stick frame into Triton fields. Mapping follows the
|
||||
/// Deck backend's conventions (PADDLE1/2/3/4 = R4/L4/R5/L5, MISC1 = QAM, the DualSense
|
||||
/// touchpad-click wire bit = right-pad click); sticks are already the device convention
|
||||
/// (+y up), triggers scale 0..255 → 0..32767.
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> TritonState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let trig = |v: u8| ((v as u32 * 32767) / 255) as u16;
|
||||
let mut b = 0u32;
|
||||
let set = |b: &mut u32, on: bool, m: u32| {
|
||||
if on {
|
||||
*b |= m;
|
||||
}
|
||||
};
|
||||
set(&mut b, on(gs::BTN_A), tbtn::A);
|
||||
set(&mut b, on(gs::BTN_B), tbtn::B);
|
||||
set(&mut b, on(gs::BTN_X), tbtn::X);
|
||||
set(&mut b, on(gs::BTN_Y), tbtn::Y);
|
||||
set(&mut b, on(gs::BTN_LB), tbtn::LB);
|
||||
set(&mut b, on(gs::BTN_RB), tbtn::RB);
|
||||
set(&mut b, on(gs::BTN_BACK), tbtn::VIEW);
|
||||
set(&mut b, on(gs::BTN_START), tbtn::MENU);
|
||||
set(&mut b, on(gs::BTN_GUIDE), tbtn::STEAM);
|
||||
set(&mut b, on(gs::BTN_LS_CLICK), tbtn::L3);
|
||||
set(&mut b, on(gs::BTN_RS_CLICK), tbtn::R3);
|
||||
set(&mut b, on(gs::BTN_DPAD_UP), tbtn::DPAD_UP);
|
||||
set(&mut b, on(gs::BTN_DPAD_DOWN), tbtn::DPAD_DOWN);
|
||||
set(&mut b, on(gs::BTN_DPAD_LEFT), tbtn::DPAD_LEFT);
|
||||
set(&mut b, on(gs::BTN_DPAD_RIGHT), tbtn::DPAD_RIGHT);
|
||||
set(&mut b, on(gs::BTN_TOUCHPAD), tbtn::RPAD_CLICK);
|
||||
set(&mut b, on(gs::BTN_PADDLE1), tbtn::R4);
|
||||
set(&mut b, on(gs::BTN_PADDLE2), tbtn::L4);
|
||||
set(&mut b, on(gs::BTN_PADDLE3), tbtn::R5);
|
||||
set(&mut b, on(gs::BTN_PADDLE4), tbtn::L5);
|
||||
set(&mut b, on(gs::BTN_MISC1), tbtn::QAM);
|
||||
// "Fully pressed" digital shadow of the analog triggers (the physical pad's own
|
||||
// threshold is a hard pull, not first-contact).
|
||||
set(&mut b, lt >= 240, tbtn::LT_CLICK);
|
||||
set(&mut b, rt >= 240, tbtn::RT_CLICK);
|
||||
TritonState {
|
||||
raw: [0u8; TRITON_REPORT_LEN],
|
||||
raw_len: 0,
|
||||
buttons: b,
|
||||
lt: trig(lt),
|
||||
rt: trig(rt),
|
||||
lx,
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the typed fallback state into a minimal `0x42` `TritonMTUNoQuat_t` report:
|
||||
/// `[0x42][seq u8][buttons u32][trigL i16][trigR i16][sticks i16×4][lpad x/y + pressure]
|
||||
/// [rpad x/y + pressure][imu ts u32 + accel i16×3 + gyro i16×3]` — pads and IMU stay zero
|
||||
/// (no raw feed = no trackpad/motion source; Steam only sees IMU data after enabling
|
||||
/// `SETTING_IMU_MODE` on a real feed anyway).
|
||||
pub fn serialize_triton_state(buf: &mut [u8; TRITON_STATE_LEN], st: &TritonState, seq: u8) {
|
||||
buf.fill(0);
|
||||
buf[0] = ID_TRITON_CONTROLLER_STATE;
|
||||
buf[1] = seq;
|
||||
buf[2..6].copy_from_slice(&st.buttons.to_le_bytes());
|
||||
buf[6..8].copy_from_slice(&(st.lt as i16).to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&(st.rt as i16).to_le_bytes());
|
||||
buf[10..12].copy_from_slice(&st.lx.to_le_bytes());
|
||||
buf[12..14].copy_from_slice(&st.ly.to_le_bytes());
|
||||
buf[14..16].copy_from_slice(&st.rx.to_le_bytes());
|
||||
buf[16..18].copy_from_slice(&st.ry.to_le_bytes());
|
||||
// [18..30] left/right pad + pressures stay zero; [30..46] IMU stays zero.
|
||||
}
|
||||
|
||||
/// One service pass's extracted feedback: the raw reports to forward (kind-tagged for
|
||||
/// [`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)) plus the rumble level parsed out of a
|
||||
/// `0x80` report for the universal 0xCA plane (drives the phone-mirror path on clients whose
|
||||
/// physical pad already gets the raw report).
|
||||
#[derive(Default)]
|
||||
pub struct TritonFeedback {
|
||||
/// `(low, high)` — `left.speed`/`right.speed` of the last rumble output report seen.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Raw reports to forward: `(kind, bytes)` with kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
|
||||
pub raw: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Parse a Triton haptic-rumble OUTPUT report (`MsgHapticRumble`, 10 bytes with id):
|
||||
/// `[0x80][type u8][intensity u16][left.speed u16][left.gain i8][right.speed u16][right.gain i8]`.
|
||||
/// Returns `(left_speed, right_speed)` as `(low, high)`.
|
||||
pub fn parse_triton_rumble(data: &[u8]) -> Option<(u16, u16)> {
|
||||
if data.len() < 10 || data[0] != ID_OUT_REPORT_HAPTIC_RUMBLE {
|
||||
return None;
|
||||
}
|
||||
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
|
||||
Some((le(4), le(7)))
|
||||
}
|
||||
|
||||
/// Strip the hidraw unnumbered-report `0x00` prefix if present: Triton report/command ids are all
|
||||
/// non-zero (`0x42+` input, `0x80+` output, `1` feature), so a leading zero can only be the
|
||||
/// synthetic report-id byte hidraw prepends on this unnumbered virtual descriptor.
|
||||
pub fn strip_report_prefix(data: &[u8]) -> &[u8] {
|
||||
match data {
|
||||
[0, rest @ ..] if !rest.is_empty() => rest,
|
||||
d => d,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-instance unit id stamped into the fake `0x83` attributes (`'T','R','I'` + index).
|
||||
pub fn triton_unit_id(index: u8) -> u32 {
|
||||
0x5452_4900 | index as u32
|
||||
}
|
||||
|
||||
/// The virtual pad's serial, FVPF-prefixed: the physical-Steam-controller conflict gate
|
||||
/// recognizes `FVPF…` (`HID_UNIQ`) as one of punktfunk's own virtual pads, so a concurrent
|
||||
/// session never mistakes this device for real hardware. Shaped like the real `FXA…` serials
|
||||
/// (13 chars). Shared by the UHID and usbip legs (identity + `0xAE` replies must agree).
|
||||
pub fn triton_serial(index: u8) -> String {
|
||||
format!("FVPF1302{index:02}D03")
|
||||
}
|
||||
|
||||
/// Build the reply to a feature GET_REPORT — the answer half of the Valve query dance. Steam's
|
||||
/// `GetControllerInfo` SETs a query (`0x83` attributes / `0xAE` string) and then GETs the answer;
|
||||
/// **the reply's command byte must echo the LAST SET's command** or Steam treats the pad as
|
||||
/// broken and never adopts it (confirmed on-glass 2026-07-15: answering every GET with a serial
|
||||
/// blob left the virtual pad unpicked). Mirrors the Deck's validated
|
||||
/// [`feature_reply`](super::steam_proto::feature_reply), with two Triton deltas: the frame rides
|
||||
/// feature report id **1** (`[0x01][cmd][len][payload…]`, matching SDL's send framing for this
|
||||
/// device), and the `0x83` blob carries the Triton's product id. The attribute VALUES beyond the
|
||||
/// product id mirror the Deck's hidraw capture (same firmware family conventions) — replace them
|
||||
/// with a capture from a physical pad if Steam still balks.
|
||||
///
|
||||
/// `last_set` is the id-first SET payload (`[0x01, cmd, …]`); a stack that already stripped the
|
||||
/// id byte (`[cmd, …]`, cmd ≥ 0x80) is handled too.
|
||||
pub fn triton_feature_reply(last_set: &[u8], serial: &str, unit_id: u32) -> [u8; 64] {
|
||||
const ID_GET_ATTRIBUTES_VALUES: u8 = 0x83;
|
||||
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
const ID_GET_FIRMWARE_INFO: u8 = 0xF2;
|
||||
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
|
||||
let body = match last_set {
|
||||
[0x01, rest @ ..] => rest,
|
||||
d => d,
|
||||
};
|
||||
let cmd = body.first().copied().unwrap_or(ID_GET_STRING_ATTRIBUTE);
|
||||
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x01;
|
||||
match cmd {
|
||||
ID_GET_ATTRIBUTES_VALUES => {
|
||||
// Captured controller response: 25-byte payload containing five id/u32 attributes.
|
||||
r[1] = ID_GET_ATTRIBUTES_VALUES;
|
||||
r[2] = 0x19;
|
||||
let attrs = [
|
||||
(0x01, TRITON_WIRED_PRODUCT),
|
||||
(0x02, 0),
|
||||
(0x0A, unit_id),
|
||||
(0x04, unit_id ^ 0x0296_DAF9),
|
||||
(0x09, 0x49),
|
||||
];
|
||||
let mut o = 3;
|
||||
for (id, val) in attrs {
|
||||
r[o] = id;
|
||||
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||
o += 5;
|
||||
}
|
||||
}
|
||||
ID_GET_STRING_ATTRIBUTE => {
|
||||
// Captured replies always declare 20 bytes: attribute id plus a 19-byte padded string.
|
||||
let attr = body.get(2).copied().unwrap_or(ATTRIB_STR_UNIT_SERIAL);
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(19);
|
||||
r[..4].copy_from_slice(&[0x01, ID_GET_STRING_ATTRIBUTE, 0x14, attr]);
|
||||
r[4..4 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
ID_GET_FIRMWARE_INFO => {
|
||||
let index = body.get(2).copied().unwrap_or(0);
|
||||
r[1] = ID_GET_FIRMWARE_INFO;
|
||||
r[3] = index;
|
||||
match index {
|
||||
0 => {
|
||||
r[2] = 0x29;
|
||||
r[4..8].copy_from_slice(&(unit_id ^ 0x0296_DAF9).to_le_bytes());
|
||||
r[8] = 0x49;
|
||||
r[12..24].copy_from_slice(b"603f69218a85");
|
||||
let b = serial.as_bytes();
|
||||
let len = b.len().min(16);
|
||||
r[28..28 + len].copy_from_slice(&b[..len]);
|
||||
}
|
||||
1 => {
|
||||
r[2] = 0x22;
|
||||
r[4..37].copy_from_slice(&[
|
||||
0x00, 0x57, 0xD0, 0x18, 0x6A, 0x37, 0x30, 0x35, 0x34, 0x32, 0x35, 0x37,
|
||||
0x64, 0x32, 0x64, 0x61, 0x37, 0x00, 0x00, 0x00, 0x00, 0x23, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x6D, 0x02, 0x00,
|
||||
]);
|
||||
}
|
||||
_ => {
|
||||
r[2] = 0x09;
|
||||
r[4..12].copy_from_slice(&[0x7C, 0x4F, 0x01, 0x00, 0x01, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let n = body.len().min(63);
|
||||
r[1..1 + n].copy_from_slice(&body[..n]);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The typed fallback lands the canonical wire mapping on the SDL-documented bit positions
|
||||
/// and byte offsets.
|
||||
#[test]
|
||||
fn fallback_state_serializes_sdl_layout() {
|
||||
let st = TritonState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_START | gs::BTN_PADDLE1 | gs::BTN_MISC1,
|
||||
1000,
|
||||
-2000,
|
||||
3000,
|
||||
-32768,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
st.buttons,
|
||||
tbtn::A | tbtn::MENU | tbtn::R4 | tbtn::QAM | tbtn::LT_CLICK
|
||||
);
|
||||
assert_eq!(st.lt, 32767); // exact full-scale, not the *128 approximation
|
||||
let mut r = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut r, &st, 7);
|
||||
assert_eq!(r[0], ID_TRITON_CONTROLLER_STATE);
|
||||
assert_eq!(r[1], 7);
|
||||
assert_eq!(u32::from_le_bytes([r[2], r[3], r[4], r[5]]), st.buttons);
|
||||
assert_eq!(i16::from_le_bytes([r[6], r[7]]), 32767); // sTriggerLeft
|
||||
assert_eq!(i16::from_le_bytes([r[10], r[11]]), 1000); // sLeftStickX
|
||||
assert_eq!(i16::from_le_bytes([r[16], r[17]]), -32768); // sRightStickY
|
||||
assert!(r[18..].iter().all(|&b| b == 0)); // pads + IMU zero
|
||||
}
|
||||
|
||||
/// A rumble output report parses to `(left_speed, right_speed)`; other ids don't.
|
||||
#[test]
|
||||
fn rumble_output_report_parses() {
|
||||
// [0x80, type, intensity(2), left.speed(2), left.gain, right.speed(2), right.gain]
|
||||
let mut d = [0u8; 10];
|
||||
d[0] = ID_OUT_REPORT_HAPTIC_RUMBLE;
|
||||
d[4..6].copy_from_slice(&0x1234u16.to_le_bytes());
|
||||
d[7..9].copy_from_slice(&0x5678u16.to_le_bytes());
|
||||
assert_eq!(parse_triton_rumble(&d), Some((0x1234, 0x5678)));
|
||||
d[0] = 0x81; // haptic pulse — not rumble
|
||||
assert_eq!(parse_triton_rumble(&d), None);
|
||||
assert_eq!(parse_triton_rumble(&d[..8]), None); // short
|
||||
}
|
||||
|
||||
/// The hidraw `0x00` unnumbered prefix strips; genuine command bytes survive.
|
||||
#[test]
|
||||
fn report_prefix_strips_only_leading_zero() {
|
||||
assert_eq!(strip_report_prefix(&[0x00, 0x80, 1, 2]), &[0x80, 1, 2]);
|
||||
assert_eq!(strip_report_prefix(&[0x80, 1, 2]), &[0x80, 1, 2]);
|
||||
assert_eq!(strip_report_prefix(&[0x01, 0x87]), &[0x01, 0x87]); // feature id 1 kept
|
||||
assert_eq!(strip_report_prefix(&[0x00]), &[0x00]); // lone zero: nothing to strip to
|
||||
}
|
||||
|
||||
/// The GET reply echoes the LAST SET's command — the Valve query dance Steam's
|
||||
/// `GetControllerInfo` runs; a mismatched command type makes Steam drop the pad.
|
||||
#[test]
|
||||
fn feature_reply_echoes_the_queried_command() {
|
||||
let serial = triton_serial(0);
|
||||
let uid = triton_unit_id(0);
|
||||
// 0x83 attributes: id-first frame, 5 captured blocks, product id = 0x1302 in the first.
|
||||
let r = triton_feature_reply(&[0x01, 0x83, 0x00], &serial, uid);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]);
|
||||
assert_eq!(r[3], 0x01); // ATTRIB product-id tag
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([r[4], r[5], r[6], r[7]]),
|
||||
TRITON_WIRED_PRODUCT
|
||||
);
|
||||
// 0xAE serial: the captured fixed 20-byte payload — attribute id + padded string.
|
||||
let r = triton_feature_reply(&[0x01, 0xAE, 0x01, 0x01], &serial, uid);
|
||||
assert_eq!(&r[..3], &[0x01, 0xAE, 0x14]);
|
||||
assert_eq!(r[3], 0x01);
|
||||
assert_eq!(&r[4..4 + serial.len()], serial.as_bytes());
|
||||
// A stack that stripped the id byte still resolves the command.
|
||||
let r = triton_feature_reply(&[0x83u8, 0x00], &serial, uid);
|
||||
assert_eq!(&r[..3], &[0x01, 0x83, 0x19]);
|
||||
// Anything else (settings write) reads back as an echo.
|
||||
let r = triton_feature_reply(&[0x01, 0x87, 3, 9, 0, 0], &serial, uid);
|
||||
assert_eq!(&r[..6], &[0x01, 0x87, 3, 9, 0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! The off-thread injector service (plan §W4, carved out of the inject facade): a host-lifetime
|
||||
//! pointer/keyboard injector pinned to its OWN thread and fed over a clonable `Send` channel, plus
|
||||
//! the pre-injection [`coalesce`] pass. The backend owns non-`Send` compositor state (a Wayland
|
||||
//! connection / xkb / EIS socket), so it must live on one thread; both the GameStream control plane
|
||||
//! and the native punktfunk/1 plane forward decoded input here instead of injecting inline.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Host-lifetime pointer/keyboard injector running on its OWN thread, fed over a clonable `Send`
|
||||
/// channel. The injector backend owns non-`Send` compositor state (a Wayland connection / xkb / EIS
|
||||
/// socket), so it must live on a single thread; both the GameStream control plane and the native
|
||||
/// punktfunk/1 plane forward their decoded keyboard/mouse events here instead of injecting inline, so
|
||||
/// a slow inject (a portal stall, a desktop switch) never head-blocks the network thread's
|
||||
/// keepalive/retransmit servicing.
|
||||
pub struct InjectorService {
|
||||
tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
}
|
||||
|
||||
impl InjectorService {
|
||||
pub fn start() -> InjectorService {
|
||||
// Windows: make sure the process-wide resident virtual HID mouse exists (idempotent).
|
||||
// Without a pointing device present, win32k reports no cursor and DWM composites none
|
||||
// into the IDD frame — SendInput injection alone moves an invisible pointer.
|
||||
#[cfg(target_os = "windows")]
|
||||
super::mouse_windows::ensure_resident();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-injector".into())
|
||||
.spawn(move || injector_service_thread(rx))
|
||||
{
|
||||
tracing::error!(error = %e, "injector service thread spawn failed — pointer/keyboard input disabled");
|
||||
}
|
||||
InjectorService { tx }
|
||||
}
|
||||
|
||||
/// A sender a session/plane forwards its pointer/keyboard events to. Cloned per caller; dropping a
|
||||
/// clone does NOT stop the service (it runs while any sender — incl. the service's own — lives).
|
||||
pub fn sender(&self) -> std::sync::mpsc::Sender<InputEvent> {
|
||||
self.tx.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Backoff between reopen attempts after the injector backend fails to open or its worker dies, so a
|
||||
/// persistently-unavailable portal isn't hammered once per event.
|
||||
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// The host-lifetime injector worker: lazily open the pointer/keyboard backend, then inject every
|
||||
/// forwarded event. Reopen (after [`INJECTOR_REOPEN_BACKOFF`]) on open failure, on a backend change
|
||||
/// (input follows the active session), or if the backend's worker dies mid-stream. Exits only when
|
||||
/// every sender has dropped (host shutdown), which drops the injector and closes its portal session.
|
||||
///
|
||||
/// Each wake drains the whole backlog and [`coalesce`]s redundant motion before injecting, so a slow
|
||||
/// backend never builds up a queue of stale relative-mouse/scroll events (latency) — while button,
|
||||
/// key, and absolute-move ordering is preserved exactly.
|
||||
fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
|
||||
let mut injector: Option<Box<dyn InputInjector>> = None;
|
||||
let mut open_backend: Option<Backend> = None;
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
while let Ok(first) = rx.recv() {
|
||||
// Drain everything already queued behind `first` so we coalesce a whole burst at once.
|
||||
let mut batch = vec![first];
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
batch.push(ev);
|
||||
}
|
||||
|
||||
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
|
||||
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
|
||||
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
|
||||
// EIS socket after the user switched to the KDE desktop).
|
||||
let want = default_backend();
|
||||
if injector.is_some() && open_backend != Some(want) {
|
||||
tracing::info!(
|
||||
?open_backend,
|
||||
?want,
|
||||
"input: backend changed — reopening injector for the active session"
|
||||
);
|
||||
injector = None;
|
||||
last_failed = None; // re-resolve immediately
|
||||
}
|
||||
if injector.is_none() {
|
||||
// Open on the first event; after a failure wait out the backoff before retrying (a few
|
||||
// events drop during setup — acceptable, input is lossy).
|
||||
let ready = last_failed.is_none_or(|t| t.elapsed() >= INJECTOR_REOPEN_BACKOFF);
|
||||
if ready {
|
||||
match open(want) {
|
||||
Ok(i) => {
|
||||
tracing::info!(backend = ?want, "input injector ready (host-lifetime)");
|
||||
injector = Some(i);
|
||||
open_backend = Some(want);
|
||||
last_failed = None;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "pointer/keyboard injection unavailable — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(inj) = injector.as_mut() {
|
||||
for ev in coalesce(batch) {
|
||||
if let Err(e) = inj.inject(&ev) {
|
||||
// The backend's worker (portal session / EIS socket) died — drop it and reopen on
|
||||
// a later event (covers a gamescope EIS socket that respawns with its session).
|
||||
tracing::warn!(error = %format!("{e:#}"), "inject failed — reopening injector");
|
||||
injector = None;
|
||||
open_backend = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
break; // abandon the rest of this batch; the next one reopens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!("injector service stopped (host shutting down)");
|
||||
}
|
||||
|
||||
/// Coalesce a drained burst: sum consecutive relative-mouse deltas and consecutive same-axis scroll
|
||||
/// deltas (identical net effect, far fewer injects), passing buttons, keys, absolute moves, and any
|
||||
/// type change through untouched and in order. Only *adjacent* same-type events merge, so a button
|
||||
/// or key between two moves flushes the accumulated motion first — ordering is never reshuffled.
|
||||
fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
|
||||
let mut out: Vec<InputEvent> = Vec::with_capacity(events.len());
|
||||
for ev in events {
|
||||
match out.last_mut() {
|
||||
Some(last) if last.kind == InputKind::MouseMove && ev.kind == InputKind::MouseMove => {
|
||||
last.x = last.x.saturating_add(ev.x);
|
||||
last.y = last.y.saturating_add(ev.y);
|
||||
}
|
||||
Some(last)
|
||||
if last.kind == InputKind::MouseScroll
|
||||
&& ev.kind == InputKind::MouseScroll
|
||||
&& last.code == ev.code =>
|
||||
{
|
||||
last.x = last.x.saturating_add(ev.x);
|
||||
}
|
||||
_ => out.push(ev),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
|
||||
fn mk(kind: InputKind, code: u32, x: i32, y: i32) -> InputEvent {
|
||||
InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_sums_adjacent_motion_and_preserves_order() {
|
||||
let events = vec![
|
||||
mk(InputKind::MouseMove, 0, 1, 2),
|
||||
mk(InputKind::MouseMove, 0, 3, -1), // → summed with the previous move
|
||||
mk(InputKind::KeyDown, 30, 0, 0), // flushes the move, passes through verbatim
|
||||
mk(InputKind::MouseMove, 0, 5, 5), // a NEW run after the key (not merged across it)
|
||||
mk(InputKind::MouseScroll, 0, 1, 0),
|
||||
mk(InputKind::MouseScroll, 0, 2, 0), // same axis (code 0) → summed
|
||||
mk(InputKind::MouseScroll, 1, 1, 0), // different axis (code 1) → separate
|
||||
];
|
||||
let out = coalesce(events);
|
||||
assert_eq!(out.len(), 5);
|
||||
assert_eq!(
|
||||
(out[0].kind, out[0].x, out[0].y),
|
||||
(InputKind::MouseMove, 4, 1)
|
||||
);
|
||||
assert_eq!(out[1].kind, InputKind::KeyDown);
|
||||
assert_eq!(
|
||||
(out[2].kind, out[2].x, out[2].y),
|
||||
(InputKind::MouseMove, 5, 5)
|
||||
);
|
||||
assert_eq!(
|
||||
(out[3].kind, out[3].code, out[3].x),
|
||||
(InputKind::MouseScroll, 0, 3)
|
||||
);
|
||||
assert_eq!(
|
||||
(out[4].kind, out[4].code, out[4].x),
|
||||
(InputKind::MouseScroll, 1, 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_handles_empty_and_singleton() {
|
||||
assert!(coalesce(vec![]).is_empty());
|
||||
assert_eq!(coalesce(vec![mk(InputKind::MouseMove, 0, 7, 8)]).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
//! The generic stateful virtual-pad manager ([`UhidManager`]) shared by the five backends that
|
||||
//! keep a full per-pad report state (Linux UHID DualSense / DualShock 4 / Steam Deck, Windows UMDF
|
||||
//! DualSense / DualShock 4): event routing, the frame merge, rich-input application, the silence
|
||||
//! heartbeat, and the feedback pump with rumble + hidout dedup are written once here; a backend
|
||||
//! supplies only its per-controller pieces via [`PadProto`]. The stateless backends (Linux uinput,
|
||||
//! Windows XUSB) write frames straight through with no state vec / heartbeat / rich plane, so they
|
||||
//! use [`PadSlots`] directly instead.
|
||||
|
||||
use crate::hidout_dedup::HidoutDedup;
|
||||
use crate::pad_slots::PadSlots;
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{GamepadEvent, GamepadFrame, MAX_PADS};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What one feedback pass extracted from a pad's driver/kernel channel. `rumble` rides the
|
||||
/// universal 0xCA plane (deduped against the last-forwarded level); `hidout` carries the rich
|
||||
/// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`].
|
||||
#[derive(Default)]
|
||||
pub struct PadFeedback {
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// Whether the game drove this pad's output channel this poll — a fresh output report landed,
|
||||
/// regardless of whether it changed the rumble level. Drives the abandoned-rumble force-off in
|
||||
/// [`UhidManager::pump`] (the same game-ACTIVITY signal the XUSB path keys on). `None` means the
|
||||
/// backend does not track activity (every Linux backend): treated as always-active, so the
|
||||
/// force-off never fires there and Linux behaviour is unchanged.
|
||||
pub game_drove: Option<bool>,
|
||||
}
|
||||
|
||||
/// The per-controller half of a stateful virtual-pad backend — everything [`UhidManager`] cannot
|
||||
/// share because it differs per protocol: the transport open, the report-state model and its
|
||||
/// GameStream/rich-input mappers, the state write, and the feedback poll.
|
||||
///
|
||||
/// The `&mut self` receivers let a backend carry configuration (the Steam-paddle remap policy, a
|
||||
/// pad identity); most implementations are otherwise stateless.
|
||||
pub trait PadProto {
|
||||
/// The per-pad transport (a UHID fd, a UMDF shared-memory channel, the Deck transport enum).
|
||||
type Pad;
|
||||
/// The pad's full report state (`DsState`, `SteamState`) — `Copy` like both of those, so the
|
||||
/// manager can hand a snapshot to [`write_state`](Self::write_state) without borrow gymnastics.
|
||||
type State: Copy;
|
||||
|
||||
/// Backend tag in the shared lifecycle log lines, e.g. `"DualSense/Windows"`.
|
||||
const LABEL: &'static str;
|
||||
/// Device name in the create-failure line ("virtual `<DEVICE>` creation failed …").
|
||||
const DEVICE: &'static str;
|
||||
/// Suffix for the create-failure line — empty on Linux, the driver-install hint on Windows.
|
||||
const CREATE_HINT: &'static str;
|
||||
|
||||
/// Open the virtual pad for wire index `idx`, logging its own success line (it knows the
|
||||
/// transport detail worth printing); failures are logged by the manager's create gate.
|
||||
fn open(&mut self, idx: u8) -> Result<Self::Pad>;
|
||||
/// The all-neutral report state a fresh or unplugged pad (re)starts from.
|
||||
fn neutral(&self) -> Self::State;
|
||||
/// Fold one decoded button/stick frame into a new state, preserving from `prev` every field
|
||||
/// that arrives on the rich plane instead (touch contacts / clicks, motion) — the G2 hook, in
|
||||
/// one place per backend. Paddle remap policy is applied here too.
|
||||
fn merge_frame(&self, prev: &Self::State, f: &GamepadFrame) -> Self::State;
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to the state.
|
||||
fn apply_rich(&self, st: &mut Self::State, rich: RichInput);
|
||||
/// Write the full state to the pad (best-effort; the next frame or heartbeat re-syncs).
|
||||
fn write_state(&self, pad: &mut Self::Pad, st: &Self::State);
|
||||
/// Poll the pad's driver/kernel channel: answer any pending handshake and return the feedback
|
||||
/// it carried. `idx` is the wire pad index (the DualSense GET_REPORT replies need it).
|
||||
fn service(&self, pad: &mut Self::Pad, idx: u8) -> PadFeedback;
|
||||
/// Whether this pad needs a heartbeat write NOW regardless of the silence gap (the Steam
|
||||
/// backend streams through its gamepad-mode-entry pulse).
|
||||
fn force_heartbeat(&self, _pad: &Self::Pad) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual pads of one stateful backend, driven from decoded controller events — the shared
|
||||
/// skeleton of the five UHID/UMDF managers. Method surface (`new` / `handle` / `apply_rich` /
|
||||
/// `pump` / `heartbeat`) is exactly what the session input thread already drives, so each backend
|
||||
/// re-exports itself as a `pub type … = UhidManager<…Proto>;` alias.
|
||||
pub struct UhidManager<B: PadProto> {
|
||||
backend: B,
|
||||
slots: PadSlots<B::Pad>,
|
||||
/// Each pad's current full report — buttons/sticks merged with persisted rich-plane fields.
|
||||
state: Vec<B::State>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes rich feedback doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last rich feedback forwarded per pad, so an output report that only changed the rumble
|
||||
/// doesn't re-send unchanged lightbar/LED/trigger state.
|
||||
hidout_dedup: Vec<HidoutDedup>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
/// When the game last drove each pad (a backend that reports `game_drove` saw a fresh output
|
||||
/// report). A non-zero `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a
|
||||
/// residual the game abandoned — see [`pump`](Self::pump).
|
||||
last_active: Vec<Instant>,
|
||||
}
|
||||
|
||||
/// How long a latched, non-zero rumble may sit without the game driving the pad before it is forced
|
||||
/// off. DualSense/DS4/Deck motors are level-triggered — they run until an output report sets them to
|
||||
/// zero — so a game that latches a rumble and then stops writing output reports (a residual left at a
|
||||
/// menu / loading screen, or a plain forgotten stop) would otherwise drone to the client forever: the
|
||||
/// resend loop in `native.rs` renews the latched level every ~120 ms and the client's envelope never
|
||||
/// expires. This mirrors the XUSB path's identical guard, and is likewise keyed on game ACTIVITY (any
|
||||
/// fresh output report, even one that does not change the level), so a rumble the game keeps asserting
|
||||
/// is never cut — only an abandoned residual. Kept above SDL's ~2 s internal rumble resend.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
impl<B: PadProto + Default> UhidManager<B> {
|
||||
pub fn new() -> UhidManager<B> {
|
||||
UhidManager::with_backend(B::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: PadProto + Default> Default for UhidManager<B> {
|
||||
fn default() -> UhidManager<B> {
|
||||
UhidManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: PadProto> UhidManager<B> {
|
||||
pub fn with_backend(backend: B) -> UhidManager<B> {
|
||||
let state = (0..MAX_PADS).map(|_| backend.neutral()).collect();
|
||||
UhidManager {
|
||||
backend,
|
||||
slots: PadSlots::new(B::LABEL, B::DEVICE, B::CREATE_HINT),
|
||||
state,
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
hidout_dedup: vec![HidoutDedup::default(); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
last_active: vec![Instant::now(); MAX_PADS],
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival ({})", B::LABEL);
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared, resetting its state.
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.reset_pad(i);
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields
|
||||
// (touch + motion arrive separately and must survive a button-only frame).
|
||||
self.state[idx] = self.backend.merge_frame(&self.state[idx], f);
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
||||
/// preserving its button/stick state. Rich events never create a pad (a controller must have
|
||||
/// arrived first); they're dropped if the pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. }
|
||||
| RichInput::HidReport { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.slots.get(idx).is_none() {
|
||||
return;
|
||||
}
|
||||
self.backend.apply_rich(&mut self.state[idx], rich);
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` (or the backend
|
||||
/// forces a write). The UHID/UMDF drivers treat a multi-second input silence — a held-steady
|
||||
/// stick produces no wire events — as an unplugged controller; re-sending the current state is
|
||||
/// idempotent (a stale-but-correct frame, never a phantom input).
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get(i) else {
|
||||
continue;
|
||||
};
|
||||
if self.backend.force_heartbeat(pad)
|
||||
|| now.duration_since(self.last_write[i]) >= max_gap
|
||||
{
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad: answer any pending driver/kernel handshake and route a game's feedback
|
||||
/// back out. `rumble` is invoked `(index, low, high)` only when the motor level *changes* (the
|
||||
/// universal 0xCA plane); `hidout` is invoked per rich feedback event that isn't an exact
|
||||
/// repeat of the last-forwarded value (the 0xCD plane). Call frequently — kernel/driver init
|
||||
/// handshakes block until answered.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
for i in 0..MAX_PADS {
|
||||
let Some(pad) = self.slots.get_mut(i) else {
|
||||
continue;
|
||||
};
|
||||
let fb = self.backend.service(pad, i as u8);
|
||||
// Refresh the game-activity clock when the game drove the pad this poll (a fresh output
|
||||
// report, even at an unchanged level). `None` = a backend that does not track activity
|
||||
// (Linux): treated as always-active, so the force-off below never fires there.
|
||||
if fb.game_drove != Some(false) {
|
||||
self.last_active[i] = now;
|
||||
}
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
} else if self.last_rumble[i] != (0, 0)
|
||||
&& now.duration_since(self.last_active[i]) >= RUMBLE_IDLE_TIMEOUT
|
||||
{
|
||||
// A non-zero rumble is latched but the game has not driven the pad for
|
||||
// RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward the
|
||||
// zero) so `native.rs`'s resend loop stops droning it to the client. Mirrors the
|
||||
// XUSB path's guard; see RUMBLE_IDLE_TIMEOUT.
|
||||
self.last_rumble[i] = (0, 0);
|
||||
rumble(i as u16, 0, 0);
|
||||
}
|
||||
for h in fb.hidout {
|
||||
// Skip rich feedback that repeats the last-forwarded value (a game's output report
|
||||
// re-sends unchanged lightbar/LED/trigger state alongside every rumble update).
|
||||
if self.hidout_dedup[i].should_forward(&h) {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the pad's current state (if it exists) and reset its heartbeat clock — on every write
|
||||
/// (real input or heartbeat), so an actively-used pad emits no extra reports.
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
self.backend.write_state(pad, &st);
|
||||
}
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Gate-checked create; a FRESH pad starts from neutral state + re-armed dedups.
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
let backend = &mut self.backend;
|
||||
if self.slots.ensure(idx, |i| backend.open(i)) {
|
||||
self.reset_pad(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a
|
||||
/// (re)connect starts from scratch and is always forwarded.
|
||||
fn reset_pad(&mut self, idx: usize) {
|
||||
self.state[idx] = self.backend.neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.hidout_dedup[idx].clear();
|
||||
self.last_write[idx] = Instant::now();
|
||||
self.last_active[idx] = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Scripted mock: `open` fails while `fail_opens > 0`; `service` replays canned feedback;
|
||||
/// `MockState` carries a marker for the frame-merge preserve check.
|
||||
#[derive(Default)]
|
||||
struct MockProto {
|
||||
fail_opens: RefCell<u32>,
|
||||
feedback: RefCell<Vec<PadFeedback>>,
|
||||
force_hb: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
struct MockState {
|
||||
buttons: u32,
|
||||
/// Stands in for the rich-plane fields (touch/motion/clicks): set by `apply_rich`,
|
||||
/// must survive `merge_frame`.
|
||||
rich_marker: u16,
|
||||
}
|
||||
|
||||
/// Per-pad transport stub recording every state write.
|
||||
#[derive(Default)]
|
||||
struct MockPad {
|
||||
writes: RefCell<Vec<MockState>>,
|
||||
}
|
||||
|
||||
impl PadProto for MockProto {
|
||||
type Pad = MockPad;
|
||||
type State = MockState;
|
||||
const LABEL: &'static str = "Mock";
|
||||
const DEVICE: &'static str = "mock pad";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, _idx: u8) -> Result<MockPad> {
|
||||
let mut fails = self.fail_opens.borrow_mut();
|
||||
if *fails > 0 {
|
||||
*fails -= 1;
|
||||
anyhow::bail!("scripted open failure");
|
||||
}
|
||||
Ok(MockPad::default())
|
||||
}
|
||||
fn neutral(&self) -> MockState {
|
||||
MockState::default()
|
||||
}
|
||||
fn merge_frame(&self, prev: &MockState, f: &GamepadFrame) -> MockState {
|
||||
MockState {
|
||||
buttons: f.buttons,
|
||||
rich_marker: prev.rich_marker, // the preserve-rich-fields contract
|
||||
}
|
||||
}
|
||||
fn apply_rich(&self, st: &mut MockState, rich: RichInput) {
|
||||
if let RichInput::Touchpad { x, .. } = rich {
|
||||
st.rich_marker = x;
|
||||
}
|
||||
}
|
||||
fn write_state(&self, pad: &mut MockPad, st: &MockState) {
|
||||
pad.writes.borrow_mut().push(*st);
|
||||
}
|
||||
fn service(&self, _pad: &mut MockPad, _idx: u8) -> PadFeedback {
|
||||
let mut fb = self.feedback.borrow_mut();
|
||||
if fb.is_empty() {
|
||||
PadFeedback::default()
|
||||
} else {
|
||||
fb.remove(0)
|
||||
}
|
||||
}
|
||||
fn force_heartbeat(&self, _pad: &MockPad) -> bool {
|
||||
self.force_hb
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(idx: i16, mask: u16, buttons: u32) -> GamepadEvent {
|
||||
GamepadEvent::State(GamepadFrame {
|
||||
index: idx,
|
||||
active_mask: mask,
|
||||
buttons,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn touch(pad: u8, x: u16) -> RichInput {
|
||||
RichInput::Touchpad {
|
||||
pad,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x,
|
||||
y: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn mgr() -> UhidManager<MockProto> {
|
||||
UhidManager::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrival_eager_creates_the_pad() {
|
||||
// G10 as a generic regression test: Arrival must build the device before the first frame.
|
||||
let mut m = mgr();
|
||||
m.handle(&GamepadEvent::Arrival {
|
||||
index: 2,
|
||||
kind: 1,
|
||||
capabilities: 0,
|
||||
});
|
||||
assert!(m.slots.get(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_frame_preserves_rich_fields_and_writes_merged_state() {
|
||||
// G2 as a generic regression test: rich-plane state must survive a button-only frame.
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
m.apply_rich(touch(0, 777));
|
||||
m.handle(&frame(0, 0b1, 0xA));
|
||||
let pad = m.slots.get(0).unwrap();
|
||||
let writes = pad.writes.borrow();
|
||||
let last = writes.last().unwrap();
|
||||
assert_eq!(last.buttons, 0xA);
|
||||
assert_eq!(last.rich_marker, 777); // preserved across the merge
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_frame_never_recreates_the_pad_it_swept() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(1, 0b10, 0));
|
||||
assert!(m.slots.get(1).is_some());
|
||||
// Bit 1 cleared and the frame IS pad 1's removal — sweep, then early-return (no ensure).
|
||||
m.handle(&frame(1, 0b00, 0));
|
||||
assert!(m.slots.get(1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_event_for_an_absent_pad_is_dropped_and_never_creates() {
|
||||
let mut m = mgr();
|
||||
m.apply_rich(touch(3, 42));
|
||||
assert!(m.slots.get(3).is_none());
|
||||
// …and it left no state behind: a later create starts truly neutral.
|
||||
m.handle(&frame(3, 0b1000, 0));
|
||||
assert_eq!(m.state[3].rich_marker, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_failure_backs_off_then_state_still_tracks() {
|
||||
let mut m = mgr();
|
||||
*m.backend.fail_opens.borrow_mut() = 1;
|
||||
m.handle(&frame(0, 0b1, 0x1));
|
||||
// Open failed: no pad, but the merged state is tracked (matching the old managers).
|
||||
assert!(m.slots.get(0).is_none());
|
||||
assert_eq!(m.state[0].buttons, 0x1);
|
||||
// Next frame inside the backoff window: still no pad, no panic.
|
||||
m.handle(&frame(0, 0b1, 0x3));
|
||||
assert!(m.slots.get(0).is_none());
|
||||
assert_eq!(m.state[0].buttons, 0x3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_dedup_forwards_changes_only_and_rearms_on_recreate() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let collect = |m: &mut UhidManager<MockProto>| {
|
||||
let out = RefCell::new(Vec::new());
|
||||
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
out.into_inner()
|
||||
};
|
||||
let rumble = |r| PadFeedback {
|
||||
rumble: Some(r),
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((100, 0)), rumble((100, 0)), rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards
|
||||
assert_eq!(collect(&mut m), vec![]); // exact repeat deduped
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards
|
||||
// Unplug + recreate re-arms the dedup: the same level forwards again.
|
||||
m.handle(&frame(0, 0b0, 0));
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
*m.backend.feedback.borrow_mut() = vec![rumble((7, 7))];
|
||||
assert_eq!(collect(&mut m), vec![(0, 7, 7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoned_rumble_is_forced_off_after_idle_timeout() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let collect = |m: &mut UhidManager<MockProto>| {
|
||||
let out = RefCell::new(Vec::new());
|
||||
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
out.into_inner()
|
||||
};
|
||||
// The game latches a non-zero rumble (a fresh report drove the pad).
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: Some((200, 0)),
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
|
||||
|
||||
// The game stops driving the pad (no fresh output report) but never sent a stop. Before the
|
||||
// idle window elapses, nothing is forwarded — the latched level is left asserting.
|
||||
let idle = || PadFeedback {
|
||||
rumble: None,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(false),
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![idle()];
|
||||
assert_eq!(collect(&mut m), vec![]);
|
||||
|
||||
// Simulate the game having abandoned the pad past the timeout: the residual is forced off
|
||||
// exactly once, then stays off (no repeated zero spam).
|
||||
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
|
||||
*m.backend.feedback.borrow_mut() = vec![idle(), idle()];
|
||||
assert_eq!(collect(&mut m), vec![(0, 0, 0)]); // forced off
|
||||
assert_eq!(collect(&mut m), vec![]); // already zero — no repeat
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asserted_rumble_survives_idle_timeout_while_game_drives() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let collect = |m: &mut UhidManager<MockProto>| {
|
||||
let out = RefCell::new(Vec::new());
|
||||
m.pump(|i, lo, hi| out.borrow_mut().push((i, lo, hi)), |_| {});
|
||||
out.into_inner()
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: Some((200, 0)),
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
assert_eq!(collect(&mut m), vec![(0, 200, 0)]);
|
||||
|
||||
// Even with a stale clock, a poll where the game drove the pad (fresh report, unchanged
|
||||
// level → rumble None but game_drove Some(true)) refreshes activity, so the held rumble is
|
||||
// NOT cut.
|
||||
m.last_active[0] = Instant::now() - (RUMBLE_IDLE_TIMEOUT + Duration::from_millis(50));
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: None,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
assert_eq!(collect(&mut m), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidout_dedup_drops_exact_repeats() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0));
|
||||
let led = |r| HidOutput::Led {
|
||||
pad: 0,
|
||||
r,
|
||||
g: 0,
|
||||
b: 0,
|
||||
};
|
||||
*m.backend.feedback.borrow_mut() = vec![PadFeedback {
|
||||
rumble: None,
|
||||
hidout: vec![led(10), led(10), led(20)],
|
||||
game_drove: Some(true),
|
||||
}];
|
||||
let out = RefCell::new(0u32);
|
||||
m.pump(
|
||||
|_, _, _| {},
|
||||
|_| {
|
||||
*out.borrow_mut() += 1;
|
||||
},
|
||||
);
|
||||
assert_eq!(out.into_inner(), 2); // 10 forwarded once, 20 forwarded; the repeat dropped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_reemits_silent_pads_and_honors_force() {
|
||||
let mut m = mgr();
|
||||
m.handle(&frame(0, 0b1, 0x5));
|
||||
let writes = |m: &UhidManager<MockProto>| m.slots.get(0).unwrap().writes.borrow().len();
|
||||
let after_frame = writes(&m);
|
||||
// A pad written just now is NOT re-emitted under a huge gap…
|
||||
m.heartbeat(Duration::from_secs(3600));
|
||||
assert_eq!(writes(&m), after_frame);
|
||||
// …but a zero gap counts it as silent and re-emits the CURRENT state.
|
||||
m.heartbeat(Duration::ZERO);
|
||||
assert_eq!(writes(&m), after_frame + 1);
|
||||
assert_eq!(
|
||||
m.slots
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.writes
|
||||
.borrow()
|
||||
.last()
|
||||
.unwrap()
|
||||
.buttons,
|
||||
0x5
|
||||
);
|
||||
// The backend's force flag overrides the gap entirely (the Steam mode-entry pulse).
|
||||
m.backend.force_hb = true;
|
||||
m.heartbeat(Duration::from_secs(3600));
|
||||
assert_eq!(writes(&m), after_frame + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Virtual Sony DualSense **Edge** on Windows via the UMDF minidriver — the Edge sibling of
|
||||
//! [`super::dualsense_windows`]. Same transport ([`DsWinPad`]: a per-session `SwDeviceCreate`
|
||||
//! devnode + the sealed shared-memory channel), same report codec ([`super::dualsense_proto`]);
|
||||
//! the host stamps `device_type = 2` so the one UMDF driver serves the Edge descriptor /
|
||||
//! `VID_054C&PID_0DF2` attributes, and the wire back-grip bits map onto the Edge's native
|
||||
//! `buttons[2]` slots instead of the fold/drop policy — a client's Deck grips / Elite paddles
|
||||
//! reach games as real buttons. Feedback is identical to the plain DualSense (rumble arrives with
|
||||
//! the vibration-v2 flag, which [`parse_ds_output`](super::dualsense_proto::parse_ds_output)
|
||||
//! already handles).
|
||||
|
||||
use super::dualsense_proto::{edge_paddle_bits, DsState, DS_TOUCH_H, DS_TOUCH_W};
|
||||
use super::dualsense_windows::{DsWinPad, WinDsIdentity};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::RichInput;
|
||||
|
||||
/// The Windows-Edge half of the shared stateful manager (see [`PadProto`]): the shared
|
||||
/// [`DsWinPad`] transport under the Edge identity, with the Edge paddle mapping in `merge_frame`.
|
||||
/// No remap config — every wire paddle has a native slot.
|
||||
#[derive(Default)]
|
||||
pub struct DsEdgeWinProto;
|
||||
|
||||
impl PadProto for DsEdgeWinProto {
|
||||
type Pad = DsWinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense Edge/Windows";
|
||||
const DEVICE: &'static str = "DualSense Edge";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense_edge())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense Edge created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
||||
/// plain DualSense, EXCEPT the wire paddles land on the Edge's own `buttons[2]` bits
|
||||
/// (rebuilt from every button frame, so no extra persistence).
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.buttons[2] |= edge_paddle_bits(f.buttons);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense Edge pads of a session — the Windows analogue of
|
||||
/// [`DualSenseEdgeManager`](crate::dualsense::DualSenseEdgeManager), with the same method
|
||||
/// surface (via the shared [`UhidManager`]) as the other Windows pad managers.
|
||||
pub type DualSenseEdgeWindowsManager = UhidManager<DsEdgeWinProto>;
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Virtual Sony DualSense on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-dualsense`).
|
||||
//!
|
||||
//! The Windows analogue of the Linux UHID backend ([`super::dualsense`]): same [`DsState`] model and
|
||||
//! the same byte-level report codec ([`super::dualsense_proto`]), but a different transport. Where
|
||||
//! the Linux backend writes report `0x01` to `/dev/uhid` and reads report `0x02` via `UHID_OUTPUT`,
|
||||
//! the Windows backend talks to the UMDF driver over an **unnamed shared DATA section** (256 B `PadShm`:
|
||||
//! magic `u32@0`, input report `@8`, output seq `u32@72`, output report `@76`) reached over the
|
||||
//! **sealed channel** ([`PadChannel`], `design/gamepad-channel-sealing.md`): the host duplicates the
|
||||
//! section handle into the driver's WUDFHost, bootstrapped via the named `Global\pfds-boot-<idx>`
|
||||
//! mailbox. The driver feeds game `READ_REPORT`s from the input bytes and publishes a game's `0x02`
|
||||
//! (rumble / lightbar / player-LEDs / adaptive triggers) into the output bytes. `hidclass` gates the
|
||||
//! device stack, so this user-mode IPC is the only viable channel (a UMDF driver has no control
|
||||
//! device); see `windows-dualsense-scoping.md`.
|
||||
//!
|
||||
//! Device lifecycle: each pad `SwDeviceCreate`s a `pf_pad_<index>` software devnode (hardware id
|
||||
//! `pf_dualsense`, enumerator `punktfunk`) on open and `SwDeviceClose`s it on drop, so the virtual
|
||||
//! DualSense appears/disappears with the session — matching the Linux UHID pad. (The driver itself
|
||||
//! must already be installed; the installer stages it.)
|
||||
|
||||
use super::dualsense_proto::{
|
||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H,
|
||||
DS_TOUCH_W,
|
||||
};
|
||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{anyhow, Result};
|
||||
use punktfunk_core::quic::RichInput;
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
use windows::core::{w, GUID, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
|
||||
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
|
||||
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
|
||||
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
|
||||
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
|
||||
pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
|
||||
pub(super) const OFF_OUT_SEQ: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
|
||||
pub(super) const OFF_OUTPUT: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
|
||||
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 =
|
||||
/// DualSense (the default — the section is zeroed), 1 = DualShock 4.
|
||||
pub(super) const OFF_DEVTYPE: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
||||
pub(super) const OFF_DRIVER_PROTO: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, driver_proto);
|
||||
pub(super) const OFF_PAD_INDEX: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, pad_index);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
pub(super) const DEVTYPE_DUALSENSE_EDGE: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSENSE_EDGE;
|
||||
|
||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||
/// loads on it and the HID DualSense appears to games) plus the sealed shared-memory channel.
|
||||
/// Dropping it removes the devnode (`SwDeviceClose`) and closes both sections.
|
||||
/// `pub`: the type appears as `type Pad` in the `PadProto` impl (a public trait), like the
|
||||
/// Linux pads.
|
||||
pub struct DsWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||
_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: u8,
|
||||
ts: u32,
|
||||
last_out_seq: u32,
|
||||
}
|
||||
|
||||
/// The PnP identity for a virtual controller devnode — varies by controller type so the same
|
||||
/// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4
|
||||
/// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below.
|
||||
pub(super) struct SwDeviceProfile<'a> {
|
||||
/// PnP instance id — distinct namespaces per type (`pf_pad_<idx>` vs `pf_ds4_<idx>`) so the two
|
||||
/// never reuse the same devnode shell.
|
||||
pub instance: &'a str,
|
||||
/// `Data1` of the deterministic ContainerId — a per-device-FAMILY tag (`"PFDS"` for the pads,
|
||||
/// `"PFMO"` for the virtual mouse) so two families at the same index never share a container
|
||||
/// (Windows would group them into one "device" in the Devices UI).
|
||||
pub container_tag: u32,
|
||||
/// Index for the deterministic per-pad ContainerId — ALSO stamped into the devnode Location,
|
||||
/// which the driver reads as its bootstrap-mailbox index.
|
||||
pub container_index: u8,
|
||||
/// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds.
|
||||
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,
|
||||
}
|
||||
|
||||
/// Spawn the per-session virtual controller devnode under enumerator `punktfunk` (instance
|
||||
/// `profile.instance`). The returned `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the
|
||||
/// pad appears/disappears with the session and nothing persists.
|
||||
///
|
||||
/// **Game-detection identity** (see `design/windows-dualsense-game-detection.md`). `HIDD_ATTRIBUTES`
|
||||
/// alone (VID/PID via the IOCTL) satisfies SDL/HIDAPI/RawInput, but a native PS5 path (libScePad-
|
||||
/// style raw HID) classifies the *connection type* by walking from the HID child to its parent
|
||||
/// (`CM_Get_Parent`) and string-matching `"USB"`/`"BTHENUM"` in that parent's
|
||||
/// `DEVPKEY_Device_CompatibleIds`; with no bus identity the pad reads as `UNKNOWN` and the native
|
||||
/// path rejects it. So we set, via `SW_DEVICE_CREATE_INFO` (NOT `pProperties` — bus/identity info is
|
||||
/// create-time-only and a `DEVPROPERTY` write of these keys is ignored):
|
||||
/// - `pszzCompatibleIds` starting with a `USB\` token → the parent walk resolves `bus_type = USB`.
|
||||
/// - `pszzHardwareIds` = `pf_dualsense` **first** (so the INF still binds our UMDF driver) followed
|
||||
/// by `USB\VID_054C&PID_0CE6[&REV_0100]`, which makes hidclass derive the real-DualSense child
|
||||
/// hardware ids `HID\VID_054C&PID_0CE6[&REV_0100]` (the set a genuine USB DS5 exposes).
|
||||
/// - a deterministic, non-sentinel per-pad `pContainerId` (groups the pad's devnodes; avoids the
|
||||
/// null-sentinel ContainerId that trips an `xinput1_4` slot-skip bug).
|
||||
///
|
||||
/// (Validated live on `.173`: the INF still binds, the child gains the `HID\VID&PID` ids, and the
|
||||
/// parent walk reports USB. Remaining gap: GameInput parses VID/PID from the child *instance path*
|
||||
/// `HID\punktfunk\…`, which only a real USB-bus instance path — a bus driver — would change.)
|
||||
///
|
||||
/// Two requirements each yield E_INVALIDARG if violated: the enumerator name must not contain `_`
|
||||
/// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark
|
||||
/// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be
|
||||
/// Administrator (the host service runs as LocalSystem).
|
||||
pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
// Build a double-NUL-terminated UTF-16 multi-sz from a list of ids.
|
||||
let multi_sz = |ids: &[&str]| -> Vec<u16> {
|
||||
ids.iter()
|
||||
.flat_map(|s| s.encode_utf16().chain(std::iter::once(0)))
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
};
|
||||
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(),
|
||||
usb.as_str(),
|
||||
]);
|
||||
let compat = multi_sz(&[
|
||||
usb.as_str(), // a `USB\` token → native bus-type detection resolves USB
|
||||
"USB\\Class_03&SubClass_00&Prot_00",
|
||||
"USB\\Class_03",
|
||||
]);
|
||||
let instid: Vec<u16> = p
|
||||
.instance
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let desc: Vec<u16> = p
|
||||
.description
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// The pad index, stamped into the device Location — the driver reads it to poll `pfds-boot-<index>`
|
||||
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
|
||||
let loc: Vec<u16> = format!("{}", p.container_index)
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// Deterministic ContainerId {<tag>-0000-0000-0000-0000000000<idx>} (tag e.g. "PFDS"/"PFMO").
|
||||
let container = GUID::from_values(
|
||||
p.container_tag,
|
||||
0x0000,
|
||||
0x0000,
|
||||
[0, 0, 0, 0, 0, 0, 0, p.container_index],
|
||||
);
|
||||
|
||||
// SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. The id
|
||||
// buffers and `container` outlive the SwDeviceCreate call (we wait on the event before return).
|
||||
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
||||
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
||||
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
||||
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
||||
info.pszzCompatibleIds = PCWSTR(compat.as_ptr());
|
||||
info.pContainerId = &container;
|
||||
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
||||
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
|
||||
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
||||
|
||||
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
||||
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
||||
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
||||
let mut ctx = SwCreateCtx {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
};
|
||||
// SAFETY: info + the buffers + ctx outlive the call (we wait on the event before returning);
|
||||
// windows-rs returns the HSWDEVICE (the C out-param) as the Result value.
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
w!("HTREE\\ROOT\\0"),
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
return Err(anyhow!("SwDeviceCreate failed: {e}"));
|
||||
}
|
||||
};
|
||||
// Block until PnP finishes enumerating (the callback signals), then check its result.
|
||||
// SAFETY: event is valid.
|
||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
if wait != WAIT_OBJECT_0 {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate enumeration callback never fired (10s) — PnP may be wedged"
|
||||
));
|
||||
}
|
||||
if ctx.result.is_err() {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate enumeration failed: {:?}",
|
||||
ctx.result
|
||||
));
|
||||
}
|
||||
Ok((hsw, ctx.instance_id()))
|
||||
}
|
||||
|
||||
/// The identity a [`DsWinPad`] enumerates with — the plain DualSense or the Edge share the whole
|
||||
/// transport (section layout, input report shape, output parse); only the `device_type` stamp and
|
||||
/// the PnP identity differ. The DS4 differs in report codec too, so it keeps its own pad type.
|
||||
pub(super) struct WinDsIdentity {
|
||||
/// `device_type` stamped into the section (the driver picks its HID identity off it).
|
||||
pub devtype: u8,
|
||||
/// PnP instance-id prefix (`pf_pad` / `pf_edge`) — distinct namespaces per type.
|
||||
pub instance_prefix: &'static str,
|
||||
/// The INF-matched hardware id.
|
||||
pub hwid: &'static str,
|
||||
/// The USB VID&PID token for the synthesized bus identity.
|
||||
pub usb_vid_pid: &'static str,
|
||||
/// Device Manager description.
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl WinDsIdentity {
|
||||
pub(super) const fn dualsense() -> WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: 0,
|
||||
instance_prefix: "pf_pad",
|
||||
hwid: "pf_dualsense",
|
||||
usb_vid_pid: "VID_054C&PID_0CE6",
|
||||
description: "punktfunk Virtual DualSense",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn dualsense_edge() -> WinDsIdentity {
|
||||
WinDsIdentity {
|
||||
devtype: DEVTYPE_DUALSENSE_EDGE,
|
||||
instance_prefix: "pf_edge",
|
||||
hwid: "pf_dualsenseedge",
|
||||
usb_vid_pid: "VID_054C&PID_0DF2",
|
||||
description: "punktfunk Virtual DualSense Edge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DsWinPad {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfds-boot-<index>` mailbox), stamp
|
||||
/// the device type FIRST (so it's visible the moment magic is) + the pad index + a neutral
|
||||
/// report + the magic LAST, then spawn the devnode (the driver loads on it and receives the
|
||||
/// DATA handle over the bootstrap). The devnode lives for the pad's lifetime — dropping the pad
|
||||
/// removes it (`SwDeviceClose`).
|
||||
pub(super) fn open(index: u8, id: &WinDsIdentity) -> Result<DsWinPad> {
|
||||
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) = id.devtype;
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||
r
|
||||
});
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
// Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the
|
||||
// rare failure we keep the section + data plane and fall back to an out-of-band devnode
|
||||
// (installer / dev-box devgen) — its persistent driver polls the same mailbox name.
|
||||
let inst = format!("{}_{index}", id.instance_prefix);
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
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),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), hwid = id.hwid, "SwDeviceCreate failed; falling back to an out-of-band devnode");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// Bounded eager delivery so the driver holds the DATA section before hidclass asks it for
|
||||
// descriptors (the driver reads `device_type` from the section to pick its HID identity).
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(DsWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
id.hwid,
|
||||
"pf_dualsense.inf", // one driver package serves every PS identity
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||
pub(super) fn write_state(&mut self, st: &DsState) {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(1);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. Unlike the
|
||||
// XUSB `packet` / DualSense `out_seq` fields, the input path has NO driver-polled change-detect
|
||||
// field to publish last: the `pf_dualsense` driver streams the whole `input` region to game
|
||||
// READ_REPORTs on its ~125 Hz timer, and the report's own sequence counter (r[7], mid-report)
|
||||
// is consumed by the game's HID stack, not the driver — so it cannot serve as a separable
|
||||
// publish flag without a seqlock generation the driver `Acquire`-reads (a `PadShm` layout +
|
||||
// driver change, deferred). The `Release` fence after the copy orders the report-body stores
|
||||
// ahead of this pad's next `Release` publish (the bootstrap/seq stores in `channel.pump()`),
|
||||
// giving the copy Release visibility on a weakly-ordered core (ARM64); on x86-TSO it is a
|
||||
// no-op. Residual: absent a driver-side `Acquire` on a per-frame input generation, a torn
|
||||
// single frame is still theoretically possible but self-heals on the next ~250 Hz write.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
r.as_ptr(),
|
||||
self.channel.data_base().add(OFF_INPUT),
|
||||
r.len(),
|
||||
);
|
||||
fence(Ordering::Release);
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
|
||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything
|
||||
/// new. Also ticks the sealed-channel delivery and feeds the driver-attach health watcher (the
|
||||
/// driver's ~125 Hz timer stamps `driver_proto` while it has the section mapped).
|
||||
pub(super) fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
self.channel.pump();
|
||||
let mut fb = DsFeedback::default();
|
||||
// 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; `OFF_OUT_SEQ` (== 72) is 4-aligned off the
|
||||
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `out_seq` AFTER
|
||||
// writing the `output` report, so an `Acquire` load here orders the `output` copy below after
|
||||
// it — a fresh seq guarantees a coherent snapshot of the output bytes on a weakly-ordered core
|
||||
// (ARM64). On x86-TSO it is a plain load.
|
||||
let seq = unsafe {
|
||||
(*(self.channel.data_base().add(OFF_OUT_SEQ) as *const AtomicU32))
|
||||
.load(Ordering::Acquire)
|
||||
};
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
fb.fresh = true;
|
||||
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_ds_output(pad, &out, &mut fb);
|
||||
}
|
||||
fb
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows-DualSense half of the shared stateful manager (see [`PadProto`]): the UMDF
|
||||
/// sealed-channel open, the same [`DsState`] mappers as `linux/dualsense.rs`, and the section
|
||||
/// feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in [`UhidManager`].
|
||||
pub struct DsWinProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
||||
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualsense.rs`.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DsWinProto {
|
||||
fn default() -> DsWinProto {
|
||||
DsWinProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for DsWinProto {
|
||||
type Pad = DsWinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualSense/Windows";
|
||||
const DEVICE: &'static str = "DualSense";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<DsWinPad> {
|
||||
let p = DsWinPad::open(idx, &WinDsIdentity::dualsense())?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualsense.rs` does.
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
||||
// configured policy (default drop) so they aren't silently lost.
|
||||
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut DsWinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the rich
|
||||
/// lightbar/player-LED/trigger events on the 0xCD plane.
|
||||
fn service(&self, pad: &mut DsWinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service(idx);
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb.hidout,
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// **N4 spike** (gamepad-new-types §6, timeboxed): create a software-devnode HID **Steam Deck**
|
||||
/// (`device_type = 3`, `VID_28DE&PID_1205`) and hold it for `secs`, streaming the neutral Deck
|
||||
/// frame, so the go/no-go question — does Steam Input on Windows promote a software-devnode HID
|
||||
/// Deck, or does it require a real USB bus identity (the documented GameInput instance-path
|
||||
/// gap)? — can be answered by watching Steam's `logs/controller.txt` / controller settings
|
||||
/// while this holds. Never used by a session; wired to the `deck-windows-spike` subcommand.
|
||||
pub fn deck_spike_hold(index: u8, secs: u64) -> Result<()> {
|
||||
let boot_name = pf_driver_proto::gamepad::pad_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name, SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// Neutral Deck input frame: [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09, 0x3C], all released.
|
||||
let mut neutral = [0u8; 64];
|
||||
(neutral[0], neutral[2], neutral[3]) = (0x01, 0x09, 0x3C);
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
let inst = format!("pf_deckspike_{index}");
|
||||
let (hsw, _) = create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
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);
|
||||
channel.deliver_eager(std::time::Duration::from_millis(1500));
|
||||
println!(
|
||||
"virtual Steam Deck devnode up (28DE:1205, device_type 3) — holding {secs}s.\n\
|
||||
Observe: Get-PnpDevice -PresentOnly | findstr 1205; Steam logs\\controller.txt for a\n\
|
||||
detect/promote line; Steam Settings > Controller for a 'Steam Deck' entry.\n\
|
||||
GO = Steam lists/promotes it; NO-GO = it never appears (the Linux `Interface: -1` gap\n\
|
||||
applies verbatim — document and keep the SteamDeck->DualSense Windows fold)."
|
||||
);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
|
||||
let mut last_out_seq = 0u32;
|
||||
while std::time::Instant::now() < deadline {
|
||||
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) };
|
||||
if seq != last_out_seq {
|
||||
last_out_seq = seq;
|
||||
let mut out = [0u8; 16];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
channel.data_base().add(OFF_OUTPUT),
|
||||
out.as_mut_ptr(),
|
||||
16,
|
||||
)
|
||||
};
|
||||
println!(" output report from a client (Steam?): {out:02x?}");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
println!("deck-windows-spike: done (devnode removed on exit)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface (via the shared
|
||||
/// [`UhidManager`]) so the session input thread drives either backend identically. The heartbeat
|
||||
/// keeps the section fresh (the driver's timer streams whatever's in it) — parity with the UHID
|
||||
/// backend's silence heartbeat.
|
||||
pub type DualSenseWindowsManager = UhidManager<DsWinProto>;
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of
|
||||
//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the sealed
|
||||
//! shared-memory channel bootstrapped via `Global\pfds-boot-<idx>`), same controller model
|
||||
//! ([`DsState`]); only the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the
|
||||
//! report codec ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4)
|
||||
//! into the DATA section so the one UMDF driver serves the DS4 descriptor / attributes / features
|
||||
//! instead of the DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar
|
||||
//! (0xCD `Led`); a DS4 has no adaptive triggers / player LEDs.
|
||||
|
||||
use super::dualsense_proto::DsState;
|
||||
use super::dualsense_windows::{
|
||||
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_DRIVER_PROTO, OFF_INPUT,
|
||||
OFF_OUTPUT, OFF_OUT_SEQ, OFF_PAD_INDEX, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||
};
|
||||
use super::gamepad_raii::PadChannel;
|
||||
use crate::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<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), like the
|
||||
/// Linux pads.
|
||||
pub struct Ds4WinPad {
|
||||
/// 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,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
last_out_seq: u32,
|
||||
}
|
||||
|
||||
impl Ds4WinPad {
|
||||
/// Create the sealed channel, stamp `device_type = DualShock 4` + the pad index + a neutral
|
||||
/// report + the magic LAST, then spawn the `pf_ds4_<index>` devnode (the driver loads on it and
|
||||
/// receives the DATA handle over the bootstrap).
|
||||
fn open(index: u8) -> Result<Ds4WinPad> {
|
||||
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();
|
||||
// device-type FIRST (so it's visible the moment magic is), pad index, neutral report,
|
||||
// magic LAST.
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; the OFF_* offsets are in range.
|
||||
unsafe {
|
||||
*base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4;
|
||||
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; DS4_INPUT_REPORT_LEN], {
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &DsState::neutral(), 0, 0);
|
||||
r
|
||||
});
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
let inst = format!("pf_ds4_{index}");
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: &inst,
|
||||
container_tag: 0x5046_4453, // "PFDS"
|
||||
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),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
// Bounded eager delivery — for the DS4 this is what closes the identity race: the driver
|
||||
// must read `device_type = 1` from the delivered DATA section before hidclass asks it for
|
||||
// descriptors, or the pad would enumerate with the (default) DualSense identity.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(Ds4WinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_dualshock4",
|
||||
"pf_dualsense.inf", // one driver package serves both HID identities
|
||||
"C:\\Users\\Public\\pfds-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and publish it to the section's input slot.
|
||||
fn write_state(&mut self, st: &DsState) {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
// 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 new `0x05` report (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. Also
|
||||
/// ticks the sealed-channel delivery and feeds the driver-attach health watcher (the driver's
|
||||
/// ~125 Hz timer stamps `driver_proto`).
|
||||
fn service(&mut self) -> Ds4Feedback {
|
||||
self.channel.pump();
|
||||
let mut fb = Ds4Feedback::default();
|
||||
// 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 {
|
||||
self.last_out_seq = seq;
|
||||
fb.fresh = true;
|
||||
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_ds4_output(&out, &mut fb);
|
||||
}
|
||||
fb
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows-DualShock-4 half of the shared stateful manager (see [`PadProto`]): the UMDF
|
||||
/// sealed-channel open (device-type 1), the same [`DsState`] mappers as `linux/dualshock4.rs`, and
|
||||
/// the section feedback poll. Lifecycle (slot table, unplug sweep, heartbeat, dedup) lives in
|
||||
/// [`UhidManager`]; the lightbar dedup that used to be a bespoke `last_led` vec now rides the
|
||||
/// shared `HidoutDedup` (identical semantics — `Led` is compared against the last-forwarded value
|
||||
/// and re-armed on create/unplug).
|
||||
pub struct Ds4WinProto {
|
||||
/// Fallback policy for the Steam back grips a client may send (the DS4 has no back-button HID
|
||||
/// slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. Parity with `linux/dualshock4.rs`.
|
||||
remap: crate::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for Ds4WinProto {
|
||||
fn default() -> Ds4WinProto {
|
||||
Ds4WinProto {
|
||||
remap: crate::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PadProto for Ds4WinProto {
|
||||
type Pad = Ds4WinPad;
|
||||
type State = DsState;
|
||||
const LABEL: &'static str = "DualShock 4/Windows";
|
||||
const DEVICE: &'static str = "DualShock 4";
|
||||
const CREATE_HINT: &'static str =
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<Ds4WinPad> {
|
||||
let p = Ds4WinPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (Windows UMDF shm channel)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> DsState {
|
||||
DsState::neutral()
|
||||
}
|
||||
|
||||
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (rich-
|
||||
/// plane fields that must survive a button-only frame) — exactly as `linux/dualshock4.rs` does.
|
||||
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
||||
// Steam back grips have no DS4 slot — fold them onto standard buttons per the configured
|
||||
// policy (default drop) so they aren't silently lost.
|
||||
let buttons = crate::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
s.touch_click = prev.touch_click;
|
||||
s
|
||||
}
|
||||
|
||||
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
||||
/// split the one touchpad left/right, pad clicks ride touch_click.
|
||||
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
||||
st.apply_rich(rich, DS4_TOUCH_W, DS4_TOUCH_H);
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut Ds4WinPad, st: &DsState) {
|
||||
pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Poll the section for a game's feedback: motor rumble on the universal 0xCA plane, the
|
||||
/// lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive triggers).
|
||||
fn service(&self, pad: &mut Ds4WinPad, idx: u8) -> PadFeedback {
|
||||
let fb = pad.service();
|
||||
PadFeedback {
|
||||
rumble: fb.rumble,
|
||||
hidout: fb
|
||||
.led
|
||||
.map(|(r, g, b)| HidOutput::Led { pad: idx, r, g, b })
|
||||
.into_iter()
|
||||
.collect(),
|
||||
game_drove: Some(fb.fresh),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface (via
|
||||
/// the shared [`UhidManager`]) as the Windows DualSense manager so the session input thread drives
|
||||
/// either backend identically.
|
||||
pub type DualShock4WindowsManager = UhidManager<Ds4WinProto>;
|
||||
@@ -0,0 +1,714 @@
|
||||
//! Per-pad Windows resource RAII + the **sealed gamepad channel** broker (DualSense / DualShock 4 /
|
||||
//! XUSB backends).
|
||||
//!
|
||||
//! Each virtual pad owns three OS resources: the **unnamed** DATA section the `pf_dualsense`/`pf_xusb`
|
||||
//! driver works against (`XusbShm`/`PadShm`), the tiny **named** bootstrap mailbox
|
||||
//! (`pf_driver_proto::gamepad::PadBootstrap`) that hands the driver a duplicated handle to it, and the
|
||||
//! `SwDeviceCreate`'d software devnode the driver loads on. [`Shm`] and [`SwDevice`] own the resources
|
||||
//! with RAII; [`PadChannel`] owns the two sections plus the delivery handshake.
|
||||
//!
|
||||
//! **Why the channel is sealed** (`design/gamepad-channel-sealing.md`): the DATA section used to be a
|
||||
//! `Global\pf…-shm-<index>` named section with an SY+LS DACL, which let any *sibling LocalService*
|
||||
//! process open it by name to read the live controller input or inject/forge input and rumble — the
|
||||
//! same name-open vector the frame ring closed (`design/idd-push-security.md`). The DATA section is now
|
||||
//! UNNAMED with a SYSTEM-only DACL and reaches the driver exclusively as a handle this host duplicated
|
||||
//! into its WUDFHost (a duplicated handle carries the source's access, so no LS ACE is needed). The pad
|
||||
//! drivers are UMDF HID minidrivers with **no control device** (hidclass owns the stack), so unlike the
|
||||
//! frame channel there is no IOCTL to deliver the handle or learn the WUDFHost pid — hence the
|
||||
//! late-bound [`PadBootstrap`] mailbox handshake, the one *named* object left. It carries only pids and
|
||||
//! a handle VALUE (meaningless outside the target process), so tampering with it yields at worst a
|
||||
//! gamepad DoS, never a read or an injection; the empirical floor from the frame work holds here too
|
||||
//! (a LocalService token is DACL-denied `OpenProcess` on a UMDF WUDFHost for every access right).
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use pf_driver_proto::gamepad::{PadBootstrap, BOOT_MAGIC, GAMEPAD_PROTO_VERSION};
|
||||
use std::ffi::c_void;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{fence, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::{w, HRESULT, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
CM_Get_DevNode_Status, CM_Locate_DevNodeW, CM_DEVNODE_STATUS_FLAGS, CM_LOCATE_DEVNODE_NORMAL,
|
||||
CM_PROB, CR_SUCCESS, DN_DRIVER_LOADED, DN_HAS_PROBLEM, DN_STARTED,
|
||||
};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::{
|
||||
DuplicateHandle, GetLastError, LocalFree, SetLastError, DUPLICATE_HANDLE_OPTIONS,
|
||||
ERROR_ALREADY_EXISTS, HANDLE, HLOCAL, INVALID_HANDLE_VALUE, WIN32_ERROR,
|
||||
};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentProcess, OpenProcess, SetEvent, PROCESS_DUP_HANDLE, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
|
||||
/// Least access the pad driver needs on the duplicated DATA section: it only MAPS it read/write, so
|
||||
/// `SECTION_MAP_READ | SECTION_MAP_WRITE` (== the driver's `FILE_MAP_RW`). Granted explicitly in
|
||||
/// [`PadChannel::deliver_to`] instead of `DUPLICATE_SAME_ACCESS` (least privilege for the sealed
|
||||
/// section — the driver's handle then can't take ownership / change security / delete the object).
|
||||
const SECTION_MAP_RW: u32 = 0x0004 | 0x0002;
|
||||
|
||||
/// An anonymous (pagefile-backed) shared section + its mapped read/write view. RAII: drop unmaps the
|
||||
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Created either
|
||||
/// [unnamed](Self::create_unnamed) (the sealed DATA section — reachable only by handle duplication) or
|
||||
/// [named](Self::create_named) (the bootstrap mailbox the driver opens by name).
|
||||
pub(super) struct Shm {
|
||||
/// Owns the section handle (closed on drop). Also the duplication source for the sealed channel —
|
||||
/// see [`Shm::raw_handle`].
|
||||
handle: OwnedHandle,
|
||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||
}
|
||||
|
||||
/// Owns an SDDL-derived `SECURITY_ATTRIBUTES` **and** the OS-allocated security descriptor its
|
||||
/// `lpSecurityDescriptor` points at (`ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
/// `LocalAlloc`s the descriptor). Drop `LocalFree`s it, so a `SecAttr` must outlive every
|
||||
/// `CreateFileMappingW` that borrows its `sa`: the section copies the security info at create time, so
|
||||
/// freeing after the create returns is safe — hence [`Shm::create_named`] builds one `SecAttr` before
|
||||
/// its squat-retry loop and reuses it across attempts instead of re-allocating (and re-leaking) per
|
||||
/// attempt.
|
||||
struct SecAttr {
|
||||
sa: SECURITY_ATTRIBUTES,
|
||||
psd: PSECURITY_DESCRIPTOR,
|
||||
}
|
||||
|
||||
impl Drop for SecAttr {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `psd` is the descriptor `ConvertStringSecurityDescriptorToSecurityDescriptorW`
|
||||
// allocated for us with `LocalAlloc`; release it with the matching `LocalFree`. Every
|
||||
// `CreateFileMappingW` that borrowed `self.sa` has already returned (so has copied the
|
||||
// security info into its section object), so no live `SECURITY_ATTRIBUTES` still points here.
|
||||
unsafe {
|
||||
let _ = LocalFree(Some(HLOCAL(self.psd.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`SecAttr`] from an SDDL literal — a `SECURITY_ATTRIBUTES` plus the descriptor it borrows,
|
||||
/// freed together on drop. The returned owner must outlive every `CreateFileMappingW` that borrows
|
||||
/// its `sa` (see [`SecAttr`]).
|
||||
fn sddl_sa(sddl: PCWSTR) -> Result<SecAttr> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is valid; `psd` receives a `LocalAlloc`'d descriptor that `SecAttr`'s
|
||||
// `Drop` `LocalFree`s once the section create that borrows it has returned.
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl,
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
Ok(SecAttr {
|
||||
sa: SECURITY_ATTRIBUTES {
|
||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
},
|
||||
psd,
|
||||
})
|
||||
}
|
||||
|
||||
impl Shm {
|
||||
/// Create + zero an **unnamed** `size`-byte section, mapped read/write — the sealed DATA section.
|
||||
/// SDDL `D:P(A;;GA;;;SY)` (SYSTEM-only, protected): with no name there is nothing to enumerate,
|
||||
/// open, or squat, and the driver reaches it through a duplicated handle, which carries the
|
||||
/// source's access without re-checking the object DACL (the exact property the frame ring
|
||||
/// validated on-glass — `design/idd-push-security.md`).
|
||||
pub(super) fn create_unnamed(size: usize) -> Result<Shm> {
|
||||
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)"))?;
|
||||
// `sa` owns the descriptor and lives to the end of this fn, so it outlives the create.
|
||||
Self::create_inner(&sa.sa, PCWSTR::null(), size)
|
||||
.context("create unnamed gamepad DATA section")
|
||||
}
|
||||
|
||||
/// Create + zero a **named** `size`-byte section, mapped read/write — the bootstrap mailbox. SDDL
|
||||
/// `D:(A;;GA;;;SY)(A;;GA;;;LS)`: SYSTEM (this host) + LocalService (the driver's WUDFHost opens it
|
||||
/// by name). Safe to leave name-openable because it carries nothing exploitable (see the module
|
||||
/// docs). **Squat-checked**: `Global\` names are creatable by any service holding
|
||||
/// `SeCreateGlobalPrivilege` (LocalService has it), so if the name already exists —
|
||||
/// `ERROR_ALREADY_EXISTS`, meaning `CreateFileMappingW` silently *opened* a pre-existing object we
|
||||
/// don't control — we close and retry briefly (our own driver holds the name for microseconds per
|
||||
/// poll tick), then fail loudly rather than run the handshake through an attacker-owned (or
|
||||
/// another host instance's) mailbox.
|
||||
pub(super) fn create_named(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||
// Build the descriptor ONCE and reuse it across the squat-retry loop — it (and the OS
|
||||
// allocation it owns) lives to the end of this fn, so it outlives every create below.
|
||||
// `D:P` (protected) — strip any inherited ACEs so only SYSTEM + LocalService are granted,
|
||||
// matching the intent of the other named objects (security-review 2026-07-17).
|
||||
let sa = sddl_sa(w!("D:P(A;;GA;;;SY)(A;;GA;;;LS)"))?;
|
||||
for attempt in 0..5 {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
// SAFETY: clearing the thread error slot so ERROR_ALREADY_EXISTS below is unambiguous.
|
||||
unsafe { SetLastError(WIN32_ERROR(0)) };
|
||||
let shm = Self::create_inner(&sa.sa, PCWSTR(name.as_ptr()), size)
|
||||
.with_context(|| format!("create gamepad bootstrap mailbox {name}"))?;
|
||||
// SAFETY: read immediately after the create; windows-rs only touches the error slot on
|
||||
// failure, so a success here preserves CreateFileMappingW's ALREADY_EXISTS signal.
|
||||
if unsafe { GetLastError() } != ERROR_ALREADY_EXISTS {
|
||||
return Ok(shm);
|
||||
}
|
||||
// `shm` drops here → unmap + close our handle to the foreign object, then retry.
|
||||
}
|
||||
bail!(
|
||||
"bootstrap mailbox {name} already exists and stayed alive across retries — another \
|
||||
punktfunk-host instance is serving this pad index, or a local service is squatting the \
|
||||
name (gamepad DoS attempt?)"
|
||||
);
|
||||
}
|
||||
|
||||
fn create_inner(sa: &SECURITY_ATTRIBUTES, name: PCWSTR, size: usize) -> Result<Shm> {
|
||||
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the caller's SDDL; the
|
||||
// descriptor behind `sa` outlives this call (owned by the caller's `SecAttr`, freed only once
|
||||
// every create that borrows it has returned).
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
size as u32,
|
||||
name,
|
||||
)?
|
||||
};
|
||||
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
||||
// return below (and the eventual drop) closes it. `map` (a `Copy` `HANDLE`) stays usable for the
|
||||
// `MapViewOfFile` borrow that follows — `from_raw_handle` only copies the inner pointer.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(map.0) };
|
||||
// SAFETY: `map` is a valid section handle; map the whole thing read/write.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
||||
if view.Value.is_null() {
|
||||
// `handle` drops here → closes the section. No view to unmap.
|
||||
return Err(anyhow!("MapViewOfFile failed"));
|
||||
}
|
||||
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
||||
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
||||
Ok(Shm { handle, view })
|
||||
}
|
||||
|
||||
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
||||
/// relocate the OS mapping — the view address is fixed by `MapViewOfFile`).
|
||||
pub(super) fn base(&self) -> *mut u8 {
|
||||
self.view.Value as *mut u8
|
||||
}
|
||||
|
||||
/// The section handle as a borrowed `HANDLE` (the sealed channel's duplication source).
|
||||
fn raw_handle(&self) -> HANDLE {
|
||||
HANDLE(self.handle.as_raw_handle())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `handle` field closes the
|
||||
// section (struct fields drop only after this `Drop::drop` returns).
|
||||
unsafe {
|
||||
let _ = UnmapViewOfFile(self.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The sealed-channel bootstrap broker ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Global delivery sequence for [`PadBootstrap::handle_seq`] — host-wide monotonic and never 0, so two
|
||||
/// consecutive pads on the same index can't hand the (persistent, out-of-band-devnode) driver the same
|
||||
/// seq twice. Starts at 1.
|
||||
static BOOT_SEQ: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
/// Hard cap on delivery attempts per pad: each attempt duplicates a handle into a WUDFHost, so a
|
||||
/// tampered mailbox flapping `driver_pid` must not mint unbounded remote handles (DoS containment).
|
||||
/// A legitimate pad needs exactly one (a driver restart within one pad lifetime is not a thing —
|
||||
/// the WUDFHost dies with the devnode).
|
||||
const MAX_DELIVERY_ATTEMPTS: u32 = 16;
|
||||
|
||||
/// One pad's sealed host↔driver channel: the unnamed DATA section (the real `XusbShm`/`PadShm`), the
|
||||
/// named bootstrap mailbox, and the delivery state machine ([`Self::pump`]) that hands the driver's
|
||||
/// WUDFHost a duplicated DATA handle once it publishes its pid. Owns both sections (RAII teardown —
|
||||
/// dropping the channel closes the mailbox, whose *name* then disappears, which is how a persistent
|
||||
/// (out-of-band-devnode) driver detects the host is gone).
|
||||
pub(super) struct PadChannel {
|
||||
data: Shm,
|
||||
boot: Shm,
|
||||
boot_name: String,
|
||||
/// Last `driver_pid` acted on (delivered or rejected) — never retry the same value, so a failed
|
||||
/// verify can't be spun into a hot loop by a static mailbox.
|
||||
last_seen_pid: u32,
|
||||
attempts: u32,
|
||||
delivered: bool,
|
||||
warned_proto: bool,
|
||||
warned_cap: bool,
|
||||
}
|
||||
|
||||
impl PadChannel {
|
||||
/// Create the unnamed DATA section (`data_size` bytes, zeroed — the caller stamps its layout and
|
||||
/// magic) plus the named bootstrap mailbox, stamped `host_proto` first and `BOOT_MAGIC` last so a
|
||||
/// driver only trusts a fully-initialized mailbox.
|
||||
pub(super) fn create(boot_name: String, data_size: usize) -> Result<PadChannel> {
|
||||
let data = Shm::create_unnamed(data_size)?;
|
||||
let boot = Shm::create_named(
|
||||
&HSTRING::from(boot_name.as_str()),
|
||||
core::mem::size_of::<PadBootstrap>(),
|
||||
)?;
|
||||
let base = boot.base();
|
||||
// SAFETY: `base` is the live, page-aligned mailbox view (>= size_of::<PadBootstrap>()); the
|
||||
// field offsets are pinned by the proto's asserts and naturally aligned, so the atomic views
|
||||
// are valid. `host_proto` is published BEFORE `magic` (Release) — a driver that observes the
|
||||
// magic (Acquire) sees the version.
|
||||
unsafe {
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, host_proto)) as *const AtomicU32))
|
||||
.store(GAMEPAD_PROTO_VERSION, Ordering::Relaxed);
|
||||
fence(Ordering::Release);
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, magic)) as *const AtomicU32))
|
||||
.store(BOOT_MAGIC, Ordering::Release);
|
||||
}
|
||||
Ok(PadChannel {
|
||||
data,
|
||||
boot,
|
||||
boot_name,
|
||||
last_seen_pid: 0,
|
||||
attempts: 0,
|
||||
delivered: false,
|
||||
warned_proto: false,
|
||||
warned_cap: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The DATA section's mapped base (the host side of `XusbShm`/`PadShm`).
|
||||
pub(super) fn data_base(&self) -> *mut u8 {
|
||||
self.data.base()
|
||||
}
|
||||
|
||||
/// The bootstrap mailbox name (log labelling).
|
||||
pub(super) fn boot_name(&self) -> &str {
|
||||
&self.boot_name
|
||||
}
|
||||
|
||||
/// Atomic `u32` load from a mailbox field.
|
||||
fn boot_load(&self, off: usize) -> u32 {
|
||||
// SAFETY: the mailbox view is live (owned by `self.boot`), page-aligned, and every
|
||||
// `PadBootstrap` u32 field offset is 4-aligned (proto asserts), so the atomic view is valid;
|
||||
// no reference into the shared region outlives the load.
|
||||
unsafe { (*(self.boot.base().add(off) as *const AtomicU32)).load(Ordering::Acquire) }
|
||||
}
|
||||
|
||||
/// One tick of the delivery state machine — called from the pad's regular service pump (≤4 ms
|
||||
/// cadence) and from [`Self::deliver_eager`]. Cheap when idle: two atomic loads.
|
||||
pub(super) fn pump(&mut self) {
|
||||
// Version diagnostics: the driver writes its own proto version even when it refuses to
|
||||
// publish a pid (host/driver mismatch), so the operator sees WHY the pad never attaches.
|
||||
let drv_proto = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_proto));
|
||||
if drv_proto != 0 && drv_proto != GAMEPAD_PROTO_VERSION && !self.warned_proto {
|
||||
self.warned_proto = true;
|
||||
tracing::warn!(
|
||||
mailbox = %self.boot_name,
|
||||
driver_proto = drv_proto,
|
||||
host_proto = GAMEPAD_PROTO_VERSION,
|
||||
"gamepad driver/host protocol mismatch on the bootstrap mailbox — update the \
|
||||
drivers: punktfunk-host.exe driver install --gamepad"
|
||||
);
|
||||
}
|
||||
let pid = self.boot_load(core::mem::offset_of!(PadBootstrap, driver_pid));
|
||||
if pid == 0 || pid == self.last_seen_pid {
|
||||
return;
|
||||
}
|
||||
self.last_seen_pid = pid;
|
||||
if self.attempts >= MAX_DELIVERY_ATTEMPTS {
|
||||
if !self.warned_cap {
|
||||
self.warned_cap = true;
|
||||
tracing::warn!(
|
||||
mailbox = %self.boot_name,
|
||||
attempts = self.attempts,
|
||||
"gamepad channel delivery cap reached — the bootstrap mailbox keeps changing \
|
||||
its driver pid (tampering?); no further handles will be duplicated"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.attempts += 1;
|
||||
match self.deliver_to(pid) {
|
||||
Ok(seq) => {
|
||||
self.delivered = true;
|
||||
tracing::info!(
|
||||
mailbox = %self.boot_name,
|
||||
wudf_pid = pid,
|
||||
seq,
|
||||
"sealed gamepad channel delivered (DATA handle duplicated into the driver's \
|
||||
WUDFHost)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
mailbox = %self.boot_name,
|
||||
pid,
|
||||
error = %format!("{e:#}"),
|
||||
"sealed gamepad channel delivery failed — will retry when the mailbox reports \
|
||||
a different driver pid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicate the DATA section into `pid`'s handle table (after verifying it is a genuine
|
||||
/// WUDFHost) and publish the handle value + owning pid, bumping `handle_seq` LAST. The driver
|
||||
/// adopts the handle by consuming the delivery; an unconsumed duplicate dies with the target
|
||||
/// process (nothing to reap — there is no fallible step after the duplication).
|
||||
fn deliver_to(&self, pid: u32) -> Result<u32> {
|
||||
// SAFETY: plain FFI; the handle (checked by `?`) is owned solely here and moved into the
|
||||
// `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it for the
|
||||
// synchronous check and forms no lasting alias.
|
||||
let process = unsafe {
|
||||
let h = OpenProcess(
|
||||
PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
false,
|
||||
pid,
|
||||
)
|
||||
.context("OpenProcess(PROCESS_DUP_HANDLE) on the mailbox-reported pid")?;
|
||||
let process = OwnedHandle::from_raw_handle(h.0 as _);
|
||||
pf_capture::verify_is_wudfhost(
|
||||
HANDLE(process.as_raw_handle()),
|
||||
pid,
|
||||
"gamepad-channel",
|
||||
)?;
|
||||
process
|
||||
};
|
||||
let mut remote = HANDLE::default();
|
||||
// SAFETY: `self.data.raw_handle()` is the live section handle this channel owns;
|
||||
// `process` is the live PROCESS_DUP_HANDLE target; `&mut remote` is a valid out-param.
|
||||
// Least privilege: the pad driver only MAPS the DATA section read/write (its `FILE_MAP_RW` =
|
||||
// `SECTION_MAP_READ | SECTION_MAP_WRITE`), so grant exactly that instead of copying our
|
||||
// full-access creator handle via `DUPLICATE_SAME_ACCESS` (Chen: don't over-grant unnamed
|
||||
// shared objects — a compromised driver's handle then can't `WRITE_DAC`/`DELETE` the section).
|
||||
unsafe {
|
||||
DuplicateHandle(
|
||||
GetCurrentProcess(),
|
||||
self.data.raw_handle(),
|
||||
HANDLE(process.as_raw_handle()),
|
||||
&mut remote,
|
||||
SECTION_MAP_RW,
|
||||
false,
|
||||
DUPLICATE_HANDLE_OPTIONS(0),
|
||||
)
|
||||
.context("DuplicateHandle(gamepad DATA section) into the driver's WUDFHost")?;
|
||||
}
|
||||
let value = remote.0 as usize as u64;
|
||||
let base = self.boot.base();
|
||||
let seq = BOOT_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
// SAFETY: live, page-aligned mailbox view; `data_handle` is 8-aligned and `handle_pid`/
|
||||
// `handle_seq` 4-aligned (proto asserts). The handle value + owning pid are published BEFORE
|
||||
// the seq (Release) — a driver that observes the new seq (Acquire) sees a complete delivery.
|
||||
unsafe {
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, data_handle)) as *const AtomicU64))
|
||||
.store(value, Ordering::Relaxed);
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_pid)) as *const AtomicU32))
|
||||
.store(pid, Ordering::Relaxed);
|
||||
fence(Ordering::Release);
|
||||
(*(base.add(core::mem::offset_of!(PadBootstrap, handle_seq)) as *const AtomicU32))
|
||||
.store(seq, Ordering::Release);
|
||||
}
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
/// Bounded wait at pad-open: pump until the mailbox produces a driver pid we act on (delivered or
|
||||
/// rejected) or `timeout` passes. Closes the identity race for the DualShock 4 (the driver reads
|
||||
/// `device_type` from the DATA section when hidclass asks for descriptors — the channel should be
|
||||
/// attached by then); the regular service pump takes over afterwards either way.
|
||||
pub(super) fn deliver_eager(&mut self, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
self.pump();
|
||||
if self.last_seen_pid != 0 || Instant::now() >= deadline {
|
||||
if !self.delivered {
|
||||
tracing::debug!(
|
||||
mailbox = %self.boot_name,
|
||||
"eager gamepad-channel delivery window passed without an attach — the \
|
||||
service pump keeps polling (driver-attach diagnosis follows if it stays \
|
||||
silent)"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context for the `SwDeviceCreate` completion callback: an event to signal, the HRESULT it reports,
|
||||
/// and the PnP instance id PnP assigned (captured for devnode health diagnostics). Shared by every
|
||||
/// Windows companion backend (XUSB / DualSense / DS4): each `create_swdevice` builds one, hands it to
|
||||
/// `SwDeviceCreate` alongside [`sw_create_cb`], and reads [`instance_id`](Self::instance_id) once the
|
||||
/// callback has signalled.
|
||||
#[repr(C)]
|
||||
pub(super) struct SwCreateCtx {
|
||||
pub(super) event: HANDLE,
|
||||
pub(super) result: HRESULT,
|
||||
pub(super) instance_id: [u16; 128],
|
||||
}
|
||||
|
||||
/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result and wake the
|
||||
/// creator, which blocks on the event (so there's no concurrent access to `*ctx`).
|
||||
pub(super) unsafe extern "system" fn sw_create_cb(
|
||||
_dev: HSWDEVICE,
|
||||
result: HRESULT,
|
||||
ctx: *const c_void,
|
||||
id: PCWSTR,
|
||||
) {
|
||||
if !ctx.is_null() {
|
||||
// SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback (the
|
||||
// creator blocks on the event). `id` is a NUL-terminated string for the callback's duration.
|
||||
unsafe {
|
||||
let c = ctx as *mut SwCreateCtx;
|
||||
(*c).result = result;
|
||||
if !id.is_null() {
|
||||
for i in 0..(*c).instance_id.len() - 1 {
|
||||
let ch = *id.0.add(i);
|
||||
(*c).instance_id[i] = ch;
|
||||
if ch == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = SetEvent((*c).event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SwCreateCtx {
|
||||
pub(super) fn instance_id(&self) -> Option<String> {
|
||||
let len = self.instance_id.iter().position(|&c| c == 0)?;
|
||||
(len > 0).then(|| String::from_utf16_lossy(&self.instance_id[..len]))
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
||||
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
||||
pub(super) struct SwDevice(HSWDEVICE);
|
||||
|
||||
impl SwDevice {
|
||||
pub(super) fn new(hsw: HSWDEVICE) -> Self {
|
||||
SwDevice(hsw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwDevice {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the handle `SwDeviceCreate` returned; `SwDeviceClose` removes the devnode.
|
||||
unsafe { SwDeviceClose(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Driver health surfacing ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The gamepad drivers have no IOCTL plane (hidclass gates the stack), so the only cross-process
|
||||
// signal is the shared section itself. The drivers stamp `driver_proto` into their section once
|
||||
// attached (pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION); [`DriverAttach`] watches that field
|
||||
// from the host's regular pump and turns silence into actionable WARN/ERROR log lines — the piece
|
||||
// that used to be missing entirely: a pad could be "created" with no driver installed and nothing
|
||||
// was ever logged until the user gave up ("host doesn't see my controller" bug reports).
|
||||
|
||||
/// How long to give PnP to bind the driver + the driver to stamp the section before warning.
|
||||
const ATTACH_GRACE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Per-pad driver-attach watcher: feed it the section's `driver_proto` on every service tick; it
|
||||
/// logs the attach (INFO), a version mismatch (WARN), or — after [`ATTACH_GRACE`] of silence — one
|
||||
/// diagnosis WARN combining the driver-store check and the devnode problem code. States never
|
||||
/// repeat their log line, so the pump can call this at full rate.
|
||||
pub(super) struct DriverAttach {
|
||||
/// Driver label for log lines (`pf_xusb` / `pf_dualsense` / `pf_dualshock4`).
|
||||
driver: &'static str,
|
||||
/// The INF the driver store must hold for this driver (`pf_xusb.inf` / `pf_dualsense.inf`).
|
||||
inf: &'static str,
|
||||
/// The driver's own debug log, referenced in the diagnosis line.
|
||||
driver_log: &'static str,
|
||||
/// Bootstrap-mailbox name, for log lines (the DATA section is unnamed).
|
||||
shm_name: String,
|
||||
/// PnP instance id of the SwDeviceCreate'd devnode (`None` on the out-of-band fallback path).
|
||||
instance_id: Option<String>,
|
||||
created: Instant,
|
||||
state: AttachState,
|
||||
}
|
||||
|
||||
enum AttachState {
|
||||
Waiting,
|
||||
/// Diagnosis logged; still watching so a late attach gets its INFO line.
|
||||
Warned,
|
||||
Attached,
|
||||
}
|
||||
|
||||
impl DriverAttach {
|
||||
pub(super) fn new(
|
||||
driver: &'static str,
|
||||
inf: &'static str,
|
||||
driver_log: &'static str,
|
||||
shm_name: String,
|
||||
instance_id: Option<String>,
|
||||
) -> DriverAttach {
|
||||
DriverAttach {
|
||||
driver,
|
||||
inf,
|
||||
driver_log,
|
||||
shm_name,
|
||||
instance_id,
|
||||
created: Instant::now(),
|
||||
state: AttachState::Waiting,
|
||||
}
|
||||
}
|
||||
|
||||
/// `driver_proto` is the section field the driver stamps once attached (0 = not attached).
|
||||
pub(super) fn observe(&mut self, driver_proto: u32) {
|
||||
match self.state {
|
||||
AttachState::Attached => {}
|
||||
AttachState::Waiting | AttachState::Warned if driver_proto != 0 => {
|
||||
let late = matches!(self.state, AttachState::Warned);
|
||||
tracing::info!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
proto = driver_proto,
|
||||
late,
|
||||
"gamepad driver attached to the shared section"
|
||||
);
|
||||
if driver_proto != pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION {
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
driver_proto,
|
||||
host_proto = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION,
|
||||
"gamepad driver/host protocol mismatch — update the drivers: punktfunk-host.exe driver install --gamepad"
|
||||
);
|
||||
}
|
||||
self.state = AttachState::Attached;
|
||||
}
|
||||
AttachState::Waiting if self.created.elapsed() >= ATTACH_GRACE => {
|
||||
self.diagnose();
|
||||
self.state = AttachState::Warned;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-shot WARN with everything the host can find out about WHY the driver isn't attached:
|
||||
/// driver-store presence, the devnode's PnP status/problem code, and where to look next.
|
||||
fn diagnose(&self) {
|
||||
let store = match driver_store_has(self.inf) {
|
||||
Some(true) => "driver package present in the driver store",
|
||||
Some(false) => {
|
||||
"driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad"
|
||||
}
|
||||
None => "driver store could not be queried (pnputil failed)",
|
||||
};
|
||||
let devnode = match &self.instance_id {
|
||||
Some(id) => devnode_status_line(id),
|
||||
None => {
|
||||
"no per-session devnode (SwDeviceCreate failed earlier — see the warning above)"
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
tracing::warn!(
|
||||
driver = self.driver,
|
||||
shm = %self.shm_name,
|
||||
grace_secs = ATTACH_GRACE.as_secs(),
|
||||
store,
|
||||
devnode = %devnode,
|
||||
driver_log = self.driver_log,
|
||||
"gamepad driver has not attached to the shared section — the virtual pad exists but no \
|
||||
driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \
|
||||
reads as not-attached: update with punktfunk-host.exe driver install --gamepad \
|
||||
(driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \
|
||||
PFDS_DEBUG_LOG system env var set + the device restarted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only
|
||||
/// consulted on the failure path, so the one-off subprocess cost never hits a healthy session.
|
||||
fn driver_store_inventory() -> &'static str {
|
||||
static INV: OnceLock<String> = OnceLock::new();
|
||||
INV.get_or_init(|| {
|
||||
// Resolve pnputil by full System32 path — the host runs as SYSTEM and must not trust PATH /
|
||||
// the CreateProcess search (which checks the launching EXE's own dir first), or a planted
|
||||
// `pnputil.exe` beside the host binary would run elevated (security-review 2026-07-17).
|
||||
let pnputil = std::env::var("SystemRoot")
|
||||
.map(|r| format!(r"{r}\System32\pnputil.exe"))
|
||||
.unwrap_or_else(|_| "pnputil.exe".to_string());
|
||||
std::process::Command::new(&pnputil)
|
||||
.arg("/enum-drivers")
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the driver store holds `inf` (e.g. `pf_xusb.inf`). `None` = pnputil unavailable/failed.
|
||||
fn driver_store_has(inf: &str) -> Option<bool> {
|
||||
let inv = driver_store_inventory();
|
||||
if inv.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(inv.contains(&inf.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Human-readable PnP status of a devnode: driver bound/started or the CM problem code with a hint.
|
||||
fn devnode_status_line(instance_id: &str) -> String {
|
||||
let wide: Vec<u16> = instance_id
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let mut devinst = 0u32;
|
||||
// SAFETY: `wide` is a valid NUL-terminated UTF-16 instance id; `devinst` receives the handle.
|
||||
let cr = unsafe {
|
||||
CM_Locate_DevNodeW(
|
||||
&mut devinst,
|
||||
PCWSTR(wide.as_ptr()),
|
||||
CM_LOCATE_DEVNODE_NORMAL,
|
||||
)
|
||||
};
|
||||
if cr != CR_SUCCESS {
|
||||
return format!(
|
||||
"devnode {instance_id} not found (CM_Locate_DevNodeW CR={})",
|
||||
cr.0
|
||||
);
|
||||
}
|
||||
let mut status = CM_DEVNODE_STATUS_FLAGS(0);
|
||||
let mut problem = CM_PROB(0);
|
||||
// SAFETY: devinst is the devnode located above; the two out-params receive status + problem.
|
||||
let cr = unsafe { CM_Get_DevNode_Status(&mut status, &mut problem, devinst, 0) };
|
||||
if cr != CR_SUCCESS {
|
||||
return format!("devnode {instance_id}: status query failed (CR={})", cr.0);
|
||||
}
|
||||
if status.0 & DN_HAS_PROBLEM.0 != 0 {
|
||||
return format!(
|
||||
"devnode {instance_id} has PnP problem code {} ({}) [status 0x{:08x}]",
|
||||
problem.0,
|
||||
cm_problem_hint(problem.0),
|
||||
status.0
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"devnode {instance_id} status 0x{:08x} (driver_loaded={} started={})",
|
||||
status.0,
|
||||
status.0 & DN_DRIVER_LOADED.0 != 0,
|
||||
status.0 & DN_STARTED.0 != 0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The CM_PROB_* codes a virtual-pad devnode realistically hits, with the operator-facing cause.
|
||||
fn cm_problem_hint(problem: u32) -> &'static str {
|
||||
match problem {
|
||||
1 => "not configured — no driver bound; install the drivers",
|
||||
10 => "device failed to start — driver bound but its start failed; check the driver log",
|
||||
18 => "reinstall required — re-run driver install",
|
||||
24 => "device not present/working — PnP could not start the virtual devnode",
|
||||
28 => "drivers not installed — the pf driver package is missing from the store or its certificate is not trusted",
|
||||
31 => "driver failed to load — binding found the package but loading it failed",
|
||||
39 => "driver corrupt or missing — reinstall the drivers",
|
||||
43 => "reported failure after start — check the driver log",
|
||||
52 => "driver signature rejected — certificate not in Root/TrustedPublisher, or blocked by Memory Integrity",
|
||||
_ => "see Device Manager for this code",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver
|
||||
//! (`packaging/windows/drivers/pf-xusb`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360
|
||||
//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel
|
||||
//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_<index>` devnode (the driver loads on it and
|
||||
//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into an **unnamed** shared
|
||||
//! DATA section the driver reaches over the **sealed channel** ([`PadChannel`] — handle duplicated
|
||||
//! into its WUDFHost, bootstrapped via `Global\pfxusb-boot-<index>`; see
|
||||
//! `design/gamepad-channel-sealing.md`). GameStream/Moonlight already speak the XInput conventions
|
||||
//! (low-16 button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1.
|
||||
//!
|
||||
//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver
|
||||
//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays
|
||||
//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path.
|
||||
|
||||
use super::gamepad_raii::{sw_create_cb, PadChannel, SwCreateCtx};
|
||||
use crate::pad_slots::PadSlots;
|
||||
use anyhow::{anyhow, Result};
|
||||
use punktfunk_core::input::{GamepadEvent, MAX_PADS};
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{fence, AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::core::{w, GUID, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, E_FAIL, WAIT_OBJECT_0};
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
|
||||
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
|
||||
// it so a layout change is a compile error, not a hand-synced literal (audit §6.1).
|
||||
use pf_driver_proto::gamepad::XusbShm;
|
||||
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
||||
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
||||
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
||||
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
||||
const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
|
||||
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
|
||||
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
|
||||
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
|
||||
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
|
||||
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
|
||||
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
|
||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
|
||||
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
|
||||
|
||||
/// Spawn the `pf_xusb_<index>` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The
|
||||
/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads,
|
||||
/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but
|
||||
/// we still pass a deterministic non-null `pContainerId` (the null-sentinel trips an `xinput1_4`
|
||||
/// slot-skip bug). `SwDeviceClose` removes it on drop.
|
||||
fn create_swdevice(index: u8) -> Result<(HSWDEVICE, Option<String>)> {
|
||||
let hwids: Vec<u16> = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect();
|
||||
let instid: Vec<u16> = format!("pf_xusb_{index}")
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let desc: Vec<u16> = "punktfunk Virtual Xbox 360 (XUSB)"
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
// The pad index, stamped into the device Location — the driver reads it to poll `pfxusb-boot-<index>`
|
||||
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
|
||||
let loc: Vec<u16> = format!("{index}")
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let container = GUID::from_values(0x5046_5855, 0x0000, 0x0000, [0, 0, 0, 0, 0, 0, 0, index]);
|
||||
|
||||
// SAFETY: zeroed then the fields we use are set; the buffers + container outlive the call.
|
||||
let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() };
|
||||
info.cbSize = std::mem::size_of::<SW_DEVICE_CREATE_INFO>() as u32;
|
||||
info.pszInstanceId = PCWSTR(instid.as_ptr());
|
||||
info.pszzHardwareIds = PCWSTR(hwids.as_ptr());
|
||||
info.pContainerId = &container;
|
||||
info.pszDeviceDescription = PCWSTR(desc.as_ptr());
|
||||
info.pszDeviceLocation = PCWSTR(loc.as_ptr());
|
||||
info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable
|
||||
|
||||
// SAFETY: a manual-reset, initially-unsignaled, unnamed event.
|
||||
let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? };
|
||||
// `result` starts as E_FAIL, NOT S_OK: if the wait below times out, a zero-initialised HRESULT
|
||||
// would read as success and mask the failure (found by the 2026-07 driver-health audit).
|
||||
let mut ctx = SwCreateCtx {
|
||||
event,
|
||||
result: E_FAIL,
|
||||
instance_id: [0; 128],
|
||||
};
|
||||
// SAFETY: info + buffers + ctx outlive the call (we wait on the event before returning).
|
||||
let hsw = match unsafe {
|
||||
SwDeviceCreate(
|
||||
w!("punktfunk"),
|
||||
w!("HTREE\\ROOT\\0"),
|
||||
&info,
|
||||
None,
|
||||
Some(sw_create_cb),
|
||||
Some(&mut ctx as *mut SwCreateCtx as *const c_void),
|
||||
)
|
||||
} {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
return Err(anyhow!("SwDeviceCreate(pf_xusb) failed: {e}"));
|
||||
}
|
||||
};
|
||||
// SAFETY: event valid; block until PnP finishes enumerating, then check the callback result.
|
||||
let wait = unsafe { WaitForSingleObject(event, 10_000) };
|
||||
// SAFETY: event is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(event);
|
||||
}
|
||||
if wait != WAIT_OBJECT_0 {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate(pf_xusb) enumeration callback never fired (10s) — PnP may be wedged"
|
||||
));
|
||||
}
|
||||
if ctx.result.is_err() {
|
||||
// SAFETY: hsw is the handle SwDeviceCreate returned.
|
||||
unsafe { SwDeviceClose(hsw) };
|
||||
return Err(anyhow!(
|
||||
"SwDeviceCreate(pf_xusb) enumeration failed: {:?}",
|
||||
ctx.result
|
||||
));
|
||||
}
|
||||
Ok((hsw, ctx.instance_id()))
|
||||
}
|
||||
|
||||
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the sealed shared-memory channel.
|
||||
struct XusbWinPad {
|
||||
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The sealed channel: the unnamed DATA section (the `XusbShm`) + the bootstrap mailbox + the
|
||||
/// handle-delivery state machine (drop closes both sections).
|
||||
channel: PadChannel,
|
||||
/// Watches the section's `driver_proto` field and logs attach / never-attached diagnosis.
|
||||
attach: super::gamepad_raii::DriverAttach,
|
||||
packet: u32,
|
||||
last_rumble_seq: u32,
|
||||
}
|
||||
|
||||
impl XusbWinPad {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfxusb-boot-<index>` mailbox), stamp
|
||||
/// the pad index then the magic LAST, spawn the devnode, and eagerly deliver the DATA handle once
|
||||
/// the driver publishes its pid.
|
||||
fn open(index: u8) -> Result<XusbWinPad> {
|
||||
let boot_name = pf_driver_proto::gamepad::xusb_boot_name(index);
|
||||
let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?;
|
||||
let base = channel.data_base();
|
||||
// The section arrives zeroed; stamp the pad index (the driver validates it against its own
|
||||
// devnode index on attach) then the magic LAST (the driver only accepts it once magic is set).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_PAD_INDEX is in range.
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32);
|
||||
std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC);
|
||||
}
|
||||
// Propagate a devnode-create failure instead of swallowing it: a swallowed failure left the
|
||||
// pad with no devnode yet still reported success, so PadSlots latched a phantom pad (never
|
||||
// re-created for the session's life) and the host logged a misleading "virtual Xbox 360
|
||||
// created". Returning Err routes it through PadSlots' ERROR + capped-backoff retry — parity
|
||||
// with the Linux uinput path, which self-heals for exactly this reason.
|
||||
let (hsw, instance_id) = create_swdevice(index)?;
|
||||
let _sw = Some(super::gamepad_raii::SwDevice::new(hsw));
|
||||
// Bounded eager delivery: the driver's EvtDeviceAdd publishes its pid right away; handing it
|
||||
// the DATA handle before we return means the pad is live for the game's first XInput poll.
|
||||
// On a missing/old driver this waits out the window once and the service pump takes over.
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(XusbWinPad {
|
||||
_sw,
|
||||
channel,
|
||||
attach: super::gamepad_raii::DriverAttach::new(
|
||||
"pf_xusb",
|
||||
"pf_xusb.inf",
|
||||
"C:\\Users\\Public\\pfxusb-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
packet: 0,
|
||||
last_rumble_seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the XInput state to the section and bump the packet number (XInput uses it to detect
|
||||
/// change). `buttons` is the XINPUT_GAMEPAD_* bitmap; sticks are i16, triggers u8.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
|
||||
self.packet = self.packet.wrapping_add(1);
|
||||
let base = self.channel.data_base();
|
||||
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
|
||||
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
|
||||
// writes. Single owner (`&mut self`), so no concurrent writer races these stores. `packet` (the
|
||||
// field XInput reads to detect a new state) is published LAST: the `Release` fence orders the
|
||||
// state-body stores above before the `Release` `AtomicU32` store of `packet`, so the driver —
|
||||
// which `Acquire`-loads `packet` — never observes a bumped packet over a torn body on a
|
||||
// weakly-ordered core (ARM64). On x86-TSO both are plain stores. `OFF_PACKET` (== 4) is
|
||||
// 4-aligned off the page-aligned section base, so the `AtomicU32` view is valid (mirrors the
|
||||
// seq-fenced publish in `gamepad_raii::PadChannel::create`).
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||
*base.add(OFF_LT) = lt;
|
||||
*base.add(OFF_RT) = rt;
|
||||
std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
|
||||
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
|
||||
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
|
||||
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
|
||||
fence(Ordering::Release);
|
||||
(*(base.add(OFF_PACKET) as *const AtomicU32)).store(self.packet, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
|
||||
/// `(large, small)` motor levels (0..=255) when a new one arrived. Also ticks the sealed-channel
|
||||
/// delivery (a late-binding driver gets its handle here) and feeds the driver-attach health
|
||||
/// watcher (the driver stamps `driver_proto` once it maps the delivered section + per IOCTL).
|
||||
fn service(&mut self) -> Option<(u8, u8)> {
|
||||
self.channel.pump();
|
||||
let base = self.channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let proto = unsafe { std::ptr::read_unaligned(base.add(OFF_DRIVER_PROTO) as *const u32) };
|
||||
self.attach.observe(proto);
|
||||
// SAFETY: base points at SHM_SIZE bytes; `OFF_RUMBLE_SEQ` (== 24) is 4-aligned off the
|
||||
// page-aligned base, so the `AtomicU32` view is valid. The driver bumps `rumble_seq` AFTER
|
||||
// writing the rumble bytes, so an `Acquire` load here orders the `rumble_large`/`rumble_small`
|
||||
// reads below after it — a fresh seq guarantees a coherent snapshot of the rumble bytes on a
|
||||
// weakly-ordered core (ARM64). On x86-TSO it is a plain load.
|
||||
let seq =
|
||||
unsafe { (*(base.add(OFF_RUMBLE_SEQ) as *const AtomicU32)).load(Ordering::Acquire) };
|
||||
if seq == self.last_rumble_seq {
|
||||
return None;
|
||||
}
|
||||
self.last_rumble_seq = seq;
|
||||
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
|
||||
let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
|
||||
Some((large, small))
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
|
||||
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
|
||||
/// session input thread already drives.
|
||||
/// How long a non-zero rumble may stay latched with the game NOT driving the pad (no `SET_STATE`)
|
||||
/// before it is forced off. XInput vibration is level-triggered — it persists until the game sets
|
||||
/// it to zero — so a game that latches a rumble and then stops calling `XInputSetState` (a residual
|
||||
/// left at a menu / loading screen, or a plain forgotten stop) would otherwise drone to the client
|
||||
/// forever (measured: a stuck `(0,512)` resent every 500 ms for 5.5 minutes). A real controller
|
||||
/// stops when the app stops driving it; this mirrors that. It is keyed on game ACTIVITY (any
|
||||
/// `SET_STATE`, even an unchanged one), so a rumble the game keeps asserting is never cut — only an
|
||||
/// abandoned residual is. Kept above SDL's ~2 s internal rumble resend so an SDL-driven host game
|
||||
/// (which re-issues the same level every ~2 s) refreshes the activity clock before this fires.
|
||||
const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500);
|
||||
|
||||
pub struct GamepadManager {
|
||||
slots: PadSlots<XusbWinPad>,
|
||||
last_rumble: Vec<(u8, u8)>,
|
||||
/// When the game last drove each pad (bumped `rumble_seq` via `SET_STATE`). A non-zero
|
||||
/// `last_rumble` older than [`RUMBLE_IDLE_TIMEOUT`] against this is a stale residual — see the
|
||||
/// const's docs.
|
||||
last_active: Vec<Instant>,
|
||||
}
|
||||
|
||||
impl Default for GamepadManager {
|
||||
fn default() -> GamepadManager {
|
||||
GamepadManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
pub fn new() -> GamepadManager {
|
||||
GamepadManager {
|
||||
slots: PadSlots::new(
|
||||
"Xbox 360/Windows",
|
||||
"Xbox 360",
|
||||
" (install/repair: punktfunk-host.exe driver install --gamepad)",
|
||||
),
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_active: (0..MAX_PADS).map(|_| Instant::now()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if self.slots.ensure(idx, XusbWinPad::open) {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Xbox 360 created (Windows XUSB companion)"
|
||||
);
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_active[idx] = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (Xbox 360/Windows)");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
let swept = self.slots.sweep(f.active_mask);
|
||||
for i in 0..MAX_PADS {
|
||||
if swept & (1 << i) != 0 {
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_active[i] = Instant::now();
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return;
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.slots.get_mut(idx) {
|
||||
pad.write_state(
|
||||
(f.buttons & 0xffff) as u16,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries
|
||||
/// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small`
|
||||
/// (high-frequency) → `high` — matching the other backends.
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
for (i, pad) in self.slots.iter_mut() {
|
||||
if let Some((large, small)) = pad.service() {
|
||||
// The game drove the pad this poll (SET_STATE bumped the seq) — refresh the
|
||||
// activity clock even when the level is unchanged, so a rumble it keeps asserting
|
||||
// never trips the stale-residual timeout below.
|
||||
self.last_active[i] = Instant::now();
|
||||
if self.last_rumble[i] != (large, small) {
|
||||
self.last_rumble[i] = (large, small);
|
||||
send(i as u16, large as u16 * 257, small as u16 * 257);
|
||||
}
|
||||
} else if self.last_rumble[i] != (0, 0)
|
||||
&& self.last_active[i].elapsed() >= RUMBLE_IDLE_TIMEOUT
|
||||
{
|
||||
// A non-zero rumble is latched but the game has not driven the pad for
|
||||
// RUMBLE_IDLE_TIMEOUT — a residual it forgot to stop. Force it off (and forward
|
||||
// the zero) so the resend loop stops droning it to the client. See the const docs.
|
||||
tracing::info!(
|
||||
index = i,
|
||||
prev_low = self.last_rumble[i].0 as u16 * 257,
|
||||
prev_high = self.last_rumble[i].1 as u16 * 257,
|
||||
"rumble: stale residual (game stopped driving the pad) — forcing off"
|
||||
);
|
||||
self.last_rumble[i] = (0, 0);
|
||||
send(i as u16, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Resident virtual HID mouse on Windows via the UMDF minidriver (`packaging/windows/drivers/pf-mouse`).
|
||||
//!
|
||||
//! **Why**: with no pointing device attached (a headless streaming box — no dongle), win32k reports
|
||||
//! the cursor as absent (`GetSystemMetrics(SM_MOUSEPRESENT)` = 0) and DWM never composites a cursor
|
||||
//! into the pf-vdisplay frame — the streamed desktop has an invisible pointer even though
|
||||
//! `SendInput` moves it. Keeping ONE virtual HID mouse devnode alive for the host's lifetime makes
|
||||
//! Windows always consider a pointer present and draw the cursor — the Sunshine/Parsec-class fix,
|
||||
//! with zero client changes. Injection stays [`super::sendinput`]; the report path here is
|
||||
//! exercised by `punktfunk-host vmouse-spike` (on-glass validation) and is the future
|
||||
//! higher-fidelity injection route.
|
||||
//!
|
||||
//! Transport is the **sealed pad channel** verbatim ([`PadChannel`],
|
||||
//! `design/gamepad-channel-sealing.md`): an unnamed 64-B `MouseShm` DATA section the host
|
||||
//! duplicates into the driver's WUDFHost, bootstrapped via the named `Global\pfmouse-boot-0`
|
||||
//! mailbox. The devnode is `SwDeviceCreate`'d like a pad but held for the PROCESS lifetime (the
|
||||
//! [`ensure_resident`] thread never drops it), so the pointer survives across sessions; it
|
||||
//! disappears with the host service, which is exactly when nobody is streaming.
|
||||
|
||||
use super::dualsense_windows::{create_swdevice, SwDeviceProfile};
|
||||
use super::gamepad_raii::{DriverAttach, PadChannel};
|
||||
use anyhow::Result;
|
||||
use pf_driver_proto::mouse::{input_report, mouse_boot_name, MouseShm, MOUSE_MAGIC};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
use windows::Win32::Foundation::POINT;
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
||||
|
||||
const SHM_SIZE: usize = core::mem::size_of::<MouseShm>();
|
||||
const OFF_IN_SEQ: usize = core::mem::offset_of!(MouseShm, in_seq);
|
||||
const OFF_REPORT: usize = core::mem::offset_of!(MouseShm, report);
|
||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(MouseShm, driver_proto);
|
||||
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(MouseShm, driver_heartbeat);
|
||||
const OFF_PAD_INDEX: usize = core::mem::offset_of!(MouseShm, pad_index);
|
||||
|
||||
/// The one resident virtual mouse: the `SwDeviceCreate`'d `pf_mouse_0` devnode (the pf-mouse HID
|
||||
/// minidriver loads on it → Windows counts a pointer present) plus the sealed shared-memory
|
||||
/// channel. Dropping it removes the devnode — [`ensure_resident`] therefore never drops it.
|
||||
pub struct VirtualMouse {
|
||||
/// Devnode RAII (`SwDeviceClose` on drop). `None` falls back to an out-of-band devnode.
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
channel: PadChannel,
|
||||
attach: DriverAttach,
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
impl VirtualMouse {
|
||||
/// Create the sealed channel (unnamed DATA section + `Global\pfmouse-boot-0` mailbox), stamp
|
||||
/// the index + the magic LAST, then spawn the devnode and eagerly deliver the DATA handle.
|
||||
pub fn open() -> Result<VirtualMouse> {
|
||||
let boot_name = mouse_boot_name(0);
|
||||
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. Index
|
||||
// first, magic LAST — the same publish order the pads use.
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, 0u32);
|
||||
std::ptr::write_unaligned(base as *mut u32, MOUSE_MAGIC);
|
||||
}
|
||||
let (hsw, instance_id) = match create_swdevice(&SwDeviceProfile {
|
||||
instance: "pf_mouse_0",
|
||||
container_tag: 0x5046_4D4F, // "PFMO" — never grouped with a pad's container
|
||||
container_index: 0,
|
||||
hwid: "pf_mouse",
|
||||
// An obviously-virtual identity (PF:MO). The synthesized USB bus tokens are inert for
|
||||
// a mouse (nothing fingerprints them); reusing the shared profile keeps one code path.
|
||||
usb_vid_pid: "VID_5046&PID_4D4F",
|
||||
usb_mi: None,
|
||||
description: "punktfunk Virtual Mouse",
|
||||
}) {
|
||||
Ok((h, i)) => (Some(h), i),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_mouse devnode");
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
channel.deliver_eager(Duration::from_millis(1500));
|
||||
Ok(VirtualMouse {
|
||||
_sw,
|
||||
channel,
|
||||
attach: DriverAttach::new(
|
||||
"pf_mouse",
|
||||
"pf_mouse.inf",
|
||||
"C:\\Users\\Public\\pfmouse-driver.log",
|
||||
boot_name,
|
||||
instance_id,
|
||||
),
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish an input report (5-bit buttons, absolute 15-bit x/y, wheel/pan deltas) and bump
|
||||
/// `in_seq` (Release) — the driver's timer completes a pended `READ_REPORT` with it. Unused by
|
||||
/// sessions today (`SendInput` injects); the spike drives it, and a future fidelity mode will.
|
||||
pub fn send_report(&mut self, buttons: u8, x: u16, y: u16, wheel: i8, pan: i8) {
|
||||
let r = input_report(buttons, x, y, wheel, pan);
|
||||
self.seq = self.seq.wrapping_add(1).max(1); // never publish seq 0 (= "nothing yet")
|
||||
let base = self.channel.data_base();
|
||||
// SAFETY: base points at SHM_SIZE bytes; the report slot is OFF_REPORT..+8 and OFF_IN_SEQ
|
||||
// (== 4) is 4-aligned off the page-aligned base, so the AtomicU32 view is valid. The report
|
||||
// bytes are published BEFORE the seq (Release) — the driver's Acquire load of `in_seq`
|
||||
// therefore observes the matching report.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(r.as_ptr(), base.add(OFF_REPORT), r.len());
|
||||
(*(base.add(OFF_IN_SEQ) as *const AtomicU32)).store(self.seq, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// One service tick: pump the sealed-channel delivery and feed the driver-attach health
|
||||
/// watcher (the driver's 8 ms timer stamps `driver_proto` while it has the section mapped).
|
||||
pub fn service(&mut self) {
|
||||
self.channel.pump();
|
||||
self.attach.observe(self.driver_proto());
|
||||
}
|
||||
|
||||
fn driver_proto(&self) -> u32 {
|
||||
// SAFETY: base points at SHM_SIZE bytes; OFF_DRIVER_PROTO is in range.
|
||||
unsafe {
|
||||
std::ptr::read_unaligned(self.channel.data_base().add(OFF_DRIVER_PROTO) as *const u32)
|
||||
}
|
||||
}
|
||||
|
||||
fn driver_heartbeat(&self) -> u32 {
|
||||
// SAFETY: base points at SHM_SIZE bytes; OFF_DRIVER_HEARTBEAT is in range.
|
||||
unsafe {
|
||||
std::ptr::read_unaligned(
|
||||
self.channel.data_base().add(OFF_DRIVER_HEARTBEAT) as *const u32
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One pending compose-kick aim, desktop coordinates: the target display's rect plus the
|
||||
/// virtual-desktop bounds to normalize against (both from CCD, so they describe the CONSOLE's
|
||||
/// layout whatever session this process is in). Newest-wins single slot — kicks are idempotent
|
||||
/// damage nudges, queueing them would only multiply pointer blips.
|
||||
struct KickAim {
|
||||
rect: (i32, i32, i32, i32),
|
||||
bounds: (i32, i32, i32, i32),
|
||||
}
|
||||
|
||||
struct KickSlot {
|
||||
slot: Mutex<Option<KickAim>>,
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
static KICK: KickSlot = KickSlot {
|
||||
slot: Mutex::new(None),
|
||||
wake: Condvar::new(),
|
||||
};
|
||||
|
||||
/// True while the keeper's mouse is open AND the pf-mouse driver is attached (its 8 ms timer
|
||||
/// stamps `driver_proto`) — the only state in which a kick's reports actually reach win32k.
|
||||
static MOUSE_READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Request a pointer jiggle on the given display through the resident virtual mouse — the
|
||||
/// COMPOSE KICK's reliable arm. A report from a HID device is REAL input to win32k: it wakes a
|
||||
/// powered-off display subsystem (lid-closed / display idle-off / modern standby), resets idle
|
||||
/// timers, counts as user presence, and is delivered regardless of the calling process's session
|
||||
/// or the active desktop — every condition under which the `SendInput` kick is silently impotent
|
||||
/// (wrong session → wrong input queue; secure desktop → blocked; display-off → nothing to
|
||||
/// damage). Asynchronous: the keeper thread (which owns the one process-wide mouse) executes it
|
||||
/// within its tick. Returns `false` when the resident mouse isn't up (opted out, driver not
|
||||
/// installed, not yet attached) — the caller falls back to `SendInput`.
|
||||
pub(crate) fn hid_kick(rect: (i32, i32, i32, i32), bounds: (i32, i32, i32, i32)) -> bool {
|
||||
if !MOUSE_READY.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
*KICK.slot.lock().unwrap() = Some(KickAim { rect, bounds });
|
||||
KICK.wake.notify_one();
|
||||
true
|
||||
}
|
||||
|
||||
/// Execute one compose kick on the keeper thread: park the pointer at the target rect's center,
|
||||
/// dwell one composition interval, wiggle ~2 px, then put it back where it was. Every report is
|
||||
/// device-level input (see [`hid_kick`]). The dwell is load-bearing (the Stage-W3 on-glass
|
||||
/// finding, same as the SendInput jump path): DWM samples the cursor position at the next vsync
|
||||
/// tick, so a sub-tick round trip composes nothing. The gaps also respect the driver's 8 ms
|
||||
/// report timer — back-to-back writes into the single report slot would coalesce.
|
||||
///
|
||||
/// The restore is best-effort via `GetCursorPos`: in a wrong-session host it describes the wrong
|
||||
/// session's pointer, so the console pointer is instead left near the target's center — which is
|
||||
/// the streamed display, exactly where the pointer is about to be useful.
|
||||
fn perform_kick(m: &mut VirtualMouse, aim: KickAim) {
|
||||
let (bx, by, bw, bh) = aim.bounds;
|
||||
if bw <= 0 || bh <= 0 {
|
||||
return;
|
||||
}
|
||||
// Field-log which kick arm fired (the SendInput arm logs in kick_dwm_compose) — a lid-closed
|
||||
// repro should show this line followed by the driver's first acquired frame.
|
||||
tracing::debug!(
|
||||
rect = ?aim.rect,
|
||||
bounds = ?aim.bounds,
|
||||
"HID compose kick — parking the pointer on the target display (display wake + damage)"
|
||||
);
|
||||
let map = |px: i32, py: i32| -> (u16, u16) {
|
||||
let nx = ((px - bx).clamp(0, bw - 1) as i64 * 0x7FFF) / i64::from(bw - 1).max(1);
|
||||
let ny = ((py - by).clamp(0, bh - 1) as i64 * 0x7FFF) / i64::from(bh - 1).max(1);
|
||||
(nx as u16, ny as u16)
|
||||
};
|
||||
let mut p = POINT::default();
|
||||
// SAFETY: plain FFI; `p` is a valid out-param for this synchronous call.
|
||||
let orig = unsafe { GetCursorPos(&mut p) }
|
||||
.is_ok()
|
||||
.then_some((p.x, p.y));
|
||||
let (rx, ry, rw, rh) = aim.rect;
|
||||
let (cx, cy) = map(rx + rw / 2, ry + rh / 2);
|
||||
// ~2 desktop pixels in HID units, at least 1 — the wiggle must actually move the pointer.
|
||||
let dx = ((2 * 0x7FFF) / bw.max(1)).max(1) as u16;
|
||||
m.send_report(0, cx, cy, 0, 0);
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
m.send_report(0, cx.saturating_add(dx).min(0x7FFF), cy, 0, 0);
|
||||
std::thread::sleep(Duration::from_millis(35));
|
||||
match orig {
|
||||
Some((ox, oy)) => {
|
||||
let (ox, oy) = map(ox, oy);
|
||||
m.send_report(0, ox, oy, 0, 0);
|
||||
}
|
||||
None => m.send_report(0, cx, cy, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure the resident virtual mouse exists (idempotent, best-effort). Called whenever an
|
||||
/// [`InjectorService`](crate::InjectorService) starts — multiple services (native +
|
||||
/// GameStream) share the ONE process-wide mouse, guarded here. Spawns a keeper thread that owns
|
||||
/// the devnode for the process lifetime and pumps the channel at a slow tick (delivery is eager at
|
||||
/// open; the pump only handles a late WUDFHost + feeds the attach diagnostics).
|
||||
///
|
||||
/// `PUNKTFUNK_NO_VIRTUAL_MOUSE=1` opts out (diagnostics, or an operator who objects to a virtual
|
||||
/// pointer device).
|
||||
pub(crate) fn ensure_resident() {
|
||||
use std::sync::OnceLock;
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
STARTED.get_or_init(|| {
|
||||
if std::env::var_os("PUNKTFUNK_NO_VIRTUAL_MOUSE").is_some_and(|v| v != "0") {
|
||||
tracing::info!(
|
||||
"virtual HID mouse disabled (PUNKTFUNK_NO_VIRTUAL_MOUSE) — with no physical \
|
||||
pointer attached, Windows will not draw a cursor into the stream"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Hand the capture crate its HID compose-kick hook (the one-way-edge inversion: pf-capture
|
||||
// never reaches back into inject). Registered exactly when the resident mouse is being
|
||||
// brought up; until the driver actually attaches, `hid_kick` reports not-ready and the
|
||||
// kick falls back to SendInput.
|
||||
let _ = pf_capture::HID_COMPOSE_KICK.set(hid_kick);
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("punktfunk-vmouse".into())
|
||||
.spawn(keeper_thread)
|
||||
{
|
||||
tracing::warn!(error = %e, "virtual-mouse keeper thread spawn failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open-with-retry, then hold + pump forever. Open only realistically fails on a mailbox squat
|
||||
/// (another punktfunk-host instance) — retry slowly; a missing/failed DRIVER is not an open
|
||||
/// failure (the devnode exists but nothing binds), which [`DriverAttach`] diagnoses via the pump.
|
||||
/// Each tick also publishes kick-readiness ([`MOUSE_READY`]) and executes at most one pending
|
||||
/// compose kick ([`hid_kick`]) — the condvar wait keeps kick latency at "immediately", not "next
|
||||
/// 250 ms tick", while an idle keeper still only wakes 4×/s.
|
||||
fn keeper_thread() {
|
||||
loop {
|
||||
match VirtualMouse::open() {
|
||||
Ok(mut m) => {
|
||||
tracing::info!(
|
||||
"resident virtual HID mouse created (pf_mouse — keeps SM_MOUSEPRESENT true \
|
||||
so DWM composites the cursor on headless hosts)"
|
||||
);
|
||||
loop {
|
||||
m.service();
|
||||
MOUSE_READY.store(m.driver_proto() != 0, Ordering::Relaxed);
|
||||
let (mut slot, _timeout) = KICK
|
||||
.wake
|
||||
.wait_timeout_while(
|
||||
KICK.slot.lock().unwrap(),
|
||||
Duration::from_millis(250),
|
||||
|k| k.is_none(),
|
||||
)
|
||||
.unwrap();
|
||||
let aim = slot.take();
|
||||
drop(slot);
|
||||
if let Some(aim) = aim {
|
||||
if m.driver_proto() != 0 {
|
||||
perform_kick(&mut m, aim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"virtual HID mouse open failed — retrying in 60s (headless hosts stream an \
|
||||
invisible cursor until it exists)"
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `vmouse-spike` (dev validation): hold the virtual mouse and drive the REAL cursor through the
|
||||
/// HID report path — proves the full chain (SwDeviceCreate → INF bind → mshidumdf → mouhid →
|
||||
/// win32k) on-glass. Run with the host service STOPPED (the resident mouse owns the mailbox name
|
||||
/// otherwise). Verify while it holds: `Get-PnpDevice` shows the pf_mouse devnode + a HID child,
|
||||
/// `GetSystemMetrics(SM_MOUSEPRESENT)` = 1 with no physical mouse, and the cursor sweeps a
|
||||
/// horizontal line mid-screen.
|
||||
pub fn spike_hold(secs: u64) -> Result<()> {
|
||||
let mut m = VirtualMouse::open()?;
|
||||
println!("virtual HID mouse devnode up (5046:4D4F) — waiting for the driver to attach…");
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while m.driver_proto() == 0 && std::time::Instant::now() < deadline {
|
||||
m.service();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if m.driver_proto() == 0 {
|
||||
println!(
|
||||
"driver never attached (10s). Install it: punktfunk-host.exe driver install --gamepad \
|
||||
--dir <stage> (pf_mouse.inf ships with the gamepad drivers); see the WARN above."
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"driver attached (proto {}). Sweeping the cursor for {secs}s — watch the glass: the \
|
||||
pointer should glide left↔right across mid-screen; wheel ticks every second.",
|
||||
m.driver_proto()
|
||||
);
|
||||
}
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut i: u64 = 0;
|
||||
let beat_before = m.driver_heartbeat();
|
||||
while t0.elapsed() < Duration::from_secs(secs) {
|
||||
// Triangle-wave X sweep over the middle 3/4 of the axis, fixed mid-screen Y; one wheel
|
||||
// tick per second so scroll delivery is visible too.
|
||||
let phase = (i % 240) as i32; // 240 steps × 16 ms ≈ 4 s per round trip
|
||||
let tri = if phase < 120 { phase } else { 240 - phase };
|
||||
let x = 4096 + (tri as u32 * (24576 / 120)) as u16;
|
||||
let wheel: i8 = if i % 60 == 0 { 1 } else { 0 };
|
||||
m.send_report(0, x, 0x4000, wheel, 0);
|
||||
m.service();
|
||||
i += 1;
|
||||
std::thread::sleep(Duration::from_millis(16));
|
||||
}
|
||||
let beat = m.driver_heartbeat();
|
||||
println!(
|
||||
"vmouse-spike: done (driver heartbeat advanced {} ticks — {}). Devnode removed on exit.",
|
||||
beat.wrapping_sub(beat_before),
|
||||
if beat != beat_before {
|
||||
"driver alive"
|
||||
} else {
|
||||
"driver NOT ticking"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
|
||||
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games,
|
||||
//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
|
||||
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches
|
||||
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
|
||||
//! desktop switched) — no per-event reattach overhead.
|
||||
//!
|
||||
//! **Keyboard conventions** (see [`crate::KEY_FLAG_SEMANTIC_VK`]): first-party punktfunk
|
||||
//! clients send **US-positional** VKs (the physical key's US-layout VK — layout-independent by
|
||||
//! construction, the mirror of the Linux host's `vk_to_evdev`), resolved here through the fixed
|
||||
//! [`positional_vk_to_scan`] table. GameStream/Moonlight clients send **layout-semantic** VKs
|
||||
//! (Sunshine's model), resolved under the foreground app's layout. Never resolve a positional VK
|
||||
//! through a layout: this thread runs in the SYSTEM service, whose layout is unrelated to the
|
||||
//! user's, and any layout re-reads a *position* as a *character* — on a German host that is
|
||||
//! exactly the y↔z swap / ü-on-ö scramble.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::mem::size_of;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, OpenInputDesktop, SetThreadDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS,
|
||||
HDESK,
|
||||
};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
GetKeyboardLayout, MapVirtualKeyExW, SendInput, HKL, INPUT, INPUT_0, INPUT_KEYBOARD,
|
||||
INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE,
|
||||
MAPVK_VK_TO_VSC_EX, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN,
|
||||
MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
|
||||
MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL,
|
||||
MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN,
|
||||
SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
use super::InputInjector;
|
||||
|
||||
const ABS_MAX: f64 = 65535.0; // SendInput absolute coords are 0..65535 over the chosen surface.
|
||||
const GENERIC_ALL: u32 = 0x1000_0000;
|
||||
const XBUTTON1: u32 = 0x0001;
|
||||
const XBUTTON2: u32 = 0x0002;
|
||||
|
||||
pub struct SendInputInjector {
|
||||
desktop: Option<HDESK>,
|
||||
}
|
||||
|
||||
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
|
||||
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
|
||||
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
|
||||
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
|
||||
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
|
||||
// sound.
|
||||
unsafe impl Send for SendInputInjector {}
|
||||
|
||||
impl SendInputInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let mut me = Self { desktop: None };
|
||||
me.reattach_input_desktop(); // best-effort
|
||||
tracing::info!("SendInput injector ready (Win32 KeyboardAndMouse)");
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
|
||||
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
|
||||
fn reattach_input_desktop(&mut self) {
|
||||
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` are FFI calls passed only
|
||||
// by-value args (constant desktop flags, a `bool`, an access mask). `OpenInputDesktop`
|
||||
// yields an owned `HDESK` only on `Ok`; we then either install it with `SetThreadDesktop`
|
||||
// (closing the previously-owned handle exactly once) or close the fresh handle on failure —
|
||||
// so every handle is closed exactly once and none is used after close. `SetThreadDesktop`
|
||||
// only rebinds this calling thread, which is where the injector runs.
|
||||
unsafe {
|
||||
match OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
false,
|
||||
DESKTOP_ACCESS_FLAGS(GENERIC_ALL),
|
||||
) {
|
||||
Ok(h) => {
|
||||
if SetThreadDesktop(h).is_ok() {
|
||||
if let Some(old) = self.desktop.replace(h) {
|
||||
let _ = CloseDesktop(old);
|
||||
}
|
||||
} else {
|
||||
let _ = CloseDesktop(h);
|
||||
}
|
||||
}
|
||||
Err(_) => { /* not privileged enough for the secure desktop; stay put */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject with Sunshine's retry-on-failure model: the thread stays bound to whatever desktop it
|
||||
/// last attached to (no per-event `OpenInputDesktop`/`SetThreadDesktop` — two syscalls saved on
|
||||
/// every mouse move), and only when `SendInput` reports a short write (0 = the input desktop
|
||||
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
|
||||
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
|
||||
fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
|
||||
// SAFETY: `inputs` is a live `&[INPUT]` slice that outlives this synchronous `SendInput`
|
||||
// call; `size_of::<INPUT>()` is the exact per-element stride Win32 requires as `cbSize`. The
|
||||
// call only reads the array (one event per element) and returns the count injected.
|
||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if n as usize == inputs.len() {
|
||||
return Ok(());
|
||||
}
|
||||
// Short write → the input desktop likely changed. Reattach + retry once.
|
||||
self.reattach_input_desktop();
|
||||
// SAFETY: same as the first `SendInput` — `inputs` is the identical live slice outliving the
|
||||
// call and `cbSize == size_of::<INPUT>()`; only re-issued after reattaching the input desktop.
|
||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if n as usize != inputs.len() {
|
||||
anyhow::bail!(
|
||||
"SendInput injected {n}/{} events (blocked desktop?)",
|
||||
inputs.len()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SendInputInjector {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.desktop.take() {
|
||||
// SAFETY: `h` is the `HDESK` this injector owned (moved out of `self.desktop`);
|
||||
// `CloseDesktop` runs once here in `Drop` on that still-valid handle, with no later use —
|
||||
// no double close.
|
||||
unsafe {
|
||||
let _ = CloseDesktop(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for SendInputInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
// No per-event desktop reattach — `send` reattaches lazily only on a short write (desktop
|
||||
// switch). The injector is bound to the input desktop at open() and follows switches on demand.
|
||||
match event.kind {
|
||||
InputKind::MouseMove => {
|
||||
let mi = MOUSEINPUT {
|
||||
dx: event.x,
|
||||
dy: event.y,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w == 0 || h == 0 {
|
||||
return Ok(()); // contract: drop zero extent
|
||||
}
|
||||
let (_vx, _vy, vw, vh) = virtual_desktop_rect();
|
||||
// One virtual output spanning the virtual desktop: map client (0..w,0..h) -> 0..65535.
|
||||
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
|
||||
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
|
||||
let ax = (cx * ABS_MAX).round() as i32;
|
||||
let ay = (cy * ABS_MAX).round() as i32;
|
||||
let _ = (vw, vh); // virtual-desktop rect reserved for multi-output mapping
|
||||
let mi = MOUSEINPUT {
|
||||
dx: ax,
|
||||
dy: ay,
|
||||
mouseData: 0,
|
||||
dwFlags: MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
let down = event.kind == InputKind::MouseButtonDown;
|
||||
let (flag, data) = match event.code {
|
||||
1 => (
|
||||
if down {
|
||||
MOUSEEVENTF_LEFTDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_LEFTUP
|
||||
},
|
||||
0u32,
|
||||
),
|
||||
2 => (
|
||||
if down {
|
||||
MOUSEEVENTF_MIDDLEDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_MIDDLEUP
|
||||
},
|
||||
0,
|
||||
),
|
||||
3 => (
|
||||
if down {
|
||||
MOUSEEVENTF_RIGHTDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_RIGHTUP
|
||||
},
|
||||
0,
|
||||
),
|
||||
4 => (
|
||||
if down {
|
||||
MOUSEEVENTF_XDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_XUP
|
||||
},
|
||||
XBUTTON1,
|
||||
),
|
||||
5 => (
|
||||
if down {
|
||||
MOUSEEVENTF_XDOWN
|
||||
} else {
|
||||
MOUSEEVENTF_XUP
|
||||
},
|
||||
XBUTTON2,
|
||||
),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mi = MOUSEINPUT {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
mouseData: data,
|
||||
dwFlags: flag,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::MouseScroll => {
|
||||
// GameStream WHEEL_DELTA(120) units. Windows WHEEL positive=up (matches GameStream —
|
||||
// no flip, unlike Wayland); HWHEEL positive=right (matches). x is 120-scaled already.
|
||||
let horizontal = event.code == 1;
|
||||
let mi = MOUSEINPUT {
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
mouseData: event.x as u32, // signed wheel delta reinterpreted as DWORD
|
||||
dwFlags: if horizontal {
|
||||
MOUSEEVENTF_HWHEEL
|
||||
} else {
|
||||
MOUSEEVENTF_WHEEL
|
||||
},
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[mouse(mi)])
|
||||
}
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
let down = event.kind == InputKind::KeyDown;
|
||||
let vk = (event.code & 0xff) as u16;
|
||||
let semantic = (event.flags & crate::KEY_FLAG_SEMANTIC_VK) != 0;
|
||||
// Positional wire VKs (first-party clients) resolve through the fixed US table —
|
||||
// never through a layout (module docs). The table covers only the layout-VARIANT
|
||||
// typing area; everything else (F-row, nav, numpad, modifiers) is layout-invariant
|
||||
// and falls through to `MapVirtualKeyExW` (same result under any layout, and it
|
||||
// keeps the proven extended-bit handling). Semantic VKs (Moonlight) skip the table
|
||||
// and resolve under the FOREGROUND app's layout — the layout the receiving app
|
||||
// will decode our scancode with (Sunshine's model; the service thread's own layout
|
||||
// is not the user's).
|
||||
let table = if semantic {
|
||||
None
|
||||
} else {
|
||||
positional_vk_to_scan(vk)
|
||||
};
|
||||
let (scan, extended) = match table {
|
||||
Some(scan) => (scan, forced_extended(vk)), // typing area: never E0-extended
|
||||
None => {
|
||||
let hkl = if semantic { foreground_hkl() } else { None };
|
||||
// SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode);
|
||||
// all three args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type
|
||||
// constant, an optional `HKL` handle used only as a lookup key). It
|
||||
// dereferences no pointer and returns a `u32` — FFI-`unsafe` only.
|
||||
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, hkl) };
|
||||
if sc_ex == 0 {
|
||||
return Ok(()); // unmappable -> drop
|
||||
}
|
||||
(
|
||||
(sc_ex & 0xff) as u16,
|
||||
(sc_ex & 0xe000) == 0xe000 || forced_extended(vk),
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut flags = KEYEVENTF_SCANCODE;
|
||||
if extended {
|
||||
flags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
if !down {
|
||||
flags |= KEYEVENTF_KEYUP;
|
||||
}
|
||||
let ki = KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(0),
|
||||
wScan: scan,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
};
|
||||
self.send(&[key(ki)])
|
||||
}
|
||||
// Gamepad goes through the XUSB backend. Touch: no SendInput equivalent -> no-op.
|
||||
InputKind::GamepadButton
|
||||
| InputKind::GamepadAxis
|
||||
| InputKind::GamepadState
|
||||
| InputKind::GamepadRemove
|
||||
| InputKind::GamepadArrival
|
||||
| InputKind::TouchDown
|
||||
| InputKind::TouchMove
|
||||
| InputKind::TouchUp => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(mi: MOUSEINPUT) -> INPUT {
|
||||
INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 { mi },
|
||||
}
|
||||
}
|
||||
|
||||
fn key(ki: KEYBDINPUT) -> INPUT {
|
||||
INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 { ki },
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
||||
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||
unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_YVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CXVIRTUALSCREEN),
|
||||
GetSystemMetrics(SM_CYVIRTUALSCREEN),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
|
||||
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
|
||||
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
|
||||
// thin safety net.
|
||||
fn forced_extended(vk: u16) -> bool {
|
||||
matches!(
|
||||
vk,
|
||||
0x21..=0x28 | 0x2D | 0x2E | 0x5B | 0x5C | 0x5D | 0xA3 | 0xA5 | 0x90
|
||||
)
|
||||
}
|
||||
|
||||
/// US-positional VK → set-1 make scancode for the layout-**variant** typing area (letters, the
|
||||
/// digit row, OEM punctuation, the ISO 102nd key). The exact mirror of the Linux host's
|
||||
/// `crate::vk_to_evdev` — for these keys the evdev code IS the set-1 scancode — and of
|
||||
/// every first-party client's capture table, so the positional round trip is
|
||||
/// identity-by-construction. Layout-invariant keys are deliberately absent (the
|
||||
/// `MapVirtualKeyExW` fallback resolves them identically under any layout, with its proven
|
||||
/// extended-key handling). All listed keys are plain make codes — never E0-extended.
|
||||
fn positional_vk_to_scan(vk: u16) -> Option<u16> {
|
||||
Some(match vk {
|
||||
0x30 => 0x0B, // VK_0
|
||||
0x31..=0x39 => vk - 0x31 + 0x02, // VK_1..VK_9 → 0x02..0x0A
|
||||
0x41 => 0x1E, // A
|
||||
0x42 => 0x30, // B
|
||||
0x43 => 0x2E, // C
|
||||
0x44 => 0x20, // D
|
||||
0x45 => 0x12, // E
|
||||
0x46 => 0x21, // F
|
||||
0x47 => 0x22, // G
|
||||
0x48 => 0x23, // H
|
||||
0x49 => 0x17, // I
|
||||
0x4A => 0x24, // J
|
||||
0x4B => 0x25, // K
|
||||
0x4C => 0x26, // L
|
||||
0x4D => 0x32, // M
|
||||
0x4E => 0x31, // N
|
||||
0x4F => 0x18, // O
|
||||
0x50 => 0x19, // P
|
||||
0x51 => 0x10, // Q
|
||||
0x52 => 0x13, // R
|
||||
0x53 => 0x1F, // S
|
||||
0x54 => 0x14, // T
|
||||
0x55 => 0x16, // U
|
||||
0x56 => 0x2F, // V
|
||||
0x57 => 0x11, // W
|
||||
0x58 => 0x2D, // X
|
||||
0x59 => 0x15, // Y (US position — a QWERTZ host renders it as Z)
|
||||
0x5A => 0x2C, // Z (US position)
|
||||
0xBA => 0x27, // VK_OEM_1 ;: (DE: ö)
|
||||
0xBB => 0x0D, // VK_OEM_PLUS =+
|
||||
0xBC => 0x33, // VK_OEM_COMMA ,<
|
||||
0xBD => 0x0C, // VK_OEM_MINUS -_ (DE: ß)
|
||||
0xBE => 0x34, // VK_OEM_PERIOD .>
|
||||
0xBF => 0x35, // VK_OEM_2 /?
|
||||
0xC0 => 0x29, // VK_OEM_3 `~ (DE: ^)
|
||||
0xDB => 0x1A, // VK_OEM_4 [{ (DE: ü)
|
||||
0xDC => 0x2B, // VK_OEM_5 \|
|
||||
0xDD => 0x1B, // VK_OEM_6 ]}
|
||||
0xDE => 0x28, // VK_OEM_7 '" (DE: ä)
|
||||
0xE2 => 0x56, // VK_OEM_102 <>| (ISO key next to left shift)
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The keyboard layout of the thread owning the foreground window — the layout the app receiving
|
||||
/// our injected scancodes will decode them under (Sunshine's model for semantic Moonlight VKs).
|
||||
/// `None` when there is no foreground window (secure desktop, transient) — the caller then falls
|
||||
/// back to the current thread's layout, today's behavior.
|
||||
fn foreground_hkl() -> Option<HKL> {
|
||||
// SAFETY: three read-only queries. `GetForegroundWindow` takes nothing and returns a possibly
|
||||
// null `HWND` (checked). `GetWindowThreadProcessId` reads the window's owning thread id (the
|
||||
// process-id out-param is `None`, allowed). `GetKeyboardLayout` maps a thread id to its input
|
||||
// locale by value. No pointer we own is dereferenced; a stale/foreign `tid` yields a null HKL,
|
||||
// which is filtered.
|
||||
unsafe {
|
||||
let hwnd = GetForegroundWindow();
|
||||
if hwnd.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
let tid = GetWindowThreadProcessId(hwnd, None);
|
||||
let hkl = GetKeyboardLayout(tid);
|
||||
(!hkl.is_invalid()).then_some(hkl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The positional table must mirror the Linux host's `vk_to_evdev` exactly — for the typing
|
||||
/// area the evdev code IS the set-1 scancode, so any divergence would make the same wire VK
|
||||
/// land on different physical keys on the two hosts.
|
||||
#[test]
|
||||
fn positional_table_mirrors_linux_vk_to_evdev() {
|
||||
let mut checked = 0;
|
||||
for vk in 0x01..=0xFEu16 {
|
||||
if let Some(scan) = positional_vk_to_scan(vk) {
|
||||
assert_eq!(
|
||||
Some(scan),
|
||||
crate::vk_to_evdev(vk as u8),
|
||||
"vk 0x{vk:02X}: sendinput scancode diverges from vk_to_evdev"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 48, "typing-area coverage changed unexpectedly");
|
||||
}
|
||||
|
||||
/// The German-scramble regression pins: the US-position VKs the first-party clients send for
|
||||
/// the physical Y/Z/ö/ü keys must resolve to those physical positions, not through a layout.
|
||||
#[test]
|
||||
fn positional_pins_for_the_qwertz_scramble() {
|
||||
assert_eq!(positional_vk_to_scan(0x59), Some(0x15)); // VK_Y → US-Y position (QWERTZ: Z key)
|
||||
assert_eq!(positional_vk_to_scan(0x5A), Some(0x2C)); // VK_Z → US-Z position (QWERTZ: Y key)
|
||||
assert_eq!(positional_vk_to_scan(0xBA), Some(0x27)); // VK_OEM_1 → ;: position (QWERTZ: ö)
|
||||
assert_eq!(positional_vk_to_scan(0xDB), Some(0x1A)); // VK_OEM_4 → [{ position (QWERTZ: ü)
|
||||
// Layout-invariant keys stay out of the table (resolved via MapVirtualKeyExW).
|
||||
assert_eq!(positional_vk_to_scan(0x70), None); // VK_F1
|
||||
assert_eq!(positional_vk_to_scan(0x0D), None); // VK_RETURN
|
||||
assert_eq!(positional_vk_to_scan(0xA0), None); // VK_LSHIFT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! 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::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_tag: 0x5046_4453, // "PFDS"
|
||||
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: &punktfunk_core::input::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 {
|
||||
// The Deck poll returns `Some` exactly when a fresh output report landed (a seq bump), so
|
||||
// its presence is the game-activity signal, even when the rumble level is unchanged.
|
||||
let rumble = pad.service();
|
||||
PadFeedback {
|
||||
rumble,
|
||||
hidout: Vec::new(),
|
||||
game_drove: Some(rumble.is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
Reference in New Issue
Block a user