diff --git a/crates/helm-unitfile/src/mul.rs b/crates/helm-unitfile/src/mul.rs index 407cb0c..a2ee5b7 100644 --- a/crates/helm-unitfile/src/mul.rs +++ b/crates/helm-unitfile/src/mul.rs @@ -54,12 +54,25 @@ use quick_xml::events::Event; /// find their camouflage gone and nothing said about it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Kept { + /// The tag's name, or `#comment` and `#text` for the things between tags. + /// + /// A `.mul` MegaMek wrote opens with a licence and attribution notice in a + /// comment, and a location carries its own name as text after the tag. + /// Neither is markup this crate acts on, and handing a file back with the + /// attribution stripped out is not something to do quietly. pub name: String, /// Attributes in the order they arrived. pub attributes: Vec<(String, String)>, pub children: Vec, + /// What a `#comment` or `#text` holds. Empty for an element. + pub text: String, } +/// The name given to a comment kept between elements. +const COMMENT: &str = "#comment"; +/// The name given to text kept between elements. +const TEXT: &str = "#text"; + impl Kept { /// Read one attribute. pub fn get(&self, name: &str) -> Option<&str> { @@ -122,6 +135,10 @@ pub struct Mul { pub root: Kept, /// What the file said it was written by, if it said. pub version: Option, + /// Anything before the document opens - MegaMek puts its licence and + /// attribution notice there, outside every element, where nothing contains + /// it and a reader that only keeps children drops it. + pub prologue: Vec, } impl Mul { @@ -259,6 +276,27 @@ pub fn parse_mul(text: &str) -> Result { close(&mut open, done, &mut mul); } } + // Neither is markup this crate reads, and both are somebody's to + // keep: the notice at the head of a MegaMek file, and the location + // names it writes for a person reading the XML. + Event::Comment(t) => { + let text = String::from_utf8_lossy(t.as_ref()).into_owned(); + between(&mut open, COMMENT, text, &mut mul.prologue); + } + // Kept exactly as written rather than decoded and re-encoded: an + // entity reference arrives as its own event, and rebuilding the + // run around it is how a `"` turns into three pieces of text + // with the quotation mark gone. + Event::Text(t) => { + let text = String::from_utf8_lossy(t.as_ref()).into_owned(); + if !text.trim().is_empty() { + between(&mut open, TEXT, text, &mut mul.prologue); + } + } + Event::GeneralRef(r) => { + let name = String::from_utf8_lossy(r.as_ref()).into_owned(); + between(&mut open, TEXT, format!("&{name};"), &mut mul.prologue); + } _ => {} } } @@ -270,6 +308,30 @@ pub fn parse_mul(text: &str) -> Result { Ok(mul) } +/// Keep a comment or a run of text where it was found. +fn between(open: &mut [Kept], name: &str, text: String, prologue: &mut Vec) { + let into = match open.last_mut() { + Some(parent) => &mut parent.children, + // Nothing contains it, so it stands before the document does. + None => prologue, + }; + { + // A run broken up by an entity reference is still one run. + if name == TEXT + && let Some(last) = into.last_mut() + && last.name == TEXT + { + last.text.push_str(&text); + return; + } + into.push(Kept { + name: name.to_string(), + text, + ..Default::default() + }); + } +} + /// Attach a finished element to whatever contains it - or, if it is an entity, /// read it into the model. fn close(open: &mut [Kept], done: Kept, mul: &mut Mul) { @@ -313,6 +375,7 @@ fn element(tag: &quick_xml::events::BytesStart<'_>) -> Result { name: String::from_utf8_lossy(tag.name().as_ref()).into_owned(), attributes, children: Vec::new(), + text: String::new(), }) } @@ -441,7 +504,12 @@ fn read_slot(unit: &mut MulUnit, name: &str, slot_tag: &Kept) { /// numbered from one, and a location with nothing to say is not written at all /// rather than written as zero. pub fn write_mul(mul: &Mul) -> String { - let mut out = String::from("\n\n"); + let mut out = String::from("\n"); + // Whatever stood before the document, still standing before it. + for node in &mul.prologue { + write_element(&mut out, node, 0); + } + out.push('\n'); // Written from the document that arrived, so that everything beside the // units - how they are organised into lances, a crew that got out - is @@ -452,6 +520,7 @@ pub fn write_mul(mul: &Mul) -> String { } else { Kept { name: "unit".to_string(), + text: String::new(), attributes: vec![( "version".to_string(), mul.version @@ -565,6 +634,7 @@ fn entity_of(unit: &MulUnit) -> Kept { name: "location".to_string(), attributes: vec![("index".to_string(), index.to_string())], children: Vec::new(), + text: String::new(), }, }; location.set("index", index.to_string()); @@ -618,6 +688,7 @@ fn set_armor(location: &mut Kept, points: Option<&i64>, kind: Option<&str>) { name: "armor".to_string(), attributes: vec![("points".to_string(), points.to_string())], children: Vec::new(), + text: String::new(), }; if let Some(kind) = kind { tag.set("type", kind); @@ -668,6 +739,7 @@ fn set_slots(location: &mut Kept, gone: Option<&BTreeSet>, empty: Option< ("isDestroyed".to_string(), "true".to_string()), ], children: Vec::new(), + text: String::new(), }); } } @@ -680,6 +752,7 @@ fn set_slots(location: &mut Kept, gone: Option<&BTreeSet>, empty: Option< ("shots".to_string(), "0".to_string()), ], children: Vec::new(), + text: String::new(), }); } } @@ -688,6 +761,22 @@ fn set_slots(location: &mut Kept, gone: Option<&BTreeSet>, empty: Option< /// One element and everything inside it. fn write_element(out: &mut String, element: &Kept, depth: usize) { let pad = " ".repeat(depth); + // The things between tags, put back where they were found. + if element.name == COMMENT { + out.push_str(&pad); + out.push_str("\n"); + return; + } + if element.name == TEXT { + // Written back as it arrived: it was already XML when it was read, and + // escaping it again would turn an ampersand into `&`. + out.push_str(&pad); + out.push_str(element.text.trim()); + out.push('\n'); + return; + } out.push_str(&pad); out.push('<'); out.push_str(&element.name); diff --git a/crates/helm-wasm/helm.mjs b/crates/helm-wasm/helm.mjs index 12dd3d8..54b603e 100644 --- a/crates/helm-wasm/helm.mjs +++ b/crates/helm-wasm/helm.mjs @@ -29,6 +29,8 @@ const ERRORS = new Map([ [-4n, "this crate cannot score that design"], [-5n, "the condition is not the JSON this expects"], [-6n, "that design has been let go of"], + [-7n, "that force has been let go of"], + [-8n, "the force holds no unit there"], ]); export class HelmError extends Error { @@ -128,6 +130,34 @@ export class Helm { return new Design(this.#api, handle, (text, use) => this.#withBytes(text, use)); } + /** + * Read a `.mul` and keep it, for showing, editing and writing back. + * + * The force stays inside the module. A `.mul` carries a great deal a page + * has no use for and no business dropping - camouflage, portraits, the + * identifier a campaign tracks a unit by, how the units are organised into + * lances - so what crosses is a summary to show, and edits are named. What + * is written back is the file that arrived, edited. + */ + force(mul) { + const handle = check(this.#withBytes(mul, (ptr, len) => this.#api.helm_mul_load(ptr, len))); + return new Force(this.#api, handle, (text, use) => this.#withBytes(text, use), (packed) => + this.#take(packed), + ); + } + + /** Read a packed pointer-and-length back as a string, and free it. */ + #take(packed) { + const value = check(packed); + const ptr = Number(value >> 32n); + const len = Number(value & 0xffffffffn); + try { + return new TextDecoder().decode(new Uint8Array(this.#api.memory.buffer, ptr, len)); + } finally { + this.#api.helm_free(ptr, len); + } + } + /** Copy text into the module, run `use`, and give the memory back. */ #withBytes(text, use) { const bytes = new TextEncoder().encode(text); @@ -146,6 +176,79 @@ export class Helm { } } +/** One force, read and held: shown as a summary, written back as itself. */ +export class Force { + #api; + #handle; + #withBytes; + #take; + + constructor(api, handle, withBytes, take) { + this.#api = api; + this.#handle = handle; + this.#withBytes = withBytes; + this.#take = take; + } + + /** The handle, or a readable error if this force has been let go of. */ + get #live() { + if (this.#handle === null) { + throw new HelmError(-7n); + } + return this.#handle; + } + + /** 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; + } + + /** The version the file said wrote it, or null. */ + get version() { + return JSON.parse(this.#take(this.#api.helm_mul_summary(this.#live))).version; + } + + /** + * Set one attribute on one unit - `camoCategory`, `portraitFile`, anything. + * + * Named rather than typed, so a page can set something this wrapper has + * never heard of and a newer MegaMek needs no change here. + */ + setAttribute(at, name, value) { + this.#withBytes(name, (namePtr, nameLen) => + this.#withBytes(String(value), (valuePtr, valueLen) => + check( + this.#api.helm_mul_set_attribute( + this.#live, + at, + namePtr, + nameLen, + valuePtr, + valueLen, + ), + ), + ), + ); + } + + /** Seat a different crew in one unit. */ + setCrew(at, { gunnery = 4, piloting = 5 } = {}) { + check(this.#api.helm_mul_set_crew(this.#live, at, gunnery, piloting)); + } + + /** The `.mul` itself: the file that arrived, with the edits applied. */ + toMul() { + return this.#take(this.#api.helm_mul_write(this.#live)); + } + + free() { + if (this.#handle !== null) { + this.#api.helm_mul_free(this.#handle); + this.#handle = null; + } + } +} + /** One design, read and held, ready to be asked about. */ export class Design { #api; diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs index 89b0318..4e818a2 100644 --- a/crates/helm-wasm/smoke.mjs +++ b/crates/helm-wasm/smoke.mjs @@ -9,10 +9,10 @@ import { readFileSync } from "node:fs"; import { Helm, HelmError } from "./helm.mjs"; -const [catalogue, design, damaged, designs] = process.argv.slice(2); +const [catalogue, design, damaged, designs, mulFile] = process.argv.slice(2); if (!catalogue || !design) { console.error( - "usage: node smoke.mjs [damaged.jsonl ]", + "usage: node smoke.mjs [damaged.jsonl [force.mul]]", ); process.exit(2); } @@ -163,4 +163,52 @@ if (damaged && designs) { check(`${checked} damaged designs match MegaMek`, wrong, 0); } +// A .mul held, shown, edited and written back must lose nothing. This is the +// check that matters most: the Rust round trip is lossless and was still lossy +// across the boundary, because the summary that crossed was mistaken for the +// document. +if (mulFile) { + const text = readFileSync(mulFile, "utf8"); + + // Every element and attribute, so two documents can be compared without + // caring about whitespace or ordering. + const census = (xml) => { + const counts = new Map(); + for (const [, tag] of xml.matchAll(/<([^/?!][^>]*)>/g)) { + const name = tag.split(/[\s/]/)[0]; + counts.set(`<${name}`, (counts.get(`<${name}`) ?? 0) + 1); + for (const [, k, v] of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) { + counts.set(`${k}=${v}`, (counts.get(`${k}=${v}`) ?? 0) + 1); + } + } + return counts; + }; + const lost = (before, after) => + [...before].filter(([k, n]) => (after.get(k) ?? 0) < n).map(([k]) => k); + + const force = helm.force(text); + check("the force reads", force.units.length, (n) => n > 0); + + const written = force.toMul(); + const missing = lost(census(text), census(written)); + check(`nothing lost writing it back${missing.length ? ": " + missing.slice(0, 6) : ""}`, + missing.length, 0); + + // An edit changes what was edited and nothing else. + force.setAttribute(0, "camoCategory", "Clans/Ghost Bear"); + const edited = force.toMul(); + check("the camo is set", edited.includes('camoCategory="Clans/Ghost Bear"'), true); + const stillMissing = lost(census(text), census(edited)).filter((k) => !k.startsWith("camoCategory=")); + check(`an edit loses nothing else${stillMissing.length ? ": " + stillMissing.slice(0, 6) : ""}`, + stillMissing.length, 0); + + // And a crew change reaches the file. + force.setCrew(0, { gunnery: 2, piloting: 3 }); + check("the crew is seated", force.toMul().includes('gunnery="2"'), true); + check("the summary agrees", force.units[0].gunnery, 2); + + force.free(); + throws("a freed force", () => force.units, -7); +} + process.exit(failed === 0 ? 0 : 1); diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 29ea522..a113af2 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -45,6 +45,10 @@ pub const ERR_UNSUPPORTED: i64 = -4; pub const ERR_BAD_STATE: i64 = -5; /// The handle names no design this is holding. pub const ERR_NO_DESIGN: i64 = -6; +/// The handle names no force this is holding. +pub const ERR_NO_FORCE: i64 = -7; +/// The force holds no unit at that position. +pub const ERR_NO_SUCH_UNIT: i64 = -8; // The catalogue, and the designs a caller is holding on to. // @@ -56,6 +60,7 @@ pub const ERR_NO_DESIGN: i64 = -6; thread_local! { static CATALOGUE: RefCell> = const { RefCell::new(None) }; static DESIGNS: RefCell>> = const { RefCell::new(Vec::new()) }; + static FORCES: RefCell>> = const { RefCell::new(Vec::new()) }; } /// Hand out `len` bytes for the caller to write into. @@ -247,6 +252,164 @@ fn give(text: String) -> i64 { ((ptr as u32 as i64) << 32) | (len as u32 as i64) } +/// Read a `.mul` and keep it, returning a handle to work on. +/// +/// The force stays here rather than crossing as JSON, because a `.mul` carries +/// a great deal a page has no use for and no business dropping: a unit's +/// camouflage, its portrait, the identifier a campaign tracks it by, how the +/// units are organised into lances. A summary crosses for display; the +/// document itself never leaves, so writing it back cannot lose what was never +/// sent. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_mul_load(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; + }; + FORCES.with(|f| { + let mut forces = f.borrow_mut(); + let handle = match forces.iter().position(|slot| slot.is_none()) { + Some(free) => { + forces[free] = Some(mul); + free + } + None => { + forces.push(Some(mul)); + forces.len() - 1 + } + }; + handle as i64 + 1 + }) +} + +/// Let go of a force. +#[unsafe(no_mangle)] +pub extern "C" fn helm_mul_free(handle: i64) { + FORCES.with(|f| { + let mut forces = f.borrow_mut(); + if let Some(slot) = usize::try_from(handle - 1) + .ok() + .and_then(|i| forces.get_mut(i)) + { + *slot = None; + } + }); +} + +/// What the force says, for a page to show: which designs, whose crews, what +/// each has lost. +/// +/// A view of the document rather than the document. Everything not here is +/// still held and still written back. +/// +/// Returns a pointer and length packed into one `i64`; the caller owns the +/// bytes and must free them. +#[unsafe(no_mangle)] +pub extern "C" fn helm_mul_summary(handle: i64) -> i64 { + with_force(handle, |mul| { + let units: Vec = mul + .units + .iter() + .enumerate() + .map(|(at, u)| { + serde_json::json!({ + "at": at, + "chassis": u.chassis, + "model": u.model, + "type": u.unit_type, + "isCrew": u.is_crew(), + "pilotName": u.pilot_name, + "gunnery": u.gunnery, + "piloting": u.piloting, + "pilotHits": u.pilot_hits, + "condition": condition_json(u), + }) + }) + .collect(); + give(serde_json::json!({ "version": mul.version, "units": units }).to_string()) + }) +} + +/// 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 { + with_force(handle, |mul| give(helm_unitfile::write_mul(mul))) +} + +/// Set one attribute on one unit - a camouflage, a portrait, a name. +/// +/// Named rather than typed on purpose: a page setting `camoCategory` should +/// not need this crate to have heard of camouflage, and a MegaMek that adds an +/// attribute should not need a new function here. +/// +/// # Safety +/// `name` and `value` must point to `name_len` and `value_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_mul_set_attribute( + handle: i64, + at: usize, + name: *const u8, + name_len: usize, + value: *const u8, + value_len: usize, +) -> i64 { + let (Some(name), Some(value)) = (unsafe { text(name, name_len) }, unsafe { + text(value, value_len) + }) else { + return ERR_NOT_UTF8; + }; + with_force_mut(handle, |mul| match mul.units.get_mut(at) { + Some(unit) => { + unit.source.set(name, value); + 0 + } + None => ERR_NO_SUCH_UNIT, + }) +} + +/// Seat a different crew in one unit. +#[unsafe(no_mangle)] +pub extern "C" fn helm_mul_set_crew(handle: i64, at: usize, gunnery: u8, piloting: u8) -> i64 { + with_force_mut(handle, |mul| match mul.units.get_mut(at) { + Some(unit) => { + unit.gunnery = gunnery; + unit.piloting = piloting; + 0 + } + None => ERR_NO_SUCH_UNIT, + }) +} + +/// Borrow a held force, or answer that there is none. +fn with_force(handle: i64, f: impl FnOnce(&helm_unitfile::Mul) -> i64) -> i64 { + FORCES.with(|forces| { + let forces = forces.borrow(); + match usize::try_from(handle - 1).ok().and_then(|i| forces.get(i)) { + Some(Some(mul)) => f(mul), + _ => ERR_NO_FORCE, + } + }) +} + +/// The same, to change one. +fn with_force_mut(handle: i64, f: impl FnOnce(&mut helm_unitfile::Mul) -> i64) -> i64 { + FORCES.with(|forces| { + let mut forces = forces.borrow_mut(); + match usize::try_from(handle - 1) + .ok() + .and_then(|i| forces.get_mut(i)) + { + Some(Some(mul)) => f(mul), + _ => ERR_NO_FORCE, + } + }) +} + /// Which shape of boundary this module speaks. /// /// Bumped when an exported function changes its arguments or their meaning,