Skip to main content

mfsk_core/msg/
jt72.rs

1//! JT 72-bit message codec, shared by JT65 and JT9.
2//!
3//! Ported from WSJT-X `lib/packjt.f90` — in particular
4//! `packmsg` / `unpackmsg`, `packcall` / `unpackcall`,
5//! and `packgrid` / `unpackgrid`. The 72-bit payload layout is
6//! identical between JT65 and JT9; it packs as
7//!
8//! ```text
9//! |---- nc1 (28) ---|--- nc2 (28) ---|-- ng (16) --|
10//! ```
11//!
12//! where `nc1` / `nc2` are the two callsigns (base-37 / base-36 /
13//! base-10 / base-27^3 packed) and `ng` is the 4-character
14//! Maidenhead grid or an encoded report code.
15//!
16//! The bytes then get laid out as 12 × 6-bit symbols. That shape
17//! matches what JT65's Reed-Solomon and JT9's convolutional encoder
18//! ingest. This module does **not** speak symbols directly — callers
19//! are expected to unpack the 72-bit byte stream into whatever FEC
20//! wants.
21//!
22//! ## Scope
23//!
24//! The MVP covers the **standard message** (two callsigns plus a
25//! grid / report) and its documented report-code variants (plain
26//! `-NN` / `RNN` / `RO` / `RRR` / `73`). Free text (Type 6) and the
27//! compound-callsign Type 2–5 cases are detected but reported as
28//! `Standard { .., grid: "…" }` rather than fully unpacked; those
29//! less common paths can be ported from `getpfx1` / `getpfx2` when
30//! needed.
31
32use alloc::format;
33use alloc::string::{String, ToString};
34use alloc::vec::Vec;
35use core::fmt;
36
37/// Base used to pack a 6-character callsign into a 28-bit integer.
38/// Matches `NBASE` in WSJT-X: `37 * 36 * 10 * 27 * 27 * 27 = 262 177 560`.
39const NBASE: u32 = 37 * 36 * 10 * 27 * 27 * 27;
40
41/// Base used for 4-character Maidenhead grids: `180 * 180 = 32 400`.
42/// Values above this encode report codes (see `unpack_grid`).
43const NGBASE: u32 = 180 * 180;
44
45/// Decoded JT 72-bit message payload.
46///
47/// The enum shape mirrors the `itype` classification in WSJT-X
48/// `packmsg` (Type 1 = standard, Types 2–5 = compound-callsign
49/// variants, Type 6 = free text) but for the MVP everything that
50/// isn't a plain standard message is collapsed into `Unsupported`.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub enum Jt72Message {
53    /// Standard two-callsign + grid / report message.
54    Standard {
55        call1: String,
56        call2: String,
57        /// Human-readable representation of the `ng` field: either a
58        /// 4-char grid ("FN42"), a report ("-15", "R-05"), or one of
59        /// the short tokens ("RO", "RRR", "73").
60        grid_or_report: String,
61    },
62    /// A message whose fields decode but don't fit the standard
63    /// pattern yet (compound callsign prefix/suffix, free text).
64    /// Raw integer fields are exposed for callers that want to dig in.
65    Unsupported { nc1: u32, nc2: u32, ng: u32 },
66}
67
68impl fmt::Display for Jt72Message {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Jt72Message::Standard {
72                call1,
73                call2,
74                grid_or_report,
75            } => write!(f, "{} {} {}", call1, call2, grid_or_report),
76            Jt72Message::Unsupported { nc1, nc2, ng } => {
77                write!(f, "<unsupported nc1={nc1} nc2={nc2} ng={ng}>")
78            }
79        }
80    }
81}
82
83// ─────────────────────────────────────────────────────────────────────────
84// Character helpers (WSJT-X `nchar` / `unpackcall` tables)
85// ─────────────────────────────────────────────────────────────────────────
86
87/// 37-char callsign alphabet: digits, uppercase letters, space.
88const CALL_ALPHA: &[u8; 37] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
89
90/// Translate a callsign char to its `nchar` index: digit→0..9,
91/// letter→10..35, space→36. Returns `None` for anything else.
92fn nchar(c: u8) -> Option<u32> {
93    match c {
94        b'0'..=b'9' => Some((c - b'0') as u32),
95        b'A'..=b'Z' => Some((c - b'A' + 10) as u32),
96        b'a'..=b'z' => Some((c - b'a' + 10) as u32),
97        b' ' => Some(36),
98        _ => None,
99    }
100}
101
102// ─────────────────────────────────────────────────────────────────────────
103// Callsign (28-bit `nc`)
104// ─────────────────────────────────────────────────────────────────────────
105
106/// Pack a ≤ 6-character callsign into a 28-bit integer. The standard
107/// layout expects the digit in position 3 (`K1ABC`) or position 2
108/// (`K9AN`); the latter gets a leading space inserted so the digit
109/// lands at index 2.
110///
111/// Returns `None` if the callsign doesn't fit the base-37/36/10/27³
112/// schema — those cases trigger the "text / compound" fallbacks in
113/// `packcall` that this MVP doesn't yet model.
114pub fn pack_call(call: &str) -> Option<u32> {
115    let bytes = call.as_bytes();
116    // Special tokens handled by WSJT-X's `packcall`.
117    match call {
118        "CQ" => return Some(NBASE + 1),
119        "QRZ" => return Some(NBASE + 2),
120        "DE" => return Some(267_796_945),
121        _ => {}
122    }
123    if bytes.is_empty() || bytes.len() > 6 {
124        return None;
125    }
126
127    // Build the 6-char right-aligned working copy `tmp`.
128    let mut tmp = [b' '; 6];
129    if bytes.len() >= 3 && bytes[2].is_ascii_digit() {
130        // Digit at position 3 (0-indexed 2) — left-aligned as-is.
131        for (i, &b) in bytes.iter().enumerate() {
132            tmp[i] = b;
133        }
134    } else if bytes.len() >= 2 && bytes[1].is_ascii_digit() {
135        // Digit at position 2 — shift right by one so digit lands at
136        // tmp[2]. Max source length becomes 5.
137        if bytes.len() > 5 {
138            return None;
139        }
140        for (i, &b) in bytes.iter().enumerate() {
141            tmp[i + 1] = b;
142        }
143    } else {
144        return None;
145    }
146
147    // Uppercase.
148    for t in tmp.iter_mut() {
149        if t.is_ascii_lowercase() {
150            *t -= b'a' - b'A';
151        }
152    }
153
154    // Validate slot alphabets.
155    let n = [
156        nchar(tmp[0])?,
157        nchar(tmp[1])?,
158        nchar(tmp[2])?,
159        nchar(tmp[3])?,
160        nchar(tmp[4])?,
161        nchar(tmp[5])?,
162    ];
163    // Slot 0: letter/digit/space (0..=36)
164    // Slot 1: letter/digit (0..=35)
165    if n[1] == 36 {
166        return None;
167    }
168    // Slot 2: digit (0..=9)
169    if n[2] >= 10 {
170        return None;
171    }
172    // Slots 3..=5: letter/space (10..=36)
173    for k in 3..6 {
174        if n[k] < 10 {
175            return None;
176        }
177    }
178
179    let mut ncall = n[0];
180    ncall = 36 * ncall + n[1];
181    ncall = 10 * ncall + n[2];
182    ncall = 27 * ncall + n[3] - 10;
183    ncall = 27 * ncall + n[4] - 10;
184    ncall = 27 * ncall + n[5] - 10;
185    Some(ncall)
186}
187
188/// Unpack a 28-bit integer back into a callsign or special token.
189/// Returns `None` for values outside the base-37/36/10/27³ range
190/// (those encode compound-callsign variants).
191pub fn unpack_call(ncall: u32) -> Option<String> {
192    // Special tokens.
193    match ncall {
194        v if v == NBASE + 1 => return Some("CQ".into()),
195        v if v == NBASE + 2 => return Some("QRZ".into()),
196        267_796_945 => return Some("DE".into()),
197        _ => {}
198    }
199    if ncall >= NBASE {
200        return None;
201    }
202    let mut n = ncall;
203    let mut chars = [b' '; 6];
204    let c6 = (n % 27) + 10;
205    chars[5] = CALL_ALPHA[c6 as usize];
206    n /= 27;
207    let c5 = (n % 27) + 10;
208    chars[4] = CALL_ALPHA[c5 as usize];
209    n /= 27;
210    let c4 = (n % 27) + 10;
211    chars[3] = CALL_ALPHA[c4 as usize];
212    n /= 27;
213    let c3 = n % 10;
214    chars[2] = CALL_ALPHA[c3 as usize];
215    n /= 10;
216    let c2 = n % 36;
217    chars[1] = CALL_ALPHA[c2 as usize];
218    n /= 36;
219    let c1 = n; // 0..=36
220    chars[0] = CALL_ALPHA[c1 as usize];
221
222    let s = core::str::from_utf8(&chars).ok()?;
223    Some(s.trim().to_string())
224}
225
226// ─────────────────────────────────────────────────────────────────────────
227// Grid / report (16-bit `ng`)
228// ─────────────────────────────────────────────────────────────────────────
229
230/// Pack a 4-character grid locator into `ng` via the Maidenhead →
231/// integer mapping used by WSJT-X `packgrid` (without the
232/// extended-range report tricks — callers can build those up
233/// manually).
234///
235/// WSJT-X reference (`lib/packjt.f90:341-345` + `grid2deg.f90`):
236/// `dlong = (180 - 20*fl - 2*sl) - 62.5/60`, `lat = 10*fla + sla`,
237/// `ng = ((int(dlong) + 180) / 2) * 180 + lat`. For valid grids
238/// (fl ∈ 0..=17, sl ∈ 0..=9) the integer-arithmetic equivalent
239/// collapses to `ng_long_part = 179 - 10*fl - sl`.
240fn pack_grid4_plain(grid: &str) -> Option<u32> {
241    let b = grid.as_bytes();
242    if b.len() != 4 {
243        return None;
244    }
245    let fl = match b[0] {
246        c @ b'A'..=b'R' => (c - b'A') as i32,
247        _ => return None,
248    };
249    let fla = match b[1] {
250        c @ b'A'..=b'R' => (c - b'A') as i32,
251        _ => return None,
252    };
253    let sl = match b[2] {
254        c @ b'0'..=b'9' => (c - b'0') as i32,
255        _ => return None,
256    };
257    let sla = match b[3] {
258        c @ b'0'..=b'9' => (c - b'0') as i32,
259        _ => return None,
260    };
261    let ng_long_part = 179 - 10 * fl - sl;
262    let lat_int = 10 * fla + sla;
263    let ng = ng_long_part * 180 + lat_int;
264    Some(ng as u32)
265}
266
267/// Pack a 4-char grid OR a report/token into `ng`. Supported short
268/// forms: "RO", "RRR", "73", "-NN" (01..30), "R-NN" (01..30), empty
269/// (= "   ").
270pub fn pack_grid_or_report(s: &str) -> Option<u32> {
271    match s.trim_end() {
272        "" => Some(NGBASE + 1),
273        "RO" => Some(NGBASE + 62),
274        "RRR" => Some(NGBASE + 63),
275        "73" => Some(NGBASE + 64),
276        other => {
277            if let Some(rest) = other.strip_prefix('-')
278                && let Ok(n) = rest.parse::<i32>()
279                && (1..=30).contains(&n)
280            {
281                return Some(NGBASE + 1 + n as u32);
282            }
283            if let Some(rest) = other.strip_prefix("R-")
284                && let Ok(n) = rest.parse::<i32>()
285                && (1..=30).contains(&n)
286            {
287                return Some(NGBASE + 31 + n as u32);
288            }
289            pack_grid4_plain(other)
290        }
291    }
292}
293
294/// Inverse of `pack_grid_or_report`. Unknown codes (extended-range
295/// reports, free-text `ng + 32768`) decode as "?".
296pub fn unpack_grid(ng: u32) -> String {
297    if ng == NGBASE + 1 {
298        return String::new();
299    }
300    match ng {
301        v if v == NGBASE + 62 => return "RO".into(),
302        v if v == NGBASE + 63 => return "RRR".into(),
303        v if v == NGBASE + 64 => return "73".into(),
304        _ => {}
305    }
306    if ng > NGBASE && ng <= NGBASE + 30 + 1 {
307        let n = ng - NGBASE - 1;
308        return format!("-{:02}", n);
309    }
310    if ng > NGBASE + 31 && ng <= NGBASE + 61 {
311        let n = ng - NGBASE - 31;
312        return format!("R-{:02}", n);
313    }
314    if ng < NGBASE {
315        // Standard grid. Inverse of pack_grid4_plain:
316        //   long_part = ng / 180 = 179 - 10*fl - sl
317        //   lat       = ng % 180 = 10*fla + sla
318        let long_part = (ng / 180) as i32;
319        let lat = (ng % 180) as i32;
320        let fl_sl = 179 - long_part;
321        let fl = fl_sl / 10;
322        let sl = fl_sl % 10;
323        let fla = lat / 10;
324        let sla = lat % 10;
325        let mut g = [0u8; 4];
326        g[0] = b'A' + fl as u8;
327        g[1] = b'A' + fla as u8;
328        g[2] = b'0' + sl as u8;
329        g[3] = b'0' + sla as u8;
330        return core::str::from_utf8(&g).unwrap_or("????").to_string();
331    }
332    "?".into()
333}
334
335// ─────────────────────────────────────────────────────────────────────────
336// 72-bit pack / unpack
337// ─────────────────────────────────────────────────────────────────────────
338
339/// Pack (nc1, nc2, ng) into 12 × 6-bit symbols (`[u8; 12]`, values
340/// 0..=63). Matches the dat(1..12) layout in WSJT-X `packmsg` lines
341/// 521–532.
342pub fn pack_words(nc1: u32, nc2: u32, ng: u32) -> [u8; 12] {
343    let mut d = [0u8; 12];
344    d[0] = ((nc1 >> 22) & 0x3f) as u8;
345    d[1] = ((nc1 >> 16) & 0x3f) as u8;
346    d[2] = ((nc1 >> 10) & 0x3f) as u8;
347    d[3] = ((nc1 >> 4) & 0x3f) as u8;
348    d[4] = (((nc1 & 0xf) << 2) | ((nc2 >> 26) & 0x3)) as u8;
349    d[5] = ((nc2 >> 20) & 0x3f) as u8;
350    d[6] = ((nc2 >> 14) & 0x3f) as u8;
351    d[7] = ((nc2 >> 8) & 0x3f) as u8;
352    d[8] = ((nc2 >> 2) & 0x3f) as u8;
353    d[9] = (((nc2 & 0x3) << 4) | ((ng >> 12) & 0xf)) as u8;
354    d[10] = ((ng >> 6) & 0x3f) as u8;
355    d[11] = (ng & 0x3f) as u8;
356    d
357}
358
359/// Inverse of [`pack_words`]. Returns the packed-field tuple
360/// `(nc1, nc2, ng)` — widths 28 / 28 / 16 bits.
361pub fn unpack_words(d: &[u8; 12]) -> (u32, u32, u32) {
362    let nc1 = ((d[0] as u32) << 22)
363        | ((d[1] as u32) << 16)
364        | ((d[2] as u32) << 10)
365        | ((d[3] as u32) << 4)
366        | (((d[4] as u32) >> 2) & 0xf);
367    let nc2 = (((d[4] as u32) & 0x3) << 26)
368        | ((d[5] as u32) << 20)
369        | ((d[6] as u32) << 14)
370        | ((d[7] as u32) << 8)
371        | ((d[8] as u32) << 2)
372        | (((d[9] as u32) >> 4) & 0x3);
373    let ng = (((d[9] as u32) & 0xf) << 12) | ((d[10] as u32) << 6) | (d[11] as u32);
374    (nc1, nc2, ng)
375}
376
377/// Convenience: pack a standard message (call1, call2, grid_or_report)
378/// into 12 six-bit words.
379pub fn pack_standard(call1: &str, call2: &str, grid_or_report: &str) -> Option<[u8; 12]> {
380    let nc1 = pack_call(call1)?;
381    let nc2 = pack_call(call2)?;
382    let ng = pack_grid_or_report(grid_or_report)?;
383    Some(pack_words(nc1, nc2, ng))
384}
385
386/// Convenience: unpack 12 six-bit words into a `Jt72Message`.
387pub fn unpack(d: &[u8; 12]) -> Jt72Message {
388    let (nc1, nc2, ng) = unpack_words(d);
389    let c1 = unpack_call(nc1);
390    let c2 = unpack_call(nc2);
391    // Text / free-form messages set a `ng + 32768` high bit that
392    // this MVP doesn't decode — collapse those and anything outside
393    // the standard NBASE range into `Unsupported`.
394    if ng >= 32768 {
395        return Jt72Message::Unsupported { nc1, nc2, ng };
396    }
397    match (c1, c2) {
398        (Some(call1), Some(call2)) => Jt72Message::Standard {
399            call1,
400            call2,
401            grid_or_report: unpack_grid(ng),
402        },
403        _ => Jt72Message::Unsupported { nc1, nc2, ng },
404    }
405}
406
407// ─────────────────────────────────────────────────────────────────────────
408// MessageCodec impl
409// ─────────────────────────────────────────────────────────────────────────
410
411use crate::core::{DecodeContext, MessageCodec, MessageFields};
412
413/// JT 72-bit message codec. Used by JT65 and JT9.
414#[derive(Copy, Clone, Debug, Default)]
415pub struct Jt72Message_;
416
417// The struct name `Jt72Message` is already taken by the output enum,
418// so the codec type lives under a trailing underscore and is
419// re-exported as `Jt72Codec` for callers.
420pub type Jt72Codec = Jt72Message_;
421
422impl MessageCodec for Jt72Message_ {
423    type Unpacked = Jt72Message;
424    const PAYLOAD_BITS: u32 = 72;
425    const CRC_BITS: u32 = 0;
426
427    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>> {
428        let c1 = fields.call1.as_deref()?;
429        let c2 = fields.call2.as_deref()?;
430        let rep = fields
431            .grid
432            .as_deref()
433            .or(fields.free_text.as_deref())
434            .unwrap_or("");
435        let words = pack_standard(c1, c2, rep)?;
436        // Flatten the 12 × 6-bit words into 72 individual bits
437        // (MSB-first within each word), matching how FEC stages
438        // consume them elsewhere in mfsk-*.
439        let mut bits = Vec::with_capacity(72);
440        for &w in &words {
441            for b in (0..6).rev() {
442                bits.push((w >> b) & 1);
443            }
444        }
445        Some(bits)
446    }
447
448    fn unpack(&self, payload: &[u8], _ctx: &DecodeContext) -> Option<Self::Unpacked> {
449        if payload.len() != 72 {
450            return None;
451        }
452        let mut words = [0u8; 12];
453        for (i, slot) in words.iter_mut().enumerate() {
454            let mut w = 0u8;
455            for b in 0..6 {
456                w = (w << 1) | (payload[6 * i + b] & 1);
457            }
458            *slot = w;
459        }
460        Some(unpack(&words))
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn call_roundtrip_standard() {
470        for call in ["K1ABC", "K9AN", "JA1ABC", "VK3KCN", "G4BWP", "W7AV"] {
471            let n = pack_call(call).unwrap_or_else(|| panic!("pack {call}"));
472            let back = unpack_call(n).unwrap_or_else(|| panic!("unpack {call}"));
473            assert_eq!(back, call, "roundtrip: {call}");
474        }
475    }
476
477    #[test]
478    fn call_special_tokens() {
479        assert_eq!(pack_call("CQ"), Some(NBASE + 1));
480        assert_eq!(pack_call("QRZ"), Some(NBASE + 2));
481        assert_eq!(unpack_call(NBASE + 1).as_deref(), Some("CQ"));
482        assert_eq!(unpack_call(NBASE + 2).as_deref(), Some("QRZ"));
483    }
484
485    #[test]
486    fn grid_roundtrip() {
487        for grid in ["FN42", "PM95", "JN58", "AA00", "RR99"] {
488            let ng = pack_grid_or_report(grid).unwrap_or_else(|| panic!("pack {grid}"));
489            let back = unpack_grid(ng);
490            assert_eq!(back, grid, "roundtrip {grid}");
491        }
492    }
493
494    /// Pin the canonical WSJT-X `ng` values for a few grids so we cannot
495    /// regress on the `packgrid` formula again. Values derived directly
496    /// from `lib/packjt.f90:341-345` + `grid2deg.f90`.
497    #[test]
498    fn grid_wsjtx_canonical_ng() {
499        for (grid, expected_ng) in [
500            ("EM41", 24421u32),
501            ("FN42", 22632),
502            ("PM95", 3725),
503            ("JN58", 15258),
504            ("AA00", 32220),
505            ("RR99", 179),
506        ] {
507            let ng = pack_grid_or_report(grid).unwrap_or_else(|| panic!("pack {grid}"));
508            assert_eq!(ng, expected_ng, "WSJT-X canonical ng for {grid}");
509        }
510    }
511
512    #[test]
513    fn grid_reports_and_tokens() {
514        for s in ["RO", "RRR", "73", "-15", "R-05"] {
515            let ng = pack_grid_or_report(s).unwrap_or_else(|| panic!("pack {s}"));
516            assert_eq!(unpack_grid(ng), s);
517        }
518    }
519
520    #[test]
521    fn standard_message_roundtrip() {
522        let words = pack_standard("K1ABC", "JA1ABC", "FN42").expect("pack");
523        let m = unpack(&words);
524        assert_eq!(
525            m,
526            Jt72Message::Standard {
527                call1: "K1ABC".into(),
528                call2: "JA1ABC".into(),
529                grid_or_report: "FN42".into(),
530            }
531        );
532    }
533
534    #[test]
535    fn codec_trait_roundtrip() {
536        let codec = Jt72Message_;
537        let fields = MessageFields {
538            call1: Some("K1ABC".into()),
539            call2: Some("JA1ABC".into()),
540            grid: Some("PM95".into()),
541            ..MessageFields::default()
542        };
543        let payload = codec.pack(&fields).expect("pack");
544        assert_eq!(payload.len(), 72);
545        let ctx = DecodeContext::default();
546        let m = codec.unpack(&payload, &ctx).expect("unpack");
547        assert!(matches!(m, Jt72Message::Standard { .. }));
548    }
549
550    #[test]
551    fn pack_words_bit_layout() {
552        // Sentinel values let us check the bit routing into dat(1..12).
553        let nc1 = 0x0F00_00F0u32; // 28-bit field exercising edges
554        let nc2 = 0x0A00_000Au32;
555        let ng = 0x0F0Fu32;
556        let words = pack_words(nc1 & 0x0fff_ffff, nc2 & 0x0fff_ffff, ng & 0xffff);
557        let (n1b, n2b, ngb) = unpack_words(&words);
558        assert_eq!(n1b, nc1 & 0x0fff_ffff);
559        assert_eq!(n2b, nc2 & 0x0fff_ffff);
560        assert_eq!(ngb, ng & 0xffff);
561    }
562
563    #[test]
564    fn cq_standard_message() {
565        let words = pack_standard("CQ", "K1ABC", "FN42").expect("pack CQ");
566        let m = unpack(&words);
567        match m {
568            Jt72Message::Standard {
569                call1,
570                call2,
571                grid_or_report,
572            } => {
573                assert_eq!(call1, "CQ");
574                assert_eq!(call2, "K1ABC");
575                assert_eq!(grid_or_report, "FN42");
576            }
577            other => panic!("expected Standard, got {:?}", other),
578        }
579    }
580}