feat(host/steam): M5 — fallback remap, motion rescale, degrade ladder
Keep the rich Steam inputs from silently dropping when the resolved backend
isn't the virtual hid-steam device, and fix a cross-device motion-scale bug.
- inject/proto/steam_remap.rs (new, pure + unit-tested):
* motion_wire_to_deck — the wire carries DualSense-convention units (20 LSB/
deg.s gyro, 10000 LSB/g accel — what every client capture emits), but the
Deck's hid-steam report wants 16 LSB/deg.s + 16384 LSB/g. The Deck backend
now rescales (gyro x16/20, accel x16384/10000): a real Deck<->Deck gyro/
accel correctness fix (the DualSense/DS4 backends consume the wire 1:1).
* fold_paddles + RemapConfig (PUNKTFUNK_STEAM_REMAP=paddles=drop|stickclicks|
shoulders, default drop) — the DualSense + DS4 managers fold a client's back
grips onto standard buttons rather than dropping them (those pads have no
back-button HID slot; the uinput Xbox pad already exposes them as Elite
paddles BTN_TRIGGER_HAPPY5-8).
- resolve_gamepad: a runtime degrade ladder — a UHID backend (DualSense / DS4 /
Steam Deck) on a host where /dev/uhid isn't writable now falls back to the
uinput Xbox 360 pad instead of a dead controller (the device-create would
just fail). Separate from pick_gamepad's compile-time platform check, so the
existing pick_gamepad tests are untouched.
- Delete the throwaway M0/M1 spike (src/bin/steam_uhid_spike.rs) — M2's
#[ignore]d backend test subsumes its validation, and removing it frees
steam_proto to reference steam_remap cleanly.
On-box backend test still green; workspace clippy/fmt/test green (incl. the new
steam_remap tests). Deferred as optional RemapConfig growth: gyro->mouse /
trackpad->stick synthesis on an Xbox target (no slot — documented drop today).
Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,182 +0,0 @@
|
||||
//! M0/M1 on-box validator (THROWAWAY) — `design/steam-controller-deck-support.md`.
|
||||
//!
|
||||
//! Creates a virtual `28DE:1205` Steam Deck via `/dev/uhid`, enters `gamepad_mode` (pulses the
|
||||
//! `b9.6` mode-switch bit ~700 ms — `steam_do_deck_input_event` else early-returns under the
|
||||
//! default `lizard_mode`), then holds a KNOWN test pattern across every field so an evdev reader can
|
||||
//! confirm [`steam_proto::serialize_deck_state`] is byte-exact against the running kernel. Services
|
||||
//! the handshake (incl. `UHID_SET_REPORT`, which the DualSense backend omits) and logs any rumble
|
||||
//! feedback. Run: `cargo run -p punktfunk-host --bin steam_uhid_spike -- [seconds]`.
|
||||
//!
|
||||
//! Deleted once M2's `inject/linux/steam_controller.rs` subsumes it.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "../inject/proto/steam_proto.rs"]
|
||||
mod steam_proto;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> anyhow::Result<()> {
|
||||
use anyhow::Context;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
use steam_proto::{
|
||||
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT,
|
||||
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
|
||||
};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h): u32 `type` then a __packed union (largest = create2_req).
|
||||
const EVENT_SIZE: usize = 4 + 4372;
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_START: u32 = 2;
|
||||
const UHID_STOP: u32 = 3;
|
||||
const UHID_OPEN: u32 = 4;
|
||||
const UHID_CLOSE: u32 = 5;
|
||||
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 BUS_USB: u16 = 0x03;
|
||||
|
||||
// The held test pattern (post mode-switch). Chosen to exercise distinct fields with distinct,
|
||||
// recognizable values; expected evdev result is asserted by the companion reader.
|
||||
fn test_pattern() -> SteamState {
|
||||
let mut st = SteamState::neutral();
|
||||
st.buttons = btn::A | btn::X | btn::L4 | btn::R5 | btn::VIEW | btn::RB;
|
||||
st.lx = 8000;
|
||||
st.ly = 4000;
|
||||
st.rx = -3000;
|
||||
st.ry = 6000;
|
||||
st.lt = 20000;
|
||||
st.rt = 10000;
|
||||
st.press(btn::RPAD_TOUCH, true);
|
||||
st.rpad_x = 5000;
|
||||
st.rpad_y = -5000;
|
||||
st.accel = [1000, 2000, 3000];
|
||||
st.gyro = [100, 200, 300];
|
||||
st
|
||||
}
|
||||
|
||||
let seconds: u64 = std::env::args()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(25);
|
||||
|
||||
let mut fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open("/dev/uhid")
|
||||
.context("open /dev/uhid (are you in the 'input' group?)")?;
|
||||
|
||||
let 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]);
|
||||
};
|
||||
let mut ev = vec![0u8; EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
put_cstr(&mut ev, 4, 128, "Punktfunk Steam Deck (spike)"); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, "punktfunk/steam/0"); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, "punktfunk-steam-0"); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(STEAMDECK_RDESC.len() as u16).to_ne_bytes());
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes());
|
||||
ev[264..268].copy_from_slice(&STEAM_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes());
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes());
|
||||
ev[280..280 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
|
||||
fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
eprintln!(
|
||||
"UHID_CREATE2 -> 28DE:1205; pulsing mode-switch then holding test pattern ({seconds}s)"
|
||||
);
|
||||
|
||||
let (mut sets, mut gets, mut outputs) = (0u32, 0u32, 0u32);
|
||||
let mut seq: u32 = 0;
|
||||
let start = Instant::now();
|
||||
let mut last_hb = start;
|
||||
let mut rbuf = vec![0u8; EVENT_SIZE];
|
||||
|
||||
while start.elapsed() < Duration::from_secs(seconds) {
|
||||
while let Ok(n) = fd.read(&mut rbuf) {
|
||||
if n < 4 {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([rbuf[0], rbuf[1], rbuf[2], rbuf[3]]) {
|
||||
UHID_START | UHID_STOP | UHID_CLOSE => {}
|
||||
UHID_OPEN => eprintln!(" <- UHID_OPEN (consumer opened the evdev/hidraw)"),
|
||||
UHID_OUTPUT => {
|
||||
outputs += 1;
|
||||
let sz = u16::from_ne_bytes([rbuf[4100], rbuf[4101]]) as usize;
|
||||
if let Some(rb) = parse_steam_output(&rbuf[4..4 + sz.min(64)]).rumble {
|
||||
eprintln!(" <- rumble (OUTPUT): {rb:?}");
|
||||
}
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
gets += 1;
|
||||
let id = u32::from_ne_bytes([rbuf[4], rbuf[5], rbuf[6], rbuf[7]]);
|
||||
let reply = serial_reply("PUNKTFUNK01");
|
||||
let mut out = vec![0u8; EVENT_SIZE];
|
||||
out[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
out[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
out[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
|
||||
out[10..12].copy_from_slice(&(reply.len() as u16).to_ne_bytes());
|
||||
out[12..12 + reply.len()].copy_from_slice(&reply);
|
||||
fd.write_all(&out).context("GET_REPORT_REPLY")?;
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
sets += 1;
|
||||
let id = u32::from_ne_bytes([rbuf[4], rbuf[5], rbuf[6], rbuf[7]]);
|
||||
// data starts at ev[12]: [report-id 0, cmd, …] — surface rumble if present.
|
||||
if let Some(rb) =
|
||||
parse_steam_output(&rbuf[12..12 + 16.min(EVENT_SIZE - 12)]).rumble
|
||||
{
|
||||
eprintln!(" <- rumble (SET_REPORT): {rb:?}");
|
||||
}
|
||||
let mut out = vec![0u8; EVENT_SIZE];
|
||||
out[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
out[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
out[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
|
||||
fd.write_all(&out).context("SET_REPORT_REPLY")?;
|
||||
}
|
||||
other => eprintln!(" <- UHID event type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
if last_hb.elapsed() >= Duration::from_millis(8) {
|
||||
last_hb = Instant::now();
|
||||
seq = seq.wrapping_add(1);
|
||||
// First ~700 ms: hold the mode-switch bit (b9.6) to toggle gamepad_mode on. After that:
|
||||
// the held test pattern (which must NOT contain b9.6, or it would toggle back).
|
||||
let st = if start.elapsed() < Duration::from_millis(700) {
|
||||
let mut s = SteamState::neutral();
|
||||
s.press(btn::STEAM_MENU_RIGHT, true);
|
||||
s
|
||||
} else {
|
||||
test_pattern()
|
||||
};
|
||||
let mut r = [0u8; STEAM_REPORT_LEN];
|
||||
serialize_deck_state(&mut r, &st, seq);
|
||||
let mut out = vec![0u8; EVENT_SIZE];
|
||||
out[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
out[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes());
|
||||
out[6..6 + r.len()].copy_from_slice(&r);
|
||||
fd.write_all(&out).context("UHID_INPUT2")?;
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; EVENT_SIZE];
|
||||
out[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = fd.write_all(&out);
|
||||
eprintln!("UHID_DESTROY. handshake: GET_REPORT={gets} SET_REPORT={sets} OUTPUT={outputs}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("steam_uhid_spike: Linux-only (needs /dev/uhid + the hid-steam kernel driver)");
|
||||
}
|
||||
@@ -500,6 +500,10 @@ pub mod steam_controller;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/steam_proto.rs"]
|
||||
pub mod steam_proto;
|
||||
/// Pure fallback-remap policy (Steam-only inputs onto a non-Steam backend) + the Deck motion rescale.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/steam_remap.rs"]
|
||||
pub mod steam_remap;
|
||||
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod gamepad {
|
||||
|
||||
@@ -182,6 +182,9 @@ pub struct DualSenseManager {
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// 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::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualSenseManager {
|
||||
@@ -198,6 +201,7 @@ impl DualSenseManager {
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +233,12 @@ impl DualSenseManager {
|
||||
// Merge buttons/sticks/triggers from the frame, preserving touch + motion (those
|
||||
// come on the rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
// 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::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
|
||||
@@ -367,6 +367,9 @@ pub struct DualShock4Manager {
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
/// 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::inject::steam_remap::RemapConfig,
|
||||
}
|
||||
|
||||
impl Default for DualShock4Manager {
|
||||
@@ -384,6 +387,7 @@ impl DualShock4Manager {
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +420,12 @@ impl DualShock4Manager {
|
||||
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
|
||||
// rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
// 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::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
|
||||
@@ -245,8 +245,11 @@ impl SteamState {
|
||||
self.rpad_y = ((y as i32) - 32768) as i16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.gyro = gyro;
|
||||
self.accel = accel;
|
||||
// The wire carries DualSense-convention units (what every client capture emits); the
|
||||
// Deck's hid-steam report wants 16 LSB/°·s + 16384 LSB/g, so rescale here.
|
||||
let (g, a) = super::steam_remap::motion_wire_to_deck(gyro, accel);
|
||||
self.gyro = g;
|
||||
self.accel = a;
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
surface,
|
||||
@@ -444,13 +447,15 @@ mod tests {
|
||||
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
|
||||
assert_eq!(s.rpad_x, 32767); // 65535-32768
|
||||
assert_eq!(s.rpad_y, -32768); // 0-32768
|
||||
// Motion is rescaled from the wire (DualSense) convention into Deck units (gyro ×16/20,
|
||||
// accel ×16384/10000) — see steam_remap::motion_wire_to_deck.
|
||||
s.apply_rich(RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [1, 2, 3],
|
||||
accel: [4, 5, 6],
|
||||
gyro: [1000, -2000, 0],
|
||||
accel: [10000, -5000, 0],
|
||||
});
|
||||
assert_eq!(s.gyro, [1, 2, 3]);
|
||||
assert_eq!(s.accel, [4, 5, 6]);
|
||||
assert_eq!(s.gyro, [800, -1600, 0]);
|
||||
assert_eq!(s.accel, [16384, -8192, 0]);
|
||||
}
|
||||
|
||||
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Pure fallback-remap policy for the Steam Controller / Steam Deck rich inputs when the resolved
|
||||
//! host backend is **not** the virtual `hid-steam` device (DualSense / DualShock 4 / Xbox), so a
|
||||
//! client's Steam-only inputs aren't silently dropped — plus the cross-device motion rescale the
|
||||
//! Deck backend itself needs.
|
||||
//!
|
||||
//! Driven by the host's `PUNKTFUNK_STEAM_REMAP` env (`key=value`, `,`/`;`-separated, e.g.
|
||||
//! `paddles=stickclicks`). No I/O beyond [`RemapConfig::from_env`]; everything else is pure +
|
||||
//! unit-testable. The uinput Xbox pad already exposes the back grips as Elite paddles
|
||||
//! (`BTN_TRIGGER_HAPPY5-8`), so only the slot-less DualSense / DS4 backends fold them.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
/// Where the four Steam back grips go on a backend with no native back-button HID slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PaddleFallback {
|
||||
/// Drop them — the back buttons are simply absent on this pad. The honest default: don't fire
|
||||
/// buttons the user didn't ask for. Set the env to map them instead.
|
||||
#[default]
|
||||
Drop,
|
||||
/// L4/L5 → left-stick click, R4/R5 → right-stick click.
|
||||
StickClicks,
|
||||
/// L4/L5 → left bumper, R4/R5 → right bumper.
|
||||
Shoulders,
|
||||
}
|
||||
|
||||
/// Fallback-remap knobs parsed from `PUNKTFUNK_STEAM_REMAP`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RemapConfig {
|
||||
pub paddles: PaddleFallback,
|
||||
}
|
||||
|
||||
impl RemapConfig {
|
||||
/// Parse the host's `PUNKTFUNK_STEAM_REMAP` env (absent / unrecognized → defaults).
|
||||
pub fn from_env() -> RemapConfig {
|
||||
std::env::var("PUNKTFUNK_STEAM_REMAP")
|
||||
.map(|s| RemapConfig::parse(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Pure parse of the `key=value[,key=value…]` string (the testable core of [`from_env`]).
|
||||
pub fn parse(s: &str) -> RemapConfig {
|
||||
let mut cfg = RemapConfig::default();
|
||||
for kv in s.split([',', ';']) {
|
||||
let mut it = kv.splitn(2, '=');
|
||||
if let (Some(k), Some(v)) = (it.next(), it.next()) {
|
||||
if k.trim().eq_ignore_ascii_case("paddles") {
|
||||
cfg.paddles = match v.trim().to_ascii_lowercase().as_str() {
|
||||
"stickclicks" | "l3r3" | "sticks" => PaddleFallback::StickClicks,
|
||||
"shoulders" | "lbrb" | "bumpers" => PaddleFallback::Shoulders,
|
||||
_ => PaddleFallback::Drop,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the wire back-grip bits (`BTN_PADDLE1..4`) into standard buttons per `policy` for a pad with
|
||||
/// no native back-button slot, clearing the paddle bits. Pure. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
||||
pub fn fold_paddles(mut buttons: u32, policy: PaddleFallback) -> u32 {
|
||||
let left = buttons & (gs::BTN_PADDLE2 | gs::BTN_PADDLE4) != 0; // L4 | L5
|
||||
let right = buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE3) != 0; // R4 | R5
|
||||
buttons &= !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4);
|
||||
let (lbit, rbit) = match policy {
|
||||
PaddleFallback::Drop => return buttons,
|
||||
PaddleFallback::StickClicks => (gs::BTN_LS_CLICK, gs::BTN_RS_CLICK),
|
||||
PaddleFallback::Shoulders => (gs::BTN_LB, gs::BTN_RB),
|
||||
};
|
||||
if left {
|
||||
buttons |= lbit;
|
||||
}
|
||||
if right {
|
||||
buttons |= rbit;
|
||||
}
|
||||
buttons
|
||||
}
|
||||
|
||||
// Motion rescale. The wire uses the DualSense convention (20 LSB/°·s gyro, 10000 LSB/g accel — the
|
||||
// scale every client capture applies). The Steam Deck's `hid-steam` report wants 16 LSB/°·s and
|
||||
// 16384 LSB/g, so the Deck backend rescales; the DualSense / DS4 backends consume the wire 1:1.
|
||||
const GYRO_NUM: i32 = 16;
|
||||
const GYRO_DEN: i32 = 20;
|
||||
const ACCEL_NUM: i32 = 16384;
|
||||
const ACCEL_DEN: i32 = 10000;
|
||||
|
||||
fn scale(v: i16, num: i32, den: i32) -> i16 {
|
||||
((v as i32 * num) / den).clamp(i16::MIN as i32, i16::MAX as i32) as i16
|
||||
}
|
||||
|
||||
/// Rescale a wire (DualSense-convention) motion sample into the Steam Deck's `hid-steam` units.
|
||||
pub fn motion_wire_to_deck(gyro: [i16; 3], accel: [i16; 3]) -> ([i16; 3], [i16; 3]) {
|
||||
(
|
||||
gyro.map(|g| scale(g, GYRO_NUM, GYRO_DEN)),
|
||||
accel.map(|a| scale(a, ACCEL_NUM, ACCEL_DEN)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_paddle_policy() {
|
||||
assert_eq!(RemapConfig::parse("").paddles, PaddleFallback::Drop);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("paddles=stickclicks").paddles,
|
||||
PaddleFallback::StickClicks
|
||||
);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("foo=bar; paddles = Shoulders").paddles,
|
||||
PaddleFallback::Shoulders
|
||||
);
|
||||
assert_eq!(
|
||||
RemapConfig::parse("paddles=nonsense").paddles,
|
||||
PaddleFallback::Drop
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_paddles_maps_and_clears() {
|
||||
// All four grips set + a real A button.
|
||||
let b = gs::BTN_A | gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4;
|
||||
// Drop: paddle bits cleared, A preserved, nothing added.
|
||||
assert_eq!(fold_paddles(b, PaddleFallback::Drop), gs::BTN_A);
|
||||
// StickClicks: left grips → L3, right grips → R3.
|
||||
assert_eq!(
|
||||
fold_paddles(b, PaddleFallback::StickClicks),
|
||||
gs::BTN_A | gs::BTN_LS_CLICK | gs::BTN_RS_CLICK
|
||||
);
|
||||
// Only a left grip (L4 = PADDLE2) → only the left bumper under Shoulders.
|
||||
assert_eq!(
|
||||
fold_paddles(gs::BTN_PADDLE2, PaddleFallback::Shoulders),
|
||||
gs::BTN_LB
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_rescale_to_deck_units() {
|
||||
// gyro × 16/20 = 0.8; accel × 16384/10000 = 1.6384.
|
||||
let (g, a) = motion_wire_to_deck([1000, -2000, 0], [10000, -5000, 0]);
|
||||
assert_eq!(g, [800, -1600, 0]);
|
||||
assert_eq!(a, [16384, -8192, 0]);
|
||||
// Saturates rather than wraps.
|
||||
let (_, a) = motion_wire_to_deck([0; 3], [32767, i16::MIN, 0]);
|
||||
assert_eq!(a[0], i16::MAX);
|
||||
assert_eq!(a[1], i16::MIN);
|
||||
}
|
||||
}
|
||||
@@ -1917,6 +1917,36 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if
|
||||
/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a
|
||||
/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device,
|
||||
/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
let needs_uhid = matches!(
|
||||
chosen,
|
||||
GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::SteamDeck
|
||||
);
|
||||
if needs_uhid
|
||||
&& std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/uhid")
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"/dev/uhid not writable — falling back to the X-Box 360 pad"
|
||||
);
|
||||
return GamepadPref::Xbox360;
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Resolve the client's gamepad-backend preference (the env/logging shell around
|
||||
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
|
||||
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
@@ -1927,6 +1957,10 @@ fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
// Runtime degrade (separate from the compile-time platform check above): the Linux UHID
|
||||
// backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller
|
||||
// goes dead — fall back to the always-available uinput X-Box 360 pad instead.
|
||||
let chosen = degrade_if_no_uhid(chosen);
|
||||
match pref {
|
||||
GamepadPref::Auto => {
|
||||
// The operator's env knob deserves a diagnostic when it didn't drive the
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
# Rich Steam Controller & Steam Deck support
|
||||
|
||||
> **Status:** **M0–M4 GREEN — the full Steam Controller/Deck pipeline is built (2026-06-29).** Host:
|
||||
> the virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=
|
||||
> steamdeck`), and the protocol carries the rich inputs. Clients: the Linux + Windows SDL clients
|
||||
> capture + send them; the Decky plugin has the Steam Deck mode + Disable-Steam-Input UX; the C-ABI
|
||||
> has the `TouchpadEx` send path; Apple/Android round-trip the type. Remaining is **validation, not
|
||||
> construction** (see below) + the deferred extras (M5 fallback-remap polish, M6 SteamOS-host
|
||||
> conflict check, M7 Windows UMDF Steam driver).
|
||||
> **Status:** **M0–M5 GREEN — full pipeline + fallback polish built (2026-06-29).** Host: the
|
||||
> virtual `hid-steam` Deck binds, is byte-exact, is a wired backend (`PUNKTFUNK_GAMEPAD=steamdeck`),
|
||||
> the protocol carries the rich inputs, and the **fallback remap** keeps them from silently dropping
|
||||
> on a non-Steam backend. Clients: the Linux + Windows SDL clients capture + send them; the Decky
|
||||
> plugin has the Steam Deck mode + Disable-Steam-Input UX; the C-ABI has the `TouchpadEx` send path;
|
||||
> Apple/Android round-trip the type. Remaining is **validation, not construction** (see below) + the
|
||||
> deferred extras (M6 SteamOS-host conflict check, M7 Windows UMDF Steam driver).
|
||||
>
|
||||
> **M5 (fallback remap + degrade ladder) result:** new pure, unit-tested `inject/proto/steam_remap.rs`:
|
||||
> (1) **motion rescale** `motion_wire_to_deck` — the wire carries DualSense-convention units (what
|
||||
> every client capture emits), the Deck's `hid-steam` report wants 16 LSB/°·s + 16384 LSB/g, so the
|
||||
> Deck backend now rescales (gyro ×16/20, accel ×16384/10000) — a real **Deck↔Deck gyro/accel
|
||||
> correctness fix**; (2) **`fold_paddles`** + `RemapConfig` (`PUNKTFUNK_STEAM_REMAP=paddles=drop|
|
||||
> stickclicks|shoulders`, default drop) wired into the DualSense + DS4 managers so a client's back
|
||||
> grips aren't silently lost on a PlayStation fallback (those pads have no back-button HID slot; the
|
||||
> uinput Xbox pad already exposes them as `BTN_TRIGGER_HAPPY5-8`). Plus a **runtime degrade ladder**
|
||||
> in `resolve_gamepad`: a UHID backend (DualSense/DS4/SteamDeck) on a host where `/dev/uhid` isn't
|
||||
> writable now falls back to the uinput Xbox 360 pad instead of a dead controller. The throwaway M0/M1
|
||||
> spike is deleted (M2's `#[ignore]`d backend test subsumes it). On-box backend test still green;
|
||||
> workspace clippy/fmt/test green. *Deferred as optional `RemapConfig` growth: gyro→mouse / trackpad→
|
||||
> stick/mouse synthesis on an Xbox target (no IMU/touchpad slot — currently a documented drop).*
|
||||
>
|
||||
> **M4 (complete) result:** *(desktop capture — see the prior entry.)* Plus: **C-ABI** —
|
||||
> `PunktfunkRichInputEx` (size-prefixed superset) + `punktfunk_connection_send_rich_input2` (the
|
||||
|
||||
Reference in New Issue
Block a user