Add ScummVM support and generalize manifest zip lookup

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
This commit is contained in:
2026-07-28 14:29:11 +02:00
co-authored by Claude Sonnet 5
parent 17426584cd
commit afb18f1b38
4 changed files with 50 additions and 26 deletions
+5 -5
View File
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
A Docker image definition for headless DOSBox 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`/`alsa-utils` 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.
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 (DOS game installs, sourced as zips from an internal SMB share `//vlda-01/software/Games/dosbox/`) is deliberately **not** baked into the image — one of the two 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).
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
@@ -47,14 +47,14 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server.
**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 next to its zip on the SMB share (e.g. `Games\dosbox\stuntcar.json`), with `name`/`title`/`zip`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` on the share (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `<name>.json` + `<name>.zip` pair on the share is enough to make a game appear, no image rebuild needed.
- 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` 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.
- `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.
- **Only `stuntcar.json` exists on the share right now** — this whole manifest/start/stop/uninstall mechanism was rolled out as a pilot on that one (small, fast-to-test) game before being extended to `t7g`. Until `t7g.json` is added, `t7g` simply won't appear on the setup page even though its zip is still there.
- **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.
+1 -1
View File
@@ -11,7 +11,7 @@ ENV PATH=${PATH}:${SCRIPTS_HOME}
# image, setup_server.py serves an HTML install UI (backed by smbget+unzip) that the user
# drives on ${SETUP_PORT} instead of anything being auto-downloaded at container start.
RUN apt-get update && \
apt-get -y install --no-install-recommends dosbox alsa-utils unzip smbclient python3 && \
apt-get -y install --no-install-recommends dosbox scummvm alsa-utils unzip smbclient python3 && \
rm -rf /var/lib/apt/lists/*
RUN openssl req -x509 -nodes -newkey rsa:2048 -keyout /tmp/novnc.key -out /tmp/novnc.pem -days 3650 -subj "/C=US/ST=NY/L=NY/O=NY/OU=NY/CN=NY emailAddress=email@example.com"
+36 -16
View File
@@ -14,24 +14,44 @@
listing every `*.zip` on the SMB share with an Installed/Install status per game; the
user clicks "Install" to fetch+extract a specific game into `${GAMES_HOME}` on demand.
- **Manifest-driven games + Start/Stop/Uninstall (test balloon, `stuntcar` only)**: games are
no longer discovered from raw `*.zip` files on the share. `setup_server.py` now lists
`*.json` manifests instead (one per game, e.g. `Games\dosbox\stuntcar.json`), each
declaring `name`/`title`/`zip`/`start_cmd`. The web page grew Start/Stop/Uninstall buttons
- **Manifest-driven games + Start/Stop/Uninstall**: games are no longer discovered from raw
`*.zip` files on the share. `setup_server.py` lists `*.json` manifests instead (one per
game, in the `Games\dosbox\` catalog directory), each declaring
`name`/`title`/`zip_path`/`start_cmd`. The web page grew Start/Stop/Uninstall buttons
alongside Install, driven entirely by that manifest — nothing about a game's identity or
how to run it is hardcoded in the image anymore. Only `stuntcar.json` exists on the share
so far (as a deliberate pilot); `t7g` has no manifest yet and will not show up in the setup
page until one is added (`t7g.json` with a `start_cmd` for its `dosbox-0.74-3.conf`).
`game.sh`/`scripts/start_game.sh` (the old `docker exec`-based launch path, with its
hardcoded `DOSBOX_VERSION`/`run.bat` convention) were intentionally left untouched for this
pilot and now overlap with the new Start button — worth reconciling (or removing) once the
manifest approach is rolled out to all games.
how to run it is hardcoded in the image anymore. Started as a `stuntcar`-only pilot, now
extended to 6 games (see the SCUMM item below). `t7g` still has no manifest and won't show
up in the setup page until one is added (`t7g.json` with a `start_cmd` for its
`dosbox-0.74-3.conf`). `game.sh`/`scripts/start_game.sh` (the old `docker exec`-based
launch path, with its hardcoded `DOSBOX_VERSION`/`run.bat` convention) were intentionally
left untouched during the pilot and still overlap with the new Start button — worth
reconciling (or removing) once every remaining game has a manifest.
- Create manifests for SCUMM engine games (Monkey Island, Day of the Tentacle, Indiana Jones,
etc.). Note these are usually best run via ScummVM rather than raw DOSBox — `scummvm` isn't
installed in the image yet, so this may need a Dockerfile change (add the `scummvm`
package) alongside the manifests themselves, and each manifest's `start_cmd` would invoke
`scummvm` with the right game-id instead of `dosbox run.bat -conf ...`.
- ~~Create manifests for SCUMM engine games~~ Done: `scummvm` added to the Dockerfile
(`/usr/games/scummvm`, not on `$PATH` by default under a non-login shell — manifests use
the absolute path). Manifests live in the usual `Games\dosbox\*.json` catalog directory on
the share, but their `zip_path` now points at wherever the game's zip actually already
lived (`Games\Monkey Island\...`, `Games\Indiana Jones\...`, etc.) — this required
generalizing the manifest schema from a bare `zip` filename (implicitly under
`Games\dosbox\`) to a full `zip_path` relative to the share root, since these games weren't
colocated with dosbox's. Added and verified end-to-end (install/start/stop/uninstall) via
`monkey2`:
- `monkey` — The Secret of Monkey Island
- `monkey2` — Monkey Island 2: LeChuck's Revenge
- `atlantis` — Indiana Jones and the Fate of Atlantis
- `indy3` — Indiana Jones and the Last Crusade
- `tentacle` — Day of the Tentacle
Skipped the German CD release of Day of the Tentacle on the share (`Day Of The Tentacle
(CD DOS, German).zip`) — unlike every other zip here, it doesn't wrap its contents in a
single top-level folder (loose files at the zip root plus a stray `MANIAC/` dir for the
embedded Maniac Mansion easter egg), so it doesn't fit the current "zip's top-level folder
== install name" extraction convention. Also skipped Curse of Monkey Island (much larger,
untested) — could be added the same way if wanted.
ScummVM's own game-target IDs (`monkey`, `monkey2`, `atlantis`, `indy3`, `tentacle`)
happened to exactly match each zip's existing top-level folder name, so no change was
needed to the install/extraction logic itself, only to where zips are looked up from.
- Get game sound actually audible during play. `--device /dev/snd` is passed through and
`alsa-utils` is installed, so DOSBox can write to an ALSA device inside the container, but
+8 -4
View File
@@ -11,11 +11,15 @@ Manifest schema (all fields required):
{
"name": "stuntcar", # must match the manifest's own filename
"title": "Stuntcar Racer",
"zip": "stuntcar.zip", # filename under SMB_DIR to fetch on install
"zip_path": "Games/dosbox/stuntcar.zip", # path to the zip, relative to the share root -
# games can live anywhere on the share, not just
# next to their manifest
"start_cmd": ["dosbox", "run.bat", "-conf", "dosbox-0.74-3.conf"]
}
Uninstall is implicit (remove GAMES_HOME/<name>); stop is implicit (terminate
the process tracked from the last start_cmd).
the process tracked from the last start_cmd). Manifests themselves always live
in SMB_DIR (the catalog directory), even though the zip_path they point at may
not.
"""
import html
@@ -95,12 +99,12 @@ def install_game(name, manifest):
tmpdir = f"/tmp/setup-{name}"
try:
os.makedirs(tmpdir, exist_ok=True)
zip_name = manifest["zip"]
zip_name = manifest["zip_path"].rsplit("/", 1)[-1]
zip_path = os.path.join(tmpdir, zip_name)
# smbget's -a (guest) can't be combined with -o (output file), so let it save
# under its default name in tmpdir instead.
subprocess.run(
["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/Games/dosbox/{zip_name}"],
["smbget", "-au", f"smb://{SMB_SERVER}/{SMB_SHARE}/{manifest['zip_path']}"],
check=True, timeout=1800, cwd=tmpdir,
)
with state_lock: