diff --git a/README.md b/README.md
index 6096f83..6190baf 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,11 @@ two so a MekBay force renders the way MekBay wrote it.
Order is the file's: a force appears where its first unit does, which is what
MegaMek wrote and what a reader expects to see. Ids are matched, not sorted.
+The same tree crosses the wasm boundary: `helm_mul_summary` carries `forces`
+and `loose` beside `units`, and `helm.mjs` exposes them as `force.forces` and
+`force.loose`, whose indices are into `force.units`. That is what lets a page
+draw a MekBay force the way MekBay wrote it without parsing XML of its own.
+
A `.mul` is a force in a state rather than a design, so scoring one is its own
command - and `--why` says where a damaged machine's value went, term by term,
against the design as it left the factory:
diff --git a/crates/helm-wasm/helm.mjs b/crates/helm-wasm/helm.mjs
index 29de966..f3f144d 100644
--- a/crates/helm-wasm/helm.mjs
+++ b/crates/helm-wasm/helm.mjs
@@ -20,7 +20,7 @@
// including when the call throws.
/** The boundary this wrapper was written against; the module must agree. */
-const ABI_VERSION = 1n;
+const ABI_VERSION = 2n;
const ERRORS = new Map([
[-1n, "no equipment catalogue is loaded"],
@@ -200,14 +200,37 @@ export class Force {
return this.#handle;
}
+ /** Everything the summary carries, read once. */
+ get #summary() {
+ return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live)));
+ }
+
/** What the force says, for showing: designs, crews, and what each has lost. */
get units() {
- return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live))).units;
+ return this.#summary.units;
+ }
+
+ /**
+ * How the units are organised, outermost forces first.
+ *
+ * A `.mul` stores no tree - every entity repeats its own chain and helm
+ * rebuilds one - so this is derived rather than read. Each node carries
+ * `units`, which are indices into `units` above, and `children`. MekBay
+ * writes forces; MegaMek's own bot files do not, and for those this is
+ * empty and everything is in `loose`.
+ */
+ get forces() {
+ return this.#summary.forces;
+ }
+
+ /** The units in no force at all, as indices into `units`. */
+ get loose() {
+ return this.#summary.loose;
}
/** The version the file said wrote it, or null. */
get version() {
- return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live))).version;
+ return this.#summary.version;
}
/**
diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs
index 549afec..ba763f0 100644
--- a/crates/helm-wasm/smoke.mjs
+++ b/crates/helm-wasm/smoke.mjs
@@ -279,4 +279,75 @@ if (mulFile) {
throws("a freed force", () => force.units, -7);
}
+// --- forces ---------------------------------------------------------------
+//
+// MekBay writes forces with sub-forces; a `.mul` carries them as a chain per
+// entity and no tree at all. This is the boundary's half of rebuilding one:
+// what a page needs to draw the brackets.
+{
+ const withForces = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+ const force = helm.force(withForces);
+
+ check("one top-level force", force.forces.length, 1);
+ check("named as the file names it", force.forces[0].name, "Steiner Ceremonial");
+ check("two sub-forces under it", force.forces[0].children.length, 2);
+ check("the first holds the Atlas", force.forces[0].children[0].units[0], 0);
+ check(
+ "a sub-force is named too",
+ force.forces[0].children[1].name,
+ "Recon Lance",
+ );
+ check("the top-level force holds nothing directly", force.forces[0].units.length, 0);
+
+ // The indices are into `units`, so a page joins the two without a second
+ // read - and the unit they point at is the one the file named.
+ check(
+ "an index names its unit",
+ force.units[force.forces[0].children[1].units[0]].chassis,
+ "Griffin",
+ );
+
+ // Everything in no force is reported rather than dropped: drawing forces
+ // and loose units covers each unit exactly once.
+ check("the Locust is loose", JSON.stringify(force.loose), "[2]");
+ const drawn =
+ force.forces[0].children.reduce((n, f) => n + f.units.length, 0) +
+ force.forces[0].units.length +
+ force.loose.length;
+ check("every unit is drawn once", drawn, force.units.length);
+
+ force.free();
+}
+
+// A file with no forces says so, rather than looking like a broken tree.
+// MegaMek's own bot files are the common case of that, and they are what a
+// match writes - so this is the shape the site sees most.
+{
+ const noForces = `
+
+
+
+
+
+`;
+ const force = helm.force(noForces);
+ check("no forces in a file that has none", force.forces.length, 0);
+ check("so every unit is loose", force.loose.length, force.units.length);
+ force.free();
+}
+
process.exit(failed === 0 ? 0 : 1);
diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs
index 8215a15..ba69de1 100644
--- a/crates/helm-wasm/src/lib.rs
+++ b/crates/helm-wasm/src/lib.rs
@@ -343,10 +343,42 @@ pub extern "C" fn helm_mul_summary(handle: i64) -> i64 {
})
})
.collect();
- give(serde_json::json!({ "version": mul.version, "units": units }).to_string())
+ give(
+ serde_json::json!({
+ "version": mul.version,
+ "units": units,
+ "forces": forces_json(&mul.force_tree()),
+ "loose": mul.loose_units(),
+ })
+ .to_string(),
+ )
})
}
+/// The force tree, as the page draws it.
+///
+/// A `.mul` stores no tree: every entity repeats its own chain and
+/// `force_tree` rebuilds one. `units` here are indices into the summary's own
+/// `units` array — the same `at` each unit carries — so a caller joins the two
+/// without a second lookup, and `loose` beside it is everything in no force at
+/// all. Between them every unit is drawn exactly once.
+fn forces_json(forces: &[helm_unitfile::ForceNode]) -> Vec {
+ forces
+ .iter()
+ .map(|force| {
+ serde_json::json!({
+ "id": force.id,
+ "name": force.name,
+ "camo": force.camo.as_ref().map(|(category, file)| {
+ serde_json::json!({ "category": category, "file": file })
+ }),
+ "units": force.units,
+ "children": forces_json(&force.children),
+ })
+ })
+ .collect()
+}
+
/// Write the force back out, as an edit of the file that arrived.
#[unsafe(no_mangle)]
pub extern "C" fn helm_mul_write(handle: i64) -> i64 {
@@ -428,9 +460,14 @@ fn with_force_mut(handle: i64, f: impl FnOnce(&mut helm_unitfile::Mul) -> i64) -
/// so a page that has cached an older `.wasm` than its wrapper - or a newer
/// one - fails at load with something readable instead of returning numbers
/// that are quietly wrong.
+///
+/// 2 adds `forces` and `loose` to `helm_mul_summary`. Additive, so an older
+/// caller is unaffected - but a page that draws a force tree cannot otherwise
+/// tell a file with no forces from a module too old to report them, and one
+/// of those is a bug worth failing on.
#[unsafe(no_mangle)]
pub extern "C" fn helm_abi_version() -> i64 {
- 1
+ 2
}
/// How many equipment entries are loaded, so a caller can tell an empty