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
7.3 KiB
TODO
-
~
Reduce image size ofDone in two steps:jayfield/dosbox-novnc(currently2.3GB).- 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.
- Stopped baking the games into the image at all (927MB of that was just t7g's two CD
ISOs).
run.shmounts a named volume (dosbox-games) so installed games persist across--rmrestarts. 1.6GB -> ~690MB. Container now needs network access tovlda-01at runtime (not just build time) to install games. - Dropped the auto-download at container startup entirely.
scripts/setup_server.pyis a small stdlib-only HTTP server that serves an HTML page on${SETUP_PORT}(70${DISPLAY_NUM}, published as7099byrun.sh) listing every*.zipon 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
*.zipfiles on the share.setup_server.pylists*.jsonmanifests instead (one per game, in theGames\dosbox\catalog directory), each declaringname/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 astuntcar-only pilot, now extended to 6 games (see the SCUMM item below).t7gstill has no manifest and won't show up in the setup page until one is added (t7g.jsonwith astart_cmdfor itsdosbox-0.74-3.conf).game.sh/scripts/start_game.sh(the olddocker exec-based launch path, with its hardcodedDOSBOX_VERSION/run.batconvention) 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 gamesDone:scummvmadded to the Dockerfile (/usr/games/scummvm, not on$PATHby default under a non-login shell — manifests use the absolute path). Manifests live in the usualGames\dosbox\*.jsoncatalog directory on the share, but theirzip_pathnow 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 barezipfilename (implicitly underGames\dosbox\) to a fullzip_pathrelative to the share root, since these games weren't colocated with dosbox's. Added and verified end-to-end (install/start/stop/uninstall) viamonkey2:monkey— The Secret of Monkey Islandmonkey2— Monkey Island 2: LeChuck's Revengeatlantis— Indiana Jones and the Fate of Atlantisindy3— Indiana Jones and the Last Crusadetentacle— 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 strayMANIAC/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 onceDone: each running game now gets its own ephemeral X session (Xvnc+fluxbox+websockify, its own display:90-:99and noVNC port8090-8099) spun up bystart_game()and torn down bystop_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 atMAX_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.shno longer starts Xvnc/fluxbox/websockify at container startup at all, onlysetup_server.pyitself; 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.shpublishes the whole8090-8099range 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 playDone: PulseAudio + a hand-rolled stdlib WebSocket bridge (pcm_ws_bridge.py, indocker-common— reusable, not dosbox-specific, per the earlier plan for this item) carries raw PCM to the browser, played back via a Web AudioAudioWorkletNode(pcm-worklet.js, also indocker-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 whilestuntcarwas 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 singleAUDIO_PORT(71${DISPLAY_NUM},7199by 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.monitorsuffix isn't valid.server.shnow startspulseaudio --start --exit-idle-time=-1and the bridge (pcm_ws_bridge.py 7199 parec --device=@DEFAULT_MONITOR@ ...) once, beforeexec-ingsetup_server.py— both live for the container's whole lifetime, independent of any game's start/stop.setup_server.pygainedGET /screen/<name>(an iframe onto the game's noVNC screen plus an "Enable Sound" button — browsers require a user gesture before audio can start) andGET /pcm-worklet.js; the "Open Screen" link is now a plain same-origin relative link (/screen/<name>) instead of the old client-side-JS-built cross-port link, since the wrapper page itself now handles connecting to the right ports (reading the actual host from the request's ownHost:header server-side).