mbelib_rs/encode/pack.rs
1// SPDX-FileCopyrightText: 2026 Swift Raccoon
2// SPDX-License-Identifier: GPL-2.0-or-later
3
4//! AMBE frame packing — the exact inverse of [`crate::unpack`].
5//!
6//! Given a 72-bit codeword array `ambe_fr` (C0 at 0..24, C1 at 24..47,
7//! C2 at 47..58, C3 at 58..72 — the same layout the decoder consumes
8//! after error correction and C1 demodulation), this module:
9//!
10//! 1. **Modulates C1** by XOR-ing its bits with an LFSR sequence
11//! seeded from the C0 data bits. The XOR operation is self-inverse,
12//! so we call [`crate::unpack::demodulate_c1`] directly — "modulate"
13//! and "demodulate" are the same byte-level op; only the pipeline
14//! direction differs. Applied after the caller has already encoded
15//! FEC into C0 and C1 (or, for P1 round-trip testing, the caller
16//! supplies already-encoded codewords and this step restores them
17//! to wire form).
18//! 2. **Packs** the 72 bits back into 9 bytes via the inverse interleave
19//! table, MSB-first within each byte (bit 7 of byte 0 is input bit 0).
20//!
21//! The output is a valid 9-byte AMBE wire frame suitable for the
22//! DSVT voice-data slot in the D-STAR frame.
23
24use crate::encode::interleave::{AMBE_FRAME_BITS, INVERSE};
25use crate::unpack::demodulate_c1;
26
27/// Pack a 72-bit FEC-codeword array into a 9-byte AMBE wire frame.
28///
29/// This is the inverse of the decoder's `unpack_frame` composed with
30/// `demodulate_c1`: given an `ambe_fr` array that contains
31/// already-FEC-encoded codewords (C0 Golay-encoded, C1 Golay-encoded
32/// but *not* yet XOR-scrambled, C2 / C3 as-is), produce the on-wire
33/// byte sequence that a conformant AMBE decoder would recover back
34/// to the same `ambe_fr`.
35///
36/// # Arguments
37///
38/// - `ambe_fr`: a 72-element array where each byte holds a single bit
39/// (0 or 1), laid out in FEC-codeword order (C0 at 0..24, C1 at
40/// 24..47, C2 at 47..58, C3 at 58..72). This is the *same* layout
41/// `unpack_frame` writes into.
42///
43/// # Returns
44///
45/// 9 packed bytes, MSB-first. `result[0]` bit 7 is the first bit that
46/// goes on the wire.
47///
48/// # Round-trip invariant
49///
50/// For any `ambe_fr` produced by the decoder's `unpack_frame` +
51/// `demodulate_c1`, the following holds (see `tests` below):
52///
53/// ```text
54/// let mut fr = [0u8; 72];
55/// unpack_frame(&wire, &mut fr);
56/// demodulate_c1(&mut fr);
57/// let wire_round_trip = pack_frame(&fr);
58/// assert_eq!(wire, wire_round_trip);
59/// ```
60#[must_use]
61pub fn pack_frame(ambe_fr: &[u8; AMBE_FRAME_BITS]) -> [u8; 9] {
62 // Step 1: modulate C1 back to its on-wire scrambled form. The
63 // decoder calls `demodulate_c1` after Golay-correcting C0; the
64 // encoder must undo that scrambling before packing. XOR is its own
65 // inverse so the same routine serves both directions.
66 let mut working = *ambe_fr;
67 demodulate_c1(&mut working);
68
69 // Step 2: pack 72 codeword bits back into 9 bytes, MSB-first.
70 // Iterate over every `ambe_fr` position paired with its
71 // wire-order input-bit index from `INVERSE`. That bit lands at
72 // byte `input_bit / 8`, bit position `7 - (input_bit % 8)`
73 // (MSB-first within each byte).
74 let mut out = [0u8; 9];
75 for (&bit_val, &input_bit_u8) in working.iter().zip(INVERSE.iter()) {
76 if bit_val == 0 {
77 continue;
78 }
79 let input_bit = input_bit_u8 as usize;
80 let byte_idx = input_bit / 8;
81 let bit_pos = 7 - (input_bit % 8);
82 if let Some(b) = out.get_mut(byte_idx) {
83 *b |= 1 << bit_pos;
84 }
85 }
86
87 out
88}
89
90#[cfg(test)]
91mod tests {
92 use super::{AMBE_FRAME_BITS, pack_frame};
93 use crate::unpack::{demodulate_c1, unpack_frame};
94 use proptest::prelude::*;
95
96 /// Hand-round-trip a zero frame. Trivial but catches "did anything
97 /// compile at all" regressions.
98 #[test]
99 fn zero_frame_round_trips() {
100 let wire = [0u8; 9];
101 let mut fr = [0u8; AMBE_FRAME_BITS];
102 unpack_frame(&wire, &mut fr);
103 demodulate_c1(&mut fr);
104 let wire_rt = pack_frame(&fr);
105 assert_eq!(wire, wire_rt);
106 }
107
108 /// Hand-round-trip a frame where every bit is 1. Exercises the
109 /// packing when every output position must be set.
110 #[test]
111 fn all_ones_frame_round_trips() {
112 let wire = [0xFFu8; 9];
113 let mut fr = [0u8; AMBE_FRAME_BITS];
114 unpack_frame(&wire, &mut fr);
115 demodulate_c1(&mut fr);
116 let wire_rt = pack_frame(&fr);
117 assert_eq!(wire, wire_rt);
118 }
119
120 proptest! {
121 /// For ANY 9-byte input, unpack → demodulate → pack must
122 /// return the original 9 bytes. This is the key correctness
123 /// invariant for the packing layer: together, unpack+pack
124 /// form a bijection on the wire-frame byte space.
125 #[test]
126 fn wire_frame_round_trip(wire in prop::array::uniform9(0u8..=255)) {
127 let mut fr = [0u8; AMBE_FRAME_BITS];
128 unpack_frame(&wire, &mut fr);
129 demodulate_c1(&mut fr);
130 let wire_rt = pack_frame(&fr);
131 prop_assert_eq!(wire, wire_rt);
132 }
133 }
134}