diff --git a/TODO.md b/TODO.md
index e511d64..efa6be4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -197,6 +197,28 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as
almost unarmoured, a Zeus-X at 28 points where MegaMek says 247. Total
armour now agrees with MegaMek on all 10,749 designs it reports one for.
+- [x] **Battle value for a unit somebody varied.** A `.mtf` says what a design
+ left the factory with; a `.mul` says what is in every critical slot now,
+ and MegaMek reads those back - a magazine whose `type` names acid rounds
+ is loaded with acid rounds and scored as such. That is how a player
+ changes a unit without designing one, and helm read the design's own
+ slots and answered as though nothing had been touched.
+
+ A Shadow Hawk SHD-2H is 1,064 as built and 1,069 with acid rounds in its
+ SRM bin. Both figures are MegaMek's, and both are now helm's.
+
+ `bridge/DumpMul.java` is the oracle, and it is the first one that reads a
+ file rather than shooting a design itself - which makes it the harness
+ for anything expressed in a `.mul` rather than derived from a `.mtf`. The
+ fixtures in `crates/helm-bv/tests/mul/` are this repository's own,
+ because MegaMek ships no file that varies a unit.
+
+ An unknown name in a slot is ignored rather than treated as an empty one:
+ a newer MegaMek's spelling should cost nothing, and the design's own
+ answer is a better guess than a hole. An omni pod swap - different
+ equipment rather than different rounds - should ride the same mechanism
+ and has not been checked.
+
- [ ] **The equipment MegaMek synthesises while loading** rather than reading
from the file, which battle value counts. Recorded on `UnitFacets::loadout`.
- [x] **The oracle says where, not just whether.** `bridge/DumpBv.java` runs
diff --git a/bridge/DumpMul.java b/bridge/DumpMul.java
new file mode 100644
index 0000000..d9d91b2
--- /dev/null
+++ b/bridge/DumpMul.java
@@ -0,0 +1,125 @@
+import megamek.common.battleValue.BVCalculator;
+import megamek.common.loaders.MekSummaryCache;
+import megamek.common.units.Entity;
+import megamek.common.loaders.MULParser;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Dump MegaMek's battle value for the units in a `.mul`.
+ *
+ *
The other dumps score a design as it leaves the factory, or a design this
+ * program shot at in a way it chose. Neither can answer for a unit somebody
+ * else varied: a `.mul` records what is in every critical slot, and MegaMek
+ * reads those types back - a magazine whose `type` names inferno rounds is
+ * loaded with inferno rounds and scored as such. That is how a player changes
+ * a unit without designing one, and it is the case helm has to agree about.
+ *
+ *
Usage: DumpMul out.jsonl file.mul [file.mul ...]
+ *
+ *
Must run with MegaMek's install dir as the working directory. The summary
+ * cache has to be up before a `.mul` can name a design.
+ */
+public final class DumpMul {
+
+ public static void main(String[] args) throws Exception {
+ if (args.length < 2) {
+ System.err.println("usage: DumpMul [file.mul ...]");
+ System.exit(2);
+ }
+
+ MekSummaryCache cache = MekSummaryCache.getInstance();
+ long deadline = System.currentTimeMillis() + 600_000L;
+ while (!cache.isInitialized()) {
+ if (System.currentTimeMillis() > deadline) {
+ System.err.println("units.cache did not initialise within 600s");
+ System.exit(1);
+ }
+ Thread.sleep(250L);
+ }
+
+ int written = 0;
+ try (PrintWriter out = new PrintWriter(new BufferedWriter(
+ new OutputStreamWriter(new FileOutputStream(args[0]), StandardCharsets.UTF_8)))) {
+ for (int i = 1; i < args.length; i++) {
+ File file = new File(args[i]);
+ MULParser parser = new MULParser(file, null);
+ for (Entity entity : parser.getEntities()) {
+ BVCalculator calc = BVCalculator.getBVCalculator(entity);
+ // Crew and C3 left out: the crew is arithmetic helm does
+ // itself from a table, and C3 needs a game to see the
+ // other units. What is being asked here is what the unit
+ // itself is worth once somebody has varied it.
+ int bv = calc.calculateBV(true, true);
+ StringBuilder sb = new StringBuilder(256);
+ sb.append("{\"file\":");
+ quote(sb, file.getName());
+ sb.append(",\"name\":");
+ quote(sb, entity.getShortNameRaw());
+ sb.append(",\"bv\":").append(bv);
+ // Which half is wrong is the first question every
+ // disagreement asks, and the fields are not public.
+ for (String field : new String[] { "defensiveValue", "offensiveValue" }) {
+ try {
+ java.lang.reflect.Field f = findField(calc.getClass(), field);
+ if (f != null) {
+ f.setAccessible(true);
+ sb.append(",\"").append(field).append("\":").append(f.get(calc));
+ }
+ } catch (Throwable ignored) {
+ // A MegaMek that renames these should lose the
+ // columns rather than fail.
+ }
+ }
+ sb.append('}');
+ out.println(sb);
+ written++;
+ }
+ String warning = parser.getWarningMessage();
+ if (warning != null && !warning.isBlank()) {
+ System.err.println(file.getName() + ": " + warning.trim());
+ }
+ }
+ }
+ System.err.println("wrote " + written + " rows");
+ }
+
+ /** A field on this class or any above it. */
+ private static java.lang.reflect.Field findField(Class> type, String name) {
+ for (Class> at = type; at != null; at = at.getSuperclass()) {
+ try {
+ return at.getDeclaredField(name);
+ } catch (NoSuchFieldException ignored) {
+ // Keep walking up.
+ }
+ }
+ return null;
+ }
+
+ private static void quote(StringBuilder sb, String value) {
+ sb.append('"');
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ switch (c) {
+ case '"' -> sb.append("\\\"");
+ case '\\' -> sb.append("\\\\");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ }
+ sb.append('"');
+ }
+}
diff --git a/bridge/dump.sh b/bridge/dump.sh
index d61436f..056da58 100755
--- a/bridge/dump.sh
+++ b/bridge/dump.sh
@@ -91,4 +91,27 @@ run DumpBv.java bv.jsonl Mek
echo "dumping damaged designs ..."
run DumpDamaged.java damaged.jsonl
-echo "wrote $OUT_DIR/units.jsonl, $OUT_DIR/equipment.jsonl, $OUT_DIR/bv.jsonl and $OUT_DIR/damaged.jsonl"
+# And the units in a `.mul`, which is the only oracle for a unit somebody
+# varied rather than designed: a magazine whose `type` names inferno rounds is
+# loaded with inferno rounds and scored as such. The fixtures are this
+# repository's own, because MegaMek ships no file that varies a unit.
+#
+# Copied into the output directory first: the container is given `bridge/`,
+# MegaMek and the output directory, and the fixtures are in none of those.
+echo "dumping the .mul fixtures ..."
+FIXTURES="$HERE/../crates/helm-bv/tests/mul"
+if compgen -G "$FIXTURES/*.mul" > /dev/null; then
+ mkdir -p "$OUT_DIR/muls"
+ cp "$FIXTURES"/*.mul "$OUT_DIR/muls/"
+ muls=()
+ for f in "$OUT_DIR"/muls/*.mul; do
+ # The container sees the output directory as /out; a local JDK sees it
+ # where it is.
+ if [ -n "${JAVA_HOME:-}" ]; then muls+=("$f"); else muls+=("/out/muls/$(basename "$f")"); fi
+ done
+ run DumpMul.java mul.jsonl "${muls[@]}"
+else
+ echo "no .mul fixtures to dump"
+fi
+
+echo "wrote $OUT_DIR/units.jsonl, $OUT_DIR/equipment.jsonl, $OUT_DIR/bv.jsonl, $OUT_DIR/damaged.jsonl and $OUT_DIR/mul.jsonl"
diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs
index 508e146..0a0aed9 100644
--- a/crates/helm-bv/src/lib.rs
+++ b/crates/helm-bv/src/lib.rs
@@ -189,6 +189,16 @@ pub struct Condition {
/// An empty bin is worth nothing and, just as usefully, no longer explodes
/// - which is why running a Locust dry *raises* its battle value.
pub empty_ammo: std::collections::BTreeMap>,
+ /// What a slot holds now, where it is not what the design put there.
+ ///
+ /// Damage is not the only thing that happens to a machine between the
+ /// factory and the field. A player loads inferno rounds instead of
+ /// standard, or swaps an omni pod, and MegaMek scores the machine that
+ /// results - a `.mul` records the type in every slot for exactly that
+ /// reason. A name here that the catalogue does not know is ignored rather
+ /// than treated as an empty slot: the design's own answer is the better
+ /// guess.
+ pub loaded: std::collections::BTreeMap>,
}
impl From<&helm_unitfile::MulUnit> for Condition {
@@ -203,6 +213,7 @@ impl From<&helm_unitfile::MulUnit> for Condition {
structure: unit.structure.clone(),
destroyed: unit.destroyed.clone(),
empty_ammo: unit.empty_ammo.clone(),
+ loaded: unit.loaded.clone(),
}
}
}
diff --git a/crates/helm-bv/src/loadout.rs b/crates/helm-bv/src/loadout.rs
index 78c7632..a424aaa 100644
--- a/crates/helm-bv/src/loadout.rs
+++ b/crates/helm-bv/src/loadout.rs
@@ -240,7 +240,21 @@ impl<'a> Loadout<'a> {
}
}
}
+ // What the machine is carrying now, which is the design's slots
+ // unless something says otherwise.
+ let none_loaded = BTreeMap::new();
+ let loaded = condition.loaded.get(location).unwrap_or(&none_loaded);
for (index, line) in slots.iter().enumerate() {
+ // A slot the caller says holds something else holds something
+ // else - an inferno bin where the sheet says standard rounds.
+ // Only when the catalogue knows the name: an unknown one is a
+ // newer MegaMek's spelling, and the design's own answer is a
+ // better guess than nothing at all.
+ let swapped = loaded
+ .get(&index)
+ .filter(|what| catalogue.resolve(what).is_some())
+ .map(String::as_str);
+ let line = swapped.unwrap_or(line);
if gone.contains(&index) || empty.contains(&index) {
// A heat sink shot out of an arm is cooling the design no
// longer has, and the `heat sinks:` line still counts it.
diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs
index 3a391f2..b553d34 100644
--- a/crates/helm-bv/tests/conformance.rs
+++ b/crates/helm-bv/tests/conformance.rs
@@ -367,6 +367,8 @@ fn condition_from(row: &helm_bridge::Damaged) -> helm_bv::Condition {
structure: row.structure.clone(),
destroyed: row.destroyed.clone(),
empty_ammo: row.empty_ammo.clone(),
+ // The damaged oracle shoots designs; it does not vary them.
+ loaded: Default::default(),
}
}
@@ -1466,3 +1468,89 @@ fn a_design_worn_down_here_reads_back_as_the_same_machine() {
assert!(wrong.is_empty(), "{} did not survive the trip", wrong.len());
assert!(checked > 5, "only {checked} designs were tried");
}
+
+/// Units somebody varied rather than designed, scored against MegaMek.
+///
+/// A `.mtf` says what a design left the factory with. A `.mul` says what is in
+/// every critical slot *now*, and MegaMek reads those back: a magazine whose
+/// `type` names acid rounds is loaded with acid rounds and scored as such.
+/// That is how a player changes a unit without designing one - the case
+/// `plan/unit-rules.md` says the product is built around - and helm read the
+/// design's own slots and answered as though nothing had been touched.
+///
+/// The fixtures are this repository's own, in `tests/mul/`, because MegaMek
+/// ships no file that varies a unit. `bridge/dump.sh` scores them into
+/// `mul.jsonl`.
+#[test]
+#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"]
+fn a_varied_unit_is_worth_what_megamek_says_it_is() {
+ let Some(inputs) = inputs() else {
+ panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump");
+ };
+ let path = PathBuf::from(std::env::var("HELM_BRIDGE").unwrap()).join("mul.jsonl");
+ if !path.is_file() {
+ println!("no mul.jsonl in the bridge dump; run bridge/dump.sh to make one");
+ return;
+ }
+ let oracle = std::fs::read_to_string(&path).expect("mul.jsonl");
+
+ // The rows come back in the order the entities appear in each file, which
+ // is the order the parser reads them in.
+ let mut rows: std::collections::BTreeMap> = Default::default();
+ for line in oracle.lines().filter(|l| !l.trim().is_empty()) {
+ let row: serde_json::Value = serde_json::from_str(line).expect("a mul row");
+ rows.entry(row["file"].as_str().unwrap_or_default().to_string())
+ .or_default()
+ .push((
+ row["name"].as_str().unwrap_or_default().to_string(),
+ row["bv"].as_i64().unwrap_or_default(),
+ ));
+ }
+
+ let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/mul");
+ let mut checked = 0;
+ let mut varied = 0;
+ let mut wrong: Vec = Vec::new();
+ for (file, expected) in &rows {
+ let text = std::fs::read_to_string(fixtures.join(file)).expect("a fixture");
+ let mul = helm_unitfile::parse_mul(&text).expect("a readable fixture");
+ let machines: Vec<&helm_unitfile::MulUnit> = mul.machines().collect();
+ assert_eq!(
+ machines.len(),
+ expected.len(),
+ "{file}: helm reads {} entities and MegaMek read {}",
+ machines.len(),
+ expected.len()
+ );
+
+ for (entity, (name, theirs)) in machines.iter().zip(expected) {
+ let Some(unit) = inputs
+ .library
+ .units
+ .iter()
+ .find(|u| u.answers_to(&entity.chassis, &entity.model))
+ else {
+ wrong.push(format!("{file}: {name} is not in the library"));
+ continue;
+ };
+ let condition = helm_bv::Condition::from(*entity);
+ if !condition.loaded.is_empty() {
+ varied += 1;
+ }
+ checked += 1;
+ match helm_bv::battle_value_in(unit, &inputs.catalogue, &condition) {
+ Ok(ours) if ours == *theirs => {}
+ other => wrong.push(format!("{file}: {name}: ours {other:?}, megamek {theirs}")),
+ }
+ }
+ }
+
+ println!("{checked} units read from .mul files, {varied} of them varied");
+ for line in &wrong {
+ println!(" {line}");
+ }
+ assert!(wrong.is_empty(), "{} disagree", wrong.len());
+ // A fixture set with no variation in it would pass without checking the
+ // thing this test is for.
+ assert!(varied > 0, "no fixture varies a unit");
+}
diff --git a/crates/helm-bv/tests/mul/varied.mul b/crates/helm-bv/tests/mul/varied.mul
new file mode 100644
index 0000000..4321612
--- /dev/null
+++ b/crates/helm-bv/tests/mul/varied.mul
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Center Torso
+
+
+
+
+
+ Center Torso
+
+
+
+
+
+ Center Torso
+
+
+
+
+
+ Right Torso
+
+
+
+
diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs
index 1c2ba92..0ae88bb 100644
--- a/crates/helm-cli/src/main.rs
+++ b/crates/helm-cli/src/main.rs
@@ -1121,6 +1121,7 @@ fn print_damaged(
structure: row.structure.clone(),
destroyed: row.destroyed.clone(),
empty_ammo: row.empty_ammo.clone(),
+ loaded: Default::default(),
};
checked += 1;
if row.name != last {
diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs
index b703bfd..770af60 100644
--- a/crates/helm-unitfile/src/mul.rs
+++ b/crates/helm-unitfile/src/mul.rs
@@ -174,6 +174,18 @@ pub struct MulUnit {
pub structure: BTreeMap,
pub destroyed: BTreeMap>,
pub empty_ammo: BTreeMap>,
+ /// What each slot holds *now*, by location and slot, where the file names
+ /// something.
+ ///
+ /// A `.mul` records a `type` on every slot it writes, and MegaMek reads
+ /// them back: a magazine whose type names inferno rounds is loaded with
+ /// inferno rounds and scored as such. That is how a player varies a unit
+ /// without designing one, and it is the difference between the design a
+ /// name resolves to and the machine that is actually on the field.
+ ///
+ /// System slots are left out - a `.mul` writes `System` for those, which
+ /// names no equipment.
+ pub loaded: BTreeMap>,
}
impl MulUnit {
@@ -520,6 +532,17 @@ fn read_slot(unit: &mut MulUnit, name: &str, slot_tag: &Kept) {
let Some(slot) = slot.checked_sub(1) else {
return;
};
+ // What the file says is in the slot. `System` is a Mek's own machinery
+ // rather than equipment, and `-Empty-` is nothing at all.
+ if let Some(what) = slot_tag.get("type").map(str::trim).filter(|what| {
+ !what.is_empty() && !what.eq_ignore_ascii_case("System") && *what != "-Empty-"
+ }) {
+ unit.loaded
+ .entry(name.to_string())
+ .or_default()
+ .insert(slot, what.to_string());
+ }
+
if slot_tag.get("isDestroyed") == Some("true") || slot_tag.get("isMissing") == Some("true") {
unit.destroyed
.entry(name.to_string())
diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs
index fb880f0..ca01d76 100644
--- a/crates/helm-wasm/src/lib.rs
+++ b/crates/helm-wasm/src/lib.rs
@@ -213,6 +213,9 @@ pub unsafe extern "C" fn helm_mul_from_json(ptr: *const u8, len: usize) -> i64 {
structure: condition.structure,
destroyed: condition.destroyed,
empty_ammo: condition.empty_ammo,
+ // A variant is what a slot holds, and this call rebuilds one unit
+ // from a summary that does not carry the slots.
+ loaded: Default::default(),
});
}
give(helm_unitfile::write_mul(&mul))