Data management tools for playing BattleTech
Rust 91%
Java 4%
JavaScript 3%
Shell 2%

README.md

helm #

A queryable database of MegaMek's unit library. 10,988 units, indexed, in one SQLite file.

Named for the Helm Memory Core, which is also a recovered library that turned out to be worth more than the fortress it was found in.

Why #

Answering "which Clan heavy Meks were available by 3050 under 2200 BV" should be a query, not a recollection. This reads MegaMek's unit files and writes a database you can filter on tech base, unit type, era, tonnage, battle value, role, movement, quirks and loadout.

SELECT name, year, mass, bv, role, point_value
FROM meks
WHERE tech_base = 'Clan' AND weight_class = 3 AND year <= 3050
  AND bv BETWEEN 1800 AND 2200
ORDER BY bv DESC;

Build #

Everything here reads a MegaMek release, and this repository contains none of one. Get the one it is pinned against:

MM_HOME="$(./scripts/fetch-megamek.sh 0.51.0)"

That fetches the release tarball, checks it against a digest this repo carries — upstream publishes none — and unpacks it under ~/.cache/helm/megamek, printing the path. It is the same release, and the same digest, that arena pins for the container a match runs in.

cargo build --release
./bridge/dump.sh "$MM_HOME" /tmp/helm-bridge            # optional, see below
./target/release/helm build \
    --megamek "$MM_HOME" \
    --bridge-dir /tmp/helm-bridge \
    --megamek-version 0.51.0 \
    --out helm.sqlite

The install gets you the unit files and the art, which is all the readers need. It does not get you the equipment catalogue: MegaMek computes that when it loads, so it comes out through bridge/dump.sh, which runs a JDK in a container. Every command that prices a design needs both.

helm stats helm.sqlite prints what a database contains.

Battle value #

Battle value is computed when MegaMek loads a design and appears in no unit file, so helm borrowed it from the Java bridge. helm-bv is the replacement: the same figure, worked out here, so that a damaged unit or a different pilot can be scored rather than only stock designs.

It is measured rather than asserted. MegaMek ships an answer for every unit it has, which makes 4,294 Meks an oracle - and bridge/dump.sh records the working of that calculation as well as its answer, so the defensive and offensive ratings are checked separately. A total that is forty points light could be light on armour or heavy on weapons, and one number cannot say which.

helm bv-report --megamek /path/to/megamek --bridge-dir /tmp/helm-bridge

That prints how many designs agree and exits non-zero while any of them do not. crates/helm-bv/conformance.txt is the same report, committed, so an improvement arrives as a visible diff and a regression cannot arrive quietly.

Two more views of the same run, both for deciding what to do next rather than for reporting:

helm bv-report ... --clusters      # group the failures by what they share
helm bv-report ... --unit "Atlas"  # one design's working beside MegaMek's
helm bv-report ... --rules         # how many designs each rule fires on
helm bv-report ... --damaged       # the same, for designs that were shot at

Forces inside a .mul #

A .mul records no force tree. Each entity carries its own chain — Steiner Ceremonial|1||Command Lance|2||, name and id per step, joined by ||, with two more fields when a force has a camo of its own — and the tree is implied by every unit repeating its ancestry. Mul::force_tree rebuilds it, Mul::loose_units is everything in no force, and helm force prints the two so a MekBay force renders the way MekBay wrote it.

Order is the file's: a force appears where its first unit does, which is what MegaMek wrote and what a reader expects to see. Ids are matched, not sorted.

The same tree crosses the wasm boundary: helm_mul_summary carries forces and loose beside units, and helm.mjs exposes them as force.forces and force.loose, whose indices are into force.units. That is what lets a page draw a MekBay force the way MekBay wrote it without parsing XML of its own.

A .mul is a force in a state rather than a design, so scoring one is its own command - and --why says where a damaged machine's value went, term by term, against the design as it left the factory:

helm force <file.mul> --megamek <dir> --bridge-dir <dir> --why

A file that arrived from outside is checked rather than trusted. helm check says what is wrong with one - a design the library does not have, a crew outside 0-8, a location a shape does not have, more plate or frame than a design was built with - and separates the file's own faults from the units helm cannot score yet, failing only on the first:

helm check <file.mul> --megamek <dir> --bridge-dir <dir>

And what it would take to put right - points of plate, points of frame, the equipment shot out and the magazines to fill, location by location:

helm repairs <file.mul> --megamek <dir> --bridge-dir <dir> --why

The other direction, for fitting a machine that has already been in a fight into a scenario's budget - damage a design until it is worth a given figure:

helm wear "Atlas AS7-D" --to 1500 --megamek <dir> --bridge-dir <dir>

--write worn.mul puts the result in a file MegaMek can load, rather than only on the screen.

--battle does it with damage that has a shape - MegaMek's hit table, fire that clusters, equipment lost when it gets through the plate - so every seed is a different machine worth the same, and none of them is a mission kill:

helm wear "Atlas AS7-D" --to 1500 --battle --seed 3 ...

--clusters is the one that matters. A ranked list of the worst disagreements shows whichever design is strangest; grouping them by what they have in common shows which rule is missing, and its second section ranks the failures by how ordinary the design is - a plain biped that disagrees is a bug in arithmetic every design shares.

What writing a .mul promises #

A file written back is the file that arrived. Not only what it said — the reader keeps every element and attribute it has no rules about, camouflage, portrait, quirks, bomb loads, <force>, a crew that got out — but the bytes it said it in. Every element remembers the span of the source it was read from, and is handed back as those bytes for as long as nothing about it has changed. Nothing written says when it was written.

So an unedited save is not a save: read a .mul and write it, and what comes out is the file that went in, its indent and its quoting and its space before /> included. Somewhere content-addressed that is no write at all and no second copy of the force. An edit is a diff of the edit — the edited element, in the file's own layout, with the lines around it untouched.

A force helm built rather than read has no bytes to remember, and gets helm's own layout: four spaces, one element to a line, no space before the slash. There is no mode to choose; an element either came from somewhere or did not.

Both corpora say the same thing:

28 files, 28 read (100.0%), 99 units, 48 of them scored     # a MegaMek install
  28 came back the same document (100.0% of those read)
  28 written again to the same bytes (100.0% of those read)
  28 handed back byte for byte as they arrived (100.0% of those read)

The files an install ships are one writer's, so there is a corpus of files other people wrote — 279 of them: 271 from five public repositories pinned by commit, and eight generated by driving MekBay, which writes .mul files that exist nowhere in public code. Fetched and generated rather than committed — the repository holds the recipe:

HELM_MUL_CORPUS="$(./scripts/fetch-mul-corpus.sh)" \
  cargo test -p helm-unitfile --test corpus -- --ignored --nocapture

That last figure was 0% before the writer kept what it read, and it was never going to be anything else by choosing better defaults. Every one of those 279 files differs from what helm would print and not one differs in anything but whitespace: tabs in two files of five and spaces in the other three, attribute lists wrapped across lines in nine of ten, a space before /> in one of four. There is no layout that matches them. The only thing that matches a file is the file.

What a file from a stranger may cost #

A .mul arrives from wherever a player got it — a link they pasted, a file they were sent — so the reader's question is not whether a document is reasonable but what an unreasonable one is allowed to cost. Three limits, and a file past any of them is refused rather than survived:

limit largest seen
nesting 64 5
elements 100,000 931
bytes 16 MiB 137KB

Depth is the one that bites. A tree is walked by recursion in three places — writing it, asking whether it is intact, and dropping it, which Rust does recursively whether anybody asked or not — so ten thousand elements inside each other overflowed the stack and aborted the process. In a browser that is the tab, and the force somebody was building goes with it.

A page gets told which of those it hit. helm_mul_load answers ERR_TOO_LARGE rather than ERR_UNREADABLE for a file past a limit, and helm_last_error says which one — a file that is not a .mul is one somebody picked by mistake, and a file past a limit is one helm will not read however long it is looked at, and those are different things to be told. helm.mjs puts them on the thrown HelmError as .tooLarge and .detail.

The XML attacks the format is known for are not reachable: quick-xml does no DTD processing, no entity expansion and no external entity resolution, so billion laughs and XXE have nowhere to land.

The corpus paid for itself three times over. A .mul whose root is <record> — what a scenario resolved to, which is what MekHQ writes after a battle — was thrown away entirely and written back as an empty force; helm force now reads one and prints it section by section, totalling the survivors, because salvage is not something anybody fields. A slot that said isMissing="true" isDestroyed="false" was rewritten as destroyed. And every unit in every MekBay force read as an unqualified 4/5, because MekBay writes MegaMek's <crew> element where this looked only for <pilot>.

The .sqlite is not committed. It is derived, it is 30MB, and MegaMek's data is CC BY-NC-SA 4.0 — build it locally.

The browser's index #

The database is the server's and the agent's. A force-building screen wants a different shape: every design it may offer, with the columns a person filters and sorts on, small enough to fetch while somebody reads the page.

helm index --megamek /path/to/megamek --bridge-dir /tmp/helm-bridge --out units.json

8,456 canon, valid designs against 0.51.0 — 4,279 Meks, 1,204 combat vehicles, 1,184 battle armour suits and 1,789 infantry platoons — 3.7MB of JSON, 302KB gzipped, each one carrying chassis and model as two fields, era, tonnage, movement, armour, battle value, cost, the derived combat metrics, and the sprite that draws it.

Those four because they are what a match fields and what helm-bv can price. The list tracks what can be priced rather than what can be drawn: every design here has a battle value, and art is allowed to be missing, because a figure a screen filters on is worth more than a picture. Every Mek and every suit has art of its own; 99 designs have none and 194 fall back to one of MegaMek's own defaults/, which is what MegaMek draws them with too. --all-types widens it to the whole library — 10,896 designs and 4.8MB, gun emplacements, buildings and handheld weapons among them.

It is a reduction of the same read rather than a second extract with its own truth: the same library, the same producer of computed values, the same mekset.txt resolution helm art uses. A figure here and a figure in the database cannot disagree, because neither is computed twice.

Every file says which MegaMek it describes and what filled its battle values — megamek-bridge when a dump is at hand, helm-bv when it is not. That is what lets a consumer join it to art: the sprites are served under the same version string, so <version>/<sprite_base>/<sprite> is an address, and an index that did not name its release would be one whose pictures may not exist.

Without units.jsonl there is no canon or validity flag, because those are MegaMek's judgement rather than anything a unit file declares — the run says so rather than quietly offering designs the game marks unplayable.

Which of these wants to be next to me #

melee: true is the designs built to fight at arm's length, and melee: false is the rest. 410 of 10,988 say yes: a hatchet, a sword, a mace, a vibroblade, a claw, talons, spikes, a shield, triple strength myomer, a ProtoMech melee weapon, and the backhoes, dual saws, mining drills and chainsaws an IndustrialMek or a support vehicle swings. It reads MegaMek's own equipment flags rather than a list of names, so a design carrying something the flag covers is found whatever its file calls it.

Every Mek can punch and kick, so what is being asked is whether this one was built for it. Two things deliberately do not count. Industrial TSM is a lifting aid rather than a damage bonus. And nothing wearing a suit is asked at all: a battle claw is the ordinary battle armour manipulator and 605 designs carry one, so counting it would make the answer "battle armour" — infantry and battle armour answer nothing, and neither true nor false returns them.

Which of these is built the same on both sides #

symmetrical: true is the designs whose mirrored locations hold the same things, and symmetrical: false is the rest. 447 of 4,294 Meks say yes. A biped mirrors its arms, its side torsos and its legs; a quad mirrors its side torsos and both pairs of legs; a tripod's centre leg has nothing to be compared with and is not asked about.

It is a question about the critical hit table rather than about the inventory. A Warhammer WHM-6J carries a PPC in each arm and is symmetrical; a Hatchetman HCT-3F carries a medium laser in each arm and is not, because only the right one holds the hatchet. Every slot counts, so an actuator removed to make room, a heat sink in one torso and not the other, and the Endo Steel a design spreads through whatever slots are free all break it — the strict reading, and the one that needs no list of what to overlook. Which way a mounting faces counts too.

Nothing but a Mek is asked. A tank has a left side and a right side and no rules-level notion of mirroring them, so it answers nothing, and neither true nor false returns it.

How many legs it stands on #

biped, quad and tripod are the Config: line as three yes-or-no questions. config: ["Quad"] asks the same thing by name; a list of names cannot say not a quad, which is what a control offering two answers has to be able to mean.

They are read through Shape::from_config, so the two configurations that do not say their own shape come out right: a QuadVee stands on four legs and a LAM on two. OmniMek and FrankenMek say how a design was built rather than what shape it is, and are ignored here as they are everywhere else.

Only a Mek has a configuration. Anything without one answers nothing, and neither true nor false returns it.

Deploying the browser artifacts #

Three files go to the site's assets bucket, under one prefix named after the commit that built them:

AWS_PROFILE=lance-blue scripts/deploy.sh 0.51.0

One command: fetch the release if it is not unpacked, dump the bridge if there is no dump for it, build the binary and the wasm, write the index and the catalogue, upload, record the id. The install and the dump are cached under ~/.cache/helm, so a second run skips to the build; --megamek and --bridge-dir override either half.

fetched
<ref>/helm_wasm.wasm the rules, compiled for a browser 222 KB gz
<ref>/<megamek>/units.json the index a force screen filters 174 KB gz
<ref>/<megamek>/catalogue.jsonl the equipment those rules read 89 KB gz

Two levels because the artifacts are functions of two different things. The wasm is this repository's code alone; the index and the catalogue are that code over a particular MegaMek. Keyed by one of them, a rebuild silently replaces a file somebody is already fetching, which is exactly what an immutable cache header promises cannot happen — so a prefix is written once, nothing is overwritten, and nothing is ever invalidated.

Which prefix the site fetches is releases.helm in infra's releases.auto.tfvars.json, alongside api, arena and site. This script writes it and applies nothing; --dry-run builds everything and uploads nothing.

Unit art #

MegaMek ships a picture for every design and no way to tell from the tree which picture belongs to which unit: data/images/units/ is 6,995 files and mekset.txt is the mapping. helm art writes it out.

helm art --megamek /path/to/megamek --out art.json

Against 0.51.0 that resolves 10,623 of 10,988 designs to 5,711 distinct files - every one of the 4,294 Meks, and every file it names is one the release ships. The 365 without art are vehicles, infantry and gun emplacements, which fall back to a generic default this does not implement yet.

The mapping follows MekTileset.entryFor, which is three lookups rather than one: the exact display name, then the full chassis, then a default_* silhouette by shape and weight. Two of its details are quiet if you get them wrong - keys are compared uppercased, and a later entry overrides an earlier one, which is what include is for. Guessing a filename from the unit's name instead gets the common cases and then hands Thor (Summoner) A the wrong machine.

The bytes are not helm's to move. files in the output is what a copy of the art tree is expected to carry and missing_files is what the set names and the release does not, so a sync that came out short can be told from a release that was short to begin with.

Hooks #

prek install

Installs the pre-commit and commit-msg hooks from prek.toml. Same set as the sibling repos where the file types overlap — whitespace, TOML and XML checks, shellcheck, cargo fmt and cargo clippy — plus two this repo needs of its own.

crate-boundaries runs scripts/check-boundaries.sh when a Cargo.toml changes. That is the file a boundary breaks in, and the breakage otherwise surfaces much later as "why can this not compile to wasm".

commit-scope checks the subject is Conventional Commits and that its scope names an epic in headquarters' plan/ — unit-library, unit-search, unit-rules, forces — or one of a short list of non-epic scopes. A commit with no scope passes.

cargo clippy runs with -j 2 here where the sibling repos do not bother: a full-parallel cargo build has taken this machine down, and a hook runs on every commit.

What comes from where #

helm-unitfile reads MegaMek's two formats directly: .mtf for Meks, .blk for everything else. That is where the declared data comes from — chassis and model, tech base, era, tonnage, engine, movement, armour by location, the equipment list, the quirks, the critical slots.

Battle value, C-bill cost and the Alpha Strike conversion are not in those files. MegaMek computes them when it loads a design. Reproducing them means reimplementing the construction rules, which helm-bv and helm-cost now do for most of the library; bridge/ is what they are measured against, and what still answers for the chassis they do not cover. Seven small Java programs read MekSummaryCache, the equipment catalogue and MegaMek's own line-by-line working, and write JSON Lines.

The bridge is optional and meant to be temporary. Build without --bridge-dir and you get every declared column and null in the computed ones. Every computed column is a candidate for a native implementation that would retire a piece of it; that work belongs in this workspace.

Both sides fill one type — helm_core::ComputedStats — so when a native producer exists, conformance is ComputedStats::differences, not a bespoke harness. The database records which producer filled it, in meta: without that a conformance failure is indistinguishable from a database built against a different MegaMek.

MCP server #

helm-mcp exposes the library to an agent over MCP, scoped to BattleMeks.

helm-mcp path/to/helm.sqlite      # or set HELM_DB

Six tools: list_facets, find_units, get_unit, aggregate, resolve_equipment, random_unit.

Three properties matter more than the tool list, and the tests are written against them rather than against the shape of the output:

  • Every search reports the total, not just what it returned. An over-broad filter is obvious in one call, and comes back with the dimensions that would actually narrow this result.
  • A wrong value explains itself. weight_class: ["enormous"] returns the legal values, not an empty list — an empty list is indistinguishable from a filter that is merely too narrow, and costs a round trip to tell apart.
  • random_unit is deterministic in its seed and echoes it back. A force you cannot regenerate is one you cannot debug.

Filtering runs over helm-facet's predicate in memory rather than in SQL. That is a conformance decision, not a performance one: the predicate is what MegaMek's own filtering is checked against, so there is no query translation in the middle that can drift.

Layout #

  • crates/helm-core — the types every other crate agrees on, and no I/O at all. This is what the rules get written against, because battle value and construction validation have to run in a browser as well as on a server.
  • crates/helm-unitfile — the .mtf and .blk readers. Parsing one design is pure; reading a whole library is behind the library feature, which is off for wasm.
  • crates/helm-bridge — a producer of ComputedStats, and the temporary one. Reads what MegaMek computed. helm-bv will sit beside it and fill the same type by computing it.
  • crates/helm-db — schema and SQLite generation. Takes ComputedStats and cannot tell who produced them, which is what keeps a battle value dependency out of the database layer.
  • crates/helm-cli — the helm binary, and the only crate that picks a producer.
  • crates/helm-mcp — the MCP server. The one crate allowed a heavy dependency graph: nothing depends on it, so rmcp and its async runtime stop there.
  • bridge/ — the Java side. Runs in a container, so no JDK is needed.

scripts/check-boundaries.sh holds that graph in place: no I/O dependency may reach helm-core, and no producer may reach helm-db. Both boundaries erode silently otherwise, and the failure surfaces much later as "why can this not compile to wasm".

Tables #

  • units — one row per unit. Declared columns always populated; computed columns (bv, cost, point_value, as_damage, as_specials, canon, invalid, weight_class) populated only with the bridge.
  • unit_equipment — loadout, one row per mount, with its location.
  • unit_loadout — what MegaMek would list for this design, one row per canonical name, with no location.
  • unit_curve — expected damage per hex, one row per hex.
  • unit_criticals — slot contents by location, empty slots included.
  • unit_armor — armour points by location.
  • unit_quirks — one row per quirk.
  • unit_fields — every key the typed columns do not claim: fluff text, manufacturers, system makers, and any field a future MegaMek adds.
  • equipment — the weapon and gear catalogue, with heat, damage and ranges.
  • meta — provenance and counts.

Views: playable (canon, valid, excluding gun emplacements, buildings and handheld weapons), meks, unit_weapons, equipment_primary.

Full-text search over name, chassis and model:

SELECT u.* FROM units_fts f JOIN units u ON u.unit_id = f.rowid
WHERE units_fts MATCH '"Timber Wolf"';

Two things that will bite you #

Equipment names. MegaMek names the same weapon three ways depending on where you read it — ISGaussRifle in a loadout, Gauss Rifle in the catalogue, IS Gauss Ammo for its ammunition. Matching on the raw string finds one and misses the others. Use name_norm, which is the name reduced to lowercase alphanumerics:

-- wrong: misses ISGaussRifle and CLGaussRifle entirely
WHERE ue.name LIKE '%Gauss Rifle%'

-- right
WHERE ue.name_norm LIKE '%gaussrifle%' AND ue.is_ammo = 0

display_name is the resolved human name and is never null.

canonical_name is the name MegaMek looks the equipment up by, and it is the column to match on when the question is "what would MegaMek's own filter return". The two spellings are not the ones you would guess: MegaMek's internal name for the Atlas's autocannon is Autocannon/20 and its display name is AC/20, and a single .mtf uses each in a different place — AC/20 in the Weapons: block, Autocannon/20 in the critical slots.

Clan names. A dual-named design is chassis (clanname) model — Black Hawk (Nova) Prime, Mad Cat (Timber Wolf) B. 434 units have one, and treating the chassis as the whole name loses all of them.

Licence #

Code here is this repo's. The unit data it reads is MegaMek's, under CC BY-NC-SA 4.0 — non-commercial and share-alike, which constrains redistributing a built database.