Skip to main content

ballistics_engine/
reticle_document_import.rs

1//! Cleanroom importer for the `.reticle` XML document format.
2//!
3//! A `.reticle` file is a third-party **drawing** format for scope reticles: a single
4//! `<reticle>` root carrying an `<elements>` list of shapes (lines, circles, rectangles,
5//! filled paths) plus a `<bdc>` section. This module is the one-way transform from that
6//! authoring format into the engine's own [`ReticleDescription`], so a reticle drawn in
7//! that format can be hold-solved by the existing
8//! [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) without any of the
9//! CLI/FFI/WASM surfaces having to learn a second schema. It is a converter, not new
10//! physics — every coordinate is carried straight through, only unit-normalized to
11//! milliradians and sign-corrected for the engine's axis convention.
12//!
13//! It is a sibling of [`crate::reticle_import`] (Bero's "Ventum" JSON), and meets the same
14//! hold-vs-decoration problem from the opposite direction: Ventum is an aiming-mark format
15//! that happens to contain some decoration, while `.reticle` is a picture that happens to
16//! contain some aiming marks.
17//!
18//! Everything here was derived from a sample `.reticle` document and the SVG it renders
19//! to — the observable file structure, and the observable output that pins down what its
20//! coordinates mean. **No third-party implementation of this format was consulted, and
21//! none is vendored.** A file format is not itself copyrightable; this crate stays MIT OR
22//! Apache-2.0. Every fixture in this module's tests and in
23//! `tests/fixtures/reticle_document/` is invented content.
24//!
25//! Like [`crate::reticle_import`], this module is fs-free (it transforms an in-memory
26//! `&str`), carries no feature gate, and compiles for `wasm32-unknown-unknown`.
27//!
28//! # What carries a hold, and what does not
29//!
30//! This is the whole design decision, and the format answers it itself rather than leaving
31//! it to be inferred:
32//!
33//! * **`<bdc>` entries are the holds.** A `<bdc position-x="…" position-y="…">` element is
34//!   a declared aiming point. Each one becomes a [`ReticleMark`].
35//! * **Everything under `<elements>` is the picture, and is dropped.** `reticle-line`,
36//!   `reticle-circle`, `reticle-rectangle`, `reticle-path` (and its
37//!   `reticle-path-move-to` / `reticle-path-line-to` vertices), and any unknown future tag
38//!   carry no hold. Their geometry attributes are never read as angles at all — a shape
39//!   this module drops cannot fail its import.
40//!
41//! The evidence that this split is the format's own and not our invention: in a real
42//! document the `<bdc>` positions are *duplicates* of shapes already drawn under
43//! `<elements>`. A BDC reticle draws its three holdover marks as three short
44//! `reticle-line` ticks, and then lists those same three coordinates again under `<bdc>`.
45//! The drawing says how it looks; the `<bdc>` section says what it means. Importing the
46//! drawing as well would double every holdover.
47//!
48//! ## The cost of that rule, stated plainly
49//!
50//! A `.reticle` document's windage marks are drawn as `reticle-line` ticks and are *not*
51//! listed under `<bdc>`. They are therefore dropped, and an imported reticle usually has
52//! marks on the elevation axis only. That has a visible consequence downstream: with every
53//! mark at `right_mil == 0`, the windage axis of the mark bounding box is degenerate, so
54//! [`ReticleHold::off_reticle`](crate::reticle::ReticleHold::off_reticle) reads `true` for
55//! *any* nonzero wind hold — see that field's documentation. For a reticle that really does
56//! have windage hashes etched on it, that answer comes from this import, not from the optic.
57//!
58//! Recovering those marks would mean classifying `reticle-line` elements as hash marks by
59//! their geometry (short, axis-perpendicular, off-center) — inventing holds out of
60//! decoration, and doing it with a heuristic that cannot distinguish a windage hash from a
61//! crosshair arm. This module will not guess. Instead the drop is made countable rather
62//! than silent: [`import_reticle_document_with_report`] returns a
63//! [`ReticleImportReport`] tallying every drawing tag it walked past, so a caller can tell
64//! the shooter how many lines and paths this document drew that are not holds, instead of
65//! leaving them to infer it from a suspiciously empty reticle.
66//!
67//! Two related non-inventions, for the reader who wonders:
68//!
69//! * **No synthetic center mark.** The zero *is* the coordinate origin every imported mark
70//!   is measured from, and a hold is reported in those same coordinates, so it survives as
71//!   the frame rather than as a mark. If a document wants the center to be a selectable
72//!   aiming point it can say so with a `<bdc position-x="0…" position-y="0…">` entry.
73//! * **No labels.** A `<bdc>` entry carries no range and no text — only a position and the
74//!   `text-offset` / `text-height` hints for where a renderer should draw a label. The range
75//!   that belongs to a holdover is a property of the firing solution, not of the document,
76//!   so every imported mark has `label: None`.
77//!
78//! # Coordinate and unit conventions
79//!
80//! **The document's `+y` is UP; the engine's is DOWN.** This is the one sign trap of the
81//! format, and it is the opposite of [`crate::reticle_import`], where no flip was needed.
82//! Confirmed from rendered output: an element at `position-y="8moa"` renders *above* the
83//! zero, and a holdover BDC entry sits at a *negative* `position-y`. So
84//!
85//! ```text
86//!   right_mil =  position-x           down_mil = -position-y
87//! ```
88//!
89//! and a holdover's negative `position-y` becomes the positive `down_mil` the engine wants.
90//!
91//! Every value names its own unit inline as a suffix — `"96moa"`, `"-0.55moa"`,
92//! `"1.5mil"`. There is no document-level unit declaration, so each attribute is converted
93//! on its own: `moa` scales by [`MOA_TO_MIL`] (shared with [`crate::reticle_import`], so
94//! the two importers cannot drift apart), `mil` / `mils` / `mrad` pass through. A value
95//! with no suffix, or with a suffix this module does not recognize, is a hard error rather
96//! than a guessed scale — silently mis-scaling a reticle by 3.44x is worse than refusing it.
97//!
98//! ## `size-x` / `size-y` / `zero-x` / `zero-y` are canvas placement, not a mark offset
99//!
100//! The root declares a canvas extent (`size-x`, `size-y`) and the position of the zero
101//! within it (`zero-x`, `zero-y`, measured from the canvas top-left). They exist so a
102//! renderer can map a reticle coordinate onto its output surface:
103//!
104//! ```text
105//!   canvas_x = (zero-x + position-x) / size-x * width
106//!   canvas_y = (zero-y - position-y) / size-y * height     (note the minus: +y is up)
107//! ```
108//!
109//! The origin of the element coordinate system *is* the zero — that is what the attributes
110//! are named after — so element coordinates are already zero-relative and `zero-x`/`zero-y`
111//! do **not** enter this transform. Changing them re-frames the picture on its canvas; it
112//! does not move a hold relative to the point of aim. They are reported (as
113//! [`ReticleImportReport::zero_in_canvas_mil`]) rather than applied, and there is a test
114//! that two documents differing only in their zero attributes import to identical marks.
115//!
116//! # What the format does not carry
117//!
118//! `.reticle` has no focal-plane and no magnification attribute. A document's own name
119//! string may hint at one in prose (`"… (4-12x40, at 12x)"`), but that is text for a human,
120//! not a field, and this module does not mine it.
121//!
122//! An imported description is therefore always [`FocalPlane::First`] with
123//! `reference_magnification = 1.0` — the identity, the only choice that scales nothing.
124//! **If the reticle is actually SFP, the caller must set
125//! [`ReticleDescription::focal_plane`] and
126//! [`ReticleDescription::reference_magnification`] from the optic before solving**, or
127//! every hold will be computed as though the subtensions were magnification-independent.
128//! Both fields are public precisely so that is a one-line fix at the call site.
129//!
130//! # Safety
131//!
132//! [`crate::reticle::MAX_RETICLE_MARKS`] is enforced *while* `<bdc>` entries are collected,
133//! so a hostile document with a million entries returns [`ReticleError::TooManyMarks`]
134//! promptly instead of allocating. Element nesting is bounded by
135//! [`MAX_DOCUMENT_DEPTH`], and the scanner is iterative — no recursion, so a deeply nested
136//! document cannot overflow the stack. This is the same defense as the 0.31.0 reticle
137//! generator size guards and the Ventum importer's repeat-expansion cap.
138
139use std::collections::BTreeMap;
140
141use crate::reticle::{
142    FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
143};
144use crate::reticle_import::MOA_TO_MIL;
145
146/// Deepest element nesting a `.reticle` document may use before it is rejected.
147///
148/// Real documents nest three deep (`reticle` > `elements` > `reticle-path` > `elements` >
149/// `reticle-path-line-to` is five). The cap exists so a hostile document cannot grow the
150/// open-element stack without bound; nothing legitimate approaches it.
151pub const MAX_DOCUMENT_DEPTH: usize = 64;
152
153/// The document root.
154const TAG_RETICLE: &str = "reticle";
155/// A structural container for child shapes. Appears both directly under the root and
156/// inside a `reticle-path` (whose vertices are its `<elements>`), so it is treated as
157/// structure wherever it occurs.
158const TAG_ELEMENTS: &str = "elements";
159/// Both the hold-bearing entry and the section that wraps a list of them; the two are told
160/// apart by whether the element carries a position (see [`bdc_mark`]).
161const TAG_BDC: &str = "bdc";
162
163const ATTR_NAME: &str = "name";
164const ATTR_SIZE_X: &str = "size-x";
165const ATTR_SIZE_Y: &str = "size-y";
166const ATTR_ZERO_X: &str = "zero-x";
167const ATTR_ZERO_Y: &str = "zero-y";
168const ATTR_POSITION_X: &str = "position-x";
169const ATTR_POSITION_Y: &str = "position-y";
170
171/// What an import left behind, so the hold/decoration rule is countable rather than
172/// implicit. See the module documentation for why the drawing is dropped.
173#[derive(Debug, Clone, PartialEq, Default)]
174pub struct ReticleImportReport {
175    /// How many drawing tags were walked past — the sum of [`Self::dropped_element_tags`].
176    ///
177    /// This counts *tags*, not shapes: a filled `reticle-path` contributes itself plus one
178    /// per `reticle-path-move-to` / `reticle-path-line-to` vertex, because that is what the
179    /// document literally contains.
180    pub drawing_elements_dropped: usize,
181    /// Per-tag counts of that drawing, sorted by tag name so the report is deterministic.
182    pub dropped_element_tags: Vec<(String, usize)>,
183    /// The document's declared canvas extent as `(size-x, size-y)` in milliradians, when
184    /// the root declares both. Rendering metadata — see the module documentation.
185    pub canvas_mil: Option<(f64, f64)>,
186    /// Where the zero sits inside that canvas, as `(zero-x, zero-y)` in milliradians from
187    /// the canvas top-left, when the root declares both. Rendering metadata: it does NOT
188    /// offset the imported marks.
189    pub zero_in_canvas_mil: Option<(f64, f64)>,
190}
191
192/// Import a `.reticle` XML document into a [`ReticleDescription`].
193///
194/// `xml` is one `.reticle` document (see the module documentation for the grammar). The
195/// returned description is ready for
196/// [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle): its marks are in
197/// milliradians from the zero, positive `down_mil` below it.
198///
199/// The description is always [`FocalPlane::First`] with `reference_magnification = 1.0`,
200/// because the format carries neither; a caller holding an SFP optic must overwrite both
201/// fields before solving.
202///
203/// This is a pure transform: like [`crate::reticle_import::import_ventum_reticle`] it does
204/// not itself reject an all-decoration document (the resulting description simply carries
205/// no marks, which [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) then
206/// reports as [`ReticleError::NoMarks`]). It DOES enforce
207/// [`crate::reticle::MAX_RETICLE_MARKS`] while collecting.
208///
209/// Use [`import_reticle_document_with_report`] when you want to know what was dropped.
210///
211/// # Errors
212///
213/// Returns [`ReticleError::InvalidSpec`] if `xml` is not well-formed, is not rooted at
214/// `<reticle>`, or carries an attribute value this module cannot read as an angle (missing
215/// unit suffix, unrecognized unit, non-numeric, non-finite, non-positive canvas), and
216/// [`ReticleError::TooManyMarks`] if the document declares more `<bdc>` entries than the
217/// mark cap.
218pub fn import_reticle_document(xml: &str) -> Result<ReticleDescription, ReticleError> {
219    import_reticle_document_with_report(xml).map(|(description, _)| description)
220}
221
222/// Import a `.reticle` document, also returning a [`ReticleImportReport`] of what the
223/// hold/decoration rule left behind.
224///
225/// Identical to [`import_reticle_document`] in every other respect; that function is this
226/// one with the report discarded.
227///
228/// # Errors
229///
230/// As [`import_reticle_document`].
231pub fn import_reticle_document_with_report(
232    xml: &str,
233) -> Result<(ReticleDescription, ReticleImportReport), ReticleError> {
234    let mut scanner = XmlScanner::new(xml);
235
236    // Names of the currently open elements, innermost last. A Vec rather than recursion:
237    // depth is data here, and a document must not be able to nest us into a stack overflow.
238    let mut open: Vec<&str> = Vec::new();
239    let mut root_seen = false;
240
241    let mut name = String::new();
242    let (mut size_x, mut size_y) = (None, None);
243    let (mut zero_x, mut zero_y) = (None, None);
244    let mut marks: Vec<ReticleMark> = Vec::new();
245    // Keyed by a borrowed slice of the input, so a document with many distinct tag names
246    // costs pointers rather than copies; owned only when the report is built.
247    let mut dropped: BTreeMap<&str, usize> = BTreeMap::new();
248
249    while let Some(event) = scanner.next_event()? {
250        match event {
251            XmlEvent::Start {
252                tag,
253                attributes,
254                self_closing,
255            } => {
256                if open.is_empty() {
257                    // Top level: this must be the one and only root, and it must be a
258                    // `.reticle` document rather than some other XML that happens to parse.
259                    if root_seen {
260                        return Err(invalid(
261                            "a .reticle document must have exactly one root element".to_string(),
262                        ));
263                    }
264                    if tag != TAG_RETICLE {
265                        return Err(invalid(format!(
266                            "expected a <{TAG_RETICLE}> root element, found <{tag}>"
267                        )));
268                    }
269                    root_seen = true;
270
271                    if let Some(raw) = attribute(&attributes, ATTR_NAME) {
272                        name = decode_entities(raw);
273                    }
274                    size_x = parse_canvas_extent(&attributes, ATTR_SIZE_X)?;
275                    size_y = parse_canvas_extent(&attributes, ATTR_SIZE_Y)?;
276                    zero_x = parse_optional_angle_mil(&attributes, ATTR_ZERO_X)?;
277                    zero_y = parse_optional_angle_mil(&attributes, ATTR_ZERO_Y)?;
278                } else {
279                    match tag {
280                        // Pure structure: neither a hold nor a drawn shape.
281                        TAG_ELEMENTS => {}
282                        // The hold-bearing section. A `<bdc>` that carries a position is an
283                        // entry; one that carries none is the wrapper around entries, and
284                        // contributes nothing itself.
285                        TAG_BDC => {
286                            if let Some(mark) = bdc_mark(&attributes)? {
287                                push_capped(&mut marks, mark)?;
288                            }
289                        }
290                        // Everything else is the picture. Its geometry attributes are never
291                        // parsed — only tallied, so the drop is visible in the report.
292                        drawing => *dropped.entry(drawing).or_insert(0) += 1,
293                    }
294                }
295
296                if !self_closing {
297                    if open.len() >= MAX_DOCUMENT_DEPTH {
298                        return Err(invalid(format!(
299                            "element nesting deeper than {MAX_DOCUMENT_DEPTH} at <{tag}>"
300                        )));
301                    }
302                    open.push(tag);
303                }
304            }
305            XmlEvent::End { tag } => match open.pop() {
306                Some(expected) if expected == tag => {}
307                Some(expected) => {
308                    return Err(invalid(format!(
309                        "</{tag}> closes <{expected}>, which is still open"
310                    )))
311                }
312                None => return Err(invalid(format!("</{tag}> has no matching open element"))),
313            },
314        }
315    }
316
317    if let Some(unclosed) = open.last() {
318        return Err(invalid(format!("<{unclosed}> is never closed")));
319    }
320    if !root_seen {
321        return Err(invalid(format!(
322            "the document has no <{TAG_RETICLE}> root element"
323        )));
324    }
325
326    let report = ReticleImportReport {
327        drawing_elements_dropped: dropped.values().sum(),
328        dropped_element_tags: dropped
329            .into_iter()
330            .map(|(tag, count)| (tag.to_string(), count))
331            .collect(),
332        canvas_mil: size_x.zip(size_y),
333        zero_in_canvas_mil: zero_x.zip(zero_y),
334    };
335
336    let description = ReticleDescription {
337        name,
338        // The format declares no focal plane, so the import applies the identity: FFP
339        // subtensions do not scale with magnification. See the module documentation.
340        focal_plane: FocalPlane::First,
341        reference_magnification: 1.0,
342        marks,
343    };
344
345    Ok((description, report))
346}
347
348/// Turn one `<bdc>` element into a mark, or `None` when it is the section wrapper rather
349/// than an entry.
350///
351/// An entry is any `<bdc>` carrying at least one of `position-x` / `position-y`; the
352/// wrapper carries neither. A missing axis defaults to `0` — an entry that names only its
353/// `position-y` sits on the vertical stadia, which is where a holdover ladder lives.
354fn bdc_mark(attributes: &[(&str, &str)]) -> Result<Option<ReticleMark>, ReticleError> {
355    let raw_x = attribute(attributes, ATTR_POSITION_X);
356    let raw_y = attribute(attributes, ATTR_POSITION_Y);
357    if raw_x.is_none() && raw_y.is_none() {
358        return Ok(None);
359    }
360
361    let right_mil = match raw_x {
362        Some(raw) => parse_angle_mil(raw, ATTR_POSITION_X)?,
363        None => 0.0,
364    };
365    let up_mil = match raw_y {
366        Some(raw) => parse_angle_mil(raw, ATTR_POSITION_Y)?,
367        None => 0.0,
368    };
369
370    // THE sign flip: the document measures +y UP from the zero, the engine measures
371    // +down_mil DOWN from it. A holdover's negative position-y is a positive down_mil.
372    // `text-offset` / `text-height` are label-rendering hints and are dropped; a `<bdc>`
373    // entry carries no range text to become a mark label.
374    Ok(Some(ReticleMark::new(
375        -up_mil,
376        right_mil,
377        // The format gives a bdc point no shape (the shape lives in the drawing), so this
378        // is the conventional drawing of a holdover point on a stadia line. `MarkKind` is
379        // descriptive only and has no effect on the hold math.
380        MarkKind::Hash,
381    )))
382}
383
384/// Push one mark, rejecting the document the instant the running count would exceed
385/// [`MAX_RETICLE_MARKS`]. Checking before every push bounds the allocation at the cap
386/// regardless of how many `<bdc>` entries the document declares.
387fn push_capped(marks: &mut Vec<ReticleMark>, mark: ReticleMark) -> Result<(), ReticleError> {
388    if marks.len() >= MAX_RETICLE_MARKS {
389        return Err(ReticleError::TooManyMarks {
390            count: marks.len() + 1,
391            max: MAX_RETICLE_MARKS,
392        });
393    }
394    marks.push(mark);
395    Ok(())
396}
397
398/// First value for `wanted`, or `None`. XML forbids duplicate attribute names; if a
399/// document has them anyway the first wins rather than the parse failing.
400fn attribute<'a>(attributes: &[(&'a str, &'a str)], wanted: &str) -> Option<&'a str> {
401    attributes
402        .iter()
403        .find(|(key, _)| *key == wanted)
404        .map(|(_, value)| *value)
405}
406
407/// Parse an optional angle-valued root attribute.
408fn parse_optional_angle_mil(
409    attributes: &[(&str, &str)],
410    wanted: &'static str,
411) -> Result<Option<f64>, ReticleError> {
412    match attribute(attributes, wanted) {
413        Some(raw) => parse_angle_mil(raw, wanted).map(Some),
414        None => Ok(None),
415    }
416}
417
418/// Parse a canvas extent (`size-x` / `size-y`), which must be strictly positive.
419///
420/// The extent never reaches a mark, so this check is not protecting the math — it is
421/// protecting the reading. A canvas of zero or negative width means the document is not
422/// shaped the way this module believes it is, and importing marks out of a file we have
423/// evidently misread is worse than refusing it.
424fn parse_canvas_extent(
425    attributes: &[(&str, &str)],
426    wanted: &'static str,
427) -> Result<Option<f64>, ReticleError> {
428    let Some(value) = parse_optional_angle_mil(attributes, wanted)? else {
429        return Ok(None);
430    };
431    if value <= 0.0 {
432        return Err(invalid(format!(
433            "{wanted} must be greater than zero (got {value} mil)"
434        )));
435    }
436    Ok(Some(value))
437}
438
439/// Convert one unit-suffixed attribute value to milliradians.
440///
441/// `.reticle` writes the unit inline on every value (`"96moa"`, `"-0.55moa"`, `"1.5mil"`),
442/// so each attribute is converted on its own terms; there is no document-level unit to
443/// fall back on. A value with no suffix or an unrecognized one is refused rather than
444/// assigned a guessed scale.
445fn parse_angle_mil(raw: &str, attribute: &str) -> Result<f64, ReticleError> {
446    let text = raw.trim();
447
448    // The unit is the trailing run of ASCII letters. Splitting from the right (rather than
449    // scanning for the first letter) keeps scientific notation intact: "1e5moa" splits into
450    // "1e5" and "moa", because the digit stops the run.
451    let suffix_len = text
452        .chars()
453        .rev()
454        .take_while(|c| c.is_ascii_alphabetic())
455        .count();
456    let (number, unit) = text.split_at(text.len() - suffix_len);
457
458    let scale = if unit.eq_ignore_ascii_case("moa") {
459        MOA_TO_MIL
460    } else if unit.eq_ignore_ascii_case("mil")
461        || unit.eq_ignore_ascii_case("mils")
462        || unit.eq_ignore_ascii_case("mrad")
463    {
464        1.0
465    } else if unit.is_empty() {
466        return Err(invalid(format!(
467            "{attribute}=\"{raw}\" has no unit suffix; every .reticle value names its own \
468             unit (e.g. \"1.5moa\")"
469        )));
470    } else {
471        return Err(invalid(format!(
472            "{attribute}=\"{raw}\" uses the unrecognized unit \"{unit}\"; this importer \
473             reads moa, mil and mrad"
474        )));
475    };
476
477    let value: f64 = number.trim().parse().map_err(|_| {
478        invalid(format!(
479            "{attribute}=\"{raw}\" is not a number followed by a unit"
480        ))
481    })?;
482    if !value.is_finite() {
483        return Err(invalid(format!("{attribute}=\"{raw}\" is not finite")));
484    }
485
486    Ok(value * scale)
487}
488
489fn invalid(reason: String) -> ReticleError {
490    ReticleError::InvalidSpec(reason)
491}
492
493/// Expand the five predefined XML entities and numeric character references.
494///
495/// Only ever applied to `name`, the one attribute that becomes a human-readable string.
496/// An unrecognized entity is left verbatim rather than failing the import — a mangled
497/// display name is not worth refusing a reticle over.
498fn decode_entities(raw: &str) -> String {
499    if !raw.contains('&') {
500        return raw.to_string();
501    }
502
503    let mut out = String::with_capacity(raw.len());
504    let mut rest = raw;
505    while let Some(start) = rest.find('&') {
506        out.push_str(&rest[..start]);
507        let tail = &rest[start..];
508        let Some(end) = tail.find(';') else {
509            // No terminator at all: the remainder is literal text.
510            out.push_str(tail);
511            return out;
512        };
513        let entity = &tail[1..end];
514        match entity {
515            "amp" => out.push('&'),
516            "lt" => out.push('<'),
517            "gt" => out.push('>'),
518            "quot" => out.push('"'),
519            "apos" => out.push('\''),
520            _ => {
521                let decoded = entity.strip_prefix('#').and_then(|digits| {
522                    let code = match digits.strip_prefix(['x', 'X']) {
523                        Some(hex) => u32::from_str_radix(hex, 16).ok()?,
524                        None => digits.parse::<u32>().ok()?,
525                    };
526                    char::from_u32(code)
527                });
528                match decoded {
529                    Some(c) => out.push(c),
530                    None => out.push_str(&tail[..=end]),
531                }
532            }
533        }
534        rest = &tail[end + 1..];
535    }
536    out.push_str(rest);
537    out
538}
539
540// ---------------------------------------------------------------------------------------
541// Minimal XML scanner.
542//
543// Hand-rolled rather than pulled from a crate, for the same reason `profile_import` hand-
544// rolls its protobuf wire decoder: this crate ships to thirteen platforms plus wasm32, and
545// a whole XML library is a large dependency to carry for one importer that needs elements
546// and attributes and nothing else. Text nodes, CDATA, namespaces, DTDs and entity
547// declarations are all skipped — a `.reticle` document puts every value in an attribute,
548// so no element content is ever read.
549//
550// It is a scanner, not a validator: it enforces the well-formedness a misread would hide
551// (balanced tags, quoted attribute values, one root) and ignores the rest.
552// ---------------------------------------------------------------------------------------
553
554/// One scanned markup event. Comments, processing instructions, doctypes, CDATA and text
555/// never surface.
556#[derive(Debug)]
557enum XmlEvent<'a> {
558    Start {
559        tag: &'a str,
560        attributes: Vec<(&'a str, &'a str)>,
561        self_closing: bool,
562    },
563    End {
564        tag: &'a str,
565    },
566}
567
568struct XmlScanner<'a> {
569    src: &'a str,
570    pos: usize,
571}
572
573impl<'a> XmlScanner<'a> {
574    fn new(src: &'a str) -> Self {
575        Self { src, pos: 0 }
576    }
577
578    fn bytes(&self) -> &'a [u8] {
579        self.src.as_bytes()
580    }
581
582    fn rest(&self) -> &'a str {
583        &self.src[self.pos..]
584    }
585
586    /// Advance past `open`-prefixed markup terminated by `close`, or fail naming it.
587    fn skip_delimited(&mut self, open: &str, close: &str, what: &str) -> Result<(), ReticleError> {
588        let after_open = self.pos + open.len();
589        match self.src[after_open..].find(close) {
590            Some(offset) => {
591                self.pos = after_open + offset + close.len();
592                Ok(())
593            }
594            None => Err(invalid(format!(
595                "unterminated {what} at byte {} (expected {close:?})",
596                self.pos
597            ))),
598        }
599    }
600
601    /// The next start or end tag, or `None` at end of input.
602    fn next_event(&mut self) -> Result<Option<XmlEvent<'a>>, ReticleError> {
603        loop {
604            // Everything between tags is text content, which this format never uses.
605            match self.rest().find('<') {
606                Some(offset) => self.pos += offset,
607                None => {
608                    self.pos = self.src.len();
609                    return Ok(None);
610                }
611            }
612
613            let rest = self.rest();
614            if rest.starts_with("<!--") {
615                self.skip_delimited("<!--", "-->", "comment")?;
616            } else if rest.starts_with("<![CDATA[") {
617                self.skip_delimited("<![CDATA[", "]]>", "CDATA section")?;
618            } else if rest.starts_with("<?") {
619                self.skip_delimited("<?", "?>", "processing instruction")?;
620            } else if rest.starts_with("<!") {
621                // DOCTYPE and friends. A document type declaration with an internal subset
622                // would end at the wrong '>', which is precisely why nothing here reads
623                // element content: the worst case is a spurious parse error, never a
624                // silently different reticle.
625                self.skip_delimited("<!", ">", "declaration")?;
626            } else if rest.starts_with("</") {
627                return self.read_end_tag().map(Some);
628            } else {
629                return self.read_start_tag().map(Some);
630            }
631        }
632    }
633
634    fn read_end_tag(&mut self) -> Result<XmlEvent<'a>, ReticleError> {
635        self.pos += 2; // past "</"
636        let start = self.pos;
637        let Some(tag) = self.read_name() else {
638            return Err(invalid(format!(
639                "an end tag has an empty name at byte {start}"
640            )));
641        };
642        self.skip_whitespace();
643        if self.bytes().get(self.pos) != Some(&b'>') {
644            return Err(invalid(format!(
645                "</{tag}> is malformed at byte {}",
646                self.pos
647            )));
648        }
649        self.pos += 1;
650        Ok(XmlEvent::End { tag })
651    }
652
653    fn read_start_tag(&mut self) -> Result<XmlEvent<'a>, ReticleError> {
654        self.pos += 1; // past '<'
655        let start = self.pos;
656        let Some(tag) = self.read_name() else {
657            return Err(invalid(format!(
658                "a start tag has an empty name at byte {start}"
659            )));
660        };
661        let mut attributes: Vec<(&'a str, &'a str)> = Vec::new();
662
663        loop {
664            self.skip_whitespace();
665            let rest = self.rest();
666            if rest.starts_with("/>") {
667                self.pos += 2;
668                return Ok(XmlEvent::Start {
669                    tag,
670                    attributes,
671                    self_closing: true,
672                });
673            }
674            if rest.starts_with('>') {
675                self.pos += 1;
676                return Ok(XmlEvent::Start {
677                    tag,
678                    attributes,
679                    self_closing: false,
680                });
681            }
682            if rest.is_empty() {
683                return Err(invalid(format!("<{tag}> is never terminated")));
684            }
685
686            let Some(key) = self.read_name() else {
687                return Err(invalid(format!(
688                    "<{tag}> has a malformed attribute at byte {}",
689                    self.pos
690                )));
691            };
692            self.skip_whitespace();
693            if self.bytes().get(self.pos) != Some(&b'=') {
694                return Err(invalid(format!(
695                    "attribute {key:?} of <{tag}> has no value"
696                )));
697            }
698            self.pos += 1;
699            self.skip_whitespace();
700
701            let quote = match self.bytes().get(self.pos) {
702                Some(&q @ (b'"' | b'\'')) => q,
703                _ => {
704                    return Err(invalid(format!(
705                        "attribute {key:?} of <{tag}> must be quoted"
706                    )))
707                }
708            };
709            self.pos += 1;
710            let start = self.pos;
711            let Some(offset) = self.rest().find(quote as char) else {
712                return Err(invalid(format!(
713                    "attribute {key:?} of <{tag}> has an unterminated value"
714                )));
715            };
716            self.pos = start + offset;
717            attributes.push((key, &self.src[start..self.pos]));
718            self.pos += 1; // past the closing quote
719        }
720    }
721
722    /// Read an element or attribute name: everything up to whitespace or one of `/>=`.
723    /// `None` when there is no name there at all, which every caller reports as malformed.
724    fn read_name(&mut self) -> Option<&'a str> {
725        let start = self.pos;
726        let bytes = self.bytes();
727        while let Some(&byte) = bytes.get(self.pos) {
728            if byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>' | b'=') {
729                break;
730            }
731            self.pos += 1;
732        }
733        if self.pos == start {
734            return None;
735        }
736        Some(&self.src[start..self.pos])
737    }
738
739    fn skip_whitespace(&mut self) {
740        let bytes = self.bytes();
741        while matches!(bytes.get(self.pos), Some(byte) if byte.is_ascii_whitespace()) {
742            self.pos += 1;
743        }
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::reticle::hold_point_in_reticle;
751
752    /// A miniature but complete document in the shape a real one takes: a filled background
753    /// rectangle, a field-of-view circle, crosshair arms, one windage hash, a filled post
754    /// path, and a `<bdc>` section listing three holdovers that the drawing also draws.
755    /// Invented content — the geometry is not any product's reticle.
756    const LADDER_MOA: &str = r#"<?xml version="1.0" encoding="utf-8"?>
757<reticle name="Test Ladder MOA" size-x="40moa" size-y="40moa" zero-x="20moa" zero-y="20moa">
758  <elements>
759    <reticle-rectangle position-x="-20moa" position-y="20moa" size-x="40moa" size-y="40moa" fill="true" color="black" />
760    <reticle-circle center-x="0moa" center-y="0moa" radius="19moa" fill="true" line-width="0moa" color="white" />
761    <!-- crosshair arms -->
762    <reticle-line start-x="0moa" start-y="0moa" end-x="0moa" end-y="6moa" line-width="0.2moa" line-color="black" />
763    <reticle-line start-x="0moa" start-y="0moa" end-x="0moa" end-y="-9moa" line-width="0.2moa" line-color="black" />
764    <reticle-line start-x="-3moa" start-y="-0.5moa" end-x="-3moa" end-y="0.5moa" line-width="0.2moa" line-color="black" />
765    <!-- the holdover ticks the bdc section names again -->
766    <reticle-line start-x="-0.5moa" start-y="-2moa" end-x="0.5moa" end-y="-2moa" line-width="0.2moa" line-color="black" />
767    <reticle-line start-x="-0.5moa" start-y="-5moa" end-x="0.5moa" end-y="-5moa" line-width="0.2moa" line-color="black" />
768    <reticle-line start-x="-0.5moa" start-y="-8moa" end-x="0.5moa" end-y="-8moa" line-width="0.2moa" line-color="black" />
769    <reticle-path fill="true" color="black">
770      <elements>
771        <reticle-path-move-to position-x="-0.3moa" position-y="19moa" />
772        <reticle-path-line-to position-x="0.3moa" position-y="19moa" />
773        <reticle-path-line-to position-x="0.3moa" position-y="6moa" />
774        <reticle-path-line-to position-x="-0.3moa" position-y="6moa" />
775      </elements>
776    </reticle-path>
777  </elements>
778  <bdc>
779    <bdc position-x="0moa" position-y="-2moa" text-offset="1moa" text-height="0.8moa" />
780    <bdc position-x="0moa" position-y="-5moa" text-offset="1moa" text-height="0.8moa" />
781    <bdc position-x="0moa" position-y="-8moa" text-offset="1moa" text-height="0.8moa" />
782  </bdc>
783</reticle>"#;
784
785    /// The imported `down_mil` values, sorted.
786    fn downs(description: &ReticleDescription) -> Vec<f64> {
787        let mut downs: Vec<f64> = description.marks.iter().map(|m| m.down_mil).collect();
788        downs.sort_by(|a, b| a.partial_cmp(b).unwrap());
789        downs
790    }
791
792    #[test]
793    fn bdc_entries_are_the_only_holds_and_the_drawing_is_dropped() {
794        let (description, report) = import_reticle_document_with_report(LADDER_MOA).unwrap();
795
796        assert_eq!(description.name, "Test Ladder MOA");
797        assert_eq!(
798            description.marks.len(),
799            3,
800            "only the three <bdc> entries carry holds; the drawing draws six lines, a \
801             circle, a rectangle and a path and none of them is a mark"
802        );
803        assert!(
804            description.marks.iter().all(|m| m.kind == MarkKind::Hash),
805            "a bdc point has no declared shape; every imported mark is a hash"
806        );
807        assert!(
808            description.marks.iter().all(|m| m.label.is_none()),
809            "a bdc entry carries no range text, so no mark is labeled"
810        );
811
812        // The three holdover ticks are drawn AND listed; importing both would double them.
813        assert_eq!(
814            report
815                .dropped_element_tags
816                .iter()
817                .find(|(tag, _)| tag == "reticle-line")
818                .map(|(_, count)| *count),
819            Some(6),
820            "the three drawn holdover ticks are dropped as drawing, not imported twice"
821        );
822        assert_eq!(
823            report.dropped_element_tags,
824            vec![
825                ("reticle-circle".to_string(), 1),
826                ("reticle-line".to_string(), 6),
827                ("reticle-path".to_string(), 1),
828                ("reticle-path-line-to".to_string(), 3),
829                ("reticle-path-move-to".to_string(), 1),
830                ("reticle-rectangle".to_string(), 1),
831            ],
832            "the report tallies every drawing tag it walked past, sorted by tag"
833        );
834        assert_eq!(report.drawing_elements_dropped, 13);
835    }
836
837    #[test]
838    fn positive_y_in_the_document_is_down_negated_in_the_engine() {
839        let description = import_reticle_document(LADDER_MOA).unwrap();
840        // -2/-5/-8 MOA in the document (below the zero, +y up) become POSITIVE down_mil.
841        let expected: Vec<f64> = vec![2.0, 5.0, 8.0]
842            .into_iter()
843            .map(|moa| moa * MOA_TO_MIL)
844            .collect();
845        for (got, want) in downs(&description).iter().zip(expected.iter()) {
846            assert!(
847                (got - want).abs() < 1e-12,
848                "holdover {got} mil should be {want} mil below the zero"
849            );
850        }
851        assert!(
852            description.marks.iter().all(|m| m.down_mil > 0.0),
853            "a holdover is BELOW the zero after the sign flip"
854        );
855    }
856
857    #[test]
858    fn a_mark_above_the_zero_imports_as_negative_down() {
859        // A stadia mark above the zero (a positive position-y) is a negative down_mil.
860        let description = import_reticle_document(
861            r#"<reticle name="Up" size-x="10mil" size-y="10mil" zero-x="5mil" zero-y="5mil">
862                 <bdc><bdc position-x="0mil" position-y="3mil" /></bdc>
863               </reticle>"#,
864        )
865        .unwrap();
866        assert_eq!(description.marks.len(), 1);
867        assert_eq!(description.marks[0].down_mil, -3.0);
868    }
869
870    #[test]
871    fn each_value_converts_on_its_own_unit_suffix() {
872        // No document-level unit: mixed suffixes in one document all normalize to mil.
873        let description = import_reticle_document(
874            r#"<reticle name="Mixed" size-x="40moa" size-y="10mrad" zero-x="20moa" zero-y="5mrad">
875                 <bdc>
876                   <bdc position-x="0mil" position-y="-1.5mil" />
877                   <bdc position-x="0mrad" position-y="-3mrad" />
878                   <bdc position-x="0moa" position-y="-2MOA" />
879                 </bdc>
880               </reticle>"#,
881        )
882        .unwrap();
883        // Document order, not sorted: each entry is converted on its own suffix.
884        let got: Vec<f64> = description.marks.iter().map(|m| m.down_mil).collect();
885        assert_eq!(got[0], 1.5, "mil passes through");
886        assert_eq!(got[1], 3.0, "mrad passes through");
887        assert!(
888            (got[2] - 2.0 * MOA_TO_MIL).abs() < 1e-12,
889            "MOA is case-insensitive and scales by MOA_TO_MIL (got {})",
890            got[2]
891        );
892    }
893
894    #[test]
895    fn a_value_this_module_cannot_scale_is_refused_rather_than_guessed() {
896        for (xml, why) in [
897            (
898                r#"<reticle><bdc><bdc position-y="-2" /></bdc></reticle>"#,
899                "no unit suffix",
900            ),
901            (
902                r#"<reticle><bdc><bdc position-y="-2cm100m" /></bdc></reticle>"#,
903                "unrecognized unit",
904            ),
905            (
906                r#"<reticle><bdc><bdc position-y="twomoa" /></bdc></reticle>"#,
907                "not a number",
908            ),
909            (
910                r#"<reticle><bdc><bdc position-y="1e400moa" /></bdc></reticle>"#,
911                "overflows to infinity",
912            ),
913        ] {
914            assert!(
915                matches!(
916                    import_reticle_document(xml),
917                    Err(ReticleError::InvalidSpec(_))
918                ),
919                "{why} must be an InvalidSpec, not a guessed scale"
920            );
921        }
922    }
923
924    #[test]
925    fn a_non_positive_canvas_is_refused() {
926        assert!(matches!(
927            import_reticle_document(r#"<reticle size-x="0moa" size-y="10moa"></reticle>"#),
928            Err(ReticleError::InvalidSpec(_))
929        ));
930        assert!(matches!(
931            import_reticle_document(r#"<reticle size-x="-4moa" size-y="10moa"></reticle>"#),
932            Err(ReticleError::InvalidSpec(_))
933        ));
934    }
935
936    #[test]
937    fn the_zero_attributes_place_the_canvas_and_never_move_a_mark() {
938        // Two documents identical but for zero-x/zero-y: the zero is the origin of the
939        // element coordinate system, so re-framing the canvas cannot move a hold.
940        let centered = import_reticle_document(
941            r#"<reticle name="Z" size-x="40moa" size-y="40moa" zero-x="20moa" zero-y="20moa">
942                 <bdc><bdc position-x="1moa" position-y="-5moa" /></bdc>
943               </reticle>"#,
944        )
945        .unwrap();
946        let offset = import_reticle_document(
947            r#"<reticle name="Z" size-x="40moa" size-y="40moa" zero-x="12moa" zero-y="31moa">
948                 <bdc><bdc position-x="1moa" position-y="-5moa" /></bdc>
949               </reticle>"#,
950        )
951        .unwrap();
952        assert_eq!(
953            centered, offset,
954            "zero-x/zero-y are canvas placement; they must not offset the marks"
955        );
956    }
957
958    #[test]
959    fn the_canvas_and_zero_are_reported_in_milliradians() {
960        let (_, report) = import_reticle_document_with_report(LADDER_MOA).unwrap();
961        let (size_x, size_y) = report.canvas_mil.expect("canvas declared");
962        let (zero_x, zero_y) = report.zero_in_canvas_mil.expect("zero declared");
963        assert!((size_x - 40.0 * MOA_TO_MIL).abs() < 1e-12);
964        assert!((size_y - 40.0 * MOA_TO_MIL).abs() < 1e-12);
965        assert!((zero_x - 20.0 * MOA_TO_MIL).abs() < 1e-12);
966        assert!((zero_y - 20.0 * MOA_TO_MIL).abs() < 1e-12);
967
968        // Undeclared canvas metadata is absent, not defaulted.
969        let (_, bare) = import_reticle_document_with_report(
970            r#"<reticle name="Bare"><bdc><bdc position-y="-1mil" /></bdc></reticle>"#,
971        )
972        .unwrap();
973        assert_eq!(bare.canvas_mil, None);
974        assert_eq!(bare.zero_in_canvas_mil, None);
975    }
976
977    #[test]
978    fn a_bdc_wrapper_is_not_itself_a_mark() {
979        // The section wrapper carries no position; only its children are entries. An entry
980        // naming one axis sits at 0 on the other.
981        let description = import_reticle_document(
982            r#"<reticle name="W" size-x="10mil" size-y="10mil">
983                 <bdc>
984                   <bdc position-y="-1mil" />
985                   <bdc position-x="2mil" />
986                 </bdc>
987               </reticle>"#,
988        )
989        .unwrap();
990        assert_eq!(description.marks.len(), 2, "the wrapper is not a mark");
991        assert_eq!(description.marks[0].down_mil, 1.0);
992        assert_eq!(description.marks[0].right_mil, 0.0);
993        assert_eq!(description.marks[1].down_mil, 0.0);
994        assert_eq!(description.marks[1].right_mil, 2.0);
995
996        // An empty section contributes nothing at all.
997        let empty = import_reticle_document(r#"<reticle name="E"><bdc /></reticle>"#).unwrap();
998        assert!(empty.marks.is_empty());
999    }
1000
1001    #[test]
1002    fn an_all_decoration_document_imports_with_no_marks() {
1003        // A pure transform: a document that draws a reticle but declares no bdc entry is
1004        // not an error here, it is simply a description with nothing to hold on. The
1005        // rejection belongs to the solver.
1006        let description = import_reticle_document(
1007            r#"<reticle name="Picture" size-x="20moa" size-y="20moa">
1008                 <elements>
1009                   <reticle-line start-x="-8moa" start-y="0moa" end-x="8moa" end-y="0moa" line-width="0.2moa" />
1010                   <reticle-circle center-x="0moa" center-y="0moa" radius="2moa" />
1011                   <future-shape whatever="7pizzas" />
1012                 </elements>
1013               </reticle>"#,
1014        )
1015        .unwrap();
1016        assert!(description.marks.is_empty());
1017        assert_eq!(description.validate(), Err(ReticleError::NoMarks));
1018        assert!(
1019            matches!(
1020                hold_point_in_reticle(1.0, 0.0, 1.0, &description),
1021                Err(ReticleError::NoMarks)
1022            ),
1023            "an all-decoration reticle has nothing to hold on"
1024        );
1025    }
1026
1027    #[test]
1028    fn dropped_geometry_is_never_read_as_an_angle() {
1029        // Decoration geometry is tallied, not read: an unreadable unit inside a shape we
1030        // drop must not fail the import, exactly as the Ventum importer ignores the fields
1031        // of its decoration variants.
1032        let (description, report) = import_reticle_document_with_report(
1033            r#"<reticle name="Odd" size-x="20moa" size-y="20moa">
1034                 <elements>
1035                   <reticle-line start-x="nonsense" end-y="12furlongs" />
1036                 </elements>
1037                 <bdc><bdc position-y="-1moa" /></bdc>
1038               </reticle>"#,
1039        )
1040        .unwrap();
1041        assert_eq!(description.marks.len(), 1);
1042        assert_eq!(report.drawing_elements_dropped, 1);
1043    }
1044
1045    #[test]
1046    fn the_import_is_always_ffp_identity_because_the_format_says_nothing() {
1047        // The name may talk about magnification in prose; the importer does not mine it.
1048        let description = import_reticle_document(
1049            r#"<reticle name="Scope 4-12x40, at 12x" size-x="20moa" size-y="20moa">
1050                 <bdc><bdc position-y="-2moa" /></bdc>
1051               </reticle>"#,
1052        )
1053        .unwrap();
1054        assert_eq!(description.focal_plane, FocalPlane::First);
1055        assert_eq!(description.reference_magnification, 1.0);
1056        assert_eq!(
1057            description.name, "Scope 4-12x40, at 12x",
1058            "the magnification hint stays prose in the name"
1059        );
1060    }
1061
1062    #[test]
1063    fn malformed_documents_report_invalid_spec() {
1064        for (xml, why) in [
1065            ("<reticle>", "unclosed root"),
1066            ("<reticle><bdc></reticle>", "mismatched close tag"),
1067            ("</reticle>", "close with nothing open"),
1068            ("", "no root at all"),
1069            ("   \n  ", "whitespace only"),
1070            ("<scope name=\"x\" />", "root is not <reticle>"),
1071            ("<reticle /><reticle />", "two roots"),
1072            (r#"<reticle name=x />"#, "unquoted attribute value"),
1073            (r#"<reticle name />"#, "attribute with no value"),
1074            (r#"<reticle name="unterminated />"#, "unterminated value"),
1075            ("<reticle", "unterminated start tag"),
1076            ("<!-- never ends", "unterminated comment"),
1077            ("<>", "empty tag name"),
1078        ] {
1079            assert!(
1080                matches!(
1081                    import_reticle_document(xml),
1082                    Err(ReticleError::InvalidSpec(_))
1083                ),
1084                "{why} must be reported as an InvalidSpec, not accepted"
1085            );
1086        }
1087    }
1088
1089    #[test]
1090    fn prologue_comments_and_cdata_are_skipped() {
1091        let description = import_reticle_document(
1092            r#"<?xml version="1.0"?>
1093               <!DOCTYPE reticle>
1094               <!-- a comment mentioning <reticle-line /> and </reticle> -->
1095               <reticle name="Skip" size-x="10mil" size-y="10mil">
1096                 <![CDATA[ <bdc position-y="-99mil" /> ]]>
1097                 <bdc><bdc position-y="-1mil" /></bdc>
1098               </reticle>"#,
1099        )
1100        .unwrap();
1101        assert_eq!(
1102            description.marks.len(),
1103            1,
1104            "markup inside a comment or CDATA is text, not an element"
1105        );
1106        assert_eq!(description.marks[0].down_mil, 1.0);
1107    }
1108
1109    #[test]
1110    fn too_many_bdc_entries_are_capped_during_collection() {
1111        let mut xml = String::from(r#"<reticle name="Flood"><bdc>"#);
1112        for _ in 0..(MAX_RETICLE_MARKS + 10) {
1113            xml.push_str(r#"<bdc position-y="-1mil" />"#);
1114        }
1115        xml.push_str("</bdc></reticle>");
1116        assert!(matches!(
1117            import_reticle_document(&xml),
1118            Err(ReticleError::TooManyMarks { max, .. }) if max == MAX_RETICLE_MARKS
1119        ));
1120    }
1121
1122    #[test]
1123    fn nesting_deeper_than_the_cap_is_refused_without_recursing() {
1124        let mut xml = String::from("<reticle>");
1125        for _ in 0..(MAX_DOCUMENT_DEPTH + 5) {
1126            xml.push_str("<elements>");
1127        }
1128        assert!(matches!(
1129            import_reticle_document(&xml),
1130            Err(ReticleError::InvalidSpec(_))
1131        ));
1132    }
1133
1134    #[test]
1135    fn entities_in_the_name_are_decoded() {
1136        let description = import_reticle_document(
1137            r#"<reticle name="Duplex &amp; BDC &quot;Pro&quot; &#8212; &#x33;x" />"#,
1138        )
1139        .unwrap();
1140        assert_eq!(description.name, r#"Duplex & BDC "Pro" — 3x"#);
1141
1142        // An entity this module does not know stays verbatim rather than failing the import.
1143        let odd = import_reticle_document(r#"<reticle name="A &nbsp; B &broken C" />"#).unwrap();
1144        assert_eq!(odd.name, "A &nbsp; B &broken C");
1145    }
1146
1147    #[test]
1148    fn an_imported_ladder_hold_solves() {
1149        let description = import_reticle_document(LADDER_MOA).unwrap();
1150        // A 5 MOA drop lands exactly on the middle holdover.
1151        let hold = hold_point_in_reticle(5.0 * MOA_TO_MIL, 0.0, 1.0, &description).unwrap();
1152        assert!(hold.nearest_mark.is_some());
1153        assert!(
1154            hold.nearest_mark_distance_mil < 1e-9,
1155            "the hold sits on the mark (distance {})",
1156            hold.nearest_mark_distance_mil
1157        );
1158        assert!(!hold.off_reticle);
1159
1160        // And the documented cost of the hold/decoration rule, asserted rather than
1161        // described: the drawn windage hash was dropped, so the windage axis is degenerate
1162        // and any wind hold reads off-reticle.
1163        let windy = hold_point_in_reticle(5.0 * MOA_TO_MIL, 0.5, 1.0, &description).unwrap();
1164        assert!(
1165            windy.off_reticle,
1166            "with elevation-only marks, any wind hold is off-reticle by construction"
1167        );
1168    }
1169}