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
202 lines
15 KiB
Markdown
202 lines
15 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.
|
|
|
|
- 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.
|