Add scummvm to the Dockerfile. Its binary lives at /usr/games/scummvm (Debian's convention for game packages), not reliably on $PATH under a non-login shell, so manifests reference it by absolute path. Generalize the manifest schema from a bare "zip" filename (implicitly under Games\dosbox\ on the SMB share) to "zip_path" (a full path relative to the share root), since the SCUMM games' zips live in their own folders (Games\Monkey Island\, Games\Indiana Jones\, etc.) rather than being colocated with their manifest like stuntcar/t7g were. Manifests themselves still all live in the Games\dosbox\ catalog directory regardless of where the actual zip sits. Added and verified (install/start/stop/uninstall via monkey2) manifests for: monkey, monkey2, atlantis, indy3, tentacle. Skipped the German CD release of Day of the Tentacle (loose files at the zip root, no single top-level folder - incompatible with the current extraction convention) and Curse of Monkey Island (untested, much larger). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
68 lines
8.7 KiB
Markdown
68 lines
8.7 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). 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) 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`/`alsa-utils` (the actual game runtime) and `unzip`/`smbclient`/`python3` (needed permanently at *runtime* now, not just at build time — see below), 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` (X display number, default 99 → `DISPLAY=:99`), `SCREEN_W`, `SCREEN_H` (framebuffer geometry).
|
|
|
|
**Port scheme**, all derived from `DISPLAY_NUM`:
|
|
- `59${DISPLAY_NUM}` — raw VNC (RFB), only bound to localhost, not published directly.
|
|
- `80${DISPLAY_NUM}` — noVNC over HTTPS/WSS. This is the port users connect to from a browser to see/play the game.
|
|
- `60${DISPLAY_NUM}` — `EXPOSE`d for consistency with the other noVNC-family projects, but nothing in `server.sh` actually bridges X11-over-TCP here (no `socat` call) — this project doesn't wire that up, unlike `docker-xserver-novnc`/`docker-sdr-novnc`.
|
|
- `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`.
|
|
|
|
`run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default) and the `7099` port mapping; update it if `DISPLAY_NUM` ever changes. Note `run.sh` does *not* currently publish the `59xx`/`80xx` noVNC ports to the host — only `7099` (setup) is mapped, plus whatever the container's on the same Docker network can reach directly.
|
|
|
|
**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) started in the background by `server.sh` alongside Xvnc/fluxbox/websockify.
|
|
- 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`/`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\`).
|
|
- `GET /` renders one row per discovered manifest with its live status (Not installed / downloading / extracting / Installed / Running / error) and the matching action buttons.
|
|
- `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`: `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/<name>)`, tracked in an in-process dict keyed by game name (`running_procs`). This inherits `setup_server.py`'s own environment, including `$DISPLAY`, so the launched process renders onto the same Xvnc display fluxbox/noVNC are already serving — no separate X setup needed.
|
|
- `POST /stop`: `terminate()`s the tracked process, escalating to `kill()` after a 5s grace period.
|
|
- Per-game install progress (`install_state`) and the running-process table (`running_procs`) are both guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page does a `<meta http-equiv="refresh">` every 3s while any install is in flight; no client-side JS anywhere.
|
|
- **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.
|
|
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 — this predates the manifest system and was deliberately left as-is during the pilot. The two paths don't know about each other (e.g. a game started via `game.sh` won't show as "Running" on the setup page). Worth reconciling once manifests cover every game — see `TODO.md`.
|
|
|
|
**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.
|