docs: add the README

This commit is contained in:
2026-09-12 10:11:02 +03:00
parent 9bb2d7d690
commit a4a650b66a
+271
View File
@@ -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-<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`.