# 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`.