feat(proto): variable-length rich-input (0xCC) + HID-output (0xCD) datagrams
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Foundation for rich DualSense support (roadmap #5). The fixed 18-byte InputEvent (0xC8) can't hold the DualSense touchpad/motion or HID feedback, so two new variable-length, kind-tagged datagram families join the side-plane (mouse/keyboard/gamepad/touch keep the fixed InputEvent): - RICH_INPUT_MAGIC 0xCC, client→host: `[0xCC][kind][fields]` Touchpad{pad,finger,active,x,y} (x/y normalized 0..65535; host scales to the pad) Motion{pad, gyro[3], accel[3]} (raw i16, straight into the DualSense report) - HIDOUT_MAGIC 0xCD, host→client: `[0xCD][kind][pad][fields]` — the rich analog of the 0xCA rumble datagram (rumble stays on 0xCA): Led{rgb} PlayerLeds{bits} Trigger{which, effect} (adaptive-trigger params to replay) `RichInput`/`HidOutput` enums with encode/decode; unknown kinds + truncation decode to None (forward-compatible). +2 round-trip/disjointness tests; quic suite green, clippy/fmt clean. Wiring (host UHID device, capture, C ABI, client) lands in following commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -556,12 +556,21 @@ pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host).
|
||||
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client).
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
/// Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of
|
||||
/// [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it.
|
||||
pub const MIC_MAGIC: u8 = 0xCB;
|
||||
/// Rich client→host input: events too big for the fixed 18-byte [`InputEvent`]
|
||||
/// (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length,
|
||||
/// kind-tagged (see [`RichInput`]).
|
||||
pub const RICH_INPUT_MAGIC: u8 = 0xCC;
|
||||
/// HID output, host → client: DualSense feedback a game wrote to the host's virtual controller
|
||||
/// (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See
|
||||
/// [`HidOutput`].
|
||||
pub const HIDOUT_MAGIC: u8 = 0xCD;
|
||||
|
||||
/// Audio datagram, host → client: `[0xC9][u32 seq LE][u64 pts_ns LE][opus payload]`.
|
||||
/// One Opus frame per datagram (5 ms — well under any MTU); QUIC already encrypts.
|
||||
@@ -625,6 +634,144 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
Some((seq, pts_ns, &b[13..]))
|
||||
}
|
||||
|
||||
const RICH_TOUCHPAD: u8 = 0x01;
|
||||
const RICH_MOTION: u8 = 0x02;
|
||||
|
||||
/// 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
|
||||
/// `[0xCC][kind][fields…]` — variable-length and kind-tagged (forward-compatible: an unknown
|
||||
/// kind decodes to `None` and is dropped).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RichInput {
|
||||
/// One touchpad contact. `x`/`y` are normalized `0..=65535` (the host scales to the
|
||||
/// DualSense touchpad resolution); `active = false` lifts the finger.
|
||||
Touchpad {
|
||||
pad: u8,
|
||||
finger: u8,
|
||||
active: bool,
|
||||
x: u16,
|
||||
y: u16,
|
||||
},
|
||||
/// Motion sensors: `gyro` (pitch/yaw/roll) + `accel`, raw signed-16 in the sensor's own
|
||||
/// units — passed straight into the DualSense report.
|
||||
Motion {
|
||||
pad: u8,
|
||||
gyro: [i16; 3],
|
||||
accel: [i16; 3],
|
||||
},
|
||||
}
|
||||
|
||||
impl RichInput {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = vec![RICH_INPUT_MAGIC];
|
||||
match *self {
|
||||
RichInput::Touchpad {
|
||||
pad,
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
} => {
|
||||
out.extend_from_slice(&[RICH_TOUCHPAD, pad, finger, active as u8]);
|
||||
out.extend_from_slice(&x.to_le_bytes());
|
||||
out.extend_from_slice(&y.to_le_bytes());
|
||||
}
|
||||
RichInput::Motion { pad, gyro, accel } => {
|
||||
out.extend_from_slice(&[RICH_MOTION, pad]);
|
||||
for v in gyro.iter().chain(accel.iter()) {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Option<RichInput> {
|
||||
if b.first() != Some(&RICH_INPUT_MAGIC) {
|
||||
return None;
|
||||
}
|
||||
match b.get(1)? {
|
||||
&RICH_TOUCHPAD if b.len() >= 9 => Some(RichInput::Touchpad {
|
||||
pad: b[2],
|
||||
finger: b[3],
|
||||
active: b[4] != 0,
|
||||
x: u16::from_le_bytes([b[5], b[6]]),
|
||||
y: u16::from_le_bytes([b[7], b[8]]),
|
||||
}),
|
||||
&RICH_MOTION if b.len() >= 15 => {
|
||||
let i16at = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]);
|
||||
Some(RichInput::Motion {
|
||||
pad: b[2],
|
||||
gyro: [i16at(3), i16at(5), i16at(7)],
|
||||
accel: [i16at(9), i16at(11), i16at(13)],
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
|
||||
/// 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;
|
||||
/// rumble itself stays on [`RUMBLE_MAGIC`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HidOutput {
|
||||
/// Lightbar RGB.
|
||||
Led { pad: u8, r: u8, g: u8, b: u8 },
|
||||
/// Player-indicator LEDs (low 5 bits).
|
||||
PlayerLeds { pad: u8, bits: u8 },
|
||||
/// 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> },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = vec![HIDOUT_MAGIC];
|
||||
match self {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
out.extend_from_slice(&[HIDOUT_LED, *pad, *r, *g, *b])
|
||||
}
|
||||
HidOutput::PlayerLeds { pad, bits } => {
|
||||
out.extend_from_slice(&[HIDOUT_PLAYER_LEDS, *pad, *bits])
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||
out.extend_from_slice(effect);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Option<HidOutput> {
|
||||
if b.first() != Some(&HIDOUT_MAGIC) {
|
||||
return None;
|
||||
}
|
||||
match b.get(1)? {
|
||||
&HIDOUT_LED if b.len() >= 6 => Some(HidOutput::Led {
|
||||
pad: b[2],
|
||||
r: b[3],
|
||||
g: b[4],
|
||||
b: b[5],
|
||||
}),
|
||||
&HIDOUT_PLAYER_LEDS if b.len() >= 4 => Some(HidOutput::PlayerLeds {
|
||||
pad: b[2],
|
||||
bits: b[3],
|
||||
}),
|
||||
&HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
|
||||
pad: b[2],
|
||||
which: b[3],
|
||||
effect: b[4..].to_vec(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io {
|
||||
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||
@@ -1222,6 +1369,70 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_input_roundtrip() {
|
||||
for ev in [
|
||||
RichInput::Touchpad {
|
||||
pad: 1,
|
||||
finger: 0,
|
||||
active: true,
|
||||
x: 40000,
|
||||
y: 12345,
|
||||
},
|
||||
RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [-100, 200, -300],
|
||||
accel: [16384, -8192, 1],
|
||||
},
|
||||
] {
|
||||
let d = ev.encode();
|
||||
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||
assert_eq!(RichInput::decode(&d), Some(ev));
|
||||
}
|
||||
// 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());
|
||||
// short
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hid_output_roundtrip() {
|
||||
let cases = [
|
||||
HidOutput::Led {
|
||||
pad: 2,
|
||||
r: 0xAA,
|
||||
g: 0xBB,
|
||||
b: 0xCC,
|
||||
},
|
||||
HidOutput::PlayerLeds {
|
||||
pad: 0,
|
||||
bits: 0b10101,
|
||||
},
|
||||
HidOutput::Trigger {
|
||||
pad: 1,
|
||||
which: 1,
|
||||
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||
},
|
||||
];
|
||||
for ev in &cases {
|
||||
let d = ev.encode();
|
||||
assert_eq!(d[0], HIDOUT_MAGIC);
|
||||
assert_eq!(HidOutput::decode(&d).as_ref(), Some(ev));
|
||||
}
|
||||
assert!(HidOutput::decode(&[HIDOUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||
// A rich-input datagram is not a HID-output datagram.
|
||||
assert!(HidOutput::decode(
|
||||
&RichInput::Motion {
|
||||
pad: 0,
|
||||
gyro: [0; 3],
|
||||
accel: [0; 3]
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_sha256_of_der() {
|
||||
// Stable across calls, distinct for distinct certs.
|
||||
|
||||
Reference in New Issue
Block a user