From 13a42b653dee30935c9b73822887e0a942ee8167 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 14:50:34 -0400 Subject: [PATCH] feat(unit-library): helm art, the mapping as JSON Resolves 10,623 of 10,988 designs to 5,711 files against 0.51.0, every Mek among them. The bytes stay MegaMek's to copy: the output says what a sync is expected to carry and what the set names that the release does not ship. --- CLAUDE.md | 4 ++ README.md | 29 +++++++++ TODO.md | 6 ++ crates/helm-cli/src/main.rs | 126 ++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 976e3d2..f8f1107 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,10 @@ Most of the suite is ordinary `cargo test`. Three things are not: - `tests/performance.rs` is **release only** — an unoptimised build has a different shape, not just a different speed, and is printed rather than checked. `HELM_PERF_BLESS=1` re-records `performance.txt`. +- `tests/mekset_install.rs` in `helm-unitfile` resolves every design against + a real release's own art: `HELM_MEGAMEK= cargo test -p helm-unitfile + -- --ignored`. It is the check that says a bucket sync of the art tree is + complete, so run it when the pinned MegaMek moves. - `crates/helm-wasm/smoke.mjs` drives the wasm boundary from Node, which is the only thing that exercises the interface a browser calls through. Build the module first: diff --git a/README.md b/README.md index 362fc7e..69e59ca 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,35 @@ every design shares. 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. +## 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 ``` diff --git a/TODO.md b/TODO.md index 1fc2290..e511d64 100644 --- a/TODO.md +++ b/TODO.md @@ -899,6 +899,12 @@ not obvious from any one of them. ## Beyond Meks +- [ ] **Generic art defaults for anything that is not a Mek.** `helm art` + leaves 365 designs with no picture — vehicles, infantry, gun + emplacements, aerospace. MegaMek always draws something: `genericFor` + falls back by motion type and tonnage against thresholds this repo has + not read, and inventing them would put a plausible wrong answer in a + column meant to be checkable. Read `MekTileset` and finish the stage. - [ ] **Vehicles and infantry.** The MCP is scoped to Meks and the plumbing is general — `unit_type` is an ordinary facet — so this is a widening rather than a rewrite. What it needs is unit-type-specific facets: bays, doors, diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index 9f19ad4..1c2ba92 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -3,17 +3,20 @@ //! helm build --megamek --out helm.sqlite [--bridge-dir ] //! helm stats //! helm bv-report --megamek --bridge-dir +//! helm art --megamek [--out ] //! helm catalogue --bridge-dir --out //! helm force --megamek --bridge-dir //! //! Argument parsing is by hand. A derive-macro dependency would cost more to //! compile than it saves to read at this size. +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::ExitCode; use helm_core::{Catalogue, ComputedStats, Provenance}; use helm_db::Inputs; +use helm_unitfile::mekset::Via; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -21,6 +24,7 @@ fn main() -> ExitCode { Some("build") => build(&args[1..]), Some("stats") => stats(&args[1..]), Some("bv-report") => bv_report(&args[1..]), + Some("art") => art(&args[1..]), Some("catalogue") => catalogue(&args[1..]), Some("force") => force(&args[1..]), Some("check") => check(&args[1..]), @@ -50,6 +54,7 @@ USAGE helm build --megamek --out [--bridge-dir ] helm stats helm bv-report --megamek --bridge-dir + helm art --megamek [--out ] helm force --megamek --bridge-dir helm check --megamek --bridge-dir helm repairs --megamek --bridge-dir @@ -1260,6 +1265,127 @@ fn print_bench(library: &helm_unitfile::Library, catalogue: &Catalogue) -> Resul /// Same format, same reader, a third of the size: 1.7MB rather than 6.0MB, and /// 88ms rather than 216ms to parse in a browser. That is a page load, so it is /// worth the one command. +/// `helm art` - which picture belongs to which design. +/// +/// The art tree is 6,995 files and says nothing about which design uses which +/// of them; `mekset.txt` is the mapping, and reading it is the only part of +/// serving MegaMek's unit art that is not a file copy. So this writes the +/// mapping and leaves the bytes alone: the sync that puts the tree in a bucket +/// is `aws s3 sync data/images/units/`, and what needs generating is this. +/// +/// `files` is what that sync is expected to carry, and `missing_files` is what +/// the set file names and the release does not ship - a sync that came out +/// short can be told from a release that was short to begin with. +fn art(args: &[String]) -> Result<(), String> { + let opts = parse_opts(args)?; + let megamek = opts.megamek.ok_or("--megamek is required")?; + let version = resolve_version(&megamek, opts.version); + let units_dir = megamek.join("data/images/units"); + if !units_dir.join("mekset.txt").is_file() { + return Err(format!( + "{} holds no mekset.txt - point --megamek at an install, not at its data directory", + units_dir.display() + )); + } + + let set = helm_unitfile::read_mekset(&units_dir).map_err(|e| e.to_string())?; + let (exact, chassis) = set.counts(); + eprintln!("mekset: {exact} exact entries, {chassis} chassis entries"); + + let library = read_library(&megamek)?; + + let mut rows = Vec::new(); + let mut unmatched = Vec::new(); + let mut files: BTreeSet = BTreeSet::new(); + let mut by_via = [0usize; 3]; + + let mut units: Vec<&helm_core::Unit> = library.units.iter().collect(); + units.sort_by(|a, b| a.name.cmp(&b.name).then(a.path.cmp(&b.path))); + + for unit in units { + match set.art_for(unit) { + Some(art) => { + let via = match art.via { + Via::Exact => "exact", + Via::Chassis => "chassis", + Via::Generic => "generic", + }; + by_via[match art.via { + Via::Exact => 0, + Via::Chassis => 1, + Via::Generic => 2, + }] += 1; + files.insert(art.path.to_string()); + rows.push(serde_json::json!({ + "chassis": unit.chassis, + "model": unit.model, + "name": unit.name, + "unit_type": unit.unit_type, + "sprite": art.path, + "via": via, + })); + } + None => unmatched.push(unit.name.clone()), + } + } + + // Named by the set file and not in the release. Reported rather than + // dropped: it is the one failure here that is upstream's rather than + // ours, and a sync cannot tell the two apart on its own. + let missing: Vec<&String> = files + .iter() + .filter(|path| !units_dir.join(path).is_file()) + .collect(); + + let doc = serde_json::json!({ + "megamek": version, + "source": "data/images/units/mekset.txt", + "prefix": "data/images/units", + "counts": { + "units": rows.len(), + "exact": by_via[0], + "chassis": by_via[1], + "generic": by_via[2], + "unmatched": unmatched.len(), + "files": files.len(), + "missing_files": missing.len(), + }, + "units": rows, + "files": files, + "missing_files": missing, + "unmatched": unmatched, + }); + + let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?; + match &opts.out { + Some(out) => { + std::fs::write(out, format!("{text}\n")) + .map_err(|e| format!("{}: {e}", out.display()))?; + println!("wrote {}", out.display()); + } + None => println!("{text}"), + } + + println!( + "{} units: {} exact, {} chassis, {} generic, {} with no art", + rows.len(), + by_via[0], + by_via[1], + by_via[2], + unmatched.len() + ); + println!( + "{} distinct files{}", + files.len(), + if missing.is_empty() { + String::new() + } else { + format!(", {} of them not in this release", missing.len()) + } + ); + Ok(()) +} + fn catalogue(args: &[String]) -> Result<(), String> { let opts = parse_opts(args)?; let bridge = opts.bridge_dir.ok_or("--bridge-dir is required")?; -- 2.51.2