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, etc.
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        _ => DragModel::G1,
181    };
182    ballistic_inputs.sight_height = inputs.sight_height;
183    ballistic_inputs.target_distance = inputs.target_distance;
184    ballistic_inputs.temperature = inputs.temperature;
185    ballistic_inputs.twist_rate = inputs.twist_rate;
186    ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
187    ballistic_inputs.shooting_angle = inputs.shooting_angle;
188    ballistic_inputs.altitude = inputs.altitude;
189
190    if !inputs.latitude.is_nan() {
191        ballistic_inputs.latitude = Some(inputs.latitude);
192    }
193
194    // Set derived values
195    ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
196    ballistic_inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
197    // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default). The C ABI does
198    // not carry a bullet length, so derive it from diameter + mass; fall back to 4.5-cal if mass<=0.
199    ballistic_inputs.bullet_length = {
200        let est = crate::stability::estimate_bullet_length_m(
201            ballistic_inputs.bullet_diameter,
202            ballistic_inputs.bullet_mass,
203        );
204        if est > 0.0 {
205            est
206        } else {
207            ballistic_inputs.bullet_diameter * 4.5
208        }
209    };
210
211    // New advanced physics flags
212    ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
213    ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
214    ballistic_inputs.sample_interval = inputs.sample_interval;
215    ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
216    ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
217    ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
218    ballistic_inputs.enable_advanced_effects =
219        inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
220    // Gate Magnus and Coriolis independently so enabling one does not enable the other.
221    ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
222    ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
223
224    ballistic_inputs
225}
226
227/// Build a validated [`crate::drag::DragTable`] from borrowed C arrays.
228///
229/// Both arrays must contain `len` elements, with `len` in `[2, MAX_FFI_DRAG_TABLE_LEN]`.
230/// The data is copied; the caller retains ownership. Returns `Err(())` for null
231/// pointers, `len` outside that range, or any deck that fails
232/// [`crate::drag::DragTable::try_new`] validation (non-ascending Mach, non-positive
233/// or non-finite Cd). No error detail crosses the ABI, matching the null/NAN
234/// error convention of this module.
235///
236/// # Safety
237///
238/// When non-null, `mach` and `cd` must each point to `len` readable `f64` values
239/// that remain valid and unmutated for the duration of the call.
240unsafe fn drag_table_from_raw(
241    mach: *const c_double,
242    cd: *const c_double,
243    len: c_int,
244) -> Result<crate::drag::DragTable, ()> {
245    if mach.is_null() || cd.is_null() || !(2..=MAX_FFI_DRAG_TABLE_LEN).contains(&len) {
246        return Err(());
247    }
248    let len = len as usize;
249    let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
250    let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
251    crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
252}
253
254/// Shared implementation for the trajectory exports. `custom_drag_table`, when
255/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
256/// density — see `BallisticInputs::custom_drag_denominator`).
257unsafe fn calculate_trajectory_impl(
258    inputs: *const FFIBallisticInputs,
259    wind: *const FFIWindConditions,
260    atmosphere: *const FFIAtmosphericConditions,
261    max_range: c_double,
262    step_size: c_double,
263    custom_drag_table: Option<crate::drag::DragTable>,
264) -> *mut FFITrajectoryResult {
265    if inputs.is_null() {
266        return ptr::null_mut();
267    }
268    if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
269        return ptr::null_mut();
270    }
271
272    let inputs = unsafe { &*inputs };
273    let mut ballistic_inputs = convert_inputs(inputs);
274    ballistic_inputs.custom_drag_table = custom_drag_table;
275    let twist_rate_in = ballistic_inputs.twist_rate;
276
277    let wind_conditions = if wind.is_null() {
278        WindConditions::default()
279    } else {
280        let wind = unsafe { &*wind };
281        WindConditions {
282            speed: wind.speed,
283            direction: wind.direction,
284            vertical_speed: wind.vertical_speed,
285        }
286    };
287
288    let atmospheric_conditions = if atmosphere.is_null() {
289        AtmosphericConditions::default()
290    } else {
291        let atmo = unsafe { &*atmosphere };
292        AtmosphericConditions {
293            temperature: atmo.temperature,
294            pressure: atmo.pressure,
295            humidity: atmo.humidity,
296            altitude: atmo.altitude,
297        }
298    };
299
300    // Create solver and calculate trajectory
301    let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
302        atmospheric_conditions.temperature,
303        atmospheric_conditions.pressure,
304        atmospheric_conditions.altitude,
305    );
306    let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
307        atmospheric_conditions.altitude,
308        Some(sample_temp_c),
309        Some(sample_pressure_hpa),
310        atmospheric_conditions.humidity,
311    );
312
313    let mut solver =
314        TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
315
316    // Set max range and time step
317    solver.set_max_range(max_range);
318    solver.set_time_step(step_size / 1000.0); // milliseconds -> seconds
319
320    match solver.solve() {
321        Ok(result) => {
322            // Convert trajectory points to FFI format
323            let point_count = result.points.len();
324            let points = if point_count > 0 {
325                let mut ffi_points = Vec::with_capacity(point_count);
326                for point in result.points.iter() {
327                    ffi_points.push(FFITrajectoryPoint {
328                        time: point.time,
329                        position_x: point.position[0],
330                        position_y: point.position[1],
331                        position_z: point.position[2],
332                        velocity_magnitude: point.velocity_magnitude,
333                        kinetic_energy: point.kinetic_energy,
334                    });
335                }
336                let points_ptr = ffi_points.as_mut_ptr();
337                std::mem::forget(ffi_points); // Prevent deallocation
338                points_ptr
339            } else {
340                ptr::null_mut()
341            };
342
343            // Convert sampled points if available
344            let (sampled_points, sampled_point_count) =
345                if let Some(ref samples) = result.sampled_points {
346                    let mut ffi_samples = Vec::with_capacity(samples.len());
347                    for sample in samples {
348                        ffi_samples.push(FFITrajectorySample {
349                            distance: sample.distance_m,
350                            time: sample.time_s,
351                            velocity_mps: sample.velocity_mps,
352                            energy_joules: sample.energy_j,
353                            drop_meters: sample.drop_m,
354                            windage_meters: sample.wind_drift_m,
355                            mach: if sample_speed_of_sound > 0.0 {
356                                sample.velocity_mps / sample_speed_of_sound
357                            } else {
358                                0.0
359                            },
360                            spin_rate_rps: if twist_rate_in > 0.0 {
361                                sample.velocity_mps / (twist_rate_in * 0.0254)
362                            } else {
363                                0.0
364                            },
365                        });
366                    }
367                    let count = ffi_samples.len() as c_int;
368                    let samples_ptr = ffi_samples.as_mut_ptr();
369                    std::mem::forget(ffi_samples);
370                    (samples_ptr, count)
371                } else {
372                    (ptr::null_mut(), 0)
373                };
374
375            // Extract angular state values if available
376            let (final_pitch, final_yaw, max_yaw, max_prec) =
377                if let Some(ref angular) = result.angular_state {
378                    (
379                        angular.pitch_angle,
380                        angular.yaw_angle,
381                        result.max_yaw_angle.unwrap_or(f64::NAN),
382                        result.max_precession_angle.unwrap_or(f64::NAN),
383                    )
384                } else {
385                    (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
386                };
387
388            // Create result on heap
389            let ffi_result = Box::new(FFITrajectoryResult {
390                max_range: result.max_range,
391                max_height: result.max_height,
392                time_of_flight: result.time_of_flight,
393                impact_velocity: result.impact_velocity,
394                impact_energy: result.impact_energy,
395                points,
396                point_count: point_count as c_int,
397                sampled_points,
398                sampled_point_count,
399                min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
400                transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
401                final_pitch_angle: final_pitch,
402                final_yaw_angle: final_yaw,
403                max_yaw_angle: max_yaw,
404                max_precession_angle: max_prec,
405            });
406
407            Box::into_raw(ffi_result)
408        }
409        Err(_) => ptr::null_mut(),
410    }
411}
412
413/// Calculate a trajectory through the C ABI.
414///
415/// `step_size` is expressed in milliseconds and must be finite and at least
416/// [`MIN_FFI_STEP_SIZE_MS`]. This boundary contract is validated for every solver mode, although
417/// adaptive RK45 chooses its integration steps internally. Invalid values return null without
418/// starting a solve. A solve that would exceed [`crate::MAX_TRAJECTORY_POINTS`] also returns null;
419/// an enabled sampling grid above [`crate::MAX_TRAJECTORY_SAMPLES`] does likewise. Callers can
420/// increase `step_size`, reduce `max_range`, or select adaptive RK45.
421///
422/// # Safety
423///
424/// `inputs` may be null, in which case this function returns null. When non-null,
425/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
426/// readable and is not mutated for the duration of this call. `wind` and
427/// `atmosphere` may also be null; each non-null pointer has the same requirements
428/// for its corresponding type.
429/// The returned pointer, when non-null, must be released exactly once with
430/// [`ballistics_free_trajectory_result`].
431#[no_mangle]
432pub unsafe extern "C" fn ballistics_calculate_trajectory(
433    inputs: *const FFIBallisticInputs,
434    wind: *const FFIWindConditions,
435    atmosphere: *const FFIAtmosphericConditions,
436    max_range: c_double,
437    step_size: c_double,
438) -> *mut FFITrajectoryResult {
439    unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None) }
440}
441
442/// [`ballistics_calculate_trajectory`] with a caller-supplied custom drag deck
443/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
444/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
445/// denominator becomes the projectile's sectional density (mass and diameter in
446/// `inputs` must therefore be positive). Mach values must be strictly ascending
447/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
448/// outside the deck's Mach domain the nearest endpoint Cd is held.
449///
450/// Returns null for an invalid deck (null arrays, `drag_table_len` outside
451/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
452/// failure mode of the base function.
453///
454/// # Safety
455///
456/// Same contract as [`ballistics_calculate_trajectory`] for `inputs`, `wind`, and
457/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
458/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
459/// of this call (the deck is copied; the caller retains ownership — no new free
460/// function is required). The returned pointer, when non-null, must be released
461/// exactly once with [`ballistics_free_trajectory_result`].
462#[no_mangle]
463pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
464    inputs: *const FFIBallisticInputs,
465    wind: *const FFIWindConditions,
466    atmosphere: *const FFIAtmosphericConditions,
467    max_range: c_double,
468    step_size: c_double,
469    drag_mach: *const c_double,
470    drag_cd: *const c_double,
471    drag_table_len: c_int,
472) -> *mut FFITrajectoryResult {
473    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
474        Ok(t) => t,
475        Err(()) => return ptr::null_mut(),
476    };
477    unsafe {
478        calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, Some(table))
479    }
480}
481
482/// Release a trajectory result allocated by [`ballistics_calculate_trajectory`].
483///
484/// # Safety
485///
486/// `result` must be null or a pointer returned by
487/// [`ballistics_calculate_trajectory`] that has not already been freed. Its
488/// embedded pointers and counts must be unchanged from the returned values.
489/// After this call, the result and its point arrays must not be accessed again.
490#[no_mangle]
491pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
492    if !result.is_null() {
493        unsafe {
494            let result = Box::from_raw(result);
495            if !result.points.is_null() && result.point_count > 0 {
496                let points = Vec::from_raw_parts(
497                    result.points,
498                    result.point_count as usize,
499                    result.point_count as usize,
500                );
501                drop(points);
502            }
503            if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
504                let samples = Vec::from_raw_parts(
505                    result.sampled_points,
506                    result.sampled_point_count as usize,
507                    result.sampled_point_count as usize,
508                );
509                drop(samples);
510            }
511            drop(result);
512        }
513    }
514}
515
516/// Shared implementation for the zero-angle exports. `custom_drag_table`, when
517/// present, replaces the G-model + BC drag (the deck's Cd is divided by sectional
518/// density — see `BallisticInputs::custom_drag_denominator`), matching the deck
519/// semantics of [`calculate_trajectory_impl`] so a zero solved with a deck and a
520/// trajectory flown with the same deck agree.
521unsafe fn calculate_zero_angle_impl(
522    inputs: *const FFIBallisticInputs,
523    wind: *const FFIWindConditions,
524    atmosphere: *const FFIAtmosphericConditions,
525    zero_distance: c_double,
526    custom_drag_table: Option<crate::drag::DragTable>,
527) -> c_double {
528    if inputs.is_null() {
529        return f64::NAN;
530    }
531
532    let inputs = unsafe { &*inputs };
533    let mut ballistic_inputs = convert_inputs(inputs);
534    ballistic_inputs.custom_drag_table = custom_drag_table;
535
536    let wind_conditions = if wind.is_null() {
537        WindConditions::default()
538    } else {
539        let wind = unsafe { &*wind };
540        WindConditions {
541            speed: wind.speed,
542            direction: wind.direction,
543            vertical_speed: wind.vertical_speed,
544        }
545    };
546
547    let atmospheric_conditions = if atmosphere.is_null() {
548        AtmosphericConditions::default()
549    } else {
550        let atmo = unsafe { &*atmosphere };
551        AtmosphericConditions {
552            temperature: atmo.temperature,
553            pressure: atmo.pressure,
554            humidity: atmo.humidity,
555            altitude: atmo.altitude,
556        }
557    };
558
559    // For zero angle, we want the bullet to hit at sight height at the zero distance
560    // This means the bullet crosses the line of sight at the zero distance
561    let target_height = ballistic_inputs.sight_height;
562
563    calculate_zero_angle_with_conditions(
564        ballistic_inputs,
565        zero_distance,
566        target_height,
567        wind_conditions,
568        atmospheric_conditions,
569    )
570    .unwrap_or(f64::NAN)
571}
572
573/// Calculate the zero angle for a target distance through the C ABI.
574///
575/// # Safety
576///
577/// `inputs` may be null, in which case this function returns NaN. When non-null,
578/// it must point to a valid, properly aligned [`FFIBallisticInputs`] that remains
579/// readable and is not mutated for the duration of this call. `wind` and
580/// `atmosphere` may also be null; each non-null pointer has the same requirements
581/// for its corresponding type.
582#[no_mangle]
583pub unsafe extern "C" fn ballistics_calculate_zero_angle(
584    inputs: *const FFIBallisticInputs,
585    wind: *const FFIWindConditions,
586    atmosphere: *const FFIAtmosphericConditions,
587    zero_distance: c_double,
588) -> c_double {
589    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None) }
590}
591
592/// [`ballistics_calculate_zero_angle`] with a caller-supplied custom drag deck
593/// (Cd vs Mach, e.g. Hornady CDM / Doppler-radar data). The deck REPLACES the
594/// G-model + BC for drag: `bc_type`/`bc_value` are ignored, and the retardation
595/// denominator becomes the projectile's sectional density (mass and diameter in
596/// `inputs` must therefore be positive). Mach values must be strictly ascending
597/// with `drag_table_len` in `[2, MAX_FFI_DRAG_TABLE_LEN]` and finite positive Cd;
598/// outside the deck's Mach domain the nearest endpoint Cd is held. Pair this with
599/// [`ballistics_calculate_trajectory_with_drag_table`] using the same deck to fly
600/// the solved angle — the two exports share identical deck semantics.
601///
602/// Returns NaN for an invalid deck (null arrays, `drag_table_len` outside
603/// `[2, MAX_FFI_DRAG_TABLE_LEN]`, or failed validation), in addition to every
604/// failure mode of the base function.
605///
606/// # Safety
607///
608/// Same contract as [`ballistics_calculate_zero_angle`] for `inputs`, `wind`, and
609/// `atmosphere`. Additionally, when non-null, `drag_mach` and `drag_cd` must each
610/// point to `drag_table_len` readable `f64` values, borrowed only for the duration
611/// of this call (the deck is copied; the caller retains ownership — no new free
612/// function is required).
613#[no_mangle]
614pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
615    inputs: *const FFIBallisticInputs,
616    wind: *const FFIWindConditions,
617    atmosphere: *const FFIAtmosphericConditions,
618    zero_distance: c_double,
619    drag_mach: *const c_double,
620    drag_cd: *const c_double,
621    drag_table_len: c_int,
622) -> c_double {
623    let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
624        Ok(t) => t,
625        Err(()) => return f64::NAN,
626    };
627    unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table)) }
628}
629
630// Simple trajectory calculation for quick results
631#[no_mangle]
632#[allow(clippy::field_reassign_with_default)] // Preserve the staged zero-angle workflow below.
633pub extern "C" fn ballistics_quick_trajectory(
634    muzzle_velocity: c_double,
635    bc: c_double,
636    sight_height: c_double,
637    zero_distance: c_double,
638    target_distance: c_double,
639) -> c_double {
640    // This provides a simple drop calculation at target distance
641    // Using simplified ballistic calculations
642
643    let mut inputs = BallisticInputs::default();
644    inputs.muzzle_velocity = muzzle_velocity;
645    inputs.bc_value = bc;
646    inputs.sight_height = sight_height;
647    inputs.target_distance = target_distance;
648
649    let wind = WindConditions::default();
650    let atmo = AtmosphericConditions::default();
651
652    // First calculate zero angle
653    let zero_angle = match calculate_zero_angle_with_conditions(
654        inputs.clone(),
655        zero_distance,
656        sight_height,
657        wind.clone(),
658        atmo.clone(),
659    ) {
660        Ok(angle) => angle,
661        Err(_) => return f64::NAN,
662    };
663
664    // Now calculate trajectory with that zero angle
665    inputs.muzzle_angle = zero_angle;
666
667    let mut solver = TrajectorySolver::new(inputs, wind, atmo);
668    solver.set_max_range(target_distance * 1.1);
669
670    match solver.solve() {
671        Ok(result) => {
672            // Find the drop at target distance
673            for point in result.points {
674                if point.position[0] >= target_distance {
675                    return sight_height - point.position[1];
676                }
677            }
678            f64::NAN
679        }
680        Err(_) => f64::NAN,
681    }
682}
683
684/// Run a Monte Carlo simulation through the C ABI.
685///
686/// # Safety
687///
688/// `inputs` and `params` may be null, in which case this function returns null.
689/// Each non-null pointer must point to a valid, properly aligned value of its
690/// corresponding FFI type that remains readable and is not mutated for the
691/// duration of this call. `atmosphere` may be null; a non-null pointer has the
692/// same requirements for [`FFIAtmosphericConditions`]. The returned pointer,
693/// when non-null, must be released exactly once with
694/// [`ballistics_free_monte_carlo_results`].
695#[no_mangle]
696pub unsafe extern "C" fn ballistics_monte_carlo(
697    inputs: *const FFIBallisticInputs,
698    atmosphere: *const FFIAtmosphericConditions,
699    params: *const FFIMonteCarloParams,
700) -> *mut FFIMonteCarloResults {
701    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
702}
703
704/// Run a Monte Carlo simulation with independent wind-direction uncertainty through the C ABI.
705///
706/// `wind_direction_std_dev` is in radians. This additive entry point keeps
707/// [`FFIMonteCarloParams`] and [`ballistics_monte_carlo`] binary-compatible; the older function
708/// delegates with zero wind-direction uncertainty.
709///
710/// # Safety
711///
712/// The pointer and ownership requirements are identical to [`ballistics_monte_carlo`].
713#[no_mangle]
714pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
715    inputs: *const FFIBallisticInputs,
716    atmosphere: *const FFIAtmosphericConditions,
717    params: *const FFIMonteCarloParams,
718    wind_direction_std_dev: c_double,
719) -> *mut FFIMonteCarloResults {
720    unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
721}
722
723unsafe fn ballistics_monte_carlo_impl(
724    inputs: *const FFIBallisticInputs,
725    atmosphere: *const FFIAtmosphericConditions,
726    params: *const FFIMonteCarloParams,
727    wind_direction_std_dev: f64,
728) -> *mut FFIMonteCarloResults {
729    if inputs.is_null() || params.is_null() {
730        return ptr::null_mut();
731    }
732
733    let inputs = unsafe { &*inputs };
734    let params = unsafe { &*params };
735
736    // Reject an out-of-range simulation count. num_simulations is a c_int (i32) cast straight to
737    // usize: a negative value would wrap to a near-max usize, and even a large positive value (up
738    // to i32::MAX ~ 2.1e9) would drive billions of iterations with the result arrays scaling to
739    // match — an unbounded-loop / OOM DoS from a single FFI call. Bound it to a sane maximum.
740    // (n == 0 also yields NaN stats and a zero-size allocation.)
741    const MAX_SIMULATIONS: c_int = 1_000_000;
742    if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
743        return ptr::null_mut();
744    }
745
746    // Convert FFI inputs to internal types
747    let mut ballistic_inputs = convert_inputs(inputs);
748    ballistic_inputs.muzzle_height = 1.5;
749    ballistic_inputs.ground_threshold = 0.0;
750    if !atmosphere.is_null() {
751        let atmo = unsafe { &*atmosphere };
752        ballistic_inputs.temperature = atmo.temperature;
753        ballistic_inputs.pressure = atmo.pressure;
754        ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
755        ballistic_inputs.altitude = atmo.altitude;
756    }
757
758    // Create Monte Carlo parameters
759    let mc_params = MonteCarloParams {
760        num_simulations: params.num_simulations as usize,
761        velocity_std_dev: params.velocity_std_dev,
762        angle_std_dev: params.angle_std_dev,
763        bc_std_dev: params.bc_std_dev,
764        wind_speed_std_dev: params.wind_speed_std_dev,
765        target_distance: if params.target_distance.is_nan() {
766            None
767        } else {
768            Some(params.target_distance)
769        },
770        base_wind_speed: params.base_wind_speed,
771        base_wind_direction: params.base_wind_direction,
772        azimuth_std_dev: params.azimuth_std_dev,
773    };
774
775    // Run Monte Carlo simulation
776    match run_monte_carlo_with_direction_std_dev(
777        ballistic_inputs,
778        mc_params,
779        wind_direction_std_dev,
780    ) {
781        Ok(results) => {
782            let num_results = results.ranges.len() as c_int;
783
784            // Calculate statistics
785            let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
786            let variance_range: f64 = results
787                .ranges
788                .iter()
789                .map(|r| (r - mean_range).powi(2))
790                .sum::<f64>()
791                / num_results as f64;
792            let std_dev_range = variance_range.sqrt();
793
794            let mean_velocity: f64 =
795                results.impact_velocities.iter().sum::<f64>() / num_results as f64;
796            let variance_velocity: f64 = results
797                .impact_velocities
798                .iter()
799                .map(|v| (v - mean_velocity).powi(2))
800                .sum::<f64>()
801                / num_results as f64;
802            let std_dev_velocity = variance_velocity.sqrt();
803
804            // Calculate hit probability if target distance was specified. MBA-971: use the shared
805            // position-based criterion (fraction within DEFAULT_HIT_RADIUS_M of the point of aim
806            // at the target plane). The old inline version had a redundant `distance < target`
807            // clause comparing a ~meter deviation to the ~hundreds-of-meters target distance
808            // (effectively always true), and the CLI used a different range-based notion entirely.
809            let hit_probability = if params.target_distance.is_nan() {
810                0.0
811            } else {
812                results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
813            };
814
815            // Allocate memory for arrays
816            let ranges_ptr = unsafe {
817                let ptr = std::alloc::alloc(
818                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
819                ) as *mut c_double;
820                for (i, &range) in results.ranges.iter().enumerate() {
821                    *ptr.add(i) = range;
822                }
823                ptr
824            };
825
826            let velocities_ptr = unsafe {
827                let ptr = std::alloc::alloc(
828                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
829                ) as *mut c_double;
830                for (i, &vel) in results.impact_velocities.iter().enumerate() {
831                    *ptr.add(i) = vel;
832                }
833                ptr
834            };
835
836            let pos_x_ptr = unsafe {
837                let ptr = std::alloc::alloc(
838                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
839                ) as *mut c_double;
840                for (i, pos) in results.impact_positions.iter().enumerate() {
841                    *ptr.add(i) = pos.x;
842                }
843                ptr
844            };
845
846            let pos_y_ptr = unsafe {
847                let ptr = std::alloc::alloc(
848                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
849                ) as *mut c_double;
850                for (i, pos) in results.impact_positions.iter().enumerate() {
851                    *ptr.add(i) = pos.y;
852                }
853                ptr
854            };
855
856            let pos_z_ptr = unsafe {
857                let ptr = std::alloc::alloc(
858                    std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
859                ) as *mut c_double;
860                for (i, pos) in results.impact_positions.iter().enumerate() {
861                    *ptr.add(i) = pos.z;
862                }
863                ptr
864            };
865
866            // Create result structure
867            let result = Box::new(FFIMonteCarloResults {
868                ranges: ranges_ptr,
869                impact_velocities: velocities_ptr,
870                impact_positions_x: pos_x_ptr,
871                impact_positions_y: pos_y_ptr,
872                impact_positions_z: pos_z_ptr,
873                num_results,
874                mean_range,
875                std_dev_range,
876                mean_impact_velocity: mean_velocity,
877                std_dev_impact_velocity: std_dev_velocity,
878                hit_probability,
879            });
880
881            Box::into_raw(result)
882        }
883        Err(_) => ptr::null_mut(),
884    }
885}
886
887/// Release Monte Carlo results allocated by either Monte Carlo C entry point.
888///
889/// # Safety
890///
891/// `results` must be null or a pointer returned by [`ballistics_monte_carlo`] or
892/// [`ballistics_monte_carlo_with_direction_std_dev`] that has not already been freed. Its
893/// embedded pointers and result count must be unchanged from the returned values. After this
894/// call, the result and all of its arrays must not be accessed again.
895#[no_mangle]
896pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
897    if results.is_null() {
898        return;
899    }
900
901    unsafe {
902        let results = Box::from_raw(results);
903        let num = results.num_results as usize;
904
905        // Free arrays
906        if !results.ranges.is_null() {
907            std::alloc::dealloc(
908                results.ranges as *mut u8,
909                std::alloc::Layout::array::<c_double>(num).unwrap(),
910            );
911        }
912
913        if !results.impact_velocities.is_null() {
914            std::alloc::dealloc(
915                results.impact_velocities as *mut u8,
916                std::alloc::Layout::array::<c_double>(num).unwrap(),
917            );
918        }
919
920        if !results.impact_positions_x.is_null() {
921            std::alloc::dealloc(
922                results.impact_positions_x as *mut u8,
923                std::alloc::Layout::array::<c_double>(num).unwrap(),
924            );
925        }
926
927        if !results.impact_positions_y.is_null() {
928            std::alloc::dealloc(
929                results.impact_positions_y as *mut u8,
930                std::alloc::Layout::array::<c_double>(num).unwrap(),
931            );
932        }
933
934        if !results.impact_positions_z.is_null() {
935            std::alloc::dealloc(
936                results.impact_positions_z as *mut u8,
937                std::alloc::Layout::array::<c_double>(num).unwrap(),
938            );
939        }
940
941        // Box automatically deallocates the result structure
942    }
943}
944
945// Get library version
946#[no_mangle]
947pub extern "C" fn ballistics_get_version() -> *const c_char {
948    // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
949    // Previously this leaked a freshly-allocated CString on every call and reported a
950    // stale hardcoded "0.3.0"; use the real crate version with no allocation.
951    concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957
958    fn valid_trajectory_inputs() -> FFIBallisticInputs {
959        FFIBallisticInputs {
960            muzzle_velocity: 800.0,
961            muzzle_angle: 0.0,
962            bc_value: 0.5,
963            bullet_mass: 0.01,
964            bullet_diameter: 0.00762,
965            bc_type: 0,
966            sight_height: 0.05,
967            target_distance: 1.0,
968            temperature: 15.0,
969            twist_rate: 12.0,
970            is_twist_right: 1,
971            shooting_angle: 0.0,
972            altitude: 0.0,
973            latitude: f64::NAN,
974            azimuth_angle: 0.0,
975            use_rk4: 1,
976            use_adaptive_rk45: 0,
977            enable_wind_shear: 0,
978            enable_trajectory_sampling: 0,
979            sample_interval: 10.0,
980            enable_pitch_damping: 0,
981            enable_precession_nutation: 0,
982            enable_spin_drift: 0,
983            enable_magnus: 0,
984            enable_coriolis: 0,
985            shot_azimuth: 0.0,
986            cant_angle: 0.0,
987        }
988    }
989
990    #[allow(dead_code)]
991    #[repr(C)]
992    struct LegacyFFIMonteCarloParams {
993        num_simulations: c_int,
994        velocity_std_dev: c_double,
995        angle_std_dev: c_double,
996        bc_std_dev: c_double,
997        wind_speed_std_dev: c_double,
998        target_distance: c_double,
999        base_wind_speed: c_double,
1000        base_wind_direction: c_double,
1001        azimuth_std_dev: c_double,
1002    }
1003
1004    #[test]
1005    fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1006        assert_eq!(
1007            std::mem::size_of::<FFIMonteCarloParams>(),
1008            std::mem::size_of::<LegacyFFIMonteCarloParams>()
1009        );
1010        assert_eq!(
1011            std::mem::align_of::<FFIMonteCarloParams>(),
1012            std::mem::align_of::<LegacyFFIMonteCarloParams>()
1013        );
1014    }
1015
1016    #[test]
1017    fn null_pointer_contracts_return_sentinels_and_free_safely() {
1018        unsafe {
1019            assert!(ballistics_calculate_trajectory(
1020                std::ptr::null(),
1021                std::ptr::null(),
1022                std::ptr::null(),
1023                1_000.0,
1024                1.0,
1025            )
1026            .is_null());
1027            assert!(ballistics_calculate_zero_angle(
1028                std::ptr::null(),
1029                std::ptr::null(),
1030                std::ptr::null(),
1031                100.0,
1032            )
1033            .is_nan());
1034            assert!(ballistics_calculate_trajectory_with_drag_table(
1035                std::ptr::null(),
1036                std::ptr::null(),
1037                std::ptr::null(),
1038                1_000.0,
1039                1.0,
1040                DECK_MACH.as_ptr(),
1041                DECK_CD_LOW.as_ptr(),
1042                DECK_MACH.len() as c_int,
1043            )
1044            .is_null());
1045            assert!(ballistics_calculate_zero_angle_with_drag_table(
1046                std::ptr::null(),
1047                std::ptr::null(),
1048                std::ptr::null(),
1049                100.0,
1050                DECK_MACH.as_ptr(),
1051                DECK_CD_LOW.as_ptr(),
1052                DECK_MACH.len() as c_int,
1053            )
1054            .is_nan());
1055            assert!(
1056                ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1057                    .is_null()
1058            );
1059            assert!(ballistics_monte_carlo_with_direction_std_dev(
1060                std::ptr::null(),
1061                std::ptr::null(),
1062                std::ptr::null(),
1063                0.1,
1064            )
1065            .is_null());
1066
1067            ballistics_free_trajectory_result(std::ptr::null_mut());
1068            ballistics_free_monte_carlo_results(std::ptr::null_mut());
1069        }
1070    }
1071
1072    #[test]
1073    fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1074        for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1075            for step_size in [
1076                f64::NAN,
1077                f64::INFINITY,
1078                f64::NEG_INFINITY,
1079                -1.0,
1080                -0.0,
1081                0.0,
1082                0.001,
1083                MIN_FFI_STEP_SIZE_MS - 0.001,
1084            ] {
1085                let mut inputs = valid_trajectory_inputs();
1086                inputs.use_rk4 = use_rk4;
1087                inputs.use_adaptive_rk45 = use_adaptive_rk45;
1088                let result = unsafe {
1089                    ballistics_calculate_trajectory(
1090                        &inputs,
1091                        std::ptr::null(),
1092                        std::ptr::null(),
1093                        0.01,
1094                        step_size,
1095                    )
1096                };
1097                assert!(
1098                    result.is_null(),
1099                    "{mode} step_size={step_size:?} bypassed the FFI floor"
1100                );
1101            }
1102
1103            let mut inputs = valid_trajectory_inputs();
1104            inputs.use_rk4 = use_rk4;
1105            inputs.use_adaptive_rk45 = use_adaptive_rk45;
1106            let result = unsafe {
1107                ballistics_calculate_trajectory(
1108                    &inputs,
1109                    std::ptr::null(),
1110                    std::ptr::null(),
1111                    0.01,
1112                    MIN_FFI_STEP_SIZE_MS,
1113                )
1114            };
1115            assert!(
1116                !result.is_null(),
1117                "the documented minimum step must remain usable in {mode}"
1118            );
1119            unsafe {
1120                assert!((*result).point_count >= 0);
1121                assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1122                ballistics_free_trajectory_result(result);
1123            }
1124        }
1125    }
1126
1127    /// A tiny valid deck: strictly ascending Mach, positive Cd.
1128    const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1129    /// Deliberately LOW drag so the deck measurably increases impact velocity vs G1.
1130    const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1131
1132    #[test]
1133    fn trajectory_with_drag_table_applies_the_deck() {
1134        let inputs = valid_trajectory_inputs();
1135        unsafe {
1136            let plain = ballistics_calculate_trajectory(
1137                &inputs,
1138                std::ptr::null(),
1139                std::ptr::null(),
1140                300.0,
1141                1.0,
1142            );
1143            let decked = ballistics_calculate_trajectory_with_drag_table(
1144                &inputs,
1145                std::ptr::null(),
1146                std::ptr::null(),
1147                300.0,
1148                1.0,
1149                DECK_MACH.as_ptr(),
1150                DECK_CD_LOW.as_ptr(),
1151                DECK_MACH.len() as c_int,
1152            );
1153            assert!(!plain.is_null() && !decked.is_null());
1154            // The low-drag deck must retain materially more velocity than the G-model.
1155            assert!(
1156                (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1157                "deck did not change the solve: plain={} decked={}",
1158                (*plain).impact_velocity,
1159                (*decked).impact_velocity
1160            );
1161            ballistics_free_trajectory_result(plain);
1162            ballistics_free_trajectory_result(decked);
1163        }
1164    }
1165
1166    #[test]
1167    fn trajectory_with_drag_table_rejects_invalid_decks() {
1168        let inputs = valid_trajectory_inputs();
1169        let descending = [3.0, 2.0, 1.0, 0.5];
1170        let negative_cd = [0.05, -0.08, 0.06, 0.05];
1171        unsafe {
1172            // null arrays
1173            assert!(ballistics_calculate_trajectory_with_drag_table(
1174                &inputs,
1175                std::ptr::null(),
1176                std::ptr::null(),
1177                300.0,
1178                1.0,
1179                std::ptr::null(),
1180                DECK_CD_LOW.as_ptr(),
1181                4,
1182            )
1183            .is_null());
1184            assert!(ballistics_calculate_trajectory_with_drag_table(
1185                &inputs,
1186                std::ptr::null(),
1187                std::ptr::null(),
1188                300.0,
1189                1.0,
1190                DECK_MACH.as_ptr(),
1191                std::ptr::null(),
1192                4,
1193            )
1194            .is_null());
1195            // too few points
1196            assert!(ballistics_calculate_trajectory_with_drag_table(
1197                &inputs,
1198                std::ptr::null(),
1199                std::ptr::null(),
1200                300.0,
1201                1.0,
1202                DECK_MACH.as_ptr(),
1203                DECK_CD_LOW.as_ptr(),
1204                1,
1205            )
1206            .is_null());
1207            // non-ascending Mach
1208            assert!(ballistics_calculate_trajectory_with_drag_table(
1209                &inputs,
1210                std::ptr::null(),
1211                std::ptr::null(),
1212                300.0,
1213                1.0,
1214                descending.as_ptr(),
1215                DECK_CD_LOW.as_ptr(),
1216                4,
1217            )
1218            .is_null());
1219            // non-positive Cd
1220            assert!(ballistics_calculate_trajectory_with_drag_table(
1221                &inputs,
1222                std::ptr::null(),
1223                std::ptr::null(),
1224                300.0,
1225                1.0,
1226                DECK_MACH.as_ptr(),
1227                negative_cd.as_ptr(),
1228                4,
1229            )
1230            .is_null());
1231            // null inputs still rejected
1232            assert!(ballistics_calculate_trajectory_with_drag_table(
1233                std::ptr::null(),
1234                std::ptr::null(),
1235                std::ptr::null(),
1236                300.0,
1237                1.0,
1238                DECK_MACH.as_ptr(),
1239                DECK_CD_LOW.as_ptr(),
1240                4,
1241            )
1242            .is_null());
1243        }
1244    }
1245
1246    #[test]
1247    fn zero_angle_with_drag_table_applies_the_deck() {
1248        // A realistic zeroing setup: 100 m zero.
1249        let inputs = valid_trajectory_inputs();
1250        unsafe {
1251            let plain =
1252                ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1253            let decked = ballistics_calculate_zero_angle_with_drag_table(
1254                &inputs,
1255                std::ptr::null(),
1256                std::ptr::null(),
1257                100.0,
1258                DECK_MACH.as_ptr(),
1259                DECK_CD_LOW.as_ptr(),
1260                DECK_MACH.len() as c_int,
1261            );
1262            assert!(plain.is_finite() && decked.is_finite());
1263            // A much lower-drag deck needs a flatter (smaller) zero angle; at minimum it
1264            // must differ measurably from the G-model zero.
1265            assert!(
1266                (plain - decked).abs() > 1e-6,
1267                "deck did not change the zero: plain={plain} decked={decked}"
1268            );
1269        }
1270    }
1271
1272    #[test]
1273    fn zero_angle_with_drag_table_rejects_invalid_decks() {
1274        let inputs = valid_trajectory_inputs();
1275        let descending = [3.0, 2.0, 1.0, 0.5];
1276        unsafe {
1277            assert!(ballistics_calculate_zero_angle_with_drag_table(
1278                &inputs,
1279                std::ptr::null(),
1280                std::ptr::null(),
1281                100.0,
1282                std::ptr::null(),
1283                DECK_CD_LOW.as_ptr(),
1284                4,
1285            )
1286            .is_nan());
1287            assert!(ballistics_calculate_zero_angle_with_drag_table(
1288                &inputs,
1289                std::ptr::null(),
1290                std::ptr::null(),
1291                100.0,
1292                DECK_MACH.as_ptr(),
1293                DECK_CD_LOW.as_ptr(),
1294                0,
1295            )
1296            .is_nan());
1297            assert!(ballistics_calculate_zero_angle_with_drag_table(
1298                &inputs,
1299                std::ptr::null(),
1300                std::ptr::null(),
1301                100.0,
1302                descending.as_ptr(),
1303                DECK_CD_LOW.as_ptr(),
1304                4,
1305            )
1306            .is_nan());
1307            // null inputs still rejected
1308            assert!(ballistics_calculate_zero_angle_with_drag_table(
1309                std::ptr::null(),
1310                std::ptr::null(),
1311                std::ptr::null(),
1312                100.0,
1313                DECK_MACH.as_ptr(),
1314                DECK_CD_LOW.as_ptr(),
1315                4,
1316            )
1317            .is_nan());
1318        }
1319    }
1320
1321    #[test]
1322    fn zero_then_fly_with_same_deck_is_consistent() {
1323        // The pair-use case the two exports exist for: zero with the deck, fly with the
1324        // deck at the solved angle; the trajectory must cross near sight height at the
1325        // zero distance (i.e. the two functions share identical deck semantics).
1326        let mut inputs = valid_trajectory_inputs();
1327        unsafe {
1328            let angle = ballistics_calculate_zero_angle_with_drag_table(
1329                &inputs,
1330                std::ptr::null(),
1331                std::ptr::null(),
1332                100.0,
1333                DECK_MACH.as_ptr(),
1334                DECK_CD_LOW.as_ptr(),
1335                DECK_MACH.len() as c_int,
1336            );
1337            assert!(angle.is_finite());
1338            inputs.muzzle_angle = angle;
1339            let result = ballistics_calculate_trajectory_with_drag_table(
1340                &inputs,
1341                std::ptr::null(),
1342                std::ptr::null(),
1343                150.0,
1344                1.0,
1345                DECK_MACH.as_ptr(),
1346                DECK_CD_LOW.as_ptr(),
1347                DECK_MACH.len() as c_int,
1348            );
1349            assert!(!result.is_null());
1350            // Interpolate y at exactly the zero distance (100 m) rather than snapping to the
1351            // nearest raw trajectory sample, so the residual reflects only zero-solver
1352            // convergence, not the sampling grid's x-offset from 100 m.
1353            let zero_distance = 100.0;
1354            let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
1355            let bracket = pts
1356                .windows(2)
1357                .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
1358                .expect("trajectory brackets the zero distance");
1359            let (lo, hi) = (&bracket[0], &bracket[1]);
1360            let y_at_zero = if hi.position_x > lo.position_x {
1361                let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
1362                lo.position_y + t * (hi.position_y - lo.position_y)
1363            } else {
1364                lo.position_y
1365            };
1366            assert!(
1367                (y_at_zero - inputs.sight_height).abs() < 0.002,
1368                "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
1369                y_at_zero,
1370                inputs.sight_height
1371            );
1372            ballistics_free_trajectory_result(result);
1373        }
1374    }
1375
1376    #[test]
1377    fn drag_table_len_above_cap_is_rejected() {
1378        // A valid, monotonically increasing deck that is simply too long: the cap
1379        // must reject it BEFORE the to_vec() copies, returning the null sentinel.
1380        let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
1381        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1382        let cd: Vec<f64> = vec![0.3; n];
1383        let inputs = valid_trajectory_inputs();
1384        unsafe {
1385            let r = ballistics_calculate_trajectory_with_drag_table(
1386                &inputs,
1387                std::ptr::null(),
1388                std::ptr::null(),
1389                300.0,
1390                1.0,
1391                mach.as_ptr(),
1392                cd.as_ptr(),
1393                n as c_int,
1394            );
1395            assert!(r.is_null(), "len {n} must be rejected by the cap");
1396        }
1397    }
1398
1399    #[test]
1400    fn drag_table_len_at_cap_is_accepted() {
1401        let n = MAX_FFI_DRAG_TABLE_LEN as usize;
1402        let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1403        let cd: Vec<f64> = vec![0.3; n];
1404        let inputs = valid_trajectory_inputs();
1405        unsafe {
1406            let r = ballistics_calculate_trajectory_with_drag_table(
1407                &inputs,
1408                std::ptr::null(),
1409                std::ptr::null(),
1410                300.0,
1411                1.0,
1412                mach.as_ptr(),
1413                cd.as_ptr(),
1414                n as c_int,
1415            );
1416            assert!(!r.is_null(), "len == cap must be accepted");
1417            ballistics_free_trajectory_result(r);
1418        }
1419    }
1420
1421    #[test]
1422    fn ffi_cant_angle_deflects_laterally() {
1423        let mut level = valid_trajectory_inputs();
1424        level.muzzle_angle = 0.003;
1425        let mut canted = valid_trajectory_inputs();
1426        canted.muzzle_angle = 0.003;
1427        canted.cant_angle = 10f64.to_radians();
1428        unsafe {
1429            let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1430            let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1431            assert!(!a.is_null() && !b.is_null());
1432            let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
1433            let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
1434            assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
1435            ballistics_free_trajectory_result(a);
1436            ballistics_free_trajectory_result(b);
1437        }
1438    }
1439
1440    #[test]
1441    fn ffi_vertical_wind_raises_trajectory() {
1442        let inputs = valid_trajectory_inputs();
1443        let no_wind = FFIWindConditions {
1444            speed: 0.0,
1445            direction: 0.0,
1446            vertical_speed: 0.0,
1447        };
1448        let updraft = FFIWindConditions {
1449            speed: 0.0,
1450            direction: 0.0,
1451            vertical_speed: 5.0,
1452        };
1453        unsafe {
1454            let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
1455            let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
1456            assert!(!a.is_null() && !b.is_null());
1457            let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
1458            let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
1459            assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
1460            ballistics_free_trajectory_result(a);
1461            ballistics_free_trajectory_result(b);
1462        }
1463    }
1464}