diff --git a/.gitignore b/.gitignore
index 3f66ecb..0b8bf93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ webswing-dist/
slim-jre/
slim-data/
slim-home/
+spike-webswing/fontconfig.properties
diff --git a/README.md b/README.md
index 294e569..139039a 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,45 @@
# mm-spike
-Feasibility spikes for running MegaMek as the tactical engine behind a browser-based,
-ATProto-backed campaign game.
+Feasibility spikes for running MegaMek as the tactical engine behind a
+browser-based, ATProto-backed campaign game.
+
+**Status:** a full 2v2 match against Princess has been played in a browser
+(11 rounds, 11 minutes) via Webswing Lite rendering the stock MegaMek Swing
+client. See [TODO.md](TODO.md) for what's next.
## Constraint
-**Zero patches to MegaMek.** Everything here compiles against an unmodified release jar
-using only public API, so tracking new MegaMek releases stays a recompile rather than a
-rebase.
+**Minimal, upstreamable patches to MegaMek.** Everything here builds against an
+unmodified release jar; the harness uses only public API, and the two patches
+that exist are latent MegaMek bugs that should become upstream PRs rather than a
+carried fork. Tracking a new MegaMek release should be a recompile plus at most a
+patch refresh, never a rebase.
## Running the Webswing spike
./spike-webswing/start.sh # blocks; Ctrl-C tears everything down
Then open . You play TraineeA, Princess plays
-TraineeB; press "Done" in the lobby.
+TraineeB; press "Done" in the lobby. The script exits on VICTORY.
+
+`AUTO_READY= ./spike-webswing/start.sh` forces the human ready
+server-side, for automated runs that need to reach the deploy phase without a UI
+click.
-**One browser session at a time, and restart the instance between runs - not just
-the browser.** Webswing keeps the spawned MegaMek client JVM alive after the page
-closes (so a reconnect resumes your match). If a second client connects while the
-first still holds `TraineeA`, the server's `correctDupeName()` renames it
-`TraineeA.2`; that player owns no units and has no team, so every control is
-greyed out and opening Player Settings throws an NPE
-(`PlayerSettingsDialog.autoConfigSection`, null team).
+### One browser session at a time
+
+**Restart the instance between runs - not just the browser.** Webswing keeps the
+spawned MegaMek client JVM alive after the page closes (so a reconnect resumes
+your match). If a second client connects while the first still holds `TraineeA`,
+the server's `correctDupeName()` renames it `TraineeA.2`; that player owns no
+units and has no team, so every control is greyed out and opening Player Settings
+throws an NPE (`PlayerSettingsDialog.autoConfigSection`, null team).
Do not try to fix this with Webswing session settings. `maxClients: 1` refuses
the second browser outright, and `sessionMode: CONTINUE_FOR_USER` plus
`allowStealSession` lets the second browser evict the first and leaves *both*
showing "too many active connections" - strictly worse than the defaults.
-`AUTO_READY= ./spike-webswing/start.sh` forces the human ready
-server-side, for automated runs that need to reach the deploy phase without a
-UI click.
-
## Patches
MegaMek patches live in `patches/patchset/` as **unified diffs**, applied with
@@ -46,28 +53,73 @@ those files, and injects the classes into a copy of `MegaMek.jar`. The MegaMek
source and release trees are never modified. (Injection rather than classpath
shadowing is required because `MegaMek.jar` is sealed.)
-Currently applied - verified to be the *only* difference from the stock jar:
-
-- `0001-sound-guard-optional-master-gain-control.patch` - MegaMek assumes the
- optional `MASTER_GAIN` Clip control exists; under Webswing's audio mixer it
- does not, and the client dies at startup with an uncaught NPE. Upstreamable.
-
-`patches/archive/` holds patches that were tried and dropped; see its README.
+Applied, and verified to be the *only* differences from the stock jar:
+
+| patch | what | why |
+|---|---|---|
+| `0001-sound-guard-optional-master-gain-control` | `Sound.setVolume()` checks `isControlSupported`/null before using `MASTER_GAIN`, and clamps the gain | `MASTER_GAIN` is an optional Clip control. Webswing's audio mixer does not provide it, so the client died at startup with an uncaught NPE. The existing formula also yields `-Infinity` at volume 0. |
+| `0002-extrapanel-use-lightweight-jlabel` | `ExtraPanel` uses `JLabel` instead of `java.awt.Label` for a blank spacer | Webswing supports only lightweight Swing components; `Toolkit.createLabel()` throws, which aborted the client's `PHASE_CHANGE` handler. The board card was then never shown, so the client sat on "Waiting for the server..." forever while the status bar correctly reported the deploy phase. |
+
+Both are genuine MegaMek bugs independent of this project - see TODO 3.3.
+
+`patches/archive/` holds patches tried and dropped, with reasoning; currently the
+multi-tenancy patch (see Findings below).
+
+## Findings
+
+Measured during the spike, and the basis for the architecture:
+
+- **~730MB RSS per concurrent match** (median across 8 runs; 616-770MB). Flat
+ across heap cap (`-Xmx` 384m..2g) *and* match size (2v2 vs 4v4) - the cost is
+ per-JVM fixed overhead, not game state. Do not expect smaller matches to be
+ cheaper.
+- **MegaMek runs one game per JVM.** `Server`'s constructor ends with
+ `serverInstance = this` and `AbstractGameManager` routes every packet through
+ that static, so a second `Server` silently misroutes the first game's traffic.
+ Games are isolated at the *data* layer (distinct Entity/Game/Board/Player) but
+ not at the transport layer. A patch fixing the packet path was necessary but
+ **not sufficient** - other `getServerInstance()` callers remain. Archived
+ rather than pursued, because one-match-per-container sidesteps it entirely.
+- **CPU is not the constraint.** Eight concurrent games used 2.2 of 16 cores;
+ turn-based means one bot thinks at a time.
+- **`units.cache` is not shipped** and is built by walking 10,989 unit files:
+ 2.9s to load a prebuilt cache vs 20.2s to build it. Bake it into the image.
+- **A jlink runtime is 59MB** (vs 346MB full JDK) and runs a 4v4 fine. `jdeps`
+ reports only `java.base`, `java.desktop`, `java.management`, `java.prefs`.
+- **Headless works** under Webswing - the JDK patch module is active, no Xvfb
+ needed.
## Layout
-- `harness/src/bench/` - benchmark + diagnostic harness
- - `MMBench` - starts N concurrent games in one JVM (staggered, each settled to a real
- combat round before the next starts) and samples memory
- - `IsolationCheck` - verifies two games in one JVM are actually independent
+- `harness/src/bench/` - benchmark + diagnostics
+ - `MMBench` - starts N concurrent games in one JVM (staggered, each settled to
+ a real combat round first) and samples memory
+ - `IsolationCheck` - whether two games in one JVM are actually independent
- `Sampler` - RSS / live-heap / metaspace sampling to CSV
-- `results/` - benchmark output
-- `scripts/fetch-deps.sh` - downloads the JDK and MegaMek release (both gitignored)
-
-## Running
+- `harness/src/spike/` - the playable/observable path
+ - `HostForHuman` - MegaMek server + Princess bot, holding one scenario faction
+ open for a human
+ - `WatchGame` - attaches an observer `Client` and dumps full text state each
+ phase (the adapter-pattern prototype)
+ - `TextRenderer` - board + per-location armor + heat as text
+- `spike-webswing/` - Webswing config template, generated fontconfig, `start.sh`
+- `patches/` - patchset, archive, and the patched-jar builder
+- `scripts/`
+ - `fetch-deps.sh` - downloads the pinned JDK and MegaMek release (gitignored)
+ - `gen-fontconfig.sh` - generates the legacy fontconfig Webswing's toolkit needs
+ - `cdp-screenshot.py` - minimal DevTools screenshot client (no node/websocket
+ library on this box)
+ - `summarize.py` - marginal-cost summary from a bench CSV
+- `results/` - benchmark and session output
+
+## Benchmarks
```sh
./scripts/fetch-deps.sh
./harness/build.sh
./harness/run.sh --games 8 --settle-round 3 --csv results/out.csv
+python3 scripts/summarize.py results/out.csv
```
+
+Note `harness/run.sh` defaults to the stock jar; set `MM_JAR` to use the patched
+one.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..dfdd543
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,143 @@
+# TODO
+
+Status as of the first playable browser match (MegaMek 0.51.0 + Webswing Lite
+26.4.5). A 2v2 against Princess ran 11 rounds in 11 minutes end to end.
+
+---
+
+## 1. Playability
+
+Things that make the current spike unpleasant to actually use.
+
+### 1.1 Missing glyphs render as boxes (□)
+Symbol characters in the unit display, heat scale and planetary-conditions
+overlay show as `□`.
+
+Cause is `spike-webswing/fontconfig.properties`, generated by
+`scripts/gen-fontconfig.sh`. It maps the five logical Java fonts to Noto
+Sans / Noto Serif / DejaVu Sans Mono, none of which cover the symbols MegaMek
+uses. Adding MegaMek's own `data/fonts` to `webswing.trustedFontDirs` removed all
+12 font-load rejections but did **not** fix the boxes, so the trusted-dir theory
+was wrong.
+
+Next: add a fallback face with symbol coverage (MegaMek ships `Noto Symbols 2`
+and `Icons` under `data/fonts`) via a `sequence.fallback` entry, or map the
+logical fonts onto MegaMek's bundled faces directly. Verify by screenshotting the
+unit display, not the lobby - the boxes only appear once a unit is selected.
+
+### 1.2 Window should open fullscreen
+The MegaMek frame opens at a fixed size inside a much larger browser viewport.
+Webswing reports the browser dimensions to the app JVM
+(`-Dwebswing.screenWidth` / `screenHeight`), so the frame should be able to fill
+them. Look at MegaMek's window-size preferences (`GUIPreferences`) and whether
+Webswing's `dockMode` / window-decorator settings can force a maximised frame.
+
+### 1.3 Victory just drops you
+On VICTORY the host prints a result, sleeps 5s, kills the server and exits, which
+tears down Webswing and closes the browser session. The player is left staring at
+a dead page with no summary.
+
+Wanted: hold the client alive on the victory report, let the player read it and
+dismiss it, and only then tear down. Probably means decoupling "game over" from
+"process exit" in `HostForHuman` - keep serving until the client disconnects or a
+timeout expires.
+
+### 1.4 Webswing chrome is ugly
+The default Webswing shell (loading spinner, window decorations, error dialogs)
+is unstyled and clashes badly with MegaMek. Webswing supports a custom
+`webFolder` per app for branding, plus window-decorator theming
+(`DefaultWindowDecoratorTheme` is already logging its colours at startup).
+
+### 1.5 MegaMek's own defaults look bad
+Independent of Webswing. Font sizes, GUI scale and the default skin are not a
+coherent look. `GUIPreferences` exposes scale and font settings, and MegaMek has
+a skin system (`data/skins`). Worth designing one deliberate theme and shipping
+it as preferences baked into the container rather than leaving stock defaults.
+
+---
+
+## 2. Product / infrastructure
+
+### 2.1 Domain name
+Must avoid Topps/CGL marks (BattleTech, BattleMech, 'Mech, MechWarrior, Alpha
+Strike, Clan names). Remember the domain becomes users' **ATProto handle
+suffix** (`natasha.example.gg`), so short and pronounceable matters more than
+usual.
+
+### 2.2 Fargate deploy architecture
+Design settled in discussion, not yet built:
+- One match = one Fargate task (which also sidesteps the multi-tenancy blocker
+ entirely - see 4.1).
+- Scenario generated by the AppView, passed by S3 presigned URL; player-owned
+ assets (camo blobs) fetched from their PDS at **challenge** time and cached in
+ S3 by CID, never bundled per match.
+- **Do not expose task IPs.** Terminate MegaMek inside the container and put a
+ small always-on WebSocket proxy in front; the MegaMek wire protocol never
+ leaves the task.
+- Public subnet + `assignPublicIp` with an SG allowing inbound only from the
+ proxy. Avoid NAT Gateway (~$33/mo) and interface VPC endpoints (~$15-29/mo).
+- Bake `units.cache` into the image (saves 17.3s of cold start, measured) and
+ use a jlink runtime (59MB vs 346MB, verified working).
+
+### 2.3 Security analysis
+Not started. At minimum: the proxy is the auth boundary (MegaMek has no
+per-player auth, only a server-wide password); ATProto OAuth token custody;
+user-uploaded camo is decoded by *other players' browsers*, so sanitise on
+upload; spectator information leakage if double-blind is ever enabled.
+
+### 2.4 Cost analysis - revisit
+The earlier model assumed **2.5 hours per match**. The first real match took
+**11 minutes** (2v2 vs Princess, so not like-for-like, but suggestive). Match
+duration is the input the model is most sensitive to - measure a few
+human-vs-human 4v4s before trusting any figure. The ~$27/month conclusion is
+dominated by always-on baseline costs and is unlikely to move much either way.
+
+---
+
+## 3. Engineering debt in this repo
+
+### 3.1 Harness bot ready-up race
+`MMBench` still wedges a game at round 0 in roughly 1 run in 5. Waiting on
+observed entity state before `sendDone` reduced but did not eliminate it. This
+contaminates benchmark runs.
+
+### 3.2 Trimmed data dir unverified
+`slim-data/` (221MB vs 932MB, dropping client-only images/fonts) loads the unit
+cache fine but then hangs before the game starts. Unresolved. Matters for
+container size and therefore cold-start time.
+
+### 3.3 Upstream the two patches
+Both are latent MegaMek bugs independent of this project and should be PRs:
+- `Sound.setVolume()` assumes the optional `MASTER_GAIN` control exists.
+- `ExtraPanel` uses a heavyweight `java.awt.Label` in a Swing app.
+
+### 3.4 Latent MegaMek NPE, recorded not patched
+`PlayerSettingsDialog.autoConfigSection` assumes every player has a team; a late
+joiner has none, so it throws. Only reachable via the duplicate-session path
+(see README), so left unpatched deliberately.
+
+---
+
+## 4. Decisions still open
+
+### 4.1 Webswing vs CheerpJ
+Currently on Webswing (server-side rendering). Costs roughly 3x memory per match
+because each player needs a server-side client JVM.
+
+CheerpJ would put the client in the browser instead, but: latest release supports
+Java 8/11/17 while MegaMek needs 21 (Java 21 is slated for CheerpJ 6.0 and
+slipping). A Java 17 backport is small - **32 errors across 3,285 files, all
+syntax** (29 switch patterns, 3 record patterns), no Java 21 APIs. The harder
+blocker is networking: CheerpJ's only documented raw-TCP path is Tailscale.
+
+Revisit if Webswing's memory cost or look-and-feel proves unacceptable.
+
+### 4.2 Who computes Battle Value
+Deferred - MVP is 1v1 with static canonical units. When custom forces arrive,
+either reimplement BV outside the JVM (correctness risk, and BV disputes poison a
+ladder) or stand up a JVM force-builder service.
+
+### 4.3 Spectator information leakage
+Spectating is free (MegaMek auto-flags a unit-less late joiner as an observer)
+but observers see everything. Fine now; a real cheat vector if double-blind is
+ever enabled.
diff --git a/harness/run.sh b/harness/run.sh
index dfda643..adeb65b 100755
--- a/harness/run.sh
+++ b/harness/run.sh
@@ -13,7 +13,7 @@ MM_HOME="${MM_HOME:-$ROOT/MegaMek-0.51.00}"
JAVA_HOME="${JAVA_HOME:-$ROOT/jdk-21.0.12+8}"
HEAP="${HEAP:-6g}"
-# MM_JAR=../MegaMek-patched.jar selects the multi-tenancy-patched build.
+# MM_JAR=../MegaMek-patched.jar selects the patched build (see patches/patchset).
MM_JAR="${MM_JAR:-$MM_HOME/MegaMek.jar}"
CP="$ROOT/harness/out:$MM_JAR"
for jar in "$MM_HOME"/lib/*.jar; do CP="$CP:$jar"; done
diff --git a/spike-webswing/fontconfig.properties b/spike-webswing/fontconfig.properties
deleted file mode 100644
index d8e5468..0000000
--- a/spike-webswing/fontconfig.properties
+++ /dev/null
@@ -1,38 +0,0 @@
-# Generated by scripts/gen-fontconfig.sh - do not edit by hand.
-version=1
-
-sequence.allfonts=latin-1
-
-serif.plain.latin-1=-*-noto serif-regular-r-normal--*-%d-*-*-p-*-iso8859-1
-serif.bold.latin-1=-*-noto serif-bold-r-normal--*-%d-*-*-p-*-iso8859-1
-serif.italic.latin-1=-*-noto serif-regular-i-normal--*-%d-*-*-p-*-iso8859-1
-serif.bolditalic.latin-1=-*-noto serif-bold-i-normal--*-%d-*-*-p-*-iso8859-1
-sansserif.plain.latin-1=-*-noto sans-regular-r-normal--*-%d-*-*-p-*-iso8859-1
-sansserif.bold.latin-1=-*-noto sans-bold-r-normal--*-%d-*-*-p-*-iso8859-1
-sansserif.italic.latin-1=-*-noto sans-regular-i-normal--*-%d-*-*-p-*-iso8859-1
-sansserif.bolditalic.latin-1=-*-noto sans-bold-i-normal--*-%d-*-*-p-*-iso8859-1
-monospaced.plain.latin-1=-*-dejavu sans mono-regular-r-normal--*-%d-*-*-p-*-iso8859-1
-monospaced.bold.latin-1=-*-dejavu sans mono-bold-r-normal--*-%d-*-*-p-*-iso8859-1
-monospaced.italic.latin-1=-*-dejavu sans mono-regular-i-normal--*-%d-*-*-p-*-iso8859-1
-monospaced.bolditalic.latin-1=-*-dejavu sans mono-bold-i-normal--*-%d-*-*-p-*-iso8859-1
-dialog.plain.latin-1=-*-noto sans-regular-r-normal--*-%d-*-*-p-*-iso8859-1
-dialog.bold.latin-1=-*-noto sans-bold-r-normal--*-%d-*-*-p-*-iso8859-1
-dialog.italic.latin-1=-*-noto sans-regular-i-normal--*-%d-*-*-p-*-iso8859-1
-dialog.bolditalic.latin-1=-*-noto sans-bold-i-normal--*-%d-*-*-p-*-iso8859-1
-dialoginput.plain.latin-1=-*-dejavu sans mono-regular-r-normal--*-%d-*-*-p-*-iso8859-1
-dialoginput.bold.latin-1=-*-dejavu sans mono-bold-r-normal--*-%d-*-*-p-*-iso8859-1
-dialoginput.italic.latin-1=-*-dejavu sans mono-regular-i-normal--*-%d-*-*-p-*-iso8859-1
-dialoginput.bolditalic.latin-1=-*-dejavu sans mono-bold-i-normal--*-%d-*-*-p-*-iso8859-1
-
-filename.-*-noto_serif-regular-r-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSerif-Regular.ttf
-filename.-*-noto_serif-bold-r-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSerif-Bold.ttf
-filename.-*-noto_serif-regular-i-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSerif-Italic.ttf
-filename.-*-noto_serif-bold-i-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSerif-BoldItalic.ttf
-filename.-*-noto_sans-regular-r-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf
-filename.-*-noto_sans-bold-r-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf
-filename.-*-noto_sans-regular-i-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSans-Italic.ttf
-filename.-*-noto_sans-bold-i-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/noto/NotoSans-BoldItalic.ttf
-filename.-*-dejavu_sans_mono-regular-r-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf
-filename.-*-dejavu_sans_mono-bold-r-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf
-filename.-*-dejavu_sans_mono-regular-i-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Oblique.ttf
-filename.-*-dejavu_sans_mono-bold-i-normal--*-%d-*-*-p-*-iso8859-1=/usr/share/fonts/truetype/dejavu/DejaVuSansMono-BoldOblique.ttf