diff --git a/Cargo.lock b/Cargo.lock index 0721dd3..c30d5e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,6 +399,18 @@ dependencies = [ "zip", ] +[[package]] +name = "helm-wasm" +version = "0.1.0" +dependencies = [ + "helm-bridge", + "helm-bv", + "helm-core", + "helm-force", + "helm-unitfile", + "serde_json", +] + [[package]] name = "iana-time-zone" version = "0.1.65" diff --git a/Cargo.toml b/Cargo.toml index 2e33978..36c91e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/helm-db", "crates/helm-cli", "crates/helm-mcp", + "crates/helm-wasm", ] [workspace.package] @@ -29,6 +30,7 @@ helm-force = { path = "crates/helm-force" } helm-bv = { path = "crates/helm-bv" } helm-bridge = { path = "crates/helm-bridge" } helm-db = { path = "crates/helm-db" } +helm-wasm = { path = "crates/helm-wasm" } # bundled: the build compiles its own libsqlite3, so no system package is # needed. Same version headquarters pins. diff --git a/TODO.md b/TODO.md index 6113329..aa47a4a 100644 --- a/TODO.md +++ b/TODO.md @@ -219,6 +219,29 @@ The long pole, and the reason `helm-core` does no I/O. Tracked as rather than approximately right. Reinstating them is deleting the guard in `battle_value` and writing those modes. +- [x] **Battle value in a browser.** `crates/helm-wasm` is a `cdylib` with + plain `extern "C"` exports and no bindgen - the boundary is four numbers + wide, and the benchmark had already shown wasm runs the arithmetic at + 1.02x native. A page loads the equipment catalogue once, then hands over + a design's `.mtf` text and gets an `i64` back; errors are negative, so a + caller that ignores them gets an obviously wrong figure rather than a + plausible one. + + `crates/helm-wasm/smoke.mjs` drives it from Node, which is the only + thing that exercises the interface a browser actually calls through: + + cargo build -p helm-wasm --target wasm32-unknown-unknown --release + node crates/helm-wasm/smoke.mjs + + An Atlas AS7-D comes back 1897 fresh, 1564 with its torsos stripped, + 2504 flown by a veteran and 1631 by a green crew. + +- [ ] **A data blob rather than the bridge's JSON Lines.** The catalogue + crosses the boundary as `equipment.jsonl`, which is what the bridge + already writes and is several times larger than it needs to be. It never + changes for a pinned MegaMek, so a compact form built once by `helm` and + shipped from infra is the version headquarters should consume. + - [ ] **Zero unregistered disagreements.** Whatever is left after the above is either our bug or theirs, and theirs gets written down by name the way the Alpha Strike specials deviation is. diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs index 1767107..cbfeca7 100644 --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -85,8 +85,16 @@ pub fn read_bv(path: &Path) -> Result, Error> { /// Read `equipment.jsonl` into the equipment catalogue. pub fn read_catalogue(path: &Path) -> Result { + parse_catalogue(&std::fs::read_to_string(path)?) +} + +/// The same, from the text rather than the file. +/// +/// Split out because the browser gets the catalogue over the network and has +/// no file to read: this is the half of the crate that has to reach wasm. +pub fn parse_catalogue(text: &str) -> Result { let mut entries = Vec::new(); - for (_, v) in objects(path)? { + for (_, v) in parse_objects(text)? { if let Some(e) = equipment_record(&v) { entries.push(e); } @@ -95,7 +103,10 @@ pub fn read_catalogue(path: &Path) -> Result { } fn objects(path: &Path) -> Result, Error> { - let text = std::fs::read_to_string(path)?; + parse_objects(&std::fs::read_to_string(path)?) +} + +fn parse_objects(text: &str) -> Result, Error> { let mut out = Vec::new(); for (i, line) in text.lines().enumerate() { if line.trim().is_empty() { diff --git a/crates/helm-wasm/Cargo.toml b/crates/helm-wasm/Cargo.toml new file mode 100644 index 0000000..4cbe82b --- /dev/null +++ b/crates/helm-wasm/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "helm-wasm" +version.workspace = true +edition.workspace = true +publish.workspace = true + +# The rules, compiled for a browser. A `cdylib` with plain `extern "C"` exports +# and no bindgen: the boundary is four numbers wide and a bindgen dependency +# would buy nothing but a build step. Everything it depends on is already held +# to no-I/O by scripts/check-boundaries.sh. +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +helm-core.workspace = true +helm-bv.workspace = true +helm-force.workspace = true +helm-bridge.workspace = true +helm-unitfile = { path = "../helm-unitfile", default-features = false } +serde_json.workspace = true diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs new file mode 100644 index 0000000..cb2a24c --- /dev/null +++ b/crates/helm-wasm/smoke.mjs @@ -0,0 +1,68 @@ +// Proves the wasm boundary works from JavaScript, which nothing else does: +// the Rust tests exercise the rules, not the four numbers wide interface a +// browser actually calls through. +// +// node crates/helm-wasm/smoke.mjs +// +// Build the module first: +// cargo build -p helm-wasm --target wasm32-unknown-unknown --release +import { readFileSync } from "node:fs"; + +const [catalogue, design] = process.argv.slice(2); +if (!catalogue || !design) { + console.error("usage: node smoke.mjs "); + process.exit(2); +} + +const wasm = new URL( + "../../target/wasm32-unknown-unknown/release/helm_wasm.wasm", + import.meta.url, +); +const { instance } = await WebAssembly.instantiate(readFileSync(wasm), {}); +const api = instance.exports; + +// Hand bytes over: allocate inside the module, write into its memory, call. +const put = (text) => { + const bytes = new TextEncoder().encode(text); + const ptr = api.helm_alloc(bytes.length); + new Uint8Array(api.memory.buffer, ptr, bytes.length).set(bytes); + return [ptr, bytes.length]; +}; + +let failed = 0; +const check = (what, got, want) => { + const ok = typeof want === "function" ? want(got) : got === want; + console.log(`${ok ? "ok " : "FAIL"} ${what}: ${got}`); + if (!ok) failed++; +}; + +// Nothing is scoreable before the catalogue is there. +check("no catalogue yet", Number(api.helm_catalogue_len()), -1); + +const [cptr, clen] = put(readFileSync(catalogue, "utf8")); +check("catalogue loads", Number(api.helm_catalogue_load(cptr, clen)), 0); +api.helm_free(cptr, clen); +check("catalogue is populated", Number(api.helm_catalogue_len()), (n) => n > 1000); + +const [dptr, dlen] = put(readFileSync(design, "utf8")); +const fresh = Number(api.helm_battle_value(dptr, dlen)); +check("a fresh design scores", fresh, (n) => n > 0); + +// Damaged is worth less, and a veteran crew is worth more. +const [sptr, slen] = put(JSON.stringify({ armor: { CT: 0, LT: 0, RT: 0 } })); +const hurt = Number(api.helm_battle_value_of(dptr, dlen, sptr, slen, 4, 5)); +check("damage lowers it", hurt, (n) => n > 0 && n < fresh); + +const veteran = Number(api.helm_battle_value_of(dptr, dlen, 0, 0, 3, 4)); +check("a veteran raises it", veteran, (n) => n > fresh); + +const green = Number(api.helm_battle_value_of(dptr, dlen, 0, 0, 5, 6)); +check("a green crew lowers it", green, (n) => n < fresh); + +// Bad input is a negative number rather than a plausible answer. +const [bptr, blen] = put("this is not a mek"); +check("unreadable design", Number(api.helm_battle_value(bptr, blen)), -3); +const [xptr, xlen] = put("{not json"); +check("bad state", Number(api.helm_battle_value_of(dptr, dlen, xptr, xlen, 4, 5)), -5); + +process.exit(failed === 0 ? 0 : 1); diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs new file mode 100644 index 0000000..ddd9d8b --- /dev/null +++ b/crates/helm-wasm/src/lib.rs @@ -0,0 +1,185 @@ +//! Battle value in a browser. +//! +//! A `cdylib` with plain `extern "C"` exports. There is no bindgen because +//! there is nothing for it to do: everything crossing the boundary is either a +//! number or a run of bytes the caller allocated here first, and a design's +//! battle value is one `i64` out. +//! +//! ```text +//! helm_alloc / helm_free hand out memory the caller writes into +//! helm_catalogue_load the equipment catalogue, once per page +//! helm_battle_value one design, from its `.mtf` text +//! helm_battle_value_of the same, damaged and with a pilot in it +//! ``` +//! +//! The catalogue is loaded once and kept. It never changes for a pinned +//! MegaMek, so a page fetches it in the background while somebody browses and +//! every later call is arithmetic on what is already there. +//! +//! Errors come back as negative numbers rather than through a side channel, so +//! a caller that ignores them gets an obviously wrong figure rather than a +//! plausible one. Battle value is never negative. + +use std::cell::RefCell; + +use helm_core::Catalogue; + +/// No catalogue has been loaded, so nothing can be scored. +pub const ERR_NO_CATALOGUE: i64 = -1; +/// The bytes handed over are not UTF-8. +pub const ERR_NOT_UTF8: i64 = -2; +/// The design could not be read. +pub const ERR_UNREADABLE: i64 = -3; +/// The design was read but this crate cannot score it - a unit type with no +/// calculator, or a rule that is not written. +pub const ERR_UNSUPPORTED: i64 = -4; +/// The state argument is not the JSON this expects. +pub const ERR_BAD_STATE: i64 = -5; + +thread_local! { + static CATALOGUE: RefCell> = const { RefCell::new(None) }; +} + +/// Hand out `len` bytes for the caller to write into. +/// +/// The caller owns them until it passes them back to one of the functions +/// below or frees them; nothing here holds a pointer after it returns. +/// +/// # Safety +/// The returned pointer is valid for `len` bytes and must be given back to +/// `helm_free` with the same `len`. +#[unsafe(no_mangle)] +pub extern "C" fn helm_alloc(len: usize) -> *mut u8 { + let mut buf = Vec::::with_capacity(len); + let ptr = buf.as_mut_ptr(); + std::mem::forget(buf); + ptr +} + +/// Give back what `helm_alloc` handed out. +/// +/// # Safety +/// `ptr` must have come from `helm_alloc` with the same `len`, and must not be +/// used afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_free(ptr: *mut u8, len: usize) { + if !ptr.is_null() { + drop(unsafe { Vec::from_raw_parts(ptr, 0, len) }); + } +} + +/// Read the equipment catalogue, as the bridge's JSON Lines. +/// +/// Returns 0, or a negative error. Calling it again replaces what is held. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_catalogue_load(ptr: *const u8, len: usize) -> i64 { + let Some(text) = (unsafe { text(ptr, len) }) else { + return ERR_NOT_UTF8; + }; + match helm_bridge::parse_catalogue(text) { + Ok(catalogue) => { + CATALOGUE.with(|c| *c.borrow_mut() = Some(catalogue)); + 0 + } + Err(_) => ERR_UNREADABLE, + } +} + +/// How many equipment entries are loaded, so a caller can tell an empty +/// catalogue from a missing one. +#[unsafe(no_mangle)] +pub extern "C" fn helm_catalogue_len() -> i64 { + CATALOGUE.with(|c| match c.borrow().as_ref() { + Some(cat) => cat.len() as i64, + None => ERR_NO_CATALOGUE, + }) +} + +/// The battle value of a design, from the text of its `.mtf`. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_battle_value(ptr: *const u8, len: usize) -> i64 { + unsafe { helm_battle_value_of(ptr, len, std::ptr::null(), 0, 4, 5) } +} + +/// The battle value of a design in the state it is in, as flown by a crew. +/// +/// `state` is JSON and may be empty for an undamaged design: +/// +/// ```json +/// {"armor": {"LT": 10, "CT": 0}} +/// ``` +/// +/// Gunnery and piloting are the crew's, 0-8. A regular 4/5 crew multiplies by +/// exactly one, which is what a figure quoted without a crew means. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes, and `state` to `state_len`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_battle_value_of( + ptr: *const u8, + len: usize, + state: *const u8, + state_len: usize, + gunnery: u8, + piloting: u8, +) -> i64 { + let Some(mtf) = (unsafe { text(ptr, len) }) else { + return ERR_NOT_UTF8; + }; + let state = match unsafe { read_state(state, state_len) } { + Ok(state) => state, + Err(code) => return code, + }; + let Ok(unit) = helm_unitfile::parse_mtf("", mtf) else { + return ERR_UNREADABLE; + }; + + CATALOGUE.with(|c| match c.borrow().as_ref() { + None => ERR_NO_CATALOGUE, + Some(catalogue) => match helm_bv::battle_value_of(&unit, catalogue, &state) { + Ok(bv) => { + helm_force::adjusted_battle_value(bv, helm_force::Pilot { gunnery, piloting }) + } + Err(_) => ERR_UNSUPPORTED, + }, + }) +} + +/// Borrow the caller's bytes as text without copying them. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes that outlive the call. +unsafe fn text<'a>(ptr: *const u8, len: usize) -> Option<&'a str> { + if ptr.is_null() { + return if len == 0 { Some("") } else { None }; + } + std::str::from_utf8(unsafe { std::slice::from_raw_parts(ptr, len) }).ok() +} + +/// # Safety +/// `ptr` must point to `len` readable bytes. +unsafe fn read_state(ptr: *const u8, len: usize) -> Result { + if len == 0 { + return Ok(helm_bv::State::undamaged()); + } + let Some(text) = (unsafe { text(ptr, len) }) else { + return Err(ERR_NOT_UTF8); + }; + let value: serde_json::Value = serde_json::from_str(text).map_err(|_| ERR_BAD_STATE)?; + let mut state = helm_bv::State::undamaged(); + if let Some(armor) = value.get("armor").and_then(|a| a.as_object()) { + for (location, points) in armor { + let Some(points) = points.as_i64() else { + return Err(ERR_BAD_STATE); + }; + state.armor.insert(location.clone(), points); + } + } + Ok(state) +} diff --git a/scripts/check-boundaries.sh b/scripts/check-boundaries.sh index 189e710..bbfeb19 100755 --- a/scripts/check-boundaries.sh +++ b/scripts/check-boundaries.sh @@ -24,9 +24,20 @@ note() { printf ' %s\n' "$1"; } fail() { printf 'FAIL: %s\n' "$1" >&2; FAIL=1; } # Crates that must reach wasm, and so must stay clear of these. +# +# serde_json is on the list for the rules crates but not because it cannot run +# in a browser - it compiles to wasm perfectly well. It is forbidden there +# because the rules are written against MegaMek's own formats with hand-written +# parsers, and a JSON dependency appearing in one of them means a rule has +# started reading somebody's serialisation instead. WASM_CRATES=(helm-core helm-facet helm-force helm-bv) FORBIDDEN=(rusqlite zip serde_json libsqlite3-sys) +# The boundary crate is the exception: decoding what JavaScript hands over is +# the whole of its job. It still may not touch a file or a database. +BOUNDARY_CRATES=(helm-wasm) +BOUNDARY_FORBIDDEN=(rusqlite zip libsqlite3-sys) + # Crates that must not depend on a producer of ComputedStats. CONSUMER_CRATES=(helm-db) PRODUCERS=(helm-bridge helm-bv) @@ -47,6 +58,20 @@ for crate in "${WASM_CRATES[@]}"; do note "$crate: clean" done +for crate in "${BOUNDARY_CRATES[@]}"; do + if ! cargo metadata --format-version 1 --no-deps 2>/dev/null | grep -q "\"$crate\""; then + note "$crate: not in the workspace yet, skipped" + continue + fi + tree="$(cargo tree -p "$crate" --edges normal 2>/dev/null || true)" + for dep in "${BOUNDARY_FORBIDDEN[@]}"; do + if grep -qE "^[^a-z]*\b$dep v" <<<"$tree"; then + fail "$crate depends on $dep, which cannot go in a browser." + fi + done + note "$crate: clean" +done + echo "checking the database layer cannot compute" for crate in "${CONSUMER_CRATES[@]}"; do tree="$(cargo tree -p "$crate" --edges normal 2>/dev/null || true)" @@ -78,6 +103,13 @@ if rustup target list --installed 2>/dev/null | grep -q wasm32-unknown-unknown; fail "$crate does not build for wasm32-unknown-unknown." fi done + for crate in "${BOUNDARY_CRATES[@]}"; do + if cargo check -q -p "$crate" --target wasm32-unknown-unknown 2>/dev/null; then + note "$crate: builds for wasm32-unknown-unknown" + else + fail "$crate does not build for wasm32-unknown-unknown." + fi + done if cargo check -q -p helm-unitfile --no-default-features \ --target wasm32-unknown-unknown 2>/dev/null; then note "helm-unitfile (no default features): builds for wasm32-unknown-unknown"