Skip to main content

mfsk_core/msg/
wspr.rs

1//! WSPR 50-bit message codec.
2//!
3//! Ports `lib/wsprd/wsprd_utils.c` (unpack) and `lib/wsprd/wsprsim_utils.c`
4//! (pack) from WSJT-X. The 50-bit payload carries one of three message
5//! types:
6//!
7//! | Type | Contents                       | n1 (28 bit) | n2 (22 bit) |
8//! |------|--------------------------------|-------------|-------------|
9//! | 1    | 6-char callsign + grid4 + dBm  | packed call | grid+power  |
10//! | 2    | prefix/suffix callsign + dBm   | packed call | prefix+type |
11//! | 3    | hashed call + grid6 + dBm      | packed grid6| hash+type   |
12//!
13//! Type discrimination happens on the decode side: `ntype = (n2 & 127) - 64`.
14//! Valid "power-in-dBm" values (0, 3, 7, 10, …, 60) mark Type 1; other
15//! positive ntype is Type 2; negative ntype is Type 3.
16//!
17//! Currently Type 1 and Type 3 are implemented end-to-end. Type 2 is
18//! detected but reported as a placeholder — the prefix/suffix unpack
19//! logic can be ported verbatim when a test corpus materialises.
20//!
21//! The decoded representation is a `WsprMessage` enum so callers can
22//! distinguish the types; the convenience `to_string()` impl yields the
23//! familiar `"CALL GRID DBM"` tuple layout that WSPRnet expects.
24
25use core::fmt;
26
27const POWERS: &[i32] = &[
28    0, 3, 7, 10, 13, 17, 20, 23, 27, 30, 33, 37, 40, 43, 47, 50, 53, 57, 60,
29];
30
31/// Decoded WSPR message payload.
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub enum WsprMessage {
34    /// Standard Type-1 message: 6-char callsign, 4-char grid, transmit power.
35    Type1 {
36        callsign: String,
37        grid: String,
38        power_dbm: i32,
39    },
40    /// Type-2 prefix/suffix callsign (e.g. `PJ4/K1ABC 37`).
41    Type2 {
42        /// Fully reconstructed callsign with the prefix or suffix baked in
43        /// (`"PJ4/K1ABC"`, `"K1ABC/7"`, etc).
44        callsign: String,
45        power_dbm: i32,
46    },
47    /// Type-3 hashed callsign + 6-char grid. The hash is exposed raw so
48    /// callers with a compatible WSPR hash table can resolve it.
49    Type3 {
50        /// 15-bit callsign hash derived from `nhash(callsign, 146)` at TX.
51        callsign_hash: u32,
52        /// 6-character Maidenhead locator.
53        grid6: String,
54        power_dbm: i32,
55    },
56}
57
58impl fmt::Display for WsprMessage {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            WsprMessage::Type1 {
62                callsign,
63                grid,
64                power_dbm,
65            } => write!(f, "{} {} {}", callsign, grid, power_dbm),
66            WsprMessage::Type2 {
67                callsign,
68                power_dbm,
69            } => write!(f, "{} {}", callsign, power_dbm),
70            WsprMessage::Type3 {
71                callsign_hash,
72                grid6,
73                power_dbm,
74            } => write!(f, "<#{:05x}> {} {}", callsign_hash, grid6, power_dbm),
75        }
76    }
77}
78
79// ─────────────────────────────────────────────────────────────────────────
80// Character tables
81// ─────────────────────────────────────────────────────────────────────────
82
83/// 37-entry table used by callsign/grid unpacking — digits, uppercase
84/// letters, and space. Matches `c[]` in `wsprd_utils.c::unpackcall`.
85const CHAR37: &[u8; 37] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
86
87fn callsign_char_code(ch: u8) -> Option<u8> {
88    match ch {
89        b'0'..=b'9' => Some(ch - b'0'),
90        b'A'..=b'Z' => Some(ch - b'A' + 10),
91        b' ' => Some(36),
92        _ => None,
93    }
94}
95
96fn locator_char_code(ch: u8) -> Option<u8> {
97    match ch {
98        b'0'..=b'9' => Some(ch - b'0'),
99        b'A'..=b'R' => Some(ch - b'A'),
100        b' ' => Some(36),
101        _ => None,
102    }
103}
104
105// ─────────────────────────────────────────────────────────────────────────
106// Pack / unpack 50 bits ↔ (n1, n2)
107// ─────────────────────────────────────────────────────────────────────────
108
109/// Pack (n1, n2) into 50 bits laid out across 7 bytes + 1 bit.
110/// Byte layout matches `wsprsim_utils.c`:
111/// ```text
112/// data[0]=n1[27..20]  data[1]=n1[19..12]  data[2]=n1[11..4]
113/// data[3]=n1[3..0]<<4 | n2[21..18]
114/// data[4]=n2[17..10]  data[5]=n2[9..2]    data[6]=n2[1..0]<<6
115/// ```
116pub fn pack50(n1: u32, n2: u32) -> [u8; 7] {
117    [
118        ((n1 >> 20) & 0xff) as u8,
119        ((n1 >> 12) & 0xff) as u8,
120        ((n1 >> 4) & 0xff) as u8,
121        (((n1 & 0x0f) << 4) | ((n2 >> 18) & 0x0f)) as u8,
122        ((n2 >> 10) & 0xff) as u8,
123        ((n2 >> 2) & 0xff) as u8,
124        (((n2 & 0x03) << 6) & 0xff) as u8,
125    ]
126}
127
128/// Inverse of [`pack50`]: 7-byte packed word → (n1, n2).
129/// Tolerates the 7th byte carrying only the top 2 bits.
130pub fn unpack50(data: &[u8; 7]) -> (u32, u32) {
131    let mut n1: u32 = (data[0] as u32) << 20;
132    n1 |= (data[1] as u32) << 12;
133    n1 |= (data[2] as u32) << 4;
134    n1 |= ((data[3] >> 4) & 0x0f) as u32;
135
136    let mut n2: u32 = ((data[3] & 0x0f) as u32) << 18;
137    n2 |= (data[4] as u32) << 10;
138    n2 |= (data[5] as u32) << 2;
139    n2 |= ((data[6] >> 6) & 0x03) as u32;
140
141    (n1, n2)
142}
143
144// ─────────────────────────────────────────────────────────────────────────
145// Callsign + grid packing (Type 1)
146// ─────────────────────────────────────────────────────────────────────────
147
148/// Encode a callsign into a 28-bit integer. Returns `None` if the callsign
149/// doesn't fit the compressed form (must be ≤ 6 chars with a digit in
150/// position 1 or 2, and only A-Z / 0-9 / space).
151pub fn pack_call(callsign: &str) -> Option<u32> {
152    let bytes = callsign.as_bytes();
153    if bytes.len() > 6 || bytes.is_empty() {
154        return None;
155    }
156    let mut call6 = [b' '; 6];
157    // Right-align to the 3rd slot: if char[2] is a digit keep as-is,
158    // else if char[1] is a digit shift one position right.
159    if bytes.len() >= 3 && bytes[2].is_ascii_digit() {
160        for (i, &b) in bytes.iter().enumerate() {
161            call6[i] = b;
162        }
163    } else if bytes.len() >= 2 && bytes[1].is_ascii_digit() {
164        for (i, &b) in bytes.iter().enumerate() {
165            call6[i + 1] = b;
166        }
167    } else {
168        return None;
169    }
170
171    let codes: [u8; 6] = {
172        let mut c = [0u8; 6];
173        for i in 0..6 {
174            c[i] = callsign_char_code(call6[i])?;
175        }
176        c
177    };
178
179    // n = c0*36 + c1 ...       (first two slots: 37-symbol alphabet)
180    // then digit (c2, 0-9), then three letter/space (c3..c5, 27 symbols).
181    let mut n: u32 = codes[0] as u32;
182    n = n * 36 + codes[1] as u32;
183    n = n * 10 + codes[2] as u32;
184    n = n * 27 + (codes[3].wrapping_sub(10)) as u32;
185    n = n * 27 + (codes[4].wrapping_sub(10)) as u32;
186    n = n * 27 + (codes[5].wrapping_sub(10)) as u32;
187    Some(n)
188}
189
190/// Unpack a 28-bit callsign integer. Returns `None` for the "reserved"
191/// range (≥ 262_177_560) that WSJT-X treats as non-Type-1.
192pub fn unpack_call(ncall: u32) -> Option<String> {
193    if ncall >= 262_177_560 {
194        return None;
195    }
196    let mut n = ncall;
197    let mut tmp = [b' '; 6];
198    // Reverse of pack_call: pull digits/letters out LSB-first.
199    let i = (n % 27 + 10) as usize;
200    tmp[5] = CHAR37[i];
201    n /= 27;
202    let i = (n % 27 + 10) as usize;
203    tmp[4] = CHAR37[i];
204    n /= 27;
205    let i = (n % 27 + 10) as usize;
206    tmp[3] = CHAR37[i];
207    n /= 27;
208    let i = (n % 10) as usize;
209    tmp[2] = CHAR37[i];
210    n /= 10;
211    let i = (n % 36) as usize;
212    tmp[1] = CHAR37[i];
213    n /= 36;
214    tmp[0] = CHAR37[n as usize];
215
216    let s = core::str::from_utf8(&tmp).ok()?;
217    Some(s.trim().to_string())
218}
219
220/// Pack a 4-char grid and transmit power into a 22-bit integer.
221pub fn pack_grid4_power(grid: &str, power_dbm: i32) -> Option<u32> {
222    let bytes = grid.as_bytes();
223    if bytes.len() != 4 {
224        return None;
225    }
226    let g0 = locator_char_code(bytes[0])? as u32;
227    let g1 = locator_char_code(bytes[1])? as u32;
228    let g2 = locator_char_code(bytes[2])? as u32;
229    let g3 = locator_char_code(bytes[3])? as u32;
230    let m = (179 - 10 * g0 - g2) * 180 + 10 * g1 + g3;
231    Some(m * 128 + (power_dbm as u32) + 64)
232}
233
234/// Unpack the 22-bit grid+power integer. Returns `(grid, ntype)` where
235/// `ntype = (n2 & 127) - 64` — the caller decides whether `ntype` names a
236/// Type 1 dBm value, a Type 2 suffix count, or a Type 3 negative tag.
237pub fn unpack_grid(ngrid_full: u32) -> Option<(String, i32)> {
238    let ntype = (ngrid_full & 127) as i32 - 64;
239    let ngrid = ngrid_full >> 7;
240    if ngrid >= 32_400 {
241        return None;
242    }
243    let dlat = (ngrid % 180) as i32 - 90;
244    let mut dlong = (ngrid / 180) as i32 * 2 - 180 + 2;
245    if dlong < -180 {
246        dlong += 360;
247    }
248    if dlong > 180 {
249        dlong += 360;
250    }
251    let nlong = (60.0 * (180.0 - dlong as f32) / 5.0) as i32;
252    let ln1 = nlong / 240;
253    let ln2 = (nlong - 240 * ln1) / 24;
254
255    let nlat = (60.0 * (dlat + 90) as f32 / 2.5) as i32;
256    let la1 = nlat / 240;
257    let la2 = (nlat - 240 * la1) / 24;
258
259    let mut grid = [b'0'; 4];
260    grid[0] = CHAR37[(10 + ln1) as usize];
261    grid[2] = CHAR37[ln2 as usize];
262    grid[1] = CHAR37[(10 + la1) as usize];
263    grid[3] = CHAR37[la2 as usize];
264    Some((core::str::from_utf8(&grid).ok()?.to_string(), ntype))
265}
266
267// ─────────────────────────────────────────────────────────────────────────
268// Public encode / decode entry points
269// ─────────────────────────────────────────────────────────────────────────
270
271/// Pack a Type-1 WSPR message (callsign + 4-char grid + power in dBm) into
272/// 50 bits, stored MSB-first across a 50-element `[u8; 50]` of 0/1 values —
273/// the form required by [`crate::fec::ConvFano`]'s encode path.
274pub fn pack_type1(callsign: &str, grid: &str, power_dbm: i32) -> Option<[u8; 50]> {
275    if !POWERS.contains(&power_dbm) {
276        return None;
277    }
278    let n1 = pack_call(callsign)?;
279    let n2 = pack_grid4_power(grid, power_dbm)?;
280    let bytes = pack50(n1, n2);
281    let mut bits = [0u8; 50];
282    for i in 0..50 {
283        let byte = bytes[i / 8];
284        bits[i] = (byte >> (7 - (i % 8))) & 1;
285    }
286    Some(bits)
287}
288
289/// Add a prefix or suffix to a callsign according to the 16-bit
290/// `nprefix` field carried in Type-2 messages. Ports
291/// `wsprd_utils.c::unpackpfx`.
292///
293/// * `nprefix < 60000` → prefix of 1-3 chars, packed base-37
294/// * `60000 ≤ nprefix ≤ 60035` → single-char digit/letter suffix
295/// * `60036 ≤ nprefix ≤ 60125` → two-digit suffix
296fn apply_prefix(nprefix: u32, base_call: &str) -> Option<String> {
297    const A37: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
298    if nprefix < 60_000 {
299        // Prefix, 1-3 chars.
300        let mut n = nprefix;
301        let mut pfx = [b' '; 3];
302        for i in (0..3).rev() {
303            let nc = (n % 37) as usize;
304            pfx[i] = A37[nc];
305            n /= 37;
306        }
307        // Strip leading spaces.
308        let start = pfx.iter().position(|&b| b != b' ')?;
309        let pfx_str = core::str::from_utf8(&pfx[start..]).ok()?;
310        Some(format!("{}/{}", pfx_str, base_call))
311    } else {
312        let nc = nprefix - 60_000;
313        if nc <= 9 {
314            Some(format!("{}/{}", base_call, (b'0' + nc as u8) as char))
315        } else if nc <= 35 {
316            Some(format!(
317                "{}/{}",
318                base_call,
319                (b'A' + (nc - 10) as u8) as char
320            ))
321        } else if nc <= 125 {
322            let d1 = (nc - 26) / 10;
323            let d2 = (nc - 26) % 10;
324            Some(format!(
325                "{}/{}{}",
326                base_call,
327                (b'0' + d1 as u8) as char,
328                (b'0' + d2 as u8) as char
329            ))
330        } else {
331            None
332        }
333    }
334}
335
336/// Unpack 50 info bits into a [`WsprMessage`]. Returns `None` for
337/// pathological ntype/ngrid combinations.
338pub fn unpack(bits: &[u8; 50]) -> Option<WsprMessage> {
339    // Pack bit vector back into the 7-byte word format unpack50 expects.
340    let mut data = [0u8; 7];
341    for i in 0..50 {
342        if bits[i] & 1 != 0 {
343            data[i / 8] |= 1 << (7 - (i % 8));
344        }
345    }
346    let (n1, n2) = unpack50(&data);
347
348    let (maybe_grid, ntype) = unpack_grid(n2).unzip();
349
350    // Type 3: negative ntype → hashed callsign + grid6.
351    // The 6-char grid is stored via pack_call with a rotated layout:
352    // grid6[..5] holds the last 5 chars of the grid, grid6[5] holds the
353    // first. We recover the packed string via unpack_call then rotate
354    // the tail char back to the front.
355    if let Some(t) = ntype
356        && t < 0
357    {
358        let power_dbm = -(t + 1);
359        // Reconstruct grid6 from the "callsign-slot" encoding.
360        let pseudo_call = unpack_call(n1).unwrap_or_default();
361        let mut grid6 = String::new();
362        if pseudo_call.len() == 6 {
363            let bytes = pseudo_call.as_bytes();
364            grid6.push(bytes[5] as char); // rotated-back first char
365            grid6.push_str(core::str::from_utf8(&bytes[..5]).ok()?);
366        }
367        // Hash extraction: ihash = (n2 - ntype - 64) / 128. Since
368        // ntype is negative, this is (n2 + (-ntype) - 64) / 128; with
369        // n2 raw, it equals n2 >> 7 exactly.
370        let hash = n2 >> 7;
371        return Some(WsprMessage::Type3 {
372            callsign_hash: hash,
373            grid6,
374            power_dbm,
375        });
376    }
377
378    let ntype_val = ntype?;
379    let grid = maybe_grid?;
380
381    // Type 1 test: nu = ntype % 10 ∈ {0,3,7} AND ntype ≤ 62.
382    if (0..=62).contains(&ntype_val) {
383        let nu = ntype_val % 10;
384        if nu == 0 || nu == 3 || nu == 7 {
385            let callsign = unpack_call(n1)?;
386            return Some(WsprMessage::Type1 {
387                callsign,
388                grid,
389                power_dbm: ntype_val,
390            });
391        }
392        // Type 2: positive ntype but power-digit not in {0,3,7}.
393        // nadd encodes "this is a compound call" — recover by
394        //   n3 = n2 / 128 + 32768 * (nadd - 1)
395        //   actual_dbm = ntype - nadd
396        let nadd = if nu > 7 {
397            nu - 7
398        } else if nu > 3 {
399            nu - 3
400        } else {
401            nu
402        };
403        let n3 = (n2 >> 7) + 32_768 * (nadd as u32 - 1);
404        let base_call = unpack_call(n1)?;
405        let full_call = apply_prefix(n3, &base_call)?;
406        let power_dbm = ntype_val - nadd;
407        // Plausibility: the recovered power digit must land on {0,3,7,10}.
408        let pu = power_dbm.rem_euclid(10);
409        if pu != 0 && pu != 3 && pu != 7 {
410            return None;
411        }
412        return Some(WsprMessage::Type2 {
413            callsign: full_call,
414            power_dbm,
415        });
416    }
417
418    None
419}
420
421// ─────────────────────────────────────────────────────────────────────────
422// MessageCodec trait impl
423// ─────────────────────────────────────────────────────────────────────────
424
425use crate::core::{DecodeContext, MessageCodec, MessageFields};
426
427#[derive(Copy, Clone, Debug, Default)]
428pub struct Wspr50Message;
429
430impl MessageCodec for Wspr50Message {
431    type Unpacked = WsprMessage;
432    const PAYLOAD_BITS: u32 = 50;
433    const CRC_BITS: u32 = 0;
434
435    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>> {
436        let call = fields.call1.as_deref()?;
437        let grid = fields.grid.as_deref()?;
438        let power = fields.report?; // re-using MessageFields.report for dBm
439        let bits = pack_type1(call, grid, power)?;
440        Some(bits.to_vec())
441    }
442
443    fn unpack(&self, payload: &[u8], _ctx: &DecodeContext) -> Option<Self::Unpacked> {
444        if payload.len() != 50 {
445            return None;
446        }
447        let mut buf = [0u8; 50];
448        buf.copy_from_slice(payload);
449        unpack(&buf)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn type1_roundtrip_callsign() {
459        let bits = pack_type1("K1ABC", "FN42", 37).expect("pack");
460        let m = unpack(&bits).expect("unpack");
461        assert_eq!(
462            m,
463            WsprMessage::Type1 {
464                callsign: "K1ABC".into(),
465                grid: "FN42".into(),
466                power_dbm: 37,
467            }
468        );
469    }
470
471    #[test]
472    fn type1_roundtrip_with_digit_in_second_slot() {
473        // Callsigns with digit at position 1 (e.g. "K9AN") shift into the
474        // "right-aligned" form, which is a known WSJT-X pack-call path.
475        let bits = pack_type1("K9AN", "EN50", 33).expect("pack");
476        let m = unpack(&bits).expect("unpack");
477        match m {
478            WsprMessage::Type1 {
479                callsign,
480                grid,
481                power_dbm,
482            } => {
483                assert_eq!(callsign, "K9AN");
484                assert_eq!(grid, "EN50");
485                assert_eq!(power_dbm, 33);
486            }
487            other => panic!("expected Type 1, got {:?}", other),
488        }
489    }
490
491    #[test]
492    fn invalid_power_rejected() {
493        assert!(pack_type1("K1ABC", "FN42", 42).is_none());
494    }
495
496    #[test]
497    fn invalid_grid_rejected() {
498        // Grid chars beyond 'R' are out of WSJT's locator alphabet.
499        assert!(pack_type1("K1ABC", "SS01", 37).is_none());
500    }
501
502    #[test]
503    fn unpack_rejects_reserved_call_range() {
504        // n1 values ≥ 262177560 have no Type-1 interpretation; when ntype
505        // looks like a Type 1 dBm we bail out to None.
506        let bits = {
507            let mut b = [0u8; 50];
508            // Set n1 = all ones = 0x0fff_ffff (28-bit) → well into reserved.
509            let n1 = 0x0fff_ffffu32;
510            let n2 = pack_grid4_power("FN42", 37).unwrap();
511            let bytes = pack50(n1, n2);
512            for i in 0..50 {
513                b[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
514            }
515            b
516        };
517        // Should not produce Type 1 — either None or Type 2/3.
518        if let Some(WsprMessage::Type1 { .. }) = unpack(&bits) {
519            panic!("shouldn't be Type 1");
520        }
521    }
522
523    #[test]
524    fn type2_single_char_suffix() {
525        // Port WSJT-X's single-char-suffix encoding for `K1ABC/7` at 37 dBm
526        // and verify our `unpack` reverses it:
527        //   encode:
528        //     base_call = "K1ABC"
529        //     m_local   = 60000 - 32768 + 7 = 27239
530        //     nadd_enc  = 1
531        //     ntype     = power + 1 + nadd_enc = 39
532        //     n2        = 128 * m_local + ntype + 64 = 3_486_695
533        //   decode:
534        //     nu        = 39 % 10 = 9 → nadd_dec = 9 - 7 = 2
535        //     n3        = n2>>7 + 32768*(nadd_dec - 1) = 27239 + 32768 = 60007
536        //     → apply_prefix(60007) → "K1ABC/7", power = 39 - 2 = 37
537        let n1 = pack_call("K1ABC").expect("pack call");
538        let m_local = 60_000 - 32_768 + 7; // 27239
539        let ntype = 37 + 1 + 1; // 39
540        let n2 = 128 * m_local + (ntype + 64);
541        let bytes = pack50(n1, n2);
542        let mut bits = [0u8; 50];
543        for i in 0..50 {
544            bits[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
545        }
546        let m = unpack(&bits).expect("unpack");
547        assert_eq!(
548            m,
549            WsprMessage::Type2 {
550                callsign: "K1ABC/7".into(),
551                power_dbm: 37,
552            }
553        );
554    }
555
556    #[test]
557    fn type2_prefix_pj4() {
558        // Port WSJT-X's prefix encoding for `PJ4/K1ABC` at 37 dBm:
559        //   prefix "PJ4" → packed as base-37 digits, length 3
560        //     start m = 0 (3-char prefix base)
561        //     for each char: m = 37*m + nc
562        //       P (25) → 25
563        //       J (19) → 25*37 + 19 = 944
564        //       4  (4) → 944*37 + 4 = 34932
565        //     m > 32768 → m -= 32768 = 2164, nadd_enc = 1
566        //   ntype = power + 1 + nadd_enc = 39
567        //   n2 = 128 * 2164 + ntype + 64 = 277095
568        //   decode:
569        //     nu = 39 % 10 = 9 → nadd_dec = 2
570        //     n3 = 2164 + 32768 = 34932 → < 60000 → prefix path
571        //     "PJ4" recovered
572        let n1 = pack_call("K1ABC").expect("pack call");
573        let m_local = {
574            let mut m: u32 = 0;
575            for &ch in b"PJ4" {
576                let nc = match ch {
577                    b'0'..=b'9' => ch - b'0',
578                    b'A'..=b'Z' => ch - b'A' + 10,
579                    _ => 36,
580                };
581                m = 37 * m + nc as u32;
582            }
583            assert!(m > 32_768, "PJ4 should land above 32768");
584            m - 32_768
585        };
586        let ntype = 37 + 1 + 1;
587        let n2 = 128 * m_local + (ntype + 64);
588        let bytes = pack50(n1, n2);
589        let mut bits = [0u8; 50];
590        for i in 0..50 {
591            bits[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
592        }
593        let m = unpack(&bits).expect("unpack");
594        assert_eq!(
595            m,
596            WsprMessage::Type2 {
597                callsign: "PJ4/K1ABC".into(),
598                power_dbm: 37,
599            }
600        );
601    }
602
603    #[test]
604    fn type3_hashed_call_grid6() {
605        // Build a Type-3 message: hash=12345, grid6="FN42LX", power=27.
606        // Encoding:
607        //   grid6_rotated = "N42LXF"   (last-5 + first char)
608        //   n1 = pack_call(grid6_rotated)
609        //   ntype = -(power + 1) = -28
610        //   n2 = 128*hash + ntype + 64  (i.e. (n2 & 127) - 64 == -28)
611        let hash = 12_345u32;
612        let grid6 = "FN42LX";
613        let power = 27i32;
614        let rotated = {
615            let b = grid6.as_bytes();
616            format!(
617                "{}{}",
618                core::str::from_utf8(&b[1..6]).unwrap(),
619                b[0] as char
620            )
621        };
622        assert_eq!(rotated, "N42LXF");
623        // Hmm — "N42LXF" has a digit at position 1 (char '4'), which
624        // pack_call handles (right-aligned digit form not triggered).
625        // Verify pack_call accepts the rotated grid6.
626        let n1 = pack_call(&rotated).expect("pack call(grid6)");
627        let ntype: i32 = -(power + 1); // -28
628        // n2 = 128*hash + (ntype + 64) where ntype + 64 = 36, all positive
629        let n2 = hash * 128 + (ntype + 64) as u32;
630        let bytes = pack50(n1, n2);
631        let mut bits = [0u8; 50];
632        for i in 0..50 {
633            bits[i] = (bytes[i / 8] >> (7 - (i % 8))) & 1;
634        }
635        let m = unpack(&bits).expect("unpack");
636        assert_eq!(
637            m,
638            WsprMessage::Type3 {
639                callsign_hash: hash,
640                grid6: grid6.into(),
641                power_dbm: power,
642            }
643        );
644    }
645
646    #[test]
647    fn pack50_unpack50_all_bits() {
648        let n1 = 0x0deadb3u32;
649        let n2 = 0x001abcdu32 & 0x003f_ffff;
650        let bytes = pack50(n1, n2);
651        let (rn1, rn2) = unpack50(&bytes);
652        assert_eq!(rn1, n1);
653        assert_eq!(rn2, n2);
654    }
655}