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    // 0.49.0: the file has no notion of an omitted reading, and an import marks its
861    // temperature and pressure as typed. A value the profile did NOT mark as typed therefore
862    // arrives as a reading. That changes what flies wherever the old rule reads the unmarked
863    // value as omitted: inside its band (15 ± 0.1 °C, 1013.25 ± 0.5 hPa), at any altitude above
864    // 1 m. The file carries no altitude, and a run supplies its own with `--altitude` (the
865    // profile's is only the fallback), so the profile's altitude cannot rule it out. Nor can a
866    // declared QNH: the file carries it as station pressure (see `pressure_reference`), which
867    // the old rule reads as omitted when it is unmarked and in band, close to the air the QNH
868    // reduced to, while the imported reading flies as typed. Outside the bands the old rule
869    // flies an unmarked value as given, so nothing is lost.
870    let (temperature_c, pressure_hpa) = match p.unit_system() {
871        Ok(UnitSystem::Imperial) => (
872            (p.temperature - 32.0) * 5.0 / 9.0,
873            p.pressure * HPA_PER_INHG,
874        ),
875        _ => (p.temperature, p.pressure),
876    };
877    let temperature_lost = !p.temperature_supplied
878        && crate::atmosphere::in_legacy_default_temperature_band(temperature_c);
879    let pressure_lost =
880        !p.pressure_supplied && crate::atmosphere::in_legacy_default_pressure_band(pressure_hpa);
881    push(
882        "temperature_supplied",
883        temperature_lost.then(|| "false".to_string()),
884        "the file has no concept of an omitted reading and an import marks its temperature as \
885         typed, so an unmarked stored 15 °C / 59 °F (within the old rule's 0.1 °C band), which \
886         flies the ICAO standard temperature at any altitude above 1 m, the profile's or a \
887         run's --altitude, travels as a reading and flies as typed there; the file cannot carry \
888         the altitude either — see `altitude`",
889    );
890    push(
891        "pressure_supplied",
892        pressure_lost.then(|| "false".to_string()),
893        "the file has no concept of an omitted reading and an import marks its pressure as \
894         typed station pressure, so an unmarked stored 1013.25 hPa / 29.92 inHg (within the old \
895         rule's 0.5 hPa band), which flies ICAO air at any altitude above 1 m, the profile's or \
896         a run's --altitude (a QNH there reduces to about the same), travels as a reading and \
897         flies as typed there — see `altitude` and `pressure_reference`",
898    );
899    push(
900        "bc_reference",
901        carried_opt(&p.bc_reference),
902        "the file names no standard atmosphere for its BCs; a non-ICAO BC travels without \
903         the note that says what it is referenced to",
904    );
905    push(
906        "created",
907        carried_opt(&p.created),
908        "the format has no creation timestamp",
909    );
910    push(
911        "wind_speed",
912        carried_opt(&p.wind_speed),
913        "a .a7p profile describes a rifle and a load, not a shot; it has no wind fields",
914    );
915    push(
916        "wind_direction",
917        carried_opt(&p.wind_direction),
918        "no wind fields — see `wind_speed`",
919    );
920    push(
921        "shooting_angle",
922        carried_opt(&p.shooting_angle),
923        "no look-angle field; a .a7p profile carries no shot conditions",
924    );
925    push(
926        "use_bc_segments",
927        carried_opt(&p.use_bc_segments),
928        "no such switch in the format: a file's coef rows ARE its BC schedule, so there is \
929         nothing to turn on or off",
930    );
931    push(
932        "dsf_points",
933        non_empty(&p.dsf_points).map(|v| format!("{} drop-scale-factor point(s)", v.len())),
934        "truing results have no place in the format; the recipient gets the untrued load",
935    );
936    push(
937        "elevation_cf",
938        carried_opt(&p.elevation_cf),
939        "no scope tracking-correction field; the recipient's solution will not be corrected \
940         for how this scope actually tracks",
941    );
942    push(
943        "windage_cf",
944        carried_opt(&p.windage_cf),
945        "no scope tracking-correction field — see `elevation_cf`",
946    );
947    push(
948        "elevation_click",
949        carried_opt(&p.elevation_click),
950        "the file stores zeroing as raw device click COUNTS and never the click size itself; \
951         a graduation written here would be read against the recipient device's own",
952    );
953    push(
954        "windage_click",
955        carried_opt(&p.windage_click),
956        "no click-size field — see `elevation_click`",
957    );
958    push(
959        "zero_poi_up_m",
960        carried_opt(&p.zero_poi_up_m),
961        "the file's only zero-offset slots are zero_x/zero_y, which are device-scoped click \
962         counts with no click size recorded; converting a linear offset into clicks with THIS \
963         profile's graduation would be misread by any device graduated differently",
964    );
965    push(
966        "zero_poi_right_m",
967        carried_opt(&p.zero_poi_right_m),
968        "device-scoped click counts only — see `zero_poi_up_m`",
969    );
970    push(
971        "sight_offset_lateral_m",
972        carried_opt(&p.sight_offset_lateral_m),
973        "the format models sight height only; there is no lateral sight-to-bore offset",
974    );
975    push(
976        "zero_sets",
977        non_empty(&p.zero_sets).map(|sets| {
978            sets.iter()
979                .map(|z| z.name.as_str())
980                .collect::<Vec<_>>()
981                .join(", ")
982        }),
983        "one profile is one rifle with one zero in this format; alternate zeros and per-load \
984         dial corrections have nowhere to go",
985    );
986    push(
987        "reticle",
988        p.reticle
989            .as_ref()
990            .map(|r| format!("\"{}\" ({} mark(s))", r.name, r.marks.len())),
991        "the format describes the optic by sight height and click counts, not by its reticle",
992    );
993    push(
994        "clicks_per_revolution",
995        carried_opt(&p.clicks_per_revolution),
996        "no turret-mechanics fields in the format",
997    );
998    push(
999        "zero_stop",
1000        carried_opt(&p.zero_stop),
1001        "no turret-mechanics fields — see `clicks_per_revolution`",
1002    );
1003    push(
1004        "elevation_travel_up_mil",
1005        carried_opt(&p.elevation_travel_up_mil),
1006        "no turret travel limits in the format",
1007    );
1008    push(
1009        "elevation_travel_down_mil",
1010        carried_opt(&p.elevation_travel_down_mil),
1011        "no turret travel limits — see `elevation_travel_up_mil`",
1012    );
1013    push(
1014        "windage_travel_left_mil",
1015        carried_opt(&p.windage_travel_left_mil),
1016        "no turret travel limits — see `elevation_travel_up_mil`",
1017    );
1018    push(
1019        "windage_travel_right_mil",
1020        carried_opt(&p.windage_travel_right_mil),
1021        "no turret travel limits — see `elevation_travel_up_mil`",
1022    );
1023    push(
1024        "turret_elevation_dialed_mil",
1025        carried_opt(&p.turret_elevation_dialed_mil),
1026        "no field for what the turrets currently read",
1027    );
1028    push(
1029        "turret_windage_dialed_mil",
1030        carried_opt(&p.turret_windage_dialed_mil),
1031        "no field for what the turrets currently read",
1032    );
1033    push(
1034        "hold_bound_up_mil",
1035        carried_opt(&p.hold_bound_up_mil),
1036        "no reticle hold bounds in the format — see `reticle`",
1037    );
1038    push(
1039        "hold_bound_down_mil",
1040        carried_opt(&p.hold_bound_down_mil),
1041        "no reticle hold bounds — see `reticle`",
1042    );
1043    push(
1044        "hold_bound_left_mil",
1045        carried_opt(&p.hold_bound_left_mil),
1046        "no reticle hold bounds — see `reticle`",
1047    );
1048    push(
1049        "hold_bound_right_mil",
1050        carried_opt(&p.hold_bound_right_mil),
1051        "no reticle hold bounds — see `reticle`",
1052    );
1053
1054    out
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1061
1062    /// Every `ProfileData` field populated, so the completeness test below sees
1063    /// the whole serialized key set (the `skip_serializing_if = Option::is_none`
1064    /// fields only appear when they hold something). Metric, and every carried
1065    /// measurement sits exactly on the `.a7p` fixed-point grid so the round-trip
1066    /// assertions can be about the mapping rather than about rounding.
1067    const FULL_FIXTURE: &str = r#"{
1068        "name": "export-fixture",
1069        "velocity": 792.0,
1070        "bc": 0.381,
1071        "mass": 19.439673,
1072        "diameter": 8.5852,
1073        "drag_model": "G7",
1074        "twist_rate": 254.0,
1075        "sight_height": 90.0,
1076        "zero_distance": 100.0,
1077        "units": "metric",
1078        "temperature": 15.0,
1079        "pressure": 1000.0,
1080        "temperature_supplied": true,
1081        "pressure_supplied": true,
1082        "humidity": 50.0,
1083        "altitude": 300.0,
1084        "bullet_name": "300GR OTM",
1085        "created": "1755400000",
1086        "wind_speed": 4.4704,
1087        "wind_direction": 90.0,
1088        "shooting_angle": -5.0,
1089        "auto_zero": 100.0,
1090        "twist_right": false,
1091        "use_bc_segments": true,
1092        "bullet_length": 45.72,
1093        "elevation_click": "0.1mil",
1094        "windage_click": "0.25moa",
1095        "bc_segments": [
1096            {"bc": 0.381, "velocity_mps": 792.0},
1097            {"bc": 0.360, "velocity_mps": 500.0}
1098        ],
1099        "drag_curve": [
1100            {"mach": 0.5, "cd": 0.23},
1101            {"mach": 3.0, "cd": 0.28}
1102        ],
1103        "dsf_points": [{"mach": 0.9, "dsf": 1.04}],
1104        "bc_reference": "army-standard-metro",
1105        "pressure_reference": "qnh",
1106        "density_altitude": 500.0,
1107        "zero_poi_up_m": 0.05,
1108        "zero_poi_right_m": -0.02,
1109        "sight_offset_lateral_m": 0.01,
1110        "elevation_cf": 0.97,
1111        "windage_cf": 1.02,
1112        "zero_sets": [{"name": "suppressed", "zero_distance": 200.0, "poi_up_mil": -0.3}],
1113        "reticle": {
1114            "name": "mil-grid 0.5/10",
1115            "focal_plane": "ffp",
1116            "reference_magnification": 10.0,
1117            "marks": [{"down_mil": 0.0, "right_mil": 0.0, "kind": "center"}]
1118        },
1119        "clicks_per_revolution": 100,
1120        "zero_stop": true,
1121        "elevation_travel_up_mil": 26.0,
1122        "elevation_travel_down_mil": 4.0,
1123        "windage_travel_left_mil": 12.0,
1124        "windage_travel_right_mil": 12.0,
1125        "turret_elevation_dialed_mil": 5.4,
1126        "turret_windage_dialed_mil": -0.2,
1127        "hold_bound_up_mil": 5.0,
1128        "hold_bound_down_mil": 10.0,
1129        "hold_bound_left_mil": 6.0,
1130        "hold_bound_right_mil": 6.0
1131    }"#;
1132
1133    fn full_profile() -> ProfileData {
1134        serde_json::from_str(FULL_FIXTURE).expect("fixture must load")
1135    }
1136
1137    /// The same rifle with nothing in it that `.a7p` cannot take — the profile
1138    /// the round-trip assertions are about.
1139    fn carryable_profile() -> ProfileData {
1140        let mut p = full_profile();
1141        p.altitude = 0.0;
1142        p.created = None;
1143        p.wind_speed = None;
1144        p.wind_direction = None;
1145        p.shooting_angle = None;
1146        p.use_bc_segments = None;
1147        p.elevation_click = None;
1148        p.windage_click = None;
1149        // G7 + a CUSTOM curve is a contradiction the file cannot hold both halves
1150        // of; the round trip is about the G7 half.
1151        p.drag_curve = None;
1152        p.dsf_points = None;
1153        p.bc_reference = None;
1154        p.pressure_reference = None;
1155        p.density_altitude = None;
1156        p.zero_poi_up_m = None;
1157        p.zero_poi_right_m = None;
1158        p.sight_offset_lateral_m = None;
1159        p.elevation_cf = None;
1160        p.windage_cf = None;
1161        p.zero_sets = None;
1162        p.reticle = None;
1163        p.clicks_per_revolution = None;
1164        p.zero_stop = None;
1165        p.elevation_travel_up_mil = None;
1166        p.elevation_travel_down_mil = None;
1167        p.windage_travel_left_mil = None;
1168        p.windage_travel_right_mil = None;
1169        p.turret_elevation_dialed_mil = None;
1170        p.turret_windage_dialed_mil = None;
1171        p.hold_bound_up_mil = None;
1172        p.hold_bound_down_mil = None;
1173        p.hold_bound_left_mil = None;
1174        p.hold_bound_right_mil = None;
1175        p
1176    }
1177
1178    fn reimport(export: &A7pExport) -> ProfileData {
1179        let doc = parse_a7p(&export.bytes).expect("our own file must parse");
1180        assert!(
1181            matches!(doc.envelope, EnvelopeStatus::Verified),
1182            "the MD5 envelope we wrote must verify"
1183        );
1184        assert!(
1185            doc.unknown_fields.is_empty(),
1186            "the encoder must not emit field numbers the parser does not know: {:?}",
1187            doc.unknown_fields
1188        );
1189        map_a7p_to_profile(&doc, None, None)
1190            .expect("our own file must map")
1191            .profile
1192    }
1193
1194    fn close(a: f64, b: f64, what: &str) {
1195        assert!(
1196            (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0),
1197            "{what}: {a} != {b}"
1198        );
1199    }
1200
1201    /// The load-bearing one: everything `CARRIED_FIELDS` claims survives really
1202    /// does survive a trip out through the encoder and back in through the
1203    /// EXISTING importer — which also pins the two modules' fixed-point scale
1204    /// tables against each other.
1205    #[test]
1206    fn round_trip_through_the_importer_preserves_every_carried_field() {
1207        let source = carryable_profile();
1208        let export = export_a7p(&source).expect("export");
1209        let back = reimport(&export);
1210
1211        assert_eq!(back.name, source.name);
1212        assert_eq!(back.drag_model, source.drag_model);
1213        assert_eq!(back.units, source.units);
1214        assert_eq!(back.bullet_name, source.bullet_name);
1215        assert_eq!(back.twist_right, source.twist_right);
1216        close(back.velocity, source.velocity, "velocity");
1217        close(back.bc, source.bc, "bc");
1218        close(back.mass, source.mass, "mass");
1219        close(back.diameter, source.diameter, "diameter");
1220        close(back.temperature, source.temperature, "temperature");
1221        close(back.pressure, source.pressure, "pressure");
1222        close(back.humidity, source.humidity, "humidity");
1223        close(
1224            back.twist_rate.unwrap(),
1225            source.twist_rate.unwrap(),
1226            "twist",
1227        );
1228        close(
1229            back.sight_height.unwrap(),
1230            source.sight_height.unwrap(),
1231            "sight_height",
1232        );
1233        close(
1234            back.bullet_length.unwrap(),
1235            source.bullet_length.unwrap(),
1236            "bullet_length",
1237        );
1238        close(
1239            back.zero_distance.unwrap(),
1240            source.zero_distance.unwrap(),
1241            "zero_distance",
1242        );
1243        close(
1244            back.auto_zero.unwrap(),
1245            source.auto_zero.unwrap(),
1246            "auto_zero",
1247        );
1248
1249        let (from, to) = (
1250            source.bc_segments.as_ref().unwrap(),
1251            back.bc_segments.as_ref().unwrap(),
1252        );
1253        assert_eq!(to.len(), from.len(), "bc_segments length");
1254        for (a, b) in from.iter().zip(to.iter()) {
1255            close(b.bc, a.bc, "bc_segments.bc");
1256            close(b.velocity_mps, a.velocity_mps, "bc_segments.velocity_mps");
1257        }
1258        assert!(back.drag_curve.is_none());
1259
1260        // A clean profile must not generate rounding warnings. The only two are the
1261        // standing notes about destination fields with no source: the ones left at
1262        // the format default, and the placeholder switch positions.
1263        assert_eq!(
1264            export.warnings.len(),
1265            2,
1266            "unexpected warnings: {:?}",
1267            export.warnings
1268        );
1269    }
1270
1271    /// 0.49.0: an import marks the file's temperature and pressure as typed readings, so a
1272    /// profile whose stored values were NOT typed loses that fact on the way out. It changes
1273    /// what flies wherever the old rule reads the unmarked value as omitted: inside its band
1274    /// (15 ± 0.1 °C, 1013.25 ± 0.5 hPa). The file carries no altitude, and a run supplies its
1275    /// own with `--altitude`, so the profile's altitude does not decide it, and neither does a
1276    /// declared QNH: the file carries the QNH as station pressure, which the old rule reads as
1277    /// omitted too when it is unmarked. Everywhere else the two entries stay unpopulated.
1278    #[test]
1279    fn unmarked_in_band_readings_are_reported_as_not_carried() {
1280        let populated = |profile: &ProfileData| {
1281            let export = export_a7p(profile).expect("export");
1282            export
1283                .not_carried
1284                .iter()
1285                .filter(|n| n.field.ends_with("_supplied"))
1286                .map(|n| (n.field, n.populated))
1287                .collect::<Vec<_>>()
1288        };
1289        let lost = |temperature: bool, pressure: bool| {
1290            vec![
1291                ("temperature_supplied", temperature),
1292                ("pressure_supplied", pressure),
1293            ]
1294        };
1295        // The fixture: 15 °C (in band) and a 1000 hPa QNH at 300 m.
1296        let marked = full_profile();
1297        assert_eq!(populated(&marked), lost(false, false));
1298        let unmarked = ProfileData {
1299            temperature_supplied: false,
1300            pressure_supplied: false,
1301            ..full_profile()
1302        };
1303        assert_eq!(populated(&unmarked), lost(true, false));
1304        // An in-band pressure is lost, absolute or declared as a QNH.
1305        let in_band = ProfileData {
1306            pressure: 1013.25,
1307            pressure_reference: None,
1308            ..unmarked.clone()
1309        };
1310        assert_eq!(populated(&in_band), lost(true, true));
1311        let in_band_qnh = ProfileData {
1312            pressure_reference: Some("qnh".to_string()),
1313            ..in_band.clone()
1314        };
1315        assert_eq!(populated(&in_band_qnh), lost(true, true));
1316        // Inside the bands but not the defaults: still read as omitted when unmarked.
1317        let near = ProfileData {
1318            temperature: 14.95,
1319            pressure: 1012.8,
1320            ..in_band.clone()
1321        };
1322        assert_eq!(populated(&near), lost(true, true));
1323        // Outside the bands the round trip flies the same air, at any altitude.
1324        let out_of_band = ProfileData {
1325            temperature: 15.15,
1326            pressure: 1013.8,
1327            ..in_band.clone()
1328        };
1329        assert_eq!(populated(&out_of_band), lost(false, false));
1330        // The profile's own altitude does not decide it: sea level included.
1331        for altitude in [0.0, 1.0, -1.0, 1.5, 3000.0] {
1332            let at = |profile: &ProfileData| ProfileData {
1333                altitude,
1334                ..profile.clone()
1335            };
1336            assert_eq!(populated(&at(&in_band)), lost(true, true), "{altitude} m");
1337            assert_eq!(
1338                populated(&at(&in_band_qnh)),
1339                lost(true, true),
1340                "{altitude} m"
1341            );
1342            assert_eq!(
1343                populated(&at(&out_of_band)),
1344                lost(false, false),
1345                "{altitude} m"
1346            );
1347        }
1348        // Imperial values are tested in the units the rule uses: 59 °F and 29.92 inHg are in
1349        // band, at 5000 ft or at sea level; 80 °F and 24.9 inHg are not.
1350        let imperial = ProfileData {
1351            units: "imperial".to_string(),
1352            velocity: 2600.0,
1353            mass: 300.0,
1354            diameter: 0.338,
1355            twist_rate: Some(10.0),
1356            sight_height: Some(3.5),
1357            bullet_length: Some(1.8),
1358            temperature: 59.0,
1359            pressure: 29.92,
1360            altitude: 5000.0,
1361            ..in_band.clone()
1362        };
1363        assert_eq!(populated(&imperial), lost(true, true));
1364        let imperial_sea_level = ProfileData {
1365            altitude: 0.0,
1366            ..imperial.clone()
1367        };
1368        assert_eq!(populated(&imperial_sea_level), lost(true, true));
1369        let imperial_out = ProfileData {
1370            temperature: 80.0,
1371            pressure: 24.9,
1372            ..imperial
1373        };
1374        assert_eq!(populated(&imperial_out), lost(false, false));
1375    }
1376
1377    /// `CARRIED_FIELDS` and the not-carried list partition `ProfileData`. A field
1378    /// added to the struct later belongs to one side or the other, and until
1379    /// somebody says which, this fails — which is the whole guarantee behind
1380    /// "names every field it cannot carry".
1381    #[test]
1382    fn not_carried_and_carried_cover_every_profile_field() {
1383        let profile = full_profile();
1384        let serialized = serde_json::to_value(&profile).expect("serialize");
1385        let keys: std::collections::BTreeSet<String> = serialized
1386            .as_object()
1387            .expect("a profile is a JSON object")
1388            .keys()
1389            .cloned()
1390            .collect();
1391
1392        let export = export_a7p(&profile).expect("export");
1393        let mut accounted: std::collections::BTreeSet<String> =
1394            CARRIED_FIELDS.iter().map(|f| f.to_string()).collect();
1395        for entry in &export.not_carried {
1396            assert!(
1397                accounted.insert(entry.field.to_string()),
1398                "{} is in BOTH the carried and not-carried lists",
1399                entry.field
1400            );
1401        }
1402
1403        let unaccounted: Vec<&String> = keys.difference(&accounted).collect();
1404        assert!(
1405            unaccounted.is_empty(),
1406            "ProfileData fields in neither list (add them to CARRIED_FIELDS or to \
1407             not_carried_for): {unaccounted:?}"
1408        );
1409        let invented: Vec<&String> = accounted.difference(&keys).collect();
1410        assert!(
1411            invented.is_empty(),
1412            "listed names that are not ProfileData fields: {invented:?}"
1413        );
1414
1415        // `switches` is a DESTINATION field with no ProfileData source, so it
1416        // belongs to neither half of this partition — see the module doc. Pinned
1417        // because the obvious "fix" when someone meets the placeholder warning is
1418        // to add it to the not-carried list, where it would claim a shooter lost
1419        // something they never had.
1420        assert!(
1421            !accounted.contains("switches"),
1422            "switches is not a ProfileData field and must not appear in either list"
1423        );
1424    }
1425
1426    /// The not-carried list is not decoration: for a profile that really does
1427    /// use the fields `.a7p` has no room for, they are reported as populated,
1428    /// carry the value that was lost, and are named exactly as the saved profile
1429    /// JSON names them.
1430    #[test]
1431    fn not_carried_names_real_populated_fields_with_their_values() {
1432        let profile = full_profile();
1433        let serialized = serde_json::to_value(&profile).expect("serialize");
1434        let object = serialized.as_object().expect("object");
1435
1436        let export = export_a7p(&profile).expect("export");
1437        let dropped = export.dropped_fields();
1438        assert!(!dropped.is_empty(), "this profile drops plenty");
1439
1440        for name in &dropped {
1441            assert!(
1442                object.contains_key(*name),
1443                "{name} is not a ProfileData field"
1444            );
1445        }
1446        // Spot-check across the different kinds of loss the ticket names:
1447        // truing results, effects-adjacent optic calibration, the DOPE-card-like
1448        // multi-zero list, and the per-device turret record.
1449        for expected in [
1450            "altitude",
1451            "dsf_points",
1452            "elevation_cf",
1453            "zero_sets",
1454            "reticle",
1455            "elevation_click",
1456            "zero_poi_up_m",
1457            "bc_reference",
1458            "clicks_per_revolution",
1459            "hold_bound_up_mil",
1460        ] {
1461            assert!(dropped.contains(&expected), "{expected} must be reported");
1462        }
1463
1464        let dsf = export
1465            .not_carried
1466            .iter()
1467            .find(|n| n.field == "dsf_points")
1468            .expect("dsf_points entry");
1469        assert!(dsf.populated);
1470        assert_eq!(dsf.value.as_deref(), Some("1 drop-scale-factor point(s)"));
1471        assert!(!dsf.reason.is_empty());
1472
1473        // Every entry is present whether or not it held anything, so a caller can
1474        // tell "had none" from "lost it".
1475        let mut empty = full_profile();
1476        empty.dsf_points = None;
1477        let empty_export = export_a7p(&empty).expect("export");
1478        let dsf = empty_export
1479            .not_carried
1480            .iter()
1481            .find(|n| n.field == "dsf_points")
1482            .expect("dsf_points is listed even when unset");
1483        assert!(!dsf.populated);
1484        assert_eq!(dsf.value, None);
1485    }
1486
1487    /// The file is fixed-unit, so an imperial profile and its metric twin must
1488    /// produce the identical bytes. This is what proves the local unit factors
1489    /// are applied at all — a missing conversion would be invisible in a
1490    /// metric-only round trip.
1491    #[test]
1492    fn an_imperial_profile_and_its_metric_twin_encode_identically() {
1493        let metric = carryable_profile();
1494        let mut imperial = metric.clone();
1495        imperial.units = "imperial".to_string();
1496        imperial.velocity = 792.0 / FPS_TO_MPS; // fps
1497        imperial.mass = 300.0; // grains
1498        imperial.diameter = 0.338; // inches
1499        imperial.bullet_length = Some(1.8); // inches
1500        imperial.twist_rate = Some(10.0); // inches/turn
1501        imperial.sight_height = Some(90.0 / MM_PER_INCH); // inches
1502        imperial.zero_distance = Some(100.0 / METERS_PER_YARD); // yards
1503        imperial.auto_zero = imperial.zero_distance;
1504        imperial.temperature = 15.0 * 9.0 / 5.0 + 32.0; // F
1505        imperial.pressure = 1000.0 / HPA_PER_INHG; // inHg
1506
1507        let a = export_a7p(&metric).expect("metric export");
1508        let b = export_a7p(&imperial).expect("imperial export");
1509        assert_eq!(
1510            a.bytes, b.bytes,
1511            "the same rifle must encode identically whatever units it was saved in"
1512        );
1513    }
1514
1515    #[test]
1516    fn a_custom_drag_curve_round_trips_as_mach_cd_rows() {
1517        let mut profile = carryable_profile();
1518        profile.drag_model = "CUSTOM".to_string();
1519        profile.bc = 0.0; // the importer's inert sentinel
1520        profile.bc_segments = None;
1521        profile.drag_curve = Some(vec![
1522            crate::profile::ProfileDragPoint {
1523                mach: 0.5,
1524                cd: 0.23,
1525            },
1526            crate::profile::ProfileDragPoint {
1527                mach: 3.0,
1528                cd: 0.28,
1529            },
1530        ]);
1531
1532        let export = export_a7p(&profile).expect("export");
1533        let back = reimport(&export);
1534        assert_eq!(back.drag_model, "CUSTOM");
1535        assert_eq!(back.bc, 0.0);
1536        let curve = back.drag_curve.as_ref().expect("curve survives");
1537        assert_eq!(curve.len(), 2);
1538        close(curve[0].mach, 0.5, "mach");
1539        close(curve[0].cd, 0.23, "cd");
1540        close(curve[1].mach, 3.0, "mach");
1541        close(curve[1].cd, 0.28, "cd");
1542    }
1543
1544    /// The drag models `.a7p` has no enum value for are refused, not relabelled.
1545    /// Silently writing a G5 load as G1 would hand the recipient different
1546    /// physics under a name they would have no reason to doubt.
1547    #[test]
1548    fn a_drag_model_the_format_cannot_name_is_refused() {
1549        let mut profile = carryable_profile();
1550        profile.drag_model = "G5".to_string();
1551        match export_a7p(&profile) {
1552            Err(A7pExportError::DragModel(m)) => assert_eq!(m, "G5"),
1553            other => panic!("expected a DragModel refusal, got {other:?}"),
1554        }
1555    }
1556
1557    /// Losses INSIDE a carried field are warnings, never silence. `.a7p` stores
1558    /// sight height as whole millimetres, so 50.8 mm (a 2-inch mount) cannot
1559    /// survive and the caller has to hear about it.
1560    #[test]
1561    fn a_value_that_does_not_fit_the_fixed_point_grid_is_warned_about() {
1562        let mut profile = carryable_profile();
1563        profile.sight_height = Some(50.8);
1564        let export = export_a7p(&profile).expect("export");
1565        assert!(
1566            export
1567                .warnings
1568                .iter()
1569                .any(|w| w.starts_with("sight_height:") && w.contains("51")),
1570            "expected a sight_height quantization warning, got {:?}",
1571            export.warnings
1572        );
1573        let back = reimport(&export);
1574        assert_eq!(back.sight_height, Some(51.0));
1575    }
1576
1577    #[test]
1578    fn an_unusable_measurement_is_a_named_field_error() {
1579        let mut profile = carryable_profile();
1580        profile.velocity = 0.0;
1581        match export_a7p(&profile) {
1582            Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "velocity"),
1583            other => panic!("expected a Field error, got {other:?}"),
1584        }
1585
1586        let mut profile = carryable_profile();
1587        profile.units = "furlongs".to_string();
1588        assert!(matches!(
1589            export_a7p(&profile),
1590            Err(A7pExportError::Units(_))
1591        ));
1592    }
1593
1594    /// A CUSTOM profile with no curve has nothing to encode, and inventing a G1
1595    /// BC for it would be exactly the silent fabrication the importer's own
1596    /// CUSTOM handling refuses.
1597    #[test]
1598    fn custom_without_a_curve_is_refused() {
1599        let mut profile = carryable_profile();
1600        profile.drag_model = "CUSTOM".to_string();
1601        profile.drag_curve = None;
1602        match export_a7p(&profile) {
1603            Err(A7pExportError::Field { field, .. }) => assert_eq!(field, "drag_curve"),
1604            other => panic!("expected a drag_curve error, got {other:?}"),
1605        }
1606    }
1607
1608    /// Count the switch positions (field 25) in an exported file, using a reader
1609    /// written here rather than the importer's — the importer only COUNTS
1610    /// switches, and this test has to be able to see inside one.
1611    fn switch_entries(bytes: &[u8]) -> Vec<Vec<(u32, u64)>> {
1612        fn varint(b: &[u8], i: &mut usize) -> u64 {
1613            let (mut v, mut shift) = (0u64, 0u32);
1614            loop {
1615                let byte = b[*i];
1616                *i += 1;
1617                v |= u64::from(byte & 0x7f) << shift;
1618                if byte & 0x80 == 0 {
1619                    return v;
1620                }
1621                shift += 7;
1622            }
1623        }
1624        fn fields(b: &[u8]) -> Vec<(u32, u64, &[u8])> {
1625            let mut i = 0usize;
1626            let mut out = Vec::new();
1627            while i < b.len() {
1628                let key = varint(b, &mut i);
1629                let (number, wire) = ((key >> 3) as u32, key & 7);
1630                match wire {
1631                    0 => {
1632                        let v = varint(b, &mut i);
1633                        out.push((number, v, &b[0..0]));
1634                    }
1635                    2 => {
1636                        let n = varint(b, &mut i) as usize;
1637                        out.push((number, 0, &b[i..i + n]));
1638                        i += n;
1639                    }
1640                    other => panic!("unexpected wire type {other}"),
1641                }
1642            }
1643            out
1644        }
1645        let payload = &bytes[32..];
1646        let profile = fields(payload)
1647            .into_iter()
1648            .find(|(n, _, _)| *n == F_PAYLOAD_PROFILE)
1649            .expect("payload carries a profile")
1650            .2;
1651        fields(profile)
1652            .into_iter()
1653            .filter(|(n, _, _)| *n == F_SWITCHES)
1654            .map(|(_, _, body)| {
1655                fields(body)
1656                    .into_iter()
1657                    .map(|(n, v, _)| (n, v))
1658                    .collect::<Vec<_>>()
1659            })
1660            .collect()
1661    }
1662
1663    /// THE REGRESSION GUARD. `.a7p` is refused outright by the ecosystem's own
1664    /// validator when it carries fewer than four switch positions — verified
1665    /// black-box against the upstream package by bisection (0/1/2/3 refused,
1666    /// 4/5 accepted). Our own parser is happy either way, so nothing in the round
1667    /// trip above would catch it: without this test, "cleaning up" the
1668    /// placeholders back to canonical proto3 default-omission would silently make
1669    /// every file this crate writes unopenable, and every test would still pass.
1670    #[test]
1671    fn every_export_carries_the_minimum_four_switch_positions() {
1672        for profile in [carryable_profile(), full_profile()] {
1673            let export = export_a7p(&profile).expect("export");
1674            let switches = switch_entries(&export.bytes);
1675            assert!(
1676                switches.len() >= MIN_SWITCHES,
1677                "exported {} switch positions; the ecosystem refuses fewer than \
1678                 {MIN_SWITCHES}",
1679                switches.len()
1680            );
1681            for (i, entry) in switches.iter().enumerate() {
1682                let (zoom, distance) = PLACEHOLDER_SWITCHES[i];
1683                assert_eq!(
1684                    entry,
1685                    &vec![
1686                        (F_SW_C_IDX, PLACEHOLDER_SWITCH_C_IDX as u64),
1687                        (F_SW_ZOOM, zoom as u64),
1688                        (F_SW_DISTANCE, distance as u64),
1689                    ],
1690                    "switch {i}"
1691                );
1692            }
1693            // And they are declared as placeholders rather than passed off as the
1694            // shooter's own presets.
1695            assert!(
1696                export
1697                    .warnings
1698                    .iter()
1699                    .any(|w| w.contains("PLACEHOLDERS") && w.contains("switch")),
1700                "the placeholder switches must be declared: {:?}",
1701                export.warnings
1702            );
1703        }
1704    }
1705
1706    /// The three fields `ProfileData` leaves optional that this export refuses to
1707    /// write without. Refused by name rather than filled in: each would otherwise
1708    /// reach the recipient as a number they cannot tell from a measurement — a
1709    /// bullet length drives their stability model, a zero distance is where their
1710    /// rifle will shoot, and a 0 mm mount is one no rifle has.
1711    #[test]
1712    fn fields_the_format_requires_are_refused_when_absent_not_invented() {
1713        for (field, clear) in [
1714            (
1715                "bullet_length",
1716                Box::new(|p: &mut ProfileData| p.bullet_length = None)
1717                    as Box<dyn Fn(&mut ProfileData)>,
1718            ),
1719            (
1720                "zero_distance",
1721                Box::new(|p: &mut ProfileData| p.zero_distance = None),
1722            ),
1723            (
1724                "sight_height",
1725                Box::new(|p: &mut ProfileData| p.sight_height = None),
1726            ),
1727        ] {
1728            let mut profile = carryable_profile();
1729            clear(&mut profile);
1730            match export_a7p(&profile) {
1731                Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
1732                other => panic!("{field}: expected a refusal, got {other:?}"),
1733            }
1734        }
1735    }
1736
1737    /// `twist_rate` is the DELIBERATE exception to the rule above, and this test
1738    /// exists to stop the three refusals and this one warning being tidied into a
1739    /// single shared rule.
1740    ///
1741    /// An absent twist still exports, with a warning. The reason is not that a
1742    /// zero twist matters less than a zero mount — it is that `r_twist` = 0 may be
1743    /// how this format says "twist unknown", which would make a refusal reject
1744    /// profiles the ecosystem handles fine. We have the ecosystem's validator,
1745    /// which accepts 0, and not the device that interprets it. See the comment at
1746    /// the conversion site for what observation would settle it and let this
1747    /// become a refusal.
1748    #[test]
1749    fn an_absent_twist_rate_warns_rather_than_refusing_and_that_is_deliberate() {
1750        let mut profile = carryable_profile();
1751        profile.twist_rate = None;
1752        let export = export_a7p(&profile).expect("an unknown twist must still export");
1753        assert!(
1754            export
1755                .warnings
1756                .iter()
1757                .any(|w| w.starts_with("twist_rate:") && w.contains("r_twist")),
1758            "an absent twist must be reported: {:?}",
1759            export.warnings
1760        );
1761        // ...and the file really does carry no twist, rather than a fabricated one.
1762        let back = reimport(&export);
1763        assert_eq!(back.twist_rate, Some(0.0));
1764    }
1765
1766    /// Values our own arithmetic accepts but the ecosystem's validator does not
1767    /// are refused here rather than written into a file the recipient cannot
1768    /// open. Ranges derived black-box from the upstream validator.
1769    #[test]
1770    fn values_outside_what_the_ecosystem_accepts_are_refused_by_name() {
1771        // Each of these is a number our own arithmetic encodes without complaint —
1772        // finite, positive, well inside an i32 — and each lands outside a limit the
1773        // recipient's validator enforces.
1774        for (field, mutate) in [
1775            (
1776                "velocity",
1777                Box::new(|p: &mut ProfileData| p.velocity = 5.0) as Box<dyn Fn(&mut ProfileData)>,
1778            ),
1779            (
1780                "pressure",
1781                Box::new(|p: &mut ProfileData| p.pressure = 100.0),
1782            ),
1783            (
1784                "temperature",
1785                Box::new(|p: &mut ProfileData| p.temperature = 250.0),
1786            ),
1787            (
1788                "zero_distance",
1789                Box::new(|p: &mut ProfileData| p.zero_distance = Some(5000.0)),
1790            ),
1791            (
1792                "sight_height",
1793                Box::new(|p: &mut ProfileData| p.sight_height = Some(9000.0)),
1794            ),
1795        ] {
1796            let mut profile = carryable_profile();
1797            mutate(&mut profile);
1798            match export_a7p(&profile) {
1799                Err(A7pExportError::Field { field: got, .. }) => assert_eq!(got, field),
1800                other => panic!("{field}: expected a refusal, got {other:?}"),
1801            }
1802        }
1803    }
1804}