Skip to main content

seyd_wire/
v2.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Wire protocol v2 chunk header and frame metadata (ADR 0001).
4//!
5//! ```text
6//! byte  0      bit 7 keyframe | bits 4-6 fec_type | bits 0-3 version = 2
7//! byte  1      channel_id   u8
8//! bytes 2-3    frame_id     u16 BE   per channel, wraps
9//! bytes 4-5    chunk_idx    u16 BE   0..n-1 data, n..n+k-1 parity, within the block
10//! bytes 6-7    n            u16 BE   data chunks in this FEC block
11//! byte  8      k            u8       parity chunks in this FEC block
12//! byte  9      flags2       bit0 frame_meta, bit1 discardable, bit2 end_of_frame
13//! bytes 10-11  last_len     u16 BE   real length of data chunk n-1 of this block
14//! bytes 12-13  chunk_len    u16 BE   payload size of full chunks in this block
15//! bytes 14-17  send_ts      u32 BE   low 32 bits of sender monotonic µs
16//! bytes 18-19  block_idx    u16 BE   FEC block index within the frame
17//! bytes 20+    payload
18//! ```
19
20pub const VERSION: u8 = 2;
21pub const HEADER_LEN: usize = 20;
22pub const CONTROL_CHANNEL: u8 = 0;
23
24/// Default chunk payload before path-MTU discovery raises it.
25pub const DEFAULT_CHUNK_LEN: u16 = 1000;
26/// Upper bound the sender will ever use; keeps a chunk inside one 1400-byte
27/// QUIC packet after QUIC + HTTP/3 + WebTransport framing.
28pub const MAX_CHUNK_LEN: u16 = 1350;
29
30pub const FLAG2_FRAME_META: u8 = 1 << 0;
31pub const FLAG2_DISCARDABLE: u8 = 1 << 1;
32pub const FLAG2_END_OF_FRAME: u8 = 1 << 2;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u8)]
36pub enum FecType {
37    None = 0,
38    ReedSolomon = 2,
39}
40
41impl FecType {
42    fn from_bits(b: u8) -> Option<Self> {
43        match b {
44            0 => Some(FecType::None),
45            2 => Some(FecType::ReedSolomon),
46            _ => None,
47        }
48    }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct ChunkHeader {
53    pub keyframe: bool,
54    pub fec_type: FecType,
55    pub channel_id: u8,
56    pub frame_id: u16,
57    pub chunk_idx: u16,
58    pub n: u16,
59    pub k: u8,
60    pub flags2: u8,
61    pub last_len: u16,
62    pub chunk_len: u16,
63    pub send_ts: u32,
64    pub block_idx: u16,
65}
66
67impl ChunkHeader {
68    #[inline]
69    pub fn is_parity(&self) -> bool {
70        self.chunk_idx >= self.n
71    }
72    #[inline]
73    pub fn has_frame_meta(&self) -> bool {
74        self.flags2 & FLAG2_FRAME_META != 0
75    }
76    #[inline]
77    pub fn is_discardable(&self) -> bool {
78        self.flags2 & FLAG2_DISCARDABLE != 0
79    }
80    #[inline]
81    pub fn is_end_of_frame(&self) -> bool {
82        self.flags2 & FLAG2_END_OF_FRAME != 0
83    }
84
85    pub fn encode(&self) -> [u8; HEADER_LEN] {
86        let mut out = [0u8; HEADER_LEN];
87        out[0] = (if self.keyframe { 0x80 } else { 0 }) | ((self.fec_type as u8) << 4) | VERSION;
88        out[1] = self.channel_id;
89        out[2..4].copy_from_slice(&self.frame_id.to_be_bytes());
90        out[4..6].copy_from_slice(&self.chunk_idx.to_be_bytes());
91        out[6..8].copy_from_slice(&self.n.to_be_bytes());
92        out[8] = self.k;
93        out[9] = self.flags2;
94        out[10..12].copy_from_slice(&self.last_len.to_be_bytes());
95        out[12..14].copy_from_slice(&self.chunk_len.to_be_bytes());
96        out[14..18].copy_from_slice(&self.send_ts.to_be_bytes());
97        out[18..20].copy_from_slice(&self.block_idx.to_be_bytes());
98        out
99    }
100
101    /// Parse a v2 chunk. `None` if too short, wrong version, or an unknown FEC type.
102    pub fn parse(buf: &[u8]) -> Option<(ChunkHeader, &[u8])> {
103        if buf.len() < HEADER_LEN {
104            return None;
105        }
106        let flags = buf[0];
107        if flags & 0x0f != VERSION {
108            return None;
109        }
110        let fec_type = FecType::from_bits((flags >> 4) & 0x07)?;
111        let be16 = |i: usize| u16::from_be_bytes([buf[i], buf[i + 1]]);
112        let hdr = ChunkHeader {
113            keyframe: flags & 0x80 != 0,
114            fec_type,
115            channel_id: buf[1],
116            frame_id: be16(2),
117            chunk_idx: be16(4),
118            n: be16(6),
119            k: buf[8],
120            flags2: buf[9],
121            last_len: be16(10),
122            chunk_len: be16(12),
123            send_ts: u32::from_be_bytes([buf[14], buf[15], buf[16], buf[17]]),
124            block_idx: be16(18),
125        };
126        if hdr.n == 0 || hdr.chunk_idx as usize >= hdr.n as usize + hdr.k as usize {
127            return None;
128        }
129        Some((hdr, &buf[HEADER_LEN..]))
130    }
131}
132
133/// Optional per-frame metadata carried at the start of block 0 / chunk 0's
134/// payload when `FLAG2_FRAME_META` is set.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct FrameMeta {
137    /// Publisher capture timestamp, its own monotonic clock, microseconds.
138    pub capture_ts_us: u64,
139    /// Position within the GOP (0 = keyframe).
140    pub seq_in_gop: u16,
141}
142
143impl FrameMeta {
144    pub const LEN: usize = 10;
145
146    pub fn encode(&self) -> [u8; Self::LEN] {
147        let mut out = [0u8; Self::LEN];
148        out[..8].copy_from_slice(&self.capture_ts_us.to_be_bytes());
149        out[8..].copy_from_slice(&self.seq_in_gop.to_be_bytes());
150        out
151    }
152
153    pub fn parse(buf: &[u8]) -> Option<(FrameMeta, &[u8])> {
154        if buf.len() < Self::LEN {
155            return None;
156        }
157        let mut ts = [0u8; 8];
158        ts.copy_from_slice(&buf[..8]);
159        Some((
160            FrameMeta {
161                capture_ts_us: u64::from_be_bytes(ts),
162                seq_in_gop: u16::from_be_bytes([buf[8], buf[9]]),
163            },
164            &buf[Self::LEN..],
165        ))
166    }
167}
168
169/// Wrap-aware difference of two 32-bit microsecond send timestamps.
170#[inline]
171pub fn ts_delta_us(later: u32, earlier: u32) -> i32 {
172    later.wrapping_sub(earlier) as i32
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn sample() -> ChunkHeader {
180        ChunkHeader {
181            keyframe: true,
182            fec_type: FecType::ReedSolomon,
183            channel_id: 3,
184            frame_id: 0xbeef,
185            chunk_idx: 9,
186            n: 8,
187            k: 4,
188            flags2: FLAG2_FRAME_META | FLAG2_END_OF_FRAME,
189            last_len: 517,
190            chunk_len: 1200,
191            send_ts: 0xdeadbeef,
192            block_idx: 2,
193        }
194    }
195
196    #[test]
197    fn roundtrip() {
198        let h = sample();
199        let mut buf = h.encode().to_vec();
200        buf.extend_from_slice(b"payload");
201        let (parsed, payload) = ChunkHeader::parse(&buf).unwrap();
202        assert_eq!(parsed, h);
203        assert_eq!(payload, b"payload");
204        assert!(parsed.is_parity());
205        assert!(parsed.has_frame_meta());
206        assert!(parsed.is_end_of_frame());
207        assert!(!parsed.is_discardable());
208    }
209
210    #[test]
211    fn layout_is_exact() {
212        let bytes = sample().encode();
213        assert_eq!(bytes[0], 0x80 | (2 << 4) | 2);
214        assert_eq!(bytes[1], 3);
215        assert_eq!(&bytes[2..4], &[0xbe, 0xef]);
216        assert_eq!(&bytes[6..8], &[0, 8]);
217        assert_eq!(bytes[8], 4);
218        assert_eq!(&bytes[14..18], &[0xde, 0xad, 0xbe, 0xef]);
219        assert_eq!(&bytes[18..20], &[0, 2]);
220    }
221
222    #[test]
223    fn rejects_bad_input() {
224        let mut bytes = sample().encode();
225        assert!(ChunkHeader::parse(&bytes[..19]).is_none(), "short");
226        bytes[0] = (bytes[0] & 0xf0) | 1;
227        assert!(
228            ChunkHeader::parse(&bytes).is_none(),
229            "v1 not accepted by v2 parser"
230        );
231        let mut bytes = sample().encode();
232        bytes[0] = (bytes[0] & 0x8f) | (5 << 4);
233        assert!(ChunkHeader::parse(&bytes).is_none(), "unknown fec type");
234        let mut bytes = sample().encode();
235        bytes[4..6].copy_from_slice(&12u16.to_be_bytes());
236        assert!(ChunkHeader::parse(&bytes).is_none(), "chunk_idx >= n+k");
237    }
238
239    #[test]
240    fn frame_meta_roundtrip() {
241        let m = FrameMeta {
242            capture_ts_us: 1_700_000_000_123_456,
243            seq_in_gop: 7,
244        };
245        let mut buf = m.encode().to_vec();
246        buf.push(0x42);
247        let (p, rest) = FrameMeta::parse(&buf).unwrap();
248        assert_eq!(p, m);
249        assert_eq!(rest, &[0x42]);
250    }
251
252    #[test]
253    fn ts_delta_wraps() {
254        assert_eq!(ts_delta_us(10, u32::MAX - 5), 16);
255        assert_eq!(ts_delta_us(5, 10), -5);
256    }
257}