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