diff --git a/crates/punktfunk-core/src/quic/caps.rs b/crates/punktfunk-core/src/quic/caps.rs new file mode 100644 index 00000000..9181b625 --- /dev/null +++ b/crates/punktfunk-core/src/quic/caps.rs @@ -0,0 +1,165 @@ +//! Client/host video capability bits, codec + chroma negotiation, and colour signalling. + +/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream. +pub const VIDEO_CAP_10BIT: u8 = 0x01; +/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit). +pub const VIDEO_CAP_HDR: u8 = 0x02; +/// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC +/// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a +/// client-side setting, default OFF — the per-session policy switch). The host emits 4:4:4 ONLY +/// when this bit is set, the host allows it (`PUNKTFUNK_444`, default on), the codec is HEVC, +/// **and** the GPU/driver actually supports a 4:4:4 encode (probed) — otherwise the session stays +/// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of +/// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine +/// where the hardware allows). +pub const VIDEO_CAP_444: u8 = 0x04; +/// [`Hello::video_caps`] bit: the client consumes per-AU host-timing datagrams +/// ([`HOST_TIMING_MAGIC`], 0xCF) — the host's capture→send duration per frame, letting the client +/// split its `host+network` latency stage into `host` and `network` +/// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older +/// host ignores it and simply never sends any); a client that doesn't set it keeps the combined +/// stage. Purely observability — never changes what the host encodes. +pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08; +/// [`Hello::video_caps`] bit: the client's reassembler keeps **speed-test probe filler in its own +/// frame-index space** (a second reassembly window keyed on the [`crate::packet::FLAG_PROBE`] +/// user-flag), so probe bursts no longer consume video `frame_index`es. Without this, a mid-session +/// speed test burns thousands of video indexes that are invisible to every client-side gap detector +/// (probe frames are filtered before the pump sees them) — the first real AU afterwards reads as a +/// phantom multi-thousand-frame loss (spurious freeze + a nonsense RFI). It also lets the host's +/// encode loop own the video numbering outright (the wire-index contract +/// [`crate::packet::Packetizer::packetize_each`] documents), which reference-frame invalidation +/// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an +/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window +/// reassembler would silently drop as stale. +pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10; + +/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] +/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder +/// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the +/// lossy datagram plane, periodically refreshed) instead of the fragile per-transition +/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events. +pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01; + +/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** +/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST +/// advertise this. +pub const CODEC_H264: u8 = 0x01; +/// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing +/// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only). +pub const CODEC_HEVC: u8 = 0x02; +/// [`Hello::video_codecs`] bit: the client can decode AV1. +pub const CODEC_AV1: u8 = 0x04; +/// [`Hello::video_codecs`] bit: the client can decode **PyroWave** — the opt-in wired-LAN +/// intra-only wavelet codec (design/pyrowave-codec-plan.md; 100–400 Mbps class, 8-bit SDR, +/// every frame independently decodable). Deliberately **absent from [`resolve_codec`]'s +/// precedence ladder**: it is selected only when the client also names it +/// [`Hello::preferred_codec`] (or the host operator forces the advertisement mask) — a codec +/// that needs a wired-LAN bitrate must never win a negotiation just because both ends support +/// it. The bit means "PyroWave bitstream as of the punktfunk-vendored pin" +/// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream +/// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol +/// version instead (plan §4.2). +pub const CODEC_PYROWAVE: u8 = 0x08; + +/// Resolve which single codec the host will emit, from the client's advertised [`Hello::video_codecs`] +/// bitfield (`0` = an older client, treated as HEVC-only) intersected with what the host's chosen +/// encoder can produce (`host_capable`, also a bitfield). `preferred` is the client's soft preference +/// ([`Hello::preferred_codec`], `0` = none): when it's in the shared set it wins; otherwise the tie is +/// broken by **HEVC > AV1 > H.264** (HEVC is the established, best-tested path; H.264 is the +/// compatibility / software floor). [`CODEC_PYROWAVE`] is intentionally NOT in that ladder — it can +/// only be returned via the `preferred` path (plan §3: opt-in, pinned, honest). Returns the +/// single-bit codec value, or `None` when client and host share nothing the ladder may pick — the +/// caller then refuses the session with a clear error rather than emitting a stream the client +/// can't decode. +pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Option { + // An older client (no codec byte) decodes HEVC — the only codec every pre-negotiation build sent. + let client = if client_codecs == 0 { + CODEC_HEVC + } else { + client_codecs + }; + let shared = client & host_capable; + if shared == 0 { + return None; + } + // Honor the client's preference when the host can also emit it; else fall back to precedence. + if preferred != 0 && shared & preferred != 0 { + return Some(preferred); + } + // Precedence: HEVC > AV1 > H.264. + [CODEC_HEVC, CODEC_AV1, CODEC_H264] + .into_iter() + .find(|&c| shared & c != 0) +} + +/// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat +/// default when a peer omits [`Welcome::chroma_format`]. +pub const CHROMA_IDC_420: u8 = 1; +/// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions). +pub const CHROMA_IDC_444: u8 = 3; + +/// Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the +/// encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these +/// instead of inferring them from the bitstream VUI. An older host omits the bytes on the wire → +/// [`ColorInfo::SDR_BT709`] (the 8-bit BT.709 limited stream every pre-HDR build produced). +/// +/// The *static* HDR mastering metadata (ST.2086 + content light level) is larger and can change +/// mid-stream, so it rides the [`HDR_META_MAGIC`] datagram rather than this fixed struct. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ColorInfo { + /// CICP colour primaries: 1 = BT.709, 9 = BT.2020. + pub primaries: u8, + /// CICP transfer characteristics: 1 = BT.709, 16 = PQ (SMPTE ST.2084), 18 = HLG. + pub transfer: u8, + /// CICP matrix coefficients: 1 = BT.709, 9 = BT.2020 non-constant-luminance. + pub matrix: u8, + /// `video_full_range_flag`: 0 = limited/studio range, 1 = full range. + pub full_range: u8, +} + +impl ColorInfo { + /// CICP colour-primaries code point: BT.709. + pub const CP_BT709: u8 = 1; + /// CICP colour-primaries code point: BT.2020. + pub const CP_BT2020: u8 = 9; + /// CICP transfer code point: BT.709. + pub const TRC_BT709: u8 = 1; + /// CICP transfer code point: PQ (SMPTE ST.2084). + pub const TRC_PQ: u8 = 16; + /// CICP transfer code point: HLG (ARIB STD-B67 / BT.2100). + pub const TRC_HLG: u8 = 18; + /// CICP matrix code point: BT.709. + pub const MC_BT709: u8 = 1; + /// CICP matrix code point: BT.2020 non-constant-luminance. (Never emit 10 / constant-luminance — + /// no client decodes it.) + pub const MC_BT2020_NCL: u8 = 9; + + /// 8-bit BT.709 limited-range SDR — what every pre-HDR build produced, and the back-compat + /// default when a peer omits the colour bytes. + pub const SDR_BT709: ColorInfo = ColorInfo { + primaries: Self::CP_BT709, + transfer: Self::TRC_BT709, + matrix: Self::MC_BT709, + full_range: 0, + }; + + /// BT.2020 PQ (HDR10), limited range — what the Windows host's HEVC VUI emits. + pub const HDR10_BT2020_PQ: ColorInfo = ColorInfo { + primaries: Self::CP_BT2020, + transfer: Self::TRC_PQ, + matrix: Self::MC_BT2020_NCL, + full_range: 0, + }; + + /// True when the transfer is an HDR curve (PQ or HLG): the stream needs HDR present, and + /// (for PQ) a [`HdrMeta`] datagram carries the mastering metadata. + pub fn is_hdr(&self) -> bool { + self.transfer == Self::TRC_PQ || self.transfer == Self::TRC_HLG + } +} + +impl Default for ColorInfo { + fn default() -> Self { + Self::SDR_BT709 + } +} diff --git a/crates/punktfunk-core/src/quic/control.rs b/crates/punktfunk-core/src/quic/control.rs new file mode 100644 index 00000000..6b2a837d --- /dev/null +++ b/crates/punktfunk-core/src/quic/control.rs @@ -0,0 +1,470 @@ +//! Typed post-handshake control messages (`CTL_MAGIC` + type byte): reconfigure, keyframe, +//! RFI, loss reports, bitrate, bandwidth probes, and clock sync. + +use super::*; +use crate::config::Mode; +use crate::error::{PunktfunkError, Result}; + +/// `client → host`, any time after [`Start`]: switch the session to a new display mode +/// (window resized, refresh changed) without reconnecting. The host answers with +/// [`Reconfigured`]; on acceptance it rebuilds its virtual output + encoder at the new +/// mode and the stream continues over the unchanged data plane — the first new-mode frame +/// is an IDR with in-band parameter sets, which is all a decoder needs to follow. +/// +/// Post-handshake messages carry a type byte after the magic (the handshake itself is +/// positional and stays untyped for wire compatibility). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reconfigure { + pub mode: Mode, +} + +/// `host → client`: answer to [`Reconfigure`]. `accepted = false` means the requested +/// mode was rejected (e.g. exceeds encoder limits) and the session continues at `mode` +/// (the still-active one); `true` means `mode` is now being switched to live. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reconfigured { + pub accepted: bool, + pub mode: Mode, +} + +/// `client → host`, any time after [`Start`]: ask the host's encoder to emit a fresh IDR +/// keyframe NOW. The infinite-GOP stream opens with one IDR then sends P-frames only, so a +/// decoder that wedges (a lost/corrupt opening IDR, a bad early P-frame — most likely on the +/// cold first session) would otherwise stay frozen until the next loss-triggered recovery +/// keyframe, which may be far off. The client sends this when it detects a stalled decode; +/// the host forces the next frame to be an IDR with in-band parameter sets, recovering the +/// picture in ~one frame. Fire-and-forget — no reply (the recovered IDR is the ack). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RequestKeyframe; + +/// `client → host`: reference-frame-invalidation recovery — the loss-aware sibling of +/// [`RequestKeyframe`]. The client detected a `frame_index` gap and reports the range `[first_frame, +/// last_frame]` of access units it can no longer trust (from the first missing index through the +/// newest received). Instead of a full IDR (a 20-40× spike that deepens the loss it recovers), a host +/// whose encoder supports RFI re-references a known-good picture *before* `first_frame` — an AMD LTR +/// force-reference or an NVENC `nvEncInvalidateRefFrames` — emitting a single clean P-frame it tags +/// [`crate::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its freeze on it. A host that +/// can't RFI (no valid reference / libavcodec backend) forces an IDR instead, exactly as for a bare +/// [`RequestKeyframe`]; a host that predates this ignores the unknown message and the client's +/// keyframe backstop still recovers. Fire-and-forget — the recovered frame is the only ack. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RfiRequest { + /// First access-unit `frame_index` the client can no longer trust (the gap start). + pub first_frame: u32, + /// Newest received `frame_index` at the time of the report (the invalidation range end). + pub last_frame: u32, +} + +/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to +/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards +/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report +/// window — i.e. the loss FEC is currently absorbing. The host maps it to a recovery percentage, +/// clamped to a sane band, and applies it live; a clean link decays toward the floor (fewer packets, +/// which directly helps a packet-rate-bound uplink like the Steam Deck's WiFi tx). Fire-and-forget. +/// A host that predates this ignores it (unknown control message) and keeps its static FEC. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LossReport { + pub loss_ppm: u32, +} + +/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate +/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request +/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with +/// [`BitrateChanged`] carrying the value it actually configured, and rebuilds the encoder in +/// place at the same mode — the first new-rate frame is an IDR with in-band parameter sets, which +/// every client decoder already follows (same discipline as a [`Reconfigure`] mode switch). +/// +/// Sent by the client's automatic-bitrate controller (active when the user's bitrate setting is +/// "Automatic", i.e. `Hello::bitrate_kbps == 0`) when the link can't sustain the current rate — +/// or can sustain more again. A host that predates this ignores it (unknown control message) and +/// never answers; the client's controller detects the silence and disables itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SetBitrate { + /// Requested encoder bitrate in kilobits per second (`0` = host default, like Hello's field). + pub bitrate_kbps: u32, +} + +/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the +/// request clamped to its supported band). The encoder retargets in place where the backend can +/// (no IDR — the stream carries straight on); a backend without in-place reconfigure rebuilds and +/// switches on the next frame (an IDR). The stream never pauses either way. Also the controller's +/// liveness signal: no answer ⇒ an old host that doesn't renegotiate bitrate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BitrateChanged { + pub bitrate_kbps: u32, +} + +/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts +/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at +/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then +/// replies with [`ProbeResult`]. The client measures the received probe bytes + time to estimate +/// the link's sustainable rate (and the loss vs. the host's reported send count) so it can pick a +/// [`Hello::bitrate_kbps`]. The host clamps both fields to sane bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProbeRequest { + /// Goodput rate the host should send the probe at, in kilobits per second. + pub target_kbps: u32, + /// How long to burst, in milliseconds. + pub duration_ms: u32, +} + +/// `host → client`: the probe burst is finished. Reports what the host actually put on the wire so +/// the client can split the two failure modes apart: **host-side** drops (the send buffer couldn't +/// keep up — raise `net.core.wmem_max`) vs **link** loss (wire packets the air dropped). The client +/// measures delivered wire packets itself and computes: +/// +/// - link loss = `(wire_packets_sent − received) / wire_packets_sent` +/// - host drop = `send_dropped / (wire_packets_sent + send_dropped)` +/// - throughput = `received_wire_bytes * 8 / duration_ms` +/// +/// Counting delivered traffic at the *packet* level (not whole reassembled AUs) makes the figure +/// degrade gracefully past the FEC budget instead of cliffing to zero. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProbeResult { + /// Total access-unit payload bytes the host emitted for the probe (application goodput offered). + pub bytes_sent: u64, + /// Number of probe access units the host emitted. + pub packets_sent: u32, + /// The burst's actual duration in milliseconds (the host clamps/measures the request). + pub duration_ms: u32, + /// Wire packets the kernel ACCEPTED for transmission — what actually went on the link (offered + /// minus the send-buffer drops below). `0` from a pre-wire-stats host (back-compat decode). + pub wire_packets_sent: u32, + /// Wire packets the host could NOT hand to the kernel (send buffer full): the host-side ceiling. + pub send_dropped: u32, +} + +/// `client → host`, right after [`Start`]: one round of the wall-clock skew handshake. The client +/// stamps `t1_ns` (its monotonic-since-epoch clock) and sends; the host echoes it in [`ClockEcho`] +/// with its own receive/send stamps. A few rounds let the client estimate the host↔client clock +/// offset, so the per-frame `capture→received` latency (the AU `pts_ns` is the host's capture +/// clock) is meaningful across machines, not just same-host. An old host ignores it (the client +/// times out and assumes a shared clock). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClockProbe { + pub t1_ns: u64, +} + +/// `host → client`: answer to [`ClockProbe`]. `t2_ns` is when the host received the probe and +/// `t3_ns` when it sent this echo (both the host clock); `t1_ns` is the client's send stamp echoed +/// back. With the client's receive time `t4`, offset = ((t2−t1)+(t3−t4))/2 (host minus client) and +/// RTT = (t4−t1)−(t3−t2). See [`clock_offset_ns`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClockEcho { + pub t1_ns: u64, + pub t2_ns: u64, + pub t3_ns: u64, +} + +/// Type byte of [`Reconfigure`] (first byte after the magic). +pub const MSG_RECONFIGURE: u8 = 0x01; +/// Type byte of [`Reconfigured`]. +pub const MSG_RECONFIGURED: u8 = 0x02; +/// Type byte of [`RequestKeyframe`]. +pub const MSG_REQUEST_KEYFRAME: u8 = 0x03; +/// Type byte of [`LossReport`]. +pub const MSG_LOSS_REPORT: u8 = 0x04; +/// Type byte of [`SetBitrate`]. +pub const MSG_SET_BITRATE: u8 = 0x05; +/// Type byte of [`BitrateChanged`]. +pub const MSG_BITRATE_CHANGED: u8 = 0x06; +/// Type byte of [`RfiRequest`]. +pub const MSG_RFI_REQUEST: u8 = 0x07; +/// Type byte of [`ProbeRequest`]. +pub const MSG_PROBE_REQUEST: u8 = 0x20; +/// Type byte of [`ProbeResult`]. +pub const MSG_PROBE_RESULT: u8 = 0x21; +/// Type byte of [`ClockProbe`]. +pub const MSG_CLOCK_PROBE: u8 = 0x30; +/// Type byte of [`ClockEcho`]. +pub const MSG_CLOCK_ECHO: u8 = 0x31; + +impl Reconfigure { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] w[5..9] h[9..13] hz[13..17] + let mut b = Vec::with_capacity(17); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_RECONFIGURE); + b.extend_from_slice(&self.mode.width.to_le_bytes()); + b.extend_from_slice(&self.mode.height.to_le_bytes()); + b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 17 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RECONFIGURE { + return Err(PunktfunkError::InvalidArg("bad Reconfigure")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + Ok(Reconfigure { + mode: Mode { + width: u32at(5), + height: u32at(9), + refresh_hz: u32at(13), + }, + }) + } +} + +impl Reconfigured { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] accepted[5] w[6..10] h[10..14] hz[14..18] + let mut b = Vec::with_capacity(18); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_RECONFIGURED); + b.push(self.accepted as u8); + b.extend_from_slice(&self.mode.width.to_le_bytes()); + b.extend_from_slice(&self.mode.height.to_le_bytes()); + b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 18 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RECONFIGURED { + return Err(PunktfunkError::InvalidArg("bad Reconfigured")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + Ok(Reconfigured { + accepted: b[5] != 0, + mode: Mode { + width: u32at(6), + height: u32at(10), + refresh_hz: u32at(14), + }, + }) + } +} + +impl RequestKeyframe { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] — no payload + let mut b = Vec::with_capacity(5); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_REQUEST_KEYFRAME); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_REQUEST_KEYFRAME { + return Err(PunktfunkError::InvalidArg("bad RequestKeyframe")); + } + Ok(RequestKeyframe) + } +} + +impl RfiRequest { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] first_frame[5..9] last_frame[9..13] + let mut b = Vec::with_capacity(13); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_RFI_REQUEST); + b.extend_from_slice(&self.first_frame.to_le_bytes()); + b.extend_from_slice(&self.last_frame.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RFI_REQUEST { + return Err(PunktfunkError::InvalidArg("bad RfiRequest")); + } + Ok(RfiRequest { + first_frame: u32::from_le_bytes(b[5..9].try_into().unwrap()), + last_frame: u32::from_le_bytes(b[9..13].try_into().unwrap()), + }) + } +} + +impl LossReport { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] loss_ppm[5..9] + let mut b = Vec::with_capacity(9); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_LOSS_REPORT); + b.extend_from_slice(&self.loss_ppm.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_LOSS_REPORT { + return Err(PunktfunkError::InvalidArg("bad LossReport")); + } + Ok(LossReport { + loss_ppm: u32::from_le_bytes(b[5..9].try_into().unwrap()), + }) + } +} + +impl SetBitrate { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] bitrate_kbps[5..9] + let mut b = Vec::with_capacity(9); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_SET_BITRATE); + b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SET_BITRATE { + return Err(PunktfunkError::InvalidArg("bad SetBitrate")); + } + Ok(SetBitrate { + bitrate_kbps: u32::from_le_bytes(b[5..9].try_into().unwrap()), + }) + } +} + +impl BitrateChanged { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] bitrate_kbps[5..9] + let mut b = Vec::with_capacity(9); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_BITRATE_CHANGED); + b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_BITRATE_CHANGED { + return Err(PunktfunkError::InvalidArg("bad BitrateChanged")); + } + Ok(BitrateChanged { + bitrate_kbps: u32::from_le_bytes(b[5..9].try_into().unwrap()), + }) + } +} + +/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered +/// (the loss it absorbed), recovered-but-then-arrived shards (`late` — reordered delivery lets a +/// block reconstruct early, so those were never lost; netting them out keeps plain reordering from +/// reading as packet loss and spooking adaptive FEC + the bitrate controller), shards received, +/// and frames that went unrecoverable. Loss ≈ (recovered − late) / (received + recovered − late) — +/// the fraction of shards that truly never arrived (a late shard is inside `received`, so the +/// denominator nets it too; saturating, so reorder straddling a window boundary can't go +/// negative). A frame drop means loss exceeded the current FEC budget (so `recovered` plateaus), +/// so add a fixed bump to push the host's FEC up past the cap on the next adjustment. Returns +/// parts-per-million, capped at 1e6. +pub fn window_loss_ppm(recovered: u64, late: u64, received: u64, frames_dropped: u64) -> u32 { + let lost = recovered.saturating_sub(late); + let denom = received.saturating_add(lost); + let mut ppm = lost + .saturating_mul(1_000_000) + .checked_div(denom) + .unwrap_or(0) as u32; + if frames_dropped > 0 { + ppm = ppm.saturating_add(50_000); // +5%: unrecoverable loss → raise FEC past the current cap + } + ppm.min(1_000_000) +} + +impl ProbeRequest { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13] + let mut b = Vec::with_capacity(13); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_PROBE_REQUEST); + b.extend_from_slice(&self.target_kbps.to_le_bytes()); + b.extend_from_slice(&self.duration_ms.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_REQUEST { + return Err(PunktfunkError::InvalidArg("bad ProbeRequest")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + Ok(ProbeRequest { + target_kbps: u32at(5), + duration_ms: u32at(9), + }) + } +} + +impl ProbeResult { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] bytes_sent[5..13] packets_sent[13..17] duration_ms[17..21] + // wire_packets_sent[21..25] send_dropped[25..29] + let mut b = Vec::with_capacity(29); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_PROBE_RESULT); + b.extend_from_slice(&self.bytes_sent.to_le_bytes()); + b.extend_from_slice(&self.packets_sent.to_le_bytes()); + b.extend_from_slice(&self.duration_ms.to_le_bytes()); + b.extend_from_slice(&self.wire_packets_sent.to_le_bytes()); + b.extend_from_slice(&self.send_dropped.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + // Back-compat: 21 bytes (pre-wire-stats host, new fields default 0) or 29 bytes (with the + // wire_packets_sent + send_dropped tail). Accept either; reject anything shorter/garbled. + if b.len() < 21 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_RESULT { + return Err(PunktfunkError::InvalidArg("bad ProbeResult")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + let (wire_packets_sent, send_dropped) = if b.len() >= 29 { + (u32at(21), u32at(25)) + } else { + (0, 0) + }; + Ok(ProbeResult { + bytes_sent: u64::from_le_bytes(b[5..13].try_into().unwrap()), + packets_sent: u32at(13), + duration_ms: u32at(17), + wire_packets_sent, + send_dropped, + }) + } +} + +impl ClockProbe { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] t1[5..13] + let mut b = Vec::with_capacity(13); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLOCK_PROBE); + b.extend_from_slice(&self.t1_ns.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLOCK_PROBE { + return Err(PunktfunkError::InvalidArg("bad ClockProbe")); + } + Ok(ClockProbe { + t1_ns: u64::from_le_bytes(b[5..13].try_into().unwrap()), + }) + } +} + +impl ClockEcho { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] t1[5..13] t2[13..21] t3[21..29] + let mut b = Vec::with_capacity(29); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_CLOCK_ECHO); + b.extend_from_slice(&self.t1_ns.to_le_bytes()); + b.extend_from_slice(&self.t2_ns.to_le_bytes()); + b.extend_from_slice(&self.t3_ns.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 29 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLOCK_ECHO { + return Err(PunktfunkError::InvalidArg("bad ClockEcho")); + } + Ok(ClockEcho { + t1_ns: u64::from_le_bytes(b[5..13].try_into().unwrap()), + t2_ns: u64::from_le_bytes(b[13..21].try_into().unwrap()), + t3_ns: u64::from_le_bytes(b[21..29].try_into().unwrap()), + }) + } +} + +/// Frame a message for the control stream: `u16 LE length || payload`. +pub fn frame(payload: &[u8]) -> Vec { + let mut b = Vec::with_capacity(2 + payload.len()); + b.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + b.extend_from_slice(payload); + b +} diff --git a/crates/punktfunk-core/src/quic/handshake.rs b/crates/punktfunk-core/src/quic/handshake.rs new file mode 100644 index 00000000..0651bf73 --- /dev/null +++ b/crates/punktfunk-core/src/quic/handshake.rs @@ -0,0 +1,497 @@ +//! The `punktfunk/1` positional handshake — Hello / Welcome / Start — and their wire codecs. + +use super::*; +use crate::config::{ + CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, +}; +use crate::error::{PunktfunkError, Result}; + +/// `client → host`: open the session, requesting a display mode (the host creates its +/// virtual output at exactly this size/refresh — native resolution end to end). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hello { + pub abi_version: u32, + pub mode: Mode, + /// Which compositor the client would like the host to drive (`Auto` = host decides). The + /// host honors it only if that backend is available, else falls back and reports the real + /// choice in [`Welcome::compositor`]. Appended to the wire form — omitted by older clients + /// (decodes to `Auto`). + pub compositor: CompositorPref, + /// Which virtual gamepad the host should create for this session's pads (`Auto` = host + /// decides: its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). Resolved choice echoed in + /// [`Welcome::gamepad`]. Appended to the wire form — omitted by older clients (decodes + /// to `Auto`). + pub gamepad: GamepadPref, + /// The client's desired video encoder bitrate, in kilobits per second. `0` = no preference + /// (the host uses its default). The host clamps the request to a supported range and reports + /// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form — + /// omitted by older clients (decodes to `0`, i.e. host default). + pub bitrate_kbps: u32, + /// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks + /// on a pairing-required host (the delegated-approval pending list) and stored on approval. + /// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by + /// older clients (decodes to `None`; the host falls back to a fingerprint-derived label). + pub name: Option, + /// Library entry the client wants this session to launch (the store-qualified `GameEntry.id`, + /// e.g. `steam:570` / `custom:abc123`). The host resolves it against ITS OWN library and runs + /// the matching launch recipe in the session — the client never sends a raw command, so a + /// remote peer can't inject one. `None` = no game requested (the host's default session). + /// Appended after `name` as `len u8 || UTF-8` (≤ [`HELLO_LAUNCH_MAX`] bytes); when present but + /// `name` is absent, a zero-length name placeholder precedes it so the offset stays + /// deterministic. Omitted by older clients (decodes to `None`). + pub launch: Option, + /// Client video capabilities the host may use to upgrade the stream — a bitfield of + /// [`VIDEO_CAP_10BIT`] (the client can decode 10-bit Main10 HEVC) and [`VIDEO_CAP_HDR`] + /// (the client can present BT.2020 PQ HDR10). The host enables a 10-bit / HDR encode ONLY + /// when the matching bit is set, so an older client (decodes to `0`) always gets the 8-bit + /// BT.709 stream it understands. Appended after `launch` as a single trailing byte; a + /// zero-length name/launch placeholder precedes it when those are absent so the offset stays + /// deterministic. Omitted by older clients (decodes to `0`). + pub video_caps: u8, + /// Requested audio channel count: `2` (stereo, default), `6` (5.1) or `8` (7.1). The host + /// resolves it against what it can capture and echoes the final count in + /// [`Welcome::audio_channels`], which is what both ends build their Opus (multistream) + /// codec from. Appended after `video_caps` as a single trailing byte; when it differs from + /// the stereo default the name/launch/video_caps placeholders are forced (0) so it lands at a + /// deterministic offset. Omitted by older clients / when `2` (decodes to `2`, i.e. stereo) so + /// the stereo wire form stays byte-identical to the pre-surround build. + pub audio_channels: u8, + /// Which video codecs the client can decode — a bitfield of [`CODEC_H264`] / [`CODEC_HEVC`] / + /// [`CODEC_AV1`]. The host picks one it can also produce (see [`resolve_codec`]) and reports it in + /// [`Welcome::codec`]; a client that only reaches a GPU-less **software** host must set + /// [`CODEC_H264`] (openh264 emits H.264). Appended after `audio_channels` as a single trailing + /// byte (forcing the video_caps/audio_channels placeholders when present). Omitted by older + /// clients (decodes to `0`, which [`resolve_codec`] treats as HEVC-only — every pre-negotiation + /// build decoded HEVC). + pub video_codecs: u8, + /// The client's *preferred* codec (a single [`CODEC_H264`] / [`CODEC_HEVC`] / [`CODEC_AV1`] bit), + /// or `0` = no preference (host decides by its own precedence). A **soft** hint: the host emits + /// it when it can also produce it (and the client advertised it in `video_codecs`), else falls + /// back to the best shared codec — see [`resolve_codec`]. Mirrors the [`Hello::compositor`] / + /// [`Hello::gamepad`] preference pattern; the resolved codec is echoed in [`Welcome::codec`]. + /// Appended after `video_codecs` as a single trailing byte. Omitted by older clients (→ `0`). + pub preferred_codec: u8, + /// The client's **display** HDR colour volume — primaries / white point / luminance range in + /// the ST.2086 units of [`HdrMeta`] — read from the client OS (e.g. Windows + /// `IDXGIOutput6::GetDesc1`) when it advertised [`VIDEO_CAP_HDR`]. The host forwards it into + /// the virtual display's EDID (the pf-vdisplay CTA-861.3 HDR static-metadata block), so host + /// apps and the OS tone-map to the CLIENT's real panel instead of the driver's built-in + /// ~1000-nit placeholder — the client can then present the PQ stream untouched. Also echoed + /// back as the session's `0xCE` mastering metadata. Appended after `preferred_codec` as a + /// fixed [`super::datagram::HDR_META_BODY_LEN`]-byte block (the [`HdrMeta`] wire body, no tag), + /// forcing the earlier placeholders. Omitted by older clients / when the client has no HDR + /// display (decodes to `None` — the host keeps its built-in EDID defaults). + pub display_hdr: Option, +} + +/// QUIC application error code a punktfunk/1 client closes the control connection with on a +/// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's +/// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the +/// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a +/// reconnect can resume. Shared so host + every client agree on the code. +pub const QUIT_CLOSE_CODE: u32 = 0x51; + +/// QUIC application error code the **host** closes the control connection with when a **dedicated game +/// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher +/// client can distinguish "the game ended" from an error and return to its library cleanly rather than +/// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of +/// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client +/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. +pub const APP_EXITED_CLOSE_CODE: u32 = 0x52; + +/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on +/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway). +pub const HELLO_NAME_MAX: usize = 64; + +/// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short +/// (`steam:` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. +pub const HELLO_LAUNCH_MAX: usize = 128; + +/// `host → client`: the complete session offer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Welcome { + pub abi_version: u32, + /// Host UDP port for the data plane. + pub udp_port: u16, + pub mode: Mode, + pub fec: FecConfig, + pub shard_payload: u16, + pub encrypt: bool, + pub key: [u8; 16], + pub salt: [u8; 4], + /// Seed/testing: how many frames the host will send (0 = unbounded). + pub frames: u32, + /// The compositor the host actually resolved for this session (the client's + /// [`Hello::compositor`] preference if available, else the host's auto-detected choice). + /// Appended to the wire form — `Auto` when an older host omitted it (i.e. "unknown"). + pub compositor: CompositorPref, + /// The virtual gamepad backend the host actually resolved (the client's [`Hello::gamepad`] + /// preference if available, else env var / X-Box 360). A client uses this to know whether + /// DualSense feedback (0xCD) can arrive at all. Appended to the wire form — `Auto` when an + /// older host omitted it (i.e. "unknown, assume X-Box 360"). + pub gamepad: GamepadPref, + /// The encoder bitrate the host actually configured for this session, in kilobits per second + /// (the client's [`Hello::bitrate_kbps`] clamped to the host's supported range, or the host + /// default when the client requested `0`). Appended to the wire form — `0` when an older host + /// omitted it (i.e. "unknown"). + pub bitrate_kbps: u32, + /// The luma/chroma bit depth the host actually encodes at — `8` (default / older host) or + /// `10` (Main10, enabled only when the client advertised [`VIDEO_CAP_10BIT`]). The client + /// configures its decoder for 10-bit (P010) when this is `10`. Appended to the wire form as a + /// single trailing byte; `8` when an older host omitted it. + pub bit_depth: u8, + /// The colour signalling (CICP primaries/transfer/matrix/range) the host encodes with — BT.709 + /// limited SDR by default, BT.2020 PQ when a 10-bit HDR session was negotiated. Appended after + /// `bit_depth` as 4 trailing bytes; an older host that omits them decodes to + /// [`ColorInfo::SDR_BT709`]. The client configures its decoder/presenter from this instead of + /// guessing from the bitstream; the mastering metadata arrives separately on [`HDR_META_MAGIC`]. + pub color: ColorInfo, + /// The chroma subsampling the host actually encodes at, as the HEVC `chroma_format_idc`: + /// [`CHROMA_IDC_420`] (4:2:0, default / older host) or [`CHROMA_IDC_444`] (full-chroma 4:4:4, + /// enabled only when the client advertised [`VIDEO_CAP_444`] *and* the host could open a real + /// 4:4:4 encode). The client sizes its decoder/surface pool from this; the in-band SPS carries + /// the authoritative value, so this is a hint (and the honest-downgrade channel — if the host + /// requested 4:4:4 but the GPU declined, this reads `CHROMA_IDC_420`). Appended after the colour + /// bytes as a single trailing byte; an older host that omits it decodes to [`CHROMA_IDC_420`]. + pub chroma_format: u8, + /// The audio channel count the host actually resolved and **will** send on the `0xC9` plane: + /// `2` (stereo, default), `6` (5.1) or `8` (7.1). Echoes [`Hello::audio_channels`] clamped to + /// what the host can capture (Linux PipeWire always synthesizes the count; Windows WASAPI + /// loopback is clamped to the render endpoint's mix-format channels). The client builds its Opus + /// (multistream) decoder from THIS value via [`crate::audio::layout_for`] — never from its own + /// request — so an older host that omits the byte (→ `2`) always yields working stereo. Appended + /// after `chroma_format` as a single trailing byte. + pub audio_channels: u8, + /// The single video codec the host resolved and **will** emit — [`CODEC_H264`], [`CODEC_HEVC`] + /// (default), or [`CODEC_AV1`] — from [`resolve_codec`] over the client's [`Hello::video_codecs`] + /// and the host encoder's capability. The client builds its decoder from THIS (never assuming + /// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it + /// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC). + pub codec: u8, + /// Host input capabilities — a bitfield of [`HOST_CAP_GAMEPAD_STATE`]. The client picks the + /// wire form its gamepad events take from this (snapshots for a capable host, the legacy + /// per-transition events otherwise). Appended after `codec` as a single trailing byte; an + /// older host that omits it decodes to `0` (no capabilities — legacy events only). + pub host_caps: u8, +} + +/// `client → host`: data plane is bound, begin streaming. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Start { + pub client_udp_port: u16, +} + +/// Truncate `s` to at most `max` bytes on a UTF-8 char boundary (so a multi-byte char straddling +/// the cap is dropped whole, never split). Shared by Hello's length-prefixed name/launch fields. +fn truncate_to(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut cut = max; + while !s.is_char_boundary(cut) { + cut -= 1; + } + &s[..cut] +} + +impl Hello { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(22); + b.extend_from_slice(MAGIC); + b.extend_from_slice(&self.abi_version.to_le_bytes()); + b.extend_from_slice(&self.mode.width.to_le_bytes()); + b.extend_from_slice(&self.mode.height.to_le_bytes()); + b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it + b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline + b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26 + // name at offset 26: len u8 || UTF-8. Omitted when `None` *and* there is no later field — + // so a Hello with neither name nor launch stays byte-identical to the bitrate-era form + // (26 bytes). When `launch` is present we must still emit name's length byte (0 for None) + // so `launch` lands at a deterministic offset. + // `video_caps`/`audio_channels` are the trailing fields, after `launch`; when either is + // present (video_caps non-zero / audio_channels not stereo) the name/launch length bytes + // AND the video_caps byte must still be emitted (0 / 0) so the later byte lands at a + // deterministic offset — the same discipline `launch` already imposes on `name`. + // Trailing single-byte fields, in wire order. Each is emitted when it (or ANY later field) + // carries a non-default value, so a present field always lands at a deterministic offset. + let ac_present = self.audio_channels != 2; + let vcodecs_present = self.video_codecs != 0; + let pref_present = self.preferred_codec != 0; + let hdr_present = self.display_hdr.is_some(); + let need_placeholders = + self.video_caps != 0 || ac_present || vcodecs_present || pref_present || hdr_present; + match (&self.name, &self.launch) { + (None, None) if !need_placeholders => {} + (name, _) => { + let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX); + b.push(n.len() as u8); + b.extend_from_slice(n.as_bytes()); + } + } + // launch after name: len u8 || UTF-8. + if self.launch.is_some() || need_placeholders { + let l = truncate_to(self.launch.as_deref().unwrap_or(""), HELLO_LAUNCH_MAX); + b.push(l.len() as u8); + b.extend_from_slice(l.as_bytes()); + } + // video_caps: single trailing byte. Emitted when non-zero OR when a later field follows (so + // that field lands at a deterministic offset right after it). + if need_placeholders { + b.push(self.video_caps); + } + // audio_channels: emitted when non-stereo OR a later field follows. + if ac_present || vcodecs_present || pref_present || hdr_present { + b.push(self.audio_channels); + } + // video_codecs: emitted when non-zero OR a later field follows. + if vcodecs_present || pref_present || hdr_present { + b.push(self.video_codecs); + } + // preferred_codec: emitted when non-zero OR display_hdr follows. + if pref_present || hdr_present { + b.push(self.preferred_codec); + } + // display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body. Last field; omitted when `None`. + if let Some(m) = &self.display_hdr { + super::datagram::write_hdr_meta_body(m, &mut b); + } + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 20 || &b[0..4] != MAGIC { + return Err(PunktfunkError::InvalidArg("bad Hello")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + // Locate the trailing single-byte fields once. name (26) and launch are `len u8 || UTF-8` + // blocks; their RAW length bytes (even when zero placeholders, or oversized garbage) + // determine where the tail starts, so a corrupt name never panics — it just pushes the + // later offsets out of range and those fields decode to their defaults. + let name_len = b.get(26).copied().unwrap_or(0) as usize; + let launch_off = 27 + name_len; // launch's length byte + let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize; + let tail = launch_off + 1 + launch_len; // first trailing byte: video_caps + Ok(Hello { + abi_version: u32at(4), + mode: Mode { + width: u32at(8), + height: u32at(12), + refresh_hz: u32at(16), + }, + // Optional trailing bytes — an older client that omits them requests `Auto`. + compositor: b + .get(20) + .map(|&v| CompositorPref::from_u8(v)) + .unwrap_or_default(), + gamepad: b + .get(21) + .map(|&v| GamepadPref::from_u8(v)) + .unwrap_or_default(), + // Optional trailing 4 bytes (LE) — absent on an older client → `0` (host default). + bitrate_kbps: b + .get(22..26) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + .unwrap_or(0), + // Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 → + // `None` (never fail the handshake over a label). + name: (name_len > 0 && name_len <= HELLO_NAME_MAX) + .then(|| { + b.get(27..27 + name_len) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(String::from) + }) + .flatten(), + // Optional trailing launch id, right after name's block (same len/UTF-8 discipline). + launch: (launch_len > 0 && launch_len <= HELLO_LAUNCH_MAX) + .then(|| { + b.get(launch_off + 1..launch_off + 1 + launch_len) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(String::from) + }) + .flatten(), + // The trailing single bytes, in wire order from `tail` (see the encode-side layout). + // Each is absent on an older client and decodes to its documented default. + video_caps: b.get(tail).copied().unwrap_or(0), + // Normalized so a corrupt/unsupported channel count can't build a bad decoder. + audio_channels: crate::audio::normalize_channels(b.get(tail + 1).copied().unwrap_or(2)), + // `0` = an older client (which `resolve_codec` treats as HEVC-only). + video_codecs: b.get(tail + 2).copied().unwrap_or(0), + // `0` = no preference; the host decides by precedence. + preferred_codec: b.get(tail + 3).copied().unwrap_or(0), + // Optional trailing HdrMeta body (fixed length) — absent on an older client / a + // client without an HDR display → `None` (the host keeps its EDID defaults). + display_hdr: b + .get(tail + 4..tail + 4 + super::datagram::HDR_META_BODY_LEN) + .map(super::datagram::read_hdr_meta_body), + }) + } +} + +impl Welcome { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(64); + b.extend_from_slice(MAGIC); + b.extend_from_slice(&self.abi_version.to_le_bytes()); + b.extend_from_slice(&self.udp_port.to_le_bytes()); + b.extend_from_slice(&self.mode.width.to_le_bytes()); + b.extend_from_slice(&self.mode.height.to_le_bytes()); + b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b.push(match self.fec.scheme { + FecScheme::Gf8 => 0, + FecScheme::Gf16 => 1, + }); + b.push(self.fec.fec_percent); + b.extend_from_slice(&self.fec.max_data_per_block.to_le_bytes()); + b.extend_from_slice(&self.shard_payload.to_le_bytes()); + b.push(self.encrypt as u8); + b.extend_from_slice(&self.key); + b.extend_from_slice(&self.salt); + b.extend_from_slice(&self.frames.to_le_bytes()); + b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it + b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline + b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 55..59 + b.push(self.bit_depth); // appended at offset 59 — older clients read [0..59] and skip it + // Colour signalling at offsets 60..64 — older clients stop before these → SDR BT.709. + b.push(self.color.primaries); + b.push(self.color.transfer); + b.push(self.color.matrix); + b.push(self.color.full_range); + // Chroma subsampling at offset 64 — older clients stop before this → 4:2:0 (CHROMA_IDC_420). + b.push(self.chroma_format); + // Audio channel count at offset 65 — older clients stop before this → stereo (2). + b.push(self.audio_channels); + // Resolved video codec at offset 66 — older clients stop before this → HEVC. + b.push(self.codec); + // Host input caps at offset 67 — older clients stop before this → 0 (legacy input only). + b.push(self.host_caps); + b + } + + pub fn decode(b: &[u8]) -> Result { + // Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22] + // scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45] + // salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59] + // bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63] + // chroma_format[64] audio_channels[65] codec[66] (everything from compositor on is an + // optional trailing byte; an older host stops earlier). + if b.len() < 53 || &b[0..4] != MAGIC { + return Err(PunktfunkError::InvalidArg("bad Welcome")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); + let mut key = [0u8; 16]; + key.copy_from_slice(&b[29..45]); + let mut salt = [0u8; 4]; + salt.copy_from_slice(&b[45..49]); + Ok(Welcome { + abi_version: u32at(4), + udp_port: u16at(8), + mode: Mode { + width: u32at(10), + height: u32at(14), + refresh_hz: u32at(18), + }, + fec: FecConfig { + scheme: if b[22] == 1 { + FecScheme::Gf16 + } else { + FecScheme::Gf8 + }, + fec_percent: b[23], + max_data_per_block: u16at(24), + }, + shard_payload: u16at(26), + encrypt: b[28] != 0, + key, + salt, + frames: u32at(49), + // Optional trailing bytes — an older host that omits them leaves the resolved + // compositor / gamepad backend unknown (`Auto`). + compositor: b + .get(53) + .map(|&v| CompositorPref::from_u8(v)) + .unwrap_or_default(), + gamepad: b + .get(54) + .map(|&v| GamepadPref::from_u8(v)) + .unwrap_or_default(), + // Optional trailing 4 bytes (LE) — absent on an older host → `0` (unknown). + bitrate_kbps: b + .get(55..59) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + .unwrap_or(0), + // Optional trailing byte — absent on an older host → `8` (8-bit, the only depth they + // encode). + bit_depth: b.get(59).copied().unwrap_or(8), + // Optional trailing colour bytes — absent on an older host → SDR BT.709 limited. + color: ColorInfo { + primaries: b.get(60).copied().unwrap_or(ColorInfo::CP_BT709), + transfer: b.get(61).copied().unwrap_or(ColorInfo::TRC_BT709), + matrix: b.get(62).copied().unwrap_or(ColorInfo::MC_BT709), + full_range: b.get(63).copied().unwrap_or(0), + }, + // Optional trailing chroma byte — absent on an older host (or an explicit 0 / unknown + // value) → 4:2:0. Only `CHROMA_IDC_444` flips the client to a 4:4:4 decode. + chroma_format: match b.get(64).copied() { + Some(CHROMA_IDC_444) => CHROMA_IDC_444, + _ => CHROMA_IDC_420, + }, + // Optional trailing audio-channel byte — absent on an older host → stereo. Any + // non-{6,8} value normalizes to stereo so a corrupt byte never builds a bad decoder. + audio_channels: crate::audio::normalize_channels(b.get(65).copied().unwrap_or(2)), + // Optional trailing codec byte — absent on an older host (or an unknown value) → HEVC, + // the codec every pre-negotiation host emitted. + codec: match b.get(66).copied() { + Some(CODEC_H264) => CODEC_H264, + Some(CODEC_AV1) => CODEC_AV1, + Some(CODEC_PYROWAVE) => CODEC_PYROWAVE, + _ => CODEC_HEVC, + }, + // Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state + // snapshots; the client keeps sending legacy per-transition events). + host_caps: b.get(67).copied().unwrap_or(0), + }) + } + + /// Build the data-plane [`Config`] this offer describes (for `role`). + pub fn session_config(&self, role: Role) -> Config { + let mut c = Config::p1_defaults(role); + c.phase = ProtocolPhase::P1GameStream; // wire phase id pending the P2 packet rev + c.fec = self.fec; + c.shard_payload = self.shard_payload as usize; + c.encrypt = self.encrypt; + c.key = self.key; + c.salt = self.salt; + // Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is + // ~10x larger than any real access unit. Derive it from the negotiated rate instead: + // 4x the average frame size at the resolved bitrate (IDR headroom), floored at 8 MiB, + // capped at the old 64 MiB. Purely local — the host never reassembles video and the + // wire is self-describing, so old hosts are unaffected; a host that reports bitrate 0 + // (pre-negotiation) keeps the old bound. + if role == Role::Client && self.bitrate_kbps > 0 { + let per_frame = (self.bitrate_kbps as usize).saturating_mul(125) + / self.mode.refresh_hz.max(1) as usize; + c.max_frame_bytes = per_frame.saturating_mul(4).clamp(8 << 20, 64 << 20); + } + c + } +} + +impl Start { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(6); + b.extend_from_slice(MAGIC); + b.extend_from_slice(&self.client_udp_port.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 6 || &b[0..4] != MAGIC { + return Err(PunktfunkError::InvalidArg("bad Start")); + } + Ok(Start { + client_udp_port: u16::from_le_bytes([b[4], b[5]]), + }) + } +} diff --git a/crates/punktfunk-core/src/quic/mod.rs b/crates/punktfunk-core/src/quic/mod.rs index f03c665d..8cf231aa 100644 --- a/crates/punktfunk-core/src/quic/mod.rs +++ b/crates/punktfunk-core/src/quic/mod.rs @@ -37,9 +37,12 @@ pub const MAGIC: &[u8; 4] = b"PKF1"; /// vice-versa, regardless of field values. pub const CTL_MAGIC: &[u8; 4] = b"PKFc"; +mod caps; mod clock; +mod control; mod datagram; -mod msgs; +mod handshake; +mod pairing; /// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via /// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via @@ -56,9 +59,17 @@ pub mod io; /// cannot reach a shared key). pub mod pake; +pub use caps::*; pub use clock::*; +pub use control::*; pub use datagram::*; -pub use msgs::*; +pub use handshake::*; +pub use pairing::*; + +// Typed rejection close codes + [`RejectReason`] live in `crate::reject` (ungated — the +// error enum references them even in `quic`-less builds) and are re-exported here so the +// wire vocabulary stays browsable next to QUIT/APP_EXITED. +pub use crate::reject::*; #[cfg(test)] mod tests; diff --git a/crates/punktfunk-core/src/quic/msgs.rs b/crates/punktfunk-core/src/quic/msgs.rs deleted file mode 100644 index 3e92eace..00000000 --- a/crates/punktfunk-core/src/quic/msgs.rs +++ /dev/null @@ -1,1302 +0,0 @@ -//! The `punktfunk/1` handshake (Hello/Welcome/Start) and every typed control message -//! (`CTL_MAGIC` + type byte), including the pairing-ceremony messages. Wire codecs only -//! — no transport state. - -use super::datagram::HdrMeta; -use super::{CTL_MAGIC, MAGIC}; -use crate::config::{ - CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, -}; -use crate::error::{PunktfunkError, Result}; - -/// `client → host`: open the session, requesting a display mode (the host creates its -/// virtual output at exactly this size/refresh — native resolution end to end). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Hello { - pub abi_version: u32, - pub mode: Mode, - /// Which compositor the client would like the host to drive (`Auto` = host decides). The - /// host honors it only if that backend is available, else falls back and reports the real - /// choice in [`Welcome::compositor`]. Appended to the wire form — omitted by older clients - /// (decodes to `Auto`). - pub compositor: CompositorPref, - /// Which virtual gamepad the host should create for this session's pads (`Auto` = host - /// decides: its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). Resolved choice echoed in - /// [`Welcome::gamepad`]. Appended to the wire form — omitted by older clients (decodes - /// to `Auto`). - pub gamepad: GamepadPref, - /// The client's desired video encoder bitrate, in kilobits per second. `0` = no preference - /// (the host uses its default). The host clamps the request to a supported range and reports - /// the value it actually configured in [`Welcome::bitrate_kbps`]. Appended to the wire form — - /// omitted by older clients (decodes to `0`, i.e. host default). - pub bitrate_kbps: u32, - /// Human-readable device name ("Enrico's MacBook"), shown by the host when this device knocks - /// on a pairing-required host (the delegated-approval pending list) and stored on approval. - /// Appended to the wire form as `len u8 || UTF-8` (≤ [`HELLO_NAME_MAX`] bytes) — omitted by - /// older clients (decodes to `None`; the host falls back to a fingerprint-derived label). - pub name: Option, - /// Library entry the client wants this session to launch (the store-qualified `GameEntry.id`, - /// e.g. `steam:570` / `custom:abc123`). The host resolves it against ITS OWN library and runs - /// the matching launch recipe in the session — the client never sends a raw command, so a - /// remote peer can't inject one. `None` = no game requested (the host's default session). - /// Appended after `name` as `len u8 || UTF-8` (≤ [`HELLO_LAUNCH_MAX`] bytes); when present but - /// `name` is absent, a zero-length name placeholder precedes it so the offset stays - /// deterministic. Omitted by older clients (decodes to `None`). - pub launch: Option, - /// Client video capabilities the host may use to upgrade the stream — a bitfield of - /// [`VIDEO_CAP_10BIT`] (the client can decode 10-bit Main10 HEVC) and [`VIDEO_CAP_HDR`] - /// (the client can present BT.2020 PQ HDR10). The host enables a 10-bit / HDR encode ONLY - /// when the matching bit is set, so an older client (decodes to `0`) always gets the 8-bit - /// BT.709 stream it understands. Appended after `launch` as a single trailing byte; a - /// zero-length name/launch placeholder precedes it when those are absent so the offset stays - /// deterministic. Omitted by older clients (decodes to `0`). - pub video_caps: u8, - /// Requested audio channel count: `2` (stereo, default), `6` (5.1) or `8` (7.1). The host - /// resolves it against what it can capture and echoes the final count in - /// [`Welcome::audio_channels`], which is what both ends build their Opus (multistream) - /// codec from. Appended after `video_caps` as a single trailing byte; when it differs from - /// the stereo default the name/launch/video_caps placeholders are forced (0) so it lands at a - /// deterministic offset. Omitted by older clients / when `2` (decodes to `2`, i.e. stereo) so - /// the stereo wire form stays byte-identical to the pre-surround build. - pub audio_channels: u8, - /// Which video codecs the client can decode — a bitfield of [`CODEC_H264`] / [`CODEC_HEVC`] / - /// [`CODEC_AV1`]. The host picks one it can also produce (see [`resolve_codec`]) and reports it in - /// [`Welcome::codec`]; a client that only reaches a GPU-less **software** host must set - /// [`CODEC_H264`] (openh264 emits H.264). Appended after `audio_channels` as a single trailing - /// byte (forcing the video_caps/audio_channels placeholders when present). Omitted by older - /// clients (decodes to `0`, which [`resolve_codec`] treats as HEVC-only — every pre-negotiation - /// build decoded HEVC). - pub video_codecs: u8, - /// The client's *preferred* codec (a single [`CODEC_H264`] / [`CODEC_HEVC`] / [`CODEC_AV1`] bit), - /// or `0` = no preference (host decides by its own precedence). A **soft** hint: the host emits - /// it when it can also produce it (and the client advertised it in `video_codecs`), else falls - /// back to the best shared codec — see [`resolve_codec`]. Mirrors the [`Hello::compositor`] / - /// [`Hello::gamepad`] preference pattern; the resolved codec is echoed in [`Welcome::codec`]. - /// Appended after `video_codecs` as a single trailing byte. Omitted by older clients (→ `0`). - pub preferred_codec: u8, - /// The client's **display** HDR colour volume — primaries / white point / luminance range in - /// the ST.2086 units of [`HdrMeta`] — read from the client OS (e.g. Windows - /// `IDXGIOutput6::GetDesc1`) when it advertised [`VIDEO_CAP_HDR`]. The host forwards it into - /// the virtual display's EDID (the pf-vdisplay CTA-861.3 HDR static-metadata block), so host - /// apps and the OS tone-map to the CLIENT's real panel instead of the driver's built-in - /// ~1000-nit placeholder — the client can then present the PQ stream untouched. Also echoed - /// back as the session's `0xCE` mastering metadata. Appended after `preferred_codec` as a - /// fixed [`super::datagram::HDR_META_BODY_LEN`]-byte block (the [`HdrMeta`] wire body, no tag), - /// forcing the earlier placeholders. Omitted by older clients / when the client has no HDR - /// display (decodes to `None` — the host keeps its built-in EDID defaults). - pub display_hdr: Option, -} - -/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream. -pub const VIDEO_CAP_10BIT: u8 = 0x01; -/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit). -pub const VIDEO_CAP_HDR: u8 = 0x02; -/// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC -/// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a -/// client-side setting, default OFF — the per-session policy switch). The host emits 4:4:4 ONLY -/// when this bit is set, the host allows it (`PUNKTFUNK_444`, default on), the codec is HEVC, -/// **and** the GPU/driver actually supports a 4:4:4 encode (probed) — otherwise the session stays -/// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of -/// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine -/// where the hardware allows). -pub const VIDEO_CAP_444: u8 = 0x04; -/// [`Hello::video_caps`] bit: the client consumes per-AU host-timing datagrams -/// ([`HOST_TIMING_MAGIC`], 0xCF) — the host's capture→send duration per frame, letting the client -/// split its `host+network` latency stage into `host` and `network` -/// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older -/// host ignores it and simply never sends any); a client that doesn't set it keeps the combined -/// stage. Purely observability — never changes what the host encodes. -pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08; -/// [`Hello::video_caps`] bit: the client's reassembler keeps **speed-test probe filler in its own -/// frame-index space** (a second reassembly window keyed on the [`crate::packet::FLAG_PROBE`] -/// user-flag), so probe bursts no longer consume video `frame_index`es. Without this, a mid-session -/// speed test burns thousands of video indexes that are invisible to every client-side gap detector -/// (probe frames are filtered before the pump sees them) — the first real AU afterwards reads as a -/// phantom multi-thousand-frame loss (spurious freeze + a nonsense RFI). It also lets the host's -/// encode loop own the video numbering outright (the wire-index contract -/// [`crate::packet::Packetizer::packetize_each`] documents), which reference-frame invalidation -/// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an -/// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window -/// reassembler would silently drop as stale. -pub const VIDEO_CAP_PROBE_SEQ: u8 = 0x10; - -/// QUIC application error code a punktfunk/1 client closes the control connection with on a -/// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's -/// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the -/// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a -/// reconnect can resume. Shared so host + every client agree on the code. -pub const QUIT_CLOSE_CODE: u32 = 0x51; - -/// QUIC application error code the **host** closes the control connection with when a **dedicated game -/// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher -/// client can distinguish "the game ended" from an error and return to its library cleanly rather than -/// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of -/// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client -/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. -pub const APP_EXITED_CLOSE_CODE: u32 = 0x52; - -// Typed rejection close codes + [`RejectReason`] live in `crate::reject` (ungated — the -// error enum references them even in `quic`-less builds) and are re-exported here so the -// wire vocabulary stays browsable next to QUIT/APP_EXITED. -pub use crate::reject::*; - -/// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`] -/// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder -/// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the -/// lossy datagram plane, periodically refreshed) instead of the fragile per-transition -/// button/axis events; toward a host that doesn't set the bit it keeps the legacy events. -pub const HOST_CAP_GAMEPAD_STATE: u8 = 0x01; - -/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** -/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST -/// advertise this. -pub const CODEC_H264: u8 = 0x01; -/// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing -/// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only). -pub const CODEC_HEVC: u8 = 0x02; -/// [`Hello::video_codecs`] bit: the client can decode AV1. -pub const CODEC_AV1: u8 = 0x04; -/// [`Hello::video_codecs`] bit: the client can decode **PyroWave** — the opt-in wired-LAN -/// intra-only wavelet codec (design/pyrowave-codec-plan.md; 100–400 Mbps class, 8-bit SDR, -/// every frame independently decodable). Deliberately **absent from [`resolve_codec`]'s -/// precedence ladder**: it is selected only when the client also names it -/// [`Hello::preferred_codec`] (or the host operator forces the advertisement mask) — a codec -/// that needs a wired-LAN bitrate must never win a negotiation just because both ends support -/// it. The bit means "PyroWave bitstream as of the punktfunk-vendored pin" -/// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream -/// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol -/// version instead (plan §4.2). -pub const CODEC_PYROWAVE: u8 = 0x08; - -/// Resolve which single codec the host will emit, from the client's advertised [`Hello::video_codecs`] -/// bitfield (`0` = an older client, treated as HEVC-only) intersected with what the host's chosen -/// encoder can produce (`host_capable`, also a bitfield). `preferred` is the client's soft preference -/// ([`Hello::preferred_codec`], `0` = none): when it's in the shared set it wins; otherwise the tie is -/// broken by **HEVC > AV1 > H.264** (HEVC is the established, best-tested path; H.264 is the -/// compatibility / software floor). [`CODEC_PYROWAVE`] is intentionally NOT in that ladder — it can -/// only be returned via the `preferred` path (plan §3: opt-in, pinned, honest). Returns the -/// single-bit codec value, or `None` when client and host share nothing the ladder may pick — the -/// caller then refuses the session with a clear error rather than emitting a stream the client -/// can't decode. -pub fn resolve_codec(client_codecs: u8, host_capable: u8, preferred: u8) -> Option { - // An older client (no codec byte) decodes HEVC — the only codec every pre-negotiation build sent. - let client = if client_codecs == 0 { - CODEC_HEVC - } else { - client_codecs - }; - let shared = client & host_capable; - if shared == 0 { - return None; - } - // Honor the client's preference when the host can also emit it; else fall back to precedence. - if preferred != 0 && shared & preferred != 0 { - return Some(preferred); - } - // Precedence: HEVC > AV1 > H.264. - [CODEC_HEVC, CODEC_AV1, CODEC_H264] - .into_iter() - .find(|&c| shared & c != 0) -} - -/// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat -/// default when a peer omits [`Welcome::chroma_format`]. -pub const CHROMA_IDC_420: u8 = 1; -/// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions). -pub const CHROMA_IDC_444: u8 = 3; - -/// Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the -/// encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these -/// instead of inferring them from the bitstream VUI. An older host omits the bytes on the wire → -/// [`ColorInfo::SDR_BT709`] (the 8-bit BT.709 limited stream every pre-HDR build produced). -/// -/// The *static* HDR mastering metadata (ST.2086 + content light level) is larger and can change -/// mid-stream, so it rides the [`HDR_META_MAGIC`] datagram rather than this fixed struct. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ColorInfo { - /// CICP colour primaries: 1 = BT.709, 9 = BT.2020. - pub primaries: u8, - /// CICP transfer characteristics: 1 = BT.709, 16 = PQ (SMPTE ST.2084), 18 = HLG. - pub transfer: u8, - /// CICP matrix coefficients: 1 = BT.709, 9 = BT.2020 non-constant-luminance. - pub matrix: u8, - /// `video_full_range_flag`: 0 = limited/studio range, 1 = full range. - pub full_range: u8, -} - -impl ColorInfo { - /// CICP colour-primaries code point: BT.709. - pub const CP_BT709: u8 = 1; - /// CICP colour-primaries code point: BT.2020. - pub const CP_BT2020: u8 = 9; - /// CICP transfer code point: BT.709. - pub const TRC_BT709: u8 = 1; - /// CICP transfer code point: PQ (SMPTE ST.2084). - pub const TRC_PQ: u8 = 16; - /// CICP transfer code point: HLG (ARIB STD-B67 / BT.2100). - pub const TRC_HLG: u8 = 18; - /// CICP matrix code point: BT.709. - pub const MC_BT709: u8 = 1; - /// CICP matrix code point: BT.2020 non-constant-luminance. (Never emit 10 / constant-luminance — - /// no client decodes it.) - pub const MC_BT2020_NCL: u8 = 9; - - /// 8-bit BT.709 limited-range SDR — what every pre-HDR build produced, and the back-compat - /// default when a peer omits the colour bytes. - pub const SDR_BT709: ColorInfo = ColorInfo { - primaries: Self::CP_BT709, - transfer: Self::TRC_BT709, - matrix: Self::MC_BT709, - full_range: 0, - }; - - /// BT.2020 PQ (HDR10), limited range — what the Windows host's HEVC VUI emits. - pub const HDR10_BT2020_PQ: ColorInfo = ColorInfo { - primaries: Self::CP_BT2020, - transfer: Self::TRC_PQ, - matrix: Self::MC_BT2020_NCL, - full_range: 0, - }; - - /// True when the transfer is an HDR curve (PQ or HLG): the stream needs HDR present, and - /// (for PQ) a [`HdrMeta`] datagram carries the mastering metadata. - pub fn is_hdr(&self) -> bool { - self.transfer == Self::TRC_PQ || self.transfer == Self::TRC_HLG - } -} - -impl Default for ColorInfo { - fn default() -> Self { - Self::SDR_BT709 - } -} - -/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on -/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway). -pub const HELLO_NAME_MAX: usize = 64; - -/// Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short -/// (`steam:` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. -pub const HELLO_LAUNCH_MAX: usize = 128; - -/// `host → client`: the complete session offer. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Welcome { - pub abi_version: u32, - /// Host UDP port for the data plane. - pub udp_port: u16, - pub mode: Mode, - pub fec: FecConfig, - pub shard_payload: u16, - pub encrypt: bool, - pub key: [u8; 16], - pub salt: [u8; 4], - /// Seed/testing: how many frames the host will send (0 = unbounded). - pub frames: u32, - /// The compositor the host actually resolved for this session (the client's - /// [`Hello::compositor`] preference if available, else the host's auto-detected choice). - /// Appended to the wire form — `Auto` when an older host omitted it (i.e. "unknown"). - pub compositor: CompositorPref, - /// The virtual gamepad backend the host actually resolved (the client's [`Hello::gamepad`] - /// preference if available, else env var / X-Box 360). A client uses this to know whether - /// DualSense feedback (0xCD) can arrive at all. Appended to the wire form — `Auto` when an - /// older host omitted it (i.e. "unknown, assume X-Box 360"). - pub gamepad: GamepadPref, - /// The encoder bitrate the host actually configured for this session, in kilobits per second - /// (the client's [`Hello::bitrate_kbps`] clamped to the host's supported range, or the host - /// default when the client requested `0`). Appended to the wire form — `0` when an older host - /// omitted it (i.e. "unknown"). - pub bitrate_kbps: u32, - /// The luma/chroma bit depth the host actually encodes at — `8` (default / older host) or - /// `10` (Main10, enabled only when the client advertised [`VIDEO_CAP_10BIT`]). The client - /// configures its decoder for 10-bit (P010) when this is `10`. Appended to the wire form as a - /// single trailing byte; `8` when an older host omitted it. - pub bit_depth: u8, - /// The colour signalling (CICP primaries/transfer/matrix/range) the host encodes with — BT.709 - /// limited SDR by default, BT.2020 PQ when a 10-bit HDR session was negotiated. Appended after - /// `bit_depth` as 4 trailing bytes; an older host that omits them decodes to - /// [`ColorInfo::SDR_BT709`]. The client configures its decoder/presenter from this instead of - /// guessing from the bitstream; the mastering metadata arrives separately on [`HDR_META_MAGIC`]. - pub color: ColorInfo, - /// The chroma subsampling the host actually encodes at, as the HEVC `chroma_format_idc`: - /// [`CHROMA_IDC_420`] (4:2:0, default / older host) or [`CHROMA_IDC_444`] (full-chroma 4:4:4, - /// enabled only when the client advertised [`VIDEO_CAP_444`] *and* the host could open a real - /// 4:4:4 encode). The client sizes its decoder/surface pool from this; the in-band SPS carries - /// the authoritative value, so this is a hint (and the honest-downgrade channel — if the host - /// requested 4:4:4 but the GPU declined, this reads `CHROMA_IDC_420`). Appended after the colour - /// bytes as a single trailing byte; an older host that omits it decodes to [`CHROMA_IDC_420`]. - pub chroma_format: u8, - /// The audio channel count the host actually resolved and **will** send on the `0xC9` plane: - /// `2` (stereo, default), `6` (5.1) or `8` (7.1). Echoes [`Hello::audio_channels`] clamped to - /// what the host can capture (Linux PipeWire always synthesizes the count; Windows WASAPI - /// loopback is clamped to the render endpoint's mix-format channels). The client builds its Opus - /// (multistream) decoder from THIS value via [`crate::audio::layout_for`] — never from its own - /// request — so an older host that omits the byte (→ `2`) always yields working stereo. Appended - /// after `chroma_format` as a single trailing byte. - pub audio_channels: u8, - /// The single video codec the host resolved and **will** emit — [`CODEC_H264`], [`CODEC_HEVC`] - /// (default), or [`CODEC_AV1`] — from [`resolve_codec`] over the client's [`Hello::video_codecs`] - /// and the host encoder's capability. The client builds its decoder from THIS (never assuming - /// HEVC). Appended after `audio_channels` as a single trailing byte; an older host that omits it - /// decodes to [`CODEC_HEVC`] (every pre-negotiation host sent HEVC). - pub codec: u8, - /// Host input capabilities — a bitfield of [`HOST_CAP_GAMEPAD_STATE`]. The client picks the - /// wire form its gamepad events take from this (snapshots for a capable host, the legacy - /// per-transition events otherwise). Appended after `codec` as a single trailing byte; an - /// older host that omits it decodes to `0` (no capabilities — legacy events only). - pub host_caps: u8, -} - -/// `client → host`: data plane is bound, begin streaming. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Start { - pub client_udp_port: u16, -} - -/// `client → host`, any time after [`Start`]: switch the session to a new display mode -/// (window resized, refresh changed) without reconnecting. The host answers with -/// [`Reconfigured`]; on acceptance it rebuilds its virtual output + encoder at the new -/// mode and the stream continues over the unchanged data plane — the first new-mode frame -/// is an IDR with in-band parameter sets, which is all a decoder needs to follow. -/// -/// Post-handshake messages carry a type byte after the magic (the handshake itself is -/// positional and stays untyped for wire compatibility). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Reconfigure { - pub mode: Mode, -} - -/// `host → client`: answer to [`Reconfigure`]. `accepted = false` means the requested -/// mode was rejected (e.g. exceeds encoder limits) and the session continues at `mode` -/// (the still-active one); `true` means `mode` is now being switched to live. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Reconfigured { - pub accepted: bool, - pub mode: Mode, -} - -/// `client → host`, any time after [`Start`]: ask the host's encoder to emit a fresh IDR -/// keyframe NOW. The infinite-GOP stream opens with one IDR then sends P-frames only, so a -/// decoder that wedges (a lost/corrupt opening IDR, a bad early P-frame — most likely on the -/// cold first session) would otherwise stay frozen until the next loss-triggered recovery -/// keyframe, which may be far off. The client sends this when it detects a stalled decode; -/// the host forces the next frame to be an IDR with in-band parameter sets, recovering the -/// picture in ~one frame. Fire-and-forget — no reply (the recovered IDR is the ack). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct RequestKeyframe; - -/// `client → host`: reference-frame-invalidation recovery — the loss-aware sibling of -/// [`RequestKeyframe`]. The client detected a `frame_index` gap and reports the range `[first_frame, -/// last_frame]` of access units it can no longer trust (from the first missing index through the -/// newest received). Instead of a full IDR (a 20-40× spike that deepens the loss it recovers), a host -/// whose encoder supports RFI re-references a known-good picture *before* `first_frame` — an AMD LTR -/// force-reference or an NVENC `nvEncInvalidateRefFrames` — emitting a single clean P-frame it tags -/// [`crate::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its freeze on it. A host that -/// can't RFI (no valid reference / libavcodec backend) forces an IDR instead, exactly as for a bare -/// [`RequestKeyframe`]; a host that predates this ignores the unknown message and the client's -/// keyframe backstop still recovers. Fire-and-forget — the recovered frame is the only ack. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct RfiRequest { - /// First access-unit `frame_index` the client can no longer trust (the gap start). - pub first_frame: u32, - /// Newest received `frame_index` at the time of the report (the invalidation range end). - pub last_frame: u32, -} - -/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to -/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards -/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report -/// window — i.e. the loss FEC is currently absorbing. The host maps it to a recovery percentage, -/// clamped to a sane band, and applies it live; a clean link decays toward the floor (fewer packets, -/// which directly helps a packet-rate-bound uplink like the Steam Deck's WiFi tx). Fire-and-forget. -/// A host that predates this ignores it (unknown control message) and keeps its static FEC. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct LossReport { - pub loss_ppm: u32, -} - -/// `client → host`, any time after [`Start`]: reconfigure the encoder to a new target bitrate -/// without reconnecting — the mid-stream lever of adaptive bitrate. The host clamps the request -/// exactly like [`Hello::bitrate_kbps`] (its `[MIN, MAX]` band; `0` → host default), answers with -/// [`BitrateChanged`] carrying the value it actually configured, and rebuilds the encoder in -/// place at the same mode — the first new-rate frame is an IDR with in-band parameter sets, which -/// every client decoder already follows (same discipline as a [`Reconfigure`] mode switch). -/// -/// Sent by the client's automatic-bitrate controller (active when the user's bitrate setting is -/// "Automatic", i.e. `Hello::bitrate_kbps == 0`) when the link can't sustain the current rate — -/// or can sustain more again. A host that predates this ignores it (unknown control message) and -/// never answers; the client's controller detects the silence and disables itself. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SetBitrate { - /// Requested encoder bitrate in kilobits per second (`0` = host default, like Hello's field). - pub bitrate_kbps: u32, -} - -/// `host → client`: answer to [`SetBitrate`] — the bitrate the host actually configured (the -/// request clamped to its supported band). The encoder retargets in place where the backend can -/// (no IDR — the stream carries straight on); a backend without in-place reconfigure rebuilds and -/// switches on the next frame (an IDR). The stream never pauses either way. Also the controller's -/// liveness signal: no answer ⇒ an old host that doesn't renegotiate bitrate. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BitrateChanged { - pub bitrate_kbps: u32, -} - -/// `client → host`, any time after [`Start`]: run a bandwidth speed test. The host bursts -/// filler access units (flagged [`crate::packet::FLAG_PROBE`]) over the data plane at -/// `target_kbps` of application goodput for `duration_ms`, *pausing video for the duration*, then -/// replies with [`ProbeResult`]. The client measures the received probe bytes + time to estimate -/// the link's sustainable rate (and the loss vs. the host's reported send count) so it can pick a -/// [`Hello::bitrate_kbps`]. The host clamps both fields to sane bounds. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ProbeRequest { - /// Goodput rate the host should send the probe at, in kilobits per second. - pub target_kbps: u32, - /// How long to burst, in milliseconds. - pub duration_ms: u32, -} - -/// `host → client`: the probe burst is finished. Reports what the host actually put on the wire so -/// the client can split the two failure modes apart: **host-side** drops (the send buffer couldn't -/// keep up — raise `net.core.wmem_max`) vs **link** loss (wire packets the air dropped). The client -/// measures delivered wire packets itself and computes: -/// -/// - link loss = `(wire_packets_sent − received) / wire_packets_sent` -/// - host drop = `send_dropped / (wire_packets_sent + send_dropped)` -/// - throughput = `received_wire_bytes * 8 / duration_ms` -/// -/// Counting delivered traffic at the *packet* level (not whole reassembled AUs) makes the figure -/// degrade gracefully past the FEC budget instead of cliffing to zero. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ProbeResult { - /// Total access-unit payload bytes the host emitted for the probe (application goodput offered). - pub bytes_sent: u64, - /// Number of probe access units the host emitted. - pub packets_sent: u32, - /// The burst's actual duration in milliseconds (the host clamps/measures the request). - pub duration_ms: u32, - /// Wire packets the kernel ACCEPTED for transmission — what actually went on the link (offered - /// minus the send-buffer drops below). `0` from a pre-wire-stats host (back-compat decode). - pub wire_packets_sent: u32, - /// Wire packets the host could NOT hand to the kernel (send buffer full): the host-side ceiling. - pub send_dropped: u32, -} - -/// `client → host`, right after [`Start`]: one round of the wall-clock skew handshake. The client -/// stamps `t1_ns` (its monotonic-since-epoch clock) and sends; the host echoes it in [`ClockEcho`] -/// with its own receive/send stamps. A few rounds let the client estimate the host↔client clock -/// offset, so the per-frame `capture→received` latency (the AU `pts_ns` is the host's capture -/// clock) is meaningful across machines, not just same-host. An old host ignores it (the client -/// times out and assumes a shared clock). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClockProbe { - pub t1_ns: u64, -} - -/// `host → client`: answer to [`ClockProbe`]. `t2_ns` is when the host received the probe and -/// `t3_ns` when it sent this echo (both the host clock); `t1_ns` is the client's send stamp echoed -/// back. With the client's receive time `t4`, offset = ((t2−t1)+(t3−t4))/2 (host minus client) and -/// RTT = (t4−t1)−(t3−t2). See [`clock_offset_ns`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ClockEcho { - pub t1_ns: u64, - pub t2_ns: u64, - pub t3_ns: u64, -} - -/// Type byte of [`Reconfigure`] (first byte after the magic). -pub const MSG_RECONFIGURE: u8 = 0x01; -/// Type byte of [`Reconfigured`]. -pub const MSG_RECONFIGURED: u8 = 0x02; -/// Type byte of [`RequestKeyframe`]. -pub const MSG_REQUEST_KEYFRAME: u8 = 0x03; -/// Type byte of [`LossReport`]. -pub const MSG_LOSS_REPORT: u8 = 0x04; -/// Type byte of [`SetBitrate`]. -pub const MSG_SET_BITRATE: u8 = 0x05; -/// Type byte of [`BitrateChanged`]. -pub const MSG_BITRATE_CHANGED: u8 = 0x06; -/// Type byte of [`RfiRequest`]. -pub const MSG_RFI_REQUEST: u8 = 0x07; -/// Type byte of [`ProbeRequest`]. -pub const MSG_PROBE_REQUEST: u8 = 0x20; -/// Type byte of [`ProbeResult`]. -pub const MSG_PROBE_RESULT: u8 = 0x21; -/// Type byte of [`ClockProbe`]. -pub const MSG_CLOCK_PROBE: u8 = 0x30; -/// Type byte of [`ClockEcho`]. -pub const MSG_CLOCK_ECHO: u8 = 0x31; - -// --------------------------------------------------------------------------------------------- -// Pairing ceremony (typed control messages): instead of a session Hello, a client may open -// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the -// user types it into the client. -// -// Trust is established by **SPAKE2** (a balanced PAKE), NOT a hash of the PIN. SPAKE2 turns -// the low-entropy PIN into a high-entropy shared key via a Diffie-Hellman exchange; the only -// thing an active man-in-the-middle who terminates the (TOFU) ceremony learns is whether a -// single PIN guess was right — there is no transcript value that reveals the PIN to an -// *offline* dictionary search (the fatal flaw of an HMAC-of-PIN proof over a 4-digit space). -// Both peers' certificate fingerprints are bound in as the SPAKE2 identities, so the -// established key — and the key-confirmation MACs derived from it — only agree when both -// sides saw the same two certificates. After mutual key confirmation the host persists the -// client's fingerprint and the client pins the host's. -// --------------------------------------------------------------------------------------------- - -/// Type byte of [`PairRequest`]. -pub const MSG_PAIR_REQUEST: u8 = 0x10; -/// Type byte of [`PairChallenge`]. -pub const MSG_PAIR_CHALLENGE: u8 = 0x11; -/// Type byte of [`PairProof`]. -pub const MSG_PAIR_PROOF: u8 = 0x12; -/// Type byte of [`PairResult`]. -pub const MSG_PAIR_RESULT: u8 = 0x13; - -/// `client → host`: begin pairing. `name` is the human label the host stores (≤64 bytes -/// UTF-8); `spake_a` is the client's SPAKE2 message (see [`SpakeRole::start`]). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PairRequest { - pub name: String, - pub spake_a: Vec, -} - -/// `host → client`: the host's SPAKE2 message + its key-confirmation MAC. The client -/// finishes SPAKE2, verifies `confirm` (proving the host derived the same key, i.e. knows -/// the PIN and saw the same certs), then sends its own confirmation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PairChallenge { - pub spake_b: Vec, - pub confirm: [u8; 32], -} - -/// `client → host`: the client's key-confirmation MAC (its single proof attempt). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PairProof { - pub confirm: [u8; 32], -} - -/// `host → client`: ceremony outcome. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PairResult { - pub ok: bool, -} - -/// A length-prefixed (u16 LE) byte field within a control message. -fn put_bytes(b: &mut Vec, x: &[u8]) { - b.extend_from_slice(&(x.len() as u16).to_le_bytes()); - b.extend_from_slice(x); -} - -/// Read a length-prefixed field at `off`, returning the bytes and the next offset. -fn get_bytes(b: &[u8], off: usize) -> Result<(&[u8], usize)> { - if off + 2 > b.len() { - return Err(PunktfunkError::InvalidArg("truncated field")); - } - let n = u16::from_le_bytes([b[off], b[off + 1]]) as usize; - let start = off + 2; - if start + n > b.len() { - return Err(PunktfunkError::InvalidArg("field overruns message")); - } - Ok((&b[start..start + n], start + n)) -} - -impl PairRequest { - pub fn encode(&self) -> Vec { - let name = self.name.as_bytes(); - let n = name.len().min(64); - let mut b = Vec::with_capacity(8 + n + self.spake_a.len()); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_PAIR_REQUEST); - b.push(n as u8); - b.extend_from_slice(&name[..n]); - put_bytes(&mut b, &self.spake_a); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() < 6 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_REQUEST { - return Err(PunktfunkError::InvalidArg("bad PairRequest")); - } - let n = b[5] as usize; - if n > 64 || b.len() < 6 + n { - return Err(PunktfunkError::InvalidArg("bad PairRequest name")); - } - let name = String::from_utf8_lossy(&b[6..6 + n]).into_owned(); - let (spake_a, end) = get_bytes(b, 6 + n)?; - if end != b.len() { - return Err(PunktfunkError::InvalidArg("trailing bytes")); - } - Ok(PairRequest { - name, - spake_a: spake_a.to_vec(), - }) - } -} - -impl PairChallenge { - pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(7 + self.spake_b.len() + 32); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_PAIR_CHALLENGE); - put_bytes(&mut b, &self.spake_b); - b.extend_from_slice(&self.confirm); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() < 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_CHALLENGE { - return Err(PunktfunkError::InvalidArg("bad PairChallenge")); - } - let (spake_b, end) = get_bytes(b, 5)?; - if end + 32 != b.len() { - return Err(PunktfunkError::InvalidArg("bad PairChallenge confirm")); - } - let mut confirm = [0u8; 32]; - confirm.copy_from_slice(&b[end..end + 32]); - Ok(PairChallenge { - spake_b: spake_b.to_vec(), - confirm, - }) - } -} - -impl PairProof { - pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(37); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_PAIR_PROOF); - b.extend_from_slice(&self.confirm); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 37 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_PROOF { - return Err(PunktfunkError::InvalidArg("bad PairProof")); - } - let mut confirm = [0u8; 32]; - confirm.copy_from_slice(&b[5..37]); - Ok(PairProof { confirm }) - } -} - -impl PairResult { - pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(6); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_PAIR_RESULT); - b.push(self.ok as u8); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 6 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_RESULT { - return Err(PunktfunkError::InvalidArg("bad PairResult")); - } - Ok(PairResult { ok: b[5] != 0 }) - } -} - -/// Truncate `s` to at most `max` bytes on a UTF-8 char boundary (so a multi-byte char straddling -/// the cap is dropped whole, never split). Shared by Hello's length-prefixed name/launch fields. -fn truncate_to(s: &str, max: usize) -> &str { - if s.len() <= max { - return s; - } - let mut cut = max; - while !s.is_char_boundary(cut) { - cut -= 1; - } - &s[..cut] -} - -impl Hello { - pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(22); - b.extend_from_slice(MAGIC); - b.extend_from_slice(&self.abi_version.to_le_bytes()); - b.extend_from_slice(&self.mode.width.to_le_bytes()); - b.extend_from_slice(&self.mode.height.to_le_bytes()); - b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); - b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it - b.push(self.gamepad.to_u8()); // appended at offset 21 — same back-compat discipline - b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 22..26 - // name at offset 26: len u8 || UTF-8. Omitted when `None` *and* there is no later field — - // so a Hello with neither name nor launch stays byte-identical to the bitrate-era form - // (26 bytes). When `launch` is present we must still emit name's length byte (0 for None) - // so `launch` lands at a deterministic offset. - // `video_caps`/`audio_channels` are the trailing fields, after `launch`; when either is - // present (video_caps non-zero / audio_channels not stereo) the name/launch length bytes - // AND the video_caps byte must still be emitted (0 / 0) so the later byte lands at a - // deterministic offset — the same discipline `launch` already imposes on `name`. - // Trailing single-byte fields, in wire order. Each is emitted when it (or ANY later field) - // carries a non-default value, so a present field always lands at a deterministic offset. - let ac_present = self.audio_channels != 2; - let vcodecs_present = self.video_codecs != 0; - let pref_present = self.preferred_codec != 0; - let hdr_present = self.display_hdr.is_some(); - let need_placeholders = - self.video_caps != 0 || ac_present || vcodecs_present || pref_present || hdr_present; - match (&self.name, &self.launch) { - (None, None) if !need_placeholders => {} - (name, _) => { - let n = truncate_to(name.as_deref().unwrap_or(""), HELLO_NAME_MAX); - b.push(n.len() as u8); - b.extend_from_slice(n.as_bytes()); - } - } - // launch after name: len u8 || UTF-8. - if self.launch.is_some() || need_placeholders { - let l = truncate_to(self.launch.as_deref().unwrap_or(""), HELLO_LAUNCH_MAX); - b.push(l.len() as u8); - b.extend_from_slice(l.as_bytes()); - } - // video_caps: single trailing byte. Emitted when non-zero OR when a later field follows (so - // that field lands at a deterministic offset right after it). - if need_placeholders { - b.push(self.video_caps); - } - // audio_channels: emitted when non-stereo OR a later field follows. - if ac_present || vcodecs_present || pref_present || hdr_present { - b.push(self.audio_channels); - } - // video_codecs: emitted when non-zero OR a later field follows. - if vcodecs_present || pref_present || hdr_present { - b.push(self.video_codecs); - } - // preferred_codec: emitted when non-zero OR display_hdr follows. - if pref_present || hdr_present { - b.push(self.preferred_codec); - } - // display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body. Last field; omitted when `None`. - if let Some(m) = &self.display_hdr { - super::datagram::write_hdr_meta_body(m, &mut b); - } - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() < 20 || &b[0..4] != MAGIC { - return Err(PunktfunkError::InvalidArg("bad Hello")); - } - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - // Locate the trailing single-byte fields once. name (26) and launch are `len u8 || UTF-8` - // blocks; their RAW length bytes (even when zero placeholders, or oversized garbage) - // determine where the tail starts, so a corrupt name never panics — it just pushes the - // later offsets out of range and those fields decode to their defaults. - let name_len = b.get(26).copied().unwrap_or(0) as usize; - let launch_off = 27 + name_len; // launch's length byte - let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize; - let tail = launch_off + 1 + launch_len; // first trailing byte: video_caps - Ok(Hello { - abi_version: u32at(4), - mode: Mode { - width: u32at(8), - height: u32at(12), - refresh_hz: u32at(16), - }, - // Optional trailing bytes — an older client that omits them requests `Auto`. - compositor: b - .get(20) - .map(|&v| CompositorPref::from_u8(v)) - .unwrap_or_default(), - gamepad: b - .get(21) - .map(|&v| GamepadPref::from_u8(v)) - .unwrap_or_default(), - // Optional trailing 4 bytes (LE) — absent on an older client → `0` (host default). - bitrate_kbps: b - .get(22..26) - .map(|s| u32::from_le_bytes(s.try_into().unwrap())) - .unwrap_or(0), - // Optional trailing device name: len u8 || UTF-8. Absent / oversized / non-UTF-8 → - // `None` (never fail the handshake over a label). - name: (name_len > 0 && name_len <= HELLO_NAME_MAX) - .then(|| { - b.get(27..27 + name_len) - .and_then(|s| std::str::from_utf8(s).ok()) - .map(String::from) - }) - .flatten(), - // Optional trailing launch id, right after name's block (same len/UTF-8 discipline). - launch: (launch_len > 0 && launch_len <= HELLO_LAUNCH_MAX) - .then(|| { - b.get(launch_off + 1..launch_off + 1 + launch_len) - .and_then(|s| std::str::from_utf8(s).ok()) - .map(String::from) - }) - .flatten(), - // The trailing single bytes, in wire order from `tail` (see the encode-side layout). - // Each is absent on an older client and decodes to its documented default. - video_caps: b.get(tail).copied().unwrap_or(0), - // Normalized so a corrupt/unsupported channel count can't build a bad decoder. - audio_channels: crate::audio::normalize_channels(b.get(tail + 1).copied().unwrap_or(2)), - // `0` = an older client (which `resolve_codec` treats as HEVC-only). - video_codecs: b.get(tail + 2).copied().unwrap_or(0), - // `0` = no preference; the host decides by precedence. - preferred_codec: b.get(tail + 3).copied().unwrap_or(0), - // Optional trailing HdrMeta body (fixed length) — absent on an older client / a - // client without an HDR display → `None` (the host keeps its EDID defaults). - display_hdr: b - .get(tail + 4..tail + 4 + super::datagram::HDR_META_BODY_LEN) - .map(super::datagram::read_hdr_meta_body), - }) - } -} - -impl Welcome { - pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(64); - b.extend_from_slice(MAGIC); - b.extend_from_slice(&self.abi_version.to_le_bytes()); - b.extend_from_slice(&self.udp_port.to_le_bytes()); - b.extend_from_slice(&self.mode.width.to_le_bytes()); - b.extend_from_slice(&self.mode.height.to_le_bytes()); - b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); - b.push(match self.fec.scheme { - FecScheme::Gf8 => 0, - FecScheme::Gf16 => 1, - }); - b.push(self.fec.fec_percent); - b.extend_from_slice(&self.fec.max_data_per_block.to_le_bytes()); - b.extend_from_slice(&self.shard_payload.to_le_bytes()); - b.push(self.encrypt as u8); - b.extend_from_slice(&self.key); - b.extend_from_slice(&self.salt); - b.extend_from_slice(&self.frames.to_le_bytes()); - b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it - b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline - b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 55..59 - b.push(self.bit_depth); // appended at offset 59 — older clients read [0..59] and skip it - // Colour signalling at offsets 60..64 — older clients stop before these → SDR BT.709. - b.push(self.color.primaries); - b.push(self.color.transfer); - b.push(self.color.matrix); - b.push(self.color.full_range); - // Chroma subsampling at offset 64 — older clients stop before this → 4:2:0 (CHROMA_IDC_420). - b.push(self.chroma_format); - // Audio channel count at offset 65 — older clients stop before this → stereo (2). - b.push(self.audio_channels); - // Resolved video codec at offset 66 — older clients stop before this → HEVC. - b.push(self.codec); - // Host input caps at offset 67 — older clients stop before this → 0 (legacy input only). - b.push(self.host_caps); - b - } - - pub fn decode(b: &[u8]) -> Result { - // Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22] - // scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45] - // salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59] - // bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63] - // chroma_format[64] audio_channels[65] codec[66] (everything from compositor on is an - // optional trailing byte; an older host stops earlier). - if b.len() < 53 || &b[0..4] != MAGIC { - return Err(PunktfunkError::InvalidArg("bad Welcome")); - } - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); - let mut key = [0u8; 16]; - key.copy_from_slice(&b[29..45]); - let mut salt = [0u8; 4]; - salt.copy_from_slice(&b[45..49]); - Ok(Welcome { - abi_version: u32at(4), - udp_port: u16at(8), - mode: Mode { - width: u32at(10), - height: u32at(14), - refresh_hz: u32at(18), - }, - fec: FecConfig { - scheme: if b[22] == 1 { - FecScheme::Gf16 - } else { - FecScheme::Gf8 - }, - fec_percent: b[23], - max_data_per_block: u16at(24), - }, - shard_payload: u16at(26), - encrypt: b[28] != 0, - key, - salt, - frames: u32at(49), - // Optional trailing bytes — an older host that omits them leaves the resolved - // compositor / gamepad backend unknown (`Auto`). - compositor: b - .get(53) - .map(|&v| CompositorPref::from_u8(v)) - .unwrap_or_default(), - gamepad: b - .get(54) - .map(|&v| GamepadPref::from_u8(v)) - .unwrap_or_default(), - // Optional trailing 4 bytes (LE) — absent on an older host → `0` (unknown). - bitrate_kbps: b - .get(55..59) - .map(|s| u32::from_le_bytes(s.try_into().unwrap())) - .unwrap_or(0), - // Optional trailing byte — absent on an older host → `8` (8-bit, the only depth they - // encode). - bit_depth: b.get(59).copied().unwrap_or(8), - // Optional trailing colour bytes — absent on an older host → SDR BT.709 limited. - color: ColorInfo { - primaries: b.get(60).copied().unwrap_or(ColorInfo::CP_BT709), - transfer: b.get(61).copied().unwrap_or(ColorInfo::TRC_BT709), - matrix: b.get(62).copied().unwrap_or(ColorInfo::MC_BT709), - full_range: b.get(63).copied().unwrap_or(0), - }, - // Optional trailing chroma byte — absent on an older host (or an explicit 0 / unknown - // value) → 4:2:0. Only `CHROMA_IDC_444` flips the client to a 4:4:4 decode. - chroma_format: match b.get(64).copied() { - Some(CHROMA_IDC_444) => CHROMA_IDC_444, - _ => CHROMA_IDC_420, - }, - // Optional trailing audio-channel byte — absent on an older host → stereo. Any - // non-{6,8} value normalizes to stereo so a corrupt byte never builds a bad decoder. - audio_channels: crate::audio::normalize_channels(b.get(65).copied().unwrap_or(2)), - // Optional trailing codec byte — absent on an older host (or an unknown value) → HEVC, - // the codec every pre-negotiation host emitted. - codec: match b.get(66).copied() { - Some(CODEC_H264) => CODEC_H264, - Some(CODEC_AV1) => CODEC_AV1, - Some(CODEC_PYROWAVE) => CODEC_PYROWAVE, - _ => CODEC_HEVC, - }, - // Optional trailing host-caps byte — absent on an older host → 0 (no gamepad-state - // snapshots; the client keeps sending legacy per-transition events). - host_caps: b.get(67).copied().unwrap_or(0), - }) - } - - /// Build the data-plane [`Config`] this offer describes (for `role`). - pub fn session_config(&self, role: Role) -> Config { - let mut c = Config::p1_defaults(role); - c.phase = ProtocolPhase::P1GameStream; // wire phase id pending the P2 packet rev - c.fec = self.fec; - c.shard_payload = self.shard_payload as usize; - c.encrypt = self.encrypt; - c.key = self.key; - c.salt = self.salt; - // Client-side reassembler ceiling: p1_defaults' 64 MiB hostile-header memory bound is - // ~10x larger than any real access unit. Derive it from the negotiated rate instead: - // 4x the average frame size at the resolved bitrate (IDR headroom), floored at 8 MiB, - // capped at the old 64 MiB. Purely local — the host never reassembles video and the - // wire is self-describing, so old hosts are unaffected; a host that reports bitrate 0 - // (pre-negotiation) keeps the old bound. - if role == Role::Client && self.bitrate_kbps > 0 { - let per_frame = (self.bitrate_kbps as usize).saturating_mul(125) - / self.mode.refresh_hz.max(1) as usize; - c.max_frame_bytes = per_frame.saturating_mul(4).clamp(8 << 20, 64 << 20); - } - c - } -} - -impl Start { - pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(6); - b.extend_from_slice(MAGIC); - b.extend_from_slice(&self.client_udp_port.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() < 6 || &b[0..4] != MAGIC { - return Err(PunktfunkError::InvalidArg("bad Start")); - } - Ok(Start { - client_udp_port: u16::from_le_bytes([b[4], b[5]]), - }) - } -} - -impl Reconfigure { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] w[5..9] h[9..13] hz[13..17] - let mut b = Vec::with_capacity(17); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_RECONFIGURE); - b.extend_from_slice(&self.mode.width.to_le_bytes()); - b.extend_from_slice(&self.mode.height.to_le_bytes()); - b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 17 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RECONFIGURE { - return Err(PunktfunkError::InvalidArg("bad Reconfigure")); - } - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - Ok(Reconfigure { - mode: Mode { - width: u32at(5), - height: u32at(9), - refresh_hz: u32at(13), - }, - }) - } -} - -impl Reconfigured { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] accepted[5] w[6..10] h[10..14] hz[14..18] - let mut b = Vec::with_capacity(18); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_RECONFIGURED); - b.push(self.accepted as u8); - b.extend_from_slice(&self.mode.width.to_le_bytes()); - b.extend_from_slice(&self.mode.height.to_le_bytes()); - b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 18 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RECONFIGURED { - return Err(PunktfunkError::InvalidArg("bad Reconfigured")); - } - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - Ok(Reconfigured { - accepted: b[5] != 0, - mode: Mode { - width: u32at(6), - height: u32at(10), - refresh_hz: u32at(14), - }, - }) - } -} - -impl RequestKeyframe { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] — no payload - let mut b = Vec::with_capacity(5); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_REQUEST_KEYFRAME); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_REQUEST_KEYFRAME { - return Err(PunktfunkError::InvalidArg("bad RequestKeyframe")); - } - Ok(RequestKeyframe) - } -} - -impl RfiRequest { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] first_frame[5..9] last_frame[9..13] - let mut b = Vec::with_capacity(13); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_RFI_REQUEST); - b.extend_from_slice(&self.first_frame.to_le_bytes()); - b.extend_from_slice(&self.last_frame.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RFI_REQUEST { - return Err(PunktfunkError::InvalidArg("bad RfiRequest")); - } - Ok(RfiRequest { - first_frame: u32::from_le_bytes(b[5..9].try_into().unwrap()), - last_frame: u32::from_le_bytes(b[9..13].try_into().unwrap()), - }) - } -} - -impl LossReport { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] loss_ppm[5..9] - let mut b = Vec::with_capacity(9); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_LOSS_REPORT); - b.extend_from_slice(&self.loss_ppm.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_LOSS_REPORT { - return Err(PunktfunkError::InvalidArg("bad LossReport")); - } - Ok(LossReport { - loss_ppm: u32::from_le_bytes(b[5..9].try_into().unwrap()), - }) - } -} - -impl SetBitrate { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] bitrate_kbps[5..9] - let mut b = Vec::with_capacity(9); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_SET_BITRATE); - b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SET_BITRATE { - return Err(PunktfunkError::InvalidArg("bad SetBitrate")); - } - Ok(SetBitrate { - bitrate_kbps: u32::from_le_bytes(b[5..9].try_into().unwrap()), - }) - } -} - -impl BitrateChanged { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] bitrate_kbps[5..9] - let mut b = Vec::with_capacity(9); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_BITRATE_CHANGED); - b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 9 || &b[0..4] != CTL_MAGIC || b[4] != MSG_BITRATE_CHANGED { - return Err(PunktfunkError::InvalidArg("bad BitrateChanged")); - } - Ok(BitrateChanged { - bitrate_kbps: u32::from_le_bytes(b[5..9].try_into().unwrap()), - }) - } -} - -/// Compute a [`LossReport`] `loss_ppm` from one window's session-stat deltas: shards FEC recovered -/// (the loss it absorbed), recovered-but-then-arrived shards (`late` — reordered delivery lets a -/// block reconstruct early, so those were never lost; netting them out keeps plain reordering from -/// reading as packet loss and spooking adaptive FEC + the bitrate controller), shards received, -/// and frames that went unrecoverable. Loss ≈ (recovered − late) / (received + recovered − late) — -/// the fraction of shards that truly never arrived (a late shard is inside `received`, so the -/// denominator nets it too; saturating, so reorder straddling a window boundary can't go -/// negative). A frame drop means loss exceeded the current FEC budget (so `recovered` plateaus), -/// so add a fixed bump to push the host's FEC up past the cap on the next adjustment. Returns -/// parts-per-million, capped at 1e6. -pub fn window_loss_ppm(recovered: u64, late: u64, received: u64, frames_dropped: u64) -> u32 { - let lost = recovered.saturating_sub(late); - let denom = received.saturating_add(lost); - let mut ppm = lost - .saturating_mul(1_000_000) - .checked_div(denom) - .unwrap_or(0) as u32; - if frames_dropped > 0 { - ppm = ppm.saturating_add(50_000); // +5%: unrecoverable loss → raise FEC past the current cap - } - ppm.min(1_000_000) -} - -impl ProbeRequest { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] target_kbps[5..9] duration_ms[9..13] - let mut b = Vec::with_capacity(13); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_PROBE_REQUEST); - b.extend_from_slice(&self.target_kbps.to_le_bytes()); - b.extend_from_slice(&self.duration_ms.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_REQUEST { - return Err(PunktfunkError::InvalidArg("bad ProbeRequest")); - } - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - Ok(ProbeRequest { - target_kbps: u32at(5), - duration_ms: u32at(9), - }) - } -} - -impl ProbeResult { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] bytes_sent[5..13] packets_sent[13..17] duration_ms[17..21] - // wire_packets_sent[21..25] send_dropped[25..29] - let mut b = Vec::with_capacity(29); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_PROBE_RESULT); - b.extend_from_slice(&self.bytes_sent.to_le_bytes()); - b.extend_from_slice(&self.packets_sent.to_le_bytes()); - b.extend_from_slice(&self.duration_ms.to_le_bytes()); - b.extend_from_slice(&self.wire_packets_sent.to_le_bytes()); - b.extend_from_slice(&self.send_dropped.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - // Back-compat: 21 bytes (pre-wire-stats host, new fields default 0) or 29 bytes (with the - // wire_packets_sent + send_dropped tail). Accept either; reject anything shorter/garbled. - if b.len() < 21 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PROBE_RESULT { - return Err(PunktfunkError::InvalidArg("bad ProbeResult")); - } - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - let (wire_packets_sent, send_dropped) = if b.len() >= 29 { - (u32at(21), u32at(25)) - } else { - (0, 0) - }; - Ok(ProbeResult { - bytes_sent: u64::from_le_bytes(b[5..13].try_into().unwrap()), - packets_sent: u32at(13), - duration_ms: u32at(17), - wire_packets_sent, - send_dropped, - }) - } -} - -impl ClockProbe { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] t1[5..13] - let mut b = Vec::with_capacity(13); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_CLOCK_PROBE); - b.extend_from_slice(&self.t1_ns.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLOCK_PROBE { - return Err(PunktfunkError::InvalidArg("bad ClockProbe")); - } - Ok(ClockProbe { - t1_ns: u64::from_le_bytes(b[5..13].try_into().unwrap()), - }) - } -} - -impl ClockEcho { - pub fn encode(&self) -> Vec { - // magic[0..4] type[4] t1[5..13] t2[13..21] t3[21..29] - let mut b = Vec::with_capacity(29); - b.extend_from_slice(CTL_MAGIC); - b.push(MSG_CLOCK_ECHO); - b.extend_from_slice(&self.t1_ns.to_le_bytes()); - b.extend_from_slice(&self.t2_ns.to_le_bytes()); - b.extend_from_slice(&self.t3_ns.to_le_bytes()); - b - } - - pub fn decode(b: &[u8]) -> Result { - if b.len() != 29 || &b[0..4] != CTL_MAGIC || b[4] != MSG_CLOCK_ECHO { - return Err(PunktfunkError::InvalidArg("bad ClockEcho")); - } - Ok(ClockEcho { - t1_ns: u64::from_le_bytes(b[5..13].try_into().unwrap()), - t2_ns: u64::from_le_bytes(b[13..21].try_into().unwrap()), - t3_ns: u64::from_le_bytes(b[21..29].try_into().unwrap()), - }) - } -} - -/// Frame a message for the control stream: `u16 LE length || payload`. -pub fn frame(payload: &[u8]) -> Vec { - let mut b = Vec::with_capacity(2 + payload.len()); - b.extend_from_slice(&(payload.len() as u16).to_le_bytes()); - b.extend_from_slice(payload); - b -} diff --git a/crates/punktfunk-core/src/quic/pairing.rs b/crates/punktfunk-core/src/quic/pairing.rs new file mode 100644 index 00000000..764c00d6 --- /dev/null +++ b/crates/punktfunk-core/src/quic/pairing.rs @@ -0,0 +1,173 @@ +//! The pairing-ceremony control messages (SPAKE2): PairRequest/Challenge/Proof/Result. + +use super::*; +use crate::error::{PunktfunkError, Result}; + +// --------------------------------------------------------------------------------------------- +// Pairing ceremony (typed control messages): instead of a session Hello, a client may open +// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the +// user types it into the client. +// +// Trust is established by **SPAKE2** (a balanced PAKE), NOT a hash of the PIN. SPAKE2 turns +// the low-entropy PIN into a high-entropy shared key via a Diffie-Hellman exchange; the only +// thing an active man-in-the-middle who terminates the (TOFU) ceremony learns is whether a +// single PIN guess was right — there is no transcript value that reveals the PIN to an +// *offline* dictionary search (the fatal flaw of an HMAC-of-PIN proof over a 4-digit space). +// Both peers' certificate fingerprints are bound in as the SPAKE2 identities, so the +// established key — and the key-confirmation MACs derived from it — only agree when both +// sides saw the same two certificates. After mutual key confirmation the host persists the +// client's fingerprint and the client pins the host's. +// --------------------------------------------------------------------------------------------- + +/// Type byte of [`PairRequest`]. +pub const MSG_PAIR_REQUEST: u8 = 0x10; +/// Type byte of [`PairChallenge`]. +pub const MSG_PAIR_CHALLENGE: u8 = 0x11; +/// Type byte of [`PairProof`]. +pub const MSG_PAIR_PROOF: u8 = 0x12; +/// Type byte of [`PairResult`]. +pub const MSG_PAIR_RESULT: u8 = 0x13; + +/// `client → host`: begin pairing. `name` is the human label the host stores (≤64 bytes +/// UTF-8); `spake_a` is the client's SPAKE2 message (see [`SpakeRole::start`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PairRequest { + pub name: String, + pub spake_a: Vec, +} + +/// `host → client`: the host's SPAKE2 message + its key-confirmation MAC. The client +/// finishes SPAKE2, verifies `confirm` (proving the host derived the same key, i.e. knows +/// the PIN and saw the same certs), then sends its own confirmation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PairChallenge { + pub spake_b: Vec, + pub confirm: [u8; 32], +} + +/// `client → host`: the client's key-confirmation MAC (its single proof attempt). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairProof { + pub confirm: [u8; 32], +} + +/// `host → client`: ceremony outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairResult { + pub ok: bool, +} + +/// A length-prefixed (u16 LE) byte field within a control message. +fn put_bytes(b: &mut Vec, x: &[u8]) { + b.extend_from_slice(&(x.len() as u16).to_le_bytes()); + b.extend_from_slice(x); +} + +/// Read a length-prefixed field at `off`, returning the bytes and the next offset. +fn get_bytes(b: &[u8], off: usize) -> Result<(&[u8], usize)> { + if off + 2 > b.len() { + return Err(PunktfunkError::InvalidArg("truncated field")); + } + let n = u16::from_le_bytes([b[off], b[off + 1]]) as usize; + let start = off + 2; + if start + n > b.len() { + return Err(PunktfunkError::InvalidArg("field overruns message")); + } + Ok((&b[start..start + n], start + n)) +} + +impl PairRequest { + pub fn encode(&self) -> Vec { + let name = self.name.as_bytes(); + let n = name.len().min(64); + let mut b = Vec::with_capacity(8 + n + self.spake_a.len()); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_PAIR_REQUEST); + b.push(n as u8); + b.extend_from_slice(&name[..n]); + put_bytes(&mut b, &self.spake_a); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 6 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_REQUEST { + return Err(PunktfunkError::InvalidArg("bad PairRequest")); + } + let n = b[5] as usize; + if n > 64 || b.len() < 6 + n { + return Err(PunktfunkError::InvalidArg("bad PairRequest name")); + } + let name = String::from_utf8_lossy(&b[6..6 + n]).into_owned(); + let (spake_a, end) = get_bytes(b, 6 + n)?; + if end != b.len() { + return Err(PunktfunkError::InvalidArg("trailing bytes")); + } + Ok(PairRequest { + name, + spake_a: spake_a.to_vec(), + }) + } +} + +impl PairChallenge { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(7 + self.spake_b.len() + 32); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_PAIR_CHALLENGE); + put_bytes(&mut b, &self.spake_b); + b.extend_from_slice(&self.confirm); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_CHALLENGE { + return Err(PunktfunkError::InvalidArg("bad PairChallenge")); + } + let (spake_b, end) = get_bytes(b, 5)?; + if end + 32 != b.len() { + return Err(PunktfunkError::InvalidArg("bad PairChallenge confirm")); + } + let mut confirm = [0u8; 32]; + confirm.copy_from_slice(&b[end..end + 32]); + Ok(PairChallenge { + spake_b: spake_b.to_vec(), + confirm, + }) + } +} + +impl PairProof { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(37); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_PAIR_PROOF); + b.extend_from_slice(&self.confirm); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 37 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_PROOF { + return Err(PunktfunkError::InvalidArg("bad PairProof")); + } + let mut confirm = [0u8; 32]; + confirm.copy_from_slice(&b[5..37]); + Ok(PairProof { confirm }) + } +} + +impl PairResult { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(6); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_PAIR_RESULT); + b.push(self.ok as u8); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 6 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_RESULT { + return Err(PunktfunkError::InvalidArg("bad PairResult")); + } + Ok(PairResult { ok: b[5] != 0 }) + } +}