Files
gamescope-hyperhdr/README.md
T
2026-09-12 10:11:02 +03:00

272 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<version>-<arch>.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@<client ip>` |
| `--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@<PC ip>` 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=<object.serial>`, `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`.