1use serde::Serialize;
77
78use super::wire::{write_bytes_field, write_i32_field, write_packed_i32_field, write_string_field};
79use crate::cli_api::UnitSystem;
80use crate::constants::{FPS_TO_MPS, GRAMS_PER_GRAIN};
81use crate::profile::ProfileData;
82use crate::profile_import::wrap_payload;
83
84const SCALE_TWIST: f64 = 100.0; const SCALE_VELOCITY: f64 = 10.0; const SCALE_DIMENSION: f64 = 1000.0; const SCALE_WEIGHT: f64 = 10.0; const SCALE_PRESSURE: f64 = 10.0; const SCALE_COEF: f64 = 10_000.0; const SCALE_DISTANCE: f64 = 100.0; const SCALE_MACH: f64 = 10_000.0; const F_PROFILE_NAME: u32 = 1;
100const F_BULLET_NAME: u32 = 3;
101const F_SIGHT_HEIGHT: u32 = 9;
102const F_TWIST: u32 = 10;
103const F_MUZZLE_VELOCITY: u32 = 11;
104const F_AIR_TEMPERATURE: u32 = 15;
105const F_AIR_PRESSURE: u32 = 16;
106const F_AIR_HUMIDITY: u32 = 17;
107const F_BULLET_DIAMETER: u32 = 20;
108const F_BULLET_WEIGHT: u32 = 21;
109const F_BULLET_LENGTH: u32 = 22;
110const F_TWIST_DIR: u32 = 23;
111const F_BC_TYPE: u32 = 24;
112const F_SWITCHES: u32 = 25;
113const F_DISTANCES: u32 = 26;
114const F_COEF_ROWS: u32 = 27;
115const F_ROW_BC_CD: u32 = 1;
117const F_ROW_MV: u32 = 2;
118const F_SW_C_IDX: u32 = 1;
124const F_SW_ZOOM: u32 = 3;
125const F_SW_DISTANCE: u32 = 4;
126const F_PAYLOAD_PROFILE: u32 = 1;
132
133const MIN_SWITCHES: usize = 4;
146
147const PLACEHOLDER_SWITCHES: [(i32, i32); MIN_SWITCHES] =
159 [(1, 10_000), (2, 20_000), (3, 30_000), (4, 100_000)];
160
161const PLACEHOLDER_SWITCH_C_IDX: i32 = 255;
165
166const BOUND_SIGHT_HEIGHT: (i32, i32) = (-5_000, 5_000); const BOUND_TWIST: (i32, i32) = (0, 10_000); const BOUND_VELOCITY: (i32, i32) = (100, 30_000); const BOUND_TEMPERATURE: (i32, i32) = (-100, 100); const BOUND_PRESSURE: (i32, i32) = (3_000, 15_000); const BOUND_HUMIDITY: (i32, i32) = (0, 100); const BOUND_DIAMETER: (i32, i32) = (1, 50_000); const BOUND_WEIGHT: (i32, i32) = (10, 65_535); const BOUND_LENGTH: (i32, i32) = (10, 200_000); const BOUND_DISTANCE: (i32, i32) = (100, 300_000); const BOUND_NONE: (i32, i32) = (i32::MIN, i32::MAX);
186
187const MM_PER_INCH: f64 = 25.4;
193const METERS_PER_YARD: f64 = 0.9144;
194const HPA_PER_INHG: f64 = 33.8639;
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct NotCarried {
199 pub field: &'static str,
203 pub populated: bool,
207 pub value: Option<String>,
209 pub reason: &'static str,
211}
212
213pub const CARRIED_FIELDS: &[&str] = &[
223 "name",
224 "velocity",
225 "bc",
226 "mass",
227 "diameter",
228 "drag_model",
229 "twist_rate",
230 "sight_height",
231 "zero_distance",
232 "units",
233 "temperature",
234 "pressure",
235 "humidity",
236 "bullet_name",
237 "auto_zero",
238 "twist_right",
239 "bullet_length",
240 "bc_segments",
241 "drag_curve",
242];
243
244#[derive(Debug, Clone)]
246pub struct A7pExport {
247 pub bytes: Vec<u8>,
249 pub not_carried: Vec<NotCarried>,
251 pub warnings: Vec<String>,
256}
257
258impl A7pExport {
259 pub fn dropped_fields(&self) -> Vec<&'static str> {
262 self.not_carried
263 .iter()
264 .filter(|n| n.populated)
265 .map(|n| n.field)
266 .collect()
267 }
268}
269
270#[derive(Debug, Clone, PartialEq)]
274pub enum A7pExportError {
275 Units(String),
277 DragModel(String),
281 Field {
285 field: &'static str,
286 message: String,
287 },
288}
289
290impl std::fmt::Display for A7pExportError {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 match self {
293 A7pExportError::Units(u) => write!(f, "{u}"),
294 A7pExportError::DragModel(m) => write!(
295 f,
296 "drag model '{m}' has no .a7p equivalent — the format stores only G1, G7 \
297 or a CUSTOM Mach/Cd curve, and writing '{m}' as one of those would give \
298 the recipient different physics under the same name"
299 ),
300 A7pExportError::Field { field, message } => {
301 write!(f, "{field}: {message}")
302 }
303 }
304 }
305}
306
307impl std::error::Error for A7pExportError {}
308
309struct Lossy {
311 warnings: Vec<String>,
312}
313
314impl Lossy {
315 fn quantize(
320 &mut self,
321 field: &'static str,
322 value: f64,
323 scale: f64,
324 unit: &str,
325 bound: (i32, i32),
326 ) -> Result<i32, A7pExportError> {
327 if !value.is_finite() {
328 return Err(A7pExportError::Field {
329 field,
330 message: format!("{value} is not a finite number"),
331 });
332 }
333 let raw = value * scale;
334 if raw < f64::from(i32::MIN) || raw > f64::from(i32::MAX) {
335 return Err(A7pExportError::Field {
336 field,
337 message: format!(
338 "{value} {unit} is outside the range .a7p can store (the file holds \
339 {unit} x {scale} in a 32-bit integer)"
340 ),
341 });
342 }
343 let rounded = raw.round();
344 let stored = rounded as i32;
345 let (min, max) = bound;
348 if stored < min || stored > max {
349 return Err(A7pExportError::Field {
350 field,
351 message: format!(
352 "{value} {unit} is outside what .a7p accepts: the format stores \
353 {unit} x {scale} and the ecosystem's validator requires that \
354 integer to be between {min} and {max} (this would be {stored})"
355 ),
356 });
357 }
358 let recovered = f64::from(stored) / scale;
359 if (recovered - value).abs() > 1e-9 * value.abs().max(1.0) {
364 let grid = if scale == 1.0 {
365 format!("whole {unit}")
366 } else {
367 format!("1/{scale} {unit}")
368 };
369 self.warnings.push(format!(
370 "{field}: {value} {unit} does not fit .a7p's {grid} grid; \
371 written as {recovered} {unit}"
372 ));
373 }
374 Ok(stored)
375 }
376}
377
378fn require_positive(field: &'static str, value: f64) -> Result<f64, A7pExportError> {
379 if !value.is_finite() || value <= 0.0 {
380 return Err(A7pExportError::Field {
381 field,
382 message: format!("{value} is not a usable measurement (must be finite and > 0)"),
383 });
384 }
385 Ok(value)
386}
387
388fn carried_opt<T: std::fmt::Display>(v: &Option<T>) -> Option<String> {
391 v.as_ref().map(|v| v.to_string())
392}
393
394fn non_empty<T>(v: &Option<Vec<T>>) -> Option<&Vec<T>> {
398 v.as_ref().filter(|v| !v.is_empty())
399}
400
401pub fn export_a7p(profile: &ProfileData) -> Result<A7pExport, A7pExportError> {
407 let units = profile.unit_system().map_err(A7pExportError::Units)?;
408 let mut lossy = Lossy {
409 warnings: Vec::new(),
410 };
411
412 let velocity_mps = match units {
416 UnitSystem::Metric => profile.velocity,
417 UnitSystem::Imperial => profile.velocity * FPS_TO_MPS,
418 };
419 let velocity_mps = require_positive("velocity", velocity_mps)?;
420
421 let weight_grains = match units {
422 UnitSystem::Metric => profile.mass / GRAMS_PER_GRAIN,
423 UnitSystem::Imperial => profile.mass,
424 };
425 let weight_grains = require_positive("mass", weight_grains)?;
426
427 let diameter_in = match units {
428 UnitSystem::Metric => profile.diameter / MM_PER_INCH,
429 UnitSystem::Imperial => profile.diameter,
430 };
431 let diameter_in = require_positive("diameter", diameter_in)?;
432
433 let length_in = match profile.bullet_length {
440 Some(v) => match units {
441 UnitSystem::Metric => v / MM_PER_INCH,
442 UnitSystem::Imperial => v,
443 },
444 None => {
445 return Err(A7pExportError::Field {
446 field: "bullet_length",
447 message: "the .a7p format requires a bullet length and this profile has \
448 none; set bullet_length (a length cannot be invented — it drives \
449 the recipient's stability model)"
450 .to_string(),
451 })
452 }
453 };
454 let twist_in = profile.twist_rate.map(|v| match units {
475 UnitSystem::Metric => v / MM_PER_INCH,
476 UnitSystem::Imperial => v,
477 });
478 if twist_in.is_none() {
479 lossy.warnings.push(
480 "twist_rate: unset, so the file's r_twist stays 0. The format may read that as \
481 \"twist unknown\" or as a zero twist, and which it is decides whether the \
482 recipient gets spin drift at all — set twist_rate if the rifle's twist is \
483 known"
484 .to_string(),
485 );
486 }
487 let sight_height_mm = match profile.sight_height {
488 Some(v) => match units {
489 UnitSystem::Metric => v,
490 UnitSystem::Imperial => v * MM_PER_INCH,
491 },
492 None => {
493 return Err(A7pExportError::Field {
494 field: "sight_height",
495 message: "the .a7p format has no way to say a sight height is unknown, and \
496 the 0 mm it would otherwise carry is a mount no rifle has — the \
497 recipient cannot tell it from a measurement and every solution \
498 they compute moves. Set sight_height"
499 .to_string(),
500 })
501 }
502 };
503 let zero_distance_m = profile.zero_distance.map(|v| match units {
504 UnitSystem::Metric => v,
505 UnitSystem::Imperial => v * METERS_PER_YARD,
506 });
507 let temperature_c = match units {
508 UnitSystem::Metric => profile.temperature,
509 UnitSystem::Imperial => (profile.temperature - 32.0) * 5.0 / 9.0,
510 };
511 let pressure_hpa = match units {
512 UnitSystem::Metric => profile.pressure,
513 UnitSystem::Imperial => profile.pressure * HPA_PER_INHG,
514 };
515
516 let model = profile.drag_model.trim().to_ascii_uppercase();
520 let (bc_type, rows): (i32, Vec<(i32, i32)>) = match model.as_str() {
521 "G1" | "G7" => {
522 let bc_type = if model == "G1" { 0 } else { 1 };
523 let (segments, bc_field): (Vec<(f64, f64)>, &'static str) = match &profile.bc_segments {
527 Some(segments) if !segments.is_empty() => (
530 segments.iter().map(|s| (s.bc, s.velocity_mps)).collect(),
531 "bc_segments.bc",
532 ),
533 _ => (
536 vec![(require_positive("bc", profile.bc)?, velocity_mps)],
537 "bc",
538 ),
539 };
540 if profile.bc_segments.as_ref().is_some_and(|s| !s.is_empty()) {
546 if let Some(fastest) = segments
547 .iter()
548 .max_by(|a, b| a.1.total_cmp(&b.1))
549 .map(|&(bc, _)| bc)
550 {
551 if (profile.bc - fastest).abs() > 1e-9 {
552 lossy.warnings.push(format!(
553 "bc: the profile's scalar BC {} differs from the fastest \
554 bc_segments row {fastest}; .a7p has no separate scalar-BC \
555 field, so the recipient will read {fastest}",
556 profile.bc
557 ));
558 }
559 }
560 }
561 let mut rows = Vec::with_capacity(segments.len());
562 for (bc, mps) in segments {
563 let velocity_field = if bc_field == "bc" {
564 "velocity"
565 } else {
566 "bc_segments.velocity_mps"
567 };
568 rows.push((
569 lossy.quantize(bc_field, bc, SCALE_COEF, "BC", BOUND_NONE)?,
570 lossy.quantize(velocity_field, mps, SCALE_VELOCITY, "m/s", BOUND_NONE)?,
571 ));
572 }
573 (bc_type, rows)
574 }
575 "CUSTOM" => {
576 let curve = profile
577 .drag_curve
578 .as_ref()
579 .filter(|c| !c.is_empty())
580 .ok_or(A7pExportError::Field {
581 field: "drag_curve",
582 message: "drag_model is CUSTOM but the profile carries no drag curve"
583 .to_string(),
584 })?;
585 if profile.bc != 0.0 {
586 lossy.warnings.push(format!(
587 "bc: {} is not carried for a CUSTOM drag model — the file's coef rows ARE \
588 the drag law, and a reader derives no scalar BC from them",
589 profile.bc
590 ));
591 }
592 let mut rows = Vec::with_capacity(curve.len());
593 for point in curve {
594 rows.push((
595 lossy.quantize("drag_curve.cd", point.cd, SCALE_COEF, "Cd", BOUND_NONE)?,
596 lossy.quantize(
597 "drag_curve.mach",
598 point.mach,
599 SCALE_MACH,
600 "Mach",
601 BOUND_NONE,
602 )?,
603 ));
604 }
605 (2, rows)
606 }
607 other => return Err(A7pExportError::DragModel(other.to_string())),
608 };
609 if profile.drag_curve.is_some() && bc_type != 2 {
610 lossy.warnings.push(format!(
611 "drag_curve: dropped — drag_model is {model}, and a .a7p profile stores EITHER \
612 G1/G7 BC rows OR a CUSTOM Mach/Cd curve in the same field, never both"
613 ));
614 }
615 if profile.bc_segments.is_some() && bc_type == 2 {
616 lossy.warnings.push(
617 "bc_segments: dropped — drag_model is CUSTOM, so the file's coef rows carry the \
618 Mach/Cd curve instead"
619 .to_string(),
620 );
621 }
622
623 let zero_distance_m = zero_distance_m.ok_or(A7pExportError::Field {
632 field: "zero_distance",
633 message: "the .a7p format requires at least one range-card distance and this \
634 profile has no zero distance to supply it; set zero_distance"
635 .to_string(),
636 })?;
637 let distances: Vec<i32> = vec![lossy.quantize(
638 "zero_distance",
639 zero_distance_m,
640 SCALE_DISTANCE,
641 "m",
642 BOUND_DISTANCE,
643 )?];
644 if let (Some(auto), Some(zero)) = (profile.auto_zero, profile.zero_distance) {
647 if (auto - zero).abs() > 1e-9 {
648 lossy.warnings.push(format!(
649 "auto_zero: {auto} differs from zero_distance {zero}; .a7p stores one zero \
650 distance, and zero_distance is what was written"
651 ));
652 }
653 }
654
655 lossy.warnings.push(
659 "the file's cartridge_name, short_name_top, short_name_bot, user_note, caliber and \
660 device_uuid are left empty, and its c_zero_temperature, c_zero_p_temperature, \
661 c_t_coeff, c_zero_w_pitch and zero_x/zero_y stay at the format default: a saved \
662 profile has no equivalent for any of them, and filling them in would be fabrication"
663 .to_string(),
664 );
665 lossy.warnings.push(format!(
671 "the file's {MIN_SWITCHES} switch positions (device zoom/range presets at 100, 200, \
672 300 and 1000 m) are PLACEHOLDERS, not this shooter's: a saved profile has no such \
673 concept, and .a7p is refused by the ecosystem with fewer than {MIN_SWITCHES} of \
674 them. They are the upstream tool's own factory values, so nothing here was derived \
675 from the profile or invented as range data"
676 ));
677
678 let mut body = Vec::new();
684 if !profile.name.is_empty() {
685 write_string_field(F_PROFILE_NAME, &profile.name, &mut body);
686 }
687 if let Some(bullet_name) = profile.bullet_name.as_deref().filter(|s| !s.is_empty()) {
688 write_string_field(F_BULLET_NAME, bullet_name, &mut body);
689 }
690 write_i32_field(
697 F_SIGHT_HEIGHT,
698 lossy.quantize(
699 "sight_height",
700 sight_height_mm,
701 1.0,
702 "mm",
703 BOUND_SIGHT_HEIGHT,
704 )?,
705 &mut body,
706 );
707 if let Some(inches) = twist_in {
708 let raw = lossy.quantize("twist_rate", inches, SCALE_TWIST, "in/turn", BOUND_TWIST)?;
709 if raw != 0 {
710 write_i32_field(F_TWIST, raw, &mut body);
711 }
712 }
713 write_i32_field(
714 F_MUZZLE_VELOCITY,
715 lossy.quantize(
716 "velocity",
717 velocity_mps,
718 SCALE_VELOCITY,
719 "m/s",
720 BOUND_VELOCITY,
721 )?,
722 &mut body,
723 );
724 let temperature_raw =
725 lossy.quantize("temperature", temperature_c, 1.0, "C", BOUND_TEMPERATURE)?;
726 if temperature_raw != 0 {
727 write_i32_field(F_AIR_TEMPERATURE, temperature_raw, &mut body);
728 }
729 write_i32_field(
730 F_AIR_PRESSURE,
731 lossy.quantize(
732 "pressure",
733 pressure_hpa,
734 SCALE_PRESSURE,
735 "hPa",
736 BOUND_PRESSURE,
737 )?,
738 &mut body,
739 );
740 let humidity_raw = lossy.quantize("humidity", profile.humidity, 1.0, "%", BOUND_HUMIDITY)?;
741 if humidity_raw != 0 {
742 write_i32_field(F_AIR_HUMIDITY, humidity_raw, &mut body);
743 }
744 write_i32_field(
745 F_BULLET_DIAMETER,
746 lossy.quantize(
747 "diameter",
748 diameter_in,
749 SCALE_DIMENSION,
750 "in",
751 BOUND_DIAMETER,
752 )?,
753 &mut body,
754 );
755 write_i32_field(
756 F_BULLET_WEIGHT,
757 lossy.quantize("mass", weight_grains, SCALE_WEIGHT, "gr", BOUND_WEIGHT)?,
758 &mut body,
759 );
760 write_i32_field(
761 F_BULLET_LENGTH,
762 lossy.quantize(
763 "bullet_length",
764 length_in,
765 SCALE_DIMENSION,
766 "in",
767 BOUND_LENGTH,
768 )?,
769 &mut body,
770 );
771 match profile.twist_right {
776 Some(false) => write_i32_field(F_TWIST_DIR, 1, &mut body),
777 Some(true) => {}
778 None => lossy.warnings.push(
779 "twist_right: unset; .a7p has no \"unknown\" twist direction, so the file says \
780 RIGHT (its own default)"
781 .to_string(),
782 ),
783 }
784 if bc_type != 0 {
785 write_i32_field(F_BC_TYPE, bc_type, &mut body);
786 }
787 for (zoom, distance) in PLACEHOLDER_SWITCHES {
790 let mut switch = Vec::new();
791 write_i32_field(F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX, &mut switch);
792 write_i32_field(F_SW_ZOOM, zoom, &mut switch);
796 write_i32_field(F_SW_DISTANCE, distance, &mut switch);
797 write_bytes_field(F_SWITCHES, &switch, &mut body);
798 }
799 if !distances.is_empty() {
800 write_packed_i32_field(F_DISTANCES, &distances, &mut body);
801 }
805 for (bc_cd, mv) in &rows {
806 let mut row = Vec::new();
807 if *bc_cd != 0 {
808 write_i32_field(F_ROW_BC_CD, *bc_cd, &mut row);
809 }
810 if *mv != 0 {
811 write_i32_field(F_ROW_MV, *mv, &mut row);
812 }
813 write_bytes_field(F_COEF_ROWS, &row, &mut body);
814 }
815
816 let mut payload = Vec::new();
817 write_bytes_field(F_PAYLOAD_PROFILE, &body, &mut payload);
818
819 Ok(A7pExport {
820 bytes: wrap_payload(&payload),
821 not_carried: not_carried_for(profile),
822 warnings: lossy.warnings,
823 })
824}
825
826fn not_carried_for(p: &ProfileData) -> Vec<NotCarried> {
830 let mut out = Vec::new();
831 let mut push = |field: &'static str, value: Option<String>, reason: &'static str| {
832 out.push(NotCarried {
833 field,
834 populated: value.is_some(),
835 value,
836 reason,
837 });
838 };
839
840 push(
841 "altitude",
844 (p.altitude != 0.0).then(|| p.altitude.to_string()),
845 "the file records the atmosphere at zeroing as temperature/pressure/humidity only; \
846 it has no altitude field, and folding altitude into the pressure would change a \
847 measured number into a derived one",
848 );
849 push(
850 "density_altitude",
851 carried_opt(&p.density_altitude),
852 "no density-altitude concept in the format — see `altitude`",
853 );
854 push(
855 "pressure_reference",
856 carried_opt(&p.pressure_reference),
857 "the file's pressure field declares no reference, so it is written as-is; a QNH \
858 profile therefore travels as if it were station pressure",
859 );
860 push(
861 "bc_reference",
862 carried_opt(&p.bc_reference),
863 "the file names no standard atmosphere for its BCs; a non-ICAO BC travels without \
864 the note that says what it is referenced to",
865 );
866 push(
867 "created",
868 carried_opt(&p.created),
869 "the format has no creation timestamp",
870 );
871 push(
872 "wind_speed",
873 carried_opt(&p.wind_speed),
874 "a .a7p profile describes a rifle and a load, not a shot; it has no wind fields",
875 );
876 push(
877 "wind_direction",
878 carried_opt(&p.wind_direction),
879 "no wind fields — see `wind_speed`",
880 );
881 push(
882 "shooting_angle",
883 carried_opt(&p.shooting_angle),
884 "no look-angle field; a .a7p profile carries no shot conditions",
885 );
886 push(
887 "use_bc_segments",
888 carried_opt(&p.use_bc_segments),
889 "no such switch in the format: a file's coef rows ARE its BC schedule, so there is \
890 nothing to turn on or off",
891 );
892 push(
893 "dsf_points",
894 non_empty(&p.dsf_points).map(|v| format!("{} drop-scale-factor point(s)", v.len())),
895 "truing results have no place in the format; the recipient gets the untrued load",
896 );
897 push(
898 "elevation_cf",
899 carried_opt(&p.elevation_cf),
900 "no scope tracking-correction field; the recipient's solution will not be corrected \
901 for how this scope actually tracks",
902 );
903 push(
904 "windage_cf",
905 carried_opt(&p.windage_cf),
906 "no scope tracking-correction field — see `elevation_cf`",
907 );
908 push(
909 "elevation_click",
910 carried_opt(&p.elevation_click),
911 "the file stores zeroing as raw device click COUNTS and never the click size itself; \
912 a graduation written here would be read against the recipient device's own",
913 );
914 push(
915 "windage_click",
916 carried_opt(&p.windage_click),
917 "no click-size field — see `elevation_click`",
918 );
919 push(
920 "zero_poi_up_m",
921 carried_opt(&p.zero_poi_up_m),
922 "the file's only zero-offset slots are zero_x/zero_y, which are device-scoped click \
923 counts with no click size recorded; converting a linear offset into clicks with THIS \
924 profile's graduation would be misread by any device graduated differently",
925 );
926 push(
927 "zero_poi_right_m",
928 carried_opt(&p.zero_poi_right_m),
929 "device-scoped click counts only — see `zero_poi_up_m`",
930 );
931 push(
932 "sight_offset_lateral_m",
933 carried_opt(&p.sight_offset_lateral_m),
934 "the format models sight height only; there is no lateral sight-to-bore offset",
935 );
936 push(
937 "zero_sets",
938 non_empty(&p.zero_sets).map(|sets| {
939 sets.iter()
940 .map(|z| z.name.as_str())
941 .collect::<Vec<_>>()
942 .join(", ")
943 }),
944 "one profile is one rifle with one zero in this format; alternate zeros and per-load \
945 dial corrections have nowhere to go",
946 );
947 push(
948 "reticle",
949 p.reticle
950 .as_ref()
951 .map(|r| format!("\"{}\" ({} mark(s))", r.name, r.marks.len())),
952 "the format describes the optic by sight height and click counts, not by its reticle",
953 );
954 push(
955 "clicks_per_revolution",
956 carried_opt(&p.clicks_per_revolution),
957 "no turret-mechanics fields in the format",
958 );
959 push(
960 "zero_stop",
961 carried_opt(&p.zero_stop),
962 "no turret-mechanics fields — see `clicks_per_revolution`",
963 );
964 push(
965 "elevation_travel_up_mil",
966 carried_opt(&p.elevation_travel_up_mil),
967 "no turret travel limits in the format",
968 );
969 push(
970 "elevation_travel_down_mil",
971 carried_opt(&p.elevation_travel_down_mil),
972 "no turret travel limits — see `elevation_travel_up_mil`",
973 );
974 push(
975 "windage_travel_left_mil",
976 carried_opt(&p.windage_travel_left_mil),
977 "no turret travel limits — see `elevation_travel_up_mil`",
978 );
979 push(
980 "windage_travel_right_mil",
981 carried_opt(&p.windage_travel_right_mil),
982 "no turret travel limits — see `elevation_travel_up_mil`",
983 );
984 push(
985 "turret_elevation_dialed_mil",
986 carried_opt(&p.turret_elevation_dialed_mil),
987 "no field for what the turrets currently read",
988 );
989 push(
990 "turret_windage_dialed_mil",
991 carried_opt(&p.turret_windage_dialed_mil),
992 "no field for what the turrets currently read",
993 );
994 push(
995 "hold_bound_up_mil",
996 carried_opt(&p.hold_bound_up_mil),
997 "no reticle hold bounds in the format — see `reticle`",
998 );
999 push(
1000 "hold_bound_down_mil",
1001 carried_opt(&p.hold_bound_down_mil),
1002 "no reticle hold bounds — see `reticle`",
1003 );
1004 push(
1005 "hold_bound_left_mil",
1006 carried_opt(&p.hold_bound_left_mil),
1007 "no reticle hold bounds — see `reticle`",
1008 );
1009 push(
1010 "hold_bound_right_mil",
1011 carried_opt(&p.hold_bound_right_mil),
1012 "no reticle hold bounds — see `reticle`",
1013 );
1014
1015 out
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1022
1023 const FULL_FIXTURE: &str = r#"{
1029 "name": "export-fixture",
1030 "velocity": 792.0,
1031 "bc": 0.381,
1032 "mass": 19.439673,
1033 "diameter": 8.5852,
1034 "drag_model": "G7",
1035 "twist_rate": 254.0,
1036 "sight_height": 90.0,
1037 "zero_distance": 100.0,
1038 "units": "metric",
1039 "temperature": 15.0,
1040 "pressure": 1000.0,
1041 "humidity": 50.0,
1042 "altitude": 300.0,
1043 "bullet_name": "300GR OTM",
1044 "created": "1755400000",
1045 "wind_speed": 4.4704,
1046 "wind_direction": 90.0,
1047 "shooting_angle": -5.0,
1048 "auto_zero": 100.0,
1049 "twist_right": false,
1050 "use_bc_segments": true,
1051 "bullet_length": 45.72,
1052 "elevation_click": "0.1mil",
1053 "windage_click": "0.25moa",
1054 "bc_segments": [
1055 {"bc": 0.381, "velocity_mps": 792.0},
1056 {"bc": 0.360, "velocity_mps": 500.0}
1057 ],
1058 "drag_curve": [
1059 {"mach": 0.5, "cd": 0.23},
1060 {"mach": 3.0, "cd": 0.28}
1061 ],
1062 "dsf_points": [{"mach": 0.9, "dsf": 1.04}],
1063 "bc_reference": "army-standard-metro",
1064 "pressure_reference": "qnh",
1065 "density_altitude": 500.0,
1066 "zero_poi_up_m": 0.05,
1067 "zero_poi_right_m": -0.02,
1068 "sight_offset_lateral_m": 0.01,
1069 "elevation_cf": 0.97,
1070 "windage_cf": 1.02,
1071 "zero_sets": [{"name": "suppressed", "zero_distance": 200.0, "poi_up_mil": -0.3}],
1072 "reticle": {
1073 "name": "mil-grid 0.5/10",
1074 "focal_plane": "ffp",
1075 "reference_magnification": 10.0,
1076 "marks": [{"down_mil": 0.0, "right_mil": 0.0, "kind": "center"}]
1077 },
1078 "clicks_per_revolution": 100,
1079 "zero_stop": true,
1080 "elevation_travel_up_mil": 26.0,
1081 "elevation_travel_down_mil": 4.0,
1082 "windage_travel_left_mil": 12.0,
1083 "windage_travel_right_mil": 12.0,
1084 "turret_elevation_dialed_mil": 5.4,
1085 "turret_windage_dialed_mil": -0.2,
1086 "hold_bound_up_mil": 5.0,
1087 "hold_bound_down_mil": 10.0,
1088 "hold_bound_left_mil": 6.0,
1089 "hold_bound_right_mil": 6.0
1090 }"#;
1091
1092 fn full_profile() -> ProfileData {
1093 serde_json::from_str(FULL_FIXTURE).expect("fixture must load")
1094 }
1095
1096 fn carryable_profile() -> ProfileData {
1099 let mut p = full_profile();
1100 p.altitude = 0.0;
1101 p.created = None;
1102 p.wind_speed = None;
1103 p.wind_direction = None;
1104 p.shooting_angle = None;
1105 p.use_bc_segments = None;
1106 p.elevation_click = None;
1107 p.windage_click = None;
1108 p.drag_curve = None;
1111 p.dsf_points = None;
1112 p.bc_reference = None;
1113 p.pressure_reference = None;
1114 p.density_altitude = None;
1115 p.zero_poi_up_m = None;
1116 p.zero_poi_right_m = None;
1117 p.sight_offset_lateral_m = None;
1118 p.elevation_cf = None;
1119 p.windage_cf = None;
1120 p.zero_sets = None;
1121 p.reticle = None;
1122 p.clicks_per_revolution = None;
1123 p.zero_stop = None;
1124 p.elevation_travel_up_mil = None;
1125 p.elevation_travel_down_mil = None;
1126 p.windage_travel_left_mil = None;
1127 p.windage_travel_right_mil = None;
1128 p.turret_elevation_dialed_mil = None;
1129 p.turret_windage_dialed_mil = None;
1130 p.hold_bound_up_mil = None;
1131 p.hold_bound_down_mil = None;
1132 p.hold_bound_left_mil = None;
1133 p.hold_bound_right_mil = None;
1134 p
1135 }
1136
1137 fn reimport(export: &A7pExport) -> ProfileData {
1138 let doc = parse_a7p(&export.bytes).expect("our own file must parse");
1139 assert!(
1140 matches!(doc.envelope, EnvelopeStatus::Verified),
1141 "the MD5 envelope we wrote must verify"
1142 );
1143 assert!(
1144 doc.unknown_fields.is_empty(),
1145 "the encoder must not emit field numbers the parser does not know: {:?}",
1146 doc.unknown_fields
1147 );
1148 map_a7p_to_profile(&doc, None, None)
1149 .expect("our own file must map")
1150 .profile
1151 }
1152
1153 fn close(a: f64, b: f64, what: &str) {
1154 assert!(
1155 (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0),
1156 "{what}: {a} != {b}"
1157 );
1158 }
1159
1160 #[test]
1165 fn round_trip_through_the_importer_preserves_every_carried_field() {
1166 let source = carryable_profile();
1167 let export = export_a7p(&source).expect("export");
1168 let back = reimport(&export);
1169
1170 assert_eq!(back.name, source.name);
1171 assert_eq!(back.drag_model, source.drag_model);
1172 assert_eq!(back.units, source.units);
1173 assert_eq!(back.bullet_name, source.bullet_name);
1174 assert_eq!(back.twist_right, source.twist_right);
1175 close(back.velocity, source.velocity, "velocity");
1176 close(back.bc, source.bc, "bc");
1177 close(back.mass, source.mass, "mass");
1178 close(back.diameter, source.diameter, "diameter");
1179 close(back.temperature, source.temperature, "temperature");
1180 close(back.pressure, source.pressure, "pressure");
1181 close(back.humidity, source.humidity, "humidity");
1182 close(
1183 back.twist_rate.unwrap(),
1184 source.twist_rate.unwrap(),
1185 "twist",
1186 );
1187 close(
1188 back.sight_height.unwrap(),
1189 source.sight_height.unwrap(),
1190 "sight_height",
1191 );
1192 close(
1193 back.bullet_length.unwrap(),
1194 source.bullet_length.unwrap(),
1195 "bullet_length",
1196 );
1197 close(
1198 back.zero_distance.unwrap(),
1199 source.zero_distance.unwrap(),
1200 "zero_distance",
1201 );
1202 close(
1203 back.auto_zero.unwrap(),
1204 source.auto_zero.unwrap(),
1205 "auto_zero",
1206 );
1207
1208 let (from, to) = (
1209 source.bc_segments.as_ref().unwrap(),
1210 back.bc_segments.as_ref().unwrap(),
1211 );
1212 assert_eq!(to.len(), from.len(), "bc_segments length");
1213 for (a, b) in from.iter().zip(to.iter()) {
1214 close(b.bc, a.bc, "bc_segments.bc");
1215 close(b.velocity_mps, a.velocity_mps, "bc_segments.velocity_mps");
1216 }
1217 assert!(back.drag_curve.is_none());
1218
1219 assert_eq!(
1223 export.warnings.len(),
1224 2,
1225 "unexpected warnings: {:?}",
1226 export.warnings
1227 );
1228 }
1229
1230 #[test]
1235 fn not_carried_and_carried_cover_every_profile_field() {
1236 let profile = full_profile();
1237 let serialized = serde_json::to_value(&profile).expect("serialize");
1238 let keys: std::collections::BTreeSet<String> = serialized
1239 .as_object()
1240 .expect("a profile is a JSON object")
1241 .keys()
1242 .cloned()
1243 .collect();
1244
1245 let export = export_a7p(&profile).expect("export");
1246 let mut accounted: std::collections::BTreeSet<String> =
1247 CARRIED_FIELDS.iter().map(|f| f.to_string()).collect();
1248 for entry in &export.not_carried {
1249 assert!(
1250 accounted.insert(entry.field.to_string()),
1251 "{} is in BOTH the carried and not-carried lists",
1252 entry.field
1253 );
1254 }
1255
1256 let unaccounted: Vec<&String> = keys.difference(&accounted).collect();
1257 assert!(
1258 unaccounted.is_empty(),
1259 "ProfileData fields in neither list (add them to CARRIED_FIELDS or to \
1260 not_carried_for): {unaccounted:?}"
1261 );
1262 let invented: Vec<&String> = accounted.difference(&keys).collect();
1263 assert!(
1264 invented.is_empty(),
1265 "listed names that are not ProfileData fields: {invented:?}"
1266 );
1267
1268 assert!(
1274 !accounted.contains("switches"),
1275 "switches is not a ProfileData field and must not appear in either list"
1276 );
1277 }
1278
1279 #[test]
1284 fn not_carried_names_real_populated_fields_with_their_values() {
1285 let profile = full_profile();
1286 let serialized = serde_json::to_value(&profile).expect("serialize");
1287 let object = serialized.as_object().expect("object");
1288
1289 let export = export_a7p(&profile).expect("export");
1290 let dropped = export.dropped_fields();
1291 assert!(!dropped.is_empty(), "this profile drops plenty");
1292
1293 for name in &dropped {
1294 assert!(
1295 object.contains_key(*name),
1296 "{name} is not a ProfileData field"
1297 );
1298 }
1299 for expected in [
1303 "altitude",
1304 "dsf_points",
1305 "elevation_cf",
1306 "zero_sets",
1307 "reticle",
1308 "elevation_click",
1309 "zero_poi_up_m",
1310 "bc_reference",
1311 "clicks_per_revolution",
1312 "hold_bound_up_mil",
1313 ] {
1314 assert!(dropped.contains(&expected), "{expected} must be reported");
1315 }
1316
1317 let dsf = export
1318 .not_carried
1319 .iter()
1320 .find(|n| n.field == "dsf_points")
1321 .expect("dsf_points entry");
1322 assert!(dsf.populated);
1323 assert_eq!(dsf.value.as_deref(), Some("1 drop-scale-factor point(s)"));
1324 assert!(!dsf.reason.is_empty());
1325
1326 let mut empty = full_profile();
1329 empty.dsf_points = None;
1330 let empty_export = export_a7p(&empty).expect("export");
1331 let dsf = empty_export
1332 .not_carried
1333 .iter()
1334 .find(|n| n.field == "dsf_points")
1335 .expect("dsf_points is listed even when unset");
1336 assert!(!dsf.populated);
1337 assert_eq!(dsf.value, None);
1338 }
1339
1340 #[test]
1345 fn an_imperial_profile_and_its_metric_twin_encode_identically() {
1346 let metric = carryable_profile();
1347 let mut imperial = metric.clone();
1348 imperial.units = "imperial".to_string();
1349 imperial.velocity = 792.0 / FPS_TO_MPS; imperial.mass = 300.0; imperial.diameter = 0.338; imperial.bullet_length = Some(1.8); imperial.twist_rate = Some(10.0); imperial.sight_height = Some(90.0 / MM_PER_INCH); imperial.zero_distance = Some(100.0 / METERS_PER_YARD); imperial.auto_zero = imperial.zero_distance;
1357 imperial.temperature = 15.0 * 9.0 / 5.0 + 32.0; imperial.pressure = 1000.0 / HPA_PER_INHG; let a = export_a7p(&metric).expect("metric export");
1361 let b = export_a7p(&imperial).expect("imperial export");
1362 assert_eq!(
1363 a.bytes, b.bytes,
1364 "the same rifle must encode identically whatever units it was saved in"
1365 );
1366 }
1367
1368 #[test]
1369 fn a_custom_drag_curve_round_trips_as_mach_cd_rows() {
1370 let mut profile = carryable_profile();
1371 profile.drag_model = "CUSTOM".to_string();
1372 profile.bc = 0.0; profile.bc_segments = None;
1374 profile.drag_curve = Some(vec![
1375 crate::profile::ProfileDragPoint {
1376 mach: 0.5,
1377 cd: 0.23,
1378 },
1379 crate::profile::ProfileDragPoint {
1380 mach: 3.0,
1381 cd: 0.28,
1382 },
1383 ]);
1384
1385 let export = export_a7p(&profile).expect("export");
1386 let back = reimport(&export);
1387 assert_eq!(back.drag_model, "CUSTOM");
1388 assert_eq!(back.bc, 0.0);
1389 let curve = back.drag_curve.as_ref().expect("curve survives");
1390 assert_eq!(curve.len(), 2);
1391 close(curve[0].mach, 0.5, "mach");
1392 close(curve[0].cd, 0.23, "cd");
1393 close(curve[1].mach, 3.0, "mach");
1394 close(curve[1].cd, 0.28, "cd");
1395 }
1396
1397 #[test]
1401 fn a_drag_model_the_format_cannot_name_is_refused() {
1402 let mut profile = carryable_profile();
1403 profile.drag_model = "G5".to_string();
1404 match export_a7p(&profile) {
1405 Err(A7pExportError::DragModel(m)) => assert_eq!(m, "G5"),
1406 other => panic!("expected a DragModel refusal, got {other:?}"),
1407 }
1408 }
1409
1410 #[test]
1414 fn a_value_that_does_not_fit_the_fixed_point_grid_is_warned_about() {
1415 let mut profile = carryable_profile();
1416 profile.sight_height = Some(50.8);
1417 let export = export_a7p(&profile).expect("export");
1418 assert!(
1419 export
1420 .warnings
1421 .iter()
1422 .any(|w| w.starts_with("sight_height:") && w.contains("51")),
1423 "expected a sight_height quantization warning, got {:?}",
1424 export.warnings
1425 );
1426 let back = reimport(&export);
1427 assert_eq!(back.sight_height, Some(51.0));
1428 }
1429
1430 #[test]
1431 fn an_unusable_measurement_is_a_named_field_error() {
1432 let mut profile = carryable_profile();
1433 profile.velocity = 0.0;
1434 match export_a7p(&profile) {
1435 Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "velocity"),
1436 other => panic!("expected a Field error, got {other:?}"),
1437 }
1438
1439 let mut profile = carryable_profile();
1440 profile.units = "furlongs".to_string();
1441 assert!(matches!(
1442 export_a7p(&profile),
1443 Err(A7pExportError::Units(_))
1444 ));
1445 }
1446
1447 #[test]
1451 fn custom_without_a_curve_is_refused() {
1452 let mut profile = carryable_profile();
1453 profile.drag_model = "CUSTOM".to_string();
1454 profile.drag_curve = None;
1455 match export_a7p(&profile) {
1456 Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "drag_curve"),
1457 other => panic!("expected a drag_curve error, got {other:?}"),
1458 }
1459 }
1460
1461 fn switch_entries(bytes: &[u8]) -> Vec<Vec<(u32, u64)>> {
1465 fn varint(b: &[u8], i: &mut usize) -> u64 {
1466 let (mut v, mut shift) = (0u64, 0u32);
1467 loop {
1468 let byte = b[*i];
1469 *i += 1;
1470 v |= u64::from(byte & 0x7f) << shift;
1471 if byte & 0x80 == 0 {
1472 return v;
1473 }
1474 shift += 7;
1475 }
1476 }
1477 fn fields(b: &[u8]) -> Vec<(u32, u64, &[u8])> {
1478 let mut i = 0usize;
1479 let mut out = Vec::new();
1480 while i < b.len() {
1481 let key = varint(b, &mut i);
1482 let (number, wire) = ((key >> 3) as u32, key & 7);
1483 match wire {
1484 0 => {
1485 let v = varint(b, &mut i);
1486 out.push((number, v, &b[0..0]));
1487 }
1488 2 => {
1489 let n = varint(b, &mut i) as usize;
1490 out.push((number, 0, &b[i..i + n]));
1491 i += n;
1492 }
1493 other => panic!("unexpected wire type {other}"),
1494 }
1495 }
1496 out
1497 }
1498 let payload = &bytes[32..];
1499 let profile = fields(payload)
1500 .into_iter()
1501 .find(|(n, _, _)| *n == F_PAYLOAD_PROFILE)
1502 .expect("payload carries a profile")
1503 .2;
1504 fields(profile)
1505 .into_iter()
1506 .filter(|(n, _, _)| *n == F_SWITCHES)
1507 .map(|(_, _, body)| {
1508 fields(body)
1509 .into_iter()
1510 .map(|(n, v, _)| (n, v))
1511 .collect::<Vec<_>>()
1512 })
1513 .collect()
1514 }
1515
1516 #[test]
1524 fn every_export_carries_the_minimum_four_switch_positions() {
1525 for profile in [carryable_profile(), full_profile()] {
1526 let export = export_a7p(&profile).expect("export");
1527 let switches = switch_entries(&export.bytes);
1528 assert!(
1529 switches.len() >= MIN_SWITCHES,
1530 "exported {} switch positions; the ecosystem refuses fewer than \
1531 {MIN_SWITCHES}",
1532 switches.len()
1533 );
1534 for (i, entry) in switches.iter().enumerate() {
1535 let (zoom, distance) = PLACEHOLDER_SWITCHES[i];
1536 assert_eq!(
1537 entry,
1538 &vec![
1539 (F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX as u64),
1540 (F_SW_ZOOM, zoom as u64),
1541 (F_SW_DISTANCE, distance as u64),
1542 ],
1543 "switch {i}"
1544 );
1545 }
1546 assert!(
1549 export
1550 .warnings
1551 .iter()
1552 .any(|w| w.contains("PLACEHOLDERS") && w.contains("switch")),
1553 "the placeholder switches must be declared: {:?}",
1554 export.warnings
1555 );
1556 }
1557 }
1558
1559 #[test]
1565 fn fields_the_format_requires_are_refused_when_absent_not_invented() {
1566 for (field, clear) in [
1567 (
1568 "bullet_length",
1569 Box::new(|p: &mut ProfileData| p.bullet_length = None)
1570 as Box<dyn Fn(&mut ProfileData)>,
1571 ),
1572 (
1573 "zero_distance",
1574 Box::new(|p: &mut ProfileData| p.zero_distance = None),
1575 ),
1576 (
1577 "sight_height",
1578 Box::new(|p: &mut ProfileData| p.sight_height = None),
1579 ),
1580 ] {
1581 let mut profile = carryable_profile();
1582 clear(&mut profile);
1583 match export_a7p(&profile) {
1584 Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
1585 other => panic!("{field}: expected a refusal, got {other:?}"),
1586 }
1587 }
1588 }
1589
1590 #[test]
1602 fn an_absent_twist_rate_warns_rather_than_refusing_and_that_is_deliberate() {
1603 let mut profile = carryable_profile();
1604 profile.twist_rate = None;
1605 let export = export_a7p(&profile).expect("an unknown twist must still export");
1606 assert!(
1607 export
1608 .warnings
1609 .iter()
1610 .any(|w| w.starts_with("twist_rate:") && w.contains("r_twist")),
1611 "an absent twist must be reported: {:?}",
1612 export.warnings
1613 );
1614 let back = reimport(&export);
1616 assert_eq!(back.twist_rate, Some(0.0));
1617 }
1618
1619 #[test]
1623 fn values_outside_what_the_ecosystem_accepts_are_refused_by_name() {
1624 for (field, mutate) in [
1628 (
1629 "velocity",
1630 Box::new(|p: &mut ProfileData| p.velocity = 5.0) as Box<dyn Fn(&mut ProfileData)>,
1631 ),
1632 (
1633 "pressure",
1634 Box::new(|p: &mut ProfileData| p.pressure = 100.0),
1635 ),
1636 (
1637 "temperature",
1638 Box::new(|p: &mut ProfileData| p.temperature = 250.0),
1639 ),
1640 (
1641 "zero_distance",
1642 Box::new(|p: &mut ProfileData| p.zero_distance = Some(5000.0)),
1643 ),
1644 (
1645 "sight_height",
1646 Box::new(|p: &mut ProfileData| p.sight_height = Some(9000.0)),
1647 ),
1648 ] {
1649 let mut profile = carryable_profile();
1650 mutate(&mut profile);
1651 match export_a7p(&profile) {
1652 Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
1653 other => panic!("{field}: expected a refusal, got {other:?}"),
1654 }
1655 }
1656 }
1657}