Skip to main content

ballistics_engine/
ffi.rs

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