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]