01c55aed38
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>
211 lines
8.6 KiB
Rust
211 lines
8.6 KiB
Rust
//! 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);
|
||
}
|
||
}
|