Skip to main content

ballistics_engine/
ffi.rs

1//! FFI bindings for iOS/Swift integration
2
3use crate::{
4    calculate_zero_angle_with_conditions, run_monte_carlo_with_direction_std_dev,
5    AtmosphericConditions, BallisticInputs, DragModel, MonteCarloParams, TrajectorySolver,
6    WindConditions,
7};
8use std::os::raw::{c_char, c_double, c_int};
9use std::ptr;
10
11/// Minimum C-ABI trajectory `step_size`, in milliseconds (0.1 ms = 0.0001 s).
12///
13/// Smaller steps are rejected before integration because fixed-step solves retain one public
14/// trajectory point per step. All in-repository FFI examples use values in `[0.1, 1.0]` ms.
15pub const MIN_FFI_STEP_SIZE_MS: c_double = 0.1;
16
17/// Maximum C-ABI custom drag-table row count.
18///
19/// `drag_table_from_raw` copies both caller arrays before validation; an unbounded
20/// `len` lets a single call request two multi-gigabyte allocations and abort the
21/// process (MBA-1407). Real Cd decks are two to three orders of magnitude smaller
22/// (the embedded G1/G7 references are under 100 rows; doppler exports run a few
23/// hundred), so 4096 is far above any legitimate deck.
24pub const MAX_FFI_DRAG_TABLE_LEN: c_int = 4096;
25
26// FFI-safe structures with C-compatible layouts
27
28#[repr(C)]
29pub struct FFIBallisticInputs {
30    pub muzzle_velocity: c_double,         // m/s
31    pub muzzle_angle: c_double,            // radians (launch angle)
32    pub bc_value: c_double,                // ballistic coefficient
33    pub bullet_mass: c_double,             // kg
34    pub bullet_diameter: c_double,         // meters
35    pub bc_type: c_int,                    // 0=G1, 1=G7, 2=G2, 3=G5, 4=G6, 5=G8, 6=GI, 7=GS, 8=RA4 (MBA-1386; unrecognized -> G1)
36    pub sight_height: c_double,            // meters
37    pub target_distance: c_double,         // meters
38    pub temperature: c_double,             // Celsius
39    pub twist_rate: c_double,              // inches per turn
40    pub is_twist_right: c_int,             // 0=false, 1=true
41    pub shooting_angle: c_double,          // uphill/downhill angle in radians
42    pub altitude: c_double,                // meters
43    pub latitude: c_double,                // degrees (use NAN if not provided)
44    pub azimuth_angle: c_double,           // horizontal aiming angle in radians
45    pub use_rk4: c_int,                    // 0=Euler, 1=RK4
46    pub use_adaptive_rk45: c_int,          // 0=false, 1=true (adaptive RK45)
47    pub enable_wind_shear: c_int,          // 0=false, 1=true
48    pub enable_trajectory_sampling: c_int, // 0=false, 1=true
49    pub sample_interval: c_double,         // meters
50    pub enable_pitch_damping: c_int,       // 0=false, 1=true
51    pub enable_precession_nutation: c_int, // 0=false, 1=true
52    pub enable_spin_drift: c_int,          // 0=false, 1=true
53    pub enable_magnus: c_int,              // 0=false, 1=true
54    pub enable_coriolis: c_int,            // 0=false, 1=true
55    // Appended (keeps existing field offsets): compass bearing the shot is fired
56    // along, radians, 0=North, PI/2=East. Drives the Coriolis Eötvös/drift azimuth.
57    // Distinct from azimuth_angle (the small aiming offset). 0.0 if unset.
58    pub shot_azimuth: c_double,
59    // Appended (keeps existing field offsets): rifle cant angle in RADIANS about the line
60    // of sight, positive = clockwise from the shooter. Rotates the sight-frame aim offsets
61    // and bore geometry ("zeroed level, fired canted" -> POI right and low). 0.0 if unset.
62    pub cant_angle: c_double,
63    // Appended (keeps existing field offsets): deliberate vertical POI offset AT the zero
64    // range, METERS (MBA-1359, Kestrel "zero height"): positive = the rifle is deliberately
65    // zeroed to impact HIGH by this much at the zero distance. Applied by the zero-angle
66    // solve as an angular bias on the solved angle. 0.0 if unset (identical to absent).
67    pub zero_poi_vertical: c_double,
68    // Appended (keeps existing field offsets): deliberate horizontal POI offset AT the zero
69    // range, METERS (MBA-1359, Kestrel "zero offset"): positive = impacts RIGHT by this much
70    // at the zero distance. 0.0 if unset (identical to absent).
71    pub zero_poi_horizontal: c_double,
72    // Appended (keeps existing field offsets): lateral sight-to-bore mount offset, METERS
73    // (MBA-1396, offset-mounted optics): positive = the sight sits RIGHT of the bore, so
74    // the trajectory starts that far LEFT of the line of sight. 0.0 if unset (identical to
75    // absent). NOTE for callers pairing ballistics_calculate_zero_angle with a trajectory
76    // call: the returned zero angle carries the VERTICAL zero_poi bias, but the horizontal
77    // terms are azimuth corrections a bare elevation angle cannot carry — a caller
78    // replicating an auto-zero flow must add
79    // (zero_poi_horizontal + sight_offset_lateral) / zero_distance to azimuth_angle itself.
80    pub sight_offset_lateral: c_double,
81    // NOT plumbed (deliberately): BallisticInputs.drops_reference (MBA-1403) is an
82    // OUTPUT-mode toggle for the trajectory sampler's drop column, not physics. This FFI
83    // surface exposes raw kinematic samples only (world-frame positions — no drop/dial
84    // outputs), so there is nothing for the toggle to act on; C callers wanting
85    // target-plane drops divide their own LOS drop by cos(shooting_angle).
86}
87
88#[repr(C)]
89pub struct FFIWindConditions {
90    pub speed: c_double, // m/s
91    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
92    // PI = tailwind, 3*PI/2 = from the left (matches WindConditions / WindSock).
93    // ALWAYS shooter-relative: the FFI deliberately has NO earth-fixed compass mode
94    // (MBA-1368 decision — bindings stay shooter-relative numeric); a caller holding a
95    // compass bearing converts it BEFORE the call as
96    // (bearing_rad - shot_azimuth_rad).rem_euclid(2*PI), which is exactly what the
97    // CLI/WASM/solve-json `--wind-ref compass` surfaces do internally.
98    pub direction: c_double,
99    // Appended (keeps existing field offsets): vertical wind m/s, positive = updraft;
100    // 0.0 if unset.
101    pub vertical_speed: c_double,
102}
103
104#[repr(C)]
105pub struct FFIAtmosphericConditions {
106    pub temperature: c_double, // Celsius
107    pub pressure: c_double,    // hPa
108    pub humidity: c_double,    // percentage (0-100)
109    pub altitude: c_double,    // meters
110}
111
112#[repr(C)]
113pub struct FFITrajectorySample {
114    pub distance: c_double,       // meters
115    pub time: c_double,           // seconds
116    pub velocity_mps: c_double,   // meters per second
117    pub energy_joules: c_double,  // joules
118    pub drop_meters: c_double,    // meters
119    pub windage_meters: c_double, // meters
120    pub mach: c_double,           // Mach number
121    pub spin_rate_rps: c_double,  // revolutions per second
122}
123
124#[repr(C)]
125pub struct FFITrajectoryPoint {
126    pub time: c_double,
127    pub position_x: c_double,
128    pub position_y: c_double,
129    pub position_z: c_double,
130    pub velocity_magnitude: c_double,
131    pub kinetic_energy: c_double,
132}
133
134#[repr(C)]
135pub struct FFITrajectoryResult {
136    pub max_range: c_double,
137    pub max_height: c_double,
138    pub time_of_flight: c_double,
139    pub impact_velocity: c_double,
140    pub impact_energy: c_double,
141    pub points: *mut FFITrajectoryPoint,
142    pub point_count: c_int,
143    pub sampled_points: *mut FFITrajectorySample,
144    pub sampled_point_count: c_int,
145    pub min_pitch_damping: c_double,    // NAN if not calculated
146    pub transonic_mach: c_double,       // NAN if not reached
147    pub final_pitch_angle: c_double,    // NAN if not calculated
148    pub final_yaw_angle: c_double,      // NAN if not calculated
149    pub max_yaw_angle: c_double,        // NAN if not calculated
150    pub max_precession_angle: c_double, // NAN if not calculated
151}
152
153// Monte Carlo simulation parameters
154#[repr(C)]
155pub struct FFIMonteCarloParams {
156    pub num_simulations: c_int,
157    pub velocity_std_dev: c_double,
158    pub angle_std_dev: c_double,
159    pub bc_std_dev: c_double,
160    pub wind_speed_std_dev: c_double,
161    pub target_distance: c_double,     // Use NAN if not specified
162    pub base_wind_speed: c_double,     // Base wind speed in m/s
163    pub base_wind_direction: c_double, // Base wind direction in radians
164    pub azimuth_std_dev: c_double,     // Horizontal aiming variation in radians
165}
166
167// Monte Carlo simulation results
168#[repr(C)]
169pub struct FFIMonteCarloResults {
170    pub ranges: *mut c_double,
171    pub impact_velocities: *mut c_double,
172    pub impact_positions_x: *mut c_double,
173    /// `-1.0e9` marks a sample that did not reach the target plane; exclude it from dispersion
174    /// statistics but retain it as a miss for probability calculations.
175    pub impact_positions_y: *mut c_double,
176    pub impact_positions_z: *mut c_double,
177    pub num_results: c_int,
178    pub mean_range: c_double,
179    pub std_dev_range: c_double,
180    pub mean_impact_velocity: c_double,
181    pub std_dev_impact_velocity: c_double,
182    pub hit_probability: c_double, // If target_distance was specified
183}
184
185// Helper function to convert FFI inputs to internal types
186#[allow(clippy::field_reassign_with_default)] // Keep the C-to-Rust field mapping sequential/auditable.
187fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
188    let mut ballistic_inputs = BallisticInputs::default();
189
190    ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
191    ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
192    ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
193    ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
194    ballistic_inputs.cant_angle = inputs.cant_angle;
195    ballistic_inputs.zero_poi_vertical_m = inputs.zero_poi_vertical;
196    ballistic_inputs.zero_poi_horizontal_m = inputs.zero_poi_horizontal;
197    ballistic_inputs.sight_offset_lateral_m = inputs.sight_offset_lateral;
198    ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
199    ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
200    ballistic_inputs.bc_value = inputs.bc_value;
201    ballistic_inputs.bullet_mass = inputs.bullet_mass;
202    ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
203    ballistic_inputs.bc_type = match inputs.bc_type {
204        1 => DragModel::G7,
205        2 => DragModel::G2,
206        3 => DragModel::G5,
207        4 => DragModel::G6,
208        5 => DragModel::G8,
209        6 => DragModel::GI,
210        7 => DragModel::GS,
211        // MBA-1386: additive slot for the new RA4 family. Existing callers passing
212        // 0-7 are unaffected; any other/unrecognized value still falls back to G1.
213        8 => DragModel::RA4,
214        _ => DragModel::G1,
215    };
216    ballistic_inputs.sight_height = inputs.sight_height;
217    ballistic_inputs.target_distance = inputs.target_distance;
218    ballistic_inputs.temperature = inputs.temperature;
219    ballistic_inputs.twist_rate = inputs.twist_rate;
220    ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
221    ballistic_inputs.shooting_angle = inputs.shooting_angle;
222    ballistic_inputs.altitude = inputs.altitude;
223
224    if !inputs.latitude.is_nan() {
225        ballistic_inputs.latitude = Some(inputs.latitude);
226    }
227
228    // Set derived values
229    ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
230    ballistic_inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
231    // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default). The C ABI does
232    // not carry a bullet length, so derive it from diameter + mass; fall back to 4.5-cal if mass<=0.
233    ballistic_inputs.bullet_length = {
234        let est = crate::stability::estimate_bullet_length_m(
235            ballistic_inputs.bullet_diameter,
236            ballistic_inputs.bullet_mass,
237        );
238        if est > 0.0 {
239            est
240        } else {
241            ballistic_inputs.bullet_diameter * 4.5
242        }
243    };
244
245    // New advanced physics flags
246    ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
247    ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
248    ballistic_inputs.sample_interval = inputs.sample_interval;
249    ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
250    ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
251    ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
252    ballistic_inputs.enable_advanced_effects =
253        inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
254    // Gate Magnus and Coriolis independently so enabling one does not enable the other.
255    ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
256    ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
257
258    ballistic_inputs
259}
260
261/// Build a validated [`crate::drag::DragTable`] from borrowed C arrays.
262///
263/// Both arrays must contain `len` elements, with `len` in `[2, MAX_FFI_DRAG_TABLE_LEN]`.
264/// The data is copied; the caller retains ownership. Returns `Err(())` for null
265/// pointers, `len` outside that range, or any deck that fails
266/// [`crate::drag::DragTable::try_new`] validation (non-ascending Mach, non-positive
267/// or non-finite Cd). No error detail crosses the ABI, matching the null/NAN
268/// error convention of this module.
269///
270/// # Safety
271///
272/// When non-null, `mach` and `cd` must each point to `len` readable `f64` values
273/// that remain valid and unmutated for the duration of the call.
274unsafe fn drag_table_from_raw(
275    mach: *const c_double,
276    cd: *const c_double,
277    len: c_int,
278) -> Result<crate::drag::DragTable, ()> {
279    if mach.is_null() || cd.is_null() || !(2..=MAX_FFI_DRAG_TABLE_LEN).contains(&len) {
280        return Err(());
281    }
282    let len = len as usize;
283    let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
284    let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
285    crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
286}
287
288/// Shared implementation for the trajectory exports. `custom_drag_table`, when
289/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
290/// density — see `BallisticInputs::custom_drag_denominator`). `cd_scale` multiplies the
291/// deck's interpolated Cd (MBA-1356); callers that don't expose a scale pass `1.0`
292/// (neutral, byte-identical to no scale). It is inert when `custom_drag_table` is `None`.
293unsafe fn calculate_trajectory_impl(
294    inputs: *const FFIBallisticInputs,
295    wind: *const FFIWindConditions,
296    atmosphere: *const FFIAtmosphericConditions,
297    max_range: c_double,
298    step_size: c_double,
299    custom_drag_table: Option<crate::drag::DragTable>,
300    cd_scale: c_double,
301) -> *mut FFITrajectoryResult {
302    if inputs.is_null() {
303        return ptr::null_mut();
304    }
305    if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
306        return ptr::null_mut();
307    }
308
309    let inputs = unsafe { &*inputs };
310    let mut ballistic_inputs = convert_inputs(inputs);
311    ballistic_inputs.custom_drag_table = custom_drag_table;
312    ballistic_inputs.cd_scale = cd_scale;
313    let twist_rate_in = ballistic_inputs.twist_rate;
314
315    let wind_conditions = if wind.is_null() {
316        WindConditions::default()
317    } else {
318        let wind = unsafe { &*wind };
319        WindConditions {
320            speed: wind.speed,
321            direction: wind.direction,
322            vertical_speed: wind.vertical_speed,
323        }
324    };
325
326    let atmospheric_conditions = if atmosphere.is_null() {
327        AtmosphericConditions::default()
328    } else {
329        let atmo = unsafe { &*atmosphere };
330        AtmosphericConditions {
331            temperature: atmo.temperature,
332            pressure: atmo.pressure,
333            humidity: atmo.humidity,
334            altitude: atmo.altitude,
335        }
336    };
337
338    // Create solver and calculate trajectory
339    let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
340        atmospheric_conditions.temperature,
341        atmospheric_conditions.pressure,
342        atmospheric_conditions.altitude,
343    );
344    let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
345        atmospheric_conditions.altitude,
346        Some(sample_temp_c),
347        Some(sample_pressure_hpa),
348        atmospheric_conditions.humidity,
349    );
350
351    let mut solver =
352        TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
353
354    // Set max range and time step
355    solver.set_max_range(max_range);
356    solver.set_time_step(step_size / 1000.0); // milliseconds -> seconds
357
358    match solver.solve() {
359        Ok(result) => {
360            // Convert trajectory points to FFI format
361            let point_count = result.points.len();
362            let points = if point_count > 0 {
363                let mut ffi_points = Vec::with_capacity(point_count);
364                for point in result.points.iter() {
365                    ffi_points.push(FFITrajectoryPoint {
366                        time: point.time,
367                        position_x: point.position[0],
368                        position_y: point.position[1],
369                        position_z: point.position[2],
370                        velocity_magnitude: point.velocity_magnitude,
371                        kinetic_energy: point.kinetic_energy,
372                    });
373                }
374                let points_ptr = ffi_points.as_mut_ptr();
375                std::mem::forget(ffi_points); // Prevent deallocation
376                points_ptr
377            } else {
378                ptr::null_mut()
379            };
380
381            // Convert sampled points if available
382            let (sampled_points, sampled_point_count) =
383                if let Some(ref samples) = result.sampled_points {
384                    let mut ffi_samples = Vec::with_capacity(samples.len());
385                    for sample in samples {
386                        ffi_samples.push(FFITrajectorySample {
387                            distance: sample.distance_m,
388                            time: sample.time_s,
389                            velocity_mps: sample.velocity_mps,
390                            energy_joules: sample.energy_j,
391                            drop_meters: sample.drop_m,
392                            windage_meters: sample.wind_drift_m,
393                            mach: if sample_speed_of_sound > 0.0 {
394                                sample.velocity_mps / sample_speed_of_sound
395                            } else {
396                                0.0
397                            },
398                            spin_rate_rps: if twist_rate_in > 0.0 {
399                                sample.velocity_mps / (twist_rate_in * 0.0254)
400                            } else {
401                                0.0
402                            },
403                        });
404                    }
405                    let count = ffi_samples.len() as c_int;
406                    let samples_ptr = ffi_samples.as_mut_ptr();
407                    std::mem::forget(ffi_samples);
408                    (samples_ptr, count)
409                } else {
410                    (ptr::null_mut(), 0)
411                };
412
413            // Extract angular state values if available
414            let (final_pitch, final_yaw, max_yaw, max_prec) =
415                if let Some(ref angular) = result.angular_state {
416                    (
417                        angular.pitch_angle,
418                        angular.yaw_angle,
419                        result.max_yaw_angle.unwrap_or(f64::NAN),
420                        result.max_precession_angle.unwrap_or(f64::NAN),
421                    )
422                } else {
423                    (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
424                };
425
426            // Create result on heap
427            let ffi_result = Box::new(FFITrajectoryResult {
428                max_range: result.max_range,
429                max_height: result.max_height,
430                time_of_flight: result.time_of_flight,
431                impact_velocity: result.impact_velocity,
432                impact_energy: result.impact_energy,
433                points,
434                point_count: point_count as c_int,
435                sampled_points,
436                sampled_point_count,
437                min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
438                transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
439                final_pitch_angle: final_pitch,
440                final_yaw_angle: final_yaw,
441                max_yaw_angle: max_yaw,
442                max_precession_angle: max_prec,
443            });
444
445            Box::into_raw(ffi_result)
446        }
447        Err(_) => ptr::null_mut(),
448    }
449}
450
451/// Calculate a trajectory through the C ABI.
452///
453/// `step_size` is expressed in milliseconds and must be finite and at least
454/// [`MIN_FFI_STEP_SIZE_MS`]. This boundary contract is validated for every solver mode, although
455/// adaptive RK45 chooses its integration steps internally. Invalid values return null without
456/// starting a solve. A solve that would exceed [`crate::MAX_TRAJECTORY_POINTS`] also returns null;
457/// an enabled sampling grid above [`crate::MAX_TRAJECTORY_SAMPLES`] does likewise. Callers can
458/// increase `step_size`, reduce `max_range`, or select adaptive RK45.
459///
460/// # Safety
461///
462/// `inputs` may be null, in which case this function returns null. When non-null,
463/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
464/// readable and is not mutated for the duration of this call. `wind` and
465/// `atmosphere` may also be null; each non-null pointer has the same requirements
466/// for its corresponding type.
467/// The returned pointer, when non-null, must be released exactly once with
468/// [`ballistics_free_trajectory_result`].
469#[no_mangle]
470pub unsafe extern "C" fn ballistics_calculate_trajectory(
471    inputs: *const FFIBallisticInputs,
472    wind: *const FFIWindConditions,
473    atmosphere: *const FFIAtmosphericConditions,
474    max_range: c_double,
475    step_size: c_double,
476) -> *mut FFITrajectoryResult {
477    unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None, 1.0) }
478}
479
480/// [`ballistics_calculate_trajectory`] with a caller-supplied custom drag deck
481/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
482/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
483/// denominator becomes the projectile's sectional density (mass and diameter in
484/// `inputs` must therefore be positive). Mach values must be strictly ascending
485/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
486/// outside the deck's Mach domain the nearest endpoint Cd is held.
487///
488/// Returns null for an invalid deck (null arrays, `drag_table_len` outside
489/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
490/// failure mode of the base function.
491///
492/// # Safety
493///
494/// Same contract as [`ballistics_calculate_trajectory`] for `inputs`, `wind`, and
495/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
496/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
497/// of this call (the deck is copied; the caller retains ownership — no new free
498/// function is required). The returned pointer, when non-null, must be released
499/// exactly once with [`ballistics_free_trajectory_result`].
500#[no_mangle]
501pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
502    inputs: *const FFIBallisticInputs,
503    wind: *const FFIWindConditions,
504    atmosphere: *const FFIAtmosphericConditions,
505    max_range: c_double,
506    step_size: c_double,
507    drag_mach: *const c_double,
508    drag_cd: *const c_double,
509    drag_table_len: c_int,
510) -> *mut FFITrajectoryResult {
511    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
512        Ok(t) => t,
513        Err(()) => return ptr::null_mut(),
514    };
515    unsafe {
516        calculate_trajectory_impl(
517            inputs, wind, atmosphere, max_range, step_size, Some(table), 1.0,
518        )
519    }
520}
521
522/// [`ballistics_calculate_trajectory_with_drag_table`] with an additional whole-curve
523/// drag scale (MBA-1356): the deck's interpolated Cd is multiplied by `cd_scale` at the
524/// same site the base export uses, i.e. `Cd_used = table.interpolate(mach) * cd_scale`.
525/// `cd_scale = 1.0` is neutral and produces byte-identical output to
526/// [`ballistics_calculate_trajectory_with_drag_table`] on the same deck. Typical truing
527/// values are in `[0.90, 1.10]`; values outside that band are accepted here (the engine
528/// only rejects non-finite or non-positive) — an "unusually large" warning is a CLI-only
529/// concern (Task 2), not part of this frozen C ABI.
530///
531/// Returns null when `cd_scale` is not finite or not `> 0`, in addition to every failure
532/// mode of [`ballistics_calculate_trajectory_with_drag_table`] (matching that export's
533/// null sentinel for an invalid deck).
534///
535/// # Safety
536///
537/// Same contract as [`ballistics_calculate_trajectory_with_drag_table`].
538#[no_mangle]
539pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table_scaled(
540    inputs: *const FFIBallisticInputs,
541    wind: *const FFIWindConditions,
542    atmosphere: *const FFIAtmosphericConditions,
543    max_range: c_double,
544    step_size: c_double,
545    drag_mach: *const c_double,
546    drag_cd: *const c_double,
547    drag_table_len: c_int,
548    cd_scale: c_double,
549) -> *mut FFITrajectoryResult {
550    if !cd_scale.is_finite() || cd_scale <= 0.0 {
551        return ptr::null_mut();
552    }
553    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
554        Ok(t) => t,
555        Err(()) => return ptr::null_mut(),
556    };
557    unsafe {
558        calculate_trajectory_impl(
559            inputs, wind, atmosphere, max_range, step_size, Some(table), cd_scale,
560        )
561    }
562}
563
564/// Release a trajectory result allocated by [`ballistics_calculate_trajectory`].
565///
566/// # Safety
567///
568/// `result` must be null or a pointer returned by
569/// [`ballistics_calculate_trajectory`] that has not already been freed. Its
570/// embedded pointers and counts must be unchanged from the returned values.
571/// After this call, the result and its point arrays must not be accessed again.
572#[no_mangle]
573pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
574    if !result.is_null() {
575        unsafe {
576            let result = Box::from_raw(result);
577            if !result.points.is_null() && result.point_count > 0 {
578                let points = Vec::from_raw_parts(
579                    result.points,
580                    result.point_count as usize,
581                    result.point_count as usize,
582                );
583                drop(points);
584            }
585            if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
586                let samples = Vec::from_raw_parts(
587                    result.sampled_points,
588                    result.sampled_point_count as usize,
589                    result.sampled_point_count as usize,
590                );
591                drop(samples);
592            }
593            drop(result);
594        }
595    }
596}
597
598/// Shared implementation for the zero-angle exports. `custom_drag_table`, when
599/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
600/// density — see `BallisticInputs::custom_drag_denominator`), matching the deck
601/// semantics of [`calculate_trajectory_impl`] so a zero solved with a deck and a
602/// trajectory flown with the same deck agree. `cd_scale` multiplies the deck's
603/// interpolated Cd (MBA-1356); callers that don't expose a scale pass `1.0` (neutral).
604unsafe fn calculate_zero_angle_impl(
605    inputs: *const FFIBallisticInputs,
606    wind: *const FFIWindConditions,
607    atmosphere: *const FFIAtmosphericConditions,
608    zero_distance: c_double,
609    custom_drag_table: Option<crate::drag::DragTable>,
610    cd_scale: c_double,
611) -> c_double {
612    if inputs.is_null() {
613        return f64::NAN;
614    }
615
616    let inputs = unsafe { &*inputs };
617    let mut ballistic_inputs = convert_inputs(inputs);
618    ballistic_inputs.custom_drag_table = custom_drag_table;
619    ballistic_inputs.cd_scale = cd_scale;
620
621    let wind_conditions = if wind.is_null() {
622        WindConditions::default()
623    } else {
624        let wind = unsafe { &*wind };
625        WindConditions {
626            speed: wind.speed,
627            direction: wind.direction,
628            vertical_speed: wind.vertical_speed,
629        }
630    };
631
632    let atmospheric_conditions = if atmosphere.is_null() {
633        AtmosphericConditions::default()
634    } else {
635        let atmo = unsafe { &*atmosphere };
636        AtmosphericConditions {
637            temperature: atmo.temperature,
638            pressure: atmo.pressure,
639            humidity: atmo.humidity,
640            altitude: atmo.altitude,
641        }
642    };
643
644    // For zero angle, we want the bullet to hit at sight height at the zero distance
645    // This means the bullet crosses the line of sight at the zero distance
646    let target_height = ballistic_inputs.sight_height;
647
648    calculate_zero_angle_with_conditions(
649        ballistic_inputs,
650        zero_distance,
651        target_height,
652        wind_conditions,
653        atmospheric_conditions,
654    )
655    .unwrap_or(f64::NAN)
656}
657
658/// Calculate the zero angle for a target distance through the C ABI.
659///
660/// # Safety
661///
662/// `inputs` may be null, in which case this function returns NaN. When non-null,
663/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
664/// readable and is not mutated for the duration of this call. `wind` and
665/// `atmosphere` may also be null; each non-null pointer has the same requirements
666/// for its corresponding type.
667#[no_mangle]
668pub unsafe extern "C" fn ballistics_calculate_zero_angle(
669    inputs: *const FFIBallisticInputs,
670    wind: *const FFIWindConditions,
671    atmosphere: *const FFIAtmosphericConditions,
672    zero_distance: c_double,
673) -> c_double {
674    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None, 1.0) }
675}
676
677/// [`ballistics_calculate_zero_angle`] with a caller-supplied custom drag deck
678/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
679/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
680/// denominator becomes the projectile's sectional density (mass and diameter in
681/// `inputs` must therefore be positive). Mach values must be strictly ascending
682/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
683/// outside the deck's Mach domain the nearest endpoint Cd is held. Pair this with
684/// [`ballistics_calculate_trajectory_with_drag_table`] using the same deck to fly
685/// the solved angle — the two exports share identical deck semantics.
686///
687/// Returns NaN for an invalid deck (null arrays, `drag_table_len` outside
688/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
689/// failure mode of the base function.
690///
691/// # Safety
692///
693/// Same contract as [`ballistics_calculate_zero_angle`] for `inputs`, `wind`, and
694/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
695/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
696/// of this call (the deck is copied; the caller retains ownership — no new free
697/// function is required).
698#[no_mangle]
699pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
700    inputs: *const FFIBallisticInputs,
701    wind: *const FFIWindConditions,
702    atmosphere: *const FFIAtmosphericConditions,
703    zero_distance: c_double,
704    drag_mach: *const c_double,
705    drag_cd: *const c_double,
706    drag_table_len: c_int,
707) -> c_double {
708    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
709        Ok(t) => t,
710        Err(()) => return f64::NAN,
711    };
712    unsafe {
713        calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table), 1.0)
714    }
715}
716
717/// [`ballistics_calculate_zero_angle_with_drag_table`] with an additional whole-curve
718/// drag scale (MBA-1356): the deck's interpolated Cd is multiplied by `cd_scale` at the
719/// same site the base export uses, i.e. `Cd_used = table.interpolate(mach) * cd_scale`.
720/// `cd_scale = 1.0` is neutral and produces byte-identical output to
721/// [`ballistics_calculate_zero_angle_with_drag_table`] on the same deck. Pair this with
722/// [`ballistics_calculate_trajectory_with_drag_table_scaled`] using the same deck AND the
723/// same `cd_scale` to fly the solved angle. Typical truing values are in `[0.90, 1.10]`;
724/// values outside that band are accepted here (the engine only rejects non-finite or
725/// non-positive) — an "unusually large" warning is a CLI-only concern (Task 2).
726///
727/// Returns NaN when `cd_scale` is not finite or not `> 0`, in addition to every failure
728/// mode of [`ballistics_calculate_zero_angle_with_drag_table`] (matching that export's
729/// NaN sentinel for an invalid deck).
730///
731/// # Safety
732///
733/// Same contract as [`ballistics_calculate_zero_angle_with_drag_table`].
734#[no_mangle]
735pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table_scaled(
736    inputs: *const FFIBallisticInputs,
737    wind: *const FFIWindConditions,
738    atmosphere: *const FFIAtmosphericConditions,
739    zero_distance: c_double,
740    drag_mach: *const c_double,
741    drag_cd: *const c_double,
742    drag_table_len: c_int,
743    cd_scale: c_double,
744) -> c_double {
745    if !cd_scale.is_finite() || cd_scale <= 0.0 {
746        return f64::NAN;
747    }
748    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
749        Ok(t) => t,
750        Err(()) => return f64::NAN,
751    };
752    unsafe {
753        calculate_zero_angle_impl(
754            inputs,
755            wind,
756            atmosphere,
757            zero_distance,
758            Some(table),
759            cd_scale,
760        )
761    }
762}
763
764// Simple trajectory calculation for quick results
765#[no_mangle]
766#[allow(clippy::field_reassign_with_default)] // Preserve the staged zero-angle workflow below.
767pub extern "C" fn ballistics_quick_trajectory(
768    muzzle_velocity: c_double,
769    bc: c_double,
770    sight_height: c_double,
771    zero_distance: c_double,
772    target_distance: c_double,
773) -> c_double {
774    // This provides a simple drop calculation at target distance
775    // Using simplified ballistic calculations
776
777    let mut inputs = BallisticInputs::default();
778    inputs.muzzle_velocity = muzzle_velocity;
779    inputs.bc_value = bc;
780    inputs.sight_height = sight_height;
781    inputs.target_distance = target_distance;
782
783    let wind = WindConditions::default();
784    let atmo = AtmosphericConditions::default();
785
786    // First calculate zero angle
787    let zero_angle = match calculate_zero_angle_with_conditions(
788        inputs.clone(),
789        zero_distance,
790        sight_height,
791        wind.clone(),
792        atmo.clone(),
793    ) {
794        Ok(angle) => angle,
795        Err(_) => return f64::NAN,
796    };
797
798    // Now calculate trajectory with that zero angle
799    inputs.muzzle_angle = zero_angle;
800
801    let mut solver = TrajectorySolver::new(inputs, wind, atmo);
802    solver.set_max_range(target_distance * 1.1);
803
804    match solver.solve() {
805        Ok(result) => {
806            // Find the drop at target distance
807            for point in result.points {
808                if point.position[0] >= target_distance {
809                    return sight_height - point.position[1];
810                }
811            }
812            f64::NAN
813        }
814        Err(_) => f64::NAN,
815    }
816}
817
818/// Run a Monte Carlo simulation through the C ABI.
819///
820/// # Safety
821///
822/// `inputs` and `params` may be null, in which case this function returns null.
823/// Each non-null pointer must point to a valid, properly aligned value of its
824/// corresponding FFI type that remains readable and is not mutated for the
825/// duration of this call. `atmosphere` may be null; a non-null pointer has the
826/// same requirements for [`FFIAtmosphericConditions`]. The returned pointer,
827/// when non-null, must be released exactly once with
828/// [`ballistics_free_monte_carlo_results`].
829#[no_mangle]
830pub unsafe extern "C" fn ballistics_monte_carlo(
831    inputs: *const FFIBallisticInputs,
832    atmosphere: *const FFIAtmosphericConditions,
833    params: *const FFIMonteCarloParams,
834) -> *mut FFIMonteCarloResults {
835    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
836}
837
838/// Run a Monte Carlo simulation with independent wind-direction uncertainty through the C ABI.
839///
840/// `wind_direction_std_dev` is in radians. This additive entry point keeps
841/// [`FFIMonteCarloParams`] and [`ballistics_monte_carlo`] binary-compatible; the older function
842/// delegates with zero wind-direction uncertainty.
843///
844/// # Safety
845///
846/// The pointer and ownership requirements are identical to [`ballistics_monte_carlo`].
847#[no_mangle]
848pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
849    inputs: *const FFIBallisticInputs,
850    atmosphere: *const FFIAtmosphericConditions,
851    params: *const FFIMonteCarloParams,
852    wind_direction_std_dev: c_double,
853) -> *mut FFIMonteCarloResults {
854    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
855}
856
857unsafe fn ballistics_monte_carlo_impl(
858    inputs: *const FFIBallisticInputs,
859    atmosphere: *const FFIAtmosphericConditions,
860    params: *const FFIMonteCarloParams,
861    wind_direction_std_dev: f64,
862) -> *mut FFIMonteCarloResults {
863    if inputs.is_null() || params.is_null() {
864        return ptr::null_mut();
865    }
866
867    let inputs = unsafe { &*inputs };
868    let params = unsafe { &*params };
869
870    // Reject an out-of-range simulation count. num_simulations is a c_int (i32) cast straight to
871    // usize: a negative value would wrap to a near-max usize, and even a large positive value (up
872    // to i32::MAX ~ 2.1e9) would drive billions of iterations with the result arrays scaling to
873    // match — an unbounded-loop / OOM DoS from a single FFI call. Bound it to a sane maximum.
874    // (n == 0 also yields NaN stats and a zero-size allocation.)
875    const MAX_SIMULATIONS: c_int = 1_000_000;
876    if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
877        return ptr::null_mut();
878    }
879
880    // Convert FFI inputs to internal types
881    let mut ballistic_inputs = convert_inputs(inputs);
882    ballistic_inputs.muzzle_height = 1.5;
883    ballistic_inputs.ground_threshold = 0.0;
884    if !atmosphere.is_null() {
885        let atmo = unsafe { &*atmosphere };
886        ballistic_inputs.temperature = atmo.temperature;
887        ballistic_inputs.pressure = atmo.pressure;
888        ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
889        ballistic_inputs.altitude = atmo.altitude;
890    }
891
892    // Create Monte Carlo parameters
893    let mc_params = MonteCarloParams {
894        num_simulations: params.num_simulations as usize,
895        velocity_std_dev: params.velocity_std_dev,
896        angle_std_dev: params.angle_std_dev,
897        bc_std_dev: params.bc_std_dev,
898        wind_speed_std_dev: params.wind_speed_std_dev,
899        target_distance: if params.target_distance.is_nan() {
900            None
901        } else {
902            Some(params.target_distance)
903        },
904        base_wind_speed: params.base_wind_speed,
905        base_wind_direction: params.base_wind_direction,
906        azimuth_std_dev: params.azimuth_std_dev,
907    };
908
909    // Run Monte Carlo simulation
910    match run_monte_carlo_with_direction_std_dev(
911        ballistic_inputs,
912        mc_params,
913        wind_direction_std_dev,
914    ) {
915        Ok(results) => {
916            let num_results = results.ranges.len() as c_int;
917
918            // Calculate statistics
919            let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
920            let variance_range: f64 = results
921                .ranges
922                .iter()
923                .map(|r| (r - mean_range).powi(2))
924                .sum::<f64>()
925                / num_results as f64;
926            let std_dev_range = variance_range.sqrt();
927
928            let mean_velocity: f64 =
929                results.impact_velocities.iter().sum::<f64>() / num_results as f64;
930            let variance_velocity: f64 = results
931                .impact_velocities
932                .iter()
933                .map(|v| (v - mean_velocity).powi(2))
934                .sum::<f64>()
935                / num_results as f64;
936            let std_dev_velocity = variance_velocity.sqrt();
937
938            // Calculate hit probability if target distance was specified. MBA-971: use the shared
939            // position-based criterion (fraction within DEFAULT_HIT_RADIUS_M of the point of aim
940            // at the target plane). The old inline version had a redundant `distance < target`
941            // clause comparing a ~meter deviation to the ~hundreds-of-meters target distance
942            // (effectively always true), and the CLI used a different range-based notion entirely.
943            let hit_probability = if params.target_distance.is_nan() {
944                0.0
945            } else {
946                results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
947            };
948
949            // Allocate memory for arrays
950            let ranges_ptr = unsafe {
951                let ptr = std::alloc::alloc(
952                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
953                ) as *mut c_double;
954                for (i, &range) in results.ranges.iter().enumerate() {
955                    *ptr.add(i) = range;
956                }
957                ptr
958            };
959
960            let velocities_ptr = unsafe {
961                let ptr = std::alloc::alloc(
962                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
963                ) as *mut c_double;
964                for (i, &vel) in results.impact_velocities.iter().enumerate() {
965                    *ptr.add(i) = vel;
966                }
967                ptr
968            };
969
970            let pos_x_ptr = unsafe {
971                let ptr = std::alloc::alloc(
972                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
973                ) as *mut c_double;
974                for (i, pos) in results.impact_positions.iter().enumerate() {
975                    *ptr.add(i) = pos.x;
976                }
977                ptr
978            };
979
980            let pos_y_ptr = unsafe {
981                let ptr = std::alloc::alloc(
982                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
983                ) as *mut c_double;
984                for (i, pos) in results.impact_positions.iter().enumerate() {
985                    *ptr.add(i) = pos.y;
986                }
987                ptr
988            };
989
990            let pos_z_ptr = unsafe {
991                let ptr = std::alloc::alloc(
992                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
993                ) as *mut c_double;
994                for (i, pos) in results.impact_positions.iter().enumerate() {
995                    *ptr.add(i) = pos.z;
996                }
997                ptr
998            };
999
1000            // Create result structure
1001            let result = Box::new(FFIMonteCarloResults {
1002                ranges: ranges_ptr,
1003                impact_velocities: velocities_ptr,
1004                impact_positions_x: pos_x_ptr,
1005                impact_positions_y: pos_y_ptr,
1006                impact_positions_z: pos_z_ptr,
1007                num_results,
1008                mean_range,
1009                std_dev_range,
1010                mean_impact_velocity: mean_velocity,
1011                std_dev_impact_velocity: std_dev_velocity,
1012                hit_probability,
1013            });
1014
1015            Box::into_raw(result)
1016        }
1017        Err(_) => ptr::null_mut(),
1018    }
1019}
1020
1021/// Release Monte Carlo results allocated by either Monte Carlo C entry point.
1022///
1023/// # Safety
1024///
1025/// `results` must be null or a pointer returned by [`ballistics_monte_carlo`] or
1026/// [`ballistics_monte_carlo_with_direction_std_dev`] that has not already been freed. Its
1027/// embedded pointers and result count must be unchanged from the returned values. After this
1028/// call, the result and all of its arrays must not be accessed again.
1029#[no_mangle]
1030pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
1031    if results.is_null() {
1032        return;
1033    }
1034
1035    unsafe {
1036        let results = Box::from_raw(results);
1037        let num = results.num_results as usize;
1038
1039        // Free arrays
1040        if !results.ranges.is_null() {
1041            std::alloc::dealloc(
1042                results.ranges as *mut u8,
1043                std::alloc::Layout::array::<c_double>(num).unwrap(),
1044            );
1045        }
1046
1047        if !results.impact_velocities.is_null() {
1048            std::alloc::dealloc(
1049                results.impact_velocities as *mut u8,
1050                std::alloc::Layout::array::<c_double>(num).unwrap(),
1051            );
1052        }
1053
1054        if !results.impact_positions_x.is_null() {
1055            std::alloc::dealloc(
1056                results.impact_positions_x as *mut u8,
1057                std::alloc::Layout::array::<c_double>(num).unwrap(),
1058            );
1059        }
1060
1061        if !results.impact_positions_y.is_null() {
1062            std::alloc::dealloc(
1063                results.impact_positions_y as *mut u8,
1064                std::alloc::Layout::array::<c_double>(num).unwrap(),
1065            );
1066        }
1067
1068        if !results.impact_positions_z.is_null() {
1069            std::alloc::dealloc(
1070                results.impact_positions_z as *mut u8,
1071                std::alloc::Layout::array::<c_double>(num).unwrap(),
1072            );
1073        }
1074
1075        // Box automatically deallocates the result structure
1076    }
1077}
1078
1079/// Which standard atmosphere a `bc` value passed to [`ballistics_bc_for_reference_standard`]
1080/// is referenced to (MBA-1365). `0` = ICAO (the default every other export in this module
1081/// assumes), `1` = Army Standard Metro (some vendor-published BCs, notably many
1082/// Sierra/Hornady/Barnes bullets). Any other value is treated as `0` (ICAO), matching the
1083/// permissive unrecognized-value convention `convert_inputs` already uses for `bc_type`.
1084pub const FFI_BC_REFERENCE_ICAO: c_int = 0;
1085pub const FFI_BC_REFERENCE_ARMY_STANDARD_METRO: c_int = 1;
1086
1087/// Convert a ballistic coefficient declared under `reference_standard` to the ICAO-referenced
1088/// value every `FFIBallisticInputs.bc_value` in this module expects (MBA-1365).
1089///
1090/// `FFIBallisticInputs` is an ABI-frozen `repr(C)` struct (an iOS-consumer contract enforced
1091/// by a regression test) with no room to add a reference-standard field, so this is a
1092/// standalone pure conversion instead of a struct setter: call it once on a raw BC before
1093/// writing the result into `FFIBallisticInputs.bc_value`, then use every existing
1094/// `ballistics_calculate_trajectory*`/`ballistics_calculate_zero_angle*`/`ballistics_monte_carlo*`
1095/// export completely unchanged. `reference_standard == FFI_BC_REFERENCE_ICAO` (`0`) is a
1096/// no-op, so every existing caller that never calls this function is unaffected — this is a
1097/// pure addition to the ABI, not a modification, so no recompile is required unless a caller
1098/// opts into the new symbol.
1099///
1100/// `reference_standard == FFI_BC_REFERENCE_ARMY_STANDARD_METRO` (`1`) multiplies by
1101/// [`crate::constants::ASM_TO_ICAO_BC`], the same constant and the same single multiply
1102/// [`crate::cli_api::TrajectorySolver::new`] applies for the CLI/WASM/Rust-native surfaces.
1103/// A non-finite `bc` is returned unchanged (this function performs no validation; the
1104/// existing `bc_value must be finite and greater than zero` solve-time check still applies).
1105#[no_mangle]
1106pub extern "C" fn ballistics_bc_for_reference_standard(
1107    bc: c_double,
1108    reference_standard: c_int,
1109) -> c_double {
1110    if reference_standard == FFI_BC_REFERENCE_ARMY_STANDARD_METRO {
1111        bc * crate::constants::ASM_TO_ICAO_BC
1112    } else {
1113        bc
1114    }
1115}
1116
1117/// Reduce a sea-level-corrected altimeter setting (QNH, in hPa) to the station pressure at
1118/// `altitude_m` (MBA-1397; see [`crate::atmosphere::reduce_qnh_to_station_pressure`] for the
1119/// formula). `FFIAtmosphericConditions.pressure` has always meant absolute station pressure,
1120/// and remains a frozen `repr(C)` struct enforced by an ABI regression test with no room to
1121/// add a pressure-reference-mode field, so this is a standalone pure conversion instead of a
1122/// struct setter — exactly the same pattern as [`ballistics_bc_for_reference_standard`]: call
1123/// it once on a caller-declared QNH reading before writing the result into
1124/// `FFIAtmosphericConditions.pressure`, then use every existing
1125/// `ballistics_calculate_trajectory*`/`ballistics_calculate_zero_angle*`/`ballistics_monte_carlo*`
1126/// export completely unchanged — every one of them reads `pressure` as absolute station
1127/// pressure already, so feeding it an already-reduced value is a pure addition to the ABI,
1128/// not a modification. No existing caller that never calls this function is affected, and no
1129/// recompile is required for callers that don't opt into QNH support.
1130///
1131/// A non-finite `qnh_hpa` or `altitude_m` is returned unchanged (this function performs no
1132/// validation; the existing per-export input checks still apply to whatever ends up in
1133/// `FFIAtmosphericConditions.pressure`).
1134#[no_mangle]
1135pub extern "C" fn ballistics_reduce_qnh_pressure(
1136    qnh_hpa: c_double,
1137    altitude_m: c_double,
1138) -> c_double {
1139    if !qnh_hpa.is_finite() || !altitude_m.is_finite() {
1140        return qnh_hpa;
1141    }
1142    crate::atmosphere::reduce_qnh_to_station_pressure(qnh_hpa, altitude_m)
1143}
1144
1145/// Sentinel `explicit_temperature_c` value meaning "no explicit temperature override" for the
1146/// `ballistics_density_altitude_*` exports below (MBA-1366) — same NaN-means-absent convention
1147/// [`FFIBallisticInputs::latitude`] already uses. Any NaN bit pattern is accepted (checked via
1148/// `is_nan()`, not equality), matching that precedent.
1149pub const FFI_NO_EXPLICIT_TEMPERATURE: c_double = f64::NAN;
1150
1151/// Back-solve the station TEMPERATURE (Celsius) an ISA-equivalent atmosphere at
1152/// `density_altitude_m` implies (MBA-1366; see
1153/// [`crate::atmosphere::resolve_atmosphere_for_density_altitude`] for the full derivation).
1154///
1155/// `FFIAtmosphericConditions` is an ABI-frozen `repr(C)` struct (the same iOS-consumer contract
1156/// enforced by a regression test as [`ballistics_reduce_qnh_pressure`]/
1157/// [`ballistics_bc_for_reference_standard`]) with no room for a density-altitude field, so this
1158/// is a standalone pure conversion — call it (and its two companions below) once on a declared
1159/// density altitude, then write the three results into `FFIAtmosphericConditions.temperature`/
1160/// `.pressure`/`.altitude` before calling any existing `ballistics_calculate_trajectory*`/
1161/// `ballistics_calculate_zero_angle*`/`ballistics_monte_carlo*` export unchanged — a pure
1162/// addition to the C ABI requiring no recompile for existing callers.
1163///
1164/// `explicit_temperature_c`: pass [`FFI_NO_EXPLICIT_TEMPERATURE`] (NaN) for the ISA-at-density-
1165/// altitude default, or a real Celsius value to have it honored exactly (density is still
1166/// honored either way — only the implied pressure/altitude differ; see the Rust doc comment).
1167/// A non-finite `density_altitude_m` returns NaN for all three exports (there is no plausible
1168/// station value to fall back to, unlike the QNH/BC conversions above, which return their input
1169/// unchanged).
1170#[no_mangle]
1171pub extern "C" fn ballistics_density_altitude_temperature_c(
1172    density_altitude_m: c_double,
1173    explicit_temperature_c: c_double,
1174) -> c_double {
1175    if !density_altitude_m.is_finite() {
1176        return f64::NAN;
1177    }
1178    let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1179    crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).1
1180}
1181
1182/// Companion to [`ballistics_density_altitude_temperature_c`]: the back-solved station PRESSURE
1183/// (hPa) for the same `(density_altitude_m, explicit_temperature_c)` pair.
1184#[no_mangle]
1185pub extern "C" fn ballistics_density_altitude_pressure_hpa(
1186    density_altitude_m: c_double,
1187    explicit_temperature_c: c_double,
1188) -> c_double {
1189    if !density_altitude_m.is_finite() {
1190        return f64::NAN;
1191    }
1192    let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1193    crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).2
1194}
1195
1196/// Companion to [`ballistics_density_altitude_temperature_c`]: the back-solved station ALTITUDE
1197/// (meters, geometric) for the same `(density_altitude_m, explicit_temperature_c)` pair. This is
1198/// NOT necessarily equal to `density_altitude_m` — it only is when `explicit_temperature_c` is
1199/// [`FFI_NO_EXPLICIT_TEMPERATURE`] (see the Rust doc comment's algebraic identity).
1200#[no_mangle]
1201pub extern "C" fn ballistics_density_altitude_altitude_m(
1202    density_altitude_m: c_double,
1203    explicit_temperature_c: c_double,
1204) -> c_double {
1205    if !density_altitude_m.is_finite() {
1206        return f64::NAN;
1207    }
1208    let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1209    crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).0
1210}
1211
1212/// Largest `marks_len` [`ballistics_hold_point_in_reticle`] will accept (MBA-1361).
1213///
1214/// Mirrors [`MAX_FFI_DRAG_TABLE_LEN`] and exists for the same reason (MBA-1407): the
1215/// export copies a caller-owned array whose length it cannot otherwise verify, so an
1216/// unbounded `len` would let one call request a multi-gigabyte read. It is also
1217/// [`crate::reticle::MAX_RETICLE_MARKS`], so the C ABI and the Rust API reject the same
1218/// inputs. Real reticles carry tens of marks; a dense tree carries a few hundred.
1219pub const MAX_FFI_RETICLE_MARKS: c_int = crate::reticle::MAX_RETICLE_MARKS as c_int;
1220
1221/// `focal_plane` value selecting a FIRST-focal-plane reticle (subtensions constant across
1222/// magnification). Any value other than [`FFI_RETICLE_SECOND_FOCAL_PLANE`] is treated as FFP.
1223pub const FFI_RETICLE_FIRST_FOCAL_PLANE: c_int = 0;
1224/// `focal_plane` value selecting a SECOND-focal-plane reticle (subtensions scale as
1225/// `reference_magnification / magnification`).
1226pub const FFI_RETICLE_SECOND_FOCAL_PLANE: c_int = 1;
1227
1228/// [`ballistics_hold_point_in_reticle`] succeeded and `out` was written.
1229pub const FFI_RETICLE_OK: c_int = 0;
1230/// A null pointer, or a `marks_len` outside `1..=`[`MAX_FFI_RETICLE_MARKS`].
1231pub const FFI_RETICLE_ERR_INVALID_ARGUMENT: c_int = -1;
1232/// `magnification` was not finite and greater than zero.
1233pub const FFI_RETICLE_ERR_MAGNIFICATION: c_int = -2;
1234/// A second-focal-plane call carried a non-finite or non-positive `reference_magnification`.
1235pub const FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION: c_int = -3;
1236/// A mark coordinate, or the supplied firing solution, was not finite.
1237pub const FFI_RETICLE_ERR_NON_FINITE: c_int = -4;
1238
1239/// Where a firing solution lands in a reticle (MBA-1361).
1240///
1241/// A NEW appended struct — no existing `repr(C)` layout is touched by this addition, so
1242/// existing callers need no recompile. All angles are milliradians from the optical
1243/// center; `down` is positive BELOW center and `right` is positive to the shooter's
1244/// RIGHT (see [`crate::reticle`] for the full convention block).
1245#[repr(C)]
1246pub struct FFIReticleHold {
1247    /// True angular milliradians below center. Equals the supplied `drop_mil`.
1248    pub down_mil: c_double,
1249    /// True angular milliradians right of center. Equals the supplied `wind_mil`.
1250    pub right_mil: c_double,
1251    /// Index of the nearest mark in the caller's array, or `-1` when there is none
1252    /// (unreachable today: an empty mark array is rejected before the search).
1253    pub nearest_mark: c_int,
1254    /// Distance from the hold to that mark, milliradians, measured in TRUE angular space
1255    /// (i.e. after second-focal-plane scaling).
1256    pub nearest_mark_distance_mil: c_double,
1257    /// `1` when the hold falls outside the marks' bounding box grown by 20% of its span
1258    /// per axis, `0` otherwise.
1259    pub off_reticle: c_int,
1260    /// The subtension scale applied to the marks: `reference_magnification / magnification`
1261    /// for a second-focal-plane reticle, exactly `1.0` for first focal plane.
1262    pub mark_scale: c_double,
1263}
1264
1265/// Place an angular firing solution in a reticle (MBA-1361).
1266///
1267/// `marks` is a FLAT array of `2 * marks_len` doubles laid out as
1268/// `[down_0, right_0, down_1, right_1, ...]` in NOMINAL milliradians (as etched; for a
1269/// second-focal-plane reticle that means "true at `reference_magnification`").
1270/// `focal_plane` is [`FFI_RETICLE_FIRST_FOCAL_PLANE`] or
1271/// [`FFI_RETICLE_SECOND_FOCAL_PLANE`]; `reference_magnification` is consulted only in the
1272/// second-focal-plane case.
1273///
1274/// Returns [`FFI_RETICLE_OK`] and writes `out` on success, or one of the negative
1275/// `FFI_RETICLE_ERR_*` codes, in which case `out` is left untouched.
1276///
1277/// # Safety
1278///
1279/// `marks` must point to at least `2 * marks_len` readable `double`s and `out` to one
1280/// writable [`FFIReticleHold`]. `marks_len` is validated against
1281/// `1..=`[`MAX_FFI_RETICLE_MARKS`] BEFORE any element is read (MBA-1407 lesson: the
1282/// bound is the only thing standing between a caller typo and an out-of-range read).
1283#[no_mangle]
1284pub unsafe extern "C" fn ballistics_hold_point_in_reticle(
1285    drop_mil: c_double,
1286    wind_mil: c_double,
1287    magnification: c_double,
1288    marks: *const c_double,
1289    marks_len: c_int,
1290    focal_plane: c_int,
1291    reference_magnification: c_double,
1292    out: *mut FFIReticleHold,
1293) -> c_int {
1294    use crate::reticle::{
1295        hold_point_in_reticle, FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark,
1296    };
1297
1298    if marks.is_null() || out.is_null() || !(1..=MAX_FFI_RETICLE_MARKS).contains(&marks_len) {
1299        return FFI_RETICLE_ERR_INVALID_ARGUMENT;
1300    }
1301    let count = marks_len as usize;
1302    // Length validated above, so this read stays inside the caller's declared array.
1303    let flat = unsafe { std::slice::from_raw_parts(marks, count * 2) };
1304
1305    let description = ReticleDescription {
1306        name: String::new(),
1307        focal_plane: if focal_plane == FFI_RETICLE_SECOND_FOCAL_PLANE {
1308            FocalPlane::Second
1309        } else {
1310            FocalPlane::First
1311        },
1312        reference_magnification,
1313        marks: flat
1314            .chunks_exact(2)
1315            .map(|pair| ReticleMark::new(pair[0], pair[1], MarkKind::Dot))
1316            .collect(),
1317    };
1318
1319    let hold = match hold_point_in_reticle(drop_mil, wind_mil, magnification, &description) {
1320        Ok(hold) => hold,
1321        Err(ReticleError::NonPositiveMagnification { .. }) => return FFI_RETICLE_ERR_MAGNIFICATION,
1322        Err(ReticleError::NonPositiveReferenceMagnification { .. }) => {
1323            return FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION
1324        }
1325        Err(ReticleError::NonFiniteMark { .. }) | Err(ReticleError::NonFiniteHold { .. }) => {
1326            return FFI_RETICLE_ERR_NON_FINITE
1327        }
1328        Err(_) => return FFI_RETICLE_ERR_INVALID_ARGUMENT,
1329    };
1330
1331    unsafe {
1332        *out = FFIReticleHold {
1333            down_mil: hold.down_mil,
1334            right_mil: hold.right_mil,
1335            nearest_mark: hold.nearest_mark.map_or(-1, |index| index as c_int),
1336            nearest_mark_distance_mil: hold.nearest_mark_distance_mil,
1337            off_reticle: c_int::from(hold.off_reticle),
1338            mark_scale: hold.mark_scale,
1339        };
1340    }
1341    FFI_RETICLE_OK
1342}
1343
1344// Get library version
1345#[no_mangle]
1346pub extern "C" fn ballistics_get_version() -> *const c_char {
1347    // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
1348    // Previously this leaked a freshly-allocated CString on every call and reported a
1349    // stale hardcoded "0.3.0"; use the real crate version with no allocation.
1350    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::*;
1356
1357    fn valid_trajectory_inputs() -> FFIBallisticInputs {
1358        FFIBallisticInputs {
1359            muzzle_velocity: 800.0,
1360            muzzle_angle: 0.0,
1361            bc_value: 0.5,
1362            bullet_mass: 0.01,
1363            bullet_diameter: 0.00762,
1364            bc_type: 0,
1365            sight_height: 0.05,
1366            target_distance: 1.0,
1367            temperature: 15.0,
1368            twist_rate: 12.0,
1369            is_twist_right: 1,
1370            shooting_angle: 0.0,
1371            altitude: 0.0,
1372            latitude: f64::NAN,
1373            azimuth_angle: 0.0,
1374            use_rk4: 1,
1375            use_adaptive_rk45: 0,
1376            enable_wind_shear: 0,
1377            enable_trajectory_sampling: 0,
1378            sample_interval: 10.0,
1379            enable_pitch_damping: 0,
1380            enable_precession_nutation: 0,
1381            enable_spin_drift: 0,
1382            enable_magnus: 0,
1383            enable_coriolis: 0,
1384            shot_azimuth: 0.0,
1385            cant_angle: 0.0,
1386            zero_poi_vertical: 0.0,
1387            zero_poi_horizontal: 0.0,
1388            sight_offset_lateral: 0.0,
1389        }
1390    }
1391
1392    #[allow(dead_code)]
1393    #[repr(C)]
1394    struct LegacyFFIMonteCarloParams {
1395        num_simulations: c_int,
1396        velocity_std_dev: c_double,
1397        angle_std_dev: c_double,
1398        bc_std_dev: c_double,
1399        wind_speed_std_dev: c_double,
1400        target_distance: c_double,
1401        base_wind_speed: c_double,
1402        base_wind_direction: c_double,
1403        azimuth_std_dev: c_double,
1404    }
1405
1406    #[test]
1407    fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1408        assert_eq!(
1409            std::mem::size_of::<FFIMonteCarloParams>(),
1410            std::mem::size_of::<LegacyFFIMonteCarloParams>()
1411        );
1412        assert_eq!(
1413            std::mem::align_of::<FFIMonteCarloParams>(),
1414            std::mem::align_of::<LegacyFFIMonteCarloParams>()
1415        );
1416    }
1417
1418    /// MBA-1361: the reticle export is APPEND-ONLY — a new struct and a new function.
1419    /// This pins that no pre-existing `repr(C)` layout moved when it landed.
1420    #[test]
1421    fn reticle_addition_does_not_disturb_existing_layouts() {
1422        assert_eq!(
1423            std::mem::size_of::<FFIMonteCarloParams>(),
1424            std::mem::size_of::<LegacyFFIMonteCarloParams>()
1425        );
1426        // The new struct is 6 fields: 4 doubles + 2 ints, C-laid-out.
1427        assert_eq!(std::mem::align_of::<FFIReticleHold>(), 8);
1428    }
1429
1430    fn zeroed_hold() -> FFIReticleHold {
1431        FFIReticleHold {
1432            down_mil: 0.0,
1433            right_mil: 0.0,
1434            nearest_mark: -99,
1435            nearest_mark_distance_mil: -1.0,
1436            off_reticle: -1,
1437            mark_scale: -1.0,
1438        }
1439    }
1440
1441    #[test]
1442    fn ffi_hold_point_matches_the_rust_api_on_both_focal_planes() {
1443        // down/right pairs: center, 2 mil, 4 mil, and a windage dot.
1444        let marks: [c_double; 8] = [0.0, 0.0, 2.0, 0.0, 4.0, 0.0, 2.0, 1.0];
1445        let mut out = zeroed_hold();
1446
1447        // FFP at any magnification: marks are used as etched.
1448        let code = unsafe {
1449            ballistics_hold_point_in_reticle(
1450                4.0,
1451                0.0,
1452                6.0,
1453                marks.as_ptr(),
1454                4,
1455                FFI_RETICLE_FIRST_FOCAL_PLANE,
1456                0.0,
1457                &mut out,
1458            )
1459        };
1460        assert_eq!(code, FFI_RETICLE_OK);
1461        assert_eq!(out.down_mil, 4.0);
1462        assert_eq!(out.nearest_mark, 2);
1463        assert_eq!(out.nearest_mark_distance_mil, 0.0);
1464        assert_eq!(out.mark_scale, 1.0);
1465        assert_eq!(out.off_reticle, 0);
1466
1467        // SFP at half the reference magnification: the 2 mil mark reads 4 mil true.
1468        let mut out = zeroed_hold();
1469        let code = unsafe {
1470            ballistics_hold_point_in_reticle(
1471                4.0,
1472                0.0,
1473                5.0,
1474                marks.as_ptr(),
1475                4,
1476                FFI_RETICLE_SECOND_FOCAL_PLANE,
1477                10.0,
1478                &mut out,
1479            )
1480        };
1481        assert_eq!(code, FFI_RETICLE_OK);
1482        assert_eq!(out.nearest_mark, 1);
1483        assert_eq!(out.nearest_mark_distance_mil, 0.0);
1484        assert_eq!(out.mark_scale, 2.0);
1485    }
1486
1487    /// The MBA-1407 lesson applied to the new export: `marks_len` is validated against a
1488    /// stated bound BEFORE a single element is read, and a null pointer is rejected.
1489    #[test]
1490    fn ffi_hold_point_bounds_check_marks_len_before_reading() {
1491        let marks: [c_double; 4] = [0.0, 0.0, 2.0, 0.0];
1492        let mut out = zeroed_hold();
1493        let call = |len: c_int, ptr: *const c_double, out: &mut FFIReticleHold| unsafe {
1494            ballistics_hold_point_in_reticle(
1495                1.0,
1496                0.0,
1497                10.0,
1498                ptr,
1499                len,
1500                FFI_RETICLE_FIRST_FOCAL_PLANE,
1501                0.0,
1502                out,
1503            )
1504        };
1505
1506        assert_eq!(call(0, marks.as_ptr(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1507        assert_eq!(call(-1, marks.as_ptr(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1508        assert_eq!(
1509            call(MAX_FFI_RETICLE_MARKS + 1, marks.as_ptr(), &mut out),
1510            FFI_RETICLE_ERR_INVALID_ARGUMENT
1511        );
1512        assert_eq!(call(c_int::MAX, marks.as_ptr(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1513        assert_eq!(call(2, std::ptr::null(), &mut out), FFI_RETICLE_ERR_INVALID_ARGUMENT);
1514        // `out` is untouched on every rejection.
1515        assert_eq!(out.nearest_mark, -99);
1516
1517        // A null `out` is rejected too, without reading the marks.
1518        assert_eq!(
1519            unsafe {
1520                ballistics_hold_point_in_reticle(
1521                    1.0,
1522                    0.0,
1523                    10.0,
1524                    marks.as_ptr(),
1525                    2,
1526                    FFI_RETICLE_FIRST_FOCAL_PLANE,
1527                    0.0,
1528                    std::ptr::null_mut(),
1529                )
1530            },
1531            FFI_RETICLE_ERR_INVALID_ARGUMENT
1532        );
1533    }
1534
1535    #[test]
1536    fn ffi_hold_point_maps_each_error_class_to_its_own_code() {
1537        let marks: [c_double; 4] = [0.0, 0.0, 2.0, 0.0];
1538        let bad_marks: [c_double; 4] = [0.0, 0.0, f64::NAN, 0.0];
1539        let mut out = zeroed_hold();
1540        let call = |drop: c_double, mag: c_double, plane: c_int, ref_mag: c_double,
1541                    m: &[c_double], out: &mut FFIReticleHold| unsafe {
1542            ballistics_hold_point_in_reticle(
1543                drop,
1544                0.0,
1545                mag,
1546                m.as_ptr(),
1547                (m.len() / 2) as c_int,
1548                plane,
1549                ref_mag,
1550                out,
1551            )
1552        };
1553
1554        assert_eq!(
1555            call(1.0, 0.0, FFI_RETICLE_FIRST_FOCAL_PLANE, 0.0, &marks, &mut out),
1556            FFI_RETICLE_ERR_MAGNIFICATION
1557        );
1558        assert_eq!(
1559            call(1.0, 10.0, FFI_RETICLE_SECOND_FOCAL_PLANE, 0.0, &marks, &mut out),
1560            FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION
1561        );
1562        assert_eq!(
1563            call(f64::NAN, 10.0, FFI_RETICLE_FIRST_FOCAL_PLANE, 0.0, &marks, &mut out),
1564            FFI_RETICLE_ERR_NON_FINITE
1565        );
1566        assert_eq!(
1567            call(1.0, 10.0, FFI_RETICLE_FIRST_FOCAL_PLANE, 0.0, &bad_marks, &mut out),
1568            FFI_RETICLE_ERR_NON_FINITE
1569        );
1570        assert_eq!(out.nearest_mark, -99, "out stays untouched on every error");
1571    }
1572
1573    /// MBA-1386 scope addition: `bc_type` gains an additive numeric slot (8) for the
1574    /// new RA4 family, appended after the existing 0-7 mapping (G1, G7, G2, G5, G6,
1575    /// G8, GI, GS). No existing caller's value changes meaning; `8` is simply new,
1576    /// and anything still outside 0-8 keeps falling back to G1.
1577    #[test]
1578    fn bc_type_8_maps_to_ra4() {
1579        let mut inputs = valid_trajectory_inputs();
1580        inputs.bc_type = 8;
1581        assert_eq!(convert_inputs(&inputs).bc_type, DragModel::RA4);
1582
1583        // Every pre-existing code is unaffected by the addition.
1584        let expected = [
1585            (0, DragModel::G1),
1586            (1, DragModel::G7),
1587            (2, DragModel::G2),
1588            (3, DragModel::G5),
1589            (4, DragModel::G6),
1590            (5, DragModel::G8),
1591            (6, DragModel::GI),
1592            (7, DragModel::GS),
1593        ];
1594        for (code, model) in expected {
1595            let mut inputs = valid_trajectory_inputs();
1596            inputs.bc_type = code;
1597            assert_eq!(convert_inputs(&inputs).bc_type, model, "code {code}");
1598        }
1599
1600        // Unrecognized codes (including ones above the new 8) still fall back to G1.
1601        for code in [9, 42, -1] {
1602            let mut inputs = valid_trajectory_inputs();
1603            inputs.bc_type = code;
1604            assert_eq!(convert_inputs(&inputs).bc_type, DragModel::G1, "code {code}");
1605        }
1606    }
1607
1608    #[test]
1609    fn null_pointer_contracts_return_sentinels_and_free_safely() {
1610        unsafe {
1611            assert!(ballistics_calculate_trajectory(
1612                std::ptr::null(),
1613                std::ptr::null(),
1614                std::ptr::null(),
1615                1_000.0,
1616                1.0,
1617            )
1618            .is_null());
1619            assert!(ballistics_calculate_zero_angle(
1620                std::ptr::null(),
1621                std::ptr::null(),
1622                std::ptr::null(),
1623                100.0,
1624            )
1625            .is_nan());
1626            assert!(ballistics_calculate_trajectory_with_drag_table(
1627                std::ptr::null(),
1628                std::ptr::null(),
1629                std::ptr::null(),
1630                1_000.0,
1631                1.0,
1632                DECK_MACH.as_ptr(),
1633                DECK_CD_LOW.as_ptr(),
1634                DECK_MACH.len() as c_int,
1635            )
1636            .is_null());
1637            assert!(ballistics_calculate_zero_angle_with_drag_table(
1638                std::ptr::null(),
1639                std::ptr::null(),
1640                std::ptr::null(),
1641                100.0,
1642                DECK_MACH.as_ptr(),
1643                DECK_CD_LOW.as_ptr(),
1644                DECK_MACH.len() as c_int,
1645            )
1646            .is_nan());
1647            assert!(
1648                ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1649                    .is_null()
1650            );
1651            assert!(ballistics_monte_carlo_with_direction_std_dev(
1652                std::ptr::null(),
1653                std::ptr::null(),
1654                std::ptr::null(),
1655                0.1,
1656            )
1657            .is_null());
1658
1659            ballistics_free_trajectory_result(std::ptr::null_mut());
1660            ballistics_free_monte_carlo_results(std::ptr::null_mut());
1661        }
1662    }
1663
1664    #[test]
1665    fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1666        for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1667            for step_size in [
1668                f64::NAN,
1669                f64::INFINITY,
1670                f64::NEG_INFINITY,
1671                -1.0,
1672                -0.0,
1673                0.0,
1674                0.001,
1675                MIN_FFI_STEP_SIZE_MS - 0.001,
1676            ] {
1677                let mut inputs = valid_trajectory_inputs();
1678                inputs.use_rk4 = use_rk4;
1679                inputs.use_adaptive_rk45 = use_adaptive_rk45;
1680                let result = unsafe {
1681                    ballistics_calculate_trajectory(
1682                        &inputs,
1683                        std::ptr::null(),
1684                        std::ptr::null(),
1685                        0.01,
1686                        step_size,
1687                    )
1688                };
1689                assert!(
1690                    result.is_null(),
1691                    "{mode} step_size={step_size:?} bypassed the FFI floor"
1692                );
1693            }
1694
1695            let mut inputs = valid_trajectory_inputs();
1696            inputs.use_rk4 = use_rk4;
1697            inputs.use_adaptive_rk45 = use_adaptive_rk45;
1698            let result = unsafe {
1699                ballistics_calculate_trajectory(
1700                    &inputs,
1701                    std::ptr::null(),
1702                    std::ptr::null(),
1703                    0.01,
1704                    MIN_FFI_STEP_SIZE_MS,
1705                )
1706            };
1707            assert!(
1708                !result.is_null(),
1709                "the documented minimum step must remain usable in {mode}"
1710            );
1711            unsafe {
1712                assert!((*result).point_count >= 0);
1713                assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1714                ballistics_free_trajectory_result(result);
1715            }
1716        }
1717    }
1718
1719    /// A tiny valid deck: strictly ascending Mach, positive Cd.
1720    const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1721    /// Deliberately LOW drag so the deck measurably increases impact velocity vs G1.
1722    const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1723
1724    #[test]
1725    fn trajectory_with_drag_table_applies_the_deck() {
1726        let inputs = valid_trajectory_inputs();
1727        unsafe {
1728            let plain = ballistics_calculate_trajectory(
1729                &inputs,
1730                std::ptr::null(),
1731                std::ptr::null(),
1732                300.0,
1733                1.0,
1734            );
1735            let decked = ballistics_calculate_trajectory_with_drag_table(
1736                &inputs,
1737                std::ptr::null(),
1738                std::ptr::null(),
1739                300.0,
1740                1.0,
1741                DECK_MACH.as_ptr(),
1742                DECK_CD_LOW.as_ptr(),
1743                DECK_MACH.len() as c_int,
1744            );
1745            assert!(!plain.is_null() && !decked.is_null());
1746            // The low-drag deck must retain materially more velocity than the G-model.
1747            assert!(
1748                (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1749                "deck did not change the solve: plain={} decked={}",
1750                (*plain).impact_velocity,
1751                (*decked).impact_velocity
1752            );
1753            ballistics_free_trajectory_result(plain);
1754            ballistics_free_trajectory_result(decked);
1755        }
1756    }
1757
1758    #[test]
1759    fn trajectory_with_drag_table_rejects_invalid_decks() {
1760        let inputs = valid_trajectory_inputs();
1761        let descending = [3.0, 2.0, 1.0, 0.5];
1762        let negative_cd = [0.05, -0.08, 0.06, 0.05];
1763        unsafe {
1764            // null arrays
1765            assert!(ballistics_calculate_trajectory_with_drag_table(
1766                &inputs,
1767                std::ptr::null(),
1768                std::ptr::null(),
1769                300.0,
1770                1.0,
1771                std::ptr::null(),
1772                DECK_CD_LOW.as_ptr(),
1773                4,
1774            )
1775            .is_null());
1776            assert!(ballistics_calculate_trajectory_with_drag_table(
1777                &inputs,
1778                std::ptr::null(),
1779                std::ptr::null(),
1780                300.0,
1781                1.0,
1782                DECK_MACH.as_ptr(),
1783                std::ptr::null(),
1784                4,
1785            )
1786            .is_null());
1787            // too few points
1788            assert!(ballistics_calculate_trajectory_with_drag_table(
1789                &inputs,
1790                std::ptr::null(),
1791                std::ptr::null(),
1792                300.0,
1793                1.0,
1794                DECK_MACH.as_ptr(),
1795                DECK_CD_LOW.as_ptr(),
1796                1,
1797            )
1798            .is_null());
1799            // non-ascending Mach
1800            assert!(ballistics_calculate_trajectory_with_drag_table(
1801                &inputs,
1802                std::ptr::null(),
1803                std::ptr::null(),
1804                300.0,
1805                1.0,
1806                descending.as_ptr(),
1807                DECK_CD_LOW.as_ptr(),
1808                4,
1809            )
1810            .is_null());
1811            // non-positive Cd
1812            assert!(ballistics_calculate_trajectory_with_drag_table(
1813                &inputs,
1814                std::ptr::null(),
1815                std::ptr::null(),
1816                300.0,
1817                1.0,
1818                DECK_MACH.as_ptr(),
1819                negative_cd.as_ptr(),
1820                4,
1821            )
1822            .is_null());
1823            // null inputs still rejected
1824            assert!(ballistics_calculate_trajectory_with_drag_table(
1825                std::ptr::null(),
1826                std::ptr::null(),
1827                std::ptr::null(),
1828                300.0,
1829                1.0,
1830                DECK_MACH.as_ptr(),
1831                DECK_CD_LOW.as_ptr(),
1832                4,
1833            )
1834            .is_null());
1835        }
1836    }
1837
1838    #[test]
1839    fn zero_angle_with_drag_table_applies_the_deck() {
1840        // A realistic zeroing setup: 100 m zero.
1841        let inputs = valid_trajectory_inputs();
1842        unsafe {
1843            let plain =
1844                ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1845            let decked = ballistics_calculate_zero_angle_with_drag_table(
1846                &inputs,
1847                std::ptr::null(),
1848                std::ptr::null(),
1849                100.0,
1850                DECK_MACH.as_ptr(),
1851                DECK_CD_LOW.as_ptr(),
1852                DECK_MACH.len() as c_int,
1853            );
1854            assert!(plain.is_finite() && decked.is_finite());
1855            // A much lower-drag deck needs a flatter (smaller) zero angle; at minimum it
1856            // must differ measurably from the G-model zero.
1857            assert!(
1858                (plain - decked).abs() > 1e-6,
1859                "deck did not change the zero: plain={plain} decked={decked}"
1860            );
1861        }
1862    }
1863
1864    #[test]
1865    fn zero_angle_with_drag_table_rejects_invalid_decks() {
1866        let inputs = valid_trajectory_inputs();
1867        let descending = [3.0, 2.0, 1.0, 0.5];
1868        unsafe {
1869            assert!(ballistics_calculate_zero_angle_with_drag_table(
1870                &inputs,
1871                std::ptr::null(),
1872                std::ptr::null(),
1873                100.0,
1874                std::ptr::null(),
1875                DECK_CD_LOW.as_ptr(),
1876                4,
1877            )
1878            .is_nan());
1879            assert!(ballistics_calculate_zero_angle_with_drag_table(
1880                &inputs,
1881                std::ptr::null(),
1882                std::ptr::null(),
1883                100.0,
1884                DECK_MACH.as_ptr(),
1885                DECK_CD_LOW.as_ptr(),
1886                0,
1887            )
1888            .is_nan());
1889            assert!(ballistics_calculate_zero_angle_with_drag_table(
1890                &inputs,
1891                std::ptr::null(),
1892                std::ptr::null(),
1893                100.0,
1894                descending.as_ptr(),
1895                DECK_CD_LOW.as_ptr(),
1896                4,
1897            )
1898            .is_nan());
1899            // null inputs still rejected
1900            assert!(ballistics_calculate_zero_angle_with_drag_table(
1901                std::ptr::null(),
1902                std::ptr::null(),
1903                std::ptr::null(),
1904                100.0,
1905                DECK_MACH.as_ptr(),
1906                DECK_CD_LOW.as_ptr(),
1907                4,
1908            )
1909            .is_nan());
1910        }
1911    }
1912
1913    #[test]
1914    fn zero_then_fly_with_same_deck_is_consistent() {
1915        // The pair-use case the two exports exist for: zero with the deck, fly with the
1916        // deck at the solved angle; the trajectory must cross near sight height at the
1917        // zero distance (i.e. the two functions share identical deck semantics).
1918        let mut inputs = valid_trajectory_inputs();
1919        unsafe {
1920            let angle = ballistics_calculate_zero_angle_with_drag_table(
1921                &inputs,
1922                std::ptr::null(),
1923                std::ptr::null(),
1924                100.0,
1925                DECK_MACH.as_ptr(),
1926                DECK_CD_LOW.as_ptr(),
1927                DECK_MACH.len() as c_int,
1928            );
1929            assert!(angle.is_finite());
1930            inputs.muzzle_angle = angle;
1931            let result = ballistics_calculate_trajectory_with_drag_table(
1932                &inputs,
1933                std::ptr::null(),
1934                std::ptr::null(),
1935                150.0,
1936                1.0,
1937                DECK_MACH.as_ptr(),
1938                DECK_CD_LOW.as_ptr(),
1939                DECK_MACH.len() as c_int,
1940            );
1941            assert!(!result.is_null());
1942            // Interpolate y at exactly the zero distance (100 m) rather than snapping to the
1943            // nearest raw trajectory sample, so the residual reflects only zero-solver
1944            // convergence, not the sampling grid's x-offset from 100 m.
1945            let zero_distance = 100.0;
1946            let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
1947            let bracket = pts
1948                .windows(2)
1949                .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
1950                .expect("trajectory brackets the zero distance");
1951            let (lo, hi) = (&bracket[0], &bracket[1]);
1952            let y_at_zero = if hi.position_x > lo.position_x {
1953                let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
1954                lo.position_y + t * (hi.position_y - lo.position_y)
1955            } else {
1956                lo.position_y
1957            };
1958            assert!(
1959                (y_at_zero - inputs.sight_height).abs() < 0.002,
1960                "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
1961                y_at_zero,
1962                inputs.sight_height
1963            );
1964            ballistics_free_trajectory_result(result);
1965        }
1966    }
1967
1968    #[test]
1969    fn drag_table_len_above_cap_is_rejected() {
1970        // A valid, monotonically increasing deck that is simply too long: the cap
1971        // must reject it BEFORE the to_vec() copies, returning the null sentinel.
1972        let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
1973        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1974        let cd: Vec<f64> = vec![0.3; n];
1975        let inputs = valid_trajectory_inputs();
1976        unsafe {
1977            let r = ballistics_calculate_trajectory_with_drag_table(
1978                &inputs,
1979                std::ptr::null(),
1980                std::ptr::null(),
1981                300.0,
1982                1.0,
1983                mach.as_ptr(),
1984                cd.as_ptr(),
1985                n as c_int,
1986            );
1987            assert!(r.is_null(), "len {n} must be rejected by the cap");
1988        }
1989    }
1990
1991    #[test]
1992    fn drag_table_len_at_cap_is_accepted() {
1993        let n = MAX_FFI_DRAG_TABLE_LEN as usize;
1994        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1995        let cd: Vec<f64> = vec![0.3; n];
1996        let inputs = valid_trajectory_inputs();
1997        unsafe {
1998            let r = ballistics_calculate_trajectory_with_drag_table(
1999                &inputs,
2000                std::ptr::null(),
2001                std::ptr::null(),
2002                300.0,
2003                1.0,
2004                mach.as_ptr(),
2005                cd.as_ptr(),
2006                n as c_int,
2007            );
2008            assert!(!r.is_null(), "len == cap must be accepted");
2009            ballistics_free_trajectory_result(r);
2010        }
2011    }
2012
2013    #[test]
2014    fn ffi_cant_angle_deflects_laterally() {
2015        let mut level = valid_trajectory_inputs();
2016        level.muzzle_angle = 0.003;
2017        let mut canted = valid_trajectory_inputs();
2018        canted.muzzle_angle = 0.003;
2019        canted.cant_angle = 10f64.to_radians();
2020        unsafe {
2021            let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
2022            let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
2023            assert!(!a.is_null() && !b.is_null());
2024            let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
2025            let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
2026            assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
2027            ballistics_free_trajectory_result(a);
2028            ballistics_free_trajectory_result(b);
2029        }
2030    }
2031
2032    #[test]
2033    fn ffi_vertical_wind_raises_trajectory() {
2034        let inputs = valid_trajectory_inputs();
2035        let no_wind = FFIWindConditions {
2036            speed: 0.0,
2037            direction: 0.0,
2038            vertical_speed: 0.0,
2039        };
2040        let updraft = FFIWindConditions {
2041            speed: 0.0,
2042            direction: 0.0,
2043            vertical_speed: 5.0,
2044        };
2045        unsafe {
2046            let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
2047            let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
2048            assert!(!a.is_null() && !b.is_null());
2049            let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
2050            let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
2051            assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
2052            ballistics_free_trajectory_result(a);
2053            ballistics_free_trajectory_result(b);
2054        }
2055    }
2056
2057    // --- MBA-1356: cd_scale `_scaled` FFI variants ---
2058
2059    #[test]
2060    fn trajectory_scaled_at_one_matches_unscaled_export() {
2061        let inputs = valid_trajectory_inputs();
2062        unsafe {
2063            let unscaled = ballistics_calculate_trajectory_with_drag_table(
2064                &inputs,
2065                std::ptr::null(),
2066                std::ptr::null(),
2067                300.0,
2068                1.0,
2069                DECK_MACH.as_ptr(),
2070                DECK_CD_LOW.as_ptr(),
2071                DECK_MACH.len() as c_int,
2072            );
2073            let scaled = ballistics_calculate_trajectory_with_drag_table_scaled(
2074                &inputs,
2075                std::ptr::null(),
2076                std::ptr::null(),
2077                300.0,
2078                1.0,
2079                DECK_MACH.as_ptr(),
2080                DECK_CD_LOW.as_ptr(),
2081                DECK_MACH.len() as c_int,
2082                1.0,
2083            );
2084            assert!(!unscaled.is_null() && !scaled.is_null());
2085            assert_eq!(
2086                (*unscaled).impact_velocity.to_bits(),
2087                (*scaled).impact_velocity.to_bits(),
2088                "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={} scaled={}",
2089                (*unscaled).impact_velocity,
2090                (*scaled).impact_velocity
2091            );
2092            ballistics_free_trajectory_result(unscaled);
2093            ballistics_free_trajectory_result(scaled);
2094        }
2095    }
2096
2097    #[test]
2098    fn trajectory_scaled_at_1_10_lowers_impact_velocity() {
2099        let inputs = valid_trajectory_inputs();
2100        unsafe {
2101            let baseline = ballistics_calculate_trajectory_with_drag_table_scaled(
2102                &inputs,
2103                std::ptr::null(),
2104                std::ptr::null(),
2105                300.0,
2106                1.0,
2107                DECK_MACH.as_ptr(),
2108                DECK_CD_LOW.as_ptr(),
2109                DECK_MACH.len() as c_int,
2110                1.0,
2111            );
2112            let scaled_up = ballistics_calculate_trajectory_with_drag_table_scaled(
2113                &inputs,
2114                std::ptr::null(),
2115                std::ptr::null(),
2116                300.0,
2117                1.0,
2118                DECK_MACH.as_ptr(),
2119                DECK_CD_LOW.as_ptr(),
2120                DECK_MACH.len() as c_int,
2121                1.10,
2122            );
2123            assert!(!baseline.is_null() && !scaled_up.is_null());
2124            assert!(
2125                (*scaled_up).impact_velocity < (*baseline).impact_velocity,
2126                "cd_scale=1.10 must increase drag -> lower impact velocity: base={} scaled={}",
2127                (*baseline).impact_velocity,
2128                (*scaled_up).impact_velocity
2129            );
2130            ballistics_free_trajectory_result(baseline);
2131            ballistics_free_trajectory_result(scaled_up);
2132        }
2133    }
2134
2135    #[test]
2136    fn trajectory_scaled_rejects_invalid_cd_scale() {
2137        let inputs = valid_trajectory_inputs();
2138        unsafe {
2139            for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2140                let r = ballistics_calculate_trajectory_with_drag_table_scaled(
2141                    &inputs,
2142                    std::ptr::null(),
2143                    std::ptr::null(),
2144                    300.0,
2145                    1.0,
2146                    DECK_MACH.as_ptr(),
2147                    DECK_CD_LOW.as_ptr(),
2148                    DECK_MACH.len() as c_int,
2149                    bad,
2150                );
2151                assert!(r.is_null(), "cd_scale={bad} must be rejected (null sentinel)");
2152            }
2153        }
2154    }
2155
2156    #[test]
2157    fn zero_angle_scaled_at_one_matches_unscaled_export() {
2158        let inputs = valid_trajectory_inputs();
2159        unsafe {
2160            let unscaled = ballistics_calculate_zero_angle_with_drag_table(
2161                &inputs,
2162                std::ptr::null(),
2163                std::ptr::null(),
2164                100.0,
2165                DECK_MACH.as_ptr(),
2166                DECK_CD_LOW.as_ptr(),
2167                DECK_MACH.len() as c_int,
2168            );
2169            let scaled = ballistics_calculate_zero_angle_with_drag_table_scaled(
2170                &inputs,
2171                std::ptr::null(),
2172                std::ptr::null(),
2173                100.0,
2174                DECK_MACH.as_ptr(),
2175                DECK_CD_LOW.as_ptr(),
2176                DECK_MACH.len() as c_int,
2177                1.0,
2178            );
2179            assert!(unscaled.is_finite() && scaled.is_finite());
2180            assert_eq!(
2181                unscaled.to_bits(),
2182                scaled.to_bits(),
2183                "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={unscaled} scaled={scaled}"
2184            );
2185        }
2186    }
2187
2188    #[test]
2189    fn zero_angle_scaled_at_1_10_differs_from_baseline() {
2190        let inputs = valid_trajectory_inputs();
2191        unsafe {
2192            let baseline = ballistics_calculate_zero_angle_with_drag_table_scaled(
2193                &inputs,
2194                std::ptr::null(),
2195                std::ptr::null(),
2196                100.0,
2197                DECK_MACH.as_ptr(),
2198                DECK_CD_LOW.as_ptr(),
2199                DECK_MACH.len() as c_int,
2200                1.0,
2201            );
2202            let scaled_up = ballistics_calculate_zero_angle_with_drag_table_scaled(
2203                &inputs,
2204                std::ptr::null(),
2205                std::ptr::null(),
2206                100.0,
2207                DECK_MACH.as_ptr(),
2208                DECK_CD_LOW.as_ptr(),
2209                DECK_MACH.len() as c_int,
2210                1.10,
2211            );
2212            assert!(baseline.is_finite() && scaled_up.is_finite());
2213            // More drag needs a steeper (larger) zero angle to still reach 100 m.
2214            assert!(
2215                scaled_up > baseline,
2216                "cd_scale=1.10 must need a larger zero angle: base={baseline} scaled={scaled_up}"
2217            );
2218        }
2219    }
2220
2221    #[test]
2222    fn zero_angle_scaled_rejects_invalid_cd_scale() {
2223        let inputs = valid_trajectory_inputs();
2224        unsafe {
2225            for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2226                let angle = ballistics_calculate_zero_angle_with_drag_table_scaled(
2227                    &inputs,
2228                    std::ptr::null(),
2229                    std::ptr::null(),
2230                    100.0,
2231                    DECK_MACH.as_ptr(),
2232                    DECK_CD_LOW.as_ptr(),
2233                    DECK_MACH.len() as c_int,
2234                    bad,
2235                );
2236                assert!(angle.is_nan(), "cd_scale={bad} must be rejected (NaN sentinel)");
2237            }
2238        }
2239    }
2240
2241    /// Legacy exports must remain byte-identical: the same deck through the unscaled
2242    /// export must be unaffected by the new cd_scale plumbing (a regression guard
2243    /// alongside the pre-existing, untouched drag-table tests above).
2244    #[test]
2245    fn legacy_drag_table_exports_unaffected_by_cd_scale_plumbing() {
2246        let inputs = valid_trajectory_inputs();
2247        unsafe {
2248            let a = ballistics_calculate_trajectory_with_drag_table(
2249                &inputs,
2250                std::ptr::null(),
2251                std::ptr::null(),
2252                300.0,
2253                1.0,
2254                DECK_MACH.as_ptr(),
2255                DECK_CD_LOW.as_ptr(),
2256                DECK_MACH.len() as c_int,
2257            );
2258            let b = ballistics_calculate_trajectory_with_drag_table(
2259                &inputs,
2260                std::ptr::null(),
2261                std::ptr::null(),
2262                300.0,
2263                1.0,
2264                DECK_MACH.as_ptr(),
2265                DECK_CD_LOW.as_ptr(),
2266                DECK_MACH.len() as c_int,
2267            );
2268            assert!(!a.is_null() && !b.is_null());
2269            assert_eq!((*a).impact_velocity.to_bits(), (*b).impact_velocity.to_bits());
2270            ballistics_free_trajectory_result(a);
2271            ballistics_free_trajectory_result(b);
2272
2273            let za = ballistics_calculate_zero_angle_with_drag_table(
2274                &inputs,
2275                std::ptr::null(),
2276                std::ptr::null(),
2277                100.0,
2278                DECK_MACH.as_ptr(),
2279                DECK_CD_LOW.as_ptr(),
2280                DECK_MACH.len() as c_int,
2281            );
2282            let zb = ballistics_calculate_zero_angle_with_drag_table(
2283                &inputs,
2284                std::ptr::null(),
2285                std::ptr::null(),
2286                100.0,
2287                DECK_MACH.as_ptr(),
2288                DECK_CD_LOW.as_ptr(),
2289                DECK_MACH.len() as c_int,
2290            );
2291            assert!(za.is_finite() && zb.is_finite());
2292            assert_eq!(za.to_bits(), zb.to_bits());
2293        }
2294    }
2295
2296    // ---- MBA-1365: ballistics_bc_for_reference_standard --------------------------------
2297
2298    #[test]
2299    fn bc_for_reference_standard_icao_is_a_byte_identical_no_op() {
2300        let bc = 0.475;
2301        assert_eq!(
2302            ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ICAO).to_bits(),
2303            bc.to_bits()
2304        );
2305    }
2306
2307    #[test]
2308    fn bc_for_reference_standard_unrecognized_value_falls_back_to_icao() {
2309        // Mirrors convert_inputs' permissive unrecognized-bc_type convention: an unknown
2310        // reference_standard is treated as ICAO (0), not rejected.
2311        let bc = 0.475;
2312        assert_eq!(
2313            ballistics_bc_for_reference_standard(bc, 99).to_bits(),
2314            bc.to_bits()
2315        );
2316    }
2317
2318    #[test]
2319    fn bc_for_reference_standard_army_standard_metro_applies_the_documented_ratio() {
2320        let bc = 0.475;
2321        let converted =
2322            ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ARMY_STANDARD_METRO);
2323        assert_eq!(converted, bc * crate::constants::ASM_TO_ICAO_BC);
2324        // Smaller BC == more drag under this engine's ICAO-calibrated retardation math.
2325        assert!(converted < bc);
2326    }
2327
2328    // ---- MBA-1397: ballistics_reduce_qnh_pressure + FFIAtmosphericConditions.pressure ---
2329
2330    #[test]
2331    fn reduce_qnh_pressure_matches_the_library_function_and_lowers_pressure() {
2332        let reduced = ballistics_reduce_qnh_pressure(1030.0, 1500.0);
2333        assert_eq!(
2334            reduced,
2335            crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1500.0)
2336        );
2337        assert!(reduced < 1030.0);
2338    }
2339
2340    #[test]
2341    fn reduce_qnh_pressure_passes_through_non_finite_inputs() {
2342        assert!(ballistics_reduce_qnh_pressure(f64::NAN, 1500.0).is_nan());
2343        assert_eq!(ballistics_reduce_qnh_pressure(1030.0, f64::INFINITY), 1030.0);
2344    }
2345
2346    /// The FFI trajectory/Monte Carlo exports have always treated
2347    /// `FFIAtmosphericConditions.pressure` as absolute station pressure. A caller declaring a
2348    /// QNH (sea-level-corrected altimeter setting) reading MUST reduce it with
2349    /// `ballistics_reduce_qnh_pressure` before writing `pressure` -- this proves the reduced
2350    /// value actually reaches the solve (a materially different, and correct -- flatter,
2351    /// less-drop -- trajectory than feeding the raw, unreduced QNH straight through, which
2352    /// would silently over-state air density).
2353    #[test]
2354    fn ffi_trajectory_uses_the_reduced_pressure_not_the_raw_qnh() {
2355        let inputs = valid_trajectory_inputs();
2356        let altitude_m = 1500.0;
2357        let qnh_hpa = 1030.0;
2358        let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2359        assert!(reduced < qnh_hpa);
2360
2361        let atmo_reduced = FFIAtmosphericConditions {
2362            temperature: 15.0,
2363            pressure: reduced,
2364            humidity: 50.0,
2365            altitude: altitude_m,
2366        };
2367        let atmo_raw_qnh = FFIAtmosphericConditions {
2368            temperature: 15.0,
2369            pressure: qnh_hpa,
2370            humidity: 50.0,
2371            altitude: altitude_m,
2372        };
2373
2374        unsafe {
2375            let a = ballistics_calculate_trajectory(
2376                &inputs,
2377                std::ptr::null(),
2378                &atmo_reduced,
2379                400.0,
2380                1.0,
2381            );
2382            let b = ballistics_calculate_trajectory(
2383                &inputs,
2384                std::ptr::null(),
2385                &atmo_raw_qnh,
2386                400.0,
2387                1.0,
2388            );
2389            assert!(!a.is_null() && !b.is_null());
2390            let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2391                .last()
2392                .unwrap()
2393                .position_y;
2394            let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2395                .last()
2396                .unwrap()
2397                .position_y;
2398            assert!(
2399                (drop_a - drop_b).abs() > 1e-6,
2400                "reduced vs. raw-QNH pressure must produce materially different trajectories: \
2401                 {drop_a} vs {drop_b}"
2402            );
2403            ballistics_free_trajectory_result(a);
2404            ballistics_free_trajectory_result(b);
2405        }
2406    }
2407
2408    /// Same proof as above, for the Monte Carlo FFI path (`ballistics_monte_carlo`), which
2409    /// reads `FFIAtmosphericConditions.pressure` into `BallisticInputs.pressure` directly
2410    /// (`ballistics_monte_carlo_impl`) before running the shared `run_monte_carlo_*` core.
2411    #[test]
2412    fn ffi_monte_carlo_uses_the_reduced_pressure_not_the_raw_qnh() {
2413        let inputs = valid_trajectory_inputs();
2414        let altitude_m = 1500.0;
2415        let qnh_hpa = 1030.0;
2416        let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2417
2418        let atmo_reduced = FFIAtmosphericConditions {
2419            temperature: 15.0,
2420            pressure: reduced,
2421            humidity: 50.0,
2422            altitude: altitude_m,
2423        };
2424        let atmo_raw_qnh = FFIAtmosphericConditions {
2425            temperature: 15.0,
2426            pressure: qnh_hpa,
2427            humidity: 50.0,
2428            altitude: altitude_m,
2429        };
2430        let params = FFIMonteCarloParams {
2431            num_simulations: 200,
2432            velocity_std_dev: 1.0,
2433            angle_std_dev: 0.0,
2434            bc_std_dev: 0.0,
2435            wind_speed_std_dev: 0.0,
2436            target_distance: f64::NAN,
2437            base_wind_speed: 0.0,
2438            base_wind_direction: 0.0,
2439            azimuth_std_dev: 0.0,
2440        };
2441
2442        unsafe {
2443            let a = ballistics_monte_carlo(&inputs, &atmo_reduced, &params);
2444            let b = ballistics_monte_carlo(&inputs, &atmo_raw_qnh, &params);
2445            assert!(!a.is_null() && !b.is_null());
2446            assert!(
2447                ((*a).mean_range - (*b).mean_range).abs() > 0.5,
2448                "reduced vs. raw-QNH pressure must change MC mean range materially: \
2449                 {} vs {}",
2450                (*a).mean_range,
2451                (*b).mean_range
2452            );
2453            ballistics_free_monte_carlo_results(a);
2454            ballistics_free_monte_carlo_results(b);
2455        }
2456    }
2457
2458    // ---- MBA-1366: ballistics_density_altitude_* + FFIAtmosphericConditions parity --------
2459
2460    #[test]
2461    fn density_altitude_ffi_exports_match_the_library_function() {
2462        let da_m = 1000.0 * 0.3048;
2463        let expected = crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, None);
2464        assert_eq!(
2465            ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2466            expected.0
2467        );
2468        assert_eq!(
2469            ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2470            expected.1
2471        );
2472        assert_eq!(
2473            ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2474            expected.2
2475        );
2476
2477        // With no explicit temperature the resolved altitude must equal the input exactly.
2478        assert!((ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE) - da_m).abs() < 1e-6);
2479    }
2480
2481    #[test]
2482    fn density_altitude_ffi_explicit_temperature_is_honored_exactly() {
2483        let da_m = 500.0;
2484        let explicit_temp_c = 30.0;
2485        let expected =
2486            crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
2487        assert_eq!(
2488            ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2489            explicit_temp_c
2490        );
2491        assert_eq!(
2492            ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2493            expected.1
2494        );
2495        assert_eq!(
2496            ballistics_density_altitude_pressure_hpa(da_m, explicit_temp_c),
2497            expected.2
2498        );
2499        assert_eq!(
2500            ballistics_density_altitude_altitude_m(da_m, explicit_temp_c),
2501            expected.0
2502        );
2503    }
2504
2505    #[test]
2506    fn density_altitude_ffi_non_finite_input_returns_nan() {
2507        assert!(
2508            ballistics_density_altitude_temperature_c(f64::INFINITY, FFI_NO_EXPLICIT_TEMPERATURE)
2509                .is_nan()
2510        );
2511        assert!(
2512            ballistics_density_altitude_pressure_hpa(f64::NAN, FFI_NO_EXPLICIT_TEMPERATURE).is_nan()
2513        );
2514        assert!(
2515            ballistics_density_altitude_altitude_m(f64::NEG_INFINITY, FFI_NO_EXPLICIT_TEMPERATURE)
2516                .is_nan()
2517        );
2518    }
2519
2520    /// Same proof shape as the QNH FFI tests above: writing the density-altitude-derived
2521    /// station values into `FFIAtmosphericConditions` before an existing trajectory export
2522    /// reaches the solve (a materially different, lower-density trajectory at a higher DA than
2523    /// at sea level, exactly like the QNH-vs-absolute divergence proof).
2524    #[test]
2525    fn ffi_trajectory_uses_the_density_altitude_derived_station_values() {
2526        let inputs = valid_trajectory_inputs();
2527        let da_m = 3000.0 * 0.3048; // 3000 ft density altitude
2528        let altitude_m =
2529            ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2530        let temperature_c =
2531            ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2532        let pressure_hpa =
2533            ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2534
2535        let atmo_da = FFIAtmosphericConditions {
2536            temperature: temperature_c,
2537            pressure: pressure_hpa,
2538            humidity: 50.0,
2539            altitude: altitude_m,
2540        };
2541        let atmo_sea_level = FFIAtmosphericConditions {
2542            temperature: 15.0,
2543            pressure: 1013.25,
2544            humidity: 50.0,
2545            altitude: 0.0,
2546        };
2547
2548        unsafe {
2549            let a = ballistics_calculate_trajectory(
2550                &inputs,
2551                std::ptr::null(),
2552                &atmo_da,
2553                400.0,
2554                1.0,
2555            );
2556            let b = ballistics_calculate_trajectory(
2557                &inputs,
2558                std::ptr::null(),
2559                &atmo_sea_level,
2560                400.0,
2561                1.0,
2562            );
2563            assert!(!a.is_null() && !b.is_null());
2564            let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2565                .last()
2566                .unwrap()
2567                .position_y;
2568            let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2569                .last()
2570                .unwrap()
2571                .position_y;
2572            assert!(
2573                (drop_a - drop_b).abs() > 1e-6,
2574                "density-altitude-derived vs sea-level atmosphere must produce materially \
2575                 different trajectories: {drop_a} vs {drop_b}"
2576            );
2577            ballistics_free_trajectory_result(a);
2578            ballistics_free_trajectory_result(b);
2579        }
2580    }
2581}