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".