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
771#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
778fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
779 const MAX_ELEMS: usize = 64_000_000; if count > MAX_ELEMS {
783 return Err(std::io::Error::new(
784 std::io::ErrorKind::InvalidData,
785 "f32 array length too large",
786 ));
787 }
788 let byte_len = count.checked_mul(4).ok_or_else(|| {
789 std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
790 })?;
791 let mut data = vec![0f32; count];
792 let mut buf = vec![0u8; byte_len];
793 reader.read_exact(&mut buf)?;
794
795 for (i, chunk) in buf.chunks_exact(4).enumerate() {
796 data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
797 }
798
799 Ok(data)
800}
801
802pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
804 const TABLE: [u32; 256] = make_crc32_table();
805 let mut crc = 0xFFFFFFFFu32;
806 for &byte in data {
807 let idx = ((crc ^ byte as u32) & 0xFF) as usize;
808 crc = (crc >> 8) ^ TABLE[idx];
809 }
810 !crc
811}
812
813const fn make_crc32_table() -> [u32; 256] {
814 const POLY: u32 = 0xEDB88320;
815 let mut table = [0u32; 256];
816 let mut i = 0;
817 while i < 256 {
818 let mut crc = i as u32;
819 let mut j = 0;
820 while j < 8 {
821 if crc & 1 != 0 {
822 crc = (crc >> 1) ^ POLY;
823 } else {
824 crc >>= 1;
825 }
826 j += 1;
827 }
828 table[i] = crc;
829 i += 1;
830 }
831 table
832}
833
834#[cfg(not(target_arch = "wasm32"))]
858pub mod path_cache {
859 use super::{Bc5dError, Bc5dTable};
860 use std::path::{Path, PathBuf};
861 use std::sync::{Arc, OnceLock, RwLock};
862
863 const CACHE_CAPACITY: usize = 4;
865
866 #[derive(Debug, Clone, PartialEq, Eq)]
867 struct CacheKey {
868 canonical_path: PathBuf,
869 file_size: u64,
870 content_crc: u32,
874 }
875
876 type CacheEntries = Vec<(CacheKey, Arc<Bc5dTable>)>;
877
878 fn cache() -> &'static RwLock<CacheEntries> {
879 static CACHE: OnceLock<RwLock<CacheEntries>> = OnceLock::new();
880 CACHE.get_or_init(|| RwLock::new(Vec::new()))
881 }
882
883 pub fn load_verified(path: &Path) -> Result<Arc<Bc5dTable>, Bc5dError> {
897 let canonical_path = std::fs::canonicalize(path)?;
898 let bytes = std::fs::read(&canonical_path)?;
901 let key = CacheKey {
902 canonical_path,
903 file_size: bytes.len() as u64,
904 content_crc: super::crc32_ieee(&bytes),
905 };
906
907 if let Ok(entries) = cache().read() {
908 if let Some((_, table)) = entries.iter().find(|(k, _)| *k == key) {
909 return Ok(Arc::clone(table));
910 }
911 }
912
913 let table = Arc::new(Bc5dTable::from_bytes(&bytes)?);
914
915 if let Ok(mut entries) = cache().write() {
916 if let Some((_, existing)) = entries.iter().find(|(k, _)| *k == key) {
919 return Ok(Arc::clone(existing));
920 }
921 if entries.len() >= CACHE_CAPACITY {
922 entries.remove(0);
923 }
924 entries.push((key, Arc::clone(&table)));
925 }
926 Ok(table)
927 }
928
929 pub fn load_verified_for_caliber(
940 path: &Path,
941 shot_caliber_in: f64,
942 ) -> Result<Arc<Bc5dTable>, Bc5dError> {
943 let table = load_verified(path)?;
944 table.ensure_caliber_matches(shot_caliber_in)?;
945 Ok(table)
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 fn create_test_table() -> Bc5dTable {
954 let weight_bins = vec![100.0, 150.0, 200.0];
956 let bc_bins = vec![0.3, 0.4, 0.5];
957 let muzzle_vel_bins = vec![2500.0, 3000.0];
958 let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
959 let num_drag_types = 2;
960
961 let total = num_drag_types * weight_bins.len() * bc_bins.len() * muzzle_vel_bins.len() * current_vel_bins.len();
963 let mut data = vec![1.0f32; total];
964
965 data[0] = 0.95; data[total - 1] = 1.05; Bc5dTable {
972 caliber: 0.308,
973 data,
974 weight_bins,
975 bc_bins,
976 muzzle_vel_bins,
977 current_vel_bins,
978 num_drag_types,
979 version: 2,
980 api_version: "test".to_string(),
981 timestamp: 0,
982 }
983 }
984
985 #[cfg(not(target_arch = "wasm32"))]
992 #[test]
993 fn path_cache_detects_same_size_replacement_under_an_identical_mtime() {
994 use super::path_cache;
995 use std::time::{Duration, SystemTime};
996
997 let dir = std::env::temp_dir().join(format!(
998 "bc5d_same_mtime_{}_{:?}",
999 std::process::id(),
1000 std::thread::current().id()
1001 ));
1002 let _ = std::fs::remove_dir_all(&dir);
1003 std::fs::create_dir_all(&dir).unwrap();
1004 let path = dir.join("bc5d_308.bin");
1005
1006 let pinned = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000);
1010 let pin = |p: &std::path::Path| {
1011 let f = std::fs::OpenOptions::new().write(true).open(p).unwrap();
1012 f.set_modified(pinned).unwrap();
1013 f.sync_all().unwrap();
1014 };
1015
1016 let original = create_test_table();
1017 let good = serialize_test_table(&original);
1018 std::fs::write(&path, &good).unwrap();
1019 pin(&path);
1020 let first = path_cache::load_verified(&path).expect("valid table loads");
1021
1022 let mut replacement = create_test_table();
1024 let last = replacement.data.len() - 1;
1025 replacement.data[last] = 0.5; let replaced = serialize_test_table(&replacement);
1027 assert_eq!(replaced.len(), good.len(), "test requires an identical size");
1028 std::fs::write(&path, &replaced).unwrap();
1029 pin(&path);
1030 assert_eq!(
1031 std::fs::metadata(&path).unwrap().modified().unwrap(),
1032 pinned,
1033 "both writes must share one mtime for this test to mean anything"
1034 );
1035 let second = path_cache::load_verified(&path).expect("replacement loads");
1036 assert!(
1037 !std::sync::Arc::ptr_eq(&first, &second),
1038 "a same-size replacement under an identical mtime must NOT be served from cache"
1039 );
1040
1041 let mut corrupt = good.clone();
1044 *corrupt.last_mut().unwrap() ^= 0xFF;
1045 assert_eq!(corrupt.len(), good.len());
1046 std::fs::write(&path, &corrupt).unwrap();
1047 pin(&path);
1048 assert!(
1049 matches!(
1050 path_cache::load_verified(&path),
1051 Err(Bc5dError::ChecksumMismatch { .. })
1052 ),
1053 "corruption under an identical mtime must be detected, not cached"
1054 );
1055
1056 std::fs::write(&path, &good).unwrap();
1058 pin(&path);
1059 let restored = path_cache::load_verified(&path).expect("original loads again");
1060 assert!(
1061 std::sync::Arc::ptr_eq(&first, &restored),
1062 "identical content must still be served from the cache"
1063 );
1064
1065 let _ = std::fs::remove_dir_all(&dir);
1066 }
1067
1068 fn create_single_cell_test_table() -> Bc5dTable {
1069 Bc5dTable {
1070 caliber: 0.308,
1071 data: vec![0.875],
1072 weight_bins: vec![168.0],
1073 bc_bins: vec![0.4],
1074 muzzle_vel_bins: vec![2500.0],
1075 current_vel_bins: vec![2000.0],
1076 num_drag_types: 1,
1077 version: 2,
1078 api_version: "test".to_string(),
1079 timestamp: 0,
1080 }
1081 }
1082
1083 fn serialize_test_table(t: &Bc5dTable) -> Vec<u8> {
1086 let mut out = Vec::new();
1087 out.extend_from_slice(MAGIC);
1088 out.extend_from_slice(&t.version.to_le_bytes());
1089 out.extend_from_slice(&t.caliber.to_le_bytes());
1090 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());
1093 out.extend_from_slice(&(t.bc_bins.len() as u32).to_le_bytes());
1094 out.extend_from_slice(&(t.muzzle_vel_bins.len() as u32).to_le_bytes());
1095 out.extend_from_slice(&(t.current_vel_bins.len() as u32).to_le_bytes());
1096 out.extend_from_slice(&(t.num_drag_types as u32).to_le_bytes());
1097 out.extend_from_slice(&t.timestamp.to_le_bytes());
1098
1099 let mut checksum_data = Vec::new();
1101 for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
1102 .chain(&t.current_vel_bins).chain(&t.data) {
1103 checksum_data.extend_from_slice(&v.to_le_bytes());
1104 }
1105 out.extend_from_slice(&crc32_ieee(&checksum_data).to_le_bytes());
1106
1107 let mut api = [0u8; 16];
1108 let bytes = t.api_version.as_bytes();
1109 api[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
1110 out.extend_from_slice(&api);
1111 out.extend_from_slice(&[0u8; 12]); for v in t.weight_bins.iter().chain(&t.bc_bins).chain(&t.muzzle_vel_bins)
1114 .chain(&t.current_vel_bins).chain(&t.data) {
1115 out.extend_from_slice(&v.to_le_bytes());
1116 }
1117 out
1118 }
1119
1120 #[test]
1121 fn test_from_bytes_roundtrip() {
1122 let original = create_test_table();
1123 let bytes = serialize_test_table(&original);
1124 let parsed = Bc5dTable::from_bytes(&bytes).expect("from_bytes should parse");
1125
1126 assert_eq!(parsed.caliber, original.caliber);
1127 assert_eq!(parsed.num_drag_types, original.num_drag_types);
1128 assert_eq!(parsed.weight_bins, original.weight_bins);
1129 assert_eq!(parsed.current_vel_bins, original.current_vel_bins);
1130 assert_eq!(parsed.data, original.data);
1131 assert_eq!(parsed.api_version, original.api_version);
1132
1133 let mut bad = bytes.clone();
1135 *bad.last_mut().unwrap() ^= 0xFF;
1136 assert!(Bc5dTable::from_bytes(&bad).is_err());
1137 }
1138
1139 #[test]
1140 fn test_generate_segments() {
1141 let mut uniform = create_test_table();
1144 uniform.data.iter_mut().for_each(|v| *v = 1.0);
1145 assert!(uniform
1146 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1147 .is_none());
1148
1149 let mut corrected = create_test_table();
1152 corrected.data.iter_mut().for_each(|v| *v = 0.9);
1153 let segments = corrected
1154 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1155 .expect("segments expected for a table with corrections");
1156 assert!(!segments.is_empty());
1157 for w in segments.windows(2) {
1158 assert!((segments[0].velocity_max - w[0].velocity_max).abs() >= 0.0);
1160 assert!(w[0].velocity_min >= w[1].velocity_max - 1e-6);
1161 }
1162 for s in &segments {
1163 assert!((s.bc_value - 0.4 * 0.9).abs() < 1e-6); assert!(s.velocity_max > s.velocity_min);
1165 }
1166 }
1167
1168 #[test]
1169 fn segment_schedule_carries_muzzle_corrected_fallback_bc() {
1170 let table = create_single_cell_test_table();
1171 let base_bc = 0.4;
1172 let schedule = table
1173 .generate_segment_schedule(base_bc, "G1", 168.0, 2500.0)
1174 .expect("uniform non-neutral correction should produce a schedule");
1175 let expected_fallback = table.get_effective_bc(168.0, base_bc, 2500.0, 2500.0, "G1");
1176
1177 assert!(!schedule.segments.is_empty());
1178 assert_eq!(expected_fallback.to_bits(), (base_bc * 0.875).to_bits());
1179 assert_eq!(schedule.fallback_bc.to_bits(), expected_fallback.to_bits());
1180 }
1181
1182 #[test]
1183 fn test_interp_idx_in_range() {
1184 let table = create_test_table();
1185
1186 let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
1188 assert_eq!(idx, 0);
1189 assert!((weight - 0.5).abs() < 0.01);
1190
1191 let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
1193 assert_eq!(idx, 0);
1194 assert!((weight - 1.0).abs() < 0.01);
1195 }
1196
1197 #[test]
1198 fn test_interp_idx_out_of_range() {
1199 let table = create_test_table();
1200
1201 let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
1203 assert_eq!(idx, 0);
1204 assert_eq!(weight, 0.0);
1205
1206 let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
1208 assert_eq!(idx, 1); assert_eq!(weight, 1.0);
1210 }
1211
1212 #[test]
1213 fn test_interp_idx_nan_defaults_to_first_bin() {
1214 let table = create_test_table();
1215
1216 assert_eq!(table.interp_idx(f32::NAN, &table.weight_bins), (0, 0.0));
1217 assert_eq!(
1218 table.interp_idx(f32::NEG_INFINITY, &table.weight_bins),
1219 (0, 0.0)
1220 );
1221 assert_eq!(
1222 table.interp_idx(f32::INFINITY, &table.weight_bins),
1223 (1, 1.0)
1224 );
1225 }
1226
1227 #[test]
1228 fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
1229 let table = create_single_cell_test_table();
1230
1231 assert_eq!(table.lookup(f64::NAN, 0.4, 2500.0, 2000.0, "G1"), 0.875);
1232 }
1233
1234 #[test]
1235 fn test_lookup_non_finite_table_cells_are_neutral() {
1236 for cell in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1237 let mut source = create_test_table();
1238 source.data.fill(cell);
1239 let bytes = serialize_test_table(&source);
1240 let table = Bc5dTable::from_bytes(&bytes).expect("CRC-valid table should load");
1241
1242 assert_eq!(
1243 table.lookup(125.0, 0.35, 2750.0, 1500.0, "G1"),
1244 1.0,
1245 "non-finite cell {cell:?} must produce a neutral correction"
1246 );
1247 assert_eq!(
1248 table.get_effective_bc(125.0, 0.35, 2750.0, 1500.0, "G1"),
1249 0.35,
1250 "neutral correction must preserve base BC for {cell:?}"
1251 );
1252 }
1253 }
1254
1255 #[test]
1256 fn test_lookup_returns_valid_range() {
1257 let table = create_test_table();
1258
1259 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
1260 assert!((0.5..=1.5).contains(&correction));
1261
1262 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
1263 assert!((0.5..=1.5).contains(&correction));
1264 }
1265
1266 #[test]
1267 fn test_effective_bc() {
1268 let table = create_test_table();
1269
1270 let base_bc = 0.4;
1271 let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
1272
1273 assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
1275 }
1276
1277 #[test]
1278 fn test_caliber_to_key() {
1279 assert_eq!(caliber_to_key(0.308), 308);
1280 assert_eq!(caliber_to_key(0.224), 224);
1281 assert_eq!(caliber_to_key(0.338), 338);
1282 }
1283
1284 #[test]
1288 fn ensure_caliber_matches_accepts_the_rounding_bucket_and_refuses_outside_it() {
1289 let table = create_test_table(); assert_eq!(table.caliber_key(), 308);
1293 assert!(table.ensure_caliber_matches(0.308).is_ok());
1294
1295 assert!(table.ensure_caliber_matches(0.3075).is_ok());
1298 assert!(table.ensure_caliber_matches(0.3084).is_ok());
1299
1300 assert!(matches!(
1302 table.ensure_caliber_matches(0.3085),
1303 Err(Bc5dError::CaliberMismatch { .. })
1304 ));
1305 assert!(matches!(
1306 table.ensure_caliber_matches(0.3074),
1307 Err(Bc5dError::CaliberMismatch { .. })
1308 ));
1309
1310 let err = table
1312 .ensure_caliber_matches(0.224)
1313 .expect_err("a .224 shot must not be served a .308 table");
1314 let message = err.to_string();
1315 assert!(
1316 message.contains("table is for 0.308, shot is 0.224"),
1317 "the error must name both calibers: {message}"
1318 );
1319
1320 assert!(table.ensure_caliber_matches(f64::NAN).is_err());
1322 assert!(table.ensure_caliber_matches(0.0).is_err());
1323 }
1324
1325 #[cfg(not(target_arch = "wasm32"))]
1328 #[test]
1329 fn manager_refuses_a_file_whose_header_caliber_is_foreign() {
1330 let dir = std::env::temp_dir().join(format!(
1331 "bc5d-mislabeled-{}-{}",
1332 std::process::id(),
1333 std::time::SystemTime::now()
1334 .duration_since(std::time::UNIX_EPOCH)
1335 .unwrap()
1336 .as_nanos()
1337 ));
1338 std::fs::create_dir_all(&dir).unwrap();
1339
1340 let mut foreign = create_test_table();
1341 foreign.caliber = 0.224;
1342 foreign.data.fill(0.9);
1343 std::fs::write(dir.join("bc5d_308.bin"), serialize_test_table(&foreign)).unwrap();
1344
1345 let mut manager = Bc5dTableManager::new(&dir);
1346 let err = manager
1347 .get_table(0.308)
1348 .expect_err("a mislabeled table must be refused, not applied");
1349 assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1350 assert!(matches!(
1352 manager.get_table(0.308),
1353 Err(Bc5dError::CaliberMismatch { .. })
1354 ));
1355 assert!(manager
1357 .lookup(0.308, 168.0, 0.4, 2500.0, 2000.0, "G1")
1358 .is_err());
1359
1360 std::fs::write(dir.join("bc5d_224.bin"), serialize_test_table(&foreign)).unwrap();
1362 assert!(manager.get_table(0.224).is_ok());
1363
1364 std::fs::remove_dir_all(&dir).unwrap();
1365 }
1366
1367 #[cfg(not(target_arch = "wasm32"))]
1370 #[test]
1371 fn path_cache_guarded_loader_refuses_a_foreign_caliber() {
1372 let dir = std::env::temp_dir().join(format!(
1373 "bc5d-guarded-load-{}-{}",
1374 std::process::id(),
1375 std::time::SystemTime::now()
1376 .duration_since(std::time::UNIX_EPOCH)
1377 .unwrap()
1378 .as_nanos()
1379 ));
1380 std::fs::create_dir_all(&dir).unwrap();
1381 let path = dir.join("bc5d_308.bin");
1382 std::fs::write(&path, serialize_test_table(&create_test_table())).unwrap();
1383
1384 assert!(path_cache::load_verified_for_caliber(&path, 0.308).is_ok());
1385 let err = path_cache::load_verified_for_caliber(&path, 0.243)
1386 .expect_err("a .243 shot must be refused a .308 table");
1387 assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1388 assert!(
1389 err.to_string().contains("table is for 0.308, shot is 0.243"),
1390 "{err}"
1391 );
1392
1393 std::fs::remove_dir_all(&dir).unwrap();
1394 }
1395
1396 #[test]
1397 fn test_table_metadata() {
1398 let table = create_test_table();
1399 assert!((table.caliber() - 0.308).abs() < 0.001);
1400 assert_eq!(table.version(), 2);
1401 assert_eq!(table.api_version(), "test");
1402 }
1403
1404 #[test]
1405 fn test_crc32() {
1406 let data = b"123456789";
1408 let crc = crc32_ieee(data);
1409 assert_eq!(crc, 0xCBF43926);
1410 }
1411
1412 #[test]
1413 fn test_bin_counts_matches_dimensions() {
1414 let table = create_test_table();
1415 assert_eq!(table.bin_counts(), (3, 3, 2, 3, 2));
1416 }
1417
1418 #[cfg(not(target_arch = "wasm32"))]
1422 #[test]
1423 fn path_cache_reuses_parsed_tables_and_detects_replacement() {
1424 let dir = std::env::temp_dir().join(format!(
1425 "bc5d-path-cache-{}-{}",
1426 std::process::id(),
1427 std::time::SystemTime::now()
1428 .duration_since(std::time::UNIX_EPOCH)
1429 .unwrap()
1430 .as_nanos()
1431 ));
1432 std::fs::create_dir_all(&dir).unwrap();
1433 let path = dir.join("bc5d_308.bin");
1434
1435 let table = create_test_table();
1436 let bytes = serialize_test_table(&table);
1437 std::fs::write(&path, &bytes).unwrap();
1438
1439 let first = path_cache::load_verified(&path).expect("valid table loads");
1440 let second = path_cache::load_verified(&path).expect("cached table loads");
1441 assert!(
1442 std::sync::Arc::ptr_eq(&first, &second),
1443 "an unchanged file must be served from the cache"
1444 );
1445
1446 let mut replacement = create_test_table();
1449 replacement.weight_bins.push(250.0);
1450 let extra_cells = replacement.num_drag_types
1451 * replacement.bc_bins.len()
1452 * replacement.muzzle_vel_bins.len()
1453 * replacement.current_vel_bins.len();
1454 replacement
1455 .data
1456 .resize(replacement.data.len() + extra_cells, 0.9f32);
1457 std::fs::write(&path, serialize_test_table(&replacement)).unwrap();
1458 let third = path_cache::load_verified(&path).expect("replacement loads");
1459 assert!(!std::sync::Arc::ptr_eq(&first, &third));
1460 assert_eq!(third.bin_counts().0, 4, "replacement content must be parsed");
1461
1462 let mut corrupt = serialize_test_table(&table);
1464 *corrupt.last_mut().unwrap() ^= 0xFF;
1465 std::fs::write(&path, corrupt).unwrap();
1466 assert!(matches!(
1467 path_cache::load_verified(&path),
1468 Err(Bc5dError::ChecksumMismatch { .. })
1469 ));
1470
1471 std::fs::remove_dir_all(&dir).unwrap();
1472 }
1473}