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