Skip to main content

mfsk_core/msg/
q65.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Q65 message codec.
3//!
4//! Q65 reuses the same 77-bit WSJT message format as FT8 / FT4 / FST4
5//! (`super::wsjt77`). The only Q65-specific detail at the message
6//! layer is the bit-to-GF(64)-symbol packing that feeds the QRA
7//! encoder: 77 bits go in as **13 GF(64) symbols** with layout `12 ×
8//! 6 bits + 1 × 5 bits`, with the last symbol's LSB zero-padded to
9//! complete a 6-bit symbol value.
10//!
11//! Mirrors the Fortran code in `lib/qra/q65/genq65.f90`:
12//!
13//! ```text
14//! read(c77, '(12b6.6, b5.5)') dgen   ! pack 77 bits into 13 ints (last is 5-bit)
15//! dgen(13) = 2 * dgen(13)            ! left-shift the 13th symbol, zero-padding the LSB
16//! ```
17//!
18//! [`Q65Message`] is the [`MessageCodec`] surface; it delegates pack /
19//! unpack to the existing 77-bit wsjt77 helpers and just records the
20//! correct `CRC_BITS = 12` for Q65 (the CRC-12 lives at the FEC
21//! layer; see [`crate::fec::qra::Q65Codec`]).
22
23use alloc::string::String;
24use alloc::vec::Vec;
25
26use super::ap::ApHint;
27use super::wsjt77;
28use super::{CallsignHashTable, Wsjt77Message};
29use crate::core::{DecodeContext, MessageCodec, MessageFields};
30
31/// Pack a 77-bit WSJT message (LSB / MSB convention matching
32/// [`super::wsjt77`]: each byte holds one bit in its LSB) into the
33/// 13-GF(64)-symbol vector that feeds Q65's QRA encoder.
34///
35/// Layout: `symbols[0..12]` carry bits `0..72` six at a time
36/// (MSB-first within each symbol). `symbols[12]` carries bits
37/// `72..77` in its top five bits, with the LSB zero-padded to make
38/// it a valid 6-bit GF(64) value.
39pub fn pack77_to_symbols(bits77: &[u8; 77]) -> [i32; 13] {
40    let mut out = [0_i32; 13];
41    for (i, slot) in out.iter_mut().enumerate().take(12) {
42        let mut s = 0_i32;
43        for b in 0..6 {
44            s = (s << 1) | (bits77[6 * i + b] & 1) as i32;
45        }
46        *slot = s;
47    }
48    // Last symbol: 5 bits from bits77[72..77], shift left by 1 to
49    // zero-pad the LSB into a 6-bit symbol value.
50    let mut last = 0_i32;
51    for b in 0..5 {
52        last = (last << 1) | (bits77[72 + b] & 1) as i32;
53    }
54    out[12] = last << 1;
55    out
56}
57
58/// Convert a 77-bit [`ApHint`] into the 13-symbol GF(64) `(mask,
59/// values)` pair that the QRA decoder's masking step
60/// (`_q65_mask` in the C reference) consumes.
61///
62/// The hint is first projected to the 77-bit Wsjt77 layout via
63/// [`ApHint::build_bits`]. We then extend it to 78 bits — the
64/// padding bit (LSB of symbol 12) is always 0 in a valid Q65
65/// transmission, so we lock it whenever the hint carries any AP
66/// information at all (matching WSJT-X iaptype=1/2/3, which fix
67/// `apmask(75:78) = 1`).
68///
69/// Pack-into-symbols layout matches [`pack77_to_symbols`]: 12 × 6
70/// bits + 1 × 5 bits left-shifted by one with the LSB acting as
71/// the padding slot.
72pub fn ap_hint_to_q65_mask(hint: &ApHint) -> ([i32; 13], [i32; 13]) {
73    let (mut mask77, mut values77) = hint.build_bits(77);
74    // Extend to 78 bits, locking the padding bit (= 0) whenever any
75    // AP info is present.
76    let lock_padding = if hint.has_info() { 1 } else { 0 };
77    mask77.push(lock_padding);
78    values77.push(0);
79
80    let mut mask_syms = [0_i32; 13];
81    let mut value_syms = [0_i32; 13];
82    for i in 0..13 {
83        let mut m = 0_i32;
84        let mut v = 0_i32;
85        for b in 0..6 {
86            m = (m << 1) | (mask77[6 * i + b] & 1) as i32;
87            v = (v << 1) | (values77[6 * i + b] & 1) as i32;
88        }
89        mask_syms[i] = m;
90        value_syms[i] = v;
91    }
92    (mask_syms, value_syms)
93}
94
95/// Inverse of [`pack77_to_symbols`]: extract a 77-bit WSJT message
96/// from the 13-symbol decoder output. The LSB of `symbols[12]` is
97/// discarded (it was zero-padding on the encode side).
98pub fn unpack_symbols_to_bits77(symbols: &[i32; 13]) -> [u8; 77] {
99    let mut bits = [0_u8; 77];
100    for i in 0..12 {
101        let s = symbols[i];
102        for b in 0..6 {
103            bits[6 * i + b] = ((s >> (5 - b)) & 1) as u8;
104        }
105    }
106    // bits 72..77 are the top 5 bits of symbol 12; the LSB is dropped.
107    let s = symbols[12];
108    for b in 0..5 {
109        // Bit positions 5..1 of the 6-bit symbol value (the LSB / bit
110        // 0 was the zero-pad).
111        bits[72 + b] = ((s >> (5 - b)) & 1) as u8;
112    }
113    bits
114}
115
116/// Q65 [`MessageCodec`] — wire-compatible with [`Wsjt77Message`] at
117/// the human-readable level (Q65 transmits standard FT-style
118/// callsign / grid / report messages and free text), but advertises
119/// the Q65-specific CRC-12 width as metadata.
120///
121/// The 77-bit ↔ 13-symbol conversion (which is the Q65-specific
122/// piece) lives as free functions in this module
123/// ([`pack77_to_symbols`] / [`unpack_symbols_to_bits77`]) and is
124/// invoked from the protocol's tx / rx paths, not through this trait.
125#[derive(Copy, Clone, Debug, Default)]
126pub struct Q65Message;
127
128impl MessageCodec for Q65Message {
129    type Unpacked = String;
130    /// Q65 carries the same 77-bit WSJT payload as FT8 / FT4 / FST4.
131    const PAYLOAD_BITS: u32 = 77;
132    /// Q65 protects the payload with a CRC-12 (vs the 14-bit CRC
133    /// FT8/FT4 use). The CRC sits inside the QRA codec — see
134    /// [`crate::fec::qra::Q65Codec`].
135    const CRC_BITS: u32 = 12;
136
137    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>> {
138        // Bit-for-bit identical to FT8/FT4/FST4 — Q65 uses the same
139        // 77-bit format. Reuse the existing implementation.
140        Wsjt77Message.pack(fields)
141    }
142
143    fn unpack(&self, payload: &[u8], ctx: &DecodeContext) -> Option<Self::Unpacked> {
144        if payload.len() != 77 {
145            return None;
146        }
147        let mut buf = [0u8; 77];
148        buf.copy_from_slice(payload);
149
150        if let Some(any) = ctx.callsign_hash_table.as_ref()
151            && let Some(ht) = any.downcast_ref::<CallsignHashTable>()
152        {
153            return wsjt77::unpack77_with_hash(&buf, ht);
154        }
155        wsjt77::unpack77(&buf)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn pack_unpack_roundtrip_random_bits() {
165        // Round-trip every distinct bit pattern we care about: zero,
166        // all-ones, and a deterministic pseudo-random pattern. The
167        // padding bit (LSB of symbol 12) gets discarded on unpack so
168        // the original 77 bits must come back unchanged.
169        let cases: Vec<[u8; 77]> = vec![
170            [0u8; 77],
171            [1u8; 77],
172            // Pseudo-random bit pattern.
173            std::array::from_fn(|i| (((i * 31) ^ 0x55) & 1) as u8),
174        ];
175        for bits in cases {
176            let symbols = pack77_to_symbols(&bits);
177            // Each symbol must be a valid GF(64) value (0..64).
178            for (k, s) in symbols.iter().enumerate() {
179                assert!(*s >= 0 && *s < 64, "symbol[{k}] = {s} out of range");
180            }
181            let back = unpack_symbols_to_bits77(&symbols);
182            assert_eq!(back, bits, "77-bit roundtrip failed");
183        }
184    }
185
186    #[test]
187    fn last_symbol_has_zero_lsb_after_pack() {
188        // The pack function must always zero-pad the LSB so that the
189        // 13th symbol stays in 0..64 even when bits77[72..77] is all
190        // ones.
191        let mut bits = [0u8; 77];
192        for b in 72..77 {
193            bits[b] = 1;
194        }
195        let symbols = pack77_to_symbols(&bits);
196        // Top 5 bits set, LSB zero → 0b111110 = 62.
197        assert_eq!(symbols[12], 62);
198        assert_eq!(symbols[12] & 1, 0, "LSB padding bit must be 0");
199    }
200
201    #[test]
202    fn message_codec_pack_matches_wsjt77() {
203        // Q65Message must produce byte-identical packed output to
204        // Wsjt77Message (Q65 reuses the format unchanged).
205        let fields = MessageFields {
206            call1: Some("CQ".to_string()),
207            call2: Some("JA1ABC".to_string()),
208            grid: Some("PM95".to_string()),
209            ..Default::default()
210        };
211        let q65 = Q65Message.pack(&fields).expect("Q65 pack must succeed");
212        let wsjt = Wsjt77Message
213            .pack(&fields)
214            .expect("Wsjt77 pack must succeed");
215        assert_eq!(q65, wsjt);
216        assert_eq!(q65.len(), 77);
217    }
218
219    #[test]
220    fn unpack_roundtrip_preserves_message_text() {
221        // Pack a standard message → convert to symbols → convert
222        // back → unpack: the human-readable string must round-trip.
223        let fields = MessageFields {
224            call1: Some("CQ".to_string()),
225            call2: Some("K1ABC".to_string()),
226            grid: Some("FN42".to_string()),
227            ..Default::default()
228        };
229        let bits = Q65Message.pack(&fields).expect("pack");
230        let bits77: [u8; 77] = bits.try_into().expect("77-bit length");
231        let symbols = pack77_to_symbols(&bits77);
232        let back = unpack_symbols_to_bits77(&symbols);
233        let text = Q65Message
234            .unpack(&back, &DecodeContext::default())
235            .expect("unpack");
236        assert_eq!(text, "CQ K1ABC FN42");
237    }
238
239    #[test]
240    fn payload_and_crc_bit_widths() {
241        assert_eq!(<Q65Message as MessageCodec>::PAYLOAD_BITS, 77);
242        assert_eq!(<Q65Message as MessageCodec>::CRC_BITS, 12);
243    }
244
245    #[test]
246    fn ap_hint_empty_yields_no_locked_symbols() {
247        // An empty hint (no calls / grid / report) must produce an
248        // all-zero mask — the decoder should fall back to plain BP.
249        let hint = ApHint::new();
250        let (mask, values) = ap_hint_to_q65_mask(&hint);
251        assert_eq!(mask, [0; 13], "empty hint must mask nothing");
252        assert_eq!(values, [0; 13], "empty hint values irrelevant but zeroed");
253    }
254
255    #[test]
256    fn ap_hint_with_call1_locks_first_29_bits() {
257        // ApHint with call1 = "CQ" sets bits 0..29 known. Mapped
258        // into 13 symbols, that means: full 6-bit mask on syms
259        // 0..3, then the top 5 bits of sym 4 (29 = 4*6 + 5).
260        let hint = ApHint::new().with_call1("CQ");
261        let (mask, _) = ap_hint_to_q65_mask(&hint);
262        assert_eq!(mask[0], 0x3F, "sym 0 must be fully locked (bits 0..6)");
263        assert_eq!(mask[1], 0x3F, "sym 1 must be fully locked (bits 6..12)");
264        assert_eq!(mask[2], 0x3F, "sym 2 must be fully locked (bits 12..18)");
265        assert_eq!(mask[3], 0x3F, "sym 3 must be fully locked (bits 18..24)");
266        // sym 4: bits 24..30, only bits 24..29 known (5 of 6).
267        assert_eq!(mask[4], 0b111110, "sym 4 must lock its top 5 bits");
268        assert_eq!(mask[5], 0, "sym 5 (bits 30..36) untouched without call2");
269    }
270
271    #[test]
272    fn ap_hint_padding_bit_is_locked_when_info_present() {
273        // Whenever AP info exists, the padding bit (LSB of sym 12)
274        // must be locked to 0 — matches WSJT-X's apmask(75:78) = 1
275        // pattern in q65_ap.f90 iaptype 1/2.
276        let hint = ApHint::new().with_call1("CQ");
277        let (mask, values) = ap_hint_to_q65_mask(&hint);
278        assert_eq!(mask[12] & 1, 1, "sym 12 LSB (= padding bit) must be locked");
279        assert_eq!(values[12] & 1, 0, "padding bit value must be 0");
280    }
281
282    #[test]
283    fn ap_hint_round_trip_preserves_known_bits() {
284        // Build a hint, convert to symbols, and verify the resulting
285        // (mask, value) pair on the same payload as the encoder
286        // produces matches the locked positions.
287        let fields = MessageFields {
288            call1: Some("CQ".to_string()),
289            call2: Some("K1ABC".to_string()),
290            grid: Some("FN42".to_string()),
291            ..Default::default()
292        };
293        let bits77 = Q65Message.pack(&fields).expect("pack");
294        let bits77_arr: [u8; 77] = bits77.try_into().expect("77-bit length");
295        let encoded_syms = pack77_to_symbols(&bits77_arr);
296
297        let hint = ApHint::new()
298            .with_call1("CQ")
299            .with_call2("K1ABC")
300            .with_grid("FN42");
301        let (mask, values) = ap_hint_to_q65_mask(&hint);
302
303        // Wherever the mask is non-zero, the locked bits must agree
304        // with the encoded symbols.
305        for k in 0..13 {
306            let m = mask[k];
307            let v = values[k];
308            let actual = encoded_syms[k];
309            assert_eq!(
310                v & m,
311                actual & m,
312                "sym {k}: AP value {v:06b} mismatches encoded {actual:06b} under mask {m:06b}"
313            );
314        }
315    }
316}