From 3c2c0127f0994ee3cd49ae0f4dc47191e7774a50 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 20:25:14 -0400 Subject: [PATCH] fix(unit-search): which battle armour can climb a Mek, and what it needs to Two hands. `BattleArmor.canMakeAntiMekAttacks` is not the weight class alone: the suit must be lighter than a heavy one, not be a quad, and have something to hold on with - two armoured gloves or two basic manipulators, or one battle claw, or a magnetic clamp, and a glove is no use at all past light. That is why a Tunnel Rat with a glove on each arm climbs and one with a single manipulator does not. Suits carrying MegaMek's own list go from 1,130 of 1,188 to 1,184, and a `.blk` now records which arm a suit's equipment is on rather than discarding it. One deliberate departure: upstream reads `basic > 0` where this reads `basic > 1`. Ninety suits carry exactly one basic manipulator and MegaMek gives none of them the attacks, so the answers say one hand is not enough whatever the bytecode says, and the answers are what helm is measured against. --- TODO.md | 11 +- crates/helm-core/src/lib.rs | 5 +- crates/helm-core/src/metrics.rs | 194 +++++++++++++++++++++++++++-- crates/helm-facet/tests/loadout.rs | 2 +- crates/helm-unitfile/src/blk.rs | 27 +++- 5 files changed, 216 insertions(+), 23 deletions(-) diff --git a/TODO.md b/TODO.md index de46806..d10fd08 100644 --- a/TODO.md +++ b/TODO.md @@ -403,11 +403,12 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as gear and aerospace bays rather than anything Mek-shaped. Battle armour too: the jets are declared in ``, - which nothing read, and the four anti-Mek attacks are invented for any - biped suit no heavier than medium. Suits carrying MegaMek's list went - from 276 of 1,188 to 1,130. The 58 left are suits MegaMek says cannot - climb a Mek where this says they can, and nobody has found the rule that - says so - mostly exoskeletons. + which nothing read, and the four anti-Mek attacks are invented for a suit + that can climb a Mek. Which can is `BattleArmor.canMakeAntiMekAttacks`: + lighter than a heavy suit, not a quad, and with two hands free - two + armoured gloves or two basic manipulators, or one battle claw, or a + magnetic clamp. Suits carrying MegaMek's list went from 276 of 1,188 to + 1,184. Infantry is untouched: a platoon's weapons are declared in a way of their own, and all 1,792 differ. diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index f609f03..df1811f 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -40,8 +40,9 @@ pub use design::{ StructureKind, }; pub use metrics::{ - CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, damage_at_range, equipment_list, - jumping_mp, max_single_damage_at_range, mounted_items, walking_mp, + CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, can_make_anti_mek_attacks, + can_ride_a_mek, damage_at_range, equipment_list, jumping_mp, max_single_damage_at_range, + mounted_items, walking_mp, }; pub use rng::Rng; pub use structure::{Shape, max_armor, structure_per_location, structure_total_in}; diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index 13b5d7a..c495a69 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -369,19 +369,7 @@ pub fn equipment_list<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Vec<(&'a Equ // A suit that can climb a Mek and pull it apart is given the attacks // for doing so, none of which is written in any file. Four of them, // and they come as a set. - // - // Who can: a biped no heavier than medium. A quad has no free hands, - // and a heavy or assault suit is too big to get a purchase. That rule - // holds for 720 of the library's suits and leaves 57 that MegaMek - // says cannot and this says can - mostly exoskeletons - which is a - // residue nobody has explained yet rather than a rule anybody has. - if unit - .unit_type - .as_deref() - .is_some_and(|t| t.eq_ignore_ascii_case("battlearmor")) - && !unit.is_quad() - && unit.suit_weight_class().is_some_and(|wc| wc <= 2) - { + if can_make_anti_mek_attacks(unit, catalogue) { for name in ["LegAttack", "SwarmMek", "SwarmWeaponMek", "StopSwarm"] { set(catalogue.resolve(name), 1); } @@ -501,6 +489,80 @@ pub fn walking_mp(unit: &Unit, catalogue: &Catalogue) -> Option { (cost > 0).then(|| (declared - cost).max(0)) } +/// Whether a battle armour suit can climb a Mek and pull it apart. +/// +/// Four conditions, and none of them is written in any file - MegaMek works +/// them out from the suit and then gives it `LegAttack`, `SwarmMek`, +/// `SwarmWeaponMek` and `StopSwarm`, which is a quarter of what such a suit is +/// worth. `BattleArmor.canMakeAntiMekAttacks`: +/// +/// * it must have a hand free, which is [`can_ride_a_mek`]; +/// * and it must be lighter than a heavy suit, since a big one cannot get a +/// purchase. +/// +/// Upstream has two further clauses that are not reproduced, because nothing +/// in the library needs them and modelling them from a `.blk` costs answers. +/// One excludes a suit whose movement mode is `INF_UMU`: a `.blk` says +/// `UMU`, which is not the same thing - a Sea Fox and an Undine +/// both say it and both can climb, while a Water Elemental says it and cannot, +/// and what separates them is their hands rather than their swimming. The +/// other excludes a *burdened* suit, an Inner Sphere one holding a reload for +/// a body-mounted launcher, which needs the link between a bin and the weapon +/// it feeds and comes out wrong without it. +pub fn can_make_anti_mek_attacks(unit: &Unit, catalogue: &Catalogue) -> bool { + unit.unit_type + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case("battlearmor")) + && unit.suit_weight_class().is_some_and(|wc| wc < 3) + && can_ride_a_mek(unit, catalogue) +} + +/// Whether the suit has enough of a hand to hold on with. +/// +/// `BattleArmor.canDoMechanizedBA`, and the thresholds are not the ones a +/// reading of the rules would suggest. A magnetic clamp is always enough. Past +/// that it is the manipulators, counted, against the weight class: a PA(L) or +/// light suit needs **two** armoured gloves, or one basic manipulator, or one +/// battle claw; a medium or heavy suit needs a manipulator or a claw and a +/// glove will not do; an assault suit cannot however it is fitted out. +/// +/// A quad has no hands at all. +pub fn can_ride_a_mek(unit: &Unit, catalogue: &Catalogue) -> bool { + if unit.is_quad() { + return false; + } + let items = mounted_items(unit, catalogue); + let count = |flag: &str| -> i64 { + items + .iter() + .filter(|(e, _)| e.has_flag(flag)) + .map(|(_, n)| n) + .sum() + }; + if count("F_MAGNETIC_CLAMP") > 0 { + return true; + } + let (basic, gloves, claws) = ( + count("F_BASIC_MANIPULATOR"), + count("F_ARMORED_GLOVE"), + count("F_BATTLE_CLAW"), + ); + match unit.suit_weight_class() { + // Two hands, or one good one. A claw is enough on its own; a glove or + // a plain manipulator is only half a grip and wants a pair. + // + // MegaMek's own `canDoMechanizedBA` reads `basic > 0` here rather than + // `basic > 1`, and every design in the library behaves as though it + // read the second: 90 suits carry exactly one basic manipulator and + // not one of them is given the attacks, while 124 of the 175 carrying + // two are. Where the bytecode and the answers disagree, helm follows + // the answers - they are what it is measured against. + Some(0 | 1) => gloves > 1 || basic > 1 || claws > 0, + Some(2 | 3) => basic > 1 || claws > 0, + _ => false, + } +} + /// How far the design jumps, which is its jets and not its `jump mp:` line. /// /// The line is unreliable in four separate ways, all of them recorded in @@ -1140,3 +1202,109 @@ mod reading_the_slots { assert_eq!(jumping_mp(&tank, &catalogue), None); } } + +/// What a battle armour suit can do with its hands, which decides whether it +/// is given the attacks for climbing a Mek. +#[cfg(test)] +mod anti_mek { + use super::*; + use crate::{EquipmentEntry, Mount}; + + fn hand(internal: &str, flag: &str) -> EquipmentEntry { + EquipmentEntry { + internal_name: internal.into(), + name: internal.into(), + flags: vec![format!("MiscType.{flag}")], + ..Default::default() + } + } + + fn catalogue() -> Catalogue { + Catalogue::new(vec![ + hand("BABasicManipulator", "F_BASIC_MANIPULATOR"), + hand("BAArmoredGlove", "F_ARMORED_GLOVE"), + hand("BABattleClaw", "F_BATTLE_CLAW"), + hand("BAMagneticClamp", "F_MAGNETIC_CLAMP"), + ]) + } + + fn suit(weight_class: i64, quad: bool, hands: &[&str]) -> Unit { + let mut unit = Unit { + chassis: "Test".into(), + model: "1".into(), + format_str: "blk".into(), + unit_type: Some("BattleArmor".into()), + equipment: hands + .iter() + .map(|name| Mount { + name: (*name).into(), + location: Some("LA".into()), + rear: false, + }) + .collect(), + ..Default::default() + }; + unit.fields + .insert("weightclass".into(), weight_class.to_string()); + unit.fields.insert( + "chassis".into(), + if quad { "quad".into() } else { "biped".into() }, + ); + unit + } + + #[test] + fn one_hand_is_not_enough_and_two_are() { + let c = catalogue(); + assert!(!can_ride_a_mek( + &suit(1, false, &["BABasicManipulator"]), + &c + )); + assert!(can_ride_a_mek( + &suit(1, false, &["BABasicManipulator", "BABasicManipulator"]), + &c + )); + assert!(!can_ride_a_mek(&suit(1, false, &["BAArmoredGlove"]), &c)); + assert!(can_ride_a_mek( + &suit(1, false, &["BAArmoredGlove", "BAArmoredGlove"]), + &c + )); + } + + #[test] + fn a_claw_is_enough_on_its_own() { + assert!(can_ride_a_mek( + &suit(2, false, &["BABattleClaw"]), + &catalogue() + )); + } + + #[test] + fn a_glove_is_no_use_to_a_suit_past_light() { + let c = catalogue(); + let gloves = ["BAArmoredGlove", "BAArmoredGlove"]; + assert!(can_ride_a_mek(&suit(1, false, &gloves), &c)); + assert!(!can_ride_a_mek(&suit(2, false, &gloves), &c)); + } + + #[test] + fn a_quad_has_no_hands_and_a_clamp_serves_for_any() { + let c = catalogue(); + assert!(!can_ride_a_mek(&suit(1, true, &["BABattleClaw"]), &c)); + // A clamp is enough whatever the weight class, though not to a quad. + assert!(can_ride_a_mek(&suit(3, false, &["BAMagneticClamp"]), &c)); + assert!(!can_ride_a_mek(&suit(3, true, &["BAMagneticClamp"]), &c)); + } + + #[test] + fn an_assault_suit_is_too_big_to_climb_however_it_is_fitted() { + let c = catalogue(); + let armed = suit(4, false, &["BABattleClaw", "BABattleClaw"]); + assert!(!can_ride_a_mek(&armed, &c)); + assert!(!can_make_anti_mek_attacks(&armed, &c)); + // And a heavy suit can ride one without being able to climb it. + let heavy = suit(3, false, &["BABattleClaw"]); + assert!(can_ride_a_mek(&heavy, &c)); + assert!(!can_make_anti_mek_attacks(&heavy, &c)); + } +} diff --git a/crates/helm-facet/tests/loadout.rs b/crates/helm-facet/tests/loadout.rs index d16bcf0..8b955dd 100644 --- a/crates/helm-facet/tests/loadout.rs +++ b/crates/helm-facet/tests/loadout.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; /// /// A ratchet rather than a target: it may fall and may not rise without /// somebody saying why. It was 9,105 before the loader was modelled at all. -const DIFFERING: usize = 5_272; +const DIFFERING: usize = 5_218; #[test] #[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] diff --git a/crates/helm-unitfile/src/blk.rs b/crates/helm-unitfile/src/blk.rs index e09c4ce..6ed706d 100644 --- a/crates/helm-unitfile/src/blk.rs +++ b/crates/helm-unitfile/src/blk.rs @@ -94,10 +94,13 @@ pub fn parse_blk(path: &str, text: &str) -> Result { continue; } slots.push(entry.clone()); - let (name, rear) = strip_mount_suffixes(entry); + let (name, rear, suit_mount) = strip_mount_suffixes_at(entry); unit.equipment.push(Mount { name, - location: Some(location.clone()), + // Where on a battle armour suit it sits, where the file + // says - `Body`, `LA` - and otherwise the block's own + // location, which is what a vehicle has. + location: Some(suit_mount.unwrap_or_else(|| location.clone())), rear, }); } @@ -163,6 +166,26 @@ pub fn parse_blk(path: &str, text: &str) -> Result { /// the 3-character facings never appear in an equipment block, and all 128 /// `(ST)` occurrences are 4 characters written flush against the name. pub(crate) fn strip_mount_suffixes(entry: &str) -> (String, bool) { + let (name, rear, _) = strip_mount_suffixes_at(entry); + (name, rear) +} + +/// The same, and where on a battle armour suit the mount sits. +/// +/// `CLBAMG:RA` is a machine gun on the right arm and `ISBASRM4:Body` is a +/// launcher on the body. Which of the two something is decides whether a suit +/// has its hands full - an Inner Sphere suit with loaded missiles on its body +/// is burdened, and cannot climb a Mek. +pub(crate) fn strip_mount_suffixes_at(entry: &str) -> (String, bool, Option) { + let suit_mount = [":Body", ":LA", ":RA", ":TU"] + .into_iter() + .find(|tag| entry.contains(tag)) + .map(|tag| tag.trim_start_matches(':').to_string()); + let (name, rear) = strip_mount_suffixes_inner(entry); + (name, rear, suit_mount) +} + +fn strip_mount_suffixes_inner(entry: &str) -> (String, bool) { let mut name = entry.trim().to_string(); // Battle armour tags, from BLKBattleArmorFile rather than BLKFile: where -- 2.51.2