refactor(core): split quic.rs (3.2k lines) into src/quic/ — pure move
Networking-audit deferred plan §3. One file per concern, zero logic edits:
quic/mod.rs MAGIC/CTL_MAGIC + re-exports (every crate::quic::X path
compiles unchanged across host + all clients)
quic/msgs.rs Hello/Welcome/Start, typed control msgs + type bytes,
resolve_codec, ColorInfo, window_loss_ppm, pairing msgs
quic/pake.rs the SPAKE2 pairing exchange
quic/datagram.rs 0xC9–0xCF plane codecs (audio/rumble/mic/rich-input/
hidout/HdrMeta/HostTiming)
quic/io.rs length-prefixed stream IO
quic/clock.rs clock_offset_ns estimator, clock_sync, ClockResync
quic/endpoint.rs quinn config, ALPN, pinning verifiers, keep-alive
quic/tests.rs the cross-cutting test module, unchanged
Mechanical deltas only: the nested `pub mod` wrappers became files (one
dedent), submodules import what they previously inherited from the parent
scope, and the three RichInput kind tags are pub(super) for the tests
(same-module before). Verified line-multiset-identical after normalizing
indentation. cargo check --workspace, core tests (quic), clippy, and
cargo ndk check all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
|||||||
|
//! Wall-clock skew: the connect-time handshake ([`clock_sync`]), the NTP-style offset
|
||||||
|
//! estimator ([`clock_offset_ns`]), and the mid-stream re-sync state machine
|
||||||
|
//! ([`ClockResync`]).
|
||||||
|
|
||||||
|
use super::{io, ClockEcho, ClockProbe};
|
||||||
|
|
||||||
|
/// Estimate the host↔client clock offset (**host minus client**, ns) and RTT (ns) from skew-handshake
|
||||||
|
/// samples `(t1, t2, t3, t4)` — NTP's formula, taking the **minimum-RTT** sample (least queuing
|
||||||
|
/// noise; also discards the first round's host-setup latency). Offset is positive when the host
|
||||||
|
/// clock is ahead of the client's; add it to a client timestamp to express it in the host clock.
|
||||||
|
/// Returns `None` for an empty sample set.
|
||||||
|
pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
|
||||||
|
samples
|
||||||
|
.iter()
|
||||||
|
.map(|&(t1, t2, t3, t4)| {
|
||||||
|
let rtt = ((t4 as i128 - t1 as i128) - (t3 as i128 - t2 as i128)).max(0) as u64;
|
||||||
|
let offset = (((t2 as i128 - t1 as i128) + (t3 as i128 - t4 as i128)) / 2) as i64;
|
||||||
|
(offset, rtt)
|
||||||
|
})
|
||||||
|
.min_by_key(|&(_, rtt)| rtt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One wall-clock skew-handshake outcome (see [`clock_sync`]).
|
||||||
|
pub struct ClockSkew {
|
||||||
|
/// Host clock minus client clock, ns: add it to a client timestamp to express it in host time.
|
||||||
|
pub offset_ns: i64,
|
||||||
|
/// Round-trip time of the minimum-RTT sample, ns.
|
||||||
|
pub rtt_ns: u64,
|
||||||
|
/// How many probe rounds the host answered.
|
||||||
|
pub rounds: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the wall-clock skew handshake from the client side over the (already-open) control stream:
|
||||||
|
/// `ROUNDS` [`ClockProbe`]/[`ClockEcho`] round-trips, returning the host↔client offset from the
|
||||||
|
/// minimum-RTT sample. `None` if the host never answers (an old host) — the caller then assumes a
|
||||||
|
/// shared clock. Each read is bounded so a silent host can't wedge session start. Shared by the
|
||||||
|
/// reference client and the embeddable connector; uses the realtime clock the host stamps `pts_ns`
|
||||||
|
/// with, so the offset aligns a client receive instant to the host's capture clock.
|
||||||
|
pub async fn clock_sync(
|
||||||
|
send: &mut quinn::SendStream,
|
||||||
|
recv: &mut quinn::RecvStream,
|
||||||
|
) -> Option<ClockSkew> {
|
||||||
|
use std::time::Duration;
|
||||||
|
const ROUNDS: usize = 8;
|
||||||
|
let read_timeout = Duration::from_secs(2);
|
||||||
|
let mut samples: Vec<(u64, u64, u64, u64)> = Vec::with_capacity(ROUNDS);
|
||||||
|
for _ in 0..ROUNDS {
|
||||||
|
let t1 = wall_clock_ns();
|
||||||
|
let probe = ClockProbe { t1_ns: t1 }.encode();
|
||||||
|
if io::write_msg(send, &probe).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
|
||||||
|
let echo = match read {
|
||||||
|
Ok(Ok(b)) => match ClockEcho::decode(&b) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
_ => break, // timeout or stream error -> old host / no skew support
|
||||||
|
};
|
||||||
|
samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, wall_clock_ns()));
|
||||||
|
}
|
||||||
|
clock_offset_ns(&samples).map(|(offset_ns, rtt_ns)| ClockSkew {
|
||||||
|
offset_ns,
|
||||||
|
rtt_ns,
|
||||||
|
rounds: samples.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wall-clock now (ns since the Unix epoch) — the clock the skew handshake stamps and the host
|
||||||
|
/// stamps AU `pts_ns` with (CLOCK_REALTIME basis, deliberately NOT monotonic: steps/slew are
|
||||||
|
/// exactly what the handshake measures across machines).
|
||||||
|
pub fn wall_clock_ns() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`ClockResync::on_echo`] asks the driver to do next.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum ResyncStep {
|
||||||
|
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
|
||||||
|
Idle,
|
||||||
|
/// Send this next-round probe and keep feeding echoes.
|
||||||
|
Probe(ClockProbe),
|
||||||
|
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
|
||||||
|
Done { offset_ns: i64, rtt_ns: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mid-stream wall-clock re-sync (networking-audit deferred plan §2): the same 8-round
|
||||||
|
/// probe/echo estimate as the connect-time [`clock_sync`], restructured as a state machine so
|
||||||
|
/// the client's control task can drive it from its `select!` loop without blocking the stream —
|
||||||
|
/// echoes interleave with other control traffic; rounds are matched by the echoed `t1`.
|
||||||
|
///
|
||||||
|
/// A step or slow drift of either wall clock after connect silently corrupts the clock-based
|
||||||
|
/// jump-to-live signal, the ABR one-way-delay signal, and every latency stat. Re-syncing
|
||||||
|
/// restores them; the disarm heuristic stays as the final backstop.
|
||||||
|
pub struct ClockResync {
|
||||||
|
/// `t1_ns` of the probe in flight; `None` = no batch active. An echo whose `t1` doesn't
|
||||||
|
/// match is stale (an abandoned batch) and ignored.
|
||||||
|
pending_t1: Option<u64>,
|
||||||
|
samples: Vec<(u64, u64, u64, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClockResync {
|
||||||
|
/// Rounds per batch — matches the connect-time [`clock_sync`].
|
||||||
|
pub const ROUNDS: usize = 8;
|
||||||
|
|
||||||
|
pub fn new() -> ClockResync {
|
||||||
|
ClockResync {
|
||||||
|
pending_t1: None,
|
||||||
|
samples: Vec::with_capacity(Self::ROUNDS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a (new) batch, abandoning any batch still in flight — its late echoes won't match
|
||||||
|
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
|
||||||
|
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
|
||||||
|
self.samples.clear();
|
||||||
|
self.pending_t1 = Some(now_ns);
|
||||||
|
ClockProbe { t1_ns: now_ns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed an inbound [`ClockEcho`] received at `now_ns` (the round's `t4`).
|
||||||
|
pub fn on_echo(&mut self, echo: &ClockEcho, now_ns: u64) -> ResyncStep {
|
||||||
|
if self.pending_t1 != Some(echo.t1_ns) {
|
||||||
|
return ResyncStep::Idle; // stale (abandoned batch) or unsolicited
|
||||||
|
}
|
||||||
|
self.samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
|
||||||
|
if self.samples.len() < Self::ROUNDS {
|
||||||
|
self.pending_t1 = Some(now_ns);
|
||||||
|
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
|
||||||
|
}
|
||||||
|
self.pending_t1 = None;
|
||||||
|
match clock_offset_ns(&self.samples) {
|
||||||
|
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
|
||||||
|
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClockResync {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
|
||||||
|
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
|
||||||
|
/// biases the offset by its queueing delay, and frames already read late exactly then; better
|
||||||
|
/// to keep the old estimate and let the next batch try again.
|
||||||
|
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
|
||||||
|
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||||
|
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||||
|
|
||||||
|
/// 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), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||||
|
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||||
|
/// (0xCE, 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.
|
||||||
|
pub fn encode_audio_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(13 + opus.len());
|
||||||
|
b.push(AUDIO_MAGIC);
|
||||||
|
b.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(opus);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an audio datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
|
||||||
|
pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||||
|
if b.len() < 13 || b[0] != AUDIO_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
|
||||||
|
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
|
||||||
|
Some((seq, pts_ns, &b[13..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
|
||||||
|
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop).
|
||||||
|
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
|
||||||
|
let mut b = [0u8; 7];
|
||||||
|
b[0] = RUMBLE_MAGIC;
|
||||||
|
b[1..3].copy_from_slice(&pad.to_le_bytes());
|
||||||
|
b[3..5].copy_from_slice(&low.to_le_bytes());
|
||||||
|
b[5..7].copy_from_slice(&high.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length.
|
||||||
|
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
|
||||||
|
if b.len() < 7 || b[0] != RUMBLE_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||||
|
Some((u16at(1), u16at(3), u16at(5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
|
||||||
|
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
|
||||||
|
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(13 + opus.len());
|
||||||
|
b.push(MIC_MAGIC);
|
||||||
|
b.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(opus);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a mic datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
|
||||||
|
pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||||
|
if b.len() < 13 || b[0] != MIC_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
|
||||||
|
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
|
||||||
|
Some((seq, pts_ns, &b[13..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
|
||||||
|
pub(super) const RICH_MOTION: u8 = 0x02;
|
||||||
|
pub(super) 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
|
||||||
|
/// `[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` in SCREEN convention —
|
||||||
|
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
|
||||||
|
/// (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],
|
||||||
|
},
|
||||||
|
/// 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) in SCREEN convention — +x right, +y DOWN,
|
||||||
|
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
|
||||||
|
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
|
||||||
|
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
|
||||||
|
/// and hosts translate). `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 {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
/// 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> },
|
||||||
|
/// 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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static HDR metadata, host → client: SMPTE ST.2086 mastering display colour volume + CEA-861.3
|
||||||
|
/// content light level. Tag [`HDR_META_MAGIC`]. Carried on a datagram (not [`Welcome`]) because it
|
||||||
|
/// is larger and can change mid-stream when the source's mastering intent changes; the host
|
||||||
|
/// re-sends it on keyframes so a client that dropped the best-effort datagram converges. Omitted
|
||||||
|
/// for HLG (scene-referred — no mastering metadata).
|
||||||
|
///
|
||||||
|
/// All fields use the standard HDR10 SEI fixed-point units, so they pass straight to
|
||||||
|
/// `DXGI_HDR_METADATA_HDR10` / Android `KEY_HDR_STATIC_INFO` / Apple `CAEDRMetadata` — the
|
||||||
|
/// libavcodec `AVMasteringDisplayMetadata` side needs an `AVRational` conversion.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub struct HdrMeta {
|
||||||
|
/// Display primaries G, B, R as (x, y) chromaticity in 1/50000 units (the ST.2086 RGB order
|
||||||
|
/// is G, B, R).
|
||||||
|
pub display_primaries: [[u16; 2]; 3],
|
||||||
|
/// White point (x, y) in 1/50000 units.
|
||||||
|
pub white_point: [u16; 2],
|
||||||
|
/// Max display mastering luminance, 0.0001 cd/m² units.
|
||||||
|
pub max_display_mastering_luminance: u32,
|
||||||
|
/// Min display mastering luminance, 0.0001 cd/m² units.
|
||||||
|
pub min_display_mastering_luminance: u32,
|
||||||
|
/// Maximum content light level (MaxCLL), nits. `0` = unknown.
|
||||||
|
pub max_cll: u16,
|
||||||
|
/// Maximum frame-average light level (MaxFALL), nits. `0` = unknown.
|
||||||
|
pub max_fall: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||||
|
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||||
|
pub const HDR_META_MAGIC: u8 = 0xCE;
|
||||||
|
|
||||||
|
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32
|
||||||
|
/// luminance + 2×u16 CLL/FALL = 29 bytes.
|
||||||
|
const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4;
|
||||||
|
|
||||||
|
/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram.
|
||||||
|
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(HDR_META_LEN);
|
||||||
|
b.push(HDR_META_MAGIC);
|
||||||
|
for p in m.display_primaries.iter() {
|
||||||
|
b.extend_from_slice(&p[0].to_le_bytes());
|
||||||
|
b.extend_from_slice(&p[1].to_le_bytes());
|
||||||
|
}
|
||||||
|
b.extend_from_slice(&m.white_point[0].to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.white_point[1].to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.max_display_mastering_luminance.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.max_cll.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.max_fall.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a [`HDR_META_MAGIC`] datagram → [`HdrMeta`]. `None` on bad tag or a short/truncated buffer
|
||||||
|
/// (every attacker-controlled field is bounds-checked by the fixed length before any read).
|
||||||
|
pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
|
||||||
|
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||||
|
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||||
|
Some(HdrMeta {
|
||||||
|
display_primaries: [
|
||||||
|
[u16at(1), u16at(3)],
|
||||||
|
[u16at(5), u16at(7)],
|
||||||
|
[u16at(9), u16at(11)],
|
||||||
|
],
|
||||||
|
white_point: [u16at(13), u16at(15)],
|
||||||
|
max_display_mastering_luminance: u32at(17),
|
||||||
|
min_display_mastering_luminance: u32at(21),
|
||||||
|
max_cll: u16at(25),
|
||||||
|
max_fall: u16at(27),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
|
||||||
|
/// [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's
|
||||||
|
/// socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`].
|
||||||
|
pub const HOST_TIMING_MAGIC: u8 = 0xCF;
|
||||||
|
|
||||||
|
/// One access unit's host-side processing time: capture → fully sent (the whole host pipeline —
|
||||||
|
/// capture read/convert, encode, FEC+seal, paced send). The client correlates it to the AU by
|
||||||
|
/// `pts_ns` (the AU's capture stamp, unique per frame) and derives
|
||||||
|
/// `network = (received + clock_offset − pts_ns) − host_us`, so the unified-stats equation's
|
||||||
|
/// `host+network` stage splits into two per-frame-tiling terms. Best-effort like every side-plane
|
||||||
|
/// datagram: a lost 0xCF just means that frame contributes no host/network sample.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct HostTiming {
|
||||||
|
/// The AU's capture stamp (host capture clock — matches the AU's `pts_ns` exactly).
|
||||||
|
pub pts_ns: u64,
|
||||||
|
/// Host capture→sent duration, µs (saturated at `u32::MAX` ≈ 71 min — far past the 10 s
|
||||||
|
/// client-side sanity clamp anyway).
|
||||||
|
pub host_us: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire length of a [`HOST_TIMING_MAGIC`] datagram: tag + u64 pts + u32 µs = 13 bytes.
|
||||||
|
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
|
||||||
|
|
||||||
|
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram.
|
||||||
|
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(HOST_TIMING_LEN);
|
||||||
|
b.push(HOST_TIMING_MAGIC);
|
||||||
|
b.extend_from_slice(&t.pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(&t.host_us.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a [`HOST_TIMING_MAGIC`] datagram → [`HostTiming`]. `None` on bad tag or a short buffer
|
||||||
|
/// (the fixed length bounds every read before it happens).
|
||||||
|
pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
|
||||||
|
if b.len() < HOST_TIMING_LEN || b[0] != HOST_TIMING_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(HostTiming {
|
||||||
|
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
|
||||||
|
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
||||||
|
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
|
||||||
|
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
|
||||||
|
/// idle timer run out and quinn closes the session — surfacing to the embedder as
|
||||||
|
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
|
||||||
|
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
|
||||||
|
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
|
||||||
|
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
||||||
|
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
|
||||||
|
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
|
||||||
|
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
|
||||||
|
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
|
||||||
|
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||||
|
|
||||||
|
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
||||||
|
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
|
||||||
|
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
|
||||||
|
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
|
||||||
|
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
|
||||||
|
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
|
||||||
|
/// the keep-alive holds it open through quiet control periods.
|
||||||
|
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
|
||||||
|
use std::time::Duration;
|
||||||
|
let idle = idle.max(Duration::from_secs(1));
|
||||||
|
let keep_alive = (idle / 2).min(Duration::from_secs(4));
|
||||||
|
let mut t = quinn::TransportConfig::default();
|
||||||
|
t.max_idle_timeout(Some(
|
||||||
|
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
||||||
|
));
|
||||||
|
t.keep_alive_interval(Some(keep_alive));
|
||||||
|
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
|
||||||
|
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
|
||||||
|
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
|
||||||
|
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
|
||||||
|
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
|
||||||
|
// standing delay that never drains while video stays live. Capping the buffer makes the
|
||||||
|
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
|
||||||
|
// surround bitrates), so sustained congestion costs concealable drops, never lag.
|
||||||
|
t.datagram_send_buffer_size(4 * 1024);
|
||||||
|
Arc::new(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
|
||||||
|
/// persist an identity and use [`server_with_identity`] so clients can pin it).
|
||||||
|
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk".into()])
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||||
|
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
|
||||||
|
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
|
||||||
|
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
|
||||||
|
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
|
||||||
|
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
|
||||||
|
pub fn server_with_identity(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
|
||||||
|
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
|
||||||
|
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
|
||||||
|
/// keep-alive scales with it so a live session never false-closes.
|
||||||
|
pub fn server_with_identity_idle(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
idle: std::time::Duration,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||||
|
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
|
||||||
|
server_from_der(cert_der, key_der, addr, idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
|
||||||
|
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
|
||||||
|
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
|
||||||
|
/// together (acceptable while the protocol/ABI is still evolving).
|
||||||
|
const QUIC_ALPN: &[u8] = b"pkf1";
|
||||||
|
|
||||||
|
fn server_from_der(
|
||||||
|
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||||
|
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
idle: std::time::Duration,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
// Client auth is OFFERED but optional: a client that presents its self-signed
|
||||||
|
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||||
|
// one that presents none still connects (and is rejected at the app layer when
|
||||||
|
// pairing is required).
|
||||||
|
let mut rustls_cfg = rustls::ServerConfig::builder()
|
||||||
|
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
||||||
|
.with_single_cert(vec![cert_der], key_der)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||||
|
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||||
|
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||||
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||||
|
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
|
||||||
|
Ok(quinn::Endpoint::server(server_config, addr)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a
|
||||||
|
/// client persists once and presents on every connect so hosts can recognize it.
|
||||||
|
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||||
|
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of the client certificate a connection presented (host side), if any.
|
||||||
|
pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
|
||||||
|
let identity = conn.peer_identity()?;
|
||||||
|
let certs = identity
|
||||||
|
.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>()
|
||||||
|
.ok()?;
|
||||||
|
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
||||||
|
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(cert_der).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
||||||
|
/// must match what the client's verifier computes from the DER on the wire).
|
||||||
|
pub fn fingerprint_of_pem(cert_pem: &str) -> anyhow_result::Result<[u8; 32]> {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||||
|
Ok(cert_fingerprint(der.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client endpoint that skips certificate verification (TOFU bootstrap — read the
|
||||||
|
/// observed fingerprint off the slot and pin it on the next connect).
|
||||||
|
pub fn client_insecure() -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
client_pinned(None).0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`client_pinned`] returns: the endpoint plus the slot the verifier writes the
|
||||||
|
/// observed host fingerprint into during the handshake.
|
||||||
|
pub type PinnedClient = (
|
||||||
|
anyhow_result::Result<quinn::Endpoint>,
|
||||||
|
Arc<Mutex<Option<[u8; 32]>>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Client endpoint that verifies the host by certificate fingerprint.
|
||||||
|
///
|
||||||
|
/// `pin = Some(sha256)` rejects any host whose leaf cert doesn't hash to `sha256`;
|
||||||
|
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
|
||||||
|
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
|
||||||
|
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
|
||||||
|
client_pinned_with_identity(pin, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8
|
||||||
|
/// key) via TLS client auth — how a paired client identifies itself to the host.
|
||||||
|
pub fn client_pinned_with_identity(
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
identity: Option<(&str, &str)>,
|
||||||
|
) -> PinnedClient {
|
||||||
|
let observed = Arc::new(Mutex::new(None));
|
||||||
|
let ep = (|| {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let builder = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
||||||
|
pin,
|
||||||
|
observed: observed.clone(),
|
||||||
|
}));
|
||||||
|
let mut rustls_cfg = match identity {
|
||||||
|
None => builder.with_no_client_auth(),
|
||||||
|
Some((cert_pem, key_pem)) => {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let cert =
|
||||||
|
rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow_result::Error::msg(format!("client cert pem: {e}"))
|
||||||
|
})?;
|
||||||
|
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?;
|
||||||
|
builder
|
||||||
|
.with_client_auth_cert(vec![cert], key)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
|
||||||
|
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||||
|
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||||
|
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
||||||
|
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
|
||||||
|
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||||
|
ep.set_default_client_config(client_cfg);
|
||||||
|
Ok(ep)
|
||||||
|
})();
|
||||||
|
(ep, observed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal error plumbing without pulling anyhow into punktfunk-core's public API.
|
||||||
|
pub mod anyhow_result {
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error(String);
|
||||||
|
impl Error {
|
||||||
|
pub fn msg(s: String) -> Self {
|
||||||
|
Error(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
Error(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
|
||||||
|
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
|
||||||
|
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
||||||
|
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
|
||||||
|
/// verify the handshake signature for real — possession of the presented cert's key is
|
||||||
|
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
||||||
|
/// Authorization (is this fingerprint paired?) happens at the application layer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AcceptAnyClientCert;
|
||||||
|
|
||||||
|
impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
||||||
|
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_auth_mandatory(&self) -> bool {
|
||||||
|
false // unpaired/legacy clients still connect; gating is per-feature
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_client_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error>
|
||||||
|
{
|
||||||
|
Ok(rustls::server::danger::ClientCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||||
|
{
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||||
|
{
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PinVerify {
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error>
|
||||||
|
{
|
||||||
|
let fp = cert_fingerprint(end_entity.as_ref());
|
||||||
|
*self.observed.lock().unwrap() = Some(fp);
|
||||||
|
if let Some(expected) = self.pin {
|
||||||
|
if fp != expected {
|
||||||
|
return Err(rustls::Error::InvalidCertificate(
|
||||||
|
rustls::CertificateError::ApplicationVerificationFailure,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handshake signatures MUST be verified for real even though we pin the cert:
|
||||||
|
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
|
||||||
|
// skip it and an active MITM can replay the host's (public) certificate, match the
|
||||||
|
// pin, and complete the handshake with its own key.
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||||
|
{
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||||
|
{
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||||
|
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
|
||||||
|
let mut len = [0u8; 2];
|
||||||
|
recv.read_exact(&mut len)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
let n = u16::from_le_bytes(len) as usize;
|
||||||
|
let mut buf = vec![0u8; n];
|
||||||
|
recv.read_exact(&mut buf)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one framed message.
|
||||||
|
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
|
||||||
|
send.write_all(&super::frame(payload))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
|
||||||
|
//!
|
||||||
|
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
|
||||||
|
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
|
||||||
|
//! length-prefixed binary handshake on one bidirectional stream:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! client → host Hello { abi_version }
|
||||||
|
//! host → client Welcome { abi_version, session: full data-plane Config + mode + UDP port }
|
||||||
|
//! client → host Start { client_udp_port }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! after which both sides bring up a [`crate::session::Session`] over a plain
|
||||||
|
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
|
||||||
|
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
|
||||||
|
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
|
||||||
|
//! plane is exactly the hardened core `Session`.
|
||||||
|
//!
|
||||||
|
//! Transport security: the host presents a long-lived self-signed certificate
|
||||||
|
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
|
||||||
|
//! ([`endpoint::client_pinned`]; no pin = trust-on-first-use, with the observed fingerprint
|
||||||
|
//! reported back for persisting). The data plane adds AES-GCM on top.
|
||||||
|
//! All integers little-endian; every message is `u16 length || payload`.
|
||||||
|
//!
|
||||||
|
//! Split by concern (networking-audit deferred plan §3 — a pure move): [`msgs`] the
|
||||||
|
//! handshake + typed control messages, [`pake`] the pairing SPAKE2, [`datagram`] the
|
||||||
|
//! 0xC9–0xCF plane codecs, [`io`] framed stream IO, [`clock`] skew estimation + mid-stream
|
||||||
|
//! re-sync, [`endpoint`] the quinn constructors. Every item is re-exported here, so all
|
||||||
|
//! existing `crate::quic::X` paths compile unchanged.
|
||||||
|
|
||||||
|
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
|
||||||
|
pub const MAGIC: &[u8; 4] = b"PKF1";
|
||||||
|
|
||||||
|
/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the
|
||||||
|
/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version
|
||||||
|
/// byte sits where a type byte would) can never be misparsed as a control message, and
|
||||||
|
/// vice-versa, regardless of field values.
|
||||||
|
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
|
||||||
|
|
||||||
|
mod clock;
|
||||||
|
mod datagram;
|
||||||
|
mod msgs;
|
||||||
|
|
||||||
|
/// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via
|
||||||
|
/// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via
|
||||||
|
/// [`endpoint::client_pinned`] ([`endpoint::client_insecure`] is the no-pin special case).
|
||||||
|
pub mod endpoint;
|
||||||
|
|
||||||
|
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||||
|
pub mod io;
|
||||||
|
|
||||||
|
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||||
|
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||||
|
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||||
|
/// *and* saw the same two certificates (a MITM, presenting different certs to each leg,
|
||||||
|
/// cannot reach a shared key).
|
||||||
|
pub mod pake;
|
||||||
|
|
||||||
|
pub use clock::*;
|
||||||
|
pub use datagram::*;
|
||||||
|
pub use msgs::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
|||||||
|
use crate::error::{PunktfunkError, Result};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use spake2::{Ed25519Group, Identity, Password, Spake2};
|
||||||
|
|
||||||
|
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
|
||||||
|
pub struct PairingPake {
|
||||||
|
state: Spake2<Ed25519Group>,
|
||||||
|
transcript: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the
|
||||||
|
/// client passes what it observed via TOFU; the host passes its own + the client's
|
||||||
|
/// presented cert). Returns the state and this side's outbound SPAKE2 message.
|
||||||
|
pub fn start(
|
||||||
|
is_client: bool,
|
||||||
|
pin: &str,
|
||||||
|
client_fp: &[u8; 32],
|
||||||
|
host_fp: &[u8; 32],
|
||||||
|
) -> (PairingPake, Vec<u8>) {
|
||||||
|
let pw = Password::new(pin.as_bytes());
|
||||||
|
let id_client = Identity::new(client_fp);
|
||||||
|
let id_host = Identity::new(host_fp);
|
||||||
|
let (state, msg) = if is_client {
|
||||||
|
Spake2::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
|
||||||
|
} else {
|
||||||
|
Spake2::<Ed25519Group>::start_b(&pw, &id_client, &id_host)
|
||||||
|
};
|
||||||
|
let mut transcript = Vec::with_capacity(64);
|
||||||
|
transcript.extend_from_slice(client_fp);
|
||||||
|
transcript.extend_from_slice(host_fp);
|
||||||
|
(PairingPake { state, transcript }, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
|
||||||
|
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
|
||||||
|
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
|
||||||
|
let mut mac =
|
||||||
|
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
|
||||||
|
mac.update(label);
|
||||||
|
mac.update(transcript);
|
||||||
|
mac.finalize().into_bytes().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare
|
||||||
|
/// our recomputed tag the same way.
|
||||||
|
fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
|
||||||
|
a.iter()
|
||||||
|
.zip(b.iter())
|
||||||
|
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation tags both sides expect, given the agreed SPAKE2 key.
|
||||||
|
pub struct Confirmations {
|
||||||
|
/// MAC the host sends (client verifies).
|
||||||
|
pub host: [u8; 32],
|
||||||
|
/// MAC the client sends (host verifies).
|
||||||
|
pub client: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairingPake {
|
||||||
|
/// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if
|
||||||
|
/// the peer's message is malformed (a wrong PIN does NOT error here — it yields a
|
||||||
|
/// *different* key, so the confirmation MACs simply won't match).
|
||||||
|
pub fn finish(self, peer_msg: &[u8]) -> Result<Confirmations> {
|
||||||
|
let key = self
|
||||||
|
.state
|
||||||
|
.finish(peer_msg)
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)?;
|
||||||
|
Ok(Confirmations {
|
||||||
|
host: confirm(&key, b"punktfunk-pair-host", &self.transcript),
|
||||||
|
client: confirm(&key, b"punktfunk-pair-client", &self.transcript),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time tag comparison for the confirmation step.
|
||||||
|
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
|
||||||
|
ct_eq(expected, got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,955 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn welcome_roundtrip() {
|
||||||
|
let w = Welcome {
|
||||||
|
abi_version: 1,
|
||||||
|
udp_port: 9999,
|
||||||
|
mode: Mode {
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
refresh_hz: 240,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [7u8; 16],
|
||||||
|
salt: [1, 2, 3, 4],
|
||||||
|
frames: 600,
|
||||||
|
compositor: CompositorPref::Gamescope,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 50_000,
|
||||||
|
bit_depth: 10,
|
||||||
|
color: ColorInfo::HDR10_BT2020_PQ,
|
||||||
|
chroma_format: CHROMA_IDC_444,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
};
|
||||||
|
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_negotiation_and_back_compat() {
|
||||||
|
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264, 0),
|
||||||
|
Some(CODEC_AV1)
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_codec(CODEC_H264, CODEC_H264, 0), Some(CODEC_H264));
|
||||||
|
// A software host (H.264 only) + an HEVC-only client share nothing → refuse.
|
||||||
|
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, 0), None);
|
||||||
|
// An older client (0 = no codec byte) is treated as HEVC-only.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(0, CODEC_HEVC | CODEC_H264, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_codec(0, CODEC_H264, 0), None);
|
||||||
|
|
||||||
|
// Soft preference: honored when the host can also emit it, overriding precedence...
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_H264 | CODEC_HEVC, CODEC_H264),
|
||||||
|
Some(CODEC_H264)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_AV1, CODEC_HEVC | CODEC_AV1, CODEC_AV1),
|
||||||
|
Some(CODEC_AV1)
|
||||||
|
);
|
||||||
|
// ...but falls back to precedence when the preferred codec isn't in the shared set.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_H264, CODEC_HEVC | CODEC_H264, CODEC_AV1),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
// A preference the host can't emit still can't rescue a no-shared-codec case.
|
||||||
|
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
|
||||||
|
|
||||||
|
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
|
||||||
|
// a build that ignores the trailing byte (back-compat: extra bytes are skipped).
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
|
||||||
|
video_codecs: CODEC_H264 | CODEC_HEVC,
|
||||||
|
preferred_codec: CODEC_H264,
|
||||||
|
};
|
||||||
|
let enc = h.encode();
|
||||||
|
let dec = Hello::decode(&enc).unwrap();
|
||||||
|
assert_eq!(dec.video_codecs, CODEC_H264 | CODEC_HEVC);
|
||||||
|
assert_eq!(dec.preferred_codec, CODEC_H264);
|
||||||
|
// Drop the preferred_codec byte → still decodes, video_codecs intact, preference gone.
|
||||||
|
let no_pref = &enc[..enc.len() - 1];
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(no_pref).unwrap().video_codecs,
|
||||||
|
CODEC_H264 | CODEC_HEVC
|
||||||
|
);
|
||||||
|
assert_eq!(Hello::decode(no_pref).unwrap().preferred_codec, 0);
|
||||||
|
// A pre-codec Hello (no video_codecs/preferred bytes) decodes to 0 → HEVC-only.
|
||||||
|
let legacy = &enc[..enc.len() - 2];
|
||||||
|
assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0);
|
||||||
|
assert_eq!(Hello::decode(legacy).unwrap().preferred_codec, 0);
|
||||||
|
|
||||||
|
// A pre-codec Welcome (no codec byte) decodes to HEVC.
|
||||||
|
let mut w = Welcome::decode(
|
||||||
|
&Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 1,
|
||||||
|
mode: h.mode,
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 0,
|
||||||
|
max_data_per_block: 1024,
|
||||||
|
},
|
||||||
|
shard_payload: 1024,
|
||||||
|
encrypt: false,
|
||||||
|
key: [0; 16],
|
||||||
|
salt: [0; 4],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_H264,
|
||||||
|
host_caps: 0,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(w.codec, CODEC_H264);
|
||||||
|
w.codec = CODEC_HEVC;
|
||||||
|
let wenc = w.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc[..wenc.len() - 1]).unwrap().codec,
|
||||||
|
CODEC_HEVC
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hdr_meta_datagram_roundtrip_and_truncation() {
|
||||||
|
let m = HdrMeta {
|
||||||
|
// BT.2020 display primaries in 1/50000 units (the DXGI/ST.2086 reference values).
|
||||||
|
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
||||||
|
white_point: [15635, 16450], // D65
|
||||||
|
max_display_mastering_luminance: 10_000_000, // 1000 nits in 0.0001 cd/m²
|
||||||
|
min_display_mastering_luminance: 1, // 0.0001 nits
|
||||||
|
max_cll: 1000,
|
||||||
|
max_fall: 400,
|
||||||
|
};
|
||||||
|
let d = encode_hdr_meta_datagram(&m);
|
||||||
|
assert_eq!(d[0], HDR_META_MAGIC);
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&d), Some(m));
|
||||||
|
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||||
|
for n in 0..d.len() {
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
let mut bad = d.clone();
|
||||||
|
bad[0] = HIDOUT_MAGIC;
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&bad), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_timing_datagram_roundtrip_and_truncation() {
|
||||||
|
let t = HostTiming {
|
||||||
|
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
|
||||||
|
host_us: 4_321,
|
||||||
|
};
|
||||||
|
let d = encode_host_timing_datagram(&t);
|
||||||
|
assert_eq!(d[0], HOST_TIMING_MAGIC);
|
||||||
|
assert_eq!(d.len(), 13);
|
||||||
|
assert_eq!(decode_host_timing_datagram(&d), Some(t));
|
||||||
|
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||||
|
for n in 0..d.len() {
|
||||||
|
assert_eq!(decode_host_timing_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
let mut bad = d.clone();
|
||||||
|
bad[0] = HDR_META_MAGIC;
|
||||||
|
assert_eq!(decode_host_timing_datagram(&bad), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_start_roundtrip() {
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 1,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 120,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Kwin,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 25_000,
|
||||||
|
name: Some("Test Device".into()),
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
|
video_caps: VIDEO_CAP_10BIT,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
|
||||||
|
preferred_codec: CODEC_HEVC,
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
|
let s = Start {
|
||||||
|
client_udp_port: 1234,
|
||||||
|
};
|
||||||
|
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compositor_pref_wire_and_names() {
|
||||||
|
for p in [
|
||||||
|
CompositorPref::Auto,
|
||||||
|
CompositorPref::Kwin,
|
||||||
|
CompositorPref::Wlroots,
|
||||||
|
CompositorPref::Mutter,
|
||||||
|
CompositorPref::Gamescope,
|
||||||
|
] {
|
||||||
|
assert_eq!(CompositorPref::from_u8(p.to_u8()), p);
|
||||||
|
assert_eq!(CompositorPref::from_name(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
// Aliases + unknowns.
|
||||||
|
assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin));
|
||||||
|
assert_eq!(
|
||||||
|
CompositorPref::from_name("sway"),
|
||||||
|
Some(CompositorPref::Wlroots)
|
||||||
|
);
|
||||||
|
assert_eq!(CompositorPref::from_name("nope"), None);
|
||||||
|
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||||
|
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_pref_wire_and_names() {
|
||||||
|
for p in [
|
||||||
|
GamepadPref::Auto,
|
||||||
|
GamepadPref::Xbox360,
|
||||||
|
GamepadPref::DualSense,
|
||||||
|
GamepadPref::XboxOne,
|
||||||
|
GamepadPref::DualShock4,
|
||||||
|
] {
|
||||||
|
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||||
|
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
|
||||||
|
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
|
||||||
|
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
|
||||||
|
// Aliases + unknowns.
|
||||||
|
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||||
|
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||||
|
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("xbox-one"),
|
||||||
|
Some(GamepadPref::XboxOne)
|
||||||
|
);
|
||||||
|
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
|
||||||
|
assert_eq!(GamepadPref::from_name("nope"), None);
|
||||||
|
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||||
|
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_welcome_compositor_back_compat() {
|
||||||
|
// Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's
|
||||||
|
// shorter message still decodes (missing fields = Auto), and a legacy peer reading a
|
||||||
|
// new message ignores the trailing bytes. Simulate both directions by truncation.
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Mutter,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 80_000,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
};
|
||||||
|
let enc = h.encode();
|
||||||
|
assert_eq!(enc.len(), 26);
|
||||||
|
// Legacy (20-byte) Hello → both Auto, no bitrate, mode intact.
|
||||||
|
let legacy = Hello::decode(&enc[..20]).unwrap();
|
||||||
|
assert_eq!(legacy.compositor, CompositorPref::Auto);
|
||||||
|
assert_eq!(legacy.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy.bitrate_kbps, 0);
|
||||||
|
assert_eq!(legacy.mode, h.mode);
|
||||||
|
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
|
||||||
|
let mid = Hello::decode(&enc[..21]).unwrap();
|
||||||
|
assert_eq!(mid.compositor, CompositorPref::Mutter);
|
||||||
|
assert_eq!(mid.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (22-byte) Hello → compositor + gamepad intact, bitrate 0 (host default).
|
||||||
|
let pre_bitrate = Hello::decode(&enc[..22]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate.gamepad, GamepadPref::DualSense);
|
||||||
|
assert_eq!(pre_bitrate.bitrate_kbps, 0);
|
||||||
|
// Full message → bitrate intact.
|
||||||
|
assert_eq!(Hello::decode(&enc).unwrap().bitrate_kbps, 80_000);
|
||||||
|
|
||||||
|
let w = Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 7000,
|
||||||
|
mode: h.mode,
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [3u8; 16],
|
||||||
|
salt: [9, 8, 7, 6],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Kwin,
|
||||||
|
gamepad: GamepadPref::Xbox360,
|
||||||
|
bitrate_kbps: 120_000,
|
||||||
|
bit_depth: 10,
|
||||||
|
color: ColorInfo::HDR10_BT2020_PQ,
|
||||||
|
chroma_format: CHROMA_IDC_444,
|
||||||
|
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||||
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
};
|
||||||
|
let wenc = w.encode();
|
||||||
|
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||||
|
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||||
|
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||||
|
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(legacy_w.frames, 0);
|
||||||
|
assert_eq!(legacy_w.key, w.key);
|
||||||
|
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
|
||||||
|
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
|
||||||
|
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (55-byte) Welcome → gamepad intact, bitrate 0 (unknown).
|
||||||
|
let pre_bitrate_w = Welcome::decode(&wenc[..55]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate_w.gamepad, GamepadPref::Xbox360);
|
||||||
|
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(pre_bitrate_w.bit_depth, 8); // older host (no trailing byte) → 8-bit assumed
|
||||||
|
assert_eq!(legacy_w.bit_depth, 8);
|
||||||
|
// A pre-colour (60-byte) Welcome → SDR BT.709 (the only colour those hosts produced).
|
||||||
|
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
|
||||||
|
assert_eq!(pre_color_w.bit_depth, 10);
|
||||||
|
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
|
||||||
|
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
|
||||||
|
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||||
|
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
|
||||||
|
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
|
||||||
|
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
|
||||||
|
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
|
||||||
|
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
|
||||||
|
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
|
||||||
|
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
|
||||||
|
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
|
||||||
|
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
|
||||||
|
assert_eq!(pre_audio_w.audio_channels, 2);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().color,
|
||||||
|
ColorInfo::HDR10_BT2020_PQ
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().chroma_format,
|
||||||
|
CHROMA_IDC_444
|
||||||
|
); // full form carries 4:4:4
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||||
|
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
|
||||||
|
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().host_caps,
|
||||||
|
HOST_CAP_GAMEPAD_STATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_name_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: Some("Enrico's MacBook".into()),
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
};
|
||||||
|
let enc = base.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&enc).unwrap().name.as_deref(),
|
||||||
|
Some("Enrico's MacBook")
|
||||||
|
);
|
||||||
|
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
|
||||||
|
// host reading a bitrate-era Hello decodes name = None.
|
||||||
|
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
|
||||||
|
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
|
||||||
|
let unnamed = Hello {
|
||||||
|
name: None,
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(unnamed.encode().len(), 26);
|
||||||
|
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
|
||||||
|
let long = Hello {
|
||||||
|
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode()).unwrap();
|
||||||
|
let n = dec.name.expect("truncated name still present");
|
||||||
|
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
|
||||||
|
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
|
||||||
|
let mut bad_len = unnamed.encode();
|
||||||
|
bad_len.push(40); // claims 40 name bytes, none follow
|
||||||
|
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
|
||||||
|
let mut bad_utf8 = unnamed.encode();
|
||||||
|
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
|
||||||
|
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_launch_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
};
|
||||||
|
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||||
|
let with_launch = Hello {
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch);
|
||||||
|
// launch + name together.
|
||||||
|
let both = Hello {
|
||||||
|
name: Some("Enrico's Mac".into()),
|
||||||
|
launch: Some("custom:abc123".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
|
||||||
|
// name but no launch (a name-era client): launch decodes None.
|
||||||
|
let name_only = Hello {
|
||||||
|
name: Some("Enrico's Mac".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None);
|
||||||
|
// Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted).
|
||||||
|
assert_eq!(base.encode().len(), 26);
|
||||||
|
assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None);
|
||||||
|
// A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it.
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&with_launch.encode()[..26]).unwrap().launch,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX.
|
||||||
|
let long = Hello {
|
||||||
|
launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode())
|
||||||
|
.unwrap()
|
||||||
|
.launch
|
||||||
|
.expect("present");
|
||||||
|
assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconfigure_roundtrip() {
|
||||||
|
let rq = Reconfigure {
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 144,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq);
|
||||||
|
for accepted in [true, false] {
|
||||||
|
let rs = Reconfigured {
|
||||||
|
accepted,
|
||||||
|
mode: rq.mode,
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs);
|
||||||
|
}
|
||||||
|
// The type byte separates the post-handshake messages from each other.
|
||||||
|
assert!(Reconfigure::decode(
|
||||||
|
&Reconfigured {
|
||||||
|
accepted: true,
|
||||||
|
mode: rq.mode
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_keyframe_roundtrip() {
|
||||||
|
let bytes = RequestKeyframe.encode();
|
||||||
|
assert!(RequestKeyframe::decode(&bytes).is_ok());
|
||||||
|
// Distinct from the other control messages — its type byte must not collide.
|
||||||
|
let mode = Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&bytes).is_err());
|
||||||
|
// Length is exact (no trailing bytes accepted).
|
||||||
|
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loss_report_roundtrip() {
|
||||||
|
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
|
||||||
|
let r = LossReport { loss_ppm };
|
||||||
|
assert_eq!(LossReport::decode(&r.encode()).unwrap(), r);
|
||||||
|
}
|
||||||
|
// Disjoint from the other control messages (type byte + length).
|
||||||
|
assert!(LossReport::decode(&RequestKeyframe.encode()).is_err());
|
||||||
|
assert!(RequestKeyframe::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||||
|
assert!(LossReport::decode(
|
||||||
|
&[LossReport { loss_ppm: 0 }.encode().as_slice(), &[0]].concat()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_loss_ppm_estimates_and_caps() {
|
||||||
|
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 0), 0);
|
||||||
|
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
|
||||||
|
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
||||||
|
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
|
||||||
|
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
||||||
|
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
|
||||||
|
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
|
||||||
|
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitrate_messages_roundtrip() {
|
||||||
|
let req = SetBitrate {
|
||||||
|
bitrate_kbps: 14_000,
|
||||||
|
};
|
||||||
|
assert_eq!(SetBitrate::decode(&req.encode()).unwrap(), req);
|
||||||
|
let ack = BitrateChanged {
|
||||||
|
bitrate_kbps: 14_000,
|
||||||
|
};
|
||||||
|
assert_eq!(BitrateChanged::decode(&ack.encode()).unwrap(), ack);
|
||||||
|
// Same payload shape as LossReport — the type byte alone must keep them disjoint.
|
||||||
|
assert!(LossReport::decode(&req.encode()).is_err());
|
||||||
|
assert!(SetBitrate::decode(&ack.encode()).is_err());
|
||||||
|
assert!(BitrateChanged::decode(&req.encode()).is_err());
|
||||||
|
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_messages_roundtrip() {
|
||||||
|
let req = ProbeRequest {
|
||||||
|
target_kbps: 250_000,
|
||||||
|
duration_ms: 2000,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeRequest::decode(&req.encode()).unwrap(), req);
|
||||||
|
let res = ProbeResult {
|
||||||
|
bytes_sent: 62_500_000,
|
||||||
|
packets_sent: 480,
|
||||||
|
duration_ms: 2003,
|
||||||
|
wire_packets_sent: 41_000,
|
||||||
|
send_dropped: 1_200,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
|
||||||
|
assert_eq!(res.encode().len(), 29);
|
||||||
|
// A pre-wire-stats host's 21-byte ProbeResult still decodes, with the new fields zeroed.
|
||||||
|
let legacy = {
|
||||||
|
let full = res.encode();
|
||||||
|
full[..21].to_vec()
|
||||||
|
};
|
||||||
|
let decoded = ProbeResult::decode(&legacy).unwrap();
|
||||||
|
assert_eq!(decoded.wire_packets_sent, 0);
|
||||||
|
assert_eq!(decoded.send_dropped, 0);
|
||||||
|
assert_eq!(decoded.bytes_sent, res.bytes_sent);
|
||||||
|
// Type bytes keep the control messages disjoint from each other.
|
||||||
|
assert!(ProbeRequest::decode(&res.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&req.encode()).is_err());
|
||||||
|
assert!(ProbeResult::decode(&req.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_messages_roundtrip() {
|
||||||
|
let probe = ClockProbe {
|
||||||
|
t1_ns: 1_700_000_000_123,
|
||||||
|
};
|
||||||
|
assert_eq!(ClockProbe::decode(&probe.encode()).unwrap(), probe);
|
||||||
|
let echo = ClockEcho {
|
||||||
|
t1_ns: 1_700_000_000_123,
|
||||||
|
t2_ns: 1_700_000_050_456,
|
||||||
|
t3_ns: 1_700_000_050_789,
|
||||||
|
};
|
||||||
|
assert_eq!(ClockEcho::decode(&echo.encode()).unwrap(), echo);
|
||||||
|
// Disjoint from the other control messages (distinct type bytes).
|
||||||
|
assert!(ClockProbe::decode(&echo.encode()).is_err());
|
||||||
|
assert!(ProbeRequest::decode(&probe.encode()).is_err());
|
||||||
|
assert!(ClockEcho::decode(&probe.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_offset_picks_min_rtt_and_recovers_offset() {
|
||||||
|
// Host clock is +1_000_000 ns ahead of the client. Construct samples where a symmetric
|
||||||
|
// round-trip recovers exactly that offset, and a noisy (asymmetric, high-RTT) sample is
|
||||||
|
// present but must be ignored by the min-RTT selection.
|
||||||
|
const OFF: i64 = 1_000_000;
|
||||||
|
// Clean sample: client t1=0, one-way=200µs each way → t2 = t1 + 200_000 + OFF (host clock),
|
||||||
|
// t3 = t2 + 50_000 (host processing), t4 = t3 - OFF + 200_000 (back in client clock).
|
||||||
|
let t1 = 0u64;
|
||||||
|
let t2 = (t1 as i64 + 200_000 + OFF) as u64;
|
||||||
|
let t3 = t2 + 50_000;
|
||||||
|
let t4 = (t3 as i64 - OFF + 200_000) as u64;
|
||||||
|
// Noisy sample: same offset but a fat, asymmetric RTT (slow return path) — higher RTT.
|
||||||
|
let n1 = 1_000_000u64;
|
||||||
|
let n2 = (n1 as i64 + 200_000 + OFF) as u64;
|
||||||
|
let n3 = n2 + 50_000;
|
||||||
|
let n4 = (n3 as i64 - OFF + 5_000_000) as u64; // 5 ms return → big RTT
|
||||||
|
let (offset, rtt) =
|
||||||
|
clock_offset_ns(&[(n1, n2, n3, n4), (t1, t2, t3, t4)]).expect("non-empty");
|
||||||
|
// The min-RTT sample recovers the offset exactly; its RTT is 2x200us, and the noisy
|
||||||
|
// (asymmetric, 5 ms return) sample is ignored by the min-RTT selection.
|
||||||
|
assert_eq!(offset, OFF);
|
||||||
|
assert_eq!(rtt, 400_000);
|
||||||
|
assert!(clock_offset_ns(&[]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mid-stream re-sync state machine: 8 rounds collected via matched echoes, stale
|
||||||
|
/// echoes ignored, a restarted batch abandons the old one, and the batch result is the
|
||||||
|
/// min-RTT estimate — the exact behavior the connect-time `clock_sync` loop has.
|
||||||
|
#[test]
|
||||||
|
fn clock_resync_collects_rounds_and_ignores_stale_echoes() {
|
||||||
|
// Host clock +1 ms ahead; symmetric 100 µs one-way paths except one congested round.
|
||||||
|
const OFF: i64 = 1_000_000;
|
||||||
|
let echo_for = |t1: u64, one_way: u64| ClockEcho {
|
||||||
|
t1_ns: t1,
|
||||||
|
t2_ns: (t1 as i64 + one_way as i64 + OFF) as u64,
|
||||||
|
t3_ns: (t1 as i64 + one_way as i64 + OFF) as u64 + 10_000,
|
||||||
|
};
|
||||||
|
let t4_for = |e: &ClockEcho, one_way: u64| (e.t3_ns as i64 - OFF + one_way as i64) as u64;
|
||||||
|
|
||||||
|
let mut rs = ClockResync::new();
|
||||||
|
// An unsolicited echo before any batch is ignored.
|
||||||
|
assert_eq!(rs.on_echo(&echo_for(42, 100_000), 500_000), ResyncStep::Idle);
|
||||||
|
|
||||||
|
let mut probe = rs.begin(1_000_000);
|
||||||
|
// A stale echo (wrong t1: the abandoned pre-begin probe) is ignored mid-batch.
|
||||||
|
assert_eq!(rs.on_echo(&echo_for(42, 100_000), 500_000), ResyncStep::Idle);
|
||||||
|
for round in 0..ClockResync::ROUNDS {
|
||||||
|
// Round 3 is congested (5 ms one-way) — it must lose the min-RTT selection.
|
||||||
|
let one_way = if round == 3 { 5_000_000 } else { 100_000 };
|
||||||
|
let echo = echo_for(probe.t1_ns, one_way);
|
||||||
|
let t4 = t4_for(&echo, one_way);
|
||||||
|
match rs.on_echo(&echo, t4) {
|
||||||
|
ResyncStep::Probe(p) => {
|
||||||
|
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
|
||||||
|
probe = p;
|
||||||
|
}
|
||||||
|
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||||
|
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
|
||||||
|
assert_eq!(offset_ns, OFF, "min-RTT round recovers the offset exactly");
|
||||||
|
assert_eq!(rtt_ns, 200_000); // 2×100 µs; host processing (t3−t2) excluded
|
||||||
|
}
|
||||||
|
ResyncStep::Idle => panic!("matched echo must advance the batch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The batch is done: even a matching-t1 replay no longer advances anything.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(probe.t1_ns, 100_000), probe.t1_ns + 300_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
// begin() mid-batch abandons the in-flight batch: its echo is stale afterwards.
|
||||||
|
let old = rs.begin(2_000_000);
|
||||||
|
let fresh = rs.begin(3_000_000);
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
|
||||||
|
ResyncStep::Probe(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
|
||||||
|
/// replace the offset — its queueing delay biases the estimate exactly when frames
|
||||||
|
/// already read late. Floor of 2 ms so a near-zero connect RTT (same-host/LAN) doesn't
|
||||||
|
/// reject every later batch over normal jitter.
|
||||||
|
#[test]
|
||||||
|
fn clock_resync_acceptance_guard() {
|
||||||
|
// Generous connect RTT (10 ms): accept up to 1.5×.
|
||||||
|
assert!(accept_resync(14_000_000, 10_000_000));
|
||||||
|
assert!(!accept_resync(16_000_000, 10_000_000));
|
||||||
|
// Tiny connect RTT (200 µs, wired LAN): the 2 ms floor governs.
|
||||||
|
assert!(accept_resync(1_900_000, 200_000));
|
||||||
|
assert!(!accept_resync(2_100_000, 200_000));
|
||||||
|
// Boundary: exactly at the bound is accepted.
|
||||||
|
assert!(accept_resync(2_000_000, 0));
|
||||||
|
assert!(accept_resync(15_000_000, 10_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_messages_disjoint_from_hello() {
|
||||||
|
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
|
||||||
|
// any abi_version — can be misparsed as a control message, and vice-versa.
|
||||||
|
for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] {
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: abi,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
|
assert!(Reconfigure::decode(&h).is_err());
|
||||||
|
}
|
||||||
|
// And a PairRequest never parses as a Hello.
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: "x".into(),
|
||||||
|
spake_a: vec![0u8; 33],
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(Hello::decode(&pr).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_messages_roundtrip() {
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: "Enrico's Mac".into(),
|
||||||
|
spake_a: vec![1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr);
|
||||||
|
let pc = PairChallenge {
|
||||||
|
spake_b: vec![9; 33],
|
||||||
|
confirm: [7u8; 32],
|
||||||
|
};
|
||||||
|
assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc);
|
||||||
|
let pp = PairProof { confirm: [3u8; 32] };
|
||||||
|
assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp);
|
||||||
|
for ok in [true, false] {
|
||||||
|
assert_eq!(
|
||||||
|
PairResult::decode(&PairResult { ok }.encode()).unwrap().ok,
|
||||||
|
ok
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Length-exact: a truncated/padded PairProof is rejected.
|
||||||
|
let mut bad = pp.encode();
|
||||||
|
bad.push(0);
|
||||||
|
assert!(PairProof::decode(&bad).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spake2_pairing_agrees_only_on_matching_pin_and_certs() {
|
||||||
|
let cfp = [0x11u8; 32];
|
||||||
|
let hfp = [0x22u8; 32];
|
||||||
|
|
||||||
|
// Right PIN, same fingerprint views on both sides → both confirmations agree.
|
||||||
|
let (ca, ma) = pake::start(true, "4321", &cfp, &hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client));
|
||||||
|
|
||||||
|
// Wrong PIN → different keys → confirmations DON'T match (one online guess wasted).
|
||||||
|
let (ca, ma) = pake::start(true, "0000", &cfp, &hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(!pake::verify(&a.client, &b.client));
|
||||||
|
|
||||||
|
// MITM: the two legs saw different host certs → no agreement even with the right PIN.
|
||||||
|
let attacker_hfp = [0x33u8; 32];
|
||||||
|
let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(!pake::verify(&a.client, &b.client));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_datagram_roundtrip() {
|
||||||
|
let opus = [0x42u8; 97];
|
||||||
|
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
|
||||||
|
assert_eq!(d[0], AUDIO_MAGIC);
|
||||||
|
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
|
||||||
|
assert_eq!((seq, pts), (7, 1_000_000_123));
|
||||||
|
assert_eq!(payload, opus);
|
||||||
|
assert!(decode_audio_datagram(&d[..12]).is_none()); // truncated header
|
||||||
|
assert!(decode_audio_datagram(&[0u8; 13]).is_none()); // bad magic
|
||||||
|
|
||||||
|
// Empty payload is legal (DTX) — header-only datagram.
|
||||||
|
let header_only = encode_audio_datagram(0, 0, &[]);
|
||||||
|
let (_, _, empty) = decode_audio_datagram(&header_only).unwrap();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumble_datagram_roundtrip() {
|
||||||
|
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
|
||||||
|
assert_eq!(d[0], RUMBLE_MAGIC);
|
||||||
|
assert_eq!(decode_rumble_datagram(&d), Some((1, 0x1234, 0xFFFF)));
|
||||||
|
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mic_datagram_roundtrip_and_disjoint_from_audio() {
|
||||||
|
let opus = [0x5Au8; 80];
|
||||||
|
let d = encode_mic_datagram(42, 9_999, &opus);
|
||||||
|
assert_eq!(d[0], MIC_MAGIC);
|
||||||
|
let (seq, pts, payload) = decode_mic_datagram(&d).unwrap();
|
||||||
|
assert_eq!((seq, pts), (42, 9_999));
|
||||||
|
assert_eq!(payload, opus);
|
||||||
|
assert!(decode_mic_datagram(&d[..12]).is_none()); // truncated
|
||||||
|
// Tag separation: a mic datagram is not an audio datagram and vice-versa.
|
||||||
|
assert!(decode_audio_datagram(&d).is_none());
|
||||||
|
assert!(decode_mic_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||||
|
// Empty payload (DTX) is legal.
|
||||||
|
assert!(decode_mic_datagram(&encode_mic_datagram(0, 0, &[]))
|
||||||
|
.unwrap()
|
||||||
|
.2
|
||||||
|
.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],
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 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],
|
||||||
|
},
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad: 0,
|
||||||
|
side: 1,
|
||||||
|
amplitude: 0x1234,
|
||||||
|
period: 0x5678,
|
||||||
|
count: 9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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.
|
||||||
|
let a = endpoint::cert_fingerprint(b"cert-a");
|
||||||
|
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||||
|
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user