Replace the single start_cmd manifest field with a "programs" list so a game can expose more than one runnable binary. Duke Nukem 3D's manifests now offer Play and Setup (DOS sound hardware config), and every ScummVM manifest offers Start and Manager (ScummVM's own graphical Launcher). Also fixes two real bugs found along the way: - All 5 ScummVM manifests were passing the game id as `-f <id>`, but -f is ScummVM's --fullscreen flag; ScummVM rejected it as a stray argument and exited before the setup page's poll interval could notice, so clicking Start silently did nothing. Switched to --auto-detect. - The page's audio-unlock listener called ctx.resume() once on first click/keydown and unconditionally removed itself with no .catch(), so a silently failed first attempt left the AudioContext stuck suspended forever with no way to retry. Now retries on every click/keydown until ctx.state actually reports "running". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
22 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A Docker image definition for headless DOS/SCUMM games exposed over noVNC (VNC-over-websockets in the browser), with game audio also carried to the browser over a second, separate WebSocket (VNC/noVNC itself only ever streams video). It builds on the public base image ich777/novnc-baseimage, which already provides TurboVNC, websockify, fluxbox, and noVNC — this repo adds dosbox/scummvm/alsa-utils (game runtimes), pulseaudio/pulseaudio-utils/libasound2-plugins (audio), plus unzip/smbclient/python3 for game management, a TLS cert for noVNC, and the entrypoint/lifecycle scripts. Published as jayfield/dosbox-novnc on Docker Hub. scummvm's binary is at /usr/games/scummvm, not /usr/bin (Debian's convention for game packages) and not reliably on $PATH under a non-login shell — manifests reference it by absolute path.
Game data (game installs, sourced as zips from an internal SMB share //vlda-01/software/) is deliberately not baked into the image — one of the games (t7g, "The 7th Guest") is ~930MB of CD-ROM ISOs on its own, more than the rest of the image combined. Instead the image ships empty and games are installed at runtime into a volume, via an HTML setup page (see below).
Commands
There is no build system, linter, or test suite — this is a Dockerfile plus a handful of shell scripts and a small Python HTTP server. Common workflows:
# Build the image
docker build -t jayfield/dosbox-novnc .
# Run it the way it's intended to be run (fixed name, volume, ports)
./run.sh
# Launch a specific installed game inside the running container
./game.sh <game-name> # e.g. ./game.sh stuntcar
There's no create.sh in this project (unlike the other noVNC-family projects) — run.sh is the only launch script, and it's -d --rm (long-lived-but-disposable), not a persistent named container created once via docker create.
There's no automated way to exercise scripts/server.sh, scripts/setup_server.py, or scripts/start_game.sh outside a running container — to test changes, rebuild the image, start a real container, and either watch docker logs or docker exec in.
Architecture
Image layering (Dockerfile):
- Base image supplies TurboVNC (
Xvncon$PATH), websockify (websockifyon$PATH), and noVNC's web client at/usr/share/novnc/. - This layer adds
dosbox/scummvm/alsa-utils(game runtimes),pulseaudio/pulseaudio-utils/libasound2-plugins(audio, see below), andunzip/smbclient/python3(needed permanently at runtime now, not just at build time — see below), writes/etc/asound.conf, generates a self-signed TLS cert/key at build time (/tmp/novnc.pem,/tmp/novnc.key) for noVNC's HTTPS/WSS listener, and copiesscripts/to/opt/scripts/. ${GAMES_HOME}(/opt/games) is created and declared as aVOLUME— it's meant to be backed by a named volume atdocker runtime (run.shuses-v dosbox-games:/opt/games) so installed games survive--rmcontainer restarts instead of being re-fetched every time.ENTRYPOINTis/opt/scripts/server_start.sh /opt/scripts/server.sh— the signal-handling wrapper invoked with the real startup script as its argument.
Runtime env vars (set at docker run time, not baked into the image): DISPLAY_NUM (only used to compute the setup port, see below — default 99), SCREEN_W, SCREEN_H (framebuffer geometry, shared by every game's Xvnc instance).
There is no single shared desktop/display for video — unlike the other noVNC-family projects, this one doesn't run one always-on Xvnc+fluxbox+websockify trio for the whole container lifetime. server.sh starts PulseAudio and the audio bridge (see below) once, then execs nothing but setup_server.py; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below). Audio, in contrast, is shared for the container's whole lifetime — see the Audio section below.
Port scheme:
70${DISPLAY_NUM}— the game setup UI (scripts/setup_server.py), plain HTTP.run.shpublishes this as7099:7099.71${DISPLAY_NUM}— the shared audio WebSocket (AUDIO_PORT,7199by default) — see Audio below.run.shpublishes this as7199:7199.5990-5999— per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family).8090-8099— per-game noVNC over HTTPS/WSS, one port per concurrent game slot (MAX_CONCURRENT_GAMES = 10insetup_server.py).run.shpublishes the whole range (-p 8090-8099:8090-8099) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started.60${DISPLAY_NUM}—EXPOSEd for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (nosocatcall) — this project doesn't do that, unlikedocker-xserver-novnc/docker-sdr-novnc.
run.sh hardcodes DISPLAY_NUM=99 (implicitly, via the image default), only relevant to the 7099 setup port; update it if DISPLAY_NUM ever changes.
Game installation & lifecycle (scripts/setup_server.py), the key thing that differs from the other noVNC-family projects:
-
A stdlib-only Python
ThreadingHTTPServer(no third-party deps, no pip installs) — the only thingserver.shstarts. No Xvnc/fluxbox/websockify run until a game is actually started. -
Games are not hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own
<name>.jsonmanifest living in theGames\dosbox\catalog directory on the SMB share, withname/title/zip_path/release_date/publisher/programsfields — see the module docstring insetup_server.pyfor the exact schema.discover_games()lists*.jsonunderGames\dosbox\(smbclient -N ... -c 'ls Games\dosbox\*.json'),smbclient gets each one into/tmp/manifests/, and parses it — so dropping a new<name>.jsonon the share is enough to make a game appear, no image rebuild needed.zip_pathis a full path relative to the share root (e.g.Games/Monkey Island/The Secret of Monkey Island.zip), not assumed to live next to its manifest — manifests always live in theGames\dosbox\catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g.Games\Monkey Island\,Games\Indiana Jones\).release_date/publisherare static declared metadata (historical facts, can't be derived from the share); install size deliberately is not a manifest field —get_zip_sizes()looks it up live via a batchedsmbclient ls(onels "<path>"per game in a single connection, results split on each command'sN blocks ... availabletrailer) so it can't go stale if a zip is replaced. -
programsis a list, not a single command, so a game can expose more than one runnable binary — e.g.duke/dn3dlist bothplay(duke3d.exe) andsetup(setup.exe, DOSBox/Build-engine's interactive hardware-configuration wizard, distinct from the game itself). Every manifest on the share has at least one entry (most just have a single{"id": "start", "label": "Start", ...}, preserving the plain "Start" button they had before this existed). Each entry gets its own button in the "Installed" row (doAction('start', name, program_id));POST /startreads theprogramform field andstart_game()looks up the matching entry byid, using itscmdin place of the old singlestart_cmd. Still only one program per game slot at a time — starting a second program while the first is running is refused the same way restarting an already-running game is (is_running()doesn't care which program is active, just whether the tracked process is alive). The active program'slabelis stashed inrunning_procs[name]["program_label"]purely for display, so the "Running" status can show e.g. "Running (Setup)" instead of just "Running" now that the game name alone doesn't imply which binary is active. -
GET /renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a"{used}/{MAX_CONCURRENT_GAMES} game slots in use"line at the top. None of the action buttons are<form>s — every one is a bare<button onclick="doAction('<action>','<name>')">;doAction()(client-side JS in the page itself)fetch()s the action and then callsrefresh(), which re-fetch()es/and replaces#content'sinnerHTMLin place. The page itself never navigates. This matters a lot — see Audio below for why a real<form>submit here was an actual bug, not just a style choice. -
POST /install:smbget -au(guest auth) the manifest'szip_pathinto a/tmp/setup-<name>scratch dir, thenunzip -o -d ${GAMES_HOME}.smbget -a(guest) cannot be combined with-o(output-file) — they conflict on the underlying-Uoption — so the downloadcds into the scratch dir and letssmbgetsave under its default filename instead. Installed-ness is just "non-empty${GAMES_HOME}/<name>directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g.monkey2.zip→monkey2/— convenient, not enforced by the code). -
POST /uninstall:rm -rf ${GAMES_HOME}/<name>. Refused while the game is running (stop it first) — there's no auto-stop-then-delete. -
POST /start: each game gets its own ephemeral X session, not a shared one._free_display_num()picks an unused display fromGAME_DISPLAY_NUMS(:90-:99, one perMAX_CONCURRENT_GAMES = 10slot); if none is free, the attempt is refused andgame_errors[name]is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale/tmp/.X{N}-lock//tmp/.X11-unix/X{N}for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then startXvnc :{N},fluxbox, andwebsockify(bridging59{N}→80{N}, no-D/daemonize flag — needs to stay a normal foreground child so itsPopenhandle actually reflects whether it's still alive, unlike the old shared-desktopserver.shwhich didn't care), wait ~1s for Xvnc to bind, thensubprocess.Popen(program["cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"})whereprogramis the manifest'sprogramsentry matching theprogram_idthe client sent. All fourPopenhandles plus the display/port numbers are tracked together inrunning_procs[name]. -
POST /stop: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), eachterminate()d then escalated tokill()after a 5s grace period, and cleans up the stale-lock files for that display. -
is_running(name)does the same full teardown lazily if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop.render_page()calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling. -
Each running game's row shows its own "Open Screen" link (only while
Running) — a plain top-level link straight to that game's own noVNC URL (https://<host>:<novnc_port>/, opens in a new tab). Deliberately not an iframe — see Audio below for why. -
Per-game install progress (
install_state), start errors (game_errors), and the running-process table (running_procs) are all guarded by onethreading.RLock(reentrant — several code paths callis_running()from inside a block that already holds the lock). The page keeps itself current viasetInterval(refresh, 3000)(client-side JS, not a server-driven<meta http-equiv="refresh">— that would navigate the page and kill the persistent audio connection, see Audio below), so a crashed/exited game's slot shows as freed within a few seconds without ever reloading the page. -
Manifests on the share today:
stuntcar,duke,dn3d(DOSBox),monkey/monkey2/atlantis/indy3/tentacle(ScummVM).t7gstill has no manifest and won't appear on the setup page until one's added.duke/dn3d's zips aren't the pre-existing ones underGames\— they're re-zipped copies of real played installs found underMiscOS\MSDos\games\, uploaded alongside those source folders (zip_pathpoints there, not intoGames\dosbox\); seeTODO.mdfor how that comparison turned them up, and a known audio gap specific to these two (video confirmed working via screenshot, but their carried-overduke3d.cfgdoesn't seem to have working DOSBox-SB16-compatible sound settings).
Playing a game — two overlapping paths right now:
- The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game, gives it its own screen, and is capped at 10 concurrent.
game.sh <name>→docker exec -it dosbox-novnc start_game.sh <name>, running inside the already-running container.scripts/start_game.shhardcodesDOSBOX_VERSION="0.74-3"and therun.bat/dosbox-<version>.confconvention, and — since there's no more a shared default display at all — has no$DISPLAYto render onto unless one happens to be set in the shell's environment. This path predates both the manifest system and the per-game-screen rework and was deliberately left as-is; it's increasingly out of step with the current architecture and worth removing or reworking once every remaining game has a manifest — seeTODO.md.
Audio — VNC/noVNC only ever streams video, so game sound needs a completely separate path to the browser:
/etc/asound.conf(pcm.!default pulse/ctl.!default pulse, set at build time) routes ALSA's default device through PulseAudio, sodosbox/scummvmneed zero special configuration — confirmed viapactl list sink-inputsthat a running game shows up as a normal, unmuted, uncorked PulseAudio client with no extra flags.server.shstartspulseaudio --start --exit-idle-time=-1once, then${SCRIPTS_HOME}/pcm_ws_bridge.py ${AUDIO_PORT} parec --device=@DEFAULT_MONITOR@ --format=s16le --rate=48000 --channels=2 --raw --latency-msec=20(backgrounded) beforeexec-ingsetup_server.py.@DEFAULT_MONITOR@is the correct macro —@DEFAULT_SINK@.monitor(concatenating the sink macro with a literal.monitorsuffix) looks like it should work but fails withStream error: Invalid argument; this cost real debugging time, don't reintroduce it.pcm_ws_bridge.pyandpcm-worklet.jsare copies fromdocker-common/scripts/(same copy-not-symlink convention assignals.sh) — genuinely reusable, nothing dosbox-specific in either file.pcm_ws_bridge.pyis a small hand-rolled stdlib-only WebSocket server (handshake viasocket/hashlib/base64, manual binary frame writing) since nowebsocat-equivalent package exists in this Debian release and adding a pip dependency would breaksetup_server.py's stdlib-only philosophy; it just runs an arbitrary command and relays its stdout to every connected client as binary frames, one-directional (server → browser) by design.pcm-worklet.jsis the matchingAudioWorkletProcessor: converts incoming Int16 PCM to Float32 and plays it through a small ring buffer that absorbs network jitter — capped at ~100ms, dropping the oldest excess on overflow, which is load-bearing (seedocker-common/CLAUDE.mdfor why: an uncapped queue was the real cause of ~2s of latency that got worse over time and made volume changes take seconds to be heard).- Audio is a single shared mix, not per-game — every running game's audio ends up mixed into PulseAudio's one default sink (normal PulseAudio behavior, multiple clients to one sink just mix), and there is exactly one bridge process/port (
AUDIO_PORT) for the container's entire lifetime, independent of any individual game's start/stop. This was a deliberate simplification from an earlier per-slot-isolated design (one sink/bridge/port per game slot, mirroring the video architecture) that was designed and then dropped as unnecessary complexity — the trade-off is that with more than one game running you can't tell their audio apart. - Audio is on by default on the main setup page (
render_page()), not a per-game page, and needs no button. TheAudioContext,/pcm-worklet.js(audioWorklet.addModule), and theWebSockettoAUDIO_PORTall connect eagerly on page load, piping incoming binary messages into the worklet node. The only thing gated on a user gesture (browsers won't produce sound otherwise) isctx.resume(), called by a one-shot listener on the page's very firstclick/keydown— whatever the user was already doing (e.g. clicking Start) — not a dedicated audio-only button. Meant to be left running in its own tab for as long as you're playing.hostis read server-side from the incoming request'sHost:header — no client-side hostname-guessing JS needed. - The page must never navigate, or the audio connection dies — this was a real, found bug, not just style. Install/Start/Stop/Uninstall used to be
<form method="post">submits; every click caused a full page reload via the server's303redirect, tearing down whateverAudioContexthad just connected. The fresh page after that reload creates a new, suspended context with no further gesture to unlock it (the reload itself doesn't count as one), so in completely ordinary use — load the page, click Start — audio never actually played. The tell was that Chrome's tab never showed its audio-playing speaker icon. Fixed by removing<form>s entirely: every button isonclick="doAction(action, name)",do_POSTreturns a bare204instead of a303/Locationredirect, anddoAction()/refresh()(both inline JS inrender_page())fetch()the action plus the updated page and swap only#content'sinnerHTML— the page itself never navigates, so theAudioContext/WebSocketset up outside#contentsurvive every action. - Per-game volume, despite the shared mix:
POST /volume(name,level0-150) callsset_volume(), which finds that game's PulseAudio sink-input — matchingpactl -f json list sink-inputs'properties["application.process.id"]againstrunning_procs[name]["game"].pid— and runspactl set-sink-input-volume <index> <level>%. Works because every game is still its own distinct sink-input even though they all feed the same default sink; PulseAudio already supports adjusting one independently of the others, no per-game audio isolation required. Each running game's row has a<input type="range">thatfetch()s this route on everyoninputtick (not a form submit, so no page navigation per drag step). - Video and audio are deliberately on separate pages, not one
<iframe>-based wrapper. An earlier version hadGET /screen/<name>render an iframe onto noVNC plus the audio button on one page — dropped after finding that Firefox refuses to load iframe content signed by a certificate whose warning hasn't been accepted at the top level, with no way to click through inside the iframe (Chrome is more lenient, which is why this briefly looked fine when only tested there). Now "Open Screen" just opens noVNC's own URL directly in a new tab, where the normal top-level "Accept the Risk and Continue" flow applies. - The self-signed cert's
CN/SAN/extensions all matter for Firefox specifically. Generated withCN=localhost+subjectAltName=DNS:localhost,IP:127.0.0.1(not the meaningless placeholderCN=NYthis project originally inherited) — Firefox validates the WebSocket-Secure connection's certificate against the hostname independently of the page load, and rejected a CN mismatch there even after the top-level HTTPS warning had been accepted. Only covers access vialocalhost/127.0.0.1; a LAN IP or other hostname will hit the same mismatch, since a static cert can't be pre-populated with every possible address. Also needs explicitbasicConstraints=critical,CA:FALSEpluskeyUsage/extendedKeyUsage=serverAuth— without them,openssl req -x509 -addext ...defaults toCA:TRUE, and Firefox hard-refuses (no override option at all, unlike a normal self-signed warning) a CA-flagged cert used as a TLS server cert. If the cert'sopenssl reqinvocation in the Dockerfile is ever touched again, keep all of these-addextflags together. - Realistic end-to-end latency lands somewhere in the tens-to-~150ms range (
parec's own buffering plus the worklet's small jitter-absorbing ring buffer) — not literally "tens of ms" best-case, but nowhere near the 1-3s a compressed-stream (ffmpeg→ mp3/ogg →<audio>tag) approach would cost, which is why that approach was rejected up front.
Signal handling (scripts/server_start.sh + scripts/signals.sh): identical pattern to the other noVNC-family projects — Docker sends SIGTERM to PID 1 on docker stop, server_start.sh traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises 128 + signal number as its own exit code. Generic ($APP passed as an argument), not specific to server.sh.
Sizing history
See TODO.md for the detailed before/after of the size-reduction work (2.31GB → 1.6GB → ~690MB) — the short version: merge build layers so intermediate files (downloaded zips, build-only packages) don't persist, then stop embedding the actual game data in the image at all and fetch it into a volume on demand instead.