Files
docker-dosbox-novnc/TODO.md
T
jensandClaude Sonnet 5 588086e82c Audio on by default, per-game volume, and fix real navigation + latency bugs
- 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
2026-07-28 19:15:51 +02:00

174 lines
13 KiB
Markdown

# 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.