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