use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use super::hash_table::CallsignHashTable;
const C1: &[u8] = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
const C2: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const C3: &[u8] = b"0123456789";
const C4: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const C38: &[u8] = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
const FREE_TEXT: &[u8] = b" 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?";
const RTTY_STATES: &[&str] = &[
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS",
"KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY",
"NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV",
"WI", "WY", "NB", "NS", "QC", "ON", "MB", "SK", "AB", "BC", "NWT", "NF", "LB", "NU", "YT",
"PEI", "DC", "DR", "FR", "GD", "GR", "OV", "ZH", "ZL", "X01", "X02", "X03", "X04", "X05",
"X06", "X07", "X08", "X09", "X10", "X11", "X12", "X13", "X14", "X15", "X16", "X17", "X18",
"X19", "X20", "X21", "X22", "X23", "X24", "X25", "X26", "X27", "X28", "X29", "X30", "X31",
"X32", "X33", "X34", "X35", "X36", "X37", "X38", "X39", "X40", "X41", "X42", "X43", "X44",
"X45", "X46", "X47", "X48", "X49", "X50", "X51", "X52", "X53", "X54", "X55", "X56", "X57",
"X58", "X59", "X60", "X61", "X62", "X63", "X64", "X65", "X66", "X67", "X68", "X69", "X70",
"X71", "X72", "X73", "X74", "X75", "X76", "X77", "X78", "X79", "X80", "X81", "X82", "X83",
"X84", "X85", "X86", "X87", "X88", "X89", "X90", "X91", "X92", "X93", "X94", "X95", "X96",
"X97", "X98", "X99",
];
const ARRL_SECTIONS: &[&str] = &[
"AB", "AK", "AL", "AR", "AZ", "BC", "CO", "CT", "DE", "EB", "EMA", "ENY", "EPA", "EWA", "GA",
"GH", "IA", "ID", "IL", "IN", "KS", "KY", "LA", "LAX", "NS", "MB", "MDC", "ME", "MI", "MN",
"MO", "MS", "MT", "NC", "ND", "NE", "NFL", "NH", "NL", "NLI", "NM", "NNJ", "NNY", "TER", "NTX",
"NV", "OH", "OK", "ONE", "ONN", "ONS", "OR", "ORG", "PAC", "PR", "QC", "RI", "SB", "SC", "SCV",
"SD", "SDG", "SF", "SFL", "SJV", "SK", "SNJ", "STX", "SV", "TN", "UT", "VA", "VI", "VT", "WCF",
"WI", "WMA", "WNY", "WPA", "WTX", "WV", "WWA", "WY", "DX", "PE", "NB",
];
const NTOKENS: u32 = 2_063_592;
const MAX22: u32 = 4_194_304;
const MAX_GRID4: u32 = 32_400;
fn read_bits(msg: &[u8], start: usize, len: usize) -> u32 {
let mut n = 0u32;
for i in start..start + len {
n = (n << 1) | (msg[i] & 1) as u32;
}
n
}
fn read_bits_u64(msg: &[u8], start: usize, len: usize) -> u64 {
let mut n = 0u64;
for i in start..start + len {
n = (n << 1) | (msg[i] & 1) as u64;
}
n
}
fn fmt_report(isnr: i32, ir: u8) -> String {
let sign = if isnr >= 0 { '+' } else { '-' };
let prefix = if ir == 1 { "R" } else { "" };
format!("{prefix}{sign}{:02}", isnr.abs())
}
fn unpack28(n28: u32) -> String {
if n28 < NTOKENS {
return match n28 {
0 => "DE".to_string(),
1 => "QRZ".to_string(),
2 => "CQ".to_string(),
3..=1002 => format!("CQ {:03}", n28 - 3),
_ => {
let n = n28 - 1003;
let i1 = (n / (27 * 27 * 27)) as usize;
let n = n % (27 * 27 * 27);
let i2 = (n / (27 * 27)) as usize;
let n = n % (27 * 27);
let i3 = (n / 27) as usize;
let i4 = (n % 27) as usize;
if i1 >= C4.len() || i2 >= C4.len() || i3 >= C4.len() || i4 >= C4.len() {
return "<?>".to_string();
}
let suffix: String = [C4[i1], C4[i2], C4[i3], C4[i4]]
.iter()
.map(|&b| b as char)
.collect();
format!("CQ {}", suffix.trim())
}
};
}
let n = n28 - NTOKENS;
if n < MAX22 {
return "<...>".to_string();
}
let n = n - MAX22;
let i1 = (n / (36 * 10 * 27 * 27 * 27)) as usize;
let n = n % (36 * 10 * 27 * 27 * 27);
let i2 = (n / (10 * 27 * 27 * 27)) as usize;
let n = n % (10 * 27 * 27 * 27);
let i3 = (n / (27 * 27 * 27)) as usize;
let n = n % (27 * 27 * 27);
let i4 = (n / (27 * 27)) as usize;
let n = n % (27 * 27);
let i5 = (n / 27) as usize;
let i6 = (n % 27) as usize;
if i1 >= C1.len()
|| i2 >= C2.len()
|| i3 >= C3.len()
|| i4 >= C4.len()
|| i5 >= C4.len()
|| i6 >= C4.len()
{
return "?????".to_string();
}
let s: String = [C1[i1], C2[i2], C3[i3], C4[i4], C4[i5], C4[i6]]
.iter()
.map(|&b| b as char)
.collect();
s.trim().to_string()
}
fn unpack28_h(n28: u32, ht: &CallsignHashTable) -> String {
if n28 >= NTOKENS {
let n = n28 - NTOKENS;
if n < MAX22 {
if let Some(resolved) = ht.lookup22(n) {
return alloc::format!("<{}>", resolved);
}
return "<...>".to_string();
}
}
unpack28(n28)
}
fn resolve_hash12(n12: u32, ht: &CallsignHashTable) -> String {
if let Some(call) = ht.lookup12(n12) {
format!("<{}>", call)
} else {
"<...>".to_string()
}
}
fn resolve_hash22(n22: u32, ht: &CallsignHashTable) -> String {
match ht.lookup22(n22) {
Some(call) => format!("<{}>", call),
None => UNRESOLVED_HASH.to_string(),
}
}
fn wspr_dbm(raw: u32) -> Option<u32> {
let dbm = (20 * raw + 3) / 6;
if dbm > 60 {
return None;
}
Some(dbm)
}
fn wspr_prefix_suffix(npfx: u32, call: &str) -> Option<String> {
const A2: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const NZZZ: u32 = 46_656; if npfx < NZZZ {
let mut n = npfx;
let mut cpfx = [b' '; 3];
for i in (0..3).rev() {
cpfx[i] = A2[(n % 36) as usize];
n /= 36;
if n == 0 {
break;
}
}
let pfx = core::str::from_utf8(&cpfx).ok()?.trim();
return Some(format!("{}/{}", pfx, call));
}
let n = npfx - NZZZ;
let mut buf = [0u8; 3];
let sfx: &[u8] = if n <= 35 {
buf[0] = A2[n as usize];
&buf[..1]
} else if n <= 1295 {
buf[0] = A2[(n / 36) as usize];
buf[1] = A2[(n % 36) as usize];
&buf[..2]
} else if n <= 12_959 {
buf[0] = A2[(n / 360) as usize];
buf[1] = A2[((n / 10) % 36) as usize];
buf[2] = A2[(n % 10) as usize];
&buf[..3]
} else {
return None;
};
Some(format!("{}/{}", call, core::str::from_utf8(sfx).ok()?))
}
fn to_grid(n: u32) -> Option<String> {
let mut n = n;
let j1 = n / (18 * 10 * 10 * 25 * 25);
if j1 > 17 {
return None;
}
n -= j1 * (18 * 10 * 10 * 25 * 25);
let j2 = n / (10 * 10 * 25 * 25);
if j2 > 17 {
return None;
}
n -= j2 * (10 * 10 * 25 * 25);
let j3 = n / (10 * 25 * 25);
if j3 > 9 {
return None;
}
n -= j3 * (10 * 25 * 25);
let j4 = n / (25 * 25);
if j4 > 9 {
return None;
}
n -= j4 * (25 * 25);
let j5 = n / 25;
let j6 = n - j5 * 25;
if j5 > 24 || j6 > 24 {
return None;
}
let mut g = String::with_capacity(6);
g.push((b'A' + j1 as u8) as char);
g.push((b'A' + j2 as u8) as char);
g.push((b'0' + j3 as u8) as char);
g.push((b'0' + j4 as u8) as char);
if j5 != 24 || j6 != 24 {
g.push((b'A' + j5 as u8) as char);
g.push((b'A' + j6 as u8) as char);
}
Some(g)
}
fn to_grid6(n: u32) -> Option<String> {
let mut n = n;
let j1 = n / (18 * 10 * 10 * 24 * 24);
if j1 > 17 {
return None;
}
n -= j1 * (18 * 10 * 10 * 24 * 24);
let j2 = n / (10 * 10 * 24 * 24);
if j2 > 17 {
return None;
}
n -= j2 * (10 * 10 * 24 * 24);
let j3 = n / (10 * 24 * 24);
if j3 > 9 {
return None;
}
n -= j3 * (10 * 24 * 24);
let j4 = n / (24 * 24);
if j4 > 9 {
return None;
}
n -= j4 * (24 * 24);
let j5 = n / 24;
let j6 = n - j5 * 24;
if j5 > 23 || j6 > 23 {
return None;
}
let mut g = String::with_capacity(6);
g.push((b'A' + j1 as u8) as char);
g.push((b'A' + j2 as u8) as char);
g.push((b'0' + j3 as u8) as char);
g.push((b'0' + j4 as u8) as char);
g.push((b'A' + j5 as u8) as char);
g.push((b'A' + j6 as u8) as char);
Some(g)
}
fn to_grid4(n: u32) -> Option<String> {
if n > MAX_GRID4 {
return None;
}
let j1 = n / (18 * 10 * 10);
let n = n % (18 * 10 * 10);
let j2 = n / (10 * 10);
let n = n % (10 * 10);
let j3 = n / 10;
let j4 = n % 10;
if j1 > 17 || j2 > 17 {
return None;
}
Some(format!(
"{}{}{}{}",
(b'A' + j1 as u8) as char,
(b'A' + j2 as u8) as char,
(b'0' + j3 as u8) as char,
(b'0' + j4 as u8) as char,
))
}
fn unpack_free_text(msg: &[u8]) -> String {
let mut n = 0u128;
for i in 0..71 {
n = (n << 1) | (msg[i] & 1) as u128;
}
let mut chars = [b' '; 13];
for i in (0..13).rev() {
chars[i] = FREE_TEXT[(n % 42) as usize];
n /= 42;
}
String::from_utf8(chars.to_vec())
.unwrap_or_default()
.trim()
.to_string()
}
pub fn unpack77(msg: &[u8]) -> Option<String> {
unpack77_with_hash(msg, &CallsignHashTable::new())
}
pub fn unpack77_learn(msg: &[u8], ht: &mut CallsignHashTable) -> Option<String> {
let text = unpack77_with_hash(msg, ht);
register_callsigns(msg, ht);
text
}
pub fn register_callsigns(msg: &[u8], ht: &mut CallsignHashTable) {
if msg.len() != 77 {
return;
}
let n3 = read_bits(msg, 71, 3);
let i3 = read_bits(msg, 74, 3);
let learn_std = |n28: u32, ht: &mut CallsignHashTable| {
let call = unpack28(n28);
if is_standard_callsign(&call) {
ht.insert(&call);
}
};
match i3 {
0 => match n3 {
1 | 3 | 4 => {
learn_std(read_bits(msg, 0, 28), ht);
learn_std(read_bits(msg, 28, 28), ht);
}
_ => {}
},
1 | 2 => {
learn_std(read_bits(msg, 0, 28), ht);
learn_std(read_bits(msg, 29, 28), ht);
}
3 => {
learn_std(read_bits(msg, 1, 28), ht);
learn_std(read_bits(msg, 29, 28), ht);
}
4 => {
let n58 = read_bits_u64(msg, 12, 58);
let mut n = n58;
let mut buf = [b' '; 11];
for i in (0..11).rev() {
buf[i] = C38[(n % 38) as usize];
n /= 38;
}
if let Ok(s) = core::str::from_utf8(&buf) {
let call = s.trim();
if call.len() >= 2 {
ht.insert(call);
}
}
}
_ => {}
}
}
pub fn unpack77_with_hash(msg: &[u8], ht: &CallsignHashTable) -> Option<String> {
unpack77_fields(msg, ht).map(|m| m.to_string())
}
pub fn is_plausible_call(field: &str) -> bool {
if matches!(field, "CQ" | "DE" | "QRZ") || field.starts_with("CQ ") {
return true;
}
if field.starts_with('<') && field.ends_with('>') {
return true;
}
is_plausible_callsign(field)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Wsjt77Fields {
FreeText(String),
DxPedition {
call1: String,
call2: String,
call3: String,
report: String,
},
FieldDay {
call1: String,
call2: String,
ack: bool,
transmitters: u32,
class: char,
section: &'static str,
},
Telemetry(String),
Wspr { call: String, tail: String },
Standard {
call1: String,
call2: String,
exchange: String,
},
RttyRoundup {
thank_you: bool,
call1: String,
call2: String,
ack: bool,
rst: String,
exchange: String,
},
Nonstandard {
call1: String,
call2: Option<String>,
exchange: &'static str,
},
EuVhfContest {
call1: String,
call2: String,
ack: bool,
exchange: String,
grid6: String,
},
}
pub const UNRESOLVED_HASH: &str = "<...>";
impl Wsjt77Fields {
pub fn callsigns(&self) -> impl Iterator<Item = &str> {
let (a, b, c) = match self {
Self::FreeText(_) | Self::Telemetry(_) => (None, None, None),
Self::DxPedition {
call1,
call2,
call3,
..
} => (Some(&**call1), Some(&**call2), Some(&**call3)),
Self::FieldDay { call1, call2, .. }
| Self::Standard { call1, call2, .. }
| Self::RttyRoundup { call1, call2, .. }
| Self::EuVhfContest { call1, call2, .. } => (Some(&**call1), Some(&**call2), None),
Self::Wspr { call, .. } => (Some(&**call), None, None),
Self::Nonstandard { call1, call2, .. } => (Some(&**call1), call2.as_deref(), None),
};
[a, b, c].into_iter().flatten()
}
pub fn is_plausible(&self) -> bool {
match self {
Self::FreeText(_) | Self::Telemetry(_) => true,
Self::EuVhfContest { call1, call2, .. } => {
call1 != UNRESOLVED_HASH || call2 != UNRESOLVED_HASH
}
_ => self.callsigns().all(is_plausible_call),
}
}
}
impl core::fmt::Display for Wsjt77Fields {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::FreeText(t) | Self::Telemetry(t) => write!(f, "{t}"),
Self::DxPedition {
call1,
call2,
call3,
report,
} => write!(f, "{call1} RR73; {call2} {call3} {report}"),
Self::FieldDay {
call1,
call2,
ack,
transmitters,
class,
section,
} => {
let r = if *ack { "R " } else { "" };
write!(f, "{call1} {call2} {r}{transmitters}{class} {section}")
}
Self::Wspr { call, tail } => write!(f, "{call} {tail}"),
Self::Standard {
call1,
call2,
exchange,
} => {
if exchange.is_empty() {
write!(f, "{call1} {call2}")
} else {
write!(f, "{call1} {call2} {exchange}")
}
}
Self::RttyRoundup {
thank_you,
call1,
call2,
ack,
rst,
exchange,
} => {
let tu = if *thank_you { "TU; " } else { "" };
let r = if *ack { "R " } else { "" };
write!(f, "{tu}{call1} {call2} {r}{rst} {exchange}")
}
Self::Nonstandard {
call1,
call2,
exchange,
} => match call2 {
None => write!(f, "{call1}"),
Some(c2) if exchange.is_empty() => write!(f, "{call1} {c2}"),
Some(c2) => write!(f, "{call1} {c2} {exchange}"),
},
Self::EuVhfContest {
call1,
call2,
ack,
exchange,
grid6,
} => {
let r = if *ack { "R " } else { "" };
write!(f, "{call1} {call2} {r}{exchange} {grid6}")
}
}
}
}
pub fn is_plausible_payload(msg: &[u8]) -> bool {
is_plausible_payload_with_hash(msg, &CallsignHashTable::new())
}
pub fn is_plausible_payload_with_hash(msg: &[u8], ht: &CallsignHashTable) -> bool {
unpack77_fields(msg, ht).is_some_and(|m| m.is_plausible())
}
pub fn unpack77_fields(msg: &[u8], ht: &CallsignHashTable) -> Option<Wsjt77Fields> {
let n3 = read_bits(msg, 71, 3);
let i3 = read_bits(msg, 74, 3);
let decoded = unpack77_dispatch(msg, ht, i3, n3)?;
let refuse = {
let mut calls = decoded.callsigns();
matches!((calls.next(), calls.next()), (Some(a), Some(b))
if (a == "CQ" || a.starts_with("CQ ")) && b.starts_with('<'))
};
if refuse {
return None;
}
Some(decoded)
}
fn unpack77_dispatch(msg: &[u8], ht: &CallsignHashTable, i3: u32, n3: u32) -> Option<Wsjt77Fields> {
match i3 {
0 => match n3 {
0 => {
let text = unpack_free_text(msg);
if text.is_empty() {
None
} else {
Some(Wsjt77Fields::FreeText(text))
}
}
1 => {
let n28a = read_bits(msg, 0, 28);
let n28b = read_bits(msg, 28, 28);
let n10 = read_bits(msg, 56, 10);
let n5 = read_bits(msg, 66, 5);
let irpt = 2 * n5 as i32 - 30;
let crpt = if irpt >= 0 {
format!("+{:02}", irpt)
} else {
format!("{:03}", irpt)
};
if n28a <= 2 || n28b <= 2 {
return None;
}
let c1 = unpack28_h(n28a, ht);
let c2 = unpack28_h(n28b, ht);
let c3 = if let Some(call) = ht.lookup10(n10) {
format!("<{}>", call)
} else {
"<...>".to_string()
};
Some(Wsjt77Fields::DxPedition {
call1: c1,
call2: c2,
call3: c3,
report: crpt,
})
}
5 => {
let hex = format!(
"{:06X}{:06X}{:06X}",
read_bits(msg, 0, 23),
read_bits(msg, 23, 24),
read_bits(msg, 47, 24)
);
Some(Wsjt77Fields::Telemetry(
hex.trim_start_matches('0').to_string(),
))
}
6 => {
let (j48, j49, j50) = (msg[47] & 1, msg[48] & 1, msg[49] & 1);
let itype = if j50 == 1 {
2
} else if j49 == 0 {
1
} else if j48 == 0 {
3
} else {
return None;
};
match itype {
1 => {
let n28 = read_bits(msg, 0, 28);
let igrid4 = read_bits(msg, 28, 15);
let idbm = wspr_dbm(read_bits(msg, 43, 5))?;
let grid = to_grid4(igrid4)?;
Some(Wsjt77Fields::Wspr {
call: unpack28_h(n28, ht),
tail: format!("{} {}", grid, idbm),
})
}
2 => {
let n28 = read_bits(msg, 0, 28);
let npfx = read_bits(msg, 28, 16);
let idbm = wspr_dbm(read_bits(msg, 44, 5))?;
let call = unpack28_h(n28, ht);
let composed = wspr_prefix_suffix(npfx, &call)?;
Some(Wsjt77Fields::Wspr {
call: composed,
tail: idbm.to_string(),
})
}
_ => {
let n22 = read_bits(msg, 0, 22);
let igrid6 = read_bits(msg, 22, 25);
let grid = to_grid(igrid6)?;
Some(Wsjt77Fields::Wspr {
call: unpack28_h(n22 + NTOKENS, ht),
tail: grid,
})
}
}
}
3 | 4 => {
let (n28a, n28b) = (read_bits(msg, 0, 28), read_bits(msg, 28, 28));
if n28a <= 2 || n28b <= 2 {
return None;
}
let ir = msg[56] & 1;
let intx = read_bits(msg, 57, 4);
let nclass = read_bits(msg, 61, 3);
let isec = read_bits(msg, 64, 7) as usize;
if isec < 1 || isec > ARRL_SECTIONS.len() {
return None;
}
let sec = ARRL_SECTIONS[isec - 1];
let ntx = intx + 1 + if n3 == 4 { 16 } else { 0 };
let class = (b'A' + nclass as u8) as char;
let c1 = unpack28_h(n28a, ht);
let c2 = unpack28_h(n28b, ht);
Some(Wsjt77Fields::FieldDay {
call1: c1,
call2: c2,
ack: ir == 1,
transmitters: ntx,
class,
section: sec,
})
}
_ => None,
},
1 | 2 => {
let n28a = read_bits(msg, 0, 28);
let ipa = msg[28] & 1;
let n28b = read_bits(msg, 29, 28);
let ipb = msg[57] & 1;
let ir = msg[58] & 1;
let igrid = read_bits(msg, 59, 15);
let mut c1 = unpack28_h(n28a, ht);
let mut c2 = unpack28_h(n28b, ht);
if ipa == 1 && !c1.starts_with('<') && !c1.starts_with("CQ") {
c1.push_str(if i3 == 1 { "/R" } else { "/P" });
}
if ipb == 1 && !c2.starts_with('<') {
c2.push_str(if i3 == 1 { "/R" } else { "/P" });
}
let is_cq = c1 == "CQ" || c1.starts_with("CQ ");
let report = if igrid <= MAX_GRID4 {
if is_cq && ir == 1 {
return None;
}
let grid = to_grid4(igrid)?;
if ir == 0 { grid } else { format!("R {}", grid) }
} else {
let irpt = igrid - MAX_GRID4;
if is_cq && irpt >= 2 {
return None;
}
match irpt {
1 => String::new(),
2 => "RRR".to_string(),
3 => "RR73".to_string(),
4 => "73".to_string(),
n => {
let mut isnr = n as i32 - 35;
if isnr > 50 {
isnr -= 101;
}
fmt_report(isnr, ir)
}
}
};
Some(Wsjt77Fields::Standard {
call1: c1,
call2: c2,
exchange: report,
})
}
3 => {
let itu = msg[0] & 1;
let n28a = read_bits(msg, 1, 28);
let n28b = read_bits(msg, 29, 28);
let ir = msg[57] & 1;
let irpt = read_bits(msg, 58, 3) as u8;
let nexch = read_bits(msg, 61, 13);
let c1 = unpack28_h(n28a, ht);
let c2 = unpack28_h(n28b, ht);
let rst = format!("5{}9", irpt + 2);
let exch = if nexch > 8000 && (nexch as usize - 8000) <= RTTY_STATES.len() {
RTTY_STATES[(nexch as usize - 8000) - 1].to_string()
} else if (1..=7999).contains(&nexch) {
format!("{:04}", nexch)
} else {
return None;
};
Some(Wsjt77Fields::RttyRoundup {
thank_you: itu == 1,
call1: c1,
call2: c2,
ack: ir == 1,
rst,
exchange: exch,
})
}
5 => {
let n12 = read_bits(msg, 0, 12);
let n22 = read_bits(msg, 12, 22);
let ir = msg[34] & 1;
let irpt = read_bits(msg, 35, 3);
let iserial = read_bits(msg, 38, 11);
let igrid6 = read_bits(msg, 49, 25);
let grid = to_grid6(igrid6)?;
let c1 = resolve_hash12(n12, ht);
let c2 = resolve_hash22(n22, ht);
let nrs = 52 + irpt;
Some(Wsjt77Fields::EuVhfContest {
call1: c1,
call2: c2,
ack: ir == 1,
exchange: format!("{:02}{:04}", nrs, iserial),
grid6: grid,
})
}
4 => {
let n12 = read_bits(msg, 0, 12);
let n58 = read_bits_u64(msg, 12, 58);
let iflip = msg[70] & 1;
let nrpt = read_bits(msg, 71, 2);
let icq = msg[73] & 1;
let mut n = n58;
let mut buf = [b' '; 11];
for i in (0..11).rev() {
buf[i] = C38[(n % 38) as usize];
n /= 38;
}
let nonstd = String::from_utf8(buf.to_vec())
.unwrap_or_default()
.trim()
.to_string();
if icq == 1 {
return Some(Wsjt77Fields::Nonstandard {
call1: "CQ".to_string(),
call2: Some(nonstd),
exchange: "",
});
}
let hashed = resolve_hash12(n12, ht);
let (c1, c2) = if iflip == 0 {
(hashed, nonstd)
} else {
(nonstd, hashed)
};
let exchange = match nrpt {
0 => "",
1 => "RRR",
2 => "RR73",
3 => "73",
_ => return None,
};
Some(Wsjt77Fields::Nonstandard {
call1: c1,
call2: Some(c2),
exchange,
})
}
_ => None,
}
}
pub fn is_standard_callsign(call: &str) -> bool {
let call = call.trim();
let base = if call.ends_with("/R") || call.ends_with("/P") {
&call[..call.len() - 2]
} else {
call
};
let b = base.as_bytes();
if b.is_empty() || b.len() > 6 {
return false;
}
let mut split = None;
for i in (0..b.len()).rev() {
if b[i].is_ascii_digit() {
if b[i + 1..].iter().all(|&c| c.is_ascii_uppercase()) {
split = Some(i);
break;
}
}
}
let split = match split {
Some(s) => s,
None => return false,
};
let part1 = &b[..split];
let part2 = &b[split..];
if part2.is_empty() || !part2[0].is_ascii_digit() {
return false;
}
if part2.len() > 4 {
return false;
}
if !part2[1..].iter().all(|c| c.is_ascii_uppercase()) {
return false;
}
match part1.len() {
0 => true, 1 => part1[0].is_ascii_uppercase() || part1[0].is_ascii_digit(),
2 => {
let (a, b) = (part1[0], part1[1]);
(a.is_ascii_uppercase() && b.is_ascii_uppercase()) || (a.is_ascii_uppercase() && b.is_ascii_digit()) || (a.is_ascii_digit() && b.is_ascii_uppercase()) }
_ => false,
}
}
fn is_base_callsign(s: &str) -> bool {
let b = s.as_bytes();
if b.len() < 2 || b.len() > 7 {
return false;
}
let mut split = None;
for i in (0..b.len()).rev() {
if b[i].is_ascii_digit() && b[i + 1..].iter().all(|c| c.is_ascii_uppercase()) {
split = Some(i);
break;
}
}
let split = match split {
Some(s) if s + 1 < b.len() => s, _ => return false,
};
let prefix = &b[..split];
let suffix = &b[split + 1..];
if prefix.is_empty() || prefix.len() > 3 {
return false;
}
if !prefix.iter().all(|c| c.is_ascii_alphanumeric()) {
return false;
}
if !prefix.iter().any(|c| c.is_ascii_alphabetic()) {
return false;
}
suffix.len() <= 4 && suffix.iter().all(|c| c.is_ascii_uppercase())
}
pub fn is_valid_callsign(call: &str) -> bool {
if is_standard_callsign(call) {
return true;
}
let parts: Vec<&str> = call.split('/').collect();
match parts.len() {
1 => is_base_callsign(parts[0]),
2 => {
let (a, b) = (parts[0], parts[1]);
let a_base = is_base_callsign(a);
let b_base = is_base_callsign(b);
let a_mod = !a.is_empty()
&& a.len() <= 3
&& a.as_bytes().iter().all(|c| c.is_ascii_alphanumeric());
let b_mod = !b.is_empty()
&& b.len() <= 3
&& b.as_bytes().iter().all(|c| c.is_ascii_alphanumeric());
(a_base && b_mod) || (a_mod && b_base) || (a_base && b_base)
}
_ => false,
}
}
const VALID_LETTER_DIGIT_PREFIXES: &[&[u8; 2]] = &[
b"A2", b"A3", b"A4", b"A5", b"A6", b"A7", b"A8", b"A9", b"B0", b"B1", b"B2", b"B3", b"B4",
b"B5", b"B6", b"B7", b"B8", b"B9", b"C2", b"C3", b"C4", b"C5", b"C6", b"C7", b"C8", b"C9",
b"D2", b"D3", b"D4", b"D6", b"D7", b"D8", b"D9", b"E2", b"E3", b"E4", b"E5", b"E6", b"E7",
b"H2", b"H4", b"H6", b"H7", b"H8", b"H9", b"J2", b"J3", b"J5", b"J6", b"J7", b"J8", b"P2",
b"P3", b"P4", b"P5", b"P6", b"P7", b"P8", b"P9", b"S0", b"S2", b"S5", b"S7", b"S9", b"T2",
b"T3", b"T4", b"T5", b"T6", b"T7", b"T8", b"V2", b"V3", b"V4", b"V5", b"V6", b"V7", b"V8",
b"Z2", b"Z3", b"Z6", b"Z8",
];
#[inline]
fn is_known_letter_digit_prefix(prefix: &[u8]) -> bool {
if prefix.len() != 2 {
return false;
}
let key: &[u8; 2] = match prefix.try_into() {
Ok(k) => k,
Err(_) => return false,
};
VALID_LETTER_DIGIT_PREFIXES.binary_search(&key).is_ok()
}
pub fn is_plausible_callsign(call: &str) -> bool {
if !is_valid_callsign(call) {
return false;
}
let parts: Vec<&str> = call.split('/').collect();
match parts.len() {
1 => has_plausible_prefix(parts[0]),
2 => {
let a_plausible = is_base_callsign(parts[0]) && has_plausible_prefix(parts[0]);
let b_plausible = is_base_callsign(parts[1]) && has_plausible_prefix(parts[1]);
a_plausible || b_plausible
}
_ => false,
}
}
fn has_plausible_prefix(s: &str) -> bool {
let b = s.as_bytes();
if b.len() < 2 || b.len() > 7 {
return true;
}
let b = if b.len() >= 2
&& b[b.len() - 2] == b'/'
&& (b[b.len() - 1] == b'R' || b[b.len() - 1] == b'P')
{
&b[..b.len() - 2]
} else {
b
};
let mut split = None;
for i in (0..b.len()).rev() {
if b[i].is_ascii_digit() && b[i + 1..].iter().all(|c| c.is_ascii_uppercase()) {
split = Some(i);
break;
}
}
let split = match split {
Some(s) => s,
None => return true, };
let prefix = &b[..split];
if prefix.len() == 1 && prefix[0].is_ascii_uppercase() {
return matches!(
prefix[0],
b'F' | b'G' | b'I' | b'K' | b'M' | b'N' | b'R' | b'W'
);
}
if prefix.len() == 2 && prefix[0].is_ascii_uppercase() && prefix[1].is_ascii_digit() {
return is_known_letter_digit_prefix(prefix);
}
true
}
fn write_bits(msg: &mut [u8; 77], start: usize, len: usize, val: u32) {
for i in 0..len {
msg[start + i] = ((val >> (len - 1 - i)) & 1) as u8;
}
}
pub fn pack28(call: &str) -> Option<u32> {
let call = call.trim();
match call {
"DE" => return Some(0),
"QRZ" => return Some(1),
"CQ" => return Some(2),
_ => {}
}
if let Some(suffix) = call.strip_prefix("CQ ") {
let suffix = suffix.trim();
if !suffix.is_empty() {
if let Ok(n) = suffix.parse::<u32>()
&& n <= 999
{
return Some(3 + n);
}
let sb = suffix.as_bytes();
if sb.len() <= 4 && sb.iter().all(|c| c.is_ascii_uppercase()) {
let mut buf = [b' '; 4];
for (i, &b) in sb.iter().enumerate() {
buf[i] = b;
}
let i1 = C4.iter().position(|&c| c == buf[0])?;
let i2 = C4.iter().position(|&c| c == buf[1])?;
let i3 = C4.iter().position(|&c| c == buf[2])?;
let i4 = C4.iter().position(|&c| c == buf[3])?;
return Some(1003 + ((i1 * 27 + i2) * 27 + i3) as u32 * 27 + i4 as u32);
}
return None; }
}
let bytes = call.as_bytes();
if bytes.is_empty() || bytes.len() > 6 {
return None;
}
let mut buf = [b' '; 6];
if bytes.len() >= 3 && bytes[2].is_ascii_digit() {
for (i, &b) in bytes.iter().enumerate().take(6) {
buf[i] = b.to_ascii_uppercase();
}
} else if bytes.len() >= 2 && bytes[1].is_ascii_digit() {
buf[0] = b' ';
for (i, &b) in bytes.iter().enumerate() {
if i + 1 < 6 {
buf[i + 1] = b.to_ascii_uppercase();
}
}
} else {
return None; }
if !buf[2].is_ascii_digit() {
return None;
}
let i1 = C1.iter().position(|&c| c == buf[0])?;
let i2 = C2.iter().position(|&c| c == buf[1])?;
let i3 = C3.iter().position(|&c| c == buf[2])?;
let i4 = C4.iter().position(|&c| c == buf[3])?;
let i5 = C4.iter().position(|&c| c == buf[4])?;
let i6 = C4.iter().position(|&c| c == buf[5])?;
let n = ((((i1 as u32 * 36 + i2 as u32) * 10 + i3 as u32) * 27 + i4 as u32) * 27 + i5 as u32)
* 27
+ i6 as u32;
Some(NTOKENS + MAX22 + n)
}
pub fn pack_grid4(grid: &str) -> Option<u32> {
let g = grid.as_bytes();
if g.len() != 4 {
return None;
}
let j1 = g[0].to_ascii_uppercase().wrapping_sub(b'A') as u32;
let j2 = g[1].to_ascii_uppercase().wrapping_sub(b'A') as u32;
let j3 = g[2].wrapping_sub(b'0') as u32;
let j4 = g[3].wrapping_sub(b'0') as u32;
if j1 > 17 || j2 > 17 || j3 > 9 || j4 > 9 {
return None;
}
Some(((j1 * 18 + j2) * 10 + j3) * 10 + j4)
}
pub fn pack77_type1(call1: &str, call2: &str, grid: &str) -> Option<[u8; 77]> {
let n28a = pack28(call1)?;
let n28b = pack28(call2)?;
let igrid = pack_grid4(grid)?;
let mut msg = [0u8; 77];
write_bits(&mut msg, 0, 28, n28a); write_bits(&mut msg, 29, 28, n28b); write_bits(&mut msg, 59, 15, igrid); write_bits(&mut msg, 74, 3, 1); Some(msg)
}
pub fn pack77(call1: &str, call2: &str, report: &str) -> Option<[u8; 77]> {
let n28a = pack28(call1)?;
let n28b = pack28(call2)?;
let report = report.trim();
let (igrid, ir): (u32, u8) = if report.is_empty() {
(MAX_GRID4 + 1, 0)
} else if report == "RRR" {
(MAX_GRID4 + 2, 0)
} else if report == "RR73" {
(MAX_GRID4 + 3, 0)
} else if report == "73" {
(MAX_GRID4 + 4, 0)
} else if report.len() == 4 && pack_grid4(report).is_some() {
(pack_grid4(report).unwrap(), 0)
} else {
let (r_prefix, num_str) = if let Some(s) = report.strip_prefix('R') {
(1u8, s)
} else {
(0u8, report)
};
let snr: i32 = num_str.parse().ok()?;
if !(-50..=49).contains(&snr) {
return None;
}
let mut isnr = snr + 35;
if isnr < 0 {
isnr += 101;
}
(MAX_GRID4 + isnr as u32, r_prefix)
};
let mut msg = [0u8; 77];
write_bits(&mut msg, 0, 28, n28a);
write_bits(&mut msg, 29, 28, n28b);
msg[58] = ir; write_bits(&mut msg, 59, 15, igrid);
write_bits(&mut msg, 74, 3, 1); Some(msg)
}
fn write_bits_u64(msg: &mut [u8; 77], start: usize, len: usize, val: u64) {
for i in 0..len {
msg[start + i] = ((val >> (len - 1 - i)) & 1) as u8;
}
}
pub fn pack77_type4(nonstd: &str, std_call: &str, report: &str, is_cq: bool) -> Option<[u8; 77]> {
let nonstd = nonstd.trim().to_ascii_uppercase();
let nb = nonstd.as_bytes();
if nb.is_empty() || nb.len() > 11 {
return None;
}
if !nb.iter().all(|c| C38.contains(c)) {
return None;
}
let mut n58: u64 = 0;
let mut padded = [b' '; 11];
let offset = 11 - nb.len();
for (i, &b) in nb.iter().enumerate() {
padded[offset + i] = b;
}
for &ch in &padded {
let idx = C38.iter().position(|&c| c == ch)?;
n58 = n58 * 38 + idx as u64;
}
let n12 = if is_cq {
0u32 } else {
use super::hash_table::ihashcall;
ihashcall(std_call, 12)
};
let nrpt: u32 = match report.trim() {
"" => 0,
"RRR" => 1,
"RR73" => 2,
"73" => 3,
_ => return None,
};
let iflip: u8 = if is_cq || pack28(std_call).is_some() {
0
} else {
1
};
let icq: u8 = if is_cq { 1 } else { 0 };
let mut msg = [0u8; 77];
write_bits(&mut msg, 0, 12, n12); write_bits_u64(&mut msg, 12, 58, n58); msg[70] = iflip; write_bits(&mut msg, 71, 2, nrpt); msg[73] = icq; write_bits(&mut msg, 74, 3, 4); Some(msg)
}
pub fn pack77_free_text(text: &str) -> Option<[u8; 77]> {
let text = text.to_ascii_uppercase();
let bytes = text.as_bytes();
if bytes.is_empty() || bytes.len() > 13 {
return None;
}
let mut padded = [b' '; 13];
for (i, &b) in bytes.iter().enumerate() {
padded[i] = b;
}
let mut n: u128 = 0;
for &ch in &padded {
let idx = FREE_TEXT.iter().position(|&c| c == ch)? as u128;
n = n * 42 + idx;
}
let mut msg = [0u8; 77];
for i in 0..71 {
msg[i] = ((n >> (70 - i)) & 1) as u8;
}
Some(msg)
}
#[cfg(test)]
mod report_format_tests {
use super::fmt_report;
#[test]
fn single_digit_reports_are_zero_padded_like_wsjtx() {
assert_eq!(fmt_report(-8, 0), "-08");
assert_eq!(fmt_report(-8, 1), "R-08");
assert_eq!(fmt_report(8, 0), "+08");
assert_eq!(fmt_report(8, 1), "R+08");
assert_eq!(fmt_report(0, 0), "+00");
}
#[test]
fn two_digit_reports_are_unchanged() {
assert_eq!(fmt_report(-23, 0), "-23");
assert_eq!(fmt_report(-23, 1), "R-23");
assert_eq!(fmt_report(15, 0), "+15");
assert_eq!(fmt_report(-30, 0), "-30");
}
#[test]
fn matches_the_jt9_strings_that_exposed_the_bug() {
assert_eq!(
format!("W1FC F5BZB {}", fmt_report(-8, 0)),
"W1FC F5BZB -08"
);
assert_eq!(
format!("WM3PEN EA6VQ {}", fmt_report(-9, 0)),
"WM3PEN EA6VQ -09"
);
assert_eq!(
format!("N1JFU EA6EE {}", fmt_report(-7, 1)),
"N1JFU EA6EE R-07"
);
assert_eq!(
format!("K1BZM EA3GP {}", fmt_report(-9, 0)),
"K1BZM EA3GP -09"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plausible_callsign_accepts_real_calls() {
for c in [
"W1AW", "JA1XYZ", "DL3DB", "EA6VQ", "HB9CQK", "F5RXL", "G3WDG", "VK6ABC", "K1JT",
"N1PJT", "JL1NIE", "WM3PEN",
] {
assert!(is_plausible_callsign(c), "should accept {c}");
}
for c in ["A22ZZ", "V51AAA", "S55BC", "T77QQ"] {
assert!(is_plausible_callsign(c), "should accept {c}");
}
for c in ["3D2RA", "4X4ABC", "5B4XYZ", "9V1ABC"] {
assert!(is_plausible_callsign(c), "should accept {c}");
}
for c in ["JA1XYZ/P", "JA1XYZ/QRP", "F/JA1XYZ", "KH6/N1ABC"] {
assert!(is_plausible_callsign(c), "should accept {c}");
}
}
#[test]
fn plausible_callsign_rejects_letter_digit_gaps() {
for c in [
"Z74QTJ", "Q1ABC", "Q4ABCD", "X0FOO", "Y0ABC",
] {
assert!(
!is_plausible_callsign(c),
"should reject {c} (unallocated letter+digit prefix)"
);
}
}
#[test]
fn plausible_callsign_compound_garbage() {
assert!(is_plausible_callsign("JA1XYZ/P"));
assert!(!is_plausible_callsign("Z74QTJ/Q4ABCD"));
assert!(!is_plausible_callsign("Z74QTJ/R"));
}
#[test]
fn unpack28_does_not_panic_for_extended_range() {
for n28 in [1003u32, 532443, 532444, 1_000_000, NTOKENS - 1] {
let _ = unpack28(n28);
}
}
fn hex_to_msg77(hex: &str) -> [u8; 77] {
assert_eq!(hex.len(), 20, "need exactly 20 hex chars (10 bytes)");
let bytes: Vec<u8> = (0..10)
.map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap())
.collect();
let mut msg = [0u8; 77];
for (j, bit) in msg.iter_mut().enumerate() {
*bit = (bytes[j / 8] >> (7 - j % 8)) & 1;
}
msg
}
#[test]
fn decode_cq_r7iw_ln35() {
let msg = hex_to_msg77("0000002059654a94a3c8");
let text = unpack77(&msg).expect("should decode");
assert_eq!(text, "CQ R7IW LN35");
}
#[test]
fn decode_cq_dx_r6wa_ln32() {
let msg = hex_to_msg77("000046f059519f14a308");
let text = unpack77(&msg).expect("should decode");
assert_eq!(text, "CQ DX R6WA LN32");
}
#[test]
fn silence_bits_returns_none_or_empty() {
let msg = [0u8; 77];
assert!(unpack77(&msg).is_none());
}
#[test]
fn pack28_roundtrip() {
for call in &["JQ1QSO", "3Y0Z", "R7IW", "JA1ABC", "W1AW", "VK2RG"] {
let n = pack28(call).unwrap_or_else(|| panic!("pack28 failed for {call}"));
let decoded = unpack28(n);
assert_eq!(
decoded,
call.trim(),
"roundtrip mismatch for {call}: got {decoded}"
);
}
assert_eq!(pack28("CQ"), Some(2));
assert_eq!(pack28("DE"), Some(0));
assert_eq!(pack28("QRZ"), Some(1));
for cq in &["CQ POTA", "CQ SOTA", "CQ DX", "CQ NA", "CQ EU"] {
let n = pack28(cq).unwrap_or_else(|| panic!("pack28 failed for {cq}"));
let decoded = unpack28(n);
assert_eq!(decoded, *cq, "CQ suffix roundtrip mismatch for {cq}");
}
let n = pack28("CQ 001").unwrap();
assert_eq!(unpack28(n), "CQ 001");
let n = pack28("CQ 999").unwrap();
assert_eq!(unpack28(n), "CQ 999");
}
#[test]
fn pack77_type1_roundtrip() {
let msg = pack77_type1("CQ", "3Y0Z", "JD34").expect("pack failed");
let text = unpack77(&msg).expect("unpack failed");
assert_eq!(text, "CQ 3Y0Z JD34");
let msg2 = pack77_type1("CQ", "JQ1QSO", "PM95").expect("pack failed");
let text2 = unpack77(&msg2).expect("unpack failed");
assert_eq!(text2, "CQ JQ1QSO PM95");
}
#[test]
fn standard_callsign_valid() {
assert!(is_standard_callsign("JA1ABC"));
assert!(is_standard_callsign("3Y0Z"));
assert!(is_standard_callsign("W1AW"));
assert!(is_standard_callsign("VK2RG"));
assert!(is_standard_callsign("R7IW"));
assert!(is_standard_callsign("JQ1QSO"));
assert!(is_standard_callsign("TA6CQ"));
assert!(is_standard_callsign("JA1ABC/P"));
assert!(is_standard_callsign("JM1VWQ/R"));
}
#[test]
fn standard_callsign_invalid() {
assert!(!is_standard_callsign("NFW/0811"));
assert!(!is_standard_callsign("791JLI"));
assert!(!is_standard_callsign(""));
assert!(!is_standard_callsign("ABCDEFG"));
assert!(!is_standard_callsign("123"));
}
#[test]
fn standard_callsign_edge_cases() {
assert!(is_standard_callsign("SY2XHO")); assert!(is_standard_callsign("8I9NIH")); }
#[test]
fn valid_callsign_standard() {
assert!(is_valid_callsign("JA1ABC"));
assert!(is_valid_callsign("3Y0Z"));
assert!(is_valid_callsign("W1AW"));
assert!(is_valid_callsign("W1AW/P"));
assert!(is_valid_callsign("JM1VWQ/R"));
assert!(is_valid_callsign("W1A")); }
#[test]
fn valid_callsign_nonstandard() {
assert!(is_valid_callsign("JL1NIE/1")); assert!(is_valid_callsign("JL1NIE/P")); assert!(is_valid_callsign("F/JA1ABC")); assert!(is_valid_callsign("ZS6/JA1ABC")); assert!(is_valid_callsign("JR9ECD/P")); assert!(is_valid_callsign("3DA0WPX")); assert!(is_valid_callsign("JA1ABC/QRP")); }
#[test]
fn valid_callsign_rejected() {
assert!(!is_valid_callsign("NFW/0811")); assert!(!is_valid_callsign("ABCDEF")); assert!(!is_valid_callsign(""));
assert!(!is_valid_callsign("A")); assert!(!is_valid_callsign("HELLO+WORLD")); assert!(!is_valid_callsign("123")); assert!(!is_valid_callsign("//////")); }
#[test]
fn plausible_call_accepts_every_shape_a_field_can_hold() {
for f in [
"CQ", "DE", "QRZ", "CQ DX", "CQ POTA", "CQ NA", "CQ SOTA", "CQ 123",
] {
assert!(is_plausible_call(f), "{f} is a token, not a callsign");
}
for f in ["<...>", "<JA1ABC>", "<KH1/KH7Z>", "<G4ABC/P>"] {
assert!(is_plausible_call(f), "{f} is a hash");
}
for f in [
"JA1ABC", "3Y0Z", "R6WA", "ZS6S", "W1AW", "JR1UJX/P", "JH4IUV/P", "JL1NIE/1",
"F/JA1ABC", "JR9ECD/P", "K1ABC", "W9XYZ",
] {
assert!(is_plausible_call(f), "{f} is a real callsign");
}
}
#[test]
fn plausible_call_rejects_what_no_allocation_produces() {
for f in [
"",
"NFW/0811",
"ABCDEF",
"GHIJKL",
"294TOW/R",
"G47OXF",
"HVA1DPFV3L",
] {
assert!(!is_plausible_call(f), "{f:?} must be refused");
}
}
#[test]
fn pack77_type4_roundtrip() {
let msg = pack77_type4("JL1NIE/P", "", "", true).expect("pack failed");
let text = unpack77(&msg).expect("unpack failed");
assert_eq!(text, "CQ JL1NIE/P");
let msg = pack77_type4("JL1NIE/1", "JA1ABC", "", false).expect("pack failed");
let text = unpack77(&msg).expect("unpack failed");
assert!(
text.contains("JL1NIE/1"),
"should contain non-std call: {text}"
);
assert!(
text.contains("<...>"),
"should contain hash placeholder: {text}"
);
let msg = pack77_type4("JR9ECD/P", "W1AW", "73", false).expect("pack failed");
let text = unpack77(&msg).expect("unpack failed");
assert!(text.contains("JR9ECD/P"), "got: {text}");
assert!(text.contains("73"), "got: {text}");
let msg = pack77_type4("F/JA1ABC", "W1AW", "RR73", false).expect("pack failed");
let text = unpack77(&msg).expect("unpack failed");
assert!(text.contains("F/JA1ABC"), "got: {text}");
assert!(text.contains("RR73"), "got: {text}");
}
#[test]
fn type4_hash_register_then_resolve() {
let mut ht = CallsignHashTable::new();
ht.insert("JA1ABC");
let msg = pack77_type4("JL1NIE/1", "JA1ABC", "", false).expect("pack failed");
let text_no_ht = unpack77(&msg).expect("unpack failed");
assert!(
text_no_ht.contains("<...>"),
"without hash table: {text_no_ht}"
);
assert!(
text_no_ht.contains("JL1NIE/1"),
"without hash table: {text_no_ht}"
);
let text_ht = unpack77_with_hash(&msg, &ht).expect("unpack failed");
assert!(
text_ht.contains("<JA1ABC>"),
"with hash table should resolve: {text_ht}"
);
assert!(text_ht.contains("JL1NIE/1"), "with hash table: {text_ht}");
assert!(
is_plausible_payload_with_hash(&msg, &ht),
"resolved message should be plausible: {text_ht}"
);
}
#[test]
fn pack77_type4_cq_with_pack77() {
let msg = pack77_type4("JL1NIE/1", "", "", true).expect("pack failed");
let text = unpack77(&msg).expect("unpack failed");
assert_eq!(text, "CQ JL1NIE/1");
assert!(is_plausible_payload(&msg));
}
#[test]
fn pack77_free_text_roundtrip() {
let msg = pack77_free_text("JA/TK-001").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JA/TK-001");
let msg = pack77_free_text("JP-1001").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JP-1001");
let msg = pack77_free_text("JCC 100110").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JCC 100110");
let msg = pack77_free_text("HELLO FT8 WLD").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "HELLO FT8 WLD");
assert!(pack77_free_text("ABCDEFGHIJKLMN").is_none());
assert!(pack77_free_text("HELLO!").is_none()); }
#[test]
fn pack77_report_roundtrip() {
let msg = pack77("CQ", "JA1ABC", "PM95").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "CQ JA1ABC PM95");
let msg = pack77("JA1ABC", "3Y0Z", "-12").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JA1ABC 3Y0Z -12");
let msg = pack77("JA1ABC", "3Y0Z", "+05").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JA1ABC 3Y0Z +05");
let msg = pack77("3Y0Z", "JA1ABC", "R-12").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "3Y0Z JA1ABC R-12");
let msg = pack77("JA1ABC", "3Y0Z", "RRR").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JA1ABC 3Y0Z RRR");
let msg = pack77("JA1ABC", "3Y0Z", "RR73").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JA1ABC 3Y0Z RR73");
let msg = pack77("3Y0Z", "JA1ABC", "73").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "3Y0Z JA1ABC 73");
let msg = pack77("JA1ABC", "3Y0Z", "").unwrap();
assert_eq!(unpack77(&msg).unwrap(), "JA1ABC 3Y0Z");
}
fn bits77(spec: &[(usize, usize, u32)]) -> [u8; 77] {
let mut m = [0u8; 77];
for &(start, len, v) in spec {
for i in 0..len {
m[start + i] = ((v >> (len - 1 - i)) & 1) as u8;
}
}
m
}
fn call28() -> u32 {
pack28("JA1ABC").expect("JA1ABC packs")
}
#[test]
fn dxpedition_rejects_a_token_in_a_callsign_field() {
let good = bits77(&[
(0, 28, call28()),
(28, 28, call28()),
(66, 5, 20),
(71, 3, 1),
]);
assert!(unpack77(&good).is_some(), "control must decode");
for (name, a, b) in [
("DE", 0, call28()),
("QRZ", 1, call28()),
("CQ", 2, call28()),
] {
let m = bits77(&[(0, 28, a), (28, 28, b), (66, 5, 20), (71, 3, 1)]);
assert!(
unpack77(&m).is_none(),
"DXpedition call1 = {name} must be refused"
);
}
let m = bits77(&[(0, 28, call28()), (28, 28, 2), (66, 5, 20), (71, 3, 1)]);
assert!(
unpack77(&m).is_none(),
"DXpedition call2 = CQ must be refused"
);
}
#[test]
fn field_day_rejects_a_token_in_a_callsign_field() {
for n3 in [3u32, 4] {
let good = bits77(&[
(0, 28, call28()),
(28, 28, call28()),
(64, 7, 11),
(71, 3, n3),
]);
assert!(unpack77(&good).is_some(), "control must decode (n3={n3})");
let m = bits77(&[(0, 28, 0), (28, 28, call28()), (64, 7, 11), (71, 3, n3)]);
assert!(
unpack77(&m).is_none(),
"Field Day call1 = DE must be refused"
);
}
}
#[test]
fn field_day_decodes_the_exchange() {
let call = pack28("JA1ABC").expect("JA1ABC packs");
let dx = pack28("3Y0Z").expect("3Y0Z packs");
let m = bits77(&[
(0, 28, call),
(28, 28, dx),
(57, 4, 5),
(61, 3, 0),
(64, 7, 11),
(71, 3, 3),
]);
assert_eq!(unpack77(&m).as_deref(), Some("JA1ABC 3Y0Z 6A EMA"));
let m = bits77(&[
(0, 28, call),
(28, 28, dx),
(56, 1, 1),
(57, 4, 15),
(61, 3, 7),
(64, 7, 84),
(71, 3, 4),
]);
assert_eq!(unpack77(&m).as_deref(), Some("JA1ABC 3Y0Z R 32H DX"));
}
fn grid6_index(g: &str) -> u32 {
let b = g.as_bytes();
let j = |i: usize, base: u8| (b[i] - base) as u32;
j(0, b'A') * 18 * 10 * 10 * 24 * 24
+ j(1, b'A') * 10 * 10 * 24 * 24
+ j(2, b'0') * 10 * 24 * 24
+ j(3, b'0') * 24 * 24
+ j(4, b'A') * 24
+ j(5, b'A')
}
#[test]
fn eu_vhf_contest_decodes_the_exchange() {
let igrid6 = grid6_index("IO91NP");
let m = bits77(&[
(0, 12, 0x123),
(12, 22, 0x2_3456),
(34, 1, 1),
(35, 3, 7),
(38, 11, 3),
(49, 25, igrid6),
(74, 3, 5),
]);
assert_eq!(unpack77(&m).as_deref(), Some("<...> <...> R 590003 IO91NP"));
let m = bits77(&[(35, 3, 0), (38, 11, 2047), (49, 25, igrid6), (74, 3, 5)]);
assert_eq!(unpack77(&m).as_deref(), Some("<...> <...> 522047 IO91NP"));
}
#[test]
fn eu_vhf_contest_resolves_hashes_from_the_table() {
use crate::msg::hash_table::ihashcall;
let mut ht = CallsignHashTable::new();
ht.insert("PA3XYZ");
let m = bits77(&[
(0, 12, ihashcall("PA3XYZ", 12)),
(12, 22, 0x2_3456),
(34, 1, 1),
(35, 3, 7),
(38, 11, 3),
(49, 25, grid6_index("IO91NP")),
(74, 3, 5),
]);
let text = unpack77_with_hash(&m, &ht).expect("type 5 unpacks");
assert_eq!(text, "<PA3XYZ> <...> R 590003 IO91NP");
assert!(
is_plausible_payload_with_hash(&m, &ht),
"a resolved hash is what makes it plausible: {text}"
);
let blind = unpack77(&m).expect("type 5 unpacks");
assert_eq!(blind, "<...> <...> R 590003 IO91NP");
assert!(!is_plausible_payload(&m));
}
#[test]
fn eu_vhf_contest_rejects_an_out_of_range_grid() {
let g = |igrid6: u32| bits77(&[(49, 25, igrid6), (74, 3, 5)]);
assert!(unpack77(&g(18_662_399)).is_some(), "last valid code");
assert!(unpack77(&g(18_662_400)).is_none(), "one past the table");
assert!(unpack77(&g(33_554_431)).is_none(), "top of the field");
}
#[test]
fn eu_vhf_contest_needs_a_resolved_hash() {
let eu = |c1: &str, c2: &str| Wsjt77Fields::EuVhfContest {
call1: c1.to_string(),
call2: c2.to_string(),
ack: true,
exchange: "590003".to_string(),
grid6: "IO91NP".to_string(),
};
assert!(!eu(UNRESOLVED_HASH, UNRESOLVED_HASH).is_plausible());
assert!(eu("<PA3XYZ>", UNRESOLVED_HASH).is_plausible());
assert!(eu(UNRESOLVED_HASH, "<G4ABC/P>").is_plausible());
}
#[test]
fn dxpedition_is_judged_on_its_callsigns() {
let dx = |c1: &str, c2: &str, c3: &str| Wsjt77Fields::DxPedition {
call1: c1.to_string(),
call2: c2.to_string(),
call3: c3.to_string(),
report: "-11".to_string(),
};
assert!(dx("K1ABC", "W9XYZ", "<KH1/KH7Z>").is_plausible());
assert!(dx("K1ABC", "W9XYZ", UNRESOLVED_HASH).is_plausible());
assert!(!dx("NFW/0811", "W9XYZ", UNRESOLVED_HASH).is_plausible());
assert!(!dx("K1ABC", "NFW/0811", UNRESOLVED_HASH).is_plausible());
}
#[test]
fn a_cq_to_a_hash_is_refused_even_behind_a_prefix() {
let hashed = NTOKENS + 1;
let m = bits77(&[
(0, 1, 1),
(1, 28, 2),
(29, 28, hashed),
(58, 3, 7),
(61, 13, 8005),
(74, 3, 3),
]);
assert_eq!(
unpack77(&m),
None,
"a `TU; CQ <...>` message must be refused"
);
let m = bits77(&[
(1, 28, 2),
(29, 28, hashed),
(58, 3, 7),
(61, 13, 8005),
(74, 3, 3),
]);
assert_eq!(unpack77(&m), None);
}
#[test]
fn rtty_roundup_refuses_an_out_of_range_exchange() {
let rr = |nexch: u32| {
bits77(&[
(1, 28, call28()),
(29, 28, call28()),
(61, 13, nexch),
(74, 3, 3),
])
};
for nexch in [0, 8000, 8172, 8191] {
assert!(
unpack77(&rr(nexch)).is_none(),
"nexch = {nexch} is unusable"
);
}
for nexch in [1, 7999, 8001, 8171] {
assert!(
unpack77(&rr(nexch)).is_some(),
"nexch = {nexch} is in range"
);
}
}
#[test]
fn field_day_rejects_an_out_of_range_section() {
let fd = |isec: u32| {
bits77(&[
(0, 28, call28()),
(28, 28, call28()),
(64, 7, isec),
(71, 3, 3),
])
};
assert!(unpack77(&fd(0)).is_none(), "isec = 0 is below the table");
assert!(unpack77(&fd(1)).is_some(), "isec = 1 is \"AB\"");
assert!(unpack77(&fd(86)).is_some(), "isec = 86 is \"NB\"");
for isec in 87..128 {
assert!(
unpack77(&fd(isec)).is_none(),
"isec = {isec} names no section"
);
}
}
#[test]
fn field_day_is_judged_on_its_callsigns() {
let fd = |c1: &str, c2: &str| Wsjt77Fields::FieldDay {
call1: c1.to_string(),
call2: c2.to_string(),
ack: false,
transmitters: 6,
class: 'A',
section: "EMA",
};
assert!(fd("JA1ABC", "3Y0Z").is_plausible());
assert!(fd(UNRESOLVED_HASH, "3Y0Z").is_plausible());
assert!(!fd("NFW/0811", "3Y0Z").is_plausible());
}
#[test]
fn cq_rejects_r_and_any_report() {
const CQ: u32 = 2;
let grid = pack_grid4("PM95").expect("PM95 packs");
let ok = bits77(&[(0, 28, CQ), (29, 28, call28()), (59, 15, grid), (74, 3, 1)]);
assert_eq!(unpack77(&ok).as_deref(), Some("CQ JA1ABC PM95"));
let r = bits77(&[
(0, 28, CQ),
(29, 28, call28()),
(58, 1, 1),
(59, 15, grid),
(74, 3, 1),
]);
assert!(unpack77(&r).is_none(), "CQ with R must be refused");
let bare = bits77(&[
(0, 28, CQ),
(29, 28, call28()),
(59, 15, MAX_GRID4 + 1),
(74, 3, 1),
]);
assert_eq!(unpack77(&bare).as_deref(), Some("CQ JA1ABC"));
for irpt in [2u32, 3, 4, 40] {
let m = bits77(&[
(0, 28, CQ),
(29, 28, call28()),
(59, 15, MAX_GRID4 + irpt),
(74, 3, 1),
]);
assert!(
unpack77(&m).is_none(),
"CQ with irpt={irpt} must be refused"
);
}
}
#[test]
fn every_message_type_survives_its_own_filter() {
let call = pack28("JA1ABC").expect("packs");
let dx = pack28("3Y0Z").expect("packs");
let grid = pack_grid4("PM95").expect("packs");
let mut cases: Vec<(&str, [u8; 77])> = alloc::vec![
("free text", pack77_free_text("HELLO WORLD").expect("packs")),
(
"DXpedition",
bits77(&[(0, 28, call), (28, 28, dx), (66, 5, 19), (71, 3, 1)]),
),
];
cases.push((
"Field Day",
bits77(&[
(0, 28, call),
(28, 28, dx),
(57, 4, 5),
(64, 7, 11),
(71, 3, 3),
]),
));
cases.push((
"telemetry",
bits77(&[
(0, 23, 0x12_3456),
(23, 24, 0x78_9ABC),
(47, 24, 0xDE_F012),
(71, 3, 5),
]),
));
cases.push((
"WSPR type 1",
bits77(&[(0, 28, call), (28, 15, grid), (43, 5, 10), (71, 3, 6)]),
));
cases.push((
"standard",
bits77(&[(0, 28, call), (29, 28, dx), (58, 15, grid), (74, 3, 1)]),
));
cases.push((
"RTTY Roundup",
bits77(&[
(1, 28, call),
(29, 28, dx),
(58, 3, 7),
(61, 13, 8005),
(74, 3, 3),
]),
));
cases.push((
"nonstandard call",
pack77_type4("JL1NIE/1", "JA1ABC", "RR73", false).expect("packs"),
));
let mut ht = CallsignHashTable::new();
ht.insert("PA3XYZ");
cases.push((
"EU VHF contest",
bits77(&[
(0, 12, crate::msg::hash_table::ihashcall("PA3XYZ", 12)),
(12, 22, 0x2_3456),
(34, 1, 1),
(35, 3, 7),
(38, 11, 3),
(49, 25, grid6_index("IO91NP")),
(74, 3, 5),
]),
));
let mut refused = Vec::new();
for (name, m) in &cases {
let text = unpack77_with_hash(m, &ht)
.unwrap_or_else(|| panic!("{name} must unpack — fix the decoder, not the filter"));
if !is_plausible_payload_with_hash(m, &ht) {
refused.push(alloc::format!("{name}: {text:?}"));
}
}
assert!(
refused.is_empty(),
"the codec verdict refuses whole message types:\n {}",
refused.join("\n ")
);
let eu = cases.last().expect("EU VHF case").1;
assert!(
!is_plausible_payload(&eu),
"type 5 must stay refused while no table reaches the filter"
);
}
#[test]
#[ignore = "diagnostic — phantom survival through unpack77 and the codec verdict"]
fn phantom_survival_rates() {
const N: usize = 2_000_000;
let mut x: u64 = 0x2026_0920_0000_0001;
let mut next_bit = || {
x = x
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((x >> 33) & 1) as u8
};
fn ft8_gate(i3: u8, n3: u8) -> bool {
!(i3 > 5 || (i3 == 0 && n3 > 6) || (i3 == 0 && n3 == 2))
}
fn msk144_gate(i3: u8, n3: u8) -> bool {
!((i3 == 0 && (n3 == 1 || n3 == 3 || n3 == 4 || n3 > 5)) || i3 == 3 || i3 > 5)
}
fn no_gate(_i3: u8, _n3: u8) -> bool {
true
}
type Gate = fn(u8, u8) -> bool;
let gates: [(&str, Gate); 3] = [
("none (FT4/FST4)", no_gate),
("ft8b.f90:510", ft8_gate),
("msk144decodeframe:103", msk144_gate),
];
let (mut unpacked, mut plausible) = (0usize, 0usize);
let mut survived_gated = [0usize; 3];
let mut cell_unpacked = [0usize; 64];
let mut cell_plausible = [0usize; 64];
for _ in 0..N {
let mut m = [0u8; 77];
for b in m.iter_mut() {
*b = next_bit();
}
let n3 = read_bits(&m, 71, 3) as u8;
let i3 = read_bits(&m, 74, 3) as u8;
if unpack77(&m).is_none() {
continue;
}
unpacked += 1;
let cell = (i3 as usize) * 8 + n3 as usize;
cell_unpacked[cell] += 1;
if !is_plausible_payload(&m) {
continue;
}
plausible += 1;
cell_plausible[cell] += 1;
for (k, (_, gate)) in gates.iter().enumerate() {
if gate(i3, n3) {
survived_gated[k] += 1;
}
}
}
let pct = |a: usize, b: usize| {
if b == 0 {
0.0
} else {
100.0 * a as f64 / b as f64
}
};
println!(" random 77-bit payloads: {N}");
println!(
" unpack77 accepts {unpacked:>9} ({:.3} % of payloads)",
pct(unpacked, N)
);
println!(
" the codec verdict keeps {plausible:>9} ({:.3} % of those unpack77 accepted)",
pct(plausible, unpacked)
);
println!(
" surviving both {plausible:>9} ({:.4} % of payloads)",
pct(plausible, N)
);
println!("\n what each mode's own (i3,n3) pre-gate removes from those survivors:");
println!(" {:<24} {:>9} {:>9}", "pre-gate", "survive", "vs none");
for (k, (name, _)) in gates.iter().enumerate() {
println!(
" {name:<24} {:>9} {:>8.1} %",
survived_gated[k],
pct(survived_gated[k], survived_gated[0])
);
}
println!("\n i3 n3 unpack77 kept kept% style");
let row = |i3: u8, n3: Option<u8>, u: usize, p: usize, style: &str| {
let n3s = match n3 {
Some(v) => alloc::format!("{v}"),
None => "*".into(),
};
println!(
" {i3:<4} {n3s:<4} {u:>8} {p:>8} {:>6.1} {style}",
pct(p, u)
);
};
for n3 in 0u8..8 {
let cell = n3 as usize;
if cell_unpacked[cell] == 0 {
continue;
}
let style = match n3 {
0 => "free text",
1 => "DXpedition",
3 | 4 => "ARRL Field Day",
5 => "telemetry",
6 => "WSPR type 1/2/3",
_ => "?",
};
row(
0,
Some(n3),
cell_unpacked[cell],
cell_plausible[cell],
style,
);
}
for i3 in 1u8..8 {
let base = (i3 as usize) * 8;
let u: usize = cell_unpacked[base..base + 8].iter().sum();
if u == 0 {
continue;
}
let p: usize = cell_plausible[base..base + 8].iter().sum();
let style = match i3 {
1 => "standard",
2 => "EU VHF /P",
3 => "ARRL RTTY Roundup",
4 => "nonstandard call",
5 => "EU VHF contest",
_ => "?",
};
row(i3, None, u, p, style);
}
}
#[test]
fn telemetry_decodes_as_eighteen_hex_digits() {
let m = bits77(&[
(0, 23, 0x12_3456),
(23, 24, 0x78_9ABC),
(47, 24, 0xDE_F012),
(71, 3, 5),
]);
assert_eq!(unpack77(&m).as_deref(), Some("123456789ABCDEF012"));
let z = bits77(&[(23, 24, 0x00_00AB), (47, 24, 0xCD_0000), (71, 3, 5)]);
assert_eq!(unpack77(&z).as_deref(), Some("ABCD0000"));
}
#[test]
fn wspr_type1_decodes_call_grid_power() {
let grid = pack_grid4("PM95").expect("PM95 packs");
let m = bits77(&[(0, 28, call28()), (28, 15, grid), (43, 5, 6), (71, 3, 6)]);
assert_eq!(unpack77(&m).as_deref(), Some("JA1ABC PM95 20"));
let bad = bits77(&[(0, 28, call28()), (28, 15, grid), (43, 5, 31), (71, 3, 6)]);
assert!(
unpack77(&bad).is_none(),
"idbm 31 -> 103 dBm must be refused"
);
}
#[test]
fn wspr_type2_decodes_prefix_and_suffix() {
let pfx = bits77(&[
(0, 28, call28()),
(28, 16, 13_368),
(44, 5, 6),
(49, 1, 1),
(71, 3, 6),
]);
assert_eq!(unpack77(&pfx).as_deref(), Some("ABC/JA1ABC 20"));
let sfx = bits77(&[
(0, 28, call28()),
(28, 16, 46_656 + 10),
(44, 5, 6),
(49, 1, 1),
(71, 3, 6),
]);
assert_eq!(unpack77(&sfx).as_deref(), Some("JA1ABC/A 20"));
}
#[test]
fn wspr_type3_decodes_hashed_call_and_grid() {
let igrid6 = 15 * 1_125_000 + 12 * 62_500 + 9 * 6_250 + 5 * 625 + 24 * 25 + 24;
let m = bits77(&[(0, 22, 1234), (22, 25, igrid6), (48, 1, 1), (71, 3, 6)]);
assert_eq!(unpack77(&m).as_deref(), Some("<...> PM95"));
}
#[test]
fn cq_rejects_a_hashed_second_call() {
let hashed = NTOKENS + 7;
let grid = pack_grid4("PM95").expect("PM95 packs");
let m = bits77(&[(0, 28, 2), (29, 28, hashed), (59, 15, grid), (74, 3, 1)]);
assert!(
unpack77(&m).is_none(),
"CQ <...> must be refused; got {:?}",
unpack77(&m)
);
}
}