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 — 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
Seven tools: list_facets, find_units, get_unit, aggregate,
resolve_equipment, force_value, 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_unitis 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.mtfand.blkreaders. Parsing one design is pure; reading a whole library is behind thelibraryfeature, which is off for wasm.crates/helm-bridge— a producer ofComputedStats, and the temporary one. Reads what MegaMek computed.helm-bvwill sit beside it and fill the same type by computing it.crates/helm-db— schema and SQLite generation. TakesComputedStatsand cannot tell who produced them, which is what keeps a battle value dependency out of the database layer.crates/helm-cli— thehelmbinary, 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_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.