From dc44c09b12ef8fa33ef8ea5ca75be8f91ed960e6 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 12:36:10 -0400 Subject: [PATCH] feat(unit-rules): hold a design, so re-crewing a force costs nothing Reading the .mtf is most of what scoring one costs, and an application asks the same design many questions. A held design keeps its base per condition and the crew only scales it, which takes re-crewing from 432us a unit to 1us - the property the native benchmark promised and the boundary was throwing away. The smoke test checks both paths agree to the point, because the multiplier is now applied in JavaScript. Change-Id: I7e4fa6e18e2ab26e01bf035f7dcfbfc8e168426c --- crates/helm-wasm/helm.mjs | 67 ++++++++++++++++++ crates/helm-wasm/smoke.mjs | 38 ++++++++-- crates/helm-wasm/src/lib.rs | 134 +++++++++++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 5 deletions(-) diff --git a/crates/helm-wasm/helm.mjs b/crates/helm-wasm/helm.mjs index 0a956b3..12dd3d8 100644 --- a/crates/helm-wasm/helm.mjs +++ b/crates/helm-wasm/helm.mjs @@ -28,6 +28,7 @@ const ERRORS = new Map([ [-3n, "the design could not be read"], [-4n, "this crate cannot score that design"], [-5n, "the condition is not the JSON this expects"], + [-6n, "that design has been let go of"], ]); export class HelmError extends Error { @@ -113,6 +114,20 @@ export class Helm { ); } + /** + * Read a design once and keep it, for asking about repeatedly. + * + * Reading the `.mtf` is most of what scoring costs, so an application that + * re-scores as somebody drags a slider should hold one of these rather than + * pass the text again each time. Call `free()` when done with it. + */ + design(mtf) { + const handle = check(this.#withBytes(mtf, (ptr, len) => + this.#api.helm_design_load(ptr, len), + )); + return new Design(this.#api, handle, (text, use) => this.#withBytes(text, use)); + } + /** Copy text into the module, run `use`, and give the memory back. */ #withBytes(text, use) { const bytes = new TextEncoder().encode(text); @@ -131,6 +146,58 @@ export class Helm { } } +/** One design, read and held, ready to be asked about. */ +export class Design { + #api; + #handle; + #withBytes; + /** The last condition asked about, and what it came to. */ + #baseFor = null; + #base = 0; + + constructor(api, handle, withBytes) { + this.#api = api; + this.#handle = handle; + this.#withBytes = withBytes; + } + + /** + * What it is worth, in a condition and with a crew. See Helm#battleValue. + * + * The base is worked out once per condition and kept, because the crew and + * the force bonus do not change it - they scale and shift it. That is what + * makes re-crewing a whole force free, which is the interactive case that + * looked most expensive and is not. + */ + battleValue(condition = null, crew = {}) { + if (this.#handle === null) { + throw new HelmError(-6n); + } + const { gunnery = 4, piloting = 5, forceBonus = 0 } = crew; + const key = condition ? JSON.stringify(condition) : ""; + if (key !== this.#baseFor) { + this.#base = Number( + check( + this.#withBytes(key, (ptr, len) => + this.#api.helm_design_base_value(this.#handle, len === 0 ? 0 : ptr, len), + ), + ), + ); + this.#baseFor = key; + } + const multiplier = this.#api.helm_skill_multiplier(gunnery, piloting); + return Math.round((this.#base + forceBonus) * multiplier); + } + + /** Let the module forget it. Asking afterwards throws rather than lying. */ + free() { + if (this.#handle !== null) { + this.#api.helm_design_free(this.#handle); + this.#handle = null; + } + } +} + function check(value) { if (typeof value === "bigint" && value < 0n) { throw new HelmError(value); diff --git a/crates/helm-wasm/smoke.mjs b/crates/helm-wasm/smoke.mjs index b5d58c8..89b0318 100644 --- a/crates/helm-wasm/smoke.mjs +++ b/crates/helm-wasm/smoke.mjs @@ -88,6 +88,29 @@ check( // Bad input raises rather than returning a plausible number. throws("an unreadable design", () => helm.battleValue("this is not a mek"), -3); +// A held design must answer exactly as the one-shot call does, including the +// rounding: the crew multiplier is applied in JavaScript there and in Rust +// here, and the two have to agree to the point. +const held = helm.design(mtf); +let heldWrong = 0; +for (const crew of [ + { gunnery: 4, piloting: 5 }, + { gunnery: 3, piloting: 4 }, + { gunnery: 5, piloting: 6 }, + { gunnery: 0, piloting: 0 }, + { gunnery: 3, piloting: 4, forceBonus: 137 }, +]) { + for (const condition of [null, { armor: { CT: 3 } }, { structure: { LT: 2 } }]) { + if (held.battleValue(condition, crew) !== helm.battleValue(mtf, condition, crew)) { + heldWrong++; + } + } +} +check("a held design answers identically", heldWrong, 0); + +held.free(); +throws("a freed design", () => held.battleValue(), -6); + // Re-scoring the same design many times must not leak the memory it borrows. for (let i = 0; i < 5000; i++) { helm.battleValue(mtf, { armor: { CT: i % 40 } }); @@ -118,16 +141,23 @@ if (damaged && designs) { for (const [where, slot] of pairs) (out[where] ??= []).push(slot); return out; }; - const ours = helm.battleValue(text, { + const state = { armor: row.armor, structure: row.structure, destroyed: group(row.destroyed), emptyAmmo: group(row.emptyAmmo), - }); + }; + const ours = helm.battleValue(text, state); + // Both paths, since an application will use the held one. + const design = helm.design(text); + const heldValue = design.battleValue(state); + design.free(); checked++; - if (ours !== row.bv) { + if (ours !== row.bv || heldValue !== row.bv) { wrong++; - console.log(`FAIL ${row.name} ${row.scenario}: ours ${ours}, megamek ${row.bv}`); + console.log( + `FAIL ${row.name} ${row.scenario}: ours ${ours}, held ${heldValue}, megamek ${row.bv}`, + ); } } check(`${checked} damaged designs match MegaMek`, wrong, 0); diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 17c9ad8..d75235f 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -43,9 +43,19 @@ pub const ERR_UNREADABLE: i64 = -3; pub const ERR_UNSUPPORTED: i64 = -4; /// The state argument is not the JSON this expects. pub const ERR_BAD_STATE: i64 = -5; +/// The handle names no design this is holding. +pub const ERR_NO_DESIGN: i64 = -6; +// The catalogue, and the designs a caller is holding on to. +// +// Reading a `.mtf` is most of what scoring one costs - the arithmetic is cheap +// and the parsing is not - and an application asks the same design a great +// many questions: what is it worth now, now that it has been shot, now with a +// veteran in it. Designs are kept in a slab because a handle crossing the +// boundary has to be a number. thread_local! { static CATALOGUE: RefCell> = const { RefCell::new(None) }; + static DESIGNS: RefCell>> = const { RefCell::new(Vec::new()) }; } /// Hand out `len` bytes for the caller to write into. @@ -132,6 +142,116 @@ pub extern "C" fn helm_catalogue_len() -> i64 { }) } +/// Read a design and keep it, returning a handle to ask about later. +/// +/// Negative on failure, and otherwise a handle that stays valid until it is +/// passed to `helm_design_free`. +/// +/// # Safety +/// `ptr` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_load(ptr: *const u8, len: usize) -> i64 { + let Some(mtf) = (unsafe { text(ptr, len) }) else { + return ERR_NOT_UTF8; + }; + let Ok(unit) = helm_unitfile::parse_mtf("", mtf) else { + return ERR_UNREADABLE; + }; + DESIGNS.with(|d| { + let mut designs = d.borrow_mut(); + // A freed slot is reused so that a page holding a lance and swapping + // designs all afternoon does not grow the slab without bound. + let handle = match designs.iter().position(|slot| slot.is_none()) { + Some(free) => { + designs[free] = Some(unit); + free + } + None => { + designs.push(Some(unit)); + designs.len() - 1 + } + }; + // Handles start at one so that zero is never a valid one. + handle as i64 + 1 + }) +} + +/// Let go of a design read by `helm_design_load`. +#[unsafe(no_mangle)] +pub extern "C" fn helm_design_free(handle: i64) { + DESIGNS.with(|d| { + let mut designs = d.borrow_mut(); + if let Some(slot) = usize::try_from(handle - 1) + .ok() + .and_then(|i| designs.get_mut(i)) + { + *slot = None; + } + }); +} + +/// What a design already read is worth, in a condition and with a crew. +/// +/// The same question as `helm_battle_value_crewed` without re-reading the +/// design, which is most of what that call costs. +/// +/// # Safety +/// `condition` must point to `condition_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_battle_value( + handle: i64, + condition: *const u8, + condition_len: usize, + force_bonus: i64, + gunnery: u8, + piloting: u8, +) -> i64 { + let condition = match unsafe { read_condition(condition, condition_len) } { + Ok(condition) => condition, + Err(code) => return code, + }; + DESIGNS.with(|d| { + let designs = d.borrow(); + let Some(Some(unit)) = usize::try_from(handle - 1) + .ok() + .and_then(|i| designs.get(i)) + else { + return ERR_NO_DESIGN; + }; + score(unit, &condition, force_bonus, gunnery, piloting) + }) +} + +/// What a design already read is worth before the force and the crew have +/// their say. +/// +/// The base is the expensive scope and the only one that depends on the +/// design: a crew multiplies it and a network adds to it. An application that +/// re-scores as somebody changes a pilot should ask for this once per +/// condition and combine it with `helm_skill_multiplier`, which is what makes +/// re-crewing a force cost nothing at all. +/// +/// # Safety +/// `condition` must point to `condition_len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn helm_design_base_value( + handle: i64, + condition: *const u8, + condition_len: usize, +) -> i64 { + unsafe { helm_design_battle_value(handle, condition, condition_len, 0, 4, 5) } +} + +/// What a crew of this skill multiplies a battle value by. +/// +/// A regular 4/5 crew is exactly one. Combined as +/// `round((base + force_bonus) * multiplier)`, which is the order MegaMek +/// uses - the bonus is inside the multiplication, not added after it. +#[unsafe(no_mangle)] +pub extern "C" fn helm_skill_multiplier(gunnery: u8, piloting: u8) -> f64 { + helm_force::bv_skill_multiplier(gunnery, piloting) +} + /// The battle value of a design as it left the factory, from its `.mtf` text. /// /// # Safety @@ -194,9 +314,21 @@ pub unsafe extern "C" fn helm_battle_value_crewed( return ERR_UNREADABLE; }; + score(&unit, &condition, force_bonus, gunnery, piloting) +} + +/// The one place the four scopes are combined, so both entry points answer +/// the same question the same way. +fn score( + unit: &helm_core::Unit, + condition: &helm_bv::Condition, + force_bonus: i64, + gunnery: u8, + piloting: u8, +) -> i64 { CATALOGUE.with(|c| match c.borrow().as_ref() { None => ERR_NO_CATALOGUE, - Some(catalogue) => match helm_bv::battle_value_in(&unit, catalogue, &condition) { + Some(catalogue) => match helm_bv::battle_value_in(unit, catalogue, condition) { Ok(bv) => helm_force::adjusted_battle_value_in_force( bv, force_bonus, -- 2.51.2