Skip to main content

mfsk_core/core/
protocol.rs

1//! Protocol trait hierarchy.
2//!
3//! A `Protocol` is a zero-sized type that ties together the four axes of
4//! variation across WSJT-family digital modes:
5//!
6//! | Axis               | Trait              | Examples                          |
7//! |--------------------|--------------------|-----------------------------------|
8//! | Tones / baseband   | `ModulationParams` | 8-FSK @ 6.25 Hz (FT8) vs 4-FSK (FT4) |
9//! | Frame layout       | `FrameLayout`      | Costas pattern, sync positions    |
10//! | FEC                | `FecCodec`         | LDPC(174,91) / Reed–Solomon / Fano |
11//! | Message payload    | `MessageCodec`     | WSJT 77-bit / JT 72-bit / WSPR 50 |
12//!
13//! Splitting the traits lets implementations share code: FT4 reuses FT8's
14//! `Ldpc174_91` and `Wsjt77Message` and differs only in `ModulationParams` +
15//! `FrameLayout`, so SIMD optimisations to the shared LDPC decoder
16//! automatically benefit every LDPC-based protocol.
17
18use alloc::string::String;
19use alloc::vec::Vec;
20
21/// Runtime protocol tag — used at FFI boundaries where generics cannot cross
22/// the C ABI. Order is stable; append new variants at the end.
23#[repr(u8)]
24#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
25pub enum ProtocolId {
26    /// FT8 — 15 s slot, 8-FSK, LDPC(174,91), 77-bit message.
27    Ft8 = 0,
28    /// FT4 — 7.5 s slot, 4-FSK, LDPC(174,91), 77-bit message.
29    Ft4 = 1,
30    /// FT2 (experimental / contest variant).
31    Ft2 = 2,
32    /// FST4 — 60 s slot, 4-FSK, LDPC(240,101) + CRC-24, 77-bit message.
33    Fst4 = 3,
34    /// JT65 — 60 s slot, 65-tone FSK, Reed-Solomon(63,12), 72-bit message.
35    Jt65 = 4,
36    /// JT9 — 60 s slot, 9-FSK, convolutional r=½ K=32 + Fano, 72-bit message.
37    Jt9 = 5,
38    /// WSPR — 120 s slot, 4-FSK, convolutional r=½ K=32 + Fano, 50-bit message.
39    Wspr = 6,
40    /// Q65 — 65-tone FSK, QRA(15,65) over GF(64), 77-bit Wsjt77 message.
41    /// Multiple T/R-period × tone-spacing variants share this tag at the
42    /// FFI level; the protocol-layer ZST disambiguates.
43    Q65 = 7,
44    /// uvpacket — 4-GFSK packet protocol for narrow-FM voice channels
45    /// at U/VHF (Rayleigh-fading-tolerant). 4 sub-modes share this
46    /// family ID; the protocol-layer ZST disambiguates.
47    UvPacket = 8,
48}
49
50/// Baseband modulation parameters (tones, symbol rate, Gray mapping, Gaussian
51/// shaping and the tunable DSP ratios the pipeline reads per protocol).
52///
53/// All constants are evaluated at compile time; the trait carries no data so
54/// implementors are typically zero-sized types.
55pub trait ModulationParams: Copy + Default + 'static {
56    /// Number of FSK tones (M in M-ary FSK).
57    const NTONES: u32;
58
59    /// Information bits carried per modulated symbol (= log2(NTONES)).
60    const BITS_PER_SYMBOL: u32;
61
62    /// Samples per symbol at the 12 kHz pipeline sample rate.
63    const NSPS: u32;
64
65    /// Symbol duration in seconds (= NSPS / 12000).
66    const SYMBOL_DT: f32;
67
68    /// Spacing between adjacent tones, in Hz.
69    const TONE_SPACING_HZ: f32;
70
71    /// Gray-code map: `GRAY_MAP[tone_index]` returns the NATURAL-bit pattern
72    /// for that tone. The map covers at least the data alphabet
73    /// (`2^BITS_PER_SYMBOL` entries) and at most the full tone set
74    /// (`NTONES` entries). Protocols whose sync tones are part of
75    /// the data alphabet (FT8 / FT4 / FST4 / WSPR) have
76    /// `len() == NTONES == 2^BITS_PER_SYMBOL`; protocols that
77    /// reserve additional sync-only tones (JT9, JT65, Q65) either
78    /// trim the map to the data alphabet (JT9: 8 entries for 9
79    /// tones) or extend it with identity over the sync slots
80    /// (JT65 / Q65). Pinned by `tests/protocol_invariants.rs`.
81    const GRAY_MAP: &'static [u8];
82
83    // ── GFSK shaping ────────────────────────────────────────────────────
84    /// Gaussian bandwidth-time product. FT8 = 2.0, FT4 = 1.0, FST4 ≈ 1.0.
85    const GFSK_BT: f32;
86    /// Modulation index h — the phase increment per symbol is `2π · h`.
87    /// FT8 and FT4 both use 1.0 (orthogonal tones at `1/T` spacing).
88    const GFSK_HMOD: f32;
89
90    // ── Per-protocol DSP ratios ─────────────────────────────────────────
91    /// Per-symbol FFT size = `NSPS * NFFT_PER_SYMBOL_FACTOR`.
92    /// FT8 = 2 (window is 2·NSPS), FT4 = 4 (window is 4·NSPS) — trade-off
93    /// between frequency resolution and time localisation.
94    const NFFT_PER_SYMBOL_FACTOR: u32;
95    /// Coarse-sync time-step = `NSPS / NSTEP_PER_SYMBOL`.
96    /// FT8 = 4 (quarter-symbol resolution), FT4 = 1 (symbol-granular).
97    const NSTEP_PER_SYMBOL: u32;
98    /// Downsample decimation factor: baseband rate = `12 000 / NDOWN` Hz.
99    /// FT8 = 60 (→200 Hz), FT4 = 18 (→667 Hz). Proportional to tone spacing.
100    const NDOWN: u32;
101
102    /// LLR scale factor applied after standard-deviation normalisation.
103    /// FT8 uses 2.83 (empirical, from WSJT-X ft8b.f90). Different
104    /// bits-per-symbol counts may shift the optimum — FT4's 2-bit LLR
105    /// dynamics are not identical to FT8's 3-bit case.
106    const LLR_SCALE: f32 = 2.83;
107
108    /// Maximum coherent-integration depth for the 3rd LLR variant.
109    /// `compute_llr` builds three variants `llra/llrb/llrc` from
110    /// `nsym` ∈ `{1, 2, LLR_NSYM_MAX}` symbol blocks. WSJT-X uses
111    /// `nsym=1, 2, 4` for FT4 (`get_ft4_bitmetrics.f90:69-71`); we
112    /// default to `nsym=3` (FT8 path is calibrated to it). FT4
113    /// overrides to `4` for an extra ~3 dB SNR boost on stable
114    /// signals — closes the recall gap on real-WAV recordings. FST4
115    /// overrides to `8`, matching WSJT-X's own 1/2/4/8-symbol
116    /// correlation ladder in `get_fst4_bitmetrics.f90` (issue #146 —
117    /// FST4 had silently been using the uncalibrated FT8 default of
118    /// 3, never wired to its own bit-metric depth).
119    /// Any value ≥ 1 works — `nt = NTONES^nsym` combination
120    /// hypotheses are computed generically, no per-nsym lookup table
121    /// — but cost grows exponentially with `nsym`, so keep it at the
122    /// WSJT-X-matched depth for the protocol rather than raising it
123    /// further.
124    const LLR_NSYM_MAX: u32 = 3;
125
126    /// Optional extra coherent-integration depth strictly between the
127    /// `nsym=2` and `nsym=LLR_NSYM_MAX` variants, populating [`LlrSet`]'s
128    /// `llre` slot. `None` for every protocol whose ladder has no gap
129    /// (FT8: {1,2,3}; FT4: {1,2,4}) — `llre` stays empty and costs
130    /// nothing. FST4 overrides to `Some(4)`: WSJT-X's
131    /// `get_fst4_bitmetrics.f90` tries all four of nsym ∈ {1,2,4,8}
132    /// (`fst4_decode.f90:429-433`), but `LLR_NSYM_MAX=8` alone only
133    /// gives `compute_llr_generic` two depths + the deepest ({1,2,8}) —
134    /// nsym=4 would otherwise never run. Diagnostic measurement (issue
135    /// #146, `fst4_diag_nsym4_ladder` in `tests/fst4_sweep.rs`) found a
136    /// real but modest effect: a standalone nsym=4 pass recovered 4/43
137    /// (~9%) of near-threshold FST4-30 AWGN failures and 2/38 (~5%) of
138    /// FST4-300's, over and above the existing {1,2,8,d} ladder.
139    ///
140    /// [`LlrSet`]: crate::core::llr::LlrSet
141    const LLR_NSYM_MID: Option<u32> = None;
142
143    /// Optional 77-bit pre-LDPC scrambler. WSJT-X applies an
144    /// FT4-specific scrambler in `genft4.f90:64`
145    /// (`msgbits=mod(msgbits+rvec,2)`) before computing CRC-14 and
146    /// running LDPC encode; the receiver removes it after LDPC
147    /// decode + CRC verify (`ft4_decode.f90:430`). Without this our
148    /// decoder converges on a valid codeword whose unscrambled
149    /// payload is the WSJT-X-transmitted message — but emerges as
150    /// nonsense because we never undo the XOR.
151    ///
152    /// Default `None` (FT8 / others don't scramble); FT4 and FST4
153    /// both override to the same 77-element `rvec` (WSJT-X
154    /// `genfst4.f90:29-31` uses the identical array to
155    /// `genft4.f90`'s). Length must be 77 when set.
156    const INFO_SCRAMBLE_RVEC: Option<&'static [u8]> = None;
157
158    /// Window function applied per `NSPS`-sample chunk in
159    /// [`crate::core::sync::compute_spectra`] before the NFFT1 FFT.
160    /// Default = [`SpectrumWindow::Rectangular`] (preserves FT8's
161    /// existing synth-roundtrip behaviour); FT4 overrides to
162    /// [`SpectrumWindow::Nuttall4`] to match WSJT-X
163    /// `getcandidates4.f90:22` and suppress sidelobe leakage that
164    /// otherwise inflates the per-bin baseline near strong signals.
165    const SPECTRUM_WINDOW: SpectrumWindow = SpectrumWindow::Rectangular;
166}
167
168/// Window function applied to each NSPS-sample chunk before the
169/// coarse-sync FFT. See [`ModulationParams::SPECTRUM_WINDOW`].
170#[derive(Copy, Clone, Debug, Eq, PartialEq)]
171pub enum SpectrumWindow {
172    /// No window (multiplied by 1.0). Default. Suitable for synth
173    /// roundtrip and for protocols whose sync metric tolerates
174    /// rectangular-window sidelobes.
175    Rectangular,
176    /// Nuttall-4 window (a0=0.3635819, a1=0.4891775, a2=0.1365995,
177    /// a3=0.0106411). Matches WSJT-X `getcandidates4.f90`/
178    /// `getcandidates.f90` (FT4 / FT8 respectively in WSJT-X, though
179    /// in our port only FT4 currently opts in — FT8's existing path
180    /// is calibrated to rectangular).
181    Nuttall4,
182}
183
184/// One Costas / pilot block: a contiguous run of tones starting at a specific
185/// symbol index within the frame.
186///
187/// FT8 has three identical blocks (positions 0/36/72, same Costas-7 pattern);
188/// FT4 has four *different* blocks (positions 0/33/66/99, each a permutation
189/// of `[0,1,2,3]`). The trait is shaped to accommodate both.
190#[derive(Copy, Clone, Debug)]
191pub struct SyncBlock {
192    /// Symbol index (0-based) where this block starts.
193    pub start_symbol: u32,
194    /// Tone sequence for this block. `pattern.len()` is the block length.
195    pub pattern: &'static [u8],
196}
197
198/// How sync information is carried in the channel symbol stream.
199///
200/// * `Block` — dedicated contiguous sync blocks (Costas arrays) occupy
201///   specific symbol positions, with data symbols filling the rest. Used by
202///   FT8, FT4, FST4.
203/// * `Interleaved` — every channel symbol carries one sync bit (fixed
204///   position within the tone index) AND payload bits. The sync bits
205///   concatenated across the frame form a known pseudorandom vector.
206///   Used by WSPR: `tone = 2·data_bit + sync_bit`, so LSB of each
207///   4-FSK symbol reproduces the 162-bit `npr3` sync vector.
208#[derive(Copy, Clone, Debug)]
209pub enum SyncMode {
210    Block(&'static [SyncBlock]),
211    Interleaved {
212        /// Position of the sync bit within the tone index, LSB-first.
213        /// WSPR = 0 (LSB).
214        sync_bit_pos: u8,
215        /// Sync vector, one bit per frame symbol. Length == `N_SYMBOLS`.
216        vector: &'static [u8],
217    },
218}
219
220impl SyncMode {
221    /// Block list for `Block` mode; empty slice for `Interleaved`.
222    /// Sync/LLR/TX helpers that only handle block-structured sync can iterate
223    /// this unconditionally — they will no-op on WSPR-style protocols, which
224    /// then need their own interleaved-sync pipeline entry point.
225    pub const fn blocks(&self) -> &'static [SyncBlock] {
226        match self {
227            SyncMode::Block(b) => b,
228            SyncMode::Interleaved { .. } => &[],
229        }
230    }
231}
232
233/// Frame structure: data / sync symbol counts, the ordered list of sync
234/// blocks, and the TX-side nominal start offset.
235pub trait FrameLayout: Copy + Default + 'static {
236    /// Data symbols carrying FEC-coded payload.
237    const N_DATA: u32;
238
239    /// Sync symbols (sum of `pattern.len()` across `SYNC_BLOCKS`).
240    const N_SYNC: u32;
241
242    /// Total channel symbols per frame (= N_DATA + N_SYNC). Excludes any
243    /// GFSK ramp-up / ramp-down symbols that are a shaping artifact.
244    const N_SYMBOLS: u32;
245
246    /// Extra symbol slots on each side of the frame reserved for amplitude
247    /// ramp (FT4 has 1 each side = 2; FT8 has 0 — ramp absorbed into the
248    /// first/last data symbol envelope). Applied at the transmitter.
249    const N_RAMP: u32;
250
251    /// Sync-symbol layout. Most WSJT protocols use `SyncMode::Block` with
252    /// dedicated Costas blocks (FT8/FT4/FST4); WSPR uses `SyncMode::Interleaved`
253    /// with a per-symbol sync bit. Callers that only support block sync should
254    /// read `SYNC_MODE.blocks()` and treat an empty slice as "unsupported".
255    const SYNC_MODE: SyncMode;
256
257    /// Nominal TX/RX slot length in seconds (informational — used by
258    /// schedulers and UI, not by the DSP pipeline). FT8 = 15 s, FT4 = 7.5 s.
259    const T_SLOT_S: f32;
260
261    /// Time (seconds) from the start of the slot-audio buffer to the start
262    /// of the first frame symbol — the "dt = 0" reference point used by
263    /// sync, signal subtraction, and DT reporting. FT8 = 0.5, FT4 = 0.5.
264    const TX_START_OFFSET_S: f32;
265
266    /// Optional bit interleaver: permutation table such that
267    /// `cw[CODEWORD_INTERLEAVE[j]]` is the codeword bit transmitted at
268    /// **channel-bit position** `j`. Length must equal
269    /// `<Self as Protocol>::Fec::N` when `Some`.
270    ///
271    /// `None` (default) means the codeword bits flow into the channel in
272    /// natural order — what FT8 / FT4 / FST4 / WSPR / JT9 / JT65 / Q65
273    /// all do, since their existing FECs and operating channels make
274    /// burst-error tolerance a non-issue (or it's handled inside the FEC,
275    /// as Q65's QRA does symbol-level dispersion).
276    ///
277    /// `Some(table)` is for codecs targeting **time-selective fading**
278    /// channels where a deep fade null can wipe out consecutive channel
279    /// bits. The interleaver spreads consecutive codeword bits across the
280    /// frame so the same fade null hits scattered codeword bits, which
281    /// soft-decision LDPC handles well. The table is a permutation of
282    /// `0..codeword_bits`; a polynomial form `INTERLEAVE[j] = (s * j)
283    /// mod n` with `gcd(s, n) = 1` gives uniform stride spacing.
284    ///
285    /// Both [`crate::core::tx::codeword_to_itone`] and the pipeline's
286    /// LLR-deinterleave step honour this constant; protocols that
287    /// override get TX/RX symmetry for free.
288    const CODEWORD_INTERLEAVE: Option<&'static [u16]> = None;
289}
290
291// ──────────────────────────────────────────────────────────────────────────
292// FEC
293// ──────────────────────────────────────────────────────────────────────────
294
295/// LDPC belief-propagation check-node update kernel.
296///
297/// `SumProduct` is the WSJT-X-equivalent log-domain sum-product update
298/// (the `2·atanh(∏ tanh(L/2))` formula). `NormalizedMinSum` and
299/// `OffsetMinSum` are min-sum approximations that skip the
300/// transcendental functions entirely — significantly faster on
301/// FPU-poor embedded targets at a small (typically <0.2 dB on Q65 /
302/// FT8 / FT4 thresholds with α=0.75 or β=0.5) SNR cost.
303///
304/// Both min-sum variants use the standard min1/min2 trick (track the
305/// two smallest |L| at each check node) plus XOR-accumulated signs,
306/// so the per-iteration cost is roughly O(check_degree) instead of
307/// the sum-product's O(check_degree²) for the per-edge `tanh`-cache
308/// lookups.
309///
310/// Use `SumProduct` on host targets (default), `NormalizedMinSum` or
311/// `OffsetMinSum` on `no_std` / FPU-limited builds.
312#[derive(Copy, Clone, Debug, Default)]
313pub enum BpKind {
314    /// WSJT-X-equivalent log-domain sum-product. Default — best
315    /// accuracy, reference output.
316    #[default]
317    SumProduct,
318    /// Normalised min-sum: `L_c→v ≈ α · sign(∏) · min|L|`. Typical
319    /// `alpha ≈ 0.75`. Trades ~0.05–0.15 dB threshold for ~3-5×
320    /// faster check-node update on f32, more on fixed-point.
321    NormalizedMinSum {
322        /// Magnitude scale factor, typically `0.7..=0.9`.
323        alpha: f32,
324    },
325    /// Offset min-sum: `L_c→v ≈ sign(∏) · max(min|L| − β, 0)`.
326    /// Typical `beta ≈ 0.5`. Performs slightly differently from NMS
327    /// near low SNR; included for sweep comparison and parity with
328    /// the LDPC literature.
329    OffsetMinSum {
330        /// Magnitude offset, typically `0.0..=1.0`.
331        beta: f32,
332    },
333}
334
335/// Options controlling FEC decoding depth / fall-backs.
336///
337/// This is deliberately a plain data struct rather than a trait — it describes
338/// *how* to decode, not *what* code to use. Codecs ignore fields that don't
339/// apply (e.g. convolutional decoders ignore `osd_depth`).
340#[derive(Copy, Clone, Debug)]
341pub struct FecOpts<'a> {
342    /// Maximum belief-propagation iterations (LDPC).
343    pub bp_max_iter: u32,
344    /// Ordered-statistics-decoding search depth (0 disables OSD fallback).
345    pub osd_depth: u32,
346    /// Optional a-priori hint: bits whose LLR should be clamped to a strong
347    /// known value before decoding. `Some((mask, values))` where `mask[i] == 1`
348    /// means `values[i]` is locked to `values[i]`.
349    ///
350    /// Lifetime is per-call: the caller allocates the AP vectors for the
351    /// duration of this decode — typical usage builds a `Vec<u8>` from an
352    /// `ApHint` and borrows into `FecOpts` for a single `decode_soft` call.
353    pub ap_mask: Option<(&'a [u8], &'a [u8])>,
354    /// Optional integrity verifier called when the FEC reaches a
355    /// parity-converged candidate. Returning `false` rejects the
356    /// candidate and BP keeps iterating; returning `true` accepts.
357    /// `None` accepts unconditionally — appropriate for FEC users
358    /// whose message codec carries no inline integrity field.
359    ///
360    /// Typical use: pipeline code threads `<P::Msg as
361    /// MessageCodec>::verify_info` here so that, e.g., FT8/FT4/FST4
362    /// reject parity-only candidates whose CRC-14 doesn't pass.
363    pub verify_info: Option<fn(&[u8]) -> bool>,
364    /// LDPC BP check-node update kernel. Defaults to `SumProduct`
365    /// (WSJT-X-equivalent). Embedded callers select
366    /// `NormalizedMinSum { alpha: 0.75 }` to trade ~0.1 dB threshold
367    /// for substantially faster decode on f32 / fixed-point math.
368    pub bp_kind: BpKind,
369    /// Override for the Fano sequential decoder's per-information-bit
370    /// cycle budget. `None` = codec-supplied default
371    /// (`ConvFano232::DEFAULT_MAX_CYCLES = 10_000`, matching WSJT-X's
372    /// standard `limit=10000`). Used by JT9's deep-search retry path
373    /// (`lib/jt9_decode.f90:84-101`: 10k → 30k → 100k).
374    pub max_cycles_per_bit: Option<u64>,
375}
376
377impl<'a> Default for FecOpts<'a> {
378    fn default() -> Self {
379        Self {
380            bp_max_iter: 30,
381            osd_depth: 0,
382            ap_mask: None,
383            verify_info: None,
384            bp_kind: BpKind::SumProduct,
385            max_cycles_per_bit: None,
386        }
387    }
388}
389
390/// Result of a successful FEC decode.
391#[derive(Clone, Debug)]
392pub struct FecResult {
393    /// Hard-decision information bits (length = `FecCodec::K`).
394    pub info: Vec<u8>,
395    /// Number of hard-decision errors corrected (for quality metric).
396    pub hard_errors: u32,
397    /// Iterations consumed (0 if N/A).
398    pub iterations: u32,
399}
400
401/// Forward-error-correction codec: maps `K` information bits ↔ `N` codeword
402/// bits.
403///
404/// Implementors MUST be `Default`-constructible so generic pipeline code can
405/// obtain an instance via `P::Fec::default()` without plumbing state.
406/// Stateless codecs (matrices in `const` / `static`) are the common case.
407///
408/// # Symbol granularity
409///
410/// The trait surface speaks in **bits**: `&[u8]` info / codeword, `&[f32]`
411/// bit-LLRs, `K` and `N` counted in bits. Non-binary codes (Q65's QRA over
412/// GF(2⁶), JT65's RS over GF(2⁶)) implement this surface by packing /
413/// unpacking bits ↔ symbols inside their own `encode`, and by using a
414/// private symbol-level decode path that lives outside `decode_soft`. In
415/// particular [`crate::q65::Q65Fec::decode_soft`] returns `None` by design —
416/// the real Q65 decode runs over GF(64) probability vectors via
417/// [`crate::fec::qra::Q65Codec`] and is invoked from
418/// [`crate::q65::rx::decode_at_for`], not through this trait.
419///
420/// Counting `K` / `N` in bits keeps the cross-protocol invariant
421/// `FecCodec::N ≤ N_DATA × BITS_PER_SYMBOL` (pinned in
422/// `tests/protocol_invariants.rs::assert_codec_consistency`) meaningful for
423/// both binary (LDPC, conv) and non-binary (RS, QRA) codes.
424pub trait FecCodec: Default + 'static {
425    /// Codeword length, in **bits** (regardless of the underlying symbol
426    /// alphabet — see "Symbol granularity" above).
427    const N: usize;
428
429    /// Information-bit length.
430    const K: usize;
431
432    /// Systematic encode: `info.len() == K`, `codeword.len() == N`. The first
433    /// `K` bits of `codeword` must equal `info` (systematic form).
434    /// Non-binary codes pack bits into their native symbols internally.
435    fn encode(&self, info: &[u8], codeword: &mut [u8]);
436
437    /// Soft-decision decode from log-likelihood ratios.
438    ///
439    /// `llr.len() == N`. On success returns the `K` information bits plus
440    /// decoder statistics. On failure returns `None`.
441    ///
442    /// Non-binary codes whose natural decode operates on symbol-level
443    /// probability vectors (Q65) MAY return `None` unconditionally and
444    /// expose their real decode through a protocol-specific entry point.
445    fn decode_soft(&self, llr: &[f32], opts: &FecOpts) -> Option<FecResult>;
446}
447
448// ──────────────────────────────────────────────────────────────────────────
449// Message codec
450// ──────────────────────────────────────────────────────────────────────────
451
452/// Human-facing message payload codec (callsigns, grids, reports, free text).
453///
454/// Operates on the FEC-decoded information bits (`PAYLOAD_BITS` wide, NOT
455/// including any CRC protecting them — callers handle the CRC layer).
456///
457/// Unlike `FecCodec`, this trait is an acceptable place for `dyn` when the
458/// caller juggles heterogeneous protocols at runtime (FFI, CLI dump tools):
459/// message unpacking is a cold path relative to DSP/FEC inner loops.
460pub trait MessageCodec: Default + 'static {
461    /// Decoded high-level representation returned by `unpack`.
462    type Unpacked;
463
464    /// Number of information bits consumed by `pack` / produced by `unpack`.
465    const PAYLOAD_BITS: u32;
466
467    /// CRC width guarding the payload during transmission (0 if the FEC itself
468    /// provides all error detection, as with JT65 Reed–Solomon).
469    const CRC_BITS: u32;
470
471    /// Encode high-level fields to a bit vector of length `PAYLOAD_BITS`.
472    /// Returns `None` on encoding failure (invalid callsign format, overflow…).
473    fn pack(&self, fields: &MessageFields) -> Option<Vec<u8>>;
474
475    /// Decode a `PAYLOAD_BITS`-long bit vector to the protocol-specific
476    /// unpacked representation. `ctx` carries side information such as the
477    /// callsign-hash table.
478    fn unpack(&self, payload: &[u8], ctx: &DecodeContext) -> Option<Self::Unpacked>;
479
480    /// Verify the integrity of post-FEC info bits. The FEC layer
481    /// invokes this when a candidate codeword satisfies parity:
482    /// returning `true` accepts the codeword; returning `false`
483    /// causes the FEC to keep iterating.
484    ///
485    /// Default: accept unconditionally — appropriate for codecs whose
486    /// message format carries no inline integrity field (the FEC layer
487    /// has already enforced parity convergence by the time this is
488    /// called).
489    ///
490    /// CRC-bearing codecs override this. For example,
491    /// [`crate::msg::Wsjt77Message`] verifies the CRC-14 stored in
492    /// info bits 77..91. The associated-function (no `&self`) shape
493    /// keeps the verifier compatible with the function-pointer field
494    /// on [`FecOpts::verify_info`].
495    fn verify_info(info: &[u8]) -> bool {
496        let _ = info;
497        true
498    }
499}
500
501/// Generic input to `MessageCodec::pack` — protocol-specific codecs accept
502/// the subset of fields they understand and return `None` for unsupported
503/// combinations.
504#[derive(Clone, Debug, Default)]
505pub struct MessageFields {
506    pub call1: Option<String>,
507    pub call2: Option<String>,
508    pub grid: Option<String>,
509    pub report: Option<i32>,
510    pub free_text: Option<String>,
511}
512
513/// Side information passed to `MessageCodec::unpack`.
514///
515/// `callsign_hash_table` is an opaque pointer the protocol crate
516/// downcasts to its own table type — generic code does not need to know the
517/// shape. This keeps `mfsk-msg` optional at the `mfsk-core` level.
518#[derive(Clone, Debug, Default)]
519pub struct DecodeContext {
520    /// Optional hashed-callsign lookup owned by the caller. Concrete layout is
521    /// protocol-defined; interpret via `Any::downcast_ref` inside the codec.
522    pub callsign_hash_table: Option<alloc::sync::Arc<dyn core::any::Any + Send + Sync>>,
523}
524
525// ──────────────────────────────────────────────────────────────────────────
526// Protocol facade
527// ──────────────────────────────────────────────────────────────────────────
528
529/// The full protocol description: ties `ModulationParams`, `FrameLayout`, a
530/// FEC codec and a message codec together under one trait for ergonomic
531/// `<P: Protocol>` bounds.
532pub trait Protocol: ModulationParams + FrameLayout + 'static {
533    /// FEC codec carrying `N_DATA * BITS_PER_SYMBOL` coded bits.
534    type Fec: FecCodec;
535
536    /// Message codec consuming the FEC-decoded information bits.
537    type Msg: MessageCodec;
538
539    /// Runtime tag used at FFI / WASM boundaries.
540    const ID: ProtocolId;
541}