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}