Skip to main content

hpr_motor/
rse.rs

1//! RockSim `.rse` motor files: reading and writing (`docs/format/rse.md`).
2//!
3//! A file is an XML engine database. Each `<engine>` carries its summary as attributes (diameter
4//! and length in mm, masses in grams) and its samples as `<eng-data t f m cg/>` points, where `m`
5//! is the propellant left in grams and `cg` the motor's center of gravity in mm.
6//!
7//! The published guide is thin and disagrees with every real file on names, so the reader follows
8//! the observed structure and also accepts the guide's (`<point>`, `type`, `initMass`,
9//! `propMass`). Like [`crate::eng`], the file model keeps the file's units and points exactly, the
10//! reader reports what it accepted as [`ParseWarning`]s, and the writer refuses what the reader
11//! would read back differently.
12
13use std::fmt::Write as _;
14
15use roxmltree::{Document, Node, ParsingOptions};
16use serde::{Deserialize, Serialize};
17
18use crate::curve::ThrustCurve;
19use crate::delay::DelayList;
20use crate::error::MotorError;
21use crate::text::{ParseWarning, Parsed, WarningKind, check_writable, finite};
22
23const FORMAT: &str = ".rse";
24
25/// Attributes that only control how RockSim draws graphs [P p.2]; read and dropped silently.
26const RENDERING: [&str; 12] = [
27    "tDiv", "tStep", "tFix", "FDiv", "FStep", "FFix", "mDiv", "mStep", "mFix", "cgDiv", "cgStep",
28    "cgFix",
29];
30
31/// A parsed `.rse` file.
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct RseFile {
34    /// The engines, in file order.
35    pub engines: Vec<RseEngine>,
36}
37
38/// One engine in a `.rse` file. Optional attributes the file doesn't give are `None`.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct RseEngine {
41    /// `mfg`: the manufacturer, verbatim (real files have leading spaces and commas).
42    pub manufacturer: String,
43    /// `code`: the engine's designation, the lookup key.
44    pub code: String,
45    /// `Type`: `single-use`, `reloadable`, `hybrid` or other text.
46    pub motor_type: Option<String>,
47    /// `dia`: diameter, mm.
48    pub diameter_mm: f64,
49    /// `len`: length, mm.
50    pub length_mm: f64,
51    /// `initWt`: loaded motor mass, g.
52    pub initial_mass_g: f64,
53    /// `propWt`: propellant mass, g.
54    pub propellant_mass_g: f64,
55    /// `delays`: the delay string as written, such as `6,10,14`; see [`RseEngine::delays`].
56    pub delays: Option<String>,
57    /// `auto-calc-mass`: RockSim computes the mass curve itself.
58    pub auto_calc_mass: Option<bool>,
59    /// `auto-calc-cg`: RockSim holds the CG at the engine's center.
60    pub auto_calc_cg: Option<bool>,
61    /// `avgThrust`: average thrust, N.
62    pub average_thrust_n: Option<f64>,
63    /// `peakThrust`: peak thrust, N.
64    pub peak_thrust_n: Option<f64>,
65    /// `throatDia`: nozzle throat diameter, mm (zero in every observed file).
66    pub throat_diameter_mm: Option<f64>,
67    /// `exitDia`: nozzle exit diameter, mm (zero in every observed file).
68    pub exit_diameter_mm: Option<f64>,
69    /// `Itot`: total impulse, N·s.
70    pub total_impulse_ns: Option<f64>,
71    /// `burn-time`: burn time, s.
72    pub burn_time_s: Option<f64>,
73    /// `massFrac`: propellant mass fraction, %.
74    pub mass_fraction_pct: Option<f64>,
75    /// `Isp`: specific impulse, s.
76    pub isp_s: Option<f64>,
77    /// `<comments>` text, verbatim.
78    pub comments: Option<String>,
79    /// The samples, as listed.
80    pub points: Vec<RsePoint>,
81}
82
83/// One `<eng-data>` sample.
84#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
85pub struct RsePoint {
86    /// `t`: time since ignition, s.
87    pub time_s: f64,
88    /// `f`: thrust, N.
89    pub thrust_n: f64,
90    /// `m`: propellant mass left, g (observed; the guide says only "mass").
91    pub mass_g: Option<f64>,
92    /// `cg`: motor center of gravity, mm, from an end the guide doesn't name.
93    pub cg_mm: Option<f64>,
94}
95
96impl RseEngine {
97    /// The delay settings read from [`RseEngine::delays`]; empty when the file gives none.
98    pub fn delays(&self) -> DelayList {
99        DelayList::parse(self.delays.as_deref().unwrap_or(""))
100    }
101
102    /// The engine's thrust curve.
103    ///
104    /// # Errors
105    ///
106    /// As [`ThrustCurve::new`]: negative thrust, decreasing time, or no thrust.
107    pub fn thrust_curve(&self) -> Result<ThrustCurve, MotorError> {
108        let (times, thrusts) = self.points.iter().map(|p| (p.time_s, p.thrust_n)).unzip();
109        ThrustCurve::new(times, thrusts)
110    }
111}
112
113/// Reads a `.rse` file.
114///
115/// An engine with an error is skipped, with the error as a warning, when other engines in the file
116/// read. An `<engine>` nested inside another engine is not read.
117///
118/// # Errors
119///
120/// [`MotorError::Syntax`], with the line number, for XML that isn't well formed (or has a DTD),
121/// elements nested more than [`MAX_ELEMENT_DEPTH`] deep, or no `<engine>` elements; and, when no
122/// engine reads, the first engine's error: a missing required attribute (`code`, `dia`, `len`,
123/// `initWt`, `propWt`), an unreadable or non-finite number, a non-positive diameter or length, a
124/// negative mass, fewer than two points, a point without `t` or `f`, or negative or decreasing
125/// time.
126pub fn parse(text: &str) -> Result<Parsed<RseFile>, MotorError> {
127    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
128    check_nesting(text)?;
129    let options = ParsingOptions {
130        allow_dtd: false,
131        ..ParsingOptions::default()
132    };
133    let doc = Document::parse_with_options(text, options).map_err(|error| {
134        syntax(
135            error.pos().row as usize,
136            format!("not well-formed XML: {error}"),
137        )
138    })?;
139    let lines = Lines::new(text);
140    let mut warnings = Vec::new();
141    let mut engines = Vec::new();
142    let mut errors = Vec::new();
143    let is_engine = |node: &Node<'_, '_>| node.has_tag_name("engine");
144    for node in doc.descendants().filter(is_engine) {
145        if node
146            .ancestors()
147            .skip(1)
148            .any(|ancestor| is_engine(&ancestor))
149        {
150            warnings.push(ParseWarning::new(
151                lines.of(node),
152                WarningKind::Dropped,
153                "an <engine> inside another engine is ignored",
154            ));
155            continue;
156        }
157        let mut engine_warnings = Vec::new();
158        match engine(&lines, node, &mut engine_warnings) {
159            Ok(engine) => {
160                engines.push(engine);
161                warnings.append(&mut engine_warnings);
162            }
163            Err(error) => errors.push(error),
164        }
165    }
166    if engines.is_empty() {
167        return Err(errors
168            .into_iter()
169            .next()
170            .unwrap_or_else(|| syntax(1, "no <engine> elements".into())));
171    }
172    for error in errors {
173        let line = match &error {
174            MotorError::Syntax { line, .. } => *line,
175            _ => 0,
176        };
177        warnings.push(ParseWarning::new(
178            line,
179            WarningKind::Skipped,
180            format!("engine skipped: {error}"),
181        ));
182    }
183    warnings.sort_by_key(|warning| warning.line);
184    Ok(Parsed {
185        value: RseFile { engines },
186        warnings,
187    })
188}
189
190/// The byte offset where each line starts, to turn node positions into line numbers without
191/// rescanning the text for every node.
192struct Lines {
193    starts: Vec<usize>,
194}
195
196impl Lines {
197    fn new(text: &str) -> Self {
198        let starts = std::iter::once(0)
199            .chain(text.match_indices('\n').map(|(i, _)| i + 1))
200            .collect();
201        Self { starts }
202    }
203
204    /// The 1-based line a node starts on.
205    fn of(&self, node: Node<'_, '_>) -> usize {
206        self.starts
207            .partition_point(|&start| start <= node.range().start)
208    }
209}
210
211/// Writes a `.rse` file in RockSim's layout and attribute order, after an XML comment naming the
212/// program that wrote it, its version and its designation
213/// (`<!-- hpr-sim 0.1.0 · FS · SW · TOOL 005 -->`, [`hpr_core::tool::stamp`]). RockSim's files
214/// start at `<engine-database>` with no XML declaration, so the comment is the first line. The
215/// reader, as any XML reader, skips comments, so the file reads back the same.
216///
217/// # Errors
218///
219/// - [`MotorError::Inconsistent`] for no engines, an engine with fewer than two points, points of
220///   which only some carry `m` or `cg`, or text holding a character XML 1.0 can't carry.
221/// - [`MotorError::Domain`] for the values the reader rejects: non-finite numbers, a
222///   non-positive diameter or length, negative masses or times, and decreasing times.
223pub fn write(file: &RseFile) -> Result<String, MotorError> {
224    if file.engines.is_empty() {
225        return Err(MotorError::Inconsistent(
226            "a .rse file needs an engine".into(),
227        ));
228    }
229    // The stamp holds no `--` and doesn't end in `-`, which an XML comment can't
230    // (XML 1.0 §2.5); `stamp_is_a_comment_xml_can_hold` pins it.
231    let mut out = format!(
232        "<!-- {} -->\n<engine-database>\n  <engine-list>\n",
233        hpr_core::tool::stamp()
234    );
235    for engine in &file.engines {
236        write_engine(&mut out, engine)?;
237    }
238    out.push_str("  </engine-list>\n</engine-database>\n");
239    Ok(out)
240}
241
242fn write_engine(out: &mut String, engine: &RseEngine) -> Result<(), MotorError> {
243    check_dimensions(engine.diameter_mm, engine.length_mm)?;
244    check_writable(engine.initial_mass_g, "initial mass (g)", true)?;
245    check_writable(engine.propellant_mass_g, "propellant mass (g)", true)?;
246    if engine.points.len() < 2 {
247        return Err(MotorError::Inconsistent(format!(
248            "the .rse engine {:?} needs at least two points",
249            engine.code
250        )));
251    }
252    let mut attributes: Vec<(&str, String)> = vec![
253        ("mfg", xml_text(&engine.manufacturer, true)?),
254        ("code", xml_text(&engine.code, true)?),
255    ];
256    let mut text = |name, value: &Option<String>| -> Result<(), MotorError> {
257        if let Some(value) = value {
258            attributes.push((name, xml_text(value, true)?));
259        }
260        Ok(())
261    };
262    text("Type", &engine.motor_type)?;
263    attributes.push(("dia", engine.diameter_mm.to_string()));
264    attributes.push(("len", engine.length_mm.to_string()));
265    attributes.push(("initWt", engine.initial_mass_g.to_string()));
266    attributes.push(("propWt", engine.propellant_mass_g.to_string()));
267    if let Some(delays) = &engine.delays {
268        attributes.push(("delays", xml_text(delays, true)?));
269    }
270    for (name, flag) in [
271        ("auto-calc-mass", engine.auto_calc_mass),
272        ("auto-calc-cg", engine.auto_calc_cg),
273    ] {
274        if let Some(flag) = flag {
275            attributes.push((name, if flag { "1" } else { "0" }.to_owned()));
276        }
277    }
278    for (name, value) in [
279        ("avgThrust", engine.average_thrust_n),
280        ("peakThrust", engine.peak_thrust_n),
281        ("throatDia", engine.throat_diameter_mm),
282        ("exitDia", engine.exit_diameter_mm),
283        ("Itot", engine.total_impulse_ns),
284        ("burn-time", engine.burn_time_s),
285        ("massFrac", engine.mass_fraction_pct),
286        ("Isp", engine.isp_s),
287    ] {
288        if let Some(value) = value {
289            check_writable(value, "optional .rse attribute", false)?;
290            attributes.push((name, value.to_string()));
291        }
292    }
293    out.push_str("    <engine");
294    for (name, value) in &attributes {
295        let _ = write!(out, " {name}=\"{value}\"");
296    }
297    out.push_str(">\n");
298    if let Some(comments) = &engine.comments {
299        let _ = writeln!(
300            out,
301            "      <comments>{}</comments>",
302            xml_text(comments, false)?
303        );
304    }
305    out.push_str("      <data>\n");
306    let with_mass = engine.points.iter().filter(|p| p.mass_g.is_some()).count();
307    let with_cg = engine.points.iter().filter(|p| p.cg_mm.is_some()).count();
308    for (count, what) in [(with_mass, "m"), (with_cg, "cg")] {
309        if count != 0 && count != engine.points.len() {
310            return Err(MotorError::Inconsistent(format!(
311                "the .rse engine {:?} gives `{what}` on some points but not all",
312                engine.code
313            )));
314        }
315    }
316    let mut previous = 0.0;
317    for point in &engine.points {
318        check_writable(point.time_s, "time (s)", true)?;
319        check_writable(point.thrust_n, "thrust (N)", false)?;
320        if point.time_s < previous {
321            return Err(MotorError::Domain {
322                what: "time (s), which decreases",
323                value: point.time_s,
324            });
325        }
326        previous = point.time_s;
327        let _ = write!(
328            out,
329            "        <eng-data t=\"{}\" f=\"{}\"",
330            point.time_s, point.thrust_n
331        );
332        if let Some(mass) = point.mass_g {
333            check_writable(mass, "point mass (g)", false)?;
334            let _ = write!(out, " m=\"{mass}\"");
335        }
336        if let Some(cg) = point.cg_mm {
337            check_writable(cg, "point CG (mm)", false)?;
338            let _ = write!(out, " cg=\"{cg}\"");
339        }
340        out.push_str("/>\n");
341    }
342    out.push_str("      </data>\n    </engine>\n");
343    Ok(())
344}
345
346/// Escapes text for an attribute value (`attribute`) or element content. Characters XML 1.0
347/// normalizes on reading are written as references: tab, LF and CR in attributes [X §3.3.3], and
348/// CR in content [X §2.11].
349fn xml_text(value: &str, attribute: bool) -> Result<String, MotorError> {
350    let mut escaped = String::with_capacity(value.len());
351    for c in value.chars() {
352        match c {
353            '&' => escaped.push_str("&amp;"),
354            '<' => escaped.push_str("&lt;"),
355            '>' => escaped.push_str("&gt;"),
356            '"' if attribute => escaped.push_str("&quot;"),
357            '\t' if attribute => escaped.push_str("&#9;"),
358            '\n' if attribute => escaped.push_str("&#10;"),
359            '\r' => escaped.push_str("&#13;"),
360            '\t' | '\n' => escaped.push(c),
361            // XML 1.0 §2.2: no other C0 controls, surrogates (impossible in a str) or U+FFFE/FFFF.
362            '\u{0}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}' => {
363                return Err(MotorError::Inconsistent(format!(
364                    "{value:?} holds a character XML 1.0 can't carry"
365                )));
366            }
367            _ => escaped.push(c),
368        }
369    }
370    Ok(escaped)
371}
372
373fn engine(
374    lines: &Lines,
375    node: Node<'_, '_>,
376    warnings: &mut Vec<ParseWarning>,
377) -> Result<RseEngine, MotorError> {
378    let line = lines.of(node);
379    for attribute in node.attributes() {
380        let name = attribute.name();
381        if !KNOWN.iter().any(|known| known.eq_ignore_ascii_case(name))
382            && !RENDERING
383                .iter()
384                .any(|known| known.eq_ignore_ascii_case(name))
385        {
386            warnings.push(ParseWarning::new(
387                line,
388                WarningKind::Dropped,
389                format!("unknown <engine> attribute `{name}` ignored"),
390            ));
391        }
392    }
393    let code = attr(node, &["code"]).ok_or_else(|| missing(line, "code"))?;
394    let manufacturer = match attr(node, &["mfg"]) {
395        Some(mfg) => mfg.to_owned(),
396        None => {
397            warnings.push(ParseWarning::new(
398                line,
399                WarningKind::Unusual,
400                format!("engine {code:?} has no `mfg`; read as empty"),
401            ));
402            String::new()
403        }
404    };
405    let required = |names: &[&'static str]| -> Result<f64, MotorError> {
406        let text = attr(node, names).ok_or_else(|| missing(line, names[0]))?;
407        number(text, names[0], line)
408    };
409    let optional = |name: &'static str| -> Result<Option<f64>, MotorError> {
410        attr(node, &[name])
411            .map(|text| number(text, name, line))
412            .transpose()
413    };
414    let diameter_mm = required(&["dia"])?;
415    let length_mm = required(&["len"])?;
416    check_dimensions(diameter_mm, length_mm).map_err(|e| syntax(line, e.to_string()))?;
417    let initial_mass_g = required(&["initWt", "initMass"])?;
418    let propellant_mass_g = required(&["propWt", "propMass"])?;
419    for (value, what) in [(initial_mass_g, "initWt"), (propellant_mass_g, "propWt")] {
420        if value < 0.0 {
421            return Err(syntax(line, format!("negative `{what}` {value}")));
422        }
423    }
424    if propellant_mass_g >= initial_mass_g && initial_mass_g > 0.0 {
425        warnings.push(ParseWarning::new(
426            line,
427            WarningKind::Unusual,
428            format!(
429                "engine {code:?}: propellant mass {propellant_mass_g} g is not below the loaded \
430                 mass {initial_mass_g} g"
431            ),
432        ));
433    }
434    let mut flag = |name: &'static str| match attr(node, &[name]) {
435        None => None,
436        Some("1") => Some(true),
437        Some("0") => Some(false),
438        Some(other) => {
439            warnings.push(ParseWarning::new(
440                line,
441                WarningKind::Dropped,
442                format!("`{name}` is {other:?}, not 0 or 1; ignored"),
443            ));
444            None
445        }
446    };
447    let auto_calc_mass = flag("auto-calc-mass");
448    let auto_calc_cg = flag("auto-calc-cg");
449
450    let mut comments = None;
451    let mut data = None;
452    for child in node.children().filter(Node::is_element) {
453        let name = child.tag_name().name();
454        let duplicate = match name {
455            "comments" => comments
456                .replace(
457                    // The text only: XML comments and processing instructions inside are not part
458                    // of it, and nested markup contributes its text.
459                    child
460                        .descendants()
461                        .filter(Node::is_text)
462                        .filter_map(|text| text.text())
463                        .collect::<String>(),
464                )
465                .is_some(),
466            "data" => data.replace(child).is_some(),
467            other => {
468                warnings.push(ParseWarning::new(
469                    lines.of(child),
470                    WarningKind::Dropped,
471                    format!("unknown element <{other}> in an engine ignored"),
472                ));
473                false
474            }
475        };
476        if duplicate {
477            warnings.push(ParseWarning::new(
478                lines.of(child),
479                WarningKind::Dropped,
480                format!("engine {code:?} has more than one <{name}>; the last is read"),
481            ));
482        }
483    }
484    let data = data.ok_or_else(|| syntax(line, format!("engine {code:?} has no <data>")))?;
485    let points = points(lines, data, code, warnings)?;
486
487    let engine = RseEngine {
488        manufacturer,
489        code: code.to_owned(),
490        motor_type: attr(node, &["Type"]).map(str::to_owned),
491        diameter_mm,
492        length_mm,
493        initial_mass_g,
494        propellant_mass_g,
495        delays: attr(node, &["delays"]).map(str::to_owned),
496        auto_calc_mass,
497        auto_calc_cg,
498        average_thrust_n: optional("avgThrust")?,
499        peak_thrust_n: optional("peakThrust")?,
500        throat_diameter_mm: optional("throatDia")?,
501        exit_diameter_mm: optional("exitDia")?,
502        total_impulse_ns: optional("Itot")?,
503        burn_time_s: optional("burn-time")?,
504        mass_fraction_pct: optional("massFrac")?,
505        isp_s: optional("Isp")?,
506        comments,
507        points,
508    };
509    summary_warnings(&engine, line, warnings);
510    Ok(engine)
511}
512
513/// Every `<engine>` attribute the reader reads (with the guide's aliases).
514const KNOWN: [&str; 20] = [
515    "mfg",
516    "code",
517    "Type",
518    "dia",
519    "len",
520    "initWt",
521    "initMass",
522    "propWt",
523    "propMass",
524    "delays",
525    "auto-calc-mass",
526    "auto-calc-cg",
527    "avgThrust",
528    "peakThrust",
529    "throatDia",
530    "exitDia",
531    "Itot",
532    "burn-time",
533    "massFrac",
534    "Isp",
535];
536
537fn points(
538    lines: &Lines,
539    data: Node<'_, '_>,
540    code: &str,
541    warnings: &mut Vec<ParseWarning>,
542) -> Result<Vec<RsePoint>, MotorError> {
543    let mut points: Vec<RsePoint> = Vec::new();
544    for node in data.children().filter(Node::is_element) {
545        let line = lines.of(node);
546        if !(node.has_tag_name("eng-data") || node.has_tag_name("point")) {
547            warnings.push(ParseWarning::new(
548                line,
549                WarningKind::Dropped,
550                format!(
551                    "unknown element <{}> in <data> ignored",
552                    node.tag_name().name()
553                ),
554            ));
555            continue;
556        }
557        let get = |name: &'static str| -> Result<Option<f64>, MotorError> {
558            attr(node, &[name])
559                .map(|text| number(text, name, line))
560                .transpose()
561        };
562        let time_s = get("t")?.ok_or_else(|| missing(line, "t"))?;
563        let thrust_n = get("f")?.ok_or_else(|| missing(line, "f"))?;
564        if time_s < 0.0 {
565            return Err(syntax(line, format!("negative time {time_s}")));
566        }
567        if let Some(previous) = points.last()
568            && time_s < previous.time_s
569        {
570            return Err(syntax(
571                line,
572                format!(
573                    "time {time_s} s is before the previous point's {} s",
574                    previous.time_s
575                ),
576            ));
577        }
578        points.push(RsePoint {
579            time_s,
580            thrust_n,
581            mass_g: get("m")?,
582            cg_mm: get("cg")?,
583        });
584    }
585    let line = lines.of(data);
586    if points.len() < 2 {
587        return Err(syntax(
588            line,
589            format!("engine {code:?} needs at least two points"),
590        ));
591    }
592    let total = points.len();
593    let partial = |count: usize| count != 0 && count != total;
594    if partial(points.iter().filter(|p| p.mass_g.is_some()).count()) {
595        warnings.push(ParseWarning::new(
596            line,
597            WarningKind::Dropped,
598            format!("engine {code:?} gives `m` on only some points; all dropped"),
599        ));
600        points.iter_mut().for_each(|p| p.mass_g = None);
601    }
602    if partial(points.iter().filter(|p| p.cg_mm.is_some()).count()) {
603        warnings.push(ParseWarning::new(
604            line,
605            WarningKind::Dropped,
606            format!("engine {code:?} gives `cg` on only some points; all dropped"),
607        ));
608        points.iter_mut().for_each(|p| p.cg_mm = None);
609    }
610    Ok(points)
611}
612
613/// Warnings about a curve's end, its delays, a hybrid motor, and summary attributes that disagree
614/// with the curve.
615fn summary_warnings(engine: &RseEngine, line: usize, warnings: &mut Vec<ParseWarning>) {
616    let mut warn = |kind: WarningKind, message: String| {
617        warnings.push(ParseWarning::new(
618            line,
619            kind,
620            format!("engine {:?}: {message}", engine.code),
621        ));
622    };
623    for warning in engine.delays().warnings {
624        warn(warning.kind, warning.message);
625    }
626    if engine
627        .motor_type
628        .as_deref()
629        .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
630    {
631        warn(
632            WarningKind::Unusual,
633            "a hybrid motor: hpr models solid motors only, and a `SolidMotor` built from this \
634             curve would be wrong"
635                .into(),
636        );
637    }
638    if let Some(last) = engine.points.last()
639        && last.thrust_n != 0.0
640    {
641        warn(
642            WarningKind::Unusual,
643            format!("the curve ends at {} N, not zero", last.thrust_n),
644        );
645    }
646    if engine.points.iter().any(|p| p.thrust_n < 0.0) {
647        warn(WarningKind::Unusual, "the curve has negative thrust".into());
648        return;
649    }
650    let Ok(curve) = engine.thrust_curve() else {
651        return;
652    };
653    for (given, computed, name) in [
654        (engine.total_impulse_ns, curve.total_impulse_ns(), "Itot"),
655        (engine.peak_thrust_n, curve.peak_thrust_n(), "peakThrust"),
656    ] {
657        if let Some(given) = given
658            && (given - computed).abs() > 0.01 * computed.abs()
659        {
660            warn(
661                WarningKind::Unusual,
662                format!("`{name}` {given} differs from the curve's {computed} by more than 1%"),
663            );
664        }
665    }
666}
667
668/// An attribute by any of `names`, matched exactly first and then ignoring ASCII case.
669fn attr<'a>(node: Node<'a, '_>, names: &[&str]) -> Option<&'a str> {
670    names
671        .iter()
672        .find_map(|name| node.attribute(*name))
673        .or_else(|| {
674            node.attributes()
675                .find(|a| names.iter().any(|name| a.name().eq_ignore_ascii_case(name)))
676                .map(|a| a.value())
677        })
678}
679
680fn check_dimensions(diameter_mm: f64, length_mm: f64) -> Result<(), MotorError> {
681    for (value, what) in [(diameter_mm, "diameter (mm)"), (length_mm, "length (mm)")] {
682        if !(value.is_finite() && value > 0.0) {
683            return Err(MotorError::Domain { what, value });
684        }
685    }
686    Ok(())
687}
688
689fn number(text: &str, what: &'static str, line: usize) -> Result<f64, MotorError> {
690    finite(text, what).map_err(|_| syntax(line, format!("can't read `{what}` from {text:?}")))
691}
692
693fn missing(line: usize, what: &str) -> MotorError {
694    syntax(line, format!("missing the `{what}` attribute"))
695}
696
697fn syntax(line: usize, message: String) -> MotorError {
698    MotorError::Syntax {
699        format: FORMAT,
700        line,
701        message,
702    }
703}
704
705/// The deepest element nesting [`parse`] accepts. Real files nest five deep (`<engine-database>`,
706/// `<engine-list>`, `<engine>`, `<data>`, `<eng-data>`).
707///
708/// The XML parser descends into nested elements recursively, and a stack overflow aborts the
709/// process rather than panicking, so a hostile or corrupt file must be refused before parsing.
710/// Measured on macOS aarch64: a debug build uses about 15 KB of stack per level (48 levels fit in
711/// 1 MB, the wasm32 default, and 64 don't), a release build under 1 KB.
712pub const MAX_ELEMENT_DEPTH: usize = 32;
713
714/// Refuses elements nested more than [`MAX_ELEMENT_DEPTH`] deep, scanning the text without
715/// recursion.
716///
717/// The count is exact for well-formed XML: comments, CDATA sections, processing instructions and
718/// declarations are skipped, a `>` inside a quoted attribute doesn't end a tag, and `<a/>` doesn't
719/// nest. On malformed text it may count too many levels but never too few before the point where
720/// the parser stops: a `<` ends a tag even inside quotes (it can't appear there in XML).
721fn check_nesting(text: &str) -> Result<(), MotorError> {
722    let bytes = text.as_bytes();
723    let find = |from: usize, needle: &[u8]| {
724        bytes[from..]
725            .windows(needle.len())
726            .position(|window| window == needle)
727            .map_or(bytes.len(), |at| from + at + needle.len())
728    };
729    let mut depth = 0_usize;
730    let mut i = 0;
731    while let Some(offset) = bytes[i..].iter().position(|&b| b == b'<') {
732        let start = i + offset;
733        let rest = &bytes[start..];
734        i = if rest.starts_with(b"<!--") {
735            find(start + 4, b"-->")
736        } else if rest.starts_with(b"<![CDATA[") {
737            find(start + 9, b"]]>")
738        } else if rest.starts_with(b"<?") {
739            find(start + 2, b"?>")
740        } else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
741            if rest[1] == b'/' {
742                depth = depth.saturating_sub(1);
743            }
744            find(start + 2, b">")
745        } else {
746            let mut quote = None;
747            let mut end = bytes.len();
748            let mut self_closing = false;
749            for (j, &b) in bytes.iter().enumerate().skip(start + 1) {
750                match (quote, b) {
751                    (_, b'<') => {
752                        end = j;
753                        break;
754                    }
755                    (None, b'"' | b'\'') => quote = Some(b),
756                    (Some(open), _) if b == open => quote = None,
757                    (None, b'>') => {
758                        self_closing = bytes[j - 1] == b'/';
759                        end = j + 1;
760                        break;
761                    }
762                    _ => {}
763                }
764            }
765            if !self_closing {
766                depth += 1;
767                if depth > MAX_ELEMENT_DEPTH {
768                    let line = bytes[..start].iter().filter(|&&b| b == b'\n').count() + 1;
769                    return Err(syntax(
770                        line,
771                        format!("elements nested more than {MAX_ELEMENT_DEPTH} deep"),
772                    ));
773                }
774            }
775            end
776        };
777        if i >= bytes.len() {
778            break;
779        }
780    }
781    Ok(())
782}
783
784#[cfg(test)]
785pub(crate) mod tests {
786    use proptest::prelude::*;
787
788    use super::*;
789    use crate::delay::Delay;
790
791    const SAMPLE: &str = r#"<engine-database>
792  <engine-list>
793    <engine  mfg=" Aerotech" code="H128W" Type="reloadable" dia="29." len="194." initWt="196.6"
794      propWt="90.6" delays="6,10,14" auto-calc-mass="1" auto-calc-cg="1" avgThrust="128."
795      peakThrust="150." throatDia="0." exitDia="0." Itot="10.2" burn-time="0.8" massFrac="46.1"
796      Isp="200." tDiv="10" tStep="-1." tFix="1" FDiv="10" FStep="-1." FFix="1" mDiv="10"
797      mStep="-1." mFix="1" cgDiv="10" cgStep="-1." cgFix="1">
798      <comments>Line one &amp; two
799<![CDATA[raw & data]]></comments>
800      <data>
801        <eng-data  t="0." f="0." m="90.6" cg="97."/>
802        <eng-data  t="0.02" f="150." m="89.1" cg="97."/>
803        <eng-data  t="0.1" f="100." m="80.2" cg="97."/>
804        <eng-data  t="0.1" f="50." m="80.2" cg="97."/>
805        <eng-data  t="0.2" f="0." m="0." cg="97."/>
806      </data>
807    </engine>
808  </engine-list>
809</engine-database>"#;
810
811    #[test]
812    fn deep_nesting_is_refused_before_the_xml_parser_recurses() {
813        // Unchecked, 100,000 levels overflow any test thread's stack inside the XML parser, in
814        // debug and release builds alike, and abort the test process; the scan refuses them
815        // without recursing.
816        let deep = |n: usize| format!("{}{}", "<a>".repeat(n), "</a>".repeat(n));
817        let result = parse(&deep(100_000));
818        assert!(
819            matches!(&result, Err(MotorError::Syntax { line: 1, message, .. })
820                if message.contains("nested")),
821            "{result:?}"
822        );
823
824        // The sample nests four deep; wrapping it to exactly the limit still reads, and one more
825        // level is refused on the line where it opens.
826        let body = SAMPLE.split_once('\n').unwrap().1;
827        let inner = body.strip_suffix("</engine-database>").unwrap();
828        let wrapped = |extra: usize| {
829            format!(
830                "<engine-database>{}\n{inner}{}</engine-database>",
831                "<!-- <x> --><x a='>'><y/>".repeat(extra),
832                "</x>".repeat(extra)
833            )
834        };
835        let at_limit = wrapped(MAX_ELEMENT_DEPTH - 4);
836        assert_eq!(parse(&at_limit).unwrap().value.engines.len(), 1);
837        let comments_line = at_limit
838            .lines()
839            .position(|l| l.contains("<comments>"))
840            .unwrap()
841            + 1;
842        assert!(matches!(
843            parse(&wrapped(MAX_ELEMENT_DEPTH - 3)),
844            Err(MotorError::Syntax { line, .. }) if line == comments_line
845        ));
846        // A CDATA section, comment or processing instruction full of tags doesn't count.
847        let hidden = format!(
848            "<?xml version='1.0'?><!-- {0} --><engine-database><![CDATA[{0}]]><?pi {0}?>\n{body}",
849            "<a>".repeat(100)
850        );
851        assert_eq!(parse(&hidden).unwrap().value.engines.len(), 1);
852    }
853
854    #[test]
855    fn reads_the_observed_layout() {
856        let parsed = parse(SAMPLE).unwrap();
857        let engine = &parsed.value.engines[0];
858        assert_eq!(engine.manufacturer, " Aerotech");
859        assert_eq!(engine.code, "H128W");
860        assert_eq!(engine.motor_type.as_deref(), Some("reloadable"));
861        assert_eq!(engine.diameter_mm, 29.0);
862        assert_eq!(engine.initial_mass_g, 196.6);
863        assert_eq!(engine.auto_calc_mass, Some(true));
864        assert_eq!(engine.exit_diameter_mm, Some(0.0));
865        assert_eq!(
866            engine.comments.as_deref(),
867            Some("Line one & two\nraw & data")
868        );
869        assert_eq!(engine.points.len(), 5);
870        assert_eq!(engine.points[1].mass_g, Some(89.1));
871        assert_eq!(engine.points[4].cg_mm, Some(97.0));
872        assert_eq!(
873            engine.delays().delays,
874            [
875                Delay::Seconds(6.0),
876                Delay::Seconds(10.0),
877                Delay::Seconds(14.0)
878            ]
879        );
880        let curve = engine.thrust_curve().unwrap();
881        assert_eq!(curve.times_s()[3], 0.1);
882        // Only the impulse disagrees with the curve (10.2 against 12.5 N·s).
883        assert_eq!(parsed.warnings.len(), 1, "{:?}", parsed.warnings);
884        assert_eq!(parsed.warnings[0].line, 3);
885    }
886
887    #[test]
888    fn reads_the_guides_names_and_nested_engines() {
889        let text = r#"<engine code="A1" mfg="X" type="single-use" dia="18" len="70" initMass="16"
890            propMass="3"><data><point t="0.1" f="2"/><point t="0.5" f="0"/></data></engine>"#;
891        let parsed = parse(text).unwrap();
892        let engine = &parsed.value.engines[0];
893        assert_eq!(engine.motor_type.as_deref(), Some("single-use"));
894        assert_eq!(engine.initial_mass_g, 16.0);
895        assert_eq!(engine.propellant_mass_g, 3.0);
896        assert_eq!(engine.points[0].mass_g, None);
897        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
898    }
899
900    #[test]
901    fn rejects_malformed_files() {
902        let engine = |attrs: &str, points: &str| {
903            format!(
904                r#"<engine-database><engine-list><engine {attrs}><data>{points}</data></engine></engine-list></engine-database>"#
905            )
906        };
907        let good_attrs = r#"code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3""#;
908        let good_points =
909            r#"<eng-data t="0" f="0"/><eng-data t="0.1" f="2"/><eng-data t="0.5" f="0"/>"#;
910        assert!(parse(&engine(good_attrs, good_points)).is_ok());
911        let bad = [
912            "".to_owned(),
913            "<engine-database/>".to_owned(),
914            "<engine code=\"A\"".to_owned(),
915            "<!DOCTYPE x [<!ENTITY e \"boom\">]><engine-database/>".to_owned(),
916            engine(
917                r#"mfg="X" dia="18" len="70" initWt="16" propWt="3""#,
918                good_points,
919            ),
920            engine(
921                r#"code="A" mfg="X" len="70" initWt="16" propWt="3""#,
922                good_points,
923            ),
924            engine(
925                r#"code="A" mfg="X" dia="0" len="70" initWt="16" propWt="3""#,
926                good_points,
927            ),
928            engine(
929                r#"code="A" mfg="X" dia="18" len="70" initWt="16" propWt="-3""#,
930                good_points,
931            ),
932            engine(
933                r#"code="A" mfg="X" dia="NaN" len="70" initWt="16" propWt="3""#,
934                good_points,
935            ),
936            engine(good_attrs, r#"<eng-data t="0" f="1"/>"#),
937            engine(good_attrs, r#"<eng-data t="0" f="1"/><eng-data t="0.1"/>"#),
938            engine(
939                good_attrs,
940                r#"<eng-data t="0.2" f="1"/><eng-data t="0.1" f="0"/>"#,
941            ),
942            engine(
943                good_attrs,
944                r#"<eng-data t="-0.1" f="1"/><eng-data t="0.1" f="0"/>"#,
945            ),
946            engine(
947                good_attrs,
948                r#"<eng-data t="0" f="inf"/><eng-data t="0.1" f="0"/>"#,
949            ),
950        ];
951        for text in &bad {
952            assert!(parse(text).is_err(), "{text}");
953        }
954    }
955
956    /// Every f64 in a file, as bits, `None` as a marker: `==` alone treats `-0.0` as `0.0`.
957    pub(crate) fn bits(file: &RseFile) -> Vec<Option<u64>> {
958        file.engines
959            .iter()
960            .flat_map(|e| {
961                [
962                    Some(e.diameter_mm),
963                    Some(e.length_mm),
964                    Some(e.initial_mass_g),
965                    Some(e.propellant_mass_g),
966                    e.average_thrust_n,
967                    e.peak_thrust_n,
968                    e.throat_diameter_mm,
969                    e.exit_diameter_mm,
970                    e.total_impulse_ns,
971                    e.burn_time_s,
972                    e.mass_fraction_pct,
973                    e.isp_s,
974                ]
975                .into_iter()
976                .chain(
977                    e.points
978                        .iter()
979                        .flat_map(|p| [Some(p.time_s), Some(p.thrust_n), p.mass_g, p.cg_mm]),
980                )
981                .map(|value| value.map(f64::to_bits))
982                .collect::<Vec<_>>()
983            })
984            .collect()
985    }
986
987    #[test]
988    fn comments_keep_only_their_text() {
989        let text = r#"<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3">
990<comments>keep<!-- editor note -->this<?pi x?><b>bold</b><b>a<i>b</i>c</b></comments>
991<comments>second</comments>
992<data><eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.3" f="0"/></data>
993<data><eng-data t="0" f="0"/><eng-data t="0.2" f="3"/><eng-data t="0.3" f="0"/></data>
994<comments><engine code="B" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
995<eng-data t="0" f="0"/><eng-data t="0.2" f="2"/></data></engine></comments>
996</engine>"#;
997        let parsed = parse(text).unwrap();
998        assert_eq!(
999            parsed.value.engines.len(),
1000            1,
1001            "the nested engine is not read"
1002        );
1003        let engine = &parsed.value.engines[0];
1004        // The last <comments> wins; it holds only the nested engine, whose text is one line break.
1005        assert_eq!(engine.comments.as_deref(), Some("\n"));
1006        assert_eq!(engine.points[1].thrust_n, 3.0);
1007        let dropped: Vec<usize> = parsed
1008            .warnings
1009            .iter()
1010            .filter(|w| w.kind == WarningKind::Dropped)
1011            .map(|w| w.line)
1012            .collect();
1013        assert_eq!(dropped, [3, 5, 6, 6], "{:?}", parsed.warnings);
1014        let single = r#"<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3">
1015<comments>keep<!-- editor note -->this<?pi x?><b>bold</b><b>a<i>b</i>c</b></comments>
1016<data><eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.3" f="0"/></data></engine>"#;
1017        let engine = &parse(single).unwrap().value.engines[0];
1018        assert_eq!(engine.comments.as_deref(), Some("keepthisboldabc"));
1019    }
1020
1021    #[test]
1022    fn large_files_read_in_linear_time() {
1023        // 200 engines of 500 points each (about 10 MB of XML): line numbers come from a table, so
1024        // this takes milliseconds rather than rescanning the text for every point.
1025        let points: String = (0..500)
1026            .map(|i| {
1027                format!(
1028                    "<eng-data t=\"{}\" f=\"{}\" m=\"1\" cg=\"35\"/>\n",
1029                    f64::from(i) * 0.01,
1030                    10.0
1031                )
1032            })
1033            .collect();
1034        let engine = format!(
1035            "<engine code=\"A\" mfg=\"X\" dia=\"18\" len=\"70\" initWt=\"16\" propWt=\"3\">\n<data>\n{points}</data></engine>\n"
1036        );
1037        let text = format!(
1038            "<engine-database><engine-list>\n{}</engine-list></engine-database>",
1039            engine.repeat(200)
1040        );
1041        let parsed = parse(&text).unwrap();
1042        assert_eq!(parsed.value.engines.len(), 200);
1043        let last = text.lines().count();
1044        assert!(parsed.warnings.iter().all(|w| w.line <= last));
1045    }
1046
1047    #[test]
1048    fn a_broken_engine_is_skipped_with_a_warning() {
1049        let text = r#"<engine-database><engine-list>
1050<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
1051  <eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.1" f="0"/></data></engine>
1052<engine code="B" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
1053  <eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.3" f="0"/></data></engine>
1054</engine-list></engine-database>"#;
1055        let parsed = parse(text).unwrap();
1056        assert_eq!(parsed.value.engines.len(), 1);
1057        assert_eq!(parsed.value.engines[0].code, "B");
1058        assert_eq!(parsed.warnings.len(), 1);
1059        assert_eq!(parsed.warnings[0].line, 3);
1060        assert_eq!(parsed.warnings[0].kind, WarningKind::Skipped);
1061    }
1062
1063    #[test]
1064    fn partial_mass_columns_are_dropped_with_a_warning() {
1065        let text = r#"<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
1066            <eng-data t="0" f="0" m="3"/><eng-data t="0.1" f="2"/><eng-data t="0.5" f="0" m="0"/>
1067            </data></engine>"#;
1068        let parsed = parse(text).unwrap();
1069        assert!(
1070            parsed.value.engines[0]
1071                .points
1072                .iter()
1073                .all(|p| p.mass_g.is_none())
1074        );
1075        assert_eq!(parsed.warnings.len(), 1);
1076    }
1077
1078    #[test]
1079    fn a_hybrid_is_read_with_a_warning() {
1080        let hybrid = |kind: &str| {
1081            format!(
1082                r#"<engine code="H" mfg="X" Type="{kind}" dia="38" len="300" initWt="900" propWt="200"><data>
1083                <eng-data t="0" f="0"/><eng-data t="0.5" f="200"/><eng-data t="1" f="0"/>
1084                </data></engine>"#
1085            )
1086        };
1087        let parsed = parse(&hybrid(" Hybrid")).unwrap();
1088        assert_eq!(parsed.value.engines.len(), 1);
1089        assert!(matches!(
1090            parsed.warnings.as_slice(),
1091            [w] if w.kind == WarningKind::Unusual && w.message.contains("hybrid")
1092        ));
1093        assert!(parse(&hybrid("reloadable")).unwrap().warnings.is_empty());
1094    }
1095
1096    #[test]
1097    fn writes_what_it_reads() {
1098        let file = parse(SAMPLE).unwrap().value;
1099        let text = write(&file).unwrap();
1100        assert_eq!(parse(&text).unwrap().value, file);
1101        assert_eq!(write(&parse(&text).unwrap().value).unwrap(), text);
1102    }
1103
1104    /// The first line names the program, its version and its designation, as every file hpr
1105    /// writes does, in a comment the reader skips: the file reads back the same, with no
1106    /// warning, and writing it again gives the same bytes, one stamp and no more.
1107    #[test]
1108    fn the_first_line_names_the_program() {
1109        let file = parse(SAMPLE).unwrap().value;
1110        let text = write(&file).unwrap();
1111        let first = format!(
1112            "<!-- hpr-sim {} · FS · SW · TOOL 005 -->\n<engine-database>\n",
1113            env!("CARGO_PKG_VERSION")
1114        );
1115        assert!(text.starts_with(&first), "{text}");
1116        assert!(text.contains(&hpr_core::tool::stamp()));
1117        let read = parse(&text).unwrap();
1118        // The stamp adds no warning: the same ones as the source's, from its values.
1119        let messages = |warnings: &[ParseWarning]| {
1120            warnings
1121                .iter()
1122                .map(|w| (w.kind, w.message.clone()))
1123                .collect::<Vec<_>>()
1124        };
1125        assert_eq!(
1126            messages(&read.warnings),
1127            messages(&parse(SAMPLE).unwrap().warnings)
1128        );
1129        assert_eq!(read.value, file);
1130        let again = write(&read.value).unwrap();
1131        assert_eq!(again, text);
1132        assert_eq!(again.matches("TOOL 005").count(), 1);
1133    }
1134
1135    /// XML 1.0 §2.5: a comment's text can't hold `--` or end in `-`.
1136    #[test]
1137    fn stamp_is_a_comment_xml_can_hold() {
1138        let stamp = hpr_core::tool::stamp();
1139        assert!(!stamp.contains("--") && !stamp.ends_with('-'), "{stamp}");
1140    }
1141
1142    #[test]
1143    fn writer_refuses_what_it_cannot_represent() {
1144        let mut engine = parse(SAMPLE).unwrap().value.engines[0].clone();
1145        engine.points[1].mass_g = None;
1146        assert!(
1147            write(&RseFile {
1148                engines: vec![engine.clone()]
1149            })
1150            .is_err()
1151        );
1152        engine.points.iter_mut().for_each(|p| p.mass_g = None);
1153        assert!(
1154            write(&RseFile {
1155                engines: vec![engine.clone()]
1156            })
1157            .is_ok()
1158        );
1159        engine.code = "bell\u{7}".into();
1160        assert!(
1161            write(&RseFile {
1162                engines: vec![engine.clone()]
1163            })
1164            .is_err()
1165        );
1166        engine.code = "A".into();
1167        engine.points.truncate(1);
1168        assert!(
1169            write(&RseFile {
1170                engines: vec![engine]
1171            })
1172            .is_err()
1173        );
1174        assert!(write(&RseFile { engines: vec![] }).is_err());
1175    }
1176
1177    fn text(max: usize) -> impl Strategy<Value = String> {
1178        prop::collection::vec(
1179            prop_oneof![
1180                Just('&'),
1181                Just('<'),
1182                Just('>'),
1183                Just('"'),
1184                Just('\''),
1185                Just('\t'),
1186                Just('\n'),
1187                Just('\r'),
1188                Just(' '),
1189                Just('é'),
1190                Just(']'),
1191                proptest::char::range('a', 'z'),
1192            ],
1193            0..max,
1194        )
1195        .prop_map(|chars| chars.into_iter().collect())
1196    }
1197
1198    fn engines() -> impl Strategy<Value = RseEngine> {
1199        let number = || prop_oneof![Just(-0.0), -1e6..1e6f64];
1200        let option = move || prop::option::of(number());
1201        (
1202            (
1203                text(12),
1204                text(12),
1205                prop::option::of(text(8)),
1206                prop::option::of(text(8)),
1207            ),
1208            (1e-3..1e4f64, 1e-3..1e5f64, 0.0..1e5f64, 0.0..1e5f64),
1209            (
1210                prop::option::of(any::<bool>()),
1211                prop::option::of(any::<bool>()),
1212            ),
1213            (
1214                option(),
1215                option(),
1216                option(),
1217                option(),
1218                option(),
1219                option(),
1220                option(),
1221                option(),
1222            ),
1223            prop::option::of(text(40)),
1224            (any::<bool>(), any::<bool>()),
1225            prop::collection::vec((0.0..10.0f64, 0.0..1e5f64, number(), number()), 2..20),
1226        )
1227            .prop_map(
1228                |(strings, envelope, flags, summary, comments, columns, mut samples)| {
1229                    samples.sort_by(|a, b| a.0.total_cmp(&b.0));
1230                    RseEngine {
1231                        manufacturer: strings.0,
1232                        code: strings.1,
1233                        motor_type: strings.2,
1234                        delays: strings.3,
1235                        diameter_mm: envelope.0,
1236                        length_mm: envelope.1,
1237                        initial_mass_g: envelope.2,
1238                        propellant_mass_g: envelope.3,
1239                        auto_calc_mass: flags.0,
1240                        auto_calc_cg: flags.1,
1241                        average_thrust_n: summary.0,
1242                        peak_thrust_n: summary.1,
1243                        throat_diameter_mm: summary.2,
1244                        exit_diameter_mm: summary.3,
1245                        total_impulse_ns: summary.4,
1246                        burn_time_s: summary.5,
1247                        mass_fraction_pct: summary.6,
1248                        isp_s: summary.7,
1249                        comments,
1250                        points: samples
1251                            .into_iter()
1252                            .map(|(t, f, m, cg)| RsePoint {
1253                                time_s: t,
1254                                thrust_n: f,
1255                                mass_g: columns.0.then_some(m),
1256                                cg_mm: columns.1.then_some(cg),
1257                            })
1258                            .collect(),
1259                    }
1260                },
1261            )
1262    }
1263
1264    proptest! {
1265        #[test]
1266        fn write_parse_round_trips_bit_for_bit(engines in prop::collection::vec(engines(), 1..4)) {
1267            let file = RseFile { engines };
1268            let written = write(&file).unwrap();
1269            let back = parse(&written).unwrap().value;
1270            prop_assert_eq!(&back, &file);
1271            prop_assert_eq!(bits(&back), bits(&file));
1272        }
1273    }
1274}