Files
docker-dosbox-novnc/TODO.md
T
jensandClaude Sonnet 5 953e0615fa Add manifests for duke and dn3d (Duke Nukem 3D)
Sourced from real played installs at MiscOS\MSDos\games\duke\ and
\dn3d\ on the share (found while comparing that directory against
Games\), not the pre-existing Games\ zips - these only existed as raw
folders, so they were downloaded, re-zipped with the usual <name>/
top-level convention, and re-uploaded alongside their source folders
(zip_path points there, manifests still live in the usual Games\dosbox\
catalog).

Skipped a third variant, duke3d_w32_bin.zip - turned out to be a native
Windows binary, not DOS, per explicit direction not to add Wine for it.

Verified end-to-end with actual screenshots (xwd/xdotool/imagemagick,
installed temporarily in the running container for testing only): both
games genuinely launch and render real gameplay, not just a menu.
Audio doesn't play for either - traced to their carried-over
duke3d.cfg (required; deleting it breaks the game outright) likely
having a stale sound-hardware selection mismatched with DOSBox's
emulated SB16, not a bug in the audio pipeline itself. Documented as a
known gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
2026-07-28 21:06:33 +02:00

15 KiB

TODO

  • ~Reduce image size of jayfield/dosbox-novnc (currently 2.3GB). Done in two steps:

    1. Merged the game download/extract steps and the unzip/smbclient install into a single layer (so the downloaded zips and the packages only needed to extract them no longer persist in the final image). 2.31GB -> 1.6GB.
    2. Stopped baking the games into the image at all (927MB of that was just t7g's two CD ISOs). run.sh mounts a named volume (dosbox-games) so installed games persist across --rm restarts. 1.6GB -> ~690MB. Container now needs network access to vlda-01 at runtime (not just build time) to install games.
    3. Dropped the auto-download at container startup entirely. scripts/setup_server.py is a small stdlib-only HTTP server that serves an HTML page on ${SETUP_PORT} (70${DISPLAY_NUM}, published as 7099 by run.sh) 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: 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. 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 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.

  • Handle multiple games running at once Done: each running game now gets its own ephemeral X session (Xvnc+fluxbox+websockify, its own display :90-:99 and noVNC port 8090-8099) spun up by start_game() and torn down by stop_game() (or lazily on the next page load, if the game exited/crashed on its own) — instead of every game sharing one screen and fighting over focus/audio. Capped at MAX_CONCURRENT_GAMES = 10; starting an 11th game while all 10 slots are in use is refused with an error shown on its row rather than silently doing nothing. There is no more a single shared "default" desktop or global noVNC link — server.sh no longer starts Xvnc/fluxbox/websockify at container startup at all, only setup_server.py itself; each game's own "Open Screen" link appears on its row only while it's running, built with a little client-side JS (the noVNC port differs from the setup port and can't be known server-side without knowing which hostname the browser used to reach the container). run.sh publishes the whole 8090-8099 range up front, since Docker can't add port mappings to an already-running container. Verified end-to-end with two different games running concurrently (separate processes, separate displays, separate noVNC ports, independent stop/teardown) and the 10-slot cap logic.

  • Get game sound actually audible during play Done: PulseAudio + a hand-rolled stdlib WebSocket bridge (pcm_ws_bridge.py, in docker-common — reusable, not dosbox-specific, per the earlier plan for this item) carries raw PCM to the browser, played back via a Web Audio AudioWorkletNode (pcm-worklet.js, also in docker-common). Verified real end-to-end audio: DOSBox → ALSA (default device, routed through Pulse via /etc/asound.conf) → PulseAudio's one default sink → parec → the bridge → a raw WebSocket client, measured RMS ≈9292 while stuntcar was actually playing (vs. silence before advancing past its title screen) — not just a plumbing check, actual game audio.

    Audio is a single shared mix, not per-game — deliberately simplified from an earlier per-slot-isolated design (considered and dropped as unnecessary complexity): every running game's audio mixes into Pulse's one default sink, and every /screen/<name> page connects to the same single AUDIO_PORT (71${DISPLAY_NUM}, 7199 by default). Trade-off: with more than one game running, you can't tell their audio apart.

    Real bug found and fixed along the way: parec --device=@DEFAULT_SINK@.monitor (the seemingly-obvious macro syntax) fails with "Stream error: Invalid argument" — PulseAudio has a separate single-token macro, @DEFAULT_MONITOR@, for exactly this; concatenating @DEFAULT_SINK@ with a literal .monitor suffix isn't valid.

    server.sh now starts pulseaudio --start --exit-idle-time=-1 and the bridge (pcm_ws_bridge.py 7199 parec --device=@DEFAULT_MONITOR@ ...) once, before exec-ing setup_server.py — both live for the container's whole lifetime, independent of any game's start/stop.

    No iframe, no per-game screen page — tried an <iframe>-based /screen/<name> wrapper first (embedding noVNC + an Enable Sound button on one page), but Firefox refuses to load iframe content signed by a certificate whose warning hasn't been accepted at the top level, with no way to click through inside the iframe (Chrome is more lenient, which is why it briefly looked like it worked). Settled on: "Open Screen" is a plain top-level link straight to noVNC's own URL (opens in a new tab — normal "Accept the Risk and Continue" applies there), and the one "Enable Sound" toggle lives on the main setup page instead, meant to be left open in its own tab for as long as you're playing. Also regenerated the self-signed cert with CN=localhost + SAN (DNS:localhost, IP:127.0.0.1) instead of the old meaningless CN=NY — Firefox validates the WebSocket-Secure connection's cert against the hostname independently of the page load and was rejecting the CN mismatch there even after the page-level warning was accepted. Only fixes access via localhost/127.0.0.1; a LAN IP or other hostname would hit the same mismatch again, since a static cert can't cover every possible address in advance.

    A second, worse cert bug turned up right after: adding -addext without also pinning basicConstraints=CA:FALSE left openssl req -x509 defaulting to CA:TRUE — i.e. the generated cert claimed to be a Certificate Authority, not a normal server certificate. Firefox hard-refuses a CA-flagged cert used as a TLS server cert (not an overridable "Advanced -> Accept the Risk" warning, just a dead end), which is exactly why "the warning appears but won't let me proceed" even after the CN fix above. Chrome is lenient about this too, which is why it kept looking fine there. Fixed by explicitly setting basicConstraints=critical,CA:FALSE, keyUsage=critical,digitalSignature,keyEncipherment, and extendedKeyUsage=serverAuth in the same openssl req -x509 -addext ... call.

    Because the setup page now hosts a persistent AudioContext/WebSocket, its old <meta http-equiv="refresh"> auto-refresh was narrowed to only fire while an install was actively in progress — it used to also refresh continuously whenever any game was running (to catch a crashed game's slot being freed), which would have torn down the live audio connection every 3 seconds. Superseded entirely below by a JS-driven refresh that doesn't navigate the page at all.

    Removed the "Enable Sound" button — audio is on by default now. The AudioContext/ AudioWorkletNode/WebSocket all connect eagerly on page load; the only thing still gated on a user gesture (unavoidable browser autoplay policy) is ctx.resume(), which now piggybacks on the page's very first click or keypress — whatever the user was already doing, e.g. clicking Start on a game — rather than requiring a dedicated audio-only click.

    Added a per-game volume slider. Despite audio being one shared mix, this is a real, independent control: every running game is still its own distinct PulseAudio sink-input even though they all feed the same sink, and pactl set-sink-input-volume adjusts one sink-input without touching the others. _sink_input_index() finds the right one by matching pactl -f json list sink-inputs' application.process.id against the game's own PID (confirmed exact via ps/pactl cross-check). The slider POSTs to a new /volume route via fetch() on every tick (not a form submit — no page navigation per drag step).

    Real bug found right after removing the button: audio still never actually played. Install/Start/Stop/Uninstall were still plain <form method="post"> submits — every click caused a full page navigation (via the server's 303 redirect back to /), which tears down whatever AudioContext was just connected. The fresh page after that reload creates a brand-new suspended context with no further gesture to unlock it (the reload itself doesn't count), so in completely normal usage — load the page, click Start — audio never actually starts. Symptom that nailed it down: no speaker icon ever appeared on the Chrome tab. Fixed by converting the whole page away from form-based navigation entirely: every button is now a bare <button onclick="doAction(...)">, do_POST returns a plain 204 instead of a 303 redirect, and client-side doAction()/refresh() fetch() the action and the updated page, then swap just #content's innerHTML in place — the page itself never navigates, so the audio connection now survives every install/start/stop/uninstall click, and setInterval(refresh, 3000) replaces the old <meta refresh> for keeping status current (crashed-game slot cleanup, install progress) without that risk at all.

    Third real bug, found once audio was actually reaching the speakers: ~2s of latency, visibly getting worse the longer a game ran (lip sync drifting further out over time), and volume-slider changes taking a couple seconds to actually be heard. Root cause: pcm-worklet.js's playback queue (docker-common/scripts/pcm-worklet.js, copied into this project) had no size cap — every incoming WebSocket message just got push()ed on regardless of how fast the network delivered it relative to real-time playback. On a fast local connection the browser routinely receives data faster than 48kHz real-time consumes it, so the backlog only ever grew, never shrank; set-sink-input-volume changes the volume at the PulseAudio source, so anything already sitting in that ever-growing client queue still played at the old volume until it drained, i.e. the whole visible backlog's worth of delay before a slider change was audible. Fixed by capping the queue at ~100ms (maxQueuedFrames) and dropping the oldest excess data whenever a new chunk would push it over that cap — verified directly in Node (stubbing AudioWorkletProcessor/ sampleRate/registerProcessor) that force-feeding a simulated 4.3s burst leaves only ~85ms actually queued afterward, instead of growing unbounded. Propagated to both the docker-common canonical copy and this project's own copy, per the usual convention.

  • Added manifests for duke (Duke Nukem 3D, unregistered shareware — confirmed via screenshot, "UNREGISTERED SHAREWARE" watermark and the nag screen, despite the folder name) and dn3d (Duke Nukem 3D: Atomic Edition), both real DOS Build-engine games, both DOSBox manifests. Sourced from real played installs at MiscOS\MSDos\games\duke\/dn3d\ on the share (found by comparing that directory against Games\, which turned up several installs — including these — not present as zips anywhere) rather than the existing Games\ zips: these are raw, unzipped folders (with real save games, duke.rts, etc.), so they were downloaded, re-zipped locally with the usual <name>/ top-level-folder convention, and re-uploaded as dn3d.zip/duke.zip alongside the source folders on the share (manifests, as always, still live in the Games\dosbox\ catalog regardless).

    A third variant found in the same comparison, Games\duke3d_w32_bin.zip, turned out to be a native Windows binary (duke3d_w32.exe, needs mfc70.dll/msvcr70.dll) rather than a DOS game — DOSBox can't run it; would need Wine, a real new dependency. Skipped per explicit direction rather than expanding scope.

    Verified end-to-end with screenshots (xwd/imagemagick, installed temporarily for testing only, not part of the image) — both games genuinely launch and render actual gameplay (HUD, level geometry, taking hits), not just a menu. Audio did not play for either despite pactl showing dosbox as a normal, uncorked sink-input — tracked down to their carried-over duke3d.cfg (deleting it entirely breaks the game outright, forcing SETUP.EXE, so it's required; likely has a stale/mismatched sound-hardware selection from whatever machine last ran SETUP.EXE for real, rather than DOSBox's emulated SB16). Not a bug in the audio pipeline itself — already proven working end-to-end for other games in the sound TODO item above. Left as a known gap for these two specifically; fixing it would mean either reverse-engineering duke3d.cfg's binary format to patch the stored IRQ/DMA, or finding a way to script SETUP.EXE's interactive hardware wizard.