1use crate::{
4 calculate_zero_angle_with_conditions, run_monte_carlo_with_direction_std_dev,
5 AtmosphericConditions, BallisticInputs, DragModel, MonteCarloParams, TrajectorySolver,
6 WindConditions,
7};
8use std::os::raw::{c_char, c_double, c_int};
9use std::ptr;
10
11pub const MIN_FFI_STEP_SIZE_MS: c_double = 0.1;
16
17pub const MAX_FFI_DRAG_TABLE_LEN: c_int = 4096;
25
26#[repr(C)]
29pub struct FFIBallisticInputs {
30 pub muzzle_velocity: c_double, pub muzzle_angle: c_double, pub bc_value: c_double, pub bullet_mass: c_double, pub bullet_diameter: c_double, pub bc_type: c_int, pub sight_height: c_double, pub target_distance: c_double, pub temperature: c_double, pub twist_rate: c_double, pub is_twist_right: c_int, pub shooting_angle: c_double, pub altitude: c_double, pub latitude: c_double, pub azimuth_angle: c_double, pub use_rk4: c_int, pub use_adaptive_rk45: c_int, pub enable_wind_shear: c_int, pub enable_trajectory_sampling: c_int, pub sample_interval: c_double, pub enable_pitch_damping: c_int, pub enable_precession_nutation: c_int, pub enable_spin_drift: c_int, pub enable_magnus: c_int, pub enable_coriolis: c_int, pub shot_azimuth: c_double,
59 pub cant_angle: c_double,
63}
64
65#[repr(C)]
66pub struct FFIWindConditions {
67 pub speed: c_double, pub direction: c_double,
71 pub vertical_speed: c_double,
74}
75
76#[repr(C)]
77pub struct FFIAtmosphericConditions {
78 pub temperature: c_double, pub pressure: c_double, pub humidity: c_double, pub altitude: c_double, }
83
84#[repr(C)]
85pub struct FFITrajectorySample {
86 pub distance: c_double, pub time: c_double, pub velocity_mps: c_double, pub energy_joules: c_double, pub drop_meters: c_double, pub windage_meters: c_double, pub mach: c_double, pub spin_rate_rps: c_double, }
95
96#[repr(C)]
97pub struct FFITrajectoryPoint {
98 pub time: c_double,
99 pub position_x: c_double,
100 pub position_y: c_double,
101 pub position_z: c_double,
102 pub velocity_magnitude: c_double,
103 pub kinetic_energy: c_double,
104}
105
106#[repr(C)]
107pub struct FFITrajectoryResult {
108 pub max_range: c_double,
109 pub max_height: c_double,
110 pub time_of_flight: c_double,
111 pub impact_velocity: c_double,
112 pub impact_energy: c_double,
113 pub points: *mut FFITrajectoryPoint,
114 pub point_count: c_int,
115 pub sampled_points: *mut FFITrajectorySample,
116 pub sampled_point_count: c_int,
117 pub min_pitch_damping: c_double, pub transonic_mach: c_double, pub final_pitch_angle: c_double, pub final_yaw_angle: c_double, pub max_yaw_angle: c_double, pub max_precession_angle: c_double, }
124
125#[repr(C)]
127pub struct FFIMonteCarloParams {
128 pub num_simulations: c_int,
129 pub velocity_std_dev: c_double,
130 pub angle_std_dev: c_double,
131 pub bc_std_dev: c_double,
132 pub wind_speed_std_dev: c_double,
133 pub target_distance: c_double, pub base_wind_speed: c_double, pub base_wind_direction: c_double, pub azimuth_std_dev: c_double, }
138
139#[repr(C)]
141pub struct FFIMonteCarloResults {
142 pub ranges: *mut c_double,
143 pub impact_velocities: *mut c_double,
144 pub impact_positions_x: *mut c_double,
145 pub impact_positions_y: *mut c_double,
148 pub impact_positions_z: *mut c_double,
149 pub num_results: c_int,
150 pub mean_range: c_double,
151 pub std_dev_range: c_double,
152 pub mean_impact_velocity: c_double,
153 pub std_dev_impact_velocity: c_double,
154 pub hit_probability: c_double, }
156
157#[allow(clippy::field_reassign_with_default)] fn convert_inputs(inputs: &FFIBallisticInputs) -> BallisticInputs {
160 let mut ballistic_inputs = BallisticInputs::default();
161
162 ballistic_inputs.muzzle_velocity = inputs.muzzle_velocity;
163 ballistic_inputs.muzzle_angle = inputs.muzzle_angle;
164 ballistic_inputs.azimuth_angle = inputs.azimuth_angle;
165 ballistic_inputs.shot_azimuth = inputs.shot_azimuth;
166 ballistic_inputs.cant_angle = inputs.cant_angle;
167 ballistic_inputs.use_rk4 = inputs.use_rk4 != 0;
168 ballistic_inputs.use_adaptive_rk45 = inputs.use_adaptive_rk45 != 0;
169 ballistic_inputs.bc_value = inputs.bc_value;
170 ballistic_inputs.bullet_mass = inputs.bullet_mass;
171 ballistic_inputs.bullet_diameter = inputs.bullet_diameter;
172 ballistic_inputs.bc_type = match inputs.bc_type {
173 1 => DragModel::G7,
174 2 => DragModel::G2,
175 3 => DragModel::G5,
176 4 => DragModel::G6,
177 5 => DragModel::G8,
178 6 => DragModel::GI,
179 7 => DragModel::GS,
180 8 => DragModel::RA4,
183 _ => DragModel::G1,
184 };
185 ballistic_inputs.sight_height = inputs.sight_height;
186 ballistic_inputs.target_distance = inputs.target_distance;
187 ballistic_inputs.temperature = inputs.temperature;
188 ballistic_inputs.twist_rate = inputs.twist_rate;
189 ballistic_inputs.is_twist_right = inputs.is_twist_right != 0;
190 ballistic_inputs.shooting_angle = inputs.shooting_angle;
191 ballistic_inputs.altitude = inputs.altitude;
192
193 if !inputs.latitude.is_nan() {
194 ballistic_inputs.latitude = Some(inputs.latitude);
195 }
196
197 ballistic_inputs.caliber_inches = inputs.bullet_diameter / 0.0254;
199 ballistic_inputs.weight_grains = inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
200 ballistic_inputs.bullet_length = {
203 let est = crate::stability::estimate_bullet_length_m(
204 ballistic_inputs.bullet_diameter,
205 ballistic_inputs.bullet_mass,
206 );
207 if est > 0.0 {
208 est
209 } else {
210 ballistic_inputs.bullet_diameter * 4.5
211 }
212 };
213
214 ballistic_inputs.enable_wind_shear = inputs.enable_wind_shear != 0;
216 ballistic_inputs.enable_trajectory_sampling = inputs.enable_trajectory_sampling != 0;
217 ballistic_inputs.sample_interval = inputs.sample_interval;
218 ballistic_inputs.enable_pitch_damping = inputs.enable_pitch_damping != 0;
219 ballistic_inputs.enable_precession_nutation = inputs.enable_precession_nutation != 0;
220 ballistic_inputs.use_enhanced_spin_drift = inputs.enable_spin_drift != 0;
221 ballistic_inputs.enable_advanced_effects =
222 inputs.enable_magnus != 0 || inputs.enable_coriolis != 0;
223 ballistic_inputs.enable_magnus = inputs.enable_magnus != 0;
225 ballistic_inputs.enable_coriolis = inputs.enable_coriolis != 0;
226
227 ballistic_inputs
228}
229
230unsafe fn drag_table_from_raw(
244 mach: *const c_double,
245 cd: *const c_double,
246 len: c_int,
247) -> Result<crate::drag::DragTable, ()> {
248 if mach.is_null() || cd.is_null() || !(2..=MAX_FFI_DRAG_TABLE_LEN).contains(&len) {
249 return Err(());
250 }
251 let len = len as usize;
252 let mach = unsafe { std::slice::from_raw_parts(mach, len) }.to_vec();
253 let cd = unsafe { std::slice::from_raw_parts(cd, len) }.to_vec();
254 crate::drag::DragTable::try_new(mach, cd).map_err(|_| ())
255}
256
257unsafe fn calculate_trajectory_impl(
263 inputs: *const FFIBallisticInputs,
264 wind: *const FFIWindConditions,
265 atmosphere: *const FFIAtmosphericConditions,
266 max_range: c_double,
267 step_size: c_double,
268 custom_drag_table: Option<crate::drag::DragTable>,
269 cd_scale: c_double,
270) -> *mut FFITrajectoryResult {
271 if inputs.is_null() {
272 return ptr::null_mut();
273 }
274 if !step_size.is_finite() || step_size < MIN_FFI_STEP_SIZE_MS {
275 return ptr::null_mut();
276 }
277
278 let inputs = unsafe { &*inputs };
279 let mut ballistic_inputs = convert_inputs(inputs);
280 ballistic_inputs.custom_drag_table = custom_drag_table;
281 ballistic_inputs.cd_scale = cd_scale;
282 let twist_rate_in = ballistic_inputs.twist_rate;
283
284 let wind_conditions = if wind.is_null() {
285 WindConditions::default()
286 } else {
287 let wind = unsafe { &*wind };
288 WindConditions {
289 speed: wind.speed,
290 direction: wind.direction,
291 vertical_speed: wind.vertical_speed,
292 }
293 };
294
295 let atmospheric_conditions = if atmosphere.is_null() {
296 AtmosphericConditions::default()
297 } else {
298 let atmo = unsafe { &*atmosphere };
299 AtmosphericConditions {
300 temperature: atmo.temperature,
301 pressure: atmo.pressure,
302 humidity: atmo.humidity,
303 altitude: atmo.altitude,
304 }
305 };
306
307 let (sample_temp_c, sample_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
309 atmospheric_conditions.temperature,
310 atmospheric_conditions.pressure,
311 atmospheric_conditions.altitude,
312 );
313 let (_, sample_speed_of_sound) = crate::atmosphere::calculate_atmosphere(
314 atmospheric_conditions.altitude,
315 Some(sample_temp_c),
316 Some(sample_pressure_hpa),
317 atmospheric_conditions.humidity,
318 );
319
320 let mut solver =
321 TrajectorySolver::new(ballistic_inputs, wind_conditions, atmospheric_conditions);
322
323 solver.set_max_range(max_range);
325 solver.set_time_step(step_size / 1000.0); match solver.solve() {
328 Ok(result) => {
329 let point_count = result.points.len();
331 let points = if point_count > 0 {
332 let mut ffi_points = Vec::with_capacity(point_count);
333 for point in result.points.iter() {
334 ffi_points.push(FFITrajectoryPoint {
335 time: point.time,
336 position_x: point.position[0],
337 position_y: point.position[1],
338 position_z: point.position[2],
339 velocity_magnitude: point.velocity_magnitude,
340 kinetic_energy: point.kinetic_energy,
341 });
342 }
343 let points_ptr = ffi_points.as_mut_ptr();
344 std::mem::forget(ffi_points); points_ptr
346 } else {
347 ptr::null_mut()
348 };
349
350 let (sampled_points, sampled_point_count) =
352 if let Some(ref samples) = result.sampled_points {
353 let mut ffi_samples = Vec::with_capacity(samples.len());
354 for sample in samples {
355 ffi_samples.push(FFITrajectorySample {
356 distance: sample.distance_m,
357 time: sample.time_s,
358 velocity_mps: sample.velocity_mps,
359 energy_joules: sample.energy_j,
360 drop_meters: sample.drop_m,
361 windage_meters: sample.wind_drift_m,
362 mach: if sample_speed_of_sound > 0.0 {
363 sample.velocity_mps / sample_speed_of_sound
364 } else {
365 0.0
366 },
367 spin_rate_rps: if twist_rate_in > 0.0 {
368 sample.velocity_mps / (twist_rate_in * 0.0254)
369 } else {
370 0.0
371 },
372 });
373 }
374 let count = ffi_samples.len() as c_int;
375 let samples_ptr = ffi_samples.as_mut_ptr();
376 std::mem::forget(ffi_samples);
377 (samples_ptr, count)
378 } else {
379 (ptr::null_mut(), 0)
380 };
381
382 let (final_pitch, final_yaw, max_yaw, max_prec) =
384 if let Some(ref angular) = result.angular_state {
385 (
386 angular.pitch_angle,
387 angular.yaw_angle,
388 result.max_yaw_angle.unwrap_or(f64::NAN),
389 result.max_precession_angle.unwrap_or(f64::NAN),
390 )
391 } else {
392 (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
393 };
394
395 let ffi_result = Box::new(FFITrajectoryResult {
397 max_range: result.max_range,
398 max_height: result.max_height,
399 time_of_flight: result.time_of_flight,
400 impact_velocity: result.impact_velocity,
401 impact_energy: result.impact_energy,
402 points,
403 point_count: point_count as c_int,
404 sampled_points,
405 sampled_point_count,
406 min_pitch_damping: result.min_pitch_damping.unwrap_or(f64::NAN),
407 transonic_mach: result.transonic_mach.unwrap_or(f64::NAN),
408 final_pitch_angle: final_pitch,
409 final_yaw_angle: final_yaw,
410 max_yaw_angle: max_yaw,
411 max_precession_angle: max_prec,
412 });
413
414 Box::into_raw(ffi_result)
415 }
416 Err(_) => ptr::null_mut(),
417 }
418}
419
420#[no_mangle]
439pub unsafe extern "C" fn ballistics_calculate_trajectory(
440 inputs: *const FFIBallisticInputs,
441 wind: *const FFIWindConditions,
442 atmosphere: *const FFIAtmosphericConditions,
443 max_range: c_double,
444 step_size: c_double,
445) -> *mut FFITrajectoryResult {
446 unsafe { calculate_trajectory_impl(inputs, wind, atmosphere, max_range, step_size, None, 1.0) }
447}
448
449#[no_mangle]
470pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table(
471 inputs: *const FFIBallisticInputs,
472 wind: *const FFIWindConditions,
473 atmosphere: *const FFIAtmosphericConditions,
474 max_range: c_double,
475 step_size: c_double,
476 drag_mach: *const c_double,
477 drag_cd: *const c_double,
478 drag_table_len: c_int,
479) -> *mut FFITrajectoryResult {
480 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
481 Ok(t) => t,
482 Err(()) => return ptr::null_mut(),
483 };
484 unsafe {
485 calculate_trajectory_impl(
486 inputs, wind, atmosphere, max_range, step_size, Some(table), 1.0,
487 )
488 }
489}
490
491#[no_mangle]
508pub unsafe extern "C" fn ballistics_calculate_trajectory_with_drag_table_scaled(
509 inputs: *const FFIBallisticInputs,
510 wind: *const FFIWindConditions,
511 atmosphere: *const FFIAtmosphericConditions,
512 max_range: c_double,
513 step_size: c_double,
514 drag_mach: *const c_double,
515 drag_cd: *const c_double,
516 drag_table_len: c_int,
517 cd_scale: c_double,
518) -> *mut FFITrajectoryResult {
519 if !cd_scale.is_finite() || cd_scale <= 0.0 {
520 return ptr::null_mut();
521 }
522 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
523 Ok(t) => t,
524 Err(()) => return ptr::null_mut(),
525 };
526 unsafe {
527 calculate_trajectory_impl(
528 inputs, wind, atmosphere, max_range, step_size, Some(table), cd_scale,
529 )
530 }
531}
532
533#[no_mangle]
542pub unsafe extern "C" fn ballistics_free_trajectory_result(result: *mut FFITrajectoryResult) {
543 if !result.is_null() {
544 unsafe {
545 let result = Box::from_raw(result);
546 if !result.points.is_null() && result.point_count > 0 {
547 let points = Vec::from_raw_parts(
548 result.points,
549 result.point_count as usize,
550 result.point_count as usize,
551 );
552 drop(points);
553 }
554 if !result.sampled_points.is_null() && result.sampled_point_count > 0 {
555 let samples = Vec::from_raw_parts(
556 result.sampled_points,
557 result.sampled_point_count as usize,
558 result.sampled_point_count as usize,
559 );
560 drop(samples);
561 }
562 drop(result);
563 }
564 }
565}
566
567unsafe fn calculate_zero_angle_impl(
574 inputs: *const FFIBallisticInputs,
575 wind: *const FFIWindConditions,
576 atmosphere: *const FFIAtmosphericConditions,
577 zero_distance: c_double,
578 custom_drag_table: Option<crate::drag::DragTable>,
579 cd_scale: c_double,
580) -> c_double {
581 if inputs.is_null() {
582 return f64::NAN;
583 }
584
585 let inputs = unsafe { &*inputs };
586 let mut ballistic_inputs = convert_inputs(inputs);
587 ballistic_inputs.custom_drag_table = custom_drag_table;
588 ballistic_inputs.cd_scale = cd_scale;
589
590 let wind_conditions = if wind.is_null() {
591 WindConditions::default()
592 } else {
593 let wind = unsafe { &*wind };
594 WindConditions {
595 speed: wind.speed,
596 direction: wind.direction,
597 vertical_speed: wind.vertical_speed,
598 }
599 };
600
601 let atmospheric_conditions = if atmosphere.is_null() {
602 AtmosphericConditions::default()
603 } else {
604 let atmo = unsafe { &*atmosphere };
605 AtmosphericConditions {
606 temperature: atmo.temperature,
607 pressure: atmo.pressure,
608 humidity: atmo.humidity,
609 altitude: atmo.altitude,
610 }
611 };
612
613 let target_height = ballistic_inputs.sight_height;
616
617 calculate_zero_angle_with_conditions(
618 ballistic_inputs,
619 zero_distance,
620 target_height,
621 wind_conditions,
622 atmospheric_conditions,
623 )
624 .unwrap_or(f64::NAN)
625}
626
627#[no_mangle]
637pub unsafe extern "C" fn ballistics_calculate_zero_angle(
638 inputs: *const FFIBallisticInputs,
639 wind: *const FFIWindConditions,
640 atmosphere: *const FFIAtmosphericConditions,
641 zero_distance: c_double,
642) -> c_double {
643 unsafe { calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, None, 1.0) }
644}
645
646#[no_mangle]
668pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table(
669 inputs: *const FFIBallisticInputs,
670 wind: *const FFIWindConditions,
671 atmosphere: *const FFIAtmosphericConditions,
672 zero_distance: c_double,
673 drag_mach: *const c_double,
674 drag_cd: *const c_double,
675 drag_table_len: c_int,
676) -> c_double {
677 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
678 Ok(t) => t,
679 Err(()) => return f64::NAN,
680 };
681 unsafe {
682 calculate_zero_angle_impl(inputs, wind, atmosphere, zero_distance, Some(table), 1.0)
683 }
684}
685
686#[no_mangle]
704pub unsafe extern "C" fn ballistics_calculate_zero_angle_with_drag_table_scaled(
705 inputs: *const FFIBallisticInputs,
706 wind: *const FFIWindConditions,
707 atmosphere: *const FFIAtmosphericConditions,
708 zero_distance: c_double,
709 drag_mach: *const c_double,
710 drag_cd: *const c_double,
711 drag_table_len: c_int,
712 cd_scale: c_double,
713) -> c_double {
714 if !cd_scale.is_finite() || cd_scale <= 0.0 {
715 return f64::NAN;
716 }
717 let table = match unsafe { drag_table_from_raw(drag_mach, drag_cd, drag_table_len) } {
718 Ok(t) => t,
719 Err(()) => return f64::NAN,
720 };
721 unsafe {
722 calculate_zero_angle_impl(
723 inputs,
724 wind,
725 atmosphere,
726 zero_distance,
727 Some(table),
728 cd_scale,
729 )
730 }
731}
732
733#[no_mangle]
735#[allow(clippy::field_reassign_with_default)] pub extern "C" fn ballistics_quick_trajectory(
737 muzzle_velocity: c_double,
738 bc: c_double,
739 sight_height: c_double,
740 zero_distance: c_double,
741 target_distance: c_double,
742) -> c_double {
743 let mut inputs = BallisticInputs::default();
747 inputs.muzzle_velocity = muzzle_velocity;
748 inputs.bc_value = bc;
749 inputs.sight_height = sight_height;
750 inputs.target_distance = target_distance;
751
752 let wind = WindConditions::default();
753 let atmo = AtmosphericConditions::default();
754
755 let zero_angle = match calculate_zero_angle_with_conditions(
757 inputs.clone(),
758 zero_distance,
759 sight_height,
760 wind.clone(),
761 atmo.clone(),
762 ) {
763 Ok(angle) => angle,
764 Err(_) => return f64::NAN,
765 };
766
767 inputs.muzzle_angle = zero_angle;
769
770 let mut solver = TrajectorySolver::new(inputs, wind, atmo);
771 solver.set_max_range(target_distance * 1.1);
772
773 match solver.solve() {
774 Ok(result) => {
775 for point in result.points {
777 if point.position[0] >= target_distance {
778 return sight_height - point.position[1];
779 }
780 }
781 f64::NAN
782 }
783 Err(_) => f64::NAN,
784 }
785}
786
787#[no_mangle]
799pub unsafe extern "C" fn ballistics_monte_carlo(
800 inputs: *const FFIBallisticInputs,
801 atmosphere: *const FFIAtmosphericConditions,
802 params: *const FFIMonteCarloParams,
803) -> *mut FFIMonteCarloResults {
804 unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, 0.0) }
805}
806
807#[no_mangle]
817pub unsafe extern "C" fn ballistics_monte_carlo_with_direction_std_dev(
818 inputs: *const FFIBallisticInputs,
819 atmosphere: *const FFIAtmosphericConditions,
820 params: *const FFIMonteCarloParams,
821 wind_direction_std_dev: c_double,
822) -> *mut FFIMonteCarloResults {
823 unsafe { ballistics_monte_carlo_impl(inputs, atmosphere, params, wind_direction_std_dev) }
824}
825
826unsafe fn ballistics_monte_carlo_impl(
827 inputs: *const FFIBallisticInputs,
828 atmosphere: *const FFIAtmosphericConditions,
829 params: *const FFIMonteCarloParams,
830 wind_direction_std_dev: f64,
831) -> *mut FFIMonteCarloResults {
832 if inputs.is_null() || params.is_null() {
833 return ptr::null_mut();
834 }
835
836 let inputs = unsafe { &*inputs };
837 let params = unsafe { &*params };
838
839 const MAX_SIMULATIONS: c_int = 1_000_000;
845 if params.num_simulations <= 0 || params.num_simulations > MAX_SIMULATIONS {
846 return ptr::null_mut();
847 }
848
849 let mut ballistic_inputs = convert_inputs(inputs);
851 ballistic_inputs.muzzle_height = 1.5;
852 ballistic_inputs.ground_threshold = 0.0;
853 if !atmosphere.is_null() {
854 let atmo = unsafe { &*atmosphere };
855 ballistic_inputs.temperature = atmo.temperature;
856 ballistic_inputs.pressure = atmo.pressure;
857 ballistic_inputs.humidity = (atmo.humidity / 100.0).clamp(0.0, 1.0);
858 ballistic_inputs.altitude = atmo.altitude;
859 }
860
861 let mc_params = MonteCarloParams {
863 num_simulations: params.num_simulations as usize,
864 velocity_std_dev: params.velocity_std_dev,
865 angle_std_dev: params.angle_std_dev,
866 bc_std_dev: params.bc_std_dev,
867 wind_speed_std_dev: params.wind_speed_std_dev,
868 target_distance: if params.target_distance.is_nan() {
869 None
870 } else {
871 Some(params.target_distance)
872 },
873 base_wind_speed: params.base_wind_speed,
874 base_wind_direction: params.base_wind_direction,
875 azimuth_std_dev: params.azimuth_std_dev,
876 };
877
878 match run_monte_carlo_with_direction_std_dev(
880 ballistic_inputs,
881 mc_params,
882 wind_direction_std_dev,
883 ) {
884 Ok(results) => {
885 let num_results = results.ranges.len() as c_int;
886
887 let mean_range: f64 = results.ranges.iter().sum::<f64>() / num_results as f64;
889 let variance_range: f64 = results
890 .ranges
891 .iter()
892 .map(|r| (r - mean_range).powi(2))
893 .sum::<f64>()
894 / num_results as f64;
895 let std_dev_range = variance_range.sqrt();
896
897 let mean_velocity: f64 =
898 results.impact_velocities.iter().sum::<f64>() / num_results as f64;
899 let variance_velocity: f64 = results
900 .impact_velocities
901 .iter()
902 .map(|v| (v - mean_velocity).powi(2))
903 .sum::<f64>()
904 / num_results as f64;
905 let std_dev_velocity = variance_velocity.sqrt();
906
907 let hit_probability = if params.target_distance.is_nan() {
913 0.0
914 } else {
915 results.hit_probability(crate::DEFAULT_HIT_RADIUS_M)
916 };
917
918 let ranges_ptr = unsafe {
920 let ptr = std::alloc::alloc(
921 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
922 ) as *mut c_double;
923 for (i, &range) in results.ranges.iter().enumerate() {
924 *ptr.add(i) = range;
925 }
926 ptr
927 };
928
929 let velocities_ptr = unsafe {
930 let ptr = std::alloc::alloc(
931 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
932 ) as *mut c_double;
933 for (i, &vel) in results.impact_velocities.iter().enumerate() {
934 *ptr.add(i) = vel;
935 }
936 ptr
937 };
938
939 let pos_x_ptr = unsafe {
940 let ptr = std::alloc::alloc(
941 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
942 ) as *mut c_double;
943 for (i, pos) in results.impact_positions.iter().enumerate() {
944 *ptr.add(i) = pos.x;
945 }
946 ptr
947 };
948
949 let pos_y_ptr = unsafe {
950 let ptr = std::alloc::alloc(
951 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
952 ) as *mut c_double;
953 for (i, pos) in results.impact_positions.iter().enumerate() {
954 *ptr.add(i) = pos.y;
955 }
956 ptr
957 };
958
959 let pos_z_ptr = unsafe {
960 let ptr = std::alloc::alloc(
961 std::alloc::Layout::array::<c_double>(num_results as usize).unwrap(),
962 ) as *mut c_double;
963 for (i, pos) in results.impact_positions.iter().enumerate() {
964 *ptr.add(i) = pos.z;
965 }
966 ptr
967 };
968
969 let result = Box::new(FFIMonteCarloResults {
971 ranges: ranges_ptr,
972 impact_velocities: velocities_ptr,
973 impact_positions_x: pos_x_ptr,
974 impact_positions_y: pos_y_ptr,
975 impact_positions_z: pos_z_ptr,
976 num_results,
977 mean_range,
978 std_dev_range,
979 mean_impact_velocity: mean_velocity,
980 std_dev_impact_velocity: std_dev_velocity,
981 hit_probability,
982 });
983
984 Box::into_raw(result)
985 }
986 Err(_) => ptr::null_mut(),
987 }
988}
989
990#[no_mangle]
999pub unsafe extern "C" fn ballistics_free_monte_carlo_results(results: *mut FFIMonteCarloResults) {
1000 if results.is_null() {
1001 return;
1002 }
1003
1004 unsafe {
1005 let results = Box::from_raw(results);
1006 let num = results.num_results as usize;
1007
1008 if !results.ranges.is_null() {
1010 std::alloc::dealloc(
1011 results.ranges as *mut u8,
1012 std::alloc::Layout::array::<c_double>(num).unwrap(),
1013 );
1014 }
1015
1016 if !results.impact_velocities.is_null() {
1017 std::alloc::dealloc(
1018 results.impact_velocities as *mut u8,
1019 std::alloc::Layout::array::<c_double>(num).unwrap(),
1020 );
1021 }
1022
1023 if !results.impact_positions_x.is_null() {
1024 std::alloc::dealloc(
1025 results.impact_positions_x as *mut u8,
1026 std::alloc::Layout::array::<c_double>(num).unwrap(),
1027 );
1028 }
1029
1030 if !results.impact_positions_y.is_null() {
1031 std::alloc::dealloc(
1032 results.impact_positions_y as *mut u8,
1033 std::alloc::Layout::array::<c_double>(num).unwrap(),
1034 );
1035 }
1036
1037 if !results.impact_positions_z.is_null() {
1038 std::alloc::dealloc(
1039 results.impact_positions_z as *mut u8,
1040 std::alloc::Layout::array::<c_double>(num).unwrap(),
1041 );
1042 }
1043
1044 }
1046}
1047
1048#[no_mangle]
1050pub extern "C" fn ballistics_get_version() -> *const c_char {
1051 concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 fn valid_trajectory_inputs() -> FFIBallisticInputs {
1062 FFIBallisticInputs {
1063 muzzle_velocity: 800.0,
1064 muzzle_angle: 0.0,
1065 bc_value: 0.5,
1066 bullet_mass: 0.01,
1067 bullet_diameter: 0.00762,
1068 bc_type: 0,
1069 sight_height: 0.05,
1070 target_distance: 1.0,
1071 temperature: 15.0,
1072 twist_rate: 12.0,
1073 is_twist_right: 1,
1074 shooting_angle: 0.0,
1075 altitude: 0.0,
1076 latitude: f64::NAN,
1077 azimuth_angle: 0.0,
1078 use_rk4: 1,
1079 use_adaptive_rk45: 0,
1080 enable_wind_shear: 0,
1081 enable_trajectory_sampling: 0,
1082 sample_interval: 10.0,
1083 enable_pitch_damping: 0,
1084 enable_precession_nutation: 0,
1085 enable_spin_drift: 0,
1086 enable_magnus: 0,
1087 enable_coriolis: 0,
1088 shot_azimuth: 0.0,
1089 cant_angle: 0.0,
1090 }
1091 }
1092
1093 #[allow(dead_code)]
1094 #[repr(C)]
1095 struct LegacyFFIMonteCarloParams {
1096 num_simulations: c_int,
1097 velocity_std_dev: c_double,
1098 angle_std_dev: c_double,
1099 bc_std_dev: c_double,
1100 wind_speed_std_dev: c_double,
1101 target_distance: c_double,
1102 base_wind_speed: c_double,
1103 base_wind_direction: c_double,
1104 azimuth_std_dev: c_double,
1105 }
1106
1107 #[test]
1108 fn monte_carlo_params_legacy_abi_size_is_unchanged() {
1109 assert_eq!(
1110 std::mem::size_of::<FFIMonteCarloParams>(),
1111 std::mem::size_of::<LegacyFFIMonteCarloParams>()
1112 );
1113 assert_eq!(
1114 std::mem::align_of::<FFIMonteCarloParams>(),
1115 std::mem::align_of::<LegacyFFIMonteCarloParams>()
1116 );
1117 }
1118
1119 #[test]
1124 fn bc_type_8_maps_to_ra4() {
1125 let mut inputs = valid_trajectory_inputs();
1126 inputs.bc_type = 8;
1127 assert_eq!(convert_inputs(&inputs).bc_type, DragModel::RA4);
1128
1129 let expected = [
1131 (0, DragModel::G1),
1132 (1, DragModel::G7),
1133 (2, DragModel::G2),
1134 (3, DragModel::G5),
1135 (4, DragModel::G6),
1136 (5, DragModel::G8),
1137 (6, DragModel::GI),
1138 (7, DragModel::GS),
1139 ];
1140 for (code, model) in expected {
1141 let mut inputs = valid_trajectory_inputs();
1142 inputs.bc_type = code;
1143 assert_eq!(convert_inputs(&inputs).bc_type, model, "code {code}");
1144 }
1145
1146 for code in [9, 42, -1] {
1148 let mut inputs = valid_trajectory_inputs();
1149 inputs.bc_type = code;
1150 assert_eq!(convert_inputs(&inputs).bc_type, DragModel::G1, "code {code}");
1151 }
1152 }
1153
1154 #[test]
1155 fn null_pointer_contracts_return_sentinels_and_free_safely() {
1156 unsafe {
1157 assert!(ballistics_calculate_trajectory(
1158 std::ptr::null(),
1159 std::ptr::null(),
1160 std::ptr::null(),
1161 1_000.0,
1162 1.0,
1163 )
1164 .is_null());
1165 assert!(ballistics_calculate_zero_angle(
1166 std::ptr::null(),
1167 std::ptr::null(),
1168 std::ptr::null(),
1169 100.0,
1170 )
1171 .is_nan());
1172 assert!(ballistics_calculate_trajectory_with_drag_table(
1173 std::ptr::null(),
1174 std::ptr::null(),
1175 std::ptr::null(),
1176 1_000.0,
1177 1.0,
1178 DECK_MACH.as_ptr(),
1179 DECK_CD_LOW.as_ptr(),
1180 DECK_MACH.len() as c_int,
1181 )
1182 .is_null());
1183 assert!(ballistics_calculate_zero_angle_with_drag_table(
1184 std::ptr::null(),
1185 std::ptr::null(),
1186 std::ptr::null(),
1187 100.0,
1188 DECK_MACH.as_ptr(),
1189 DECK_CD_LOW.as_ptr(),
1190 DECK_MACH.len() as c_int,
1191 )
1192 .is_nan());
1193 assert!(
1194 ballistics_monte_carlo(std::ptr::null(), std::ptr::null(), std::ptr::null(),)
1195 .is_null()
1196 );
1197 assert!(ballistics_monte_carlo_with_direction_std_dev(
1198 std::ptr::null(),
1199 std::ptr::null(),
1200 std::ptr::null(),
1201 0.1,
1202 )
1203 .is_null());
1204
1205 ballistics_free_trajectory_result(std::ptr::null_mut());
1206 ballistics_free_monte_carlo_results(std::ptr::null_mut());
1207 }
1208 }
1209
1210 #[test]
1211 fn mba1283_ffi_enforces_step_floor_for_every_solver_mode() {
1212 for (mode, use_rk4, use_adaptive_rk45) in [("Euler", 0, 0), ("RK4", 1, 0), ("RK45", 1, 1)] {
1213 for step_size in [
1214 f64::NAN,
1215 f64::INFINITY,
1216 f64::NEG_INFINITY,
1217 -1.0,
1218 -0.0,
1219 0.0,
1220 0.001,
1221 MIN_FFI_STEP_SIZE_MS - 0.001,
1222 ] {
1223 let mut inputs = valid_trajectory_inputs();
1224 inputs.use_rk4 = use_rk4;
1225 inputs.use_adaptive_rk45 = use_adaptive_rk45;
1226 let result = unsafe {
1227 ballistics_calculate_trajectory(
1228 &inputs,
1229 std::ptr::null(),
1230 std::ptr::null(),
1231 0.01,
1232 step_size,
1233 )
1234 };
1235 assert!(
1236 result.is_null(),
1237 "{mode} step_size={step_size:?} bypassed the FFI floor"
1238 );
1239 }
1240
1241 let mut inputs = valid_trajectory_inputs();
1242 inputs.use_rk4 = use_rk4;
1243 inputs.use_adaptive_rk45 = use_adaptive_rk45;
1244 let result = unsafe {
1245 ballistics_calculate_trajectory(
1246 &inputs,
1247 std::ptr::null(),
1248 std::ptr::null(),
1249 0.01,
1250 MIN_FFI_STEP_SIZE_MS,
1251 )
1252 };
1253 assert!(
1254 !result.is_null(),
1255 "the documented minimum step must remain usable in {mode}"
1256 );
1257 unsafe {
1258 assert!((*result).point_count >= 0);
1259 assert!((*result).point_count as usize <= crate::MAX_TRAJECTORY_POINTS);
1260 ballistics_free_trajectory_result(result);
1261 }
1262 }
1263 }
1264
1265 const DECK_MACH: [f64; 4] = [0.5, 1.0, 2.0, 3.0];
1267 const DECK_CD_LOW: [f64; 4] = [0.05, 0.08, 0.06, 0.05];
1269
1270 #[test]
1271 fn trajectory_with_drag_table_applies_the_deck() {
1272 let inputs = valid_trajectory_inputs();
1273 unsafe {
1274 let plain = ballistics_calculate_trajectory(
1275 &inputs,
1276 std::ptr::null(),
1277 std::ptr::null(),
1278 300.0,
1279 1.0,
1280 );
1281 let decked = ballistics_calculate_trajectory_with_drag_table(
1282 &inputs,
1283 std::ptr::null(),
1284 std::ptr::null(),
1285 300.0,
1286 1.0,
1287 DECK_MACH.as_ptr(),
1288 DECK_CD_LOW.as_ptr(),
1289 DECK_MACH.len() as c_int,
1290 );
1291 assert!(!plain.is_null() && !decked.is_null());
1292 assert!(
1294 (*decked).impact_velocity > (*plain).impact_velocity + 1.0,
1295 "deck did not change the solve: plain={} decked={}",
1296 (*plain).impact_velocity,
1297 (*decked).impact_velocity
1298 );
1299 ballistics_free_trajectory_result(plain);
1300 ballistics_free_trajectory_result(decked);
1301 }
1302 }
1303
1304 #[test]
1305 fn trajectory_with_drag_table_rejects_invalid_decks() {
1306 let inputs = valid_trajectory_inputs();
1307 let descending = [3.0, 2.0, 1.0, 0.5];
1308 let negative_cd = [0.05, -0.08, 0.06, 0.05];
1309 unsafe {
1310 assert!(ballistics_calculate_trajectory_with_drag_table(
1312 &inputs,
1313 std::ptr::null(),
1314 std::ptr::null(),
1315 300.0,
1316 1.0,
1317 std::ptr::null(),
1318 DECK_CD_LOW.as_ptr(),
1319 4,
1320 )
1321 .is_null());
1322 assert!(ballistics_calculate_trajectory_with_drag_table(
1323 &inputs,
1324 std::ptr::null(),
1325 std::ptr::null(),
1326 300.0,
1327 1.0,
1328 DECK_MACH.as_ptr(),
1329 std::ptr::null(),
1330 4,
1331 )
1332 .is_null());
1333 assert!(ballistics_calculate_trajectory_with_drag_table(
1335 &inputs,
1336 std::ptr::null(),
1337 std::ptr::null(),
1338 300.0,
1339 1.0,
1340 DECK_MACH.as_ptr(),
1341 DECK_CD_LOW.as_ptr(),
1342 1,
1343 )
1344 .is_null());
1345 assert!(ballistics_calculate_trajectory_with_drag_table(
1347 &inputs,
1348 std::ptr::null(),
1349 std::ptr::null(),
1350 300.0,
1351 1.0,
1352 descending.as_ptr(),
1353 DECK_CD_LOW.as_ptr(),
1354 4,
1355 )
1356 .is_null());
1357 assert!(ballistics_calculate_trajectory_with_drag_table(
1359 &inputs,
1360 std::ptr::null(),
1361 std::ptr::null(),
1362 300.0,
1363 1.0,
1364 DECK_MACH.as_ptr(),
1365 negative_cd.as_ptr(),
1366 4,
1367 )
1368 .is_null());
1369 assert!(ballistics_calculate_trajectory_with_drag_table(
1371 std::ptr::null(),
1372 std::ptr::null(),
1373 std::ptr::null(),
1374 300.0,
1375 1.0,
1376 DECK_MACH.as_ptr(),
1377 DECK_CD_LOW.as_ptr(),
1378 4,
1379 )
1380 .is_null());
1381 }
1382 }
1383
1384 #[test]
1385 fn zero_angle_with_drag_table_applies_the_deck() {
1386 let inputs = valid_trajectory_inputs();
1388 unsafe {
1389 let plain =
1390 ballistics_calculate_zero_angle(&inputs, std::ptr::null(), std::ptr::null(), 100.0);
1391 let decked = ballistics_calculate_zero_angle_with_drag_table(
1392 &inputs,
1393 std::ptr::null(),
1394 std::ptr::null(),
1395 100.0,
1396 DECK_MACH.as_ptr(),
1397 DECK_CD_LOW.as_ptr(),
1398 DECK_MACH.len() as c_int,
1399 );
1400 assert!(plain.is_finite() && decked.is_finite());
1401 assert!(
1404 (plain - decked).abs() > 1e-6,
1405 "deck did not change the zero: plain={plain} decked={decked}"
1406 );
1407 }
1408 }
1409
1410 #[test]
1411 fn zero_angle_with_drag_table_rejects_invalid_decks() {
1412 let inputs = valid_trajectory_inputs();
1413 let descending = [3.0, 2.0, 1.0, 0.5];
1414 unsafe {
1415 assert!(ballistics_calculate_zero_angle_with_drag_table(
1416 &inputs,
1417 std::ptr::null(),
1418 std::ptr::null(),
1419 100.0,
1420 std::ptr::null(),
1421 DECK_CD_LOW.as_ptr(),
1422 4,
1423 )
1424 .is_nan());
1425 assert!(ballistics_calculate_zero_angle_with_drag_table(
1426 &inputs,
1427 std::ptr::null(),
1428 std::ptr::null(),
1429 100.0,
1430 DECK_MACH.as_ptr(),
1431 DECK_CD_LOW.as_ptr(),
1432 0,
1433 )
1434 .is_nan());
1435 assert!(ballistics_calculate_zero_angle_with_drag_table(
1436 &inputs,
1437 std::ptr::null(),
1438 std::ptr::null(),
1439 100.0,
1440 descending.as_ptr(),
1441 DECK_CD_LOW.as_ptr(),
1442 4,
1443 )
1444 .is_nan());
1445 assert!(ballistics_calculate_zero_angle_with_drag_table(
1447 std::ptr::null(),
1448 std::ptr::null(),
1449 std::ptr::null(),
1450 100.0,
1451 DECK_MACH.as_ptr(),
1452 DECK_CD_LOW.as_ptr(),
1453 4,
1454 )
1455 .is_nan());
1456 }
1457 }
1458
1459 #[test]
1460 fn zero_then_fly_with_same_deck_is_consistent() {
1461 let mut inputs = valid_trajectory_inputs();
1465 unsafe {
1466 let angle = ballistics_calculate_zero_angle_with_drag_table(
1467 &inputs,
1468 std::ptr::null(),
1469 std::ptr::null(),
1470 100.0,
1471 DECK_MACH.as_ptr(),
1472 DECK_CD_LOW.as_ptr(),
1473 DECK_MACH.len() as c_int,
1474 );
1475 assert!(angle.is_finite());
1476 inputs.muzzle_angle = angle;
1477 let result = ballistics_calculate_trajectory_with_drag_table(
1478 &inputs,
1479 std::ptr::null(),
1480 std::ptr::null(),
1481 150.0,
1482 1.0,
1483 DECK_MACH.as_ptr(),
1484 DECK_CD_LOW.as_ptr(),
1485 DECK_MACH.len() as c_int,
1486 );
1487 assert!(!result.is_null());
1488 let zero_distance = 100.0;
1492 let pts = std::slice::from_raw_parts((*result).points, (*result).point_count as usize);
1493 let bracket = pts
1494 .windows(2)
1495 .find(|w| w[0].position_x <= zero_distance && w[1].position_x >= zero_distance)
1496 .expect("trajectory brackets the zero distance");
1497 let (lo, hi) = (&bracket[0], &bracket[1]);
1498 let y_at_zero = if hi.position_x > lo.position_x {
1499 let t = (zero_distance - lo.position_x) / (hi.position_x - lo.position_x);
1500 lo.position_y + t * (hi.position_y - lo.position_y)
1501 } else {
1502 lo.position_y
1503 };
1504 assert!(
1505 (y_at_zero - inputs.sight_height).abs() < 0.002,
1506 "zeroed flight missed the line of sight at 100 m: y={} (sight_height={})",
1507 y_at_zero,
1508 inputs.sight_height
1509 );
1510 ballistics_free_trajectory_result(result);
1511 }
1512 }
1513
1514 #[test]
1515 fn drag_table_len_above_cap_is_rejected() {
1516 let n = (MAX_FFI_DRAG_TABLE_LEN + 1) as usize;
1519 let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1520 let cd: Vec<f64> = vec![0.3; n];
1521 let inputs = valid_trajectory_inputs();
1522 unsafe {
1523 let r = ballistics_calculate_trajectory_with_drag_table(
1524 &inputs,
1525 std::ptr::null(),
1526 std::ptr::null(),
1527 300.0,
1528 1.0,
1529 mach.as_ptr(),
1530 cd.as_ptr(),
1531 n as c_int,
1532 );
1533 assert!(r.is_null(), "len {n} must be rejected by the cap");
1534 }
1535 }
1536
1537 #[test]
1538 fn drag_table_len_at_cap_is_accepted() {
1539 let n = MAX_FFI_DRAG_TABLE_LEN as usize;
1540 let mach: Vec<f64> = (0..n).map(|i| 0.01 + i as f64 * 0.001).collect();
1541 let cd: Vec<f64> = vec![0.3; n];
1542 let inputs = valid_trajectory_inputs();
1543 unsafe {
1544 let r = ballistics_calculate_trajectory_with_drag_table(
1545 &inputs,
1546 std::ptr::null(),
1547 std::ptr::null(),
1548 300.0,
1549 1.0,
1550 mach.as_ptr(),
1551 cd.as_ptr(),
1552 n as c_int,
1553 );
1554 assert!(!r.is_null(), "len == cap must be accepted");
1555 ballistics_free_trajectory_result(r);
1556 }
1557 }
1558
1559 #[test]
1560 fn ffi_cant_angle_deflects_laterally() {
1561 let mut level = valid_trajectory_inputs();
1562 level.muzzle_angle = 0.003;
1563 let mut canted = valid_trajectory_inputs();
1564 canted.muzzle_angle = 0.003;
1565 canted.cant_angle = 10f64.to_radians();
1566 unsafe {
1567 let a = ballistics_calculate_trajectory(&level, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1568 let b = ballistics_calculate_trajectory(&canted, std::ptr::null(), std::ptr::null(), 400.0, 1.0);
1569 assert!(!a.is_null() && !b.is_null());
1570 let za = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_z;
1571 let zb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_z;
1572 assert!(zb > za + 0.005, "FFI cant must deflect right: level={za} canted={zb}");
1573 ballistics_free_trajectory_result(a);
1574 ballistics_free_trajectory_result(b);
1575 }
1576 }
1577
1578 #[test]
1579 fn ffi_vertical_wind_raises_trajectory() {
1580 let inputs = valid_trajectory_inputs();
1581 let no_wind = FFIWindConditions {
1582 speed: 0.0,
1583 direction: 0.0,
1584 vertical_speed: 0.0,
1585 };
1586 let updraft = FFIWindConditions {
1587 speed: 0.0,
1588 direction: 0.0,
1589 vertical_speed: 5.0,
1590 };
1591 unsafe {
1592 let a = ballistics_calculate_trajectory(&inputs, &no_wind, std::ptr::null(), 400.0, 1.0);
1593 let b = ballistics_calculate_trajectory(&inputs, &updraft, std::ptr::null(), 400.0, 1.0);
1594 assert!(!a.is_null() && !b.is_null());
1595 let ya = std::slice::from_raw_parts((*a).points, (*a).point_count as usize).last().unwrap().position_y;
1596 let yb = std::slice::from_raw_parts((*b).points, (*b).point_count as usize).last().unwrap().position_y;
1597 assert!(yb > ya + 0.01, "FFI updraft must raise the trajectory: no_wind={ya} updraft={yb}");
1598 ballistics_free_trajectory_result(a);
1599 ballistics_free_trajectory_result(b);
1600 }
1601 }
1602
1603 #[test]
1606 fn trajectory_scaled_at_one_matches_unscaled_export() {
1607 let inputs = valid_trajectory_inputs();
1608 unsafe {
1609 let unscaled = ballistics_calculate_trajectory_with_drag_table(
1610 &inputs,
1611 std::ptr::null(),
1612 std::ptr::null(),
1613 300.0,
1614 1.0,
1615 DECK_MACH.as_ptr(),
1616 DECK_CD_LOW.as_ptr(),
1617 DECK_MACH.len() as c_int,
1618 );
1619 let scaled = ballistics_calculate_trajectory_with_drag_table_scaled(
1620 &inputs,
1621 std::ptr::null(),
1622 std::ptr::null(),
1623 300.0,
1624 1.0,
1625 DECK_MACH.as_ptr(),
1626 DECK_CD_LOW.as_ptr(),
1627 DECK_MACH.len() as c_int,
1628 1.0,
1629 );
1630 assert!(!unscaled.is_null() && !scaled.is_null());
1631 assert_eq!(
1632 (*unscaled).impact_velocity.to_bits(),
1633 (*scaled).impact_velocity.to_bits(),
1634 "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={} scaled={}",
1635 (*unscaled).impact_velocity,
1636 (*scaled).impact_velocity
1637 );
1638 ballistics_free_trajectory_result(unscaled);
1639 ballistics_free_trajectory_result(scaled);
1640 }
1641 }
1642
1643 #[test]
1644 fn trajectory_scaled_at_1_10_lowers_impact_velocity() {
1645 let inputs = valid_trajectory_inputs();
1646 unsafe {
1647 let baseline = ballistics_calculate_trajectory_with_drag_table_scaled(
1648 &inputs,
1649 std::ptr::null(),
1650 std::ptr::null(),
1651 300.0,
1652 1.0,
1653 DECK_MACH.as_ptr(),
1654 DECK_CD_LOW.as_ptr(),
1655 DECK_MACH.len() as c_int,
1656 1.0,
1657 );
1658 let scaled_up = ballistics_calculate_trajectory_with_drag_table_scaled(
1659 &inputs,
1660 std::ptr::null(),
1661 std::ptr::null(),
1662 300.0,
1663 1.0,
1664 DECK_MACH.as_ptr(),
1665 DECK_CD_LOW.as_ptr(),
1666 DECK_MACH.len() as c_int,
1667 1.10,
1668 );
1669 assert!(!baseline.is_null() && !scaled_up.is_null());
1670 assert!(
1671 (*scaled_up).impact_velocity < (*baseline).impact_velocity,
1672 "cd_scale=1.10 must increase drag -> lower impact velocity: base={} scaled={}",
1673 (*baseline).impact_velocity,
1674 (*scaled_up).impact_velocity
1675 );
1676 ballistics_free_trajectory_result(baseline);
1677 ballistics_free_trajectory_result(scaled_up);
1678 }
1679 }
1680
1681 #[test]
1682 fn trajectory_scaled_rejects_invalid_cd_scale() {
1683 let inputs = valid_trajectory_inputs();
1684 unsafe {
1685 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1686 let r = ballistics_calculate_trajectory_with_drag_table_scaled(
1687 &inputs,
1688 std::ptr::null(),
1689 std::ptr::null(),
1690 300.0,
1691 1.0,
1692 DECK_MACH.as_ptr(),
1693 DECK_CD_LOW.as_ptr(),
1694 DECK_MACH.len() as c_int,
1695 bad,
1696 );
1697 assert!(r.is_null(), "cd_scale={bad} must be rejected (null sentinel)");
1698 }
1699 }
1700 }
1701
1702 #[test]
1703 fn zero_angle_scaled_at_one_matches_unscaled_export() {
1704 let inputs = valid_trajectory_inputs();
1705 unsafe {
1706 let unscaled = ballistics_calculate_zero_angle_with_drag_table(
1707 &inputs,
1708 std::ptr::null(),
1709 std::ptr::null(),
1710 100.0,
1711 DECK_MACH.as_ptr(),
1712 DECK_CD_LOW.as_ptr(),
1713 DECK_MACH.len() as c_int,
1714 );
1715 let scaled = ballistics_calculate_zero_angle_with_drag_table_scaled(
1716 &inputs,
1717 std::ptr::null(),
1718 std::ptr::null(),
1719 100.0,
1720 DECK_MACH.as_ptr(),
1721 DECK_CD_LOW.as_ptr(),
1722 DECK_MACH.len() as c_int,
1723 1.0,
1724 );
1725 assert!(unscaled.is_finite() && scaled.is_finite());
1726 assert_eq!(
1727 unscaled.to_bits(),
1728 scaled.to_bits(),
1729 "cd_scale=1.0 must be byte-identical to the unscaled export: unscaled={unscaled} scaled={scaled}"
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn zero_angle_scaled_at_1_10_differs_from_baseline() {
1736 let inputs = valid_trajectory_inputs();
1737 unsafe {
1738 let baseline = ballistics_calculate_zero_angle_with_drag_table_scaled(
1739 &inputs,
1740 std::ptr::null(),
1741 std::ptr::null(),
1742 100.0,
1743 DECK_MACH.as_ptr(),
1744 DECK_CD_LOW.as_ptr(),
1745 DECK_MACH.len() as c_int,
1746 1.0,
1747 );
1748 let scaled_up = ballistics_calculate_zero_angle_with_drag_table_scaled(
1749 &inputs,
1750 std::ptr::null(),
1751 std::ptr::null(),
1752 100.0,
1753 DECK_MACH.as_ptr(),
1754 DECK_CD_LOW.as_ptr(),
1755 DECK_MACH.len() as c_int,
1756 1.10,
1757 );
1758 assert!(baseline.is_finite() && scaled_up.is_finite());
1759 assert!(
1761 scaled_up > baseline,
1762 "cd_scale=1.10 must need a larger zero angle: base={baseline} scaled={scaled_up}"
1763 );
1764 }
1765 }
1766
1767 #[test]
1768 fn zero_angle_scaled_rejects_invalid_cd_scale() {
1769 let inputs = valid_trajectory_inputs();
1770 unsafe {
1771 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1772 let angle = ballistics_calculate_zero_angle_with_drag_table_scaled(
1773 &inputs,
1774 std::ptr::null(),
1775 std::ptr::null(),
1776 100.0,
1777 DECK_MACH.as_ptr(),
1778 DECK_CD_LOW.as_ptr(),
1779 DECK_MACH.len() as c_int,
1780 bad,
1781 );
1782 assert!(angle.is_nan(), "cd_scale={bad} must be rejected (NaN sentinel)");
1783 }
1784 }
1785 }
1786
1787 #[test]
1791 fn legacy_drag_table_exports_unaffected_by_cd_scale_plumbing() {
1792 let inputs = valid_trajectory_inputs();
1793 unsafe {
1794 let a = ballistics_calculate_trajectory_with_drag_table(
1795 &inputs,
1796 std::ptr::null(),
1797 std::ptr::null(),
1798 300.0,
1799 1.0,
1800 DECK_MACH.as_ptr(),
1801 DECK_CD_LOW.as_ptr(),
1802 DECK_MACH.len() as c_int,
1803 );
1804 let b = ballistics_calculate_trajectory_with_drag_table(
1805 &inputs,
1806 std::ptr::null(),
1807 std::ptr::null(),
1808 300.0,
1809 1.0,
1810 DECK_MACH.as_ptr(),
1811 DECK_CD_LOW.as_ptr(),
1812 DECK_MACH.len() as c_int,
1813 );
1814 assert!(!a.is_null() && !b.is_null());
1815 assert_eq!((*a).impact_velocity.to_bits(), (*b).impact_velocity.to_bits());
1816 ballistics_free_trajectory_result(a);
1817 ballistics_free_trajectory_result(b);
1818
1819 let za = ballistics_calculate_zero_angle_with_drag_table(
1820 &inputs,
1821 std::ptr::null(),
1822 std::ptr::null(),
1823 100.0,
1824 DECK_MACH.as_ptr(),
1825 DECK_CD_LOW.as_ptr(),
1826 DECK_MACH.len() as c_int,
1827 );
1828 let zb = ballistics_calculate_zero_angle_with_drag_table(
1829 &inputs,
1830 std::ptr::null(),
1831 std::ptr::null(),
1832 100.0,
1833 DECK_MACH.as_ptr(),
1834 DECK_CD_LOW.as_ptr(),
1835 DECK_MACH.len() as c_int,
1836 );
1837 assert!(za.is_finite() && zb.is_finite());
1838 assert_eq!(za.to_bits(), zb.to_bits());
1839 }
1840 }
1841}