Skip to main content

ballistics_engine/
bc_table_5d.rs

1// BC5D - 5-Dimensional BC Correction Table with Caliber-Specific Files
2//
3// This module provides offline BC corrections by loading precomputed tables
4// of correction factors derived from ML model predictions. The tables are
5// caliber-specific and indexed by:
6//   - Weight (grains) - caliber-specific ranges
7//   - Base BC (0.05-1.2)
8//   - Muzzle Velocity (2000-4000 fps)
9//   - Current Velocity (500-4000 fps, dense in transonic)
10//   - Drag Model (G1, G7)
11//
12// Binary file format (BC5D v2):
13//   Header (80 bytes):
14//     - Magic: 4 bytes ('BC5D')
15//     - Version: 4 bytes (uint32)
16//     - Caliber: 4 bytes (float32)
17//     - Flags: 4 bytes (uint32)
18//     - Padding: 4 bytes
19//     - dim_weight: 4 bytes (uint32)
20//     - dim_bc: 4 bytes (uint32)
21//     - dim_muzzle_vel: 4 bytes (uint32)
22//     - dim_current_vel: 4 bytes (uint32)
23//     - dim_drag_types: 4 bytes (uint32)
24//     - timestamp: 8 bytes (uint64)
25//     - checksum: 4 bytes (uint32, CRC32 of data section)
26//     - api_version: 16 bytes (null-padded string)
27//     - reserved: 12 bytes
28//   Bin definitions:
29//     - Weight bins: dim_weight * 4 bytes (float32)
30//     - BC bins: dim_bc * 4 bytes (float32)
31//     - Muzzle velocity bins: dim_muzzle_vel * 4 bytes (float32)
32//     - Current velocity bins: dim_current_vel * 4 bytes (float32)
33//   Data section:
34//     - Correction factors: total_cells * 4 bytes (float32)
35//     - Layout: [drag_type][weight][bc][muzzle_vel][current_vel]
36//
37// Correction factors are ratios: predicted_bc / base_bc
38// Range: 0.5 to 1.5 (clipped during generation)
39
40use std::collections::HashMap;
41use std::fs::File;
42use std::io::{BufReader, Read};
43use std::path::{Path, PathBuf};
44
45/// Magic bytes for BC5D format
46const MAGIC: &[u8; 4] = b"BC5D";
47
48/// Supported format version
49const SUPPORTED_VERSION: u32 = 2;
50
51/// BC5D table with 4D interpolation (drag type is discrete)
52#[derive(Debug)]
53pub struct Bc5dTable {
54    /// Caliber this table is for
55    caliber: f32,
56    /// Correction data: [drag_type][weight][bc][muzzle_vel][current_vel]
57    data: Vec<f32>,
58    /// Weight bin values (grains)
59    weight_bins: Vec<f32>,
60    /// BC bin values
61    bc_bins: Vec<f32>,
62    /// Muzzle velocity bin values (fps)
63    muzzle_vel_bins: Vec<f32>,
64    /// Current velocity bin values (fps)
65    current_vel_bins: Vec<f32>,
66    /// Number of drag types (typically 2: G1=0, G7=1)
67    num_drag_types: usize,
68    /// Table version
69    version: u32,
70    /// API version used to generate the table
71    api_version: String,
72    /// Generation timestamp
73    timestamp: u64,
74}
75
76/// A velocity-keyed BC schedule and the scalar BC used for any interior coverage gap.
77#[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/// Manager for loading caliber-specific BC5D tables
84#[derive(Debug, Default)]
85pub struct Bc5dTableManager {
86    /// Directory containing BC5D table files
87    table_dir: Option<PathBuf>,
88    /// Loaded tables by caliber (rounded to 3 decimal places)
89    tables: HashMap<i32, Bc5dTable>,
90}
91
92/// Error type for BC5D table operations
93#[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    /// The table's own header caliber is not the caliber of the shot it was handed to.
103    /// See [`Bc5dTable::ensure_caliber_matches`] for why this is refused rather than
104    /// applied or silently ignored. Both calibers are in inches.
105    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    /// Load a BC5D table from a binary file
146    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    /// Parse a BC5D table directly from an in-memory byte slice.
152    ///
153    /// Behaves identically to [`Bc5dTable::load`] but performs no filesystem
154    /// access, which makes it usable from WASM (`wasm32-unknown-unknown`, where
155    /// there is no `std::fs`). The host JS/Node layer fetches the `.bin` and
156    /// hands the raw bytes across the boundary.
157    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        // Read and validate magic
163        let mut magic = [0u8; 4];
164        reader.read_exact(&mut magic)?;
165        if &magic != MAGIC {
166            return Err(Bc5dError::InvalidMagic);
167        }
168
169        // Read header fields
170        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        // Read API version (16 bytes, null-terminated)
189        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        // Skip reserved bytes
196        let mut reserved = [0u8; 12];
197        reader.read_exact(&mut reserved)?;
198
199        // Validate dimensions
200        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        // Read bin definitions
205        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        // Read data section. Bound the product with checked arithmetic so a corrupt or
211        // hostile file cannot overflow (debug panic / release wrap) or trigger a huge OOM
212        // allocation before the trailing CRC check can reject it.
213        const MAX_TOTAL_CELLS: usize = 64_000_000; // ~256 MB of f32; far above any real table
214        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        // Verify checksum (CRC32 of bins + data)
224        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 &current_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    /// Look up a BC correction factor with 4D linear interpolation
264    /// (drag type is discrete, not interpolated)
265    ///
266    /// # Arguments
267    /// * `weight_grains` - Bullet weight in grains
268    /// * `base_bc` - Published BC value
269    /// * `muzzle_velocity` - Initial muzzle velocity in fps
270    /// * `current_velocity` - Current bullet velocity in fps
271    /// * `drag_type` - "G1" or "G7"
272    ///
273    /// # Returns
274    /// Correction factor (multiply published BC by this value)
275    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        // Get drag type index (0 = G1, 1 = G7)
284        let drag_idx = if drag_type.eq_ignore_ascii_case("G7") { 1 } else { 0 };
285
286        // Clamp drag_idx to valid range
287        let drag_idx = drag_idx.min(self.num_drag_types - 1);
288
289        // Find interpolation indices and weights for each continuous dimension
290        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        // 4D linear interpolation (16 corners of a hypercube)
296        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                        // Calculate weight for this corner
303                        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                        // Get clamped indices
309                        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                        // Calculate flat index
315                        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        // A correction is multiplicative, so an undefined table result must be neutral rather
323        // than silently becoming the most aggressive allowed degradation.
324        if !result.is_finite() {
325            return 1.0;
326        }
327
328        result.clamp(0.5, 1.5)
329    }
330
331    /// Get the effective BC at a given velocity
332    ///
333    /// This multiplies the base BC by the correction factor from the table.
334    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    /// Generate velocity-dependent BC segments for a bullet from this table.
347    ///
348    /// This mirrors the CLI's `--bc-table-dir` segment synthesis: the 4D
349    /// correction surface is sampled at a fixed ladder of velocity breakpoints
350    /// (from 500 fps up through the muzzle velocity) and each adjacent pair
351    /// becomes a [`crate::BCSegmentData`] carrying the corrected BC over that
352    /// band. The solver consumes these via `inputs.bc_segments_data` +
353    /// `use_bc_segments`, giving the same velocity-dependent BC degradation
354    /// offline that the online solver produces.
355    ///
356    /// All velocities are in fps and `weight_grains` in grains, matching the
357    /// table's native units. Returns `None` when the table carries no
358    /// meaningful correction for this bullet (every sampled cell ~= 1.0), so
359    /// callers can leave the constant published BC in place.
360    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        // Correction factors were generated relative to the highest (muzzle)
384        // velocity, so anchor every lookup to that reference.
385        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    /// Generate the BC5D schedule consumed by the WASM frontend.
414    #[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    /// Find interpolation index and weight for a value in bins
439    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        // Handle out of range (clamp to edges)
445        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        // Binary search for interval containing value
453        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        // Calculate interpolation weight
464        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    /// Calculate flat array index from 5D indices
476    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    /// Get caliber this table is for
490    pub fn caliber(&self) -> f32 {
491        self.caliber
492    }
493
494    /// The table's own caliber as a BC5D key ([`caliber_to_key`]) — exactly the value
495    /// [`Self::ensure_caliber_matches`] compares against, and the value `bc5d.info`
496    /// reports as `caliber_key` so an app can pre-check a downloaded table itself.
497    pub fn caliber_key(&self) -> i32 {
498        caliber_to_key(self.caliber as f64)
499    }
500
501    /// Refuse this table for a shot of a different caliber.
502    ///
503    /// `shot_caliber_in` is the shot's bullet diameter in INCHES.
504    ///
505    /// # Why this is an error and not a warning or a silent skip
506    ///
507    /// Nothing in the lookup path fails on a foreign table: [`Self::lookup`] clamps
508    /// out-of-range values to the edge bins, so a table for another caliber still
509    /// returns a plausible-looking correction for every cell and
510    /// [`Self::generate_segments`] still emits a full ladder. Measured: the published
511    /// `bc5d_224.bin` handed a 175 gr / G1 0.505 / 2600 fps .308 shot yields a
512    /// 25-segment ladder whose segment BCs are 0.4710..0.5114 where the .308 table
513    /// gives 0.4989..0.5072 — ~6.7 % off in the low bands, with no diagnostic
514    /// anywhere; a .308 table on a .243 shot measures -17.9 % effective BC. A
515    /// wrong-caliber table is therefore WORSE than no table at all: no table leaves
516    /// the published BC intact, while a wrong one silently biases every row. So the
517    /// caller is refused outright — never corrected with foreign data, and never
518    /// quietly downgraded to an uncorrected solve either, because a caller that asked
519    /// for a table and got an unannotated uncorrected answer cannot tell.
520    ///
521    /// # Matching rule
522    ///
523    /// Equality of the 3-digit BC5D caliber key ([`caliber_to_key`]) — the same key
524    /// `find_table_file` uses to choose `bc5d_<key>.bin` for `--bc-table-dir`. The
525    /// guard therefore accepts exactly the diameters for which the CLI would have
526    /// selected this table's own filename: precedent decides the rule, so the CLI and
527    /// the path/bytes consumers (bridge cards, bridge solve, solve-json, WASM) cannot
528    /// disagree about what "this table is for my shot" means.
529    ///
530    /// In practice that is a half-thousandth tolerance — a `308` table accepts
531    /// `[0.3075, 0.3085)` — expressed as a bucket rather than a centered epsilon.
532    /// Bucketing matters: the header caliber is an `f32`, so `0.308` arrives as
533    /// 0.30799998, and rounding to the nearest thousandth absorbs that representation
534    /// error, whereas a centered `|a - b| <= 0.0005` comparison would have to carry a
535    /// separate fudge for it.
536    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    /// Get table version
547    pub fn version(&self) -> u32 {
548        self.version
549    }
550
551    /// Get API version used to generate the table
552    pub fn api_version(&self) -> &str {
553        &self.api_version
554    }
555
556    /// Get generation timestamp
557    pub fn timestamp(&self) -> u64 {
558        self.timestamp
559    }
560
561    /// Get total number of cells in the table
562    pub fn total_cells(&self) -> usize {
563        self.data.len()
564    }
565
566    /// Bin counts per axis: `(weight, bc, muzzle_vel, current_vel, drag_types)`.
567    ///
568    /// The structured counterpart of [`Self::dimensions_str`], for callers that
569    /// report table metadata over a wire (e.g. the bridge's `bc5d.info`).
570    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    /// Get table dimensions as a string
581    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    /// Get weight range
593    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    /// Get velocity range
598    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    /// Create a new table manager with a directory path
605    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    /// Create an empty manager (no table directory)
613    pub fn empty() -> Self {
614        Bc5dTableManager {
615            table_dir: None,
616            tables: HashMap::new(),
617        }
618    }
619
620    /// Get or load the table for a caliber
621    ///
622    /// Tables are cached after first load.
623    ///
624    /// The file is selected by NAME (`bc5d_<key>.bin`) but verified by CONTENT: a file
625    /// whose header caliber is not `caliber` is rejected with
626    /// [`Bc5dError::CaliberMismatch`] and never cached, so a rotated manifest, a
627    /// hand-copied `.bin`, or a future generator bug cannot silently bias every lookup
628    /// (see [`Bc5dTable::ensure_caliber_matches`]).
629    pub fn get_table(&mut self, caliber: f64) -> Result<&Bc5dTable, Bc5dError> {
630        let caliber_key = caliber_to_key(caliber);
631
632        // Check if already loaded
633        if self.tables.contains_key(&caliber_key) {
634            return Ok(self.tables.get(&caliber_key).unwrap());
635        }
636
637        // Need to load
638        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    /// Look up BC correction for a bullet
647    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    /// Get effective BC with correction applied
661    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    /// Check if a table is available for a caliber
675    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    /// List available calibers in the table directory
684    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                                    // Parse caliber from filename (e.g., bc5d_308.bin -> 0.308)
696                                    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
711/// The canonical BC5D caliber key: a caliber in inches rounded to the nearest
712/// thousandth, as an integer (0.308 -> 308, 0.224 -> 224).
713///
714/// This is the ONE key BC5D identity is expressed in. `find_table_file` builds
715/// `bc5d_<key>.bin` from it (so it decides which file `--bc-table-dir` picks),
716/// [`Bc5dTableManager`] caches by it, and [`Bc5dTable::ensure_caliber_matches`]
717/// compares by it — one rule, so the CLI's file selection and every path/bytes
718/// consumer's identity check cannot drift apart.
719pub fn caliber_to_key(caliber: f64) -> i32 {
720    // A non-finite caliber saturates to 0 here (Rust's float->int cast), which is not
721    // any real table's key, so it falls out as a mismatch rather than matching anything.
722    (caliber * 1000.0).round() as i32
723}
724
725/// Find the table file for a caliber
726fn 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    // Try common variations
736    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
751// Helper functions for reading binary data
752
753fn 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// A newer clippy stable added `chunks_exact_to_as_chunks`, which flags a constant chunk
772// size and suggests `as_chunks`. Suppressed rather than restructured: this is binary-format
773// parsing, `as_chunks` changes the element type from `&[u8]` to `&[u8; N]` and so ripples
774// into every use inside the loop, and the change would land in the middle of a 13-platform
775// release. `unknown_lints` is allowed alongside it so toolchains predating the lint do not
776// warn on the name. Adopting `as_chunks` properly is a follow-up.
777#[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    // Defensive bounds: reject absurd lengths from corrupt/hostile files before
780    // allocating, and guard the byte-count multiply against overflow.
781    const MAX_ELEMS: usize = 64_000_000; // 256 MB of f32
782    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
802/// Simple CRC32 (IEEE polynomial) implementation
803pub(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/// Process-wide cache of BC5D tables loaded from explicit filesystem paths.
835///
836/// The bridge/solve-json surfaces accept a caller-supplied table PATH (mobile apps
837/// download the `.bin` themselves and hand the engine a file path), and PARSING a
838/// several-MB table on every card or solve call would dominate the request. What the
839/// cache saves is the parse; the read and CRC are the price of knowing what is
840/// actually on disk.
841///
842/// Entries are keyed by `(canonical path, file size, CRC32 of the file's bytes)` —
843/// i.e. by CONTENT. An earlier version keyed on `(canonical path, file size, mtime)`
844/// and could serve a stale parsed table when a file was replaced in place by
845/// same-size content within one filesystem mtime tick: the key was unchanged, so the
846/// new bytes were never read. That is a live scenario here, not a theoretical one —
847/// a table-set refresh overwrites `bc5d_<caliber>.bin` in place, and a regenerated
848/// table with identical dimensions has identical size. It reached a release because
849/// mtime granularity is fine enough on macOS and Linux to hide it, and only surfaced
850/// on an OpenBSD guest whose granularity is coarse enough to collide.
851///
852/// Size is retained alongside the CRC purely as a second, free discriminator.
853/// The cache is bounded (oldest entry evicted at capacity).
854///
855/// Filesystem-only by construction, so the whole module is compiled out on
856/// `wasm32` (where WASM callers pass table BYTES via `loadBc5dTable` instead).
857#[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    /// Small on purpose: a mobile app realistically has one or two calibers live.
864    const CACHE_CAPACITY: usize = 4;
865
866    #[derive(Debug, Clone, PartialEq, Eq)]
867    struct CacheKey {
868        canonical_path: PathBuf,
869        file_size: u64,
870        /// CRC32 of the file's ENTIRE byte content. Not the checksum field stored
871        /// inside the table (that describes only the data section, and a corrupted
872        /// data byte leaves it untouched — exactly the case that must invalidate).
873        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    /// Load a BC5D table from `path`, verifying the header (magic, version,
884    /// dimensions) and the stored CRC32 exactly as [`Bc5dTable::load`] does, with
885    /// the parsed result cached process-wide.
886    ///
887    /// A cache hit requires the canonical path, file size, AND a CRC32 over the
888    /// file's bytes to match, so ANY change to the content invalidates — including a
889    /// same-size in-place replacement and single-byte corruption, neither of which a
890    /// timestamp reliably distinguishes. Corrupt, truncated, or missing files are
891    /// never cached.
892    ///
893    /// The file is therefore read on every call; only the parse is cached. At the
894    /// once-per-request call sites (bridge cards, bridge `solve`, `solve-json`,
895    /// `bc5d.info`) that trade is not measurable against the solve itself.
896    pub fn load_verified(path: &Path) -> Result<Arc<Bc5dTable>, Bc5dError> {
897        let canonical_path = std::fs::canonicalize(path)?;
898        // Read BEFORE consulting the cache: the bytes are the identity, so there is
899        // nothing trustworthy to look up until we have them.
900        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            // Re-check under the write lock: another thread may have inserted the
917            // same key between our read miss and here.
918            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    /// [`load_verified`] plus the caliber-identity guard: the loaded table must be for
930    /// the caliber of the shot that is about to use it, per
931    /// [`Bc5dTable::ensure_caliber_matches`] (`shot_caliber_in` in INCHES).
932    ///
933    /// This is the entry point every consumer that HAS a shot must use — bridge cards,
934    /// bridge `solve`, and `solve-json` all take a caller-supplied table path, and a
935    /// path says nothing about content. Only surfaces with no shot in hand (e.g. the
936    /// bridge's `bc5d.info`, which just describes a file) call [`load_verified`]
937    /// directly. Keeping the guard inside the loader is deliberate: adding a fourth
938    /// path-based consumer cannot forget it.
939    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        // Create a small test table with known values
955        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        // Total cells: 2 * 3 * 3 * 2 * 3 = 108
962        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        // Set some non-uniform values for testing interpolation
966        // At weight=150, bc=0.4, muzzle=2750 (interpolated), current=2000, G1
967        // We'll set corners to test 4D interpolation
968        data[0] = 0.95; // First corner
969        data[total - 1] = 1.05; // Last corner
970
971        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    /// The defect that reached 0.33.3: the cache keyed on `(path, size, mtime)`, so a
986    /// file replaced IN PLACE by same-size content within one mtime tick kept its key
987    /// and the stale parsed table was served. The sibling test above only caught it on
988    /// filesystems whose timestamp granularity happens to collide (it failed on an
989    /// OpenBSD guest and passed everywhere else), so this one removes the luck: it
990    /// pins both writes to an IDENTICAL mtime and asserts content still decides.
991    #[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        // A fixed, explicitly-set mtime that BOTH writes will carry, emulating a
1007        // filesystem that cannot separate them. `File::set_modified` is std, so this
1008        // stays portable instead of shelling out to `touch`.
1009        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        // 1. A same-size VALID replacement must be parsed, not served from cache.
1023        let mut replacement = create_test_table();
1024        let last = replacement.data.len() - 1;
1025        replacement.data[last] = 0.5; // different content, identical dimensions => identical size
1026        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        // 2. Same-size CORRUPTION must be reported, never served from cache. The stored
1042        //    checksum field is untouched here, so only hashing the real bytes catches it.
1043        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        // 3. Restoring the original bytes is a cache HIT again — content, not history.
1057        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    /// Serialize a table into the BC5D v2 `.bin` byte layout so we can exercise
1084    /// the `from_bytes` parser without depending on an external file.
1085    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()); // flags
1091        out.extend_from_slice(&0u32.to_le_bytes()); // padding
1092        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        // Checksum is CRC32 of bins + data, in declaration order.
1100        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]); // reserved
1112
1113        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        // A corrupted body must be rejected by the CRC check.
1134        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        // A table whose corrections are all exactly 1.0 carries no useful
1142        // correction, so generate_segments returns None (leave published BC).
1143        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        // A table with a real (0.9) correction across the sampled slice must
1150        // produce contiguous, descending velocity segments carrying bc*corr.
1151        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            // Bands are contiguous and descend in velocity.
1159            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); // base_bc * correction
1164            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        // Test middle of range
1187        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        // Test at bin boundary
1192        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        // Test below range
1202        let (idx, weight) = table.interp_idx(50.0, &table.weight_bins);
1203        assert_eq!(idx, 0);
1204        assert_eq!(weight, 0.0);
1205
1206        // Test above range
1207        let (idx, weight) = table.interp_idx(250.0, &table.weight_bins);
1208        assert_eq!(idx, 1); // len - 2
1209        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        // Effective BC should be base_bc * correction
1274        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    /// The matching rule's boundaries, pinned. A `308` table is the bucket of diameters
1285    /// that round to 0.308 at the thousandth — precisely the diameters for which
1286    /// `find_table_file` would have chosen this table's own `bc5d_308.bin`.
1287    #[test]
1288    fn ensure_caliber_matches_accepts_the_rounding_bucket_and_refuses_outside_it() {
1289        let table = create_test_table(); // header caliber 0.308 (as f32)
1290
1291        // The f32 header (0.30799998) must not cost us the exact match.
1292        assert_eq!(table.caliber_key(), 308);
1293        assert!(table.ensure_caliber_matches(0.308).is_ok());
1294
1295        // Inclusive at the bottom edge: 0.3075 * 1000 is exactly 307.5, which rounds
1296        // half-away-from-zero to 308.
1297        assert!(table.ensure_caliber_matches(0.3075).is_ok());
1298        assert!(table.ensure_caliber_matches(0.3084).is_ok());
1299
1300        // Exclusive at the top edge: 0.3085 * 1000 is exactly 308.5 -> key 309.
1301        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        // The real-world failure mode: a whole different caliber, named in the message.
1311        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        // A garbage diameter cannot accidentally match a real table either.
1321        assert!(table.ensure_caliber_matches(f64::NAN).is_err());
1322        assert!(table.ensure_caliber_matches(0.0).is_err());
1323    }
1324
1325    /// A file named `bc5d_308.bin` whose CONTENT is a .224 table must be refused by the
1326    /// CLI's manager, and must not be cached (so a retry cannot resurrect it).
1327    #[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        // Not cached: the second attempt fails the same way rather than succeeding.
1351        assert!(matches!(
1352            manager.get_table(0.308),
1353            Err(Bc5dError::CaliberMismatch { .. })
1354        ));
1355        // And no correction leaks out through the convenience wrappers.
1356        assert!(manager
1357            .lookup(0.308, 168.0, 0.4, 2500.0, 2000.0, "G1")
1358            .is_err());
1359
1360        // The same bytes ARE usable for the caliber they actually describe.
1361        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    /// `load_verified_for_caliber` is the guarded loader every shot-bearing consumer
1368    /// uses: same bytes, accepted for their own caliber and refused for another.
1369    #[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        // Test with known CRC32 value
1407        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    /// The path cache must hand back the SAME parsed table for repeated loads of an
1419    /// unchanged file, reject corruption instead of caching it, and pick up an
1420    /// in-place replacement (size change breaks the key).
1421    #[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        // Replace the file with a differently sized (still valid) table: the key
1447        // changes, so the next load parses the new content.
1448        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        // Corruption is a clean error, not a cached table.
1463        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}