1use std::collections::HashMap;
41use std::fs::File;
42use std::io::{BufReader, Read};
43use std::path::{Path, PathBuf};
44
45const MAGIC: &[u8; 4] = b"BC5D";
47
48const SUPPORTED_VERSION: u32 = 2;
50
51#[derive(Debug)]
53pub struct Bc5dTable {
54 caliber: f32,
56 data: Vec<f32>,
58 weight_bins: Vec<f32>,
60 bc_bins: Vec<f32>,
62 muzzle_vel_bins: Vec<f32>,
64 current_vel_bins: Vec<f32>,
66 num_drag_types: usize,
68 version: u32,
70 api_version: String,
72 timestamp: u64,
74}
75
76#[cfg(any(test, target_arch = "wasm32"))]
78pub(crate) struct Bc5dSegmentSchedule {
79 pub(crate) segments: Vec<crate::BCSegmentData>,
80 pub(crate) fallback_bc: f64,
81}
82
83#[derive(Debug, Default)]
85pub struct Bc5dTableManager {
86 table_dir: Option<PathBuf>,
88 tables: HashMap<i32, Bc5dTable>,
90}
91
92#[derive(Debug)]
94pub enum Bc5dError {
95 IoError(std::io::Error),
96 InvalidMagic,
97 UnsupportedVersion(u32),
98 ChecksumMismatch { expected: u32, actual: u32 },
99 InvalidDimensions,
100 TableNotFound(f64),
101 NoTableDirectory,
102 CaliberMismatch { table_caliber: f64, shot_caliber: f64 },
106}
107
108impl std::fmt::Display for Bc5dError {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 match self {
111 Bc5dError::IoError(e) => write!(f, "IO error: {}", e),
112 Bc5dError::InvalidMagic => write!(f, "Invalid file magic (expected 'BC5D')"),
113 Bc5dError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
114 Bc5dError::ChecksumMismatch { expected, actual } => {
115 write!(f, "Checksum mismatch: expected {:08x}, got {:08x}", expected, actual)
116 }
117 Bc5dError::InvalidDimensions => write!(f, "Invalid table dimensions"),
118 Bc5dError::TableNotFound(cal) => write!(f, "No BC5D table found for caliber {:.3}", cal),
119 Bc5dError::NoTableDirectory => write!(f, "No BC table directory configured"),
120 Bc5dError::CaliberMismatch {
121 table_caliber,
122 shot_caliber,
123 } => write!(
124 f,
125 "BC5D table caliber does not match the shot: table is for {:.3}, shot is {:.3} \
126 (BC5D tables are keyed to the nearest 0.001 in: {} vs {})",
127 table_caliber,
128 shot_caliber,
129 caliber_to_key(*table_caliber),
130 caliber_to_key(*shot_caliber),
131 ),
132 }
133 }
134}
135
136impl std::error::Error for Bc5dError {}
137
138impl From<std::io::Error> for Bc5dError {
139 fn from(e: std::io::Error) -> Self {
140 Bc5dError::IoError(e)
141 }
142}
143
144impl Bc5dTable {
145 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Bc5dError> {
147 let file = File::open(&path)?;
148 Self::from_reader(BufReader::new(file))
149 }
150
151 pub fn from_bytes(bytes: &[u8]) -> Result<Self, Bc5dError> {
158 Self::from_reader(std::io::Cursor::new(bytes))
159 }
160
161 fn from_reader<R: Read>(mut reader: R) -> Result<Self, Bc5dError> {
162 let mut magic = [0u8; 4];
164 reader.read_exact(&mut magic)?;
165 if &magic != MAGIC {
166 return Err(Bc5dError::InvalidMagic);
167 }
168
169 let version = read_u32(&mut reader)?;
171 if version != SUPPORTED_VERSION {
172 return Err(Bc5dError::UnsupportedVersion(version));
173 }
174
175 let caliber = read_f32(&mut reader)?;
176 let _flags = read_u32(&mut reader)?;
177 let _padding = read_u32(&mut reader)?;
178
179 let dim_weight = read_u32(&mut reader)? as usize;
180 let dim_bc = read_u32(&mut reader)? as usize;
181 let dim_muzzle_vel = read_u32(&mut reader)? as usize;
182 let dim_current_vel = read_u32(&mut reader)? as usize;
183 let dim_drag_types = read_u32(&mut reader)? as usize;
184
185 let timestamp = read_u64(&mut reader)?;
186 let stored_checksum = read_u32(&mut reader)?;
187
188 let mut api_version_bytes = [0u8; 16];
190 reader.read_exact(&mut api_version_bytes)?;
191 let api_version = String::from_utf8_lossy(&api_version_bytes)
192 .trim_end_matches('\0')
193 .to_string();
194
195 let mut reserved = [0u8; 12];
197 reader.read_exact(&mut reserved)?;
198
199 if dim_weight == 0 || dim_bc == 0 || dim_muzzle_vel == 0 || dim_current_vel == 0 || dim_drag_types == 0 {
201 return Err(Bc5dError::InvalidDimensions);
202 }
203
204 let weight_bins = read_f32_array(&mut reader, dim_weight)?;
206 let bc_bins = read_f32_array(&mut reader, dim_bc)?;
207 let muzzle_vel_bins = read_f32_array(&mut reader, dim_muzzle_vel)?;
208 let current_vel_bins = read_f32_array(&mut reader, dim_current_vel)?;
209
210 const MAX_TOTAL_CELLS: usize = 64_000_000; let total_cells = dim_drag_types
215 .checked_mul(dim_weight)
216 .and_then(|x| x.checked_mul(dim_bc))
217 .and_then(|x| x.checked_mul(dim_muzzle_vel))
218 .and_then(|x| x.checked_mul(dim_current_vel))
219 .filter(|&n| n <= MAX_TOTAL_CELLS)
220 .ok_or(Bc5dError::InvalidDimensions)?;
221 let data = read_f32_array(&mut reader, total_cells)?;
222
223 let mut checksum_data = Vec::new();
225 for &v in &weight_bins {
226 checksum_data.extend_from_slice(&v.to_le_bytes());
227 }
228 for &v in &bc_bins {
229 checksum_data.extend_from_slice(&v.to_le_bytes());
230 }
231 for &v in &muzzle_vel_bins {
232 checksum_data.extend_from_slice(&v.to_le_bytes());
233 }
234 for &v in ¤t_vel_bins {
235 checksum_data.extend_from_slice(&v.to_le_bytes());
236 }
237 for &v in &data {
238 checksum_data.extend_from_slice(&v.to_le_bytes());
239 }
240
241 let calculated_checksum = crc32_ieee(&checksum_data);
242 if calculated_checksum != stored_checksum {
243 return Err(Bc5dError::ChecksumMismatch {
244 expected: stored_checksum,
245 actual: calculated_checksum,
246 });
247 }
248
249 Ok(Bc5dTable {
250 caliber,
251 data,
252 weight_bins,
253 bc_bins,
254 muzzle_vel_bins,
255 current_vel_bins,
256 num_drag_types: dim_drag_types,
257 version,
258 api_version,
259 timestamp,
260 })
261 }
262
263 pub fn lookup(
276 &self,
277 weight_grains: f64,
278 base_bc: f64,
279 muzzle_velocity: f64,
280 current_velocity: f64,
281 drag_type: &str,
282 ) -> f64 {
283 let drag_idx = if drag_type.eq_ignore_ascii_case("G7") { 1 } else { 0 };
285
286 let drag_idx = drag_idx.min(self.num_drag_types - 1);
288
289 let (weight_idx, weight_w) = self.interp_idx(weight_grains as f32, &self.weight_bins);
291 let (bc_idx, bc_w) = self.interp_idx(base_bc as f32, &self.bc_bins);
292 let (muzzle_idx, muzzle_w) = self.interp_idx(muzzle_velocity as f32, &self.muzzle_vel_bins);
293 let (current_idx, current_w) = self.interp_idx(current_velocity as f32, &self.current_vel_bins);
294
295 let mut result = 0.0f64;
297
298 for dw in 0..2 {
299 for db in 0..2 {
300 for dm in 0..2 {
301 for dc in 0..2 {
302 let weight = (if dw == 0 { 1.0 - weight_w } else { weight_w })
304 * (if db == 0 { 1.0 - bc_w } else { bc_w })
305 * (if dm == 0 { 1.0 - muzzle_w } else { muzzle_w })
306 * (if dc == 0 { 1.0 - current_w } else { current_w });
307
308 let wi = (weight_idx + dw).min(self.weight_bins.len() - 1);
310 let bi = (bc_idx + db).min(self.bc_bins.len() - 1);
311 let mi = (muzzle_idx + dm).min(self.muzzle_vel_bins.len() - 1);
312 let ci = (current_idx + dc).min(self.current_vel_bins.len() - 1);
313
314 let idx = self.flat_index(drag_idx, wi, bi, mi, ci);
316 result += weight * self.data[idx] as f64;
317 }
318 }
319 }
320 }
321
322 if !result.is_finite() {
325 return 1.0;
326 }
327
328 result.clamp(0.5, 1.5)
329 }
330
331 pub fn get_effective_bc(
335 &self,
336 weight_grains: f64,
337 base_bc: f64,
338 muzzle_velocity: f64,
339 current_velocity: f64,
340 drag_type: &str,
341 ) -> f64 {
342 let correction = self.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type);
343 base_bc * correction
344 }
345
346 pub fn generate_segments(
361 &self,
362 base_bc: f64,
363 drag_type: &str,
364 weight_grains: f64,
365 muzzle_velocity_fps: Option<f64>,
366 ) -> Option<Vec<crate::BCSegmentData>> {
367 let mut breakpoints: Vec<f64> = vec![
368 4000.0, 3500.0, 3000.0, 2700.0, 2500.0, 2300.0, 2100.0, 2000.0, 1900.0, 1800.0, 1700.0,
369 1600.0, 1500.0, 1400.0, 1350.0, 1300.0, 1250.0, 1200.0, 1150.0, 1100.0, 1050.0, 1000.0,
370 950.0, 900.0, 850.0, 800.0, 700.0, 600.0, 500.0,
371 ];
372 if let Some(mv) = muzzle_velocity_fps {
373 breakpoints.push(mv);
374 }
375
376 let mut velocities: Vec<f64> = breakpoints
377 .into_iter()
378 .filter(|&v| v >= 500.0 && muzzle_velocity_fps.is_none_or(|mv| v <= mv))
379 .collect();
380 velocities.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
381 velocities.dedup();
382
383 let reference_mv = velocities.first().copied().unwrap_or(3000.0);
386
387 let mut segments: Vec<crate::BCSegmentData> = Vec::new();
388 let mut any_correction = false;
389 for i in 0..velocities.len().saturating_sub(1) {
390 let vel_max = velocities[i];
391 let vel_min = velocities[i + 1];
392 let vel_mid = (vel_max + vel_min) / 2.0;
393
394 let correction =
395 self.lookup(weight_grains, base_bc, reference_mv, vel_mid, drag_type);
396 if (correction - 1.0).abs() > 1e-6 {
397 any_correction = true;
398 }
399 segments.push(crate::BCSegmentData {
400 velocity_min: vel_min,
401 velocity_max: vel_max,
402 bc_value: base_bc * correction,
403 });
404 }
405
406 if any_correction && !segments.is_empty() {
407 Some(segments)
408 } else {
409 None
410 }
411 }
412
413 #[cfg(any(test, target_arch = "wasm32"))]
415 pub(crate) fn generate_segment_schedule(
416 &self,
417 base_bc: f64,
418 drag_type: &str,
419 weight_grains: f64,
420 muzzle_velocity_fps: f64,
421 ) -> Option<Bc5dSegmentSchedule> {
422 let segments =
423 self.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_velocity_fps))?;
424 let fallback_bc = self.get_effective_bc(
425 weight_grains,
426 base_bc,
427 muzzle_velocity_fps,
428 muzzle_velocity_fps,
429 drag_type,
430 );
431
432 Some(Bc5dSegmentSchedule {
433 segments,
434 fallback_bc,
435 })
436 }
437
438 fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
440 if bins.len() < 2 || value.is_nan() {
441 return (0, 0.0);
442 }
443
444 if value <= bins[0] {
446 return (0, 0.0);
447 }
448 if value >= bins[bins.len() - 1] {
449 return (bins.len().saturating_sub(2), 1.0);
450 }
451
452 let last_interval = bins.len().saturating_sub(2);
454 let idx = match bins.binary_search_by(|probe| {
455 probe
456 .partial_cmp(&value)
457 .unwrap_or(std::cmp::Ordering::Equal)
458 }) {
459 Ok(i) => i.saturating_sub(1).min(last_interval),
460 Err(i) => i.saturating_sub(1).min(last_interval),
461 };
462
463 let low = bins[idx];
465 let high = bins[idx + 1];
466 let weight = if high > low {
467 ((value - low) / (high - low)) as f64
468 } else {
469 0.0
470 };
471
472 (idx, weight)
473 }
474
475 fn flat_index(&self, drag_idx: usize, weight_idx: usize, bc_idx: usize, muzzle_idx: usize, current_idx: usize) -> usize {
477 let n_weight = self.weight_bins.len();
478 let n_bc = self.bc_bins.len();
479 let n_muzzle = self.muzzle_vel_bins.len();
480 let n_current = self.current_vel_bins.len();
481
482 drag_idx * (n_weight * n_bc * n_muzzle * n_current)
483 + weight_idx * (n_bc * n_muzzle * n_current)
484 + bc_idx * (n_muzzle * n_current)
485 + muzzle_idx * n_current
486 + current_idx
487 }
488
489 pub fn caliber(&self) -> f32 {
491 self.caliber
492 }
493
494 pub fn caliber_key(&self) -> i32 {
498 caliber_to_key(self.caliber as f64)
499 }
500
501 pub fn ensure_caliber_matches(&self, shot_caliber_in: f64) -> Result<(), Bc5dError> {
537 if self.caliber_key() == caliber_to_key(shot_caliber_in) {
538 return Ok(());
539 }
540 Err(Bc5dError::CaliberMismatch {
541 table_caliber: self.caliber as f64,
542 shot_caliber: shot_caliber_in,
543 })
544 }
545
546 pub fn version(&self) -> u32 {
548 self.version
549 }
550
551 pub fn api_version(&self) -> &str {
553 &self.api_version
554 }
555
556 pub fn timestamp(&self) -> u64 {
558 self.timestamp
559 }
560
561 pub fn total_cells(&self) -> usize {
563 self.data.len()
564 }
565
566 pub fn bin_counts(&self) -> (usize, usize, usize, usize, usize) {
571 (
572 self.weight_bins.len(),
573 self.bc_bins.len(),
574 self.muzzle_vel_bins.len(),
575 self.current_vel_bins.len(),
576 self.num_drag_types,
577 )
578 }
579
580 pub fn dimensions_str(&self) -> String {
582 format!(
583 "{}x{}x{}x{}x{} (weight x bc x muzzle_vel x current_vel x drag_types)",
584 self.weight_bins.len(),
585 self.bc_bins.len(),
586 self.muzzle_vel_bins.len(),
587 self.current_vel_bins.len(),
588 self.num_drag_types
589 )
590 }
591
592 pub fn weight_range(&self) -> (f32, f32) {
594 (*self.weight_bins.first().unwrap_or(&0.0), *self.weight_bins.last().unwrap_or(&0.0))
595 }
596
597 pub fn velocity_range(&self) -> (f32, f32) {
599 (*self.current_vel_bins.first().unwrap_or(&0.0), *self.current_vel_bins.last().unwrap_or(&0.0))
600 }
601}
602
603impl Bc5dTableManager {
604 pub fn new<P: AsRef<Path>>(table_dir: P) -> Self {
606 Bc5dTableManager {
607 table_dir: Some(table_dir.as_ref().to_path_buf()),
608 tables: HashMap::new(),
609 }
610 }
611
612 pub fn empty() -> Self {
614 Bc5dTableManager {
615 table_dir: None,
616 tables: HashMap::new(),
617 }
618 }
619
620 pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
630 let caliber_key = caliber_to_key(caliber);
631
632 if self.tables.contains_key(&caliber_key) {
634 return Ok(self.tables.get(&caliber_key).unwrap());
635 }
636
637 let table_dir = self.table_dir.as_ref().ok_or(Bc5dError::NoTableDirectory)?;
639 let table_path = find_table_file(table_dir, caliber)?;
640 let table = Bc5dTable::load(&table_path)?;
641 table.ensure_caliber_matches(caliber)?;
642 self.tables.insert(caliber_key, table);
643 Ok(self.tables.get(&caliber_key).unwrap())
644 }
645
646 pub fn lookup(
648 &mut self,
649 caliber: f64,
650 weight_grains: f64,
651 base_bc: f64,
652 muzzle_velocity: f64,
653 current_velocity: f64,
654 drag_type: &str,
655 ) -> Result<f64, Bc5dError> {
656 let table = self.get_table(caliber)?;
657 Ok(table.lookup(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
658 }
659
660 pub fn get_effective_bc(
662 &mut self,
663 caliber: f64,
664 weight_grains: f64,
665 base_bc: f64,
666 muzzle_velocity: f64,
667 current_velocity: f64,
668 drag_type: &str,
669 ) -> Result<f64, Bc5dError> {
670 let table = self.get_table(caliber)?;
671 Ok(table.get_effective_bc(weight_grains, base_bc, muzzle_velocity, current_velocity, drag_type))
672 }
673
674 pub fn has_table(&self, caliber: f64) -> bool {
676 if let Some(ref table_dir) = self.table_dir {
677 find_table_file(table_dir, caliber).is_ok()
678 } else {
679 false
680 }
681 }
682
683 pub fn available_calibers(&self) -> Vec<f64> {
685 let mut calibers = Vec::new();
686 if let Some(ref table_dir) = self.table_dir {
687 if let Ok(entries) = std::fs::read_dir(table_dir) {
688 for entry in entries.flatten() {
689 let path = entry.path();
690 if let Some(ext) = path.extension() {
691 if ext == "bin" {
692 if let Some(stem) = path.file_stem() {
693 let name = stem.to_string_lossy();
694 if let Some(caliber) = name.strip_prefix("bc5d_") {
695 if let Ok(cal_int) = caliber.parse::<i32>() {
697 calibers.push(cal_int as f64 / 1000.0);
698 }
699 }
700 }
701 }
702 }
703 }
704 }
705 }
706 calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
707 calibers
708 }
709}
710
711pub fn caliber_to_key(caliber: f64) -> i32 {
720 (caliber * 1000.0).round() as i32
723}
724
725fn find_table_file(table_dir: &Path, caliber: f64) -> Result<PathBuf, Bc5dError> {
727 let caliber_int = (caliber * 1000.0).round() as i32;
728 let filename = format!("bc5d_{}.bin", caliber_int);
729 let path = table_dir.join(&filename);
730
731 if path.exists() {
732 return Ok(path);
733 }
734
735 let variations = [
737 format!("bc5d_{:03}.bin", caliber_int),
738 format!("bc5d_0{}.bin", caliber_int),
739 ];
740
741 for var in &variations {
742 let var_path = table_dir.join(var);
743 if var_path.exists() {
744 return Ok(var_path);
745 }
746 }
747
748 Err(Bc5dError::TableNotFound(caliber))
749}
750
751fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
754 let mut buf = [0u8; 4];
755 reader.read_exact(&mut buf)?;
756 Ok(u32::from_le_bytes(buf))
757}
758
759fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
760 let mut buf = [0u8; 8];
761 reader.read_exact(&mut buf)?;
762 Ok(u64::from_le_bytes(buf))
763}
764
765fn read_f32<R: Read>(reader: &mut R) -> Result<f32, std::io::Error> {
766 let mut buf = [0u8; 4];
767 reader.read_exact(&mut buf)?;
768 Ok(f32::from_le_bytes(buf))
769}
770
771fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
772 const MAX_ELEMS: usize = 64_000_000; if count > MAX_ELEMS {
776 return Err(std::io::Error::new(
777 std::io::ErrorKind::InvalidData,
778 "f32 array length too large",
779 ));
780 }
781 let byte_len = count.checked_mul(4).ok_or_else(|| {
782 std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
783 })?;
784 let mut data = vec![0f32; count];
785 let mut buf = vec![0u8; byte_len];
786 reader.read_exact(&mut buf)?;
787
788 for (i, chunk) in buf.chunks_exact(4).enumerate() {
789 data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
790 }
791
792 Ok(data)
793}
794
795pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
797 const TABLE: [u32; 256] = make_crc32_table();
798 let mut crc = 0xFFFFFFFFu32;
799 for &byte in data {
800 let idx = ((crc ^ byte as u32) & 0xFF) as usize;
801 crc = (crc >> 8) ^ TABLE[idx];
802 }
803 !crc
804}
805
806const fn make_crc32_table() -> [u32; 256] {
807 const POLY: u32 = 0xEDB88320;
808 let mut table = [0u32; 256];
809 let mut i = 0;
810 while i < 256 {
811 let mut crc = i as u32;
812 let mut j = 0;
813 while j < 8 {
814 if crc & 1 != 0 {
815 crc = (crc >> 1) ^ POLY;
816 } else {
817 crc >>= 1;
818 }
819 j += 1;
820 }
821 table[i] = crc;
822 i += 1;
823 }
824 table
825}
826
827#[cfg(not(target_arch = "wasm32"))]
839pub mod path_cache {
840 use super::{Bc5dError, Bc5dTable};
841 use std::path::{Path, PathBuf};
842 use std::sync::{Arc, OnceLock, RwLock};
843 use std::time::SystemTime;
844
845 const CACHE_CAPACITY: usize = 4;
847
848 #[derive(Debug, Clone, PartialEq, Eq)]
849 struct CacheKey {
850 canonical_path: PathBuf,
851 file_size: u64,
852 modified: Option<SystemTime>,
853 }
854
855 type CacheEntries = Vec<(CacheKey, Arc<Bc5dTable>)>;
856
857 fn cache() -> &'static RwLock<CacheEntries> {
858 static CACHE: OnceLock<RwLock<CacheEntries>> = OnceLock::new();
859 CACHE.get_or_init(|| RwLock::new(Vec::new()))
860 }
861
862 pub fn load_verified(path: &Path) -> Result<Arc<Bc5dTable>, Bc5dError> {
870 let canonical_path = std::fs::canonicalize(path)?;
871 let metadata = std::fs::metadata(&canonical_path)?;
872 let key = CacheKey {
873 canonical_path,
874 file_size: metadata.len(),
875 modified: metadata.modified().ok(),
876 };
877
878 if let Ok(entries) = cache().read() {
879 if let Some((_, table)) = entries.iter().find(|(k, _)| *k == key) {
880 return Ok(Arc::clone(table));
881 }
882 }
883
884 let bytes = std::fs::read(&key.canonical_path)?;
885 let table = Arc::new(Bc5dTable::from_bytes(&bytes)?);
886
887 if let Ok(mut entries) = cache().write() {
888 if let Some((_, existing)) = entries.iter().find(|(k, _)| *k == key) {
891 return Ok(Arc::clone(existing));
892 }
893 if entries.len() >= CACHE_CAPACITY {
894 entries.remove(0);
895 }
896 entries.push((key, Arc::clone(&table)));
897 }
898 Ok(table)
899 }
900
901 pub fn load_verified_for_caliber(
912 path: &Path,
913 shot_caliber_in: f64,
914 ) -> Result<Arc<Bc5dTable>, Bc5dError> {
915 let table = load_verified(path)?;
916 table.ensure_caliber_matches(shot_caliber_in)?;
917 Ok(table)
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 fn create_test_table() -> Bc5dTable {
926 let weight_bins = vec![100.0, 150.0, 200.0];
928 let bc_bins = vec![0.3, 0.4, 0.5];
929 let muzzle_vel_bins = vec![2500.0, 3000.0];
930 let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
931 let num_drag_types = 2;
932
933 let total = num_drag_types * weight_bins.len() * bc_bins.len() * muzzle_vel_bins.len() * current_vel_bins.len();
935 let mut data = vec![1.0f32; total];
936
937 data[0] = 0.95; data[total - 1] = 1.05; Bc5dTable {
944 caliber: 0.308,
945 data,
946 weight_bins,
947 bc_bins,
948 muzzle_vel_bins,
949 current_vel_bins,
950 num_drag_types,
951 version: 2,
952 api_version: "test".to_string(),
953 timestamp: 0,
954 }
955 }
956
957 fn create_single_cell_test_table() -> Bc5dTable {
958 Bc5dTable {
959 caliber: 0.308,
960 data: vec![0.875],
961 weight_bins: vec![168.0],
962 bc_bins: vec![0.4],
963 muzzle_vel_bins: vec![2500.0],
964 current_vel_bins: vec![2000.0],
965 num_drag_types: 1,
966 version: 2,
967 api_version: "test".to_string(),
968 timestamp: 0,
969 }
970 }
971
972 fn serialize_test_table(t: &Bc5dTable) -> Vec<u8> {
975 let mut out = Vec::new();
976 out.extend_from_slice(MAGIC);
977 out.extend_from_slice(&t.version.to_le_bytes());
978 out.extend_from_slice(&t.caliber.to_le_bytes());
979 out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&(t.weight_bins.len() as u32).to_le_bytes());
982 out.extend_from_slice(&(t.bc_bins.len() as u32).to_le_bytes());
983 out.extend_from_slice(&(t.muzzle_vel_bins.len() as u32).to_le_bytes());
984 out.extend_from_slice(&(t.current_vel_bins.len() as u32).to_le_bytes());
985 out.extend_from_slice(&(t.num_drag_types as u32).to_le_bytes());
986 out.extend_from_slice(&t.timestamp.to_le_bytes());
987
988 let mut checksum_data = Vec::new();
990 for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
991 .chain(&t.current_vel_bins).chain(&t.data) {
992 checksum_data.extend_from_slice(&v.to_le_bytes());
993 }
994 out.extend_from_slice(&crc32_ieee(&checksum_data).to_le_bytes());
995
996 let mut api = [0u8; 16];
997 let bytes = t.api_version.as_bytes();
998 api[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
999 out.extend_from_slice(&api);
1000 out.extend_from_slice(&[0u8; 12]); for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
1003 .chain(&t.current_vel_bins).chain(&t.data) {
1004 out.extend_from_slice(&v.to_le_bytes());
1005 }
1006 out
1007 }
1008
1009 #[test]
1010 fn test_from_bytes_roundtrip() {
1011 let original = create_test_table();
1012 let bytes = serialize_test_table(&original);
1013 let parsed = Bc5dTable::from_bytes(&bytes).expect("from_bytes should parse");
1014
1015 assert_eq!(parsed.caliber, original.caliber);
1016 assert_eq!(parsed.num_drag_types, original.num_drag_types);
1017 assert_eq!(parsed.weight_bins, original.weight_bins);
1018 assert_eq!(parsed.current_vel_bins, original.current_vel_bins);
1019 assert_eq!(parsed.data, original.data);
1020 assert_eq!(parsed.api_version, original.api_version);
1021
1022 let mut bad = bytes.clone();
1024 *bad.last_mut().unwrap() ^= 0xFF;
1025 assert!(Bc5dTable::from_bytes(&bad).is_err());
1026 }
1027
1028 #[test]
1029 fn test_generate_segments() {
1030 let mut uniform = create_test_table();
1033 uniform.data.iter_mut().for_each(|v| *v = 1.0);
1034 assert!(uniform
1035 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1036 .is_none());
1037
1038 let mut corrected = create_test_table();
1041 corrected.data.iter_mut().for_each(|v| *v = 0.9);
1042 let segments = corrected
1043 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1044 .expect("segments expected for a table with corrections");
1045 assert!(!segments.is_empty());
1046 for w in segments.windows(2) {
1047 assert!((segments[0].velocity_max - w[0].velocity_max).abs() >= 0.0);
1049 assert!(w[0].velocity_min >= w[1].velocity_max - 1e-6);
1050 }
1051 for s in &segments {
1052 assert!((s.bc_value - 0.4 * 0.9).abs() < 1e-6); assert!(s.velocity_max > s.velocity_min);
1054 }
1055 }
1056
1057 #[test]
1058 fn segment_schedule_carries_muzzle_corrected_fallback_bc() {
1059 let table = create_single_cell_test_table();
1060 let base_bc = 0.4;
1061 let schedule = table
1062 .generate_segment_schedule(base_bc, "G1", 168.0, 2500.0)
1063 .expect("uniform non-neutral correction should produce a schedule");
1064 let expected_fallback = table.get_effective_bc(168.0, base_bc, 2500.0, 2500.0, "G1");
1065
1066 assert!(!schedule.segments.is_empty());
1067 assert_eq!(expected_fallback.to_bits(), (base_bc * 0.875).to_bits());
1068 assert_eq!(schedule.fallback_bc.to_bits(), expected_fallback.to_bits());
1069 }
1070
1071 #[test]
1072 fn test_interp_idx_in_range() {
1073 let table = create_test_table();
1074
1075 let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
1077 assert_eq!(idx, 0);
1078 assert!((weight - 0.5).abs() < 0.01);
1079
1080 let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
1082 assert_eq!(idx, 0);
1083 assert!((weight - 1.0).abs() < 0.01);
1084 }
1085
1086 #[test]
1087 fn test_interp_idx_out_of_range() {
1088 let table = create_test_table();
1089
1090 let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
1092 assert_eq!(idx, 0);
1093 assert_eq!(weight, 0.0);
1094
1095 let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
1097 assert_eq!(idx, 1); assert_eq!(weight, 1.0);
1099 }
1100
1101 #[test]
1102 fn test_interp_idx_nan_defaults_to_first_bin() {
1103 let table = create_test_table();
1104
1105 assert_eq!(table.interp_idx(f32::NAN, &table.weight_bins), (0, 0.0));
1106 assert_eq!(
1107 table.interp_idx(f32::NEG_INFINITY, &table.weight_bins),
1108 (0, 0.0)
1109 );
1110 assert_eq!(
1111 table.interp_idx(f32::INFINITY, &table.weight_bins),
1112 (1, 1.0)
1113 );
1114 }
1115
1116 #[test]
1117 fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
1118 let table = create_single_cell_test_table();
1119
1120 assert_eq!(table.lookup(f64::NAN, 0.4, 2500.0, 2000.0, "G1"), 0.875);
1121 }
1122
1123 #[test]
1124 fn test_lookup_non_finite_table_cells_are_neutral() {
1125 for cell in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1126 let mut source = create_test_table();
1127 source.data.fill(cell);
1128 let bytes = serialize_test_table(&source);
1129 let table = Bc5dTable::from_bytes(&bytes).expect("CRC-valid table should load");
1130
1131 assert_eq!(
1132 table.lookup(125.0, 0.35, 2750.0, 1500.0, "G1"),
1133 1.0,
1134 "non-finite cell {cell:?} must produce a neutral correction"
1135 );
1136 assert_eq!(
1137 table.get_effective_bc(125.0, 0.35, 2750.0, 1500.0, "G1"),
1138 0.35,
1139 "neutral correction must preserve base BC for {cell:?}"
1140 );
1141 }
1142 }
1143
1144 #[test]
1145 fn test_lookup_returns_valid_range() {
1146 let table = create_test_table();
1147
1148 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
1149 assert!((0.5..=1.5).contains(&correction));
1150
1151 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
1152 assert!((0.5..=1.5).contains(&correction));
1153 }
1154
1155 #[test]
1156 fn test_effective_bc() {
1157 let table = create_test_table();
1158
1159 let base_bc = 0.4;
1160 let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
1161
1162 assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
1164 }
1165
1166 #[test]
1167 fn test_caliber_to_key() {
1168 assert_eq!(caliber_to_key(0.308), 308);
1169 assert_eq!(caliber_to_key(0.224), 224);
1170 assert_eq!(caliber_to_key(0.338), 338);
1171 }
1172
1173 #[test]
1177 fn ensure_caliber_matches_accepts_the_rounding_bucket_and_refuses_outside_it() {
1178 let table = create_test_table(); assert_eq!(table.caliber_key(), 308);
1182 assert!(table.ensure_caliber_matches(0.308).is_ok());
1183
1184 assert!(table.ensure_caliber_matches(0.3075).is_ok());
1187 assert!(table.ensure_caliber_matches(0.3084).is_ok());
1188
1189 assert!(matches!(
1191 table.ensure_caliber_matches(0.3085),
1192 Err(Bc5dError::CaliberMismatch { .. })
1193 ));
1194 assert!(matches!(
1195 table.ensure_caliber_matches(0.3074),
1196 Err(Bc5dError::CaliberMismatch { .. })
1197 ));
1198
1199 let err = table
1201 .ensure_caliber_matches(0.224)
1202 .expect_err("a .224 shot must not be served a .308 table");
1203 let message = err.to_string();
1204 assert!(
1205 message.contains("table is for 0.308, shot is 0.224"),
1206 "the error must name both calibers: {message}"
1207 );
1208
1209 assert!(table.ensure_caliber_matches(f64::NAN).is_err());
1211 assert!(table.ensure_caliber_matches(0.0).is_err());
1212 }
1213
1214 #[cfg(not(target_arch = "wasm32"))]
1217 #[test]
1218 fn manager_refuses_a_file_whose_header_caliber_is_foreign() {
1219 let dir = std::env::temp_dir().join(format!(
1220 "bc5d-mislabeled-{}-{}",
1221 std::process::id(),
1222 std::time::SystemTime::now()
1223 .duration_since(std::time::UNIX_EPOCH)
1224 .unwrap()
1225 .as_nanos()
1226 ));
1227 std::fs::create_dir_all(&dir).unwrap();
1228
1229 let mut foreign = create_test_table();
1230 foreign.caliber = 0.224;
1231 foreign.data.fill(0.9);
1232 std::fs::write(dir.join("bc5d_308.bin"), serialize_test_table(&foreign)).unwrap();
1233
1234 let mut manager = Bc5dTableManager::new(&dir);
1235 let err = manager
1236 .get_table(0.308)
1237 .expect_err("a mislabeled table must be refused, not applied");
1238 assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1239 assert!(matches!(
1241 manager.get_table(0.308),
1242 Err(Bc5dError::CaliberMismatch { .. })
1243 ));
1244 assert!(manager
1246 .lookup(0.308, 168.0, 0.4, 2500.0, 2000.0, "G1")
1247 .is_err());
1248
1249 std::fs::write(dir.join("bc5d_224.bin"), serialize_test_table(&foreign)).unwrap();
1251 assert!(manager.get_table(0.224).is_ok());
1252
1253 std::fs::remove_dir_all(&dir).unwrap();
1254 }
1255
1256 #[cfg(not(target_arch = "wasm32"))]
1259 #[test]
1260 fn path_cache_guarded_loader_refuses_a_foreign_caliber() {
1261 let dir = std::env::temp_dir().join(format!(
1262 "bc5d-guarded-load-{}-{}",
1263 std::process::id(),
1264 std::time::SystemTime::now()
1265 .duration_since(std::time::UNIX_EPOCH)
1266 .unwrap()
1267 .as_nanos()
1268 ));
1269 std::fs::create_dir_all(&dir).unwrap();
1270 let path = dir.join("bc5d_308.bin");
1271 std::fs::write(&path, serialize_test_table(&create_test_table())).unwrap();
1272
1273 assert!(path_cache::load_verified_for_caliber(&path, 0.308).is_ok());
1274 let err = path_cache::load_verified_for_caliber(&path, 0.243)
1275 .expect_err("a .243 shot must be refused a .308 table");
1276 assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1277 assert!(
1278 err.to_string().contains("table is for 0.308, shot is 0.243"),
1279 "{err}"
1280 );
1281
1282 std::fs::remove_dir_all(&dir).unwrap();
1283 }
1284
1285 #[test]
1286 fn test_table_metadata() {
1287 let table = create_test_table();
1288 assert!((table.caliber() - 0.308).abs() < 0.001);
1289 assert_eq!(table.version(), 2);
1290 assert_eq!(table.api_version(), "test");
1291 }
1292
1293 #[test]
1294 fn test_crc32() {
1295 let data = b"123456789";
1297 let crc = crc32_ieee(data);
1298 assert_eq!(crc, 0xCBF43926);
1299 }
1300
1301 #[test]
1302 fn test_bin_counts_matches_dimensions() {
1303 let table = create_test_table();
1304 assert_eq!(table.bin_counts(), (3, 3, 2, 3, 2));
1305 }
1306
1307 #[cfg(not(target_arch = "wasm32"))]
1311 #[test]
1312 fn path_cache_reuses_parsed_tables_and_detects_replacement() {
1313 let dir = std::env::temp_dir().join(format!(
1314 "bc5d-path-cache-{}-{}",
1315 std::process::id(),
1316 std::time::SystemTime::now()
1317 .duration_since(std::time::UNIX_EPOCH)
1318 .unwrap()
1319 .as_nanos()
1320 ));
1321 std::fs::create_dir_all(&dir).unwrap();
1322 let path = dir.join("bc5d_308.bin");
1323
1324 let table = create_test_table();
1325 let bytes = serialize_test_table(&table);
1326 std::fs::write(&path, &bytes).unwrap();
1327
1328 let first = path_cache::load_verified(&path).expect("valid table loads");
1329 let second = path_cache::load_verified(&path).expect("cached table loads");
1330 assert!(
1331 std::sync::Arc::ptr_eq(&first, &second),
1332 "an unchanged file must be served from the cache"
1333 );
1334
1335 let mut replacement = create_test_table();
1338 replacement.weight_bins.push(250.0);
1339 let extra_cells = replacement.num_drag_types
1340 * replacement.bc_bins.len()
1341 * replacement.muzzle_vel_bins.len()
1342 * replacement.current_vel_bins.len();
1343 replacement
1344 .data
1345 .resize(replacement.data.len() + extra_cells, 0.9f32);
1346 std::fs::write(&path, serialize_test_table(&replacement)).unwrap();
1347 let third = path_cache::load_verified(&path).expect("replacement loads");
1348 assert!(!std::sync::Arc::ptr_eq(&first, &third));
1349 assert_eq!(third.bin_counts().0, 4, "replacement content must be parsed");
1350
1351 let mut corrupt = serialize_test_table(&table);
1353 *corrupt.last_mut().unwrap() ^= 0xFF;
1354 std::fs::write(&path, corrupt).unwrap();
1355 assert!(matches!(
1356 path_cache::load_verified(&path),
1357 Err(Bc5dError::ChecksumMismatch { .. })
1358 ));
1359
1360 std::fs::remove_dir_all(&dir).unwrap();
1361 }
1362}