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 {
99 expected: u32,
100 actual: u32,
101 },
102 InvalidDimensions,
103 TableNotFound(f64),
104 NoTableDirectory,
105 CaliberMismatch {
109 table_caliber: f64,
110 shot_caliber: f64,
111 },
112}
113
114impl std::fmt::Display for Bc5dError {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 Bc5dError::IoError(e) => write!(f, "IO error: {}", e),
118 Bc5dError::InvalidMagic => write!(f, "Invalid file magic (expected 'BC5D')"),
119 Bc5dError::UnsupportedVersion(v) => write!(f, "Unsupported table version: {}", v),
120 Bc5dError::ChecksumMismatch { expected, actual } => {
121 write!(
122 f,
123 "Checksum mismatch: expected {:08x}, got {:08x}",
124 expected, actual
125 )
126 }
127 Bc5dError::InvalidDimensions => write!(f, "Invalid table dimensions"),
128 Bc5dError::TableNotFound(cal) => {
129 write!(f, "No BC5D table found for caliber {:.3}", cal)
130 }
131 Bc5dError::NoTableDirectory => write!(f, "No BC table directory configured"),
132 Bc5dError::CaliberMismatch {
133 table_caliber,
134 shot_caliber,
135 } => write!(
136 f,
137 "BC5D table caliber does not match the shot: table is for {:.3}, shot is {:.3} \
138 (BC5D tables are keyed to the nearest 0.001 in: {} vs {})",
139 table_caliber,
140 shot_caliber,
141 caliber_to_key(*table_caliber),
142 caliber_to_key(*shot_caliber),
143 ),
144 }
145 }
146}
147
148impl std::error::Error for Bc5dError {}
149
150impl From<std::io::Error> for Bc5dError {
151 fn from(e: std::io::Error) -> Self {
152 Bc5dError::IoError(e)
153 }
154}
155
156impl Bc5dTable {
157 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Bc5dError> {
159 let file = File::open(&path)?;
160 Self::from_reader(BufReader::new(file))
161 }
162
163 pub fn from_bytes(bytes: &[u8]) -> Result<Self, Bc5dError> {
170 Self::from_reader(std::io::Cursor::new(bytes))
171 }
172
173 fn from_reader<R: Read>(mut reader: R) -> Result<Self, Bc5dError> {
174 let mut magic = [0u8; 4];
176 reader.read_exact(&mut magic)?;
177 if &magic != MAGIC {
178 return Err(Bc5dError::InvalidMagic);
179 }
180
181 let version = read_u32(&mut reader)?;
183 if version != SUPPORTED_VERSION {
184 return Err(Bc5dError::UnsupportedVersion(version));
185 }
186
187 let caliber = read_f32(&mut reader)?;
188 let _flags = read_u32(&mut reader)?;
189 let _padding = read_u32(&mut reader)?;
190
191 let dim_weight = read_u32(&mut reader)? as usize;
192 let dim_bc = read_u32(&mut reader)? as usize;
193 let dim_muzzle_vel = read_u32(&mut reader)? as usize;
194 let dim_current_vel = read_u32(&mut reader)? as usize;
195 let dim_drag_types = read_u32(&mut reader)? as usize;
196
197 let timestamp = read_u64(&mut reader)?;
198 let stored_checksum = read_u32(&mut reader)?;
199
200 let mut api_version_bytes = [0u8; 16];
202 reader.read_exact(&mut api_version_bytes)?;
203 let api_version = String::from_utf8_lossy(&api_version_bytes)
204 .trim_end_matches('\0')
205 .to_string();
206
207 let mut reserved = [0u8; 12];
209 reader.read_exact(&mut reserved)?;
210
211 if dim_weight == 0
213 || dim_bc == 0
214 || dim_muzzle_vel == 0
215 || dim_current_vel == 0
216 || dim_drag_types == 0
217 {
218 return Err(Bc5dError::InvalidDimensions);
219 }
220
221 let weight_bins = read_f32_array(&mut reader, dim_weight)?;
223 let bc_bins = read_f32_array(&mut reader, dim_bc)?;
224 let muzzle_vel_bins = read_f32_array(&mut reader, dim_muzzle_vel)?;
225 let current_vel_bins = read_f32_array(&mut reader, dim_current_vel)?;
226
227 const MAX_TOTAL_CELLS: usize = 64_000_000; let total_cells = dim_drag_types
232 .checked_mul(dim_weight)
233 .and_then(|x| x.checked_mul(dim_bc))
234 .and_then(|x| x.checked_mul(dim_muzzle_vel))
235 .and_then(|x| x.checked_mul(dim_current_vel))
236 .filter(|&n| n <= MAX_TOTAL_CELLS)
237 .ok_or(Bc5dError::InvalidDimensions)?;
238 let data = read_f32_array(&mut reader, total_cells)?;
239
240 let mut checksum_data = Vec::new();
242 for &v in &weight_bins {
243 checksum_data.extend_from_slice(&v.to_le_bytes());
244 }
245 for &v in &bc_bins {
246 checksum_data.extend_from_slice(&v.to_le_bytes());
247 }
248 for &v in &muzzle_vel_bins {
249 checksum_data.extend_from_slice(&v.to_le_bytes());
250 }
251 for &v in ¤t_vel_bins {
252 checksum_data.extend_from_slice(&v.to_le_bytes());
253 }
254 for &v in &data {
255 checksum_data.extend_from_slice(&v.to_le_bytes());
256 }
257
258 let calculated_checksum = crc32_ieee(&checksum_data);
259 if calculated_checksum != stored_checksum {
260 return Err(Bc5dError::ChecksumMismatch {
261 expected: stored_checksum,
262 actual: calculated_checksum,
263 });
264 }
265
266 Ok(Bc5dTable {
267 caliber,
268 data,
269 weight_bins,
270 bc_bins,
271 muzzle_vel_bins,
272 current_vel_bins,
273 num_drag_types: dim_drag_types,
274 version,
275 api_version,
276 timestamp,
277 })
278 }
279
280 pub fn lookup(
293 &self,
294 weight_grains: f64,
295 base_bc: f64,
296 muzzle_velocity: f64,
297 current_velocity: f64,
298 drag_type: &str,
299 ) -> f64 {
300 let drag_idx = if drag_type.eq_ignore_ascii_case("G7") {
302 1
303 } else {
304 0
305 };
306
307 let drag_idx = drag_idx.min(self.num_drag_types - 1);
309
310 let (weight_idx, weight_w) = self.interp_idx(weight_grains as f32, &self.weight_bins);
312 let (bc_idx, bc_w) = self.interp_idx(base_bc as f32, &self.bc_bins);
313 let (muzzle_idx, muzzle_w) = self.interp_idx(muzzle_velocity as f32, &self.muzzle_vel_bins);
314 let (current_idx, current_w) =
315 self.interp_idx(current_velocity as f32, &self.current_vel_bins);
316
317 let mut result = 0.0f64;
319
320 for dw in 0..2 {
321 for db in 0..2 {
322 for dm in 0..2 {
323 for dc in 0..2 {
324 let weight = (if dw == 0 { 1.0 - weight_w } else { weight_w })
326 * (if db == 0 { 1.0 - bc_w } else { bc_w })
327 * (if dm == 0 { 1.0 - muzzle_w } else { muzzle_w })
328 * (if dc == 0 { 1.0 - current_w } else { current_w });
329
330 let wi = (weight_idx + dw).min(self.weight_bins.len() - 1);
332 let bi = (bc_idx + db).min(self.bc_bins.len() - 1);
333 let mi = (muzzle_idx + dm).min(self.muzzle_vel_bins.len() - 1);
334 let ci = (current_idx + dc).min(self.current_vel_bins.len() - 1);
335
336 let idx = self.flat_index(drag_idx, wi, bi, mi, ci);
338 result += weight * self.data[idx] as f64;
339 }
340 }
341 }
342 }
343
344 if !result.is_finite() {
347 return 1.0;
348 }
349
350 result.clamp(0.5, 1.5)
351 }
352
353 pub fn get_effective_bc(
357 &self,
358 weight_grains: f64,
359 base_bc: f64,
360 muzzle_velocity: f64,
361 current_velocity: f64,
362 drag_type: &str,
363 ) -> f64 {
364 let correction = self.lookup(
365 weight_grains,
366 base_bc,
367 muzzle_velocity,
368 current_velocity,
369 drag_type,
370 );
371 base_bc * correction
372 }
373
374 pub fn generate_segments(
389 &self,
390 base_bc: f64,
391 drag_type: &str,
392 weight_grains: f64,
393 muzzle_velocity_fps: Option<f64>,
394 ) -> Option<Vec<crate::BCSegmentData>> {
395 let mut breakpoints: Vec<f64> = vec![
396 4000.0, 3500.0, 3000.0, 2700.0, 2500.0, 2300.0, 2100.0, 2000.0, 1900.0, 1800.0, 1700.0,
397 1600.0, 1500.0, 1400.0, 1350.0, 1300.0, 1250.0, 1200.0, 1150.0, 1100.0, 1050.0, 1000.0,
398 950.0, 900.0, 850.0, 800.0, 700.0, 600.0, 500.0,
399 ];
400 if let Some(mv) = muzzle_velocity_fps {
401 breakpoints.push(mv);
402 }
403
404 let mut velocities: Vec<f64> = breakpoints
405 .into_iter()
406 .filter(|&v| v >= 500.0 && muzzle_velocity_fps.is_none_or(|mv| v <= mv))
407 .collect();
408 velocities.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
409 velocities.dedup();
410
411 let reference_mv = velocities.first().copied().unwrap_or(3000.0);
414
415 let mut segments: Vec<crate::BCSegmentData> = Vec::new();
416 let mut any_correction = false;
417 for i in 0..velocities.len().saturating_sub(1) {
418 let vel_max = velocities[i];
419 let vel_min = velocities[i + 1];
420 let vel_mid = (vel_max + vel_min) / 2.0;
421
422 let correction = self.lookup(weight_grains, base_bc, reference_mv, vel_mid, drag_type);
423 if (correction - 1.0).abs() > 1e-6 {
424 any_correction = true;
425 }
426 segments.push(crate::BCSegmentData {
427 velocity_min: vel_min,
428 velocity_max: vel_max,
429 bc_value: base_bc * correction,
430 });
431 }
432
433 if any_correction && !segments.is_empty() {
434 Some(segments)
435 } else {
436 None
437 }
438 }
439
440 #[cfg(any(test, target_arch = "wasm32"))]
442 pub(crate) fn generate_segment_schedule(
443 &self,
444 base_bc: f64,
445 drag_type: &str,
446 weight_grains: f64,
447 muzzle_velocity_fps: f64,
448 ) -> Option<Bc5dSegmentSchedule> {
449 let segments =
450 self.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_velocity_fps))?;
451 let fallback_bc = self.get_effective_bc(
452 weight_grains,
453 base_bc,
454 muzzle_velocity_fps,
455 muzzle_velocity_fps,
456 drag_type,
457 );
458
459 Some(Bc5dSegmentSchedule {
460 segments,
461 fallback_bc,
462 })
463 }
464
465 fn interp_idx(&self, value: f32, bins: &[f32]) -> (usize, f64) {
467 if bins.len() < 2 || value.is_nan() {
468 return (0, 0.0);
469 }
470
471 if value <= bins[0] {
473 return (0, 0.0);
474 }
475 if value >= bins[bins.len() - 1] {
476 return (bins.len().saturating_sub(2), 1.0);
477 }
478
479 let last_interval = bins.len().saturating_sub(2);
481 let idx = match bins.binary_search_by(|probe| {
482 probe
483 .partial_cmp(&value)
484 .unwrap_or(std::cmp::Ordering::Equal)
485 }) {
486 Ok(i) => i.saturating_sub(1).min(last_interval),
487 Err(i) => i.saturating_sub(1).min(last_interval),
488 };
489
490 let low = bins[idx];
492 let high = bins[idx + 1];
493 let weight = if high > low {
494 ((value - low) / (high - low)) as f64
495 } else {
496 0.0
497 };
498
499 (idx, weight)
500 }
501
502 fn flat_index(
504 &self,
505 drag_idx: usize,
506 weight_idx: usize,
507 bc_idx: usize,
508 muzzle_idx: usize,
509 current_idx: usize,
510 ) -> usize {
511 let n_weight = self.weight_bins.len();
512 let n_bc = self.bc_bins.len();
513 let n_muzzle = self.muzzle_vel_bins.len();
514 let n_current = self.current_vel_bins.len();
515
516 drag_idx * (n_weight * n_bc * n_muzzle * n_current)
517 + weight_idx * (n_bc * n_muzzle * n_current)
518 + bc_idx * (n_muzzle * n_current)
519 + muzzle_idx * n_current
520 + current_idx
521 }
522
523 pub fn caliber(&self) -> f32 {
525 self.caliber
526 }
527
528 pub fn caliber_key(&self) -> i32 {
532 caliber_to_key(self.caliber as f64)
533 }
534
535 pub fn ensure_caliber_matches(&self, shot_caliber_in: f64) -> Result<(), Bc5dError> {
571 if self.caliber_key() == caliber_to_key(shot_caliber_in) {
572 return Ok(());
573 }
574 Err(Bc5dError::CaliberMismatch {
575 table_caliber: self.caliber as f64,
576 shot_caliber: shot_caliber_in,
577 })
578 }
579
580 pub fn version(&self) -> u32 {
582 self.version
583 }
584
585 pub fn api_version(&self) -> &str {
587 &self.api_version
588 }
589
590 pub fn timestamp(&self) -> u64 {
592 self.timestamp
593 }
594
595 pub fn total_cells(&self) -> usize {
597 self.data.len()
598 }
599
600 pub fn bin_counts(&self) -> (usize, usize, usize, usize, usize) {
605 (
606 self.weight_bins.len(),
607 self.bc_bins.len(),
608 self.muzzle_vel_bins.len(),
609 self.current_vel_bins.len(),
610 self.num_drag_types,
611 )
612 }
613
614 pub fn dimensions_str(&self) -> String {
616 format!(
617 "{}x{}x{}x{}x{} (weight x bc x muzzle_vel x current_vel x drag_types)",
618 self.weight_bins.len(),
619 self.bc_bins.len(),
620 self.muzzle_vel_bins.len(),
621 self.current_vel_bins.len(),
622 self.num_drag_types
623 )
624 }
625
626 pub fn weight_range(&self) -> (f32, f32) {
628 (
629 *self.weight_bins.first().unwrap_or(&0.0),
630 *self.weight_bins.last().unwrap_or(&0.0),
631 )
632 }
633
634 pub fn velocity_range(&self) -> (f32, f32) {
636 (
637 *self.current_vel_bins.first().unwrap_or(&0.0),
638 *self.current_vel_bins.last().unwrap_or(&0.0),
639 )
640 }
641}
642
643impl Bc5dTableManager {
644 pub fn new<P: AsRef<Path>>(table_dir: P) -> Self {
646 Bc5dTableManager {
647 table_dir: Some(table_dir.as_ref().to_path_buf()),
648 tables: HashMap::new(),
649 }
650 }
651
652 pub fn empty() -> Self {
654 Bc5dTableManager {
655 table_dir: None,
656 tables: HashMap::new(),
657 }
658 }
659
660 pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
670 let caliber_key = caliber_to_key(caliber);
671
672 if self.tables.contains_key(&caliber_key) {
674 return Ok(self.tables.get(&caliber_key).unwrap());
675 }
676
677 let table_dir = self.table_dir.as_ref().ok_or(Bc5dError::NoTableDirectory)?;
679 let table_path = find_table_file(table_dir, caliber)?;
680 let table = Bc5dTable::load(&table_path)?;
681 table.ensure_caliber_matches(caliber)?;
682 self.tables.insert(caliber_key, table);
683 Ok(self.tables.get(&caliber_key).unwrap())
684 }
685
686 pub fn lookup(
688 &mut self,
689 caliber: f64,
690 weight_grains: f64,
691 base_bc: f64,
692 muzzle_velocity: f64,
693 current_velocity: f64,
694 drag_type: &str,
695 ) -> Result<f64, Bc5dError> {
696 let table = self.get_table(caliber)?;
697 Ok(table.lookup(
698 weight_grains,
699 base_bc,
700 muzzle_velocity,
701 current_velocity,
702 drag_type,
703 ))
704 }
705
706 pub fn get_effective_bc(
708 &mut self,
709 caliber: f64,
710 weight_grains: f64,
711 base_bc: f64,
712 muzzle_velocity: f64,
713 current_velocity: f64,
714 drag_type: &str,
715 ) -> Result<f64, Bc5dError> {
716 let table = self.get_table(caliber)?;
717 Ok(table.get_effective_bc(
718 weight_grains,
719 base_bc,
720 muzzle_velocity,
721 current_velocity,
722 drag_type,
723 ))
724 }
725
726 pub fn has_table(&self, caliber: f64) -> bool {
728 if let Some(ref table_dir) = self.table_dir {
729 find_table_file(table_dir, caliber).is_ok()
730 } else {
731 false
732 }
733 }
734
735 pub fn available_calibers(&self) -> Vec<f64> {
737 let mut calibers = Vec::new();
738 if let Some(ref table_dir) = self.table_dir {
739 if let Ok(entries) = std::fs::read_dir(table_dir) {
740 for entry in entries.flatten() {
741 let path = entry.path();
742 if let Some(ext) = path.extension() {
743 if ext == "bin" {
744 if let Some(stem) = path.file_stem() {
745 let name = stem.to_string_lossy();
746 if let Some(caliber) = name.strip_prefix("bc5d_") {
747 if let Ok(cal_int) = caliber.parse::<i32>() {
749 calibers.push(cal_int as f64 / 1000.0);
750 }
751 }
752 }
753 }
754 }
755 }
756 }
757 }
758 calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
759 calibers
760 }
761}
762
763pub fn caliber_to_key(caliber: f64) -> i32 {
772 (caliber * 1000.0).round() as i32
775}
776
777fn find_table_file(table_dir: &Path, caliber: f64) -> Result<PathBuf, Bc5dError> {
779 let caliber_int = (caliber * 1000.0).round() as i32;
780 let filename = format!("bc5d_{}.bin", caliber_int);
781 let path = table_dir.join(&filename);
782
783 if path.exists() {
784 return Ok(path);
785 }
786
787 let variations = [
789 format!("bc5d_{:03}.bin", caliber_int),
790 format!("bc5d_0{}.bin", caliber_int),
791 ];
792
793 for var in &variations {
794 let var_path = table_dir.join(var);
795 if var_path.exists() {
796 return Ok(var_path);
797 }
798 }
799
800 Err(Bc5dError::TableNotFound(caliber))
801}
802
803fn read_u32<R: Read>(reader: &mut R) -> Result<u32, std::io::Error> {
806 let mut buf = [0u8; 4];
807 reader.read_exact(&mut buf)?;
808 Ok(u32::from_le_bytes(buf))
809}
810
811fn read_u64<R: Read>(reader: &mut R) -> Result<u64, std::io::Error> {
812 let mut buf = [0u8; 8];
813 reader.read_exact(&mut buf)?;
814 Ok(u64::from_le_bytes(buf))
815}
816
817fn read_f32<R: Read>(reader: &mut R) -> Result<f32, std::io::Error> {
818 let mut buf = [0u8; 4];
819 reader.read_exact(&mut buf)?;
820 Ok(f32::from_le_bytes(buf))
821}
822
823#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
830fn read_f32_array<R: Read>(reader: &mut R, count: usize) -> Result<Vec<f32>, std::io::Error> {
831 const MAX_ELEMS: usize = 64_000_000; if count > MAX_ELEMS {
835 return Err(std::io::Error::new(
836 std::io::ErrorKind::InvalidData,
837 "f32 array length too large",
838 ));
839 }
840 let byte_len = count.checked_mul(4).ok_or_else(|| {
841 std::io::Error::new(std::io::ErrorKind::InvalidData, "f32 array length overflow")
842 })?;
843 let mut data = vec![0f32; count];
844 let mut buf = vec![0u8; byte_len];
845 reader.read_exact(&mut buf)?;
846
847 for (i, chunk) in buf.chunks_exact(4).enumerate() {
848 data[i] = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
849 }
850
851 Ok(data)
852}
853
854pub(crate) fn crc32_ieee(data: &[u8]) -> u32 {
856 const TABLE: [u32; 256] = make_crc32_table();
857 let mut crc = 0xFFFFFFFFu32;
858 for &byte in data {
859 let idx = ((crc ^ byte as u32) & 0xFF) as usize;
860 crc = (crc >> 8) ^ TABLE[idx];
861 }
862 !crc
863}
864
865const fn make_crc32_table() -> [u32; 256] {
866 const POLY: u32 = 0xEDB88320;
867 let mut table = [0u32; 256];
868 let mut i = 0;
869 while i < 256 {
870 let mut crc = i as u32;
871 let mut j = 0;
872 while j < 8 {
873 if crc & 1 != 0 {
874 crc = (crc >> 1) ^ POLY;
875 } else {
876 crc >>= 1;
877 }
878 j += 1;
879 }
880 table[i] = crc;
881 i += 1;
882 }
883 table
884}
885
886#[cfg(not(target_arch = "wasm32"))]
910pub mod path_cache {
911 use super::{Bc5dError, Bc5dTable};
912 use std::path::{Path, PathBuf};
913 use std::sync::{Arc, OnceLock, RwLock};
914
915 const CACHE_CAPACITY: usize = 4;
917
918 #[derive(Debug, Clone, PartialEq, Eq)]
919 struct CacheKey {
920 canonical_path: PathBuf,
921 file_size: u64,
922 content_crc: u32,
926 }
927
928 type CacheEntries = Vec<(CacheKey, Arc<Bc5dTable>)>;
929
930 fn cache() -> &'static RwLock<CacheEntries> {
931 static CACHE: OnceLock<RwLock<CacheEntries>> = OnceLock::new();
932 CACHE.get_or_init(|| RwLock::new(Vec::new()))
933 }
934
935 pub fn load_verified(path: &Path) -> Result<Arc<Bc5dTable>, Bc5dError> {
949 let canonical_path = std::fs::canonicalize(path)?;
950 let bytes = std::fs::read(&canonical_path)?;
953 let key = CacheKey {
954 canonical_path,
955 file_size: bytes.len() as u64,
956 content_crc: super::crc32_ieee(&bytes),
957 };
958
959 if let Ok(entries) = cache().read() {
960 if let Some((_, table)) = entries.iter().find(|(k, _)| *k == key) {
961 return Ok(Arc::clone(table));
962 }
963 }
964
965 let table = Arc::new(Bc5dTable::from_bytes(&bytes)?);
966
967 if let Ok(mut entries) = cache().write() {
968 if let Some((_, existing)) = entries.iter().find(|(k, _)| *k == key) {
971 return Ok(Arc::clone(existing));
972 }
973 if entries.len() >= CACHE_CAPACITY {
974 entries.remove(0);
975 }
976 entries.push((key, Arc::clone(&table)));
977 }
978 Ok(table)
979 }
980
981 pub fn load_verified_for_caliber(
992 path: &Path,
993 shot_caliber_in: f64,
994 ) -> Result<Arc<Bc5dTable>, Bc5dError> {
995 let table = load_verified(path)?;
996 table.ensure_caliber_matches(shot_caliber_in)?;
997 Ok(table)
998 }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004
1005 fn create_test_table() -> Bc5dTable {
1006 let weight_bins = vec![100.0, 150.0, 200.0];
1008 let bc_bins = vec![0.3, 0.4, 0.5];
1009 let muzzle_vel_bins = vec![2500.0, 3000.0];
1010 let current_vel_bins = vec![1000.0, 2000.0, 3000.0];
1011 let num_drag_types = 2;
1012
1013 let total = num_drag_types
1015 * weight_bins.len()
1016 * bc_bins.len()
1017 * muzzle_vel_bins.len()
1018 * current_vel_bins.len();
1019 let mut data = vec![1.0f32; total];
1020
1021 data[0] = 0.95; data[total - 1] = 1.05; Bc5dTable {
1028 caliber: 0.308,
1029 data,
1030 weight_bins,
1031 bc_bins,
1032 muzzle_vel_bins,
1033 current_vel_bins,
1034 num_drag_types,
1035 version: 2,
1036 api_version: "test".to_string(),
1037 timestamp: 0,
1038 }
1039 }
1040
1041 #[cfg(not(target_arch = "wasm32"))]
1048 #[test]
1049 fn path_cache_detects_same_size_replacement_under_an_identical_mtime() {
1050 use super::path_cache;
1051 use std::time::{Duration, SystemTime};
1052
1053 let dir = std::env::temp_dir().join(format!(
1054 "bc5d_same_mtime_{}_{:?}",
1055 std::process::id(),
1056 std::thread::current().id()
1057 ));
1058 let _ = std::fs::remove_dir_all(&dir);
1059 std::fs::create_dir_all(&dir).unwrap();
1060 let path = dir.join("bc5d_308.bin");
1061
1062 let pinned = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000);
1066 let pin = |p: &std::path::Path| {
1067 let f = std::fs::OpenOptions::new().write(true).open(p).unwrap();
1068 f.set_modified(pinned).unwrap();
1069 f.sync_all().unwrap();
1070 };
1071
1072 let original = create_test_table();
1073 let good = serialize_test_table(&original);
1074 std::fs::write(&path, &good).unwrap();
1075 pin(&path);
1076 let first = path_cache::load_verified(&path).expect("valid table loads");
1077
1078 let mut replacement = create_test_table();
1080 let last = replacement.data.len() - 1;
1081 replacement.data[last] = 0.5; let replaced = serialize_test_table(&replacement);
1083 assert_eq!(
1084 replaced.len(),
1085 good.len(),
1086 "test requires an identical size"
1087 );
1088 std::fs::write(&path, &replaced).unwrap();
1089 pin(&path);
1090 assert_eq!(
1091 std::fs::metadata(&path).unwrap().modified().unwrap(),
1092 pinned,
1093 "both writes must share one mtime for this test to mean anything"
1094 );
1095 let second = path_cache::load_verified(&path).expect("replacement loads");
1096 assert!(
1097 !std::sync::Arc::ptr_eq(&first, &second),
1098 "a same-size replacement under an identical mtime must NOT be served from cache"
1099 );
1100
1101 let mut corrupt = good.clone();
1104 *corrupt.last_mut().unwrap() ^= 0xFF;
1105 assert_eq!(corrupt.len(), good.len());
1106 std::fs::write(&path, &corrupt).unwrap();
1107 pin(&path);
1108 assert!(
1109 matches!(
1110 path_cache::load_verified(&path),
1111 Err(Bc5dError::ChecksumMismatch { .. })
1112 ),
1113 "corruption under an identical mtime must be detected, not cached"
1114 );
1115
1116 std::fs::write(&path, &good).unwrap();
1118 pin(&path);
1119 let restored = path_cache::load_verified(&path).expect("original loads again");
1120 assert!(
1121 std::sync::Arc::ptr_eq(&first, &restored),
1122 "identical content must still be served from the cache"
1123 );
1124
1125 let _ = std::fs::remove_dir_all(&dir);
1126 }
1127
1128 fn create_single_cell_test_table() -> Bc5dTable {
1129 Bc5dTable {
1130 caliber: 0.308,
1131 data: vec![0.875],
1132 weight_bins: vec![168.0],
1133 bc_bins: vec![0.4],
1134 muzzle_vel_bins: vec![2500.0],
1135 current_vel_bins: vec![2000.0],
1136 num_drag_types: 1,
1137 version: 2,
1138 api_version: "test".to_string(),
1139 timestamp: 0,
1140 }
1141 }
1142
1143 fn serialize_test_table(t: &Bc5dTable) -> Vec<u8> {
1146 let mut out = Vec::new();
1147 out.extend_from_slice(MAGIC);
1148 out.extend_from_slice(&t.version.to_le_bytes());
1149 out.extend_from_slice(&t.caliber.to_le_bytes());
1150 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());
1153 out.extend_from_slice(&(t.bc_bins.len() as u32).to_le_bytes());
1154 out.extend_from_slice(&(t.muzzle_vel_bins.len() as u32).to_le_bytes());
1155 out.extend_from_slice(&(t.current_vel_bins.len() as u32).to_le_bytes());
1156 out.extend_from_slice(&(t.num_drag_types as u32).to_le_bytes());
1157 out.extend_from_slice(&t.timestamp.to_le_bytes());
1158
1159 let mut checksum_data = Vec::new();
1161 for v in t
1162 .weight_bins
1163 .iter()
1164 .chain(&t.bc_bins)
1165 .chain(&t.muzzle_vel_bins)
1166 .chain(&t.current_vel_bins)
1167 .chain(&t.data)
1168 {
1169 checksum_data.extend_from_slice(&v.to_le_bytes());
1170 }
1171 out.extend_from_slice(&crc32_ieee(&checksum_data).to_le_bytes());
1172
1173 let mut api = [0u8; 16];
1174 let bytes = t.api_version.as_bytes();
1175 api[..bytes.len().min(16)].copy_from_slice(&bytes[..bytes.len().min(16)]);
1176 out.extend_from_slice(&api);
1177 out.extend_from_slice(&[0u8; 12]); for v in t
1180 .weight_bins
1181 .iter()
1182 .chain(&t.bc_bins)
1183 .chain(&t.muzzle_vel_bins)
1184 .chain(&t.current_vel_bins)
1185 .chain(&t.data)
1186 {
1187 out.extend_from_slice(&v.to_le_bytes());
1188 }
1189 out
1190 }
1191
1192 #[test]
1193 fn test_from_bytes_roundtrip() {
1194 let original = create_test_table();
1195 let bytes = serialize_test_table(&original);
1196 let parsed = Bc5dTable::from_bytes(&bytes).expect("from_bytes should parse");
1197
1198 assert_eq!(parsed.caliber, original.caliber);
1199 assert_eq!(parsed.num_drag_types, original.num_drag_types);
1200 assert_eq!(parsed.weight_bins, original.weight_bins);
1201 assert_eq!(parsed.current_vel_bins, original.current_vel_bins);
1202 assert_eq!(parsed.data, original.data);
1203 assert_eq!(parsed.api_version, original.api_version);
1204
1205 let mut bad = bytes.clone();
1207 *bad.last_mut().unwrap() ^= 0xFF;
1208 assert!(Bc5dTable::from_bytes(&bad).is_err());
1209 }
1210
1211 #[test]
1212 fn test_generate_segments() {
1213 let mut uniform = create_test_table();
1216 uniform.data.iter_mut().for_each(|v| *v = 1.0);
1217 assert!(uniform
1218 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1219 .is_none());
1220
1221 let mut corrected = create_test_table();
1224 corrected.data.iter_mut().for_each(|v| *v = 0.9);
1225 let segments = corrected
1226 .generate_segments(0.4, "G1", 150.0, Some(2700.0))
1227 .expect("segments expected for a table with corrections");
1228 assert!(!segments.is_empty());
1229 for w in segments.windows(2) {
1230 assert!((segments[0].velocity_max - w[0].velocity_max).abs() >= 0.0);
1232 assert!(w[0].velocity_min >= w[1].velocity_max - 1e-6);
1233 }
1234 for s in &segments {
1235 assert!((s.bc_value - 0.4 * 0.9).abs() < 1e-6); assert!(s.velocity_max > s.velocity_min);
1237 }
1238 }
1239
1240 #[test]
1241 fn segment_schedule_carries_muzzle_corrected_fallback_bc() {
1242 let table = create_single_cell_test_table();
1243 let base_bc = 0.4;
1244 let schedule = table
1245 .generate_segment_schedule(base_bc, "G1", 168.0, 2500.0)
1246 .expect("uniform non-neutral correction should produce a schedule");
1247 let expected_fallback = table.get_effective_bc(168.0, base_bc, 2500.0, 2500.0, "G1");
1248
1249 assert!(!schedule.segments.is_empty());
1250 assert_eq!(expected_fallback.to_bits(), (base_bc * 0.875).to_bits());
1251 assert_eq!(schedule.fallback_bc.to_bits(), expected_fallback.to_bits());
1252 }
1253
1254 #[test]
1255 fn test_interp_idx_in_range() {
1256 let table = create_test_table();
1257
1258 let (idx, weight) = table.interp_idx(125.0, &table.weight_bins);
1260 assert_eq!(idx, 0);
1261 assert!((weight - 0.5).abs() < 0.01);
1262
1263 let (idx, weight) = table.interp_idx(150.0, &table.weight_bins);
1265 assert_eq!(idx, 0);
1266 assert!((weight - 1.0).abs() < 0.01);
1267 }
1268
1269 #[test]
1270 fn test_interp_idx_out_of_range() {
1271 let table = create_test_table();
1272
1273 let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
1275 assert_eq!(idx, 0);
1276 assert_eq!(weight, 0.0);
1277
1278 let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
1280 assert_eq!(idx, 1); assert_eq!(weight, 1.0);
1282 }
1283
1284 #[test]
1285 fn test_interp_idx_nan_defaults_to_first_bin() {
1286 let table = create_test_table();
1287
1288 assert_eq!(table.interp_idx(f32::NAN, &table.weight_bins), (0, 0.0));
1289 assert_eq!(
1290 table.interp_idx(f32::NEG_INFINITY, &table.weight_bins),
1291 (0, 0.0)
1292 );
1293 assert_eq!(
1294 table.interp_idx(f32::INFINITY, &table.weight_bins),
1295 (1, 1.0)
1296 );
1297 }
1298
1299 #[test]
1300 fn test_lookup_nan_with_single_bin_axes_uses_only_cell() {
1301 let table = create_single_cell_test_table();
1302
1303 assert_eq!(table.lookup(f64::NAN, 0.4, 2500.0, 2000.0, "G1"), 0.875);
1304 }
1305
1306 #[test]
1307 fn test_lookup_non_finite_table_cells_are_neutral() {
1308 for cell in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1309 let mut source = create_test_table();
1310 source.data.fill(cell);
1311 let bytes = serialize_test_table(&source);
1312 let table = Bc5dTable::from_bytes(&bytes).expect("CRC-valid table should load");
1313
1314 assert_eq!(
1315 table.lookup(125.0, 0.35, 2750.0, 1500.0, "G1"),
1316 1.0,
1317 "non-finite cell {cell:?} must produce a neutral correction"
1318 );
1319 assert_eq!(
1320 table.get_effective_bc(125.0, 0.35, 2750.0, 1500.0, "G1"),
1321 0.35,
1322 "neutral correction must preserve base BC for {cell:?}"
1323 );
1324 }
1325 }
1326
1327 #[test]
1328 fn test_lookup_returns_valid_range() {
1329 let table = create_test_table();
1330
1331 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G1");
1332 assert!((0.5..=1.5).contains(&correction));
1333
1334 let correction = table.lookup(150.0, 0.4, 2750.0, 2000.0, "G7");
1335 assert!((0.5..=1.5).contains(&correction));
1336 }
1337
1338 #[test]
1339 fn test_effective_bc() {
1340 let table = create_test_table();
1341
1342 let base_bc = 0.4;
1343 let effective = table.get_effective_bc(150.0, base_bc, 2750.0, 2000.0, "G1");
1344
1345 assert!(effective >= base_bc * 0.5 && effective <= base_bc * 1.5);
1347 }
1348
1349 #[test]
1350 fn test_caliber_to_key() {
1351 assert_eq!(caliber_to_key(0.308), 308);
1352 assert_eq!(caliber_to_key(0.224), 224);
1353 assert_eq!(caliber_to_key(0.338), 338);
1354 }
1355
1356 #[test]
1360 fn ensure_caliber_matches_accepts_the_rounding_bucket_and_refuses_outside_it() {
1361 let table = create_test_table(); assert_eq!(table.caliber_key(), 308);
1365 assert!(table.ensure_caliber_matches(0.308).is_ok());
1366
1367 assert!(table.ensure_caliber_matches(0.3075).is_ok());
1370 assert!(table.ensure_caliber_matches(0.3084).is_ok());
1371
1372 assert!(matches!(
1374 table.ensure_caliber_matches(0.3085),
1375 Err(Bc5dError::CaliberMismatch { .. })
1376 ));
1377 assert!(matches!(
1378 table.ensure_caliber_matches(0.3074),
1379 Err(Bc5dError::CaliberMismatch { .. })
1380 ));
1381
1382 let err = table
1384 .ensure_caliber_matches(0.224)
1385 .expect_err("a .224 shot must not be served a .308 table");
1386 let message = err.to_string();
1387 assert!(
1388 message.contains("table is for 0.308, shot is 0.224"),
1389 "the error must name both calibers: {message}"
1390 );
1391
1392 assert!(table.ensure_caliber_matches(f64::NAN).is_err());
1394 assert!(table.ensure_caliber_matches(0.0).is_err());
1395 }
1396
1397 #[cfg(not(target_arch = "wasm32"))]
1400 #[test]
1401 fn manager_refuses_a_file_whose_header_caliber_is_foreign() {
1402 let dir = std::env::temp_dir().join(format!(
1403 "bc5d-mislabeled-{}-{}",
1404 std::process::id(),
1405 std::time::SystemTime::now()
1406 .duration_since(std::time::UNIX_EPOCH)
1407 .unwrap()
1408 .as_nanos()
1409 ));
1410 std::fs::create_dir_all(&dir).unwrap();
1411
1412 let mut foreign = create_test_table();
1413 foreign.caliber = 0.224;
1414 foreign.data.fill(0.9);
1415 std::fs::write(dir.join("bc5d_308.bin"), serialize_test_table(&foreign)).unwrap();
1416
1417 let mut manager = Bc5dTableManager::new(&dir);
1418 let err = manager
1419 .get_table(0.308)
1420 .expect_err("a mislabeled table must be refused, not applied");
1421 assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1422 assert!(matches!(
1424 manager.get_table(0.308),
1425 Err(Bc5dError::CaliberMismatch { .. })
1426 ));
1427 assert!(manager
1429 .lookup(0.308, 168.0, 0.4, 2500.0, 2000.0, "G1")
1430 .is_err());
1431
1432 std::fs::write(dir.join("bc5d_224.bin"), serialize_test_table(&foreign)).unwrap();
1434 assert!(manager.get_table(0.224).is_ok());
1435
1436 std::fs::remove_dir_all(&dir).unwrap();
1437 }
1438
1439 #[cfg(not(target_arch = "wasm32"))]
1442 #[test]
1443 fn path_cache_guarded_loader_refuses_a_foreign_caliber() {
1444 let dir = std::env::temp_dir().join(format!(
1445 "bc5d-guarded-load-{}-{}",
1446 std::process::id(),
1447 std::time::SystemTime::now()
1448 .duration_since(std::time::UNIX_EPOCH)
1449 .unwrap()
1450 .as_nanos()
1451 ));
1452 std::fs::create_dir_all(&dir).unwrap();
1453 let path = dir.join("bc5d_308.bin");
1454 std::fs::write(&path, serialize_test_table(&create_test_table())).unwrap();
1455
1456 assert!(path_cache::load_verified_for_caliber(&path, 0.308).is_ok());
1457 let err = path_cache::load_verified_for_caliber(&path, 0.243)
1458 .expect_err("a .243 shot must be refused a .308 table");
1459 assert!(matches!(err, Bc5dError::CaliberMismatch { .. }), "{err}");
1460 assert!(
1461 err.to_string()
1462 .contains("table is for 0.308, shot is 0.243"),
1463 "{err}"
1464 );
1465
1466 std::fs::remove_dir_all(&dir).unwrap();
1467 }
1468
1469 #[test]
1470 fn test_table_metadata() {
1471 let table = create_test_table();
1472 assert!((table.caliber() - 0.308).abs() < 0.001);
1473 assert_eq!(table.version(), 2);
1474 assert_eq!(table.api_version(), "test");
1475 }
1476
1477 #[test]
1478 fn test_crc32() {
1479 let data = b"123456789";
1481 let crc = crc32_ieee(data);
1482 assert_eq!(crc, 0xCBF43926);
1483 }
1484
1485 #[test]
1486 fn test_bin_counts_matches_dimensions() {
1487 let table = create_test_table();
1488 assert_eq!(table.bin_counts(), (3, 3, 2, 3, 2));
1489 }
1490
1491 #[cfg(not(target_arch = "wasm32"))]
1495 #[test]
1496 fn path_cache_reuses_parsed_tables_and_detects_replacement() {
1497 let dir = std::env::temp_dir().join(format!(
1498 "bc5d-path-cache-{}-{}",
1499 std::process::id(),
1500 std::time::SystemTime::now()
1501 .duration_since(std::time::UNIX_EPOCH)
1502 .unwrap()
1503 .as_nanos()
1504 ));
1505 std::fs::create_dir_all(&dir).unwrap();
1506 let path = dir.join("bc5d_308.bin");
1507
1508 let table = create_test_table();
1509 let bytes = serialize_test_table(&table);
1510 std::fs::write(&path, &bytes).unwrap();
1511
1512 let first = path_cache::load_verified(&path).expect("valid table loads");
1513 let second = path_cache::load_verified(&path).expect("cached table loads");
1514 assert!(
1515 std::sync::Arc::ptr_eq(&first, &second),
1516 "an unchanged file must be served from the cache"
1517 );
1518
1519 let mut replacement = create_test_table();
1522 replacement.weight_bins.push(250.0);
1523 let extra_cells = replacement.num_drag_types
1524 * replacement.bc_bins.len()
1525 * replacement.muzzle_vel_bins.len()
1526 * replacement.current_vel_bins.len();
1527 replacement
1528 .data
1529 .resize(replacement.data.len() + extra_cells, 0.9f32);
1530 std::fs::write(&path, serialize_test_table(&replacement)).unwrap();
1531 let third = path_cache::load_verified(&path).expect("replacement loads");
1532 assert!(!std::sync::Arc::ptr_eq(&first, &third));
1533 assert_eq!(
1534 third.bin_counts().0,
1535 4,
1536 "replacement content must be parsed"
1537 );
1538
1539 let mut corrupt = serialize_test_table(&table);
1541 *corrupt.last_mut().unwrap() ^= 0xFF;
1542 std::fs::write(&path, corrupt).unwrap();
1543 assert!(matches!(
1544 path_cache::load_verified(&path),
1545 Err(Bc5dError::ChecksumMismatch { .. })
1546 ));
1547
1548 std::fs::remove_dir_all(&dir).unwrap();
1549 }
1550}