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:
@@ -114,6 +114,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|||||||
out[2..n].copy_from_slice(&effect);
|
out[2..n].copy_from_slice(&effect);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
|
HidOutput::TrackpadHaptic { .. } => {
|
||||||
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
||||||
|
// rumble already rides the universal 0xCA plane).
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
n as jint
|
n as jint
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -492,6 +492,10 @@ pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
|||||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
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).
|
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||||
|
|
||||||
@@ -559,6 +563,23 @@ impl PunktfunkHidOutput {
|
|||||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||||
out.effect_len = n as u8;
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -618,6 +639,11 @@ impl PunktfunkHdrMeta {
|
|||||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
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
|
/// 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`
|
/// ([`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
|
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||||
/// hosts); otherwise the host falls back to X-Box 360.
|
/// hosts); otherwise the host falls back to X-Box 360.
|
||||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
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`.
|
/// 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
|
/// 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).
|
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||||
const _: () = {
|
const _: () = {
|
||||||
use crate::config::GamepadPref;
|
use crate::config::GamepadPref;
|
||||||
|
use crate::input::gamepad as g;
|
||||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.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_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.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_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
|
/// 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
|
c.fec.fec_percent = 15; // 250 + ceil(250*15/100)=288 > 255
|
||||||
assert!(c.validate().is_err());
|
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_B: u32 = 0x2000;
|
||||||
pub const BTN_X: u32 = 0x4000;
|
pub const BTN_X: u32 = 0x4000;
|
||||||
pub const BTN_Y: u32 = 0x8000;
|
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`
|
/// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||||
/// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
/// 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.
|
/// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||||
pub const BTN_TOUCHPAD: u32 = 0x10_0000;
|
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`.
|
/// Axis ids for `InputKind::GamepadAxis`.
|
||||||
pub const AXIS_LS_X: u32 = 0;
|
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_TOUCHPAD: u8 = 0x01;
|
||||||
const RICH_MOTION: u8 = 0x02;
|
const RICH_MOTION: u8 = 0x02;
|
||||||
|
const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||||
|
|
||||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
/// 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
|
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||||
@@ -1241,6 +1242,22 @@ pub enum RichInput {
|
|||||||
gyro: [i16; 3],
|
gyro: [i16; 3],
|
||||||
accel: [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 {
|
impl RichInput {
|
||||||
@@ -1264,6 +1281,22 @@ impl RichInput {
|
|||||||
out.extend_from_slice(&v.to_le_bytes());
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -1288,6 +1321,16 @@ impl RichInput {
|
|||||||
accel: [i16at(9), i16at(11), i16at(13)],
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1296,6 +1339,7 @@ impl RichInput {
|
|||||||
const HIDOUT_LED: u8 = 0x01;
|
const HIDOUT_LED: u8 = 0x01;
|
||||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
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).
|
/// 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;
|
/// 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
|
/// 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 parameter block (mode + params) for the client to replay on a real controller.
|
||||||
Trigger { pad: u8, which: u8, effect: Vec<u8> },
|
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 {
|
impl HidOutput {
|
||||||
@@ -1325,6 +1379,18 @@ impl HidOutput {
|
|||||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||||
out.extend_from_slice(effect);
|
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
|
out
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1415,13 @@ impl HidOutput {
|
|||||||
which: b[3],
|
which: b[3],
|
||||||
effect: b[4..].to_vec(),
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2486,6 +2559,16 @@ mod tests {
|
|||||||
gyro: [-100, 200, -300],
|
gyro: [-100, 200, -300],
|
||||||
accel: [16384, -8192, 1],
|
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();
|
let d = ev.encode();
|
||||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||||
@@ -2494,7 +2577,8 @@ mod tests {
|
|||||||
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||||
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_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, 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
|
// short
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2516,6 +2600,13 @@ mod tests {
|
|||||||
which: 1,
|
which: 1,
|
||||||
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||||
},
|
},
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad: 0,
|
||||||
|
side: 1,
|
||||||
|
amplitude: 0x1234,
|
||||||
|
period: 0x5678,
|
||||||
|
count: 9,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
for ev in &cases {
|
for ev in &cases {
|
||||||
let d = ev.encode();
|
let d = ev.encode();
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ pub const BTN_A: u32 = 0x1000;
|
|||||||
pub const BTN_B: u32 = 0x2000;
|
pub const BTN_B: u32 = 0x2000;
|
||||||
pub const BTN_X: u32 = 0x4000;
|
pub const BTN_X: u32 = 0x4000;
|
||||||
pub const BTN_Y: u32 = 0x8000;
|
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,
|
/// 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`]).
|
/// 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.
|
/// arrived first); they're dropped if the pad isn't present.
|
||||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||||
let idx = match rich {
|
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() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -280,6 +282,26 @@ impl DualSenseManager {
|
|||||||
self.state[idx].gyro = gyro;
|
self.state[idx].gyro = gyro;
|
||||||
self.state[idx].accel = accel;
|
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);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,7 +439,9 @@ impl DualShock4Manager {
|
|||||||
/// pad isn't present.
|
/// pad isn't present.
|
||||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||||
let idx = match rich {
|
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() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -466,6 +468,26 @@ impl DualShock4Manager {
|
|||||||
self.state[idx].gyro = gyro;
|
self.state[idx].gyro = gyro;
|
||||||
self.state[idx].accel = accel;
|
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);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,9 +69,16 @@ const BTN_START: u16 = 0x13b;
|
|||||||
const BTN_MODE: u16 = 0x13c;
|
const BTN_MODE: u16 = 0x13c;
|
||||||
const BTN_THUMBL: u16 = 0x13d;
|
const BTN_THUMBL: u16 = 0x13d;
|
||||||
const BTN_THUMBR: u16 = 0x13e;
|
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.
|
/// `(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_A, BTN_SOUTH),
|
||||||
(gamepad::BTN_B, BTN_EAST),
|
(gamepad::BTN_B, BTN_EAST),
|
||||||
(gamepad::BTN_X, BTN_NORTH),
|
(gamepad::BTN_X, BTN_NORTH),
|
||||||
@@ -83,6 +90,10 @@ const BUTTON_MAP: [(u32, u16); 11] = [
|
|||||||
(gamepad::BTN_GUIDE, BTN_MODE),
|
(gamepad::BTN_GUIDE, BTN_MODE),
|
||||||
(gamepad::BTN_LS_CLK, BTN_THUMBL),
|
(gamepad::BTN_LS_CLK, BTN_THUMBL),
|
||||||
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
(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
|
/// 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.
|
/// Apply a rich client→host event (right trackpad / motion) to an existing pad.
|
||||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||||
let idx = match rich {
|
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() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -423,6 +425,19 @@ mod tests {
|
|||||||
rc >= 0 && (bits[(code / 8) as usize] >> (code % 8)) & 1 == 1
|
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
|
/// 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
|
/// 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 —
|
/// on the evdev (`BTN_A`, code 0x130) — proving the entry overlay + byte-exact serialize path —
|
||||||
@@ -431,11 +446,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
|
||||||
fn backend_binds_and_input_flows() {
|
fn backend_binds_and_input_flows() {
|
||||||
|
use punktfunk_core::input::gamepad as gs;
|
||||||
const BTN_A: u16 = 0x130;
|
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?)");
|
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.
|
// Drive the full M3 wire path: build state through `from_gamepad` (BTN_A + the L4 back grip)
|
||||||
let mut st = SteamState::neutral();
|
// and `apply_rich` (a left-pad TouchpadEx contact), then hold it past MODE_ENTER (the b9.6
|
||||||
st.buttons = btn::A;
|
// 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();
|
let start = Instant::now();
|
||||||
while start.elapsed() < Duration::from_millis(1200) {
|
while start.elapsed() < Duration::from_millis(1200) {
|
||||||
let _ = pad.service();
|
let _ = pad.service();
|
||||||
@@ -453,6 +481,12 @@ mod tests {
|
|||||||
key_is_down(&node, BTN_A),
|
key_is_down(&node, BTN_A),
|
||||||
"BTN_A not down — gamepad_mode entry or serialize failed"
|
"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);
|
drop(pad);
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
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
|
// 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).
|
// stands in for the DualSense touchpad — see apply_rich).
|
||||||
set(&mut b, on(gs::BTN_TOUCHPAD), btn::RPAD_CLICK);
|
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.buttons = b;
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
@@ -241,6 +248,28 @@ impl SteamState {
|
|||||||
self.gyro = gyro;
|
self.gyro = gyro;
|
||||||
self.accel = accel;
|
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]);
|
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*
|
/// 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…]`.
|
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -385,7 +385,9 @@ impl DualSenseWindowsManager {
|
|||||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
||||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||||
let idx = match rich {
|
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() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -409,6 +411,26 @@ impl DualSenseWindowsManager {
|
|||||||
self.state[idx].gyro = gyro;
|
self.state[idx].gyro = gyro;
|
||||||
self.state[idx].accel = accel;
|
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);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ impl DualShock4WindowsManager {
|
|||||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad.
|
||||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||||
let idx = match rich {
|
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() {
|
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -210,6 +212,26 @@ impl DualShock4WindowsManager {
|
|||||||
self.state[idx].gyro = gyro;
|
self.state[idx].gyro = gyro;
|
||||||
self.state[idx].accel = accel;
|
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);
|
self.write(idx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
# Rich Steam Controller & Steam Deck support
|
# Rich Steam Controller & Steam Deck support
|
||||||
|
|
||||||
> **Status:** **M0–M2 GREEN — Linux virtual Deck binds, is byte-exact, AND is a wired host backend,
|
> **Status:** **M0–M3 GREEN — virtual Deck binds + byte-exact + wired backend + the rich wire,
|
||||||
> on-box (2026-06-29).** The greenfield virtual `hid-steam` device works end-to-end as a selectable
|
> on-box (2026-06-29).** The greenfield virtual `hid-steam` device works end-to-end as a selectable
|
||||||
> host gamepad backend: `PUNKTFUNK_GAMEPAD=steamdeck` builds a per-session `SteamControllerManager`
|
> host gamepad backend (`PUNKTFUNK_GAMEPAD=steamdeck`), and the protocol now carries the rich Steam
|
||||||
> that creates a `/dev/uhid` `28DE:1205` device, enters `gamepad_mode`, and feeds the byte-exact Deck
|
> inputs (back buttons + second trackpad). Next: M4 (client capture — SDL Steam hints, paddles, 2nd
|
||||||
> report. Next: M3 (the protocol/ABI wire surface — back-button bits, `TouchpadEx`, the C-ABI
|
> touchpad, the Decky Disable-Steam-Input UX, + the C-ABI `PunktfunkRichInputEx`/`send_rich_input2`
|
||||||
> `GamepadPref` constants) + M4 (client capture). This remains the design + milestone plan; the Steam
|
> for the Apple/embedder send path). The Steam analogue of the shipped virtual DualSense.
|
||||||
> analogue of the shipped virtual DualSense (`design/windows-dualsense-scoping.md`).
|
>
|
||||||
|
> **M3 result (protocol / ABI wire, on-box):** strictly additive + forward-compatible (§5).
|
||||||
|
> Core: back-button bits `BTN_PADDLE1..4` + `BTN_MISC1` (in Moonlight's `buttonFlags2<<16`
|
||||||
|
> namespace, so GameStream paddle + native grips share one map); `RichInput::TouchpadEx` (kind
|
||||||
|
> `0x03` — surface 0/1/2, click, signed coords, pressure); `HidOutput::TrackpadHaptic` (kind `0x04`).
|
||||||
|
> ABI: `PUNKTFUNK_GAMEPAD_STEAMDECK=6`/`_STEAMCONTROLLER=5` + the paddle/`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 `size_of==20/19` asserts);
|
||||||
|
> regenerated `punktfunk_core.h`. Host: `steam_proto::from_gamepad` maps the paddles → the four Deck
|
||||||
|
> grips + QAM; `apply_rich` routes `TouchpadEx` left/right → the matching pad; every DS manager
|
||||||
|
> (DualSense/DS4, Linux + Windows) gained a `TouchpadEx` arm (surface 0/2 → its one touchpad); the
|
||||||
|
> xpad `BUTTON_MAP` finally consumes the GameStream paddle bits (`BTN_TRIGGER_HAPPY5-8`, previously
|
||||||
|
> dropped). Wire round-trips + mapping unit-tested; the on-box backend test now drives the full path
|
||||||
|
> (`from_gamepad` grip + `apply_rich` left-pad) → evdev `BTN_A` + `ABS_HAT0X`. Workspace
|
||||||
|
> clippy/fmt/test green. **Deferred to M4:** the C-ABI `PunktfunkRichInputEx` + `send_rich_input2`
|
||||||
|
> (only the Apple/C *send* path needs it; the host decodes `TouchpadEx` today).
|
||||||
>
|
>
|
||||||
> **M2 result (backend + wiring, on-box):** `inject/linux/steam_controller.rs`
|
> **M2 result (backend + wiring, on-box):** `inject/linux/steam_controller.rs`
|
||||||
> (`SteamControllerManager`/`SteamDeckPad`, mirroring `dualsense.rs`) is wired into `PadBackend`
|
> (`SteamControllerManager`/`SteamDeckPad`, mirroring `dualsense.rs`) is wired into `PadBackend`
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||||
#define PUNKTFUNK_HIDOUT_TRIGGER 3
|
#define PUNKTFUNK_HIDOUT_TRIGGER 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.
|
||||||
|
#define PUNKTFUNK_HIDOUT_TRACKPAD_HAPTIC 4
|
||||||
|
|
||||||
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||||
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
#define PUNKTFUNK_HID_EFFECT_MAX 11
|
||||||
|
|
||||||
@@ -37,6 +42,12 @@
|
|||||||
// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||||
#define PUNKTFUNK_RICH_MOTION 2
|
#define PUNKTFUNK_RICH_MOTION 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).
|
||||||
|
#define PUNKTFUNK_RICH_TOUCHPAD_EX 3
|
||||||
|
|
||||||
// Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host
|
// Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host
|
||||||
// pick (auto-detect from its running desktop); a concrete value is honored only if that backend
|
// pick (auto-detect from its running desktop); a concrete value is honored only if that backend
|
||||||
// is available on the host right now, else the host falls back to auto-detect. The resolved
|
// is available on the host right now, else the host falls back to auto-detect. The resolved
|
||||||
@@ -82,6 +93,28 @@
|
|||||||
// hosts); otherwise the host falls back to X-Box 360.
|
// hosts); otherwise the host falls back to X-Box 360.
|
||||||
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 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.
|
||||||
|
#define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER 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.
|
||||||
|
#define PUNKTFUNK_GAMEPAD_STEAMDECK 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`.
|
||||||
|
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE1 65536
|
||||||
|
|
||||||
|
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE2 131072
|
||||||
|
|
||||||
|
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE3 262144
|
||||||
|
|
||||||
|
#define PUNKTFUNK_GAMEPAD_BTN_PADDLE4 524288
|
||||||
|
|
||||||
|
#define PUNKTFUNK_GAMEPAD_BTN_MISC1 2097152
|
||||||
|
|
||||||
// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
// 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
|
// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||||
// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
||||||
@@ -139,11 +172,26 @@
|
|||||||
|
|
||||||
#define PUNKTFUNK_BTN_Y 32768
|
#define PUNKTFUNK_BTN_Y 32768
|
||||||
|
|
||||||
|
// Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`.
|
||||||
|
#define BTN_PADDLE1 65536
|
||||||
|
|
||||||
|
// Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`.
|
||||||
|
#define BTN_PADDLE2 131072
|
||||||
|
|
||||||
|
// Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`.
|
||||||
|
#define BTN_PADDLE3 262144
|
||||||
|
|
||||||
|
// Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`.
|
||||||
|
#define BTN_PADDLE4 524288
|
||||||
|
|
||||||
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
// DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2`
|
||||||
// merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on
|
// 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.
|
// the same bit. Only the DualSense backend renders it; the xpad has no such button.
|
||||||
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
|
#define PUNKTFUNK_BTN_TOUCHPAD 1048576
|
||||||
|
|
||||||
|
// Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`.
|
||||||
|
#define BTN_MISC1 2097152
|
||||||
|
|
||||||
// Axis ids for `InputKind::GamepadAxis`.
|
// Axis ids for `InputKind::GamepadAxis`.
|
||||||
#define PUNKTFUNK_AXIS_LS_X 0
|
#define PUNKTFUNK_AXIS_LS_X 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user