Skip to content

Add doom cog: browser-playable DOOM on the Seed (first C-FFI cog, GPLv2) - #28

Open
shaal wants to merge 5 commits into
cognitum-one:mainfrom
shaal:add-doom-cog
Open

Add doom cog: browser-playable DOOM on the Seed (first C-FFI cog, GPLv2)#28
shaal wants to merge 5 commits into
cognitum-one:mainfrom
shaal:add-doom-cog

Conversation

@shaal

@shaal shaal commented Jun 8, 2026

Copy link
Copy Markdown

1780898284148.webp

Summary

Adds doom — an API-provider cog that runs the portable
doomgeneric engine on-device (software
rendered, no GPU / X11 / SDL) and serves a touch- and keyboard-friendly game
client to any browser. The seed agent reverse-proxies
/api/v1/cogs/doom/*127.0.0.1:8066, injecting a per-cog bearer token.

Open the Seed dashboard, tap DOOM, play in your phone's browser. Rendered
entirely on the device.

ADR: docs/adrs/ADR-019-doom.md.

What it adds

  • src/cogs/doom/Cargo.toml, build.rs, cog.toml, Dockerfile,
    README.md, LICENSE (GPLv2), NOTICE, src/main.rs, assets/index.html,
    and vendor/doomgeneric/ (the vendored C engine + its GPLv2 LICENSE and an
    origin README).
  • docs/adrs/ADR-019-doom.md + an ADR-index entry (017–019 platform/API cogs).

Architecture

  • First C-FFI cog. Every other cog is pure Rust; this one compiles the
    vendored doomgeneric C engine via build.rs + the cc crate and drives it
    through extern "C" DG_* callbacks. One dedicated engine thread owns the
    (non-thread-safe) engine — doomgeneric_Create() once, then a _Tick() loop
    — publishing frames to HTTP worker threads that only read frames / push input.
  • Transport: polling JPEG-over-HTTP, native 320×200. The client long-polls
    GET /frame?since=N and posts to POST /input. We do not use WebSocket or
    a persistent MJPEG stream because the agent proxy buffers response bodies and
    has no WS upgrade — a polling JSON/JPEG API is what survives it unchanged.
    /frame forces Content-Length (the proxy otherwise leaks chunk framing and
    relabels image/jpeg); the client re-wraps bytes as an image/jpeg Blob.
    320×200 keeps per-frame JPEG encode cheap on a Pi Zero 2 W. Client uses
    adaptive frame pacing (backs off on 429).
  • Loopback + token auth (ADR-095 default). bind_loopback_only = true;
    /frame,/input,/stream require the per-cog bearer token (constant-time compare
    via subtle); / and /health are open. If the agent spawns the cog without
    injecting COGNITUM_COG_TOKEN, the cog falls back to the agent's own
    <exe_dir>/.cog-token file (the same secret the proxy injects) so paired
    clients keep working; unpaired clients are still rejected.

First C-FFI cog

This establishes the build.rs + cc + extern "C" pattern for the repo. The
runner already has a C toolchain, so cargo check/cargo build work in CI with
no new infra. The vendored source set is the upstream SRC_DOOM list minus all
platform backends and the SDL/Allegro mixers (the cog provides a headless
backend; no audio in v0.1.0).

⚠️ Licensing disclosure — GPLv2 in an MIT repo (please confirm)

This cog directory is GPLv2; the rest of the repo stays MIT. Linking the
vendored doomgeneric engine (derived from id Software's GPLv2 DOOM source) makes
the cog binary a GPLv2 derivative, so Cargo.toml sets
license = "GPL-2.0-or-later". We ship:

  • src/cogs/doom/LICENSE — full GPLv2 text governing this cog
  • src/cogs/doom/NOTICE — explains the GPLv2-in-MIT scope (this directory only)
  • src/cogs/doom/vendor/doomgeneric/LICENSE + README — engine license + origin

GPL applies only to this self-contained derivative; it does not relicense the
repo. Maintainers: please confirm you're OK accepting a GPLv2 subdirectory in
this otherwise-MIT repository.
If not, the alternative is hosting the doom cog
in a separate repo referenced from the registry — but vendoring keeps the PR
self-contained and CI green. This is the main thing to decide on review.

FreeDoom for game data (no DOOM WAD shipped)

The cog ships no DOOM game data. It uses the freely-redistributable
FreeDoom IWAD as a runtime asset (cog.toml [[assets]]):

  • FreeDoom v0.13.0, freedoom1.wad
  • sha256 7323bcc168c5a45ff10749b339960e98314740a734c30d4b9f3337001f9e703d,
    28,795,076 bytes
  • Source: freedoom-0.13.0.zip from the FreeDoom GitHub release

The agent fetches + sha256-verifies it at install and places it next to the
binary; the cog resolves $DOOM_WAD<exe_dir>/freedoom1.wad
./freedoom1.wad. The WAD is not committed. Maintainers may re-host it to
the cognitum registry on publish (add gcs_path to the asset entry, like the
other asset-bearing cogs).

Optional direct-LAN mode (off by default) — proposal

Two env flags let you play directly over the LAN, bypassing the agent proxy and
its ~1 fps rate limit: DOOM_OPEN=1 disables the token check on the game
endpoints and DOOM_BIND=0.0.0.0 binds all interfaces. Both default off.

Tradeoff vs ADR-095's loopback-only posture: in this mode the game
frame/input endpoints are reachable by anyone on the LAN, unauthenticated and
unthrottled — never the device/agent API, only this cog's game endpoints. It's
opt-in, loudly logged at startup, and documented. Happy to drop the flags
entirely and ship proxy-only if maintainers prefer; nothing else depends on them.

When such a direct-LAN instance runs alongside the proxied one, set
DOOM_FAST_PORT (or drop a fast-port file next to the binary) on the proxied
instance and its web UI shows a banner linking to the full-speed instance — the
cog substitutes the port into index.html at serve time. Unset → no banner.

Build / test

# Native (x86_64) — needs a C compiler for build.rs
cd src/cogs/doom
cargo build --release
cp /path/to/freedoom1.wad target/release/freedoom1.wad
COGNITUM_COG_TOKEN=dev ./target/release/cog-doom   # 127.0.0.1:8066

# ARM (Pi Zero 2 W / armhf) — the shippable artifact
docker build -t cog-doom-arm -f Dockerfile .       # context = this dir

What I verified

  • cargo check --release --all-targets passes (the gate CI runs) — the C
    engine compiles via build.rs.
  • cargo build --release links cleanly (~988 KB native binary).
  • Native smoke run: loaded the FreeDoom IWAD and rendered real frames;
    GET /health → 200, GET / → client HTML, GET /frame401 without a
    token / 200 + valid image/jpeg (JFIF) with the token
    , POST /input
    accepted.
  • Manifest gates: cog.toml id = doom, [[bin]] name = cog-doom, ADR present,
    asset sha256 is real (no TODO-).

The armhf Docker cross-build (per-cog Dockerfile, manual) was not run in this
environment.

Notes / decisions

  • Vendored doomgeneric (not a submodule) so the PR is self-contained and CI
    builds without git submodule update; the exact compiled sources are pinned
    and reviewable in-tree.
  • The repo root is a single cognitum package (not a Cargo workspace) and cogs
    are standalone crates, so nothing was added to the root Cargo.toml — matches
    how tailscale/cognitive-pipeline are set up.

Adds src/cogs/doom — an API-provider cog that runs the portable doomgeneric
engine on-device (software rendered, no GPU/X11/SDL) and serves a touch- and
keyboard-friendly game client over a loopback HTTP API. The seed agent proxies
/api/v1/cogs/doom/* to 127.0.0.1:8066 with a per-cog bearer token.

This is the first C-FFI cog: build.rs compiles the vendored doomgeneric C
engine via the cc crate and src/main.rs drives it through extern "C" callbacks
on a dedicated engine thread, publishing frames to HTTP workers.

Key points:
- Auth: loopback bind + bearer-token (constant-time, subtle) required on
  /frame,/input,/stream by default; / and /health open. Optional opt-in
  direct-LAN mode (DOOM_OPEN=1 + DOOM_BIND=0.0.0.0), OFF by default.
- Transport: polling JPEG-over-HTTP at native 320x200 (Content-Length forced,
  client re-wraps as image/jpeg) — the agent proxy buffers streams and has no
  WebSocket support, so polling is what survives it. Adaptive frame pacing.
- Game data: ships no DOOM WAD; uses the redistributable FreeDoom IWAD
  (v0.13.0 freedoom1.wad) as a cog.toml [[assets]] entry with real sha256.
  Resolved at runtime from DOOM_WAD / <exe_dir>/freedoom1.wad / ./freedoom1.wad.
- Licensing: this cog is GPLv2 (it links GPLv2 doomgeneric/DOOM source) while
  the rest of the repo is MIT. LICENSE (full GPLv2), NOTICE, and the vendored
  engine's own GPLv2 LICENSE document the scope (this directory only).

Also adds docs/adrs/ADR-019-doom.md and lists the platform/API cogs (017-019)
in the ADR index.

Verified: cargo check --release --all-targets and cargo build --release pass
(C engine compiles + links); a native smoke run loaded the FreeDoom IWAD and
served real JPEG frames, with /frame returning 401 without a token and 200 with.
@shaal

shaal commented Jun 9, 2026

Copy link
Copy Markdown
Author

Heads-up from running this cog on a Seed (Pi Zero 2 W) for a couple of days: the engine can wedge after long uptime — the render thread stops producing frames while the HTTP server stays up, so the client just shows a frozen last frame. After ~38h (~6.4M frames) the doom-engine thread was parked in its paced sleep (DG_SleepMs), DG_DrawFrame had stopped being called, the frame counter was frozen, and CPU sat at ~2% (a wedge, not a runaway). /health kept returning {"status":"ok"} the whole time. A systemctl restart cleared it. Root cause wasn't pinnable on-device (journald had rotated; engine stack needs root).

The platform-level half — the agent should detect a wedged-but-alive cog rather than trusting "process up / HTTP 200" — is filed as cognitum-one/support#54 (sibling of support#52). But there are a few cog-side improvements worth folding into this PR so doom is recoverable and observable on its own:

  1. Make /health reflect frame liveness, not just thread liveness. Right now it's a static {"status":"ok"} (src/main.rs:467), which is true even when the engine is wedged. Have it report the frame counter and time since the last DG_DrawFrame, e.g. {"status":"ok","frame":<counter>,"stale_ms":<n>}, and return non-200 once stale_ms exceeds a threshold. That alone makes the wedge externally detectable (by the agent, a monitor, or a human).

  2. Add an in-loop watchdog so a stalled engine self-recovers. The engine loop is a bare loop { doomgeneric_Tick(); } (src/main.rs:249-251) with no liveness check — and because the process never exits, systemd/the agent can't restart it. Track the last-draw timestamp (updated in DG_DrawFrame); if no new frame in N seconds, log it and std::process::exit(1) so the supervisor restarts cleanly. (Logging the stall before exit also fixes the "no retained signal" gap — journald had already rotated by the time I looked.)

  3. Harden the shared-state locks against poisoning. DG_DrawFrame, DG_GetKey (src/main.rs:130), and encode_current_jpeg (src/main.rs:261) all use Mutex::lock().unwrap(), and encode_current_jpeg also has .expect("jpeg encode") on an HTTP thread. A panic in any one handler poisons the shared frame/input/jpeg_cache mutexes and can cascade into exactly this kind of silent stall. Worth using a poison-tolerant access pattern (or at least making a poisoned lock a clean fatal exit per ruview-densepose: fetch_sensors() fallback fails with EAGAIN on every tick — cog produces 0 output for 14h+ on v0.10.11.5 #2, rather than a wedged thread).

Happy to send these as a follow-up once the PR lands, or fold them in here — whichever you prefer.

Ofer Shaal added 3 commits June 10, 2026 16:18
GetAdjustedTime() computed (time_ms * TICRATE) in signed 32-bit int. Once
uptime passed ~17h (time_ms > INT_MAX/35) the multiply overflowed and wrapped
negative, so NetUpdate()'s newtics went negative and the BuildNewTic() loop
stopped running. BuildNewTic() is the only thing that advances maketic and the
only caller of I_StartTic() (the input pump), so the game simulation and all
input froze while D_Display() kept drawing — the HTTP server stayed up and the
frame counter kept climbing, but the picture was stuck and the game ignored
every key (not even ESC opened the menu).

I_GetTime() had the same latent bug in uint32 arithmetic at ~34h. Widen both
multiplies to 64-bit so the tic clock can't overflow.

Verified by cross-compiling the armv7 binary and running it on the device:
input is responsive again (ESC toggles the menu) and frames advance normally.
The cog ships no game data and resolves freedoom1.wad next to the binary at
runtime, but the asset block only had source_url (the upstream ZIP) and no
gcs_path — so the agent's install flow had no single re-hosted file to fetch and
sha-verify, the way the cognitive-pipeline cog does. Result: a fresh install
would start with no IWAD and fail to find freedoom1.wad.

Add gcs_path = "wads/freedoom1.wad" so the agent pulls the WAD from the cognitum
registry bucket, verifies the (already-correct) sha256, and drops it beside the
binary. source_url stays as provenance. Maintainer still has to extract
freedoom1.wad from the upstream ZIP and upload that one file to
gs://cognitum-apps/wads/freedoom1.wad.
The agent resolves an asset's gcs_path under cogs/<arch>/ (the cognitive-pipeline
cog's "models/..." path lands at cogs/arm/models/...). Spell out that for this
arm-only cog the WAD must be uploaded to
gs://cognitum-apps/cogs/arm/wads/freedoom1.wad, so whoever publishes the cog
knows the precise destination.
@shaal

shaal commented Jun 10, 2026

Copy link
Copy Markdown
Author

⚠️ Publish step for a maintainer: upload the FreeDoom IWAD

This cog ships no game data — it fetches the FreeDoom IWAD at install via the asset gcs_path in cog.toml (same mechanism the cognitive-pipeline cog uses). The cog declaration is complete, but the asset itself still has to be published to the registry bucket, which a contributor can't do (no write access to gs://cognitum-apps).

On publish, upload freedoom1.wad to:

gs://cognitum-apps/cogs/arm/wads/freedoom1.wad

(The agent resolves gcs_path under cogs/<arch>/; doom is arm-only. cog.toml declares gcs_path = "wads/freedoom1.wad".)

field value
file freedoom1.wad
size 28,795,076 bytes
sha256 7323bcc168c5a45ff10749b339960e98314740a734c30d4b9f3337001f9e703d
source extract freedoom-0.13.0/freedoom1.wad from https://github.com/freedoom/freedoom/releases/download/v0.13.0/freedoom-0.13.0.zip
license BSD-3-Clause (FreeDoom)

Without this upload, fresh installs start with no IWAD and the cog can't render. The sha256 above already matches cog.toml and is what the install handler verifies.

- Fall back to reading <exe_dir>/.cog-token when the agent spawns the cog
  without COGNITUM_COG_TOKEN (its cold-boot path skips it), so the paired
  game endpoints stop 401ing for correctly-proxied clients.
- Advertise an un-proxied full-speed LAN instance in the web UI via
  DOOM_FAST_PORT / a `fast-port` file (the proxy caps frames at ~1 fps);
  the cog substitutes the port into index.html at serve time.
@shaal

shaal commented Jun 11, 2026

Copy link
Copy Markdown
Author

Update — pushed d024355: two fixes from running this on the actual Seed (Pi Zero 2 W), both validated on-device.

  1. Token fallback. The agent's cold-boot spawn path doesn't always inject COGNITUM_COG_TOKEN — that left the cog with an empty expected token, so every /frame,/input,/stream returned 401 (page loaded, screen never rendered). The cog now falls back to the agent's own <exe_dir>/.cog-token when the env var is absent — the same secret the proxy injects, so correctly-paired clients work and unpaired ones are still rejected (no auth downgrade).
  2. Direct-LAN fast-path banner. The proxy caps frames at ~1 fps; a separate DOOM_OPEN instance on the LAN renders at the native ~35 fps. The proxied web UI now links to it via a banner, with the port supplied by DOOM_FAST_PORT or a fast-port file next to the binary (works even though the agent controls the cog's env). Unset → no banner, so generic deploys are unchanged.

Verified end-to-end through the agent proxy on the device: /frame → 200 with a valid JPEG, and the banner links to the working full-speed instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant