diff --git a/CLAUDE.md b/CLAUDE.md index 07e34c0..9f99fe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,30 +35,34 @@ There's no automated way to exercise `scripts/server.sh`, `scripts/setup_server. - `${GAMES_HOME}` (`/opt/games`) is created and declared as a `VOLUME` — it's meant to be backed by a named volume at `docker run` time (`run.sh` uses `-v dosbox-games:/opt/games`) so installed games survive `--rm` container restarts instead of being re-fetched every time. - `ENTRYPOINT` is `/opt/scripts/server_start.sh /opt/scripts/server.sh` — the signal-handling wrapper invoked with the real startup script as its argument. -**Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (X display number, default 99 → `DISPLAY=:99`), `SCREEN_W`, `SCREEN_H` (framebuffer geometry). +**Runtime env vars** (set at `docker run` time, not baked into the image): `DISPLAY_NUM` (only used to compute the setup port, see below — default 99), `SCREEN_W`, `SCREEN_H` (framebuffer geometry, shared by every game's Xvnc instance). -**Port scheme**, all derived from `DISPLAY_NUM`: -- `59${DISPLAY_NUM}` — raw VNC (RFB), only bound to localhost, not published directly. -- `80${DISPLAY_NUM}` — noVNC over HTTPS/WSS. This is the port users connect to from a browser to see/play the game. -- `60${DISPLAY_NUM}` — `EXPOSE`d for consistency with the other noVNC-family projects, but nothing in `server.sh` actually bridges X11-over-TCP here (no `socat` call) — this project doesn't wire that up, unlike `docker-xserver-novnc`/`docker-sdr-novnc`. +There is **no single shared desktop/display** — unlike the other noVNC-family projects, this one doesn't run one always-on `Xvnc`+`fluxbox`+`websockify` trio for the whole container lifetime. `server.sh` starts nothing but `setup_server.py`; every game gets its own ephemeral X session, created when it's started and torn down when it's stopped (see below). + +**Port scheme**: - `70${DISPLAY_NUM}` — the game **setup UI** (`scripts/setup_server.py`), plain HTTP. `run.sh` publishes this as `7099:7099`. +- `5990`-`5999` — per-game raw VNC (RFB), one port per concurrent game slot, not published (matches the "RFB stays localhost-only" convention elsewhere in the noVNC family). +- `8090`-`8099` — per-game noVNC over HTTPS/WSS, one port per concurrent game slot (`MAX_CONCURRENT_GAMES = 10` in `setup_server.py`). `run.sh` publishes the whole range (`-p 8090-8099:8090-8099`) up front, since Docker can't add port mappings to an already-running container — a game's actual assigned port within that range is only known once it's started. +- `60${DISPLAY_NUM}` — `EXPOSE`d for consistency with the other noVNC-family projects, but nothing wires up X11-over-TCP here (no `socat` call) — this project doesn't do that, unlike `docker-xserver-novnc`/`docker-sdr-novnc`. -`run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default) and the `7099` port mapping; update it if `DISPLAY_NUM` ever changes. Note `run.sh` does *not* currently publish the `59xx`/`80xx` noVNC ports to the host — only `7099` (setup) is mapped, plus whatever the container's on the same Docker network can reach directly. +`run.sh` hardcodes `DISPLAY_NUM=99` (implicitly, via the image default), only relevant to the `7099` setup port; update it if `DISPLAY_NUM` ever changes. **Game installation & lifecycle** (`scripts/setup_server.py`), the key thing that differs from the other noVNC-family projects: -- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) started in the background by `server.sh` alongside Xvnc/fluxbox/websockify. +- A stdlib-only Python `ThreadingHTTPServer` (no third-party deps, no pip installs) — the *only* thing `server.sh` starts. No Xvnc/fluxbox/websockify run until a game is actually started. - Games are **not** hardcoded anywhere in the image or discovered from raw zip files. Each game is described by its own `.json` manifest living in the `Games\dosbox\` catalog directory on the SMB share, with `name`/`title`/`zip_path`/`release_date`/`publisher`/`start_cmd` fields — see the module docstring in `setup_server.py` for the exact schema. `discover_games()` lists `*.json` under `Games\dosbox\` (`smbclient -N ... -c 'ls Games\dosbox\*.json'`), `smbclient get`s each one into `/tmp/manifests/`, and parses it — so dropping a new `.json` on the share is enough to make a game appear, no image rebuild needed. `zip_path` is a full path relative to the share root (e.g. `Games/Monkey Island/The Secret of Monkey Island.zip`), **not** assumed to live next to its manifest — manifests always live in the `Games\dosbox\` catalog regardless of where the actual game data sits on the share (the SCUMM games' zips live in their own folders, e.g. `Games\Monkey Island\`, `Games\Indiana Jones\`). `release_date`/`publisher` are static declared metadata (historical facts, can't be derived from the share); install size deliberately is **not** a manifest field — `get_zip_sizes()` looks it up live via a batched `smbclient ls` (one `ls ""` per game in a single connection, results split on each command's `N blocks ... available` trailer) so it can't go stale if a zip is replaced. -- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus an "Open noVNC Screen" link at the top. That link's `href` is set by a few lines of inline client-side JS (the only JS on the page) reading `window.location.hostname` at render time and pointing at `${NOVNC_PORT}` (passed into `setup_server.py` as an env var by `server.sh`, alongside `SETUP_PORT`) — has to be client-side since the container may be reached via different hostnames/IPs and the noVNC port differs from the setup port, so a fixed server-rendered URL would be wrong. noVNC's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root. +- `GET /` renders one row per discovered manifest (title/release/publisher/size/status/actions) plus a `"{used}/{MAX_CONCURRENT_GAMES} game slots in use"` line at the top. - `POST /install`: `smbget -au` (guest auth) the manifest's `zip_path` into a `/tmp/setup-` scratch dir, then `unzip -o -d ${GAMES_HOME}`. `smbget -a` (guest) cannot be combined with `-o` (output-file) — they conflict on the underlying `-U` option — so the download `cd`s into the scratch dir and lets `smbget` save under its default filename instead. Installed-ness is just "non-empty `${GAMES_HOME}/` directory exists"; a zip is expected to contain one top-level folder matching its own basename (this happens to already match ScummVM's own game-target IDs for the SCUMM titles, e.g. `monkey2.zip` → `monkey2/` — convenient, not enforced by the code). - `POST /uninstall`: `rm -rf ${GAMES_HOME}/`. Refused while the game is running (stop it first) — there's no auto-stop-then-delete. -- `POST /start`: `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/)`, tracked in an in-process dict keyed by game name (`running_procs`). This inherits `setup_server.py`'s own environment, including `$DISPLAY`, so the launched process renders onto the same Xvnc display fluxbox/noVNC are already serving — no separate X setup needed. -- `POST /stop`: `terminate()`s the tracked process, escalating to `kill()` after a 5s grace period. -- Per-game install progress (`install_state`) and the running-process table (`running_procs`) are both guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page does a `` every 3s while any install is in flight; the only client-side JS is the noVNC link's `href` (above). +- **`POST /start`: each game gets its own ephemeral X session, not a shared one.** `_free_display_num()` picks an unused display from `GAME_DISPLAY_NUMS` (`:90`-`:99`, one per `MAX_CONCURRENT_GAMES = 10` slot); if none is free, the attempt is refused and `game_errors[name]` is set to a message shown on that game's row (no slot ever gets allocated for it). Otherwise: clean any stale `/tmp/.X{N}-lock`/`/tmp/.X11-unix/X{N}` for that display (the same reason the other noVNC-family projects do this on startup — a prior Xvnc for that display might not have cleaned up after itself), then start `Xvnc :{N}`, `fluxbox`, and `websockify` (bridging `59{N}` → `80{N}`, no `-D`/daemonize flag — needs to stay a normal foreground child so its `Popen` handle actually reflects whether it's still alive, unlike the old shared-desktop `server.sh` which didn't care), wait ~1s for Xvnc to bind, then `subprocess.Popen(manifest["start_cmd"], cwd=GAMES_HOME/, env={..., "DISPLAY": f":{N}"})`. All four `Popen` handles plus the display/port numbers are tracked together in `running_procs[name]`. +- `POST /stop`: tears down all four processes for that game (game first, then websockify/fluxbox/Xvnc), each `terminate()`d then escalated to `kill()` after a 5s grace period, and cleans up the stale-lock files for that display. +- `is_running(name)` does the same full teardown **lazily** if the game process exited on its own (quit or crash) — it won't leave an idle Xvnc/websockify pair holding a slot forever just because nobody clicked Stop. `render_page()` calls this for every tracked game on every load, so the slot count and each row's status stay accurate without polling. +- Each running game's row shows its own "Open Screen" link (only while `Running`), pointing at that game's specific noVNC port. Its `href` is set by a few lines of inline client-side JS (the only JS on the page) reading `window.location.hostname` at render time — has to be client-side since the container may be reached via different hostnames/IPs and each game's port is only known once it's actually started. noVNC's `index.html` already redirects to `vnc.html?autoconnect=true`, so the link just points at the port root. +- Per-game install progress (`install_state`), start errors (`game_errors`), and the running-process table (`running_procs`) are all guarded by one `threading.RLock` (reentrant — several code paths call `is_running()` from inside a block that already holds the lock). The page auto-refreshes every 3s while any install is in flight or any game is running, so a game that crashed shows its slot freed up on the next load without the user having to do anything. - **Manifests on the share today**: `stuntcar` (DOSBox), `monkey`/`monkey2`/`atlantis`/`indy3`/`tentacle` (ScummVM). `t7g` still has no manifest and won't appear on the setup page until one's added. **Playing a game — two overlapping paths right now**: -1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game. -2. `game.sh ` → `docker exec -it dosbox-novnc start_game.sh `, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-.conf` convention — this predates the manifest system and was deliberately left as-is during the pilot. The two paths don't know about each other (e.g. a game started via `game.sh` won't show as "Running" on the setup page). Worth reconciling once manifests cover every game — see `TODO.md`. +1. The setup page's Start/Stop buttons (above) — the current way, works for any manifest-driven game, gives it its own screen, and is capped at 10 concurrent. +2. `game.sh ` → `docker exec -it dosbox-novnc start_game.sh `, running *inside* the already-running container. `scripts/start_game.sh` hardcodes `DOSBOX_VERSION="0.74-3"` and the `run.bat`/`dosbox-.conf` convention, and — since there's no more a shared default display at all — has no `$DISPLAY` to render onto unless one happens to be set in the shell's environment. This path predates both the manifest system and the per-game-screen rework and was deliberately left as-is; it's increasingly out of step with the current architecture and worth removing or reworking once every remaining game has a manifest — see `TODO.md`. **Signal handling** (`scripts/server_start.sh` + `scripts/signals.sh`): identical pattern to the other noVNC-family projects — Docker sends `SIGTERM` to PID 1 on `docker stop`, `server_start.sh` traps it, forwards it to the child process tree, waits for the descendants to exit, and re-raises `128 + signal number` as its own exit code. Generic (`$APP` passed as an argument), not specific to `server.sh`. diff --git a/Dockerfile b/Dockerfile index 77e8f8c..15bd994 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,6 @@ FROM ich777/novnc-baseimage ENV DISPLAY_NUM=99 -ENV DISPLAY=:${DISPLAY_NUM} ENV GAMES_HOME=/opt/games ENV SCRIPTS_HOME=/opt/scripts ENV PATH=${PATH}:${SCRIPTS_HOME} @@ -25,6 +24,7 @@ WORKDIR / EXPOSE 60${DISPLAY_NUM} EXPOSE 70${DISPLAY_NUM} +EXPOSE 8090-8099 EXPOSE 5900 ENTRYPOINT ["/opt/scripts/server_start.sh", "/opt/scripts/server.sh"] diff --git a/TODO.md b/TODO.md index 2bb88a2..33c6228 100644 --- a/TODO.md +++ b/TODO.md @@ -9,10 +9,10 @@ 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 (started alongside Xvnc/fluxbox/websockify) 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. + 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 @@ -53,6 +53,22 @@ 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. `--device /dev/snd` is passed through and `alsa-utils` is installed, so DOSBox can write to an ALSA device inside the container, but VNC/noVNC only ever streams video, not audio — nothing currently carries that sound out to diff --git a/run.sh b/run.sh index f9f3fa9..c25f435 100755 --- a/run.sh +++ b/run.sh @@ -1,4 +1,4 @@ #/bin/bash -docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 -p 8099:8099 jayfield/dosbox-novnc +docker run --name dosbox-novnc -d --rm --net bridge -e SCREEN_W=1536 -e SCREEN_H=960 --device /dev/snd -v dosbox-games:/opt/games -p 7099:7099 -p 8090-8099:8090-8099 jayfield/dosbox-novnc diff --git a/scripts/server.sh b/scripts/server.sh index e1dd120..10f8de7 100755 --- a/scripts/server.sh +++ b/scripts/server.sh @@ -1,13 +1,6 @@ #!/usr/bin/env bash set -x -RFB_PORT=59${DISPLAY_NUM} -NOVNC_PORT=80${DISPLAY_NUM} SETUP_PORT=70${DISPLAY_NUM} -SETUP_PORT=${SETUP_PORT} NOVNC_PORT=${NOVNC_PORT} ${SCRIPTS_HOME}/setup_server.py & - -echo Display at ${DISPLAY} with ${SCREEN_W}x${SCREEN_H}x24 -Xvnc ${DISPLAY} -geometry ${SCREEN_W}x${SCREEN_H} -depth 24 +xinerama -securitytypes none >/var/log/xvfb.log & -fluxbox & -websockify -D --web=/usr/share/novnc/ --cert=/tmp/novnc.pem --key=/tmp/novnc.key ${NOVNC_PORT} localhost:${RFB_PORT} +SETUP_PORT=${SETUP_PORT} exec ${SCRIPTS_HOME}/setup_server.py diff --git a/scripts/setup_server.py b/scripts/setup_server.py index a3110d2..7509d74 100755 --- a/scripts/setup_server.py +++ b/scripts/setup_server.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""HTML setup routine for DOSBox games. +"""HTML setup routine for DOSBox/ScummVM games. Nothing about which games exist is hardcoded here: available games are discovered by listing *.json manifests on the SMB share, one per game, @@ -23,6 +23,11 @@ the process tracked from the last start_cmd). Manifests themselves always live in SMB_DIR (the catalog directory), even though the zip_path they point at may not. Install size isn't a manifest field - it's looked up live from the share (via `smbclient ls`) so it can't go stale. + +Each running game gets its own ephemeral X session (Xvnc + fluxbox + +websockify on their own display/ports) instead of sharing one screen, so up +to MAX_CONCURRENT_GAMES can run side by side without fighting over focus or +being invisible to each other. Starting past that cap is refused. """ import html @@ -31,6 +36,7 @@ import os import re import subprocess import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse @@ -39,11 +45,18 @@ SMB_SHARE = "software" SMB_DIR = r"Games\dosbox" GAMES_HOME = os.environ["GAMES_HOME"] SETUP_PORT = int(os.environ.get("SETUP_PORT", "8080")) -NOVNC_PORT = os.environ.get("NOVNC_PORT") +SCREEN_W = os.environ.get("SCREEN_W", "1024") +SCREEN_H = os.environ.get("SCREEN_H", "768") +NOVNC_CERT = "/tmp/novnc.pem" +NOVNC_KEY = "/tmp/novnc.key" + +MAX_CONCURRENT_GAMES = 10 +GAME_DISPLAY_NUMS = list(range(90, 90 + MAX_CONCURRENT_GAMES)) # :90..:99, one per game slot state_lock = threading.RLock() install_state = {} # name -> "downloading" | "extracting" | "done" | "error: ..." -running_procs = {} # name -> subprocess.Popen +game_errors = {} # name -> error message from the last failed start attempt +running_procs = {} # name -> {game, xvnc, fluxbox, websockify: Popen, display_num, novnc_port} def smb_run(*commands): @@ -100,13 +113,46 @@ def is_installed(name): return os.path.isdir(path) and os.listdir(path) +def _free_display_num(): + used = {info["display_num"] for info in running_procs.values()} + for d in GAME_DISPLAY_NUMS: + if d not in used: + return d + return None + + +def _clean_display(display_num): + subprocess.run( + ["rm", "-f", f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"], + check=False, + ) + + +def _teardown(info): + for key in ("game", "websockify", "fluxbox", "xvnc"): + proc = info[key] + if proc.poll() is None: + proc.terminate() + for key in ("game", "websockify", "fluxbox", "xvnc"): + proc = info[key] + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + _clean_display(info["display_num"]) + + def is_running(name): with state_lock: - proc = running_procs.get(name) - if proc is None: + info = running_procs.get(name) + if info is None: return False - if proc.poll() is None: + if info["game"].poll() is None: return True + # The game exited on its own (quit/crash) - tear down its X session too + # instead of leaving an idle Xvnc/websockify pair holding the slot. + _teardown(info) del running_procs[name] return False @@ -153,21 +199,46 @@ def start_game(name, manifest): with state_lock: if is_running(name): return - proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name)) - running_procs[name] = proc + display_num = _free_display_num() + if display_num is None: + game_errors[name] = f"no free game slot ({MAX_CONCURRENT_GAMES}/{MAX_CONCURRENT_GAMES} running)" + return + game_errors.pop(name, None) + + _clean_display(display_num) + display = f":{display_num}" + rfb_port = f"59{display_num}" + novnc_port = int(f"80{display_num}") + game_env = dict(os.environ, DISPLAY=display) + + xvnc = subprocess.Popen( + ["Xvnc", display, "-geometry", f"{SCREEN_W}x{SCREEN_H}", "-depth", "24", + "+xinerama", "-securitytypes", "none"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + fluxbox = subprocess.Popen( + ["fluxbox"], env=game_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + websockify = subprocess.Popen( + ["websockify", "--web=/usr/share/novnc/", f"--cert={NOVNC_CERT}", f"--key={NOVNC_KEY}", + str(novnc_port), f"localhost:{rfb_port}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + time.sleep(1) # give Xvnc a moment to bind before the game connects + game_proc = subprocess.Popen(manifest["start_cmd"], cwd=os.path.join(GAMES_HOME, name), env=game_env) + + running_procs[name] = { + "game": game_proc, "xvnc": xvnc, "fluxbox": fluxbox, "websockify": websockify, + "display_num": display_num, "novnc_port": novnc_port, + } def stop_game(name): with state_lock: - proc = running_procs.get(name) - if proc is None: + info = running_procs.get(name) + if info is None: return - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + _teardown(info) with state_lock: running_procs.pop(name, None) @@ -177,9 +248,14 @@ def render_page(): sizes = get_zip_sizes(games) with state_lock: pending = dict(install_state) + errors = dict(game_errors) + for name in list(running_procs): + is_running(name) # lazy cleanup of any game that exited on its own + novnc_ports = {name: info["novnc_port"] for name, info in running_procs.items()} + slots_used = len(novnc_ports) any_pending = any(v in ("downloading", "extracting") for v in pending.values()) - refresh_tag = '' if any_pending else "" + refresh_tag = '' if any_pending or slots_used else "" def button(action, name, label): return ( @@ -196,8 +272,15 @@ def render_page(): size = sizes.get(name) size_html = f"{size / 1_000_000:.1f} MB" if size else "?" status = pending.get(name) - if is_running(name): - status_html = 'Running' + if name in novnc_ports: + port = novnc_ports[name] + link_id = f"novnc-{name}" + status_html = ( + f'Running ' + f'Open Screen' + f"" + ) actions = button("stop", name, "Stop") elif status in ("downloading", "extracting"): status_html = f'{status}...' @@ -207,7 +290,10 @@ def render_page(): actions = button("install", name, "Retry") elif is_installed(name): status_html = 'Installed' - actions = button("start", name, "Start") + button("uninstall", name, "Uninstall") + if name in errors: + status_html += f'
{html.escape(errors[name])}' + start_button = button("start", name, "Start") if slots_used < MAX_CONCURRENT_GAMES else "" + actions = start_button + button("uninstall", name, "Uninstall") else: status_html = "Not installed" actions = button("install", name, "Install") @@ -216,14 +302,6 @@ def render_page(): f"{size_html}{status_html}{actions}" ) - novnc_link = "" - if NOVNC_PORT: - novnc_link = f"""Open noVNC Screen -""" - return f""" @@ -238,11 +316,11 @@ button {{ padding: 0.3em 0.8em; margin-right: 0.3em; }} .status.ok {{ color: #2a2; font-weight: bold; }} .status.busy {{ color: #a70; font-weight: bold; }} .status.err {{ color: #c22; }} -.novnc-link {{ margin-bottom: 1.5em; display: block; }} +.slots {{ margin-bottom: 1.5em; }} - +

{slots_used}/{MAX_CONCURRENT_GAMES} game slots in use

DOSBox games

GameReleasePublisherSizeStatusActions