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