VNC/noVNC only ever streams video, so game sound needed a completely separate path. Adds pulseaudio/pulseaudio-utils/libasound2-plugins and routes ALSA's default device through Pulse (/etc/asound.conf), so dosbox/scummvm need zero special config. server.sh starts one PulseAudio daemon and one pcm_ws_bridge.py (from docker-common) for the container's whole lifetime, capturing Pulse's single default sink via parec. Audio is a single shared mix, not per-game - considered and dropped a per-slot-isolated design (mirroring the video architecture) as unnecessary complexity per direction. Every running game's audio just mixes into the one default sink; every /screen/<name> page connects to the same AUDIO_PORT. setup_server.py gains GET /screen/<name> (an iframe onto the game's noVNC screen plus an Enable Sound button - browsers require a user gesture before audio can start) and GET /pcm-worklet.js. The "Open Screen" link simplifies from a client-side-JS-built cross-port link to a plain same-origin relative link, since /screen/<name> now reads the real host server-side from the request's own Host header. Found and fixed a real bug along the way: parec --device=@DEFAULT_SINK@.monitor looks correct but fails with "Stream error: Invalid argument" - the actual PulseAudio macro is the single token @DEFAULT_MONITOR@. Verified end-to-end with real audio, not just plumbing: confirmed via `pactl list sink-inputs` that dosbox connects to Pulse correctly (unmuted, uncorked), then used xdotool to advance stuntcar past its silent title screen and captured real audible game audio (RMS ~9292) through the WebSocket bridge with a raw Python client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
15 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/start_cmdfields — 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. 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.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(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"}). 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 same-origin relative link toGET /screen/<name>(see Audio below for what that route actually renders). - 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 auto-refreshes every 3s while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything. - Manifests on the share today:
stuntcar(DOSBox),monkey/monkey2/atlantis/indy3/tentacle(ScummVM).t7gstill has no manifest and won't appear on the setup page until one's added.
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.- 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. GET /screen/<name>(insetup_server.py) is what the "Open Screen" link on the main page actually points at: a small server-rendered page with an<iframe>onto that game's noVNC URL, plus an "Enable Sound" button (browsers require a user gesture before audio can start) that creates anAudioContext, loads/pcm-worklet.jsviaaudioWorklet.addModule, and opens aWebSockettoAUDIO_PORT, piping incoming binary messages into the worklet node. The host used to build the iframe/WebSocket URLs is read server-side from the incoming request'sHost:header — no client-side hostname-guessing JS needed for this page (unlike the old global noVNC link it replaced).- 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.