Replace the single start_cmd manifest field with a "programs" list so a
game can expose more than one runnable binary. Duke Nukem 3D's manifests
now offer Play and Setup (DOS sound hardware config), and every ScummVM
manifest offers Start and Manager (ScummVM's own graphical Launcher).
Also fixes two real bugs found along the way:
- All 5 ScummVM manifests were passing the game id as `-f <id>`, but -f
is ScummVM's --fullscreen flag; ScummVM rejected it as a stray argument
and exited before the setup page's poll interval could notice, so
clicking Start silently did nothing. Switched to --auto-detect.
- The page's audio-unlock listener called ctx.resume() once on first
click/keydown and unconditionally removed itself with no .catch(), so
a silently failed first attempt left the AudioContext stuck suspended
forever with no way to retry. Now retries on every click/keydown until
ctx.state actually reports "running".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
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
- Removed the "Enable Sound" button: AudioContext/worklet/WebSocket now
connect eagerly on page load; only ctx.resume() still needs a user
gesture, piggybacking on the page's first click/keypress (e.g.
clicking Start) instead of a dedicated audio-only button.
- Added a per-game volume slider (POST /volume). Despite audio being
one shared PulseAudio mix, this is a real independent control: every
game is still its own distinct sink-input, found by matching
`pactl -f json list sink-inputs`'s application.process.id against
the game's own PID, then `pactl set-sink-input-volume`.
- Real bug: audio still never played after removing the button, because
Install/Start/Stop/Uninstall were still <form method="post"> submits.
Every click caused a full page navigation (303 redirect), tearing
down whatever AudioContext had just connected; the fresh page after
reload creates a new suspended context with no further gesture to
unlock it. Tell: no speaker icon ever appeared on the Chrome tab.
Fixed by removing <form>s entirely - every button is now
onclick="doAction(...)", do_POST returns a plain 204, and client-side
doAction()/refresh() fetch() the action and the updated page, then
swap only #content's innerHTML. The page itself never navigates, so
the audio connection survives every action. setInterval(refresh,
3000) replaces the old <meta refresh> for keeping status current
without that risk.
- Real bug: once audio worked, ~2s of latency that got worse over time
plus multi-second delay before volume changes were audible - fixed
upstream in docker-common's pcm-worklet.js (uncapped playback queue),
propagated here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
Video and audio no longer share one iframe-based /screen/<name> page.
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 this
briefly looked fine there). "Open Screen" is now a plain top-level link
straight to noVNC's own URL (new tab, normal Accept-the-Risk applies),
and the "Enable Sound" toggle moved to the main setup page - meant to
be left open in its own tab while playing. Because that page now hosts
a persistent AudioContext/WebSocket, its auto-refresh no longer fires
just because a game is running (only during active installs), since a
full-page refresh would have killed that connection every 3s.
Two real certificate bugs found and fixed along the way:
- CN=NY (a meaningless placeholder) -> CN=localhost + SAN
(DNS:localhost, IP:127.0.0.1). Firefox validates the WebSocket-Secure
connection's cert against the hostname independently of the page
load and rejected the CN mismatch there even after the page-level
warning was accepted.
- Worse: adding -addext without also pinning basicConstraints=CA:FALSE
left the cert defaulting to CA:TRUE - i.e. flagged as a Certificate
Authority, not a server cert. Firefox hard-refuses that with no
override option at all (not a normal clickable warning), which is
why "the warning appears but won't let me proceed" even after the CN
fix. Fixed by explicitly setting basicConstraints=critical,CA:FALSE
plus keyUsage/extendedKeyUsage=serverAuth. Confirmed fixed: Firefox
now shows the same clickable self-signed warning Chrome always did,
and the noVNC connection completes successfully after accepting it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
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
Previously every game shared one always-on Xvnc/fluxbox/websockify
desktop, so starting two games at once meant they fought over focus on
the same screen and the same ALSA device. Now:
- server.sh no longer starts a shared desktop at all - it just runs
setup_server.py. There's no default display anymore.
- start_game() allocates a free display from GAME_DISPLAY_NUMS
(:90-:99, one per MAX_CONCURRENT_GAMES=10 slot), spins up a fresh
Xvnc+fluxbox+websockify for it, and launches the game with DISPLAY set
to that display. All four processes are tracked together per game.
- stop_game() tears down all four; is_running() does the same lazily if
the game exited on its own (crash/quit), so a slot doesn't stay stuck
just because nobody clicked Stop.
- Starting past the 10-slot cap is refused with an error shown on that
game's row instead of silently failing.
- Each running game's row gets its own "Open Screen" link (client-side
JS, since the port is only known once the game is actually started)
instead of one global noVNC link.
- run.sh publishes the whole 8090-8099 noVNC port range up front, since
Docker can't add port mappings to an already-running container.
Verified end-to-end: two different games running concurrently get fully
independent Xvnc/fluxbox/websockify/game process sets and noVNC
endpoints; stopping one leaves the other untouched; the capacity guard
correctly refuses a start at the limit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
Manifests gain release_date/publisher fields (static historical facts).
Install size deliberately isn't a manifest field - get_zip_sizes() looks
it up live via a batched smbclient ls (one call per game, single
connection, split on each command's trailer line) so it can't go stale
if a zip is replaced.
Add an "Open noVNC Screen" link at the top of the page. Its href is set
by a few lines of client-side JS reading window.location.hostname at
render time, since the noVNC port (NOVNC_PORT, now passed into
setup_server.py by server.sh alongside SETUP_PORT) differs from the
setup port and the container may be reached via different
hostnames/IPs - a fixed server-rendered URL would be wrong.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
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
Games are no longer discovered from *.zip files on the SMB share; each
game now has its own <name>.json manifest (name/title/zip/start_cmd)
living next to its zip, discovered dynamically via smbclient. Nothing
about a game's identity or how to install/start it is hardcoded in the
image anymore. The setup page also grew Start/Stop/Uninstall buttons,
backed by real process tracking (Popen + terminate/kill).
Piloted on stuntcar only (manifest already uploaded to the share); t7g
has no manifest yet so it won't appear until one's added. The old
game.sh/start_game.sh docker-exec launch path is left as-is for now and
overlaps with the new Start button - noted in TODO.md for later cleanup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz
Mirrors docker-xserver-novnc/CLAUDE.md's structure, adapted for this
project's specifics: no build-time game embedding, the runtime volume,
the setup_server.py install flow, and the game.sh/start_game.sh play path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiNnj78HGx1KWyCCo39HSz