diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs
index 8eb2a5e..23405cc 100644
--- a/crates/helm-unitfile/src/mul.rs
+++ b/crates/helm-unitfile/src/mul.rs
@@ -116,11 +116,12 @@ impl MulUnit {
}
}
-/// A Mek's locations, in MegaMek's own order, as `(short code, full name)`.
+/// A biped's locations, in MegaMek's own order, as `(short code, full name)`.
///
/// The index in a `.mul` is the location constant, so the order here is the
-/// contract: `Mek.LOC_HEAD` is 0 and `LOC_LEFT_LEG` is 7.
-const MEK_LOCATIONS: [(&str, &str); 8] = [
+/// contract: `Mek.LOC_HEAD` is 0 and `LOC_LEFT_LEG` is 7. A tripod is these
+/// eight and one more.
+const BIPED_LOCATIONS: [(&str, &str); 9] = [
("HD", "Head"),
("CT", "Center Torso"),
("RT", "Right Torso"),
@@ -129,8 +130,41 @@ const MEK_LOCATIONS: [(&str, &str); 8] = [
("LA", "Left Arm"),
("RL", "Right Leg"),
("LL", "Left Leg"),
+ ("CL", "Center Leg"),
];
+/// The same slots on a quad, which has four legs where a biped has two arms
+/// and two legs - and numbers them right before left, as the arms are.
+///
+/// This is why a shape has to be known before a location can be named: index
+/// four is a Right Arm on one design and a Front Right Leg on another, and
+/// reading a quad as a biped files its damage under limbs it does not have.
+const QUAD_LOCATIONS: [(&str, &str); 8] = [
+ ("HD", "Head"),
+ ("CT", "Center Torso"),
+ ("RT", "Right Torso"),
+ ("LT", "Left Torso"),
+ ("FRL", "Front Right Leg"),
+ ("FLL", "Front Left Leg"),
+ ("RRL", "Rear Right Leg"),
+ ("RLL", "Rear Left Leg"),
+];
+
+/// Which names a design's locations go by.
+///
+/// The `type` on an entity is its movement mode, which for a Mek is its shape.
+/// A file that does not say is read as a biped, which all but a couple of
+/// hundred designs are.
+fn locations_for(kind: Option<&str>) -> &'static [(&'static str, &'static str)] {
+ match kind {
+ Some(k) if k.eq_ignore_ascii_case("quad") || k.eq_ignore_ascii_case("quadvee") => {
+ &QUAD_LOCATIONS
+ }
+ Some(k) if k.eq_ignore_ascii_case("tripod") => &BIPED_LOCATIONS,
+ _ => &BIPED_LOCATIONS[..8],
+ }
+}
+
/// Read a `.mul`.
///
/// Unknown tags and attributes are ignored rather than refused: a `.mul`
@@ -243,7 +277,7 @@ impl Attrs {
/// Plate or frame left in one location.
fn read_armor(unit: &mut MulUnit, index: usize, attrs: &Attrs) {
- let Some((code, _)) = MEK_LOCATIONS.get(index) else {
+ let Some((code, _)) = locations_for(unit.unit_type.as_deref()).get(index) else {
return;
};
let Some(points) = attrs.get("points") else {
@@ -277,7 +311,7 @@ fn read_armor(unit: &mut MulUnit, index: usize, attrs: &Attrs) {
/// One critical slot: shot out, or a magazine with something left in it.
fn read_slot(unit: &mut MulUnit, index: usize, attrs: &Attrs) {
- let Some((_, name)) = MEK_LOCATIONS.get(index) else {
+ let Some((_, name)) = locations_for(unit.unit_type.as_deref()).get(index) else {
return;
};
let Some(slot) = attrs.number("index").and_then(|n| usize::try_from(n).ok()) else {
@@ -345,7 +379,7 @@ pub fn write_mul(mul: &Mul) -> String {
}
out.push_str("/>\n");
- for (index, (code, name)) in MEK_LOCATIONS.iter().enumerate() {
+ for (index, (code, name)) in locations_for(unit.unit_type.as_deref()).iter().enumerate() {
let rear = format!("RT{}", code.chars().next().unwrap_or('C'));
let front = unit.armor.get(*code);
let behind = unit.armor.get(&rear);
@@ -639,6 +673,48 @@ mod tests {
assert_eq!(back.units[0].armor.get("CT"), Some(&7));
}
+ // Index four is a Right Arm on a biped and a Front Right Leg on a quad.
+ // Reading a quad as a biped files its damage under limbs it does not have,
+ // and none of the .mul files MegaMek ships contains a quad - so nothing
+ // would have noticed.
+ #[test]
+ fn a_quads_legs_are_not_a_bipeds_arms() {
+ let quad = parse_mul(
+ r#"
+ Front Right Leg
+
+
+
+ "#,
+ )
+ .unwrap();
+ let unit = &quad.units[0];
+ assert_eq!(unit.armor.get("FRL"), Some(&20));
+ assert_eq!(unit.armor.get("RA"), None, "a quad has no arms");
+ assert!(unit.destroyed.contains_key("Front Right Leg"));
+
+ // And the same file read as a biped names the same index differently.
+ let biped = parse_mul(
+ r#"
+ Right Arm>
+ "#,
+ )
+ .unwrap();
+ assert_eq!(biped.units[0].armor.get("RA"), Some(&20));
+ }
+
+ // A tripod's ninth location is its centre leg, which no other shape has.
+ #[test]
+ fn a_tripod_has_a_centre_leg() {
+ let mul = parse_mul(
+ r#"
+ Center Leg>
+ "#,
+ )
+ .unwrap();
+ assert_eq!(mul.units[0].armor.get("CL"), Some(&12));
+ }
+
// `type` must not match `crewType`, and `armor` must not match
// `armorDivisor`: an attribute name only counts at a word boundary.
#[test]
diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs
index d75235f..9c36143 100644
--- a/crates/helm-wasm/src/lib.rs
+++ b/crates/helm-wasm/src/lib.rs
@@ -121,6 +121,128 @@ pub unsafe extern "C" fn helm_catalogue_load(ptr: *const u8, len: usize) -> i64
}
}
+/// Read a `.mul` and hand back the force as JSON.
+///
+/// A `.mul` is MegaMek's own record of units in play, and the format lance.blue
+/// works in. The page gets back one object per machine - which design, who is
+/// flying it, and what it has lost - in the shape `helm_battle_value_crewed`
+/// takes its condition in, so a force can be scored without translating
+/// anything.
+///
+/// Crews that got out are left out: an ejected MekWarrior is written into the
+/// same file as another entity and is a person rather than a design.
+///
+/// Returns a pointer and a length packed into one `i64` - the pointer in the
+/// high half, the length in the low - because a wasm32 pointer is 32 bits and
+/// a single return value is all the boundary has. Negative is an error. The
+/// caller owns the bytes and must free them.
+///
+/// # Safety
+/// `ptr` must point to `len` readable bytes.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn helm_mul_to_json(ptr: *const u8, len: usize) -> i64 {
+ let Some(text) = (unsafe { text(ptr, len) }) else {
+ return ERR_NOT_UTF8;
+ };
+ let Ok(mul) = helm_unitfile::parse_mul(text) else {
+ return ERR_UNREADABLE;
+ };
+ let units: Vec = mul
+ .machines()
+ .map(|u| {
+ serde_json::json!({
+ "chassis": u.chassis,
+ "model": u.model,
+ "pilotName": u.pilot_name,
+ "gunnery": u.gunnery,
+ "piloting": u.piloting,
+ "pilotHits": u.pilot_hits,
+ "condition": condition_json(u),
+ })
+ })
+ .collect();
+ give(serde_json::json!({ "units": units }).to_string())
+}
+
+/// Write a force back out as a `.mul`, from the same JSON this hands back.
+///
+/// # Safety
+/// `ptr` must point to `len` readable bytes.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn helm_mul_from_json(ptr: *const u8, len: usize) -> i64 {
+ let Some(text) = (unsafe { text(ptr, len) }) else {
+ return ERR_NOT_UTF8;
+ };
+ let Ok(value) = serde_json::from_str::(text) else {
+ return ERR_BAD_STATE;
+ };
+ let Some(units) = value.get("units").and_then(|u| u.as_array()) else {
+ return ERR_BAD_STATE;
+ };
+
+ let mut mul = helm_unitfile::Mul::default();
+ for unit in units {
+ let condition = match read_condition_value(unit.get("condition")) {
+ Ok(condition) => condition,
+ Err(code) => return code,
+ };
+ mul.units.push(helm_unitfile::MulUnit {
+ chassis: unit["chassis"].as_str().unwrap_or_default().to_string(),
+ model: unit["model"].as_str().unwrap_or_default().to_string(),
+ unit_type: unit
+ .get("type")
+ .and_then(|t| t.as_str())
+ .map(str::to_string),
+ pilot_name: unit
+ .get("pilotName")
+ .and_then(|n| n.as_str())
+ .map(str::to_string),
+ gunnery: unit.get("gunnery").and_then(|g| g.as_u64()).unwrap_or(4) as u8,
+ piloting: unit.get("piloting").and_then(|p| p.as_u64()).unwrap_or(5) as u8,
+ pilot_hits: unit.get("pilotHits").and_then(|h| h.as_u64()).unwrap_or(0) as u8,
+ armor: condition.armor,
+ structure: condition.structure,
+ destroyed: condition.destroyed,
+ empty_ammo: condition.empty_ammo,
+ });
+ }
+ give(helm_unitfile::write_mul(&mul))
+}
+
+/// What a `.mul` says has happened to one machine, as the condition JSON the
+/// scoring calls take.
+fn condition_json(unit: &helm_unitfile::MulUnit) -> serde_json::Value {
+ let slots = |m: &std::collections::BTreeMap>| {
+ m.iter()
+ .map(|(where_, slots)| {
+ (
+ where_.clone(),
+ serde_json::json!(slots.iter().copied().collect::>()),
+ )
+ })
+ .collect::>()
+ };
+ serde_json::json!({
+ "armor": unit.armor,
+ "structure": unit.structure,
+ "destroyed": slots(&unit.destroyed),
+ "emptyAmmo": slots(&unit.empty_ammo),
+ })
+}
+
+/// Hand a string to the caller as a packed pointer and length.
+fn give(text: String) -> i64 {
+ let bytes = text.into_bytes();
+ let len = bytes.len();
+ let ptr = helm_alloc(len);
+ if ptr.is_null() {
+ return ERR_UNREADABLE;
+ }
+ // SAFETY: helm_alloc just handed out exactly `len` writable bytes.
+ unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len) };
+ ((ptr as u32 as i64) << 32) | (len as u32 as i64)
+}
+
/// Which shape of boundary this module speaks.
///
/// Bumped when an exported function changes its arguments or their meaning,
@@ -360,7 +482,15 @@ unsafe fn read_condition(ptr: *const u8, len: usize) -> Result) -> Result {
let mut condition = helm_bv::Condition::undamaged();
+ let Some(value) = value else {
+ return Ok(condition);
+ };
// What is left of each location, and what was shot out of it. A field that
// is absent means "nothing to say", never "nothing left".