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

81 lines
15 KiB
Markdown

# 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:
```bash
# 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 `exec`s 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}``EXPOSE`d 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 get`s 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 `cd`s 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.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 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.