From a4a650b66a986d80cde2d7dc1535ffeb2038f12b Mon Sep 17 00:00:00 2001 From: Oleksandr Berezovskyi Date: Sat, 12 Sep 2026 10:11:02 +0300 Subject: [PATCH] docs: add the README --- README.md | 271 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcf23a5 --- /dev/null +++ b/README.md @@ -0,0 +1,271 @@ +# gamescope-hyperhdr + +A small daemon that feeds [HyperHDR](https://github.com/awawa-dev/HyperHDR) with the +picture of a [gamescope](https://github.com/ValveSoftware/gamescope) session: + +``` +gamescope ──PipeWire (BGRx, MemFd, requested_size)──▶ gamescope-hyperhdr ──TCP flatbuffers──▶ HyperHDR :19400 +``` + +- It connects to gamescope's PipeWire node as a consumer and asks gamescope for an already + downscaled frame through gamescope's `requested_size` format extension. Scaling and colour + processing happen on the GPU inside gamescope, and only ~230 KB per frame are copied instead + of ~33 MB at 4K. +- It converts BGRx → RGB24 (optionally area-averaging down by `--supersample`). +- It sends frames straight to HyperHDR's flatbuffers server with its own priority (150 by + default), so HyperHDR switches between this source and a USB grabber (240) automatically. +- It connects to HyperHDR only while gamescope delivers frames. When the gamescope node + disappears it sends `Clear` and disconnects, and HyperHDR falls back to the next source. + +## Build (CachyOS / Arch) + +```sh +sudo pacman -S --needed rust pipewire clang pkgconf # clang is needed by bindgen +cargo build --release +``` + +`flatbuffers` (`flatc`) is only needed to regenerate `src/hyperhdr/generated/` with +`scripts/regen-fbs.sh`; its version must match the `flatbuffers` crate. + +## Arch Linux package + +[`contrib/PKGBUILD`](contrib/PKGBUILD) builds a package from this checkout, including +uncommitted changes. Apart from Rust crates (`cargo fetch`) it downloads nothing, and `pkgver` +is taken from `Cargo.toml`. + +```sh +sudo pacman -S --needed base-devel rust clang pkgconf # or rustup instead of rust +cd contrib +makepkg -si # build, run the tests, install +``` + +Other ways to run it: + +- `makepkg -s`, then `sudo pacman -U gamescope-hyperhdr--.pkg.tar.zst`: build + and install separately. +- `makepkg --nocheck`: skip `check()`. It runs the unit tests and the network scenarios against + the mock HyperHDR, which need neither PipeWire nor a running gamescope. +- `makepkg -C`: clean build. Otherwise `contrib/src/target` keeps the Cargo build between runs. + +The build artifacts (`contrib/src/`, `contrib/pkg/`, `*.pkg.tar.*`) are ignored by git. + +| Installed file | Purpose | +|---|---| +| `/usr/bin/gamescope-hyperhdr` | the daemon | +| `/usr/lib/systemd/user/gamescope-hyperhdr.service` | user unit, see [systemd](#systemd) | +| `/usr/share/doc/gamescope-hyperhdr/gamescope-hyperhdr.env.example` | example configuration | +| `/usr/share/doc/gamescope-hyperhdr/gamescope-tv.conf` | drop-in that binds the unit to `gamescope-tv.service` | +| `/usr/share/doc/gamescope-hyperhdr/README.md` | this file | +| `/usr/share/licenses/gamescope-hyperhdr/` | MIT license and the HyperHDR schema notice | + +Runtime dependencies are `libpipewire`, `libgcc` and `glibc`; `wireplumber` is optional (it +links the stream to gamescope). The package does not enable the unit and puts nothing into +`/etc`; configure it as described in [systemd](#systemd). + +The PKGBUILD turns off makepkg's LTO (`options=('!lto')`). With `-flto`, the C shim of +`libspa-sys` is compiled into LTO bytecode, and linking fails with undefined `spa_*_libspa_rs` +symbols. `namcap PKGBUILD` complains about files referenced from `$startdir` and a missing +`url`; both are expected for a package built from a local tree. + +Upgrade after pulling changes (`-f` rebuilds even if this version was already built): + +```sh +cd contrib +makepkg -sif +systemctl --user daemon-reload +systemctl --user restart gamescope-hyperhdr.service +``` + +Uninstall: + +```sh +systemctl --user disable --now gamescope-hyperhdr.service +sudo pacman -R gamescope-hyperhdr +``` + +`disable` also removes the `gamescope-tv.service.wants/` link created by `add-wants`. Per-user files (`~/.config/gamescope-hyperhdr/env`, the drop-in in +`~/.config/systemd/user/gamescope-hyperhdr.service.d/`) stay behind. + +## Install without a package + +```sh +sudo install -Dm755 target/release/gamescope-hyperhdr /usr/local/bin/gamescope-hyperhdr +install -Dm644 contrib/systemd/gamescope-hyperhdr.service ~/.config/systemd/user/gamescope-hyperhdr.service +``` + +## systemd + +There is one user unit, [`contrib/systemd/gamescope-hyperhdr.service`](contrib/systemd/gamescope-hyperhdr.service). +It must run as the same user as the PipeWire session that hosts gamescope. `ExecStart=` names +the binary without a path, so systemd finds it in `/usr/bin` (package) or `/usr/local/bin` +(manual install). + +Configure it system-wide in `/etc/gamescope-hyperhdr.env` or per user in +`~/.config/gamescope-hyperhdr/env`; the per-user file wins. Start from +`contrib/gamescope-hyperhdr.env.example` (the package ships it in `/usr/share/doc/gamescope-hyperhdr/`): + +```sh +install -Dm644 contrib/gamescope-hyperhdr.env.example ~/.config/gamescope-hyperhdr/env +$EDITOR ~/.config/gamescope-hyperhdr/env # set GSHDR_HOST +systemctl --user daemon-reload +``` + +Then either keep the service always running (it waits for the gamescope node): + +```sh +systemctl --user enable --now gamescope-hyperhdr.service +``` + +or run it only together with `gamescope-tv.service`, using the drop-in +[`contrib/systemd/gamescope-tv.conf`](contrib/systemd/gamescope-tv.conf) (`BindsTo=` and +`After=` gamescope-tv.service): + +```sh +install -Dm644 contrib/systemd/gamescope-tv.conf \ + ~/.config/systemd/user/gamescope-hyperhdr.service.d/gamescope-tv.conf +systemctl --user daemon-reload +systemctl --user add-wants gamescope-tv.service gamescope-hyperhdr.service +``` + +In this mode do not `enable` gamescope-hyperhdr.service as well: with `BindsTo=`, starting the +feed would also start gamescope-tv.service. + +A missing or invalid configuration exits with code 2, and the unit does not restart in a loop. + +## Configuration + +Every option is a flag and an environment variable (handy for `EnvironmentFile=`). + +| Flag | Env | Default | Meaning | +|---|---|---|---| +| `--host` | `GSHDR_HOST` | required | HyperHDR host | +| `--port` | `GSHDR_PORT` | `19400` | flatbuffers server port | +| `--priority` | `GSHDR_PRIORITY` | `150` | 50..250, must be below the USB grabber's 240 | +| `--origin` | `GSHDR_ORIGIN` | `gamescope` | source name; HyperHDR shows `origin@` | +| `--size` | `GSHDR_SIZE` | `320x180` | maximum frame size sent to HyperHDR (aspect from the frame) | +| `--supersample` | `GSHDR_SUPERSAMPLE` | `1` | 1..4: request `size × N` from gamescope, area-average down by N | +| `--max-fps` | `GSHDR_MAX_FPS` | `30` | 1..120, send rate ceiling | +| `--keepalive-ms` | `GSHDR_KEEPALIVE_MS` | `1000` | resend the last frame when the picture is static; keep below HyperHDR's timeout (5 s) | +| `--idle-clear-secs` | `GSHDR_IDLE_CLEAR_SECS` | `0` (off) | `Clear` after this long without new frames; resumes on the next frame | +| `--target` | `GSHDR_TARGET` | `gamescope` | `node.name` of the source node | +| `--connect-timeout-ms` | | `2000` | TCP connect timeout | +| `--write-timeout-ms` | | `2000` | a write blocked longer means the connection is dead | +| `--log-level` | `RUST_LOG` | `info` | log level or tracing filter | + +While the priority is cleared by `--idle-clear-secs`, the connection is kept open with +periodic `Register` messages (HyperHDR would otherwise drop it after its timeout); they do not +show anything. + +## HyperHDR setup (on the Raspberry Pi) + +- *Network Services → Flatbuffers server*: enabled, port 19400, timeout 5 s, + **HDR tone mapping off**. gamescope always hands PipeWire an SDR frame (gamma 2.2), even + when the TV gets HDR. +- HyperHDR accepts network clients only from its own /24 subnet (IPv6: /64). If the PC is in + another subnet, add its IP to the whitelist (or enable internet access, which is worse). +- Priorities: the USB grabber defaults to 240; this source (150) wins while gamescope delivers + frames. +- Check: *Remote Control → Source Selection* lists `gamescope@` with priority 150. +- After migrating, remove the old chain: the `gst-launch-1.0 pipewiresrc … v4l2sink` pipeline, + v4l2loopback and the HyperHDR instance on the PC. + +## How it talks to gamescope + +gamescope (checked against 3.16.28, `src/pipewire.cpp`) offers BGRx and NV12, each as DMA-BUF +(with a MANDATORY modifier) and as MemFd, plus two custom format keys: `requested_size` +(`0x70000`) and `focus_appid` (`0x70001`). The client offers one EnumFormat: BGRx, no modifier, +any size, framerate range including `0/1`, and a fixed `requested_size = size × supersample`. + +gamescope first negotiates its output size (e.g. 3840×2160). It then computes the capture +size from `requested_size` (aspect preserved, rounded up to even), marks mis-sized buffers +`CORRUPTED` and renegotiates. The log therefore shows two Format events: + +``` +format: BGRx 3840x2160, expected stride 15360, framerate 0/1, requested_size 320x180 event=1 +format: BGRx 320x180, expected stride 1280, framerate 0/1, requested_size 320x180 event=2 +receiving frames: 320x180 stride 1280 offset 0 chunk 230400 bytes, sending 320x180 +``` + +gamescope only produces frames on vblank when the focused content changed; a static picture +produces no buffers at all, which is what the keepalive is for. If a gamescope build ignores +`requested_size`, the client logs a warning and downscales on the CPU. + +The stream uses `target.object=`, `node.dont-fallback`, `node.dont-reconnect` +and `node.dont-move`, and the client only ever targets nodes whose `node.name` equals +`--target` and whose `media.class` is `Video/Source`. It never links to another video +source such as a webcam. + +## Diagnostics + +```sh +journalctl --user -u gamescope-hyperhdr -f +pw-link -l | grep -A1 gamescope # gamescope:output_0 |-> gamescope-hyperhdr:input_0 +pw-dump | jq '.[] | select(.info.props["node.name"]=="gamescope-hyperhdr")' +pw-top # the client node and its format +``` + +Once a minute the log has a statistics line (frames received/sent, keepalives, superseded, +corrupted, bounds errors, reconnects, bytes, conversion time). + +Typical problems: + +- **"HyperHDR closed the connection right after connecting … /24 subnet …"**: the PC is + not whitelisted. HyperHDR's log says `…has been rejected! It's not whitelisted, access denied.` +- **Washed out or too dark LEDs**: HDR tone mapping is enabled for the flatbuffers server. +- **"several 'gamescope' Video/Source nodes exist"**: e.g. a nested gamescope in a desktop + session of the same user. The newest node (highest `object.serial`) is used; stop the other + gamescope or use `--target`. +- **Steam Remote Play / Game Recording** consumes the same node with its own `requested_size`. + All consumers share one format, so one of them may get the other's size (the client then + downscales on the CPU) or the link may fail. +- **"stream is streaming, but no frames for 5 s"**: gamescope only renders a frame for + PipeWire when the picture changes. On a static screen (e.g. an idle Steam UI) the first frame, + and with it the HyperHDR connection, comes with the next change; until then HyperHDR stays + on the USB grabber. Games produce frames continuously. +- **`stream error: defined target not found`** right after gamescope stops is expected: + WirePlumber does not relink the stream (`node.dont-reconnect`), and the client waits for the + node to come back. + +## Measurements + +Nested headless gamescope 3.16.28 running `vkcube`, output 3840×2160 at 60 Hz, AMD GPU, +release build, 10 s averages (`scripts/nested-gamescope.sh`). The old pipeline wrote into +`fakesink` instead of `v4l2sink`, so v4l2loopback and the second HyperHDR instance on the PC +are not included in its numbers. CPU is a percentage of one core. + +| Consumer | Negotiated format | gamescope CPU | gamescope RSS | consumer CPU | consumer RSS | +|---|---|---|---|---|---| +| `gst-launch-1.0 pipewiresrc … videorate … videoscale … videoconvert` | BGRx 3840×2160 | 19.9 % | 324 MB | 3.8 % | 23.5 MB | +| `gamescope-hyperhdr` (`--size 320x180`) | BGRx 320×180 | 0.5 % | 198 MB | 0.3 % | 9.3 MB | +| `gamescope-hyperhdr --supersample 2` | BGRx 640×360 | 0.8 % | 124 MB | 2.8 % | 11.9 MB | + +Every buffer gamescope delivers is converted (60 per second here), not only the 30 that are +sent, so the client's CPU grows with the supersampled frame size. + +Consumer connect/disconnect stress on the same nested gamescope, 15 cycles of 1.5 s each: +gamescope survived 15/15 cycles of `gamescope-hyperhdr` and 15/15 with `--supersample 2`, but +segfaulted on the 3rd disconnect of the old `pipewiresrc` pipeline (full-size 3840×2160 +buffers). This looks like a race in gamescope's buffer teardown that large frames make likely. + +gamescope stopped (SIGTERM) → `Clear` at the HyperHDR mock: 109 ms. Client SIGTERM → exit 0 +with `Clear`: 22 ms. The production session (DRM backend, 120 Hz, HDR) still has to be +measured. + +## Development + +```sh +cargo test # unit tests + network scenarios against a mock HyperHDR +cargo clippy --all-targets -- -D warnings +scripts/e2e-fake.sh # fake gamescope + client + mock in your PipeWire session +cargo run --example mock_hyperhdr -- --listen 127.0.0.1:19400 +cargo run --release --example fake_gamescope -- --name gamescope --width 3840 --height 2160 +``` + +`examples/fake_gamescope.rs` reproduces gamescope's producer side (EnumFormat objects, custom +keys, capture size math, MemFd buffers, CORRUPTED marking, renegotiation). `--static` stops +producing frames after the first few, and `--ignore-requested` behaves like a gamescope without +the extension. + +## License + +MIT. `schema/*.fbs` are copied from HyperHDR (MIT), see `schema/NOTICE`.