272 lines
13 KiB
Markdown
272 lines
13 KiB
Markdown
# 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`.
|