Skip to main content

ballistics_engine/profile_export/
a7p.rs

1//! [`ProfileData`] -> ArcherBC2 `.a7p` encoder, and the honest account of what a
2//! `.a7p` file has no place to put.
3//!
4//! The inverse of `profile_import::a7p`. File layout (32 ASCII hex characters of
5//! MD5 over the remainder, then a proto3 `Payload { Profile profile = 1; }`
6//! message), the field numbers, and the fixed-point scale factors are the same
7//! interoperability facts the parser was written from; nothing is vendored from
8//! the upstream a7p package (GPL-3.0 as distributed; this crate is MIT OR
9//! Apache-2.0).
10//!
11//! # The not-carried list is the point
12//!
13//! `.a7p` is somebody else's format, built around one rifle, one load and one
14//! device. [`ProfileData`] carries a good deal it has no slot for. Exporting
15//! therefore returns [`A7pExport::not_carried`] alongside the bytes: EVERY
16//! `ProfileData` field this encoder cannot put in the file, named exactly as the
17//! struct and the saved-profile JSON spell it, each with whether this particular
18//! profile actually held a value and what that value was.
19//!
20//! The list is unconditional — a field that cannot be carried appears whether or
21//! not it was set — because a caller that only ever hears about populated fields
22//! cannot distinguish "this profile had no scope tracking factor" from "the
23//! tracking factor was silently thrown away". `populated` makes that distinction
24//! explicit, and [`A7pExport::dropped_fields`] narrows the list to the ones that
25//! really did lose data on this export.
26//!
27//! [`CARRIED_FIELDS`] names the other side of the same partition, and a test
28//! asserts the two together account for every serialized `ProfileData` key. A
29//! field added to `ProfileData` later cannot quietly join the drop set: the test
30//! fails until somebody puts it in one list or the other.
31//!
32//! Solve-time effect flags (spin drift, Magnus, Coriolis, aerodynamic jump,
33//! wind shear model) are deliberately absent from both lists: they are not
34//! `ProfileData` fields at all, so they were never part of a profile to lose.
35//!
36//! One case sits deliberately in [`A7pExport::warnings`] rather than in the
37//! not-carried list, and a caller showing a drop list should show the warnings
38//! beside it. A `.a7p` coef row means (BC, m/s) for G1/G7 and (Cd, Mach) for
39//! CUSTOM, never both at once, so a profile carrying a `drag_curve` under a G7
40//! `drag_model` (or `bc_segments` under CUSTOM) loses the half its own
41//! `drag_model` does not select. That is a contradictory profile rather than an
42//! unsupported field — `drag_model` already decides which half is live, and
43//! both names stay in [`CARRIED_FIELDS`] because either one CAN travel — so the
44//! warning names the dropped half instead of the list claiming the format has no
45//! room for it.
46//!
47//! # What "valid" means here
48//!
49//! The minimum switch count, the value ranges and the mandatory fields enforced
50//! below are the upstream `a7p` package's VALIDATOR's rules, established
51//! black-box against it; the ArcherBC2 device itself may well be stricter, so
52//! nothing here should be read as the full set of what a device will accept.
53//!
54//! # `switches` is in neither list, on purpose
55//!
56//! The partition above is over `ProfileData` fields — things a shooter has that
57//! may or may not reach the file. `.a7p`'s `switches` (device zoom/range presets)
58//! runs the other way: a DESTINATION field with no source on our side. It is not
59//! carried and it is not dropped, because there was never anything of the
60//! shooter's in it, so putting it in either list would answer a question nobody
61//! asked and crowd out the fields that really did lose data.
62//!
63//! It still cannot be skipped. The ecosystem refuses a file with fewer than
64//! [`MIN_SWITCHES`] of them, so every export writes that many placeholders and
65//! names them as placeholders in [`A7pExport::warnings`] — see
66//! [`PLACEHOLDER_SWITCHES`] for why they are the upstream tool's own factory
67//! values rather than anything computed from the profile.
68//!
69//! # Do not extend the format
70//!
71//! There is no escape hatch here for the dropped fields and there must not be
72//! one. Smuggling our data into unused field numbers would produce files that
73//! Archer's own tools misread, and would undo the reason the parser was written
74//! cleanroom in the first place.
75
76use 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
84// Fixed-point scale factors (physical value * SCALE = the stored integer). The
85// importer holds the identical table; they are duplicated rather than shared
86// because the reader's copy is private to its own subtree, and the round-trip
87// test at the bottom of this file is what keeps the two in lockstep — a scale
88// edited on one side alone fails it immediately.
89const SCALE_TWIST: f64 = 100.0; // inches/turn
90const SCALE_VELOCITY: f64 = 10.0; // m/s
91const SCALE_DIMENSION: f64 = 1000.0; // inches
92const SCALE_WEIGHT: f64 = 10.0; // grains
93const SCALE_PRESSURE: f64 = 10.0; // hPa
94const SCALE_COEF: f64 = 10_000.0; // BC or Cd
95const SCALE_DISTANCE: f64 = 100.0; // meters
96const SCALE_MACH: f64 = 10_000.0; // Mach (CUSTOM coef rows only)
97
98// Profile field numbers, matching the parser's `match field.number` arms.
99const 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;
115// Sub-message field numbers inside one coef row.
116const F_ROW_BC_CD: u32 = 1;
117const F_ROW_MV: u32 = 2;
118// Sub-message field numbers inside one switch position. Confirmed black-box:
119// the upstream package's public factory API was asked for files with known
120// switch values and the resulting BYTES were decoded here — no schema file was
121// read and nothing from that package is vendored. Same footing as the field
122// numbers and scale factors the importer documents.
123const F_SW_C_IDX: u32 = 1;
124const F_SW_ZOOM: u32 = 3;
125const F_SW_DISTANCE: u32 = 4;
126// Fields 2 (reticle_idx) and 5 (distance_from) are identified and deliberately
127// unused: both placeholder values are the proto3 default, so they are omitted
128// like every other default. Named here so a later reader does not have to
129// re-derive them.
130// Payload wrapper.
131const F_PAYLOAD_PROFILE: u32 = 1;
132
133/// `.a7p` REQUIRES at least four switch positions. This is not a stylistic
134/// preference of the ecosystem's: the upstream validator refuses a file with
135/// three or fewer outright (`'[] is too short'`), so canonical proto3
136/// default-omission — correct for every other field here — produces a file
137/// nothing in the Archer world will open.
138///
139/// Verified black-box by bisection against the upstream package: 0, 1, 2 and 3
140/// switches are all refused; 4 and 5 are accepted.
141///
142/// DO NOT "clean up" the placeholders below back to an empty list. That silently
143/// makes every file this crate writes invalid, and nothing in our own round trip
144/// would notice, because our parser is happy either way.
145const MIN_SWITCHES: usize = 4;
146
147/// The four placeholder switch positions, as `(zoom, distance)` in the file's
148/// own `SCALE_DISTANCE` fixed point — 100 m, 200 m, 300 m, 1000 m.
149///
150/// PLACEHOLDERS, NOT DATA. A switch position is a device UI preset: a zoom level
151/// and the range the reticle is set up for. `ProfileData` has no such concept,
152/// so there is nothing of the shooter's to put here and nothing is derived from
153/// their profile. These are the values the upstream package's OWN factory
154/// writes, chosen precisely so that what we emit is the ecosystem's neutral
155/// default rather than a range card we invented and presented as theirs — a
156/// fabricated preset at a range the shooter never shot would look exactly like
157/// one they had. Every export says so in [`A7pExport::warnings`].
158const PLACEHOLDER_SWITCHES: [(i32, i32); MIN_SWITCHES] =
159    [(1, 10_000), (2, 20_000), (3, 30_000), (4, 100_000)];
160
161/// The `c_idx` the upstream factory writes on every placeholder position: the
162/// sentinel for "no distance-list index selected", which is what makes these
163/// positions inert rather than pointing at a range-card entry.
164const PLACEHOLDER_SWITCH_C_IDX: i32 = 255;
165
166// Value ranges the ArcherBC2 ecosystem enforces, in the FILE's own integer
167// units. Derived black-box by bisecting the upstream validator (its public API
168// in, accept/refuse out); nothing from that package is vendored. These are
169// REFUSALS here rather than warnings, because a file outside them is one the
170// recipient cannot open at all — that is not a lossy export, it is no export,
171// and reporting it as a success with a warning would be the worst of both.
172const BOUND_SIGHT_HEIGHT: (i32, i32) = (-5_000, 5_000); // mm
173const BOUND_TWIST: (i32, i32) = (0, 10_000); // in/turn x100
174const BOUND_VELOCITY: (i32, i32) = (100, 30_000); // m/s x10
175const BOUND_TEMPERATURE: (i32, i32) = (-100, 100); // C
176const BOUND_PRESSURE: (i32, i32) = (3_000, 15_000); // hPa x10
177const BOUND_HUMIDITY: (i32, i32) = (0, 100); // %
178const BOUND_DIAMETER: (i32, i32) = (1, 50_000); // in x1000
179const BOUND_WEIGHT: (i32, i32) = (10, 65_535); // gr x10
180const BOUND_LENGTH: (i32, i32) = (10, 200_000); // in x1000
181const BOUND_DISTANCE: (i32, i32) = (100, 300_000); // m x100
182                                                   // Deliberately unbounded here: coef rows carry no per-value limits the validator
183                                                   // enforces, and the 200-entry cap on the distances list cannot be reached by an
184                                                   // exporter that writes exactly one distance.
185const BOUND_NONE: (i32, i32) = (i32::MIN, i32::MAX);
186
187// Unit factors. Deliberately a local copy of the handful of factors the CLI's
188// `UnitConverter` (main.rs) uses for the same quantities: this module must
189// compile for wasm32 and for lib-only builds, where main.rs does not exist. Any
190// change to a factor here without the matching change there is a bug in this
191// file, not a licence to diverge.
192const MM_PER_INCH: f64 = 25.4;
193const METERS_PER_YARD: f64 = 0.9144;
194const HPA_PER_INHG: f64 = 33.8639;
195
196/// One `ProfileData` field the `.a7p` format has no place for.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
198pub struct NotCarried {
199    /// The `ProfileData` field name, spelled exactly as the struct and the saved
200    /// profile JSON spell it. Never a prose label: a caller has to be able to
201    /// look it up in the document it was handed.
202    pub field: &'static str,
203    /// Whether THIS profile actually held something in that field. `false` means
204    /// nothing was lost here; `true` means data went missing and the shooter's
205    /// recipient will not receive it.
206    pub populated: bool,
207    /// What was in the field, when `populated` — short enough to show a user.
208    pub value: Option<String>,
209    /// Why `.a7p` cannot take it.
210    pub reason: &'static str,
211}
212
213/// The `ProfileData` fields this encoder DOES put in the file. The complement of
214/// the not-carried list; the two partition the struct, and
215/// `not_carried_and_carried_cover_every_profile_field` proves it.
216///
217/// `units` is in here because it is consumed rather than dropped: it decides how
218/// every other value is read on the way out, and the file itself is written in
219/// the format's own fixed units. `auto_zero` is in here because it shares the
220/// file's single zero distance with `zero_distance`; when the two disagree there
221/// is only one slot, `zero_distance` wins, and the export warns.
222pub 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/// A `.a7p` file plus everything the caller has to be told about it.
245#[derive(Debug, Clone)]
246pub struct A7pExport {
247    /// The complete `.a7p` file: MD5 hex envelope followed by the proto3 payload.
248    pub bytes: Vec<u8>,
249    /// Every `ProfileData` field `.a7p` cannot carry — see the module doc.
250    pub not_carried: Vec<NotCarried>,
251    /// Losses and approximations INSIDE the fields that were carried: values
252    /// rounded onto the format's fixed-point grid, destination fields left at
253    /// their format default because `ProfileData` has no equivalent, and
254    /// disagreements the encoder had to resolve.
255    pub warnings: Vec<String>,
256}
257
258impl A7pExport {
259    /// The not-carried fields that this profile actually had data in — the list
260    /// to put in front of a shooter, as opposed to the full drop surface.
261    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/// Why an export could not happen at all. Distinct from the lossy-but-successful
271/// outcomes above: these are profiles that cannot be expressed as a `.a7p` file
272/// without inventing something.
273#[derive(Debug, Clone, PartialEq)]
274pub enum A7pExportError {
275    /// The profile's `units` string is neither `imperial` nor `metric`.
276    Units(String),
277    /// A drag model `.a7p` has no enum value for. Refused rather than written as
278    /// G1, which would silently hand the recipient different physics under a
279    /// familiar-looking label.
280    DragModel(String),
281    /// A field whose value cannot be encoded: non-finite, non-positive where the
282    /// format requires a real measurement, or past the range of the int32 the
283    /// format stores it in.
284    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
309/// Accumulates the encoder's warnings while values are converted.
310struct Lossy {
311    warnings: Vec<String>,
312}
313
314impl Lossy {
315    /// Convert one physical value to the stored integer, recording a warning
316    /// when it does not land on the format's fixed-point grid. `unit` names the
317    /// unit the value is in AT THIS POINT (the format's, not the profile's), so
318    /// a warning reads in the numbers actually written to the file.
319    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        // The ecosystem's own limits, checked on the STORED integer because that
346        // is the number the recipient's validator sees.
347        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        // Relative tolerance: the conversions above (mm -> inches, grams ->
360        // grains) do not produce exact binary values even when the underlying
361        // measurement sits precisely on the grid, and warning about the last bit
362        // of a double would make this list noise instead of information.
363        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
388/// `Some(value.to_string())` when the field holds something, `None` when it does
389/// not — which is precisely [`NotCarried::populated`].
390fn carried_opt<T: std::fmt::Display>(v: &Option<T>) -> Option<String> {
391    v.as_ref().map(|v| v.to_string())
392}
393
394/// An optional list counts as populated only when it is non-empty: `Some(vec![])`
395/// lost nothing, and reporting it as a drop would be the same false alarm the
396/// unconditional list exists to avoid.
397fn non_empty<T>(v: &Option<Vec<T>>) -> Option<&Vec<T>> {
398    v.as_ref().filter(|v| !v.is_empty())
399}
400
401/// Encode a saved profile as an ArcherBC2 `.a7p` file.
402///
403/// Lossy by construction — see the module doc. The returned
404/// [`A7pExport::not_carried`] is the contract; callers are expected to show it,
405/// not to discard it.
406pub 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    // --- profile values, converted into the units .a7p stores ----------------
413    // The format is fixed-unit (metric atmosphere, imperial bullet dimensions),
414    // so the profile's own `units` is resolved here and never travels.
415    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    // REQUIRED, not optional: the ecosystem's validator enforces a minimum
434    // b_length, so a file written without one is refused outright. There is
435    // nothing honest to substitute — a bullet length drives the recipient's own
436    // stability and spin-drift model, and a plausible-looking invented one would
437    // be indistinguishable from a measured one — so this refuses by name and
438    // tells the caller to set the field.
439    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    // `twist_rate` and `sight_height` are DELIBERATELY handled differently, and
455    // the difference is decided rather than missed. Both are `Option` here, both
456    // become 0 in the file when absent, and the validator accepts 0 for both — so
457    // no rule derived from the validator alone separates them. What separates
458    // them is what a 0 MEANS to the recipient:
459    //
460    //   * `sc_height` = 0 is a 0 mm mount, which no rifle has. It is an absence
461    //     encoded as a number the recipient cannot tell from a measurement, and it
462    //     moves every solution they compute. Refused below, like `bullet_length`.
463    //   * `r_twist` = 0 may legitimately be this format's way of saying "twist
464    //     unknown", in which case refusing would reject profiles the ecosystem
465    //     handles perfectly well. We do not know that it is. We have the
466    //     ecosystem's VALIDATOR, which accepts 0, and not the device, so this
467    //     warns and writes nothing rather than refusing.
468    //
469    // What would settle it: observing how ArcherBC2 itself treats a profile with
470    // r_twist = 0 — whether it presents the twist as unknown or silently solves
471    // with a zero. If it is the latter, `twist_rate` should join `sight_height`
472    // and `bullet_length` as a refusal. Until somebody can watch the device do it,
473    // this asymmetry stands on a stated unknown, not on an oversight.
474    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    // --- drag: bc_type + coef rows ------------------------------------------
517    // The one branch where the two sides of the format genuinely disagree about
518    // what a coef row means: (BC, m/s) for G1/G7, (Cd, Mach) for CUSTOM.
519    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            // `bc_field` follows the data: a warning about a rounded coefficient
524            // has to name the field the number actually came from, or the reader
525            // goes looking in the wrong place.
526            let (segments, bc_field): (Vec<(f64, f64)>, &'static str) = match &profile.bc_segments {
527                // `velocity_mps` is pinned to SI regardless of the profile's
528                // `units` (see ProfileBcSegment), so it needs no conversion.
529                Some(segments) if !segments.is_empty() => (
530                    segments.iter().map(|s| (s.bc, s.velocity_mps)).collect(),
531                    "bc_segments.bc",
532                ),
533                // No schedule: the scalar BC at the muzzle, which is exactly how
534                // the importer reconstructs a scalar BC (the fastest row).
535                _ => (
536                    vec![(require_positive("bc", profile.bc)?, velocity_mps)],
537                    "bc",
538                ),
539            };
540            // The file has no separate scalar-BC slot: a reader rebuilds one from
541            // the fastest coef row (that is exactly what the importer does). When
542            // a schedule is present and the profile's own scalar `bc` disagrees
543            // with its fastest row, the recipient will read the row — say so
544            // rather than let the number change in transit unannounced.
545            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    // --- zero distance -------------------------------------------------------
624    // .a7p keeps a list of range-card distances and zeroes at one INDEX into it.
625    // A ProfileData has one zero distance and no card, so the list is that one
626    // distance at index 0 (the index is then the proto3 default and is omitted).
627    // REQUIRED for the same reason as `bullet_length` above: the ecosystem's
628    // validator refuses an empty distances list, and the one distance we have to
629    // put in it is the zero distance. Guessing one would hand the recipient a
630    // rifle zeroed somewhere its owner never zeroed it.
631    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    // `auto_zero` shares the file's single zero distance with `zero_distance`.
645    // When they disagree there is only one slot, and `zero_distance` wins.
646    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    // Destination fields with no ProfileData source. Named for the same reason
656    // the not-carried list exists, in the other direction: the recipient's
657    // device will show these, and they will be the format's defaults.
658    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    // `switches` is the one destination field that could NOT be left at its
666    // default, because the ecosystem refuses a file with fewer than four. It is
667    // therefore filled with placeholders, and the caller is told so in the same
668    // breath — the alternative to saying it out loud is a shooter's friend seeing
669    // four range presets that look like the shooter's own.
670    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    // --- encode --------------------------------------------------------------
679    // proto3 canonical form omits scalar fields equal to their default, which is
680    // what other tools in this ecosystem are most likely to have been tested
681    // against. The parser reads present-and-zero identically, so this is a
682    // compatibility choice rather than a semantic one.
683    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    // Unconditional, unlike the optional fields above: a sight height is required
691    // to get this far, so there is no "absent" case left to omit, and writing an
692    // explicit 0 for a mount that rounds to nothing keeps this field's presence
693    // in the file matching its presence in the profile. A present-and-zero
694    // scalar reads identically to an omitted one, so this costs a byte and no
695    // compatibility.
696    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    // TwistDir: RIGHT = 0 (the proto3 default, omitted), LEFT = 1. An unset
772    // `twist_right` means the profile never recorded a direction; the format has
773    // no way to say that, so it becomes the format's own default of RIGHT and
774    // the not-carried list is not the right place to say so — the warning is.
775    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    // Switch positions (field 25) precede the distances list so the message stays
788    // in ascending field order, which is what a canonical serializer emits.
789    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        // reticle_idx (field 2) and distance_from (field 5) are both 0 here — the
793        // first reticle, and "distance is a value rather than an index into the
794        // distances list" — so they are omitted like every other proto3 default.
795        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        // c_zero_distance_idx (field 14) would be 0 here — the only entry — which
802        // is the proto3 default, so it is omitted like every other default above.
803        // A reader that sees no index resolves it to 0 as well.
804    }
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
826/// Build the unconditional not-carried list for one profile. Every entry names a
827/// real `ProfileData` field; see the module doc for why absent fields are listed
828/// too.
829fn 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` is not an Option, so "populated" has to mean something: it
842        // means non-zero. At sea level there is nothing to lose.
843        "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    /// Every `ProfileData` field populated, so the completeness test below sees
1024    /// the whole serialized key set (the `skip_serializing_if = Option::is_none`
1025    /// fields only appear when they hold something). Metric, and every carried
1026    /// measurement sits exactly on the `.a7p` fixed-point grid so the round-trip
1027    /// assertions can be about the mapping rather than about rounding.
1028    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    /// The same rifle with nothing in it that `.a7p` cannot take — the profile
1097    /// the round-trip assertions are about.
1098    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        // G7 + a CUSTOM curve is a contradiction the file cannot hold both halves
1109        // of; the round trip is about the G7 half.
1110        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    /// The load-bearing one: everything `CARRIED_FIELDS` claims survives really
1161    /// does survive a trip out through the encoder and back in through the
1162    /// EXISTING importer — which also pins the two modules' fixed-point scale
1163    /// tables against each other.
1164    #[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        // A clean profile must not generate rounding warnings. The only two are the
1220        // standing notes about destination fields with no source: the ones left at
1221        // the format default, and the placeholder switch positions.
1222        assert_eq!(
1223            export.warnings.len(),
1224            2,
1225            "unexpected warnings: {:?}",
1226            export.warnings
1227        );
1228    }
1229
1230    /// `CARRIED_FIELDS` and the not-carried list partition `ProfileData`. A field
1231    /// added to the struct later belongs to one side or the other, and until
1232    /// somebody says which, this fails — which is the whole guarantee behind
1233    /// "names every field it cannot carry".
1234    #[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        // `switches` is a DESTINATION field with no ProfileData source, so it
1269        // belongs to neither half of this partition — see the module doc. Pinned
1270        // because the obvious "fix" when someone meets the placeholder warning is
1271        // to add it to the not-carried list, where it would claim a shooter lost
1272        // something they never had.
1273        assert!(
1274            !accounted.contains("switches"),
1275            "switches is not a ProfileData field and must not appear in either list"
1276        );
1277    }
1278
1279    /// The not-carried list is not decoration: for a profile that really does
1280    /// use the fields `.a7p` has no room for, they are reported as populated,
1281    /// carry the value that was lost, and are named exactly as the saved profile
1282    /// JSON names them.
1283    #[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        // Spot-check across the different kinds of loss the ticket names:
1300        // truing results, effects-adjacent optic calibration, the DOPE-card-like
1301        // multi-zero list, and the per-device turret record.
1302        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        // Every entry is present whether or not it held anything, so a caller can
1327        // tell "had none" from "lost it".
1328        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    /// The file is fixed-unit, so an imperial profile and its metric twin must
1341    /// produce the identical bytes. This is what proves the local unit factors
1342    /// are applied at all — a missing conversion would be invisible in a
1343    /// metric-only round trip.
1344    #[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; // fps
1350        imperial.mass = 300.0; // grains
1351        imperial.diameter = 0.338; // inches
1352        imperial.bullet_length = Some(1.8); // inches
1353        imperial.twist_rate = Some(10.0); // inches/turn
1354        imperial.sight_height = Some(90.0 / MM_PER_INCH); // inches
1355        imperial.zero_distance = Some(100.0 / METERS_PER_YARD); // yards
1356        imperial.auto_zero = imperial.zero_distance;
1357        imperial.temperature = 15.0 * 9.0 / 5.0 + 32.0; // F
1358        imperial.pressure = 1000.0 / HPA_PER_INHG; // inHg
1359
1360        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; // the importer's inert sentinel
1373        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    /// The drag models `.a7p` has no enum value for are refused, not relabelled.
1398    /// Silently writing a G5 load as G1 would hand the recipient different
1399    /// physics under a name they would have no reason to doubt.
1400    #[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    /// Losses INSIDE a carried field are warnings, never silence. `.a7p` stores
1411    /// sight height as whole millimetres, so 50.8 mm (a 2-inch mount) cannot
1412    /// survive and the caller has to hear about it.
1413    #[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    /// A CUSTOM profile with no curve has nothing to encode, and inventing a G1
1448    /// BC for it would be exactly the silent fabrication the importer's own
1449    /// CUSTOM handling refuses.
1450    #[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    /// Count the switch positions (field 25) in an exported file, using a reader
1462    /// written here rather than the importer's — the importer only COUNTS
1463    /// switches, and this test has to be able to see inside one.
1464    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    /// THE REGRESSION GUARD. `.a7p` is refused outright by the ecosystem's own
1517    /// validator when it carries fewer than four switch positions — verified
1518    /// black-box against the upstream package by bisection (0/1/2/3 refused,
1519    /// 4/5 accepted). Our own parser is happy either way, so nothing in the round
1520    /// trip above would catch it: without this test, "cleaning up" the
1521    /// placeholders back to canonical proto3 default-omission would silently make
1522    /// every file this crate writes unopenable, and every test would still pass.
1523    #[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            // And they are declared as placeholders rather than passed off as the
1547            // shooter's own presets.
1548            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    /// The three fields `ProfileData` leaves optional that this export refuses to
1560    /// write without. Refused by name rather than filled in: each would otherwise
1561    /// reach the recipient as a number they cannot tell from a measurement — a
1562    /// bullet length drives their stability model, a zero distance is where their
1563    /// rifle will shoot, and a 0 mm mount is one no rifle has.
1564    #[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    /// `twist_rate` is the DELIBERATE exception to the rule above, and this test
1591    /// exists to stop the three refusals and this one warning being tidied into a
1592    /// single shared rule.
1593    ///
1594    /// An absent twist still exports, with a warning. The reason is not that a
1595    /// zero twist matters less than a zero mount — it is that `r_twist` = 0 may be
1596    /// how this format says "twist unknown", which would make a refusal reject
1597    /// profiles the ecosystem handles fine. We have the ecosystem's validator,
1598    /// which accepts 0, and not the device that interprets it. See the comment at
1599    /// the conversion site for what observation would settle it and let this
1600    /// become a refusal.
1601    #[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        // ...and the file really does carry no twist, rather than a fabricated one.
1615        let back = reimport(&export);
1616        assert_eq!(back.twist_rate, Some(0.0));
1617    }
1618
1619    /// Values our own arithmetic accepts but the ecosystem's validator does not
1620    /// are refused here rather than written into a file the recipient cannot
1621    /// open. Ranges derived black-box from the upstream validator.
1622    #[test]
1623    fn values_outside_what_the_ecosystem_accepts_are_refused_by_name() {
1624        // Each of these is a number our own arithmetic encodes without complaint —
1625        // finite, positive, well inside an i32 — and each lands outside a limit the
1626        // recipient's validator enforces.
1627        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}