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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user