Compare commits
14
Commits
722d99675e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a650b66a
|
||
|
|
9bb2d7d690
|
||
|
|
e2b1c2983d
|
||
|
|
1aa7d802eb
|
||
|
|
885ee7fa04
|
||
|
|
5b5cb9bf38
|
||
|
|
df857c7424
|
||
|
|
de88dd5799
|
||
|
|
c50a55cf79
|
||
|
|
a2fc524619
|
||
|
|
b3993be5cd
|
||
|
|
9855d1e18a
|
||
|
|
617296afae
|
||
|
|
623fab55e5
|
Generated
+820
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Executable
+214
@@ -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))
|
||||
Executable
+148
@@ -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/^[^ ]* */ /'
|
||||
Executable
+20
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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::*;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user