# TODO State: a match launched by headquarters played through to victory on Fargate (2026-08-03), which exercised the upload path for the first time. The latency work that followed is measured and shipped — [PERFORMANCE.md](PERFORMANCE.md) is the record, and what is still open there is not repeated here. What is weak is everything around the game: end-of-match handling, and the artifacts the finish is supposed to leave behind. ## Verify the port - [x] **`./scripts/test.sh` and `./scripts/build.sh`** — both run clean. - [x] **Suramadu under the jlink runtime.** `jdk.net` was the missing module: without it Jetty bound the port and then threw on every accept(), which presents as a network fault. `tests/module-scan.py` reports what is referenced but absent. - [x] **Pin digests for MegaMek and Suramadu.** All three archives now carry a `*_SHA256` in `versions.env`, taken from a double fetch that agreed with itself and enforced in the Dockerfile the way `JDK_SHA256` is. The source tarball's digest rests on GitHub keeping tag-archive generation stable, which it has committed to; if that ever moves, the build fails naming the line to refresh. - [ ] **The tangled workflow has never run.** `.tangled/workflows/build.yml` is written to the documented schema but no runner is attached, so the schema is unverified. It also assumes the runner provides a Docker daemon. ## Blocked on other repos - [x] **ECR target** exists: infra's `lance-blue/arena` repository. `scripts/push.sh` defaults to that registry; `REGISTRY` overrides. - [ ] **The `/run/arena` tmpfs guarantee does not hold on Fargate.** ECS offers no `linuxParameters.tmpfs` there, so the manifest and player camo land on ephemeral storage. Either the claim weakens to Fargate's encrypted ephemeral storage or init keeps the manifest in memory. - [x] **`diagnostics` has an upload target.** Three things go out under that key: the per-layer latency table, the profiles, and the MegaMek client's own logs. Until headquarters carried the key, `upload()` treated it as having no target and all three died with the container's writable layer. Shipped both sides and seen working in production on 2026-08-08 — `uploaded 2 client log(s)` with no minting warning above it. That line is now unambiguous: with the direct route gone it can only be printed by a presigned PUT that S3 accepted. - [x] **Signed uploads are the only route.** A minted presigned PUT straight to S3 is verified working in production, so the fallback through headquarters' own API is gone — it read whole artefacts into memory on the instance that also serves the API, and as a fallback it could come back silently. A minting failure now fails that upload and keeps the file in the container. Two consequences. Arena requires a headquarters that mints: an upload key without `signUrl` or `signTemplate` is refused by `10-manifest.sh` at container start rather than discovered at finalize. And arena had to ship before headquarters dropped `url`/`urlTemplate`, because an un-updated arena against a manifest with no signing targets has no route at all. Both are deployed as of 2026-08-08, arena first. - [ ] **Manifest signing.** Currently trusted because the URL is presigned and the network path is private. A signature over the document — so arena can verify the control plane authored it independent of transport — is the obvious hardening. ## Container gaps - [x] **End of match no longer dumps players into an empty lobby.** The server reset the game as the VICTORY phase ended - upstream's own FIXME asks why - and that put the client in the lobby with no Princess, no observer and no units, which is what a player saw for the whole linger. Patch 0012 leaves the game standing, so the victory report is still on screen when the host stops the server. Verified locally against the same build with and without it: the same Done on the report gives the empty lobby unpatched and holds the report patched. - [x] **Neither save nag is raised any more.** `AskForVictoryList=false` in clientsettings takes the "Save Units?" dialog. The "Save GIF?" one had to be patch 0013: the preference behind it is the same one that writes the GIF, and answering the dialog deleted the file, so the only reason finalize ever collected a GIF was that nobody answered. A spectated match to victory now ends on the victory report with no dialogs at all, and the GIF is collected. - [x] **Closing the client ends the client, rather than showing the launcher.** `MegaMekGUI.launch()` re-showed its main menu when the client frame closed, which handed a player who had just finished a match a menu with "host a game", "connect to a game" and three file choosers in it - a saved game, the map editor, the skin editor - each browsing the container's own filesystem, where the launch manifest lives. Patch 0014 exits the JVM instead, and takes the "you have become disconnected" alert with it: `disconnectQuietly` defaults on now, since upstream's setter for it is called from nowhere and the only disconnect that happens here is the host stopping at the end of a match. - [ ] **The menu bar is the same surface, during the match.** The launcher is shut, but `CommonMenuBar` still carries File > Save Game As, Load Unit List and Save Unit List while the game is on, and each opens a chooser rooted in the container. Decide whether the client should have a File menu at all here; that is the same question as the read-only save paths below, and it wants answering once. - [x] **The page says the match is over itself.** `suramadu/web/report.js` polls headquarters' `/api/matches//result` and, when the result of the match exists, covers the page with a report of it and hides the client - so Suramadu's "connection to the server is lost" card, built inside `.suramadu-element` when the container stops, is built inside something that no longer paints. No war patch and no `customization` seam: the dialog is left exactly as it is and simply never seen. `result.json` carries `victoryTeam` and `victoryPlayerId` now, because a forced victory leaves the loser's units standing and a report inferring the winner from the counts would get that case backwards. - [x] **The report hands the player on to the match's page.** The screen at the end of a match is the verdict, the forces, and three seconds - then the match's own page takes over, which is headquarters' to draw. No controls: the full report and the composer are on that page, and a screen inside a container that is about to be deleted should not offer a worse copy of somewhere the player is already going. It does not wait to be dismissed, either: a container being deleted is not a place to keep anybody. - [x] **The result says what happened to each machine, not just how many.** `src/arena/UnitReport.java` reads the live game at victory and writes a `units` array per player: every location's armour, rear armour and structure against what it started with, every critical slot with what is in it and whether it was hit, the crew and its wounds, and what became of the machine - destroyed, withdrawn, salvage, ejected. Meks carry their slots; every other kind reports locations and status, because naming a vehicle's system slots means inventing a table. Every unit type reports its slots, not only Meks - a vehicle carries twenty-five a location - and each also carries what it was built with: its equipment list, a vehicle's own criticals (locked turret, motive damage, driver and commander hits, stabilisers) and a platoon's remaining troopers. This is the arena half of headquarters' `plan/after-action.md`; the drawing is headquarters', on the match's own page. - [x] **The report arrives in about a second, not about ten.** `watch/results.sh` writes the result into the staged web folder as `decided.json` before it uploads anything - 348ms from the host writing it to the page being able to fetch it, measured in the container - and `report.js` polls that a second at a time with the S3 read demoted to a fifteen-second fallback. The five-second settle now covers only the images it was always for. There is no event to use instead. Suramadu forwards a window title when it creates the window and not when it changes, so MegaMek relabelling its frame "Victory phase" on every phase change never reaches the browser: a probe on `base.processMessage` saw exactly one title across six rounds of a live match. If a Suramadu bump ever starts sending them, that is the zero-latency signal and this poll can go. - [ ] **A reload after the container stops has nothing to show.** The report lives in the page the container serves, so refreshing it once the task is gone gets the proxy's 502 rather than the match's result. The durable copy is in S3 and headquarters can already read it; what is missing is a screen on the site that does. - [ ] **Save dialogs write into the read-only image tree.** No longer raised at the end of a match - both nags are off - but still reachable from the menus. "Save Unit List" defaults to `/opt/arena/megamek/TraineeA.mul` and the summary GIF export to `/opt/arena/megamek/combat_summary_*.gif`; both fail with Permission denied. Point MegaMek's save paths at the tmpfs (the same treatment userdata and data/images/temp already get), and decide whether a player-initiated save should reach them at all - a file inside the container is lost when the task exits, so it may want to go through the upload path instead. - [x] **End-of-game images are proven.** `container/exit/finalize.sh` collects what MegaMek's own `GameSummary*` preferences write per round. The per-round minimap PNGs are produced despite `MinimapEnabled=false` - a local spectated match to victory wrote one per phase - and the animated GIF stitched from them survives to finalize now that patch 0013 has stopped the dialog that deleted it. What nobody has decided is whether any of them is worth the ~18% of client EDT time they cost (PERFORMANCE.md); that is a product call, not an open question about the mechanism. - [x] **The result no longer waits for the container to stop.** `container/watch/results.sh` sends the result and the end-of-game images when the host writes the game-over marker, which is a minute before finalize used to see them and in a moment when nothing else is happening. `container/lib/collect.sh` holds both collectors and a `state/sent/` marker per artifact, so `exit/finalize.sh` runs the same two and re-sends only what did not land. Everything that is *not* complete at victory - the latency table, the client's logs, the profiles - still goes at exit, because that is when the JVMs stop writing them. - [x] **Turn reports are not uploaded.** The observer spooled one text dump a phase and `watch/turn-reports.sh` sent every one to `derived/turns/` - 55 files and 831KB for the match that first proved the path works, half of it consecutive duplicates, for an AppView that would show a match in progress and does not exist. Nothing has ever read one: headquarters reads the result and the board and nothing else. What they stood in for is now kept properly, which is what made the question answerable - `raw/game_actions_N.tsv` is the same match structured, at 27.7KB against 831KB, and `raw/gamelog.html` is MegaMek's own account of it. The observer still runs: MatchHost leaves it unflagged so an all-human match has one not-done player holding the game shut, and its spool is still written for anyone debugging a local run by eye. If a live feed is wanted later it wants designing as one, not as per-phase objects in a bucket that expires at 30 days. - [ ] **Kill attribution is not computed anywhere yet.** "Kills" today would mean "enemy units that stopped existing", which counts ammo explosions, falls and a machine that walked into a chasm. Two sources could say who actually did it, and neither is wired up. MegaMek tracks it per unit (`Entity.getKillerId`), which `UnitReport` does not emit - one field, and the engine's own answer, recorded when it happened. Or it can be derived from `raw/game_actions_N.tsv`, which carries attacker-to-target attack rows and per-phase unit state, and is now kept for every match. What is undecided is where the deriving lives. In the container it would have to run at victory, on two cores, over a file that grows with the match - a long one is megabytes of TSV and the parse is not free at the moment a player is waiting to be handed on. In headquarters it can run off S3, be re-run when the logic improves, and never delay anybody; but that is a parser in a second language against a format upstream owns. The field and the derivation are also not exclusive: the field is evidence and the derivation is a claim, and a record could carry both. Deliberately parked until matches are on-lexicon, because what the numbers are *for* decides where they should be computed. - [x] **Artifacts are filed by what they are.** `launch/` is what the match was given - the staged `scenario.mms` and `identity.json`, both sent at init, so a match that never reaches victory still says what it was and who was in it. `raw/` is MegaMek's own output kept byte-for-byte, which now includes the things that used to be deleted with the container: the action log, the round report, the end-of-game sheets and the MULs. `derived/` is what arena made of it, and `derived/socials/` the pictures we picked out of it: the board at victory, chosen from the thirty-one MegaMek rendered, is the only one so far. The other thirty are no longer uploaded - one is read and thirty are not, at 3.3MB each - and neither are the per-phase minimap frames, which are the GIF's own. headquarters offers both layouts in the manifest, so this image runs against a control plane that has not deployed them yet. - [x] **Every match leaves a result.** `MatchHost` keeps `state/result.json` current from the first tick of the game and rewrites it at victory, so a match that is abandoned, times out on `watch/idle.sh`, is signalled or is killed has one too. `ending` is what tells the two apart - `victory` or `no-victory-at-exit` - and an undecided match carries no winner: `victoryTeam` and `victoryPlayerId` stay at MegaMek's own "nobody" values rather than being inferred from what is left standing. A snapshot rather than a shutdown hook, because `exit/finalize.sh` runs from the entrypoint's trap before the JVMs are signalled and a SIGKILL runs no hook at all. - [x] **The result says when the match ran and what was played.** `startedAt` is the container's own clock read at the top of `MatchHost.main`, `endedAt` is read as the document is built - the victory on a decided match, the last thing the host saw on one that stopped without being decided. `scenario` carries the name the scenario file gives itself, which of the manifest's two forms it arrived in, and the library path it was picked from when it had one. headquarters could derive some of this from the launch row; it is written here so the document a player is handed is complete on its own. - [ ] **The rest of the artifacts still ride the shutdown.** That window is a SIGTERM and a bounded wait on Fargate, and a diagnostic that does not finish inside it is gone. The logs could be shipped in rolling chunks and the stats table could be summarised at game-over from the log so far; neither is worth doing until one has actually been lost. - [ ] **Multi-human matches** are expressible in the manifest and handled by `MatchHost`, and every human seat now renders its own Suramadu app at `/` — cloned at render time from the template app with only the path, name and login changed, then the template is deleted, so the per-identity paths are the only clients the container serves. Keyed by DID because a DID is public and unique, and because it points where this wants to go: the container checking the visitor's identity itself — a proof bound to the DID the path names — rather than trusting whatever the proxy let through. Spectator manifests render `/ArenaSpectator`. What is left: headquarters' proxy matching caller DID to path DID (a string compare on its side), the container-side identity check above, and actually exercising two browsers in one container — reconnection is where the spike said it breaks. Until a second browser has been through a match, treat this as plumbing, not a feature. - [x] **Seats fight under their handles.** `20-identity.sh` writes `names.tsv` — `@handle` per slot when the manifest carries one, the slot name otherwise, `name (slot)` on a collision — and `MatchHost --names` renames the scenario's players at load, before the server binds. The lobby and reports show `@permadeath.com` and `@lance.blue` instead of `TraineeA`/`TraineeB`. `--humans` and the camo index stay slot-named and are translated; `result.json` gained a `slot` field so attribution still speaks the manifest's key. An old manifest with no handles renames nothing. Not yet run against a live match. - [x] **Idle timeout.** `limits.idleTimeoutSeconds` is read now, and "idle" means "no sign of play": the host writes `state/host.activity` on every join or return, round or phase advance, and unit turn, and `container/watch/idle.sh` emits `idle-timeout` and sends the entrypoint the same SIGTERM Fargate would when the timestamp goes stale by the limit - so the shutdown finalizes and the artefacts still upload. One rule covers the empty lobby, the lounge nobody starts, and the match everyone left or stopped playing; game-over disarms it, and a manifest without the limit is unaffected. Neither half has been seen firing on Fargate yet. headquarters' `docs/launch-manifest.md` still describes the event as "no player joins" and needs the wider wording. - [ ] **Reconnection.** The host re-arms when a player drops, but Suramadu keeps the client JVM alive after the page closes, and the spike found that reconnecting to a live session is where things break. A live match hit it on 2026-08-17: a frozen client could not rejoin, and the dialog was "There are too many active connections right now, please try again later." That part is understood and fixed — `maxClients` defaults to 1 and Suramadu tests it in `connectView` before it tries to reconnect anything, so a stale socket refused the player their own seat until it timed out; the seat now allows a takeover (see `_comment_maxClients` in `suramadu/suramadu.config.template`). What is still untested is the half below Suramadu: whether MegaMek itself picks up a seat whose client was replaced mid-game, and whether the host's re-arm interacts with a takeover. Needs a two-browser match where one side is killed and comes back. - [ ] **The raw stats stream still floods CloudWatch.** finalize already summarises the `stats,` lines into `stats-summary.txt` and uploads it, but the lines themselves also go to stdout — 6,600 of them in the first PvP match, most of the log group's volume and cost. Route the stats logger to a file in the state dir, upload it under `diagnostics/` beside the summary, and leave lifecycle lines as the only live stream. The deterministic fetch already exists because artifacts are keyed by match id; a ten-line `matches/artifacts.sh` in infra beside `logs.sh` wraps it. Low priority: the uploaded summary already answers most questions, this is about log cost and grep-ability. - [ ] **The watcher could live in the host JVM.** MatchWatcher is a MegaMek `Client` in a process of its own; Princess proves in-process clients work. Folding it into MatchHost saves one JVM's boot and memory per match and removes a moving part, and nothing depends on the separation. Low priority — a quarter-GB and a few boot seconds per match. If the "Princess in its own process" proposal ever lands, that is the moment to reconsider both placements together. - [ ] **An arm64 image would cut every Fargate match-hour by about 20%.** Graviton pricing; the JDK and MegaMek run fine on aarch64 and the api host is already Graviton, so the ground is familiar. versions.env pins the x64 JDK archive and the Dockerfile follows it; an arm variant needs its own pins, a build, and one live match to prove Suramadu's native corners behave. Low priority until match volume makes the compute line matter — the saving is a percentage of a small number. - [ ] **Structured turn reports.** `MatchWatcher` spools MegaMek's report strings verbatim, HTML and all — they are the Swing pane's presentation layer. The right seam is the `Report` objects behind them (`Game.getAllReports()`), which is what the lexicons will want. ## Render performance Rendering was sluggish; the shipped fixes and what each one measured are in [PERFORMANCE.md](PERFORMANCE.md) - median downpress-to-pixels roughly halved and the p90 tail fell from ~470ms to ~330ms. The proxy was ruled out early (the api host stayed under 5% CPU during a match). What is still open: - [ ] **Nothing here has ever been profiled.** Every finding above and in PERFORMANCE.md was deduced from source plus aggregate latency, not from a flame graph. async-profiler 4.5 now ships in the image (~290KB of the pull) and loads into both the client JVM and the match host when the task sets `ARENA_PROFILE=1`; unset, neither command line changes and `tests/shell/test-profiling.sh` is what keeps that true. The client is wall clock, because the EDT question is a latency question and a blocked EDT costs a player exactly what a busy one does. The host is `ctimer`, because the question there is whether Princess takes cores from rendering and `-e cpu` means perf_events, which Docker's default seccomp profile blocks. Both dump collapsed stacks to `$ARENA_STATE/profile-*.collapsed`, which `exit/finalize.sh` uploads under a `diagnostics` key. *Verified 2026-08-07.* The agent loads under the jlink-trimmed runtime on both JVMs, and a SIGTERM produced 12447 client stacks and 1483 host stacks, collected by finalize along with the stats table. The first profile confirms by measurement what the DirectDraw item below argues from source. Of the 8407 EDT samples that were running rather than waiting, 85% are under `BoardView` painting, 76% under DirectDraw and **72% under PNG encode and pixel hashing** - and `libzpng` alone is 43% of the EDT's whole working time. The hottest working stack is `BoardView.drawHexes -> drawHex -> AbstractVectorGraphics.drawImage -> WebGraphics.writeImage -> DrawInstructionFactory.drawImage -> ImageConst. -> ImageServiceImpl.getPngImage -> ZPNG.encode`. That is the encode-in-the-constructor path, on the EDT, inside paint. `tests/profile-summary.py` is what produces those numbers. (An earlier reading of the same profile said 79% and 53%. It was wrong: the split between waiting and working excluded parked stacks but the bucket totals did not, so the denominators disagreed.) Two caveats on that run: the match never left the lobby, so these are ambient costs rather than interaction costs, and the host was busy with an unrelated build, so `latencyClientRendering` from the same run is not usable. A run that plays is what settles the top two items below. A match that plays itself now exists for exactly this - `arena.sh up --spectate`, Princess on every faction and the browser watching as `ArenaSpectator`. *Written, not verified:* the MatchHost side has never been compiled or run, because no build has happened since. The next profile should be taken from a spectated match, not a lobby. Still open: `scripts/build.sh` does not pass the four async-profiler build args - the Dockerfile carries them as defaults and the test fails if those drift from versions.env, but they belong in build.sh. A short run can also finish before the client JVM has a session to flush, and then only the host profile is collected, with nothing said about the missing one. - [ ] **Game-summary images cost ~18% of the EDT's working time.** Measured on the first spectated profile (2026-08-07): `BoardView$1.gamePhaseChange` calls `ImageIO.write` on every phase change, on the EDT, because we set `GameSummaryBoardView` in `megamek/clientsettings.xml.template`. That is 1541 of 8383 running EDT samples going into PNGs nobody has looked at - and the sibling `GameSummaryMinimap` is the one already doubted above, since `MinimapEnabled` is false. It is also a second PNG encoder: the summary goes through the JDK's `com.sun.imageio.plugins.png.PNGImageWriter`, not Suramadu's native ZPNG, so the two compete for the same EDT. Caveat before acting: a bot-vs-bot match changes phase far more often per minute than a human one, so 18% is the fast end. The fix is to decide whether these images are wanted at all - `container/exit/finalize.sh` collects them - and if they are, whether they can be written off the EDT. - [ ] **Frame tearing.** A white or half-drawn band at the board's top edge, phase-independent, survives patches 0006 and 0007 (0006 confirmed effective on the 2026-08-05 retest). MegaMek's paint paths audit clean: all three image loaders block until full decode (`ImageUtil.waitUntilLoaded`, with a silent 10s give-up as the only hole), composites are built from finished `BufferedImage`s on the EDT, the top strip is the one-hex board pad (`BoardView` translate(HEX_W, HEX_H)) fully covered by the background tiling loop, overlays compose within `draw()`, and after 0007 no off-EDT painter remains. The evidence points at Suramadu's frame pipeline: the dispatcher thread (`drawDelayMs` tick) extracts the DirectDraw frame while `paintComponent` is mid-flight, and the incremental damage protocol never re-ships the strip once the damage was consumed. Discriminator for the next Docker test: once corruption appears, force damage in that region (hover a unit there, or Ctrl+K twice to toggle the keybinds overlay). If the band heals it is the frame-extraction race - upstream, and out of scope short of patching the app-toolkit jar. If it does not heal, a DirectDraw constant is poisoned and a MegaMek-side mutation vector still exists. - [ ] **Full-repaint frames still reach ~1MB**, which is the floor of the latency tail now that transport is no longer the limit. A board repaint costs ~105ms against ~26ms for a round trip that repaints nothing, and repainting by hex instead has been measured and rejected - Swing unions all damage on a component into one rectangle, so 480 of 500 repaints dirty the whole viewport anyway. PERFORMANCE.md has the numbers; do not retry it without a new argument. What is left reduces the work rather than the area: capping the client canvas resolution (fewer pixels per frame beats every other optimization), a max-FPS cap, and the two items below. JPEG quality is not a knob - DirectDraw encodes PNG only. - [ ] **The board background is tiled from a 1008px image.** Every frame that draws it pays a ~1Mpx PNG encode and pixel hash that the DirectDraw cache then throws away, plus a 4MB byte[] that G1 treats as a humongous allocation - and humongous allocation triggered 17 of 23 concurrent-mark cycles in the match log, each 0.8-3.0s on a 2-vCPU task. Turning tiling off in `lanceBlueSkin.xml` costs no code. Unmeasured, but it is the largest single image in the per-frame budget and it is one we added. - [ ] **DirectDraw re-encodes every image on every draw.** `ImageConst`'s constructor PNG-encoded and hashed the image before `DrawConstantPool` compared the hash, so a cache hit threw the encode away - on the EDT, during paint. We have no source for the class, so this is a javaagent (`instrument/lazy-png-agent/`) rather than a Suramadu patch: it rewrites `ImageConst` at classload with ASM so the encode happens lazily, in `toMessage()`, and only on a cache miss. `ImmutableDrawConstantHolder`'s inherited `value` field turned out to be `final` and unwritable outside its own constructor - confirmed by the JVM refusing the naive rewrite with `IllegalAccessError` at invoke time - so the cached bytes live in a field the agent adds instead, and `value` is left permanently `null` (safe: nothing but `toMessage()` reads it - see `DrawConstantPool`). Written and wired, but **off by default** behind `ARENA_LAZY_PNG=1`, so this stays open. Two things are missing and they are the same thing: the 72% figure above came from a profile of the *lobby*, and no client wall-clock profile has ever been collected during play - two attempts produced none - so nothing says what the transform is worth, or that the encode is still where the EDT goes once the frame path is warm. Until that A/B exists an unmeasured classload rewrite of the path that encodes every frame does not belong on every player's client, and while "intermittent frame tearing" is open it would confound that investigation as well. `instrument/lazy-png-agent/test/HarnessMain.java` is the correctness check - it runs the transform against the real `ImageConst` out of `suramadu-directdraw-swing-*.jar` and asserts the constructor stashed the source image and did *not* fill the byte field - and it is not in the build gate. Wiring it in wants the war's directdraw jars in the `lazy-png-agent` stage; do that with the A/B, when the transform has to be trusted rather than merely available. - [ ] **The ack gate sets a cadence floor, and nobody has measured it.** Suramadu waits for the browser's ack before sending the next frame, which puts a floor under how often a playing client can be updated - about 33ms here, and it scales with the player's round trip rather than with ours, so a distant player pays more than a local measurement ever shows. `webswing.paintDispatcherClass` is the supported seam for replacing that policy, and `webswing.paintAckTimeout` (5000ms) is the stuck-client valve the gate exists to feed. Two frames in flight is the obvious thing to try. Untested: the shipped series never touched it, and every latency figure in PERFORMANCE.md was taken against a 20ms netem link, which is the condition least likely to show this. - [ ] **A higher PNG level may win at first paint, having lost during play.** Raising ZPNG's compression level was measured under play and reverted - p90 85ms to 470ms, p99 133ms to 1,547ms - because the encode lands on the EDT while a player is waiting on it. First paint is a different regime: the client is otherwise idle, the board is the one large transfer (~465-609KB in a single frame locally, ~3.9MB in production), and the trade that loses during a turn may pay there. It needs the level chosen per-frame rather than per-build, so it is not a one-line experiment. `scripts/perf/startup.py` is the measurement. - [ ] **MegaMek renders more than it needs to.** Antialiasing, unit shadows, incline graphics and soft-center scrolling all multiply repaint cost; `GUIPreferences` can turn them off, and the theme work already establishes how to bake preferences in. Move-step animation, the chat auto-slide and FoV darkening are already off. - [ ] **Text rasterizes server-side.** `Logical font SansSerif.plain not defined in font configuration` and `Font Anta Regular not defined ... Falling back to glyph rendering`: Suramadu's fast path sends text for the browser to draw, and glyph fallback ships pixels instead. Shippable via `#@@` fontconfig entries, but measured as polish rather than latency - see PERFORMANCE.md. - [ ] **The load-time batch.** ~2s of JS boot before first paint. Two smaller items around it: the page does declare a charset, but at byte 1859 of `suramadu/web/index.html` - past the 1024 bytes the browser prescans, because the CSP comment sits in front of it; and our `webFolder` files ship without `.gz` siblings, which is the only form of compression the server offers (`AbstractResourceHandler` serves a precompressed `.br` or `.gz` next to the file if one exists, and compresses nothing on the fly). The war's own bundle is covered - `patch-war.sh` regenerates its `.gz` after patching. Assets are not re-downloaded on every visit: the same handler sends `Cache-Control: public, max-age=120` with `Last-Modified`, and revalidates with 304. - [ ] **Window icons are the last unverified piece of the lance.blue skin.** Everything else is verified - the skin, board backgrounds and scroll tile install against the real 0.51.0 tree, and the test stage byte-compares what the image carries against the repo files. But a 2026-08-05 test still showed the stock MegaMek window icon. The load chain (files -> Toolkit -> `Frame.getIconImage` -> Suramadu's server-side decorator) is sound outside Docker, so if it persists on a build that passes the test-stage check, suspect the tested image rather than the tree. - [ ] **Prove out the client latency instrument.** Every number in PERFORMANCE.md was correlated by hand from a Chrome trace - input, to the WebSocket send it caused, to the reply, to the draw. `suramadu/web/perf.js` records those four points itself when a match is opened with `?perf=1`, and `window.__arenaPerf.dump()` returns them with a p50/p90 summary in one call, which is what a headless pilot over CDP can reach (`tests/screenshot.py` already drives Chrome that way). Written, not verified: nothing has run it in a browser. The pure logic - ring buffer, loop pairing, percentiles - was exercised under node against a stub DOM, but the two hooks were not. The draw hook goes through Suramadu's `options.customization` seam onto `base.paintDoneCallback`; if it silently misses, paint timing falls back to `requestAnimationFrame` and `dump().drawHook` says so. Run it against a real match, check `drawHook` and the loop counts are sane, then decide whether the pilot script belongs in `tests/`. *Interaction widened 2026-08-31.* The routine was a pan and two clicks - eight mousemoves, both clicks on the same hex - which is not what a player does and not what feels slow. It now pans, sweeps the cursor across the board at roughly hand speed, and clicks a grid: about six times the recorded input per run, and enough of it to put a percentile on. Still short of the real thing in one way worth knowing - on a 16-unit board only three of fifty-two clicks drew a reply over 4KB, so most landed on bare terrain and the expensive case, selecting a unit and redrawing the unit display, is under-sampled. The grid is the cheap fix; knowing where the units are would need the board read out of the client. - [x] **Two upstream repaint costs, known and left.** Both are now closed. The chat overlay blinked its text cursor via an ungated 500ms Timer that repainted the overlays for the whole session (`ChatterBoxOverlay:169`); the cursor is only drawn while the box is active, so patch 0016 skips the refresh when it is not. Captured off the wire, that was 5,980 bytes every 500ms with consecutive frames 99.8% identical - 12,480 B/s - and a quiet client now sends nothing at all. DirectDraw re-encoding every drawn image even on a cache hit is what `instrument/lazy-png-agent/` fixes, and it is on by default now: 737 png/deflate samples on the EDT to 0. - [x] **The per-layer latency split is reduced per match.** Suramadu has always logged `latency`, `latencyServerRendering`, `latencyClientRendering`, `latencyNetworkTransfer`, `latencyPing`, `edtThreadBlockedForSeconds`, `inboundSize`, `outboundSize` and `cpuUtilization` as `stats,` lines, and nobody ever summed them. `exit/finalize.sh` now writes n/mean/p50/p90/max per metric to `state/stats-summary.txt`, logs the table, and uploads it. It runs on every match, profiled or not - the lines were already there. ## Cold start A production cold start measured 1m42s from "click deploy" to a visible mech: 18.0s Fargate provisioning, 15.7s image pull, 6.1s container start, 16.9s init to `host-ready`, then 31.7s from the client JVM starting to the board being on screen. PERFORMANCE.md has the anatomy of that last number and the two things it is *not* - it is not CPU-bound, so a larger task buys nothing, and the client JVM cannot use CDS at all. `scripts/perf/startup.py` measures it. None of the below is worth pursuing yet; they are written down so the next person starts from the measurement rather than from the guess. - [ ] **Suramadu rewrites every class through BCEL as it loads.** `org/webswing/classloader/SwingClassloader.modifyClass`, in `suramadu-app-services.jar`, parses and rewrites each application class to redirect a fixed set of calls (`RedirectedMethods.getSystemClassLoader`, `setOut`, `listRoots`, the JSObject accessors). There is no cache and no skip-list: the rewrite is unconditional, per class, every boot, and the client loads 13,993 classes in one continuous chain from 0.01s to 34.9s. Caching the rewritten bytes - warmed at image build, so a player's boot reads them instead of recomputing them - is the obvious move. **Size it before building it.** BCEL sampled 2.5s of wall time, not the 15s the chain takes, so the prize may be 2-4s rather than a third of the cold start, and a bytecode cache that has to stay correct across a Suramadu bump is not worth 2s. The first 15s is Suramadu's own dependency stack in any case - log4j2 ~1,341 classes, two Jackson stacks ~885, BCEL 344, Tyrus, protobuf, JAXB, snakeyaml - before MegaMek loads anything, so there may be a cheaper cut in what gets initialised at all. - [ ] **MegaMek's art recompresses losslessly by about 32%.** Roughly 40MB off the image, against a 15.7s pull. Measured on the tree, not applied; it touches data that is not ours to modify in place, so it belongs in the build rather than in the repository. This is not the same claim the Debt section rejects: that one is about *layer* compression, where already-PNG payload does not recompress, and this is re-encoding the PNGs themselves. Weigh it against the arm64 image above, which is the larger and better-understood win on the same number. - [ ] **Pre-warming is worth 39.8s of the 1m42s, and the debt section already prefers it.** Not a warmed *cache* - Fargate has no host affinity and pulls every layer cold for every task, so there is nothing to steer a task onto. It is a task already running when the match is created, which has nobody waiting on its provisioning (18.0s), pull (15.7s) or container start (6.1s). The SOCI entry under Debt is argued against partly on the grounds that pre-warming subsumes it, so this is the piece that entry is waiting on. The open question is cost: what a warm pool of idle tasks costs against how often a match is actually created, and whether the pool is sized or a single spare. That is a headquarters and infra question rather than an arena one. ## Playability, carried from the spike - [ ] **A few glyphs still box** — the temperature and gravity indicators (U+1F321/U+1F525, supplementary-plane emoji the legacy fontconfig fallback does not appear to reach) and U+23AF. *Ruled out, do not retry:* adding "Noto Sans Symbols" (no 2). Its family name is a prefix of "Noto Sans Symbols2" and the parser mis-resolves between them — punctuation turns to boxes and the armor pips vanish. - [x] **Suramadu chrome** — the loading page and dialogs are themed to headquarters' palette via `webFolder`, confirmed in a browser. Overrides must be prefixed with `:root`; upstream declares the same colours at two depths and on every link pseudo-class, so a plain override silently loses. `tests/shell/test-theme-css.sh` enforces it. Still unconfirmed: whether `org.webswing.window.theme.*` reaches the app JVM at all — MegaMek runs fullscreen, so the window decorations may never be visible enough to tell. - [x] **MegaMek defaults look bad** — one deliberate theme is baked into the image now: `lanceBlueSkin.xml` in `data/skins`, the board backgrounds, the window icons and the `GUIPreferences` that go with them. What is left is verification of the window icons, tracked under render performance. - [x] **Matches ran on MegaMek's stock rules** — there was no game options file at all, so every match got upstream's defaults whether or not they suited a browser match. `megamek/gameoptions.xml.template` is now seeded into `mmconf/` beside the client settings, and `ScenarioV1` picks it up for any scenario that does not name a `GameOptionsFile` of its own. Two options differ from stock: `ejected_pilots_flee`, so a dead mek's MechWarrior does not stay on the board as a unit its owner moves every round, and `skip_ineligible_firing`, so a shut-down unit gets no firing turn it can only press Done on. Verified by mounting the file into the shipped image and loading with `ScenarioReport`: MegaMek logs both as set, and all 51 scenarios load exactly as they did without it. What has not been seen is the effect in play — a mek ejecting in a hosted match and the MechWarrior not appearing. Considered and left alone: `skip_ineligible_movement`, because the movement phase is where a shut-down mek starts up and where TacOps:AR abandonment happens; the nag dialogs, which are protective rather than noise; and turn timers, which are a feature rather than a default worth correcting. - [ ] **The minimap cannot dock** — unlike the unit display and mini report it has no location preference and is dialog-only. Hidden by default (patch 0003 made that possible at all). Docking it properly needs more patching. - [ ] **No sound at all, for now** — the stock turn bing is unpleasant on every round, so all three `SoundMute*` preferences are on in `clientsettings.xml.template`. The audio path itself still works (patch 0001 keeps it alive under Suramadu), so this is one flag away from coming back once there is a sound worth playing. - [ ] **Pre-damage a scenario asked for is not all applied.** Patch 0011 (not yet seen in a container) makes `ScenarioV1` honour `CritHit`, the unit data key every shipped scenario writes; stock switched on `CriticalHit` and logged the real one as unknown, so those critical hits never reached the game. Two scenarios in this image are affected, `1stSomersetStrikers/5-BattleInTheSwamp.mms` and `LawyersGunsAndMoney/1-TheMistake.mms`. Crits are applied by `applyDamage()` on the server, not by `createGame()`, so `ScenarioReport` cannot see the difference and nothing in `tests/` covers it. Confirming it needs a hosted match: load The Mistake and check the Redfield's Renegades unit 4 starts with its head slot 2 already out. One unknown unit data key is left over after this run, `Unit_PlayerA_1_Camo` in `Examples/Example.mms`. `PARAM_CAMO` is `camo` and `PARAM_ALTITUDE` is `altitude`, but the per-unit key regex requires an initial capital, so neither case can ever match. Not fixed here. ## Debt - [x] **The boot screen is one design across both frames.** The card index.html serves and the dialog Suramadu builds are styled together now, so the handover from the first to the second is not visible. Two latent bugs turned up while doing it: the empty `.ws-header` was drawing a divider above nothing on every dialog, and the first frame's spinner was 0x0 until the loader ran, because upstream only declares the spinner at a depth that does not exist until then. Both fixed. - [ ] **The boot string reaches players through one locale.** It is served as `javascript/nls/en/msg.json`, and the client only ever asks for the tag the browser reports, shortened. `lang.js` pins the tag to `en` so the file applies to everyone, which is right while the game is English-only and wrong the day it is not. Whoever adds a second language has to remove that pin and ship a directory per locale. - [x] **The local match lifecycle now runs.** `up`, `status`, `logs`, `wait-ready` and `down` were exercised against the real daemon on 2026-08-07. What the stubbed tests could not answer, answered: the client URL returned 200, so the readiness probe tested the right URL — `/megamek/` then; the per-DID clients replaced it and the probe checks the container root now; a match serves **7s** after `up`, nowhere near the 180s timeout; the caps docker reports back match what was asked for; `down` leaves nothing behind; and `up -i newest` resolves through `scripts/image.sh`. Two bugs the stubs could not see are fixed in the same branch as this note — see the dual-stack port entry below. - [ ] **Two of the lifecycle's unknowns are still open.** Whether 8GB is enough is untested: nothing measured the container's actual peak, and a match reaching the cap would be killed rather than slowed. And whether `docker ps --format '{{.Label ...}}'` reports labels a container inherits from its image is still unconfirmed — it is one of the singleton guard's three signals and the other two covered it in every run so far, so a failure here would be silent. - [x] **A stubbed docker cannot check a Go template.** `container_port` asked for `{{range ...}}{{$p.HostPort}}{{end}}`, and a dual-stack host publishes one port as two bindings — 0.0.0.0 and [::] — so the range ran twice and returned `80808080`. `status` printed that as the port and `wait-ready` probed it and timed out against a container that was serving. All 77 tests passed throughout, because the stub answered `HostPort` with one line and no stub runs a template engine. The stub now returns the bindings a real host would; the lesson is that a fixture proves the shell logic and nothing about what was asked of docker. - [ ] **Headless clients abort entity packets on image work.** Patch 0005 (shipped, verified live: camo renders, no more per-packet stacks) fixed `EntityImage`'s static init; the residue is the one-arg `ImageUtil.createAcceleratedImage(Image)` returning null headless, which NPEs `loadFacings()` and aborts `SENDING_ENTITIES` handling — entities after the failure lose their force ids in the bot and observer clients. Patch 0008 adds the headless fallback — on main since 2026-08-06, not verified in a container yet: check a bot client's entities all carry force ids after `SENDING_ENTITIES`. - [ ] **What actually made a camo overlay unreadable is still unknown.** Patch 0009 (on main, not yet seen in a container) stops one failed overlay blacking out a facing, and logs the file, the reason and the facing when it happens. The trigger was never captured: the old code's one `logger.error` lands in the client's own `logs/megamek.log`, not container stderr, because Suramadu launches the client with no `-Dlog4j2.configurationFile`. With the fix in, a match that hits it now says so by name. If nothing ever logs, the `ImageIcon`/`PixelGrabber` load was the whole story and this can close. `finalize.sh` now collects the client's logs under the `diagnostics` upload key, so from the next match on there is somewhere to read the answer — as long as the manifest carries that key, which is its own item above. Two things to look at while a camo is on screen: that the shading is back on every facing, and that the unit-overview icon matches the board sprite. - [ ] **`webswing-loader-stub.js` 404s through the page.** The served index references it but the war answers 404 text/html; the client recovers by another path. Cosmetic, seen on every match load. - [x] **Suramadu reports its own CPU.** `jdk.management` was missing from the jlink runtime, so `com.sun.management.OperatingSystemMXBean` was absent, `CpuMonitor.` threw, monitoring disabled itself and every stats line carried `cpuUtilization,-1`. One entry in `JRE_MODULES`; the runtime grows 59KB. Verified 2026-08-09 in a container: real values (7-14% during hover interaction) and no initialiser failure in the log. That number is the one worth having on a 2-vCPU task - it is how you answer "is this match CPU-bound" without guessing. - [x] **The image tag is unique per build.** `IMAGE_TAG` is `mm-sur--`; a dirty tree is tagged `-dirty` and `push.sh` refuses to push it. Infra can flip ECR to `IMMUTABLE`. The prefix was `-ws` until the Suramadu rename. Tags already in ECR keep it and simply age out; nothing resolves an image by parsing the tag, so the two forms coexist. infra's `ecr` and `match-cluster` modules and headquarters' `config.rs` describe the format and were updated with it. - [ ] **The image pull adds ~16s to every match start.** The image is on the order of a gigabyte, and Fargate pulls it cold for each task. `megamek/prune-data.sh` now removes ~420MB of it - the crew portrait packs, two hex sets nothing loads, MekHQ/MegaMekLab art, an 89MB leftover `border.psd`, the map-pack `.docx`, a duplicate `lib/MegaMek.jar` and the planetary-systems data - against a MegaMek release tarball of 609MiB gzipped. What that is worth in seconds is still unmeasured: it needs a build and a push, because pull time follows compressed image size and nothing has yet reported one. Read it back with `aws ecr describe-images --query 'imageDetails[].imageSizeInBytes'` and against `pullStartedAt`/`pullStoppedAt` on the next Fargate task. Of the remaining candidates, **layer ordering is a dead end** - Fargate pulls every layer cold for every task, so nothing caches between tasks and only fewer compressed bytes or lazy loading can move the number. zstd layers look weak for the same reason the prune works: the payload is about three quarters already-compressed image data, which does not recompress. SOCI is the largest theoretical win left and is an infra conversation - it needs a containerd content store and an ECR-side index. SOCI is now argued against rather than merely unbuilt. What a match does not read before it is listening is almost entirely MegaMek's render assets, and those are read on the client's first board paint - so lazy loading moves the delay into the frame path, which is the one number PERFORMANCE.md exists to protect, and adds a mid-match failure mode when a chunk fetch fails. It is also subsumed by pre-warming: a task already running when the match is created has nobody waiting on its pull. Revisit only if pre-warming is abandoned. - [x] **The boot phases are timed.** The entrypoint stamps `phase= at=+` for init, each JVM launch and each port opening, and the `ready` callback carries the same numbers to headquarters. Until this there was no way to attribute a start: ECS reports the pull and the container start, and nothing said where the seconds in between went. - [x] **The two JVMs start together.** The wait for Suramadu's port used to sit between the two launches, so the match host's entire startup was charged to every match on top of Suramadu's. They now launch back to back and both ports are waited on afterwards; the observer, which nothing waits on, moved after readiness so it is not competing for the two cores while they warm. `tests/shell/test-start-order.sh` is what keeps the order. Unmeasured on Fargate: the overlap is real but the two JVMs now contend, and only a live task says what the net is. The phase marks above are how to read it - compare `host-ready` against the old serial sum. - [ ] **Upstream all fourteen MegaMek patches.** Each is a latent MegaMek bug. An accepted PR costs nothing to maintain; a carried patch costs something every release. The three Suramadu frontend patches are the same argument against a different upstream. - [ ] **x86_64 only.** `JDK_SHA256` in `versions.env` pins one architecture, and the Dockerfile's JDK URL is x64. Needs a per-arch checksum table before an arm64 image is possible — which is the cheaper Fargate tier. - [ ] **Harness ready-up race** — `MMBench` still wedges a game at round 0 in roughly one run in five, contaminating benchmarks. - [ ] **The benchmark has no way to run.** `tests/bench.sh` still assumes a host JDK and an extracted MegaMek release, which no longer exist now that the host build is gone. It needs to run in a container against the build stage, or be dropped. - [ ] **Trimmed data dir unverified** — the spike's `slim-data` (221MB vs 932MB) loaded the unit cache but hung before the game started. `megamek/prune-data.sh` is the narrow version of the same idea: it removes only what a match provably cannot reach, and each line has to say why. The spike's aggressive cut is still the bigger prize and still unexplained - whatever it removed that hung the game is worth finding, because it is probably one file. - [ ] **Regression-test against a MegaMek build from source.** Everything is pinned to release 0.51.0 and there is no signal for upstream breaking us: patches failing to apply, the Suramadu incompatibilities returning, or new heavyweight AWT components appearing. - [ ] **`scripts/image.sh` has not run inside the `test` stage.** Every command was run against the images already on this machine, including one build that landed mid-work and grouped with its `latest` tag as intended, and `tests/shell/test-image.sh` covers the parsing against fixtures. What is unverified is the test itself in the place it will actually run: it stubs `docker` because the stage has no daemon, and it has only ever been run on a laptop. It parses the commit out of the tag, which the entry above says nothing does. Reporting is all it does with it: `resolve` will hand back a tag whose commit this repo has never seen, because a local image nobody can rebuild is still an image you may want to run. `deploy.sh` remains the place that refuses one. ## Proposal: Princess in its own process Not started, and written down because the measurement points at it and the code turns out to be most of the way there already. **What the numbers say.** On the 16-unit bench at the production 2 vCPU, the match host takes ~155% of the two cores once Princess is thinking, and 0% while a human is deciding. Rendering wants ~1.35 cores on its own. They do not fit, the renderer gets throttled, and the throttled repaints coalesce into larger frames - so the contention costs bandwidth as well as latency. Doubling the task to 4 vCPU halves p90 input latency (146ms -> 77ms). **Why the split is cheap.** `MatchHost` already constructs each bot as `new Princess(name, LOCALHOST, port)` and calls `connect()`. Princess is a *network client* that happens to live in the server's JVM. Moving it out is a new `main` that parses a slot name, host and port, and the same three lines - not a re-architecture. **What it buys, stated correctly.** A container's `cpu` in a task definition is a *reservation*, not a cap: it sets a relative weight, and a container is free to burst into whatever the other containers are not using. It is not CPU pinning, which is exclusive in both directions. So the split does not make it impossible for the bots to slow the renderer down - it gives the renderer a *floor* under contention while leaving it free to use the whole task when the bots are idle. For this workload that soft behaviour is the right one, and it is worth being explicit about why, because the intuitive fix is the wrong one. Load here alternates hard: Princess is at 0% while a human is deciding and ~155% once it is thinking, while rendering wants ~1.35 cores during interaction. A static partition - N cores dedicated to bots, M shared - is therefore wrong half the time by construction. Reserve enough for Princess's peak and the renderer is capped during the human's turn, when Princess is using nothing; reserve less and there is no floor when it matters. A work-conserving share beats any fixed split on a workload whose two consumers are rarely busy at once, and it is the reason the "N dedicated + M burst" shape can measure *worse* than doing nothing if the weights are picked badly. Within a task the containers share a network namespace, so Princess still reaches the server over loopback and the connection stays as cheap as the uncompressed- loopback change made it. **What it costs.** A second JVM's memory and startup; container ordering, since the bots cannot connect before the server listens; and a new failure mode where the bot container dies and the game stalls with nobody to take a turn. The entrypoint's supervision would have to grow a case for it. **Why the scheduling unit is the actual mechanism.** Inside one container the kernel schedules *threads*, and the two consumers are wildly asymmetric in thread count: Princess's search is parallel across many runnable threads, while rendering is bottlenecked on a single hot EDT. Fair-share scheduling between threads therefore hands Princess the larger portion by construction, however modest its priority. Container-level shares are applied to the cgroup instead, so the arena container keeps its weight no matter how many threads the bots run. That - not isolation - is what the split would actually fix. **Do not pin.** An earlier draft of this entry suggested trying `taskset` or `-XX:ActiveProcessorCount` first as the cheap version. On this workload that is likely to be actively worse: pinning is exclusive in both directions, so a renderer pinned to one core is capped at one core during the human's turn, which is exactly the phase when Princess is idle and the whole task is free. The measurement says rendering wants ~1.35 cores when it can have them. **Re-measure before building any of this.** The 155% figure was taken before loopback packet compression was removed, which cut the host's CPU per game round by 44%. The contention this entry exists to solve is materially smaller than when it was written, and it may no longer clear the bar for a second container. Run the 16-unit bench again first; `scripts/perf/stats.py` and the CPU sampling method are already in the repo. ## Not started - [ ] **Units from the manifest.** Every match plays the scenario's fixed units; no slot can bring its own force. The interchange format is MegaMek's own `.mul` unit list: headquarters is looking at MekBay (mekbay.com, MegaMek's web force builder — see "MekBay for force building" in headquarters' TODO) as the tool players build with, and the handoff shape is a staged, presigned MUL per slot, the same treatment camo gets. `MekSummaryCache` is already warm in `MatchHost`. The open questions are where the MUL is applied — loaded server-side 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. - [x] **The portrait prune broke two scenarios.** `megamek/prune-data.sh` removed `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. Found by `tests/scenario-load.sh`. It now keeps the 25 portraits the shipped scenarios name and drops the other 1352, which costs 4.5MB of the 252MB those two trees were worth. Kell Hounds' DeathOfTheLegion and LoweringTheBoom load, so both came off `tests/scenarios-known-bad.txt`. - [ ] **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 that is not fails at load. Staging a `.board` the same way the scenario is staged is the other half. - [ ] **Security review.** The proxy is the auth boundary; ATProto token custody stays out of the container by design; user-uploaded camo is decoded by other players' browsers, so headquarters must sanitise on upload; observers see everything, which matters if double-blind is ever enabled. - [ ] **Cost model.** The old model assumed 2.5h per match; the first real one took 11 minutes. Not like-for-like (2v2 versus a bot), but match duration is the input the model is most sensitive to. Measure a few human 4v4s.