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}