1use crate::codec::jpeg::JpegImage;
21use crate::codec::jpeg::dct::DctGrid;
22use crate::codec::jpeg::pixels;
23use crate::stego::armor::ecc;
24use crate::stego::armor::embedding::{self, stdm_embed, stdm_extract_soft};
25use crate::stego::armor::fft2d;
26use crate::stego::armor::fortress;
27use crate::stego::armor::repetition;
28use crate::stego::armor::resample;
29use crate::stego::armor::selection::compute_stability_map;
30use crate::stego::armor::spreading::{generate_spreading_vectors, SPREAD_LEN};
31use crate::stego::armor::template;
32use crate::stego::crypto;
33use crate::stego::error::StegoError;
34use crate::stego::frame;
35use crate::stego::payload::{self, PayloadData};
36use crate::stego::permute;
37use crate::stego::progress;
38
39#[cfg(feature = "parallel")]
40use rayon::prelude::*;
41
42use crate::stego::quality::{self, EncodeQuality, ArmorMetrics};
43
44const HEADER_UNITS: usize = embedding::HEADER_UNITS; const HEADER_COPIES: usize = embedding::HEADER_COPIES; pub const ARMOR_ENCODE_STEPS: u32 = 6;
53
54const ARMOR_ENCODE_FORTRESS_STEPS: u32 = 3;
57
58pub fn armor_encode(
75 image_bytes: &[u8],
76 message: &str,
77 passphrase: &str,
78) -> Result<Vec<u8>, StegoError> {
79 armor_encode_impl(image_bytes, message, passphrase)
80 .map(|(bytes, _)| bytes)
81}
82
83pub fn armor_encode_with_quality(
85 image_bytes: &[u8],
86 message: &str,
87 passphrase: &str,
88) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
89 armor_encode_impl(image_bytes, message, passphrase)
90}
91
92fn armor_encode_impl(
93 image_bytes: &[u8],
94 message: &str,
95 passphrase: &str,
96) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
97 progress::init(ARMOR_ENCODE_STEPS);
99
100 let payload_bytes = payload::encode_payload(message, &[])?;
102
103 let mut img = JpegImage::from_bytes(image_bytes)?;
104
105 let fi = img.frame_info();
107 crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;
108
109 if img.num_components() == 0 {
110 return Err(StegoError::NoLuminanceChannel);
111 }
112
113 let use_compact = passphrase.is_empty();
116 if let Ok(max_fort) = fortress::fortress_max_frame_bytes_ext(&img, use_compact) {
117 let fortress_frame = if use_compact {
118 let ct = crypto::encrypt_with(
119 &payload_bytes,
120 passphrase,
121 &crypto::FORTRESS_EMPTY_SALT,
122 &crypto::FORTRESS_EMPTY_NONCE,
123 )?;
124 frame::build_fortress_compact_frame(payload_bytes.len(), &ct)
125 } else {
126 let (ct, nonce, salt) = crypto::encrypt(&payload_bytes, passphrase)?;
127 frame::build_frame(payload_bytes.len(), &salt, &nonce, &ct)
128 };
129
130 if fortress_frame.len() <= max_fort {
131 progress::set_total(ARMOR_ENCODE_FORTRESS_STEPS);
133
134 pre_settle_for_fortress(&mut img)?;
136 progress::advance(); let fort_result = fortress::fortress_encode(&mut img, &fortress_frame, passphrase)?;
139 progress::advance(); let stego_bytes = if let Ok(bytes) = img.to_bytes() { bytes } else {
142 img.rebuild_huffman_tables();
143 img.to_bytes().map_err(StegoError::InvalidJpeg)?
144 };
145 progress::advance(); let fill_ratio = fortress_frame.len() as f64 / max_fort as f64;
149 let fort_qt_id = img.frame_info().components[0].quant_table_id as usize;
151 let fort_mean_qt = img.quant_table(fort_qt_id)
152 .map_or(10.0, |qt| embedding::compute_mean_qt(&qt.values));
153 let encode_quality = quality::armor_robustness_score(&ArmorMetrics {
154 repetition_factor: fort_result.repetition_factor,
155 parity_symbols: fort_result.parity_symbols,
156 fortress: true,
157 mean_qt: fort_mean_qt,
158 fill_ratio,
159 delta: 12.0,
160 });
161 return Ok((stego_bytes, encode_quality));
162 }
163 }
164
165 let (ciphertext, nonce, salt) = crypto::encrypt(&payload_bytes, passphrase)?;
167 let frame_bytes = frame::build_frame(payload_bytes.len(), &salt, &nonce, &ciphertext);
168
169 embed_dft_template(&mut img, passphrase, message)?;
171 progress::advance(); pre_clamp_y_channel(&mut img)?;
175 progress::advance(); let qt_id = img.frame_info().components[0].quant_table_id as usize;
179 let qt = img
180 .quant_table(qt_id)
181 .ok_or(StegoError::NoLuminanceChannel)?;
182 let cost_map = compute_stability_map(img.dct_grid(0), qt);
183 progress::advance(); let structural_key = crypto::derive_armor_structural_key(passphrase)?;
187 let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
188 let spread_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
189
190 let positions = permute::select_and_permute(&cost_map, &perm_seed);
192 let num_units = positions.len() / SPREAD_LEN;
193 if num_units == 0 {
194 return Err(StegoError::ImageTooSmall);
195 }
196 let n_used = num_units * SPREAD_LEN;
197 let positions = &positions[..n_used];
198
199 let mean_qt = embedding::compute_mean_qt(&qt.values);
203 let header_byte = embedding::encode_mean_qt(mean_qt);
204 let bootstrap_delta = embedding::BOOTSTRAP_DELTA;
205 let reference_delta = embedding::compute_delta_from_mean_qt(mean_qt, 1);
206
207 let mut all_bits = Vec::with_capacity(num_units);
209 for _ in 0..HEADER_COPIES {
210 for bp in (0..8).rev() {
211 all_bits.push((header_byte >> bp) & 1);
212 }
213 }
214
215 let payload_units = if num_units > HEADER_UNITS {
217 num_units - HEADER_UNITS
218 } else {
219 return Err(StegoError::ImageTooSmall);
220 };
221
222 let phase2_result: Option<(usize, Vec<u8>)> = {
224 let mut found = None;
225 for &parity in &ecc::PARITY_TIERS {
226 let rs_encoded = ecc::rs_encode_blocks_with_parity(&frame_bytes, parity);
227 let rs_bits_len = rs_encoded.len() * 8;
228 if rs_bits_len <= payload_units {
229 let r = repetition::compute_r(rs_bits_len, payload_units);
230 if r >= 3 {
231 found = Some((parity, rs_encoded));
232 break;
233 }
234 }
235 }
236 found
237 };
238
239 let (armor_r, armor_parity, armor_delta);
241
242 let embed_delta_fn: Box<dyn Fn(usize) -> f64> = if let Some((chosen_parity, rs_encoded)) = phase2_result {
243 let rs_bits = frame::bytes_to_bits(&rs_encoded);
245
246 let r = repetition::compute_r(rs_bits.len(), payload_units);
247 let rs_bit_count_aligned = payload_units / r;
248 let mut rs_bits_padded = rs_bits;
249 rs_bits_padded.resize(rs_bit_count_aligned, 0);
250 let (rep_bits, _) = repetition::repetition_encode(&rs_bits_padded, payload_units);
251
252 let adaptive_delta = embedding::compute_delta_from_mean_qt(mean_qt, r);
253
254 armor_r = r;
255 armor_parity = chosen_parity;
256 armor_delta = adaptive_delta;
257
258 all_bits.extend_from_slice(&rep_bits[..payload_units.min(rep_bits.len())]);
259
260 Box::new(move |bit_idx| {
261 if bit_idx < HEADER_UNITS { bootstrap_delta } else { adaptive_delta }
262 })
263 } else {
264 let rs_encoded = ecc::rs_encode_blocks(&frame_bytes);
266 let rs_bits = frame::bytes_to_bits(&rs_encoded);
267
268 if rs_bits.len() > payload_units {
269 return Err(StegoError::MessageTooLarge);
270 }
271
272 armor_r = 1;
273 armor_parity = 64;
274 armor_delta = reference_delta;
275
276 let mut payload_bits = rs_bits;
277 payload_bits.resize(payload_units, 0);
278 all_bits.extend_from_slice(&payload_bits);
279
280 Box::new(move |bit_idx| {
281 if bit_idx < HEADER_UNITS { bootstrap_delta } else { reference_delta }
282 })
283 };
284
285 let embed_count = all_bits.len().min(num_units);
286
287 let vectors = generate_spreading_vectors(&spread_seed, embed_count);
289 progress::advance(); let grid_mut = img.dct_grid_mut(0);
293 for bit_idx in 0..embed_count {
294 let group_start = bit_idx * SPREAD_LEN;
295 let group = &positions[group_start..group_start + SPREAD_LEN];
296
297 let mut coeffs = [0.0f64; SPREAD_LEN];
298 for (k, pos) in group.iter().enumerate() {
299 coeffs[k] = flat_get(grid_mut, pos.flat_idx as usize) as f64;
300 }
301
302 let delta = embed_delta_fn(bit_idx);
303 stdm_embed(&mut coeffs, &vectors[bit_idx], all_bits[bit_idx], delta);
304
305 for (k, pos) in group.iter().enumerate() {
306 let new_val = coeffs[k].round() as i16;
307 flat_set(grid_mut, pos.flat_idx as usize, new_val);
308 }
309 }
310
311 progress::advance(); let stego_bytes = if let Ok(bytes) = img.to_bytes() { bytes } else {
315 img.rebuild_huffman_tables();
316 img.to_bytes().map_err(StegoError::InvalidJpeg)?
317 };
318
319 progress::advance(); let fill_ratio = frame_bytes.len() as f64 / (payload_units / 8).max(1) as f64;
323 let encode_quality = quality::armor_robustness_score(&ArmorMetrics {
324 repetition_factor: armor_r,
325 parity_symbols: armor_parity,
326 fortress: false,
327 mean_qt,
328 fill_ratio,
329 delta: armor_delta,
330 });
331
332 Ok((stego_bytes, encode_quality))
333}
334
335#[derive(Debug, Clone)]
337pub struct DecodeQuality {
338 pub mode: u8,
340 pub rs_errors_corrected: u32,
342 pub rs_error_capacity: u32,
344 pub integrity_percent: u8,
346 pub repetition_factor: u8,
348 pub parity_len: u16,
350 pub geometry_corrected: bool,
352 pub template_peaks_detected: u8,
354 pub estimated_rotation_deg: f32,
356 pub estimated_scale: f32,
358 pub dft_ring_used: bool,
360 pub dft_ring_capacity: u16,
362 pub fortress_used: bool,
364 pub signal_strength: f64,
367}
368
369impl DecodeQuality {
370 pub fn ghost() -> Self {
372 Self {
373 mode: crate::stego::frame::MODE_GHOST,
374 rs_errors_corrected: 0,
375 rs_error_capacity: 0,
376 integrity_percent: 100,
377 repetition_factor: 0,
378 parity_len: 0,
379 geometry_corrected: false,
380 template_peaks_detected: 0,
381 estimated_rotation_deg: 0.0,
382 estimated_scale: 1.0,
383 dft_ring_used: false,
384 dft_ring_capacity: 0,
385 fortress_used: false,
386 signal_strength: 0.0,
387 }
388 }
389
390 pub fn from_rs_stats_with_signal(
400 stats: &ecc::RsDecodeStats,
401 repetition_factor: u8,
402 parity_len: u16,
403 signal_strength: f64,
404 reference_llr: f64,
405 ) -> Self {
406 let integrity = compute_integrity(signal_strength, stats, reference_llr);
407 Self {
408 mode: crate::stego::frame::MODE_ARMOR,
409 rs_errors_corrected: stats.total_errors as u32,
410 rs_error_capacity: stats.error_capacity as u32,
411 integrity_percent: integrity,
412 repetition_factor,
413 parity_len,
414 geometry_corrected: false,
415 template_peaks_detected: 0,
416 estimated_rotation_deg: 0.0,
417 estimated_scale: 1.0,
418 dft_ring_used: false,
419 dft_ring_capacity: 0,
420 fortress_used: false,
421 signal_strength,
422 }
423 }
424}
425
426fn compute_integrity(signal_strength: f64, rs_stats: &ecc::RsDecodeStats, reference_llr: f64) -> u8 {
438 let llr_score = if reference_llr > 0.0 {
439 (signal_strength / reference_llr).clamp(0.0, 1.0)
440 } else {
441 1.0 };
443 let rs_score = if rs_stats.error_capacity == 0 {
444 1.0
445 } else {
446 let ratio = rs_stats.total_errors as f64 / rs_stats.error_capacity as f64;
447 (1.0 - ratio).max(0.0)
448 };
449 let combined = 0.7 * llr_score + 0.3 * rs_score;
451 (combined * 100.0).round().clamp(0.0, 100.0) as u8
452}
453
454fn compute_avg_abs_llr(llrs: &[f64]) -> f64 {
456 if llrs.is_empty() {
457 return 0.0;
458 }
459 let sum: f64 = llrs.iter().map(|llr| llr.abs()).sum();
460 sum / llrs.len() as f64
461}
462
463pub fn armor_decode(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
475 let img = JpegImage::from_bytes(stego_bytes)?;
477 if img.num_components() > 0
478 && let Ok(result) = fortress::fortress_decode(&img, passphrase) {
479 return Ok(result);
480 }
481
482 match try_armor_decode(&img, passphrase) {
485 Ok(result) => Ok(result),
486 Err(new_err) => {
487 progress::advance(); match try_geometric_recovery(stego_bytes, passphrase) {
490 Ok(result) => Ok(result),
491 Err(_) => Err(new_err),
492 }
493 }
494 }
495}
496
497pub(crate) fn try_armor_decode(img: &JpegImage, passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
499 if img.num_components() == 0 {
500 return Err(StegoError::NoLuminanceChannel);
501 }
502
503 let qt_id = img.frame_info().components[0].quant_table_id as usize;
505 let qt = img
506 .quant_table(qt_id)
507 .ok_or(StegoError::NoLuminanceChannel)?;
508 let cost_map = compute_stability_map(img.dct_grid(0), qt);
509
510 let structural_key = crypto::derive_armor_structural_key(passphrase)?;
512 let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
513 let spread_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
514
515 let positions = permute::select_and_permute(&cost_map, &perm_seed);
517 let num_units = positions.len() / SPREAD_LEN;
518 if num_units == 0 {
519 return Err(StegoError::ImageTooSmall);
520 }
521 let n_used = num_units * SPREAD_LEN;
522 let positions = &positions[..n_used];
523
524 let vectors = generate_spreading_vectors(&spread_seed, num_units);
526
527 let grid = img.dct_grid(0);
528
529 if num_units <= HEADER_UNITS {
531 return Err(StegoError::ImageTooSmall);
532 }
533 let header_byte = extract_header_byte(grid, positions, &vectors, embedding::BOOTSTRAP_DELTA, 0);
534 let header_mean_qt = embedding::decode_mean_qt(header_byte);
535
536 let current_mean_qt = embedding::compute_mean_qt(&qt.values);
538
539 let payload_units = num_units - HEADER_UNITS;
540
541 let mut raw_candidates = Vec::with_capacity(24);
544 raw_candidates.push(header_mean_qt);
545 raw_candidates.push(current_mean_qt);
546 for step in 1..=10 {
547 let factor = step as f64 * 0.03;
548 raw_candidates.push(header_mean_qt * (1.0 - factor));
549 raw_candidates.push(header_mean_qt * (1.0 + factor));
550 }
551
552 let mut candidates: Vec<f64> = Vec::with_capacity(raw_candidates.len());
554 for &c in &raw_candidates {
555 if c > 0.1 && !candidates.iter().any(|&existing| (existing - c).abs() < 0.1) {
556 candidates.push(c);
557 }
558 }
559
560 let nc = candidates.len() as u32;
565 if progress::get().1 == 0 {
567 let total = (2 * (1 + nc + nc) + 1).max(50);
568 progress::set_total(total);
571 }
572 progress::advance(); #[cfg(feature = "parallel")]
576 {
577 let result = candidates.par_iter().find_map_first(|&mean_qt| {
578 if progress::is_cancelled() { return Some(Err(StegoError::Cancelled)); }
579 let reference_delta = embedding::compute_delta_from_mean_qt(mean_qt, 1);
580 match decode_phase1_with_offset(
581 grid, positions, &vectors, reference_delta, num_units, HEADER_UNITS,
582 passphrase,
583 ) {
584 Ok(result) => Some(Ok(result)),
585 Err(StegoError::DecryptionFailed) => Some(Err(StegoError::DecryptionFailed)),
586 Err(_) => { progress::advance(); None }
587 }
588 });
589 match result {
590 Some(Ok(payload)) => return Ok(payload),
591 Some(Err(e)) => return Err(e),
592 None => {} }
594 }
595 #[cfg(not(feature = "parallel"))]
596 for &mean_qt in &candidates {
597 progress::check_cancelled()?;
598 let reference_delta = embedding::compute_delta_from_mean_qt(mean_qt, 1);
599
600 if let Ok(result) = decode_phase1_with_offset(
601 grid, positions, &vectors, reference_delta, num_units, HEADER_UNITS,
602 passphrase,
603 ) {
604 return Ok(result);
605 }
606 progress::advance();
607 }
608
609 let mut all_p2_candidates: Vec<(usize, usize, f64)> = Vec::new();
613 for &mean_qt in &candidates {
614 for &parity in &ecc::PARITY_TIERS {
615 let candidate_rs = compute_candidate_rs(payload_units, parity);
616 for r in candidate_rs {
617 let delta = embedding::compute_delta_from_mean_qt(mean_qt, r);
618 all_p2_candidates.push((parity, r, delta));
619 }
620 }
621 }
622
623 let mut cached_llrs: Vec<(f64, Vec<f64>)> = Vec::new();
625 for &(_, _, delta) in &all_p2_candidates {
626 get_or_extract_llrs(
627 &mut cached_llrs, delta,
628 grid, positions, &vectors, num_units, HEADER_UNITS,
629 );
630 }
631 progress::check_cancelled()?;
632
633 let llr_cache: &[(f64, Vec<f64>)] = &cached_llrs;
635
636 let find_llrs = |delta: f64| -> &[f64] {
637 for (cached_delta, llrs) in llr_cache.iter() {
638 if (cached_delta - delta).abs() < 0.001 {
639 return llrs;
640 }
641 }
642 &[]
643 };
644
645 let try_p2_candidate = |&(parity, r, adaptive_delta): &(usize, usize, f64)| -> Option<Result<(PayloadData, DecodeQuality), StegoError>> {
646 if progress::is_cancelled() { return Some(Err(StegoError::Cancelled)); }
647 let raw_llrs = find_llrs(adaptive_delta);
648
649 let rs_bit_count = payload_units / r;
650 if rs_bit_count == 0 { return None; }
651 let used_llrs = rs_bit_count * r;
652 if used_llrs > raw_llrs.len() { return None; }
653
654 let (voted_bits, rep_quality) = repetition::repetition_decode_soft_with_quality(
655 &raw_llrs[..used_llrs], rs_bit_count,
656 );
657 let voted_bytes = frame::bits_to_bytes(&voted_bits);
658
659 let (decoded_frame, rs_stats) = try_rs_decode_frame_with_parity(&voted_bytes, parity)?;
660 let parsed = frame::parse_frame(&decoded_frame).ok()?;
661 match crypto::decrypt(&parsed.ciphertext, passphrase, &parsed.salt, &parsed.nonce) {
662 Ok(plaintext) => {
663 let len = parsed.plaintext_len as usize;
664 if len > plaintext.len() { return None; }
665 let payload_data = payload::decode_payload(&plaintext[..len]).ok()?;
666 let reference_llr = adaptive_delta / 2.0;
667 let quality = DecodeQuality::from_rs_stats_with_signal(
668 &rs_stats, r as u8, parity as u16,
669 rep_quality.avg_abs_llr_per_copy, reference_llr,
670 );
671 Some(Ok((payload_data, quality)))
672 }
673 Err(StegoError::DecryptionFailed) => Some(Err(StegoError::DecryptionFailed)),
674 Err(_) => None,
675 }
676 };
677
678 #[cfg(feature = "parallel")]
679 let p2_result = all_p2_candidates.par_iter().find_map_first(try_p2_candidate);
680 #[cfg(not(feature = "parallel"))]
681 let p2_result = all_p2_candidates.iter().find_map(try_p2_candidate);
682
683 for _ in 0..candidates.len() { progress::advance(); }
685
686 match p2_result {
687 Some(Ok(payload)) => return Ok(payload),
688 Some(Err(e)) => return Err(e),
689 None => {}
690 }
691
692 Err(StegoError::FrameCorrupted)
693}
694
695pub(crate) fn armor_decode_no_fortress(img: &JpegImage, stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
700 match try_armor_decode(img, passphrase) {
701 Ok(result) => Ok(result),
702 Err(_stdm_err) => {
703 progress::check_cancelled()?;
704 match try_geometric_recovery(stego_bytes, passphrase) {
705 Ok(result) => Ok(result),
706 Err(_) => Err(_stdm_err),
707 }
708 }
709 }
710}
711
712pub(crate) fn try_geometric_recovery(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
715 use crate::stego::armor::dft_payload;
716
717 let img = JpegImage::from_bytes(stego_bytes)?;
718
719 if img.num_components() == 0 {
720 return Err(StegoError::NoLuminanceChannel);
721 }
722
723 let (luma_pixels, w, h) = pixels::jpeg_to_luma_f64(&img)
725 .ok_or(StegoError::NoLuminanceChannel)?;
726 let spectrum = fft2d::fft2d(&luma_pixels, w, h);
727
728 let peaks = template::generate_template_peaks(passphrase, w, h)?;
730 let detected = template::detect_template(&spectrum, &peaks);
731
732 let transform = template::estimate_transform(&detected)
734 .ok_or(StegoError::FrameCorrupted)?;
735
736 if transform.rotation_rad.abs() < 0.001 && (transform.scale - 1.0).abs() < 0.001 {
738 if let Some(ring_bytes) = dft_payload::extract_ring_payload(&spectrum, passphrase)
740 && let Ok(text) = std::str::from_utf8(&ring_bytes) {
741 let ring_cap = dft_payload::ring_capacity(w, h);
742 return Ok((PayloadData { text: text.to_string(), files: vec![] }, DecodeQuality {
743 mode: crate::stego::frame::MODE_ARMOR,
744 rs_errors_corrected: 0,
745 rs_error_capacity: 0,
746 integrity_percent: 50, repetition_factor: 0,
748 parity_len: 0,
749 geometry_corrected: false,
750 template_peaks_detected: detected.len() as u8,
751 estimated_rotation_deg: 0.0,
752 estimated_scale: 1.0,
753 dft_ring_used: true,
754 dft_ring_capacity: ring_cap as u16,
755 fortress_used: false,
756 signal_strength: 0.0,
757 }));
758 }
759 return Err(StegoError::FrameCorrupted);
760 }
761
762 drop(spectrum);
764
765 let corrected_pixels = resample::resample_bilinear(
767 &luma_pixels, w, h, &transform, w, h,
768 );
769
770 drop(luma_pixels);
772
773 let mut corrected_img = img;
775 pixels::luma_f64_to_jpeg(&corrected_pixels, w, h, &mut corrected_img)
776 .ok_or(StegoError::NoLuminanceChannel)?;
777
778 drop(corrected_pixels);
780
781 match try_armor_decode(&corrected_img, passphrase) {
783 Ok((text, mut quality)) => {
784 quality.geometry_corrected = true;
785 quality.template_peaks_detected = detected.len() as u8;
786 quality.estimated_rotation_deg = transform.rotation_rad.to_degrees() as f32;
787 quality.estimated_scale = transform.scale as f32;
788 return Ok((text, quality));
789 }
790 Err(_) => {
791 {
794 let (cp, cw, ch) = pixels::jpeg_to_luma_f64(&corrected_img)
795 .ok_or(StegoError::NoLuminanceChannel)?;
796 let corrected_spectrum = fft2d::fft2d(&cp, cw, ch);
797 if let Some(ring_bytes) = dft_payload::extract_ring_payload(&corrected_spectrum, passphrase)
798 && let Ok(text) = std::str::from_utf8(&ring_bytes) {
799 let ring_cap = dft_payload::ring_capacity(cw, ch);
800 return Ok((PayloadData { text: text.to_string(), files: vec![] }, DecodeQuality {
801 mode: crate::stego::frame::MODE_ARMOR,
802 rs_errors_corrected: 0,
803 rs_error_capacity: 0,
804 integrity_percent: 50,
805 repetition_factor: 0,
806 parity_len: 0,
807 geometry_corrected: true,
808 template_peaks_detected: detected.len() as u8,
809 estimated_rotation_deg: transform.rotation_rad.to_degrees() as f32,
810 estimated_scale: transform.scale as f32,
811 dft_ring_used: true,
812 dft_ring_capacity: ring_cap as u16,
813 fortress_used: false,
814 signal_strength: 0.0,
815 }));
816 }
817 }
818 }
819 }
820
821 Err(StegoError::FrameCorrupted)
822}
823
824fn extract_header_byte(
828 grid: &DctGrid,
829 positions: &[crate::stego::permute::CoeffPos],
830 vectors: &[[f64; SPREAD_LEN]],
831 delta: f64,
832 offset: usize,
833) -> u8 {
834 let mut header_llrs = [0.0f64; 56]; for i in 0..56 {
836 let unit_idx = offset + i;
837 let group_start = unit_idx * SPREAD_LEN;
838 let group = &positions[group_start..group_start + SPREAD_LEN];
839
840 let mut coeffs = [0.0f64; SPREAD_LEN];
841 for (k, pos) in group.iter().enumerate() {
842 coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
843 }
844
845 header_llrs[i] = stdm_extract_soft(&coeffs, &vectors[unit_idx], delta);
846 }
847
848 let mut byte = 0u8;
850 for bit_pos in 0..8 {
851 let mut total = 0.0;
852 for copy in 0..7 {
853 total += header_llrs[copy * 8 + bit_pos];
854 }
855 if total < 0.0 {
856 byte |= 1 << (7 - bit_pos);
857 }
858 }
859 byte
860}
861
862fn decode_phase1_with_offset(
864 grid: &DctGrid,
865 positions: &[crate::stego::permute::CoeffPos],
866 vectors: &[[f64; SPREAD_LEN]],
867 delta: f64,
868 num_units: usize,
869 payload_offset: usize,
870 passphrase: &str,
871) -> Result<(PayloadData, DecodeQuality), StegoError> {
872 let payload_units = num_units - payload_offset;
873
874 let mut all_llrs = Vec::with_capacity(payload_units);
876 for unit_idx in payload_offset..num_units {
877 let group_start = unit_idx * SPREAD_LEN;
878 let group = &positions[group_start..group_start + SPREAD_LEN];
879
880 let mut coeffs = [0.0f64; SPREAD_LEN];
881 for (k, pos) in group.iter().enumerate() {
882 coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
883 }
884
885 all_llrs.push(stdm_extract_soft(&coeffs, &vectors[unit_idx], delta));
886 }
887
888 let signal_strength = compute_avg_abs_llr(&all_llrs);
890 let reference_llr = delta / 2.0;
892
893 let extracted_bits: Vec<u8> = all_llrs.iter()
895 .map(|&llr| if llr >= 0.0 { 0 } else { 1 })
896 .collect();
897
898 let extracted_bytes = frame::bits_to_bytes(&extracted_bits);
899 let (decoded_frame, rs_stats) = try_rs_decode_frame(&extracted_bytes)?;
900
901 let parsed = frame::parse_frame(&decoded_frame)?;
902 let plaintext = crypto::decrypt(
903 &parsed.ciphertext,
904 passphrase,
905 &parsed.salt,
906 &parsed.nonce,
907 )?;
908
909 let len = parsed.plaintext_len as usize;
910 if len > plaintext.len() {
911 return Err(StegoError::FrameCorrupted);
912 }
913
914 let payload_data = payload::decode_payload(&plaintext[..len])?;
915 let quality = DecodeQuality::from_rs_stats_with_signal(
916 &rs_stats, 1, ecc::parity_len() as u16, signal_strength, reference_llr,
917 );
918 Ok((payload_data, quality))
919}
920
921
922
923pub(super) fn compute_candidate_rs(payload_units: usize, parity: usize) -> Vec<usize> {
925 let mut rs_set = std::collections::BTreeSet::new();
926
927 let min_frame = frame::FRAME_OVERHEAD;
931 let max_frame = frame::MAX_FRAME_BYTES;
932
933 for frame_len in min_frame..=max_frame {
934 let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_len, parity);
935 let rs_bits = rs_encoded_len * 8;
936 if rs_bits > payload_units {
937 break;
938 }
939 let r = repetition::compute_r(rs_bits, payload_units);
940 if r >= 3 {
941 rs_set.insert(r);
942 }
943 }
944
945 rs_set.into_iter().collect()
946}
947
948pub(super) fn compute_candidate_rs_compact(payload_units: usize, parity: usize) -> Vec<usize> {
953 let mut rs_set = std::collections::BTreeSet::new();
954
955 let min_frame = frame::FORTRESS_COMPACT_FRAME_OVERHEAD;
956 let max_frame = frame::MAX_FRAME_BYTES;
957
958 for frame_len in min_frame..=max_frame {
959 let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_len, parity);
960 let rs_bits = rs_encoded_len * 8;
961 if rs_bits > payload_units {
962 break;
963 }
964 let r = repetition::compute_r(rs_bits, payload_units);
965 if r >= 3 {
966 rs_set.insert(r);
967 }
968 }
969
970 rs_set.into_iter().collect()
971}
972
973const LLR_CACHE_MAX: usize = 5;
976
977fn get_or_extract_llrs(
982 cache: &mut Vec<(f64, Vec<f64>)>,
983 delta: f64,
984 grid: &DctGrid,
985 positions: &[crate::stego::permute::CoeffPos],
986 vectors: &[[f64; SPREAD_LEN]],
987 num_units: usize,
988 payload_offset: usize,
989) {
990 for i in 0..cache.len() {
992 if (cache[i].0 - delta).abs() < 0.001 {
993 if i < cache.len() - 1 {
995 let entry = cache.remove(i);
996 cache.push(entry);
997 }
998 return;
999 }
1000 }
1001
1002 let unit_indices: Vec<usize> = (payload_offset..num_units).collect();
1004
1005 let extract_one = |&unit_idx: &usize| -> f64 {
1006 let group_start = unit_idx * SPREAD_LEN;
1007 let group = &positions[group_start..group_start + SPREAD_LEN];
1008
1009 let mut coeffs = [0.0f64; SPREAD_LEN];
1010 for (k, pos) in group.iter().enumerate() {
1011 coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
1012 }
1013
1014 stdm_extract_soft(&coeffs, &vectors[unit_idx], delta)
1015 };
1016
1017 #[cfg(feature = "parallel")]
1018 let llrs: Vec<f64> = unit_indices.par_iter().map(extract_one).collect();
1019 #[cfg(not(feature = "parallel"))]
1020 let llrs: Vec<f64> = unit_indices.iter().map(extract_one).collect();
1021
1022 if cache.len() >= LLR_CACHE_MAX {
1024 cache.remove(0); }
1026
1027 cache.push((delta, llrs));
1028}
1029
1030fn pre_clamp_y_channel(img: &mut JpegImage) -> Result<(), StegoError> {
1036 let qt_id = img.frame_info().components[0].quant_table_id as usize;
1037 let qt_values = img.quant_table(qt_id)
1038 .ok_or(StegoError::NoLuminanceChannel)?.values;
1039 let grid = img.dct_grid_mut(0);
1040
1041 let process_block = |chunk: &mut [i16]| {
1042 let quantized: [i16; 64] = chunk.try_into().unwrap();
1043 let mut px = pixels::idct_block(&quantized, &qt_values);
1044 for p in px.iter_mut() {
1045 *p = p.clamp(0.0, 255.0);
1046 }
1047 let settled = pixels::dct_block(&px, &qt_values);
1048 chunk.copy_from_slice(&settled);
1049 };
1050
1051 #[cfg(feature = "parallel")]
1052 grid.coeffs_mut().par_chunks_mut(64).for_each(process_block);
1053 #[cfg(not(feature = "parallel"))]
1054 grid.coeffs_mut().chunks_mut(64).for_each(process_block);
1055 Ok(())
1056}
1057
1058pub(super) fn try_rs_decode_frame_with_parity(
1065 extracted_bytes: &[u8],
1066 parity: usize,
1067) -> Option<(Vec<u8>, ecc::RsDecodeStats)> {
1068 let k_max = 255 - parity;
1069 let min_data = 2usize.min(k_max);
1070
1071 for data_len in min_data..=k_max.min(extracted_bytes.len().saturating_sub(parity)) {
1072 let block_len = data_len + parity;
1073 if block_len > extracted_bytes.len() {
1074 break;
1075 }
1076
1077 if let Ok((first_block_data, first_errors)) =
1078 ecc::rs_decode_with_parity(&extracted_bytes[..block_len], data_len, parity)
1079 && first_block_data.len() >= 2 {
1080 let pt_len =
1081 u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1082
1083 if pt_len == 0 {
1085 continue;
1086 }
1087
1088 let ct_len = pt_len + 16;
1089 let total_frame_len = 2 + 16 + 12 + ct_len + 4;
1090
1091 if total_frame_len > frame::MAX_FRAME_BYTES {
1092 continue;
1093 }
1094
1095 let rs_encoded_len =
1097 ecc::rs_encoded_len_with_parity(total_frame_len, parity);
1098 if rs_encoded_len > extracted_bytes.len() {
1099 continue;
1100 }
1101
1102 if total_frame_len == data_len {
1103 let t_max = parity / 2;
1104 let stats = ecc::RsDecodeStats {
1105 total_errors: first_errors,
1106 error_capacity: t_max,
1107 max_block_errors: first_errors,
1108 num_blocks: 1,
1109 };
1110 return Some((first_block_data, stats));
1111 }
1112
1113 if total_frame_len < data_len {
1114 let t_max = parity / 2;
1115 let stats = ecc::RsDecodeStats {
1116 total_errors: first_errors,
1117 error_capacity: t_max,
1118 max_block_errors: first_errors,
1119 num_blocks: 1,
1120 };
1121 return Some((first_block_data[..total_frame_len].to_vec(), stats));
1122 }
1123
1124 if total_frame_len > data_len
1125 && let Ok((decoded, stats)) = ecc::rs_decode_blocks_with_parity(
1126 &extracted_bytes[..rs_encoded_len],
1127 total_frame_len,
1128 parity,
1129 ) {
1130 return Some((decoded, stats));
1131 }
1132 }
1135 }
1136
1137 None
1138}
1139
1140pub(super) fn try_rs_decode_compact_frame_with_parity(
1145 extracted_bytes: &[u8],
1146 parity: usize,
1147) -> Option<(Vec<u8>, ecc::RsDecodeStats)> {
1148 let k_max = 255 - parity;
1149 let min_data = 2usize.min(k_max);
1150
1151 for data_len in min_data..=k_max.min(extracted_bytes.len().saturating_sub(parity)) {
1152 let block_len = data_len + parity;
1153 if block_len > extracted_bytes.len() {
1154 break;
1155 }
1156
1157 if let Ok((first_block_data, first_errors)) =
1158 ecc::rs_decode_with_parity(&extracted_bytes[..block_len], data_len, parity)
1159 && first_block_data.len() >= 2 {
1160 let pt_len =
1161 u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1162
1163 if pt_len == 0 {
1165 continue;
1166 }
1167
1168 let ct_len = pt_len + 16;
1169 let total_frame_len = 2 + ct_len + 4;
1171
1172 if total_frame_len > frame::MAX_FRAME_BYTES {
1173 continue;
1174 }
1175
1176 let rs_encoded_len =
1178 ecc::rs_encoded_len_with_parity(total_frame_len, parity);
1179 if rs_encoded_len > extracted_bytes.len() {
1180 continue;
1181 }
1182
1183 if total_frame_len == data_len {
1184 let t_max = parity / 2;
1185 let stats = ecc::RsDecodeStats {
1186 total_errors: first_errors,
1187 error_capacity: t_max,
1188 max_block_errors: first_errors,
1189 num_blocks: 1,
1190 };
1191 return Some((first_block_data, stats));
1192 }
1193
1194 if total_frame_len < data_len {
1195 let t_max = parity / 2;
1196 let stats = ecc::RsDecodeStats {
1197 total_errors: first_errors,
1198 error_capacity: t_max,
1199 max_block_errors: first_errors,
1200 num_blocks: 1,
1201 };
1202 return Some((first_block_data[..total_frame_len].to_vec(), stats));
1203 }
1204
1205 if total_frame_len > data_len
1206 && let Ok((decoded, stats)) = ecc::rs_decode_blocks_with_parity(
1207 &extracted_bytes[..rs_encoded_len],
1208 total_frame_len,
1209 parity,
1210 ) {
1211 return Some((decoded, stats));
1212 }
1213 }
1216 }
1217
1218 None
1219}
1220
1221fn try_rs_decode_frame(extracted_bytes: &[u8]) -> Result<(Vec<u8>, ecc::RsDecodeStats), StegoError> {
1223 let parity = ecc::parity_len();
1224
1225 let min_data = crate::stego::frame::FRAME_OVERHEAD;
1226 let max_first_block_data = 191usize; for data_len in min_data..=max_first_block_data.min(extracted_bytes.len().saturating_sub(parity))
1229 {
1230 let block_len = data_len + parity;
1231 if block_len > extracted_bytes.len() {
1232 break;
1233 }
1234
1235 if let Ok((first_block_data, first_errors)) = ecc::rs_decode(&extracted_bytes[..block_len], data_len)
1236 && first_block_data.len() >= 2 {
1237 let pt_len =
1238 u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1239
1240 if pt_len == 0 {
1242 continue;
1243 }
1244
1245 let ct_len = pt_len + 16;
1246 let total_frame_len = 2 + 16 + 12 + ct_len + 4;
1247
1248 if total_frame_len > frame::MAX_FRAME_BYTES {
1249 continue;
1250 }
1251
1252 let rs_encoded_len = ecc::rs_encoded_len(total_frame_len);
1254 if rs_encoded_len > extracted_bytes.len() {
1255 continue;
1256 }
1257
1258 if total_frame_len <= data_len {
1259 let stats = ecc::RsDecodeStats {
1260 total_errors: first_errors,
1261 error_capacity: ecc::T_MAX,
1262 max_block_errors: first_errors,
1263 num_blocks: 1,
1264 };
1265 let frame_data = if total_frame_len == data_len {
1267 first_block_data
1268 } else {
1269 first_block_data[..total_frame_len].to_vec()
1270 };
1271 return Ok((frame_data, stats));
1272 }
1273
1274 if let Ok((decoded, stats)) = ecc::rs_decode_blocks(
1276 &extracted_bytes[..rs_encoded_len],
1277 total_frame_len,
1278 ) {
1279 return Ok((decoded, stats));
1280 }
1281 }
1284 }
1285
1286 Err(StegoError::FrameCorrupted)
1287}
1288
1289fn flat_get(grid: &DctGrid, flat_idx: usize) -> i16 {
1293 let bw = grid.blocks_wide();
1294 let block_idx = flat_idx / 64;
1295 let pos = flat_idx % 64;
1296 let br = block_idx / bw;
1297 let bc = block_idx % bw;
1298 let i = pos / 8;
1299 let j = pos % 8;
1300 grid.get(br, bc, i, j)
1301}
1302
1303fn flat_set(grid: &mut DctGrid, flat_idx: usize, val: i16) {
1305 let bw = grid.blocks_wide();
1306 let block_idx = flat_idx / 64;
1307 let pos = flat_idx % 64;
1308 let br = block_idx / bw;
1309 let bc = block_idx % bw;
1310 let i = pos / 8;
1311 let j = pos % 8;
1312 grid.set(br, bc, i, j, val);
1313}
1314
1315#[derive(Debug, Clone)]
1320pub struct ArmorCapacityInfo {
1321 pub fortress_capacity: usize,
1323 pub stdm_capacity: usize,
1325}
1326
1327pub fn armor_capacity_info(jpeg_bytes: &[u8]) -> Result<ArmorCapacityInfo, StegoError> {
1332 let img = JpegImage::from_bytes(jpeg_bytes)?;
1333
1334 if img.num_components() == 0 {
1335 return Err(StegoError::NoLuminanceChannel);
1336 }
1337
1338 let fortress_cap = fortress::fortress_capacity(&img).unwrap_or(0);
1339 let stdm_cap = super::capacity::estimate_armor_capacity(&img).unwrap_or(0);
1340
1341 Ok(ArmorCapacityInfo {
1342 fortress_capacity: fortress_cap,
1343 stdm_capacity: stdm_cap,
1344 })
1345}
1346
1347fn embed_dft_template(img: &mut JpegImage, passphrase: &str, message: &str) -> Result<(), StegoError> {
1352 let (luma_pixels, w, h) = pixels::jpeg_to_luma_f64(img)
1353 .ok_or(StegoError::NoLuminanceChannel)?;
1354
1355 let mut spectrum = fft2d::fft2d(&luma_pixels, w, h);
1357 drop(luma_pixels);
1358
1359 let peaks = template::generate_template_peaks(passphrase, w, h)?;
1361 template::embed_template(&mut spectrum, &peaks);
1362
1363 use crate::stego::armor::dft_payload;
1365 let ring_cap = dft_payload::ring_capacity(w, h);
1366 if ring_cap > 0 && !message.is_empty() {
1367 let max_byte = message.len().min(ring_cap);
1370 let truncated_len = message[..max_byte]
1371 .char_indices()
1372 .last()
1373 .map_or(0, |(i, c)| i + c.len_utf8());
1374 let truncated = &message.as_bytes()[..truncated_len];
1375 dft_payload::embed_ring_payload(&mut spectrum, truncated, passphrase)?;
1376 }
1377
1378 let modified = fft2d::ifft2d(&spectrum);
1380 drop(spectrum);
1381
1382 pixels::luma_f64_to_jpeg(&modified, w, h, img)
1383 .ok_or(StegoError::NoLuminanceChannel)?;
1384 Ok(())
1385}
1386
1387const JPEG_BASE_LUMINANCE_QT: [u16; 64] = [
1394 16, 11, 10, 16, 24, 40, 51, 61,
1395 12, 12, 14, 19, 26, 58, 60, 55,
1396 14, 13, 16, 24, 40, 57, 69, 56,
1397 14, 17, 22, 29, 51, 87, 80, 62,
1398 18, 22, 37, 56, 68, 109, 103, 77,
1399 24, 35, 55, 64, 81, 104, 113, 92,
1400 49, 64, 78, 87, 103, 121, 120, 101,
1401 72, 92, 95, 98, 112, 100, 103, 99,
1402];
1403
1404const JPEG_BASE_CHROMINANCE_QT: [u16; 64] = [
1407 17, 18, 24, 47, 99, 99, 99, 99,
1408 18, 21, 26, 66, 99, 99, 99, 99,
1409 24, 26, 56, 99, 99, 99, 99, 99,
1410 47, 66, 99, 99, 99, 99, 99, 99,
1411 99, 99, 99, 99, 99, 99, 99, 99,
1412 99, 99, 99, 99, 99, 99, 99, 99,
1413 99, 99, 99, 99, 99, 99, 99, 99,
1414 99, 99, 99, 99, 99, 99, 99, 99,
1415];
1416
1417fn compute_jpeg_qt(base: &[u16; 64], qf: u32) -> [u16; 64] {
1426 let scale = if qf >= 50 { 200 - 2 * qf } else { 5000 / qf };
1427 let mut qt = [0u16; 64];
1428 for i in 0..64 {
1429 let val = (base[i] as u32 * scale + 50) / 100;
1430 qt[i] = val.clamp(1, 255) as u16;
1431 }
1432 qt
1433}
1434
1435fn pre_settle_for_fortress(img: &mut JpegImage) -> Result<(), StegoError> {
1446 use crate::codec::jpeg::dct::QuantTable;
1447
1448 let num_components = img.num_components();
1449 let target_qf = 75u32;
1450
1451 let mut new_qts: Vec<(usize, [u16; 64], [u16; 64])> = Vec::new(); for comp_idx in 0..num_components {
1457 let qt_id = img.frame_info().components[comp_idx].quant_table_id as usize;
1458 let old_qt = img.quant_table(qt_id)
1459 .ok_or(StegoError::NoLuminanceChannel)?.values;
1460 let base = if comp_idx == 0 {
1461 &JPEG_BASE_LUMINANCE_QT
1462 } else {
1463 &JPEG_BASE_CHROMINANCE_QT
1464 };
1465 let new_qt = compute_jpeg_qt(base, target_qf);
1466
1467 let grid = img.dct_grid_mut(comp_idx);
1469
1470 let process_block = |chunk: &mut [i16]| {
1471 let quantized: [i16; 64] = chunk.try_into().unwrap();
1472 let mut px = pixels::idct_block(&quantized, &old_qt);
1473 for p in px.iter_mut() {
1474 *p = p.clamp(0.0, 255.0);
1475 }
1476 let settled = pixels::dct_block(&px, &new_qt);
1477 chunk.copy_from_slice(&settled);
1478 };
1479
1480 #[cfg(feature = "parallel")]
1481 grid.coeffs_mut().par_chunks_mut(64).for_each(process_block);
1482 #[cfg(not(feature = "parallel"))]
1483 grid.coeffs_mut().chunks_mut(64).for_each(process_block);
1484
1485 new_qts.push((qt_id, old_qt, new_qt));
1486 }
1487
1488 let mut replaced = [false; 4];
1490 for (qt_id, _old, new_qt) in &new_qts {
1491 if !replaced[*qt_id] {
1492 img.set_quant_table(*qt_id, QuantTable::new(*new_qt));
1493 replaced[*qt_id] = true;
1494 }
1495 }
1496 Ok(())
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501 use super::*;
1502
1503 #[test]
1504 fn compute_integrity_pristine() {
1505 let stats = ecc::RsDecodeStats {
1507 total_errors: 0,
1508 error_capacity: 32,
1509 max_block_errors: 0,
1510 num_blocks: 1,
1511 };
1512 let integrity = compute_integrity(15.0, &stats, 15.0);
1513 assert_eq!(integrity, 100);
1515 }
1516
1517 #[test]
1518 fn compute_integrity_half_signal() {
1519 let stats = ecc::RsDecodeStats {
1521 total_errors: 0,
1522 error_capacity: 32,
1523 max_block_errors: 0,
1524 num_blocks: 1,
1525 };
1526 let integrity = compute_integrity(7.5, &stats, 15.0);
1527 assert_eq!(integrity, 65);
1529 }
1530
1531 #[test]
1532 fn compute_integrity_zero_signal() {
1533 let stats = ecc::RsDecodeStats {
1535 total_errors: 0,
1536 error_capacity: 32,
1537 max_block_errors: 0,
1538 num_blocks: 1,
1539 };
1540 let integrity = compute_integrity(0.0, &stats, 15.0);
1541 assert_eq!(integrity, 30);
1543 }
1544
1545 #[test]
1546 fn compute_integrity_with_rs_errors() {
1547 let stats = ecc::RsDecodeStats {
1549 total_errors: 16,
1550 error_capacity: 32,
1551 max_block_errors: 16,
1552 num_blocks: 1,
1553 };
1554 let integrity = compute_integrity(15.0, &stats, 15.0);
1555 assert_eq!(integrity, 85);
1557 }
1558
1559 #[test]
1560 fn compute_integrity_both_degraded() {
1561 let stats = ecc::RsDecodeStats {
1563 total_errors: 16,
1564 error_capacity: 32,
1565 max_block_errors: 16,
1566 num_blocks: 1,
1567 };
1568 let integrity = compute_integrity(7.5, &stats, 15.0);
1569 assert_eq!(integrity, 50);
1571 }
1572
1573 #[test]
1574 fn compute_integrity_signal_exceeds_reference() {
1575 let stats = ecc::RsDecodeStats {
1577 total_errors: 0,
1578 error_capacity: 32,
1579 max_block_errors: 0,
1580 num_blocks: 1,
1581 };
1582 let integrity = compute_integrity(20.0, &stats, 15.0);
1583 assert_eq!(integrity, 100);
1585 }
1586
1587 #[test]
1588 fn compute_integrity_zero_reference() {
1589 let stats = ecc::RsDecodeStats {
1591 total_errors: 0,
1592 error_capacity: 0,
1593 max_block_errors: 0,
1594 num_blocks: 0,
1595 };
1596 let integrity = compute_integrity(5.0, &stats, 0.0);
1597 assert_eq!(integrity, 100);
1599 }
1600
1601 #[test]
1602 fn compute_avg_abs_llr_basic() {
1603 let llrs = vec![5.0, -3.0, 4.0, -2.0];
1604 let avg = compute_avg_abs_llr(&llrs);
1605 assert!((avg - 3.5).abs() < 1e-10);
1607 }
1608
1609 #[test]
1610 fn compute_avg_abs_llr_empty() {
1611 assert_eq!(compute_avg_abs_llr(&[]), 0.0);
1612 }
1613
1614 #[test]
1615 fn decode_quality_ghost_unchanged() {
1616 let q = DecodeQuality::ghost();
1617 assert_eq!(q.integrity_percent, 100, "Ghost always 100%");
1618 assert_eq!(q.signal_strength, 0.0);
1619 }
1620
1621 #[test]
1622 fn decode_quality_from_rs_stats_with_signal_pristine() {
1623 let stats = ecc::RsDecodeStats {
1624 total_errors: 0,
1625 error_capacity: 32,
1626 max_block_errors: 0,
1627 num_blocks: 1,
1628 };
1629 let q = DecodeQuality::from_rs_stats_with_signal(&stats, 5, 64, 15.0, 15.0);
1630 assert_eq!(q.integrity_percent, 100);
1631 assert!((q.signal_strength - 15.0).abs() < 1e-10);
1632 assert_eq!(q.repetition_factor, 5);
1633 assert_eq!(q.parity_len, 64);
1634 }
1635
1636}