Skip to main content

mfsk_core/uvpacket/
framing.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Frame header + CRC-16 (CCITT-FALSE) for the redesigned uvpacket.
3//!
4//! ## On-the-wire frame layout (post 0.4.0 redesign)
5//!
6//! ```text
7//! [ Long preamble (mode-encoded, 127 chips) ]
8//! [ Header LDPC block — Robust, fixed, 12 bytes info ]
9//! [ Payload LDPC blocks × n_blocks — at the mode the preamble
10//!   identified ]
11//! ```
12//!
13//! Mode (Robust / Standard / Fast / Express) is now carried in the
14//! **preamble pattern selection** rather than in a header field, so
15//! the receiver knows the mode after sync detection — well before
16//! it tries to decode any LDPC block. This eliminates the
17//! `4 modes × 32 n_blocks = 128` brute-force decode sweep that the
18//! prior decoder needed to discover layout, and keeps decode cost
19//! at `1 + n_blocks` LDPC operations per frame.
20//!
21//! ## Header block bit layout
22//!
23//! The header LDPC block carries 96 information bits, of which the
24//! first 32 are the header word + CRC and the remaining 64 are
25//! zero-pad (extra coding gain for the most-critical block). The
26//! header word + CRC layout matches the prior single-frame header
27//! exactly except for the removed `mode` field:
28//!
29//! ```text
30//! Bit:   15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
31//! Field: └── blocks ──┘└── app ──┘└── seq ──────┘└rsv─┘
32//! ```
33//!
34//! - `blocks` (5 bits) — payload LDPC block count, encoded as
35//!   `count - 1` (`0b00000` = 1 block, `0b11111` = 32 blocks).
36//! - `app` (4 bits) — application-layer dispatch tag, 0..=15.
37//! - `seq` (5 bits) — ARQ sequence number 0..=31, wraps mod 32.
38//! - `rsv` (2 bits) — reserved, must be zero.
39//!
40//! Bytes 0..2 carry this 16-bit word in big-endian. Bytes 2..4
41//! carry the CRC-16/CCITT-FALSE computed over the header word
42//! plus the payload bytes (and any trailing zero padding to the
43//! `n_blocks × 12-byte` block boundary).
44
45use crc::{CRC_16_IBM_3740, Crc};
46
47use super::puncture::Mode;
48
49/// Total header-word + CRC byte count.
50pub const HEADER_BYTES: usize = 4;
51
52/// Information bytes carried per LDPC block (96 bits of the 101
53/// `Ldpc240_101` info bits; the 5 trailing bits are zero-padded).
54pub const INFO_BYTES_PER_BLOCK: usize = 12;
55
56/// Maximum payload LDPC blocks per frame (5-bit `blocks` field,
57/// encoded as `count − 1`).
58pub const MAX_BLOCKS_PER_FRAME: usize = 32;
59
60/// Maximum application payload — info-byte budget across all 32
61/// payload blocks (the dedicated header block carries no payload).
62pub const MAX_PAYLOAD_BYTES: usize = MAX_BLOCKS_PER_FRAME * INFO_BYTES_PER_BLOCK;
63
64/// CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection,
65/// no XOR-out.
66const CRC16_ALGO: Crc<u16> = Crc::<u16>::new(&CRC_16_IBM_3740);
67
68/// Decoded uvpacket frame header. `mode` is included for caller
69/// convenience even though it is technically conveyed by the
70/// preamble pattern at the modulation layer rather than by any
71/// bits in the header word.
72#[derive(Copy, Clone, Debug, Eq, PartialEq)]
73pub struct FrameHeader {
74    pub mode: Mode,
75    /// Payload LDPC block count, 1..=32.
76    pub block_count: u8,
77    /// Application-layer dispatch tag, 0..=15.
78    pub app_type: u8,
79    /// ARQ sequence number, 0..=31.
80    pub sequence: u8,
81}
82
83/// Errors returned by header packing.
84#[derive(Copy, Clone, Debug, Eq, PartialEq)]
85pub enum PackError {
86    /// `block_count` was outside `1..=32`.
87    InvalidBlockCount(u8),
88    /// `app_type` was outside `0..=15`.
89    InvalidAppType(u8),
90    /// `sequence` was outside `0..=31`.
91    InvalidSequence(u8),
92    /// `payload.len()` exceeded [`MAX_PAYLOAD_BYTES`].
93    PayloadTooLarge(usize),
94}
95
96/// Errors returned by header unpacking.
97#[derive(Copy, Clone, Debug, Eq, PartialEq)]
98pub enum UnpackError {
99    /// Input was shorter than [`HEADER_BYTES`].
100    Truncated,
101    /// CRC-16 mismatch.
102    CrcMismatch { expected: u16, computed: u16 },
103    /// Reserved bits were non-zero (forward-compatibility check).
104    ReservedNotZero(u8),
105}
106
107/// Pack the header word + CRC into the first [`HEADER_BYTES`] of a
108/// fresh `Vec<u8>`. The CRC is computed over `header_word ++ payload`,
109/// where `payload` is the byte stream that the receiver will recover
110/// after concatenating decoded payload-block info bytes.
111///
112/// Note that the returned bytes are **only the header**, not the
113/// header-block info bytes. Callers concatenate
114/// `pack_header(...)` plus their own payload to form the post-LDPC
115/// byte stream that gets verified end-to-end.
116pub fn pack_header(header: &FrameHeader, payload: &[u8]) -> Result<[u8; HEADER_BYTES], PackError> {
117    if !(1..=MAX_BLOCKS_PER_FRAME as u8).contains(&header.block_count) {
118        return Err(PackError::InvalidBlockCount(header.block_count));
119    }
120    if header.app_type > 15 {
121        return Err(PackError::InvalidAppType(header.app_type));
122    }
123    if header.sequence > 31 {
124        return Err(PackError::InvalidSequence(header.sequence));
125    }
126    if payload.len() > MAX_PAYLOAD_BYTES {
127        return Err(PackError::PayloadTooLarge(payload.len()));
128    }
129    let blocks_bits = u16::from(header.block_count - 1) & 0x1F;
130    let app_bits = u16::from(header.app_type) & 0xF;
131    let seq_bits = u16::from(header.sequence) & 0x1F;
132    let header_word: u16 = (blocks_bits << 11) | (app_bits << 7) | (seq_bits << 2);
133
134    let mut out = [0u8; HEADER_BYTES];
135    out[0..2].copy_from_slice(&header_word.to_be_bytes());
136    let mut crc_input = Vec::with_capacity(2 + payload.len());
137    crc_input.extend_from_slice(&out[0..2]);
138    crc_input.extend_from_slice(payload);
139    let crc = crc16(&crc_input);
140    out[2..4].copy_from_slice(&crc.to_be_bytes());
141    Ok(out)
142}
143
144/// Inverse of [`pack_header`]: parse the 4-byte header off the front
145/// of `bytes`, verify CRC over `header_word ++ payload`, return
146/// `(header, payload_slice)` on success.
147///
148/// `mode` is supplied externally (the preamble identified it) and
149/// passed through into the returned [`FrameHeader`] for caller
150/// convenience. The CRC is computed over the bytes following
151/// `HEADER_BYTES` exactly as produced by [`pack_header`] — the
152/// caller is responsible for trimming any zero-padding before
153/// presenting the result to the application layer.
154pub fn unpack_header(bytes: &[u8], mode: Mode) -> Result<(FrameHeader, &[u8]), UnpackError> {
155    if bytes.len() < HEADER_BYTES {
156        return Err(UnpackError::Truncated);
157    }
158    let header_word = u16::from_be_bytes([bytes[0], bytes[1]]);
159    let crc_recv = u16::from_be_bytes([bytes[2], bytes[3]]);
160    let payload = &bytes[HEADER_BYTES..];
161
162    let mut crc_input = Vec::with_capacity(2 + payload.len());
163    crc_input.extend_from_slice(&bytes[..2]);
164    crc_input.extend_from_slice(payload);
165    let crc_calc = crc16(&crc_input);
166    if crc_calc != crc_recv {
167        return Err(UnpackError::CrcMismatch {
168            expected: crc_recv,
169            computed: crc_calc,
170        });
171    }
172
173    let blocks_code = ((header_word >> 11) & 0x1F) as u8;
174    let app_type = ((header_word >> 7) & 0x0F) as u8;
175    let sequence = ((header_word >> 2) & 0x1F) as u8;
176    let reserved = (header_word & 0x3) as u8;
177    if reserved != 0 {
178        return Err(UnpackError::ReservedNotZero(reserved));
179    }
180    let block_count = blocks_code + 1;
181
182    Ok((
183        FrameHeader {
184            mode,
185            block_count,
186            app_type,
187            sequence,
188        },
189        payload,
190    ))
191}
192
193/// CRC-16/CCITT-FALSE (poly `0x1021`, init `0xFFFF`, no reflection,
194/// no XOR-out). Public so callers can verify checksums against
195/// alternative byte slicings.
196pub fn crc16(bytes: &[u8]) -> u16 {
197    CRC16_ALGO.checksum(bytes)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn sample_header() -> FrameHeader {
205        FrameHeader {
206            mode: Mode::Robust,
207            block_count: 5,
208            app_type: 1,
209            sequence: 7,
210        }
211    }
212
213    #[test]
214    fn pack_unpack_roundtrip_empty_payload() {
215        let h = sample_header();
216        let bytes = pack_header(&h, &[]).unwrap();
217        let mut all = Vec::new();
218        all.extend_from_slice(&bytes);
219        let (h2, p2) = unpack_header(&all, Mode::Robust).unwrap();
220        assert_eq!(h, h2);
221        assert!(p2.is_empty());
222    }
223
224    #[test]
225    fn pack_unpack_roundtrip_with_payload() {
226        let h = sample_header();
227        let payload: Vec<u8> = (0..60).collect();
228        let bytes = pack_header(&h, &payload).unwrap();
229        let mut all = Vec::new();
230        all.extend_from_slice(&bytes);
231        all.extend_from_slice(&payload);
232        let (h2, p2) = unpack_header(&all, Mode::Robust).unwrap();
233        assert_eq!(h, h2);
234        assert_eq!(p2, &payload[..]);
235    }
236
237    #[test]
238    fn pack_unpack_roundtrip_all_modes() {
239        for mode in [
240            Mode::Robust,
241            Mode::Standard,
242            Mode::UltraRobust,
243            Mode::Express,
244        ] {
245            let h = FrameHeader {
246                mode,
247                block_count: 1,
248                app_type: 0,
249                sequence: 0,
250            };
251            let bytes = pack_header(&h, b"hi").unwrap();
252            let mut all = Vec::new();
253            all.extend_from_slice(&bytes);
254            all.extend_from_slice(b"hi");
255            let (h2, p2) = unpack_header(&all, mode).unwrap();
256            assert_eq!(h, h2);
257            assert_eq!(p2, b"hi");
258        }
259    }
260
261    #[test]
262    fn pack_rejects_invalid_block_count() {
263        for bad in [0u8, 33, 200] {
264            let h = FrameHeader {
265                mode: Mode::Robust,
266                block_count: bad,
267                app_type: 0,
268                sequence: 0,
269            };
270            assert_eq!(
271                pack_header(&h, &[]).unwrap_err(),
272                PackError::InvalidBlockCount(bad),
273            );
274        }
275    }
276
277    #[test]
278    fn unpack_detects_header_bit_flip() {
279        let h = sample_header();
280        let mut bytes = Vec::new();
281        bytes.extend_from_slice(&pack_header(&h, b"hello").unwrap());
282        bytes.extend_from_slice(b"hello");
283        bytes[0] ^= 0x40;
284        match unpack_header(&bytes, Mode::Robust) {
285            Err(UnpackError::CrcMismatch { .. }) => {}
286            other => panic!("expected CrcMismatch, got {other:?}"),
287        }
288    }
289
290    #[test]
291    fn unpack_detects_payload_bit_flip() {
292        let h = sample_header();
293        let mut bytes = Vec::new();
294        bytes.extend_from_slice(&pack_header(&h, b"hello").unwrap());
295        bytes.extend_from_slice(b"hello");
296        let pos = HEADER_BYTES + 2;
297        bytes[pos] ^= 0x01;
298        match unpack_header(&bytes, Mode::Robust) {
299            Err(UnpackError::CrcMismatch { .. }) => {}
300            other => panic!("expected CrcMismatch, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn unpack_rejects_truncated() {
306        for n in 0..HEADER_BYTES {
307            let bytes = vec![0u8; n];
308            assert_eq!(
309                unpack_header(&bytes, Mode::Robust).unwrap_err(),
310                UnpackError::Truncated,
311            );
312        }
313    }
314
315    #[test]
316    fn crc16_canonical_check_value() {
317        assert_eq!(crc16(b"123456789"), 0x29B1);
318    }
319
320    #[test]
321    fn capacity_constants_consistent() {
322        assert_eq!(MAX_BLOCKS_PER_FRAME, 32);
323        assert_eq!(INFO_BYTES_PER_BLOCK, 12);
324        assert_eq!(MAX_PAYLOAD_BYTES, 32 * 12); // = 384, no header subtraction
325    }
326}