diff --git a/Cargo.lock b/Cargo.lock index 366f0cb9..3d36df75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1448 @@ # 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 = "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 0.61.2", +] + +[[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 0.61.2", +] + +[[package]] +name = "appendlist" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e149dc73cd30538307e7ffa2acd3d2221148eaeed4871f246657b1c3eaa1cbd2" + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic_float" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags", + "nix", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx", + "num-traits", +] + +[[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.4", +] + +[[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 = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[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 = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "input" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9" +dependencies = [ + "bitflags", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[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 = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[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 = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "nescope" +version = "0.1.0" +dependencies = [ + "calloop", + "clap", + "glob", + "input", + "libc", + "nesprotocol", + "smithay", + "tracing", + "tracing-subscriber", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "nesprotocol" version = "0.1.0" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pixman" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea217d496c19ac0a8e502b37078e1f683d16344adee9eb247a5d57c165e1edf" +dependencies = [ + "drm-fourcc", + "paste", + "pixman-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "pixman-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0483e89e81d7915defe83c51f23f6800594d64f6f4a21253ce87fd8444ada" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[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 = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[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 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740cea6927892bc182d5bf70c8f79806c8bc9f68f2fb96e55a30be171b63af98" +dependencies = [ + "appendlist", + "atomic_float", + "bitflags", + "calloop", + "cc", + "cgmath", + "cursor-icon", + "downcast-rs", + "drm", + "drm-ffi", + "drm-fourcc", + "encoding_rs", + "errno", + "gbm", + "gl_generator", + "indexmap", + "input", + "libc", + "libloading", + "pixman", + "pkg-config", + "profiling", + "rand", + "rustix 1.1.4", + "scopeguard", + "sha2", + "smallvec", + "tempfile", + "thiserror 2.0.20", + "tracing", + "wayland-protocols", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-server", + "x11rb", + "xkbcommon", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.4", +] + +[[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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-server" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dde9c29be0f723a573977de51ee455bf3dfa03652730a74f9dd3b337e374d75" +dependencies = [ + "bitflags", + "downcast-rs", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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 = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml index f02547aa..4bd013d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ [workspace] resolver = "3" members = [ + "apps/nescope", "crates/nesprotocol", ] diff --git a/apps/nescope/.gitignore b/apps/nescope/.gitignore new file mode 100644 index 00000000..113321c2 --- /dev/null +++ b/apps/nescope/.gitignore @@ -0,0 +1,4 @@ +.idea/ +.vscode/ +.zed/ +/target/ diff --git a/apps/nescope/Cargo.toml b/apps/nescope/Cargo.toml new file mode 100644 index 00000000..dfa79a7b --- /dev/null +++ b/apps/nescope/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "nescope" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "nescope" +path = "src/main.rs" + +[dependencies] +# Smithay – Wayland compositor framework. +# "backend_drm" gives us Fourcc/Format/Modifier without pulling in a full renderer. +smithay = { version = "0.7", default-features = false, features = [ + "wayland_frontend", + "xwayland", + "backend_drm", + "desktop", + "backend_libinput", + "renderer_pixman", # needed for on_commit_buffer_handler + # Reading a dmabuf back to the CPU. nescope still does not composite or + # present anything -- this is import-and-copy only, so a client that + # renders on the GPU can be screenshotted like any other. + "renderer_gl", + "backend_egl", + "backend_gbm", +] } + +# Wayland client – connects to the host compositor to forward buffers. +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "staging", "server"] } +# Needed to generate the gamescope_swapchain protocol bindings. +wayland-scanner = "0.31" +wayland-backend = "0.3" + +# Event loop +calloop = { version = "0.14", features = ["signals"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4.6", features = ["derive", "env"] } + +libc = "0.2" +input = { version = "0.9", features = ["udev"] } +nesprotocol = { path = "../../crates/nesprotocol" } +glob = "0.3" diff --git a/apps/nescope/README.md b/apps/nescope/README.md new file mode 100644 index 00000000..226507ae --- /dev/null +++ b/apps/nescope/README.md @@ -0,0 +1,134 @@ +# nescope + +A lightweight headless Wayland compositor designed for game capture scenarios. nescope creates a complete compositor environment for games while delegating frame capture to an external Vulkan interception layer. + +## Overview + +nescope is built on top of [Smithay](https://smithay.org/), the Rust Wayland compositor framework. Unlike traditional compositors that manage window rendering and buffer scanout, nescope operates in **headless mode**—it provides the Wayland/X11 environment games expect but never allocates GBM buffers or performs rendering. + +``` +┌─────────────────┐ +│ nescope │ ← Headless Wayland compositor +├─────────────────┤ +│ Virtual Output │ ← 1920x1080 @ 60Hz (configurable) +│ XWayland │ ← X11 compatibility layer +│ DMA-BUF (v4) │ ← Required for XWayland DRI3 +│ wp_color_mgmt │ ← HDR signaling +│ gamescope WSI │ ← Vulkan bypass protocol +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Game │ ← Render target +└────────┬────────┘ + │ frame capture + ▼ +┌─────────────────┐ +│ vkcapture layer │ ← External Vulkan interception +└─────────────────┘ +``` + +## Architecture + +### Core Components + +| Module | Purpose | +| ------------------ | -------------------------------------------------------------------------------- | +| `main.rs` | CLI entry point, event loop, process management | +| `state.rs` | `NescopeState` — central compositor state, Wayland globals | +| `handlers.rs` | Smithay protocol handlers (`CompositorHandler`, `XdgShellHandler`, `XwmHandler`) | +| `input.rs` | Programmatic input injection via `calloop::channel` | +| `hdr.rs` | HDR/color management protocol handlers | +| `focus.rs` | Keyboard focus routing for X11 windows | +| `protocols/mod.rs` | Generated gamescope swapchain bindings | + +### Design Decisions + +1. **Headless Operation**: nescope never allocates GBM buffers. The DMA-BUF global exists solely so XWayland can initialize DRI3/GBM/glamor. + +2. **Frame Callbacks**: Driven by an internal calloop timer at the configured FPS, not by a real scanout. This keeps the game's render loop alive. + +3. **Input via Channel**: Input events arrive through a `calloop::channel::Sender` rather than being decoded from a proxy connection. This allows external code (streaming servers, test harnesses) to inject input. + +4. **Process Subreaper**: nescope registers as a subreaper (`PR_SET_CHILD_SUBREAPER`) so orphaned descendants (e.g., Steam launcher → game client) are reparented to it, enabling reliable cleanup. + +## Building + +```sh +cargo build --release +``` + +## Usage + +```text +nescope [OPTIONS] -- [args...] + +Options: + --width Output width [default: 1920] + --height Output height [default: 1080] + --fps Virtual refresh rate [default: 60] + --hdr Enable HDR protocols + --socket Wayland socket name [default: nescope-0] +``` + +### Environment Variables + +| Variable | Effect | +| ---------------- | -------------------------------------- | +| `DISPLAY` | XWayland display (`:N`) | +| `NESCOPE_WIDTH` | Override `--width` | +| `NESCOPE_HEIGHT` | Override `--height` | +| `NESCOPE_FPS` | Override `--fps` | +| `NESCOPE_HDR` | Enable HDR | +| `NESCOPE_SOCKET` | Override socket name | +| `RUST_LOG` | Tracing filter (e.g., `nescope=debug`) | + +### Example + +```sh +# Launch a game at 1440p with HDR +nescope --width 2560 --height 1440 --hdr -- %command% + +# 1080p with debug logging +RUST_LOG=nescope=debug nescope -- %command% +``` + +## HDR Support + +nescope supports two HDR signaling paths: + +1. **wp_color_management_v1** — Standard Wayland staging protocol used by Wine/Proton/SDL2 when requesting HDR via Vulkan color-space extensions (disabled by default). + +2. **gamescope_swapchain_factory_v2** — Valve's private protocol used by the gamescope WSI Vulkan layer. This is the primary path for Steam games using `PROTON_ENABLE_NVAPI`. + +When `--hdr` is enabled, nescope: + +- Advertises both protocol globals +- Sets `ENABLE_GAMESCOPE_WSI=1` and `GAMESCOPE_WAYLAND_DISPLAY=$(socket_path_here)` on the X11 root window +- Tracks per-surface color space for external capture layer retrieval + +## Shutdown Behavior + +- **First SIGINT/SIGTERM**: Sets an atomic flag; the event loop exits cleanly, killing all child process groups. +- **Second signal**: Falls through to OS default handler (hard kill). + +nescope waits 5 seconds after the last mapped window disappears before exiting, allowing save-game/cleanup processes to complete. + +## Dependencies + +- **smithay** — Wayland compositor framework (`wayland_frontend`, `xwayland`, `backend_drm`, `desktop`, `renderer_pixman`) +- **wayland-client** — Connects to host compositor (if needed) +- **calloop** — Event loop +- **tracing** — Structured logging +- **clap** — CLI parsing +- **x11rb** — X11 atom management + +## License + +See project repository. + +## TODO + +- [ ] Handle sending frames thru to the client +- [ ] Handle packetizing and sharding +- [ ] Handle mouse and kb input from the client diff --git a/apps/nescope/build.rs b/apps/nescope/build.rs new file mode 100644 index 00000000..719d740f --- /dev/null +++ b/apps/nescope/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=src/protocols/gamescope-swapchain.xml"); +} diff --git a/apps/nescope/src/bin/nescope-shot.rs b/apps/nescope/src/bin/nescope-shot.rs new file mode 100644 index 00000000..9a8488f1 --- /dev/null +++ b/apps/nescope/src/bin/nescope-shot.rs @@ -0,0 +1,286 @@ +//! `nescope-shot` — ask a running nescope for a picture of what it is showing. +//! +//! nescope has no display and no renderer, so "is anything actually there?" is +//! otherwise unanswerable: a black stream, a window that never mapped and a +//! client rendering on the GPU all look identical from outside. +//! +//! This is the listener side of the screenshot socket. nescope dials *out*, so +//! a shell one-liner cannot stand in for it — something has to be listening +//! before nescope starts, and the reply is a length-prefixed binary frame +//! rather than text. +//! +//! ```text +//! nescope-shot --socket /tmp/nestri-screenshot.sock --watch --out shot.ppm +//! nescope --screenshot-ipc /tmp/nestri-screenshot.sock -- +//! ``` +//! +//! Start this first. It waits for nescope to connect, then asks — once, or on +//! an interval with `--watch`. +//! +//! # Watching, and why it is the useful mode +//! +//! A client can take a long time to put anything on screen. Steam unpacks, +//! self-updates, verifies and starts a browser process before it shows a login +//! window at all, so a single capture almost always lands on nothing and says +//! so. Watching turns that into a story: nothing, then a window with no buffer, +//! then pixels. +//! +//! # Reading the answer +//! +//! The status is the whole diagnosis: +//! +//! - **ok** — pixels arrived; whatever is running presents an shm surface +//! - **no-surface** — no window is mapped at all +//! - **no-buffer** — windows exist but none has drawn anything readable yet. +//! Normal while something is starting; suspicious if it persists +//! - **unreadable** — a surface exists but could not be read by any route: +//! not as shm, and importing it from the GPU failed as well. This is a bug +//! rather than a limitation, and nescope's own log carries the reason +//! +//! PPM is written rather than PNG so this tool needs no image dependency. Any +//! viewer opens it, and `magick shot.ppm shot.png` converts it. + +// Included by path rather than duplicated, so the tool and the compositor +// cannot drift apart on the wire format. Most of the module is the +// compositor's half and unused here, which is expected rather than a problem. +#[path = "../screenshot_wire.rs"] +#[allow(dead_code)] +mod screenshot_wire; + +use std::io::{Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::time::{Duration, Instant}; + +use screenshot_wire::{REQUEST_CAPTURE, Status}; + +fn status_from(byte: u8) -> Option { + match byte { + 0 => Some(Status::Ok), + 1 => Some(Status::NoSurface), + 2 => Some(Status::Unreadable), + 3 => Some(Status::NoBuffer), + _ => None, + } +} + +/// What to tell somebody staring at the result. +fn explain(status: Status) -> &'static str { + match status { + Status::Ok => "a surface was read", + Status::NoSurface => "no window is mapped at all", + Status::NoBuffer => { + "windows exist but none has drawn anything readable yet — normal while something \ + is starting, suspicious if it persists" + } + Status::Unreadable => { + "a surface exists but could not be read by any route — not as shm, and importing \ + it from the GPU failed too. nescope's own log says why" + } + } +} + +struct Args { + socket: String, + out: Option, + watch: bool, + interval: Duration, + keep: usize, +} + +fn parse_args() -> Args { + let mut args = Args { + socket: "/tmp/nestri-screenshot.sock".to_string(), + out: None, + watch: false, + interval: Duration::from_millis(1000), + keep: 5, + }; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--socket" | "-s" => args.socket = it.next().unwrap_or(args.socket), + "--out" | "-o" => args.out = it.next(), + "--watch" | "-w" => args.watch = true, + "--interval" => { + args.interval = it + .next() + .and_then(|v| v.parse().ok()) + .map(Duration::from_millis) + .unwrap_or(args.interval) + } + "--keep" => args.keep = it.next().and_then(|v| v.parse().ok()).unwrap_or(args.keep), + "--help" | "-h" => { + println!("nescope-shot [OPTIONS]"); + println!(); + println!(" -s, --socket PATH socket to listen on"); + println!(" -o, --out FILE.ppm write the capture here"); + println!(" -w, --watch keep capturing until Ctrl-C"); + println!(" --interval MS how often to capture when watching (1000)"); + println!(" --keep N rolling files to keep when watching (5)"); + println!(); + println!("Start this before nescope; nescope connects to it."); + std::process::exit(0); + } + other => { + eprintln!("unknown argument {other:?}; try --help"); + std::process::exit(2); + } + } + } + args +} + +/// `shot.ppm` + 2 -> `shot-2.ppm`, so the files sort next to each other. +fn numbered(path: &str, n: usize) -> String { + match path.rsplit_once('.') { + Some((stem, ext)) => format!("{stem}-{n}.{ext}"), + None => format!("{path}-{n}"), + } +} + +fn write_ppm(path: &str, width: u32, height: u32, rgba: &[u8]) -> std::io::Result<()> { + let mut ppm = Vec::with_capacity(rgba.len() / 4 * 3 + 32); + ppm.extend_from_slice(format!("P6\n{width} {height}\n255\n").as_bytes()); + for px in rgba.chunks_exact(4) { + ppm.extend_from_slice(&px[..3]); + } + std::fs::write(path, ppm) +} + +/// One request and its reply. `None` means the connection ended. +fn capture(stream: &mut UnixStream) -> std::io::Result)>> { + if stream.write_all(&[REQUEST_CAPTURE]).is_err() { + return Ok(None); + } + let mut header = [0u8; 9]; + if stream.read_exact(&mut header).is_err() { + return Ok(None); + } + let Some(status) = status_from(header[0]) else { + eprintln!("unknown status byte {:#x} — version mismatch?", header[0]); + std::process::exit(1); + }; + let width = u32::from_le_bytes(header[1..5].try_into().unwrap()); + let height = u32::from_le_bytes(header[5..9].try_into().unwrap()); + let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4]; + if !rgba.is_empty() { + stream.read_exact(&mut rgba)?; + } + Ok(Some((status, width, height, rgba))) +} + +fn main() -> std::io::Result<()> { + let args = parse_args(); + + // A stale socket file from a previous run makes bind fail with EADDRINUSE, + // which reads as "something is already listening" when nothing is. + let _ = std::fs::remove_file(&args.socket); + let listener = UnixListener::bind(&args.socket)?; + eprintln!( + "listening on {} — start nescope with --screenshot-ipc {}", + args.socket, args.socket + ); + + let (mut stream, _) = listener.accept()?; + eprintln!("nescope connected; requesting a capture"); + + let started = Instant::now(); + let mut frame = 0usize; + let mut last_report: Option<(Status, u32, u32)> = None; + // Something unchanging still has to say it is alive. Watching a client + // that never maps a window otherwise prints one line and then looks hung, + // which is indistinguishable from the tool having died -- and sends you + // looking at the wrong thing. + let mut last_line = Instant::now(); + let heartbeat = Duration::from_secs(15); + + loop { + let Some((status, width, height, rgba)) = capture(&mut stream)? else { + eprintln!("nescope disconnected"); + return Ok(()); + }; + + // In watch mode only changes are worth a line — a status printed once a + // second for two minutes buries the moment it changed, which is the + // only thing being watched for. + let now = (status, width, height); + let changed = last_report != Some(now); + let due = last_line.elapsed() >= heartbeat; + if changed || due || !args.watch { + let at = started.elapsed().as_secs_f32(); + let still = if changed { "" } else { " (still)" }; + println!("[{at:6.1}s] {status:?}{still} — {}", explain(status)); + if status == Status::Ok { + println!(" size: {width}x{height}"); + } + last_report = Some(now); + last_line = Instant::now(); + } + + if status == Status::Ok && !rgba.is_empty() { + if let Some(out) = &args.out { + let path = if args.watch { + numbered(out, frame % args.keep.max(1)) + } else { + out.clone() + }; + write_ppm(&path, width, height, &rgba)?; + if changed || !args.watch { + println!(" wrote {path}"); + } + } else if changed || !args.watch { + // A fully uniform image is the signature of "mapped but never + // drew anything", which looks identical to a working capture + // until somebody checks. + let first = &rgba[..4.min(rgba.len())]; + let uniform = rgba.chunks_exact(4).all(|px| px == first); + println!( + " first pixel RGBA: {first:02x?}{}", + if uniform { + " (every pixel identical — the window is blank)" + } else { + "" + } + ); + } + frame += 1; + } + + if !args.watch { + // Non-zero for anything but a real capture, so a script can ask + // whether the path works at all. + std::process::exit(if status == Status::Ok { 0 } else { 1 }); + } + std::thread::sleep(args.interval); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rolling_names_sort_beside_the_original() { + assert_eq!(numbered("shot.ppm", 3), "shot-3.ppm"); + assert_eq!(numbered("/tmp/a/shot.ppm", 0), "/tmp/a/shot-0.ppm"); + // No extension is not an error; the number still has to land somewhere. + assert_eq!(numbered("shot", 2), "shot-2"); + } + + #[test] + fn every_status_byte_maps_back() { + // The tool and the compositor share this file, so a status added on one + // side without the other would otherwise surface as "version mismatch" + // against a peer of exactly the same version. + for (byte, expected) in [ + (0, Status::Ok), + (1, Status::NoSurface), + (2, Status::Unreadable), + (3, Status::NoBuffer), + ] { + assert_eq!(status_from(byte), Some(expected), "byte {byte}"); + assert_eq!(expected as u8, byte, "{expected:?} encodes as {byte}"); + } + assert_eq!(status_from(9), None); + } +} diff --git a/apps/nescope/src/focus.rs b/apps/nescope/src/focus.rs new file mode 100644 index 00000000..d729fd08 --- /dev/null +++ b/apps/nescope/src/focus.rs @@ -0,0 +1,298 @@ +//! Keyboard and pointer focus target types for the compositor. +//! +//! Smithay's `SeatHandler::KeyboardFocus` / `PointerFocus` types determine how +//! keyboard and pointer events are dispatched. For X11 windows (via XWayland) +//! focus must go through the `X11Surface` implementation — this calls +//! `XSetInputFocus` (keyboard) or translates Wayland pointer events back to +//! X11 pointer events, which X11 clients require. +//! +//! The `ProxiedX11` variant handles the narrow window between when a game's +//! X11 window is mapped and when its `wl_surface` becomes available. We +//! route events through any other XWayland surface so that `wl_keyboard` / +//! `wl_pointer` delivery still reaches the XWayland process; XWayland then +//! forwards the events to whichever X11 window holds X11 focus. + +use std::borrow::Cow; + +use smithay::backend::input::KeyState; +use smithay::desktop::{Window, WindowSurface}; +use smithay::input::Seat; +use smithay::input::keyboard::{KeyboardTarget, KeysymHandle, ModifiersState}; +use smithay::input::pointer::{ + AxisFrame, ButtonEvent, MotionEvent, PointerTarget, RelativeMotionEvent, +}; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::utils::{IsAlive, Serial}; +use smithay::wayland::seat::WaylandFocus; + +use crate::state::NescopeState; + +/// Focus target for keyboard and pointer input. +/// +/// Wraps a [`Window`] and dispatches keyboard/pointer events to the correct +/// underlying surface type (Wayland toplevel or X11 surface). +#[derive(Debug, Clone, PartialEq)] +pub enum KeyboardFocusTarget { + /// A normal Wayland or X11 window with a live `wl_surface`. + Window(Window), + /// An X11 window whose `wl_surface` is not yet available (e.g. the + /// gamescope WSI bypass creates a raw VkSurface before XWayland gets a + /// chance to create a `wl_surface`). Events are proxied through + /// a different XWayland surface so that delivery still reaches the + /// XWayland client. + ProxiedX11 { + window: Window, + proxy_surface: WlSurface, + }, +} + +impl IsAlive for KeyboardFocusTarget { + #[inline] + fn alive(&self) -> bool { + match self { + Self::Window(w) => w.alive(), + Self::ProxiedX11 { window, .. } => window.alive(), + } + } +} + +impl WaylandFocus for KeyboardFocusTarget { + #[inline] + fn wl_surface(&self) -> Option> { + match self { + Self::Window(w) => w.wl_surface(), + Self::ProxiedX11 { proxy_surface, .. } => Some(Cow::Borrowed(proxy_surface)), + } + } +} + +impl From for KeyboardFocusTarget { + #[inline] + fn from(w: Window) -> Self { + Self::Window(w) + } +} + +impl KeyboardTarget for KeyboardFocusTarget { + fn enter( + &self, + seat: &Seat, + data: &mut NescopeState, + keys: Vec>, + serial: Serial, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + KeyboardTarget::enter(w.wl_surface(), seat, data, keys, serial) + } + WindowSurface::X11(s) => KeyboardTarget::enter(s, seat, data, keys, serial), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + KeyboardTarget::enter(proxy_surface, seat, data, keys, serial) + } + } + } + + fn leave(&self, seat: &Seat, data: &mut NescopeState, serial: Serial) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + KeyboardTarget::leave(w.wl_surface(), seat, data, serial) + } + WindowSurface::X11(s) => KeyboardTarget::leave(s, seat, data, serial), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + KeyboardTarget::leave(proxy_surface, seat, data, serial) + } + } + } + + fn key( + &self, + seat: &Seat, + data: &mut NescopeState, + key: KeysymHandle<'_>, + state: KeyState, + serial: Serial, + time: u32, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + KeyboardTarget::key(w.wl_surface(), seat, data, key, state, serial, time) + } + WindowSurface::X11(s) => { + KeyboardTarget::key(s, seat, data, key, state, serial, time) + } + }, + Self::ProxiedX11 { proxy_surface, .. } => { + KeyboardTarget::key(proxy_surface, seat, data, key, state, serial, time) + } + } + } + + fn modifiers( + &self, + seat: &Seat, + data: &mut NescopeState, + modifiers: ModifiersState, + serial: Serial, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + KeyboardTarget::modifiers(w.wl_surface(), seat, data, modifiers, serial) + } + WindowSurface::X11(s) => { + KeyboardTarget::modifiers(s, seat, data, modifiers, serial) + } + }, + Self::ProxiedX11 { proxy_surface, .. } => { + KeyboardTarget::modifiers(proxy_surface, seat, data, modifiers, serial) + } + } + } +} + +impl PointerTarget for KeyboardFocusTarget { + fn enter( + &self, + seat: &Seat, + data: &mut NescopeState, + event: &MotionEvent, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::enter(w.wl_surface(), seat, data, event) + } + WindowSurface::X11(s) => PointerTarget::enter(s, seat, data, event), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::enter(proxy_surface, seat, data, event) + } + } + } + + fn motion( + &self, + seat: &Seat, + data: &mut NescopeState, + event: &MotionEvent, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::motion(w.wl_surface(), seat, data, event) + } + WindowSurface::X11(s) => PointerTarget::motion(s, seat, data, event), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::motion(proxy_surface, seat, data, event) + } + } + } + + fn relative_motion( + &self, + seat: &Seat, + data: &mut NescopeState, + event: &RelativeMotionEvent, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::relative_motion(w.wl_surface(), seat, data, event) + } + WindowSurface::X11(s) => { + PointerTarget::relative_motion(s, seat, data, event) + } + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::relative_motion(proxy_surface, seat, data, event) + } + } + } + + fn button( + &self, + seat: &Seat, + data: &mut NescopeState, + event: &ButtonEvent, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::button(w.wl_surface(), seat, data, event) + } + WindowSurface::X11(s) => PointerTarget::button(s, seat, data, event), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::button(proxy_surface, seat, data, event) + } + } + } + + fn axis( + &self, + seat: &Seat, + data: &mut NescopeState, + frame: AxisFrame, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::axis(w.wl_surface(), seat, data, frame) + } + WindowSurface::X11(s) => PointerTarget::axis(s, seat, data, frame), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::axis(proxy_surface, seat, data, frame) + } + } + } + + fn leave( + &self, + seat: &Seat, + data: &mut NescopeState, + serial: Serial, + time: u32, + ) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::leave(w.wl_surface(), seat, data, serial, time) + } + WindowSurface::X11(s) => PointerTarget::leave(s, seat, data, serial, time), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::leave(proxy_surface, seat, data, serial, time) + } + } + } + + fn gesture_swipe_begin(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureSwipeBeginEvent) {} + fn gesture_swipe_update(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureSwipeUpdateEvent) {} + fn gesture_swipe_end(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureSwipeEndEvent) {} + fn gesture_pinch_begin(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GesturePinchBeginEvent) {} + fn gesture_pinch_update(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GesturePinchUpdateEvent) {} + fn gesture_pinch_end(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GesturePinchEndEvent) {} + fn gesture_hold_begin(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureHoldBeginEvent) {} + fn gesture_hold_end(&self, _seat: &Seat, _data: &mut NescopeState, _event: &smithay::input::pointer::GestureHoldEndEvent) {} + + fn frame(&self, seat: &Seat, data: &mut NescopeState) { + match self { + Self::Window(w) => match w.underlying_surface() { + WindowSurface::Wayland(w) => { + PointerTarget::frame(w.wl_surface(), seat, data) + } + WindowSurface::X11(s) => PointerTarget::frame(s, seat, data), + }, + Self::ProxiedX11 { proxy_surface, .. } => { + PointerTarget::frame(proxy_surface, seat, data) + } + } + } +} diff --git a/apps/nescope/src/gpu_readback.rs b/apps/nescope/src/gpu_readback.rs new file mode 100644 index 00000000..14bfbb1b --- /dev/null +++ b/apps/nescope/src/gpu_readback.rs @@ -0,0 +1,182 @@ +//! Copying a GPU buffer back to the CPU, so a screenshot is not limited to +//! clients that render in software. +//! +//! nescope hands dmabuf straight through to `nescapture` and never looks at it, +//! which is right for a game and useless for everything else. XWayland with +//! glamor — which is every XWayland smithay spawns, since it passes no +//! `-noglamor` — always presents dmabuf, so a Steam client under XWayland was +//! unreadable by construction. +//! +//! This imports such a buffer as a texture and copies it back. It is the only +//! GPU work nescope does, and it is deliberately not compositing: one buffer +//! in, one image out, no output, no swapchain, no presentation. +//! +//! # Why the renderer is created lazily and kept +//! +//! Building an EGL context costs enough to notice, and a login screen is polled +//! every second or so. Building one per capture would spend most of the poll +//! interval on setup. It is created on the first capture that needs it, so a +//! box that only ever runs games never pays for it at all. +//! +//! Thread-local rather than in `NescopeState`: the compositor is single +//! threaded, `GlesRenderer` is not `Send`, and this keeps a debugging aid out +//! of the state every other part of the compositor passes around. + +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; + +use smithay::backend::allocator::dmabuf::Dmabuf; +use smithay::backend::egl::{EGLContext, EGLDisplay}; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::allocator::Buffer; +use smithay::backend::renderer::{ExportMem, ImportDma}; +use smithay::utils::{Point, Rectangle, Size}; + +thread_local! { + /// `None` until the first attempt; the inner `None` means the attempt + /// failed and should not be retried on every poll. + static RENDERER: RefCell>> = const { RefCell::new(None) }; + /// Whether the reason for a failed read has been said out loud yet. + static WARNED: Cell = const { Cell::new(false) }; + /// The render device to import on, from `--render-device`. + static RENDER_DEVICE: RefCell> = const { RefCell::new(None) }; +} + +/// Tell the readback which GPU to use, once, at startup. +/// +/// The device cannot be taken from the buffer: a dmabuf handed over by a +/// client carries no `DrmNode` — smithay only fills that in when the +/// compositor put it there, and nescope never does. The operator already names +/// the GPU for the game's sake, so the same answer serves here. +pub fn set_render_device(path: Option) { + RENDER_DEVICE.with(|d| *d.borrow_mut() = path.map(PathBuf::from)); +} + +/// The configured render node, or the first one on the system. +/// +/// Scanning is a fallback rather than the plan: on a single-GPU box it is +/// right, and on a multi-GPU box picking the wrong one fails at import with a +/// message naming the device, which is better than refusing to try. +fn render_device() -> Result { + if let Some(path) = RENDER_DEVICE.with(|d| d.borrow().clone()) { + return Ok(path); + } + let mut nodes: Vec = glob::glob("/dev/dri/renderD*") + .map_err(|e| format!("could not scan /dev/dri: {e}"))? + .filter_map(Result::ok) + .collect(); + nodes.sort(); + nodes.into_iter().next().ok_or_else(|| { + "no render node found in /dev/dri; pass --render-device".to_string() + }) +} + +/// Why a read did not happen. +enum ReadError { + /// The GPU path could not be set up at all. Reported once, at setup. + Unavailable, + /// Setup worked and this particular buffer could not be read. + Failed(String), +} + +/// Build a renderer on the configured render device. +fn build_renderer() -> Result { + let path = render_device()?; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .map_err(|e| format!("could not open {}: {e}", path.display()))?; + let gbm = smithay::backend::allocator::gbm::GbmDevice::new(file) + .map_err(|e| format!("could not create a GBM device on {}: {e}", path.display()))?; + + // SAFETY: the GBM device stays alive for the life of the display, which + // lives in the thread-local below for the life of the process. + let display = unsafe { EGLDisplay::new(gbm) } + .map_err(|e| format!("could not open an EGL display: {e}"))?; + let context = + EGLContext::new(&display).map_err(|e| format!("could not create an EGL context: {e}"))?; + // SAFETY: the context is current only on this thread, and the renderer is + // thread-local so it can never be used from another. + unsafe { GlesRenderer::new(context) }.map_err(|e| format!("could not create a renderer: {e}")) +} + +/// Copy a dmabuf back to CPU memory as RGBA8. +/// +/// Returns `Err` with something worth printing when the GPU path is not +/// available at all — that is a configuration answer, not a transient one, so +/// the caller should say it rather than retry silently. +fn read_dmabuf(dmabuf: &Dmabuf) -> Result<(u32, u32, Vec), ReadError> { + RENDERER.with(|cell| { + let mut slot = cell.borrow_mut(); + if slot.is_none() { + match build_renderer() { + Ok(renderer) => *slot = Some(Some(renderer)), + Err(e) => { + // Remembered as a failure so the next poll does not repeat + // the whole EGL setup just to fail the same way. + tracing::warn!("GPU readback unavailable: {e}"); + *slot = Some(None); + } + } + } + let Some(renderer) = slot.as_mut().and_then(|r| r.as_mut()) else { + // Already explained at setup; saying it again per capture would + // report one problem twice in two different words. + return Err(ReadError::Unavailable); + }; + + let size = dmabuf.size(); + let texture = renderer + .import_dmabuf(dmabuf, None) + .map_err(|e| ReadError::Failed(format!("could not import the buffer: {e}")))?; + + let region = Rectangle::new(Point::from((0, 0)), Size::from((size.w, size.h))); + let mapping = renderer + .copy_texture( + &texture, + region, + smithay::backend::allocator::Fourcc::Abgr8888, + ) + .map_err(|e| ReadError::Failed(format!("could not copy the texture back: {e}")))?; + let bytes = renderer + .map_texture(&mapping) + .map_err(|e| ReadError::Failed(format!("could not map the copied texture: {e}")))?; + + Ok((size.w as u32, size.h as u32, bytes.to_vec())) + }) +} + +/// Read a `wl_buffer` that is backed by a dmabuf. +/// +/// `None` when the buffer is not a dmabuf at all, or when the GPU path is +/// unavailable — the caller has already tried shm, so there is nothing left to +/// distinguish and a failure here means "not readable by any route". +pub fn from_wl_buffer( + buffer: &smithay::reexports::wayland_server::protocol::wl_buffer::WlBuffer, +) -> Option { + let dmabuf = smithay::wayland::dmabuf::get_dmabuf(buffer).ok()?; + match read_dmabuf(dmabuf) { + Ok((width, height, rgba)) => Some(crate::screenshot_wire::Capture { + width, + height, + rgba, + }), + // Setup already said why, once, and repeating it per capture would + // report one problem twice in two different words. + Err(ReadError::Unavailable) => None, + Err(ReadError::Failed(e)) => { + // Loudly the first time and quietly afterwards. A capture polled + // every few hundred milliseconds would otherwise either bury the + // log or -- at debug level, which is off by default -- never say + // anything at all, leaving `Unreadable` with no explanation + // anywhere. + if !WARNED.with(|w| w.replace(true)) { + tracing::warn!("GPU readback failed: {e}"); + } else { + tracing::debug!("GPU readback failed: {e}"); + } + None + } + } +} diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs new file mode 100644 index 00000000..0a00bd1b --- /dev/null +++ b/apps/nescope/src/handlers.rs @@ -0,0 +1,581 @@ +//! Smithay protocol handler implementations for nescope. +//! +//! - `CompositorHandler` on `NescopeState` +//! - `XdgShellHandler` on `NescopeState` +//! - `SeatHandler` on `NescopeState` +//! - `XwmHandler` on `CalloopData` (calloop dispatch type) + stub on `NescopeState` +//! - All delegate macros + +use smithay::backend::allocator::dmabuf::Dmabuf; +use smithay::desktop::Window; +use smithay::input::pointer::{CursorImageStatus, PointerHandle}; +use smithay::input::{Seat, SeatHandler, SeatState}; +use smithay::output::Output; +use smithay::reexports::wayland_server::protocol::wl_buffer; +use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; +use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::utils::{Logical, Point, Rectangle, Serial}; +use smithay::wayland::buffer::BufferHandler; +use smithay::wayland::compositor::{ + BufferAssignment, CompositorClientState, CompositorHandler, CompositorState, SurfaceAttributes, + is_sync_subsurface, with_states, +}; +use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier}; +use smithay::wayland::output::OutputHandler; +use smithay::wayland::pointer_constraints::{PointerConstraintsHandler, with_pointer_constraint}; +use smithay::wayland::seat::WaylandFocus; +use smithay::wayland::selection::SelectionHandler; +use smithay::wayland::selection::data_device::{ + ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler, +}; +use smithay::wayland::shell::xdg::{ + PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, +}; +use smithay::wayland::shm::{ShmHandler, ShmState, with_buffer_contents}; +use smithay::wayland::xwayland_shell::{XWaylandShellHandler, XWaylandShellState}; +use smithay::xwayland::xwm::{Reorder, ResizeEdge, WmWindowProperty, XwmId}; +use smithay::xwayland::{X11Surface, X11Wm, XWaylandClientData, XwmHandler}; + +use crate::focus::KeyboardFocusTarget; +use crate::state::{CalloopData, ClientState, NescopeState}; + +// =========================================================================== +// BufferHandler / ShmHandler +// =========================================================================== + +impl BufferHandler for NescopeState { + fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} +} + +impl ShmHandler for NescopeState { + fn shm_state(&self) -> &ShmState { + &self.shm_state + } +} + +// =========================================================================== +// CompositorHandler +// =========================================================================== + +impl CompositorHandler for NescopeState { + fn compositor_state(&mut self) -> &mut CompositorState { + &mut self.compositor_state + } + + fn client_compositor_state<'a>( + &self, + client: &'a smithay::reexports::wayland_server::Client, + ) -> &'a CompositorClientState { + if let Some(state) = client.get_data::() { + return &state.compositor_state; + } + if let Some(state) = client.get_data::() { + return &state.compositor_state; + } + panic!("Client has neither ClientState nor XWaylandClientData"); + } + + fn commit(&mut self, surface: &WlSurface) { + self.hdr.commit(surface); + + if is_sync_subsurface(surface) { + return; + } + + // Hold a reference to the committed buffer — delays wl_buffer.release + // until the next frame tick, starving the swapchain of images in FIFO mode. + smithay::wayland::compositor::with_states(surface, |states| { + let mut cached = states + .cached_state + .get::(); + let attrs = cached.current(); + if let Some(smithay::wayland::compositor::BufferAssignment::NewBuffer(ref wl_buf)) = + attrs.buffer + { + self.held_buffer = Some(wl_buf.clone()); + } + }); + + // Notify the window of the commit so it can refresh its cached state. + if let Some(window) = self + .space + .elements() + .find(|w| { + w.toplevel() + .map(|t| t.wl_surface() == surface) + .unwrap_or(false) + }) + .cloned() + { + window.on_commit(); + } + } + + fn destroyed(&mut self, surface: &WlSurface) { + self.hdr.surface_destroyed(surface); + self.vulkan_surfaces.remove(surface); + } +} + +// =========================================================================== +// DmabufHandler — accept everything; nescope never imports the buffers itself +// =========================================================================== + +impl DmabufHandler for NescopeState { + fn dmabuf_state(&mut self) -> &mut DmabufState { + &mut self.dmabuf_state + } + + fn dmabuf_imported( + &mut self, + _global: &DmabufGlobal, + _dmabuf: Dmabuf, + notifier: ImportNotifier, + ) { + // Accept unconditionally — libhudless reads buffers + // directly from the game's Vulkan queue; nescope doesn't need to. + let _ = notifier.successful::(); + } +} + +// =========================================================================== +// XDG shell +// =========================================================================== + +impl XdgShellHandler for NescopeState { + fn xdg_shell_state(&mut self) -> &mut XdgShellState { + &mut self.xdg_shell_state + } + + fn new_toplevel(&mut self, surface: ToplevelSurface) { + // Tell the toplevel to fill the entire virtual output. + surface.with_pending_state(|state| { + state.size = Some((self.width as i32, self.height as i32).into()); + }); + surface.send_configure(); + + let window = Window::new_wayland_window(surface); + self.space.map_element(window.clone(), (0, 0), false); + self.set_keyboard_focus_to_window(&window); + tracing::debug!("New XDG toplevel"); + } + + fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + // Remove the dead window from the space. + let elem = self + .space + .elements() + .find(|w| { + w.toplevel() + .map(|t| t.wl_surface() == surface.wl_surface()) + .unwrap_or(false) + }) + .cloned(); + + if let Some(elem) = elem { + self.space.unmap_elem(&elem); + tracing::debug!("Unmapped destroyed XDG toplevel"); + } + + // Re-focus the next window (if any). + self.determine_and_apply_focus(); + } + + fn new_popup(&mut self, _: PopupSurface, _: PositionerState) {} + fn grab(&mut self, _: PopupSurface, _: WlSeat, _: Serial) {} + fn reposition_request(&mut self, _: PopupSurface, _: PositionerState, _: u32) {} +} + +// =========================================================================== +// SeatHandler +// =========================================================================== + +impl SeatHandler for NescopeState { + type KeyboardFocus = KeyboardFocusTarget; + type PointerFocus = KeyboardFocusTarget; + type TouchFocus = WlSurface; + + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + + fn focus_changed(&mut self, _seat: &Seat, focused: Option<&KeyboardFocusTarget>) { + let kind = focused + .map(|f| match f { + KeyboardFocusTarget::Window(_) => "wayland window", + KeyboardFocusTarget::ProxiedX11 { .. } => "proxied x11", + }) + .unwrap_or("none"); + tracing::debug!("keyboard focus changed to {kind}"); + } + + fn cursor_image(&mut self, _seat: &Seat, image: CursorImageStatus) { + let kind = match &image { + CursorImageStatus::Hidden => "hidden", + CursorImageStatus::Named(n) => { + tracing::debug!("cursor: named({n})"); + "named" + } + CursorImageStatus::Surface(_) => { + tracing::debug!("cursor: custom surface"); + "surface" + } + }; + tracing::trace!("cursor_image set to {kind}"); + + // Try to capture custom cursor pixels from SHM buffer + let captured = if let CursorImageStatus::Surface(ref surface) = image { + let buffer_opt: Option = with_states(surface, |data| { + let mut attrs = data.cached_state.get::(); + match attrs.current().buffer { + Some(BufferAssignment::NewBuffer(ref buf)) => Some(buf.clone()), + _ => None, + } + }); + buffer_opt.and_then(|buffer| { + match with_buffer_contents(&buffer, |ptr, _len, data| { + let offset = data.offset as isize; + let src = unsafe { ptr.offset(offset) }; + let pixel_bytes = (data.width * data.height * 4) as usize; + let stride = data.stride as usize; + let is_xrgb = data.format + == smithay::reexports::wayland_server::protocol::wl_shm::Format::Xrgb8888; + let mut rgba = vec![0u8; pixel_bytes]; + for row in 0..data.height as usize { + let src_start = row * stride; + let dst_row = (data.height as usize - 1 - row) * data.width as usize * 4; + unsafe { + std::ptr::copy_nonoverlapping( + src.add(src_start), + rgba.as_mut_ptr().add(dst_row), + data.width as usize * 4, + ); + } + } + if is_xrgb { + for px in rgba.chunks_exact_mut(4) { + px[3] = 255; + } + } + let preview = if rgba.len() >= 16 { + &rgba[..16] + } else { + &rgba[..] + }; + tracing::debug!( + "cursor: captured {}x{} xrgb={} preview={:02x?}", + data.width, + data.height, + is_xrgb, + preview, + ); + nesprotocol::input::CursorImageData { + x: 0.0, + y: 0.0, + width: data.width as u16, + height: data.height as u16, + hotspot_x: 0, + hotspot_y: 0, + rgba, + } + }) { + Ok(d) => Some(d), + Err(_) => { + tracing::debug!("cursor: surface buffer is not SHM, using box fallback"); + None + } + } + }) + } else { + None + }; + + self.cursor_image_data = captured; + self.cursor_image_sent = false; // allow re-send for new cursor surface + if self.cursor_image_data.is_some() { + tracing::debug!( + "cursor: captured custom image {}x{} (BGRA)", + self.cursor_image_data.as_ref().unwrap().width, + self.cursor_image_data.as_ref().unwrap().height, + ); + } + + self.cursor_status = image; + } + + fn led_state_changed(&mut self, _: &Seat, _: smithay::input::keyboard::LedState) {} +} + +// =========================================================================== +// Selection / data device +// =========================================================================== + +impl SelectionHandler for NescopeState { + type SelectionUserData = (); +} +impl DataDeviceHandler for NescopeState { + fn data_device_state(&self) -> &DataDeviceState { + &self.data_device_state + } +} +impl ClientDndGrabHandler for NescopeState {} +impl ServerDndGrabHandler for NescopeState {} + +// =========================================================================== +// Output +// =========================================================================== + +impl OutputHandler for NescopeState { + fn output_bound(&mut self, _output: Output, _wl_output: WlOutput) {} +} + +// =========================================================================== +// Pointer constraints +// =========================================================================== + +impl PointerConstraintsHandler for NescopeState { + fn new_constraint(&mut self, surface: &WlSurface, pointer: &PointerHandle) { + if let Some(focus) = pointer.current_focus() { + if focus.wl_surface().as_deref() == Some(surface) { + with_pointer_constraint(surface, pointer, |c| { + if let Some(c) = c { + c.activate(); + } + }); + } + } + } + + fn cursor_position_hint( + &mut self, + surface: &WlSurface, + pointer: &PointerHandle, + location: Point, + ) { + if with_pointer_constraint(surface, pointer, |c| c.is_some_and(|c| c.is_active())) { + use smithay::wayland::seat::WaylandFocus; + let origin = self + .space + .elements() + .find_map(|w| (w.wl_surface().as_deref() == Some(surface)).then(|| w.geometry())) + .unwrap_or_default() + .loc + .to_f64(); + pointer.set_location(origin + location); + } + } +} + +// =========================================================================== +// XWayland shell +// =========================================================================== + +impl XWaylandShellHandler for NescopeState { + fn xwayland_shell_state(&mut self) -> &mut XWaylandShellState { + &mut self.xwayland_shell_state + } + + fn surface_associated(&mut self, _xwm: XwmId, _wl_surface: WlSurface, surface: X11Surface) { + tracing::debug!(window_id = surface.window_id(), "X11 surface associated"); + self.determine_and_apply_focus(); + } +} + +impl XWaylandShellHandler for CalloopData { + fn xwayland_shell_state(&mut self) -> &mut XWaylandShellState { + &mut self.state.xwayland_shell_state + } + + fn surface_associated(&mut self, xwm: XwmId, wl_surface: WlSurface, surface: X11Surface) { + XWaylandShellHandler::surface_associated(&mut self.state, xwm, wl_surface, surface); + } +} + +// =========================================================================== +// XwmHandler — real implementation on CalloopData +// =========================================================================== + +impl XwmHandler for CalloopData { + fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm { + self.state.xwm.as_mut().expect("XWM not initialized") + } + + fn new_window(&mut self, _xwm: XwmId, window: X11Surface) { + tracing::debug!( + window_id = window.window_id(), + title = ?window.title(), + "New X11 window" + ); + } + + fn new_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) { + tracing::debug!( + window_id = window.window_id(), + "New override-redirect window" + ); + } + + fn map_window_request(&mut self, _xwm: XwmId, window: X11Surface) { + tracing::debug!( + title = ?window.title(), + class = ?window.class(), + "X11 map_window_request" + ); + let geo = Rectangle::new( + (0, 0).into(), + (self.state.width as i32, self.state.height as i32).into(), + ); + if let Err(e) = window.configure(geo) { + tracing::warn!("configure failed: {e}"); + } + if let Err(e) = window.set_mapped(true) { + tracing::error!("set_mapped failed: {e}"); + return; + } + let win = Window::new_x11_window(window); + self.state.space.map_element(win, (0, 0), true); + self.state.determine_and_apply_focus(); + } + + fn mapped_override_redirect_window(&mut self, _xwm: XwmId, window: X11Surface) { + let location = window.geometry().loc; + let win = Window::new_x11_window(window); + self.state.space.map_element(win, location, false); + } + + fn unmapped_window(&mut self, _xwm: XwmId, window: X11Surface) { + let was_focused = Some(window.window_id()) == self.state.focused_x11_window; + + let elem = self + .state + .space + .elements() + .find(|e| e.x11_surface().map(|x| x == &window).unwrap_or(false)) + .cloned(); + + if let Some(elem) = elem { + self.state.space.unmap_elem(&elem); + } + + if !window.is_override_redirect() { + let _ = window.set_mapped(false); + } + + if was_focused { + self.state.focused_x11_window = None; + self.state.determine_and_apply_focus(); + } + } + + fn destroyed_window(&mut self, _xwm: XwmId, _window: X11Surface) {} + + fn configure_request( + &mut self, + _xwm: XwmId, + window: X11Surface, + _x: Option, + _y: Option, + w: Option, + h: Option, + _reorder: Option, + ) { + // Honor size requests within reason but always keep position at (0,0). + let mut geo = window.geometry(); + if let Some(w) = w { + geo.size.w = w as i32; + } + if let Some(h) = h { + geo.size.h = h as i32; + } + let _ = window.configure(geo); + } + + fn configure_notify( + &mut self, + _xwm: XwmId, + window: X11Surface, + geometry: Rectangle, + _above: Option, + ) { + let target_elem = self + .state + .space + .elements() + .find(|e| e.x11_surface().map(|x| x == &window).unwrap_or(false)) + .cloned(); + + if let Some(elem) = target_elem { + self.state.space.map_element(elem, geometry.loc, false); + } + } + + fn property_notify(&mut self, _xwm: XwmId, _window: X11Surface, property: WmWindowProperty) { + // Only recalculate focus when the window class changes (steam_app_* detection). + if matches!(property, WmWindowProperty::Class) { + self.state.determine_and_apply_focus(); + } + } + + fn resize_request(&mut self, _: XwmId, _: X11Surface, _: u32, _: ResizeEdge) {} + fn move_request(&mut self, _: XwmId, _: X11Surface, _: u32) {} +} + +// =========================================================================== +// XwmHandler stub on NescopeState +// +// `delegate_xwayland_shell!(NescopeState)` generates Dispatch impls with a +// `NescopeState: XwmHandler` bound. Only `xwm_state()` is ever called on +// this path (surface association). Real WM logic lives in the CalloopData impl. +// =========================================================================== + +impl XwmHandler for NescopeState { + fn xwm_state(&mut self, _xwm: XwmId) -> &mut X11Wm { + self.xwm.as_mut().expect("XWM not initialized") + } + fn new_window(&mut self, _: XwmId, _: X11Surface) {} + fn new_override_redirect_window(&mut self, _: XwmId, _: X11Surface) {} + fn map_window_request(&mut self, _: XwmId, _: X11Surface) {} + fn mapped_override_redirect_window(&mut self, _: XwmId, _: X11Surface) {} + fn unmapped_window(&mut self, _: XwmId, _: X11Surface) {} + fn destroyed_window(&mut self, _: XwmId, _: X11Surface) {} + fn configure_request( + &mut self, + _: XwmId, + _: X11Surface, + _: Option, + _: Option, + _: Option, + _: Option, + _: Option, + ) { + } + fn configure_notify( + &mut self, + _: XwmId, + _: X11Surface, + _: Rectangle, + _: Option, + ) { + } + fn property_notify(&mut self, _: XwmId, _: X11Surface, _: WmWindowProperty) {} + fn resize_request(&mut self, _: XwmId, _: X11Surface, _: u32, _: ResizeEdge) {} + fn move_request(&mut self, _: XwmId, _: X11Surface, _: u32) {} +} + +// =========================================================================== +// Delegate macros +// =========================================================================== + +smithay::delegate_compositor!(NescopeState); +smithay::delegate_dmabuf!(NescopeState); +smithay::delegate_shm!(NescopeState); +smithay::delegate_xdg_shell!(NescopeState); +smithay::delegate_seat!(NescopeState); +smithay::delegate_data_device!(NescopeState); +smithay::delegate_output!(NescopeState); +smithay::delegate_relative_pointer!(NescopeState); +smithay::delegate_pointer_constraints!(NescopeState); +smithay::delegate_xwayland_shell!(NescopeState); +smithay::delegate_viewporter!(NescopeState); +smithay::delegate_presentation!(NescopeState); diff --git a/apps/nescope/src/hdr.rs b/apps/nescope/src/hdr.rs new file mode 100644 index 00000000..9d946991 --- /dev/null +++ b/apps/nescope/src/hdr.rs @@ -0,0 +1,840 @@ +//! HDR / color management protocol handlers. +//! +//! Two signalling paths feed into [`HdrState`]: +//! +//! 1. **`wp_color_management_v1`** — the standard staging Wayland protocol. +//! Wine/Proton/SDL2 uses this when the game requests an HDR swapchain via +//! standard Vulkan color-space extensions. +//! +//! 2. **`gamescope_swapchain_factory_v2`** — Valve's private protocol used by +//! the gamescope WSI Vulkan layer. This is the primary path for Steam +//! games using PROTON_ENABLE_NVAPI / HDR10_ST2084. +//! +//! nescope never performs color conversion itself — it just tracks which color +//! space the active surface has declared so that an external capture library +//! (e.g. the Vulkan vkcapture layer) can retrieve it via the public API. +#![allow(unused)] +use std::collections::HashMap; +use std::sync::Mutex; + +use smithay::reexports::wayland_protocols::wp::color_management::v1::server::{ + wp_color_management_output_v1, wp_color_management_surface_feedback_v1, + wp_color_management_surface_v1, wp_color_manager_v1, wp_image_description_creator_icc_v1, + wp_image_description_creator_params_v1, wp_image_description_info_v1, wp_image_description_v1, +}; +use smithay::reexports::wayland_protocols::wp::color_representation::v1::server::{ + wp_color_representation_manager_v1, wp_color_representation_surface_v1, +}; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{ + Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, +}; + +use crate::protocols::{ + gamescope_swapchain::GamescopeSwapchain, + gamescope_swapchain_factory_v2::GamescopeSwapchainFactoryV2, +}; +use crate::state::NescopeState; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Simplified color space used by the external capture library. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorSpace { + /// BT.709 primaries, sRGB EOTF. + Srgb, + /// BT.2020 primaries, PQ (ST 2084) EOTF — HDR10. + Bt2020Pq, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferFunction { + Gamma22, + St2084Pq, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Primaries { + Srgb, + Bt2020, +} + +/// A resolved per-surface color / HDR description. +#[derive(Debug, Clone, Copy)] +pub struct ImageDescription { + pub transfer_function: TransferFunction, + pub primaries: Primaries, + pub max_cll: Option, + pub max_fall: Option, + pub mastering_luminance: Option<(u32, u32)>, + pub mastering_primaries: Option<[(u32, u32); 3]>, + pub white_point: Option<(u32, u32)>, +} + +impl ImageDescription { + pub fn srgb() -> Self { + Self { + transfer_function: TransferFunction::Gamma22, + primaries: Primaries::Srgb, + max_cll: None, + max_fall: None, + mastering_luminance: None, + mastering_primaries: None, + white_point: None, + } + } + + pub fn bt2020_pq() -> Self { + Self { + transfer_function: TransferFunction::St2084Pq, + primaries: Primaries::Bt2020, + max_cll: None, + max_fall: None, + mastering_luminance: None, + mastering_primaries: None, + white_point: None, + } + } + + pub fn color_space(self) -> ColorSpace { + if self.primaries == Primaries::Bt2020 + && self.transfer_function == TransferFunction::St2084Pq + { + ColorSpace::Bt2020Pq + } else { + ColorSpace::Srgb + } + } +} + +// --------------------------------------------------------------------------- +// Resource user-data +// --------------------------------------------------------------------------- + +pub struct ColorSurfaceData { + pub surface: WlSurface, +} + +pub struct ImageDescriptionUserData { + pub desc: ImageDescription, +} + +pub struct CreatorParamsUserData { + pub params: Mutex, +} + +#[derive(Debug, Default)] +pub struct CreatorParams { + transfer_function: Option, + primaries: Option, + max_cll: Option, + max_fall: Option, + mastering_luminance: Option<(u32, u32)>, + mastering_primaries: Option<[(u32, u32); 3]>, + white_point: Option<(u32, u32)>, +} + +pub struct ColorOutputData; +pub struct ColorSurfaceFeedbackData { + pub surface: WlSurface, +} +pub struct ImageDescriptionInfoData; +pub struct IccCreatorData; +pub struct ColorRepresentationSurfaceData; + +// User data for gamescope protocol objects. +pub struct SwapchainFactoryData; +pub struct SwapchainData { + pub surface: WlSurface, +} + +// --------------------------------------------------------------------------- +// HdrState +// --------------------------------------------------------------------------- + +/// Per-compositor HDR / color management state. +pub struct HdrState { + /// Whether HDR protocols are advertised to clients. + pub enabled: bool, + /// Pending (not-yet-committed) image descriptions keyed by surface. + pending: HashMap>, + /// Committed image descriptions keyed by surface. + current: HashMap, +} + +impl HdrState { + /// Create state and, if `enabled`, register the protocol globals. + pub fn new(display: &DisplayHandle, enabled: bool) -> Self { + if enabled { + display.create_global::(1, ()); + display.create_global::(1, ()); + register_gamescope_swapchain(display); + tracing::info!( + "HDR protocols registered (wp_color_management_v1 + gamescope_swapchain)" + ); + } + + Self { + enabled, + pending: HashMap::new(), + current: HashMap::new(), + } + } + + // ── Pending state ───────────────────────────────────────────────────── + + pub fn set_pending(&mut self, surface: &WlSurface, desc: ImageDescription) { + tracing::debug!( + surface_id = ?surface.id(), + color_space = ?desc.color_space(), + "HDR: set_pending" + ); + self.pending.insert(surface.clone(), Some(desc)); + } + + pub fn unset_pending(&mut self, surface: &WlSurface) { + self.pending.insert(surface.clone(), None); + } + + /// Apply pending state on `wl_surface.commit`. + pub fn commit(&mut self, surface: &WlSurface) { + if let Some(pending) = self.pending.remove(surface) { + match pending { + Some(desc) => { + tracing::debug!( + surface_id = ?surface.id(), + color_space = ?desc.color_space(), + "HDR: committed" + ); + self.current.insert(surface.clone(), desc); + } + None => { + self.current.remove(surface); + } + } + } + } + + pub fn surface_destroyed(&mut self, surface: &WlSurface) { + self.pending.remove(surface); + self.current.remove(surface); + } + + // ── Queries ─────────────────────────────────────────────────────────── + + /// Active color space of the fullscreen surface. + /// + /// Returns `Bt2020Pq` if any mapped surface has declared BT.2020+PQ, + /// otherwise `Srgb`. + pub fn color_space(&self) -> ColorSpace { + for desc in self.current.values() { + if desc.color_space() == ColorSpace::Bt2020Pq { + return ColorSpace::Bt2020Pq; + } + } + ColorSpace::Srgb + } + + /// HDR metadata from the active surface, if available. + pub fn hdr_metadata(&self) -> Option { + for desc in self.current.values() { + if desc.color_space() != ColorSpace::Bt2020Pq { + continue; + } + if desc.max_cll.is_none() + && desc.max_fall.is_none() + && desc.mastering_luminance.is_none() + { + continue; + } + let sat = |v: u32| v.min(u16::MAX as u32) as u16; + return Some(HdrMetadata { + display_primaries: desc.mastering_primaries.map_or([(0, 0); 3], |p| { + [ + (sat(p[0].0), sat(p[0].1)), + (sat(p[1].0), sat(p[1].1)), + (sat(p[2].0), sat(p[2].1)), + ] + }), + white_point: desc.white_point.map_or((0, 0), |(x, y)| (sat(x), sat(y))), + max_luminance: desc.mastering_luminance.map_or(0, |(_, max)| max), + min_luminance: desc.mastering_luminance.map_or(0, |(min, _)| min), + max_cll: sat(desc.max_cll.unwrap_or(0)), + max_fall: sat(desc.max_fall.unwrap_or(0)), + }); + } + None + } +} + +/// Static HDR10 metadata for the capture layer. +#[derive(Debug, Clone, Copy)] +pub struct HdrMetadata { + /// CIE 1931 xy primaries in 0.00002 units. + pub display_primaries: [(u16, u16); 3], + /// CIE 1931 xy white point in 0.00002 units. + pub white_point: (u16, u16), + /// Max mastering luminance in 0.0001 cd/m². + pub max_luminance: u32, + /// Min mastering luminance in 0.0001 cd/m². + pub min_luminance: u32, + /// Max content light level in cd/m². + pub max_cll: u16, + /// Max frame-average light level in cd/m². + pub max_fall: u16, +} + +// --------------------------------------------------------------------------- +// gamescope_swapchain — register global +// --------------------------------------------------------------------------- + +const VK_COLOR_SPACE_HDR10_ST2084_EXT: u32 = 1000104008; + +pub fn register_gamescope_swapchain(display: &DisplayHandle) { + display.create_global::(1, ()); +} + +// --------------------------------------------------------------------------- +// gamescope_swapchain_factory_v2 — Global + Dispatch +// --------------------------------------------------------------------------- + +impl GlobalDispatch for NescopeState { + fn bind( + _: &mut Self, + _: &DisplayHandle, + _: &Client, + resource: New, + _: &(), + data_init: &mut DataInit<'_, Self>, + ) { + tracing::debug!("gamescope_swapchain_factory_v2 bound"); + data_init.init(resource, SwapchainFactoryData); + } +} + +impl Dispatch for NescopeState { + fn request( + _: &mut Self, + _: &Client, + _: &GamescopeSwapchainFactoryV2, + request: ::Request, + _: &SwapchainFactoryData, + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + use crate::protocols::gamescope_swapchain_factory_v2::Request; + match request { + Request::CreateSwapchain { surface, callback } => { + tracing::debug!("gamescope_swapchain_factory_v2: create_swapchain"); + data_init.init(callback, SwapchainData { surface }); + } + Request::Destroy => {} + } + } +} + +// --------------------------------------------------------------------------- +// gamescope_swapchain — Dispatch +// --------------------------------------------------------------------------- + +impl Dispatch for NescopeState { + fn request( + state: &mut Self, + _: &Client, + _: &GamescopeSwapchain, + request: ::Request, + data: &SwapchainData, + _: &DisplayHandle, + _: &mut DataInit<'_, Self>, + ) { + use crate::protocols::gamescope_swapchain::Request; + match request { + Request::SwapchainFeedback { + vk_colorspace, + vk_format, + vk_engine_name, + .. + } => { + tracing::debug!( + vk_colorspace, + vk_format, + vk_engine_name, + "gamescope_swapchain: swapchain_feedback — registering as Vulkan surface" + ); + // Record this as a known Vulkan surface (used for focus routing). + state.vulkan_surfaces.insert(data.surface.clone()); + + if vk_colorspace == VK_COLOR_SPACE_HDR10_ST2084_EXT { + state + .hdr + .set_pending(&data.surface, ImageDescription::bt2020_pq()); + } else { + state + .hdr + .set_pending(&data.surface, ImageDescription::srgb()); + } + } + + Request::OverrideWindowContent { + x11_window, + gamescope_xwayland_server_id: _, + } => { + tracing::debug!( + x11_window, + "gamescope_swapchain: override_window_content — WSI bypass surface" + ); + state.vulkan_surfaces.insert(data.surface.clone()); + state.override_window_surface(x11_window, data.surface.clone()); + } + + Request::SetHdrMetadata { + display_primary_red_x, + display_primary_red_y, + display_primary_green_x, + display_primary_green_y, + display_primary_blue_x, + display_primary_blue_y, + white_point_x, + white_point_y, + max_display_mastering_luminance, + min_display_mastering_luminance, + max_cll, + max_fall, + } => { + tracing::debug!( + max_cll, + max_fall, + max_display_mastering_luminance, + min_display_mastering_luminance, + "gamescope_swapchain: set_hdr_metadata" + ); + let desc = ImageDescription { + transfer_function: TransferFunction::St2084Pq, + primaries: Primaries::Bt2020, + max_cll: Some(max_cll), + max_fall: Some(max_fall), + // max_display_mastering_luminance is in cd/m², normalize to 0.0001 units. + mastering_luminance: Some(( + min_display_mastering_luminance, + max_display_mastering_luminance.saturating_mul(10000), + )), + mastering_primaries: Some([ + (display_primary_red_x, display_primary_red_y), + (display_primary_green_x, display_primary_green_y), + (display_primary_blue_x, display_primary_blue_y), + ]), + white_point: Some((white_point_x, white_point_y)), + }; + state.hdr.set_pending(&data.surface, desc); + } + + Request::SetPresentMode { .. } | Request::SetPresentTime { .. } | Request::Destroy => {} + } + } +} + +// =========================================================================== +// wp_color_manager_v1 +// =========================================================================== + +impl GlobalDispatch for NescopeState { + fn bind( + _: &mut Self, + _: &DisplayHandle, + _: &Client, + resource: New, + _: &(), + data_init: &mut DataInit<'_, Self>, + ) { + tracing::debug!("wp_color_manager_v1 bound"); + let res = data_init.init(resource, ()); + res.supported_intent(wp_color_manager_v1::RenderIntent::Perceptual); + res.supported_feature(wp_color_manager_v1::Feature::Parametric); + res.supported_feature(wp_color_manager_v1::Feature::SetPrimaries); + res.supported_feature(wp_color_manager_v1::Feature::SetMasteringDisplayPrimaries); + res.supported_feature(wp_color_manager_v1::Feature::ExtendedTargetVolume); + res.supported_feature(wp_color_manager_v1::Feature::SetLuminances); + res.supported_feature(wp_color_manager_v1::Feature::WindowsScrgb); + res.supported_tf_named(wp_color_manager_v1::TransferFunction::Srgb); + res.supported_tf_named(wp_color_manager_v1::TransferFunction::Gamma22); + res.supported_tf_named(wp_color_manager_v1::TransferFunction::St2084Pq); + res.supported_primaries_named(wp_color_manager_v1::Primaries::Srgb); + res.supported_primaries_named(wp_color_manager_v1::Primaries::Bt2020); + res.done(); + } +} + +impl Dispatch for NescopeState { + fn request( + _: &mut Self, + _: &Client, + _: &wp_color_manager_v1::WpColorManagerV1, + request: wp_color_manager_v1::Request, + _: &(), + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + wp_color_manager_v1::Request::Destroy => {} + wp_color_manager_v1::Request::GetSurface { id, surface } => { + data_init.init(id, ColorSurfaceData { surface }); + } + wp_color_manager_v1::Request::GetOutput { id, .. } => { + data_init.init(id, ColorOutputData); + } + wp_color_manager_v1::Request::GetSurfaceFeedback { id, surface } => { + data_init.init(id, ColorSurfaceFeedbackData { surface }); + } + wp_color_manager_v1::Request::CreateParametricCreator { obj } => { + data_init.init( + obj, + CreatorParamsUserData { + params: Mutex::new(CreatorParams::default()), + }, + ); + } + wp_color_manager_v1::Request::CreateIccCreator { obj } => { + data_init.init(obj, IccCreatorData); + } + wp_color_manager_v1::Request::CreateWindowsScrgb { image_description } => { + // Windows scRGB is declared as BT.2020+PQ by Proton's gamescope WSI + // after converting the surface, so treat it as HDR. + let res = data_init.init( + image_description, + ImageDescriptionUserData { + desc: ImageDescription::bt2020_pq(), + }, + ); + res.ready(0); + } + _ => {} + } + } +} + +// =========================================================================== +// wp_color_management_surface_v1 +// =========================================================================== + +impl Dispatch + for NescopeState +{ + fn request( + state: &mut Self, + _: &Client, + _: &wp_color_management_surface_v1::WpColorManagementSurfaceV1, + request: wp_color_management_surface_v1::Request, + data: &ColorSurfaceData, + _: &DisplayHandle, + _: &mut DataInit<'_, Self>, + ) { + match request { + wp_color_management_surface_v1::Request::SetImageDescription { + image_description, + .. + } => { + if let Some(d) = image_description.data::() { + state.hdr.set_pending(&data.surface, d.desc); + } + } + wp_color_management_surface_v1::Request::UnsetImageDescription => { + state.hdr.unset_pending(&data.surface); + } + _ => {} + } + } +} + +// =========================================================================== +// wp_image_description_creator_params_v1 +// =========================================================================== + +impl + Dispatch< + wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1, + CreatorParamsUserData, + > for NescopeState +{ + fn request( + _: &mut Self, + _: &Client, + _: &wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1, + request: wp_image_description_creator_params_v1::Request, + data: &CreatorParamsUserData, + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + wp_image_description_creator_params_v1::Request::Create { image_description } => { + let p = data.params.lock().unwrap(); + let desc = ImageDescription { + transfer_function: p.transfer_function.unwrap_or(TransferFunction::Gamma22), + primaries: p.primaries.unwrap_or(Primaries::Srgb), + max_cll: p.max_cll, + max_fall: p.max_fall, + mastering_luminance: p.mastering_luminance, + mastering_primaries: p.mastering_primaries, + white_point: p.white_point, + }; + let r = data_init.init(image_description, ImageDescriptionUserData { desc }); + r.ready(0); + } + wp_image_description_creator_params_v1::Request::SetTfNamed { tf } => { + let tf = match tf.into_result() { + Ok(wp_color_manager_v1::TransferFunction::St2084Pq) => { + TransferFunction::St2084Pq + } + _ => TransferFunction::Gamma22, + }; + data.params.lock().unwrap().transfer_function = Some(tf); + } + wp_image_description_creator_params_v1::Request::SetPrimariesNamed { primaries } => { + let p = match primaries.into_result() { + Ok(wp_color_manager_v1::Primaries::Bt2020) => Primaries::Bt2020, + _ => Primaries::Srgb, + }; + data.params.lock().unwrap().primaries = Some(p); + } + wp_image_description_creator_params_v1::Request::SetMaxCll { max_cll } => { + data.params.lock().unwrap().max_cll = Some(max_cll); + } + wp_image_description_creator_params_v1::Request::SetMaxFall { max_fall } => { + data.params.lock().unwrap().max_fall = Some(max_fall); + } + wp_image_description_creator_params_v1::Request::SetMasteringLuminance { + min_lum, + max_lum, + } => { + // max_lum is in cd/m², min_lum is already in 0.0001 cd/m² units. + data.params.lock().unwrap().mastering_luminance = + Some((min_lum, max_lum.saturating_mul(10000))); + } + wp_image_description_creator_params_v1::Request::SetMasteringDisplayPrimaries { + r_x, + r_y, + g_x, + g_y, + b_x, + b_y, + w_x, + w_y, + } => { + // Protocol values are in 1/1,000,000 chromaticity; convert to 0.00002 units. + let to_cta = |v: i32| (v.max(0) as u32) / 20; + let mut p = data.params.lock().unwrap(); + p.mastering_primaries = Some([ + (to_cta(r_x), to_cta(r_y)), + (to_cta(g_x), to_cta(g_y)), + (to_cta(b_x), to_cta(b_y)), + ]); + p.white_point = Some((to_cta(w_x), to_cta(w_y))); + } + _ => {} + } + } +} + +// =========================================================================== +// wp_image_description_v1 +// =========================================================================== + +impl Dispatch + for NescopeState +{ + fn request( + _: &mut Self, + _: &Client, + _: &wp_image_description_v1::WpImageDescriptionV1, + request: wp_image_description_v1::Request, + data: &ImageDescriptionUserData, + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + if let wp_image_description_v1::Request::GetInformation { information } = request { + let info = data_init.init(information, ImageDescriptionInfoData); + match data.desc.transfer_function { + TransferFunction::St2084Pq => { + info.tf_named(wp_color_manager_v1::TransferFunction::St2084Pq) + } + TransferFunction::Gamma22 => { + info.tf_named(wp_color_manager_v1::TransferFunction::Gamma22) + } + } + match data.desc.primaries { + Primaries::Bt2020 => info.primaries_named(wp_color_manager_v1::Primaries::Bt2020), + Primaries::Srgb => info.primaries_named(wp_color_manager_v1::Primaries::Srgb), + } + info.done(); + } + } +} + +// =========================================================================== +// Minimal stubs for remaining protocol objects +// =========================================================================== + +impl Dispatch + for NescopeState +{ + fn request( + _: &mut Self, + _: &Client, + _: &wp_image_description_info_v1::WpImageDescriptionInfoV1, + _: wp_image_description_info_v1::Request, + _: &ImageDescriptionInfoData, + _: &DisplayHandle, + _: &mut DataInit<'_, Self>, + ) { + } +} + +impl Dispatch + for NescopeState +{ + fn request( + state: &mut Self, + _: &Client, + _: &wp_color_management_output_v1::WpColorManagementOutputV1, + request: wp_color_management_output_v1::Request, + _: &ColorOutputData, + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + if let wp_color_management_output_v1::Request::GetImageDescription { image_description } = + request + { + let desc = if state.hdr.enabled { + ImageDescription::bt2020_pq() + } else { + ImageDescription::srgb() + }; + let r = data_init.init(image_description, ImageDescriptionUserData { desc }); + r.ready(0); + } + } +} + +impl + Dispatch< + wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1, + ColorSurfaceFeedbackData, + > for NescopeState +{ + fn request( + state: &mut Self, + _: &Client, + _: &wp_color_management_surface_feedback_v1::WpColorManagementSurfaceFeedbackV1, + request: wp_color_management_surface_feedback_v1::Request, + _: &ColorSurfaceFeedbackData, + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + wp_color_management_surface_feedback_v1::Request::GetPreferred { + image_description, + } + | wp_color_management_surface_feedback_v1::Request::GetPreferredParametric { + image_description, + } => { + let desc = if state.hdr.enabled { + ImageDescription::bt2020_pq() + } else { + ImageDescription::srgb() + }; + let r = data_init.init(image_description, ImageDescriptionUserData { desc }); + r.ready(0); + } + _ => {} + } + } +} + +impl Dispatch + for NescopeState +{ + fn request( + _: &mut Self, + _: &Client, + _: &wp_image_description_creator_icc_v1::WpImageDescriptionCreatorIccV1, + request: wp_image_description_creator_icc_v1::Request, + _: &IccCreatorData, + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + if let wp_image_description_creator_icc_v1::Request::Create { image_description } = request + { + let r = data_init.init( + image_description, + ImageDescriptionUserData { + desc: ImageDescription::srgb(), + }, + ); + r.failed( + wp_image_description_v1::Cause::Unsupported, + "ICC profiles not supported".into(), + ); + } + } +} + +impl GlobalDispatch + for NescopeState +{ + fn bind( + _: &mut Self, + _: &DisplayHandle, + _: &Client, + resource: New, + _: &(), + data_init: &mut DataInit<'_, Self>, + ) { + let r = data_init.init(resource, ()); + r.supported_alpha_mode(wp_color_representation_surface_v1::AlphaMode::Straight); + r.supported_alpha_mode( + wp_color_representation_surface_v1::AlphaMode::PremultipliedElectrical, + ); + r.supported_coefficients_and_ranges( + wp_color_representation_surface_v1::Coefficients::Identity, + wp_color_representation_surface_v1::Range::Full, + ); + r.done(); + } +} + +impl Dispatch + for NescopeState +{ + fn request( + _: &mut Self, + _: &Client, + _: &wp_color_representation_manager_v1::WpColorRepresentationManagerV1, + request: wp_color_representation_manager_v1::Request, + _: &(), + _: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + if let wp_color_representation_manager_v1::Request::GetSurface { id, .. } = request { + data_init.init(id, ColorRepresentationSurfaceData); + } + } +} + +impl + Dispatch< + wp_color_representation_surface_v1::WpColorRepresentationSurfaceV1, + ColorRepresentationSurfaceData, + > for NescopeState +{ + fn request( + _: &mut Self, + _: &Client, + _: &wp_color_representation_surface_v1::WpColorRepresentationSurfaceV1, + _: wp_color_representation_surface_v1::Request, + _: &ColorRepresentationSurfaceData, + _: &DisplayHandle, + _: &mut DataInit<'_, Self>, + ) { + } +} diff --git a/apps/nescope/src/input.rs b/apps/nescope/src/input.rs new file mode 100644 index 00000000..08e77d69 --- /dev/null +++ b/apps/nescope/src/input.rs @@ -0,0 +1,394 @@ +//! Programmatic input injection into the Smithay seat. +//! +//! Unlike the proxy version, nescope does **not** receive input from a host +//! compositor. Instead callers (e.g. a streaming server, a test harness, or +//! a control socket handler) send [`InputEvent`]s over a +//! [`calloop::channel::Channel`] that is registered in the event loop. +//! +//! # Usage +//! +//! ```ignore +//! // Obtained when calling NescopeState::new() +//! let input_tx: calloop::channel::Sender = ...; +//! +//! // From any thread: +//! input_tx.send(InputEvent::KeyDown { keycode: 28 }).unwrap(); // evdev KEY_ENTER +//! ``` +//! +//! Keycodes follow the Linux evdev convention (the same as used in +//! Moonshine's `CompositorInputEvent`). The compositor adds 8 to convert +//! them to X11/xkbcommon keycodes before passing them to Smithay. + +#![allow(dead_code)] +use smithay::backend::input::{Axis, AxisSource, ButtonState, KeyState}; +use smithay::input::keyboard::{FilterResult, Keycode}; +use smithay::input::pointer::{AxisFrame, ButtonEvent, MotionEvent, RelativeMotionEvent}; +use smithay::utils::{Logical, Point, SERIAL_COUNTER}; +use smithay::wayland::pointer_constraints::{PointerConstraint, with_pointer_constraint}; +use smithay::wayland::seat::WaylandFocus; + +use nesprotocol::input::{self as nestri_input, DecodedInput}; + +use crate::focus::KeyboardFocusTarget; +use crate::state::NescopeState; + +// ── Wire protocol constants (mirrors nestri-protocol/src/input.rs) ── +const WIRE_INPUT_KEY: u8 = 0; +const WIRE_INPUT_MOUSE_MOVE: u8 = 1; +const WIRE_INPUT_MOUSE_BUTTON: u8 = 2; +const WIRE_INPUT_MOUSE_WHEEL: u8 = 3; +const WIRE_KEY_DOWN: u8 = 1; +const WIRE_BTN_LEFT: u32 = 0x110; // BTN_LEFT +const WIRE_BTN_MIDDLE: u32 = 0x112; // BTN_MIDDLE +const WIRE_BTN_RIGHT: u32 = 0x111; // BTN_RIGHT + +// --------------------------------------------------------------------------- +// Public event type +// --------------------------------------------------------------------------- + +/// Events that can be injected into the compositor seat programmatically. +/// +/// All coordinates are in logical (output-space) pixels. +/// Keycodes are Linux evdev keycodes (NOT X11 keycodes). +#[derive(Debug, Clone)] +pub enum InputEvent { + // ── Keyboard ───────────────────────────────────────────────────────── + /// Key pressed. `keycode` is a Linux evdev keycode (8 will be added + /// internally to produce an X11 keycode). + KeyDown { keycode: u32 }, + /// Key released. + KeyUp { keycode: u32 }, + + // ── Pointer — absolute ─────────────────────────────────────────────── + /// Absolute pointer position. Coordinates are in the Moonlight client's + /// coordinate space; they are scaled to the compositor output. + MouseMoveAbsolute { + x: f64, + y: f64, + /// Client screen width used for coordinate mapping. + screen_width: f64, + /// Client screen height used for coordinate mapping. + screen_height: f64, + }, + + // ── Pointer — relative ─────────────────────────────────────────────── + /// Relative pointer delta in logical pixels. + MouseMoveRelative { dx: f64, dy: f64 }, + + // ── Pointer — buttons ──────────────────────────────────────────────── + /// Mouse button pressed. `button` is a Linux button code + /// (e.g. `BTN_LEFT = 0x110`). + MouseButtonDown { button: u32 }, + /// Mouse button released. + MouseButtonUp { button: u32 }, + + // ── Pointer — scroll ───────────────────────────────────────────────── + /// Vertical scroll. Positive = up, negative = down. + ScrollVertical { amount: f64 }, + /// Horizontal scroll. Positive = right, negative = left. + ScrollHorizontal { amount: f64 }, +} + +// --------------------------------------------------------------------------- +// Injection +// --------------------------------------------------------------------------- + +/// Process a single [`InputEvent`] injected from outside the compositor. +/// +/// Called from the calloop idle callback after draining the input channel. +pub fn process_input(event: InputEvent, state: &mut NescopeState) { + let serial = SERIAL_COUNTER.next_serial(); + let time = state.clock.now().as_millis(); + + // Track pointer activity for cursor inactivity timer. + match event { + InputEvent::KeyDown { .. } | InputEvent::KeyUp { .. } => {} + _ => state.last_pointer_activity = std::time::Instant::now(), + } + + // One-time X11 focus reset when the gamescope WSI surface is active. + if state.override_surface.is_some() && state.x11_focus_needs_reset { + state.sync_x11_focus(); + } + + match event { + InputEvent::KeyDown { keycode } => { + if let Some(kb) = state.seat.get_keyboard() { + // Auto-focus the topmost window if nothing has focus yet. + if kb.current_focus().is_none() { + state.determine_and_apply_focus(); + } + // evdev → X11/xkbcommon keycode: add 8. + kb.input::<(), _>( + state, + Keycode::from(keycode + 8), + KeyState::Pressed, + serial, + time, + |_, _, _| FilterResult::Forward, + ); + } + } + + InputEvent::KeyUp { keycode } => { + if let Some(kb) = state.seat.get_keyboard() { + if kb.current_focus().is_none() { + state.determine_and_apply_focus(); + } + kb.input::<(), _>( + state, + Keycode::from(keycode + 8), + KeyState::Released, + serial, + time, + |_, _, _| FilterResult::Forward, + ); + } + } + + InputEvent::MouseMoveAbsolute { + x, + y, + screen_width, + screen_height, + } => { + let output_size = state + .output + .current_mode() + .map(|m| m.size) + .unwrap_or((state.width as i32, state.height as i32).into()); + + let new_x = if screen_width > 0.0 { + x / screen_width * output_size.w as f64 + } else { + x + }; + let new_y = if screen_height > 0.0 { + y / screen_height * output_size.h as f64 + } else { + y + }; + + state.cursor_position = Point::from((new_x, new_y)); + clamp_cursor(state); + + let under = surface_under(state); + let pointer = state.seat.get_pointer().unwrap(); + pointer.motion( + state, + under, + &MotionEvent { + location: state.cursor_position, + serial, + time, + }, + ); + pointer.frame(state); + } + + InputEvent::MouseMoveRelative { dx, dy } => { + if !state.cursor_initialized { + let size = state + .output + .current_mode() + .map(|m| m.size) + .unwrap_or((state.width as i32, state.height as i32).into()); + state.cursor_position = Point::from((size.w as f64 / 2.0, size.h as f64 / 2.0)); + state.cursor_initialized = true; + tracing::debug!("cursor initialized to center: {:?}", state.cursor_position); + } + + let delta = Point::from((dx, dy)); + let pointer = state.seat.get_pointer().unwrap(); + + // Check for a pointer lock constraint. + let mut locked = false; + let under = surface_under(state); + if let Some((ref target, _)) = under { + if let Some(surf) = target.wl_surface() { + with_pointer_constraint(&surf, &pointer, |c| { + if let Some(c) = c { + if c.is_active() { + if let PointerConstraint::Locked(_) = &*c { + locked = true; + } + } + } + }); + } + } + + pointer.relative_motion( + state, + under.clone(), + &RelativeMotionEvent { + delta, + delta_unaccel: delta, + utime: time as u64, + }, + ); + + state.cursor_position += delta; + clamp_cursor(state); + + if locked { + pointer.frame(state); + return; + } + + pointer.motion( + state, + under.clone(), + &MotionEvent { + location: state.cursor_position, + serial, + time, + }, + ); + pointer.frame(state); + } + + InputEvent::MouseButtonDown { button } => { + let pointer = state.seat.get_pointer().unwrap(); + pointer.button( + state, + &ButtonEvent { + serial, + time, + button, + state: ButtonState::Pressed, + }, + ); + pointer.frame(state); + } + + InputEvent::MouseButtonUp { button } => { + let pointer = state.seat.get_pointer().unwrap(); + pointer.button( + state, + &ButtonEvent { + serial, + time, + button, + state: ButtonState::Released, + }, + ); + pointer.frame(state); + } + + InputEvent::ScrollVertical { amount } => { + let pointer = state.seat.get_pointer().unwrap(); + pointer.axis( + state, + AxisFrame::new(time) + .source(AxisSource::Wheel) + .value(Axis::Vertical, -amount), + ); + pointer.frame(state); + } + + InputEvent::ScrollHorizontal { amount } => { + let pointer = state.seat.get_pointer().unwrap(); + pointer.axis( + state, + AxisFrame::new(time) + .source(AxisSource::Wheel) + .value(Axis::Horizontal, amount), + ); + pointer.frame(state); + } + } +} + +// --------------------------------------------------------------------------- +// Wire-protocol decoder +// --------------------------------------------------------------------------- + +/// Decode a single input event from the nestri guest-hub wire protocol +/// and convert it to an [`InputEvent`] for the compositor seat. +/// +/// Uses the shared [`nesprotocol::input::decode_input_event`] and maps: +/// - Button 0 → `BTN_LEFT` (0x110), 1 → `BTN_MIDDLE` (0x112), 2 → `BTN_RIGHT` (0x111) +/// - Mouse wheel `dy` → `ScrollVertical` +/// +/// Returns `None` if the buffer cannot be decoded. +pub fn decode_wire_event(data: &[u8]) -> Option { + match nestri_input::decode_input_event(data)? { + DecodedInput::Key { down, keycode } => Some(if down { + InputEvent::KeyDown { + keycode: keycode as u32, + } + } else { + InputEvent::KeyUp { + keycode: keycode as u32, + } + }), + DecodedInput::MouseMove { dx, dy } => Some(InputEvent::MouseMoveRelative { + dx: dx as f64, + dy: dy as f64, + }), + DecodedInput::MouseButton { button, down } => { + let btn = match button { + 0 => 0x110, // BTN_LEFT + 1 => 0x112, // BTN_MIDDLE + 2 => 0x111, // BTN_RIGHT + _ => return None, + }; + Some(if down { + InputEvent::MouseButtonDown { button: btn } + } else { + InputEvent::MouseButtonUp { button: btn } + }) + } + DecodedInput::MouseWheel { dx: _dx, dy } => { + Some(InputEvent::ScrollVertical { amount: dy as f64 }) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Clamp the cursor to the output bounds. +fn clamp_cursor(state: &mut NescopeState) { + let size = state + .output + .current_mode() + .map(|m| m.size) + .unwrap_or((state.width as i32, state.height as i32).into()); + state.cursor_position.x = state.cursor_position.x.clamp(0.0, (size.w - 1) as f64); + state.cursor_position.y = state.cursor_position.y.clamp(0.0, (size.h - 1) as f64); +} + +/// Find the focused target under the current cursor position. +pub fn surface_under(state: &NescopeState) -> Option<(KeyboardFocusTarget, Point)> { + if state.override_surface.is_some() { + if let Some(wid) = state.focused_x11_window { + for window in state.space.elements() { + if let Some(x11) = window.x11_surface() { + if x11.window_id() == wid { + let loc = state.space.element_geometry(window)?.loc; + return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64())); + } + } + } + } + let (window, loc) = state.space.element_under(state.cursor_position)?; + return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64())); + } + + // Try element_under first + if let Some((window, loc)) = state.space.element_under(state.cursor_position) { + return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64())); + } + + // Fallback: use the keyboard-focused window if any window is mapped + if let Some(w) = state.space.elements().next() { + if let Some(geo) = state.space.element_geometry(w) { + return Some((KeyboardFocusTarget::Window(w.clone()), geo.loc.to_f64())); + } + } + + None +} diff --git a/apps/nescope/src/input_ipc.rs b/apps/nescope/src/input_ipc.rs new file mode 100644 index 00000000..78ab79da --- /dev/null +++ b/apps/nescope/src/input_ipc.rs @@ -0,0 +1,84 @@ +use std::io::{self, Read}; +use std::os::unix::io::{AsFd, BorrowedFd}; +use std::os::unix::net::UnixStream; + +use calloop::{EventSource, Interest, Mode, Poll, PostAction, Readiness, Token, TokenFactory}; + +pub struct InputIpcSource { + stream: UnixStream, + buf: Vec, +} + +impl InputIpcSource { + pub fn connect(path: &str) -> io::Result { + let stream = UnixStream::connect(path)?; + stream.set_nonblocking(true)?; + Ok(Self { stream, buf: Vec::new() }) + } + + pub fn try_clone(&self) -> io::Result { + self.stream.try_clone() + } +} + +impl AsFd for InputIpcSource { + fn as_fd(&self) -> BorrowedFd<'_> { + self.stream.as_fd() + } +} + +impl EventSource for InputIpcSource { + type Event = Vec; + type Metadata = (); + type Ret = (); + type Error = io::Error; + + fn process_events( + &mut self, + _readiness: Readiness, + _token: Token, + mut callback: F, + ) -> Result + where + F: FnMut(Self::Event, &mut Self::Metadata), + { + let mut tmp = [0u8; 4096]; + loop { + match self.stream.read(&mut tmp) { + Ok(0) => { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "IPC socket closed")); + } + Ok(n) => { + self.buf.extend_from_slice(&tmp[..n]); + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => return Err(e), + } + } + + while self.buf.len() >= 2 { + let payload_len = u16::from_le_bytes([self.buf[0], self.buf[1]]) as usize; + let frame_total = 2 + payload_len; + if self.buf.len() < frame_total { + break; + } + let payload = self.buf[2..frame_total].to_vec(); + self.buf.drain(..frame_total); + callback(payload, &mut ()); + } + + Ok(PostAction::Continue) + } + + fn register(&mut self, poll: &mut Poll, factory: &mut TokenFactory) -> calloop::Result<()> { + unsafe { poll.register(self.as_fd(), Interest::READ, Mode::Level, factory.token()) } + } + + fn reregister(&mut self, poll: &mut Poll, factory: &mut TokenFactory) -> calloop::Result<()> { + poll.reregister(self.as_fd(), Interest::READ, Mode::Level, factory.token()) + } + + fn unregister(&mut self, poll: &mut Poll) -> calloop::Result<()> { + poll.unregister(self.as_fd()) + } +} diff --git a/apps/nescope/src/libinput_backend.rs b/apps/nescope/src/libinput_backend.rs new file mode 100644 index 00000000..b9af5c73 --- /dev/null +++ b/apps/nescope/src/libinput_backend.rs @@ -0,0 +1,127 @@ +//! libinput backend — monitors real and virtual (inputtino) input devices +//! via udev and feeds keyboard/pointer events into the Smithay seat. + +use std::os::unix::io::{AsRawFd, OwnedFd}; +use std::path::Path; + +use smithay::reexports::input::{ + self as libinput, + event::{ + self, + keyboard::KeyboardEventTrait, + pointer::PointerScrollEvent, + }, + }; + +use crate::input::{InputEvent, process_input}; +use crate::state::NescopeState; + +// Use the input crate's Axis (not smithay's) for scroll methods +use libinput::event::pointer::Axis as InputAxis; + +struct SessionInterface; +impl libinput::LibinputInterface for SessionInterface { + fn open_restricted(&mut self, path: &Path, flags: i32) -> Result { + smithay::reexports::rustix::fs::open( + path, + smithay::reexports::rustix::fs::OFlags::from_bits_truncate(flags as u32), + smithay::reexports::rustix::fs::Mode::empty(), + ) + .map_err(|e| e.raw_os_error()) + } + + fn close_restricted(&mut self, fd: OwnedFd) { + drop(fd); + } +} + +/// Create a new libinput context with udev monitoring. +pub fn create_libinput() -> Result> { + let mut ctx = libinput::Libinput::new_with_udev(SessionInterface); + ctx.udev_assign_seat("seat0") + .map_err(|()| std::io::Error::new(std::io::ErrorKind::Other, "udev_assign_seat failed"))?; + tracing::info!("libinput context created, fd={}", ctx.as_raw_fd()); + Ok(ctx) +} + +/// Dispatch pending libinput events and inject them into the Smithay seat. +pub fn dispatch_libinput(ctx: &mut libinput::Libinput, state: &mut NescopeState) { + let mut event_count = 0u32; + if let Err(e) = ctx.dispatch() { + tracing::error!("libinput dispatch error: {e}"); + return; + } + + for event in &mut *ctx { + event_count += 1; + match event { + libinput::Event::Keyboard(kev) => { + let keycode = kev.key(); + let ev = match kev.key_state() { + event::keyboard::KeyState::Pressed => InputEvent::KeyDown { keycode }, + event::keyboard::KeyState::Released => InputEvent::KeyUp { keycode }, + }; + process_input(ev, state); + } + libinput::Event::Pointer(pev) => { + match pev { + event::PointerEvent::Motion(ev) => { + process_input( + InputEvent::MouseMoveRelative { dx: ev.dx(), dy: ev.dy() }, + state, + ); + } + event::PointerEvent::MotionAbsolute(ev) => { + let size = state + .output + .current_mode() + .map(|m| m.size) + .unwrap_or((state.width as i32, state.height as i32).into()); + process_input( + InputEvent::MouseMoveAbsolute { + x: ev.absolute_x_transformed(size.w as u32), + y: ev.absolute_y_transformed(size.h as u32), + screen_width: size.w as f64, + screen_height: size.h as f64, + }, + state, + ); + } + event::PointerEvent::Button(ev) => { + let button = ev.button(); + let down = matches!( + ev.button_state(), + event::pointer::ButtonState::Pressed + ); + let ev = if down { + InputEvent::MouseButtonDown { button } + } else { + InputEvent::MouseButtonUp { button } + }; + process_input(ev, state); + } + event::PointerEvent::ScrollWheel(ev) => handle_scroll(ev, state), + event::PointerEvent::ScrollFinger(ev) => handle_scroll(ev, state), + event::PointerEvent::ScrollContinuous(ev) => handle_scroll(ev, state), + _ => {} // PointerAxis deprecated, covered above + } + state.last_pointer_activity = std::time::Instant::now(); + } + _ => {} + } + } + if event_count > 0 { + tracing::trace!("libinput: dispatched {event_count} events"); + } +} + +fn handle_scroll(sev: SE, state: &mut NescopeState) { + if sev.has_axis(InputAxis::Vertical) { + let amount = sev.scroll_value(InputAxis::Vertical); + process_input(InputEvent::ScrollVertical { amount }, state); + } + if sev.has_axis(InputAxis::Horizontal) { + let amount = sev.scroll_value(InputAxis::Horizontal); + process_input(InputEvent::ScrollHorizontal { amount }, state); + } +} diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs new file mode 100644 index 00000000..1cce9c59 --- /dev/null +++ b/apps/nescope/src/main.rs @@ -0,0 +1,722 @@ +//! nescope — lightweight headless Wayland compositor for game capture. +//! +//! # Overview +//! +//! nescope creates a virtual Wayland output, starts XWayland, and gives games +//! a complete compositor environment. Frames are captured externally by a +//! Vulkan interception library (`hudless`); nescope itself +//! never allocates a GBM pool or forwards DMA-BUFs. +//! +//! # Usage +//! +//! ```text +//! nescope [OPTIONS] -- [args...] +//! +//! Options: +//! --width Output width [default: 1920] +//! --height Output height [default: 1080] +//! --fps Virtual refresh rate [default: 60] +//! --hdr Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain) +//! --socket Wayland socket name [default: nescope-0] +//! ``` +//! +//! # Environment variables +//! +//! | Variable | Effect | +//! |----------------|-----------------------------------------------| +//! | `WAYLAND_DISPLAY` | Set by nescope before spawning the game | +//! | `DISPLAY` | Set to the XWayland display (`:N`) | +//! | `XCURSOR_THEME` | XCursor theme name for the software cursor | +//! | `XCURSOR_SIZE` | XCursor size in pixels | +//! | `RUST_LOG` | Tracing filter (e.g. `nescope=debug`) | +//! +//! # Ctrl+C / shutdown +//! +//! The first SIGINT/SIGTERM sets an atomic flag; the event loop detects it on +//! the next idle tick, kills all child process groups, and exits cleanly. +//! A second signal falls through to the OS default handler (hard kill). +//! +//! nescope registers itself as a subreaper (`PR_SET_CHILD_SUBREAPER`) so that +//! orphaned game descendants (grandchildren, great-grandchildren, …) are +//! reparented to it instead of PID 1. This prevents zombie accumulation and +//! ensures `kill_all_children()` can reach every descendant. + +use std::os::unix::process::CommandExt; +use std::sync::Arc; +use std::time::Duration; + +use calloop::generic::Generic; +use calloop::signals::{Signal, Signals}; +use calloop::timer::Timer; +use calloop::{EventLoop, Interest, Mode, PostAction}; +use clap::Parser; +use smithay::reexports::wayland_server::Display; +use smithay::wayland::socket::ListeningSocketSource; + +mod focus; +mod gpu_readback; +mod handlers; +mod hdr; +mod input; +mod input_ipc; +mod libinput_backend; +mod protocols; +mod screenshot_ipc; +mod screenshot_wire; +mod state; +mod xwm; + +use crate::input::{decode_wire_event, process_input}; +use state::{CalloopData, ClientState, NescopeState}; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +#[derive(Parser, Debug)] +#[command( + name = "nescope", + about = "Lightweight headless Wayland compositor for game capture", + after_help = "Everything after '--' is the game command, e.g.:\n nescope --hdr -- %command%" +)] +struct Args { + /// Output width in pixels. + #[arg(long, default_value = "1920", env = "NESCOPE_WIDTH")] + width: u32, + + /// Output height in pixels. + #[arg(long, default_value = "1080", env = "NESCOPE_HEIGHT")] + height: u32, + + /// Virtual output refresh rate (fps). + #[arg(long, default_value = "60", env = "NESCOPE_FPS")] + fps: u32, + + /// Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain_factory_v2). + #[arg(long, env = "NESCOPE_HDR")] + hdr: bool, + + /// Wayland socket name (created in $XDG_RUNTIME_DIR). + #[arg(long, default_value = "nescope-0", env = "NESCOPE_SOCKET")] + socket: String, + + /// Path to the hub's input IPC socket (nescope connects as client). + #[arg( + long, + env = "NESCOPE_INPUT_IPC", + default_value = "/tmp/nestri-input.sock" + )] + input_ipc: String, + + /// Path to the hub's screenshot IPC socket (nescope connects as client). + /// + /// Optional, and absent means the feature is simply off: it exists for + /// clients that are not games — a Steam login screen has no Vulkan frames + /// for `nescapture` to take, so its pixels can only come from here. + #[arg(long, env = "NESCOPE_SCREENSHOT_IPC")] + screenshot_ipc: Option, + + /// GPU render device (e.g. /dev/dri/renderD128). Sets VK_DRIVER_FILES + /// for the game so it uses the same GPU. + #[arg(long, env = "NESCOPE_RENDER_DEVICE")] + render_device: Option, + + /// X display number for XWayland, so clients can be pointed at it. + /// + /// Fixed rather than whatever XWayland picks: in compositor mode the + /// processes that join are started by something else entirely, and a + /// display number nobody can predict would need a discovery handshake to + /// communicate something that is free to agree on in advance. + #[arg(long, env = "NESCOPE_X_DISPLAY", default_value_t = 1)] + x_display: u32, + + /// Game command — everything after '--'. + /// + /// **Optional.** With one, nescope launches it and exits when it and its + /// windows are gone — a wrapper around a single game. Without one, nescope + /// is a plain compositor: it comes up, publishes its displays and waits, + /// and whatever wants to draw connects to it. + /// + /// The second shape is what a session needs. A Steam client and the game + /// it authorises have to share a compositor *and* a Wine prefix, and + /// neither can be the other's parent. + #[arg(last = true)] + command: Vec, +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let args = Args::parse(); + tracing::info!( + "nescope {}×{}@{}fps hdr={} socket={}", + args.width, + args.height, + args.fps, + args.hdr, + args.socket, + ); + + // ── Become a process subreaper ──────────────────────────────────────── + // Orphaned grandchild processes (Steam launcher → real game client) are + // reparented to us instead of PID 1. This lets us: + // • reap all zombie descendants + // • detect when the entire game tree has exited + // • kill all children reliably on shutdown + unsafe { + if libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0 { + tracing::warn!("prctl(PR_SET_CHILD_SUBREAPER) failed — orphans may become zombies"); + } else { + tracing::debug!("Registered as child subreaper"); + } + } + + // ── Event loop ──────────────────────────────────────────────────────── + let mut event_loop: EventLoop = + EventLoop::try_new().expect("Failed to create event loop"); + let loop_handle = event_loop.handle(); + let loop_signal = event_loop.get_signal(); + + // ── Signal handling ─────────────────────────────────────────────────── + let signals = + Signals::new(&[Signal::SIGINT, Signal::SIGTERM]).expect("Failed to create signal source"); + + loop_handle + .insert_source(signals, |event, _, data| { + tracing::info!("Received signal {:?} — shutting down", event.signal()); + + // Kill game process group + if let Some(pgid) = data.game_pgid { + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } + } + + // Kill everything else + kill_all_children_sigkill(); + + // Reap + std::thread::sleep(Duration::from_millis(200)); + reap_zombies(data); + + data.loop_signal.stop(); + }) + .expect("Failed to register signal source"); + + // ── Wayland display ─────────────────────────────────────────────────── + let mut display: Display = + Display::new().expect("Failed to create Wayland display"); + let display_handle = display.handle(); + + // Wake calloop when game clients send requests. + { + let fd = display + .backend() + .poll_fd() + .try_clone_to_owned() + .expect("Failed to clone display fd"); + loop_handle + .insert_source( + Generic::new(fd, Interest::READ, Mode::Level), + |_, _, data| { + data.display + .dispatch_clients(&mut data.state) + .expect("dispatch_clients failed"); + Ok(PostAction::Continue) + }, + ) + .expect("Failed to register display fd"); + } + + // ── Wayland socket ──────────────────────────────────────────────────── + let xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + + // Remove stale socket + lock files from a previous crash. + for name in [&args.socket, &format!("{}.lock", args.socket)] { + let path = std::path::Path::new(&xdg_runtime_dir).join(name); + if path.exists() { + tracing::warn!("Removing stale socket file: {}", path.display()); + let _ = std::fs::remove_file(&path); + } + } + + let socket_source = ListeningSocketSource::with_name(&args.socket) + .unwrap_or_else(|e| panic!("Failed to create Wayland socket '{}': {e}", args.socket)); + let socket_name = socket_source.socket_name().to_os_string(); + + tracing::info!("Wayland socket: {socket_name:?}"); + + { + let mut dh = display_handle.clone(); + loop_handle + .insert_source(socket_source, move |stream, _, _| { + if let Err(e) = dh.insert_client( + stream, + Arc::new(ClientState { + compositor_state: Default::default(), + }), + ) { + tracing::error!("Failed to accept Wayland client: {e}"); + } + }) + .expect("Failed to register socket source"); + } + + // ── Compositor state ────────────────────────────────────────────────── + let (mut state, _input_tx) = NescopeState::new( + display_handle.clone(), + loop_handle.clone(), + args.width, + args.height, + args.fps, + args.hdr, + args.render_device.clone(), + ); + //state.init_xwayland(&loop_handle, Some(args.x_display)); + + // Said out loud because in compositor mode nothing else can work them out. + // A process started by the hub rather than by nescope has no inherited + // environment to read them from. + if args.command.is_empty() { + tracing::info!( + wayland_display = %socket_name.to_string_lossy(), + display = format!(":{}", args.x_display), + "compositor mode — point clients at these and they will connect" + ); + } + + // The GPU to import dmabufs on for screenshots. Same device the game is + // pointed at, because a buffer the game produced can only be imported on + // the device that made it. + gpu_readback::set_render_device(args.render_device.clone()); + + // ── Screenshot IPC source ──────────────────────────────────────────── + // Same dial-out shape as the input socket below, so the hub is the + // listener and there is no race against a socket that does not exist yet. + // Absent means the feature is off, which is the normal case for a game. + if let Some(path) = args.screenshot_ipc.clone() { + match screenshot_ipc::ScreenshotIpcSource::connect(&path) { + Ok(source) => match source.try_clone_writer() { + Ok(mut writer) => { + tracing::info!("Connected to screenshot IPC socket: {path}"); + loop_handle + .insert_source(source, move |request, _, data| { + if request != screenshot_ipc::REQUEST_CAPTURE { + tracing::warn!("unknown screenshot request {request:#x}"); + return; + } + let (status, capture) = + screenshot_ipc::capture_frontmost(&data.state.space); + if status != screenshot_wire::Status::Ok { + // Worth saying: `Unreadable` means the client is + // rendering on the GPU and this path can never + // see it -- a configuration problem, not a + // transient one. + tracing::debug!("screenshot answered with {status:?}"); + } + if let Err(e) = screenshot_ipc::write_reply_to( + &mut writer, + status, + capture.as_ref(), + ) { + tracing::warn!("failed to answer a screenshot request: {e}"); + } + }) + .expect("Failed to register screenshot IPC source"); + } + Err(e) => tracing::warn!("Failed to clone screenshot IPC stream: {e}"), + }, + Err(e) => tracing::warn!("Failed to connect to screenshot IPC socket {path}: {e}"), + } + } + + // ── Input IPC source ───────────────────────────────────────────────── + // Connect to the nestri-guest-hub input socket and feed events into the + // compositor seat. Reconnection is handled in the idle callback. + let ipc_path = args.input_ipc.clone(); + match input_ipc::InputIpcSource::connect(&ipc_path) { + Ok(source) => { + tracing::info!("Connected to input IPC socket: {ipc_path}"); + match source.try_clone() { + Ok(write_stream) => { + state.ipc_write = Some(write_stream); + state.cursor_image_sent = false; // re-send on reconnect + } + Err(e) => { + tracing::warn!("Failed to clone IPC write stream: {e}"); + } + } + loop_handle + .insert_source(source, move |payload, _, data| { + if let Some(event) = decode_wire_event(&payload) { + process_input(event, &mut data.state); + } + }) + .expect("Failed to register input IPC source"); + } + Err(e) => { + tracing::warn!("Failed to connect to input IPC socket {ipc_path}: {e}"); + } + } + + // ── Frame-callback timer ────────────────────────────────────────────── + // Send wl_surface.frame done events at the target fps. This is what + // drives the game's render loop in the absence of a real scanout. + let frame_interval = Duration::from_micros(1_000_000 / args.fps.max(1) as u64); + loop_handle + .insert_source(Timer::from_duration(frame_interval), move |_, _, data| { + if let Some(ref mut li) = data.libinput { + libinput_backend::dispatch_libinput(li, &mut data.state); + } + data.state.on_frame_tick(); + calloop::timer::TimeoutAction::ToDuration(frame_interval) + }) + .expect("Failed to register frame timer"); + + // ── CalloopData ─────────────────────────────────────────────────────── + let socket_name_for_cleanup = args.socket.clone(); + let command = args.command.clone(); + let gamescope_wayland_socket = args.socket.clone(); + + // ── libinput backend ───────────────────────────────────────────────── + let libinput_ctx = + libinput_backend::create_libinput().expect("Failed to create libinput context"); + + let mut data = CalloopData { + state, + display, + loop_signal, + libinput: Some(libinput_ctx), + game_process: None, + primary_pid: None, + game_pgid: None, + }; + + tracing::info!("Entering event loop"); + + // Run with a 1-second timeout so the idle closure fires even when no + // Wayland events arrive (needed for zombie reaping and auto-exit checks). + event_loop + .run(Some(Duration::from_secs(1)), &mut data, move |data| { + // ── Reap zombie children ────────────────────────────────── + // As subreaper we own all orphaned descendants. Reap them + // here on every tick so they don't accumulate. + reap_zombies(data); + + // ── Launch game once XWayland is ready ──────────────────── + if !command.is_empty() + && data.game_process.is_none() + && data.primary_pid.is_none() + && !data.state.game_launched + { + //if let Some(xdisplay) = data.state.xdisplay { + data.state.game_launched = true; + tracing::info!("Launching {:?}", command[0]); + + let mut cmd = std::process::Command::new(&command[0]); + + cmd.args(&command[1..]) + //.env("DISPLAY", format!(":{xdisplay}")) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + // Put the game in its own process group so we can + // kill the whole tree at once with kill(-pgid, …). + .process_group(0) + // Provide also WAYLAND_DISPLAY, so if the game or application + // is Wayland-native and doesn't support older X11 it'll still run. + .env("WAYLAND_DISPLAY", &gamescope_wayland_socket); + + if args.hdr { + tracing::debug!( + gamescope_wayland_socket, + "Setting GAMESCOPE_WAYLAND_DISPLAY for application" + ); + cmd.env("GAMESCOPE_WAYLAND_DISPLAY", &gamescope_wayland_socket); + cmd.env("ENABLE_GAMESCOPE_WSI", "1"); + // DXVK's dxgi.dll gates HDR color space exposure on this env var. + // Without it, both DX11 (DXVK) and DX12 (vkd3d-proton via DXVK dxgi) + // games will not see HDR as available. + cmd.env("DXVK_HDR", "1"); + } + + // Detect GPU vendor from render device and set VK_DRIVER_FILES + // so the game uses the same GPU as nescope. + if let Some(ref rd) = args.render_device { + if let Some(icd_path) = detect_gpu_icd(rd) { + cmd.env("VK_ICD_FILENAMES", &icd_path); + cmd.env("VK_DRIVER_FILES", &icd_path); // Mesa fallback + tracing::info!("GPU ICD → {icd_path}"); + } + } + + match cmd.spawn() { + Ok(child) => { + let pid = child.id(); + tracing::info!("Game process spawned (pid {pid})"); + data.primary_pid = Some(pid as i32); + data.game_pgid = Some(pid as i32); // PGID == PID due to .process_group(0) + data.game_process = Some(child); + } + Err(e) => { + tracing::error!("Failed to launch {:?}: {e}", command[0]); + data.loop_signal.stop(); + return; + } + } + //} + } + + // ── Poll primary process ────────────────────────────────── + // The launcher (e.g. Steam's shell wrapper) may exit quickly + // while the real game client stays alive as a reparented + // child. We keep the loop running until all mapped windows + // are gone. + if let Some(ref mut child) = data.game_process { + match child.try_wait() { + Ok(Some(status)) => { + tracing::info!("Primary game process exited: {status}"); + data.game_process = None; + } + Ok(None) => {} + Err(e) if e.raw_os_error() == Some(libc::ECHILD) => { + tracing::info!("Primary process already reaped"); + data.game_process = None; + } + Err(e) => { + tracing::warn!("try_wait error: {e}"); + data.game_process = None; + } + } + } + + // ── Auto-exit after all windows are gone ────────────────── + // Wait 5 s after the last mapped window disappears to give + // any lingering save-game / cleanup processes time to finish. + if data.state.game_launched && data.game_process.is_none() { + let has_windows = data.state.space.elements().next().is_some(); + if !has_windows { + let since = data + .state + .no_clients_since + .get_or_insert_with(std::time::Instant::now); + if since.elapsed() > Duration::from_secs(5) { + tracing::info!("No mapped windows for 5 s — exiting."); + kill_all_children(); + data.loop_signal.stop(); + return; + } + } else { + data.state.no_clients_since = None; + } + } + + // ── Flush Wayland clients ───────────────────────────────── + if let Err(e) = data.display.flush_clients() { + tracing::warn!("Error flushing Wayland clients: {e}"); + } + }) + .expect("Event loop error"); + + // ── Final cleanup ───────────────────────────────────────────────────── + // Kill the game process group directly — SIGKILL, not SIGTERM. + // This runs regardless of whether the shutdown handler fired. + if let Some(pgid) = data.game_pgid { + tracing::debug!("Final cleanup: SIGKILL to game pgid {pgid}"); + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } + } + kill_all_children_sigkill(); + + // Give kills time to be delivered before we remove sockets + std::thread::sleep(Duration::from_millis(200)); + reap_zombies(&mut data); + + // Remove socket files so the next launch doesn't hit stale-lock errors. + for name in [ + &socket_name_for_cleanup, + &format!("{}.lock", socket_name_for_cleanup), + ] { + let path = std::path::Path::new(&xdg_runtime_dir).join(name); + if path.exists() { + let _ = std::fs::remove_file(&path); + tracing::debug!("Cleaned up {}", path.display()); + } + } + + tracing::info!("nescope exiting cleanly."); +} + +// --------------------------------------------------------------------------- +// GPU ICD detection +// --------------------------------------------------------------------------- + +/// Detect the GPU vendor from a render device path and return the +/// appropriate Vulkan ICD JSON path for VK_ICD_FILENAMES. +fn detect_gpu_icd(render_device: &str) -> Option { + // Extract the device number (e.g. "renderD128" → "128") + let dev_name = std::path::Path::new(render_device) + .file_name() + .and_then(|n| n.to_str())?; + let card_num = dev_name.strip_prefix("renderD")?; + + let vendor_path = format!("/sys/class/drm/renderD{card_num}/device/vendor"); + let vendor_str = std::fs::read_to_string(&vendor_path).ok()?; + let vendor = u32::from_str_radix(vendor_str.trim().trim_start_matches("0x"), 16).ok()?; + + let glob_pattern = match vendor { + 0x1002 | 0x1022 => "radeon_icd*.json", + 0x10de => "nvidia_icd*.json", + 0x8086 => "intel_icd*.json", + _ => return None, + }; + + let icd_dirs = &["/usr/share/vulkan/icd.d", "/etc/vulkan/icd.d"]; + for dir in icd_dirs { + let pat = format!("{dir}/{glob_pattern}"); + if let Ok(entries) = glob::glob(&pat) { + let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + // Prefer 64-bit (x86_64) over 32-bit (i686) + paths.sort_by(|a, b| { + let a32 = a.to_string_lossy().contains("i686"); + let b32 = b.to_string_lossy().contains("i686"); + a32.cmp(&b32) + }); + if let Some(path) = paths.first() { + return Some(path.to_string_lossy().to_string()); + } + } + } + None +} + +/// Reap all zombie children without blocking. +/// +/// Called every event loop tick since we are a subreaper. +fn reap_zombies(data: &mut CalloopData) { + loop { + let mut status: i32 = 0; + let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) }; + match pid { + 0 => break, // no more zombies right now + -1 => break, // ECHILD — no children left + pid => { + if Some(pid) == data.primary_pid { + tracing::info!("Primary process reaped (pid {pid})"); + data.game_process = None; + } else { + tracing::debug!("Reaped orphaned child (pid {pid})"); + } + } + } + } +} + +/// Send SIGTERM to all direct children and their process groups. +/// +/// Because we are a subreaper, any descendant that re-parented itself (e.g. +/// via double-fork) also ends up under us. We scan `/proc` for direct +/// children and kill their process groups, which catches the full game tree. +fn kill_all_children() { + let our_pid = unsafe { libc::getpid() }; + + let proc = match std::fs::read_dir("/proc") { + Ok(d) => d, + Err(_) => return, + }; + + for entry in proc.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + continue; + } + + let stat_path = entry.path().join("stat"); + let Ok(contents) = std::fs::read_to_string(&stat_path) else { + continue; + }; + + // The `stat` format is: pid (comm) state ppid ... + // The comm field may contain spaces, so we search backwards from the + // closing ')' to find the field boundary reliably. + let Some(after_comm) = contents.rfind(')') else { + continue; + }; + let fields: Vec<&str> = contents[after_comm + 1..].split_whitespace().collect(); + let Some(ppid_str) = fields.get(1) else { + continue; + }; + let Ok(ppid) = ppid_str.parse::() else { + continue; + }; + + if ppid == our_pid { + let Ok(child_pid) = name_str.parse::() else { + continue; + }; + tracing::debug!("Killing child pid {child_pid} and its process group"); + unsafe { + libc::kill(-child_pid, libc::SIGTERM); // kill the process group + libc::kill(child_pid, libc::SIGTERM); // kill the process itself + } + } + } +} + +/// Like kill_all_children() but sends SIGKILL instead of SIGTERM. +fn kill_all_children_sigkill() { + let our_pid = unsafe { libc::getpid() }; + let proc = match std::fs::read_dir("/proc") { + Ok(d) => d, + Err(_) => return, + }; + for entry in proc.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + continue; + } + let stat_path = entry.path().join("stat"); + let Ok(contents) = std::fs::read_to_string(&stat_path) else { + continue; + }; + let Some(after_comm) = contents.rfind(')') else { + continue; + }; + let fields: Vec<&str> = contents[after_comm + 1..].split_whitespace().collect(); + let Some(ppid_str) = fields.get(1) else { + continue; + }; + let Ok(ppid) = ppid_str.parse::() else { + continue; + }; + if ppid == our_pid { + let Ok(child_pid) = name_str.parse::() else { + continue; + }; + tracing::debug!("SIGKILL to child pid {child_pid}"); + unsafe { + libc::kill(-child_pid, libc::SIGKILL); + libc::kill(child_pid, libc::SIGKILL); + } + } + } +} diff --git a/apps/nescope/src/protocols/gamescope-swapchain.xml b/apps/nescope/src/protocols/gamescope-swapchain.xml new file mode 100644 index 00000000..58ac8463 --- /dev/null +++ b/apps/nescope/src/protocols/gamescope-swapchain.xml @@ -0,0 +1,194 @@ + + + + + Copyright © 2023 Joshua Ashton for Valve Software + + 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 (including the next + paragraph) 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. + + + + This is a private Gamescope protocol. Regular Wayland clients must not use + it. + + + + + + + + + + + + + + + + + + + + Xwayland creates a wl_surface for each X11 window. It sends a + WL_SURFACE_ID client message to indicate the mapping between the X11 + windows and the wl_surface objects. + + This request overrides this mapping for a given X11 window, allowing an + X11 client to submit buffers via the Wayland protocol. The override + only affects buffer submission. Everything else (e.g. input events) + still uses Xwayland's WL_SURFACE_ID. + + x11_server is gotten by the GAMESCOPE_XWAYLAND_SERVER_ID property on the + root window of the associated server. + + + + + + + + Provide swapchain feedback to the compositor. + + This is what the useless tearing protocol should have been. + Absolutely not enough information in the final protocol to do what we want for SteamOS -- + which is have the Allow Tearing toggle apply to *both* Mailbox + Immediate and NOT fifo, + essentially acting as an override for tearing on/off for games. + The upstream protocol is very useless for our usecase here. + + Provides image count ahead of time instead of needing to try and calculate it from + an initial stall if we are doing low latency. + + Provides colorspace info for us to do HDR for both HDR10 PQ and scRGB. + The upstream HDR efforts seem to have no interest in supporting scRGB but we *need* that so /shrug + We can do it here now! Yipee! + + Swapchain feedback solves so many problems! :D + + + + + + + + + + + + + + + + + + Forward HDR metadata from Vulkan to the compositor. + + HDR Metadata Infoframe as per CTA 861.G spec. + This is expected to match exactly with the spec. + + display_primary_*: + Color Primaries of the Data. + Specifies X and Y coordinates. + These are coded as unsigned 16-bit values in units of + 0.00002, where 0x0000 represents zero and 0xC350 + represents 1.0000. + + white_point_*: + White Point of Colorspace Data. + Specifies X and Y coordinates. + These are coded as unsigned 16-bit values in units of + 0.00002, where 0x0000 represents zero and 0xC350 + represents 1.0000. + + max_display_mastering_luminance: + Max Mastering Display Luminance. + This value is coded as an unsigned 16-bit value in units of 1 cd/m2, + where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2. + + max_display_mastering_luminance: + Min Mastering Display Luminance. + This value is coded as an unsigned 16-bit value in units of + 0.0001 cd/m2, where 0x0001 represents 0.0001 cd/m2 and 0xFFFF + represents 6.5535 cd/m2. + + max_cll: + Max Content Light Level. + This value is coded as an unsigned 16-bit value in units of 1 cd/m2, + where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2. + + max_fall: + Max Frame Average Light Level. + This value is coded as an unsigned 16-bit value in units of 1 cd/m2, + where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2. + + + + + + + + + + + + + + + + + + Sets the display timing of the next commit. + + This gets reset to 0s in the compositor's state after a commit. + + + + + + + + + Gives information on the past presentation timing + + + + + + + + + + + + + + + Gives information on the refresh cycle for this swapchain + + + + + + + + + + diff --git a/apps/nescope/src/protocols/mod.rs b/apps/nescope/src/protocols/mod.rs new file mode 100644 index 00000000..22f7d1e2 --- /dev/null +++ b/apps/nescope/src/protocols/mod.rs @@ -0,0 +1,16 @@ +//! Generated Wayland protocol bindings for the gamescope swapchain protocol. + +#![allow(non_upper_case_globals, non_camel_case_types, unused)] + +use smithay::reexports::wayland_server; +use wayland_server::protocol::*; + +pub mod __interfaces { + use super::wayland_server; + use wayland_server::backend as wayland_backend; + use wayland_server::protocol::__interfaces::*; + wayland_scanner::generate_interfaces!("src/protocols/gamescope-swapchain.xml"); +} + +use self::__interfaces::*; +wayland_scanner::generate_server_code!("src/protocols/gamescope-swapchain.xml"); diff --git a/apps/nescope/src/screenshot_ipc.rs b/apps/nescope/src/screenshot_ipc.rs new file mode 100644 index 00000000..3ac51919 --- /dev/null +++ b/apps/nescope/src/screenshot_ipc.rs @@ -0,0 +1,264 @@ +//! Reading a window's pixels out, for things that are not the game. +//! +//! nescope does not render. It is a headless compositor whose job is to make a +//! game happy so `nescapture` can pull Vulkan frames straight off it — the +//! frames never pass through here at all. +//! +//! That works for games and not for anything else. A Steam client showing a +//! login QR is not a Vulkan application, so nothing captures it, and the person +//! who needs to scan that QR has no way to see it. +//! +//! This is the way out: a client surface's committed buffer, read directly and +//! handed to whoever asked. No rendering, no compositing, no swapchain. +//! +//! # Only shm buffers can be read +//! +//! nescope accepts dmabuf and never imports it — see `DmabufHandler` — because +//! nothing here needs the pixels. So a surface backed by dmabuf cannot be read +//! by this path, and [`Status::Unreadable`] says so rather than returning +//! something wrong. +//! +//! In practice that makes **software rendering a requirement, not a +//! preference**, for anything meant to be captured this way. A Steam client +//! started with its browser GPU-accelerated will hand over dmabuf and be +//! invisible here. +//! +//! # Shape +//! +//! nescope dials out, exactly as [`crate::input_ipc`] does, so whatever +//! supervises it is the listener and there is no race against a socket that +//! does not exist yet. One byte in, one framed image out: +//! +//! ```text +//! -> [u8 request] 0x01 = capture +//! <- [u8 status][u32 LE width][u32 LE height][RGBA…] +//! ``` +//! +//! Width and height are zero unless the status is [`Status::Ok`]. + +use std::io::{self, Read, Write}; +use std::os::unix::io::{AsFd, BorrowedFd}; +use std::os::unix::net::UnixStream; + +pub use crate::screenshot_wire::{Capture, Status}; +pub use crate::screenshot_wire::{REQUEST_CAPTURE, encode_reply}; + +use smithay::desktop::{Space, Window}; +use smithay::reexports::wayland_server::protocol::wl_buffer; +use smithay::wayland::seat::WaylandFocus; +use smithay::wayland::compositor::{BufferAssignment, SurfaceAttributes, with_states}; +use smithay::wayland::shm::with_buffer_contents; + +/// Read the pixels of the frontmost mapped window. +/// +/// Deliberately the *frontmost* rather than a composite of everything: nescope +/// does not composite, and inventing a stacking order here would be guessing at +/// something no one has asked for. One window is what a Steam login screen is. +pub fn capture_frontmost(space: &Space) -> (Status, Option) { + // Front to back rather than the frontmost alone. A client often maps a + // small helper window over its real one -- a splash, a tooltip, an + // override-redirect popup -- and reading only the topmost would report + // nothing readable while the window somebody wants is sitting right + // behind it. + let mut saw_window = false; + let mut saw_gpu_buffer = false; + for window in space.elements().rev() { + saw_window = true; + let Some(surface) = window.wl_surface() else { + continue; + }; + let buffer = with_states(&surface, |data| { + let mut attrs = data.cached_state.get::(); + match attrs.current().buffer { + Some(BufferAssignment::NewBuffer(ref buf)) => Some(buf.clone()), + _ => None, + } + }); + let Some(buffer) = buffer else { + continue; + }; + + // `with_buffer_contents` fails for anything that is not shm, which is + // how a GPU-rendered client is told apart from one that has not drawn. + match read_shm(&buffer) { + Ok(Some(capture)) => return (Status::Ok, Some(capture)), + Ok(None) => continue, + // Not shm. Try the GPU: importing the dmabuf and copying it back + // is the only way to see a client that renders on hardware, which + // under XWayland is every client, since smithay spawns it with + // glamor enabled and no way to ask for otherwise. + Err(()) => { + saw_gpu_buffer = true; + match crate::gpu_readback::from_wl_buffer(&buffer) { + Some(capture) => return (Status::Ok, Some(capture)), + // Stepped over rather than given up on: a GPU-backed + // splash often sits in front of a readable window. + None => continue, + } + } + } + } + + // Ordered by which answer is most actionable. A readable window anywhere + // wins; failing that, `Unreadable` now means the GPU path was tried and + // could not do it either -- a real failure rather than a limitation -- + // and it outranks "nothing has drawn" because it will not resolve by + // waiting. + match (saw_gpu_buffer, saw_window) { + (true, _) => (Status::Unreadable, None), + (false, true) => (Status::NoBuffer, None), + (false, false) => (Status::NoSurface, None), + } +} + +/// Copy an shm buffer out as RGBA. `Err` means it is not shm at all. +fn read_shm(buffer: &wl_buffer::WlBuffer) -> Result, ()> { + with_buffer_contents(buffer, |ptr, _len, data| { + let width = data.width.max(0) as usize; + let height = data.height.max(0) as usize; + let stride = data.stride.max(0) as usize; + if width == 0 || height == 0 { + return None; + } + let is_xrgb = + data.format == smithay::reexports::wayland_server::protocol::wl_shm::Format::Xrgb8888; + let mut rgba = vec![0u8; width * height * 4]; + for row in 0..height { + // SAFETY: the compositor guarantees the pool covers + // offset + stride * height, and each row copy stays inside it. + unsafe { + std::ptr::copy_nonoverlapping( + ptr.offset(data.offset as isize).add(row * stride), + rgba.as_mut_ptr().add(row * width * 4), + width * 4, + ); + } + } + if is_xrgb { + // The alpha byte is undefined in XRGB, and decoding a QR from a + // fully transparent image finds nothing. + for px in rgba.chunks_exact_mut(4) { + px[3] = 255; + } + } + Some(Capture { + width: width as u32, + height: height as u32, + rgba, + }) + }) + .map_err(|_| ()) +} + +/// Write one reply to an already-cloned socket half. +pub fn write_reply_to( + stream: &mut UnixStream, + status: Status, + capture: Option<&Capture>, +) -> io::Result<()> { + let bytes = encode_reply(status, capture); + // Blocking for the write: a capture is megabytes and the reader is waiting + // for it, so a partial non-blocking write would have to be buffered and + // re-driven for no benefit. + stream.set_nonblocking(false)?; + let result = stream.write_all(&bytes); + let _ = stream.set_nonblocking(true); + result +} + +/// The socket nescope reads requests from and writes images to. +pub struct ScreenshotIpcSource { + stream: UnixStream, +} + +impl ScreenshotIpcSource { + pub fn connect(path: &str) -> io::Result { + let stream = UnixStream::connect(path)?; + stream.set_nonblocking(true)?; + Ok(Self { stream }) + } + + /// A writable handle to the same socket. + /// + /// The source itself is moved into the event loop, so replies go out + /// through a clone — the same split `input_ipc` uses for its write side. + pub fn try_clone_writer(&self) -> io::Result { + self.stream.try_clone() + } + +} + +impl AsFd for ScreenshotIpcSource { + fn as_fd(&self) -> BorrowedFd<'_> { + self.stream.as_fd() + } +} + +impl calloop::EventSource for ScreenshotIpcSource { + /// One request byte per event. + type Event = u8; + type Metadata = (); + type Ret = (); + type Error = io::Error; + + fn process_events( + &mut self, + _readiness: calloop::Readiness, + _token: calloop::Token, + mut callback: F, + ) -> Result + where + F: FnMut(Self::Event, &mut Self::Metadata), + { + let mut tmp = [0u8; 64]; + loop { + match self.stream.read(&mut tmp) { + Ok(0) => return Ok(calloop::PostAction::Remove), + Ok(n) => { + for byte in &tmp[..n] { + callback(*byte, &mut ()); + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(calloop::PostAction::Continue) + } + + fn register( + &mut self, + poll: &mut calloop::Poll, + factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + // SAFETY: the fd stays owned by this source for as long as it is + // registered, and is unregistered before the stream is dropped — + // the same contract `input_ipc` relies on. + unsafe { + poll.register( + self.stream.as_fd(), + calloop::Interest::READ, + calloop::Mode::Level, + factory.token(), + ) + } + } + + fn reregister( + &mut self, + poll: &mut calloop::Poll, + factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + poll.reregister( + self.stream.as_fd(), + calloop::Interest::READ, + calloop::Mode::Level, + factory.token(), + ) + } + + fn unregister(&mut self, poll: &mut calloop::Poll) -> calloop::Result<()> { + poll.unregister(self.stream.as_fd()) + } +} diff --git a/apps/nescope/src/screenshot_wire.rs b/apps/nescope/src/screenshot_wire.rs new file mode 100644 index 00000000..4873861c --- /dev/null +++ b/apps/nescope/src/screenshot_wire.rs @@ -0,0 +1,104 @@ +//! The screenshot wire format, shared by the compositor and `nescope-shot`. +//! +//! Split from the compositor half so the tool can speak the protocol without +//! linking a Wayland compositor and an EGL renderer to do it — and so the two +//! cannot drift apart on the format, which duplicating it would invite. +//! +//! ```text +//! -> [u8 request] 0x01 = capture +//! <- [u8 status][u32 LE width][u32 LE height][RGBA…] +//! ``` +//! +//! Width and height are zero unless the status is [`Status::Ok`]. + +/// Ask for a picture of what is on screen. +pub const REQUEST_CAPTURE: u8 = 0x01; + +/// How a capture turned out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum Status { + Ok = 0, + /// No window is mapped at all. + NoSurface = 1, + /// A surface exists but could not be read by any route — not as shm, and + /// not by importing it from the GPU either. + Unreadable = 2, + /// Windows are mapped, but none has drawn anything readable yet. + /// + /// Distinct from [`Status::NoSurface`] on purpose: "nothing is running" and + /// "something is running but has not drawn" send you to different places, + /// and one status for both means watching a client start up tells you + /// nothing about which is happening. + NoBuffer = 3, +} + +/// A captured image, in RGBA8, top row first. +pub struct Capture { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// Serialise a reply. Split out from the socket so it can be asserted without +/// one. +pub fn encode_reply(status: Status, capture: Option<&Capture>) -> Vec { + let mut out = Vec::new(); + out.push(status as u8); + match capture { + Some(c) if status == Status::Ok => { + out.extend_from_slice(&c.width.to_le_bytes()); + out.extend_from_slice(&c.height.to_le_bytes()); + out.extend_from_slice(&c.rgba); + } + // A non-Ok status carries no pixels, and says so with zeroed + // dimensions rather than by the reader having to know. + _ => out.extend_from_slice(&[0u8; 8]), + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reply_carries_its_dimensions_before_its_pixels() { + let capture = Capture { + width: 2, + height: 1, + rgba: vec![1, 2, 3, 4, 5, 6, 7, 8], + }; + let bytes = encode_reply(Status::Ok, Some(&capture)); + assert_eq!(bytes[0], Status::Ok as u8); + assert_eq!(u32::from_le_bytes(bytes[1..5].try_into().unwrap()), 2); + assert_eq!(u32::from_le_bytes(bytes[5..9].try_into().unwrap()), 1); + assert_eq!(&bytes[9..], &capture.rgba[..]); + // The reader sizes its buffer from the header, so this has to be exact. + assert_eq!(bytes.len(), 9 + (2 * 1 * 4)); + } + + #[test] + fn a_failure_carries_no_pixels_and_zero_dimensions() { + // A reader that trusted a non-zero size on a failed capture would wait + // for bytes that are never sent. + for status in [Status::NoSurface, Status::Unreadable, Status::NoBuffer] { + let bytes = encode_reply(status, None); + assert_eq!(bytes.len(), 9, "{status:?}"); + assert_eq!(bytes[0], status as u8); + assert!(bytes[1..9].iter().all(|b| *b == 0), "{status:?}"); + } + } + + #[test] + fn pixels_are_dropped_when_the_status_is_not_ok() { + // Guards against a caller passing both a failure and a stale capture: + // the status is what the reader believes, so the two must agree. + let capture = Capture { + width: 4, + height: 4, + rgba: vec![0xff; 64], + }; + assert_eq!(encode_reply(Status::Unreadable, Some(&capture)).len(), 9); + } +} diff --git a/apps/nescope/src/state.rs b/apps/nescope/src/state.rs new file mode 100644 index 00000000..53659437 --- /dev/null +++ b/apps/nescope/src/state.rs @@ -0,0 +1,1013 @@ +//! Central compositor state for nescope. +//! +//! # Design +//! +//! nescope is a **headless Wayland compositor**. It creates a virtual output, +//! runs XWayland, and gives games a fully-functional Wayland environment. +//! Frame capture is handled by an external Vulkan interception library (similar +//! to `OBS_vkcapture`) — nescope never touches GBM, DRM, or DMA-BUF pools. +//! +//! Key differences from nestriscope (the previous proxy version): +//! +//! - No `ProxyClient` / host Wayland connection. +//! - No DMA-BUF forwarding or GBM buffer pool. +//! - Frame callbacks are driven by an internal calloop timer, not a host +//! frame-done event. +//! - Input comes via a [`calloop::channel::Channel`] rather than being decoded +//! from a proxy Wayland connection. + +#![allow(unused)] +use std::collections::HashSet; +use std::time::Duration; + +use calloop::channel::Sender; +use smithay::desktop::utils::{ + OutputPresentationFeedback, send_frames_surface_tree, + surface_presentation_feedback_flags_from_states, surface_primary_scanout_output, +}; +use smithay::desktop::{Space, Window}; +use smithay::input::pointer::CursorImageStatus; +use smithay::input::{Seat, SeatState}; +use smithay::output::Output; +use smithay::reexports::calloop::{LoopHandle, LoopSignal}; +use smithay::reexports::wayland_server::backend::ClientData; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{Display, DisplayHandle}; +use smithay::utils::{Clock, Monotonic, Point, Rectangle}; +use smithay::wayland::compositor::{CompositorClientState, CompositorState}; +use smithay::wayland::dmabuf::{DmabufGlobal, DmabufState}; +use smithay::wayland::output::OutputManagerState; +use smithay::wayland::pointer_constraints::PointerConstraintsState; +use smithay::wayland::presentation::PresentationState; +use smithay::wayland::presentation::Refresh; +use smithay::wayland::relative_pointer::RelativePointerManagerState; +use smithay::wayland::seat::WaylandFocus; +use smithay::wayland::selection::data_device::DataDeviceState; +use smithay::wayland::shell::xdg::XdgShellState; +use smithay::wayland::shm::ShmState; +use smithay::wayland::viewporter::ViewporterState; +use smithay::wayland::xwayland_shell::XWaylandShellState; +use smithay::xwayland::{X11Wm, XWayland, XWaylandEvent}; +use wayland_protocols::wp::presentation_time::server::wp_presentation_feedback; + +use crate::focus::KeyboardFocusTarget; +use crate::hdr::HdrState; +use crate::input::InputEvent; + +// --------------------------------------------------------------------------- +// CalloopData — threaded through the event loop +// --------------------------------------------------------------------------- + +pub struct CalloopData { + pub state: NescopeState, + pub display: Display, + pub loop_signal: LoopSignal, + pub libinput: Option, + /// The spawned game process handle (the launcher — may exit before the + /// real game client if Steam is the launcher). + pub game_process: Option, + /// PID of the primary child, retained after `game_process` is consumed + /// so we can identify when the subreaper reaps it. + pub primary_pid: Option, + /// Process group ID of the game (== primary_pid due to .process_group(0)). + /// Retained separately because game_process can be consumed by reap_zombies. + pub game_pgid: Option, +} + +// --------------------------------------------------------------------------- +// Per-client data +// --------------------------------------------------------------------------- + +pub struct ClientState { + pub compositor_state: CompositorClientState, +} + +impl ClientData for ClientState { + fn initialized(&self, _: smithay::reexports::wayland_server::backend::ClientId) {} + fn disconnected( + &self, + _: smithay::reexports::wayland_server::backend::ClientId, + _: smithay::reexports::wayland_server::backend::DisconnectReason, + ) { + } +} + +// --------------------------------------------------------------------------- +// X11 atoms + connection +// --------------------------------------------------------------------------- + +/// Gamescope-compatible X11 atoms used for focus and HDR signalling. +pub struct CachedAtoms { + pub net_active_window: u32, + pub gamescope_focused_app: u32, + pub gamescope_focusable_apps: u32, + pub gamescope_focusable_windows: u32, + pub gamescope_hdr_output_feedback: u32, + pub gamescope_xwayland_server_id: u32, + pub xa_window: u32, + pub xa_cardinal: u32, +} + +/// Secondary X11 connection dedicated to atom management and explicit +/// `SetInputFocus` calls (separate from the XWM connection). +pub struct X11InputConnection { + pub conn: smithay::reexports::x11rb::rust_connection::RustConnection, + pub root: u32, + pub atoms: CachedAtoms, +} + +// --------------------------------------------------------------------------- +// NescopeState — central compositor state +// --------------------------------------------------------------------------- + +pub struct NescopeState { + // ── Wayland server ──────────────────────────────────────────────────── + pub display_handle: DisplayHandle, + pub compositor_state: CompositorState, + pub xdg_shell_state: XdgShellState, + pub shm_state: ShmState, + /// DMA-BUF global — needed so XWayland can initialize DRI3 / GBM / glamor. + /// nescope never renders into these buffers itself. + pub dmabuf_state: DmabufState, + pub dmabuf_global: DmabufGlobal, + pub seat_state: SeatState, + pub data_device_state: DataDeviceState, + pub output_manager_state: OutputManagerState, + pub xwayland_shell_state: XWaylandShellState, + pub viewporter_state: ViewporterState, + pub seat: Seat, + pub space: Space, + pub output: Output, + pub clock: Clock, + pub presentation_state: PresentationState, + pub held_buffer: Option, + + // ── HDR / color management ──────────────────────────────────────────── + pub hdr: HdrState, + + // ── XWayland ────────────────────────────────────────────────────────── + pub xwm: Option, + /// X display number (set when XWayland becomes ready). + pub xdisplay: Option, + /// Secondary X11 connection for atom management. + pub x11_input_conn: Option, + /// Window ID of the currently focused X11 window. + pub focused_x11_window: Option, + /// Steam app ID of the focused game (0 = Steam itself). + pub focused_app_id: u32, + /// True when X11 focus needs to be re-synced on the next input event. + pub x11_focus_needs_reset: bool, + /// Gamescope WSI override surface (direct Vulkan → Wayland bypass). + pub override_surface: Option, + /// Surfaces that have announced themselves as Vulkan via gamescope protocol. + pub vulkan_surfaces: HashSet, + + // ── Input ───────────────────────────────────────────────────────────── + /// Sender half of the input channel — clone and hand to callers. + pub input_tx: Sender, + /// Current cursor position in logical output coordinates. + pub cursor_position: Point, + /// Cursor surface set by the client via wl_pointer.set_cursor. + pub cursor_status: CursorImageStatus, + /// Captured custom cursor image data (SHM pixel buffer), if available. + pub cursor_image_data: Option, + /// Whether the cursor image has been sent over IPC since capture. + pub cursor_image_sent: bool, + /// Write half of the input IPC socket (for sending cursor updates back). + pub ipc_write: Option, + /// Whether the cursor has been explicitly positioned at least once. + pub cursor_initialized: bool, + /// Game FPS tracking: frame count since last stats send. + game_frame_count: u64, + /// Last time stats were sent. + last_stats_time: std::time::Instant, + /// Last cursor position sent over IPC (for change detection). + last_sent_cursor_pos: Point, + /// Last cursor status sent over IPC. + last_sent_cursor_status: u8, + /// Timestamp of the last non-keyboard pointer event (for inactivity hiding). + pub last_pointer_activity: std::time::Instant, + + // ── Dimensions + frame rate ─────────────────────────────────────────── + pub width: u32, + pub height: u32, + pub fps: u32, + + // ── Lifecycle ───────────────────────────────────────────────────────── + pub loop_handle: LoopHandle<'static, CalloopData>, + /// True after the game command has been spawned. + pub game_launched: bool, + /// Time since all mapped windows disappeared (for auto-exit). + pub no_clients_since: Option, +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +impl NescopeState { + /// Create the compositor state and register all Wayland globals. + /// + /// Returns `(state, input_tx)` where `input_tx` is the sender that callers + /// use to inject [`InputEvent`]s from any thread. + pub fn new( + display_handle: DisplayHandle, + loop_handle: LoopHandle<'static, CalloopData>, + width: u32, + height: u32, + fps: u32, + hdr: bool, + render_device: Option, + ) -> (Self, Sender) { + let compositor_state = CompositorState::new::(&display_handle); + let xdg_shell_state = XdgShellState::new::(&display_handle); + let shm_state = ShmState::new::(&display_handle, vec![]); + let data_device_state = DataDeviceState::new::(&display_handle); + let output_manager_state = OutputManagerState::new_with_xdg_output::(&display_handle); + let xwayland_shell_state = XWaylandShellState::new::(&display_handle); + let viewporter_state = ViewporterState::new::(&display_handle); + RelativePointerManagerState::new::(&display_handle); + PointerConstraintsState::new::(&display_handle); + + // Seat: keyboard + pointer. + let mut seat_state = SeatState::new(); + let mut seat = seat_state.new_wl_seat(&display_handle, "nescope"); + seat.add_keyboard(Default::default(), 200, 25) + .expect("Failed to add keyboard"); + seat.add_pointer(); + + // Virtual output — physical size assumes 96 DPI. + let phys_w_mm = (width as f64 * 25.4 / 96.0) as i32; + let phys_h_mm = (height as f64 * 25.4 / 96.0) as i32; + let output = Output::new( + "nescope-virtual".into(), + smithay::output::PhysicalProperties { + size: (phys_w_mm, phys_h_mm).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "nescope".into(), + model: "Virtual".into(), + }, + ); + let mode = smithay::output::Mode { + size: (width as i32, height as i32).into(), + refresh: (fps * 1_000) as i32, + }; + output.change_current_state(Some(mode), None, None, Some((0, 0).into())); + output.set_preferred(mode); + output.create_global::(&display_handle); + + let mut space = Space::default(); + space.map_output(&output, (0, 0)); + + let clock = Clock::new(); + let presentation_state = PresentationState::new::(&display_handle, clock.id() as _); + + // DMA-BUF global — required so XWayland can set up DRI3/GBM/glamor. + // nescope itself never touches the buffers; it just accepts everything. + let (dmabuf_state, dmabuf_global) = + build_dmabuf_global::(&display_handle, render_device.as_deref()); + + // HDR + gamescope swapchain globals (optional). + let hdr_state = HdrState::new(&display_handle, hdr); + + // Input channel — the Sender is returned to the caller. + let (input_tx, input_rx) = calloop::channel::channel::(); + + // Register the input channel as a calloop event source. + // Events are dispatched synchronously in the idle callback via + // the CalloopData state. + loop_handle + .insert_source(input_rx, |event, _, data| { + if let calloop::channel::Event::Msg(ev) = event { + crate::input::process_input(ev, &mut data.state); + } + }) + .expect("Failed to register input channel"); + + let state = Self { + display_handle, + compositor_state, + xdg_shell_state, + shm_state, + dmabuf_state, + dmabuf_global, + seat_state, + data_device_state, + output_manager_state, + xwayland_shell_state, + viewporter_state, + seat, + space, + output, + clock, + presentation_state, + held_buffer: None, + hdr: hdr_state, + xwm: None, + xdisplay: None, + x11_input_conn: None, + focused_x11_window: None, + focused_app_id: 0, + x11_focus_needs_reset: false, + override_surface: None, + vulkan_surfaces: HashSet::new(), + input_tx: input_tx.clone(), + cursor_position: Point::from((0.0f64, 0.0f64)), + cursor_status: CursorImageStatus::default_named(), + cursor_image_data: None, + cursor_image_sent: false, + ipc_write: None, + cursor_initialized: false, + game_frame_count: 0, + last_stats_time: std::time::Instant::now(), + last_sent_cursor_pos: Point::from((-1.0f64, -1.0f64)), + last_sent_cursor_status: 0xFF, + last_pointer_activity: std::time::Instant::now(), + width, + height, + fps, + loop_handle, + game_launched: false, + no_clients_since: None, + }; + + (state, input_tx) + } + + // ----------------------------------------------------------------------- + // XWayland + // ----------------------------------------------------------------------- + + /// Spawn XWayland and register its calloop event source. + pub fn init_xwayland( + &mut self, + loop_handle: &LoopHandle<'static, CalloopData>, + display: Option, + ) { + let (xwayland, client) = XWayland::spawn( + &self.display_handle, + display, + std::iter::empty::<(String, String)>(), + true, + std::process::Stdio::null(), // XWayland stdout (very noisy) + std::process::Stdio::null(), // XWayland stderr (very noisy) + |_| {}, + ) + .expect("Failed to spawn XWayland"); + + let ret = loop_handle.insert_source(xwayland, move |event, _, data| match event { + XWaylandEvent::Ready { + x11_socket, + display_number, + .. + } => { + tracing::info!("XWayland ready on :{display_number}"); + let xwm = + X11Wm::start_wm(data.state.loop_handle.clone(), x11_socket, client.clone()) + .expect("Failed to start X11 WM"); + data.state.xwm = Some(xwm); + data.state.xdisplay = Some(display_number); + NescopeState::open_x11_input_conn(data, display_number); + } + XWaylandEvent::Error => { + tracing::error!("XWayland crashed at startup"); + } + }); + + if let Err(e) = ret { + tracing::error!("Failed to insert XWayland event source: {e}"); + } + } + + fn open_x11_input_conn(data: &mut CalloopData, display_number: u32) { + use smithay::reexports::x11rb::connection::Connection as _; + use smithay::reexports::x11rb::rust_connection::RustConnection; + + let display_str = format!(":{display_number}"); + match RustConnection::connect(Some(&display_str)) { + Ok((conn, screen_num)) => { + let root = conn.setup().roots[screen_num].root; + let atoms = CachedAtoms { + net_active_window: intern_atom(&conn, b"_NET_ACTIVE_WINDOW"), + gamescope_focused_app: intern_atom(&conn, b"GAMESCOPE_FOCUSED_APP"), + gamescope_focusable_apps: intern_atom(&conn, b"GAMESCOPE_FOCUSABLE_APPS"), + gamescope_focusable_windows: intern_atom(&conn, b"GAMESCOPE_FOCUSABLE_WINDOWS"), + gamescope_hdr_output_feedback: intern_atom( + &conn, + b"GAMESCOPE_HDR_OUTPUT_FEEDBACK", + ), + gamescope_xwayland_server_id: intern_atom( + &conn, + b"GAMESCOPE_XWAYLAND_SERVER_ID", + ), + xa_window: intern_atom(&conn, b"WINDOW"), + xa_cardinal: intern_atom(&conn, b"CARDINAL"), + }; + + if data.state.hdr.enabled { + data.state + .set_gamescope_atoms(&conn, root, &atoms, display_number); + } + + data.state.x11_input_conn = Some(X11InputConnection { conn, root, atoms }); + tracing::debug!("X11 input connection opened on :{display_number}"); + } + Err(e) => tracing::warn!("Failed to open X11 input connection: {e}"), + } + } + + /// Write gamescope-specific X11 root window properties so the WSI layer + /// can discover this compositor as a gamescope-compatible server. + pub fn set_gamescope_atoms( + &self, + conn: &smithay::reexports::x11rb::rust_connection::RustConnection, + root: u32, + atoms: &CachedAtoms, + display_number: u32, + ) { + use smithay::reexports::x11rb::connection::Connection; + use smithay::reexports::x11rb::protocol::xproto::{AtomEnum, PropMode}; + use smithay::reexports::x11rb::wrapper::ConnectionExt as _; + + let replace = PropMode::REPLACE; + let cardinal = AtomEnum::CARDINAL; + + // HDR output feedback — set to 1 when HDR is active. + let _ = conn.change_property32( + replace, + root, + atoms.gamescope_hdr_output_feedback, + cardinal, + &[1u32], + ); + // XWayland server ID — always 0 for a standalone compositor. + let _ = conn.change_property32( + replace, + root, + atoms.gamescope_xwayland_server_id, + cardinal, + &[0u32], + ); + let _ = conn.change_property32( + replace, + root, + atoms.gamescope_focused_app, + cardinal, + &[0u32], + ); + let _ = conn.flush(); + tracing::debug!("Set gamescope atoms on display :{display_number}"); + } + + // ----------------------------------------------------------------------- + // Override surface (gamescope WSI bypass) + // ----------------------------------------------------------------------- + + /// Register the gamescope WSI override surface for an X11 window. + pub fn override_window_surface(&mut self, x11_window: u32, surface: WlSurface) { + tracing::debug!(x11_window, "Registered gamescope WSI override surface"); + self.override_surface = Some(surface); + } + + // ----------------------------------------------------------------------- + // Resize + // ----------------------------------------------------------------------- + + /// Apply a new output resolution (e.g. from a runtime resize request). + #[allow(unused)] + pub fn apply_resize(&mut self, width: u32, height: u32) { + tracing::info!("Applying resize: {width}x{height}"); + self.width = width; + self.height = height; + + let mode = smithay::output::Mode { + size: (width as i32, height as i32).into(), + refresh: (self.fps * 1_000) as i32, + }; + self.output + .change_current_state(Some(mode), None, None, None); + + for window in self.space.elements().cloned().collect::>() { + if let Some(tl) = window.toplevel() { + tl.with_pending_state(|s| { + s.size = Some((width as i32, height as i32).into()); + }); + tl.send_pending_configure(); + } + if let Some(x11) = window.x11_surface() { + let geo = Rectangle::new((0, 0).into(), (width as i32, height as i32).into()); + if let Err(e) = x11.configure(geo) { + tracing::warn!("Failed to reconfigure X11 window on resize: {e}"); + } + } + } + } + + // ----------------------------------------------------------------------- + // X11 focus helpers + // ----------------------------------------------------------------------- + + pub(crate) fn sync_x11_focus(&mut self) { + use smithay::reexports::x11rb::connection::Connection as _; + use smithay::reexports::x11rb::protocol::xproto::{ + ConnectionExt as _, InputFocus, PropMode, + }; + + let Some(ref x11) = self.x11_input_conn else { + return; + }; + let Some(win_id) = self.focused_x11_window else { + return; + }; + + tracing::debug!("sync_x11_focus: id:{}", win_id); + + let _ = x11.conn.set_input_focus( + InputFocus::PARENT, + win_id, + smithay::reexports::x11rb::CURRENT_TIME, + ); + + let _ = x11.conn.change_property( + PropMode::REPLACE, + x11.root, + x11.atoms.net_active_window, + x11.atoms.xa_window, + 32, + 1, + &win_id.to_ne_bytes(), + ); + + // Build focusable apps and windows lists (gamescope-compatible). + let mut focusable_apps: Vec = Vec::new(); + let mut focusable_windows: Vec = Vec::new(); + + for win in self.space.elements() { + let Some(x11_win) = win.x11_surface() else { + continue; + }; + if x11_win.is_override_redirect() { + continue; + } + + let class = x11_win.class(); + let wapp_id: u32 = class + .strip_prefix("steam_app_") + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| { + if class.eq_ignore_ascii_case("steam") { + 769 + } else { + 0 + } + }); + + if wapp_id != 0 && !focusable_apps.contains(&wapp_id) { + focusable_apps.push(wapp_id); + } + focusable_windows.extend_from_slice(&[ + x11_win.window_id(), + wapp_id, + x11_win.pid().unwrap_or(0), + ]); + } + + let apps_bytes: Vec = focusable_apps + .iter() + .flat_map(|id| id.to_ne_bytes()) + .collect(); + let wins_bytes: Vec = focusable_windows + .iter() + .flat_map(|id| id.to_ne_bytes()) + .collect(); + + let _ = x11.conn.change_property( + PropMode::REPLACE, + x11.root, + x11.atoms.gamescope_focusable_apps, + x11.atoms.xa_cardinal, + 32, + focusable_apps.len() as u32, + &apps_bytes, + ); + let _ = x11.conn.change_property( + PropMode::REPLACE, + x11.root, + x11.atoms.gamescope_focusable_windows, + x11.atoms.xa_cardinal, + 32, + focusable_windows.len() as u32, + &wins_bytes, + ); + + let app_id = self.focused_app_id; + let (app_data, app_len): (&[u8], u32) = if app_id != 0 { + (&app_id.to_ne_bytes(), 1) + } else { + (&[], 0) + }; + let _ = x11.conn.change_property( + PropMode::REPLACE, + x11.root, + x11.atoms.gamescope_focused_app, + x11.atoms.xa_cardinal, + 32, + app_len, + app_data, + ); + + let _ = x11.conn.flush(); + self.x11_focus_needs_reset = false; + } + + pub(crate) fn set_keyboard_focus_to_window(&mut self, window: &Window) { + use smithay::utils::SERIAL_COUNTER; + let serial = SERIAL_COUNTER.next_serial(); + + if let Some(x11) = window.x11_surface() { + self.focused_x11_window = Some(x11.window_id()); + + if x11.wl_surface().is_none() { + if let Some(proxy) = self.find_xwayland_proxy_surface() { + let target = crate::focus::KeyboardFocusTarget::ProxiedX11 { + window: window.clone(), + proxy_surface: proxy, + }; + if let Some(kb) = self.seat.get_keyboard() { + kb.set_focus(self, Some(target), serial); + } + self.sync_x11_focus(); + self.x11_focus_needs_reset = true; + return; + } + } + } + + if let Some(kb) = self.seat.get_keyboard() { + kb.set_focus( + self, + Some(crate::focus::KeyboardFocusTarget::Window(window.clone())), + serial, + ); + } + self.sync_x11_focus(); + } + + pub(crate) fn find_xwayland_proxy_surface(&self) -> Option { + self.space + .elements() + .filter_map(|w| w.x11_surface()) + .find_map(|x11| x11.wl_surface()) + } + + /// Re-evaluate focus after any window map/unmap/property-change event. + pub(crate) fn determine_and_apply_focus(&mut self) { + let mut game_window: Option = None; + let mut fallback_window: Option = None; + + for win in self.space.elements() { + if let Some(x11) = win.x11_surface() { + if x11.is_override_redirect() { + continue; + } + let class = x11.class(); + if class.starts_with("steam_app_") { + game_window = Some(win.clone()); + } else { + fallback_window = Some(win.clone()); + } + } else { + // Native Wayland toplevel — treat as a game window + // (higher priority than unknown X11 windows). + if game_window.is_none() { + game_window = Some(win.clone()); + } + } + } + + let target = match (game_window, fallback_window) { + (Some(g), _) => g, + (None, Some(f)) => f, + (None, None) => return, + }; + + // For X11 windows, check if focus actually changed. + let new_id = target.x11_surface().map(|x| x.window_id()); + if new_id.is_some() && new_id == self.focused_x11_window && !self.x11_focus_needs_reset { + return; + } + + // Update app ID tracking (X11 only — native Wayland doesn't use steam class names). + if let Some(x11) = target.x11_surface() { + let class = x11.class(); + self.focused_app_id = class + .strip_prefix("steam_app_") + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| { + if class.eq_ignore_ascii_case("steam") { + 769 + } else { + 0 + } + }); + self.focused_x11_window = new_id; + } else { + // Native Wayland window — clear X11 focus state. + self.focused_x11_window = None; + self.focused_app_id = 0; + } + + self.set_keyboard_focus_to_window(&target); + + // Point the pointer at the window so games receive the initial + // enter event correctly. + use smithay::desktop::WindowSurfaceType; + if let Some(geo) = self.space.element_geometry(&target) { + let loc = geo.loc.to_f64(); + if target + .surface_under(Point::from((0.0f64, 0.0f64)), WindowSurfaceType::ALL) + .is_some() + { + if let Some(pointer) = self.seat.get_pointer() { + let serial = smithay::utils::SERIAL_COUNTER.next_serial(); + let target_focus = KeyboardFocusTarget::Window(target.clone()); + pointer.motion( + self, + Some((target_focus, loc)), + &smithay::input::pointer::MotionEvent { + location: loc, + serial, + time: self.clock.now().as_millis(), + }, + ); + pointer.frame(self); + } + } + } + } + + // ----------------------------------------------------------------------- + // Frame callbacks — driven by the fps timer in main.rs + // ----------------------------------------------------------------------- + + /// Called from the calloop timer at target fps. + pub fn on_frame_tick(&mut self) { + self.game_frame_count += 1; + + let output = self.output.clone(); + let now = self.clock.now(); + + // 1. Release the held buffer → frees a swapchain image for the game. + self.held_buffer.take(); + + // 2. Presentation feedback — tell clients about vsync timing. + let mut output_presentation_feedback = OutputPresentationFeedback::new(&output); + for window in self.space.elements().cloned().collect::>() { + window.take_presentation_feedback( + &mut output_presentation_feedback, + surface_primary_scanout_output, + |_, _| wp_presentation_feedback::Kind::Vsync, + ); + } + output_presentation_feedback.presented( + now, + output + .current_mode() + .map(|mode| Refresh::fixed(Duration::from_secs_f64(1_000f64 / mode.refresh as f64))) + .unwrap_or(Refresh::Unknown), + 0, + wp_presentation_feedback::Kind::Vsync, + ); + + // 3. Frame callbacks — tell the game it can present the next frame. + for window in self.space.elements().cloned().collect::>() { + if let Some(surface) = window.wl_surface() { + send_frames_surface_tree(&*surface, &output, now, Some(Duration::ZERO), |_, _| { + Some(output.clone()) + }); + } + } + + if let Some(ref s) = self.override_surface { + send_frames_surface_tree(s, &output, now, Some(Duration::ZERO), |_, _| { + Some(output.clone()) + }); + } + + // 4. Send periodic stats over IPC + let now = std::time::Instant::now(); + if now.duration_since(self.last_stats_time) >= std::time::Duration::from_secs(1) { + let fps = self.game_frame_count.min(255) as u8; + let count = self.game_frame_count as u32; + self.game_frame_count = 0; + self.last_stats_time = now; + + if let Some(ref mut ipc) = self.ipc_write { + use std::io::Write; + let mut buf = Vec::with_capacity(6); + nesprotocol::stats::encode_nescope_stats(&mut buf, fps, count); + let len = buf.len() as u16; + let _ = ipc.write_all(&len.to_le_bytes()); + let _ = ipc.write_all(&buf); + let _ = ipc.flush(); + } + } + + // 5. Send cursor position update over IPC (if changed). + self.send_cursor_update(); + } + + fn send_cursor_update(&mut self) { + use std::io::Write; + let ipc = match self.ipc_write.as_mut() { + Some(s) => s, + None => return, + }; + + // Send cursor image if it hasn't been sent yet + if let Some(ref image_data) = self.cursor_image_data { + if !self.cursor_image_sent { + let x = self.cursor_position.x as f32; + let y = self.cursor_position.y as f32; + let mut buf = Vec::with_capacity(21 + image_data.rgba.len()); + nesprotocol::input::encode_cursor_image( + &mut buf, + x, + y, + image_data.width, + image_data.height, + image_data.hotspot_x, + image_data.hotspot_y, + &image_data.rgba, + ); + let len = buf.len() as u16; + let _ = ipc.write_all(&len.to_le_bytes()); + let _ = ipc.write_all(&buf); + let _ = ipc.flush(); + tracing::debug!( + "cursor: sent custom image {}x{} ({} bytes) pos=({:.0},{:.0})", + image_data.width, + image_data.height, + image_data.rgba.len(), + x, + y + ); + self.cursor_image_sent = true; + self.last_sent_cursor_pos = self.cursor_position; + self.last_sent_cursor_status = nesprotocol::input::CURSOR_IMAGE; + return; + } + } + + let status = match &self.cursor_status { + CursorImageStatus::Hidden => nesprotocol::input::CURSOR_HIDDEN, + CursorImageStatus::Named(_) => nesprotocol::input::CURSOR_NAMED, + CursorImageStatus::Surface(_) => nesprotocol::input::CURSOR_SURFACE, + }; + + let pos_changed = (self.cursor_position.x - self.last_sent_cursor_pos.x).abs() > 0.5 + || (self.cursor_position.y - self.last_sent_cursor_pos.y).abs() > 0.5; + let status_changed = status != self.last_sent_cursor_status; + + if !pos_changed && !status_changed { + return; + } + + let x = self.cursor_position.x as f32; + let y = self.cursor_position.y as f32; + + let mut buf = Vec::with_capacity(10); + nesprotocol::input::encode_cursor_update(&mut buf, x, y, status); + + let len = buf.len() as u16; + let _ = ipc.write_all(&len.to_le_bytes()); + let _ = ipc.write_all(&buf); + let _ = ipc.flush(); + + tracing::debug!( + "cursor: sent update pos=({:.0},{:.0}) status={status}", + x, + y + ); + self.last_sent_cursor_pos = self.cursor_position; + self.last_sent_cursor_status = status; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn intern_atom( + conn: &smithay::reexports::x11rb::rust_connection::RustConnection, + name: &[u8], +) -> u32 { + use smithay::reexports::x11rb::protocol::xproto::ConnectionExt as _; + conn.intern_atom(false, name) + .map_err(Into::into) + .and_then(|c| c.reply()) + .map(|r| r.atom) + .unwrap_or_else(|e| { + tracing::warn!("intern_atom({:?}): {e}", String::from_utf8_lossy(name)); + 0 + }) +} + +/// Register the `zwp_linux_dmabuf_v1` global. +/// +/// nescope does not render into DMA-BUFs itself, but XWayland needs DRI3 +/// (backed by this global) for GBM/glamor initialization — without it Steam's +/// GLX initialization fails. A v4 global with default feedback is preferred; +/// a v3 fallback is used when no render node is available. +fn build_dmabuf_global( + display: &DisplayHandle, + render_device: Option<&str>, +) -> (DmabufState, DmabufGlobal) +where + D: smithay::wayland::dmabuf::DmabufHandler + + smithay::reexports::wayland_server::GlobalDispatch< + wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_v1::ZwpLinuxDmabufV1, + smithay::wayland::dmabuf::DmabufGlobalData, + > + 'static, +{ + use smithay::backend::allocator::{Format, Fourcc, Modifier}; + use smithay::wayland::dmabuf::{DmabufFeedbackBuilder, DmabufState}; + use std::os::unix::fs::MetadataExt; + + // Formats declared to XWayland for DRI3. The actual pixel format used + // by the game's Vulkan swapchain is independent of this list. + let formats = [ + Format { + code: Fourcc::Argb8888, + modifier: Modifier::Linear, + }, + Format { + code: Fourcc::Xrgb8888, + modifier: Modifier::Linear, + }, + Format { + code: Fourcc::Abgr8888, + modifier: Modifier::Linear, + }, + Format { + code: Fourcc::Abgr2101010, + modifier: Modifier::Linear, + }, + Format { + code: Fourcc::Argb2101010, + modifier: Modifier::Linear, + }, + Format { + code: Fourcc::Argb8888, + modifier: Modifier::Invalid, + }, + Format { + code: Fourcc::Xrgb8888, + modifier: Modifier::Invalid, + }, + ]; + + let mut dmabuf_state = DmabufState::new(); + + let render_node = render_device + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .or_else(|| { + std::env::var("NESCOPE_RENDER_DEVICE") + .ok() + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + }) + .or_else(|| { + std::fs::read_dir("/dev/dri").ok().and_then(|dir| { + dir.filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with("renderD")) + .map(|e| e.path()) + }) + }); + + if let Some(ref path) = render_node { + if let Ok(meta) = std::fs::metadata(path) { + let dev = meta.rdev(); + tracing::info!( + "DMA-BUF: using render node {} (dev={}:{})", + path.display(), + libc::major(dev), + libc::minor(dev) + ); + if let Ok(feedback) = DmabufFeedbackBuilder::new(dev, formats.iter().copied()).build() { + let global = + dmabuf_state.create_global_with_default_feedback::(display, &feedback); + return (dmabuf_state, global); + } + } + } else { + tracing::warn!( + "No render node in /dev/dri — XWayland DRI3 will be unavailable; \ + Steam may fail with glXChooseVisual" + ); + } + + let global = dmabuf_state.create_global::(display, formats.iter().copied()); + (dmabuf_state, global) +} diff --git a/apps/nescope/src/xwm.rs b/apps/nescope/src/xwm.rs new file mode 100644 index 00000000..e85601bc --- /dev/null +++ b/apps/nescope/src/xwm.rs @@ -0,0 +1,7 @@ +//! X11 window manager helpers. +//! +//! The `XwmHandler` implementation lives in `handlers.rs` on `CalloopData` +//! because the `X11Wm` calloop event source dispatches through it. +//! +//! This file is reserved for future X11 atom helpers, window-policy tweaks, +//! or gamescope X11 atom extensions that outgrow the handlers module.