use serde::Serialize;
use super::wire::{write_bytes_field, write_i32_field, write_packed_i32_field, write_string_field};
use crate::cli_api::UnitSystem;
use crate::constants::{FPS_TO_MPS, GRAMS_PER_GRAIN};
use crate::profile::ProfileData;
use crate::profile_import::wrap_payload;
const 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;
const F_BULLET_NAME: u32 = 3;
const F_SIGHT_HEIGHT: u32 = 9;
const F_TWIST: u32 = 10;
const F_MUZZLE_VELOCITY: u32 = 11;
const F_AIR_TEMPERATURE: u32 = 15;
const F_AIR_PRESSURE: u32 = 16;
const F_AIR_HUMIDITY: u32 = 17;
const F_BULLET_DIAMETER: u32 = 20;
const F_BULLET_WEIGHT: u32 = 21;
const F_BULLET_LENGTH: u32 = 22;
const F_TWIST_DIR: u32 = 23;
const F_BC_TYPE: u32 = 24;
const F_SWITCHES: u32 = 25;
const F_DISTANCES: u32 = 26;
const F_COEF_ROWS: u32 = 27;
const F_ROW_BC_CD: u32 = 1;
const F_ROW_MV: u32 = 2;
const F_SW_C_IDX: u32 = 1;
const F_SW_ZOOM: u32 = 3;
const F_SW_DISTANCE: u32 = 4;
const F_PAYLOAD_PROFILE: u32 = 1;
const MIN_SWITCHES: usize = 4;
const PLACEHOLDER_SWITCHES: [(i32, i32); MIN_SWITCHES] =
[(1, 10_000), (2, 20_000), (3, 30_000), (4, 100_000)];
const PLACEHOLDER_SWITCH_C_IDX: i32 = 255;
const 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);
const MM_PER_INCH: f64 = 25.4;
const METERS_PER_YARD: f64 = 0.9144;
const HPA_PER_INHG: f64 = 33.8639;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NotCarried {
pub field: &'static str,
pub populated: bool,
pub value: Option<String>,
pub reason: &'static str,
}
pub const CARRIED_FIELDS: &[&str] = &[
"name",
"velocity",
"bc",
"mass",
"diameter",
"drag_model",
"twist_rate",
"sight_height",
"zero_distance",
"units",
"temperature",
"pressure",
"humidity",
"bullet_name",
"auto_zero",
"twist_right",
"bullet_length",
"bc_segments",
"drag_curve",
];
#[derive(Debug, Clone)]
pub struct A7pExport {
pub bytes: Vec<u8>,
pub not_carried: Vec<NotCarried>,
pub warnings: Vec<String>,
}
impl A7pExport {
pub fn dropped_fields(&self) -> Vec<&'static str> {
self.not_carried
.iter()
.filter(|n| n.populated)
.map(|n| n.field)
.collect()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum A7pExportError {
Units(String),
DragModel(String),
Field {
field: &'static str,
message: String,
},
}
impl std::fmt::Display for A7pExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
A7pExportError::Units(u) => write!(f, "{u}"),
A7pExportError::DragModel(m) => write!(
f,
"drag model '{m}' has no .a7p equivalent — the format stores only G1, G7 \
or a CUSTOM Mach/Cd curve, and writing '{m}' as one of those would give \
the recipient different physics under the same name"
),
A7pExportError::Field { field, message } => {
write!(f, "{field}: {message}")
}
}
}
}
impl std::error::Error for A7pExportError {}
struct Lossy {
warnings: Vec<String>,
}
impl Lossy {
fn quantize(
&mut self,
field: &'static str,
value: f64,
scale: f64,
unit: &str,
bound: (i32, i32),
) -> Result<i32, A7pExportError> {
if !value.is_finite() {
return Err(A7pExportError::Field {
field,
message: format!("{value} is not a finite number"),
});
}
let raw = value * scale;
if raw < f64::from(i32::MIN) || raw > f64::from(i32::MAX) {
return Err(A7pExportError::Field {
field,
message: format!(
"{value} {unit} is outside the range .a7p can store (the file holds \
{unit} x {scale} in a 32-bit integer)"
),
});
}
let rounded = raw.round();
let stored = rounded as i32;
let (min, max) = bound;
if stored < min || stored > max {
return Err(A7pExportError::Field {
field,
message: format!(
"{value} {unit} is outside what .a7p accepts: the format stores \
{unit} x {scale} and the ecosystem's validator requires that \
integer to be between {min} and {max} (this would be {stored})"
),
});
}
let recovered = f64::from(stored) / scale;
if (recovered - value).abs() > 1e-9 * value.abs().max(1.0) {
let grid = if scale == 1.0 {
format!("whole {unit}")
} else {
format!("1/{scale} {unit}")
};
self.warnings.push(format!(
"{field}: {value} {unit} does not fit .a7p's {grid} grid; \
written as {recovered} {unit}"
));
}
Ok(stored)
}
}
fn require_positive(field: &'static str, value: f64) -> Result<f64, A7pExportError> {
if !value.is_finite() || value <= 0.0 {
return Err(A7pExportError::Field {
field,
message: format!("{value} is not a usable measurement (must be finite and > 0)"),
});
}
Ok(value)
}
fn carried_opt<T: std::fmt::Display>(v: &Option<T>) -> Option<String> {
v.as_ref().map(|v| v.to_string())
}
fn non_empty<T>(v: &Option<Vec<T>>) -> Option<&Vec<T>> {
v.as_ref().filter(|v| !v.is_empty())
}
pub fn export_a7p(profile: &ProfileData) -> Result<A7pExport, A7pExportError> {
let units = profile.unit_system().map_err(A7pExportError::Units)?;
let mut lossy = Lossy {
warnings: Vec::new(),
};
let velocity_mps = match units {
UnitSystem::Metric => profile.velocity,
UnitSystem::Imperial => profile.velocity * FPS_TO_MPS,
};
let velocity_mps = require_positive("velocity", velocity_mps)?;
let weight_grains = match units {
UnitSystem::Metric => profile.mass / GRAMS_PER_GRAIN,
UnitSystem::Imperial => profile.mass,
};
let weight_grains = require_positive("mass", weight_grains)?;
let diameter_in = match units {
UnitSystem::Metric => profile.diameter / MM_PER_INCH,
UnitSystem::Imperial => profile.diameter,
};
let diameter_in = require_positive("diameter", diameter_in)?;
let length_in = match profile.bullet_length {
Some(v) => match units {
UnitSystem::Metric => v / MM_PER_INCH,
UnitSystem::Imperial => v,
},
None => {
return Err(A7pExportError::Field {
field: "bullet_length",
message: "the .a7p format requires a bullet length and this profile has \
none; set bullet_length (a length cannot be invented — it drives \
the recipient's stability model)"
.to_string(),
})
}
};
let twist_in = profile.twist_rate.map(|v| match units {
UnitSystem::Metric => v / MM_PER_INCH,
UnitSystem::Imperial => v,
});
if twist_in.is_none() {
lossy.warnings.push(
"twist_rate: unset, so the file's r_twist stays 0. The format may read that as \
\"twist unknown\" or as a zero twist, and which it is decides whether the \
recipient gets spin drift at all — set twist_rate if the rifle's twist is \
known"
.to_string(),
);
}
let sight_height_mm = match profile.sight_height {
Some(v) => match units {
UnitSystem::Metric => v,
UnitSystem::Imperial => v * MM_PER_INCH,
},
None => {
return Err(A7pExportError::Field {
field: "sight_height",
message: "the .a7p format has no way to say a sight height is unknown, and \
the 0 mm it would otherwise carry is a mount no rifle has — the \
recipient cannot tell it from a measurement and every solution \
they compute moves. Set sight_height"
.to_string(),
})
}
};
let zero_distance_m = profile.zero_distance.map(|v| match units {
UnitSystem::Metric => v,
UnitSystem::Imperial => v * METERS_PER_YARD,
});
let temperature_c = match units {
UnitSystem::Metric => profile.temperature,
UnitSystem::Imperial => (profile.temperature - 32.0) * 5.0 / 9.0,
};
let pressure_hpa = match units {
UnitSystem::Metric => profile.pressure,
UnitSystem::Imperial => profile.pressure * HPA_PER_INHG,
};
let model = profile.drag_model.trim().to_ascii_uppercase();
let (bc_type, rows): (i32, Vec<(i32, i32)>) = match model.as_str() {
"G1" | "G7" => {
let bc_type = if model == "G1" { 0 } else { 1 };
let (segments, bc_field): (Vec<(f64, f64)>, &'static str) = match &profile.bc_segments {
Some(segments) if !segments.is_empty() => (
segments.iter().map(|s| (s.bc, s.velocity_mps)).collect(),
"bc_segments.bc",
),
_ => (
vec![(require_positive("bc", profile.bc)?, velocity_mps)],
"bc",
),
};
if profile.bc_segments.as_ref().is_some_and(|s| !s.is_empty()) {
if let Some(fastest) = segments
.iter()
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|&(bc, _)| bc)
{
if (profile.bc - fastest).abs() > 1e-9 {
lossy.warnings.push(format!(
"bc: the profile's scalar BC {} differs from the fastest \
bc_segments row {fastest}; .a7p has no separate scalar-BC \
field, so the recipient will read {fastest}",
profile.bc
));
}
}
}
let mut rows = Vec::with_capacity(segments.len());
for (bc, mps) in segments {
let velocity_field = if bc_field == "bc" {
"velocity"
} else {
"bc_segments.velocity_mps"
};
rows.push((
lossy.quantize(bc_field, bc, SCALE_COEF, "BC", BOUND_NONE)?,
lossy.quantize(velocity_field, mps, SCALE_VELOCITY, "m/s", BOUND_NONE)?,
));
}
(bc_type, rows)
}
"CUSTOM" => {
let curve = profile
.drag_curve
.as_ref()
.filter(|c| !c.is_empty())
.ok_or(A7pExportError::Field {
field: "drag_curve",
message: "drag_model is CUSTOM but the profile carries no drag curve"
.to_string(),
})?;
if profile.bc != 0.0 {
lossy.warnings.push(format!(
"bc: {} is not carried for a CUSTOM drag model — the file's coef rows ARE \
the drag law, and a reader derives no scalar BC from them",
profile.bc
));
}
let mut rows = Vec::with_capacity(curve.len());
for point in curve {
rows.push((
lossy.quantize("drag_curve.cd", point.cd, SCALE_COEF, "Cd", BOUND_NONE)?,
lossy.quantize(
"drag_curve.mach",
point.mach,
SCALE_MACH,
"Mach",
BOUND_NONE,
)?,
));
}
(2, rows)
}
other => return Err(A7pExportError::DragModel(other.to_string())),
};
if profile.drag_curve.is_some() && bc_type != 2 {
lossy.warnings.push(format!(
"drag_curve: dropped — drag_model is {model}, and a .a7p profile stores EITHER \
G1/G7 BC rows OR a CUSTOM Mach/Cd curve in the same field, never both"
));
}
if profile.bc_segments.is_some() && bc_type == 2 {
lossy.warnings.push(
"bc_segments: dropped — drag_model is CUSTOM, so the file's coef rows carry the \
Mach/Cd curve instead"
.to_string(),
);
}
let zero_distance_m = zero_distance_m.ok_or(A7pExportError::Field {
field: "zero_distance",
message: "the .a7p format requires at least one range-card distance and this \
profile has no zero distance to supply it; set zero_distance"
.to_string(),
})?;
let distances: Vec<i32> = vec![lossy.quantize(
"zero_distance",
zero_distance_m,
SCALE_DISTANCE,
"m",
BOUND_DISTANCE,
)?];
if let (Some(auto), Some(zero)) = (profile.auto_zero, profile.zero_distance) {
if (auto - zero).abs() > 1e-9 {
lossy.warnings.push(format!(
"auto_zero: {auto} differs from zero_distance {zero}; .a7p stores one zero \
distance, and zero_distance is what was written"
));
}
}
lossy.warnings.push(
"the file's cartridge_name, short_name_top, short_name_bot, user_note, caliber and \
device_uuid are left empty, and its c_zero_temperature, c_zero_p_temperature, \
c_t_coeff, c_zero_w_pitch and zero_x/zero_y stay at the format default: a saved \
profile has no equivalent for any of them, and filling them in would be fabrication"
.to_string(),
);
lossy.warnings.push(format!(
"the file's {MIN_SWITCHES} switch positions (device zoom/range presets at 100, 200, \
300 and 1000 m) are PLACEHOLDERS, not this shooter's: a saved profile has no such \
concept, and .a7p is refused by the ecosystem with fewer than {MIN_SWITCHES} of \
them. They are the upstream tool's own factory values, so nothing here was derived \
from the profile or invented as range data"
));
let mut body = Vec::new();
if !profile.name.is_empty() {
write_string_field(F_PROFILE_NAME, &profile.name, &mut body);
}
if let Some(bullet_name) = profile.bullet_name.as_deref().filter(|s| !s.is_empty()) {
write_string_field(F_BULLET_NAME, bullet_name, &mut body);
}
write_i32_field(
F_SIGHT_HEIGHT,
lossy.quantize(
"sight_height",
sight_height_mm,
1.0,
"mm",
BOUND_SIGHT_HEIGHT,
)?,
&mut body,
);
if let Some(inches) = twist_in {
let raw = lossy.quantize("twist_rate", inches, SCALE_TWIST, "in/turn", BOUND_TWIST)?;
if raw != 0 {
write_i32_field(F_TWIST, raw, &mut body);
}
}
write_i32_field(
F_MUZZLE_VELOCITY,
lossy.quantize(
"velocity",
velocity_mps,
SCALE_VELOCITY,
"m/s",
BOUND_VELOCITY,
)?,
&mut body,
);
let temperature_raw =
lossy.quantize("temperature", temperature_c, 1.0, "C", BOUND_TEMPERATURE)?;
if temperature_raw != 0 {
write_i32_field(F_AIR_TEMPERATURE, temperature_raw, &mut body);
}
write_i32_field(
F_AIR_PRESSURE,
lossy.quantize(
"pressure",
pressure_hpa,
SCALE_PRESSURE,
"hPa",
BOUND_PRESSURE,
)?,
&mut body,
);
let humidity_raw = lossy.quantize("humidity", profile.humidity, 1.0, "%", BOUND_HUMIDITY)?;
if humidity_raw != 0 {
write_i32_field(F_AIR_HUMIDITY, humidity_raw, &mut body);
}
write_i32_field(
F_BULLET_DIAMETER,
lossy.quantize(
"diameter",
diameter_in,
SCALE_DIMENSION,
"in",
BOUND_DIAMETER,
)?,
&mut body,
);
write_i32_field(
F_BULLET_WEIGHT,
lossy.quantize("mass", weight_grains, SCALE_WEIGHT, "gr", BOUND_WEIGHT)?,
&mut body,
);
write_i32_field(
F_BULLET_LENGTH,
lossy.quantize(
"bullet_length",
length_in,
SCALE_DIMENSION,
"in",
BOUND_LENGTH,
)?,
&mut body,
);
match profile.twist_right {
Some(false) => write_i32_field(F_TWIST_DIR, 1, &mut body),
Some(true) => {}
None => lossy.warnings.push(
"twist_right: unset; .a7p has no \"unknown\" twist direction, so the file says \
RIGHT (its own default)"
.to_string(),
),
}
if bc_type != 0 {
write_i32_field(F_BC_TYPE, bc_type, &mut body);
}
for (zoom, distance) in PLACEHOLDER_SWITCHES {
let mut switch = Vec::new();
write_i32_field(F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX, &mut switch);
write_i32_field(F_SW_ZOOM, zoom, &mut switch);
write_i32_field(F_SW_DISTANCE, distance, &mut switch);
write_bytes_field(F_SWITCHES, &switch, &mut body);
}
if !distances.is_empty() {
write_packed_i32_field(F_DISTANCES, &distances, &mut body);
}
for (bc_cd, mv) in &rows {
let mut row = Vec::new();
if *bc_cd != 0 {
write_i32_field(F_ROW_BC_CD, *bc_cd, &mut row);
}
if *mv != 0 {
write_i32_field(F_ROW_MV, *mv, &mut row);
}
write_bytes_field(F_COEF_ROWS, &row, &mut body);
}
let mut payload = Vec::new();
write_bytes_field(F_PAYLOAD_PROFILE, &body, &mut payload);
Ok(A7pExport {
bytes: wrap_payload(&payload),
not_carried: not_carried_for(profile),
warnings: lossy.warnings,
})
}
fn not_carried_for(p: &ProfileData) -> Vec<NotCarried> {
let mut out = Vec::new();
let mut push = |field: &'static str, value: Option<String>, reason: &'static str| {
out.push(NotCarried {
field,
populated: value.is_some(),
value,
reason,
});
};
push(
"altitude",
(p.altitude != 0.0).then(|| p.altitude.to_string()),
"the file records the atmosphere at zeroing as temperature/pressure/humidity only; \
it has no altitude field, and folding altitude into the pressure would change a \
measured number into a derived one",
);
push(
"density_altitude",
carried_opt(&p.density_altitude),
"no density-altitude concept in the format — see `altitude`",
);
push(
"pressure_reference",
carried_opt(&p.pressure_reference),
"the file's pressure field declares no reference, so it is written as-is; a QNH \
profile therefore travels as if it were station pressure",
);
push(
"bc_reference",
carried_opt(&p.bc_reference),
"the file names no standard atmosphere for its BCs; a non-ICAO BC travels without \
the note that says what it is referenced to",
);
push(
"created",
carried_opt(&p.created),
"the format has no creation timestamp",
);
push(
"wind_speed",
carried_opt(&p.wind_speed),
"a .a7p profile describes a rifle and a load, not a shot; it has no wind fields",
);
push(
"wind_direction",
carried_opt(&p.wind_direction),
"no wind fields — see `wind_speed`",
);
push(
"shooting_angle",
carried_opt(&p.shooting_angle),
"no look-angle field; a .a7p profile carries no shot conditions",
);
push(
"use_bc_segments",
carried_opt(&p.use_bc_segments),
"no such switch in the format: a file's coef rows ARE its BC schedule, so there is \
nothing to turn on or off",
);
push(
"dsf_points",
non_empty(&p.dsf_points).map(|v| format!("{} drop-scale-factor point(s)", v.len())),
"truing results have no place in the format; the recipient gets the untrued load",
);
push(
"elevation_cf",
carried_opt(&p.elevation_cf),
"no scope tracking-correction field; the recipient's solution will not be corrected \
for how this scope actually tracks",
);
push(
"windage_cf",
carried_opt(&p.windage_cf),
"no scope tracking-correction field — see `elevation_cf`",
);
push(
"elevation_click",
carried_opt(&p.elevation_click),
"the file stores zeroing as raw device click COUNTS and never the click size itself; \
a graduation written here would be read against the recipient device's own",
);
push(
"windage_click",
carried_opt(&p.windage_click),
"no click-size field — see `elevation_click`",
);
push(
"zero_poi_up_m",
carried_opt(&p.zero_poi_up_m),
"the file's only zero-offset slots are zero_x/zero_y, which are device-scoped click \
counts with no click size recorded; converting a linear offset into clicks with THIS \
profile's graduation would be misread by any device graduated differently",
);
push(
"zero_poi_right_m",
carried_opt(&p.zero_poi_right_m),
"device-scoped click counts only — see `zero_poi_up_m`",
);
push(
"sight_offset_lateral_m",
carried_opt(&p.sight_offset_lateral_m),
"the format models sight height only; there is no lateral sight-to-bore offset",
);
push(
"zero_sets",
non_empty(&p.zero_sets).map(|sets| {
sets.iter()
.map(|z| z.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}),
"one profile is one rifle with one zero in this format; alternate zeros and per-load \
dial corrections have nowhere to go",
);
push(
"reticle",
p.reticle
.as_ref()
.map(|r| format!("\"{}\" ({} mark(s))", r.name, r.marks.len())),
"the format describes the optic by sight height and click counts, not by its reticle",
);
push(
"clicks_per_revolution",
carried_opt(&p.clicks_per_revolution),
"no turret-mechanics fields in the format",
);
push(
"zero_stop",
carried_opt(&p.zero_stop),
"no turret-mechanics fields — see `clicks_per_revolution`",
);
push(
"elevation_travel_up_mil",
carried_opt(&p.elevation_travel_up_mil),
"no turret travel limits in the format",
);
push(
"elevation_travel_down_mil",
carried_opt(&p.elevation_travel_down_mil),
"no turret travel limits — see `elevation_travel_up_mil`",
);
push(
"windage_travel_left_mil",
carried_opt(&p.windage_travel_left_mil),
"no turret travel limits — see `elevation_travel_up_mil`",
);
push(
"windage_travel_right_mil",
carried_opt(&p.windage_travel_right_mil),
"no turret travel limits — see `elevation_travel_up_mil`",
);
push(
"turret_elevation_dialed_mil",
carried_opt(&p.turret_elevation_dialed_mil),
"no field for what the turrets currently read",
);
push(
"turret_windage_dialed_mil",
carried_opt(&p.turret_windage_dialed_mil),
"no field for what the turrets currently read",
);
push(
"hold_bound_up_mil",
carried_opt(&p.hold_bound_up_mil),
"no reticle hold bounds in the format — see `reticle`",
);
push(
"hold_bound_down_mil",
carried_opt(&p.hold_bound_down_mil),
"no reticle hold bounds — see `reticle`",
);
push(
"hold_bound_left_mil",
carried_opt(&p.hold_bound_left_mil),
"no reticle hold bounds — see `reticle`",
);
push(
"hold_bound_right_mil",
carried_opt(&p.hold_bound_right_mil),
"no reticle hold bounds — see `reticle`",
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
const FULL_FIXTURE: &str = r#"{
"name": "export-fixture",
"velocity": 792.0,
"bc": 0.381,
"mass": 19.439673,
"diameter": 8.5852,
"drag_model": "G7",
"twist_rate": 254.0,
"sight_height": 90.0,
"zero_distance": 100.0,
"units": "metric",
"temperature": 15.0,
"pressure": 1000.0,
"humidity": 50.0,
"altitude": 300.0,
"bullet_name": "300GR OTM",
"created": "1755400000",
"wind_speed": 4.4704,
"wind_direction": 90.0,
"shooting_angle": -5.0,
"auto_zero": 100.0,
"twist_right": false,
"use_bc_segments": true,
"bullet_length": 45.72,
"elevation_click": "0.1mil",
"windage_click": "0.25moa",
"bc_segments": [
{"bc": 0.381, "velocity_mps": 792.0},
{"bc": 0.360, "velocity_mps": 500.0}
],
"drag_curve": [
{"mach": 0.5, "cd": 0.23},
{"mach": 3.0, "cd": 0.28}
],
"dsf_points": [{"mach": 0.9, "dsf": 1.04}],
"bc_reference": "army-standard-metro",
"pressure_reference": "qnh",
"density_altitude": 500.0,
"zero_poi_up_m": 0.05,
"zero_poi_right_m": -0.02,
"sight_offset_lateral_m": 0.01,
"elevation_cf": 0.97,
"windage_cf": 1.02,
"zero_sets": [{"name": "suppressed", "zero_distance": 200.0, "poi_up_mil": -0.3}],
"reticle": {
"name": "mil-grid 0.5/10",
"focal_plane": "ffp",
"reference_magnification": 10.0,
"marks": [{"down_mil": 0.0, "right_mil": 0.0, "kind": "center"}]
},
"clicks_per_revolution": 100,
"zero_stop": true,
"elevation_travel_up_mil": 26.0,
"elevation_travel_down_mil": 4.0,
"windage_travel_left_mil": 12.0,
"windage_travel_right_mil": 12.0,
"turret_elevation_dialed_mil": 5.4,
"turret_windage_dialed_mil": -0.2,
"hold_bound_up_mil": 5.0,
"hold_bound_down_mil": 10.0,
"hold_bound_left_mil": 6.0,
"hold_bound_right_mil": 6.0
}"#;
fn full_profile() -> ProfileData {
serde_json::from_str(FULL_FIXTURE).expect("fixture must load")
}
fn carryable_profile() -> ProfileData {
let mut p = full_profile();
p.altitude = 0.0;
p.created = None;
p.wind_speed = None;
p.wind_direction = None;
p.shooting_angle = None;
p.use_bc_segments = None;
p.elevation_click = None;
p.windage_click = None;
p.drag_curve = None;
p.dsf_points = None;
p.bc_reference = None;
p.pressure_reference = None;
p.density_altitude = None;
p.zero_poi_up_m = None;
p.zero_poi_right_m = None;
p.sight_offset_lateral_m = None;
p.elevation_cf = None;
p.windage_cf = None;
p.zero_sets = None;
p.reticle = None;
p.clicks_per_revolution = None;
p.zero_stop = None;
p.elevation_travel_up_mil = None;
p.elevation_travel_down_mil = None;
p.windage_travel_left_mil = None;
p.windage_travel_right_mil = None;
p.turret_elevation_dialed_mil = None;
p.turret_windage_dialed_mil = None;
p.hold_bound_up_mil = None;
p.hold_bound_down_mil = None;
p.hold_bound_left_mil = None;
p.hold_bound_right_mil = None;
p
}
fn reimport(export: &A7pExport) -> ProfileData {
let doc = parse_a7p(&export.bytes).expect("our own file must parse");
assert!(
matches!(doc.envelope, EnvelopeStatus::Verified),
"the MD5 envelope we wrote must verify"
);
assert!(
doc.unknown_fields.is_empty(),
"the encoder must not emit field numbers the parser does not know: {:?}",
doc.unknown_fields
);
map_a7p_to_profile(&doc, None, None)
.expect("our own file must map")
.profile
}
fn close(a: f64, b: f64, what: &str) {
assert!(
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0),
"{what}: {a} != {b}"
);
}
#[test]
fn round_trip_through_the_importer_preserves_every_carried_field() {
let source = carryable_profile();
let export = export_a7p(&source).expect("export");
let back = reimport(&export);
assert_eq!(back.name, source.name);
assert_eq!(back.drag_model, source.drag_model);
assert_eq!(back.units, source.units);
assert_eq!(back.bullet_name, source.bullet_name);
assert_eq!(back.twist_right, source.twist_right);
close(back.velocity, source.velocity, "velocity");
close(back.bc, source.bc, "bc");
close(back.mass, source.mass, "mass");
close(back.diameter, source.diameter, "diameter");
close(back.temperature, source.temperature, "temperature");
close(back.pressure, source.pressure, "pressure");
close(back.humidity, source.humidity, "humidity");
close(
back.twist_rate.unwrap(),
source.twist_rate.unwrap(),
"twist",
);
close(
back.sight_height.unwrap(),
source.sight_height.unwrap(),
"sight_height",
);
close(
back.bullet_length.unwrap(),
source.bullet_length.unwrap(),
"bullet_length",
);
close(
back.zero_distance.unwrap(),
source.zero_distance.unwrap(),
"zero_distance",
);
close(
back.auto_zero.unwrap(),
source.auto_zero.unwrap(),
"auto_zero",
);
let (from, to) = (
source.bc_segments.as_ref().unwrap(),
back.bc_segments.as_ref().unwrap(),
);
assert_eq!(to.len(), from.len(), "bc_segments length");
for (a, b) in from.iter().zip(to.iter()) {
close(b.bc, a.bc, "bc_segments.bc");
close(b.velocity_mps, a.velocity_mps, "bc_segments.velocity_mps");
}
assert!(back.drag_curve.is_none());
assert_eq!(
export.warnings.len(),
2,
"unexpected warnings: {:?}",
export.warnings
);
}
#[test]
fn not_carried_and_carried_cover_every_profile_field() {
let profile = full_profile();
let serialized = serde_json::to_value(&profile).expect("serialize");
let keys: std::collections::BTreeSet<String> = serialized
.as_object()
.expect("a profile is a JSON object")
.keys()
.cloned()
.collect();
let export = export_a7p(&profile).expect("export");
let mut accounted: std::collections::BTreeSet<String> =
CARRIED_FIELDS.iter().map(|f| f.to_string()).collect();
for entry in &export.not_carried {
assert!(
accounted.insert(entry.field.to_string()),
"{} is in BOTH the carried and not-carried lists",
entry.field
);
}
let unaccounted: Vec<&String> = keys.difference(&accounted).collect();
assert!(
unaccounted.is_empty(),
"ProfileData fields in neither list (add them to CARRIED_FIELDS or to \
not_carried_for): {unaccounted:?}"
);
let invented: Vec<&String> = accounted.difference(&keys).collect();
assert!(
invented.is_empty(),
"listed names that are not ProfileData fields: {invented:?}"
);
assert!(
!accounted.contains("switches"),
"switches is not a ProfileData field and must not appear in either list"
);
}
#[test]
fn not_carried_names_real_populated_fields_with_their_values() {
let profile = full_profile();
let serialized = serde_json::to_value(&profile).expect("serialize");
let object = serialized.as_object().expect("object");
let export = export_a7p(&profile).expect("export");
let dropped = export.dropped_fields();
assert!(!dropped.is_empty(), "this profile drops plenty");
for name in &dropped {
assert!(
object.contains_key(*name),
"{name} is not a ProfileData field"
);
}
for expected in [
"altitude",
"dsf_points",
"elevation_cf",
"zero_sets",
"reticle",
"elevation_click",
"zero_poi_up_m",
"bc_reference",
"clicks_per_revolution",
"hold_bound_up_mil",
] {
assert!(dropped.contains(&expected), "{expected} must be reported");
}
let dsf = export
.not_carried
.iter()
.find(|n| n.field == "dsf_points")
.expect("dsf_points entry");
assert!(dsf.populated);
assert_eq!(dsf.value.as_deref(), Some("1 drop-scale-factor point(s)"));
assert!(!dsf.reason.is_empty());
let mut empty = full_profile();
empty.dsf_points = None;
let empty_export = export_a7p(&empty).expect("export");
let dsf = empty_export
.not_carried
.iter()
.find(|n| n.field == "dsf_points")
.expect("dsf_points is listed even when unset");
assert!(!dsf.populated);
assert_eq!(dsf.value, None);
}
#[test]
fn an_imperial_profile_and_its_metric_twin_encode_identically() {
let metric = carryable_profile();
let mut imperial = metric.clone();
imperial.units = "imperial".to_string();
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;
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");
let b = export_a7p(&imperial).expect("imperial export");
assert_eq!(
a.bytes, b.bytes,
"the same rifle must encode identically whatever units it was saved in"
);
}
#[test]
fn a_custom_drag_curve_round_trips_as_mach_cd_rows() {
let mut profile = carryable_profile();
profile.drag_model = "CUSTOM".to_string();
profile.bc = 0.0; profile.bc_segments = None;
profile.drag_curve = Some(vec![
crate::profile::ProfileDragPoint {
mach: 0.5,
cd: 0.23,
},
crate::profile::ProfileDragPoint {
mach: 3.0,
cd: 0.28,
},
]);
let export = export_a7p(&profile).expect("export");
let back = reimport(&export);
assert_eq!(back.drag_model, "CUSTOM");
assert_eq!(back.bc, 0.0);
let curve = back.drag_curve.as_ref().expect("curve survives");
assert_eq!(curve.len(), 2);
close(curve[0].mach, 0.5, "mach");
close(curve[0].cd, 0.23, "cd");
close(curve[1].mach, 3.0, "mach");
close(curve[1].cd, 0.28, "cd");
}
#[test]
fn a_drag_model_the_format_cannot_name_is_refused() {
let mut profile = carryable_profile();
profile.drag_model = "G5".to_string();
match export_a7p(&profile) {
Err(A7pExportError::DragModel(m)) => assert_eq!(m, "G5"),
other => panic!("expected a DragModel refusal, got {other:?}"),
}
}
#[test]
fn a_value_that_does_not_fit_the_fixed_point_grid_is_warned_about() {
let mut profile = carryable_profile();
profile.sight_height = Some(50.8);
let export = export_a7p(&profile).expect("export");
assert!(
export
.warnings
.iter()
.any(|w| w.starts_with("sight_height:") && w.contains("51")),
"expected a sight_height quantization warning, got {:?}",
export.warnings
);
let back = reimport(&export);
assert_eq!(back.sight_height, Some(51.0));
}
#[test]
fn an_unusable_measurement_is_a_named_field_error() {
let mut profile = carryable_profile();
profile.velocity = 0.0;
match export_a7p(&profile) {
Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "velocity"),
other => panic!("expected a Field error, got {other:?}"),
}
let mut profile = carryable_profile();
profile.units = "furlongs".to_string();
assert!(matches!(
export_a7p(&profile),
Err(A7pExportError::Units(_))
));
}
#[test]
fn custom_without_a_curve_is_refused() {
let mut profile = carryable_profile();
profile.drag_model = "CUSTOM".to_string();
profile.drag_curve = None;
match export_a7p(&profile) {
Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "drag_curve"),
other => panic!("expected a drag_curve error, got {other:?}"),
}
}
fn switch_entries(bytes: &[u8]) -> Vec<Vec<(u32, u64)>> {
fn varint(b: &[u8], i: &mut usize) -> u64 {
let (mut v, mut shift) = (0u64, 0u32);
loop {
let byte = b[*i];
*i += 1;
v |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return v;
}
shift += 7;
}
}
fn fields(b: &[u8]) -> Vec<(u32, u64, &[u8])> {
let mut i = 0usize;
let mut out = Vec::new();
while i < b.len() {
let key = varint(b, &mut i);
let (number, wire) = ((key >> 3) as u32, key & 7);
match wire {
0 => {
let v = varint(b, &mut i);
out.push((number, v, &b[0..0]));
}
2 => {
let n = varint(b, &mut i) as usize;
out.push((number, 0, &b[i..i + n]));
i += n;
}
other => panic!("unexpected wire type {other}"),
}
}
out
}
let payload = &bytes[32..];
let profile = fields(payload)
.into_iter()
.find(|(n, _, _)| *n == F_PAYLOAD_PROFILE)
.expect("payload carries a profile")
.2;
fields(profile)
.into_iter()
.filter(|(n, _, _)| *n == F_SWITCHES)
.map(|(_, _, body)| {
fields(body)
.into_iter()
.map(|(n, v, _)| (n, v))
.collect::<Vec<_>>()
})
.collect()
}
#[test]
fn every_export_carries_the_minimum_four_switch_positions() {
for profile in [carryable_profile(), full_profile()] {
let export = export_a7p(&profile).expect("export");
let switches = switch_entries(&export.bytes);
assert!(
switches.len() >= MIN_SWITCHES,
"exported {} switch positions; the ecosystem refuses fewer than \
{MIN_SWITCHES}",
switches.len()
);
for (i, entry) in switches.iter().enumerate() {
let (zoom, distance) = PLACEHOLDER_SWITCHES[i];
assert_eq!(
entry,
&vec![
(F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX as u64),
(F_SW_ZOOM, zoom as u64),
(F_SW_DISTANCE, distance as u64),
],
"switch {i}"
);
}
assert!(
export
.warnings
.iter()
.any(|w| w.contains("PLACEHOLDERS") && w.contains("switch")),
"the placeholder switches must be declared: {:?}",
export.warnings
);
}
}
#[test]
fn fields_the_format_requires_are_refused_when_absent_not_invented() {
for (field, clear) in [
(
"bullet_length",
Box::new(|p: &mut ProfileData| p.bullet_length = None)
as Box<dyn Fn(&mut ProfileData)>,
),
(
"zero_distance",
Box::new(|p: &mut ProfileData| p.zero_distance = None),
),
(
"sight_height",
Box::new(|p: &mut ProfileData| p.sight_height = None),
),
] {
let mut profile = carryable_profile();
clear(&mut profile);
match export_a7p(&profile) {
Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
other => panic!("{field}: expected a refusal, got {other:?}"),
}
}
}
#[test]
fn an_absent_twist_rate_warns_rather_than_refusing_and_that_is_deliberate() {
let mut profile = carryable_profile();
profile.twist_rate = None;
let export = export_a7p(&profile).expect("an unknown twist must still export");
assert!(
export
.warnings
.iter()
.any(|w| w.starts_with("twist_rate:") && w.contains("r_twist")),
"an absent twist must be reported: {:?}",
export.warnings
);
let back = reimport(&export);
assert_eq!(back.twist_rate, Some(0.0));
}
#[test]
fn values_outside_what_the_ecosystem_accepts_are_refused_by_name() {
for (field, mutate) in [
(
"velocity",
Box::new(|p: &mut ProfileData| p.velocity = 5.0) as Box<dyn Fn(&mut ProfileData)>,
),
(
"pressure",
Box::new(|p: &mut ProfileData| p.pressure = 100.0),
),
(
"temperature",
Box::new(|p: &mut ProfileData| p.temperature = 250.0),
),
(
"zero_distance",
Box::new(|p: &mut ProfileData| p.zero_distance = Some(5000.0)),
),
(
"sight_height",
Box::new(|p: &mut ProfileData| p.sight_height = Some(9000.0)),
),
] {
let mut profile = carryable_profile();
mutate(&mut profile);
match export_a7p(&profile) {
Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
other => panic!("{field}: expected a refusal, got {other:?}"),
}
}
}
}