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 (density is still
1329/// honored either way — only the implied pressure/altitude differ; see the Rust doc comment).
1330/// A non-finite `density_altitude_m` returns NaN for all three exports (there is no plausible
1331/// station value to fall back to, unlike the QNH/BC conversions above, which return their input
1332/// unchanged).
1333#[no_mangle]
1334pub extern "C" fn ballistics_density_altitude_temperature_c(
1335 density_altitude_m: c_double,
1336 explicit_temperature_c: c_double,
1337) -> c_double {
1338 if !density_altitude_m.is_finite() {
1339 return f64::NAN;
1340 }
1341 let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1342 crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).1
1343}
1344
1345/// Companion to [`ballistics_density_altitude_temperature_c`]: the back-solved station PRESSURE
1346/// (hPa) for the same `(density_altitude_m, explicit_temperature_c)` pair.
1347#[no_mangle]
1348pub extern "C" fn ballistics_density_altitude_pressure_hpa(
1349 density_altitude_m: c_double,
1350 explicit_temperature_c: c_double,
1351) -> c_double {
1352 if !density_altitude_m.is_finite() {
1353 return f64::NAN;
1354 }
1355 let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1356 crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).2
1357}
1358
1359/// Companion to [`ballistics_density_altitude_temperature_c`]: the back-solved station ALTITUDE
1360/// (meters, geometric) for the same `(density_altitude_m, explicit_temperature_c)` pair. This is
1361/// NOT necessarily equal to `density_altitude_m` — it only is when `explicit_temperature_c` is
1362/// [`FFI_NO_EXPLICIT_TEMPERATURE`] (see the Rust doc comment's algebraic identity).
1363#[no_mangle]
1364pub extern "C" fn ballistics_density_altitude_altitude_m(
1365 density_altitude_m: c_double,
1366 explicit_temperature_c: c_double,
1367) -> c_double {
1368 if !density_altitude_m.is_finite() {
1369 return f64::NAN;
1370 }
1371 let explicit = (!explicit_temperature_c.is_nan()).then_some(explicit_temperature_c);
1372 crate::atmosphere::resolve_atmosphere_for_density_altitude(density_altitude_m, explicit).0
1373}
1374
1375/// Largest `marks_len` [`ballistics_hold_point_in_reticle`] will accept (MBA-1361).
1376///
1377/// Mirrors [`MAX_FFI_DRAG_TABLE_LEN`] and exists for the same reason (MBA-1407): the
1378/// export copies a caller-owned array whose length it cannot otherwise verify, so an
1379/// unbounded `len` would let one call request a multi-gigabyte read. It is also
1380/// [`crate::reticle::MAX_RETICLE_MARKS`], so the C ABI and the Rust API reject the same
1381/// inputs. Real reticles carry tens of marks; a dense tree carries a few hundred.
1382pub const MAX_FFI_RETICLE_MARKS: c_int = crate::reticle::MAX_RETICLE_MARKS as c_int;
1383
1384/// `focal_plane` value selecting a FIRST-focal-plane reticle (subtensions constant across
1385/// magnification). Any value other than [`FFI_RETICLE_SECOND_FOCAL_PLANE`] is treated as FFP.
1386pub const FFI_RETICLE_FIRST_FOCAL_PLANE: c_int = 0;
1387/// `focal_plane` value selecting a SECOND-focal-plane reticle (subtensions scale as
1388/// `reference_magnification / magnification`).
1389pub const FFI_RETICLE_SECOND_FOCAL_PLANE: c_int = 1;
1390
1391/// [`ballistics_hold_point_in_reticle`] succeeded and `out` was written.
1392pub const FFI_RETICLE_OK: c_int = 0;
1393/// A null pointer, or a `marks_len` outside `1..=`[`MAX_FFI_RETICLE_MARKS`].
1394pub const FFI_RETICLE_ERR_INVALID_ARGUMENT: c_int = -1;
1395/// `magnification` was not finite and greater than zero.
1396pub const FFI_RETICLE_ERR_MAGNIFICATION: c_int = -2;
1397/// A second-focal-plane call carried a non-finite or non-positive `reference_magnification`.
1398pub const FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION: c_int = -3;
1399/// A mark coordinate, or the supplied firing solution, was not finite.
1400pub const FFI_RETICLE_ERR_NON_FINITE: c_int = -4;
1401
1402/// Where a firing solution lands in a reticle (MBA-1361).
1403///
1404/// A NEW appended struct — no existing `repr(C)` layout is touched by this addition, so
1405/// existing callers need no recompile. All angles are milliradians from the optical
1406/// center; `down` is positive BELOW center and `right` is positive to the shooter's
1407/// RIGHT (see [`crate::reticle`] for the full convention block).
1408#[repr(C)]
1409pub struct FFIReticleHold {
1410 /// True angular milliradians below center. Equals the supplied `drop_mil`.
1411 pub down_mil: c_double,
1412 /// True angular milliradians right of center. Equals the supplied `wind_mil`.
1413 pub right_mil: c_double,
1414 /// Index of the nearest mark in the caller's array, or `-1` when there is none
1415 /// (unreachable today: an empty mark array is rejected before the search).
1416 pub nearest_mark: c_int,
1417 /// Distance from the hold to that mark, milliradians, measured in TRUE angular space
1418 /// (i.e. after second-focal-plane scaling).
1419 pub nearest_mark_distance_mil: c_double,
1420 /// `1` when the hold falls outside the marks' bounding box grown by 20% of its span
1421 /// per axis, `0` otherwise.
1422 pub off_reticle: c_int,
1423 /// The subtension scale applied to the marks: `reference_magnification / magnification`
1424 /// for a second-focal-plane reticle, exactly `1.0` for first focal plane.
1425 pub mark_scale: c_double,
1426}
1427
1428/// Place an angular firing solution in a reticle (MBA-1361).
1429///
1430/// `marks` is a FLAT array of `2 * marks_len` doubles laid out as
1431/// `[down_0, right_0, down_1, right_1, ...]` in NOMINAL milliradians (as etched; for a
1432/// second-focal-plane reticle that means "true at `reference_magnification`").
1433/// `focal_plane` is [`FFI_RETICLE_FIRST_FOCAL_PLANE`] or
1434/// [`FFI_RETICLE_SECOND_FOCAL_PLANE`]; `reference_magnification` is consulted only in the
1435/// second-focal-plane case.
1436///
1437/// Returns [`FFI_RETICLE_OK`] and writes `out` on success, or one of the negative
1438/// `FFI_RETICLE_ERR_*` codes, in which case `out` is left untouched.
1439///
1440/// # Safety
1441///
1442/// `marks` must point to at least `2 * marks_len` readable `double`s and `out` to one
1443/// writable [`FFIReticleHold`]. `marks_len` is validated against
1444/// `1..=`[`MAX_FFI_RETICLE_MARKS`] BEFORE any element is read (MBA-1407 lesson: the
1445/// bound is the only thing standing between a caller typo and an out-of-range read).
1446#[no_mangle]
1447// A newer clippy stable added `chunks_exact_to_as_chunks`, which flags a constant chunk
1448// size and suggests `as_chunks`. Suppressed rather than restructured: this is binary-format
1449// parsing, `as_chunks` changes the element type from `&[u8]` to `&[u8; N]` and so ripples
1450// into every use inside the loop, and the change would land in the middle of a 13-platform
1451// release. `unknown_lints` is allowed alongside it so toolchains predating the lint do not
1452// warn on the name. Adopting `as_chunks` properly is a follow-up.
1453#[allow(unknown_lints, clippy::chunks_exact_to_as_chunks)]
1454pub unsafe extern "C" fn ballistics_hold_point_in_reticle(
1455 drop_mil: c_double,
1456 wind_mil: c_double,
1457 magnification: c_double,
1458 marks: *const c_double,
1459 marks_len: c_int,
1460 focal_plane: c_int,
1461 reference_magnification: c_double,
1462 out: *mut FFIReticleHold,
1463) -> c_int {
1464 use crate::reticle::{
1465 hold_point_in_reticle, FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark,
1466 };
1467
1468 if marks.is_null() || out.is_null() || !(1..=MAX_FFI_RETICLE_MARKS).contains(&marks_len) {
1469 return FFI_RETICLE_ERR_INVALID_ARGUMENT;
1470 }
1471 let count = marks_len as usize;
1472 // Length validated above, so this read stays inside the caller's declared array.
1473 let flat = unsafe { std::slice::from_raw_parts(marks, count * 2) };
1474
1475 let description = ReticleDescription {
1476 name: String::new(),
1477 focal_plane: if focal_plane == FFI_RETICLE_SECOND_FOCAL_PLANE {
1478 FocalPlane::Second
1479 } else {
1480 FocalPlane::First
1481 },
1482 reference_magnification,
1483 marks: flat
1484 .chunks_exact(2)
1485 .map(|pair| ReticleMark::new(pair[0], pair[1], MarkKind::Dot))
1486 .collect(),
1487 };
1488
1489 let hold = match hold_point_in_reticle(drop_mil, wind_mil, magnification, &description) {
1490 Ok(hold) => hold,
1491 Err(ReticleError::NonPositiveMagnification { .. }) => return FFI_RETICLE_ERR_MAGNIFICATION,
1492 Err(ReticleError::NonPositiveReferenceMagnification { .. }) => {
1493 return FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION
1494 }
1495 Err(ReticleError::NonFiniteMark { .. }) | Err(ReticleError::NonFiniteHold { .. }) => {
1496 return FFI_RETICLE_ERR_NON_FINITE
1497 }
1498 Err(_) => return FFI_RETICLE_ERR_INVALID_ARGUMENT,
1499 };
1500
1501 unsafe {
1502 *out = FFIReticleHold {
1503 down_mil: hold.down_mil,
1504 right_mil: hold.right_mil,
1505 nearest_mark: hold.nearest_mark.map_or(-1, |index| index as c_int),
1506 nearest_mark_distance_mil: hold.nearest_mark_distance_mil,
1507 off_reticle: c_int::from(hold.off_reticle),
1508 mark_scale: hold.mark_scale,
1509 };
1510 }
1511 FFI_RETICLE_OK
1512}
1513
1514// Get library version
1515#[no_mangle]
1516pub extern "C" fn ballistics_get_version() -> *const c_char {
1517 // Return a pointer to a static NUL-terminated string (the caller must NOT free it).
1518 // Previously this leaked a freshly-allocated CString on every call and reported a
1519 // stale hardcoded "0.3.0"; use the real crate version with no allocation.
1520 concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use super::*;
1526
1527 fn valid_trajectory_inputs() -> FFIBallisticInputs {
1528 FFIBallisticInputs {
1529 muzzle_velocity: 800.0,
1530 muzzle_angle: 0.0,
1531 bc_value: 0.5,
1532 bullet_mass: 0.01,
1533 bullet_diameter: 0.00762,
1534 bc_type: 0,
1535 sight_height: 0.05,
1536 target_distance: 1.0,
1537 temperature: 15.0,
1538 twist_rate: 12.0,
1539 is_twist_right: 1,
1540 shooting_angle: 0.0,
1541 altitude: 0.0,
1542 latitude: f64::NAN,
1543 azimuth_angle: 0.0,
1544 use_rk4: 1,
1545 use_adaptive_rk45: 0,
1546 enable_wind_shear: 0,
1547 enable_trajectory_sampling: 0,
1548 sample_interval: 10.0,
1549 enable_pitch_damping: 0,
1550 enable_precession_nutation: 0,
1551 enable_spin_drift: 0,
1552 enable_magnus: 0,
1553 enable_coriolis: 0,
1554 shot_azimuth: 0.0,
1555 cant_angle: 0.0,
1556 zero_poi_vertical: 0.0,
1557 zero_poi_horizontal: 0.0,
1558 sight_offset_lateral: 0.0,
1559 }
1560 }
1561
1562 #[allow(dead_code)]
1563 #[repr(C)]
1564 struct LegacyFFIMonteCarloParams {
1565 num_simulations: c_int,
1566 velocity_std_dev: c_double,
1567 angle_std_dev: c_double,
1568 bc_std_dev: c_double,
1569 wind_speed_std_dev: c_double,
1570 target_distance: c_double,
1571 base_wind_speed: c_double,
1572 base_wind_direction: c_double,
1573 azimuth_std_dev: c_double,
1574 }
1575
1576 #[test]
1577 fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1578 assert_eq!(
1579 std::mem::size_of::<FFIMonteCarloParams>(),
1580 std::mem::size_of::<LegacyFFIMonteCarloParams>()
1581 );
1582 assert_eq!(
1583 std::mem::align_of::<FFIMonteCarloParams>(),
1584 std::mem::align_of::<LegacyFFIMonteCarloParams>()
1585 );
1586 }
1587
1588 /// MBA-1361: the reticle export is APPEND-ONLY — a new struct and a new function.
1589 /// This pins that no pre-existing `repr(C)` layout moved when it landed.
1590 #[test]
1591 fn reticle_addition_does_not_disturb_existing_layouts() {
1592 assert_eq!(
1593 std::mem::size_of::<FFIMonteCarloParams>(),
1594 std::mem::size_of::<LegacyFFIMonteCarloParams>()
1595 );
1596 // The new struct is 6 fields: 4 doubles + 2 ints, C-laid-out.
1597 assert_eq!(std::mem::align_of::<FFIReticleHold>(), 8);
1598 }
1599
1600 /// Asking for shear over the C ABI has to survive model resolution.
1601 ///
1602 /// `FFIBallisticInputs` carries no model name, so a caller that set the flag
1603 /// used to inherit `BallisticInputs`' default `"none"` — and the two solvers
1604 /// read that oppositely. `TrajectorySolver::get_wind_at_altitude` falls
1605 /// unknown names onto `PowerLaw`, so the trajectory path always applied the
1606 /// power law; `fast_integrate` resolves them to `WindShearModel::None` and
1607 /// then drops the model, so the Monte Carlo path applied nothing at all and
1608 /// the flag was silently inert. The assertion that matters is the second one:
1609 /// the name has to be one the fast path KEEPS.
1610 #[test]
1611 fn asking_for_shear_over_the_c_abi_names_a_profile_the_fast_path_keeps() {
1612 let mut inputs = valid_trajectory_inputs();
1613 inputs.enable_wind_shear = 1;
1614
1615 let converted = convert_inputs(&inputs);
1616
1617 assert!(converted.enable_wind_shear);
1618 assert_ne!(
1619 crate::wind_shear::boundary_layer_model_from_name(&converted.wind_shear_model),
1620 crate::wind_shear::WindShearModel::None,
1621 "the fast-integrate path would drop this model and leave the flag inert"
1622 );
1623 // power_law specifically, because that is what the trajectory path has
1624 // always applied for an unset name — this makes the two agree rather than
1625 // moving either one onto a new profile.
1626 assert_eq!(converted.wind_shear_model, "power_law");
1627 }
1628
1629 /// The behaviour the naming exists for: Monte Carlo must actually move.
1630 ///
1631 /// Deterministic by construction — every std dev is zero, so the samples are
1632 /// one repeated trajectory and any difference is the shear itself. The shot
1633 /// is lofted on purpose: `boundary_layer_speed_ratio` floors at 1.0 below
1634 /// H_REF = 10 m, so a flat-fire Monte Carlo is bit-identical either way and
1635 /// would prove nothing.
1636 #[test]
1637 fn shear_moves_the_monte_carlo_result_it_used_to_leave_untouched() {
1638 let mut lofted = valid_trajectory_inputs();
1639 lofted.muzzle_angle = 0.15; // mirrors the engine's own shear fixture: ~186 m apex
1640
1641 // Built independently rather than cloned: FFIBallisticInputs is a public
1642 // C-ABI struct and does not derive Clone, which a test has no business changing.
1643 let mut sheared = valid_trajectory_inputs();
1644 sheared.muzzle_angle = lofted.muzzle_angle;
1645 sheared.enable_wind_shear = 1;
1646
1647 let atmo = FFIAtmosphericConditions {
1648 temperature: 15.0,
1649 pressure: 1013.25,
1650 humidity: 50.0,
1651 altitude: 0.0,
1652 };
1653 let params = FFIMonteCarloParams {
1654 num_simulations: 4,
1655 velocity_std_dev: 0.0,
1656 angle_std_dev: 0.0,
1657 bc_std_dev: 0.0,
1658 wind_speed_std_dev: 0.0,
1659 target_distance: 2500.0,
1660 base_wind_speed: 10.0,
1661 // radians; from the right, so the whole wind is crosswind
1662 base_wind_direction: std::f64::consts::FRAC_PI_2,
1663 azimuth_std_dev: 0.0,
1664 };
1665
1666 unsafe {
1667 let off = ballistics_monte_carlo(&lofted, &atmo, ¶ms);
1668 let on = ballistics_monte_carlo(&sheared, &atmo, ¶ms);
1669 assert!(!off.is_null() && !on.is_null());
1670 assert!((*off).num_results > 0 && (*on).num_results > 0);
1671
1672 // mean_range, not impact_positions_*: those arrays stay unpopulated in
1673 // this configuration (x, y and z are all zero for every sample), so they
1674 // would report "no difference" no matter what the solver did.
1675 let range_off = (*off).mean_range;
1676 let range_on = (*on).mean_range;
1677
1678 assert!(
1679 (range_off - range_on).abs() > 1.0e-2,
1680 "the shear flag must change the Monte Carlo result; it was inert until \
1681 convert_inputs named a profile: off={range_off} on={range_on}"
1682 );
1683
1684 ballistics_free_monte_carlo_results(off);
1685 ballistics_free_monte_carlo_results(on);
1686 }
1687 }
1688
1689 /// Not asking must stay not asking: the default may not acquire a profile.
1690 #[test]
1691 fn leaving_shear_off_over_the_c_abi_names_no_profile() {
1692 let mut inputs = valid_trajectory_inputs();
1693 inputs.enable_wind_shear = 0;
1694
1695 let converted = convert_inputs(&inputs);
1696
1697 assert!(!converted.enable_wind_shear);
1698 assert_eq!(
1699 crate::wind_shear::boundary_layer_model_from_name(&converted.wind_shear_model),
1700 crate::wind_shear::WindShearModel::None
1701 );
1702 }
1703
1704 fn zeroed_hold() -> FFIReticleHold {
1705 FFIReticleHold {
1706 down_mil: 0.0,
1707 right_mil: 0.0,
1708 nearest_mark: -99,
1709 nearest_mark_distance_mil: -1.0,
1710 off_reticle: -1,
1711 mark_scale: -1.0,
1712 }
1713 }
1714
1715 #[test]
1716 fn ffi_hold_point_matches_the_rust_api_on_both_focal_planes() {
1717 // down/right pairs: center, 2 mil, 4 mil, and a windage dot.
1718 let marks: [c_double; 8] = [0.0, 0.0, 2.0, 0.0, 4.0, 0.0, 2.0, 1.0];
1719 let mut out = zeroed_hold();
1720
1721 // FFP at any magnification: marks are used as etched.
1722 let code = unsafe {
1723 ballistics_hold_point_in_reticle(
1724 4.0,
1725 0.0,
1726 6.0,
1727 marks.as_ptr(),
1728 4,
1729 FFI_RETICLE_FIRST_FOCAL_PLANE,
1730 0.0,
1731 &mut out,
1732 )
1733 };
1734 assert_eq!(code, FFI_RETICLE_OK);
1735 assert_eq!(out.down_mil, 4.0);
1736 assert_eq!(out.nearest_mark, 2);
1737 assert_eq!(out.nearest_mark_distance_mil, 0.0);
1738 assert_eq!(out.mark_scale, 1.0);
1739 assert_eq!(out.off_reticle, 0);
1740
1741 // SFP at half the reference magnification: the 2 mil mark reads 4 mil true.
1742 let mut out = zeroed_hold();
1743 let code = unsafe {
1744 ballistics_hold_point_in_reticle(
1745 4.0,
1746 0.0,
1747 5.0,
1748 marks.as_ptr(),
1749 4,
1750 FFI_RETICLE_SECOND_FOCAL_PLANE,
1751 10.0,
1752 &mut out,
1753 )
1754 };
1755 assert_eq!(code, FFI_RETICLE_OK);
1756 assert_eq!(out.nearest_mark, 1);
1757 assert_eq!(out.nearest_mark_distance_mil, 0.0);
1758 assert_eq!(out.mark_scale, 2.0);
1759 }
1760
1761 /// The MBA-1407 lesson applied to the new export: `marks_len` is validated against a
1762 /// stated bound BEFORE a single element is read, and a null pointer is rejected.
1763 #[test]
1764 fn ffi_hold_point_bounds_check_marks_len_before_reading() {
1765 let marks: [c_double; 4] = [0.0, 0.0, 2.0, 0.0];
1766 let mut out = zeroed_hold();
1767 let call = |len: c_int, ptr: *const c_double, out: &mut FFIReticleHold| unsafe {
1768 ballistics_hold_point_in_reticle(
1769 1.0,
1770 0.0,
1771 10.0,
1772 ptr,
1773 len,
1774 FFI_RETICLE_FIRST_FOCAL_PLANE,
1775 0.0,
1776 out,
1777 )
1778 };
1779
1780 assert_eq!(
1781 call(0, marks.as_ptr(), &mut out),
1782 FFI_RETICLE_ERR_INVALID_ARGUMENT
1783 );
1784 assert_eq!(
1785 call(-1, marks.as_ptr(), &mut out),
1786 FFI_RETICLE_ERR_INVALID_ARGUMENT
1787 );
1788 assert_eq!(
1789 call(MAX_FFI_RETICLE_MARKS + 1, marks.as_ptr(), &mut out),
1790 FFI_RETICLE_ERR_INVALID_ARGUMENT
1791 );
1792 assert_eq!(
1793 call(c_int::MAX, marks.as_ptr(), &mut out),
1794 FFI_RETICLE_ERR_INVALID_ARGUMENT
1795 );
1796 assert_eq!(
1797 call(2, std::ptr::null(), &mut out),
1798 FFI_RETICLE_ERR_INVALID_ARGUMENT
1799 );
1800 // `out` is untouched on every rejection.
1801 assert_eq!(out.nearest_mark, -99);
1802
1803 // A null `out` is rejected too, without reading the marks.
1804 assert_eq!(
1805 unsafe {
1806 ballistics_hold_point_in_reticle(
1807 1.0,
1808 0.0,
1809 10.0,
1810 marks.as_ptr(),
1811 2,
1812 FFI_RETICLE_FIRST_FOCAL_PLANE,
1813 0.0,
1814 std::ptr::null_mut(),
1815 )
1816 },
1817 FFI_RETICLE_ERR_INVALID_ARGUMENT
1818 );
1819 }
1820
1821 #[test]
1822 fn ffi_hold_point_maps_each_error_class_to_its_own_code() {
1823 let marks: [c_double; 4] = [0.0, 0.0, 2.0, 0.0];
1824 let bad_marks: [c_double; 4] = [0.0, 0.0, f64::NAN, 0.0];
1825 let mut out = zeroed_hold();
1826 let call = |drop: c_double,
1827 mag: c_double,
1828 plane: c_int,
1829 ref_mag: c_double,
1830 m: &[c_double],
1831 out: &mut FFIReticleHold| unsafe {
1832 ballistics_hold_point_in_reticle(
1833 drop,
1834 0.0,
1835 mag,
1836 m.as_ptr(),
1837 (m.len() / 2) as c_int,
1838 plane,
1839 ref_mag,
1840 out,
1841 )
1842 };
1843
1844 assert_eq!(
1845 call(
1846 1.0,
1847 0.0,
1848 FFI_RETICLE_FIRST_FOCAL_PLANE,
1849 0.0,
1850 &marks,
1851 &mut out
1852 ),
1853 FFI_RETICLE_ERR_MAGNIFICATION
1854 );
1855 assert_eq!(
1856 call(
1857 1.0,
1858 10.0,
1859 FFI_RETICLE_SECOND_FOCAL_PLANE,
1860 0.0,
1861 &marks,
1862 &mut out
1863 ),
1864 FFI_RETICLE_ERR_REFERENCE_MAGNIFICATION
1865 );
1866 assert_eq!(
1867 call(
1868 f64::NAN,
1869 10.0,
1870 FFI_RETICLE_FIRST_FOCAL_PLANE,
1871 0.0,
1872 &marks,
1873 &mut out
1874 ),
1875 FFI_RETICLE_ERR_NON_FINITE
1876 );
1877 assert_eq!(
1878 call(
1879 1.0,
1880 10.0,
1881 FFI_RETICLE_FIRST_FOCAL_PLANE,
1882 0.0,
1883 &bad_marks,
1884 &mut out
1885 ),
1886 FFI_RETICLE_ERR_NON_FINITE
1887 );
1888 assert_eq!(out.nearest_mark, -99, "out stays untouched on every error");
1889 }
1890
1891 /// MBA-1386 scope addition: `bc_type` gains an additive numeric slot (8) for the
1892 /// new RA4 family, appended after the existing 0-7 mapping (G1, G7, G2, G5, G6,
1893 /// G8, GI, GS). No existing caller's value changes meaning; `8` is simply new,
1894 /// and anything still outside 0-8 keeps falling back to G1.
1895 #[test]
1896 fn bc_type_8_maps_to_ra4() {
1897 let mut inputs = valid_trajectory_inputs();
1898 inputs.bc_type = 8;
1899 assert_eq!(convert_inputs(&inputs).bc_type, DragModel::RA4);
1900
1901 // Every pre-existing code is unaffected by the addition.
1902 let expected = [
1903 (0, DragModel::G1),
1904 (1, DragModel::G7),
1905 (2, DragModel::G2),
1906 (3, DragModel::G5),
1907 (4, DragModel::G6),
1908 (5, DragModel::G8),
1909 (6, DragModel::GI),
1910 (7, DragModel::GS),
1911 ];
1912 for (code, model) in expected {
1913 let mut inputs = valid_trajectory_inputs();
1914 inputs.bc_type = code;
1915 assert_eq!(convert_inputs(&inputs).bc_type, model, "code {code}");
1916 }
1917
1918 // Unrecognized codes (including ones above the new 8) still fall back to G1.
1919 for code in [9, 42, -1] {
1920 let mut inputs = valid_trajectory_inputs();
1921 inputs.bc_type = code;
1922 assert_eq!(
1923 convert_inputs(&inputs).bc_type,
1924 DragModel::G1,
1925 "code {code}"
1926 );
1927 }
1928 }
1929
1930 #[test]
1931 fn null_pointer_contracts_return_sentinels_and_free_safely() {
1932 unsafe {
1933 assert!(ballistics_calculate_trajectory(
1934 std::ptr::null(),
1935 std::ptr::null(),
1936 std::ptr::null(),
1937 1_000.0,
1938 1.0,
1939 )
1940 .is_null());
1941 assert!(ballistics_calculate_zero_angle(
1942 std::ptr::null(),
1943 std::ptr::null(),
1944 std::ptr::null(),
1945 100.0,
1946 )
1947 .is_nan());
1948 assert!(ballistics_calculate_trajectory_with_drag_table(
1949 std::ptr::null(),
1950 std::ptr::null(),
1951 std::ptr::null(),
1952 1_000.0,
1953 1.0,
1954 DECK_MACH.as_ptr(),
1955 DECK_CD_LOW.as_ptr(),
1956 DECK_MACH.len() as c_int,
1957 )
1958 .is_null());
1959 assert!(ballistics_calculate_zero_angle_with_drag_table(
1960 std::ptr::null(),
1961 std::ptr::null(),
1962 std::ptr::null(),
1963 100.0,
1964 DECK_MACH.as_ptr(),
1965 DECK_CD_LOW.as_ptr(),
1966 DECK_MACH.len() as c_int,
1967 )
1968 .is_nan());
1969 assert!(
1970 ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1971 .is_null()
1972 );
1973 assert!(ballistics_monte_carlo_with_direction_std_dev(
1974 std::ptr::null(),
1975 std::ptr::null(),
1976 std::ptr::null(),
1977 0.1,
1978 )
1979 .is_null());
1980
1981 ballistics_free_trajectory_result(std::ptr::null_mut());
1982 ballistics_free_monte_carlo_results(std::ptr::null_mut());
1983 }
1984 }
1985
1986 #[test]
1987 fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1988 for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1989 for step_size in [
1990 f64::NAN,
1991 f64::INFINITY,
1992 f64::NEG_INFINITY,
1993 -1.0,
1994 -0.0,
1995 0.0,
1996 0.001,
1997 MIN_FFI_STEP_SIZE_MS - 0.001,
1998 ] {
1999 let mut inputs = valid_trajectory_inputs();
2000 inputs.use_rk4 = use_rk4;
2001 inputs.use_adaptive_rk45 = use_adaptive_rk45;
2002 let result = unsafe {
2003 ballistics_calculate_trajectory(
2004 &inputs,
2005 std::ptr::null(),
2006 std::ptr::null(),
2007 0.01,
2008 step_size,
2009 )
2010 };
2011 assert!(
2012 result.is_null(),
2013 "{mode} step_size={step_size:?} bypassed the FFI floor"
2014 );
2015 }
2016
2017 let mut inputs = valid_trajectory_inputs();
2018 inputs.use_rk4 = use_rk4;
2019 inputs.use_adaptive_rk45 = use_adaptive_rk45;
2020 let result = unsafe {
2021 ballistics_calculate_trajectory(
2022 &inputs,
2023 std::ptr::null(),
2024 std::ptr::null(),
2025 0.01,
2026 MIN_FFI_STEP_SIZE_MS,
2027 )
2028 };
2029 assert!(
2030 !result.is_null(),
2031 "the documented minimum step must remain usable in {mode}"
2032 );
2033 unsafe {
2034 assert!((*result).point_count >= 0);
2035 assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
2036 ballistics_free_trajectory_result(result);
2037 }
2038 }
2039 }
2040
2041 /// A tiny valid deck: strictly ascending Mach, positive Cd.
2042 const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
2043 /// Deliberately LOW drag so the deck measurably increases impact velocity vs G1.
2044 const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
2045
2046 #[test]
2047 fn trajectory_with_drag_table_applies_the_deck() {
2048 let inputs = valid_trajectory_inputs();
2049 unsafe {
2050 let plain = ballistics_calculate_trajectory(
2051 &inputs,
2052 std::ptr::null(),
2053 std::ptr::null(),
2054 300.0,
2055 1.0,
2056 );
2057 let decked = ballistics_calculate_trajectory_with_drag_table(
2058 &inputs,
2059 std::ptr::null(),
2060 std::ptr::null(),
2061 300.0,
2062 1.0,
2063 DECK_MACH.as_ptr(),
2064 DECK_CD_LOW.as_ptr(),
2065 DECK_MACH.len() as c_int,
2066 );
2067 assert!(!plain.is_null() && !decked.is_null());
2068 // The low-drag deck must retain materially more velocity than the G-model.
2069 assert!(
2070 (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
2071 "deck did not change the solve: plain={} decked={}",
2072 (*plain).impact_velocity,
2073 (*decked).impact_velocity
2074 );
2075 ballistics_free_trajectory_result(plain);
2076 ballistics_free_trajectory_result(decked);
2077 }
2078 }
2079
2080 #[test]
2081 fn trajectory_with_drag_table_rejects_invalid_decks() {
2082 let inputs = valid_trajectory_inputs();
2083 let descending = [3.0, 2.0, 1.0, 0.5];
2084 let negative_cd = [0.05, -0.08, 0.06, 0.05];
2085 unsafe {
2086 // null arrays
2087 assert!(ballistics_calculate_trajectory_with_drag_table(
2088 &inputs,
2089 std::ptr::null(),
2090 std::ptr::null(),
2091 300.0,
2092 1.0,
2093 std::ptr::null(),
2094 DECK_CD_LOW.as_ptr(),
2095 4,
2096 )
2097 .is_null());
2098 assert!(ballistics_calculate_trajectory_with_drag_table(
2099 &inputs,
2100 std::ptr::null(),
2101 std::ptr::null(),
2102 300.0,
2103 1.0,
2104 DECK_MACH.as_ptr(),
2105 std::ptr::null(),
2106 4,
2107 )
2108 .is_null());
2109 // too few points
2110 assert!(ballistics_calculate_trajectory_with_drag_table(
2111 &inputs,
2112 std::ptr::null(),
2113 std::ptr::null(),
2114 300.0,
2115 1.0,
2116 DECK_MACH.as_ptr(),
2117 DECK_CD_LOW.as_ptr(),
2118 1,
2119 )
2120 .is_null());
2121 // non-ascending Mach
2122 assert!(ballistics_calculate_trajectory_with_drag_table(
2123 &inputs,
2124 std::ptr::null(),
2125 std::ptr::null(),
2126 300.0,
2127 1.0,
2128 descending.as_ptr(),
2129 DECK_CD_LOW.as_ptr(),
2130 4,
2131 )
2132 .is_null());
2133 // non-positive Cd
2134 assert!(ballistics_calculate_trajectory_with_drag_table(
2135 &inputs,
2136 std::ptr::null(),
2137 std::ptr::null(),
2138 300.0,
2139 1.0,
2140 DECK_MACH.as_ptr(),
2141 negative_cd.as_ptr(),
2142 4,
2143 )
2144 .is_null());
2145 // null inputs still rejected
2146 assert!(ballistics_calculate_trajectory_with_drag_table(
2147 std::ptr::null(),
2148 std::ptr::null(),
2149 std::ptr::null(),
2150 300.0,
2151 1.0,
2152 DECK_MACH.as_ptr(),
2153 DECK_CD_LOW.as_ptr(),
2154 4,
2155 )
2156 .is_null());
2157 }
2158 }
2159
2160 #[test]
2161 fn zero_angle_with_drag_table_applies_the_deck() {
2162 // A realistic zeroing setup: 100 m zero.
2163 let inputs = valid_trajectory_inputs();
2164 unsafe {
2165 let plain =
2166 ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
2167 let decked = ballistics_calculate_zero_angle_with_drag_table(
2168 &inputs,
2169 std::ptr::null(),
2170 std::ptr::null(),
2171 100.0,
2172 DECK_MACH.as_ptr(),
2173 DECK_CD_LOW.as_ptr(),
2174 DECK_MACH.len() as c_int,
2175 );
2176 assert!(plain.is_finite() && decked.is_finite());
2177 // A much lower-drag deck needs a flatter (smaller) zero angle; at minimum it
2178 // must differ measurably from the G-model zero.
2179 assert!(
2180 (plain - decked).abs() > 1e-6,
2181 "deck did not change the zero: plain={plain} decked={decked}"
2182 );
2183 }
2184 }
2185
2186 #[test]
2187 fn zero_angle_with_drag_table_rejects_invalid_decks() {
2188 let inputs = valid_trajectory_inputs();
2189 let descending = [3.0, 2.0, 1.0, 0.5];
2190 unsafe {
2191 assert!(ballistics_calculate_zero_angle_with_drag_table(
2192 &inputs,
2193 std::ptr::null(),
2194 std::ptr::null(),
2195 100.0,
2196 std::ptr::null(),
2197 DECK_CD_LOW.as_ptr(),
2198 4,
2199 )
2200 .is_nan());
2201 assert!(ballistics_calculate_zero_angle_with_drag_table(
2202 &inputs,
2203 std::ptr::null(),
2204 std::ptr::null(),
2205 100.0,
2206 DECK_MACH.as_ptr(),
2207 DECK_CD_LOW.as_ptr(),
2208 0,
2209 )
2210 .is_nan());
2211 assert!(ballistics_calculate_zero_angle_with_drag_table(
2212 &inputs,
2213 std::ptr::null(),
2214 std::ptr::null(),
2215 100.0,
2216 descending.as_ptr(),
2217 DECK_CD_LOW.as_ptr(),
2218 4,
2219 )
2220 .is_nan());
2221 // null inputs still rejected
2222 assert!(ballistics_calculate_zero_angle_with_drag_table(
2223 std::ptr::null(),
2224 std::ptr::null(),
2225 std::ptr::null(),
2226 100.0,
2227 DECK_MACH.as_ptr(),
2228 DECK_CD_LOW.as_ptr(),
2229 4,
2230 )
2231 .is_nan());
2232 }
2233 }
2234
2235 #[test]
2236 fn zero_then_fly_with_same_deck_is_consistent() {
2237 // The pair-use case the two exports exist for: zero with the deck, fly with the
2238 // deck at the solved angle; the trajectory must cross near sight height at the
2239 // zero distance (i.e. the two functions share identical deck semantics).
2240 let mut inputs = valid_trajectory_inputs();
2241 unsafe {
2242 let angle = ballistics_calculate_zero_angle_with_drag_table(
2243 &inputs,
2244 std::ptr::null(),
2245 std::ptr::null(),
2246 100.0,
2247 DECK_MACH.as_ptr(),
2248 DECK_CD_LOW.as_ptr(),
2249 DECK_MACH.len() as c_int,
2250 );
2251 assert!(angle.is_finite());
2252 inputs.muzzle_angle = angle;
2253 let result = ballistics_calculate_trajectory_with_drag_table(
2254 &inputs,
2255 std::ptr::null(),
2256 std::ptr::null(),
2257 150.0,
2258 1.0,
2259 DECK_MACH.as_ptr(),
2260 DECK_CD_LOW.as_ptr(),
2261 DECK_MACH.len() as c_int,
2262 );
2263 assert!(!result.is_null());
2264 // Interpolate y at exactly the zero distance (100 m) rather than snapping to the
2265 // nearest raw trajectory sample, so the residual reflects only zero-solver
2266 // convergence, not the sampling grid's x-offset from 100 m.
2267 let zero_distance = 100.0;
2268 let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
2269 let bracket = pts
2270 .windows(2)
2271 .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
2272 .expect("trajectory brackets the zero distance");
2273 let (lo, hi) = (&bracket[0], &bracket[1]);
2274 let y_at_zero = if hi.position_x > lo.position_x {
2275 let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
2276 lo.position_y + t * (hi.position_y - lo.position_y)
2277 } else {
2278 lo.position_y
2279 };
2280 assert!(
2281 (y_at_zero - inputs.sight_height).abs() < 0.002,
2282 "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
2283 y_at_zero,
2284 inputs.sight_height
2285 );
2286 ballistics_free_trajectory_result(result);
2287 }
2288 }
2289
2290 #[test]
2291 fn drag_table_len_above_cap_is_rejected() {
2292 // A valid, monotonically increasing deck that is simply too long: the cap
2293 // must reject it BEFORE the to_vec() copies, returning the null sentinel.
2294 let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
2295 let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
2296 let cd: Vec<f64> = vec![0.3; n];
2297 let inputs = valid_trajectory_inputs();
2298 unsafe {
2299 let r = ballistics_calculate_trajectory_with_drag_table(
2300 &inputs,
2301 std::ptr::null(),
2302 std::ptr::null(),
2303 300.0,
2304 1.0,
2305 mach.as_ptr(),
2306 cd.as_ptr(),
2307 n as c_int,
2308 );
2309 assert!(r.is_null(), "len {n} must be rejected by the cap");
2310 }
2311 }
2312
2313 #[test]
2314 fn drag_table_len_at_cap_is_accepted() {
2315 let n = MAX_FFI_DRAG_TABLE_LEN as usize;
2316 let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
2317 let cd: Vec<f64> = vec![0.3; n];
2318 let inputs = valid_trajectory_inputs();
2319 unsafe {
2320 let r = ballistics_calculate_trajectory_with_drag_table(
2321 &inputs,
2322 std::ptr::null(),
2323 std::ptr::null(),
2324 300.0,
2325 1.0,
2326 mach.as_ptr(),
2327 cd.as_ptr(),
2328 n as c_int,
2329 );
2330 assert!(!r.is_null(), "len == cap must be accepted");
2331 ballistics_free_trajectory_result(r);
2332 }
2333 }
2334
2335 #[test]
2336 fn ffi_cant_angle_deflects_laterally() {
2337 let mut level = valid_trajectory_inputs();
2338 level.muzzle_angle = 0.003;
2339 let mut canted = valid_trajectory_inputs();
2340 canted.muzzle_angle = 0.003;
2341 canted.cant_angle = 10f64.to_radians();
2342 unsafe {
2343 let a = ballistics_calculate_trajectory(
2344 &level,
2345 std::ptr::null(),
2346 std::ptr::null(),
2347 400.0,
2348 1.0,
2349 );
2350 let b = ballistics_calculate_trajectory(
2351 &canted,
2352 std::ptr::null(),
2353 std::ptr::null(),
2354 400.0,
2355 1.0,
2356 );
2357 assert!(!a.is_null() && !b.is_null());
2358 let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2359 .last()
2360 .unwrap()
2361 .position_z;
2362 let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2363 .last()
2364 .unwrap()
2365 .position_z;
2366 assert!(
2367 zb > za + 0.005,
2368 "FFI cant must deflect right: level={za} canted={zb}"
2369 );
2370 ballistics_free_trajectory_result(a);
2371 ballistics_free_trajectory_result(b);
2372 }
2373 }
2374
2375 #[test]
2376 fn ffi_vertical_wind_raises_trajectory() {
2377 let inputs = valid_trajectory_inputs();
2378 let no_wind = FFIWindConditions {
2379 speed: 0.0,
2380 direction: 0.0,
2381 vertical_speed: 0.0,
2382 };
2383 let updraft = FFIWindConditions {
2384 speed: 0.0,
2385 direction: 0.0,
2386 vertical_speed: 5.0,
2387 };
2388 unsafe {
2389 let a =
2390 ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
2391 let b =
2392 ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
2393 assert!(!a.is_null() && !b.is_null());
2394 let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2395 .last()
2396 .unwrap()
2397 .position_y;
2398 let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2399 .last()
2400 .unwrap()
2401 .position_y;
2402 assert!(
2403 yb > ya + 0.01,
2404 "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}"
2405 );
2406 ballistics_free_trajectory_result(a);
2407 ballistics_free_trajectory_result(b);
2408 }
2409 }
2410
2411 // --- MBA-1356: cd_scale `_scaled` FFI variants ---
2412
2413 #[test]
2414 fn trajectory_scaled_at_one_matches_unscaled_export() {
2415 let inputs = valid_trajectory_inputs();
2416 unsafe {
2417 let unscaled = ballistics_calculate_trajectory_with_drag_table(
2418 &inputs,
2419 std::ptr::null(),
2420 std::ptr::null(),
2421 300.0,
2422 1.0,
2423 DECK_MACH.as_ptr(),
2424 DECK_CD_LOW.as_ptr(),
2425 DECK_MACH.len() as c_int,
2426 );
2427 let scaled = ballistics_calculate_trajectory_with_drag_table_scaled(
2428 &inputs,
2429 std::ptr::null(),
2430 std::ptr::null(),
2431 300.0,
2432 1.0,
2433 DECK_MACH.as_ptr(),
2434 DECK_CD_LOW.as_ptr(),
2435 DECK_MACH.len() as c_int,
2436 1.0,
2437 );
2438 assert!(!unscaled.is_null() && !scaled.is_null());
2439 assert_eq!(
2440 (*unscaled).impact_velocity.to_bits(),
2441 (*scaled).impact_velocity.to_bits(),
2442 "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={} scaled={}",
2443 (*unscaled).impact_velocity,
2444 (*scaled).impact_velocity
2445 );
2446 ballistics_free_trajectory_result(unscaled);
2447 ballistics_free_trajectory_result(scaled);
2448 }
2449 }
2450
2451 #[test]
2452 fn trajectory_scaled_at_1_10_lowers_impact_velocity() {
2453 let inputs = valid_trajectory_inputs();
2454 unsafe {
2455 let baseline = ballistics_calculate_trajectory_with_drag_table_scaled(
2456 &inputs,
2457 std::ptr::null(),
2458 std::ptr::null(),
2459 300.0,
2460 1.0,
2461 DECK_MACH.as_ptr(),
2462 DECK_CD_LOW.as_ptr(),
2463 DECK_MACH.len() as c_int,
2464 1.0,
2465 );
2466 let scaled_up = ballistics_calculate_trajectory_with_drag_table_scaled(
2467 &inputs,
2468 std::ptr::null(),
2469 std::ptr::null(),
2470 300.0,
2471 1.0,
2472 DECK_MACH.as_ptr(),
2473 DECK_CD_LOW.as_ptr(),
2474 DECK_MACH.len() as c_int,
2475 1.10,
2476 );
2477 assert!(!baseline.is_null() && !scaled_up.is_null());
2478 assert!(
2479 (*scaled_up).impact_velocity < (*baseline).impact_velocity,
2480 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} scaled={}",
2481 (*baseline).impact_velocity,
2482 (*scaled_up).impact_velocity
2483 );
2484 ballistics_free_trajectory_result(baseline);
2485 ballistics_free_trajectory_result(scaled_up);
2486 }
2487 }
2488
2489 #[test]
2490 fn trajectory_scaled_rejects_invalid_cd_scale() {
2491 let inputs = valid_trajectory_inputs();
2492 unsafe {
2493 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2494 let r = ballistics_calculate_trajectory_with_drag_table_scaled(
2495 &inputs,
2496 std::ptr::null(),
2497 std::ptr::null(),
2498 300.0,
2499 1.0,
2500 DECK_MACH.as_ptr(),
2501 DECK_CD_LOW.as_ptr(),
2502 DECK_MACH.len() as c_int,
2503 bad,
2504 );
2505 assert!(
2506 r.is_null(),
2507 "cd_scale={bad} must be rejected (null sentinel)"
2508 );
2509 }
2510 }
2511 }
2512
2513 #[test]
2514 fn zero_angle_scaled_at_one_matches_unscaled_export() {
2515 let inputs = valid_trajectory_inputs();
2516 unsafe {
2517 let unscaled = ballistics_calculate_zero_angle_with_drag_table(
2518 &inputs,
2519 std::ptr::null(),
2520 std::ptr::null(),
2521 100.0,
2522 DECK_MACH.as_ptr(),
2523 DECK_CD_LOW.as_ptr(),
2524 DECK_MACH.len() as c_int,
2525 );
2526 let scaled = ballistics_calculate_zero_angle_with_drag_table_scaled(
2527 &inputs,
2528 std::ptr::null(),
2529 std::ptr::null(),
2530 100.0,
2531 DECK_MACH.as_ptr(),
2532 DECK_CD_LOW.as_ptr(),
2533 DECK_MACH.len() as c_int,
2534 1.0,
2535 );
2536 assert!(unscaled.is_finite() && scaled.is_finite());
2537 assert_eq!(
2538 unscaled.to_bits(),
2539 scaled.to_bits(),
2540 "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={unscaled} scaled={scaled}"
2541 );
2542 }
2543 }
2544
2545 #[test]
2546 fn zero_angle_scaled_at_1_10_differs_from_baseline() {
2547 let inputs = valid_trajectory_inputs();
2548 unsafe {
2549 let baseline = ballistics_calculate_zero_angle_with_drag_table_scaled(
2550 &inputs,
2551 std::ptr::null(),
2552 std::ptr::null(),
2553 100.0,
2554 DECK_MACH.as_ptr(),
2555 DECK_CD_LOW.as_ptr(),
2556 DECK_MACH.len() as c_int,
2557 1.0,
2558 );
2559 let scaled_up = ballistics_calculate_zero_angle_with_drag_table_scaled(
2560 &inputs,
2561 std::ptr::null(),
2562 std::ptr::null(),
2563 100.0,
2564 DECK_MACH.as_ptr(),
2565 DECK_CD_LOW.as_ptr(),
2566 DECK_MACH.len() as c_int,
2567 1.10,
2568 );
2569 assert!(baseline.is_finite() && scaled_up.is_finite());
2570 // More drag needs a steeper (larger) zero angle to still reach 100 m.
2571 assert!(
2572 scaled_up > baseline,
2573 "cd_scale=1.10 must need a larger zero angle: base={baseline} scaled={scaled_up}"
2574 );
2575 }
2576 }
2577
2578 #[test]
2579 fn zero_angle_scaled_rejects_invalid_cd_scale() {
2580 let inputs = valid_trajectory_inputs();
2581 unsafe {
2582 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
2583 let angle = ballistics_calculate_zero_angle_with_drag_table_scaled(
2584 &inputs,
2585 std::ptr::null(),
2586 std::ptr::null(),
2587 100.0,
2588 DECK_MACH.as_ptr(),
2589 DECK_CD_LOW.as_ptr(),
2590 DECK_MACH.len() as c_int,
2591 bad,
2592 );
2593 assert!(
2594 angle.is_nan(),
2595 "cd_scale={bad} must be rejected (NaN sentinel)"
2596 );
2597 }
2598 }
2599 }
2600
2601 /// Legacy exports must remain byte-identical: the same deck through the unscaled
2602 /// export must be unaffected by the new cd_scale plumbing (a regression guard
2603 /// alongside the pre-existing, untouched drag-table tests above).
2604 #[test]
2605 fn legacy_drag_table_exports_unaffected_by_cd_scale_plumbing() {
2606 let inputs = valid_trajectory_inputs();
2607 unsafe {
2608 let a = ballistics_calculate_trajectory_with_drag_table(
2609 &inputs,
2610 std::ptr::null(),
2611 std::ptr::null(),
2612 300.0,
2613 1.0,
2614 DECK_MACH.as_ptr(),
2615 DECK_CD_LOW.as_ptr(),
2616 DECK_MACH.len() as c_int,
2617 );
2618 let b = ballistics_calculate_trajectory_with_drag_table(
2619 &inputs,
2620 std::ptr::null(),
2621 std::ptr::null(),
2622 300.0,
2623 1.0,
2624 DECK_MACH.as_ptr(),
2625 DECK_CD_LOW.as_ptr(),
2626 DECK_MACH.len() as c_int,
2627 );
2628 assert!(!a.is_null() && !b.is_null());
2629 assert_eq!(
2630 (*a).impact_velocity.to_bits(),
2631 (*b).impact_velocity.to_bits()
2632 );
2633 ballistics_free_trajectory_result(a);
2634 ballistics_free_trajectory_result(b);
2635
2636 let za = ballistics_calculate_zero_angle_with_drag_table(
2637 &inputs,
2638 std::ptr::null(),
2639 std::ptr::null(),
2640 100.0,
2641 DECK_MACH.as_ptr(),
2642 DECK_CD_LOW.as_ptr(),
2643 DECK_MACH.len() as c_int,
2644 );
2645 let zb = ballistics_calculate_zero_angle_with_drag_table(
2646 &inputs,
2647 std::ptr::null(),
2648 std::ptr::null(),
2649 100.0,
2650 DECK_MACH.as_ptr(),
2651 DECK_CD_LOW.as_ptr(),
2652 DECK_MACH.len() as c_int,
2653 );
2654 assert!(za.is_finite() && zb.is_finite());
2655 assert_eq!(za.to_bits(), zb.to_bits());
2656 }
2657 }
2658
2659 // ---- MBA-1365: ballistics_bc_for_reference_standard --------------------------------
2660
2661 #[test]
2662 fn bc_for_reference_standard_icao_is_a_byte_identical_no_op() {
2663 let bc = 0.475;
2664 assert_eq!(
2665 ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ICAO).to_bits(),
2666 bc.to_bits()
2667 );
2668 }
2669
2670 #[test]
2671 fn bc_for_reference_standard_unrecognized_value_falls_back_to_icao() {
2672 // Mirrors convert_inputs' permissive unrecognized-bc_type convention: an unknown
2673 // reference_standard is treated as ICAO (0), not rejected.
2674 let bc = 0.475;
2675 assert_eq!(
2676 ballistics_bc_for_reference_standard(bc, 99).to_bits(),
2677 bc.to_bits()
2678 );
2679 }
2680
2681 #[test]
2682 fn bc_for_reference_standard_army_standard_metro_applies_the_documented_ratio() {
2683 let bc = 0.475;
2684 let converted =
2685 ballistics_bc_for_reference_standard(bc, FFI_BC_REFERENCE_ARMY_STANDARD_METRO);
2686 assert_eq!(converted, bc * crate::constants::ASM_TO_ICAO_BC);
2687 // Smaller BC == more drag under this engine's ICAO-calibrated retardation math.
2688 assert!(converted < bc);
2689 }
2690
2691 // ---- MBA-1397: ballistics_reduce_qnh_pressure + FFIAtmosphericConditions.pressure ---
2692
2693 #[test]
2694 fn reduce_qnh_pressure_matches_the_library_function_and_lowers_pressure() {
2695 let reduced = ballistics_reduce_qnh_pressure(1030.0, 1500.0);
2696 assert_eq!(
2697 reduced,
2698 crate::atmosphere::reduce_qnh_to_station_pressure(1030.0, 1500.0)
2699 );
2700 assert!(reduced < 1030.0);
2701 }
2702
2703 #[test]
2704 fn reduce_qnh_pressure_passes_through_non_finite_inputs() {
2705 assert!(ballistics_reduce_qnh_pressure(f64::NAN, 1500.0).is_nan());
2706 assert_eq!(
2707 ballistics_reduce_qnh_pressure(1030.0, f64::INFINITY),
2708 1030.0
2709 );
2710 }
2711
2712 /// The FFI trajectory/Monte Carlo exports have always treated
2713 /// `FFIAtmosphericConditions.pressure` as absolute station pressure. A caller declaring a
2714 /// QNH (sea-level-corrected altimeter setting) reading MUST reduce it with
2715 /// `ballistics_reduce_qnh_pressure` before writing `pressure` -- this proves the reduced
2716 /// value actually reaches the solve (a materially different, and correct -- flatter,
2717 /// less-drop -- trajectory than feeding the raw, unreduced QNH straight through, which
2718 /// would silently over-state air density).
2719 #[test]
2720 fn ffi_trajectory_uses_the_reduced_pressure_not_the_raw_qnh() {
2721 let inputs = valid_trajectory_inputs();
2722 let altitude_m = 1500.0;
2723 let qnh_hpa = 1030.0;
2724 let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2725 assert!(reduced < qnh_hpa);
2726
2727 let atmo_reduced = FFIAtmosphericConditions {
2728 temperature: 15.0,
2729 pressure: reduced,
2730 humidity: 50.0,
2731 altitude: altitude_m,
2732 };
2733 let atmo_raw_qnh = FFIAtmosphericConditions {
2734 temperature: 15.0,
2735 pressure: qnh_hpa,
2736 humidity: 50.0,
2737 altitude: altitude_m,
2738 };
2739
2740 unsafe {
2741 let a = ballistics_calculate_trajectory(
2742 &inputs,
2743 std::ptr::null(),
2744 &atmo_reduced,
2745 400.0,
2746 1.0,
2747 );
2748 let b = ballistics_calculate_trajectory(
2749 &inputs,
2750 std::ptr::null(),
2751 &atmo_raw_qnh,
2752 400.0,
2753 1.0,
2754 );
2755 assert!(!a.is_null() && !b.is_null());
2756 let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2757 .last()
2758 .unwrap()
2759 .position_y;
2760 let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2761 .last()
2762 .unwrap()
2763 .position_y;
2764 assert!(
2765 (drop_a - drop_b).abs() > 1e-6,
2766 "reduced vs. raw-QNH pressure must produce materially different trajectories: \
2767 {drop_a} vs {drop_b}"
2768 );
2769 ballistics_free_trajectory_result(a);
2770 ballistics_free_trajectory_result(b);
2771 }
2772 }
2773
2774 /// Same proof as above, for the Monte Carlo FFI path (`ballistics_monte_carlo`), which
2775 /// reads `FFIAtmosphericConditions.pressure` into `BallisticInputs.pressure` directly
2776 /// (`ballistics_monte_carlo_impl`) before running the shared `run_monte_carlo_*` core.
2777 #[test]
2778 fn ffi_monte_carlo_uses_the_reduced_pressure_not_the_raw_qnh() {
2779 let inputs = valid_trajectory_inputs();
2780 let altitude_m = 1500.0;
2781 let qnh_hpa = 1030.0;
2782 let reduced = ballistics_reduce_qnh_pressure(qnh_hpa, altitude_m);
2783
2784 let atmo_reduced = FFIAtmosphericConditions {
2785 temperature: 15.0,
2786 pressure: reduced,
2787 humidity: 50.0,
2788 altitude: altitude_m,
2789 };
2790 let atmo_raw_qnh = FFIAtmosphericConditions {
2791 temperature: 15.0,
2792 pressure: qnh_hpa,
2793 humidity: 50.0,
2794 altitude: altitude_m,
2795 };
2796 let params = FFIMonteCarloParams {
2797 num_simulations: 200,
2798 velocity_std_dev: 1.0,
2799 angle_std_dev: 0.0,
2800 bc_std_dev: 0.0,
2801 wind_speed_std_dev: 0.0,
2802 target_distance: f64::NAN,
2803 base_wind_speed: 0.0,
2804 base_wind_direction: 0.0,
2805 azimuth_std_dev: 0.0,
2806 };
2807
2808 unsafe {
2809 let a = ballistics_monte_carlo(&inputs, &atmo_reduced, ¶ms);
2810 let b = ballistics_monte_carlo(&inputs, &atmo_raw_qnh, ¶ms);
2811 assert!(!a.is_null() && !b.is_null());
2812 assert!(
2813 ((*a).mean_range - (*b).mean_range).abs() > 0.5,
2814 "reduced vs. raw-QNH pressure must change MC mean range materially: \
2815 {} vs {}",
2816 (*a).mean_range,
2817 (*b).mean_range
2818 );
2819 ballistics_free_monte_carlo_results(a);
2820 ballistics_free_monte_carlo_results(b);
2821 }
2822 }
2823
2824 // ---- MBA-1366: ballistics_density_altitude_* + FFIAtmosphericConditions parity --------
2825
2826 #[test]
2827 fn density_altitude_ffi_exports_match_the_library_function() {
2828 let da_m = 1000.0 * 0.3048;
2829 let expected = crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, None);
2830 assert_eq!(
2831 ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2832 expected.0
2833 );
2834 assert_eq!(
2835 ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2836 expected.1
2837 );
2838 assert_eq!(
2839 ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE),
2840 expected.2
2841 );
2842
2843 // With no explicit temperature the resolved altitude must equal the input exactly.
2844 assert!(
2845 (ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE) - da_m)
2846 .abs()
2847 < 1e-6
2848 );
2849 }
2850
2851 #[test]
2852 fn density_altitude_ffi_explicit_temperature_is_honored_exactly() {
2853 let da_m = 500.0;
2854 let explicit_temp_c = 30.0;
2855 let expected =
2856 crate::atmosphere::resolve_atmosphere_for_density_altitude(da_m, Some(explicit_temp_c));
2857 assert_eq!(
2858 ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2859 explicit_temp_c
2860 );
2861 assert_eq!(
2862 ballistics_density_altitude_temperature_c(da_m, explicit_temp_c),
2863 expected.1
2864 );
2865 assert_eq!(
2866 ballistics_density_altitude_pressure_hpa(da_m, explicit_temp_c),
2867 expected.2
2868 );
2869 assert_eq!(
2870 ballistics_density_altitude_altitude_m(da_m, explicit_temp_c),
2871 expected.0
2872 );
2873 }
2874
2875 #[test]
2876 fn density_altitude_ffi_non_finite_input_returns_nan() {
2877 assert!(ballistics_density_altitude_temperature_c(
2878 f64::INFINITY,
2879 FFI_NO_EXPLICIT_TEMPERATURE
2880 )
2881 .is_nan());
2882 assert!(
2883 ballistics_density_altitude_pressure_hpa(f64::NAN, FFI_NO_EXPLICIT_TEMPERATURE)
2884 .is_nan()
2885 );
2886 assert!(ballistics_density_altitude_altitude_m(
2887 f64::NEG_INFINITY,
2888 FFI_NO_EXPLICIT_TEMPERATURE
2889 )
2890 .is_nan());
2891 }
2892
2893 /// Same proof shape as the QNH FFI tests above: writing the density-altitude-derived
2894 /// station values into `FFIAtmosphericConditions` before an existing trajectory export
2895 /// reaches the solve (a materially different, lower-density trajectory at a higher DA than
2896 /// at sea level, exactly like the QNH-vs-absolute divergence proof).
2897 #[test]
2898 fn ffi_trajectory_uses_the_density_altitude_derived_station_values() {
2899 let inputs = valid_trajectory_inputs();
2900 let da_m = 3000.0 * 0.3048; // 3000 ft density altitude
2901 let altitude_m = ballistics_density_altitude_altitude_m(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2902 let temperature_c =
2903 ballistics_density_altitude_temperature_c(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2904 let pressure_hpa =
2905 ballistics_density_altitude_pressure_hpa(da_m, FFI_NO_EXPLICIT_TEMPERATURE);
2906
2907 let atmo_da = FFIAtmosphericConditions {
2908 temperature: temperature_c,
2909 pressure: pressure_hpa,
2910 humidity: 50.0,
2911 altitude: altitude_m,
2912 };
2913 let atmo_sea_level = FFIAtmosphericConditions {
2914 temperature: 15.0,
2915 pressure: 1013.25,
2916 humidity: 50.0,
2917 altitude: 0.0,
2918 };
2919
2920 unsafe {
2921 let a =
2922 ballistics_calculate_trajectory(&inputs, std::ptr::null(), &atmo_da, 400.0, 1.0);
2923 let b = ballistics_calculate_trajectory(
2924 &inputs,
2925 std::ptr::null(),
2926 &atmo_sea_level,
2927 400.0,
2928 1.0,
2929 );
2930 assert!(!a.is_null() && !b.is_null());
2931 let drop_a = std::slice::from_raw_parts((*a).points, (*a).point_count as usize)
2932 .last()
2933 .unwrap()
2934 .position_y;
2935 let drop_b = std::slice::from_raw_parts((*b).points, (*b).point_count as usize)
2936 .last()
2937 .unwrap()
2938 .position_y;
2939 assert!(
2940 (drop_a - drop_b).abs() > 1e-6,
2941 "density-altitude-derived vs sea-level atmosphere must produce materially \
2942 different trajectories: {drop_a} vs {drop_b}"
2943 );
2944 ballistics_free_trajectory_result(a);
2945 ballistics_free_trajectory_result(b);
2946 }
2947 }
2948}