feat(proto/steam): M3 — rich Steam wire (back buttons + 2nd trackpad)

Carry the rich Steam Controller / Steam Deck inputs end-to-end on the wire —
strictly additive + forward-compatible (unknown kinds/bits drop on old peers).

Core (punktfunk-core):
- input.rs: BTN_PADDLE1..4 + BTN_MISC1 in Moonlight's buttonFlags2<<16 namespace
  (so the GameStream paddle path and native grips share one host injector map;
  Steam L4/L5/R4/R5 reuse the four Xbox-Elite paddle slots).
- quic.rs: RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, touch+click, signed
  coords, pressure; the second trackpad the single Touchpad can't express) and
  HidOutput::TrackpadHaptic (kind 0x04 — the SC voice-coil pulse). Round-tripped.
- abi.rs: PUNKTFUNK_GAMEPAD_STEAMDECK=6 / _STEAMCONTROLLER=5, the paddle bits,
  RICH_TOUCHPAD_EX / HIDOUT_TRACKPAD_HAPTIC constants. from_hid packs
  TrackpadHaptic into the existing which + effect[0..6] — the legacy structs do
  NOT grow (guarded by new size_of==20/19 asserts); GamepadPref lockstep +
  paddle-bit lockstep asserts extended. include/punktfunk_core.h regenerated.

Host (punktfunk-host):
- steam_proto::from_gamepad maps the wire paddles -> the four Deck grips + QAM;
  apply_rich routes TouchpadEx left/right -> the matching pad.
- every DualSense/DS4 manager (Linux + Windows) gained a TouchpadEx arm
  (surface 0/2 -> its one touchpad; surface 1 ignored) so the variant compiles
  everywhere and a Steam client streaming to a DS host keeps its right pad.
- the xpad BUTTON_MAP finally consumes the GameStream paddle bits
  (BTN_TRIGGER_HAPPY5-8) — Sunshine/Moonlight paddle clients were silently
  no-op'd before (design §5.6).
- Android feedback: drop TrackpadHaptic (no coils; rumble rides 0xCA).

Validated on-box: the ignored backend test now drives the full wire path —
from_gamepad (BTN_A + the L4 grip) + apply_rich (a left-pad TouchpadEx) reach the
evdev as BTN_A + ABS_HAT0X=-8000. Wire round-trips + paddle/TouchpadEx mapping
unit-tested. Workspace clippy/fmt/test green. Not pushed.

Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the
Apple/embedder *send* path needs it; the host decodes TouchpadEx today).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 11:59:09 +00:00
parent 95308d352b
commit 01c55aed38
15 changed files with 487 additions and 16 deletions
@@ -66,6 +66,13 @@ pub const BTN_A: u32 = 0x1000;
pub const BTN_B: u32 = 0x2000;
pub const BTN_X: u32 = 0x4000;
pub const BTN_Y: u32 = 0x8000;
// Extended buttons in the `buttonFlags2 << 16` namespace (mirror `punktfunk_core::input::gamepad`):
// the four back-grip paddles. `decode` already merges `buttonFlags2 << 16` into `buttons`, but the
// injector map dropped these bits — Sunshine/Moonlight paddle clients were silently no-op'd.
pub const BTN_PADDLE1: u32 = 0x0001_0000;
pub const BTN_PADDLE2: u32 = 0x0002_0000;
pub const BTN_PADDLE3: u32 = 0x0004_0000;
pub const BTN_PADDLE4: u32 = 0x0008_0000;
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
@@ -252,7 +252,9 @@ impl DualSenseManager {
/// arrived first); they're dropped if the pad isn't present.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
@@ -280,6 +282,26 @@ impl DualSenseManager {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
self.write(idx);
}
@@ -439,7 +439,9 @@ impl DualShock4Manager {
/// pad isn't present.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
@@ -466,6 +468,26 @@ impl DualShock4Manager {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
self.write(idx);
}
@@ -69,9 +69,16 @@ 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); 11] = [
const BUTTON_MAP: [(u32, u16); 15] = [
(gamepad::BTN_A, BTN_SOUTH),
(gamepad::BTN_B, BTN_EAST),
(gamepad::BTN_X, BTN_NORTH),
@@ -83,6 +90,10 @@ const BUTTON_MAP: [(u32, u16); 11] = [
(gamepad::BTN_GUIDE, BTN_MODE),
(gamepad::BTN_LS_CLK, BTN_THUMBL),
(gamepad::BTN_RS_CLK, 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
@@ -315,7 +315,9 @@ impl SteamControllerManager {
/// Apply a rich client→host event (right trackpad / motion) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
@@ -423,6 +425,19 @@ mod tests {
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 —
@@ -431,11 +446,24 @@ mod tests {
#[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 past MODE_ENTER (the b9.6 pulse) then hold BTN_A, servicing the handshake.
let mut st = SteamState::neutral();
st.buttons = btn::A;
// 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();
@@ -453,6 +481,12 @@ mod tests {
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();
@@ -221,6 +221,13 @@ impl SteamState {
// The DualSense touchpad-click wire bit maps to the Deck's RIGHT pad click (the pad that
// stands in for the DualSense touchpad — see apply_rich).
set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK);
// Back grips (the whole reason for the Deck identity): the wire paddle bits map to the four
// Deck grips — PADDLE1/2/3/4 = R4/L4/R5/L5 (see `input::gamepad`); MISC1 = the QAM '…' button.
set(&mut b, on(gs::BTN_PADDLE1), btn::R4);
set(&mut b, on(gs::BTN_PADDLE2), btn::L4);
set(&mut b, on(gs::BTN_PADDLE3), btn::R5);
set(&mut b, on(gs::BTN_PADDLE4), btn::L5);
set(&mut b, on(gs::BTN_MISC1), btn::QAM);
s.buttons = b;
s
}
@@ -241,6 +248,28 @@ impl SteamState {
self.gyro = gyro;
self.accel = accel;
}
RichInput::TouchpadEx {
surface,
touch,
click,
x,
y,
..
} => {
// Steam pads are natively signed (centre 0), so x/y map straight in. surface 1 =
// left pad, anything else (0 single / 2 right) = right pad.
if surface == 1 {
self.press(btn::LPAD_TOUCH, touch);
self.press(btn::LPAD_CLICK, click);
self.lpad_x = x;
self.lpad_y = y;
} else {
self.press(btn::RPAD_TOUCH, touch);
self.press(btn::RPAD_CLICK, click);
self.rpad_x = x;
self.rpad_y = y;
}
}
}
}
}
@@ -424,6 +453,53 @@ mod tests {
assert_eq!(s.accel, [4, 5, 6]);
}
/// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the
/// left / right surfaces to the matching pad (signed coords pass straight through).
#[test]
fn back_buttons_and_dual_trackpad_mapping() {
let s = SteamState::from_gamepad(
gs::BTN_PADDLE1 | gs::BTN_PADDLE2 | gs::BTN_PADDLE3 | gs::BTN_PADDLE4 | gs::BTN_MISC1,
0,
0,
0,
0,
0,
0,
);
assert_ne!(s.buttons & btn::R4, 0); // PADDLE1 = R4
assert_ne!(s.buttons & btn::L4, 0); // PADDLE2 = L4
assert_ne!(s.buttons & btn::R5, 0); // PADDLE3 = R5
assert_ne!(s.buttons & btn::L5, 0); // PADDLE4 = L5
assert_ne!(s.buttons & btn::QAM, 0); // MISC1 = QAM
let mut s = SteamState::neutral();
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: -5000,
y: 6000,
pressure: 100,
});
assert_ne!(s.buttons & btn::LPAD_TOUCH, 0);
assert_ne!(s.buttons & btn::LPAD_CLICK, 0);
assert_eq!((s.lpad_x, s.lpad_y), (-5000, 6000));
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 2,
finger: 0,
touch: true,
click: false,
x: 7000,
y: -8000,
pressure: 0,
});
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0);
assert_eq!((s.rpad_x, s.rpad_y), (7000, -8000));
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test]
@@ -385,7 +385,9 @@ impl DualSenseWindowsManager {
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
@@ -409,6 +411,26 @@ impl DualSenseWindowsManager {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DualSense touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DualSense equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
self.write(idx);
}
@@ -186,7 +186,9 @@ impl DualShock4WindowsManager {
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
pub fn apply_rich(&mut self, rich: RichInput) {
let idx = match rich {
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. } => pad as usize,
};
if idx >= MAX_PADS || self.pads[idx].is_none() {
return;
@@ -210,6 +212,26 @@ impl DualShock4WindowsManager {
self.state[idx].gyro = gyro;
self.state[idx].accel = accel;
}
RichInput::TouchpadEx {
surface,
finger,
touch,
x,
y,
..
} => {
// A Steam right/single pad maps onto the one DS4 touchpad (signed centre-0 →
// 0..=65535); surface 1 (the Steam left pad) has no DS4 equivalent.
if surface != 1 {
let slot = (finger as usize).min(1);
let n = |v: i16| ((v as i32) + 32768) as u32;
let t = &mut self.state[idx].touch[slot];
t.active = touch;
t.id = slot as u8;
t.x = (n(x) * (DS4_TOUCH_W - 1) as u32 / u16::MAX as u32) as u16;
t.y = (n(y) * (DS4_TOUCH_H - 1) as u32 / u16::MAX as u32) as u16;
}
}
}
self.write(idx);
}