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:
2026-06-29 12:57:23 +00:00
parent d8c254281e
commit 486a292845
8 changed files with 237 additions and 197 deletions
@@ -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);
}
}