Skip to content

Commit 4aa7ced

Browse files
committed
feat(games): add Doom dashboard widget (Chocolate Doom WASM)
Eighth Games widget: the real Doom running in a sandboxed iframe via the cloudflare/doom-wasm Chocolate Doom build (vendored with sha256 provenance, GPL-2 license included). The IWAD is never embedded into the binary: it is served from disk through the new read-only /gamedata/ route (coordinator.http_server.gamedata_dir, empty = disabled) and provisioned with scripts/fetch-doom-wad.sh. Widget checks WAD availability with a ranged GET that validates IWAD/PWAD magic and shows provisioning hints when missing.
1 parent b1a6535 commit 4aa7ced

19 files changed

Lines changed: 10554 additions & 2 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,8 @@ bundled_extensions/
7373
.cursor/*
7474
!.cursor/rules/
7575
!.cursor/rules/**
76+
77+
# Doom widget IWAD — served from disk via gamedata_dir, never committed
78+
# nor embedded into the binary (see scripts/fetch-doom-wad.sh)
79+
gamedata/
80+
*.wad

docs/COORDINATOR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ Authorization **code** flow (server exchanges `code`, loads userinfo, provisions
192192
| `read_timeout` | int | 30 | Read timeout in seconds |
193193
| `write_timeout` | int | 30 | Write timeout in seconds |
194194
| `static_path` | string | "" | Path to UI static files (optional) |
195+
| `gamedata_dir` | string | "" | On-disk directory served read-only at `/gamedata/` for large game assets (Doom widget IWAD). Empty disables the route. See `docs/VOIPGames.md`. |
195196

196197
### nodes
197198

docs/VOIPGames.md

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Games (Homer Next-Gen dashboard)
22

3-
The dashboard includes seven mini-games under the **Games** category. Widgets are added from the dashboard palette (`registry.ts`: `packet_defender`, `sip_dialog_master`, `jitter_buffer_hero`, `sipetris`, `netris`, `chess`, `netchess`). Five are educational (SIP/RTP-themed) and two are general-purpose chess games. The chess pair shares one rules engine (`chess.js` on the UI, `notnil/chess` on the server) and one presentational board component (`ChessBoard.tsx`).
3+
The dashboard includes eight mini-games under the **Games** category. Widgets are added from the dashboard palette (`registry.ts`: `packet_defender`, `sip_dialog_master`, `jitter_buffer_hero`, `sipetris`, `netris`, `chess`, `netchess`, `doom`). Five are educational (SIP/RTP-themed), two are general-purpose chess games, and one is the actual 1993 Doom compiled to WebAssembly. The chess pair shares one rules engine (`chess.js` on the UI, `notnil/chess` on the server) and one presentational board component (`ChessBoard.tsx`).
44

55
### Default dashboards
66

@@ -312,6 +312,62 @@ JSON envelope shared with the client; constants live in `src/coordinator/games/n
312312

313313
---
314314

315+
## 8. Doom (`doom`)
316+
317+
**Concept:** the real thing — Chocolate Doom compiled to WebAssembly (the
318+
[cloudflare/doom-wasm](https://github.com/cloudflare/doom-wasm) build, GPL-2)
319+
running inside the widget. No SIP theming, no excuses.
320+
321+
### Architecture
322+
323+
| Piece | Where | Shipped how |
324+
|-------|-------|-------------|
325+
| Engine (`websockets-doom.js` + `.wasm`, ~2.5 MB) | `src/ui/public/game/` | Committed + embedded with the UI bundle (provenance and sha256 in `public/game/README.md`) |
326+
| Host page | `src/ui/public/game/index.html` | Embedded; loaded by the widget in an `<iframe>` |
327+
| IWAD (`doom1.wad`, ~4 MB) | On-disk `gamedata_dir`, served at `/gamedata/` | **Never embedded** into the homer-core binary; provisioned manually |
328+
329+
The engine runs in an iframe because Emscripten/SDL2 builds pollute window
330+
globals and cannot be torn down cleanly — removing the iframe (the Stop
331+
button or deleting the widget) is the teardown. Widget and host page talk
332+
over same-origin `postMessage` (`doomWad.ts` validates the frames).
333+
334+
### WAD provisioning
335+
336+
The shareware `doom1.wad` is freely distributable but deliberately kept out
337+
of the repo, the UI bundle, and the `go:embed` binary. On the coordinator
338+
host:
339+
340+
```bash
341+
./scripts/fetch-doom-wad.sh /usr/local/homer-core/gamedata
342+
```
343+
344+
then set the config and restart:
345+
346+
```json
347+
{ "coordinator": { "http_server": { "gamedata_dir": "/usr/local/homer-core/gamedata" } } }
348+
```
349+
350+
Any IWAD/PWAD named `doom1.wad` in that directory works (full Doom,
351+
Freedoom Phase 1 renamed — note vanilla visplane limits apply). If the WAD
352+
or the route is missing, the widget shows provisioning instructions instead
353+
of starting. The download happens once per page load with a progress bar;
354+
afterwards the browser HTTP cache applies.
355+
356+
### Controls
357+
358+
Standard Chocolate Doom bindings: arrows move, **Ctrl** fire, **Space**
359+
use, **ESC** in-game menu (which is also the pause). Click the game screen
360+
first so the iframe has keyboard focus. The header has **Fullscreen** and
361+
**Stop** buttons; music is disabled (`-nomusic`), sound effects work after
362+
the first user gesture (the Start click satisfies autoplay policy).
363+
364+
### Dev mode
365+
366+
`vite.config.ts` proxies `/gamedata` to the coordinator, mirroring `/api`
367+
run a coordinator with `gamedata_dir` set, or expect the WAD-missing hint.
368+
369+
---
370+
315371
## Source locations
316372

317373
| Game | File |
@@ -323,6 +379,7 @@ JSON envelope shared with the client; constants live in `src/coordinator/games/n
323379
| Netris | `src/ui/src/dashboard/widgets/NetrisPanel.tsx` |
324380
| Chess | `src/ui/src/dashboard/widgets/ChessPanel.tsx` |
325381
| NetChess | `src/ui/src/dashboard/widgets/NetChessPanel.tsx` |
382+
| Doom | `src/ui/src/dashboard/widgets/DoomPanel.tsx`, `doomWad.ts`, `src/ui/public/game/` |
326383
| Widget registration | `src/ui/src/dashboard/widgets/registry.ts` |
327384

328-
The single-player widgets (Packet Defender, SIP Dialog Master, Jitter Buffer Hero, SIPetris, Chess in bot mode) are **not** wired to live Homer capture; they are **local dashboard UI** only. **Netris** and **NetChess** talk to homer-core's coordinator over JWT-protected WebSockets (`/api/v4/games/netris`, `/api/v4/games/netchess`); the **Chess LLM mode** also relies on the coordinator (`/api/v4/games/chess/llm-{status,move}`). None of these endpoints consume captured traffic.
385+
The single-player widgets (Packet Defender, SIP Dialog Master, Jitter Buffer Hero, SIPetris, Chess in bot mode, Doom) are **not** wired to live Homer capture; they are **local dashboard UI** only. **Netris** and **NetChess** talk to homer-core's coordinator over JWT-protected WebSockets (`/api/v4/games/netris`, `/api/v4/games/netchess`); the **Chess LLM mode** also relies on the coordinator (`/api/v4/games/chess/llm-{status,move}`); **Doom** only fetches its IWAD from the static `/gamedata/` route. None of these endpoints consume captured traffic.

scripts/fetch-doom-wad.sh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
# Download the shareware doom1.wad for the dashboard Doom widget.
3+
#
4+
# The WAD is served at runtime by the coordinator's /gamedata/ route
5+
# (config key `gamedata_dir`) and is deliberately kept out of the UI
6+
# build so it never gets embedded into the homer-core binary.
7+
#
8+
# Usage: ./scripts/fetch-doom-wad.sh [target_dir] (default: ./gamedata)
9+
set -euo pipefail
10+
11+
TARGET_DIR="${1:-./gamedata}"
12+
WAD="${TARGET_DIR}/doom1.wad"
13+
14+
# Shareware DOOM1.WAD v1.9 (freely distributable per the original
15+
# shareware license). Same copy the cloudflare/doom-wasm demo uses.
16+
URL="https://silentspacemarine.com/doom1.wad"
17+
SHA256="1d7d43be501e67d927e415e0b8f3e29c3bf33075e859721816f652a526cac771"
18+
19+
if [ -f "${WAD}" ]; then
20+
if echo "${SHA256} ${WAD}" | sha256sum -c --quiet - 2>/dev/null; then
21+
echo "OK: ${WAD} already present and checksum matches."
22+
exit 0
23+
fi
24+
echo "WARN: ${WAD} exists but checksum differs (custom WAD?) — leaving it untouched."
25+
exit 0
26+
fi
27+
28+
mkdir -p "${TARGET_DIR}"
29+
echo "Downloading shareware doom1.wad -> ${WAD}"
30+
curl -fSL --max-time 300 "${URL}" -o "${WAD}.tmp"
31+
32+
echo "${SHA256} ${WAD}.tmp" | sha256sum -c --quiet - || {
33+
echo "ERROR: checksum mismatch, removing download." >&2
34+
rm -f "${WAD}.tmp"
35+
exit 1
36+
}
37+
mv "${WAD}.tmp" "${WAD}"
38+
echo "OK: $(ls -la "${WAD}" | awk '{print $5}') bytes. Set coordinator gamedata_dir to $(cd "${TARGET_DIR}" && pwd)"

src/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,10 @@ type CoordinatorHTTPServerConfig struct {
708708
ReadTimeout int `json:"read_timeout" mapstructure:"read_timeout" default:"30"`
709709
WriteTimeout int `json:"write_timeout" mapstructure:"write_timeout" default:"30"`
710710
StaticPath string `json:"static_path" mapstructure:"static_path" default:""`
711+
// GamedataDir is an on-disk directory served read-only at /gamedata/.
712+
// Used for large game assets (e.g. the Doom widget IWAD) that must not
713+
// be embedded into the binary via go:embed. Empty = route disabled.
714+
GamedataDir string `json:"gamedata_dir" mapstructure:"gamedata_dir" default:""`
711715
}
712716

713717
// NodeEndpoint defines a FlightSQL node to query

src/coordinator/coordinator.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,15 @@ func mapNodeEndpoints(nodes []config.NodeEndpoint) []handlers.NodeInfo {
570570
func (c *Coordinator) setupStaticRoutes() {
571571
staticPath := c.config.HTTPServer.StaticPath
572572

573+
// Large on-disk game assets (e.g. the Doom widget IWAD) are served from
574+
// gamedata_dir and intentionally kept out of the embedded UI bundle so
575+
// they never inflate the binary. Registered before the SPA handlers so
576+
// /gamedata/* never falls through to index.html.
577+
if dir := c.config.HTTPServer.GamedataDir; dir != "" {
578+
logger.Info("Coordinator: Serving game data from filesystem", "path", dir)
579+
c.echo.Static("/gamedata", dir)
580+
}
581+
573582
// Serve from filesystem if static_path is configured
574583
if staticPath != "" {
575584
logger.Info("Coordinator: Serving UI from filesystem", "path", staticPath)

src/coordinator/gamedata_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package coordinator
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/labstack/echo/v4"
11+
"github.com/sipcapture/homer-core/src/config"
12+
)
13+
14+
// newStaticTestCoordinator builds the minimal Coordinator needed to
15+
// exercise setupStaticRoutes (no DB, no services).
16+
func newStaticTestCoordinator(httpCfg config.CoordinatorHTTPServerConfig) *Coordinator {
17+
return &Coordinator{
18+
config: &config.CoordinatorConfig{HTTPServer: httpCfg},
19+
echo: echo.New(),
20+
}
21+
}
22+
23+
func TestGamedataRouteServesWadFromDisk(t *testing.T) {
24+
dir := t.TempDir()
25+
wad := []byte("IWAD\x00\x00\x00\x00fake")
26+
if err := os.WriteFile(filepath.Join(dir, "doom1.wad"), wad, 0o644); err != nil {
27+
t.Fatal(err)
28+
}
29+
30+
c := newStaticTestCoordinator(config.CoordinatorHTTPServerConfig{GamedataDir: dir})
31+
c.setupStaticRoutes()
32+
33+
rec := httptest.NewRecorder()
34+
c.echo.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gamedata/doom1.wad", nil))
35+
if rec.Code != http.StatusOK {
36+
t.Fatalf("expected 200 for existing wad, got %d", rec.Code)
37+
}
38+
if got := rec.Body.String(); got != string(wad) {
39+
t.Fatalf("wad bytes mismatch: %q", got)
40+
}
41+
42+
rec = httptest.NewRecorder()
43+
c.echo.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gamedata/missing.wad", nil))
44+
if rec.Code != http.StatusNotFound {
45+
t.Fatalf("expected 404 for missing file, got %d", rec.Code)
46+
}
47+
}
48+
49+
func TestGamedataRouteDisabledWhenDirUnset(t *testing.T) {
50+
c := newStaticTestCoordinator(config.CoordinatorHTTPServerConfig{})
51+
c.setupStaticRoutes()
52+
53+
rec := httptest.NewRecorder()
54+
c.echo.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gamedata/doom1.wad", nil))
55+
if rec.Code == http.StatusOK {
56+
t.Fatalf("expected non-200 when gamedata_dir is unset, got %d", rec.Code)
57+
}
58+
}
59+
60+
func TestGamedataRouteRejectsPathTraversal(t *testing.T) {
61+
dir := t.TempDir()
62+
secret := filepath.Join(dir, "..", "secret.txt")
63+
if err := os.WriteFile(secret, []byte("nope"), 0o644); err != nil {
64+
t.Fatal(err)
65+
}
66+
t.Cleanup(func() { _ = os.Remove(secret) })
67+
68+
c := newStaticTestCoordinator(config.CoordinatorHTTPServerConfig{GamedataDir: dir})
69+
c.setupStaticRoutes()
70+
71+
rec := httptest.NewRecorder()
72+
req := httptest.NewRequest(http.MethodGet, "/gamedata/../secret.txt", nil)
73+
c.echo.ServeHTTP(rec, req)
74+
if rec.Code == http.StatusOK && rec.Body.String() == "nope" {
75+
t.Fatal("path traversal escaped gamedata_dir")
76+
}
77+
}

0 commit comments

Comments
 (0)