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_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)

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, 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
/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 … 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

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

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.

S
Description
No description provided
Readme MIT
132 KiB
Languages
Rust 91.9%
Shell 8.1%