Files
docker-dosbox-novnc/CLAUDE.md
T
jensandClaude Sonnet 5 301503a276 Get game audio into the browser via PulseAudio + a WebSocket bridge
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
2026-07-28 16:27:10 +02:00

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 (Xvnc on $PATH), websockify (websockify on $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), and unzip/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 copies scripts/ to /opt/scripts/.
  • ${GAMES_HOME} (/opt/games) is created and declared as a VOLUME — it's meant to be backed by a named volume at docker run time (run.sh uses -v dosbox-games:/opt/games) so installed games survive --rm container restarts instead of being re-fetched every time.
  • ENTRYPOINT is /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.sh publishes this as 7099:7099.
  • 71${DISPLAY_NUM} — the shared audio WebSocket (AUDIO_PORT, 7199 by default) — see Audio below. run.sh publishes this as 7199: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 = 10 in setup_server.py). run.sh publishes 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 (no socat call) — this project doesn't do that, unlike docker-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 thing server.sh starts. 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>.json manifest living in the Games\dosbox\ catalog directory on the SMB share, with name/title/zip_path/release_date/publisher/start_cmd fields — see the module docstring in setup_server.py for the exact schema. discover_games() lists *.json under Games\dosbox\ (smbclient -N ... -c 'ls Games\dosbox\*.json'), smbclient gets each one into /tmp/manifests/, and parses it — so dropping a new <name>.json on the share is enough to make a game appear, no image rebuild needed. zip_path is 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 the Games\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/publisher are 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 batched smbclient ls (one ls "<path>" per game in a single connection, results split on each command's N blocks ... available trailer) 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's zip_path into a /tmp/setup-<name> scratch dir, then unzip -o -d ${GAMES_HOME}. smbget -a (guest) cannot be combined with -o (output-file) — they conflict on the underlying -U option — so the download cds into the scratch dir and lets smbget save 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.zipmonkey2/ — 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 from GAME_DISPLAY_NUMS (:90-:99, one per MAX_CONCURRENT_GAMES = 10 slot); if none is free, the attempt is refused and game_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 start Xvnc :{N}, fluxbox, and websockify (bridging 59{N}80{N}, no -D/daemonize flag — needs to stay a normal foreground child so its Popen handle actually reflects whether it's still alive, unlike the old shared-desktop server.sh which didn't care), wait ~1s for Xvnc to bind, then subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>, env={..., "DISPLAY": f":{N}"}). All four Popen handles plus the display/port numbers are tracked together in running_procs[name].
  • POST /stop: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each terminate()d then escalated to kill() 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 to GET /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 one threading.RLock (reentrant — several code paths call is_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). t7g still has no manifest and won't appear on the setup page until one's added.

Playing a game — two overlapping paths right now:

  1. 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.
  2. game.sh <name>docker exec -it dosbox-novnc start_game.sh <name>, running inside the already-running container. scripts/start_game.sh hardcodes DOSBOX_VERSION="0.74-3" and the run.bat/dosbox-<version>.conf convention, and — since there's no more a shared default display at all — has no $DISPLAY to 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 — see TODO.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, so dosbox/scummvm need zero special configuration — confirmed via pactl list sink-inputs that a running game shows up as a normal, unmuted, uncorked PulseAudio client with no extra flags.
  • server.sh starts pulseaudio --start --exit-idle-time=-1 once, then ${SCRIPTS_HOME}/pcm_ws_bridge.py ${AUDIO_PORT} parec --device=@DEFAULT_MONITOR@ --format=s16le --rate=48000 --channels=2 --raw --latency-msec=20 (backgrounded) before exec-ing setup_server.py. @DEFAULT_MONITOR@ is the correct macro@DEFAULT_SINK@.monitor (concatenating the sink macro with a literal .monitor suffix) looks like it should work but fails with Stream error: Invalid argument; this cost real debugging time, don't reintroduce it.
  • pcm_ws_bridge.py and pcm-worklet.js are copies from docker-common/scripts/ (same copy-not-symlink convention as signals.sh) — genuinely reusable, nothing dosbox-specific in either file. pcm_ws_bridge.py is a small hand-rolled stdlib-only WebSocket server (handshake via socket/hashlib/base64, manual binary frame writing) since no websocat-equivalent package exists in this Debian release and adding a pip dependency would break setup_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.js is the matching AudioWorkletProcessor: 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> (in setup_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 an AudioContext, loads /pcm-worklet.js via audioWorklet.addModule, and opens a WebSocket to AUDIO_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's Host: 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.