diff --git a/TODO.md b/TODO.md
index 19eeda2..ed994de 100644
--- a/TODO.md
+++ b/TODO.md
@@ -596,6 +596,18 @@ method are already in the repo.
into the slot's player, or handed to the client the way the lounge's
own "Load Unit List" works — and validation, since a manifest URL is
trusted but the file it stages was authored by a player.
+- [ ] **The portrait prune breaks two scenarios.** `megamek/prune-data.sh`
+ removes `data/images/portraits/{Male,Female}` on the stated grounds that
+ "a scenario that does name one degrades to the default rather than
+ failing". That holds for `Portrait.getBaseImage()` and not for the path
+ an MMSVersion 2 scenario takes: a `messages:` block naming a portrait
+ goes through `MessageDeserializer`, which throws. Kell Hounds'
+ DeathOfTheLegion and LoweringTheBoom do not load in this image for that
+ reason, and both load in a stock release. Found by
+ `tests/scenario-load.sh`. The fix is either keeping the ~30 portraits the
+ shipped scenarios name, or accepting it and correcting the comment - but
+ 252MB is the largest single block in the image, so measure before
+ keeping anything.
- [ ] **Maps from the manifest.** `scenario.content` now carries a whole `.mms`,
so a fight nobody baked into the image can be launched - but the boards
that file names still have to be in this image, and a scenario naming one
diff --git a/src/arena/ScenarioReport.java b/src/arena/ScenarioReport.java
new file mode 100644
index 0000000..9eab765
--- /dev/null
+++ b/src/arena/ScenarioReport.java
@@ -0,0 +1,180 @@
+package arena;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import megamek.common.Player;
+import megamek.common.board.Board;
+import megamek.common.compute.Compute;
+import megamek.common.game.IGame;
+import megamek.common.game.InGameObject;
+import megamek.common.interfaces.IStartingPositions;
+import megamek.common.loaders.MekSummaryCache;
+import megamek.common.scenario.Scenario;
+import megamek.common.scenario.ScenarioLoader;
+import megamek.common.units.Entity;
+
+/**
+ * Load scenarios the way a match does, and print what is in them as JSON.
+ *
+ *
Two jobs, and the first one is the point: a scenario that loads here is a
+ * scenario {@link MatchHost} can launch. Every way an {@code .mms} can be wrong
+ * - a unit name no cache entry matches, a board file this image does not have,
+ * a faction line that parses to nothing - fails here, in a second, instead of
+ * failing a player's match. Anything generating scenarios has to be able to ask
+ * that question before a player is told the fight exists.
+ *
+ *
The second job is battle value. BV is not in the {@code .mms} file; it is
+ * computed from the unit and its pilot's skills, so the only honest way to know
+ * what a side is worth is to load the scenario and ask MegaMek. That number is
+ * what a daily challenge is scored against, and what the site shows before
+ * anybody commits to the fight.
+ *
+ *
+ * ScenarioReport data/scenarios/TrainingScenarios/1-FirstRun.mms
+ * ScenarioReport $(find data/scenarios -name '*.mms')
+ *
+ *
+ * Always a JSON array, one entry per file in the order given, so one run
+ * covers a whole library: the unit cache takes far longer to warm than a
+ * scenario takes to load, and a JVM per file turns a second of work into
+ * minutes. A file that does not load gets an {@code error} entry rather than
+ * ending the run, and the exit status is non-zero if any did - which is what
+ * makes this usable as a build gate.
+ */
+public final class ScenarioReport {
+
+ private ScenarioReport() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ if (args.length == 0) {
+ System.err.println("usage: ScenarioReport [ ...]");
+ System.exit(2);
+ }
+
+ // The same warm-up MatchHost does. Entity construction reads the unit
+ // cache, so a scenario loaded before it is ready fails on units that
+ // are in fact present.
+ Compute.d6();
+ MekSummaryCache cache = MekSummaryCache.getInstance();
+ while (!cache.isInitialized()) {
+ Thread.sleep(250L);
+ }
+
+ List reports = new ArrayList<>();
+ boolean failed = false;
+ for (String arg : args) {
+ File file = new File(arg);
+ try {
+ reports.add(report(file));
+ } catch (Exception e) {
+ failed = true;
+ // The loader's own message: it is the one that says which unit
+ // or which board file is the problem.
+ String message = e.getMessage() == null ? e.toString() : e.getMessage();
+ reports.add(" {\"file\": " + quote(file.getPath())
+ + ", \"error\": " + quote(message) + "}");
+ System.err.println("[report] " + file + ": " + message);
+ }
+ }
+
+ System.out.println("[\n" + String.join(",\n", reports) + "\n]");
+ if (failed) {
+ System.exit(1);
+ }
+ }
+
+ private static String report(File file) throws Exception {
+ if (!file.isFile()) {
+ throw new IllegalArgumentException("no such scenario");
+ }
+ Scenario scenario = new ScenarioLoader(file).load();
+ IGame game = scenario.createGame();
+
+ StringBuilder json = new StringBuilder();
+ json.append(" {\n");
+ json.append(" \"file\": ").append(quote(file.getPath())).append(",\n");
+ json.append(" \"name\": ").append(quote(scenario.getName())).append(",\n");
+ json.append(" \"description\": ").append(quote(scenario.getDescription())).append(",\n");
+ json.append(" \"singlePlayer\": ").append(scenario.isSinglePlayer()).append(",\n");
+
+ Board board = game.getBoard();
+ json.append(" \"board\": {\"width\": ").append(board.getWidth())
+ .append(", \"height\": ").append(board.getHeight()).append("},\n");
+
+ json.append(" \"factions\": [\n");
+ List players = game.getPlayersList();
+ for (int i = 0; i < players.size(); i++) {
+ Player p = players.get(i);
+ // getStrength() is the unit's current battle value, which for a
+ // freshly loaded scenario is its full one - unless the file
+ // damaged it on purpose, in which case this is still the right
+ // number.
+ List owned = game.getInGameObjects().stream()
+ .filter(o -> o.getOwnerId() == p.getId())
+ .toList();
+ List units = new ArrayList<>();
+ int bv = 0;
+ for (InGameObject o : owned) {
+ int unitBv = o.getStrength();
+ bv += unitBv;
+ String name = (o instanceof Entity e) ? e.getShortName() : o.generalName();
+ // The year the design was introduced, which is the only fact
+ // in here that says when a fight is set. Anything picking an
+ // era for a scenario should read this rather than infer one
+ // from the unit's name.
+ String year = (o instanceof Entity e) ? String.valueOf(e.getYear()) : "null";
+ units.add(" {\"name\": " + quote(name)
+ + ", \"year\": " + year
+ + ", \"bv\": " + unitBv + "}");
+ }
+ json.append(" {\"name\": ").append(quote(p.getName()))
+ .append(", \"team\": ").append(p.getTeam())
+ .append(", \"start\": ").append(quote(startingPosition(p)))
+ .append(", \"bv\": ").append(bv)
+ .append(", \"units\": [\n").append(String.join(",\n", units))
+ .append(units.isEmpty() ? "" : "\n").append(" ]}");
+ json.append(i + 1 < players.size() ? ",\n" : "\n");
+ }
+ json.append(" ]\n }");
+ return json.toString();
+ }
+
+ /**
+ * The deployment edge as the scenario file spells it. The scenario carries
+ * a name ("S", "NW"); MegaMek stores the index it resolved to, so this
+ * turns it back.
+ */
+ private static String startingPosition(Player p) {
+ String[] names = IStartingPositions.START_LOCATION_NAMES;
+ int i = p.getStartingPos();
+ return (i >= 0 && i < names.length) ? names[i] : "Custom";
+ }
+
+ private static String quote(String s) {
+ if (s == null) {
+ return "null";
+ }
+ StringBuilder out = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"' -> out.append("\\\"");
+ case '\\' -> out.append("\\\\");
+ case '\n' -> out.append("\\n");
+ case '\r' -> out.append("\\r");
+ case '\t' -> out.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ out.append(String.format("\\u%04x", (int) c));
+ } else {
+ out.append(c);
+ }
+ }
+ }
+ }
+ return out.append('"').toString();
+ }
+}
diff --git a/tests/run.sh b/tests/run.sh
index d6a4b29..78d2640 100755
--- a/tests/run.sh
+++ b/tests/run.sh
@@ -112,6 +112,11 @@ if want compile; then
check "arena.jar compiles against stock MegaMek" \
env OUT_JAR=/tmp/arena-test.jar ./src/build-jar.sh
check "benchmark harness compiles" ./tests/build.sh
+ # After the jar, because it loads every scenario with it. One JVM for the
+ # whole library: warming the unit cache costs far more than loading a
+ # scenario does.
+ check "every scenario in the image loads, or is known not to" \
+ env ARENA_JAR=/tmp/arena-test.jar ./tests/scenario-load.sh
if [ -n "${PATCHED_JAR:-}" ] && [ -f "$PATCHED_JAR" ]; then
check "patched jar differs from stock in exactly the patched classes" \
./megamek/verify-patches.sh
diff --git a/tests/scenario-load.sh b/tests/scenario-load.sh
new file mode 100755
index 0000000..ba923e4
--- /dev/null
+++ b/tests/scenario-load.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+# Load every scenario in the image and check the set that fails is the set we
+# know about.
+#
+# MM_HOME extracted MegaMek release, with data/scenarios and MegaMek.jar
+# JAVA_HOME a JDK
+# ARENA_JAR arena.jar; built into a temporary file if unset
+#
+# Why a gate and not a fix: a scenario that does not load is a launch failure
+# for whoever picked it, and eleven of the fifty-one in this image do not load
+# today - some because upstream ships a scenario naming a board it does not
+# ship, some because of what megamek/prune-data.sh removes. Neither is fixable
+# here in passing. What is fixable is finding out when the set changes: a
+# MegaMek bump that breaks a twelfth, or a prune that breaks one, should fail
+# this build rather than a player's match.
+#
+# The list is exact in both directions. A scenario that starts loading is as
+# much of a signal as one that stops - it means upstream fixed something and
+# the entry should go.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+KNOWN="$ROOT/tests/scenarios-known-bad.txt"
+
+: "${MM_HOME:?MM_HOME is required}"
+: "${JAVA_HOME:?JAVA_HOME is required}"
+[ -f "$KNOWN" ] || { echo "ERROR: no known-bad list at $KNOWN" >&2; exit 1; }
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+JAR="${ARENA_JAR:-}"
+if [ -z "$JAR" ]; then
+ JAR="$TMP/arena.jar"
+ OUT_JAR="$JAR" CLASSES="$TMP/classes" "$ROOT/src/build-jar.sh" >/dev/null
+fi
+
+CP="$MM_HOME/MegaMek.jar"
+for jar in "$MM_HOME"/lib/*.jar; do CP="$CP:$jar"; done
+CP="$CP:$JAR"
+
+# Relative paths, run from MM_HOME: MegaMek resolves its data directory against
+# the working directory, and the report names each file back as it was given -
+# which is what the known-bad list holds.
+mapfile -t SCENARIOS < <(cd "$MM_HOME" && find data/scenarios -name '*.mms' | sort)
+[ "${#SCENARIOS[@]}" -gt 0 ] || { echo "ERROR: no scenarios under $MM_HOME/data/scenarios" >&2; exit 1; }
+
+# Non-zero exit just means something failed to load, which is what is being
+# measured; the report is on stdout either way.
+( cd "$MM_HOME" && "$JAVA_HOME/bin/java" -Djava.awt.headless=true -cp "$CP" \
+ arena.ScenarioReport "${SCENARIOS[@]}" ) > "$TMP/report.json" 2>"$TMP/report.err" || true
+
+python3 - "$TMP/report.json" "$KNOWN" "${#SCENARIOS[@]}" <<'PY'
+import json, sys
+
+report_path, known_path, expected_count = sys.argv[1], sys.argv[2], int(sys.argv[3])
+
+try:
+ with open(report_path) as fh:
+ reports = json.load(fh)
+except (OSError, ValueError) as exc:
+ sys.exit(f"ERROR: the report is not readable JSON ({exc}); the run itself failed")
+
+if len(reports) != expected_count:
+ sys.exit(f"ERROR: reported on {len(reports)} scenarios, expected {expected_count}")
+
+failed = {r["file"] for r in reports if "error" in r}
+reasons = {r["file"]: r["error"] for r in reports if "error" in r}
+
+with open(known_path) as fh:
+ known = {
+ line.strip()
+ for line in fh
+ if line.strip() and not line.lstrip().startswith("#")
+ }
+
+new = sorted(failed - known)
+fixed = sorted(known - failed)
+
+for path in new:
+ print(f" BROKE {path}\n {reasons[path]}")
+for path in fixed:
+ print(f" FIXED {path} now loads; drop it from {known_path}")
+
+if new or fixed:
+ sys.exit(f"{len(new)} newly broken, {len(fixed)} no longer broken")
+
+print(f" {len(reports) - len(failed)}/{len(reports)} scenarios load; "
+ f"{len(failed)} known-bad, unchanged")
+PY
diff --git a/tests/scenarios-known-bad.txt b/tests/scenarios-known-bad.txt
new file mode 100644
index 0000000..def0e36
--- /dev/null
+++ b/tests/scenarios-known-bad.txt
@@ -0,0 +1,38 @@
+# Scenarios in this image that do not load, and why. tests/scenario-load.sh
+# checks this list is exactly the set that fails: a scenario that starts or
+# stops loading fails the build until this file is updated to match.
+#
+# Established against mm0.51.0-sur26.4.7 on 2026-08-15.
+#
+# None of these are reachable through headquarters' catalog, which is curated
+# by hand; the point of the list is that nothing new joins it unnoticed.
+
+# --- boards upstream does not ship ------------------------------------------
+# The scenario names a board file that is in no MegaMek release. Nothing on our
+# side can fix these; they are broken in stock MegaMek too.
+data/scenarios/DownOnOurLevel/DownOnOurLevel.mms
+data/scenarios/FistAndDragon.mms
+data/scenarios/Lucho/Citadel_Blitz_multiplayer.mms
+data/scenarios/Lucho/Citadel_Blitz_oneonone.mms
+data/scenarios/Lucho/Last_Stand_at_Guanabara.mms
+data/scenarios/Lucho/Uninvited_Guests.mms
+
+# A board named with a backslash - "Map Set 3\16x17 Rolling Hills 2" - which
+# resolves literally on Linux and so names a file that cannot exist.
+data/scenarios/OntheProwl/OntheProwl.mms
+
+# --- portraits megamek/prune-data.sh removes --------------------------------
+# Both are MMSVersion 2 scenarios whose `messages:` blocks embed a crew
+# portrait, and MessageDeserializer throws when the image is missing rather
+# than falling back. The prune's own comment says a scenario naming a portrait
+# "degrades to the default rather than failing", which is true of
+# Portrait.getBaseImage() and not of this path. See TODO.md.
+data/scenarios/Kell Hounds/DeathOfTheLegion/DeathOfTheLegion.mms
+data/scenarios/Kell Hounds/LoweringTheBoom/LoweringTheBoom.mms
+
+# --- a different game -------------------------------------------------------
+# Strategic BattleForce scenarios. createGame() builds a Total Warfare game and
+# casts the options, which SBFRuleOptions is not. Not a bug on our side and not
+# a game arena hosts.
+data/scenarios/Examples/SBFTestScenario/TestSBF.mms
+data/scenarios/Examples/SBFTestScenario/TestSBF2.mms