Skip to main content

ballistics_engine/
ffi.rs

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