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:
@@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// `PunktfunkHidOutput::kind` — a trackpad haptic pulse (Steam Controller voice-coils). `which` =
|
||||
/// side (0 = right pad, 1 = left pad); `effect[0..6]` packs `amplitude` / `period` / `count` as
|
||||
/// little-endian `u16`s with `effect_len = 6`. Clients without trackpad coils drop it.
|
||||
pub const PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC: u8 = 4;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
@@ -559,6 +563,23 @@ impl PunktfunkHidOutput {
|
||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||
out.effect_len = n as u8;
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
side,
|
||||
amplitude,
|
||||
period,
|
||||
count,
|
||||
} => {
|
||||
// No new struct (PunktfunkHidOutput has no size guard): pack into the existing
|
||||
// `which` (side) + `effect[0..6]` (amplitude/period/count LE), `effect_len = 6`.
|
||||
out.kind = PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC;
|
||||
out.pad = *pad;
|
||||
out.which = *side;
|
||||
out.effect[0..2].copy_from_slice(&litude.to_le_bytes());
|
||||
out.effect[2..4].copy_from_slice(&period.to_le_bytes());
|
||||
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
|
||||
out.effect_len = 6;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -618,6 +639,11 @@ impl PunktfunkHdrMeta {
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
||||
/// `RichInput::TouchpadEx` kind on the wire — an extended trackpad contact that identifies the
|
||||
/// surface (0 single / 1 Steam-left / 2 Steam-right) and carries click + pressure. The host decodes
|
||||
/// it today; *sending* it from a C client needs the size-prefixed `PunktfunkRichInputEx` +
|
||||
/// `punktfunk_connection_send_rich_input2` (added with client capture).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD_EX: u8 = 3;
|
||||
|
||||
/// One rich client→host input for the host's virtual DualSense
|
||||
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
||||
@@ -714,6 +740,22 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
/// hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
|
||||
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
|
||||
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
|
||||
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
|
||||
/// Steam runs on the host. Honored only where available (Linux hosts); else folds to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_STEAMDECK: u32 = 6;
|
||||
|
||||
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
|
||||
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's
|
||||
/// `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`.
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
pub const PUNKTFUNK_GAMEPAD_BTN_MISC1: u32 = 0x0020_0000;
|
||||
|
||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
@@ -742,11 +784,28 @@ const _: () = {
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
const _: () = {
|
||||
use crate::config::GamepadPref;
|
||||
use crate::input::gamepad as g;
|
||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER == GamepadPref::SteamController.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
|
||||
// Extended button bits mirror the wire `input::gamepad` constants.
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE3 == g::BTN_PADDLE3);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE4 == g::BTN_PADDLE4);
|
||||
assert!(PUNKTFUNK_GAMEPAD_BTN_MISC1 == g::BTN_MISC1);
|
||||
};
|
||||
|
||||
// The additive M3 kinds (TouchpadEx / TrackpadHaptic) must never grow the legacy ABI structs —
|
||||
// they have no `struct_size` guard, so a layout change would corrupt old-built callers' buffers.
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<PunktfunkRichInput>() == 20);
|
||||
assert!(core::mem::size_of::<PunktfunkHidOutput>() == 19);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
|
||||
@@ -399,4 +399,27 @@ mod tests {
|
||||
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_pref_steam_roundtrip() {
|
||||
use GamepadPref::*;
|
||||
// Wire-byte round-trip for the Steam additions; an unknown byte still degrades to Auto.
|
||||
for (p, b) in [(SteamController, 5u8), (SteamDeck, 6)] {
|
||||
assert_eq!(p.to_u8(), b);
|
||||
assert_eq!(GamepadPref::from_u8(b), p);
|
||||
}
|
||||
assert_eq!(GamepadPref::from_u8(99), Auto);
|
||||
// Name parsing + canonical-name round-trip.
|
||||
assert_eq!(GamepadPref::from_name("steamdeck"), Some(SteamDeck));
|
||||
assert_eq!(GamepadPref::from_name("deck"), Some(SteamDeck));
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("steamcontroller"),
|
||||
Some(SteamController)
|
||||
);
|
||||
assert_eq!(SteamDeck.as_str(), "steamdeck");
|
||||
assert_eq!(
|
||||
GamepadPref::from_name(SteamController.as_str()),
|
||||
Some(SteamController)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,24 @@ pub mod gamepad {
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
// Extended buttons in Moonlight's `buttonFlags2 << 16` namespace (see `gamestream/gamepad.rs`),
|
||||
// so the GameStream paddle path and the native path share one host injector map. The four Steam
|
||||
// Deck back grips (L4/L5/R4/R5) reuse the four GameStream/Xbox-Elite paddle slots — a semantic
|
||||
// 1:1 for binding (the device identity carries the glyph distinction).
|
||||
/// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||
pub const BTN_PADDLE1: u32 = 0x0001_0000;
|
||||
/// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||
pub const BTN_PADDLE2: u32 = 0x0002_0000;
|
||||
/// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||
pub const BTN_PADDLE3: u32 = 0x0004_0000;
|
||||
/// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||
pub const BTN_PADDLE4: u32 = 0x0008_0000;
|
||||
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
||||
/// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||
pub const BTN_TOUCHPAD: u32 = 0x10_0000;
|
||||
/// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||
pub const BTN_MISC1: u32 = 0x0020_0000;
|
||||
|
||||
/// Axis ids for `InputKind::GamepadAxis`.
|
||||
pub const AXIS_LS_X: u32 = 0;
|
||||
|
||||
@@ -1218,6 +1218,7 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
|
||||
const RICH_TOUCHPAD: u8 = 0x01;
|
||||
const RICH_MOTION: u8 = 0x02;
|
||||
const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||
|
||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
||||
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||
@@ -1241,6 +1242,22 @@ pub enum RichInput {
|
||||
gyro: [i16; 3],
|
||||
accel: [i16; 3],
|
||||
},
|
||||
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
||||
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
||||
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
||||
/// Coordinates are **signed** (centred at 0), matching the real Steam report; `pressure` is `0`
|
||||
/// for a surface with no force sensor. New clients send this for every touch surface; the host
|
||||
/// decodes both `Touchpad` (`0x01`) and `TouchpadEx` (`0x03`) indefinitely.
|
||||
TouchpadEx {
|
||||
pad: u8,
|
||||
surface: u8,
|
||||
finger: u8,
|
||||
touch: bool,
|
||||
click: bool,
|
||||
x: i16,
|
||||
y: i16,
|
||||
pressure: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl RichInput {
|
||||
@@ -1264,6 +1281,22 @@ impl RichInput {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
RichInput::TouchpadEx {
|
||||
pad,
|
||||
surface,
|
||||
finger,
|
||||
touch,
|
||||
click,
|
||||
x,
|
||||
y,
|
||||
pressure,
|
||||
} => {
|
||||
let state = (touch as u8) | ((click as u8) << 1);
|
||||
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
|
||||
out.extend_from_slice(&x.to_le_bytes());
|
||||
out.extend_from_slice(&y.to_le_bytes());
|
||||
out.extend_from_slice(&pressure.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1288,6 +1321,16 @@ impl RichInput {
|
||||
accel: [i16at(9), i16at(11), i16at(13)],
|
||||
})
|
||||
}
|
||||
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
|
||||
pad: b[2],
|
||||
surface: b[3],
|
||||
finger: b[4],
|
||||
touch: b[5] & 0x01 != 0,
|
||||
click: b[5] & 0x02 != 0,
|
||||
x: i16::from_le_bytes([b[6], b[7]]),
|
||||
y: i16::from_le_bytes([b[8], b[9]]),
|
||||
pressure: u16::from_le_bytes([b[10], b[11]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1296,6 +1339,7 @@ impl RichInput {
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
|
||||
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
|
||||
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||
@@ -1309,6 +1353,16 @@ pub enum HidOutput {
|
||||
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
|
||||
/// trigger parameter block (mode + params) for the client to replay on a real controller.
|
||||
Trigger { pad: u8, which: u8, effect: Vec<u8> },
|
||||
/// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble").
|
||||
/// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses)
|
||||
/// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble).
|
||||
TrackpadHaptic {
|
||||
pad: u8,
|
||||
side: u8,
|
||||
amplitude: u16,
|
||||
period: u16,
|
||||
count: u16,
|
||||
},
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -1325,6 +1379,18 @@ impl HidOutput {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
}
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad,
|
||||
side,
|
||||
amplitude,
|
||||
period,
|
||||
count,
|
||||
} => {
|
||||
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
|
||||
out.extend_from_slice(&litude.to_le_bytes());
|
||||
out.extend_from_slice(&period.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1349,6 +1415,13 @@ impl HidOutput {
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
}),
|
||||
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||
pad: b[2],
|
||||
side: b[3],
|
||||
amplitude: u16::from_le_bytes([b[4], b[5]]),
|
||||
period: u16::from_le_bytes([b[6], b[7]]),
|
||||
count: u16::from_le_bytes([b[8], b[9]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2486,6 +2559,16 @@ mod tests {
|
||||
gyro: [-100, 200, -300],
|
||||
accel: [16384, -8192, 1],
|
||||
},
|
||||
RichInput::TouchpadEx {
|
||||
pad: 2,
|
||||
surface: 1,
|
||||
finger: 1,
|
||||
touch: true,
|
||||
click: false,
|
||||
x: -12345,
|
||||
y: 30000,
|
||||
pressure: 4000,
|
||||
},
|
||||
] {
|
||||
let d = ev.encode();
|
||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||
@@ -2494,7 +2577,8 @@ mod tests {
|
||||
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none());
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
|
||||
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
|
||||
// short
|
||||
}
|
||||
|
||||
@@ -2516,6 +2600,13 @@ mod tests {
|
||||
which: 1,
|
||||
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||
},
|
||||
HidOutput::TrackpadHaptic {
|
||||
pad: 0,
|
||||
side: 1,
|
||||
amplitude: 0x1234,
|
||||
period: 0x5678,
|
||||
count: 9,
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
|
||||
Reference in New Issue
Block a user