feat: M0 capture→encode pipeline + M2 GameStream host (pairing, RTSP, video)

M0 (lumen-host) — verified on NVIDIA RTX 5070 Ti / Ubuntu 25.10:
headless wlroots → xdg ScreenCast portal → PipeWire → NVENC HEVC → playable file,
with each access unit round-tripped through a lumen_core host↔client Session
(FEC + packetize + reassemble), 0 mismatches.
- capture.rs: SyntheticCapturer + portal capture (ashpd 0.13 + pipewire 0.9), format-aware
- encode/linux.rs: NVENC via ffmpeg-next 7 (BGRx/RGB → rgb0, no host-side swscale)
- m0.rs: capture→encode→file + lumen-core loopback verification

M2 P1 (lumen-host gamestream/) — a stock Moonlight client pairs + launches, verified live:
- mDNS _nvstream._tcp + nvhttp /serverinfo (HTTP 47989, mutual-TLS HTTPS 47984)
- 4-phase pairing: PIN→AES-128-ECB / SHA-256 / RSA-PKCS1v15 / X.509, custom rustls
  ClientCertVerifier for the mutual-TLS pairchallenge
- /applist, /launch (rikey/rikeyid/mode), hand-rolled RTSP (OPTIONS/DESCRIBE/SETUP×3/
  ANNOUNCE/PLAY, one-request-per-TCP-connection per moonlight-common-c's read-to-EOF)
- video.rs: GameStream RTP + NV_VIDEO_PACKET wire packetizer, data-shards-only (0% FEC,
  clean-LAN), unit-tested (single/multi-block)

Docs: docs/m2-plan.md (phased plan) + docs/research/ (ground-truth protocol spec).
Bootstrap/setup updated for the verified path (libnvidia-gl, render/video groups, GPU
EGL, pipewire 0.9). Workspace clippy-clean, tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 07:14:59 +00:00
parent 8b0172d793
commit ab6dda2e5f
26 changed files with 5148 additions and 123 deletions
+61 -12
View File
@@ -38,6 +38,20 @@ cat /sys/module/nvidia_drm/parameters/modeset # must print Y after reboot
```
- Driver **≥ 535** is the floor for headless wlroots (EGL/dmabuf); 550+ recommended.
- **Install the NVIDIA GL/EGL userspace, not just `nvidia-utils`:**
`sudo apt install libnvidia-gl-<NNN>` (matching the driver, e.g. `libnvidia-gl-595`).
`nvidia-utils-NNN` ships nvidia-smi + NVENC but **not** `libEGL_nvidia.so.0` or the GLVND
vendor JSON (`/usr/share/glvnd/egl_vendor.d/10_nvidia.json`). Without them libglvnd falls
back to Mesa, wlroots can't init EGL on the GPU and drops to the **pixman** software
renderer — and the ScreenCast portal then fails to negotiate a buffer format
(`unable to receive a valid format from wlr_screencopy`). Verify after install:
`ls /usr/share/glvnd/egl_vendor.d/10_nvidia.json && ldconfig -p | grep libEGL_nvidia`.
A correct GPU Sway logs `EGL vendor: NVIDIA` and a list of DMA-BUF formats.
- **Join the `render` + `video` groups:** `sudo usermod -aG render,video $USER`, then
**re-login** (group changes only apply to new logins). wlroots opens
`/dev/dri/renderD128` (group `render`) and `/dev/dri/card*` (group `video`), both 0660;
without membership Sway aborts with `Permission denied`. (`scripts/headless/*.sh` bridge a
not-yet-re-logged-in shell with `sg render`, but re-login is the clean fix.)
- A **headless VM GPU exposes no DRM connectors** — that's expected. We don't use the DRM
backend; `WLR_BACKENDS=headless` renders to an offscreen GBM/EGL surface and creates a
virtual `HEADLESS-1` output. Use the render node `/dev/dri/renderD128`.
@@ -49,20 +63,27 @@ cat /sys/module/nvidia_drm/parameters/modeset # must print Y after reboot
## 3. Bring up the headless compositor + prove capture→NVENC
```sh
# shell 1 — start headless Sway (prints WAYLAND_DISPLAY, default wayland-1)
bash scripts/headless/run-headless-sway.sh
# shell 1 — start headless GPU Sway on the shared user bus (blocks; -d for debug log)
bash scripts/headless/run-headless-sway.sh # success logs "EGL vendor: NVIDIA"
# shell 2 — same user
export XDG_RUNTIME_DIR=/run/user/$(id -u) WAYLAND_DISPLAY=wayland-1
swaymsg -t get_outputs # confirm HEADLESS-1
swaymsg output HEADLESS-1 resolution 2560x1440@60Hz # set your client size
# shell 2 — same user: set the client mode, import the portal env, write the env file
bash scripts/headless/prepare-session.sh 2560x1440@60Hz
source /tmp/lumen-sway-env.sh
swaymsg -t get_outputs # confirm HEADLESS-1 active
swaymsg exec foot # optional: animated content to capture
bash scripts/headless/capture-smoke-test.sh # wf-recorder (wlr-screencopy) -> hevc_nvenc
ffprobe /tmp/lumen-headless-test.mkv # confirm a real H.265 stream
```
`wf-recorder` uses `wlr-screencopy` directly (no portal/D-Bus) — the fastest way to
de-risk the GPU encode path. **Note:** screencopy encodes straight to a file and *cannot*
feed PipeWire; the real integration uses the ScreenCast portal (see M0).
feed PipeWire; the real integration uses the ScreenCast portal (see M0). If shell 1 logged
a Mesa/EGL fallback (or Sway dropped to pixman) instead of `EGL vendor: NVIDIA`, install the
NVIDIA GL userspace (§2) — the portal cannot capture a pixman output.
**An idle headless output produces no frames** (its frame clock is driven by damage); give
it a real refresh mode (`prepare-session.sh` does) *and* run something animated
(`swaymsg exec foot`) or the capture will be ~1 frame.
The wlroots-on-NVIDIA env workarounds (`WLR_RENDERER=gles2`, `WLR_NO_HARDWARE_CURSORS=1`,
`GBM_BACKEND=nvidia-drm`, `sway --unsupported-gpu`, …) live in
@@ -74,13 +95,41 @@ Goal (plan §8): headless output → PipeWire ScreenCast → NVENC → a playabl
the encoded access units into a `lumen_core::Session` (host role). The module seams exist
in `crates/lumen-host/src/{vdisplay,capture,encode,inject,pipeline}.rs`.
**Status: implemented and verified end-to-end** in `crates/lumen-host` (`m0.rs`,
`capture/linux.rs`, `encode/linux.rs`). After the §3 bring-up:
```sh
source /tmp/lumen-sway-env.sh
swaymsg exec foot # animated content
# Live portal capture → NVENC HEVC → playable file, with each AU also round-tripped
# through a lumen_core host→client Session (FEC + packetize + reassemble) and verified:
cargo run -p lumen-host -- m0 --source portal --seconds 5 --out /tmp/lumen-m0.h265
ffprobe /tmp/lumen-m0.h265
# No capture session needed (encode + core only): --source synthetic
```
Verified result: `1920x1080` HEVC, ~300 frames in 5s, `lumen-core loopback … 0 mismatches`.
The portal negotiates packed **`RGB` (24-bit, 3 bpp)** on wlroots; the encoder expands it to
`rgb0` (one pad byte/pixel, no colour math) since NVENC accepts `rgb0`/`bgr0` but not
`rgb24`. dmabuf zero-copy import is still deferred (plan §9) — this is the CPU-copy path.
Crate choices, verified current:
- **Capture (portal path):** [`ashpd`](https://docs.rs/ashpd) **0.13** with the
`screencast` + `pipewire` features → `ScreenCast::create_session``select_sources`
(`Monitor`) → `start``pipe_wire_node_id()` + `open_pipe_wire_remote()`; pull frames
with [`pipewire`](https://docs.rs/pipewire) **0.10**. (crates.io's "newest" field shows
0.9 for ashpd — ignore it, pin `0.13`.) Set `XDG_CURRENT_DESKTOP=sway` so the wlr
portal backend is chosen.
`screencast` feature (the `pipewire` feature is *not* needed — `open_pipe_wire_remote`
is unconditional). Flow (0.13 API, verified against the vendored source): `Screencast::new`
`create_session(Default)``select_sources(&session, SelectSourcesOptions::default()
.set_sources(BitFlags::from_flag(SourceType::Monitor))…)``start(&session, None,
Default)``.response()?``Stream::pipe_wire_node_id()` + `open_pipe_wire_remote()`.
Note 0.13 takes **options structs**, not the old positional args, and defaults to the
**tokio** runtime — drive the handshake on a *multi-thread* tokio runtime (a
current-thread one starves zbus's reader and the portal reports "Invalid session").
Pull frames with [`pipewire`](https://docs.rs/pipewire) **0.9** — it must match the
pipewire crate ashpd 0.13 links (the `pipewire-sys` `links` key is unique per build, so
`0.10` fails to resolve). 0.9 uses `MainLoopRc`/`ContextRc::connect_fd_rc(OwnedFd)`/
`StreamBox`. Only request `SourceType::Monitor` — the wlr backend's
`AvailableSourceTypes` is `1` (Monitor only); asking for `Window`/`Virtual` invalidates
the session. Set `XDG_CURRENT_DESKTOP=sway` so the wlr portal backend is chosen, and
import it into the portal's environment (see "Portal bring-up" below).
- **Encode:** [`ffmpeg-next`](https://crates.io/crates/ffmpeg-next) **7.x** (binds the
system FFmpeg 6.1.1 via pkg-config; needs `clang`/`libclang`). Select the encoder by
name — `encoder::find_by_name("hevc_nvenc")`, *not* by codec id (that's the SW encoder).