Compare commits

...
14 Commits
Author SHA1 Message Date
oleksandr a4a650b66a docs: add the README 2026-09-12 10:11:02 +03:00
oleksandr 9bb2d7d690 build(packaging): add the Arch package, systemd unit and gamescope config
The PKGBUILD builds from the surrounding source tree (makepkg's -flto is
disabled because it breaks the libspa-sys C shim). The user unit and the
env example ship the intended deployment, and gamescope-tv.conf documents the
gamescope session it is meant to run against.
2026-09-12 10:11:01 +03:00
oleksandr e2b1c2983d test: add the fake gamescope producer and the end-to-end scripts
fake_gamescope mirrors gamescope 3.16.28's src/pipewire.cpp closely enough to
exercise the requested_size negotiation, MemFd buffers and CORRUPTED frames.
e2e-fake.sh runs it against the client and the mock in the current PipeWire
session; nested-gamescope.sh does the same with a real nested gamescope and
compares the load with the old gstreamer pipeline.
2026-09-12 10:10:53 +03:00
oleksandr 1aa7d802eb test: add the HyperHDR mock server and the integration tests
The mock imitates FlatBuffersServerConnection and FlatBuffersParser (spec
3.2), including the priority handling, the idle timeout and the reject paths,
so the client can be tested against it over a real socket. It doubles as a
standalone example binary for end-to-end runs.
2026-09-12 10:10:44 +03:00
oleksandr 885ee7fa04 feat(cli): add the binary that wires capture, slot and client together
Sets up logging, starts the network thread, runs the PipeWire loop in the
main thread and shuts down in order so HyperHDR gets a Clear before exit.
2026-09-12 10:10:29 +03:00
oleksandr 5b5cb9bf38 feat(capture): add the PipeWire capture of the gamescope node
Finds the gamescope node, negotiates a small BGRx MemFd format through
gamescope's requested_size extension, converts each buffer and publishes it
into the frame slot. Runs on the PipeWire main loop in the main thread.
2026-09-12 10:10:21 +03:00
oleksandr df857c7424 feat(hyperhdr): add the protocol codec and the network thread
proto encodes registration, image and clear requests with the
[u32 big-endian length][flatbuffer] framing and decodes the replies. client
drives them from a thread: it connects while frames flow, applies the pacer,
sends keepalives and reconnects with backoff.
2026-09-12 10:09:42 +03:00
oleksandr de88dd5799 feat(convert): add BGRx to RGB24 conversion with downscaling
Box filter for integer scale factors, fixed-point area averaging otherwise.
The source view is bounds-checked so a short or misdescribed PipeWire buffer
is reported instead of read out of range.
2026-09-12 10:09:41 +03:00
oleksandr c50a55cf79 feat(stats): add the counters shared by the capture and network threads 2026-09-12 10:09:25 +03:00
oleksandr a2fc524619 feat(pacing): add send pacing, reconnect backoff and log rate limiting
Pure scheduling logic with an injected `now`, so the rules of spec 4.3.6
(fps cap, keepalive, idle clear, heartbeat) are unit-testable without I/O.
2026-09-12 10:09:24 +03:00
oleksandr b3993be5cd feat(slot): add the latest-frame-wins hand-off between threads
The PipeWire thread publishes into a single slot and the network thread takes
from it, swapping buffers instead of copying so the steady state does not
allocate. An older pending frame is dropped when a newer one arrives.
2026-09-12 10:09:23 +03:00
oleksandr 9855d1e18a feat(config): add the CLI and environment configuration
Parses the WxH sizes, clamps them against the HyperHDR message limit and
carries the HyperHDR server constants (max message size, default timeout).
2026-09-12 10:09:09 +03:00
oleksandr 617296afae feat(hyperhdr): vendor the flatbuffers schemas and generated bindings
The .fbs files come from HyperHDR (see schema/NOTICE); the *_generated.rs
files are flatc output regenerated by scripts/regen-fbs.sh, which refuses to
run unless flatc matches the pinned flatbuffers crate.
2026-09-12 10:08:56 +03:00
oleksandr 623fab55e5 build: add cargo manifest and MIT license
Rust 2021, MSRV 1.85. flatbuffers is pinned to an exact version because the
generated bindings must match the flatc that produced them.
2026-09-12 10:08:29 +03:00
32 changed files with 8469 additions and 0 deletions
Generated
+820
View File
@@ -0,0 +1,820 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "annotate-snippets"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
dependencies = [
"anstyle",
"unicode-width",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"annotate-snippets",
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex 1.3.0",
"syn 2.0.119",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cc"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"shlex 2.0.1",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "cfg-expr"
version = "0.20.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clang-sys"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flatbuffers"
version = "25.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3"
dependencies = [
"bitflags",
"rustc_version",
]
[[package]]
name = "gamescope-hyperhdr"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"flatbuffers",
"libc",
"libspa",
"pipewire",
"thiserror",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libspa"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40"
dependencies = [
"bitflags",
"cc",
"cookie-factory",
"libc",
"libspa-sys",
"nom 8.0.0",
"rustix",
"system-deps",
]
[[package]]
name = "libspa-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924"
dependencies = [
"bindgen",
"cc",
"system-deps",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pipewire"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c"
dependencies = [
"bitflags",
"libc",
"libspa",
"libspa-sys",
"pipewire-sys",
"rustix",
]
[[package]]
name = "pipewire-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0"
dependencies = [
"bindgen",
"libspa-sys",
"system-deps",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "smallvec"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "system-deps"
version = "7.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr",
"heck",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "gamescope-hyperhdr"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
license = "MIT"
description = "Capture gamescope's PipeWire stream (downscaled by gamescope) and feed it to HyperHDR's flatbuffers server"
[dependencies]
anyhow = "1"
clap = { version = "4.6", features = ["derive", "env"] }
flatbuffers = "=25.12.19"
libspa = "0.10.1"
pipewire = "0.10.1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
[dev-dependencies]
libc = "0.2"
[profile.release]
lto = "thin"
codegen-units = 1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Oleksandr Berezovskyi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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`.
+64
View File
@@ -0,0 +1,64 @@
# Arch Linux package built from this source tree (the directory above this PKGBUILD).
#
# cd contrib && makepkg -si
pkgname=gamescope-hyperhdr
pkgver=0.1.0
pkgrel=1
pkgdesc="Feeds gamescope's PipeWire picture, downscaled by gamescope, to HyperHDR's flatbuffers server"
arch=('x86_64' 'aarch64')
license=('MIT')
depends=('libpipewire' 'libgcc' 'glibc')
makedepends=('cargo' 'clang' 'pkgconf')
optdepends=('wireplumber: links the capture stream to the gamescope node')
# makepkg's -flto turns libspa-sys' C shim into LTO bytecode that the Rust link step cannot
# resolve (undefined spa_*_libspa_rs symbols).
options=('!lto')
_tree() {
realpath "$startdir/.."
}
pkgver() {
sed -n 's/^version = "\(.*\)"$/\1/p' "$(_tree)/Cargo.toml" | head -n1
}
prepare() {
rm -rf "$srcdir/$pkgname"
mkdir -p "$srcdir/$pkgname"
cp -a "$(_tree)"/{Cargo.toml,Cargo.lock,src,tests,examples,schema} "$srcdir/$pkgname/"
cd "$srcdir/$pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "$srcdir/$pkgname"
export RUSTUP_TOOLCHAIN=stable
# Outside the copied tree, so rebuilds stay incremental.
export CARGO_TARGET_DIR="$srcdir/target"
cargo build --frozen --release --bin gamescope-hyperhdr
}
check() {
cd "$srcdir/$pkgname"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR="$srcdir/target"
cargo test --frozen --release
}
package() {
local tree
tree="$(_tree)"
install -Dm755 "$srcdir/target/release/gamescope-hyperhdr" "$pkgdir/usr/bin/gamescope-hyperhdr"
install -Dm644 "$tree/contrib/systemd/gamescope-hyperhdr.service" \
"$pkgdir/usr/lib/systemd/user/gamescope-hyperhdr.service"
install -Dm644 "$tree/contrib/gamescope-hyperhdr.env.example" \
"$pkgdir/usr/share/doc/$pkgname/gamescope-hyperhdr.env.example"
install -Dm644 "$tree/contrib/systemd/gamescope-tv.conf" \
"$pkgdir/usr/share/doc/$pkgname/gamescope-tv.conf"
install -Dm644 "$tree/README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 "$tree/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 "$tree/schema/NOTICE" "$pkgdir/usr/share/licenses/$pkgname/NOTICE.HyperHDR"
}
+23
View File
@@ -0,0 +1,23 @@
# ~/.config/gamescope-hyperhdr/env — every flag can also be set here.
# HyperHDR host (required)
GSHDR_HOST=192.168.1.50
#GSHDR_PORT=19400
# Must be lower (= more important) than the USB grabber's priority (240)
#GSHDR_PRIORITY=150
#GSHDR_ORIGIN=gamescope
# Frame size sent to HyperHDR and CPU supersampling factor (gamescope renders SIZE x N)
#GSHDR_SIZE=320x180
#GSHDR_SUPERSAMPLE=1
#GSHDR_MAX_FPS=30
#GSHDR_KEEPALIVE_MS=1000
# Clear the priority after this many seconds without new frames (0 = off)
#GSHDR_IDLE_CLEAR_SECS=0
# node.name of the gamescope PipeWire node
#GSHDR_TARGET=gamescope
#RUST_LOG=info
@@ -0,0 +1,25 @@
# User unit for gamescope-hyperhdr. The Arch package installs it into /usr/lib/systemd/user;
# for a manual install copy it to ~/.config/systemd/user. It runs as the user of the PipeWire
# session and waits for the gamescope node, so it can simply stay enabled. To run it only
# together with gamescope-tv.service, add the gamescope-tv.conf drop-in.
[Unit]
Description=gamescope → HyperHDR ambilight feed
After=pipewire.service wireplumber.service
Wants=pipewire.service
[Service]
Type=simple
# System-wide settings, overridden by the per-user file.
EnvironmentFile=-/etc/gamescope-hyperhdr.env
EnvironmentFile=-%h/.config/gamescope-hyperhdr/env
# No absolute path: systemd searches /usr/local/bin and /usr/bin, which covers both the package
# and a manual install.
ExecStart=gamescope-hyperhdr
Restart=on-failure
RestartSec=2
# Exit code 2 is a configuration error (e.g. GSHDR_HOST not set); restarting will not help.
RestartPreventExitStatus=2
NoNewPrivileges=yes
[Install]
WantedBy=default.target
+13
View File
@@ -0,0 +1,13 @@
# Drop-in for gamescope-hyperhdr.service: run the feed only together with gamescope-tv.service,
# starting with it and stopping when it stops.
#
# install -Dm644 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
#
# The Arch package ships this file as /usr/share/doc/gamescope-hyperhdr/gamescope-tv.conf.
# add-wants makes gamescope-tv.service pull the feed in. Do not also `enable` the feed:
# BindsTo= would then start gamescope-tv.service whenever the feed starts.
[Unit]
BindsTo=gamescope-tv.service
After=gamescope-tv.service
+740
View File
@@ -0,0 +1,740 @@
//! Imitates gamescope's PipeWire producer for local end-to-end tests (spec 9.3).
//!
//! Mirrors `src/pipewire.cpp` of gamescope 3.16.28: node properties, the four EnumFormat
//! objects with the custom `requested_size`/`focus_appid` keys, `calculate_capture_size`,
//! MemFd buffers, CORRUPTED marking of buffers whose size does not match the render size,
//! and renegotiation of the format when the capture size changes.
//!
//! cargo run --release --example fake_gamescope -- --name gamescope --width 3840 --height 2160
use std::cell::RefCell;
use std::io::{Cursor, IsTerminal};
use std::rc::Rc;
use std::time::Duration;
use clap::Parser;
use gamescope_hyperhdr::capture::{
requested_size_of, SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID, SPA_FORMAT_VIDEO_REQUESTED_SIZE,
SPA_META_REQUESTED_SIZE_SCALE,
};
use pipewire as pw;
use pw::properties::properties;
use pw::spa;
use pw::stream::{Stream, StreamFlags, StreamRc, StreamState};
use spa::param::format::{FormatProperties, MediaSubtype, MediaType};
use spa::param::video::{VideoFormat, VideoInfoRaw};
use spa::param::ParamType;
use spa::pod::deserialize::PodDeserializer;
use spa::pod::serialize::PodSerializer;
use spa::pod::{ChoiceValue, Object, Pod, Property, PropertyFlags, Value};
use spa::sys as spa_sys;
use spa::utils::{Choice, ChoiceEnum, ChoiceFlags, Direction, Fraction, Id, Rectangle, SpaTypes};
use tracing::{debug, error, info, warn};
const DRM_FORMAT_MOD_LINEAR: i64 = 0;
#[derive(Parser)]
#[command(about = "Fake gamescope PipeWire producer")]
struct Opt {
/// node.name of the stream
#[arg(long, default_value = "gamescope")]
name: String,
/// Output (display) width
#[arg(long, default_value_t = 3840)]
width: u32,
/// Output (display) height
#[arg(long, default_value_t = 2160)]
height: u32,
/// Frames per second while streaming
#[arg(long, default_value_t = 60)]
fps: u32,
/// Stop producing frames after the first few correctly sized ones (static picture)
#[arg(long = "static")]
static_picture: bool,
/// Ignore requested_size, like a gamescope without the extension
#[arg(long)]
ignore_requested: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Dim {
w: u32,
h: u32,
}
#[derive(Debug, Clone, Copy)]
struct Negotiated {
format: VideoFormat,
size: Dim,
stride: u32,
dmabuf: bool,
}
impl Negotiated {
/// `stride * height`, plus the chroma plane for NV12.
fn byte_size(&self, height: u32) -> u32 {
let mut size = self.stride * height;
if self.format == VideoFormat::NV12 {
size += self.stride * height.div_ceil(2);
}
size
}
}
struct Fake {
output: Dim,
requested: Dim,
capture: Dim,
format: Option<Negotiated>,
streaming: bool,
seq: u64,
frame_no: u64,
good: u64,
corrupted: u64,
out_of_buffers: u64,
static_picture: bool,
ignore_requested: bool,
}
impl Fake {
/// gamescope's `calculate_capture_size`, including its float math.
fn calculate_capture_size(&mut self) {
let (ow, oh) = (self.output.w, self.output.h);
let (rw, rh) = (self.requested.w, self.requested.h);
let (mut cw, mut ch) = (ow, oh);
if rw > 0 && rh > 0 && (ow > rw || oh > rh) {
let ratio_w = rw as f32 / ow as f32;
let ratio_h = rh as f32 / oh as f32;
if ratio_w <= ratio_h {
cw = rw;
ch = (ratio_w * oh as f32).ceil() as u32;
} else {
cw = (ratio_h * ow as f32).ceil() as u32;
ch = rh;
}
}
self.capture = Dim {
w: cw.next_multiple_of(2),
h: ch.next_multiple_of(2),
};
}
}
/// A MemFd buffer; `texture` is the size frames are rendered at (gamescope's CVulkanTexture).
struct ShmBuffer {
fd: i32,
ptr: *mut u8,
len: usize,
video: Negotiated,
texture: Dim,
}
fn serialize(object: Object) -> Vec<u8> {
PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(object))
.expect("serializing into a Vec cannot fail")
.0
.into_inner()
}
fn id_enum(default: u32, alternatives: &[u32]) -> Value {
Value::Choice(ChoiceValue::Id(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Enum {
default: Id(default),
alternatives: alternatives.iter().map(|v| Id(*v)).collect(),
},
)))
}
/// gamescope's `build_format_params`: BGRx and NV12, each with and without a modifier.
fn build_format_params(capture: Dim) -> Vec<Vec<u8>> {
let mut params = Vec::new();
for format in [VideoFormat::BGRx, VideoFormat::NV12] {
for with_modifier in [true, false] {
let mut properties = vec![
Property::new(
FormatProperties::MediaType.as_raw(),
Value::Id(Id(MediaType::Video.as_raw())),
),
Property::new(
FormatProperties::MediaSubtype.as_raw(),
Value::Id(Id(MediaSubtype::Raw.as_raw())),
),
Property::new(
FormatProperties::VideoFormat.as_raw(),
Value::Id(Id(format.as_raw())),
),
Property::new(
FormatProperties::VideoSize.as_raw(),
Value::Rectangle(Rectangle {
width: capture.w,
height: capture.h,
}),
),
Property::new(
FormatProperties::VideoFramerate.as_raw(),
Value::Fraction(Fraction { num: 0, denom: 1 }),
),
Property::new(
SPA_FORMAT_VIDEO_REQUESTED_SIZE,
Value::Choice(ChoiceValue::Rectangle(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: Rectangle {
width: 0,
height: 0,
},
min: Rectangle {
width: 0,
height: 0,
},
max: Rectangle {
width: u32::MAX,
height: u32::MAX,
},
},
))),
),
Property::new(
SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID,
Value::Choice(ChoiceValue::Long(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: 0,
min: i64::MIN,
max: i64::MAX,
},
))),
),
];
if format == VideoFormat::NV12 {
properties.push(Property::new(
FormatProperties::VideoColorMatrix.as_raw(),
id_enum(
spa_sys::SPA_VIDEO_COLOR_MATRIX_BT601,
&[
spa_sys::SPA_VIDEO_COLOR_MATRIX_BT601,
spa_sys::SPA_VIDEO_COLOR_MATRIX_BT709,
],
),
));
properties.push(Property::new(
FormatProperties::VideoColorRange.as_raw(),
id_enum(
spa_sys::SPA_VIDEO_COLOR_RANGE_16_235,
&[
spa_sys::SPA_VIDEO_COLOR_RANGE_16_235,
spa_sys::SPA_VIDEO_COLOR_RANGE_0_255,
],
),
));
}
if with_modifier {
properties.push(Property {
key: FormatProperties::VideoModifier.as_raw(),
flags: PropertyFlags::MANDATORY,
value: Value::Choice(ChoiceValue::Long(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Enum {
default: DRM_FORMAT_MOD_LINEAR,
alternatives: vec![DRM_FORMAT_MOD_LINEAR],
},
))),
});
}
params.push(serialize(Object {
type_: SpaTypes::ObjectParamFormat.as_raw(),
id: ParamType::EnumFormat.as_raw(),
properties,
}));
}
}
params
}
fn meta_param(meta_type: u32, size: usize) -> Vec<u8> {
serialize(Object {
type_: SpaTypes::ObjectParamMeta.as_raw(),
id: ParamType::Meta.as_raw(),
properties: vec![
Property::new(spa_sys::SPA_PARAM_META_type, Value::Id(Id(meta_type))),
Property::new(spa_sys::SPA_PARAM_META_size, Value::Int(size as i32)),
],
})
}
/// Buffers and Meta params set by `stream_handle_param_changed`.
fn buffer_params(n: &Negotiated) -> Vec<Vec<u8>> {
let data_type = if n.dmabuf {
1 << spa_sys::SPA_DATA_DmaBuf
} else {
1 << spa_sys::SPA_DATA_MemFd
};
let buffers = serialize(Object {
type_: SpaTypes::ObjectParamBuffers.as_raw(),
id: ParamType::Buffers.as_raw(),
properties: vec![
Property::new(
spa_sys::SPA_PARAM_BUFFERS_buffers,
Value::Choice(ChoiceValue::Int(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: 4,
min: 1,
max: 8,
},
))),
),
Property::new(spa_sys::SPA_PARAM_BUFFERS_blocks, Value::Int(1)),
Property::new(
spa_sys::SPA_PARAM_BUFFERS_size,
Value::Int(n.byte_size(n.size.h) as i32),
),
Property::new(
spa_sys::SPA_PARAM_BUFFERS_stride,
Value::Int(n.stride as i32),
),
Property::new(
spa_sys::SPA_PARAM_BUFFERS_dataType,
Value::Choice(ChoiceValue::Int(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Flags {
default: data_type,
flags: vec![],
},
))),
),
],
});
vec![
buffers,
meta_param(
spa_sys::SPA_META_Header,
size_of::<spa_sys::spa_meta_header>(),
),
meta_param(SPA_META_REQUESTED_SIZE_SCALE, size_of::<f32>()),
]
}
fn update_params(stream: &Stream, pods: &[Vec<u8>]) {
let mut params: Vec<&Pod> = pods
.iter()
.map(|p| Pod::from_bytes(p).expect("valid pod"))
.collect();
if let Err(e) = stream.update_params(&mut params) {
error!("pw_stream_update_params failed: {e}");
}
}
fn has_modifier(format: &Pod) -> bool {
matches!(
PodDeserializer::deserialize_any_from(format.as_bytes()),
Ok((_, Value::Object(o))) if o.properties.iter().any(|p| p.key == FormatProperties::VideoModifier.as_raw())
)
}
fn on_param_changed(stream: &Stream, fake: &RefCell<Fake>, id: u32, param: Option<&Pod>) {
if id != ParamType::Format.as_raw() {
return;
}
let Some(param) = param else {
return;
};
let mut info = VideoInfoRaw::new();
if info.parse(param).is_err() {
error!("spa_format_video_raw_parse failed");
return;
}
let requested = requested_size_of(param).map_or(Dim { w: 0, h: 0 }, |r| Dim {
w: r.width,
h: r.height,
});
let negotiated = {
let mut f = fake.borrow_mut();
f.requested = if f.ignore_requested {
Dim { w: 0, h: 0 }
} else {
requested
};
f.calculate_capture_size();
let size = Dim {
w: info.size().width,
h: info.size().height,
};
let bpp = if info.format() == VideoFormat::NV12 {
1
} else {
4
};
let negotiated = Negotiated {
format: info.format(),
size,
stride: (size.w * bpp).next_multiple_of(4),
dmabuf: has_modifier(param),
};
f.format = Some(negotiated);
info!(
"format changed (size {}x{}, requested {}x{}, capture {}x{}, format {:?}, stride {}, dmabuf {})",
size.w,
size.h,
requested.w,
requested.h,
f.capture.w,
f.capture.h,
negotiated.format,
negotiated.stride,
negotiated.dmabuf
);
negotiated
};
update_params(stream, &buffer_params(&negotiated));
}
/// # Safety
/// `pw_buffer` must be the buffer passed to the `add_buffer` event.
unsafe fn on_add_buffer(fake: &Fake, pw_buffer: *mut pw::sys::pw_buffer) {
let spa_buffer = (*pw_buffer).buffer;
if spa_buffer.is_null() || (*spa_buffer).n_datas < 1 {
return;
}
let data = &mut *(*spa_buffer).datas;
let Some(n) = fake.format else {
error!("add_buffer without a format");
return;
};
if data.type_ & (1 << spa_sys::SPA_DATA_MemFd) == 0 {
error!(
"unsupported data types {:#x}: the fake only implements MemFd",
data.type_
);
data.type_ = spa_sys::SPA_DATA_Invalid;
return;
}
let len = n.byte_size(n.size.h) as usize;
let fd = libc::memfd_create(c"gamescope-fake-pw".as_ptr(), libc::MFD_CLOEXEC);
if fd < 0 {
error!("memfd_create failed");
return;
}
if libc::ftruncate(fd, len as libc::off_t) != 0 {
error!("ftruncate failed");
libc::close(fd);
return;
}
let ptr = libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
);
if ptr == libc::MAP_FAILED {
error!("mmap failed");
libc::close(fd);
return;
}
data.type_ = spa_sys::SPA_DATA_MemFd;
data.flags = spa_sys::SPA_DATA_FLAG_READABLE;
data.fd = i64::from(fd);
data.mapoffset = 0;
data.maxsize = len as u32;
data.data = ptr;
debug!(
len,
"added buffer, texture {}x{}", fake.capture.w, fake.capture.h
);
let buffer = Box::new(ShmBuffer {
fd,
ptr: ptr.cast(),
len,
video: n,
texture: fake.capture,
});
(*pw_buffer).user_data = Box::into_raw(buffer).cast();
}
/// # Safety
/// `pw_buffer` must be the buffer passed to the `remove_buffer` event.
unsafe fn on_remove_buffer(pw_buffer: *mut pw::sys::pw_buffer) {
let ptr = (*pw_buffer).user_data.cast::<ShmBuffer>();
if ptr.is_null() {
return;
}
(*pw_buffer).user_data = std::ptr::null_mut();
let buffer = Box::from_raw(ptr);
libc::munmap(buffer.ptr.cast(), buffer.len);
libc::close(buffer.fd);
}
/// RGB colour bars that move by one bar every 8 frames.
fn render(buffer: &mut ShmBuffer, frame_no: u64) {
const BARS: [[u8; 3]; 8] = [
[235, 235, 235],
[235, 235, 16],
[16, 235, 235],
[16, 235, 16],
[235, 16, 235],
[235, 16, 16],
[16, 16, 235],
[16, 16, 16],
];
// SAFETY: `ptr` maps `len` bytes for the lifetime of the buffer.
let mem = unsafe { std::slice::from_raw_parts_mut(buffer.ptr, buffer.len) };
let (w, h) = (buffer.texture.w as usize, buffer.texture.h as usize);
let stride = buffer.video.stride as usize;
if buffer.video.format == VideoFormat::NV12 {
mem.fill(128);
return;
}
let shift = (frame_no / 8) as usize;
for x in 0..w {
let [r, g, b] = BARS[(x * BARS.len() / w + shift) % BARS.len()];
mem[x * 4..x * 4 + 4].copy_from_slice(&[b, g, r, 0xFF]);
}
for y in 1..h {
mem.copy_within(0..w * 4, y * stride);
}
}
/// gamescope's `paint_pipewire` + `dispatch_nudge` + `copy_buffer`, once per frame.
fn tick(stream: &Stream, fake: &RefCell<Fake>) {
let renegotiate = {
let f = fake.borrow();
if !f.streaming {
return;
}
let current = f.format.map_or(Dim { w: 0, h: 0 }, |n| n.size);
(f.capture != current).then(|| {
debug!(
"renegotiating stream params (size: {}x{})",
f.capture.w, f.capture.h
);
build_format_params(f.capture)
})
};
if let Some(pods) = renegotiate {
update_params(stream, &pods);
}
let mut f = fake.borrow_mut();
if f.static_picture && f.good >= 5 {
return;
}
// SAFETY: the buffer is queued back below and only accessed in between.
unsafe {
let raw = stream.dequeue_raw_buffer();
if raw.is_null() {
f.out_of_buffers += 1;
if f.out_of_buffers.is_power_of_two() {
warn!("out of buffers ({} times)", f.out_of_buffers);
}
return;
}
let shm = (*raw).user_data.cast::<ShmBuffer>();
if shm.is_null() {
stream.queue_raw_buffer(raw);
return;
}
let shm = &mut *shm;
let spa_buffer = (*raw).buffer;
let needs_reneg = shm.video.size != shm.texture;
let header = spa_sys::spa_buffer_find_meta_data(
spa_buffer,
spa_sys::SPA_META_Header,
size_of::<spa_sys::spa_meta_header>(),
)
.cast::<spa_sys::spa_meta_header>();
if !header.is_null() {
(*header).pts = -1;
(*header).flags = if needs_reneg {
spa_sys::SPA_META_HEADER_FLAG_CORRUPTED
} else {
0
};
(*header).seq = f.seq;
(*header).dts_offset = 0;
}
f.seq += 1;
let scale = spa_sys::spa_buffer_find_meta_data(
spa_buffer,
SPA_META_REQUESTED_SIZE_SCALE,
size_of::<f32>(),
)
.cast::<f32>();
if !scale.is_null() {
*scale = shm.texture.w as f32 / f.output.w as f32;
}
let data = &mut *(*spa_buffer).datas;
let chunk = &mut *data.chunk;
chunk.flags = if needs_reneg {
spa_sys::SPA_CHUNK_FLAG_CORRUPTED as i32
} else {
0
};
// Like gamescope, the chunk size follows the current format, the stride the buffer.
let current = f.format.unwrap_or(shm.video);
chunk.offset = 0;
chunk.size = Negotiated {
stride: shm.video.stride,
..current
}
.byte_size(current.size.h);
chunk.stride = shm.video.stride as i32;
if needs_reneg {
f.corrupted += 1;
} else {
render(shm, f.frame_no);
f.good += 1;
}
f.frame_no += 1;
drop(f);
stream.queue_raw_buffer(raw);
}
}
fn main() -> anyhow::Result<()> {
let opt = Opt::parse();
tracing_subscriber::fmt()
.with_target(false)
.with_writer(std::io::stderr)
.with_ansi(std::io::stderr().is_terminal())
.init();
pw::init();
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?;
let core = context.connect_rc(None)?;
let _signals = [pw::loop_::Signal::INT, pw::loop_::Signal::TERM].map(|signal| {
let mainloop_weak = mainloop.downgrade();
mainloop.loop_().add_signal_local(signal, move || {
if let Some(mainloop) = mainloop_weak.upgrade() {
mainloop.quit();
}
})
});
let _core_listener = {
let mainloop_weak = mainloop.downgrade();
core.add_listener_local()
.error(move |id, _, res, message| {
error!(id, res, "PipeWire error: {message}");
if id == pw::sys::PW_ID_CORE {
if let Some(mainloop) = mainloop_weak.upgrade() {
mainloop.quit();
}
}
})
.register()
};
let fake = Rc::new(RefCell::new(Fake {
output: Dim {
w: opt.width,
h: opt.height,
},
requested: Dim { w: 0, h: 0 },
capture: Dim { w: 0, h: 0 },
format: None,
streaming: false,
seq: 0,
frame_no: 0,
good: 0,
corrupted: 0,
out_of_buffers: 0,
static_picture: opt.static_picture,
ignore_requested: opt.ignore_requested,
}));
fake.borrow_mut().calculate_capture_size();
// gamescope passes only media.class. With PipeWire 1.6 the stream name becomes media.name
// and node.name defaults to the program name, which is "gamescope" for the real binary,
// so the fake sets node.name explicitly.
let stream = StreamRc::new(
core.clone(),
&opt.name,
properties! {
*pw::keys::MEDIA_CLASS => "Video/Source",
*pw::keys::NODE_NAME => opt.name.as_str(),
},
)?;
let _listener = {
let mainloop_weak = mainloop.downgrade();
stream
.add_local_listener_with_user_data(fake.clone())
.state_changed(move |stream, fake, old, new| {
info!("stream state changed: {old:?} -> {new:?}");
let mut f = fake.borrow_mut();
match new {
StreamState::Paused => {
info!("stream available on node ID: {}", stream.node_id());
f.streaming = false;
f.seq = 0;
}
StreamState::Streaming => f.streaming = true,
StreamState::Error(_) | StreamState::Unconnected => {
if let Some(mainloop) = mainloop_weak.upgrade() {
mainloop.quit();
}
}
StreamState::Connecting => {}
}
})
.param_changed(|stream, fake, id, param| on_param_changed(stream, fake, id, param))
// SAFETY: the pointers come straight from the stream events.
.add_buffer(|_, fake, buffer| unsafe { on_add_buffer(&fake.borrow(), buffer) })
.remove_buffer(|_, _, buffer| unsafe { on_remove_buffer(buffer) })
.register()?
};
let pods = build_format_params(fake.borrow().capture);
let mut params: Vec<&Pod> = pods
.iter()
.map(|p| Pod::from_bytes(p).expect("valid pod"))
.collect();
stream.connect(
Direction::Output,
None,
StreamFlags::DRIVER | StreamFlags::ALLOC_BUFFERS,
&mut params,
)?;
let frame_timer = {
let (stream, fake) = (stream.clone(), fake.clone());
mainloop.loop_().add_timer(move |_| tick(&stream, &fake))
};
let interval = Duration::from_secs(1) / opt.fps.max(1);
let _ = frame_timer.update_timer(Some(interval), Some(interval));
let report_timer = {
let fake = fake.clone();
mainloop.loop_().add_timer(move |_| {
let f = fake.borrow();
info!(
"frames: {} ok, {} corrupted, out of buffers {}, streaming {}, capture {}x{}",
f.good, f.corrupted, f.out_of_buffers, f.streaming, f.capture.w, f.capture.h
);
})
};
let _ = report_timer.update_timer(Some(Duration::from_secs(5)), Some(Duration::from_secs(5)));
info!(
"fake gamescope '{}' output {}x{} at {} fps{}{}",
opt.name,
opt.width,
opt.height,
opt.fps,
if opt.static_picture { ", static" } else { "" },
if opt.ignore_requested {
", ignoring requested_size"
} else {
""
}
);
mainloop.run();
Ok(())
}
+60
View File
@@ -0,0 +1,60 @@
//! Standalone HyperHDR flatbuffers server mock, the same code the integration tests use.
//!
//! cargo run --example mock_hyperhdr -- --listen 127.0.0.1:19400
use std::io::IsTerminal;
use std::time::Duration;
use clap::{Parser, ValueEnum};
use gamescope_hyperhdr::hyperhdr::mock::{Behavior, MockOptions, MockServer};
use tracing::info;
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Mode {
Normal,
Reject,
NoRead,
ErrorReplies,
}
#[derive(Parser)]
#[command(about = "Imitation of HyperHDR's flatbuffers server")]
struct Opt {
/// Address to listen on
#[arg(long, default_value = "127.0.0.1:19400")]
listen: String,
/// Close connections after this many seconds without data (HyperHDR's `timeout`)
#[arg(long, default_value_t = 5)]
timeout_secs: u64,
/// How to treat connections
#[arg(long, value_enum, default_value_t = Mode::Normal)]
behavior: Mode,
}
fn main() -> anyhow::Result<()> {
let opt = Opt::parse();
tracing_subscriber::fmt()
.with_target(false)
.with_writer(std::io::stderr)
.with_ansi(std::io::stderr().is_terminal())
.init();
let server = MockServer::start(
opt.listen.as_str(),
MockOptions {
idle_timeout: Duration::from_secs(opt.timeout_secs),
record_events: false,
log_events: true,
},
)?;
server.set_behavior(match opt.behavior {
Mode::Normal => Behavior::Normal,
Mode::Reject => Behavior::Reject,
Mode::NoRead => Behavior::NoRead,
Mode::ErrorReplies => Behavior::ErrorReplies,
});
info!("listening on {} ({:?})", server.addr(), opt.behavior);
loop {
std::thread::park();
}
}
+26
View File
@@ -0,0 +1,26 @@
The *.fbs files in this directory are copied from HyperHDR
(https://github.com/awawa-dev/HyperHDR, include/flatbuffers/parser/,
commit 3a76dcd, version 24.0.0-beta.0) and are distributed under the
following license:
MIT License
Copyright (c) 2020-2026 awawa-dev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
// Copied verbatim from HyperHDR (https://github.com/awawa-dev/HyperHDR),
// include/flatbuffers/parser/hyperhdr_reply.fbs @ 3a76dcd (24.0.0-beta.0).
// Copyright (c) 2020-2026 awawa-dev. MIT License, see schema/NOTICE.
namespace hyperhdrnet;
table Reply {
error:string;
video:int = -1;
registered:int = -1;
}
root_type Reply;
+50
View File
@@ -0,0 +1,50 @@
// Copied verbatim from HyperHDR (https://github.com/awawa-dev/HyperHDR),
// include/flatbuffers/parser/hyperhdr_request.fbs @ 3a76dcd (24.0.0-beta.0).
// Copyright (c) 2020-2026 awawa-dev. MIT License, see schema/NOTICE.
namespace hyperhdrnet;
// A priority value of -1 clears all priorities
table Register {
origin:string (required);
priority:int;
}
table RawImage {
data:[ubyte];
width:int = -1;
height:int = -1;
}
table NV12Image {
data_y:[ubyte];
data_uv:[ubyte];
width:int;
height:int;
stride_y:int = 0;
stride_uv:int = 0;
}
union ImageType {RawImage, NV12Image}
table Image {
data:ImageType (required);
duration:int = -1;
}
table Clear {
priority:int;
}
table Color {
data:int = -1;
duration:int = -1;
}
union Command {Color, Image, Clear, Register}
table Request {
command:Command (required);
}
root_type Request;
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# End-to-end check in the current PipeWire session (spec 9.3):
# examples/fake_gamescope + gamescope-hyperhdr + examples/mock_hyperhdr.
#
# The fake node gets a unique name, so a real gamescope node is never touched.
# Requires: pw-link, pw-dump, jq; gst-launch-1.0 with pipewiresink for scenario 4.
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
cargo build --release --bin gamescope-hyperhdr --example fake_gamescope --example mock_hyperhdr || exit 1
bin="$root/target/release"
logs="$(mktemp -d /tmp/gshdr-e2e.XXXXXX)"
name="gamescope-e2e-$$"
client_node="gamescope-hyperhdr"
port=$((20000 + RANDOM % 20000))
failures=0
mock_pid="" fake_pid="" client_pid="" gst_pid=""
pass() { printf ' \033[32mPASS\033[0m %s\n' "$*"; }
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; failures=$((failures + 1)); }
skip() { printf ' \033[33mSKIP\033[0m %s\n' "$*"; }
check() {
local desc=$1
shift
if "$@"; then pass "$desc"; else fail "$desc"; fi
}
# wait_pid PID TIMEOUT_S: returns the exit status, or kills the process and returns 124.
wait_pid() {
local pid=$1 end=$((SECONDS + $2))
while [[ -e /proc/$pid && "$(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null)" != Z ]]; do
if ((SECONDS >= end)); then
kill -9 "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
fi
sleep 0.05
done
wait "$pid" 2>/dev/null
}
cleanup() {
for pid in $client_pid $fake_pid $gst_pid $mock_pid; do
kill "$pid" 2>/dev/null
wait_pid "$pid" 3
done
echo "logs: $logs"
}
trap cleanup EXIT
# wait_count FILE REGEX MIN_COUNT TIMEOUT_S [FROM_LINE]
wait_count() {
local file=$1 re=$2 min=$3 from=${5:-1} end=$((SECONDS + $4))
while :; do
local n
n=$(tail -n "+$from" "$file" 2>/dev/null | grep -Ec -- "$re")
((n >= min)) && return 0
((SECONDS >= end)) && return 1
sleep 0.2
done
}
wait_log() { wait_count "$1" "$2" 1 "${3:-10}" "${4:-1}"; }
next_line() { echo $(($(wc -l <"$1") + 1)); }
absent_since() { ! tail -n "+$3" "$1" | grep -Eq -- "$2"; }
start_mock() {
"$bin/examples/mock_hyperhdr" --listen "127.0.0.1:$port" --timeout-secs 5 >>"$logs/mock.log" 2>&1 &
mock_pid=$!
}
start_fake() {
echo "--- start $*" >>"$logs/fake.log"
"$bin/examples/fake_gamescope" --name "$name" "$@" >>"$logs/fake.log" 2>&1 &
fake_pid=$!
}
stop_fake() {
kill "$fake_pid" 2>/dev/null
wait_pid "$fake_pid" 3
fake_pid=""
}
start_client() {
local log=$1
shift
"$bin/gamescope-hyperhdr" --host 127.0.0.1 --port "$port" --target "$name" "$@" >"$log" 2>&1 &
client_pid=$!
}
stop_client() {
kill -TERM "$client_pid" 2>/dev/null
wait_pid "$client_pid" 3
client_pid=""
}
# "<output port> -> <input port>" for every link.
links() {
pw-link -l 2>/dev/null | awk '/^[^ \t]/ { port = $0 } /\|->/ { sub(/^[ \t]*\|->[ \t]*/, ""); print port " -> " $0 }'
}
linked_to_fake() { links | grep -q "^$name:.* -> $client_node:"; }
foreign_links() { links | grep " -> $client_node:" | grep -v "^$name:"; }
node_exists() {
pw-dump 2>/dev/null | jq -e --arg n "$1" \
'any(.[]; .type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n)' >/dev/null
}
# Negotiated Format of the client's ports, e.g. "BGRx 320x180".
client_port_format() {
pw-dump 2>/dev/null | jq -r --arg n "$client_node" '
[.[] | select(.type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n) | .id] as $ids
| .[] | select(.type == "PipeWire:Interface:Port" and (.info.props["node.id"] as $id | $ids | index($id)))
| .info.params.Format[]? | "\(.format) \(.size.width)x\(.size.height)"'
}
echo "== setup (fake node '$name', mock on port $port, logs in $logs)"
start_mock
wait_log "$logs/mock.log" "listening on" 5 || { fail "mock did not start"; exit 1; }
start_client "$logs/client.log"
wait_log "$logs/client.log" "waiting for a '$name'" 5 || { fail "client did not start"; exit 1; }
start_fake --width 3840 --height 2160 --fps 60
echo "== 1. requested_size renegotiation"
check "1st Format event is the output size 3840x2160" wait_log "$logs/client.log" 'format: BGRx 3840x2160.*event=1'
check "2nd Format event is 320x180" wait_log "$logs/client.log" 'format: BGRx 320x180.*event=2'
check "frames arrive as 320x180" wait_log "$logs/client.log" 'receiving frames: 320x180 .*sending 320x180'
check "registered with priority 150" wait_log "$logs/client.log" 'registered with priority 150'
check "mock receives 320x180 RGB" wait_log "$logs/mock.log" 'image 320x180 len=172800 size_ok=true'
echo "== 2. graph"
sleep 1
links >"$logs/links.txt"
client_port_format >"$logs/format.txt"
check "client is linked to the fake node" linked_to_fake
check "nothing else is linked to the client" test -z "$(foreign_links)"
check "pw-dump: negotiated $(tr '\n' ' ' <"$logs/format.txt")" grep -q "BGRx 320x180" "$logs/format.txt"
echo "== 3. source disappears and returns"
mock_mark=$(next_line "$logs/mock.log")
client_mark=$(next_line "$logs/client.log")
stop_fake
check "client sends Clear when the node disappears" wait_log "$logs/client.log" 'source gone: sent Clear' 5 "$client_mark"
check "mock gets Clear{150}" wait_log "$logs/mock.log" 'Clear \{ priority: 150 \}' 5 "$mock_mark"
check "mock sees the client close the connection" wait_log "$logs/mock.log" 'Disconnected\(ClosedByClient\)' 5 "$mock_mark"
sleep 1
mock_mark=$(next_line "$logs/mock.log")
start_fake --width 3840 --height 2160 --fps 60
check "client re-registers without a restart" wait_log "$logs/mock.log" 'Register \{ origin: "gamescope", priority: 150 \}' 10 "$mock_mark"
check "frames flow again" wait_log "$logs/mock.log" 'image 320x180 len=172800 size_ok=true' 10 "$mock_mark"
echo "== 4. foreign Video/Source is ignored"
client_mark=$(next_line "$logs/client.log")
stop_fake
wait_log "$logs/client.log" 'destroying stream' 5 "$client_mark"
client_mark=$(next_line "$logs/client.log")
if command -v gst-launch-1.0 >/dev/null; then
foreign="e2e-foreign-$$"
gst-launch-1.0 -q videotestsrc is-live=true \
! video/x-raw,format=BGRx,width=320,height=180,framerate=30/1 \
! pipewiresink mode=provide "stream-properties=props,media.class=Video/Source,node.name=$foreign" \
>"$logs/gst.log" 2>&1 &
gst_pid=$!
sleep 3
if node_exists "$foreign"; then
check "client does not create a stream" absent_since "$logs/client.log" 'stream created' "$client_mark"
check "client node does not exist" test "$(node_exists "$client_node" && echo yes)" != yes
check "nothing is linked to the client" test -z "$(links | grep " -> $client_node:")"
else
skip "pipewiresink did not create node '$foreign'"
fi
kill "$gst_pid" 2>/dev/null
wait_pid "$gst_pid" 3
gst_pid=""
else
skip "gst-launch-1.0 not installed"
fi
echo "== 5. SIGTERM"
mock_mark=$(next_line "$logs/mock.log")
start_fake --width 3840 --height 2160 --fps 60
wait_log "$logs/mock.log" 'image 320x180' 10 "$mock_mark"
started=$(date +%s%N)
kill -TERM "$client_pid"
wait_pid "$client_pid" 3
status=$?
elapsed_ms=$((($(date +%s%N) - started) / 1000000))
client_pid=""
check "exit code 0 (got $status)" test "$status" -eq 0
check "exits within 1 s (${elapsed_ms} ms)" test "$elapsed_ms" -lt 1000
check "sends Clear on shutdown" grep -q 'shutting down: sent Clear' "$logs/client.log"
check "mock gets Clear{150}" wait_log "$logs/mock.log" 'Clear \{ priority: 150 \}' 2 "$mock_mark"
echo "== 6. --supersample 2"
mock_mark=$(next_line "$logs/mock.log")
start_client "$logs/client-supersample.log" --supersample 2
check "requests 640x360" wait_log "$logs/client-supersample.log" 'format: BGRx 640x360.*requested_size 640x360'
check "downscales 640x360 to 320x180" wait_log "$logs/client-supersample.log" 'receiving frames: 640x360 .*sending 320x180'
check "mock receives 320x180" wait_log "$logs/mock.log" 'image 320x180 len=172800 size_ok=true' 10 "$mock_mark"
stop_client
echo "== 7. static picture: keepalive holds the connection"
stop_fake
start_fake --width 3840 --height 2160 --fps 60 --static
mock_mark=$(next_line "$logs/mock.log")
start_client "$logs/client-static.log"
wait_log "$logs/mock.log" 'image 320x180' 10 "$mock_mark"
sleep 8
check "no idle timeout on the server (5 s)" absent_since "$logs/mock.log" 'Disconnected' "$mock_mark"
check "keepalive images keep arriving" wait_count "$logs/mock.log" 'images in' 3 1 "$mock_mark"
stop_client
echo
if ((failures == 0)); then
echo "all scenarios passed"
else
echo "$failures check(s) failed"
fi
exit $((failures > 0))
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Spec 9.4.1 / 9.4.3 with a real gamescope: a nested headless gamescope running vkcube,
# gamescope-hyperhdr and the mock HyperHDR, plus a load comparison with the old gst pipeline
# (pipewiresrc → videorate → videoscale → videoconvert, into fakesink instead of v4l2loopback).
#
# Environment: W, H (output size, default 3840x2160), REFRESH (60), SECS (measurement, 10),
# CLIENT_ARGS (extra client flags, e.g. "--supersample 2").
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
cargo build --release --bin gamescope-hyperhdr --example mock_hyperhdr || exit 1
W=${W:-3840} H=${H:-2160} REFRESH=${REFRESH:-60} SECS=${SECS:-10}
read -r -a client_args <<<"${CLIENT_ARGS:-}"
bin="$root/target/release"
logs="$(mktemp -d /tmp/gshdr-nested.XXXXXX)"
port=$((20000 + RANDOM % 20000))
mock_pid="" gs_pid="" client_pid="" gst_pid=""
node_exists() {
pw-dump 2>/dev/null | jq -e --arg n "$1" \
'any(.[]; .type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n)' >/dev/null
}
if node_exists gamescope; then
echo "a 'gamescope' node already exists (gamescope-tv running?); stop it first" >&2
exit 1
fi
wait_pid() {
local pid=$1 end=$((SECONDS + $2))
while [[ -e /proc/$pid && "$(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null)" != Z ]]; do
if ((SECONDS >= end)); then
kill -9 "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
fi
sleep 0.02
done
wait "$pid" 2>/dev/null
}
cleanup() {
for pid in $client_pid $gst_pid $gs_pid $mock_pid; do
kill "$pid" 2>/dev/null
wait_pid "$pid" 5
done
echo "logs: $logs"
}
trap cleanup EXIT
wait_log() {
local end=$((SECONDS + $3))
until grep -Eq -- "$2" "$1" 2>/dev/null; do
((SECONDS >= end)) && return 1
sleep 0.1
done
}
links() {
pw-link -l 2>/dev/null | awk '/^[^ \t]/ { port = $0 } /\|->/ { sub(/^[ \t]*\|->[ \t]*/, ""); print port " -> " $0 }'
}
port_format() {
pw-dump 2>/dev/null | jq -r --arg n "$1" '
[.[] | select(.type == "PipeWire:Interface:Node" and .info.props["node.name"] == $n) | .id] as $ids
| .[] | select(.type == "PipeWire:Interface:Port" and (.info.props["node.id"] as $id | $ids | index($id)))
| .info.params.Format[]? | "\(.format) \(.size.width)x\(.size.height)"'
}
cpu_ticks() { awk '{ print $14 + $15 }' "/proc/$1/stat"; }
rss_kib() { awk '/^VmRSS:/ { print $2 }' "/proc/$1/status"; }
# measure NAME=PID...: CPU (% of one core) and RSS over SECS seconds
measure() {
local hz item t0 t1
hz=$(getconf CLK_TCK)
local -A before
for item in "$@"; do before[$item]=$(cpu_ticks "${item#*=}"); done
t0=$(date +%s%N)
sleep "$SECS"
t1=$(date +%s%N)
for item in "$@"; do
local pid=${item#*=}
awk -v ticks="$(($(cpu_ticks "$pid") - before[$item]))" -v hz="$hz" -v ns="$((t1 - t0))" \
-v name="${item%%=*}" -v rss="$(rss_kib "$pid")" \
'BEGIN { printf " %-20s CPU %6.2f %% RSS %6.1f MB\n", name, ticks / hz / (ns / 1e9) * 100, rss / 1024 }'
done
}
"$bin/examples/mock_hyperhdr" --listen "127.0.0.1:$port" >"$logs/mock.log" 2>&1 &
mock_pid=$!
echo "== nested gamescope ${W}x${H}@${REFRESH} (headless, vkcube)"
gamescope --backend headless -W "$W" -H "$H" -r "$REFRESH" -- vkcube >"$logs/gamescope.log" 2>&1 &
gs_pid=$!
for _ in $(seq 50); do node_exists gamescope && break; sleep 0.2; done
node_exists gamescope || { echo "gamescope node did not appear, see $logs/gamescope.log"; exit 1; }
sleep 2
echo "== gamescope-hyperhdr ${client_args[*]}"
"$bin/gamescope-hyperhdr" --host 127.0.0.1 --port "$port" "${client_args[@]}" >"$logs/client.log" 2>&1 &
client_pid=$!
wait_log "$logs/client.log" 'receiving frames' 15 || echo " no frames within 15 s"
wait_log "$logs/client.log" 'registered with priority' 5
grep -E 'format:|receiving frames|registered|warn|WARN' "$logs/client.log" | sed 's/^[^ ]* */ /'
echo " links: $(links | grep ' -> gamescope-hyperhdr:' | tr '\n' ' ')"
echo " format: $(port_format gamescope-hyperhdr | tr '\n' ' ')"
echo " load over $SECS s:"
measure gamescope="$gs_pid" gamescope-hyperhdr="$client_pid"
echo " mock: $(grep 'images in' "$logs/mock.log" | tail -1 | sed 's/^.*INFO //')"
started=$(date +%s%N)
kill -TERM "$client_pid"
wait_pid "$client_pid" 3
status=$?
client_pid=""
echo " SIGTERM: exit $status after $((($(date +%s%N) - started) / 1000000)) ms, $(grep -c 'Clear { priority' "$logs/mock.log") Clear(s) at the mock"
if command -v gst-launch-1.0 >/dev/null; then
echo "== old pipeline: pipewiresrc → videorate 30 → videoscale 320x180 → videoconvert YUY2 → fakesink"
sleep 2
gst-launch-1.0 -q pipewiresrc target-object=gamescope do-timestamp=true keepalive-time=200 \
! videorate ! video/x-raw,framerate=30/1 \
! videoscale ! video/x-raw,width=320,height=180 \
! videoconvert ! video/x-raw,format=YUY2 \
! fakesink sync=false >"$logs/gst.log" 2>&1 &
gst_pid=$!
sleep 4
echo " format: $(links | grep -oE '[^ ]+ -> gst-launch[^ ]*' | head -1) $(port_format gst-launch-1.0 | tr '\n' ' ')"
echo " load over $SECS s:"
measure gamescope="$gs_pid" gst-launch-1.0="$gst_pid"
kill "$gst_pid"
wait_pid "$gst_pid" 3
gst_pid=""
fi
echo "== gamescope stops → Clear"
"$bin/gamescope-hyperhdr" --host 127.0.0.1 --port "$port" "${client_args[@]}" >"$logs/client2.log" 2>&1 &
client_pid=$!
wait_log "$logs/client2.log" 'registered with priority' 15
clears_before=$(grep -c 'Clear { priority' "$logs/mock.log")
started=$(date +%s%N)
kill -TERM "$gs_pid"
until (($(grep -c 'Clear { priority' "$logs/mock.log") > clears_before)); do
(($(date +%s%N) - started > 10000000000)) && break
sleep 0.01
done
echo " Clear at the mock $((($(date +%s%N) - started) / 1000000)) ms after SIGTERM to gamescope"
wait_pid "$gs_pid" 10
gs_pid=""
sleep 1
grep -E 'source node removed|destroying stream|source gone|ERROR' "$logs/client2.log" | sed 's/^[^ ]* */ /'
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Regenerate Rust code from the HyperHDR flatbuffers schemas.
# The flatc version must match the `flatbuffers` crate version in Cargo.toml.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
out="$root/src/hyperhdr/generated"
crate_ver="$(grep -E '^flatbuffers\s*=' "$root/Cargo.toml" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')"
flatc_ver="$(flatc --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')"
if [[ "$crate_ver" != "$flatc_ver" ]]; then
echo "flatc $flatc_ver does not match flatbuffers crate $crate_ver" >&2
exit 1
fi
rm -f "$out"/*_generated.rs
mkdir -p "$out"
flatc --rust -o "$out" "$root/schema/hyperhdr_request.fbs" "$root/schema/hyperhdr_reply.fbs"
echo "generated with flatc $flatc_ver:"
ls -1 "$out"
+758
View File
@@ -0,0 +1,758 @@
//! PipeWire side (spec 4.2): finds the gamescope node, negotiates a small BGRx MemFd format
//! through gamescope's `requested_size` extension, converts buffers and publishes frames.
//!
//! Everything here runs on the PipeWire main loop in the main thread.
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, VecDeque};
use std::fmt::{self, Write as _};
use std::io::Cursor;
use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context as _};
use pipewire as pw;
use pw::properties::properties;
use pw::spa;
use pw::stream::{Stream, StreamFlags, StreamListener, StreamRc, StreamState};
use spa::buffer::meta::{MetaHeader, MetaHeaderFlags};
use spa::buffer::ChunkFlags;
use spa::param::format::{FormatProperties, MediaSubtype, MediaType};
use spa::param::video::{VideoFormat, VideoInfoRaw};
use spa::param::ParamType;
use spa::pod::deserialize::PodDeserializer;
use spa::pod::serialize::PodSerializer;
use spa::pod::{ChoiceValue, Object, Pod, Property, PropertyFlags, Value};
use spa::utils::{Choice, ChoiceEnum, ChoiceFlags, Direction, Fraction, Id, Rectangle, SpaTypes};
use tracing::{debug, error, info, warn};
use crate::config::Size;
use crate::convert::{fit_within, BgrxFrame, Converter};
use crate::pacing::RateLimit;
use crate::slot::{Frame, FrameSlot};
use crate::stats::{self, Stats};
/// gamescope's `SPA_FORMAT_VIDEO_requested_size` (`src/pipewire_gamescope.hpp`).
pub const SPA_FORMAT_VIDEO_REQUESTED_SIZE: u32 = 0x70000;
/// gamescope's `SPA_FORMAT_VIDEO_gamescope_focus_appid`. Deliberately never set.
pub const SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID: u32 = 0x70001;
/// gamescope's `SPA_META_requested_size_scale`.
pub const SPA_META_REQUESTED_SIZE_SCALE: u32 = 0x70000;
const STREAM_NODE_NAME: &str = "gamescope-hyperhdr";
const SOURCE_MEDIA_CLASS: &str = "Video/Source";
const RETRY_DELAY: Duration = Duration::from_secs(1);
/// Period of the housekeeping timer that applies deferred teardowns and (re)creates the stream.
/// A stream must not be destroyed from its own callbacks, and `pw::channel` cannot defer the
/// work: its receiver callback holds the channel lock, so sending from there deadlocks.
const TICK: Duration = Duration::from_millis(100);
/// Explain the silence when a streaming stream has not delivered a frame for this long.
const NO_FRAMES_HINT_AFTER: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct CaptureConfig {
/// `node.name` of the source.
pub target: String,
/// Size requested from gamescope (`size × supersample`).
pub requested: Size,
/// Frames are downscaled to fit into this size.
pub output_max: Size,
}
struct Teardown {
generation: u64,
reason: String,
retry_delay: Option<Duration>,
}
type Teardowns = Rc<RefCell<VecDeque<Teardown>>>;
/// Progress towards the first frame of a stream, shared by its callbacks and the housekeeping
/// timer. gamescope renders a frame for PipeWire only when the picture changes, so on a static
/// screen the first frame can take arbitrarily long.
#[derive(Debug, Default)]
struct FirstFrame {
/// When the stream first reached Streaming.
streaming_since: Cell<Option<Instant>>,
received: Cell<bool>,
hinted: Cell<bool>,
}
struct Active {
node_id: u32,
serial: u64,
generation: u64,
first_frame: Rc<FirstFrame>,
// Dropped before the stream, so no callbacks run during its destruction.
_listener: StreamListener<StreamData>,
_stream: StreamRc,
}
struct State {
cfg: CaptureConfig,
core: pw::core::CoreRc,
slot: Arc<FrameSlot>,
stats: Arc<Stats>,
teardowns: Teardowns,
/// Matching source nodes: global id → object.serial.
candidates: BTreeMap<u32, u64>,
active: Option<Active>,
retry_at: Option<Instant>,
generation: u64,
}
/// Runs the PipeWire main loop until SIGINT/SIGTERM (`Ok`) or a fatal PipeWire error (`Err`).
///
/// `on_ready` runs once the signal handlers are installed; start other threads there.
/// PipeWire receives signals through a signalfd and blocks them only in the calling thread,
/// so a thread spawned earlier would still get the default (terminating) disposition.
pub fn run(
cfg: CaptureConfig,
slot: Arc<FrameSlot>,
stats: Arc<Stats>,
on_ready: impl FnOnce() -> anyhow::Result<()>,
) -> anyhow::Result<()> {
pw::init();
let mainloop =
pw::main_loop::MainLoopRc::new(None).context("cannot create the PipeWire main loop")?;
let _signals = [
(pw::loop_::Signal::INT, "SIGINT"),
(pw::loop_::Signal::TERM, "SIGTERM"),
]
.map(|(signal, name)| {
let mainloop_weak = mainloop.downgrade();
mainloop.loop_().add_signal_local(signal, move || {
info!("{name} received, shutting down");
if let Some(mainloop) = mainloop_weak.upgrade() {
mainloop.quit();
}
})
});
on_ready()?;
let context =
pw::context::ContextRc::new(&mainloop, None).context("cannot create a PipeWire context")?;
let core = context
.connect_rc(None)
.context("cannot connect to the PipeWire daemon")?;
let registry = core
.get_registry_rc()
.context("cannot get the PipeWire registry")?;
let fatal: Rc<RefCell<Option<String>>> = Rc::default();
let _core_listener = {
let mainloop_weak = mainloop.downgrade();
let fatal = fatal.clone();
core.add_listener_local()
.error(move |id, seq, res, message| {
let broken_pipe = std::io::Error::from_raw_os_error(-res).kind()
== std::io::ErrorKind::BrokenPipe;
if id == pw::sys::PW_ID_CORE && broken_pipe {
error!(seq, res, "lost connection to PipeWire: {message}");
*fatal.borrow_mut() =
Some(format!("lost connection to PipeWire: {message} ({res})"));
if let Some(mainloop) = mainloop_weak.upgrade() {
mainloop.quit();
}
} else if id == pw::sys::PW_ID_CORE {
// E.g. "unknown resource" after the server destroyed our stream's node
// because its target disappeared.
warn!(seq, res, "PipeWire core error: {message}");
} else {
// Stream errors are also reported through its state_changed event.
debug!(id, seq, res, "PipeWire error: {message}");
}
})
.register()
};
let state = Rc::new(RefCell::new(State {
cfg,
core: core.clone(),
slot,
stats,
teardowns: Teardowns::default(),
candidates: BTreeMap::new(),
active: None,
retry_at: None,
generation: 0,
}));
let _registry_listener = {
let (on_add, on_remove) = (state.clone(), state.clone());
registry
.add_listener_local()
.global(move |global| {
if global.type_ != pw::types::ObjectType::Node {
return;
}
let Some(props) = global.props else {
return;
};
let mut s = on_add.borrow_mut();
if props.get("node.name") != Some(s.cfg.target.as_str()) {
return;
}
let class = props.get("media.class");
if class != Some(SOURCE_MEDIA_CLASS) {
debug!(
id = global.id,
"ignoring node '{}' with media.class {class:?}", s.cfg.target
);
return;
}
let serial = props
.get("object.serial")
.and_then(|v| v.parse().ok())
.unwrap_or(u64::from(global.id));
info!(
id = global.id,
serial, "found source node '{}'", s.cfg.target
);
// The next tick picks the newest candidate, after the registry burst.
s.candidates.insert(global.id, serial);
})
.global_remove(move |id| {
let mut s = on_remove.borrow_mut();
if let Some(serial) = s.candidates.remove(&id) {
info!(id, serial, "source node removed");
}
if let Some(active) = s.active.as_ref().filter(|a| a.node_id == id) {
let teardown = Teardown {
generation: active.generation,
reason: "source node removed".to_owned(),
retry_delay: None,
};
s.teardowns.borrow_mut().push_back(teardown);
}
})
.register()
};
let tick = {
let state = state.clone();
mainloop.loop_().add_timer(move |_| housekeeping(&state))
};
let _ = tick.update_timer(Some(TICK), Some(TICK));
info!(
"waiting for a '{}' {SOURCE_MEDIA_CLASS} node",
state.borrow().cfg.target
);
mainloop.run();
let active = state.borrow_mut().active.take();
drop(active);
let fatal = fatal.borrow_mut().take();
match fatal {
Some(reason) => Err(anyhow!(reason)),
None => Ok(()),
}
}
fn housekeeping(state: &Rc<RefCell<State>>) {
let teardowns = state.borrow().teardowns.clone();
loop {
let next = teardowns.borrow_mut().pop_front();
match next {
Some(t) => teardown(state, t),
None => break,
}
}
reconcile(state);
hint_missing_frames(state);
}
fn hint_missing_frames(state: &Rc<RefCell<State>>) {
let s = state.borrow();
let Some(active) = &s.active else {
return;
};
let first_frame = &active.first_frame;
if first_frame.received.get() || first_frame.hinted.get() {
return;
}
if first_frame
.streaming_since
.get()
.is_some_and(|since| since.elapsed() >= NO_FRAMES_HINT_AFTER)
{
first_frame.hinted.set(true);
info!(
node_id = active.node_id,
"stream is streaming, but no frames for {} s: gamescope sends frames only when the \
picture changes; the HyperHDR connection opens with the first frame",
NO_FRAMES_HINT_AFTER.as_secs()
);
}
}
fn reconcile(state: &Rc<RefCell<State>>) {
let mut s = state.borrow_mut();
if s.active.is_some()
|| s.candidates.is_empty()
|| s.retry_at.is_some_and(|at| Instant::now() < at)
{
return;
}
let Some((&node_id, &serial)) = s.candidates.iter().max_by_key(|(_, serial)| **serial) else {
return;
};
if s.candidates.len() > 1 {
warn!(
count = s.candidates.len(),
node_id,
serial,
"several '{}' {SOURCE_MEDIA_CLASS} nodes exist; using the newest (highest object.serial)",
s.cfg.target
);
}
s.generation += 1;
let generation = s.generation;
match connect_stream(&s, node_id, serial, generation) {
Ok(active) => {
info!(node_id, serial, requested_size = %s.cfg.requested, "stream created, linking to the source");
s.active = Some(active);
s.retry_at = None;
}
Err(e) => {
error!(node_id, serial, "cannot create the stream: {e:#}");
s.retry_at = Some(Instant::now() + RETRY_DELAY);
}
}
}
fn teardown(state: &Rc<RefCell<State>>, t: Teardown) {
let active = {
let mut s = state.borrow_mut();
if s.active.as_ref().map(|a| a.generation) != Some(t.generation) {
return;
}
s.retry_at = t.retry_delay.map(|d| Instant::now() + d);
s.active.take()
};
if let Some(active) = active {
info!(
node_id = active.node_id,
serial = active.serial,
"destroying stream: {}",
t.reason
);
drop(active);
}
state.borrow().slot.source_gone();
}
fn connect_stream(s: &State, node_id: u32, serial: u64, generation: u64) -> anyhow::Result<Active> {
let props = properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Screen",
*pw::keys::NODE_NAME => STREAM_NODE_NAME,
*pw::keys::NODE_DESCRIPTION => "gamescope → HyperHDR",
"target.object" => serial.to_string(),
"node.dont-fallback" => "true",
*pw::keys::NODE_DONT_RECONNECT => "true",
"node.dont-move" => "true",
};
let stream =
StreamRc::new(s.core.clone(), STREAM_NODE_NAME, props).context("pw_stream_new failed")?;
let first_frame = Rc::new(FirstFrame::default());
let data = StreamData {
node_id,
generation,
requested: s.cfg.requested,
output_max: s.cfg.output_max,
slot: s.slot.clone(),
stats: s.stats.clone(),
teardowns: s.teardowns.clone(),
first_frame: first_frame.clone(),
format: None,
format_events: 0,
first_frame_logged: false,
warned_ignored: false,
converter: Converter::new(),
frame: Frame::default(),
bounds_log: RateLimit::new(Duration::from_secs(10)),
};
let listener = stream
.add_local_listener_with_user_data(data)
.state_changed(|_, d, old, new| on_state_changed(d, old, new))
.param_changed(|_, d, id, param| on_param_changed(d, id, param))
.process(on_process)
.register()
.context("cannot register stream listener")?;
let pod = enum_format_pod(s.cfg.requested);
if tracing::enabled!(tracing::Level::DEBUG) {
debug!("EnumFormat pod ({} bytes): {}", pod.len(), Hex(&pod));
}
let mut params = [Pod::from_bytes(&pod).ok_or_else(|| anyhow!("invalid EnumFormat pod"))?];
stream
.connect(
Direction::Input,
None,
StreamFlags::AUTOCONNECT | StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw_stream_connect failed")?;
Ok(Active {
node_id,
serial,
generation,
first_frame,
_listener: listener,
_stream: stream,
})
}
/// The single EnumFormat object offered to gamescope (spec 4.2.4).
pub fn enum_format_pod(requested: Size) -> Vec<u8> {
let rect = |width, height| Rectangle { width, height };
let requested_rect = rect(requested.width, requested.height);
let object = Object {
type_: SpaTypes::ObjectParamFormat.as_raw(),
id: ParamType::EnumFormat.as_raw(),
properties: vec![
Property::new(
FormatProperties::MediaType.as_raw(),
Value::Id(Id(MediaType::Video.as_raw())),
),
Property::new(
FormatProperties::MediaSubtype.as_raw(),
Value::Id(Id(MediaSubtype::Raw.as_raw())),
),
// BGRx only, and no modifier: that rules out gamescope's DMA-BUF offers
// (their modifier is MANDATORY) and NV12.
Property::new(
FormatProperties::VideoFormat.as_raw(),
Value::Id(Id(VideoFormat::BGRx.as_raw())),
),
Property::new(
FormatProperties::VideoSize.as_raw(),
Value::Choice(ChoiceValue::Rectangle(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: requested_rect,
min: rect(1, 1),
max: rect(16384, 16384),
},
))),
),
// Must include 0/1: gamescope offers a variable frame rate.
Property::new(
FormatProperties::VideoFramerate.as_raw(),
Value::Choice(ChoiceValue::Fraction(Choice(
ChoiceFlags::empty(),
ChoiceEnum::Range {
default: Fraction { num: 0, denom: 1 },
min: Fraction { num: 0, denom: 1 },
max: Fraction {
num: 1000,
denom: 1,
},
},
))),
),
// A fixed value, not a choice: gamescope renders into a frame that fits it.
Property {
key: SPA_FORMAT_VIDEO_REQUESTED_SIZE,
flags: PropertyFlags::empty(),
value: Value::Rectangle(requested_rect),
},
],
};
PodSerializer::serialize(Cursor::new(Vec::new()), &Value::Object(object))
.expect("serializing into a Vec cannot fail")
.0
.into_inner()
}
/// Reads gamescope's `requested_size` from a Format pod.
pub fn requested_size_of(format: &Pod) -> Option<Rectangle> {
let (_, Value::Object(object)) =
PodDeserializer::deserialize_any_from(format.as_bytes()).ok()?
else {
return None;
};
let prop = object
.properties
.into_iter()
.find(|p| p.key == SPA_FORMAT_VIDEO_REQUESTED_SIZE)?;
match prop.value {
Value::Rectangle(r) => Some(r),
Value::Choice(ChoiceValue::Rectangle(Choice(
_,
ChoiceEnum::None(r)
| ChoiceEnum::Range { default: r, .. }
| ChoiceEnum::Step { default: r, .. },
))) => Some(r),
_ => None,
}
}
struct StreamData {
node_id: u32,
generation: u64,
requested: Size,
output_max: Size,
slot: Arc<FrameSlot>,
stats: Arc<Stats>,
teardowns: Teardowns,
first_frame: Rc<FirstFrame>,
/// Negotiated BGRx size, `None` until a usable format is negotiated.
format: Option<Size>,
format_events: u32,
first_frame_logged: bool,
warned_ignored: bool,
converter: Converter,
frame: Frame,
bounds_log: RateLimit,
}
impl StreamData {
fn bounds_error(&mut self, what: impl fmt::Display) {
stats::inc(&self.stats.bounds_errors);
if let Some(suppressed) = self.bounds_log.check(Instant::now()) {
warn!(suppressed, "skipping invalid buffer: {what}");
}
}
}
fn on_state_changed(d: &mut StreamData, old: StreamState, new: StreamState) {
let reason = match new {
StreamState::Error(e) => {
warn!(node_id = d.node_id, "stream error: {e}");
format!("stream error: {e}")
}
StreamState::Unconnected => {
info!(node_id = d.node_id, "stream state {old:?} -> Unconnected");
"stream unconnected".to_owned()
}
new => {
info!(node_id = d.node_id, "stream state {old:?} -> {new:?}");
// Renegotiation toggles Streaming/Paused; count from the first Streaming.
if new == StreamState::Streaming && d.first_frame.streaming_since.get().is_none() {
d.first_frame.streaming_since.set(Some(Instant::now()));
}
return;
}
};
d.teardowns.borrow_mut().push_back(Teardown {
generation: d.generation,
reason,
retry_delay: Some(RETRY_DELAY),
});
}
fn on_param_changed(d: &mut StreamData, id: u32, param: Option<&Pod>) {
if id != ParamType::Format.as_raw() {
return;
}
let Some(param) = param else {
info!("format cleared");
d.format = None;
return;
};
d.format_events += 1;
d.first_frame_logged = false;
let mut info = VideoInfoRaw::new();
if let Err(e) = info.parse(param) {
error!("cannot parse the negotiated format: {e:?}");
d.format = None;
return;
}
if tracing::enabled!(tracing::Level::DEBUG) {
if let Ok((_, value)) = PodDeserializer::deserialize_any_from(param.as_bytes()) {
debug!("Format pod: {value:?}");
}
}
if info.format() != VideoFormat::BGRx {
error!(
"negotiated unsupported video format {:?}, ignoring buffers",
info.format()
);
d.format = None;
return;
}
let size = info.size();
let framerate = info.framerate();
let requested = requested_size_of(param).map_or_else(
|| "absent".to_owned(),
|r| format!("{}x{}", r.width, r.height),
);
info!(
event = d.format_events,
"format: BGRx {}x{}, expected stride {}, framerate {}/{}, requested_size {requested}",
size.width,
size.height,
size.width.saturating_mul(4).next_multiple_of(4),
framerate.num,
framerate.denom,
);
d.format = Some(Size::new(size.width, size.height));
}
fn on_process(stream: &Stream, d: &mut StreamData) {
// Drain the queue: only the newest buffer is used, older ones are requeued on drop.
let mut newest = None;
while let Some(buffer) = stream.dequeue_buffer() {
newest = Some(buffer);
}
let Some(mut buffer) = newest else {
return;
};
let Some(size) = d.format else {
return;
};
let header_corrupted = buffer
.find_meta::<MetaHeader>()
.is_some_and(|h| h.flags().contains(MetaHeaderFlags::CORRUPTED));
let Some(data) = buffer.datas_mut().first_mut() else {
d.bounds_error("buffer has no data blocks");
return;
};
let chunk = data.chunk();
let (offset, chunk_size, stride) = (chunk.offset(), chunk.size(), chunk.stride());
// Buffers without data show up around renegotiation; they are skipped like corrupted ones.
if header_corrupted || chunk.flags().contains(ChunkFlags::CORRUPTED) || chunk_size == 0 {
stats::inc(&d.stats.corrupted);
return;
}
let data_type = data.type_();
let Some(mapped) = data.data() else {
d.bounds_error(format_args!(
"buffer is not mapped (data type {data_type:?})"
));
return;
};
let mapped_len = mapped.len();
let started = Instant::now();
let bgrx = match BgrxFrame::new(
mapped,
offset as usize,
chunk_size as usize,
usize::try_from(stride).unwrap_or(0),
size.width as usize,
size.height as usize,
) {
Ok(frame) => frame,
Err(e) => {
d.bounds_error(format_args!(
"{e} (chunk offset {offset}, size {chunk_size}, stride {stride}; {mapped_len} bytes mapped)"
));
return;
}
};
let requested = d.requested;
if !d.warned_ignored
&& (size.width > requested.width.saturating_mul(2)
|| size.height > requested.height.saturating_mul(2))
{
d.warned_ignored = true;
warn!(
"gamescope seems to ignore requested_size {requested}: receiving {size} frames, downscaling on the CPU"
);
}
let (width, height) = fit_within(
size.width,
size.height,
d.output_max.width,
d.output_max.height,
);
d.converter
.convert(&bgrx, width as usize, height as usize, &mut d.frame.rgb);
d.frame.width = width;
d.frame.height = height;
d.stats.record_convert(started.elapsed());
stats::inc(&d.stats.frames_received);
if !d.first_frame.received.replace(true) && d.first_frame.hinted.get() {
if let Some(since) = d.first_frame.streaming_since.get() {
info!(
"first frame arrived {:.1?} after the stream started streaming",
since.elapsed()
);
}
}
if !d.first_frame_logged {
d.first_frame_logged = true;
info!(
"receiving frames: {size} stride {stride} offset {offset} chunk {chunk_size} bytes, sending {width}x{height}"
);
}
if d.slot.publish(&mut d.frame) {
stats::inc(&d.stats.superseded);
}
}
struct Hex<'a>(&'a [u8]);
impl fmt::Display for Hex<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, b) in self.0.iter().enumerate() {
if i > 0 && i % 4 == 0 {
f.write_char(' ')?;
}
write!(f, "{b:02x}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enum_format_pod_contents() {
let bytes = enum_format_pod(Size::new(640, 360));
let pod = Pod::from_bytes(&bytes).unwrap();
let (_, value) = PodDeserializer::deserialize_any_from(pod.as_bytes()).unwrap();
let Value::Object(object) = value else {
panic!("not an object");
};
assert_eq!(object.type_, SpaTypes::ObjectParamFormat.as_raw());
assert_eq!(object.id, ParamType::EnumFormat.as_raw());
let keys: Vec<u32> = object.properties.iter().map(|p| p.key).collect();
assert!(!keys.contains(&FormatProperties::VideoModifier.as_raw()));
assert!(!keys.contains(&SPA_FORMAT_VIDEO_GAMESCOPE_FOCUS_APPID));
let prop = |key: u32| object.properties.iter().find(|p| p.key == key).unwrap();
assert_eq!(
prop(FormatProperties::VideoFormat.as_raw()).value,
Value::Id(Id(VideoFormat::BGRx.as_raw()))
);
let custom = prop(SPA_FORMAT_VIDEO_REQUESTED_SIZE);
assert_eq!(custom.flags, PropertyFlags::empty());
assert_eq!(
custom.value,
Value::Rectangle(Rectangle {
width: 640,
height: 360
})
);
assert!(matches!(
prop(FormatProperties::VideoSize.as_raw()).value,
Value::Choice(ChoiceValue::Rectangle(Choice(_, ChoiceEnum::Range { .. })))
));
assert_eq!(
requested_size_of(pod),
Some(Rectangle {
width: 640,
height: 360
})
);
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Command line and environment configuration.
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use clap::Parser;
/// Largest message the HyperHDR flatbuffers server accepts (`FlatBuffersServerConnection::readyRead`).
pub const HYPERHDR_MAX_MESSAGE: usize = 10_000_000;
/// Default `timeout` of the HyperHDR flatbuffers server.
pub const HYPERHDR_DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_SIZE_DIM: u32 = 1920;
const MAX_REQUEST_DIM: u32 = 3840;
/// Generous upper bound of the flatbuffers overhead of an `Image` request plus framing.
const IMAGE_MESSAGE_OVERHEAD: usize = 256;
/// A `WxH` frame size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Size {
pub width: u32,
pub height: u32,
}
impl Size {
pub const fn new(width: u32, height: u32) -> Self {
Self { width, height }
}
}
impl FromStr for Size {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (w, h) = s
.split_once(['x', 'X'])
.ok_or_else(|| format!("expected WxH, got {s:?}"))?;
let dim = |v: &str| {
v.trim()
.parse::<u32>()
.ok()
.filter(|v| *v > 0)
.ok_or_else(|| format!("invalid dimension {v:?} in {s:?}"))
};
Ok(Size::new(dim(w)?, dim(h)?))
}
}
impl fmt::Display for Size {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}x{}", self.width, self.height)
}
}
/// Capture gamescope's PipeWire stream and feed it to HyperHDR's flatbuffers server.
#[derive(Parser, Debug, Clone)]
#[command(version, about)]
pub struct Config {
/// HyperHDR host (IP address or name)
#[arg(long, env = "GSHDR_HOST")]
pub host: String,
/// HyperHDR flatbuffers server port
#[arg(long, env = "GSHDR_PORT", default_value_t = 19400)]
pub port: u16,
/// Source priority in HyperHDR (50..250); must be lower than the USB grabber's (240)
#[arg(long, env = "GSHDR_PRIORITY", default_value_t = 150,
value_parser = clap::value_parser!(i32).range(50..=250))]
pub priority: i32,
/// Source name shown in HyperHDR (the server appends "@<client ip>")
#[arg(long, env = "GSHDR_ORIGIN", default_value = "gamescope")]
pub origin: String,
/// Maximum size of the frame sent to HyperHDR; the aspect ratio is taken from the frame
#[arg(long, env = "GSHDR_SIZE", default_value = "320x180")]
pub size: Size,
/// Request size x N from gamescope and area-average down by N on the CPU (1..4)
#[arg(long, env = "GSHDR_SUPERSAMPLE", default_value_t = 1,
value_parser = clap::value_parser!(u32).range(1..=4))]
pub supersample: u32,
/// Upper bound of the send rate (1..120)
#[arg(long, env = "GSHDR_MAX_FPS", default_value_t = 30,
value_parser = clap::value_parser!(u32).range(1..=120))]
pub max_fps: u32,
/// Re-send the last frame after this many milliseconds without new frames
#[arg(long, env = "GSHDR_KEEPALIVE_MS", default_value_t = 1000,
value_parser = clap::value_parser!(u64).range(1..))]
pub keepalive_ms: u64,
/// Clear the priority after this many seconds without new frames (0 = never)
#[arg(long, env = "GSHDR_IDLE_CLEAR_SECS", default_value_t = 0)]
pub idle_clear_secs: u64,
/// node.name of the source node
#[arg(long, env = "GSHDR_TARGET", default_value = "gamescope")]
pub target: String,
/// TCP connect timeout
#[arg(long, default_value_t = 2000, value_parser = clap::value_parser!(u64).range(1..))]
pub connect_timeout_ms: u64,
/// A write blocked for longer than this means the connection is dead
#[arg(long, default_value_t = 2000, value_parser = clap::value_parser!(u64).range(1..))]
pub write_timeout_ms: u64,
/// Log level or tracing filter expression
#[arg(long, env = "RUST_LOG", default_value = "info")]
pub log_level: String,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ConfigError {
#[error("--host must not be empty")]
EmptyHost,
#[error("--target must not be empty")]
EmptyTarget,
#[error("--size {0}: each dimension must be <= {MAX_SIZE_DIM}")]
SizeTooLarge(Size),
#[error("--size {size} x --supersample {supersample} = {requested}: each dimension must be <= {MAX_REQUEST_DIM}")]
RequestTooLarge {
size: Size,
supersample: u32,
requested: Size,
},
#[error("--size {size}: an image message would be up to {bytes} bytes, HyperHDR accepts less than {HYPERHDR_MAX_MESSAGE}")]
MessageTooLarge { size: Size, bytes: usize },
}
impl Config {
/// Checks cross-field constraints. Returns warnings that do not prevent startup.
pub fn validate(&self) -> Result<Vec<String>, ConfigError> {
if self.host.trim().is_empty() {
return Err(ConfigError::EmptyHost);
}
if self.target.is_empty() {
return Err(ConfigError::EmptyTarget);
}
let size = self.size;
if size.width > MAX_SIZE_DIM || size.height > MAX_SIZE_DIM {
return Err(ConfigError::SizeTooLarge(size));
}
let requested = self.requested_size();
if requested.width > MAX_REQUEST_DIM || requested.height > MAX_REQUEST_DIM {
return Err(ConfigError::RequestTooLarge {
size,
supersample: self.supersample,
requested,
});
}
let bytes = size.width as usize * size.height as usize * 3 + IMAGE_MESSAGE_OVERHEAD;
if bytes >= HYPERHDR_MAX_MESSAGE {
return Err(ConfigError::MessageTooLarge { size, bytes });
}
let mut warnings = Vec::new();
if self.keepalive() >= HYPERHDR_DEFAULT_TIMEOUT {
warnings.push(format!(
"--keepalive-ms {} is not below HyperHDR's default connection timeout ({} s); \
the server may drop the connection while the picture is static",
self.keepalive_ms,
HYPERHDR_DEFAULT_TIMEOUT.as_secs()
));
}
Ok(warnings)
}
/// Size requested from gamescope via `requested_size`.
pub fn requested_size(&self) -> Size {
Size::new(
self.size.width.saturating_mul(self.supersample),
self.size.height.saturating_mul(self.supersample),
)
}
pub fn keepalive(&self) -> Duration {
Duration::from_millis(self.keepalive_ms)
}
pub fn idle_clear(&self) -> Option<Duration> {
(self.idle_clear_secs > 0).then(|| Duration::from_secs(self.idle_clear_secs))
}
pub fn min_send_interval(&self) -> Duration {
Duration::from_secs(1) / self.max_fps
}
pub fn connect_timeout(&self) -> Duration {
Duration::from_millis(self.connect_timeout_ms)
}
pub fn write_timeout(&self) -> Duration {
Duration::from_millis(self.write_timeout_ms)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Config, clap::Error> {
let mut argv = vec!["gamescope-hyperhdr", "--host", "192.0.2.10"];
argv.extend_from_slice(args);
Config::try_parse_from(argv)
}
#[test]
fn size_parsing() {
assert_eq!("320x180".parse::<Size>(), Ok(Size::new(320, 180)));
assert_eq!("64X36".parse::<Size>(), Ok(Size::new(64, 36)));
assert!("320".parse::<Size>().is_err());
assert!("0x180".parse::<Size>().is_err());
assert!("320x-1".parse::<Size>().is_err());
assert!("axb".parse::<Size>().is_err());
}
#[test]
fn defaults() {
let cfg = parse(&[]).unwrap();
assert_eq!(cfg.port, 19400);
assert_eq!(cfg.priority, 150);
assert_eq!(cfg.origin, "gamescope");
assert_eq!(cfg.size, Size::new(320, 180));
assert_eq!(cfg.supersample, 1);
assert_eq!(cfg.max_fps, 30);
assert_eq!(cfg.keepalive(), Duration::from_secs(1));
assert_eq!(cfg.idle_clear(), None);
assert_eq!(cfg.target, "gamescope");
assert_eq!(cfg.validate(), Ok(vec![]));
assert_eq!(cfg.requested_size(), Size::new(320, 180));
}
#[test]
fn range_checks() {
assert!(parse(&["--priority", "49"]).is_err());
assert!(parse(&["--priority", "251"]).is_err());
assert!(parse(&["--priority", "250"]).is_ok());
assert!(parse(&["--supersample", "0"]).is_err());
assert!(parse(&["--supersample", "5"]).is_err());
assert!(parse(&["--max-fps", "0"]).is_err());
assert!(parse(&["--max-fps", "121"]).is_err());
assert!(parse(&["--keepalive-ms", "0"]).is_err());
assert!(parse(&["--size", "320x0"]).is_err());
}
#[test]
fn validation() {
let cfg = parse(&["--size", "1921x100"]).unwrap();
assert_eq!(
cfg.validate(),
Err(ConfigError::SizeTooLarge(Size::new(1921, 100)))
);
let cfg = parse(&["--size", "1920x1080", "--supersample", "3"]).unwrap();
assert!(matches!(
cfg.validate(),
Err(ConfigError::RequestTooLarge { .. })
));
let cfg = parse(&["--size", "1920x1080", "--supersample", "2"]).unwrap();
assert_eq!(cfg.validate(), Ok(vec![]));
assert_eq!(cfg.requested_size(), Size::new(3840, 2160));
let cfg = parse(&["--size", "1920x1920"]).unwrap();
assert!(matches!(
cfg.validate(),
Err(ConfigError::MessageTooLarge { .. })
));
let cfg = parse(&["--keepalive-ms", "5000"]).unwrap();
assert_eq!(cfg.validate().unwrap().len(), 1);
let cfg = parse(&["--target", ""]).unwrap();
assert_eq!(cfg.validate(), Err(ConfigError::EmptyTarget));
}
#[test]
fn derived_durations() {
let cfg = parse(&["--max-fps", "60", "--idle-clear-secs", "7"]).unwrap();
assert_eq!(cfg.min_send_interval(), Duration::from_secs(1) / 60);
assert_eq!(cfg.idle_clear(), Some(Duration::from_secs(7)));
}
}
+501
View File
@@ -0,0 +1,501 @@
//! BGRx → RGB24 conversion with optional downscaling (box filter for integer factors,
//! area averaging otherwise).
/// Fixed-point unit of the area-average weights.
const WEIGHT_BITS: u32 = 16;
const WEIGHT_ONE: u32 = 1 << WEIGHT_BITS;
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum ConvertError {
#[error("empty frame {width}x{height}")]
Empty { width: usize, height: usize },
#[error("stride {stride} is smaller than width*4 ({width}*4)")]
StrideTooSmall { stride: usize, width: usize },
#[error("{width}x{height} frame with stride {stride} at offset {offset} does not fit into {mapped} mapped bytes")]
OutOfBounds {
width: usize,
height: usize,
stride: usize,
offset: usize,
mapped: usize,
},
#[error("chunk size {chunk_size} is smaller than stride*height ({needed})")]
ChunkTooSmall { chunk_size: usize, needed: usize },
}
/// A bounds-checked view of a packed BGRx frame.
#[derive(Debug, Clone, Copy)]
pub struct BgrxFrame<'a> {
/// Starts at the first pixel of row 0.
data: &'a [u8],
width: usize,
height: usize,
stride: usize,
}
impl<'a> BgrxFrame<'a> {
/// Validates the frame geometry against the mapped memory and the chunk size.
pub fn new(
mapped: &'a [u8],
offset: usize,
chunk_size: usize,
stride: usize,
width: usize,
height: usize,
) -> Result<Self, ConvertError> {
if width == 0 || height == 0 {
return Err(ConvertError::Empty { width, height });
}
let row_bytes = width
.checked_mul(4)
.ok_or(ConvertError::StrideTooSmall { stride, width })?;
if stride < row_bytes {
return Err(ConvertError::StrideTooSmall { stride, width });
}
let out_of_bounds = ConvertError::OutOfBounds {
width,
height,
stride,
offset,
mapped: mapped.len(),
};
let end = stride
.checked_mul(height - 1)
.and_then(|v| v.checked_add(row_bytes))
.and_then(|v| v.checked_add(offset))
.ok_or_else(|| out_of_bounds.clone())?;
if end > mapped.len() {
return Err(out_of_bounds);
}
match stride.checked_mul(height) {
Some(needed) if chunk_size >= needed => {}
needed => {
return Err(ConvertError::ChunkTooSmall {
chunk_size,
needed: needed.unwrap_or(usize::MAX),
})
}
}
Ok(Self {
data: &mapped[offset..end],
width,
height,
stride,
})
}
pub fn width(&self) -> usize {
self.width
}
pub fn height(&self) -> usize {
self.height
}
fn row(&self, y: usize) -> &'a [u8] {
let start = y * self.stride;
&self.data[start..start + self.width * 4]
}
}
/// Largest size that fits into `max_w`x`max_h` with the aspect ratio of `src_w`x`src_h`.
/// Never upscales.
pub fn fit_within(src_w: u32, src_h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
if src_w <= max_w && src_h <= max_h {
return (src_w, src_h);
}
let scale = f64::min(
f64::from(max_w) / f64::from(src_w),
f64::from(max_h) / f64::from(src_h),
);
let w = (f64::from(src_w) * scale)
.round()
.clamp(1.0, f64::from(max_w)) as u32;
let h = (f64::from(src_h) * scale)
.round()
.clamp(1.0, f64::from(max_h)) as u32;
(w, h)
}
/// Source taps of one axis for area averaging: for destination index `i`,
/// `taps[starts[i]..starts[i + 1]]` holds `(source index, weight)` with weights summing
/// to `WEIGHT_ONE`.
#[derive(Debug, Default)]
struct AxisTaps {
starts: Vec<usize>,
taps: Vec<(usize, u32)>,
}
impl AxisTaps {
fn build(src: usize, dst: usize) -> Self {
let scale = src as f64 / dst as f64;
let mut starts = Vec::with_capacity(dst + 1);
let mut taps = Vec::new();
for i in 0..dst {
starts.push(taps.len());
let begin = i as f64 * scale;
let end = (i + 1) as f64 * scale;
let first = begin.floor() as usize;
let last = (end.ceil() as usize).min(src);
let from = taps.len();
for j in first..last {
let overlap = end.min(j as f64 + 1.0) - begin.max(j as f64);
let weight = (overlap / scale * f64::from(WEIGHT_ONE)).round() as u32;
if weight > 0 {
taps.push((j, weight));
}
}
// Make the weights sum to exactly WEIGHT_ONE so flat areas stay flat.
let sum: u32 = taps[from..].iter().map(|t| t.1).sum();
if let Some(max) = taps[from..].iter_mut().max_by_key(|t| t.1) {
max.1 = (max.1 + WEIGHT_ONE).saturating_sub(sum);
} else {
taps.push((first.min(src - 1), WEIGHT_ONE));
}
}
starts.push(taps.len());
Self { starts, taps }
}
fn get(&self, i: usize) -> &[(usize, u32)] {
&self.taps[self.starts[i]..self.starts[i + 1]]
}
}
/// Reusable converter; keeps its scratch buffers and filter taps between frames.
#[derive(Debug, Default)]
pub struct Converter {
geometry: (usize, usize, usize, usize),
x_taps: AxisTaps,
y_taps: AxisTaps,
row: Vec<u32>,
acc: Vec<u64>,
}
impl Converter {
pub fn new() -> Self {
Self::default()
}
/// Converts `src` to RGB24 of `dst_w`x`dst_h` into `out` (resized as needed).
pub fn convert(&mut self, src: &BgrxFrame<'_>, dst_w: usize, dst_h: usize, out: &mut Vec<u8>) {
assert!(dst_w > 0 && dst_h > 0, "destination must not be empty");
out.resize(dst_w * dst_h * 3, 0);
let (sw, sh) = (src.width, src.height);
if (sw, sh) == (dst_w, dst_h) {
copy_rgb(src, out);
} else if sw % dst_w == 0 && sh % dst_h == 0 {
self.box_filter(src, sw / dst_w, sh / dst_h, dst_w, out);
} else {
self.area_average(src, dst_w, dst_h, out);
}
}
fn box_filter(
&mut self,
src: &BgrxFrame<'_>,
fx: usize,
fy: usize,
dst_w: usize,
out: &mut [u8],
) {
let n = (fx * fy) as u32;
let sums = &mut self.row;
sums.clear();
sums.resize(dst_w * 3, 0);
for (dy, out_row) in out.chunks_exact_mut(dst_w * 3).enumerate() {
sums.fill(0);
for sy in dy * fy..(dy + 1) * fy {
let row = src.row(sy);
for (block, sum) in row.chunks_exact(fx * 4).zip(sums.chunks_exact_mut(3)) {
for px in block.chunks_exact(4) {
sum[0] += u32::from(px[2]);
sum[1] += u32::from(px[1]);
sum[2] += u32::from(px[0]);
}
}
}
for (o, s) in out_row.iter_mut().zip(sums.iter()) {
*o = ((s + n / 2) / n) as u8;
}
}
}
fn area_average(&mut self, src: &BgrxFrame<'_>, dst_w: usize, dst_h: usize, out: &mut [u8]) {
let geometry = (src.width, src.height, dst_w, dst_h);
if self.geometry != geometry {
self.x_taps = AxisTaps::build(src.width, dst_w);
self.y_taps = AxisTaps::build(src.height, dst_h);
self.geometry = geometry;
}
self.row.clear();
self.row.resize(dst_w * 3, 0);
self.acc.clear();
self.acc.resize(dst_w * 3, 0);
let round = 1u64 << (2 * WEIGHT_BITS - 1);
for (dy, out_row) in out.chunks_exact_mut(dst_w * 3).enumerate() {
self.acc.fill(0);
for &(sy, wy) in self.y_taps.get(dy) {
let src_row = src.row(sy);
// Horizontal pass: values in [0, 255 * WEIGHT_ONE].
for (dx, rgb) in self.row.chunks_exact_mut(3).enumerate() {
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for &(sx, wx) in self.x_taps.get(dx) {
let px = &src_row[sx * 4..sx * 4 + 4];
r += wx * u32::from(px[2]);
g += wx * u32::from(px[1]);
b += wx * u32::from(px[0]);
}
rgb[0] = r;
rgb[1] = g;
rgb[2] = b;
}
let wy = u64::from(wy);
for (a, v) in self.acc.iter_mut().zip(self.row.iter()) {
*a += wy * u64::from(*v);
}
}
for (o, a) in out_row.iter_mut().zip(self.acc.iter()) {
*o = ((a + round) >> (2 * WEIGHT_BITS)) as u8;
}
}
}
}
fn copy_rgb(src: &BgrxFrame<'_>, out: &mut [u8]) {
for (y, out_row) in out.chunks_exact_mut(src.width * 3).enumerate() {
for (px, rgb) in src.row(y).chunks_exact(4).zip(out_row.chunks_exact_mut(3)) {
rgb[0] = px[2];
rgb[1] = px[1];
rgb[2] = px[0];
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a BGRx buffer with padding and offset filled with garbage.
fn make_buffer(
width: usize,
height: usize,
stride: usize,
offset: usize,
pixel: impl Fn(usize, usize) -> [u8; 3],
) -> Vec<u8> {
let mut buf = vec![0xEE; offset + stride * height];
for y in 0..height {
for x in 0..width {
let [r, g, b] = pixel(x, y);
let i = offset + y * stride + x * 4;
buf[i..i + 4].copy_from_slice(&[b, g, r, 0xFF]);
}
}
buf
}
fn convert(
buf: &[u8],
offset: usize,
stride: usize,
w: usize,
h: usize,
dw: usize,
dh: usize,
) -> Vec<u8> {
let frame = BgrxFrame::new(buf, offset, buf.len() - offset, stride, w, h).unwrap();
let mut out = Vec::new();
Converter::new().convert(&frame, dw, dh, &mut out);
out
}
/// Reference area average in floating point.
fn reference(
w: usize,
h: usize,
dw: usize,
dh: usize,
pixel: impl Fn(usize, usize) -> [u8; 3],
) -> Vec<u8> {
let (sx, sy) = (w as f64 / dw as f64, h as f64 / dh as f64);
let overlap =
|a0: f64, a1: f64, j: usize| (a1.min(j as f64 + 1.0) - a0.max(j as f64)).max(0.0);
let mut out = Vec::new();
for dy in 0..dh {
for dx in 0..dw {
let (x0, x1, y0, y1) = (
dx as f64 * sx,
(dx + 1) as f64 * sx,
dy as f64 * sy,
(dy + 1) as f64 * sy,
);
let mut acc = [0f64; 3];
let xs = x0.floor() as usize..(x1.ceil() as usize).min(w);
for y in y0.floor() as usize..(y1.ceil() as usize).min(h) {
for x in xs.clone() {
let wgt = overlap(x0, x1, x) * overlap(y0, y1, y);
let p = pixel(x, y);
for c in 0..3 {
acc[c] += wgt * f64::from(p[c]);
}
}
}
for a in acc {
out.push((a / (sx * sy)).round() as u8);
}
}
}
out
}
fn pattern(x: usize, y: usize) -> [u8; 3] {
[
(x * 37 + y * 11) as u8,
(x * 5 + y * 71) as u8,
((x ^ y) * 29) as u8,
]
}
#[test]
fn identity_with_padding_and_offset() {
let (w, h, stride, offset) = (3, 2, 16, 5);
let buf = make_buffer(w, h, stride, offset, pattern);
let out = convert(&buf, offset, stride, w, h, w, h);
let expected: Vec<u8> = (0..h)
.flat_map(|y| (0..w).flat_map(move |x| pattern(x, y)))
.collect();
assert_eq!(out, expected);
}
#[test]
fn identity_odd_size() {
let (w, h) = (7, 5);
let buf = make_buffer(w, h, w * 4, 0, pattern);
let out = convert(&buf, 0, w * 4, w, h, w, h);
let expected: Vec<u8> = (0..h)
.flat_map(|y| (0..w).flat_map(move |x| pattern(x, y)))
.collect();
assert_eq!(out, expected);
}
#[test]
fn box_filter_factor_2() {
let values = [
[0u8, 10, 20, 30],
[40, 50, 60, 70],
[100, 100, 0, 0],
[100, 101, 0, 0],
];
let buf = make_buffer(4, 4, 16, 0, |x, y| [values[y][x], 255 - values[y][x], 7]);
let out = convert(&buf, 0, 16, 4, 4, 2, 2);
// (0+10+40+50)/4 = 25, (20+30+60+70)/4 = 45, (100+100+100+101)/4 = 100.25, 0
let r: Vec<u8> = out.chunks(3).map(|p| p[0]).collect();
assert_eq!(r, [25, 45, 100, 0]);
let g: Vec<u8> = out.chunks(3).map(|p| p[1]).collect();
assert_eq!(g, [230, 210, 155, 255]);
assert!(out.chunks(3).all(|p| p[2] == 7));
}
#[test]
fn box_filter_factor_3_with_padding() {
let (w, h, stride, offset) = (6, 3, 28, 8);
let buf = make_buffer(w, h, stride, offset, |x, y| [(x + y * 6) as u8 * 10, 0, 0]);
let out = convert(&buf, offset, stride, w, h, 2, 1);
// Left block: x 0..3, all rows: mean of {0,10,20,60,70,80,120,130,140} = 70
// Right block: +30 = 100
assert_eq!(out, [70, 0, 0, 100, 0, 0]);
}
#[test]
fn area_average_non_integer_known_values() {
let values = [0u8, 90, 180];
let buf = make_buffer(3, 1, 12, 0, |x, _| [values[x], values[x], values[x]]);
let out = convert(&buf, 0, 12, 3, 1, 2, 1);
assert_eq!(out, [30, 30, 30, 150, 150, 150]);
}
#[test]
fn area_average_matches_reference() {
for &(w, h, dw, dh) in &[
(5, 3, 2, 2),
(322, 182, 320, 181),
(17, 9, 4, 3),
(10, 10, 3, 7),
] {
let buf = make_buffer(w, h, w * 4, 0, pattern);
let out = convert(&buf, 0, w * 4, w, h, dw, dh);
let expected = reference(w, h, dw, dh, pattern);
assert_eq!(out.len(), expected.len());
for (i, (a, b)) in out.iter().zip(expected.iter()).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"{w}x{h}->{dw}x{dh} byte {i}: {a} vs {b}"
);
}
}
}
#[test]
fn flat_color_stays_flat() {
let buf = make_buffer(641, 359, 641 * 4, 0, |_, _| [200, 17, 255]);
let out = convert(&buf, 0, 641 * 4, 641, 359, 320, 180);
assert!(out.chunks(3).all(|p| p == [200, 17, 255]));
}
#[test]
fn converter_reuse_across_geometries() {
let mut conv = Converter::new();
let mut out = Vec::new();
for &(w, h, dw, dh) in &[(9, 9, 4, 4), (8, 8, 4, 4), (9, 9, 4, 4), (5, 5, 5, 5)] {
let buf = make_buffer(w, h, w * 4, 0, pattern);
let frame = BgrxFrame::new(&buf, 0, buf.len(), w * 4, w, h).unwrap();
conv.convert(&frame, dw, dh, &mut out);
let expected = reference(w, h, dw, dh, pattern);
assert!(out
.iter()
.zip(expected.iter())
.all(|(a, b)| a.abs_diff(*b) <= 1));
}
}
#[test]
fn bounds_violations_are_errors() {
let buf = vec![0u8; 64];
assert!(matches!(
BgrxFrame::new(&buf, 0, 64, 16, 0, 4),
Err(ConvertError::Empty { .. })
));
assert!(matches!(
BgrxFrame::new(&buf, 0, 64, 15, 4, 4),
Err(ConvertError::StrideTooSmall { .. })
));
assert!(matches!(
BgrxFrame::new(&buf, 1, 64, 16, 4, 4),
Err(ConvertError::OutOfBounds { .. })
));
assert!(matches!(
BgrxFrame::new(&buf, 0, 63, 16, 4, 4),
Err(ConvertError::ChunkTooSmall { .. })
));
assert!(BgrxFrame::new(&buf, 0, 64, 16, 4, 4).is_ok());
// Last row without padding still fits.
assert!(BgrxFrame::new(&buf[..60], 0, 64, 16, 3, 4).is_ok());
// Overflowing geometry must not panic.
assert!(BgrxFrame::new(&buf, usize::MAX, 64, 16, 4, 4).is_err());
assert!(BgrxFrame::new(&buf, 0, usize::MAX, usize::MAX, 4, usize::MAX).is_err());
assert!(BgrxFrame::new(&buf, 0, 64, 16, usize::MAX, 1).is_err());
}
#[test]
fn fit_within_keeps_aspect() {
assert_eq!(fit_within(3840, 2160, 320, 180), (320, 180));
assert_eq!(fit_within(640, 360, 320, 180), (320, 180));
assert_eq!(fit_within(320, 134, 320, 180), (320, 134));
assert_eq!(fit_within(322, 182, 321, 181), (320, 181));
assert_eq!(fit_within(1080, 1920, 320, 180), (101, 180));
assert_eq!(fit_within(100, 100, 320, 180), (100, 100));
assert_eq!(fit_within(10000, 1, 320, 180), (320, 1));
}
}
+508
View File
@@ -0,0 +1,508 @@
//! Network thread: connects to HyperHDR while frames are available, paces them, keeps the
//! connection alive and reconnects with backoff. Blocking `std::net` I/O with timeouts.
use std::io::{self, Write};
use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use tracing::{debug, info, info_span, warn};
use super::proto::{self, Encoder};
use crate::config::{Config, HYPERHDR_MAX_MESSAGE};
use crate::pacing::{Action, Backoff, Pacer, PacingConfig, RateLimit};
use crate::slot::{Frame, FrameSlot};
use crate::stats::{self, Stats};
/// A connection closed this quickly, before any reply, was most likely rejected by HyperHDR.
const REJECT_WINDOW: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub host: String,
pub port: u16,
pub priority: i32,
pub origin: String,
pub pacing: PacingConfig,
pub connect_timeout: Duration,
pub write_timeout: Duration,
pub backoff_initial: Duration,
pub backoff_max: Duration,
pub backoff_reset_after: Duration,
/// Minimum interval between logged error replies and rejection hints.
pub error_log_interval: Duration,
pub stats_interval: Option<Duration>,
/// Bound for sending the final `Clear` and for waiting until the server closes.
pub shutdown_timeout: Duration,
}
impl ClientConfig {
/// Daemon defaults for the given server.
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
host: host.into(),
port,
priority: 150,
origin: "gamescope".to_owned(),
pacing: PacingConfig {
min_interval: Duration::from_secs(1) / 30,
keepalive: Duration::from_secs(1),
idle_clear: None,
},
connect_timeout: Duration::from_secs(2),
write_timeout: Duration::from_secs(2),
backoff_initial: Duration::from_millis(500),
backoff_max: Duration::from_secs(10),
backoff_reset_after: Duration::from_secs(30),
error_log_interval: Duration::from_secs(10),
stats_interval: Some(Duration::from_secs(60)),
shutdown_timeout: Duration::from_millis(400),
}
}
pub fn from_config(cfg: &Config) -> Self {
Self {
priority: cfg.priority,
origin: cfg.origin.clone(),
pacing: PacingConfig {
min_interval: cfg.min_send_interval(),
keepalive: cfg.keepalive(),
idle_clear: cfg.idle_clear(),
},
connect_timeout: cfg.connect_timeout(),
write_timeout: cfg.write_timeout(),
..Self::new(cfg.host.clone(), cfg.port)
}
}
}
/// Starts the network thread. It runs until [`FrameSlot::shutdown`].
pub fn spawn(
cfg: ClientConfig,
slot: Arc<FrameSlot>,
stats: Arc<Stats>,
) -> io::Result<JoinHandle<()>> {
thread::Builder::new()
.name("hyperhdr".into())
.spawn(move || Client::new(cfg, slot, stats).run())
}
#[derive(Debug, Default)]
struct ConnShared {
closed: AtomicBool,
got_reply: AtomicBool,
reason: Mutex<Option<String>>,
}
struct Connection {
stream: TcpStream,
peer: SocketAddr,
connected_at: Instant,
shared: Arc<ConnShared>,
reader: Option<JoinHandle<()>>,
}
impl Connection {
fn open(cfg: &ClientConfig, slot: &Arc<FrameSlot>, stats: &Arc<Stats>) -> io::Result<Self> {
let mut last_err = None;
for addr in (cfg.host.as_str(), cfg.port).to_socket_addrs()? {
match TcpStream::connect_timeout(&addr, cfg.connect_timeout) {
Ok(stream) => return Self::start(stream, addr, cfg, slot, stats),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("{} resolved to no addresses", cfg.host),
)
}))
}
fn start(
stream: TcpStream,
peer: SocketAddr,
cfg: &ClientConfig,
slot: &Arc<FrameSlot>,
stats: &Arc<Stats>,
) -> io::Result<Self> {
stream.set_nodelay(true)?;
stream.set_write_timeout(Some(cfg.write_timeout))?;
let read_half = stream.try_clone()?;
let shared = Arc::new(ConnShared::default());
let reader = {
let (shared, slot, stats) = (shared.clone(), slot.clone(), stats.clone());
let (priority, interval) = (cfg.priority, cfg.error_log_interval);
let span = tracing::Span::current();
thread::Builder::new()
.name("hyperhdr-reader".into())
.spawn(move || {
let _enter = span.enter();
read_replies(read_half, &shared, &stats, priority, interval);
slot.wake();
})?
};
Ok(Self {
stream,
peer,
connected_at: Instant::now(),
shared,
reader: Some(reader),
})
}
fn is_closed(&self) -> bool {
self.shared.closed.load(Ordering::Relaxed)
}
fn close_reason(&self) -> String {
let reason = self.shared.reason.lock().unwrap_or_else(|e| e.into_inner());
reason
.clone()
.unwrap_or_else(|| "connection closed".to_owned())
}
/// Closes both directions immediately and reaps the reader.
fn abort(mut self) {
let _ = self.stream.shutdown(Shutdown::Both);
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
/// Sends FIN and lets the reader drain replies until the server closes or `timeout`.
/// Closing with unread replies would send RST, which may discard our last message.
fn finish(self, timeout: Duration) {
let _ = self.stream.shutdown(Shutdown::Write);
let deadline = Instant::now() + timeout;
while self.reader.as_ref().is_some_and(|r| !r.is_finished()) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
self.abort();
}
}
fn read_replies(
mut stream: TcpStream,
shared: &ConnShared,
stats: &Stats,
priority: i32,
error_log_interval: Duration,
) {
let mut buf = Vec::with_capacity(64);
let mut error_log = RateLimit::new(error_log_interval);
let mut registered = false;
let reason = loop {
match proto::read_message(&mut stream, &mut buf, HYPERHDR_MAX_MESSAGE) {
Ok(true) => {}
Ok(false) => break "connection closed by HyperHDR".to_owned(),
Err(e) => break format!("read failed: {e}"),
}
shared.got_reply.store(true, Ordering::Relaxed);
match proto::decode_reply(&buf) {
Ok(reply) => {
if let Some(error) = reply.error {
stats::inc(&stats.error_replies);
if let Some(suppressed) = error_log.check(Instant::now()) {
warn!(suppressed, "HyperHDR replied with an error: {error}");
}
} else if reply.registered != -1 && !registered {
registered = true;
if reply.registered == priority {
info!("registered with priority {priority}");
} else {
warn!(
"HyperHDR reports priority {} instead of {priority}",
reply.registered
);
}
}
}
Err(e) => {
if let Some(suppressed) = error_log.check(Instant::now()) {
warn!(suppressed, "undecodable reply from HyperHDR: {e}");
}
}
}
};
*shared.reason.lock().unwrap_or_else(|e| e.into_inner()) = Some(reason);
shared.closed.store(true, Ordering::Relaxed);
}
struct Client {
cfg: ClientConfig,
slot: Arc<FrameSlot>,
stats: Arc<Stats>,
pacer: Pacer,
backoff: Backoff,
encoder: Encoder,
frame: Frame,
conn: Option<Connection>,
retry_at: Option<Instant>,
/// The next successful connection is a reconnect (after a failure).
reconnecting: bool,
failed_attempts: u32,
last_connect_error: Option<String>,
reject_log: RateLimit,
next_stats: Option<Instant>,
}
impl Client {
fn new(cfg: ClientConfig, slot: Arc<FrameSlot>, stats: Arc<Stats>) -> Self {
Self {
pacer: Pacer::new(cfg.pacing),
backoff: Backoff::new(
cfg.backoff_initial,
cfg.backoff_max,
cfg.backoff_reset_after,
),
encoder: Encoder::new(),
frame: Frame::default(),
conn: None,
retry_at: None,
reconnecting: false,
failed_attempts: 0,
last_connect_error: None,
reject_log: RateLimit::new(cfg.error_log_interval.max(Duration::from_secs(60))),
next_stats: cfg.stats_interval.map(|i| Instant::now() + i),
cfg,
slot,
stats,
}
}
fn run(mut self) {
let server = format!("{}:{}", self.cfg.host, self.cfg.port);
let span = info_span!("hyperhdr", server = %server);
let _enter = span.enter();
loop {
let deadline = self.next_deadline(Instant::now());
let update = self.slot.wait(&mut self.frame, deadline);
let now = Instant::now();
if update.shutdown {
if let Some(conn) = self.conn.take() {
self.close_gracefully(conn, "shutting down");
}
return;
}
if update.source_gone {
self.on_source_gone();
}
if update.new_frame {
let was_cleared = self.pacer.is_cleared();
if self.pacer.on_new_frame(now) {
stats::inc(&self.stats.superseded);
}
if was_cleared {
info!("new frames after idle, resuming");
}
}
if let Some(conn) = self.conn.as_ref().filter(|c| c.is_closed()) {
let reason = conn.close_reason();
self.on_connection_lost(reason, now);
}
self.maybe_connect(now);
self.pump(now);
self.maybe_log_stats(now);
}
}
fn next_deadline(&self, now: Instant) -> Option<Instant> {
let deadline = match self.conn {
Some(_) => match self.pacer.poll(now) {
Action::Wait(at) => at,
_ => Some(now),
},
None => self.retry_at,
};
match (deadline, self.next_stats) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
fn on_source_gone(&mut self) {
self.pacer.on_source_gone();
if let Some(conn) = self.conn.take() {
self.close_gracefully(conn, "source gone");
}
self.retry_at = None;
self.reconnecting = false;
self.failed_attempts = 0;
self.last_connect_error = None;
self.backoff.reset();
}
/// Clears our priority and closes the connection cleanly.
fn close_gracefully(&mut self, mut conn: Connection, reason: &str) {
let timeout = self.cfg.shutdown_timeout;
let _ = conn.stream.set_write_timeout(Some(timeout));
let msg = self.encoder.clear(self.cfg.priority);
match conn
.stream
.write_all(msg)
.and_then(|()| conn.stream.flush())
{
Ok(()) => {
stats::add(&self.stats.bytes_sent, msg.len() as u64);
info!(
priority = self.cfg.priority,
"{reason}: sent Clear, disconnecting"
);
}
Err(e) => warn!("{reason}: could not send Clear ({e}), disconnecting"),
}
conn.finish(timeout);
}
fn maybe_connect(&mut self, now: Instant) {
if self.conn.is_some() || !self.pacer.wants_connection(now) {
return;
}
if self.retry_at.is_some_and(|at| now < at) {
return;
}
match Connection::open(&self.cfg, &self.slot, &self.stats) {
Ok(mut conn) => {
if self.failed_attempts > 0 {
info!(peer = %conn.peer, "connected after {} failed attempts", self.failed_attempts);
} else {
info!(peer = %conn.peer, "connected");
}
if self.reconnecting {
stats::inc(&self.stats.reconnects);
}
self.reconnecting = false;
self.failed_attempts = 0;
self.last_connect_error = None;
self.retry_at = None;
let msg = self.encoder.register(&self.cfg.origin, self.cfg.priority);
let result = conn.stream.write_all(msg);
let sent = msg.len() as u64;
self.conn = Some(conn);
match result {
Ok(()) => {
stats::add(&self.stats.bytes_sent, sent);
self.pacer.on_connected();
}
Err(e) => self.on_connection_lost(
format!("sending Register failed: {e}"),
Instant::now(),
),
}
}
Err(e) => {
self.failed_attempts += 1;
self.reconnecting = true;
let delay = self.backoff.on_failure(None);
self.retry_at = Some(now + delay);
let error = e.to_string();
if self.last_connect_error.as_deref() == Some(error.as_str()) {
debug!(
attempt = self.failed_attempts,
"cannot connect: {error}; next attempt in {delay:?}"
);
} else {
warn!(
"cannot connect: {error}; retrying with backoff, next attempt in {delay:?}"
);
self.last_connect_error = Some(error);
}
}
}
}
fn on_connection_lost(&mut self, reason: String, now: Instant) {
let Some(conn) = self.conn.take() else {
return;
};
let session = now.saturating_duration_since(conn.connected_at);
let got_reply = conn.shared.got_reply.load(Ordering::Relaxed);
conn.abort();
let delay = self.backoff.on_failure(Some(session));
self.retry_at = Some(now + delay);
self.reconnecting = true;
if session < REJECT_WINDOW && !got_reply {
match self.reject_log.check(now) {
Some(suppressed) => warn!(
suppressed,
"HyperHDR closed the connection right after connecting ({reason}). HyperHDR only \
accepts clients from its own /24 subnet unless their IP is whitelisted \
(Network Services) or internet access is enabled; see the HyperHDR log for \
\"not whitelisted, access denied\". Retrying in {delay:?}"
),
None => debug!("connection closed immediately again ({reason}); retrying in {delay:?}"),
}
} else {
warn!("disconnected after {session:.1?}: {reason}; reconnecting in {delay:?}");
}
}
fn pump(&mut self, now: Instant) {
loop {
let Some(conn) = self.conn.as_mut() else {
return;
};
let action = self.pacer.poll(now);
let msg = match action {
Action::Wait(_) => return,
Action::SendFrame | Action::SendKeepalive => {
self.encoder
.image(self.frame.width, self.frame.height, &self.frame.rgb)
}
Action::Clear => self.encoder.clear(self.cfg.priority),
Action::Heartbeat => self.encoder.register(&self.cfg.origin, self.cfg.priority),
};
match conn.stream.write_all(msg) {
Ok(()) => {
stats::add(&self.stats.bytes_sent, msg.len() as u64);
match action {
Action::SendFrame => stats::inc(&self.stats.frames_sent),
Action::SendKeepalive => stats::inc(&self.stats.keepalives),
Action::Clear => info!(
priority = self.cfg.priority,
"no new frames for {:?}, cleared priority",
self.cfg.pacing.idle_clear.unwrap_or_default()
),
_ => {}
}
self.pacer.on_sent(action, now);
}
Err(e) => {
let reason = if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) {
format!(
"write timed out after {:?} (HyperHDR is not reading)",
self.cfg.write_timeout
)
} else {
format!("write failed: {e}")
};
self.on_connection_lost(reason, Instant::now());
return;
}
}
}
}
fn maybe_log_stats(&mut self, now: Instant) {
let (Some(interval), Some(next)) = (self.cfg.stats_interval, self.next_stats) else {
return;
};
if now < next {
return;
}
self.next_stats = Some(now + interval);
let snapshot = self.stats.take();
info!("stats for the last {interval:?}: {snapshot}");
}
}
@@ -0,0 +1,242 @@
// automatically generated by the FlatBuffers compiler, do not modify
// @generated
extern crate alloc;
#[allow(unused_imports, dead_code)]
pub mod hyperhdrnet {
extern crate alloc;
pub enum ReplyOffset {}
#[derive(Copy, Clone, PartialEq)]
pub struct Reply<'a> {
pub _tab: ::flatbuffers::Table<'a>,
}
impl<'a> ::flatbuffers::Follow<'a> for Reply<'a> {
type Inner = Reply<'a>;
#[inline]
unsafe fn follow(buf: &'a [u8], loc: usize) -> Self::Inner {
Self {
_tab: unsafe { ::flatbuffers::Table::new(buf, loc) },
}
}
}
impl<'a> Reply<'a> {
pub const VT_ERROR: ::flatbuffers::VOffsetT = 4;
pub const VT_VIDEO: ::flatbuffers::VOffsetT = 6;
pub const VT_REGISTERED: ::flatbuffers::VOffsetT = 8;
#[inline]
pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self {
Reply { _tab: table }
}
#[allow(unused_mut)]
pub fn create<
'bldr: 'args,
'args: 'mut_bldr,
'mut_bldr,
A: ::flatbuffers::Allocator + 'bldr,
>(
_fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>,
args: &'args ReplyArgs<'args>,
) -> ::flatbuffers::WIPOffset<Reply<'bldr>> {
let mut builder = ReplyBuilder::new(_fbb);
builder.add_registered(args.registered);
builder.add_video(args.video);
if let Some(x) = args.error {
builder.add_error(x);
}
builder.finish()
}
#[inline]
pub fn error(&self) -> Option<&'a str> {
// Safety:
// Created from valid Table for this object
// which contains a valid value in this slot
unsafe {
self._tab
.get::<::flatbuffers::ForwardsUOffset<&str>>(Reply::VT_ERROR, None)
}
}
#[inline]
pub fn video(&self) -> i32 {
// Safety:
// Created from valid Table for this object
// which contains a valid value in this slot
unsafe { self._tab.get::<i32>(Reply::VT_VIDEO, Some(-1)).unwrap() }
}
#[inline]
pub fn registered(&self) -> i32 {
// Safety:
// Created from valid Table for this object
// which contains a valid value in this slot
unsafe {
self._tab
.get::<i32>(Reply::VT_REGISTERED, Some(-1))
.unwrap()
}
}
}
impl ::flatbuffers::Verifiable for Reply<'_> {
#[inline]
fn run_verifier(
v: &mut ::flatbuffers::Verifier,
pos: usize,
) -> Result<(), ::flatbuffers::InvalidFlatbuffer> {
v.visit_table(pos)?
.visit_field::<::flatbuffers::ForwardsUOffset<&str>>(
"error",
Self::VT_ERROR,
false,
)?
.visit_field::<i32>("video", Self::VT_VIDEO, false)?
.visit_field::<i32>("registered", Self::VT_REGISTERED, false)?
.finish();
Ok(())
}
}
pub struct ReplyArgs<'a> {
pub error: Option<::flatbuffers::WIPOffset<&'a str>>,
pub video: i32,
pub registered: i32,
}
impl<'a> Default for ReplyArgs<'a> {
#[inline]
fn default() -> Self {
ReplyArgs {
error: None,
video: -1,
registered: -1,
}
}
}
pub struct ReplyBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> {
fbb_: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>,
start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>,
}
impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> ReplyBuilder<'a, 'b, A> {
#[inline]
pub fn add_error(&mut self, error: ::flatbuffers::WIPOffset<&'b str>) {
self.fbb_
.push_slot_always::<::flatbuffers::WIPOffset<_>>(Reply::VT_ERROR, error);
}
#[inline]
pub fn add_video(&mut self, video: i32) {
self.fbb_.push_slot::<i32>(Reply::VT_VIDEO, video, -1);
}
#[inline]
pub fn add_registered(&mut self, registered: i32) {
self.fbb_
.push_slot::<i32>(Reply::VT_REGISTERED, registered, -1);
}
#[inline]
pub fn new(
_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>,
) -> ReplyBuilder<'a, 'b, A> {
let start = _fbb.start_table();
ReplyBuilder {
fbb_: _fbb,
start_: start,
}
}
#[inline]
pub fn finish(self) -> ::flatbuffers::WIPOffset<Reply<'a>> {
let o = self.fbb_.end_table(self.start_);
::flatbuffers::WIPOffset::new(o.value())
}
}
impl ::core::fmt::Debug for Reply<'_> {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
let mut ds = f.debug_struct("Reply");
ds.field("error", &self.error());
ds.field("video", &self.video());
ds.field("registered", &self.registered());
ds.finish()
}
}
#[inline]
/// Verifies that a buffer of bytes contains a `Reply`
/// and returns it.
/// Note that verification is still experimental and may not
/// catch every error, or be maximally performant. For the
/// previous, unchecked, behavior use
/// `root_as_reply_unchecked`.
pub fn root_as_reply(buf: &[u8]) -> Result<Reply<'_>, ::flatbuffers::InvalidFlatbuffer> {
::flatbuffers::root::<Reply>(buf)
}
#[inline]
/// Verifies that a buffer of bytes contains a size prefixed
/// `Reply` and returns it.
/// Note that verification is still experimental and may not
/// catch every error, or be maximally performant. For the
/// previous, unchecked, behavior use
/// `size_prefixed_root_as_reply_unchecked`.
pub fn size_prefixed_root_as_reply(
buf: &[u8],
) -> Result<Reply<'_>, ::flatbuffers::InvalidFlatbuffer> {
::flatbuffers::size_prefixed_root::<Reply>(buf)
}
#[inline]
/// Verifies, with the given options, that a buffer of bytes
/// contains a `Reply` and returns it.
/// Note that verification is still experimental and may not
/// catch every error, or be maximally performant. For the
/// previous, unchecked, behavior use
/// `root_as_reply_unchecked`.
pub fn root_as_reply_with_opts<'b, 'o>(
opts: &'o ::flatbuffers::VerifierOptions,
buf: &'b [u8],
) -> Result<Reply<'b>, ::flatbuffers::InvalidFlatbuffer> {
::flatbuffers::root_with_opts::<Reply<'b>>(opts, buf)
}
#[inline]
/// Verifies, with the given verifier options, that a buffer of
/// bytes contains a size prefixed `Reply` and returns
/// it. Note that verification is still experimental and may not
/// catch every error, or be maximally performant. For the
/// previous, unchecked, behavior use
/// `root_as_reply_unchecked`.
pub fn size_prefixed_root_as_reply_with_opts<'b, 'o>(
opts: &'o ::flatbuffers::VerifierOptions,
buf: &'b [u8],
) -> Result<Reply<'b>, ::flatbuffers::InvalidFlatbuffer> {
::flatbuffers::size_prefixed_root_with_opts::<Reply<'b>>(opts, buf)
}
#[inline]
/// Assumes, without verification, that a buffer of bytes contains a Reply and returns it.
/// # Safety
/// Callers must trust the given bytes do indeed contain a valid `Reply`.
pub unsafe fn root_as_reply_unchecked(buf: &[u8]) -> Reply<'_> {
unsafe { ::flatbuffers::root_unchecked::<Reply>(buf) }
}
#[inline]
/// Assumes, without verification, that a buffer of bytes contains a size prefixed Reply and returns it.
/// # Safety
/// Callers must trust the given bytes do indeed contain a valid size prefixed `Reply`.
pub unsafe fn size_prefixed_root_as_reply_unchecked(buf: &[u8]) -> Reply<'_> {
unsafe { ::flatbuffers::size_prefixed_root_unchecked::<Reply>(buf) }
}
#[inline]
pub fn finish_reply_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>(
fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>,
root: ::flatbuffers::WIPOffset<Reply<'a>>,
) {
fbb.finish(root, None);
}
#[inline]
pub fn finish_size_prefixed_reply_buffer<'a, 'b, A: ::flatbuffers::Allocator + 'a>(
fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>,
root: ::flatbuffers::WIPOffset<Reply<'a>>,
) {
fbb.finish_size_prefixed(root, None);
}
} // pub mod hyperhdrnet
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//! Code generated by `flatc --rust` from `schema/*.fbs` (see `scripts/regen-fbs.sh`).
//! Only this file is hand-written.
#![allow(
clippy::all,
dead_code,
unused_imports,
non_camel_case_types,
non_snake_case,
unsafe_op_in_unsafe_fn
)]
mod hyperhdr_reply_generated;
mod hyperhdr_request_generated;
pub mod request {
pub use super::hyperhdr_request_generated::hyperhdrnet::*;
}
pub mod reply {
pub use super::hyperhdr_reply_generated::hyperhdrnet::*;
}
+415
View File
@@ -0,0 +1,415 @@
//! A small imitation of HyperHDR's flatbuffers server (`FlatBuffersServerConnection` and
//! `FlatBuffersParser`, spec 3.2) for integration tests and local end-to-end runs.
use std::io::{self, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use flatbuffers::FlatBufferBuilder;
use tracing::{debug, info};
use super::proto::{self, Command, FrameError};
use crate::config::HYPERHDR_MAX_MESSAGE;
/// Priority of a connection that has not registered.
const DEFAULT_PRIORITY: i32 = 140;
/// How the mock treats new connections.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Behavior {
#[default]
Normal,
/// Close right after accepting (like a client that is not whitelisted).
Reject,
/// Accept but never read from the socket.
NoRead,
/// Answer every message with an error reply.
ErrorReplies,
}
#[derive(Debug, Clone)]
pub struct MockOptions {
/// Close a connection after this long without incoming data.
pub idle_timeout: Duration,
/// Keep events for [`MockServer::events`]; disable for long runs.
pub record_events: bool,
/// Log events at info level (images are summarized once per second).
pub log_events: bool,
}
impl Default for MockOptions {
fn default() -> Self {
Self {
idle_timeout: Duration::from_secs(5),
record_events: true,
log_events: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageInfo {
pub width: i32,
pub height: i32,
pub len: usize,
pub duration: i32,
pub first_pixel: [u8; 3],
pub mean: [u8; 3],
}
impl ImageInfo {
fn new(width: i32, height: i32, duration: i32, data: &[u8]) -> Self {
let mut first_pixel = [0; 3];
if data.len() >= 3 {
first_pixel.copy_from_slice(&data[..3]);
}
let mut sum = [0u64; 3];
for px in data.chunks_exact(3) {
for c in 0..3 {
sum[c] += u64::from(px[c]);
}
}
let n = (data.len() / 3).max(1) as u64;
Self {
width,
height,
len: data.len(),
duration,
first_pixel,
mean: sum.map(|s| (s / n) as u8),
}
}
/// The check done by `FlatBuffersServer::handlerImageReceived`.
pub fn size_ok(&self) -> bool {
self.len != 0 && self.len as i64 == i64::from(self.width) * i64::from(self.height) * 3
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Disconnect {
ClosedByClient,
Timeout,
BadLength(u32),
Error(String),
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventKind {
Connected { peer: SocketAddr },
Rejected { peer: SocketAddr },
Register { origin: String, priority: i32 },
Image(ImageInfo),
Clear { priority: i32 },
Other(&'static str),
Invalid,
Disconnected(Disconnect),
}
#[derive(Debug, Clone)]
pub struct Event {
pub conn: u64,
pub at: Instant,
pub kind: EventKind,
}
#[derive(Debug)]
struct Shared {
opts: MockOptions,
behavior: Mutex<Behavior>,
events: Mutex<Vec<Event>>,
cond: Condvar,
stop: AtomicBool,
streams: Mutex<Vec<(u64, TcpStream)>>,
next_conn: AtomicU64,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl Shared {
fn push(&self, conn: u64, kind: EventKind) {
if self.opts.log_events && !matches!(kind, EventKind::Image(_)) {
info!(conn, "{kind:?}");
}
if self.opts.record_events {
lock(&self.events).push(Event {
conn,
at: Instant::now(),
kind,
});
self.cond.notify_all();
}
}
}
pub struct MockServer {
addr: SocketAddr,
shared: Arc<Shared>,
acceptor: Option<JoinHandle<()>>,
}
impl MockServer {
pub fn start(addr: impl ToSocketAddrs, opts: MockOptions) -> io::Result<Self> {
let listener = TcpListener::bind(addr)?;
listener.set_nonblocking(true)?;
let addr = listener.local_addr()?;
let shared = Arc::new(Shared {
opts,
behavior: Mutex::default(),
events: Mutex::default(),
cond: Condvar::new(),
stop: AtomicBool::new(false),
streams: Mutex::default(),
next_conn: AtomicU64::new(1),
});
let acceptor = {
let shared = shared.clone();
thread::Builder::new()
.name("mock-accept".into())
.spawn(move || accept_loop(listener, shared))?
};
Ok(Self {
addr,
shared,
acceptor: Some(acceptor),
})
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
/// Applies to connections accepted from now on.
pub fn set_behavior(&self, behavior: Behavior) {
*lock(&self.shared.behavior) = behavior;
}
pub fn events(&self) -> Vec<Event> {
lock(&self.shared.events).clone()
}
/// Waits until `pred` holds for the recorded events.
pub fn wait_for(&self, timeout: Duration, pred: impl Fn(&[Event]) -> bool) -> bool {
let deadline = Instant::now() + timeout;
let mut events = lock(&self.shared.events);
loop {
if pred(&events) {
return true;
}
let now = Instant::now();
if now >= deadline {
return false;
}
events = self
.shared
.cond
.wait_timeout(events, deadline - now)
.unwrap_or_else(|e| e.into_inner())
.0;
}
}
/// Closes the listener and all connections.
pub fn stop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
for (_, stream) in lock(&self.shared.streams).drain(..) {
let _ = stream.shutdown(Shutdown::Both);
}
if let Some(acceptor) = self.acceptor.take() {
let _ = acceptor.join();
}
}
}
impl Drop for MockServer {
fn drop(&mut self) {
self.stop();
}
}
fn accept_loop(listener: TcpListener, shared: Arc<Shared>) {
while !shared.stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, peer)) => {
let id = shared.next_conn.fetch_add(1, Ordering::Relaxed);
let shared = shared.clone();
let spawned = thread::Builder::new()
.name(format!("mock-conn-{id}"))
.spawn(move || serve(stream, peer, id, &shared));
if let Err(e) = spawned {
debug!("cannot spawn connection thread: {e}");
}
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(5))
}
Err(e) => {
debug!("accept failed: {e}");
thread::sleep(Duration::from_millis(50));
}
}
}
}
fn serve(stream: TcpStream, peer: SocketAddr, id: u64, shared: &Shared) {
let behavior = *lock(&shared.behavior);
if behavior == Behavior::Reject {
shared.push(id, EventKind::Rejected { peer });
let _ = stream.shutdown(Shutdown::Both);
return;
}
let _ = stream.set_nonblocking(false);
if let Ok(clone) = stream.try_clone() {
lock(&shared.streams).push((id, clone));
}
shared.push(id, EventKind::Connected { peer });
let reason = if behavior == Behavior::NoRead {
while !shared.stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(10));
}
Disconnect::Stopped
} else {
read_loop(&stream, id, shared, behavior)
};
let _ = stream.shutdown(Shutdown::Both);
lock(&shared.streams).retain(|(conn, _)| *conn != id);
shared.push(id, EventKind::Disconnected(reason));
}
fn read_loop(mut stream: &TcpStream, id: u64, shared: &Shared, behavior: Behavior) -> Disconnect {
if let Err(e) = stream.set_read_timeout(Some(shared.opts.idle_timeout)) {
return Disconnect::Error(e.to_string());
}
let stopped = || shared.stop.load(Ordering::Relaxed);
let mut buf = Vec::new();
let mut fbb = FlatBufferBuilder::new();
let mut reply = Vec::new();
let mut priority = DEFAULT_PRIORITY;
let mut summary = ImageSummary::default();
loop {
match proto::read_message(&mut stream, &mut buf, HYPERHDR_MAX_MESSAGE) {
Ok(true) => {}
Ok(false) if stopped() => return Disconnect::Stopped,
Ok(false) => return Disconnect::ClosedByClient,
Err(FrameError::InvalidLength(len)) => return Disconnect::BadLength(len),
Err(FrameError::Io(e))
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
return Disconnect::Timeout
}
Err(_) if stopped() => return Disconnect::Stopped,
Err(FrameError::Io(e)) => return Disconnect::Error(e.to_string()),
}
let (kind, mut error, mut registered) = match proto::decode_request(&buf) {
Ok(Command::Register {
origin,
priority: p,
}) => {
if (50..=250).contains(&p) {
priority = p;
}
(
EventKind::Register {
origin: origin.to_owned(),
priority: p,
},
None,
p,
)
}
Ok(Command::RawImage {
width,
height,
duration,
data,
}) => {
let info = ImageInfo::new(width, height, duration, data);
if shared.opts.log_events {
summary.add(id, &info);
}
(EventKind::Image(info), None, priority)
}
Ok(Command::Nv12Image) => (EventKind::Other("NV12Image"), None, priority),
Ok(Command::ImageWithoutData) => (
EventKind::Other("Image without data"),
Some("Do not have frame data"),
-1,
),
Ok(Command::Clear { priority: p }) => (EventKind::Clear { priority: p }, None, -1),
Ok(Command::Color { .. }) => (EventKind::Other("Color"), None, -1),
Ok(Command::Unknown) => (EventKind::Invalid, Some("Received invalid packet"), -1),
Err(_) => (EventKind::Invalid, Some("Unable to parse message"), -1),
};
shared.push(id, kind);
if behavior == Behavior::ErrorReplies {
(error, registered) = (Some("Unable to parse message"), -1);
}
proto::encode_reply(&mut fbb, error, registered, &mut reply);
if let Err(e) = stream.write_all(&reply) {
return if stopped() {
Disconnect::Stopped
} else {
Disconnect::Error(e.to_string())
};
}
}
}
/// Logs the first image of a connection, size changes, and a per-second summary.
#[derive(Default)]
struct ImageSummary {
count: u64,
since: Option<Instant>,
last_size: Option<(i32, i32)>,
}
impl ImageSummary {
fn add(&mut self, conn: u64, info: &ImageInfo) {
let now = Instant::now();
self.count += 1;
if self.last_size != Some((info.width, info.height)) {
self.last_size = Some((info.width, info.height));
info!(
conn,
"image {}x{} len={} size_ok={} duration={} mean={:?}",
info.width,
info.height,
info.len,
info.size_ok(),
info.duration,
info.mean
);
}
let since = *self.since.get_or_insert(now);
let elapsed = now - since;
if elapsed >= Duration::from_secs(1) {
info!(
conn,
"{} images in {:.1?} ({:.1} fps), last {}x{} size_ok={} mean={:?}",
self.count,
elapsed,
self.count as f64 / elapsed.as_secs_f64(),
info.width,
info.height,
info.size_ok(),
info.mean
);
self.count = 0;
self.since = Some(now);
}
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Client for the HyperHDR flatbuffers server.
pub mod client;
pub mod generated;
pub mod mock;
pub mod proto;
pub use client::ClientConfig;
+374
View File
@@ -0,0 +1,374 @@
//! HyperHDR flatbuffers messages and their `[u32 big-endian length][flatbuffer]` framing.
use std::io::{self, Read};
use flatbuffers::{FlatBufferBuilder, InvalidFlatbuffer, UnionWIPOffset, WIPOffset};
use super::generated::{reply, request};
/// Builds framed requests, reusing one builder.
pub struct Encoder {
fbb: FlatBufferBuilder<'static>,
}
impl Default for Encoder {
fn default() -> Self {
Self::new()
}
}
impl Encoder {
pub fn new() -> Self {
Self {
fbb: FlatBufferBuilder::with_capacity(1024),
}
}
pub fn register(&mut self, origin: &str, priority: i32) -> &[u8] {
self.fbb.reset();
let origin = self.fbb.create_string(origin);
let register = request::Register::create(
&mut self.fbb,
&request::RegisterArgs {
origin: Some(origin),
priority,
},
);
self.finish(request::Command::Register, register.as_union_value())
}
/// `Image{RawImage}` with `duration = -1` (shown until cleared or disconnected).
pub fn image(&mut self, width: u32, height: u32, rgb: &[u8]) -> &[u8] {
self.fbb.reset();
let data = self.fbb.create_vector(rgb);
let raw = request::RawImage::create(
&mut self.fbb,
&request::RawImageArgs {
data: Some(data),
width: i32::try_from(width).unwrap_or(i32::MAX),
height: i32::try_from(height).unwrap_or(i32::MAX),
},
);
let image = request::Image::create(
&mut self.fbb,
&request::ImageArgs {
data_type: request::ImageType::RawImage,
data: Some(raw.as_union_value()),
duration: -1,
},
);
self.finish(request::Command::Image, image.as_union_value())
}
pub fn clear(&mut self, priority: i32) -> &[u8] {
self.fbb.reset();
let clear = request::Clear::create(&mut self.fbb, &request::ClearArgs { priority });
self.finish(request::Command::Clear, clear.as_union_value())
}
fn finish(
&mut self,
command_type: request::Command,
command: WIPOffset<UnionWIPOffset>,
) -> &[u8] {
let req = request::Request::create(
&mut self.fbb,
&request::RequestArgs {
command_type,
command: Some(command),
},
);
self.fbb.finish_size_prefixed(req, None);
// flatbuffers writes a little-endian length prefix; HyperHDR expects big-endian.
let (buf, start) = self.fbb.mut_finished_buffer();
let message = &mut buf[start..];
let (prefix, _) = message.split_at_mut(4);
let len = u32::from_le_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]);
prefix.copy_from_slice(&len.to_be_bytes());
message
}
}
/// A decoded `Request` (used by the mock server and tests).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command<'a> {
Register {
origin: &'a str,
priority: i32,
},
RawImage {
width: i32,
height: i32,
duration: i32,
data: &'a [u8],
},
Nv12Image,
ImageWithoutData,
Clear {
priority: i32,
},
Color {
data: i32,
duration: i32,
},
Unknown,
}
/// Verifies and decodes a request body (without the length prefix).
pub fn decode_request(body: &[u8]) -> Result<Command<'_>, InvalidFlatbuffer> {
let req = request::root_as_request(body)?;
let command = if let Some(r) = req.command_as_register() {
Command::Register {
origin: r.origin(),
priority: r.priority(),
}
} else if let Some(image) = req.command_as_image() {
if let Some(raw) = image.data_as_raw_image() {
Command::RawImage {
width: raw.width(),
height: raw.height(),
duration: image.duration(),
data: raw.data().map_or(&[][..], |d| d.bytes()),
}
} else if image.data_as_nv12_image().is_some() {
Command::Nv12Image
} else {
Command::ImageWithoutData
}
} else if let Some(c) = req.command_as_clear() {
Command::Clear {
priority: c.priority(),
}
} else if let Some(c) = req.command_as_color() {
Command::Color {
data: c.data(),
duration: c.duration(),
}
} else {
Command::Unknown
};
Ok(command)
}
/// A decoded `Reply`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Reply<'a> {
pub error: Option<&'a str>,
pub video: i32,
pub registered: i32,
}
/// Verifies and decodes a reply body (without the length prefix).
pub fn decode_reply(body: &[u8]) -> Result<Reply<'_>, InvalidFlatbuffer> {
let r = reply::root_as_reply(body)?;
Ok(Reply {
error: r.error(),
video: r.video(),
registered: r.registered(),
})
}
/// Encodes a framed `Reply` into `out`, the way `FlatBuffersParser` does.
pub fn encode_reply(
fbb: &mut FlatBufferBuilder<'_>,
error: Option<&str>,
registered: i32,
out: &mut Vec<u8>,
) {
fbb.reset();
let error = error.map(|e| fbb.create_string(e));
let r = reply::Reply::create(
fbb,
&reply::ReplyArgs {
error,
video: -1,
registered,
},
);
fbb.finish(r, None);
let data = fbb.finished_data();
out.clear();
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(data);
}
#[derive(Debug, thiserror::Error)]
pub enum FrameError {
#[error("invalid message length {0}")]
InvalidLength(u32),
#[error(transparent)]
Io(#[from] io::Error),
}
/// Reads one framed message body into `buf`. Returns `Ok(false)` on a clean EOF between messages.
pub fn read_message(
r: &mut impl Read,
buf: &mut Vec<u8>,
max_len: usize,
) -> Result<bool, FrameError> {
let mut header = [0u8; 4];
let mut filled = 0;
while filled < header.len() {
match r.read(&mut header[filled..]) {
Ok(0) if filled == 0 => return Ok(false),
Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()),
Ok(n) => filled += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e.into()),
}
}
let len = u32::from_be_bytes(header);
if len == 0 || len as usize > max_len {
return Err(FrameError::InvalidLength(len));
}
buf.resize(len as usize, 0);
r.read_exact(buf)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn body(message: &[u8]) -> &[u8] {
let len = u32::from_be_bytes(message[..4].try_into().unwrap()) as usize;
assert_eq!(len, message.len() - 4, "big-endian length prefix");
&message[4..]
}
#[test]
fn register_roundtrip() {
let mut enc = Encoder::new();
let msg = enc.register("gamescope", 150).to_vec();
let req = request::root_as_request(body(&msg)).unwrap();
assert_eq!(req.command_type(), request::Command::Register);
assert_eq!(
decode_request(body(&msg)).unwrap(),
Command::Register {
origin: "gamescope",
priority: 150
}
);
}
#[test]
fn image_roundtrip() {
let mut enc = Encoder::new();
let rgb: Vec<u8> = (0..3 * 5 * 3).map(|i| i as u8).collect();
let msg = enc.image(5, 3, &rgb).to_vec();
let req = request::root_as_request(body(&msg)).unwrap();
assert_eq!(req.command_type(), request::Command::Image);
let image = req.command_as_image().unwrap();
assert_eq!(image.data_type(), request::ImageType::RawImage);
assert_eq!(
decode_request(body(&msg)).unwrap(),
Command::RawImage {
width: 5,
height: 3,
duration: -1,
data: &rgb
}
);
}
#[test]
fn clear_roundtrip() {
let mut enc = Encoder::new();
let msg = enc.clear(150).to_vec();
assert_eq!(
decode_request(body(&msg)).unwrap(),
Command::Clear { priority: 150 }
);
}
#[test]
fn encoder_reuse() {
let mut enc = Encoder::new();
let big = vec![7u8; 320 * 180 * 3];
let _ = enc.image(320, 180, &big);
let msg = enc.register("x", 99).to_vec();
assert_eq!(
decode_request(body(&msg)).unwrap(),
Command::Register {
origin: "x",
priority: 99
}
);
let msg = enc.image(320, 180, &big).to_vec();
match decode_request(body(&msg)).unwrap() {
Command::RawImage { data, .. } => assert_eq!(data, &big[..]),
other => panic!("unexpected {other:?}"),
}
// 172800 bytes of pixels plus a small overhead.
assert!(msg.len() < 320 * 180 * 3 + 128, "{}", msg.len());
}
#[test]
fn reply_roundtrip() {
let mut fbb = FlatBufferBuilder::new();
let mut out = Vec::new();
encode_reply(&mut fbb, None, 150, &mut out);
let r = decode_reply(body(&out)).unwrap();
assert_eq!(
r,
Reply {
error: None,
video: -1,
registered: 150
}
);
encode_reply(&mut fbb, Some("Unable to parse message"), -1, &mut out);
let r = decode_reply(body(&out)).unwrap();
assert_eq!(r.error, Some("Unable to parse message"));
assert_eq!(r.registered, -1);
}
#[test]
fn garbage_is_rejected() {
assert!(decode_request(&[1, 2, 3]).is_err());
assert!(decode_request(&[0xFF; 64]).is_err());
assert!(decode_reply(&[0xFF; 16]).is_err());
}
#[test]
fn framing() {
let mut enc = Encoder::new();
let mut stream = enc.register("a", 150).to_vec();
stream.extend_from_slice(enc.clear(150));
let mut cursor = io::Cursor::new(stream);
let mut buf = Vec::new();
assert!(read_message(&mut cursor, &mut buf, 1000).unwrap());
assert!(matches!(
decode_request(&buf).unwrap(),
Command::Register { .. }
));
assert!(read_message(&mut cursor, &mut buf, 1000).unwrap());
assert!(matches!(
decode_request(&buf).unwrap(),
Command::Clear { .. }
));
assert!(!read_message(&mut cursor, &mut buf, 1000).unwrap());
let mut zero = io::Cursor::new(vec![0, 0, 0, 0]);
assert!(matches!(
read_message(&mut zero, &mut buf, 1000),
Err(FrameError::InvalidLength(0))
));
let mut long = io::Cursor::new(vec![0, 0, 3, 233, 0]);
assert!(matches!(
read_message(&mut long, &mut buf, 1000),
Err(FrameError::InvalidLength(1001))
));
let mut truncated = io::Cursor::new(vec![0, 0, 0, 8, 1, 2]);
assert!(matches!(
read_message(&mut truncated, &mut buf, 1000),
Err(FrameError::Io(_))
));
let mut half_header = io::Cursor::new(vec![0, 0]);
assert!(matches!(
read_message(&mut half_header, &mut buf, 1000),
Err(FrameError::Io(_))
));
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Captures gamescope's PipeWire stream at a small `requested_size` and forwards it to
//! HyperHDR's flatbuffers server.
pub mod capture;
pub mod config;
pub mod convert;
pub mod hyperhdr;
pub mod pacing;
pub mod slot;
pub mod stats;
+107
View File
@@ -0,0 +1,107 @@
use std::io::IsTerminal;
use std::process::ExitCode;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use anyhow::Context as _;
use clap::Parser;
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;
use gamescope_hyperhdr::capture::{self, CaptureConfig};
use gamescope_hyperhdr::config::Config;
use gamescope_hyperhdr::hyperhdr::{client, ClientConfig};
use gamescope_hyperhdr::slot::FrameSlot;
use gamescope_hyperhdr::stats::Stats;
/// How long the network thread gets to send `Clear` and close on shutdown.
const NETWORK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
fn init_logging(level: &str) {
let filter = EnvFilter::try_new(level).unwrap_or_else(|e| {
eprintln!("invalid log level {level:?} ({e}), using info");
EnvFilter::new("info")
});
// Under systemd, journald adds its own timestamps.
let journal = std::env::var_os("JOURNAL_STREAM").is_some();
let builder = tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(filter)
.with_target(false)
.with_ansi(!journal && std::io::stderr().is_terminal());
if journal {
builder.without_time().init();
} else {
builder.init();
}
}
fn main() -> ExitCode {
let cfg = Config::parse();
init_logging(&cfg.log_level);
match cfg.validate() {
Ok(warnings) => warnings.iter().for_each(|w| warn!("{w}")),
Err(e) => {
error!("invalid configuration: {e}");
return ExitCode::from(2);
}
}
info!(
host = %cfg.host,
port = cfg.port,
priority = cfg.priority,
origin = %cfg.origin,
size = %cfg.size,
supersample = cfg.supersample,
requested_size = %cfg.requested_size(),
max_fps = cfg.max_fps,
keepalive_ms = cfg.keepalive_ms,
idle_clear_secs = cfg.idle_clear_secs,
target = %cfg.target,
connect_timeout_ms = cfg.connect_timeout_ms,
write_timeout_ms = cfg.write_timeout_ms,
"gamescope-hyperhdr {} starting",
env!("CARGO_PKG_VERSION")
);
let slot = Arc::new(FrameSlot::new());
let stats = Arc::new(Stats::default());
let capture_cfg = CaptureConfig {
target: cfg.target.clone(),
requested: cfg.requested_size(),
output_max: cfg.size,
};
let mut network = None;
let result = capture::run(capture_cfg, slot.clone(), stats.clone(), || {
let handle = client::spawn(ClientConfig::from_config(&cfg), slot.clone(), stats)
.context("cannot start the network thread")?;
network = Some(handle);
Ok(())
});
slot.shutdown();
if let Some(network) = network {
let deadline = Instant::now() + NETWORK_SHUTDOWN_TIMEOUT;
while !network.is_finished() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(5));
}
if network.is_finished() {
let _ = network.join();
} else {
warn!("network thread did not finish within {NETWORK_SHUTDOWN_TIMEOUT:?}");
}
}
match result {
Ok(()) => {
info!("exiting");
ExitCode::SUCCESS
}
Err(e) => {
error!("{e:#}");
ExitCode::FAILURE
}
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Pure scheduling logic for the HyperHDR connection: send pacing, reconnect backoff
//! and log rate limiting. Nothing here does I/O or reads the clock; `now` is injected.
use std::time::{Duration, Instant};
/// What the network thread should do next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// Send the newest frame, which has not been sent yet.
SendFrame,
/// Re-send the last frame so the server does not time out the connection.
SendKeepalive,
/// Clear our priority: no new frames for `idle_clear`.
Clear,
/// Keep the connection open while cleared, without showing an image.
Heartbeat,
/// Nothing to do until the given instant, or until the next event if `None`.
Wait(Option<Instant>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PacingConfig {
/// `1 / max_fps`.
pub min_interval: Duration,
pub keepalive: Duration,
pub idle_clear: Option<Duration>,
}
/// Send rules (spec 4.3.6), evaluated as a function of `now` and the recorded events.
#[derive(Debug)]
pub struct Pacer {
cfg: PacingConfig,
has_frame: bool,
unsent: bool,
cleared: bool,
last_frame_at: Option<Instant>,
last_image_at: Option<Instant>,
last_message_at: Option<Instant>,
}
impl Pacer {
pub fn new(cfg: PacingConfig) -> Self {
Self {
cfg,
has_frame: false,
unsent: false,
cleared: false,
last_frame_at: None,
last_image_at: None,
last_message_at: None,
}
}
/// A new frame arrived. Returns `true` if it replaces a frame that was never sent.
pub fn on_new_frame(&mut self, now: Instant) -> bool {
let superseded = self.unsent;
self.has_frame = true;
self.unsent = true;
self.cleared = false;
self.last_frame_at = Some(now);
superseded
}
/// The source disappeared: forget the last frame.
pub fn on_source_gone(&mut self) {
self.has_frame = false;
self.unsent = false;
self.cleared = false;
self.last_frame_at = None;
}
/// A new connection was established (and registered).
pub fn on_connected(&mut self) {
self.last_image_at = None;
self.last_message_at = None;
if self.has_frame && !self.cleared {
self.unsent = true;
}
}
/// `action` was written successfully at `now`.
pub fn on_sent(&mut self, action: Action, now: Instant) {
match action {
Action::SendFrame | Action::SendKeepalive => {
self.unsent = false;
self.last_image_at = Some(now);
self.last_message_at = Some(now);
}
Action::Clear => {
self.cleared = true;
self.last_message_at = Some(now);
}
Action::Heartbeat => self.last_message_at = Some(now),
Action::Wait(_) => {}
}
}
/// Whether a connection to HyperHDR should exist while disconnected.
/// Marks the source as cleared if the idle timeout expired in the meantime.
pub fn wants_connection(&mut self, now: Instant) -> bool {
if self.idle_expired(now) {
self.cleared = true;
self.unsent = false;
}
self.has_frame && !self.cleared
}
pub fn is_cleared(&self) -> bool {
self.cleared
}
fn idle_expired(&self, now: Instant) -> bool {
match (self.cfg.idle_clear, self.last_frame_at) {
(Some(idle), Some(at)) => !self.unsent && now >= at + idle,
_ => false,
}
}
/// Next action for a connected client.
pub fn poll(&self, now: Instant) -> Action {
let image_allowed_at = self.last_image_at.map(|t| t + self.cfg.min_interval);
if self.unsent {
return match image_allowed_at {
Some(at) if now < at => Action::Wait(Some(at)),
_ => Action::SendFrame,
};
}
if !self.has_frame {
return Action::Wait(None);
}
let keepalive_at = self.last_message_at.map_or(now, |t| t + self.cfg.keepalive);
if self.cleared {
return if now >= keepalive_at {
Action::Heartbeat
} else {
Action::Wait(Some(keepalive_at))
};
}
let idle_at = match (self.cfg.idle_clear, self.last_frame_at) {
(Some(idle), Some(at)) => Some(at + idle),
_ => None,
};
if idle_at.is_some_and(|at| now >= at) {
return Action::Clear;
}
let keepalive_at = image_allowed_at.map_or(keepalive_at, |at| at.max(keepalive_at));
if now >= keepalive_at {
return Action::SendKeepalive;
}
Action::Wait(Some(
idle_at.map_or(keepalive_at, |at| at.min(keepalive_at)),
))
}
}
/// Exponential reconnect backoff (spec 4.3.7).
#[derive(Debug, Clone)]
pub struct Backoff {
initial: Duration,
max: Duration,
reset_after: Duration,
next: Duration,
}
impl Backoff {
pub fn new(initial: Duration, max: Duration, reset_after: Duration) -> Self {
Self {
initial,
max,
reset_after,
next: initial,
}
}
/// Delay before the next attempt. `session` is how long the failed connection had been
/// up, or `None` if connecting itself failed.
pub fn on_failure(&mut self, session: Option<Duration>) -> Duration {
if session.is_some_and(|s| s >= self.reset_after) {
self.next = self.initial;
}
let delay = self.next;
self.next = (self.next * 2).min(self.max);
delay
}
pub fn reset(&mut self) {
self.next = self.initial;
}
}
/// Lets one event through per `interval` and counts the suppressed ones.
#[derive(Debug, Clone)]
pub struct RateLimit {
interval: Duration,
last: Option<Instant>,
suppressed: u64,
}
impl RateLimit {
pub fn new(interval: Duration) -> Self {
Self {
interval,
last: None,
suppressed: 0,
}
}
/// Returns `Some(suppressed since the last emitted event)` if this event should be logged.
pub fn check(&mut self, now: Instant) -> Option<u64> {
match self.last {
Some(last) if now < last + self.interval => {
self.suppressed += 1;
None
}
_ => {
self.last = Some(now);
Some(std::mem::take(&mut self.suppressed))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const MS: Duration = Duration::from_millis(1);
fn pacer(idle_clear: Option<Duration>) -> (Pacer, Instant) {
let cfg = PacingConfig {
min_interval: 100 * MS,
keepalive: 1000 * MS,
idle_clear,
};
(Pacer::new(cfg), Instant::now())
}
#[test]
fn nothing_without_frames() {
let (p, t0) = pacer(None);
assert_eq!(p.poll(t0), Action::Wait(None));
}
#[test]
fn fps_ceiling_and_freshest_wins() {
let (mut p, t0) = pacer(None);
assert!(!p.on_new_frame(t0));
assert_eq!(p.poll(t0), Action::SendFrame);
p.on_sent(Action::SendFrame, t0);
// Too early: wait for the slot, newer frames replace the pending one.
assert!(!p.on_new_frame(t0 + 10 * MS));
assert_eq!(p.poll(t0 + 10 * MS), Action::Wait(Some(t0 + 100 * MS)));
assert!(p.on_new_frame(t0 + 20 * MS));
assert_eq!(p.poll(t0 + 99 * MS), Action::Wait(Some(t0 + 100 * MS)));
assert_eq!(p.poll(t0 + 100 * MS), Action::SendFrame);
p.on_sent(Action::SendFrame, t0 + 100 * MS);
// Late frame goes out immediately.
p.on_new_frame(t0 + 350 * MS);
assert_eq!(p.poll(t0 + 350 * MS), Action::SendFrame);
}
#[test]
fn keepalive_repeats_last_frame() {
let (mut p, t0) = pacer(None);
p.on_new_frame(t0);
p.on_sent(Action::SendFrame, t0);
assert_eq!(p.poll(t0 + 500 * MS), Action::Wait(Some(t0 + 1000 * MS)));
assert_eq!(p.poll(t0 + 1000 * MS), Action::SendKeepalive);
p.on_sent(Action::SendKeepalive, t0 + 1000 * MS);
assert_eq!(p.poll(t0 + 1001 * MS), Action::Wait(Some(t0 + 2000 * MS)));
}
#[test]
fn keepalive_respects_fps_ceiling() {
let cfg = PacingConfig {
min_interval: 500 * MS,
keepalive: 100 * MS,
idle_clear: None,
};
let mut p = Pacer::new(cfg);
let t0 = Instant::now();
p.on_new_frame(t0);
p.on_sent(Action::SendFrame, t0);
assert_eq!(p.poll(t0 + 100 * MS), Action::Wait(Some(t0 + 500 * MS)));
assert_eq!(p.poll(t0 + 500 * MS), Action::SendKeepalive);
}
#[test]
fn idle_clear_and_resume() {
let (mut p, t0) = pacer(Some(3000 * MS));
p.on_new_frame(t0);
p.on_sent(Action::SendFrame, t0);
for s in 1..3 {
let t = t0 + s * 1000 * MS;
assert_eq!(p.poll(t), Action::SendKeepalive);
p.on_sent(Action::SendKeepalive, t);
}
assert_eq!(p.poll(t0 + 2500 * MS), Action::Wait(Some(t0 + 3000 * MS)));
let t_clear = t0 + 3000 * MS;
assert_eq!(p.poll(t_clear), Action::Clear);
p.on_sent(Action::Clear, t_clear);
assert!(p.is_cleared());
// No more frame keepalives, only heartbeats.
assert_eq!(
p.poll(t_clear + 500 * MS),
Action::Wait(Some(t_clear + 1000 * MS))
);
assert_eq!(p.poll(t_clear + 1000 * MS), Action::Heartbeat);
p.on_sent(Action::Heartbeat, t_clear + 1000 * MS);
// A new frame resumes sending immediately.
let t_new = t_clear + 1200 * MS;
p.on_new_frame(t_new);
assert!(!p.is_cleared());
assert_eq!(p.poll(t_new), Action::SendFrame);
}
#[test]
fn pending_frame_is_not_idle() {
let (mut p, t0) = pacer(Some(1000 * MS));
p.on_new_frame(t0);
assert!(p.wants_connection(t0 + 5000 * MS));
assert_eq!(p.poll(t0 + 5000 * MS), Action::SendFrame);
}
#[test]
fn connection_wanted_only_with_fresh_frames() {
let (mut p, t0) = pacer(Some(2000 * MS));
assert!(!p.wants_connection(t0));
p.on_new_frame(t0);
p.on_sent(Action::SendFrame, t0);
assert!(p.wants_connection(t0 + 1000 * MS));
assert!(!p.wants_connection(t0 + 2000 * MS));
p.on_new_frame(t0 + 3000 * MS);
assert!(p.wants_connection(t0 + 3000 * MS));
p.on_source_gone();
assert!(!p.wants_connection(t0 + 3000 * MS));
}
#[test]
fn reconnect_resends_last_frame() {
let (mut p, t0) = pacer(None);
p.on_new_frame(t0);
p.on_sent(Action::SendFrame, t0);
p.on_connected();
assert_eq!(p.poll(t0 + MS), Action::SendFrame);
}
#[test]
fn backoff_sequence_and_reset() {
let mut b = Backoff::new(500 * MS, 10_000 * MS, 30_000 * MS);
let delays: Vec<_> = (0..7).map(|_| b.on_failure(None).as_millis()).collect();
assert_eq!(delays, [500, 1000, 2000, 4000, 8000, 10000, 10000]);
// Short session does not reset.
assert_eq!(b.on_failure(Some(29_000 * MS)).as_millis(), 10000);
// Long session does.
assert_eq!(b.on_failure(Some(31_000 * MS)).as_millis(), 500);
assert_eq!(b.on_failure(None).as_millis(), 1000);
b.reset();
assert_eq!(b.on_failure(None).as_millis(), 500);
}
#[test]
fn rate_limit() {
let mut r = RateLimit::new(10_000 * MS);
let t0 = Instant::now();
assert_eq!(r.check(t0), Some(0));
assert_eq!(r.check(t0 + 1000 * MS), None);
assert_eq!(r.check(t0 + 9999 * MS), None);
assert_eq!(r.check(t0 + 10_000 * MS), Some(2));
assert_eq!(r.check(t0 + 10_001 * MS), None);
}
}
+187
View File
@@ -0,0 +1,187 @@
//! "Latest frame wins" hand-off between the PipeWire thread and the network thread.
//!
//! Buffers are swapped rather than copied, so in steady state no allocations happen.
use std::sync::{Condvar, Mutex, MutexGuard};
use std::time::Instant;
/// An RGB24 frame, rows without padding.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Frame {
pub width: u32,
pub height: u32,
pub rgb: Vec<u8>,
}
#[derive(Debug, Default)]
struct Inner {
frame: Frame,
/// `frame` was published and not taken yet.
pending: bool,
source_gone: bool,
wake: bool,
shutdown: bool,
}
/// What happened since the last [`FrameSlot::wait`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Update {
pub shutdown: bool,
/// The source disappeared. Handle before `new_frame`: such a frame is from a new source.
pub source_gone: bool,
/// The caller's frame was replaced with a newer one.
pub new_frame: bool,
/// Someone called [`FrameSlot::wake`].
pub woken: bool,
}
#[derive(Debug, Default)]
pub struct FrameSlot {
inner: Mutex<Inner>,
cond: Condvar,
}
impl FrameSlot {
pub fn new() -> Self {
Self::default()
}
fn lock(&self) -> MutexGuard<'_, Inner> {
// A panic while holding this lock cannot leave the plain data inconsistent.
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
/// Publishes `frame` by swapping it into the slot; `frame` receives the previous buffer
/// for reuse. Returns `true` if an untaken frame was replaced.
pub fn publish(&self, frame: &mut Frame) -> bool {
let mut inner = self.lock();
std::mem::swap(&mut inner.frame, frame);
let superseded = std::mem::replace(&mut inner.pending, true);
drop(inner);
self.cond.notify_all();
superseded
}
/// The source is gone; drops a pending frame.
pub fn source_gone(&self) {
let mut inner = self.lock();
inner.pending = false;
inner.source_gone = true;
drop(inner);
self.cond.notify_all();
}
pub fn wake(&self) {
self.lock().wake = true;
self.cond.notify_all();
}
pub fn shutdown(&self) {
self.lock().shutdown = true;
self.cond.notify_all();
}
/// Waits until an event occurs or `deadline` passes. A newer frame is swapped into `frame`.
pub fn wait(&self, frame: &mut Frame, deadline: Option<Instant>) -> Update {
let mut inner = self.lock();
loop {
if inner.shutdown || inner.source_gone || inner.pending || inner.wake {
break;
}
match deadline {
None => inner = self.cond.wait(inner).unwrap_or_else(|e| e.into_inner()),
Some(deadline) => {
let now = Instant::now();
if now >= deadline {
break;
}
inner = self
.cond
.wait_timeout(inner, deadline - now)
.unwrap_or_else(|e| e.into_inner())
.0;
}
}
}
let new_frame = std::mem::take(&mut inner.pending);
if new_frame {
std::mem::swap(&mut inner.frame, frame);
}
Update {
shutdown: inner.shutdown,
source_gone: std::mem::take(&mut inner.source_gone),
new_frame,
woken: std::mem::take(&mut inner.wake),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Duration;
fn frame(tag: u8) -> Frame {
Frame {
width: 1,
height: 1,
rgb: vec![tag, tag, tag],
}
}
#[test]
fn latest_wins_and_buffers_rotate() {
let slot = FrameSlot::new();
let mut producer = frame(1);
assert!(!slot.publish(&mut producer));
producer = frame(2);
assert!(slot.publish(&mut producer));
// The producer got the previous buffer back.
assert_eq!(producer.rgb, [1, 1, 1]);
let mut consumer = Frame::default();
let u = slot.wait(&mut consumer, Some(Instant::now()));
assert!(u.new_frame);
assert_eq!(consumer.rgb, [2, 2, 2]);
let u = slot.wait(&mut consumer, Some(Instant::now()));
assert!(!u.new_frame);
assert_eq!(consumer.rgb, [2, 2, 2]);
}
#[test]
fn source_gone_drops_pending_frame() {
let slot = FrameSlot::new();
slot.publish(&mut frame(1));
slot.source_gone();
let mut consumer = Frame::default();
let u = slot.wait(&mut consumer, None);
assert!(u.source_gone && !u.new_frame);
slot.source_gone();
slot.publish(&mut frame(3));
let u = slot.wait(&mut consumer, None);
assert!(u.source_gone && u.new_frame);
assert_eq!(consumer.rgb, [3, 3, 3]);
}
#[test]
fn wait_times_out_and_wakes() {
let slot = Arc::new(FrameSlot::new());
let mut consumer = Frame::default();
let start = Instant::now();
let u = slot.wait(&mut consumer, Some(start + Duration::from_millis(30)));
assert_eq!(u, Update::default());
assert!(start.elapsed() >= Duration::from_millis(30));
let s = slot.clone();
let t = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
s.shutdown();
});
let u = slot.wait(&mut consumer, None);
assert!(u.shutdown);
t.join().unwrap();
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Counters shared by the capture and network threads, logged periodically.
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
#[derive(Debug, Default)]
pub struct Stats {
pub frames_received: AtomicU64,
pub frames_sent: AtomicU64,
pub keepalives: AtomicU64,
/// Frames replaced by a newer one before they were sent.
pub superseded: AtomicU64,
pub corrupted: AtomicU64,
pub bounds_errors: AtomicU64,
pub reconnects: AtomicU64,
pub bytes_sent: AtomicU64,
pub error_replies: AtomicU64,
convert_count: AtomicU64,
convert_ns_total: AtomicU64,
convert_ns_max: AtomicU64,
}
pub fn inc(counter: &AtomicU64) {
counter.fetch_add(1, Ordering::Relaxed);
}
pub fn add(counter: &AtomicU64, value: u64) {
counter.fetch_add(value, Ordering::Relaxed);
}
impl Stats {
pub fn record_convert(&self, took: Duration) {
let ns = u64::try_from(took.as_nanos()).unwrap_or(u64::MAX);
inc(&self.convert_count);
add(&self.convert_ns_total, ns);
self.convert_ns_max.fetch_max(ns, Ordering::Relaxed);
}
/// Returns the counters accumulated since the previous call and resets them.
pub fn take(&self) -> Snapshot {
let take = |c: &AtomicU64| c.swap(0, Ordering::Relaxed);
let convert_count = take(&self.convert_count);
let convert_ns_total = take(&self.convert_ns_total);
Snapshot {
frames_received: take(&self.frames_received),
frames_sent: take(&self.frames_sent),
keepalives: take(&self.keepalives),
superseded: take(&self.superseded),
corrupted: take(&self.corrupted),
bounds_errors: take(&self.bounds_errors),
reconnects: take(&self.reconnects),
bytes_sent: take(&self.bytes_sent),
error_replies: take(&self.error_replies),
convert_avg: Duration::from_nanos(
convert_ns_total.checked_div(convert_count).unwrap_or(0),
),
convert_max: Duration::from_nanos(take(&self.convert_ns_max)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Snapshot {
pub frames_received: u64,
pub frames_sent: u64,
pub keepalives: u64,
pub superseded: u64,
pub corrupted: u64,
pub bounds_errors: u64,
pub reconnects: u64,
pub bytes_sent: u64,
pub error_replies: u64,
pub convert_avg: Duration,
pub convert_max: Duration,
}
impl fmt::Display for Snapshot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"received={} sent={} keepalive={} superseded={} corrupted={} bounds_errors={} \
reconnects={} bytes_sent={} error_replies={} convert_avg={:?} convert_max={:?}",
self.frames_received,
self.frames_sent,
self.keepalives,
self.superseded,
self.corrupted,
self.bounds_errors,
self.reconnects,
self.bytes_sent,
self.error_replies,
self.convert_avg,
self.convert_max,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn take_resets() {
let s = Stats::default();
inc(&s.frames_sent);
add(&s.bytes_sent, 100);
s.record_convert(Duration::from_micros(100));
s.record_convert(Duration::from_micros(300));
let snap = s.take();
assert_eq!(snap.frames_sent, 1);
assert_eq!(snap.bytes_sent, 100);
assert_eq!(snap.convert_avg, Duration::from_micros(200));
assert_eq!(snap.convert_max, Duration::from_micros(300));
let snap = s.take();
assert_eq!(snap.frames_sent, 0);
assert_eq!(snap.convert_avg, Duration::ZERO);
}
}
+487
View File
@@ -0,0 +1,487 @@
//! Network module against the in-process HyperHDR mock, without PipeWire (spec 9.2).
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use gamescope_hyperhdr::hyperhdr::client::{self, ClientConfig};
use gamescope_hyperhdr::hyperhdr::mock::{
Behavior, Disconnect, Event, EventKind, ImageInfo, MockOptions, MockServer,
};
use gamescope_hyperhdr::pacing::PacingConfig;
use gamescope_hyperhdr::slot::{Frame, FrameSlot};
use gamescope_hyperhdr::stats::Stats;
const WAIT: Duration = Duration::from_secs(5);
const MS: Duration = Duration::from_millis(1);
// Client logs are captured globally; each client logs inside a `hyperhdr{server=...}` span.
#[derive(Clone)]
struct LogBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for LogBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn log_buffer() -> &'static LogBuffer {
static LOGS: OnceLock<LogBuffer> = OnceLock::new();
LOGS.get_or_init(|| {
let logs = LogBuffer(Arc::default());
let writer = logs.clone();
tracing_subscriber::fmt()
.with_ansi(false)
.with_max_level(tracing::Level::DEBUG)
.with_writer(move || writer.clone())
.init();
logs
})
}
fn log_lines(server: &MockServer, needle: &str) -> Vec<String> {
let tag = format!("server={}}}", server.addr());
let logs = log_buffer().0.lock().unwrap();
String::from_utf8_lossy(&logs)
.lines()
.filter(|l| l.contains(&tag) && l.contains(needle))
.map(str::to_owned)
.collect()
}
fn wait_for_log(server: &MockServer, needle: &str) -> bool {
let deadline = Instant::now() + WAIT;
while Instant::now() < deadline {
if !log_lines(server, needle).is_empty() {
return true;
}
thread::sleep(10 * MS);
}
false
}
struct Harness {
server: MockServer,
opts: MockOptions,
slot: Arc<FrameSlot>,
stats: Arc<Stats>,
client: Option<JoinHandle<()>>,
}
impl Harness {
fn start(opts: MockOptions, configure: impl FnOnce(&mut ClientConfig)) -> Self {
Self::start_with(opts, Behavior::Normal, configure)
}
fn start_with(
opts: MockOptions,
behavior: Behavior,
configure: impl FnOnce(&mut ClientConfig),
) -> Self {
log_buffer();
let server = MockServer::start("127.0.0.1:0", opts.clone()).unwrap();
server.set_behavior(behavior);
let mut cfg = ClientConfig {
pacing: PacingConfig {
min_interval: 50 * MS,
keepalive: 200 * MS,
idle_clear: None,
},
connect_timeout: 500 * MS,
write_timeout: 500 * MS,
backoff_initial: 50 * MS,
backoff_max: 400 * MS,
stats_interval: None,
..ClientConfig::new("127.0.0.1", server.addr().port())
};
configure(&mut cfg);
let slot = Arc::new(FrameSlot::new());
let stats = Arc::new(Stats::default());
let client = client::spawn(cfg, slot.clone(), stats.clone()).unwrap();
Self {
server,
opts,
slot,
stats,
client: Some(client),
}
}
fn publish(&self, width: u32, height: u32, tag: u8) {
let mut frame = Frame {
width,
height,
rgb: vec![tag; (width * height * 3) as usize],
};
self.slot.publish(&mut frame);
}
fn wait_for(&self, pred: impl Fn(&[Event]) -> bool) -> bool {
self.server.wait_for(WAIT, pred)
}
fn shutdown(&mut self) -> Duration {
let started = Instant::now();
self.slot.shutdown();
if let Some(client) = self.client.take() {
client.join().unwrap();
}
started.elapsed()
}
}
impl Drop for Harness {
fn drop(&mut self) {
self.slot.shutdown();
if let Some(client) = self.client.take() {
let _ = client.join();
}
}
}
fn images(events: &[Event]) -> Vec<(Instant, u64, ImageInfo)> {
events
.iter()
.filter_map(|e| match &e.kind {
EventKind::Image(info) => Some((e.at, e.conn, info.clone())),
_ => None,
})
.collect()
}
fn count(events: &[Event], pred: impl Fn(&EventKind) -> bool) -> usize {
events.iter().filter(|e| pred(&e.kind)).count()
}
fn is_connected(k: &EventKind) -> bool {
matches!(k, EventKind::Connected { .. })
}
fn has_image(events: &[Event]) -> bool {
count(events, |k| matches!(k, EventKind::Image(_))) > 0
}
/// Scenario 1: lazy connect, `Register` first with the configured origin and priority.
#[test]
fn registers_before_sending_images() {
let h = Harness::start(MockOptions::default(), |cfg| {
cfg.origin = "test-origin".into();
cfg.priority = 123;
});
thread::sleep(300 * MS);
assert!(
h.server.events().is_empty(),
"must not connect before the first frame"
);
h.publish(32, 18, 1);
assert!(h.wait_for(has_image));
let events = h.server.events();
assert!(is_connected(&events[0].kind));
assert_eq!(
events[1].kind,
EventKind::Register {
origin: "test-origin".into(),
priority: 123
}
);
let EventKind::Image(image) = &events[2].kind else {
panic!("expected an image, got {:?}", events[2].kind);
};
assert_eq!((image.width, image.height, image.duration), (32, 18, -1));
assert!(image.size_ok());
assert_eq!(image.first_pixel, [1, 1, 1]);
assert!(wait_for_log(&h.server, "registered with priority 123"));
}
/// Scenario 2: correct sizes, rate below max-fps, the newest frame wins in a burst.
#[test]
fn paces_frames_and_sends_the_newest() {
let h = Harness::start(MockOptions::default(), |cfg| {
cfg.pacing.min_interval = 100 * MS;
cfg.pacing.keepalive = Duration::from_secs(4);
});
h.publish(64, 36, 1);
assert!(h.wait_for(has_image));
for tag in 2..=60 {
h.publish(64, 36, tag);
thread::sleep(2 * MS);
}
assert!(h.wait_for(|ev| images(ev)
.last()
.is_some_and(|(_, _, i)| i.first_pixel == [60; 3])));
thread::sleep(300 * MS);
let sent = images(&h.server.events());
assert!(sent
.iter()
.all(|(_, _, i)| i.size_ok() && (i.width, i.height) == (64, 36)));
for pair in sent.windows(2) {
let gap = pair[1].0 - pair[0].0;
assert!(gap >= 80 * MS, "images {gap:?} apart, max-fps is 10");
}
// 59 frames in ~130 ms at 10 fps: only a handful go out.
assert!(sent.len() <= 5, "{} images sent", sent.len());
assert_eq!(sent.last().unwrap().2.first_pixel, [60; 3]);
assert!(h.stats.superseded.load(Ordering::Relaxed) > 0);
}
/// Scenario 3: keepalives keep the connection open longer than the server timeout.
#[test]
fn keepalive_outlives_server_timeout() {
let opts = MockOptions {
idle_timeout: 500 * MS,
..MockOptions::default()
};
let h = Harness::start(opts, |cfg| cfg.pacing.keepalive = 150 * MS);
h.publish(8, 8, 9);
thread::sleep(2000 * MS);
let events = h.server.events();
assert_eq!(count(&events, is_connected), 1);
assert_eq!(
count(&events, |k| matches!(k, EventKind::Disconnected(_))),
0
);
assert!(
images(&events).len() >= 8,
"{} images",
images(&events).len()
);
assert!(h.stats.keepalives.load(Ordering::Relaxed) >= 7);
}
/// The mock's timeout itself works (the premise of scenario 3).
#[test]
fn mock_drops_silent_clients() {
let opts = MockOptions {
idle_timeout: 300 * MS,
..MockOptions::default()
};
let h = Harness::start(opts, |cfg| cfg.pacing.keepalive = Duration::from_secs(3));
h.publish(8, 8, 1);
assert!(h.wait_for(|ev| count(ev, |k| *k == EventKind::Disconnected(Disconnect::Timeout)) > 0));
}
/// Scenario 4: server restart → reconnect with backoff and a new `Register`.
#[test]
fn reconnects_after_server_restart() {
let mut h = Harness::start(MockOptions::default(), |_| {});
h.publish(8, 8, 1);
assert!(h.wait_for(has_image));
let addr = h.server.addr();
h.server.stop();
thread::sleep(300 * MS);
h.server = MockServer::start(addr, h.opts.clone()).unwrap();
assert!(h.wait_for(|ev| {
count(ev, |k| matches!(k, EventKind::Register { .. })) == 1 && has_image(ev)
}));
let events = h.server.events();
assert!(matches!(
events[1].kind,
EventKind::Register { priority: 150, .. }
));
assert!(h.stats.reconnects.load(Ordering::Relaxed) >= 1);
assert!(wait_for_log(&h.server, "disconnected after"));
}
/// Scenario 5: immediate close (not whitelisted) → one hint in the log, growing backoff.
#[test]
fn rejected_connection_hints_and_backs_off() {
let h = Harness::start_with(MockOptions::default(), Behavior::Reject, |_| {});
h.publish(8, 8, 1);
thread::sleep(2500 * MS);
let rejected: Vec<Instant> = h
.server
.events()
.iter()
.filter(|e| matches!(e.kind, EventKind::Rejected { .. }))
.map(|e| e.at)
.collect();
assert!(rejected.len() >= 4, "{} attempts", rejected.len());
assert!(
rejected.len() <= 12,
"{} attempts, backoff not applied",
rejected.len()
);
let gaps: Vec<Duration> = rejected.windows(2).map(|w| w[1] - w[0]).collect();
assert!(gaps[0] < 250 * MS, "first gap {:?}", gaps[0]);
assert!(
*gaps.last().unwrap() >= 350 * MS,
"last gap {:?}",
gaps.last()
);
let hints = log_lines(&h.server, "/24 subnet");
assert_eq!(hints.len(), 1, "{hints:#?}");
}
/// Scenario 6: a server that does not read → bounded memory, write timeout, reconnect.
#[test]
fn unread_socket_triggers_write_timeout() {
let mut h = Harness::start_with(MockOptions::default(), Behavior::NoRead, |cfg| {
cfg.pacing.min_interval = 5 * MS;
cfg.write_timeout = 300 * MS;
});
let rss_before = rss_kib();
let stop = Arc::new(AtomicBool::new(false));
let publisher = {
let (slot, stop) = (h.slot.clone(), stop.clone());
thread::spawn(move || {
let mut tag = 0u8;
while !stop.load(Ordering::Relaxed) {
tag = tag.wrapping_add(1);
let mut frame = Frame {
width: 640,
height: 360,
rgb: vec![tag; 640 * 360 * 3],
};
slot.publish(&mut frame);
thread::sleep(5 * MS);
}
})
};
assert!(h.wait_for(|ev| count(ev, is_connected) >= 1));
h.server.set_behavior(Behavior::Normal);
assert!(h.wait_for(|ev| count(ev, is_connected) >= 2 && has_image(ev)));
assert!(wait_for_log(&h.server, "write timed out"));
stop.store(true, Ordering::Relaxed);
publisher.join().unwrap();
h.shutdown();
let growth = rss_kib().saturating_sub(rss_before);
assert!(growth < 64 * 1024, "RSS grew by {growth} KiB");
}
fn rss_kib() -> u64 {
std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("VmRSS:"))
.and_then(|l| l.split_whitespace().nth(1)?.parse().ok())
})
.unwrap_or(0)
}
/// Scenario 7: source gone → `Clear` → FIN; the next frame opens a new connection.
#[test]
fn source_gone_clears_and_disconnects() {
let h = Harness::start(MockOptions::default(), |_| {});
h.publish(8, 8, 1);
assert!(h.wait_for(has_image));
h.slot.source_gone();
assert!(h.wait_for(|ev| count(ev, |k| *k
== EventKind::Disconnected(Disconnect::ClosedByClient))
== 1));
let events = h.server.events();
let clear = events
.iter()
.position(|e| e.kind == EventKind::Clear { priority: 150 });
let fin = events
.iter()
.position(|e| e.kind == EventKind::Disconnected(Disconnect::ClosedByClient));
assert!(clear.is_some() && clear < fin, "{events:#?}");
thread::sleep(300 * MS);
assert_eq!(
count(&h.server.events(), is_connected),
1,
"no frames, no connection"
);
h.publish(8, 8, 2);
assert!(h.wait_for(|ev| {
count(ev, is_connected) == 2
&& images(ev)
.last()
.is_some_and(|(_, conn, i)| *conn != ev[0].conn && i.first_pixel == [2; 3])
}));
}
/// Scenario 8: shutdown → `Clear` → FIN within a second.
#[test]
fn shutdown_clears_quickly() {
let mut h = Harness::start(MockOptions::default(), |_| {});
h.publish(8, 8, 1);
assert!(h.wait_for(has_image));
let took = h.shutdown();
assert!(took < Duration::from_secs(1), "shutdown took {took:?}");
assert!(h.server.wait_for(Duration::from_secs(1), |ev| {
count(ev, |k| *k == EventKind::Clear { priority: 150 }) == 1
&& count(ev, |k| {
*k == EventKind::Disconnected(Disconnect::ClosedByClient)
}) == 1
}));
}
/// Scenario 9: error replies are logged with a rate limit.
#[test]
fn error_replies_are_rate_limited() {
let h = Harness::start_with(MockOptions::default(), Behavior::ErrorReplies, |cfg| {
cfg.pacing.min_interval = 10 * MS;
cfg.pacing.keepalive = 20 * MS;
});
h.publish(8, 8, 1);
thread::sleep(1500 * MS);
assert!(h.stats.error_replies.load(Ordering::Relaxed) >= 20);
let logged = log_lines(&h.server, "replied with an error");
assert_eq!(logged.len(), 1, "{logged:#?}");
}
/// Idle clear: `Clear` once, connection kept with heartbeats, resumed by a new frame.
#[test]
fn idle_clear_and_resume() {
let opts = MockOptions {
idle_timeout: Duration::from_secs(1),
..MockOptions::default()
};
let h = Harness::start(opts, |cfg| {
cfg.pacing.keepalive = 100 * MS;
cfg.pacing.idle_clear = Some(300 * MS);
});
h.publish(8, 8, 1);
thread::sleep(1600 * MS);
let events = h.server.events();
let clear_at = events
.iter()
.position(|e| e.kind == EventKind::Clear { priority: 150 })
.expect("Clear after idle");
assert_eq!(count(&events, |k| matches!(k, EventKind::Clear { .. })), 1);
assert_eq!(
count(&events[clear_at..], |k| matches!(k, EventKind::Image(_))),
0
);
assert!(
count(&events[clear_at..], |k| matches!(
k,
EventKind::Register { .. }
)) >= 5
);
assert_eq!(
count(&events, |k| matches!(k, EventKind::Disconnected(_))),
0
);
h.publish(8, 8, 2);
assert!(h.wait_for(|ev| images(ev)
.last()
.is_some_and(|(_, _, i)| i.first_pixel == [2; 3])));
assert_eq!(count(&h.server.events(), is_connected), 1);
}