diff --git a/crates/helm-unitfile/src/lib.rs b/crates/helm-unitfile/src/lib.rs
index 86312ec..190269d 100644
--- a/crates/helm-unitfile/src/lib.rs
+++ b/crates/helm-unitfile/src/lib.rs
@@ -27,7 +27,9 @@ mod mul;
pub use blk::parse_blk;
pub use mtf::{armor_location_order, parse_mtf, split_pipe_list, split_system_field};
-pub use mul::{Kept, MUL_VERSION, Mul, MulUnit, locations_for, parse_mul, write_mul};
+pub use mul::{
+ Kept, MUL_VERSION, Mul, MulUnit, locations_for, locations_for_config, parse_mul, write_mul,
+};
#[cfg(feature = "library")]
mod library;
diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs
index 6468ce0..b703bfd 100644
--- a/crates/helm-unitfile/src/mul.rs
+++ b/crates/helm-unitfile/src/mul.rs
@@ -253,6 +253,42 @@ pub fn locations_for(kind: Option<&str>) -> &'static [(&'static str, &'static st
}
}
+/// The same, for a design rather than an entity.
+///
+/// A `.mul` names a shape exactly - `Quad`, `Tripod` - and a `.mtf` writes it
+/// as part of a longer line: `Quad Omnimek` is a quad, and matching that the
+/// way the reader matches a `.mul`'s `type` would silently read it as a biped
+/// and put its legs where a biped keeps its arms.
+pub fn locations_for_config(config: Option<&str>) -> &'static [(&'static str, &'static str)] {
+ let config = config.unwrap_or("").to_ascii_lowercase();
+ // Tripod first only because nothing else contains it; QuadVee would give
+ // the same answer either way round.
+ if config.contains("tripod") {
+ &BIPED_LOCATIONS
+ } else if config.contains("quad") {
+ &QUAD_LOCATIONS
+ } else {
+ &BIPED_LOCATIONS[..8]
+ }
+}
+
+/// The `.mtf` key for a location's rear plate, where it has one.
+///
+/// Only the three torsos do. Building the key as `RT` plus the location's
+/// first letter is nearly right and quietly wrong: it sends a *left arm* to
+/// `RTL`, which is the left torso's rear plate, so a `Rear` tag written
+/// anywhere but a torso would overwrite a figure belonging to a different
+/// location. MegaMek writes none, and a file this crate did not write is
+/// exactly the case that cannot be assumed about.
+fn rear_code(code: &str) -> Option<&'static str> {
+ match code {
+ "CT" => Some("RTC"),
+ "LT" => Some("RTL"),
+ "RT" => Some("RTR"),
+ _ => None,
+ }
+}
+
/// Read a `.mul`.
///
/// Every element is kept as it arrived and then the parts this crate has rules
@@ -459,10 +495,12 @@ fn read_armor(unit: &mut MulUnit, code: &str, armor: &Kept) {
unit.structure.insert(code.to_string(), points);
}
// Rear plate belongs to the same location and the `.mtf` spells it
- // RTC, RTR, RTL.
+ // RTC, RTR, RTL. A location that has no rear is left alone rather than
+ // written to some other location's key - see `rear_code`.
Some("Rear") => {
- unit.armor
- .insert(format!("RT{}", code.chars().next().unwrap_or('C')), points);
+ if let Some(rear) = rear_code(code) {
+ unit.armor.insert(rear.to_string(), points);
+ }
}
_ => {
unit.armor.insert(code.to_string(), points);
@@ -618,9 +656,8 @@ fn entity_of(unit: &MulUnit) -> Kept {
let shape = locations_for(unit.unit_type.as_deref());
let mut fresh: Vec<(usize, Kept)> = Vec::new();
for (index, (code, name)) in shape.iter().enumerate() {
- let rear = format!("RT{}", code.chars().next().unwrap_or('C'));
let front = unit.armor.get(*code);
- let behind = unit.armor.get(&rear);
+ let behind = rear_code(code).and_then(|rear| unit.armor.get(rear));
let internal = unit.structure.get(*code);
let gone = unit.destroyed.get(*name);
let empty = unit.empty_ammo.get(*name);
@@ -827,6 +864,7 @@ fn escape(out: &mut String, text: &str) {
#[cfg(test)]
mod tests {
+
use super::*;
/// A trimmed copy of what MegaMek wrote after a real game, keeping every
@@ -1492,6 +1530,60 @@ mod tests {
}
}
+ /// Rear plate belongs to the three torsos and nowhere else.
+ ///
+ /// The key for it used to be built as `RT` plus the location's first
+ /// letter, which sends a left *arm* and a left *leg* to `RTL` - the left
+ /// torso's rear. Reading was harmless, because MegaMek writes no rear
+ /// plate on a limb; writing was not, because a torso with rear damage put
+ /// a rear armour tag on two limbs that never had any, and handed the
+ /// player back a file MegaMek would read as damage they did not take.
+ #[test]
+ fn a_torsos_rear_plate_does_not_land_on_the_limbs() {
+ let xml = r#"
+
+
+
+ "#;
+ let mul = parse_mul(xml).unwrap();
+ assert_eq!(mul.units[0].armor.get("RTL"), Some(&6));
+
+ let written = parse_mul(&write_mul(&mul)).unwrap();
+ assert_eq!(written.units[0].armor, mul.units[0].armor);
+
+ // The unit's own element, not the document's: an entity is read out of
+ // the tree and an empty one left standing in its place, so a check
+ // that walks the document finds nothing and passes without looking at
+ // anything. This one counts what it saw for that reason.
+ let entity = &written.units[0].source;
+ let mut seen = 0;
+ for location in entity.children_named("location") {
+ seen += 1;
+ let rears = location
+ .children_named("armor")
+ .filter(|a| a.get("type") == Some("Rear"))
+ .count();
+ let index = location.number("index").unwrap();
+ // Index 3 is the left torso; 5 is the left arm and 7 the left leg.
+ assert_eq!(rears, usize::from(index == 3), "location {index}");
+ }
+ assert_eq!(seen, 3, "all three locations were written back");
+ }
+
+ /// A rear tag somewhere it cannot belong is left in the document and kept
+ /// out of the model, rather than overwriting a figure of another
+ /// location's.
+ #[test]
+ fn a_rear_tag_on_a_limb_is_not_read_as_a_torsos() {
+ let mul = parse_mul(
+ r#"
+
+ "#,
+ )
+ .unwrap();
+ assert!(mul.units[0].armor.is_empty(), "{:?}", mul.units[0].armor);
+ }
+
// `type` must not match `crewType`, and `armor` must not match
// `armorDivisor`: an attribute name only counts at a word boundary.
#[test]