gamescope-hyperhdr
A small daemon that feeds HyperHDR with the picture of a 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_sizeformat 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
Clearand disconnects, and HyperHDR falls back to the next source.
Build (CachyOS / Arch)
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 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.
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, thensudo pacman -U gamescope-hyperhdr-<version>-<arch>.pkg.tar.zst: build and install separately.makepkg --nocheck: skipcheck(). 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. Otherwisecontrib/src/targetkeeps 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 |
/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.
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):
cd contrib
makepkg -sif
systemctl --user daemon-reload
systemctl --user restart gamescope-hyperhdr.service
Uninstall:
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
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.
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/):
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):
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 (BindsTo= and
After= gamescope-tv.service):
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 … v4l2sinkpipeline, 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
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 foundright 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
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.