Files
punktfunk/crates/punktfunk-host/src/gamestream/gamepad.rs
T
enricobuehler 01c55aed38 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>
2026-06-29 19:17:37 +00:00

211 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Decode GameStream controller packets (carried on the same encrypted control stream as
//! mouse/keyboard — see [`super::input`]) into [`GamepadFrame`]s for the uinput virtual pads.
//!
//! Layouts mirror moonlight-common-c `Input.h` (all `#pragma pack(1)`; the `size` header field
//! is big-endian, everything else little-endian). We implement the Gen5+ `MULTI_CONTROLLER`
//! event (magic `0x0C`) — the only controller event Sunshine-class hosts receive — plus the
//! Sunshine-extension `CONTROLLER_ARRIVAL` (`0x55000004`). Because our serverinfo advertises a
//! Sunshine appversion (4th component negative), clients also send `buttonFlags2` (paddles /
//! touchpad-click / Share) inside the MC packet.
/// Inner control-message type for input (same as [`super::input`]).
const INPUT_DATA_TYPE: u16 = 0x0206;
/// `NV_INPUT_HEADER.magic` for the Gen5+ multi-controller event.
const MAGIC_MULTI_CONTROLLER: u32 = 0x0C;
/// Sunshine extension: controller arrival metadata (type/capabilities).
const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004;
/// Most controllers a session tracks (Sunshine's MAX_GAMEPADS).
pub const MAX_PADS: usize = 16;
/// One decoded controller event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GamepadEvent {
/// Full state of one controller + the set of attached controllers.
State(GamepadFrame),
/// Sunshine arrival metadata (precedes the first State for that pad).
Arrival {
index: u8,
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
},
}
/// Snapshot of one controller's inputs (Moonlight conventions: sticks 32768..32767 with +Y
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GamepadFrame {
pub index: i16,
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
pub active_mask: u16,
pub buttons: u32,
pub left_trigger: u8,
pub right_trigger: u8,
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
// buttonFlags bits (Limelight.h).
pub const BTN_DPAD_UP: u32 = 0x0001;
pub const BTN_DPAD_DOWN: u32 = 0x0002;
pub const BTN_DPAD_LEFT: u32 = 0x0004;
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
pub const BTN_START: u32 = 0x0010;
pub const BTN_BACK: u32 = 0x0020;
pub const BTN_LS_CLK: u32 = 0x0040;
pub const BTN_RS_CLK: u32 = 0x0080;
pub const BTN_LB: u32 = 0x0100;
pub const BTN_RB: u32 = 0x0200;
pub const BTN_GUIDE: u32 = 0x0400;
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`]).
pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
if plaintext.len() < 4 || u16::from_le_bytes([plaintext[0], plaintext[1]]) != INPUT_DATA_TYPE {
return None;
}
let p = &plaintext[4..];
if p.len() < 8 {
return None;
}
let magic = u32::from_le_bytes([p[4], p[5], p[6], p[7]]);
let b = &p[8..]; // body after NV_INPUT_HEADER
let le16 = |o: usize| -> Option<i16> { Some(i16::from_le_bytes([*b.get(o)?, *b.get(o + 1)?])) };
match magic {
MAGIC_MULTI_CONTROLLER => {
// Body: headerB@0, controllerNumber@2, activeGamepadMask@4, midB@6, buttonFlags@8,
// LT@10, RT@11, lsX@12, lsY@14, rsX@16, rsY@18, tailA@20, buttonFlags2@22, tailB@24.
// The constants (headerB/midB/tail*) are never validated, mirroring Sunshine.
let buttons_lo = le16(8)? as u16 as u32;
// buttonFlags2 is absent on pre-extension clients (shorter packet) — treat as 0.
let buttons_hi = le16(22).map(|v| v as u16 as u32).unwrap_or(0);
Some(GamepadEvent::State(GamepadFrame {
index: le16(2)?,
active_mask: le16(4)? as u16,
buttons: buttons_lo | (buttons_hi << 16),
left_trigger: *b.get(10)?,
right_trigger: *b.get(11)?,
ls_x: le16(12)?,
ls_y: le16(14)?,
rs_x: le16(16)?,
rs_y: le16(18)?,
}))
}
MAGIC_CONTROLLER_ARRIVAL => Some(GamepadEvent::Arrival {
index: *b.first()?,
kind: *b.get(1)?,
capabilities: le16(2)? as u16,
}),
_ => None,
}
}
/// Build the host→client rumble plaintext (type `0x010B`): `[type][len=10][u32 filler]
/// [controllerNumber][lowFreqMotor][highFreqMotor]` (all LE; motors 0..0xFFFF). The caller
/// seals it with the host-direction GCM scheme and sends it on the ENet control peer.
pub fn rumble_plaintext(index: u16, low: u16, high: u16) -> Vec<u8> {
let mut pt = Vec::with_capacity(14);
pt.extend_from_slice(&0x010Bu16.to_le_bytes());
pt.extend_from_slice(&10u16.to_le_bytes());
pt.extend_from_slice(&0x00C0_FFEEu32.to_le_bytes()); // filler — present but ignored
pt.extend_from_slice(&index.to_le_bytes());
pt.extend_from_slice(&low.to_le_bytes());
pt.extend_from_slice(&high.to_le_bytes());
pt
}
#[cfg(test)]
mod tests {
use super::*;
fn wrap(magic: u32, body: &[u8]) -> Vec<u8> {
let mut inp = Vec::new();
inp.extend_from_slice(&((4 + body.len()) as u32).to_be_bytes());
inp.extend_from_slice(&magic.to_le_bytes());
inp.extend_from_slice(body);
let mut pt = Vec::new();
pt.extend_from_slice(&INPUT_DATA_TYPE.to_le_bytes());
pt.extend_from_slice(&(inp.len() as u16).to_le_bytes());
pt.extend_from_slice(&inp);
pt
}
#[test]
fn decodes_multi_controller() {
// Pad 1 attached (mask 0b10), A+RB held, LT=10 RT=200, LS=(1000,-2000), RS=(-1,32767),
// paddle1 via buttonFlags2.
let mut body = Vec::new();
body.extend_from_slice(&0x001Ai16.to_le_bytes()); // headerB
body.extend_from_slice(&1i16.to_le_bytes()); // controllerNumber
body.extend_from_slice(&0b10i16.to_le_bytes()); // activeGamepadMask
body.extend_from_slice(&0x0014i16.to_le_bytes()); // midB
body.extend_from_slice(&((BTN_A | BTN_RB) as u16).to_le_bytes());
body.push(10); // LT
body.push(200); // RT
body.extend_from_slice(&1000i16.to_le_bytes());
body.extend_from_slice(&(-2000i16).to_le_bytes());
body.extend_from_slice(&(-1i16).to_le_bytes());
body.extend_from_slice(&32767i16.to_le_bytes());
body.extend_from_slice(&0x009Ci16.to_le_bytes()); // tailA
body.extend_from_slice(&0x0001u16.to_le_bytes()); // buttonFlags2 (paddle1)
body.extend_from_slice(&0x0055i16.to_le_bytes()); // tailB
let Some(GamepadEvent::State(f)) = decode(&wrap(MAGIC_MULTI_CONTROLLER, &body)) else {
panic!("expected State");
};
assert_eq!(f.index, 1);
assert_eq!(f.active_mask, 0b10);
assert_eq!(f.buttons, BTN_A | BTN_RB | 0x0001_0000);
assert_eq!((f.left_trigger, f.right_trigger), (10, 200));
assert_eq!((f.ls_x, f.ls_y, f.rs_x, f.rs_y), (1000, -2000, -1, 32767));
}
#[test]
fn decodes_arrival() {
let body = [0u8, 1, 0x02, 0x00, 0xFF, 0xFF, 0x0F, 0x00]; // pad 0, xbox, rumble cap
let Some(GamepadEvent::Arrival {
index,
kind,
capabilities,
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
else {
panic!("expected Arrival");
};
assert_eq!((index, kind, capabilities), (0, 1, 0x0002));
}
#[test]
fn ignores_mouse_and_short_packets() {
assert!(decode(&wrap(0x07, &[0, 1, 0, 2])).is_none()); // relative mouse
assert!(decode(&[0u8; 3]).is_none());
}
#[test]
fn rumble_layout() {
let pt = rumble_plaintext(2, 0x1234, 0xBEEF);
assert_eq!(pt.len(), 14);
assert_eq!(u16::from_le_bytes([pt[0], pt[1]]), 0x010B);
assert_eq!(u16::from_le_bytes([pt[2], pt[3]]), 10);
assert_eq!(u16::from_le_bytes([pt[8], pt[9]]), 2);
assert_eq!(u16::from_le_bytes([pt[10], pt[11]]), 0x1234);
assert_eq!(u16::from_le_bytes([pt[12], pt[13]]), 0xBEEF);
}
}