Skip to main content

mfsk_core/fec/rs/
mod.rs

1//! Reed-Solomon RS(63, 12) over GF(2^6) — the codec used by JT65.
2//!
3//! Ported from Phil Karn's classic Reed-Solomon library (as
4//! wrapped in WSJT-X `wrapkarn.c` + `init_rs.c` / `encode_rs.c` /
5//! `decode_rs.c`). Parameters match the `init_rs_int(6, 0x43, 3, 1,
6//! 51, 0)` call used for JT65:
7//!
8//! - symbol size: 6 bits (field: GF(2^6), 63 nonzero elements)
9//! - field generator polynomial: `x^6 + x + 1` (0x43)
10//! - first consecutive root of generator polynomial: `α^3`
11//! - primitive element for root stride: 1
12//! - number of parity symbols (generator poly roots): 51
13//! - code: (63, 12), corrects up to `⌊(63 − 12) / 2⌋ = 25` symbol errors
14//!
15//! ## Symbol ordering
16//!
17//! This module provides two entry-point pairs:
18//!
19//! - [`Rs63_12::encode_native`] / [`Rs63_12::decode_native`] — the
20//!   canonical Karn layout: codeword = `[data12 || parity51]` with
21//!   data / parity each in their original order. General-purpose.
22//! - [`Rs63_12::encode_jt65`] / [`Rs63_12::decode_jt65`] — the
23//!   JT65-specific byte ordering used by WSJT-X (`wrapkarn.c`):
24//!   `sent[0..51]` is the parity block, reversed; `sent[51..63]`
25//!   is the data block, reversed. Use these from `jt65-core`.
26//!
27//! This type implements [`super::FecCodec`] only minimally: the
28//! `encode` path packs a bit-level 72-bit info into 378 codeword bits
29//! (63 × 6 bits), and `decode_soft` always returns `None` because
30//! Reed-Solomon needs hard symbols rather than bit LLRs. The real
31//! RS entry points are [`Rs63_12::encode_native`] /
32//! [`Rs63_12::decode_native`] (and the JT65-specific reversed-layout
33//! variants). The FecCodec impl exists so `Rs63_12` can be named as
34//! a `Protocol::Fec` associated type; callers that want to actually
35//! decode JT65 should go through `jt65-core`'s decode helpers rather
36//! than the generic `decode_frame` pipeline.
37
38/// Sentinel used to mark "log of zero" in the index_of table. Matches
39/// Karn's `A0 = NN` convention: any log value equal to `A0` represents
40/// the field zero.
41const A0: u8 = Rs63_12::NN as u8;
42
43/// Encoder / decoder for Reed-Solomon (63, 12) over GF(2^6).
44#[derive(Clone, Debug)]
45pub struct Rs63_12 {
46    /// `alpha_to[i] = α^i` for `0 ≤ i < 63`, 0 otherwise.
47    alpha_to: [u8; 64],
48    /// `index_of[x] = log_α(x)` for x ≠ 0, [`A0`] for x = 0.
49    index_of: [u8; 64],
50    /// Generator polynomial in index form.
51    genpoly: [u8; Rs63_12::NROOTS + 1],
52}
53
54impl Default for Rs63_12 {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl Rs63_12 {
61    /// Code length in symbols (2^6 − 1 = 63).
62    pub const NN: usize = 63;
63    /// Symbol size in bits.
64    pub const MM: u32 = 6;
65    /// First consecutive root of generator polynomial: `α^FCR = α^3`.
66    pub const FCR: u32 = 3;
67    /// Primitive element for generator roots (α^(FCR·PRIM + i·PRIM)).
68    pub const PRIM: u32 = 1;
69    /// Number of parity symbols.
70    pub const NROOTS: usize = 51;
71    /// Info-symbol count (NN − NROOTS).
72    pub const K_SYMBOLS: usize = Self::NN - Self::NROOTS;
73    /// Full codeword length in symbols.
74    pub const N_SYMBOLS: usize = Self::NN;
75    /// Field generator polynomial (x^6 + x + 1).
76    const GFPOLY: u32 = 0x43;
77
78    /// Build the codec: pre-compute alpha_to, index_of, and genpoly.
79    pub fn new() -> Self {
80        let mut alpha_to = [0u8; 64];
81        let mut index_of = [0u8; 64];
82
83        // Galois field tables.
84        index_of[0] = A0; // log(0) = −∞
85        alpha_to[A0 as usize] = 0; // α^{−∞} = 0
86        let mut sr: u32 = 1;
87        for i in 0..Self::NN {
88            index_of[sr as usize] = i as u8;
89            alpha_to[i] = sr as u8;
90            sr <<= 1;
91            if sr & (1 << Self::MM) != 0 {
92                sr ^= Self::GFPOLY;
93            }
94            sr &= Self::NN as u32;
95        }
96        debug_assert_eq!(sr, 1, "gfpoly must be primitive");
97
98        // Generator polynomial g(x) = Π (x − α^(FCR + i)·PRIM) for i=0..NROOTS.
99        let mut genpoly = [0u8; Self::NROOTS + 1];
100        genpoly[0] = 1;
101        for i in 0..Self::NROOTS {
102            let root = Self::FCR * Self::PRIM + i as u32 * Self::PRIM;
103            genpoly[i + 1] = 1;
104            // Multiply by (x + α^root). In the poly loop below, j descends.
105            for j in (1..=i).rev() {
106                if genpoly[j] != 0 {
107                    let idx = Self::modnn(index_of[genpoly[j] as usize] as u32 + root);
108                    genpoly[j] = genpoly[j - 1] ^ alpha_to[idx as usize];
109                } else {
110                    genpoly[j] = genpoly[j - 1];
111                }
112            }
113            // genpoly[0] is always nonzero at this stage.
114            let idx = Self::modnn(index_of[genpoly[0] as usize] as u32 + root);
115            genpoly[0] = alpha_to[idx as usize];
116        }
117        // Convert genpoly to index form for faster encoding.
118        for g in genpoly.iter_mut() {
119            *g = index_of[*g as usize];
120        }
121
122        Self {
123            alpha_to,
124            index_of,
125            genpoly,
126        }
127    }
128
129    /// `x mod NN` via a subtract-in-a-loop that terminates in at most
130    /// one iteration for inputs `< 2·NN`.
131    #[inline]
132    fn modnn(mut x: u32) -> u32 {
133        while x >= Self::NN as u32 {
134            x -= Self::NN as u32;
135            x = (x >> Self::MM) + (x & Self::NN as u32);
136        }
137        x
138    }
139
140    /// Systematic encode: 12 info symbols → 51 parity symbols. Layout
141    /// in the native Karn order is `[info[0..12] || parity[0..51]]`.
142    pub fn encode_native(&self, info: &[u8; Self::K_SYMBOLS]) -> [u8; Self::N_SYMBOLS] {
143        let mut bb = [0u8; Self::NROOTS];
144        for i in 0..Self::K_SYMBOLS {
145            let feedback = self.index_of[(info[i] ^ bb[0]) as usize];
146            if feedback != A0 {
147                for j in 1..Self::NROOTS {
148                    bb[j] ^= self.alpha_to[Self::modnn(
149                        feedback as u32 + self.genpoly[Self::NROOTS - j] as u32,
150                    ) as usize];
151                }
152            }
153            // Shift bb left by one.
154            for j in 0..Self::NROOTS - 1 {
155                bb[j] = bb[j + 1];
156            }
157            bb[Self::NROOTS - 1] = if feedback != A0 {
158                self.alpha_to[Self::modnn(feedback as u32 + self.genpoly[0] as u32) as usize]
159            } else {
160                0
161            };
162        }
163        let mut out = [0u8; Self::N_SYMBOLS];
164        out[..Self::K_SYMBOLS].copy_from_slice(info);
165        out[Self::K_SYMBOLS..].copy_from_slice(&bb);
166        out
167    }
168
169    /// Decode a received codeword in the native Karn layout with no
170    /// erasures. Returns `Some((corrected, err_count))` on success,
171    /// `None` when uncorrectable.
172    pub fn decode_native(
173        &self,
174        data: &[u8; Self::N_SYMBOLS],
175    ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
176        self.decode_native_erasures(data, &[])
177    }
178
179    /// Like [`Self::decode_native`] but also accepts a list of **erasure
180    /// positions** (symbol indices 0..=62 in the native codeword
181    /// layout that the caller has flagged as unreliable). Each
182    /// erasure lets RS correct one more symbol than the
183    /// ⌊(NROOTS)/2⌋ = 25 hard-error bound: the combined limit is
184    /// `2·errors + erasures ≤ NROOTS = 51`. Passing erasures is
185    /// particularly helpful at low SNR where the demodulator has
186    /// per-symbol confidence information.
187    ///
188    /// Ported from Phil Karn's `decode_rs.c` with the `no_eras > 0`
189    /// branch active. Duplicate or out-of-range entries in
190    /// `eras_pos` will produce `None` from the Chien search.
191    pub fn decode_native_erasures(
192        &self,
193        data: &[u8; Self::N_SYMBOLS],
194        eras_pos: &[u32],
195    ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
196        let no_eras = eras_pos.len();
197        if no_eras > Self::NROOTS {
198            return None; // more erasures than parity — uncorrectable a priori
199        }
200        let mut recd = *data;
201
202        // 1. Syndromes — evaluate recd(x) at α^(FCR + i·PRIM) for i=0..NROOTS.
203        let mut s = [0u8; Self::NROOTS];
204        for i in 0..Self::NROOTS {
205            s[i] = recd[0];
206        }
207        for j in 1..Self::NN {
208            for i in 0..Self::NROOTS {
209                if s[i] == 0 {
210                    s[i] = recd[j];
211                } else {
212                    let sidx =
213                        self.index_of[s[i] as usize] as u32 + (Self::FCR + i as u32) * Self::PRIM;
214                    s[i] = recd[j] ^ self.alpha_to[Self::modnn(sidx) as usize];
215                }
216            }
217        }
218
219        // Convert syndromes to index form + detect non-zero syndrome.
220        let mut syn_error: u8 = 0;
221        for i in 0..Self::NROOTS {
222            syn_error |= s[i];
223            s[i] = self.index_of[s[i] as usize];
224        }
225        if syn_error == 0 {
226            let mut info = [0u8; Self::K_SYMBOLS];
227            info.copy_from_slice(&recd[..Self::K_SYMBOLS]);
228            return Some((info, 0));
229        }
230
231        // 2. Berlekamp-Massey. When erasures are supplied, initialise
232        //    λ(x) to the erasure locator polynomial
233        //        λ(x) = Π (1 + β_j·x), β_j = α^(PRIM·(NN−1−pos_j))
234        //    and start BM at r = el = no_eras.
235        let mut lambda = [0u8; Self::NROOTS + 1];
236        lambda[0] = 1;
237        let mut b = [0u8; Self::NROOTS + 1];
238        let mut t = [0u8; Self::NROOTS + 1];
239
240        if no_eras > 0 {
241            for &pos in eras_pos {
242                if pos as usize >= Self::NN {
243                    return None;
244                }
245            }
246            let e0 = Self::modnn(Self::PRIM * (Self::NN as u32 - 1 - eras_pos[0]));
247            lambda[1] = self.alpha_to[e0 as usize];
248            for i in 1..no_eras {
249                let u = Self::modnn(Self::PRIM * (Self::NN as u32 - 1 - eras_pos[i]));
250                for j in (1..=i + 1).rev() {
251                    let tmp = self.index_of[lambda[j - 1] as usize];
252                    if tmp != A0 {
253                        lambda[j] ^= self.alpha_to[Self::modnn(u + tmp as u32) as usize];
254                    }
255                }
256            }
257        }
258
259        for i in 0..Self::NROOTS + 1 {
260            b[i] = self.index_of[lambda[i] as usize];
261        }
262
263        let mut el: i32 = no_eras as i32;
264        for r in (no_eras + 1)..=Self::NROOTS {
265            // Discrepancy at step r (in poly form).
266            let mut discr_r: u8 = 0;
267            for i in 0..r {
268                if lambda[i] != 0 && s[r - i - 1] != A0 {
269                    let idx = self.index_of[lambda[i] as usize] as u32 + s[r - i - 1] as u32;
270                    discr_r ^= self.alpha_to[Self::modnn(idx) as usize];
271                }
272            }
273            let discr_idx = self.index_of[discr_r as usize];
274            if discr_idx == A0 {
275                // B(x) ← x·B(x)
276                for j in (1..=Self::NROOTS).rev() {
277                    b[j] = b[j - 1];
278                }
279                b[0] = A0;
280            } else {
281                // T(x) ← λ(x) − discr_r·x·B(x)
282                t[0] = lambda[0];
283                for i in 0..Self::NROOTS {
284                    if b[i] != A0 {
285                        t[i + 1] = lambda[i + 1]
286                            ^ self.alpha_to[Self::modnn(discr_idx as u32 + b[i] as u32) as usize];
287                    } else {
288                        t[i + 1] = lambda[i + 1];
289                    }
290                }
291                // With erasures the BM invariant becomes
292                // 2·el ≤ r + no_eras − 1.
293                if 2 * el < r as i32 + no_eras as i32 {
294                    el = r as i32 + no_eras as i32 - el;
295                    for i in 0..=Self::NROOTS {
296                        b[i] = if lambda[i] == 0 {
297                            A0
298                        } else {
299                            Self::modnn(
300                                self.index_of[lambda[i] as usize] as u32 + Self::NN as u32
301                                    - discr_idx as u32,
302                            ) as u8
303                        };
304                    }
305                } else {
306                    for j in (1..=Self::NROOTS).rev() {
307                        b[j] = b[j - 1];
308                    }
309                    b[0] = A0;
310                }
311                lambda.copy_from_slice(&t);
312            }
313        }
314
315        // deg(λ) and conversion to index form.
316        let mut deg_lambda = 0usize;
317        let mut lambda_idx = [0u8; Self::NROOTS + 1];
318        for i in 0..Self::NROOTS + 1 {
319            lambda_idx[i] = self.index_of[lambda[i] as usize];
320            if lambda_idx[i] != A0 {
321                deg_lambda = i;
322            }
323        }
324
325        // 3. Chien search for roots of λ(x).
326        let iprim: u32 = Self::find_iprim();
327        let mut reg = [0u8; Self::NROOTS + 1];
328        reg[1..].copy_from_slice(&lambda_idx[1..]);
329        let mut root = [0u32; Self::NROOTS];
330        let mut loc = [0u32; Self::NROOTS];
331        let mut count = 0usize;
332        let mut k_idx: u32 = Self::modnn(iprim + Self::NN as u32 - 1);
333        for i in 1..=Self::NN as u32 {
334            let mut q: u8 = 1;
335            for j in (1..=deg_lambda).rev() {
336                if reg[j] != A0 {
337                    reg[j] = Self::modnn(reg[j] as u32 + j as u32) as u8;
338                    q ^= self.alpha_to[reg[j] as usize];
339                }
340            }
341            if q != 0 {
342                k_idx = Self::modnn(k_idx + iprim);
343                continue;
344            }
345            root[count] = i;
346            loc[count] = k_idx;
347            count += 1;
348            if count == deg_lambda {
349                break;
350            }
351            k_idx = Self::modnn(k_idx + iprim);
352        }
353        if deg_lambda != count {
354            return None; // uncorrectable
355        }
356
357        // 4. Compute ω(x) = s(x)·λ(x) mod x^NROOTS.
358        let deg_omega = deg_lambda.saturating_sub(1);
359        let mut omega = [0u8; Self::NROOTS + 1];
360        for i in 0..=deg_omega {
361            let mut tmp: u8 = 0;
362            for j in 0..=i {
363                if s[i - j] != A0 && lambda_idx[j] != A0 {
364                    tmp ^=
365                        self.alpha_to[Self::modnn(s[i - j] as u32 + lambda_idx[j] as u32) as usize];
366                }
367            }
368            omega[i] = self.index_of[tmp as usize];
369        }
370
371        // 5. Forney's formula for error values, applied in place.
372        for j in (0..count).rev() {
373            // num1 = Σ ω[i] · root[j]^i
374            let mut num1: u8 = 0;
375            for i in (0..=deg_omega).rev() {
376                if omega[i] != A0 {
377                    num1 ^=
378                        self.alpha_to[Self::modnn(omega[i] as u32 + (i as u32) * root[j]) as usize];
379                }
380            }
381            let num2 =
382                self.alpha_to[Self::modnn(root[j] * (Self::FCR - 1) + Self::NN as u32) as usize];
383
384            // den = λ_prime(X^{-1}) — formal derivative, odd-indexed terms.
385            let mut den: u8 = 0;
386            let end = deg_lambda.min(Self::NROOTS - 1) & !1;
387            let mut i = end as i32;
388            while i >= 0 {
389                if lambda_idx[i as usize + 1] != A0 {
390                    den ^= self.alpha_to[Self::modnn(
391                        lambda_idx[i as usize + 1] as u32 + (i as u32) * root[j],
392                    ) as usize];
393                }
394                i -= 2;
395            }
396            if den == 0 {
397                return None;
398            }
399            if num1 != 0 {
400                let err = self.alpha_to[Self::modnn(
401                    self.index_of[num1 as usize] as u32
402                        + self.index_of[num2 as usize] as u32
403                        + Self::NN as u32
404                        - self.index_of[den as usize] as u32,
405                ) as usize];
406                let pos = loc[j] as usize;
407                if pos < Self::NN {
408                    recd[pos] ^= err;
409                }
410            }
411        }
412
413        // Extract the (now corrected) info symbols from the systematic
414        // prefix and return the error count.
415        let mut info = [0u8; Self::K_SYMBOLS];
416        info.copy_from_slice(&recd[..Self::K_SYMBOLS]);
417        Some((info, count as u32))
418    }
419
420    /// Primitive-root helper. With PRIM = 1, the prim-th root of 1 is
421    /// just 1 itself, so iprim = 1. Computed generically for clarity.
422    fn find_iprim() -> u32 {
423        let mut iprim: u32 = 1;
424        while !iprim.is_multiple_of(Self::PRIM) {
425            iprim += Self::NN as u32;
426        }
427        iprim / Self::PRIM
428    }
429
430    // ─────────────────────────────────────────────────────────────────
431    // WSJT-X (JT65) wrappers
432    // ─────────────────────────────────────────────────────────────────
433
434    /// Encode for JT65 with the byte ordering WSJT-X expects
435    /// (`wrapkarn.c::rs_encode_`): info is reversed before encoding,
436    /// and the output places parity (reversed) at `[0..51]` and data
437    /// (reversed) at `[51..63]`.
438    pub fn encode_jt65(&self, info: &[u8; Self::K_SYMBOLS]) -> [u8; Self::N_SYMBOLS] {
439        let mut dat1 = [0u8; Self::K_SYMBOLS];
440        for i in 0..Self::K_SYMBOLS {
441            dat1[i] = info[Self::K_SYMBOLS - 1 - i];
442        }
443        let cw = self.encode_native(&dat1);
444        // cw = [dat1 || parity]; transform to WSJT-X layout.
445        let mut sent = [0u8; Self::N_SYMBOLS];
446        for i in 0..Self::NROOTS {
447            sent[Self::NROOTS - 1 - i] = cw[Self::K_SYMBOLS + i];
448        }
449        for i in 0..Self::K_SYMBOLS {
450            sent[Self::NROOTS + i] = dat1[Self::K_SYMBOLS - 1 - i];
451        }
452        sent
453    }
454
455    /// Decode JT65 symbols with the WSJT-X layout. Returns
456    /// `Some((info, err_count))` or `None` if uncorrectable.
457    pub fn decode_jt65(
458        &self,
459        recd0: &[u8; Self::N_SYMBOLS],
460    ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
461        self.decode_jt65_erasures(recd0, &[])
462    }
463
464    /// JT65-layout decode with a caller-supplied list of **erasure
465    /// positions in the WSJT-X `sent[]` layout** (0..=50 = parity
466    /// reversed; 51..=62 = data reversed). The positions are
467    /// translated to the native Karn layout (identity mapping
468    /// `native = NN − 1 − wsjt` for both halves) before entering
469    /// [`Self::decode_native_erasures`].
470    pub fn decode_jt65_erasures(
471        &self,
472        recd0: &[u8; Self::N_SYMBOLS],
473        eras_pos_wsjt: &[u32],
474    ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
475        let mut recd = [0u8; Self::N_SYMBOLS];
476        for i in 0..Self::K_SYMBOLS {
477            recd[i] = recd0[Self::NN - 1 - i];
478        }
479        for i in 0..Self::NROOTS {
480            recd[Self::K_SYMBOLS + i] = recd0[Self::NROOTS - 1 - i];
481        }
482        // The WSJT-X ↔ native index relation is `native = NN − 1 − wsjt`
483        // on both halves of the codeword (verified against the loops
484        // above). Translate the caller's erasure positions.
485        let eras_native: Vec<u32> = eras_pos_wsjt
486            .iter()
487            .filter(|&&p| (p as usize) < Self::NN)
488            .map(|&p| (Self::NN as u32 - 1) - p)
489            .collect();
490        let (info_native, nerr) = self.decode_native_erasures(&recd, &eras_native)?;
491        let mut info = [0u8; Self::K_SYMBOLS];
492        for i in 0..Self::K_SYMBOLS {
493            info[i] = info_native[Self::K_SYMBOLS - 1 - i];
494        }
495        Some((info, nerr))
496    }
497}
498
499// ─────────────────────────────────────────────────────────────────────────
500// FecCodec boundary stub
501//
502// Rs63_12 is used as `<Jt65 as Protocol>::Fec`. The `Protocol` trait
503// requires `type Fec: FecCodec`, which is a bit-LLR-oriented interface
504// that does not map naturally onto hard-decision symbol-level RS. We
505// provide the minimum viable impl: `encode` packs 12 × 6 = 72 info
506// bits into 63 × 6 = 378 codeword bits using the JT65 layout, and
507// `decode_soft` always returns `None` — hard-symbol RS decoding lives
508// in `jt65-core` and uses `decode_jt65` / `decode_native` directly.
509// ─────────────────────────────────────────────────────────────────────────
510
511use crate::core::{FecOpts, FecResult};
512
513impl super::FecCodec for Rs63_12 {
514    const N: usize = Rs63_12::N_SYMBOLS * 6; // 63 × 6 = 378 bits
515    const K: usize = Rs63_12::K_SYMBOLS * 6; // 12 × 6 = 72 bits
516
517    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
518        assert_eq!(info.len(), Self::K);
519        assert_eq!(codeword.len(), Self::N);
520        // Pack 72 bits (MSB-first within each 6-bit symbol) into 12 symbols.
521        let mut info_syms = [0u8; Rs63_12::K_SYMBOLS];
522        for (i, slot) in info_syms.iter_mut().enumerate() {
523            let mut w = 0u8;
524            for b in 0..6 {
525                w = (w << 1) | (info[6 * i + b] & 1);
526            }
527            *slot = w;
528        }
529        let sent = self.encode_jt65(&info_syms);
530        // Expand 63 × 6-bit symbols back into 378 bits (MSB-first).
531        for (i, &sym) in sent.iter().enumerate() {
532            for b in 0..6 {
533                codeword[6 * i + b] = (sym >> (5 - b)) & 1;
534            }
535        }
536    }
537
538    /// Symbol-hard RS decoding cannot consume bit LLRs, so this path
539    /// returns `None`. Callers that want JT65 decoding should use the
540    /// symbol-level methods on [`Rs63_12`] from `jt65-core`.
541    fn decode_soft(&self, _llr: &[f32], _opts: &FecOpts) -> Option<FecResult> {
542        None
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn tables_are_primitive() {
552        let rs = Rs63_12::new();
553        // Every nonzero element 1..=62 should have a log, and α^log(x) = x.
554        for x in 1u8..=62 {
555            let lg = rs.index_of[x as usize];
556            assert_ne!(lg, A0, "log of {x} is A0");
557            assert_eq!(
558                rs.alpha_to[lg as usize], x,
559                "alpha_to[index_of[{x}]] != {x}"
560            );
561        }
562    }
563
564    #[test]
565    fn encode_clean_codeword_has_zero_syndrome() {
566        // Round-tripping a clean codeword through decode_native must
567        // yield err_count == 0 and recover the original info.
568        let rs = Rs63_12::new();
569        let info: [u8; 12] = [0, 1, 2, 3, 4, 5, 62, 61, 7, 8, 33, 44];
570        let cw = rs.encode_native(&info);
571        let (decoded, nerr) = rs.decode_native(&cw).expect("clean decode");
572        assert_eq!(decoded, info);
573        assert_eq!(nerr, 0);
574    }
575
576    #[test]
577    fn corrects_single_error() {
578        let rs = Rs63_12::new();
579        let info: [u8; 12] = [12, 34, 56, 7, 8, 9, 10, 11, 42, 21, 0, 63 & 0x3f];
580        let mut cw = rs.encode_native(&info);
581        cw[17] ^= 0x2a; // inject error
582        let (decoded, nerr) = rs.decode_native(&cw).expect("1 error correctable");
583        assert_eq!(decoded, info);
584        assert_eq!(nerr, 1);
585    }
586
587    #[test]
588    fn corrects_max_errors() {
589        // (63 − 12) / 2 = 25 errors correctable.
590        let rs = Rs63_12::new();
591        let info: [u8; 12] = [5, 17, 29, 41, 53, 62, 1, 13, 25, 37, 49, 61];
592        let mut cw = rs.encode_native(&info);
593        // Flip 25 scattered symbols, each by a different nonzero value.
594        let positions = [
595            0, 3, 5, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35, 38, 41, 44, 47, 50, 53, 56, 59, 62, 1,
596            4, 7,
597        ];
598        // Pick a nonzero XOR value (1..=31) so every flip really is an
599        // error — a 0x40 XOR masked down to 0 is a no-op and would
600        // reduce the effective error count.
601        for (i, &p) in positions.iter().enumerate() {
602            let delta = ((i as u8 * 7 + 1) & 0x1f) | 1;
603            cw[p] ^= delta;
604            debug_assert!(delta != 0);
605        }
606        let (decoded, nerr) = rs.decode_native(&cw).expect("25 errors correctable");
607        assert_eq!(decoded, info);
608        assert_eq!(nerr, 25);
609    }
610
611    #[test]
612    fn rejects_26_errors() {
613        let rs = Rs63_12::new();
614        let info: [u8; 12] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
615        let mut cw = rs.encode_native(&info);
616        // 26 symbol errors exceed the correctable bound; decoder should
617        // return None or miscorrect — either way, it should NOT silently
618        // claim the wrong info with err_count == 26.
619        for p in 0..26 {
620            cw[p] ^= 0x15;
621        }
622        match rs.decode_native(&cw) {
623            None => {} // expected — uncorrectable
624            Some((decoded, _)) => {
625                // Decoder may return "a valid codeword" ≠ original.
626                assert_ne!(decoded, info, "must not decode to original beyond bound");
627            }
628        }
629    }
630
631    #[test]
632    fn erasures_only_all_51_parity() {
633        // 51 erasures on the parity block + 0 errors in data → should
634        // decode. Saturates the `2·errors + eras ≤ NROOTS = 51` bound.
635        let rs = Rs63_12::new();
636        let info: [u8; 12] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 42, 21];
637        let mut cw = rs.encode_native(&info);
638        // Zero out parity (positions 12..63 in native layout) and
639        // mark them erased.
640        let mut eras = Vec::new();
641        for p in 12..63u32 {
642            cw[p as usize] = 0;
643            eras.push(p);
644        }
645        let (decoded, nerr) = rs
646            .decode_native_erasures(&cw, &eras)
647            .expect("51-erasure decode");
648        assert_eq!(decoded, info);
649        assert_eq!(nerr, 51);
650    }
651
652    #[test]
653    fn erasures_let_us_correct_beyond_25_errors() {
654        // Inject 30 symbol errors BUT tell the decoder where 20 of them
655        // are (erasures). That leaves 10 unknown error positions — well
656        // inside the new bound (`2·10 + 20 = 40 ≤ 51`).
657        let rs = Rs63_12::new();
658        let info: [u8; 12] = [1, 13, 25, 37, 49, 61, 5, 17, 29, 41, 53, 62];
659        let mut cw = rs.encode_native(&info);
660        // Flip 30 distinct positions. (i*2) mod 63 walks all residues
661        // once because gcd(2, 63) = 1, but dedupe defensively.
662        let positions: Vec<usize> = {
663            let mut s = Vec::with_capacity(30);
664            let mut used = [false; 63];
665            for i in 0..63 {
666                let p = (i * 2) % 63;
667                if !used[p] {
668                    used[p] = true;
669                    s.push(p);
670                    if s.len() == 30 {
671                        break;
672                    }
673                }
674            }
675            s
676        };
677        for (i, &p) in positions.iter().enumerate() {
678            let delta = ((i as u8 * 7 + 1) & 0x1f) | 1;
679            cw[p] ^= delta;
680        }
681        // Reveal the first 20 as erasures.
682        let eras: Vec<u32> = positions.iter().take(20).map(|&p| p as u32).collect();
683        let (decoded, _nerr) = rs
684            .decode_native_erasures(&cw, &eras)
685            .expect("20 erasures + 10 errors must decode");
686        assert_eq!(decoded, info);
687    }
688
689    #[test]
690    fn jt65_wrapper_roundtrip() {
691        // Verify the reversed-layout wrappers are mutual inverses on a
692        // clean codeword.
693        let rs = Rs63_12::new();
694        let info: [u8; 12] = [0, 1, 2, 3, 4, 5, 62, 61, 7, 8, 33, 44];
695        let sent = rs.encode_jt65(&info);
696        let (decoded, nerr) = rs.decode_jt65(&sent).expect("jt65 clean roundtrip");
697        assert_eq!(decoded, info);
698        assert_eq!(nerr, 0);
699    }
700}