diff --git a/Cargo.lock b/Cargo.lock index 2ef71260..2c01cd4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,12 +52,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.6" @@ -341,7 +335,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -359,16 +353,16 @@ dependencies = [ "bitflags 2.13.2", "nix", "polling", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "tracing", ] [[package]] name = "cc" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -401,9 +395,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -496,7 +490,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -765,7 +759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -885,7 +879,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -926,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" dependencies = [ "drm-sys", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -1013,7 +1007,7 @@ checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1069,9 +1063,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" @@ -1187,7 +1181,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -1221,9 +1215,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" dependencies = [ "cc", "cfg-if", @@ -1250,7 +1244,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-link", ] @@ -1352,8 +1346,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -1453,9 +1445,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1637,7 +1629,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.10.2", + "rand 0.10.3", "tokio", "url", "xmltree", @@ -1744,7 +1736,7 @@ dependencies = [ "pin-project", "portable-atomic", "portmapper", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "rustc-hash", "rustls", @@ -1774,7 +1766,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.4.3", "n0-error", - "rand 0.10.2", + "rand 0.10.3", "serde", "url", "zeroize", @@ -1794,7 +1786,7 @@ dependencies = [ "n0-error", "n0-future", "portable-atomic", - "rand 0.10.2", + "rand 0.10.3", "rustls", "simple-dns", "strum", @@ -1857,7 +1849,7 @@ dependencies = [ "num_enum", "pin-project", "postcard", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "rustls", "rustls-pki-types", @@ -2051,7 +2043,7 @@ dependencies = [ "libc", "libspa-sys", "nom 8.0.0", - "rustix 1.1.4", + "rustix 1.1.5", "system-deps", ] @@ -2130,9 +2122,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ "hashbrown 0.17.1", ] @@ -2227,7 +2219,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -2249,7 +2241,7 @@ dependencies = [ "n0-error", "n0-future", "ndk-context", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "rustc-hash", "rustls", @@ -2356,7 +2348,6 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "wayland-scanner", ] [[package]] @@ -2660,7 +2651,7 @@ dependencies = [ "getrandom 0.4.3", "identity-hash", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", @@ -2963,7 +2954,7 @@ dependencies = [ "libspa", "libspa-sys", "pipewire-sys", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -2980,7 +2971,7 @@ dependencies = [ [[package]] name = "pixelforge" version = "0.9.1" -source = "git+https://github.com/DatCaptainHorse/pixelforge.git?rev=681fa4dd8bce5dabf008d00983e991b0eb8b3696#681fa4dd8bce5dabf008d00983e991b0eb8b3696" +source = "git+https://github.com/DatCaptainHorse/pixelforge.git?rev=b4b7b36af6f9cbd0fedae220915edd75de933743#b4b7b36af6f9cbd0fedae220915edd75de933743" dependencies = [ "ash", "futures-channel", @@ -3028,7 +3019,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.5.3", "pin-project-lite", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -3085,7 +3076,7 @@ dependencies = [ "n0-future", "netwatch", "num_enum", - "rand 0.10.2", + "rand 0.10.3", "serde", "smallvec", "socket2", @@ -3233,9 +3224,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3395,9 +3386,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", "errno", @@ -3601,7 +3592,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3779,7 +3770,7 @@ dependencies = [ "libc", "profiling", "rand 0.9.5", - "rustix 1.1.4", + "rustix 1.1.5", "scopeguard", "sha2 0.10.9", "smallvec", @@ -3889,9 +3880,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3915,7 +3906,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -3967,7 +3958,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -4008,7 +3999,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4092,7 +4083,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -4158,7 +4149,7 @@ dependencies = [ "getrandom 0.4.3", "http", "httparse", - "rand 0.10.2", + "rand 0.10.3", "ring", "rustls-pki-types", "sha1_smol", @@ -4393,9 +4384,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -4450,9 +4441,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ "base64 0.23.1", "http", @@ -4606,7 +4597,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -4640,7 +4631,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix 1.1.5", "smallvec", "wayland-sys", ] @@ -4652,7 +4643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.4", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] @@ -4715,7 +4706,7 @@ checksum = "0dde9c29be0f723a573977de51ee455bf3dfa03652730a74f9dd3b337e374d75" dependencies = [ "bitflags 2.13.2", "downcast-rs", - "rustix 1.1.4", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] @@ -5219,7 +5210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.4", + "rustix 1.1.5", "x11rb-protocol", ] @@ -5280,7 +5271,7 @@ checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "synstructure", ] @@ -5321,7 +5312,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", "synstructure", ] @@ -5375,7 +5366,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] diff --git a/apps/nescapture/Cargo.toml b/apps/nescapture/Cargo.toml index 7ae7a971..69217304 100644 --- a/apps/nescapture/Cargo.toml +++ b/apps/nescapture/Cargo.toml @@ -32,9 +32,9 @@ toml = "0.8" serde = { version = "1", features = ["derive"] } # Vulkan Video hardware encoding. -pixelforge = { git = "https://github.com/DatCaptainHorse/pixelforge.git", rev = "681fa4dd8bce5dabf008d00983e991b0eb8b3696", features = ["dmabuf"] } +pixelforge = { git = "https://github.com/DatCaptainHorse/pixelforge.git", rev = "b4b7b36af6f9cbd0fedae220915edd75de933743" } -# libc for DMA-BUF OS primitives +# libc for socket options libc = "0.2" # Shared IPC protocol diff --git a/apps/nescapture/README.md b/apps/nescapture/README.md index 13a88441..12e8ad66 100644 --- a/apps/nescapture/README.md +++ b/apps/nescapture/README.md @@ -24,15 +24,12 @@ Game process │ vkCmdBindPipeline → detect HUD │ │ vkQueuePresentKHR → capture+encode │ └──────────────────────────────────────────────┘ - │ GPU blit, same device + │ GPU blit on the game's queue, signals a timeline semaphore ▼ -final_image (DMA-BUF exportable) - │ get_dmabuf_fd(final_memory) +ring slot (an image on the game's own device) + │ read in place, once the blit's point is reached ▼ -DmaBufImporter (pixelforge VkDevice) - │ import_or_reuse() → vk::Image - ▼ -ColorConverter (GPU compute shader) +ColorConverter (GPU compute shader, the encoder's own queue) │ BGRA/RGB10/FP16 → NV12/P010/YUV444 ▼ Encoder (Vulkan Video: H.264 / H.265 / AV1) @@ -41,8 +38,24 @@ Encoder (Vulkan Video: H.264 / H.265 / AV1) Unix datagram → neshub → the client ``` -CPU fallback exists only for driver configurations without DMA-BUF external -memory export. +The encoder runs on the game's own `VkDevice`. The layer creates that device +with what the encoder needs: the extensions and feature bits it asks for, and +queues of its own wherever a queue family has one to spare, since a `VkQueue` +may not be submitted to from two threads at once. Where no family has room, +the game's queue is created internally synchronized and shared. Every step of +a frame is ordered on the GPU; nothing waits on the CPU. + +Where the conversion is only the YUV matrix and the device has +`VK_VALVE_video_encode_rgb_conversion`, the encoder takes the RGB frame and +converts it itself, and the converter is not built. That path runs at limited +range, since the one driver offering it writes limited range whatever it is +asked; everything else is full range. + +When the game's device cannot host the encoder -- an instance the loader +cannot raise to Vulkan 1.1, no queue the encoder could safely use, a driver +refusing the additions, or `NESCAPTURE_SHARED_DEVICE=0` -- the encoder gets a +device of its own, and each frame is read back on the CPU and uploaded there. +That works everywhere and costs a copy each way. --- @@ -93,11 +106,18 @@ implicit layer is loaded into *every* Vulkan process on the system. | `NESCAPTURE_CODEC` | best available | `h264`, `h265` or `av1`; probes if unset | | `NESCAPTURE_FORMAT` | `yuv420` | `yuv420` or `yuv444` | | `NESCAPTURE_DEPTH` | auto | `8` or `10`; inferred from the swapchain `VkFormat` if unset | -| `NESCAPTURE_BITRATE` | `10000` | CBR target in kbps. Ignored when `NESCAPTURE_QP` is set | -| `NESCAPTURE_QP` | _(unset)_ | Constant QP instead of CBR | +| `NESCAPTURE_RC` | _(inferred)_ | `cqp`, `cbr` or `vbr`. Unset infers `cqp` when `NESCAPTURE_QP` is set, `cbr` otherwise | +| `NESCAPTURE_BITRATE` | `10000` | Target bitrate in kbps, under `cbr` and `vbr` | +| `NESCAPTURE_BITRATE_MAX` | 1.5x the target | VBR ceiling in kbps. Ignored outside `vbr` | +| `NESCAPTURE_QP` | _(unset)_ | Constant QP, under `cqp` | | `NESCAPTURE_FPS` | `60` | Target frame rate | | `NESCAPTURE_IDR_INTERVAL` | `4` | Force an IDR every N **seconds** | +| `NESCAPTURE_INTRA_REFRESH` | _(off)_ | Set to `1` to replace periodic key frames with an intra refresh cycle | +| `NESCAPTURE_INTRA_REFRESH_QP_DELTA` | `-4` | QP shift inside the refresh band; negative spends bits on it | +| `NESCAPTURE_INTRA_REFRESH_SHAPE` | auto | `rows`, `columns` or `partitions`; the driver chooses if unset | | `NESCAPTURE_TUNE` | _(unset)_ | `highquality`, `lowlatency`, `ultralowlatency`, `lossless` | +| `NESCAPTURE_SHARED_DEVICE` | _(on)_ | Set to `0` to leave the game's device as the game asked for it, and encode on a device of the encoder's own with CPU readback | +| `NESCAPTURE_RGB_ENCODE` | _(on)_ | Set to `0` to always convert with the shader, even where the encoder could convert RGB itself | | `NESCAPTURE_CONFIG` | _(unset)_ | Path to the per-app shader-hash TOML | | `NESCAPTURE_GAME_NAME` | exe basename | Override app identification for that config | | `NESCAPTURE_DISCOVER` | _(unset)_ | Set to `1` to log every draw, for finding HUD shaders | @@ -107,6 +127,57 @@ Everything else is decided at runtime: the client asks `neshub` for a codec or bitrate change and it arrives on the command socket, so the encoder is reconfigured without a restart. +### Intra refresh + +Instead of a key frame every few seconds, each picture codes one slice of the +image as intra, so after a full cycle every part has been refreshed. The same +cost, paid evenly, with no picture much larger than any other — which is what +a link with a latency budget wants, since a key frame is the largest frame +there is. + +**The cycle length is not configurable, deliberately.** It is bounded by how +many refresh regions the picture actually has, and that depends on the codec's +block size: at 1080p an H.265 picture is 17 CTB rows tall where an H.264 one is +68 macroblock rows, so the same duration is comfortable for one codec and +impossible for the other. The encoder knows the codec, the resolution and what +the device allows, and derives it from the key frame interval it replaces. + +The refresh here spreads cost only; it does not make the cycle a recovery +point. Doing that would restrict prediction on every picture — expensive, and +what turns the refreshed band into a visible discontinuity — to buy a +guarantee this stream gets more cheaply from the client asking for an IDR. + +The band is coded intra every cycle, so it carries none of the refinement its +neighbours have accumulated and reads as a strip of lower quality sweeping +across the picture. `NESCAPTURE_INTRA_REFRESH_QP_DELTA` spends bits back into +it, out of the rest of the frame. Measured at 1080p with ColorVideoVDP, `-4` +recovers a fifth of what intra refresh costs and the encoded size does not +grow — but the best value depends on the content, and too large a shift +starves the rest of the frame faster than too small a one helps. Devices that +cannot express a negative delta, or whose refresh regions follow the slice +layout rather than a block sweep, decline it and say so. + +`NESCAPTURE_INTRA_REFRESH_SHAPE` stays configurable because the device cannot +answer it: whether a horizontal or vertical sweep looks better depends on how +the content moves. It also changes how many regions there are — a 1080p +picture in 64×64 blocks is 17 rows but 30 columns, so `columns` allows a +longer cycle and thus less intra per picture. + +### Rate control + +`cbr` holds every frame to the same size, which is what a link with a fixed +budget wants. `vbr` holds the same *average* while letting a frame that needs +it spend up to the ceiling — a scene change is coded rather than smeared, at +the cost of a burst the path has to absorb. `cqp` holds quality constant and +lets the bitrate go wherever the content takes it, which is a recording +setting rather than a streaming one. + +The command socket carries a target bitrate but has no way to name a mode +beyond CBR and constant QP. A target arriving while the encode is `vbr` is +therefore applied as a target, leaving the mode and the ceiling alone — so the +ceiling asked for at launch survives a session, and a congestion controller +adjusts underneath it. Retargeting costs no rebuild and no key frame. + --- ## Per-app shader-hash config @@ -145,10 +216,10 @@ src/ ├── framebuffer.rs image view and framebuffer tracking ├── commands.rs vkCmdBind*, vkCmdDraw*, vkCmdBeginRenderPass ├── swapchain.rs vkCreateSwapchainKHR, image enumeration -├── capture.rs GPU blit to the capture image, DMA-BUF export +├── capture.rs GPU blit into the capture ring, CPU readback fallback ├── present.rs vkQueuePresentKHR, encode dispatch ├── encode.rs pixelforge pipeline, codec probing, IPC send -├── dmabuf_import.rs cross-device zero-copy import +├── shared.rs creating the game's device for the encoder to share ├── config.rs per-app TOML shader-hash config └── discovery.rs draw-call logging for shader discovery ``` diff --git a/apps/nescapture/scripts/verify-chain.sh b/apps/nescapture/scripts/verify-chain.sh index b68245fb..f3632e8d 100755 --- a/apps/nescapture/scripts/verify-chain.sh +++ b/apps/nescapture/scripts/verify-chain.sh @@ -4,7 +4,7 @@ # Runs a Vulkan workload under the compositor with the layer active, then checks # the encoded result against the compositor's own readback of the same frames. # Two independent paths see the same content: the compositor reads the surface -# back to the CPU, the layer exports it as a DMA-BUF and encodes it on the GPU. +# back to the CPU, the layer encodes it on the GPU without it leaving the device. # Agreement between them is the evidence; a single path cannot tell a correct # frame from a plausible-looking wrong one. # @@ -96,7 +96,8 @@ wait $RECV || true FRAMES="$(cat "$WORK/frames.txt")" echo echo "frames encoded: $FRAMES" -grep -m1 "First import" "$WORK/run.log" || echo " (no DMA-BUF import logged)" +grep -m1 "encoding on the game's own device" "$WORK/run.log" \ + || echo " (encoding on a device of its own, with CPU readback)" python3 - "$WORK" "$STREAM" "$FRAMES" <<'PY' import glob, subprocess, sys diff --git a/apps/nescapture/src/capture.rs b/apps/nescapture/src/capture.rs index 5e8b08cb..1a30aa82 100644 --- a/apps/nescapture/src/capture.rs +++ b/apps/nescapture/src/capture.rs @@ -1,20 +1,15 @@ // ───────────────────────────────────────────────────────────────────────────── // capture.rs — Frame capture helpers // -// Each ring slot is allocated with VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT -// so that after the GPU blit we can export an fd and import it into pixelforge's -// separate VkDevice for zero-copy hardware encoding via DmaBufImporter. -// -// The ring is allocated tiled where the driver offers a single-plane DRM -// format modifier, and linear where it does not. The stride and the chosen -// modifier come from the image itself, queried once at allocation, and both -// travel with every frame: the importer creates its side with that exact -// modifier, and a wrong value there is a correctly sized frame of nonsense. +// Each ring slot is a destination for the blit out of the presented swapchain +// image. Where the encoder shares the game's device, a slot is an image on +// that device which the encoder reads in place, ordered by a timeline +// semaphore the blit signals. Where it has a device of its own, a slot is a +// host-visible image the encoder thread reads back on the CPU. // ───────────────────────────────────────────────────────────────────────────── -use crate::state::{CB_STATE, CAPTURE_SLOTS, CaptureRing, CaptureSlot, DEVICE_STATE}; +use crate::state::{CAPTURE_SLOTS, CB_STATE, CaptureRing, CaptureSlot, DEVICE_STATE}; use ash::vk::{self, Handle}; -use std::os::raw::c_int; fn make_subresource_range() -> vk::ImageSubresourceRange { vk::ImageSubresourceRange { @@ -73,75 +68,10 @@ unsafe fn find_memory_type( crate::memory::pick_memory_type(&types, bits, want) } -/// Modifiers the device can both receive a transfer into and have sampled from, -/// for `fmt`. -/// -/// Both feature bits matter and for different sides: the layer writes the image -/// with `vkCmdCopyImage`, and pixelforge samples it in the colour-conversion -/// compute shader after importing it. A modifier that supports only one of -/// those is no use to this ring. -unsafe fn supported_modifiers( - ds: &crate::state::DeviceState, - fmt: vk::Format, -) -> Vec { - let k = unsafe { crate::dispatch_key(ds.physical_device.as_raw() as *const std::ffi::c_void) }; - let Some(istate) = crate::state::INSTANCE_STATE.get(&k) else { - return Vec::new(); - }; - let Some(get_props2) = istate.get_physical_device_format_properties2 else { - return Vec::new(); - }; - - // Two calls: the first to learn the count, the second to fill the list. - let mut list = vk::DrmFormatModifierPropertiesListEXT::default(); - let mut props2 = vk::FormatProperties2 { - p_next: &mut list as *mut _ as *mut std::ffi::c_void, - ..Default::default() - }; - unsafe { get_props2(ds.physical_device, fmt, &mut props2) }; - - let count = list.drm_format_modifier_count as usize; - if count == 0 { - return Vec::new(); - } - let mut entries = vec![vk::DrmFormatModifierPropertiesEXT::default(); count]; - list.p_drm_format_modifier_properties = entries.as_mut_ptr(); - let mut props2 = vk::FormatProperties2 { - p_next: &mut list as *mut _ as *mut std::ffi::c_void, - ..Default::default() - }; - unsafe { get_props2(ds.physical_device, fmt, &mut props2) }; - - let needed = - vk::FormatFeatureFlags::TRANSFER_DST | vk::FormatFeatureFlags::SAMPLED_IMAGE; - entries - .iter() - .filter(|e| e.drm_format_modifier_tiling_features.contains(needed)) - .map(|e| crate::modifiers::ModifierProps { - modifier: e.drm_format_modifier, - plane_count: e.drm_format_modifier_plane_count, - }) - .collect() -} - -/// Which modifier the driver actually gave an image. -/// -/// The image is created from a list of acceptable modifiers and the driver -/// chooses; the importer needs the one it chose, not the list. `None` when the -/// extension is absent or the call fails, which sends the caller back to the -/// linear path rather than letting it guess. -unsafe fn image_modifier(ds: &crate::state::DeviceState, image: vk::Image) -> Option { - let get = ds.fp.get_image_drm_format_modifier_properties_ext?; - let mut props = vk::ImageDrmFormatModifierPropertiesEXT::default(); - if unsafe { get(ds.raw, image, &mut props) } != vk::Result::SUCCESS { - return None; - } - Some(props.drm_format_modifier) -} - // ── Image allocators ────────────────────────────────────────────────────────── -/// Plain HOST_VISIBLE image (nescapture capture — no cross-device sharing needed). +/// Plain HOST_VISIBLE image, read back on the CPU when the encoder has a +/// device of its own. unsafe fn allocate_host_image( ds: &crate::state::DeviceState, w: u32, @@ -174,110 +104,33 @@ unsafe fn allocate_host_image( unsafe { alloc_image(ds, &ci, None, label) } } -/// DMA-BUF exportable image (final capture — imported into pixelforge for encoding). +/// A capture slot on a device the encoder shares: device-local, optimally +/// tiled, and readable by the encoder's queues as well as the presenting one. /// -/// Falls back to a plain host image if the driver rejects external memory. -/// In that case `get_dmabuf_fd` will return `None` and the encoder will use -/// the CPU pixel-readback fallback. -unsafe fn allocate_dmabuf_image( +/// Created in the converter's input format rather than the swapchain's, which +/// differ for an sRGB swapchain: the copy between the two is legal, since they +/// have the same texel size, and the converter then needs no mutable-format +/// view to read it. +unsafe fn allocate_shared_image( ds: &crate::state::DeviceState, w: u32, h: u32, fmt: vk::Format, + families: &[u32], label: &str, -) -> Option<(vk::Image, vk::DeviceMemory, u64)> { - let export_ai = vk::ExportMemoryAllocateInfo { - s_type: vk::StructureType::EXPORT_MEMORY_ALLOCATE_INFO, - p_next: std::ptr::null_mut(), - handle_types: vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, - _marker: std::marker::PhantomData, - }; - - // Tiled first. A linear destination means the copy detiles a whole frame on - // the way in and the encoder samples a linear image on the way out; the - // importer has always been able to take a tiled buffer, and only this side - // was ever linear. - let candidates = unsafe { supported_modifiers(ds, fmt) }; - if let Some(chosen) = crate::modifiers::pick_modifier(&candidates) - && chosen.modifier != crate::modifiers::LINEAR - { - let mut ext_img = vk::ExternalMemoryImageCreateInfo { - s_type: vk::StructureType::EXTERNAL_MEMORY_IMAGE_CREATE_INFO, - p_next: std::ptr::null_mut(), - handle_types: vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, - _marker: std::marker::PhantomData, - }; - let modifiers = [chosen.modifier]; - let mut mod_list = vk::ImageDrmFormatModifierListCreateInfoEXT::default() - .drm_format_modifiers(&modifiers); - mod_list.p_next = &mut ext_img as *mut _ as *mut std::ffi::c_void; - - let ci = vk::ImageCreateInfo { - s_type: vk::StructureType::IMAGE_CREATE_INFO, - p_next: &mod_list as *const _ as *const _, - flags: vk::ImageCreateFlags::empty(), - image_type: vk::ImageType::TYPE_2D, - format: fmt, - extent: vk::Extent3D { - width: w, - height: h, - depth: 1, - }, - mip_levels: 1, - array_layers: 1, - samples: vk::SampleCountFlags::TYPE_1, - tiling: vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT, - usage: vk::ImageUsageFlags::TRANSFER_DST, - sharing_mode: vk::SharingMode::EXCLUSIVE, - queue_family_index_count: 0, - p_queue_family_indices: std::ptr::null(), - initial_layout: vk::ImageLayout::UNDEFINED, - _marker: std::marker::PhantomData, - }; - if let Some((image, memory)) = unsafe { alloc_image(ds, &ci, Some(&export_ai), label) } { - // Ask which one it took rather than assuming the one offered: the - // importer is given an explicit modifier and a wrong value there is - // a correctly sized frame full of nonsense. - match unsafe { image_modifier(ds, image) } { - Some(actual) => { - log::info!( - "capture '{label}': tiled, modifier {actual:#018x} \ - (offered {:#018x}, {} candidate(s))", - chosen.modifier, - candidates.len() - ); - return Some((image, memory, actual)); - } - None => { - log::warn!( - "capture '{label}': the driver would not report the modifier it \ - chose — falling back to linear rather than importing a guess" - ); - unsafe { - (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()); - (ds.fp.free_memory)(ds.raw, memory, std::ptr::null()); - } - } - } - } else { - log::warn!("capture '{label}': tiled allocation refused — falling back to linear"); - } +) -> Option<(vk::Image, vk::DeviceMemory)> { + let (sharing_mode, count, indices) = if families.len() > 1 { + ( + vk::SharingMode::CONCURRENT, + families.len() as u32, + families.as_ptr(), + ) } else { - log::info!( - "capture '{label}': no tiled modifier offered ({} candidate(s)) — linear", - candidates.len() - ); - } - - let ext_img = vk::ExternalMemoryImageCreateInfo { - s_type: vk::StructureType::EXTERNAL_MEMORY_IMAGE_CREATE_INFO, - p_next: std::ptr::null_mut(), - handle_types: vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, - _marker: std::marker::PhantomData, + (vk::SharingMode::EXCLUSIVE, 0, std::ptr::null()) }; let ci = vk::ImageCreateInfo { s_type: vk::StructureType::IMAGE_CREATE_INFO, - p_next: &ext_img as *const _ as *const _, + p_next: std::ptr::null(), flags: vk::ImageCreateFlags::empty(), image_type: vk::ImageType::TYPE_2D, format: fmt, @@ -289,24 +142,53 @@ unsafe fn allocate_dmabuf_image( mip_levels: 1, array_layers: 1, samples: vk::SampleCountFlags::TYPE_1, - tiling: vk::ImageTiling::LINEAR, - usage: vk::ImageUsageFlags::TRANSFER_DST, - sharing_mode: vk::SharingMode::EXCLUSIVE, - queue_family_index_count: 0, - p_queue_family_indices: std::ptr::null(), + tiling: vk::ImageTiling::OPTIMAL, + // Written by the blit, sampled by the converter, and copied from when + // the encoder takes RGB input and converts it itself. + usage: vk::ImageUsageFlags::TRANSFER_DST + | vk::ImageUsageFlags::SAMPLED + | vk::ImageUsageFlags::TRANSFER_SRC, + sharing_mode, + queue_family_index_count: count, + p_queue_family_indices: indices, initial_layout: vk::ImageLayout::UNDEFINED, _marker: std::marker::PhantomData, }; - if let Some((image, memory)) = unsafe { alloc_image(ds, &ci, Some(&export_ai), label) } { - return Some((image, memory, crate::modifiers::LINEAR)); + let mut image = vk::Image::null(); + if unsafe { (ds.fp.create_image)(ds.raw, &ci, std::ptr::null(), &mut image) } + != vk::Result::SUCCESS + { + return None; } - log::warn!( - "DMA-BUF alloc failed for '{}' — using plain host image. \ - Zero-copy GPU path will be unavailable; CPU readback fallback active.", - label - ); - unsafe { allocate_host_image(ds, w, h, fmt, label) } - .map(|(i, m)| (i, m, crate::modifiers::LINEAR)) + let mut mr = vk::MemoryRequirements::default(); + unsafe { (ds.fp.get_image_memory_requirements)(ds.raw, image, &mut mr) }; + let Some(mt) = + (unsafe { find_memory_type(ds, mr.memory_type_bits, crate::memory::Want::DeviceLocal) }) + else { + log::warn!("no device-local memory type for '{label}' - not allocating"); + unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) }; + return None; + }; + let ai = vk::MemoryAllocateInfo { + s_type: vk::StructureType::MEMORY_ALLOCATE_INFO, + p_next: std::ptr::null(), + allocation_size: mr.size, + memory_type_index: mt, + _marker: std::marker::PhantomData, + }; + let mut mem = vk::DeviceMemory::null(); + if unsafe { (ds.fp.allocate_memory)(ds.raw, &ai, std::ptr::null(), &mut mem) } + != vk::Result::SUCCESS + { + unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) }; + return None; + } + if unsafe { (ds.fp.bind_image_memory)(ds.raw, image, mem, 0) } != vk::Result::SUCCESS { + unsafe { (ds.fp.free_memory)(ds.raw, mem, std::ptr::null()) }; + unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) }; + return None; + } + Some((image, mem)) } unsafe fn alloc_image( @@ -379,62 +261,6 @@ unsafe fn alloc_image( Some((image, mem)) } -// ── Stride query ────────────────────────────────────────────────────────────── - -/// Row stride in bytes of a LINEAR image, or 0 on failure. -pub unsafe fn query_stride(ds: &crate::state::DeviceState, image: vk::Image, modifier: u64) -> u32 { - // A DRM_FORMAT_MODIFIER image is laid out in memory planes, not colour - // planes, and asking it for COLOR is invalid — the aspect has to name the - // memory plane. Single-plane is all `pick_modifier` will accept, so plane - // zero is the whole image. - let aspect_mask = if modifier == crate::modifiers::LINEAR { - vk::ImageAspectFlags::COLOR - } else { - vk::ImageAspectFlags::MEMORY_PLANE_0_EXT - }; - let sub = vk::ImageSubresource { - aspect_mask, - mip_level: 0, - array_layer: 0, - }; - let mut layout = vk::SubresourceLayout::default(); - unsafe { (ds.fp.get_image_subresource_layout)(ds.raw, image, &sub, &mut layout) }; - layout.row_pitch as u32 -} - -// ── DMA-BUF fd export ───────────────────────────────────────────────────────── - -/// Export `memory` as a DMA-BUF fd via vkGetMemoryFdKHR. -/// Callers own the fd and must close it when done. -/// Returns `None` if VK_KHR_external_memory_fd is unavailable. -pub unsafe fn get_dmabuf_fd( - ds: &crate::state::DeviceState, - memory: vk::DeviceMemory, -) -> Option { - let f = match ds.fp.get_memory_fd_khr { - Some(f) => f, - None => { - log::warn!("get_dmabuf_fd: vkGetMemoryFdKHR not available"); - return None; - } - }; - let fi = vk::MemoryGetFdInfoKHR { - s_type: vk::StructureType::MEMORY_GET_FD_INFO_KHR, - p_next: std::ptr::null(), - memory, - handle_type: vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, - _marker: std::marker::PhantomData, - }; - let mut fd: c_int = -1; - let result = unsafe { f(ds.raw, &fi, &mut fd) }; - if result == vk::Result::SUCCESS && fd >= 0 { - Some(fd) - } else { - log::warn!("get_dmabuf_fd failed: result={:?} fd={}", result, fd); - None - } -} - // ── ensure helpers ──────────────────────────────────────────────────────────── pub unsafe fn ensure_hudless_image(ds: &crate::state::DeviceState, w: u32, h: u32, f: vk::Format) { @@ -467,6 +293,34 @@ pub unsafe fn ensure_hudless_image(ds: &crate::state::DeviceState, w: u32, h: u3 /// A rebuild destroys images the encoder may still be reading, so it only /// happens when every slot has come back. Resolution changes are rare and one /// dropped frame at a resize is not worth a use-after-free. +/// Whether an existing ring can serve a request, or has to be rebuilt. +/// +/// **The extent must match exactly.** This used to accept any ring at least as +/// large as the request, which sounds like a saving and is a corruption: the +/// blit follows the new, smaller extent while the slot image stays the old +/// size, so everything outside the copied region keeps whatever the previous +/// resolution left there. The encoder then sends the whole image, stale margins +/// and all -- a band of the old picture down the right edge and along the +/// bottom. A resolution change is rare enough that rebuilding is the cheaper +/// mistake. +/// +/// `image_count` is part of the identity because the blit buffers are allocated +/// one per (image, slot) pair. A swapchain that gained an image needs more of +/// them, and a ring that kept the old count would silently stop capturing +/// whenever that image came round. +fn ring_still_serves( + existing: (u32, u32, vk::Format), + existing_family: u32, + existing_image_count: usize, + wanted: (u32, u32, vk::Format), + wanted_family: u32, + wanted_image_count: usize, +) -> bool { + existing == wanted + && existing_family == wanted_family + && existing_image_count == wanted_image_count +} + unsafe fn ensure_capture_ring( ds: &crate::state::DeviceState, ring: &mut Option, @@ -482,12 +336,15 @@ unsafe fn ensure_capture_ring( // allocated one per (image, slot) pair. A swapchain that gained an // image needs more of them, and a ring that kept the old count would // silently stop capturing whenever that image came round. - if ew >= w - && eh >= h - && ef == f - && existing.queue_family == queue_family - && existing.image_count == image_count - { + // + if ring_still_serves( + (ew, eh, ef), + existing.queue_family, + existing.image_count, + (w, h, f), + queue_family, + image_count, + ) { return true; } if !ds.capture_slots.all_free() { @@ -497,7 +354,11 @@ unsafe fn ensure_capture_ring( unsafe { destroy_capture_ring(ds, old) }; } } - match unsafe { create_capture_ring(ds, w, h, f, queue_family, image_count) } { + let generation = ds + .ring_generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + match unsafe { create_capture_ring(ds, w, h, f, queue_family, image_count, generation) } { Some(fresh) => { *ring = Some(fresh); true @@ -513,6 +374,7 @@ unsafe fn create_capture_ring( f: vk::Format, queue_family: u32, image_count: usize, + generation: u64, ) -> Option { let pci = vk::CommandPoolCreateInfo { s_type: vk::StructureType::COMMAND_POOL_CREATE_INFO, @@ -549,6 +411,9 @@ unsafe fn create_capture_ring( unsafe { (ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null()) }; return None; } + for &cb in &blits { + unsafe { crate::device::stamp(ds, cb.as_raw() as *mut std::ffi::c_void) }; + } // Pre-signalled: the first wait on a fresh slot must return immediately. let fci = vk::FenceCreateInfo { @@ -558,14 +423,57 @@ unsafe fn create_capture_ring( _marker: std::marker::PhantomData, }; - let (timestamp_pool, timestamp_period) = - unsafe { create_timestamp_pool(ds, queue_family) }; + let (timestamp_pool, timestamp_period) = unsafe { create_timestamp_pool(ds, queue_family) }; + + // On a device the encoder shares, the slots are images the encoder reads + // in place, in the format its converter reads, and every blit advances a + // timeline the encoder waits on. + let shared = ds.shared_encoder(); + let slot_format = match shared { + Some(_) => match crate::encode::vk_format_to_input_format(f.as_raw() as u32) { + Some(input) => input.vk_format(), + None => { + log::warn!( + "swapchain format {} has no converter input format; not capturing", + f.as_raw() + ); + unsafe { (ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null()) }; + return None; + } + }, + None => f, + }; + let blit_timeline = match shared { + Some(s) => match s.create_timeline() { + Some(t) => t, + None => { + log::warn!("could not create the capture timeline; not capturing"); + unsafe { (ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null()) }; + return None; + } + }, + None => vk::Semaphore::null(), + }; let mut slots = Vec::with_capacity(CAPTURE_SLOTS); - for i in 0..CAPTURE_SLOTS { - let Some((image, memory, modifier)) = - (unsafe { allocate_dmabuf_image(ds, w, h, f, "capture") }) - else { + for _ in 0..CAPTURE_SLOTS { + let allocated = match shared { + Some(s) => unsafe { + allocate_shared_image( + ds, + w, + h, + slot_format, + &s.image_families(queue_family), + "capture", + ) + }, + None => unsafe { allocate_host_image(ds, w, h, f, "capture") }, + }; + let Some((image, memory)) = allocated else { + if let Some(s) = shared { + s.destroy_timeline(blit_timeline); + } unsafe { destroy_partial_ring(ds, command_pool, slots) }; return None; }; @@ -575,29 +483,27 @@ unsafe fn create_capture_ring( { unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) }; unsafe { (ds.fp.free_memory)(ds.raw, memory, std::ptr::null()) }; + if let Some(s) = shared { + s.destroy_timeline(blit_timeline); + } unsafe { destroy_partial_ring(ds, command_pool, slots) }; return None; } - let stride = unsafe { query_stride(ds, image, modifier) }; - // Export once. Each frame hands the encoder a dup of this fd, which - // costs a file-descriptor clone instead of a kernel export per frame. - let dmabuf_fd = unsafe { get_dmabuf_fd(ds, memory) }.unwrap_or(-1); - if dmabuf_fd < 0 { - log::warn!("capture slot {i}: no DMA-BUF export, falling back to CPU readback"); - } slots.push(CaptureSlot { image, memory, - dmabuf_fd, - stride, - modifier, fence, }); } log::info!( - "capture ring: {CAPTURE_SLOTS} slots of {w}x{h} fmt={} on queue family {queue_family}", - f.as_raw() + "capture ring {generation}: {CAPTURE_SLOTS} slots of {w}x{h} fmt={} on queue family {queue_family}{}", + slot_format.as_raw(), + if shared.is_some() { + ", read in place by the encoder" + } else { + "" + } ); Some(CaptureRing { command_pool, @@ -608,6 +514,7 @@ unsafe fn create_capture_ring( timestamp_pool, timestamp_period, size: (w, h, f), + generation, queue_family, // Zero so the first frame always records: no real extent equals it, so // the invalidation check in `capture_present_frame` fires once and then @@ -618,6 +525,8 @@ unsafe fn create_capture_ring( }, present_wait: Vec::new(), retired: Vec::new(), + blit_timeline, + blit_value: 0, }) } @@ -631,9 +540,6 @@ unsafe fn destroy_partial_ring( (ds.fp.destroy_fence)(ds.raw, slot.fence, std::ptr::null()); (ds.fp.destroy_image)(ds.raw, slot.image, std::ptr::null()); (ds.fp.free_memory)(ds.raw, slot.memory, std::ptr::null()); - if slot.dmabuf_fd >= 0 { - libc::close(slot.dmabuf_fd); - } } (ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null()); } @@ -670,6 +576,14 @@ pub unsafe fn destroy_capture_ring(ds: &crate::state::DeviceState, ring: Capture destroy(ds.raw, ring.timestamp_pool, std::ptr::null()); } } + // The fences above cover the blits that signal it, and every slot being + // back covers the encoder work that waits on it: a slot returns only once + // its conversion has finished. + if !ring.blit_timeline.is_null() + && let Some(s) = ds.shared.as_ref() + { + s.destroy_timeline(ring.blit_timeline); + } unsafe { destroy_partial_ring(ds, ring.command_pool, ring.slots) }; } @@ -814,6 +728,10 @@ pub struct CaptureSubmission { /// semaphores were consumed by the blit submission, so presenting on them /// again would be a double wait. pub present_wait: vk::Semaphore, + /// The point this blit's timeline signal reaches, on a device the encoder + /// shares. The encoder waits on it on the GPU instead of on the slot's + /// fence on the CPU. + pub blit: Option, } /// Create the blit timestamp pool, or a null handle where it cannot be used. @@ -893,10 +811,16 @@ unsafe fn create_timestamp_pool( /// GPU nanoseconds the last blit into `slot` took. /// -/// Call only after that slot's fence has signalled, so the results are there -/// and the `WAIT` flag returns immediately. `None` when timing is off, when the -/// driver refuses the results, or when the counter wrapped between the pair. -pub unsafe fn blit_gpu_time_ns(ds: &crate::state::DeviceState, slot: usize) -> Option { +/// Call only once that blit has finished, so `wait` returns immediately: after +/// the slot's fence has signalled, or after work that waited on the blit has +/// itself finished. `None` when timing is off, when the driver refuses the +/// results, when they are not there yet and `wait` is false, or when the +/// counter wrapped between the pair. +pub unsafe fn blit_gpu_time_ns( + ds: &crate::state::DeviceState, + slot: usize, + wait: bool, +) -> Option { let ring_guard = ds.capture_ring.lock().ok()?; let ring = ring_guard.as_ref()?; if ring.timestamp_pool.is_null() { @@ -913,7 +837,11 @@ pub unsafe fn blit_gpu_time_ns(ds: &crate::state::DeviceState, slot: usize) -> O std::mem::size_of_val(&ticks), ticks.as_mut_ptr() as *mut std::ffi::c_void, std::mem::size_of::() as vk::DeviceSize, - vk::QueryResultFlags::WAIT | vk::QueryResultFlags::TYPE_64, + if wait { + vk::QueryResultFlags::WAIT | vk::QueryResultFlags::TYPE_64 + } else { + vk::QueryResultFlags::TYPE_64 + }, ) }; if result != vk::Result::SUCCESS { @@ -1044,18 +972,29 @@ unsafe fn record_blit( vk::ImageLayout::PRESENT_SRC_KHR, si ); + // The slot goes to GENERAL, which every reader accepts: the converter, the + // encoder's own copy when it takes RGB input, and the CPU readback, which + // maps the memory and may only do so in GENERAL. + let b4 = image_barrier!( + vk::AccessFlags::TRANSFER_WRITE, + vk::AccessFlags::MEMORY_READ | vk::AccessFlags::HOST_READ, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::GENERAL, + fi + ); + let after = [b3, b4]; unsafe { (ds.fp.cmd_pipeline_barrier)( cb, vk::PipelineStageFlags::TRANSFER, - vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::PipelineStageFlags::BOTTOM_OF_PIPE | vk::PipelineStageFlags::HOST, vk::DependencyFlags::empty(), 0, std::ptr::null(), 0, std::ptr::null(), - 1, - &b3, + after.len() as u32, + after.as_ptr(), ); } @@ -1144,6 +1083,7 @@ pub unsafe fn capture_present_frame( let slot = ring.slots.get(slot_index)?; let fence = slot.fence; let fi = slot.image; + let slot_memory = slot.memory; // A free slot's fence is already signalled — the encoder side waits on it // before it ever reads the slot. This covers the paths that abandon a frame @@ -1156,9 +1096,9 @@ pub unsafe fn capture_present_frame( } // The recording depends on the source image, the destination image and the - // extent. A ring survives the swapchain shrinking, so the extent can move - // under recordings whose images are still valid — invalidate on it here - // rather than trusting every caller to have noticed. + // extent. A swapchain recreated at the same extent keeps the ring but + // replaces the source images, so invalidate here rather than trusting every + // caller to have noticed. if ring.blit_extent != ext { ring.blits_recorded.iter_mut().for_each(|r| *r = false); ring.blit_extent = ext; @@ -1173,11 +1113,27 @@ pub unsafe fn capture_present_frame( ring.blits_recorded[blit] = true; } - let wait_stages = vec![vk::PipelineStageFlags::TRANSFER; app_waits.len()]; + + // On a shared device the blit also advances the ring's timeline. A + // timeline signal needs its value given alongside, and the binary + // semaphore next to it a placeholder the driver ignores. + let blit_point = (!ring.blit_timeline.is_null()) + .then(|| pixelforge::TimelinePoint::new(ring.blit_timeline, ring.blit_value + 1)); + let signals = [present_wait, ring.blit_timeline]; + let signal_values = [0, blit_point.map_or(0, |p| p.value)]; + let timeline_info = vk::TimelineSemaphoreSubmitInfo { + signal_semaphore_value_count: 2, + p_signal_semaphore_values: signal_values.as_ptr(), + ..Default::default() + }; let subi = vk::SubmitInfo { s_type: vk::StructureType::SUBMIT_INFO, - p_next: std::ptr::null(), + p_next: if blit_point.is_some() { + (&raw const timeline_info).cast() + } else { + std::ptr::null() + }, wait_semaphore_count: app_waits.len() as u32, p_wait_semaphores: if app_waits.is_empty() { std::ptr::null() @@ -1191,8 +1147,8 @@ pub unsafe fn capture_present_frame( }, command_buffer_count: 1, p_command_buffers: &cb, - signal_semaphore_count: 1, - p_signal_semaphores: &present_wait, + signal_semaphore_count: if blit_point.is_some() { 2 } else { 1 }, + p_signal_semaphores: signals.as_ptr(), _marker: std::marker::PhantomData, }; @@ -1208,9 +1164,19 @@ pub unsafe fn capture_present_frame( } } + if let Some(p) = blit_point { + ring.blit_value = p.value; + } + + // Once per process, and only when asked for: reads this frame back and + // says what range its values are in. See `probe_float_range`. + let pool = ring.command_pool; + unsafe { probe_float_range(ds, pool, queue, fi, slot_memory, fence, fmt, ext) }; + Some(CaptureSubmission { slot: guard, present_wait, + blit: blit_point, }) } @@ -1277,7 +1243,8 @@ unsafe fn ensure_present_semaphore( ) -> Option { let create = ds.fp.create_semaphore?; if ring.present_wait.len() <= image_index { - ring.present_wait.resize(image_index + 1, vk::Semaphore::null()); + ring.present_wait + .resize(image_index + 1, vk::Semaphore::null()); } if ring.present_wait[image_index] == vk::Semaphore::null() { let ci = vk::SemaphoreCreateInfo { @@ -1295,7 +1262,7 @@ unsafe fn ensure_present_semaphore( Some(ring.present_wait[image_index]) } -// ── CPU pixel readback (fallback when DMA-BUF unavailable) ─────────────────── +// ── CPU pixel readback (when the encoder has a device of its own) ─────────── pub unsafe fn read_frame_pixels( ds: &crate::state::DeviceState, @@ -1344,3 +1311,567 @@ pub unsafe fn read_frame_pixels( unsafe { (ds.fp.unmap_memory)(ds.raw, mem) }; Some(pixels) } + +// ── One-shot float-range probe ──────────────────────────────────────────────── + +/// Decode one IEEE half into a float. +fn half_to_f32(bits: u16) -> f32 { + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1f) as u32; + let mant = (bits & 0x3ff) as u32; + let out = match exp { + 0 if mant == 0 => sign << 31, + // Subnormal: normalise it by hand. Irrelevant to what the probe asks, + // but a wrong answer here would be a wrong answer everywhere. + 0 => { + let mut e = -1i32; + let mut m = mant; + while m & 0x400 == 0 { + m <<= 1; + e -= 1; + } + m &= 0x3ff; + (sign << 31) | (((e + 127 - 15) as u32) << 23) | (m << 13) + } + 0x1f => (sign << 31) | 0x7f80_0000 | (mant << 13), + _ => (sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13), + }; + f32::from_bits(out) +} + +/// What the probe found in one frame. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FloatRange { + pub max: f32, + pub above_one: u64, + pub components: u64, +} + +impl FloatRange { + /// Whether this frame settles it. A single component over 1.0 can only be + /// linear extended-range colour: PQ is defined on [0, 1] and nothing that + /// encodes it can leave that range. + pub fn is_extended(&self) -> bool { + self.above_one > 0 + } +} + +/// Scan a mapped `R16G16B16A16_SFLOAT` image for the largest colour component +/// and how many exceed 1.0. +/// +/// Alpha is skipped: it is 1.0 on every opaque frame and would be counted as +/// evidence of exactly the thing being looked for. +pub fn scan_float_range(base: &[u8], row_pitch: usize, w: u32, h: u32) -> Option { + let mut max = f32::NEG_INFINITY; + let mut above_one = 0u64; + let mut components = 0u64; + for row in 0..h as usize { + let start = row * row_pitch; + let end = start + w as usize * 8; + let line = base.get(start..end)?; + for texel in line.as_chunks::<8>().0 { + for c in 0..3 { + let v = half_to_f32(u16::from_le_bytes([texel[c * 2], texel[c * 2 + 1]])); + if !v.is_finite() { + continue; + } + components += 1; + if v > max { + max = v; + } + if v > 1.0 { + above_one += 1; + } + } + } + } + (components > 0).then_some(FloatRange { + max, + above_one, + components, + }) +} + +fn probe_wanted() -> bool { + static WANTED: std::sync::OnceLock = std::sync::OnceLock::new(); + *WANTED.get_or_init(|| std::env::var("NESCAPTURE_FP16_PROBE").as_deref() == Ok("1")) +} + +/// How often a sample is taken, in captured frames, and how many are taken +/// before the probe gives up and reports what it saw. +/// +/// One sample would land on whatever is on screen when the game's first frame +/// reaches the swapchain, which is a loading screen or a black frame -- and an +/// image with no highlight in it is inside [0, 1] whichever encoding it is, so +/// that sample answers nothing. Spreading them out gets the probe as far as +/// actual gameplay. +const PROBE_EVERY: u64 = 120; +const PROBE_SAMPLES: u64 = 40; + +static PROBE_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static PROBE_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// The largest component seen so far, as bits, so it survives between samples. +static PROBE_MAX: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Whether this frame is one to sample, and whether it is the last. +/// +/// Split out so the schedule is testable without a device. +fn probe_schedule(frames: u64, taken: u64) -> (bool, bool) { + if taken >= PROBE_SAMPLES { + return (false, false); + } + let sample = frames.is_multiple_of(PROBE_EVERY); + (sample, sample && taken + 1 == PROBE_SAMPLES) +} + +/// Read one captured frame back and say whether it holds extended-range values. +/// +/// This answers one question and is not part of any path: a float16 swapchain +/// is how both scRGB and PQ arrive, and they are told apart by what is in the +/// buffer rather than by anything either side declares. scRGB is linear and +/// goes past 1.0 for anything brighter than SDR white; PQ is an encoding of +/// absolute luminance and never leaves [0, 1]. So the largest component in a +/// frame with any highlight in it decides which one this is. +/// +/// Runs once per process, behind `NESCAPTURE_FP16_PROBE=1`, and stalls the +/// present it runs on — it waits for the blit and then for a copy of its own. +/// Deliberately: a probe that samples asynchronously answers about some frame +/// rather than this one. +pub unsafe fn probe_float_range( + ds: &crate::state::DeviceState, + pool: vk::CommandPool, + queue: vk::Queue, + slot_image: vk::Image, + slot_memory: vk::DeviceMemory, + slot_fence: vk::Fence, + fmt: vk::Format, + ext: vk::Extent2D, +) { + use std::sync::atomic::Ordering; + + if !probe_wanted() { + return; + } + let frames = PROBE_FRAMES.fetch_add(1, Ordering::Relaxed); + let taken = PROBE_TAKEN.load(Ordering::Relaxed); + let (sample, last) = probe_schedule(frames, taken); + if !sample { + return; + } + PROBE_TAKEN.store(taken + 1, Ordering::Relaxed); + + if fmt != vk::Format::R16G16B16A16_SFLOAT { + if taken == 0 { + log::info!( + "float-range probe: capture format is {fmt:?}, which cannot hold values above 1.0 - nothing to ask" + ); + } + PROBE_TAKEN.store(PROBE_SAMPLES, Ordering::Relaxed); + return; + } + + // The blit writing this slot was submitted a moment ago. + if unsafe { (ds.fp.wait_for_fences)(ds.raw, 1, &slot_fence, vk::TRUE, 1_000_000_000) } + != vk::Result::SUCCESS + { + log::warn!("float-range probe: the capture blit did not finish"); + return; + } + + // Where the encoder has a device of its own the slot is already + // host-visible and is read in place; a shared slot is device-local and + // needs a copy into one that is not. + let shared = ds.shared_active.load(Ordering::Acquire); + let (image, memory, staged) = if shared { + match unsafe { allocate_host_image(ds, ext.width, ext.height, fmt, "float-range probe") } { + Some((i, m)) => (i, m, true), + None => { + log::warn!("float-range probe: no host-visible image to read into"); + return; + } + } + } else { + (slot_image, slot_memory, false) + }; + + if staged && !unsafe { copy_into_host_image(ds, pool, queue, slot_image, image, ext) } { + unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) }; + unsafe { (ds.fp.free_memory)(ds.raw, memory, std::ptr::null()) }; + log::warn!("float-range probe: the readback copy did not run"); + return; + } + + let subresource = vk::ImageSubresource { + aspect_mask: vk::ImageAspectFlags::COLOR, + mip_level: 0, + array_layer: 0, + }; + let mut layout = vk::SubresourceLayout::default(); + unsafe { (ds.fp.get_image_subresource_layout)(ds.raw, image, &subresource, &mut layout) }; + + let mut mp: *mut std::os::raw::c_void = std::ptr::null_mut(); + let mapped = unsafe { + (ds.fp.map_memory)( + ds.raw, + memory, + 0, + vk::WHOLE_SIZE, + vk::MemoryMapFlags::empty(), + &mut mp, + ) + }; + if mapped == vk::Result::SUCCESS { + let bytes = layout.offset as usize + layout.row_pitch as usize * ext.height as usize; + let all = unsafe { std::slice::from_raw_parts(mp as *const u8, bytes) }; + match scan_float_range( + &all[layout.offset as usize..], + layout.row_pitch as usize, + ext.width, + ext.height, + ) { + Some(found) => { + let share = found.above_one as f64 * 100.0 / found.components as f64; + let best = f32::from_bits(PROBE_MAX.load(Ordering::Relaxed)).max(found.max); + PROBE_MAX.store(best.to_bits(), Ordering::Relaxed); + log::debug!( + "float-range probe sample {}: largest component {:.4}, {} of {} above 1.0 ({share:.3}%)", + taken + 1, + found.max, + found.above_one, + found.components + ); + // Above 1.0 settles it, so say so and stop sampling. Staying + // inside the range never settles anything -- an SDR frame with + // no highlight looks the same -- so that only gets reported + // once the samples run out. + if found.is_extended() { + log::info!( + "float-range probe: largest component {:.4}, {} of {} above 1.0 ({share:.3}%) - this buffer is extended-range linear (scRGB), not PQ", + found.max, + found.above_one, + found.components + ); + PROBE_TAKEN.store(PROBE_SAMPLES, Ordering::Relaxed); + } else if last { + log::info!( + "float-range probe: nothing above 1.0 in {PROBE_SAMPLES} samples, largest component {best:.4} - consistent with PQ, and with an SDR picture that never got bright" + ); + } + } + None => log::warn!("float-range probe: the mapped image was too small to read"), + } + unsafe { (ds.fp.unmap_memory)(ds.raw, memory) }; + } else { + log::warn!("float-range probe: could not map the readback image ({mapped:?})"); + } + + if staged { + unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) }; + unsafe { (ds.fp.free_memory)(ds.raw, memory, std::ptr::null()) }; + } +} + +/// Copy a capture slot into a linear host-visible image, and wait for it. +unsafe fn copy_into_host_image( + ds: &crate::state::DeviceState, + pool: vk::CommandPool, + queue: vk::Queue, + src: vk::Image, + dst: vk::Image, + ext: vk::Extent2D, +) -> bool { + let ai = vk::CommandBufferAllocateInfo { + s_type: vk::StructureType::COMMAND_BUFFER_ALLOCATE_INFO, + p_next: std::ptr::null(), + command_pool: pool, + level: vk::CommandBufferLevel::PRIMARY, + command_buffer_count: 1, + _marker: std::marker::PhantomData, + }; + let mut cb = vk::CommandBuffer::null(); + if unsafe { (ds.fp.allocate_command_buffers)(ds.raw, &ai, &mut cb) } != vk::Result::SUCCESS { + return false; + } + + let mut ok = true; + let begin = vk::CommandBufferBeginInfo { + s_type: vk::StructureType::COMMAND_BUFFER_BEGIN_INFO, + p_next: std::ptr::null(), + flags: vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT, + p_inheritance_info: std::ptr::null(), + _marker: std::marker::PhantomData, + }; + if unsafe { (ds.fp.begin_command_buffer)(cb, &begin) } != vk::Result::SUCCESS { + ok = false; + } + + if ok { + // The slot stays in GENERAL, which is what every other reader expects + // to find it in. Only the destination transitions. + let to_dst = image_barrier!( + vk::AccessFlags::empty(), + vk::AccessFlags::TRANSFER_WRITE, + vk::ImageLayout::UNDEFINED, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + dst + ); + unsafe { + (ds.fp.cmd_pipeline_barrier)( + cb, + vk::PipelineStageFlags::TOP_OF_PIPE, + vk::PipelineStageFlags::TRANSFER, + vk::DependencyFlags::empty(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 1, + &to_dst, + ); + } + let cr = vk::ImageCopy { + src_subresource: make_subresource_layers(), + src_offset: vk::Offset3D { x: 0, y: 0, z: 0 }, + dst_subresource: make_subresource_layers(), + dst_offset: vk::Offset3D { x: 0, y: 0, z: 0 }, + extent: vk::Extent3D { + width: ext.width, + height: ext.height, + depth: 1, + }, + }; + unsafe { + (ds.fp.cmd_copy_image)( + cb, + src, + vk::ImageLayout::GENERAL, + dst, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + 1, + &cr, + ); + } + let to_host = image_barrier!( + vk::AccessFlags::TRANSFER_WRITE, + vk::AccessFlags::HOST_READ, + vk::ImageLayout::TRANSFER_DST_OPTIMAL, + vk::ImageLayout::GENERAL, + dst + ); + unsafe { + (ds.fp.cmd_pipeline_barrier)( + cb, + vk::PipelineStageFlags::TRANSFER, + vk::PipelineStageFlags::HOST, + vk::DependencyFlags::empty(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 1, + &to_host, + ); + } + if unsafe { (ds.fp.end_command_buffer)(cb) } != vk::Result::SUCCESS { + ok = false; + } + } + + let fence_ci = vk::FenceCreateInfo { + s_type: vk::StructureType::FENCE_CREATE_INFO, + p_next: std::ptr::null(), + flags: vk::FenceCreateFlags::empty(), + _marker: std::marker::PhantomData, + }; + let mut fence = vk::Fence::null(); + if ok + && unsafe { (ds.fp.create_fence)(ds.raw, &fence_ci, std::ptr::null(), &mut fence) } + != vk::Result::SUCCESS + { + ok = false; + } + + if ok { + let subi = vk::SubmitInfo { + s_type: vk::StructureType::SUBMIT_INFO, + p_next: std::ptr::null(), + wait_semaphore_count: 0, + p_wait_semaphores: std::ptr::null(), + p_wait_dst_stage_mask: std::ptr::null(), + command_buffer_count: 1, + p_command_buffers: &cb, + signal_semaphore_count: 0, + p_signal_semaphores: std::ptr::null(), + _marker: std::marker::PhantomData, + }; + ok = unsafe { (ds.fp.queue_submit)(queue, 1, &subi, fence) } == vk::Result::SUCCESS + && unsafe { (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000_000) } + == vk::Result::SUCCESS; + } + + if fence != vk::Fence::null() { + unsafe { (ds.fp.destroy_fence)(ds.raw, fence, std::ptr::null()) }; + } + unsafe { (ds.fp.free_command_buffers)(ds.raw, pool, 1, &cb) }; + ok +} + +#[cfg(test)] +mod float_range_tests { + use super::{half_to_f32, scan_float_range}; + + fn half(v: f32) -> [u8; 2] { + // Round-to-nearest is not needed: every value used here is exact. + let bits = v.to_bits(); + let sign = ((bits >> 16) & 0x8000) as u16; + let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15; + let mant = ((bits >> 13) & 0x3ff) as u16; + let h = if v == 0.0 { + sign + } else { + sign | ((exp as u16) << 10) | mant + }; + h.to_le_bytes() + } + + #[test] + fn a_half_reads_as_the_value_it_holds() { + for v in [0.0f32, 0.5, 1.0, 2.0, 125.0, -1.5] { + let [a, b] = half(v); + assert_eq!(half_to_f32(u16::from_le_bytes([a, b])), v, "{v}"); + } + } + + fn frame(texels: &[[f32; 4]], w: u32, h: u32, pad: usize) -> Vec { + let pitch = w as usize * 8 + pad; + let mut out = vec![0u8; pitch * h as usize]; + for (i, t) in texels.iter().enumerate() { + let row = i / w as usize; + let col = i % w as usize; + for (c, v) in t.iter().enumerate() { + let at = row * pitch + col * 8 + c * 2; + out[at..at + 2].copy_from_slice(&half(*v)); + } + } + out + } + + /// The case the probe exists for: scRGB goes past 1.0 wherever the picture + /// is brighter than SDR white, and nothing that encodes PQ can. + #[test] + fn a_value_above_one_is_extended_range() { + let px = [[0.2, 0.3, 4.0, 1.0], [0.1, 0.1, 0.1, 1.0]]; + let found = scan_float_range(&frame(&px, 2, 1, 0), 16, 2, 1).unwrap(); + assert!(found.is_extended()); + assert_eq!(found.above_one, 1); + assert_eq!(found.max, 4.0); + } + + /// PQ fills [0, 1] and stops there, and so does an SDR frame -- which is + /// why this direction is evidence rather than an answer. + #[test] + fn everything_inside_the_range_is_not_extended() { + let px = [[0.0, 0.5, 1.0, 1.0], [1.0, 1.0, 1.0, 1.0]]; + let found = scan_float_range(&frame(&px, 2, 1, 0), 16, 2, 1).unwrap(); + assert!(!found.is_extended()); + assert_eq!(found.max, 1.0); + } + + /// Alpha is 1.0 on every opaque frame, so counting it would report every + /// frame as extended -- the exact false positive that would make the probe + /// worthless. + #[test] + fn alpha_is_not_colour() { + let px = [[0.5, 0.5, 0.5, 8.0]]; + let found = scan_float_range(&frame(&px, 1, 1, 0), 8, 1, 1).unwrap(); + assert_eq!(found.above_one, 0); + assert_eq!(found.components, 3); + assert_eq!(found.max, 0.5); + } + + /// A linear image's rows are padded to its own pitch, which is not the + /// width in bytes. Reading it as tightly packed walks diagonally through + /// the picture. + #[test] + fn rows_are_read_at_the_image_pitch() { + let px = [[0.25, 0.25, 0.25, 1.0], [3.0, 0.25, 0.25, 1.0]]; + let bytes = frame(&px, 1, 2, 24); + let found = scan_float_range(&bytes, 32, 1, 2).unwrap(); + assert_eq!(found.components, 6); + assert_eq!(found.max, 3.0); + } + + /// A short mapping is a wrong answer, not a smaller one. + #[test] + fn a_buffer_that_does_not_hold_the_image_reads_as_nothing() { + assert!(scan_float_range(&[0u8; 8], 16, 2, 1).is_none()); + } + + /// The regression this guards: sampling only the first frame reads a + /// loading screen, which is inside [0, 1] whichever encoding it is. + #[test] + fn samples_are_spread_out_rather_than_all_at_the_start() { + use super::{PROBE_EVERY, probe_schedule}; + assert_eq!(probe_schedule(0, 0), (true, false)); + assert_eq!(probe_schedule(1, 1), (false, false)); + assert_eq!(probe_schedule(PROBE_EVERY, 1), (true, false)); + } + + /// The last sample is the one that gets to report a negative, and it has + /// to know it is the last. + #[test] + fn the_final_sample_says_so_and_nothing_follows_it() { + use super::{PROBE_EVERY, PROBE_SAMPLES, probe_schedule}; + let at = PROBE_EVERY * (PROBE_SAMPLES - 1); + assert_eq!(probe_schedule(at, PROBE_SAMPLES - 1), (true, true)); + assert_eq!( + probe_schedule(at + PROBE_EVERY, PROBE_SAMPLES), + (false, false) + ); + } +} + +#[cfg(test)] +mod ring_identity_tests { + use super::ring_still_serves; + use ash::vk; + + const FMT: vk::Format = vk::Format::B8G8R8A8_UNORM; + const OTHER: vk::Format = vk::Format::R8G8B8A8_UNORM; + + fn serves(existing: (u32, u32, vk::Format), wanted: (u32, u32, vk::Format)) -> bool { + ring_still_serves(existing, 0, 3, wanted, 0, 3) + } + + #[test] + fn an_identical_request_reuses_the_ring() { + assert!(serves((1920, 1080, FMT), (1920, 1080, FMT))); + } + + #[test] + fn a_smaller_request_rebuilds() { + // The regression this guards. A ring that is merely large enough leaves + // the area outside the new extent holding the old resolution's picture, + // and the encoder sends it. + assert!(!serves((1920, 1080, FMT), (1280, 720, FMT))); + assert!(!serves((1920, 1080, FMT), (1920, 720, FMT))); + assert!(!serves((1920, 1080, FMT), (1280, 1080, FMT))); + } + + #[test] + fn a_larger_request_rebuilds() { + assert!(!serves((1280, 720, FMT), (1920, 1080, FMT))); + } + + #[test] + fn a_different_format_rebuilds() { + assert!(!serves((1920, 1080, FMT), (1920, 1080, OTHER))); + } + + #[test] + fn a_different_queue_family_or_image_count_rebuilds() { + let g = (1920, 1080, FMT); + assert!(!ring_still_serves(g, 0, 3, g, 1, 3)); + assert!(!ring_still_serves(g, 0, 3, g, 0, 4)); + } +} diff --git a/apps/nescapture/src/device.rs b/apps/nescapture/src/device.rs index 3d1834b4..e11321b9 100644 --- a/apps/nescapture/src/device.rs +++ b/apps/nescapture/src/device.rs @@ -14,6 +14,7 @@ use std::os::raw::c_void; use std::sync::Arc; const VK_LAYER_LINK_INFO: u32 = 0; +const VK_LOADER_DATA_CALLBACK: u32 = 1; #[unsafe(no_mangle)] pub unsafe extern "system" fn vkCreateDevice( @@ -43,6 +44,14 @@ pub unsafe extern "system" fn vkCreateDevice( }; unsafe { (*layer_info).u.pDeviceLayerInfo = (*dev_link).pNext }; + let set_loader_data = unsafe { + find_layer_link::( + (*p_create_info).p_next as *const c_void, + VK_LOADER_DATA_CALLBACK, + ) + } + .and_then(|info| unsafe { (*info).u.pfnSetDeviceLoaderData }); + let inst_key = unsafe { dispatch_key(physical_device.as_raw() as *const c_void) }; let istate = match INSTANCE_STATE.get(&inst_key) { Some(s) => s.clone(), @@ -54,11 +63,8 @@ pub unsafe extern "system" fn vkCreateDevice( } }; - // ── Inject DMA-BUF extensions for zero-copy capture ────────────────── let ci = unsafe { &*p_create_info }; - - // Collect the game's original extensions. - let original_extensions: Vec<*const libc::c_char> = + let game_extensions: Vec<*const libc::c_char> = if ci.enabled_extension_count > 0 && !ci.pp_enabled_extension_names.is_null() { unsafe { std::slice::from_raw_parts( @@ -71,69 +77,34 @@ pub unsafe extern "system" fn vkCreateDevice( Vec::new() }; - // Extensions we need — static byte strings so pointers stay valid. - const EXT_EXTERNAL_MEMORY: &[u8] = b"VK_KHR_external_memory\0"; - const EXT_EXTERNAL_MEMORY_FD: &[u8] = b"VK_KHR_external_memory_fd\0"; - const EXT_EXTERNAL_MEMORY_DMABUF: &[u8] = b"VK_EXT_external_memory_dma_buf\0"; - // Lets the capture ring be allocated tiled. The importer has always created - // its side with DRM_FORMAT_MODIFIER_EXT tiling; without this the producer - // can only offer it a linear buffer. - const EXT_IMAGE_DRM_FORMAT_MODIFIER: &[u8] = b"VK_EXT_image_drm_format_modifier\0"; - - let needed: &[&[u8]] = &[ - EXT_EXTERNAL_MEMORY, - EXT_EXTERNAL_MEMORY_FD, - EXT_EXTERNAL_MEMORY_DMABUF, - EXT_IMAGE_DRM_FORMAT_MODIFIER, - ]; - - // Build extended list: original + any of ours not already present. - let mut extended = original_extensions.clone(); - for &ext in needed { - let name_cstr = unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(ext) }; - let already = extended - .iter() - .any(|&ptr| unsafe { std::ffi::CStr::from_ptr(ptr) == name_cstr }); - if !already { - extended.push(ext.as_ptr() as *const libc::c_char); + // Where it can, the encoder runs on this very device, so the device is + // first created with what that needs. Anything refused falls back to the + // device exactly as the game asked for it, and the encoder to a device of + // its own. + let mut shared = None; + if let Some(prepared) = + unsafe { crate::shared::prepare(&istate, next_gdpa, physical_device, ci, &game_extensions) } + { + let shared_ci = prepared.create_info(ci); + let result = + unsafe { (istate.create_device)(physical_device, &shared_ci, p_allocator, p_device) }; + let (additions, entry, instance) = unsafe { prepared.finish() }; + if result == vk::Result::SUCCESS { + shared = Some((additions, entry, instance)); + } else { + log::warn!( + "vkCreateDevice with the encoder's additions failed ({result:?}), \ + retrying as the game asked" + ); } } - - // Try with injected extensions first. - // - // The device's queue create info is passed through unchanged. An earlier - // version bumped the first family's queue count by one to get a dedicated - // capture queue, which was then never used — and could not be: the capture - // blit has to be submitted to the queue the game presents on, or it gains - // no ordering against the present. All the bump did was risk exceeding the - // family's available queue count on the way in. - let mut modified_ci = *ci; - modified_ci.enabled_extension_count = extended.len() as u32; - modified_ci.pp_enabled_extension_names = extended.as_ptr(); - - let mut dmabuf_available = true; - let result = - unsafe { (istate.create_device)(physical_device, &modified_ci, p_allocator, p_device) }; - - let result = if result != vk::Result::SUCCESS { - // Driver rejected our extensions — retry with original create info. - log::warn!( - "vkCreateDevice with DMA-BUF extensions failed ({:?}), \ - retrying without — CPU readback fallback will be used", - result - ); - dmabuf_available = false; - unsafe { (istate.create_device)(physical_device, p_create_info, p_allocator, p_device) } - } else { - log::info!("DMA-BUF extensions injected successfully"); - result - }; - if result != vk::Result::SUCCESS { - return result; - } - - if !dmabuf_available { - log::warn!("DMA-BUF extensions missing — will use CPU readback fallback (expensive!)"); + if shared.is_none() { + let result = unsafe { + (istate.create_device)(physical_device, p_create_info, p_allocator, p_device) + }; + if result != vk::Result::SUCCESS { + return result; + } } let device = unsafe { *p_device }; @@ -156,6 +127,7 @@ pub unsafe extern "system" fn vkCreateDevice( get_device_proc_addr: next_gdpa, destroy_device: load!(b"vkDestroyDevice\0"), get_device_queue: load!(b"vkGetDeviceQueue\0"), + get_device_queue2: try_load!(b"vkGetDeviceQueue2\0"), queue_present_khr: try_load!(b"vkQueuePresentKHR\0"), // Phase 1 @@ -189,10 +161,6 @@ pub unsafe extern "system" fn vkCreateDevice( cmd_pipeline_barrier: load!(b"vkCmdPipelineBarrier\0"), cmd_copy_image: load!(b"vkCmdCopyImage\0"), get_image_subresource_layout: load!(b"vkGetImageSubresourceLayout\0"), - get_memory_fd_khr: try_load!(b"vkGetMemoryFdKHR\0"), - get_image_drm_format_modifier_properties_ext: try_load!( - b"vkGetImageDrmFormatModifierPropertiesEXT\0" - ), create_query_pool: try_load!(b"vkCreateQueryPool\0"), destroy_query_pool: try_load!(b"vkDestroyQueryPool\0"), cmd_reset_query_pool: try_load!(b"vkCmdResetQueryPool\0"), @@ -232,6 +200,17 @@ pub unsafe extern "system" fn vkCreateDevice( let key = unsafe { dispatch_key(device.as_raw() as *const c_void) }; + let shared = shared.map(|(additions, entry, instance)| unsafe { + crate::shared::SharedDevice::adopt( + additions, + entry, + instance, + physical_device, + device, + next_gdpa, + ) + }); + // Phase 3: load shader hash config let shader_hashes = config::resolve_config_path() .as_ref() @@ -252,6 +231,9 @@ pub unsafe extern "system" fn vkCreateDevice( raw: device, physical_device, fp, + shared, + shared_active: std::sync::atomic::AtomicBool::new(false), + set_loader_data, shader_registry: DashMap::new(), pipeline_registry: DashMap::new(), @@ -279,6 +261,7 @@ pub unsafe extern "system" fn vkCreateDevice( }), swapchain_colorspace: std::sync::atomic::AtomicU32::new(0), frame_counter: std::sync::atomic::AtomicU64::new(0), + ring_generation: std::sync::atomic::AtomicU64::new(0), hud_detected_frame: std::sync::atomic::AtomicBool::new(false), pending_capture_frame: std::sync::atomic::AtomicBool::new(false), @@ -287,7 +270,6 @@ pub unsafe extern "system" fn vkCreateDevice( encoder: std::sync::Mutex::new(None), - frame_gate: std::sync::Mutex::new(crate::pacing::FrameGate::from_env()), frame_pacer: std::sync::Mutex::new(crate::pacing::FramePacer::from_env()), last_present_return: std::sync::Mutex::new(None), @@ -317,23 +299,20 @@ pub unsafe extern "system" fn vkDestroyDevice( ds.pipeline_registry.len(), ); - // ── 1. Shut down encoder pipeline (unblocks encoder + RTP threads) ─── - { - let mut enc_guard = ds.encoder.lock().unwrap(); - if let Some(handle) = enc_guard.take() { - handle.shutdown(); - // `handle` is dropped here → drops `frame_tx` → encoder thread's - // recv_timeout returns Disconnected → encoder thread drops - // `encoded_tx` → RTP thread exits too. - // - // Give threads a moment to drain. In production you'd join the - // JoinHandles, but since we don't store them, a short sleep + - // the AtomicBool shutdown flag is sufficient. - log::info!("encoder pipeline shutdown signaled"); + // ── 1. Shut down the encode pipeline ────────────────────────────────── + // + // Waited for, not just signalled. On a shared device the encoder, its + // converter and their images are objects on this very device, and + // destroying the device under them is a use-after-free. The receive + // timeout bounds how long the thread takes to notice. + let handle = ds.encoder.lock().ok().and_then(|mut g| g.take()); + if let Some(handle) = handle { + if handle.finish(std::time::Duration::from_secs(2)) { + log::info!("encoder pipeline stopped"); + } else { + log::error!("encoder thread did not stop in time; destroying the device anyway"); } } - // Brief yield to let threads notice the disconnect. - std::thread::sleep(std::time::Duration::from_millis(50)); // ── 2. Tear down the capture ring ───────────────────────────────────── // @@ -392,13 +371,111 @@ pub unsafe extern "system" fn vkGetDeviceQueue( ) { let key = unsafe { dispatch_key(device.as_raw() as *const c_void) }; if let Some(ds) = DEVICE_STATE.get(&key) { - unsafe { (ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue) }; + // A queue created internally synchronized, so the encoder can share + // it, is invisible to vkGetDeviceQueue: only vkGetDeviceQueue2 with the + // matching flags returns it. The game asked for a plain queue and gets + // this one. + match ( + shares_family(&ds, queue_family_index), + ds.fp.get_device_queue2, + ) { + (true, Some(get2)) => { + let info = vk::DeviceQueueInfo2::default() + .flags(vk::DeviceQueueCreateFlags::INTERNALLY_SYNCHRONIZED_KHR) + .queue_family_index(queue_family_index) + .queue_index(queue_index); + unsafe { get2(device, &info, p_queue) }; + } + _ => unsafe { + (ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue) + }, + } let queue = unsafe { *p_queue }; + // The encoder's queues reach this hook without the loader in between. + // For the game's, the loader stamps them again on the way out. + unsafe { stamp(&ds, queue.as_raw() as *mut c_void) }; QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key); crate::state::QUEUE_TO_FAMILY.insert(queue.as_raw(), queue_family_index); } } +/// Give a dispatchable object this layer obtained itself the loader's +/// dispatch data. +/// +/// The loader writes its dispatch pointer into every queue and command buffer +/// that passes through its own entry points. One a layer allocates by calling +/// the next layer directly never does, and a layer below this one that finds +/// its per-object state by that pointer then finds nothing: the validation +/// layer aborts, in the first vkCmd* recorded into such a command buffer. The +/// loader hands every layer this callback at vkCreateDevice for exactly this. +/// +/// # Safety +/// +/// `object` must be a queue or command buffer of `ds`'s device. +pub unsafe fn stamp(ds: &DeviceState, object: *mut c_void) { + if let Some(set) = ds.set_loader_data + && unsafe { set(ds.raw, object) } != vk::Result::SUCCESS + { + log::warn!("vkSetDeviceLoaderData refused an object of the layer's own"); + } +} + +/// vkAllocateCommandBuffers for the encoder, whose command buffers are the +/// layer's own: allocated below the loader, so stamped here. See [`stamp`]. +pub unsafe extern "system" fn encoder_allocate_command_buffers( + device: vk::Device, + p_allocate_info: *const vk::CommandBufferAllocateInfo<'_>, + p_command_buffers: *mut vk::CommandBuffer, +) -> vk::Result { + let key = unsafe { dispatch_key(device.as_raw() as *const c_void) }; + let Some(ds) = DEVICE_STATE.get(&key).map(|s| s.clone()) else { + return vk::Result::ERROR_INITIALIZATION_FAILED; + }; + let result = + unsafe { (ds.fp.allocate_command_buffers)(device, p_allocate_info, p_command_buffers) }; + if result == vk::Result::SUCCESS { + let count = unsafe { (*p_allocate_info).command_buffer_count } as usize; + for cb in unsafe { std::slice::from_raw_parts(p_command_buffers, count) } { + unsafe { stamp(&ds, cb.as_raw() as *mut c_void) }; + } + } + result +} + +/// Whether the game's queues in `family` were created internally synchronized +/// for the encoder to share. +fn shares_family(ds: &DeviceState, family: u32) -> bool { + ds.shared + .as_ref() + .is_some_and(|s| s.queues.internally_synchronized.contains(&family)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn vkGetDeviceQueue2( + device: vk::Device, + p_queue_info: *const vk::DeviceQueueInfo2, + p_queue: *mut vk::Queue, +) { + let key = unsafe { dispatch_key(device.as_raw() as *const c_void) }; + let Some(ds) = DEVICE_STATE.get(&key) else { + return; + }; + let Some(get2) = ds.fp.get_device_queue2 else { + return; + }; + // The flags have to match the ones the queue was created with, and for a + // shared family the layer added one the game does not know about. + let mut info = unsafe { *p_queue_info }; + if shares_family(&ds, info.queue_family_index) { + info.flags |= vk::DeviceQueueCreateFlags::INTERNALLY_SYNCHRONIZED_KHR; + } + unsafe { get2(device, &info, p_queue) }; + let queue = unsafe { *p_queue }; + unsafe { stamp(&ds, queue.as_raw() as *mut c_void) }; + QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key); + crate::state::QUEUE_TO_FAMILY.insert(queue.as_raw(), info.queue_family_index); +} + /// Enumerate device extensions supported by the physical device. unsafe fn enumerate_device_extensions( istate: &crate::dispatch::NextInstanceFn, diff --git a/apps/nescapture/src/discovery.rs b/apps/nescapture/src/discovery.rs index 8e410ae6..8afaa49d 100644 --- a/apps/nescapture/src/discovery.rs +++ b/apps/nescapture/src/discovery.rs @@ -2,7 +2,7 @@ // discovery.rs — Phase 6: per-draw logging for shader hash discovery // // When HUDLESS_DISCOVER=1 is set, every vkCmdDraw* call is logged to: -// /tmp/hudless_discover_$EXE.log +// /tmp/nescapture_discover_$EXE.log // // Log format: // frame=0001 draw=00042 vert=0x1a2b3c4d5e6f7890 frag=0xaabbccddeeff0011 verts=6 blend=true depth=false @@ -40,7 +40,7 @@ fn ensure_log_file() -> bool { .unwrap_or_else(|| "unknown".to_string()) }); - let path = format!("/tmp/hudless_discover_{}.log", exe_name); + let path = format!("/tmp/nescapture_discover_{}.log", exe_name); match OpenOptions::new().create(true).append(true).open(&path) { Ok(f) => { log::info!("discovery logging to {}", path); diff --git a/apps/nescapture/src/dispatch.rs b/apps/nescapture/src/dispatch.rs index ad7457b3..683fdf51 100644 --- a/apps/nescapture/src/dispatch.rs +++ b/apps/nescapture/src/dispatch.rs @@ -38,6 +38,8 @@ pub type PFN_vkDestroyDevice = unsafe extern "system" fn(vk::Device, *const vk::AllocationCallbacks); pub type PFN_vkGetDeviceQueue = unsafe extern "system" fn(vk::Device, u32, u32, *mut vk::Queue); +pub type PFN_vkGetDeviceQueue2 = + unsafe extern "system" fn(vk::Device, *const vk::DeviceQueueInfo2, *mut vk::Queue); pub type PFN_vkQueuePresentKHR = unsafe extern "system" fn(vk::Queue, *const vk::PresentInfoKHR) -> vk::Result; @@ -190,20 +192,6 @@ pub type PFN_vkGetImageSubresourceLayout = unsafe extern "system" fn( *mut vk::SubresourceLayout, ); -// DRM format modifiers. Both optional: without them the capture ring stays -// linear, which is what it was before it could be anything else. -pub type PFN_vkGetPhysicalDeviceFormatProperties2 = unsafe extern "system" fn( - vk::PhysicalDevice, - vk::Format, - *mut vk::FormatProperties2<'_>, -); - -pub type PFN_vkGetImageDrmFormatModifierPropertiesEXT = unsafe extern "system" fn( - vk::Device, - vk::Image, - *mut vk::ImageDrmFormatModifierPropertiesEXT<'_>, -) -> vk::Result; - // Timestamp queries around the capture blit. All optional: without them the // blit's GPU cost is simply not reported. pub type PFN_vkGetPhysicalDeviceProperties = @@ -219,21 +207,14 @@ pub type PFN_vkCreateQueryPool = unsafe extern "system" fn( *mut vk::QueryPool, ) -> vk::Result; -pub type PFN_vkDestroyQueryPool = unsafe extern "system" fn( - vk::Device, - vk::QueryPool, - *const vk::AllocationCallbacks, -); +pub type PFN_vkDestroyQueryPool = + unsafe extern "system" fn(vk::Device, vk::QueryPool, *const vk::AllocationCallbacks); pub type PFN_vkCmdResetQueryPool = unsafe extern "system" fn(vk::CommandBuffer, vk::QueryPool, u32, u32); -pub type PFN_vkCmdWriteTimestamp = unsafe extern "system" fn( - vk::CommandBuffer, - vk::PipelineStageFlags, - vk::QueryPool, - u32, -); +pub type PFN_vkCmdWriteTimestamp = + unsafe extern "system" fn(vk::CommandBuffer, vk::PipelineStageFlags, vk::QueryPool, u32); pub type PFN_vkGetQueryPoolResults = unsafe extern "system" fn( vk::Device, @@ -246,13 +227,6 @@ pub type PFN_vkGetQueryPoolResults = unsafe extern "system" fn( vk::QueryResultFlags, ) -> vk::Result; -// DMA-BUF fd export (used to share final_image with pixelforge zero-copy) -pub type PFN_vkGetMemoryFdKHR = unsafe extern "system" fn( - vk::Device, - *const vk::MemoryGetFdInfoKHR, - *mut std::os::raw::c_int, -) -> vk::Result; - // ── Phase 4: Synchronisation ───────────────────────────────────────────────── pub type PFN_vkCreateFence = unsafe extern "system" fn( @@ -366,13 +340,14 @@ pub type PFN_vkResetCommandBuffer = // ── Dispatch table structs ──────────────────────────────────────────────────── pub struct NextInstanceFn { + /// The instance itself, for building the encoder's view of it. + pub instance: vk::Instance, + /// The Vulkan version the application asked for, which bounds what core + /// functionality exists on its devices. + pub api_version: u32, pub get_instance_proc_addr: PFN_vkGetInstanceProcAddr, pub destroy_instance: PFN_vkDestroyInstance, pub get_physical_device_memory_properties: PFN_vkGetPhysicalDeviceMemoryProperties, - /// `None` on an instance below Vulkan 1.1 without - /// `VK_KHR_get_physical_device_properties2`. Without it the modifier list - /// cannot be queried and the capture ring stays linear. - pub get_physical_device_format_properties2: Option, /// Needed for `timestampPeriod`, which turns device ticks into nanoseconds. pub get_physical_device_properties: Option, /// Needed for a queue family's `timestampValidBits`. A family reporting @@ -388,6 +363,9 @@ pub struct NextDeviceFn { pub get_device_proc_addr: PFN_vkGetDeviceProcAddr, pub destroy_device: PFN_vkDestroyDevice, pub get_device_queue: PFN_vkGetDeviceQueue, + /// Core in 1.1. Needed to fetch queues created with flags, which is how a + /// queue shared with the encoder is created. + pub get_device_queue2: Option, pub queue_present_khr: Option, // Phase 1 @@ -421,15 +399,6 @@ pub struct NextDeviceFn { pub cmd_pipeline_barrier: PFN_vkCmdPipelineBarrier, pub cmd_copy_image: PFN_vkCmdCopyImage, pub get_image_subresource_layout: PFN_vkGetImageSubresourceLayout, - /// `None` when `VK_KHR_external_memory_fd` is unavailable. - /// Required for DMA-BUF export to pixelforge's VkDevice. - pub get_memory_fd_khr: Option, - /// `None` when `VK_EXT_image_drm_format_modifier` was not enabled. The - /// driver picks the modifier from the list it is offered, so this is how - /// the layer learns which one it actually got — and the importer needs the - /// exact value, not the list. - pub get_image_drm_format_modifier_properties_ext: - Option, // Phase 4 — blit timing. All-or-nothing: the ring only times the blit when // every one of these loaded and the presenting queue family can timestamp. diff --git a/apps/nescapture/src/dmabuf_import.rs b/apps/nescapture/src/dmabuf_import.rs deleted file mode 100644 index ba5adb47..00000000 --- a/apps/nescapture/src/dmabuf_import.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! DMA-BUF import support for zero-copy video encoding. -//! -//! This module provides the ability to import Linux DMA-BUF file descriptors as -//! Vulkan images for direct video encoding without CPU-side copies. -//! -//! `DmaBufImporter` caches imported Vulkan resources per compositor buffer index -//! so that pre-allocated GBM buffers are imported only once. Subsequent frames -//! from the same buffer reuse the cached `VkImage` and `VkDeviceMemory`, -//! eliminating per-frame Vulkan object creation and layout transitions. - -use anyhow::Result; -use ash::vk; -use log::debug; -use pixelforge::VideoContext; -use std::os::fd::RawFd; -use std::os::unix::io::{BorrowedFd, IntoRawFd}; - -/// Information about a single DMA-BUF plane. -#[derive(Debug, Clone, Copy)] -pub struct DmaBufPlane { - /// File descriptor for the DMA-BUF. - pub fd: RawFd, - /// Offset within the DMA-BUF to the start of this plane. - pub offset: u32, - /// Row stride in bytes. - pub stride: u32, - /// DRM format modifier. - pub modifier: u64, -} - -/// Cached Vulkan resources for a single compositor buffer slot. -struct CachedImport { - image: vk::Image, - memory: vk::DeviceMemory, -} - -/// Importer for DMA-BUF file descriptors into Vulkan images. -/// -/// Owns a per-buffer-index cache of `VkImage` + `VkDeviceMemory`. -/// Layout transitions are deferred to the consumer (e.g. `ColorConverter`) -/// to avoid a separate GPU submission per first-time import. -pub struct DmaBufImporter { - context: VideoContext, - external_memory_fd: ash::khr::external_memory_fd::Device, - /// Per-buffer-index cache. Index corresponds to `ExportedFrame::buffer_index`. - cached_imports: Vec>, -} - -impl DmaBufImporter { - /// Create a new DMA-BUF importer. - pub fn new(context: VideoContext) -> Result { - let external_memory_fd = - ash::khr::external_memory_fd::Device::load(context.instance(), context.device()); - - Ok(Self { - context, - external_memory_fd, - cached_imports: Vec::new(), - }) - } - - /// Import a DMA-BUF as a Vulkan image, reusing a cached import when - /// the same `buffer_index` has been seen before. - /// - /// The `format` parameter specifies the Vulkan format matching the DMA-BUF - /// pixel format (e.g. `B8G8R8A8_UNORM` for SDR, `A2B10G10R10_UNORM_PACK32` - /// for 10-bit HDR, `R16G16B16A16_SFLOAT` for FP16 HDR). - /// - /// Returns `(image, needs_transition)` where `needs_transition` is `true` - /// for first-time imports whose image is still in `UNDEFINED` layout. - /// The caller is responsible for transitioning the image (e.g. by passing - /// the appropriate `src_layout` to `ColorConverter::convert`). - pub fn import_or_reuse( - &mut self, - buffer_index: usize, - width: u32, - height: u32, - format: vk::Format, - planes: &[DmaBufPlane], - ) -> Result<(vk::Image, bool)> { - // Grow the cache vector if needed. - if self.cached_imports.len() <= buffer_index { - self.cached_imports.resize_with(buffer_index + 1, || None); - } - - if let Some(cached) = &self.cached_imports[buffer_index] { - return Ok((cached.image, false)); - } - - // First time seeing this buffer — full import. - debug!( - "First import for buffer {buffer_index}: {}x{}, format={:?}, fd={}, stride={}, modifier={:#x}", - width, height, format, planes[0].fd, planes[0].stride, planes[0].modifier - ); - - let (image, memory) = self.import_internal(width, height, format, planes)?; - - self.cached_imports[buffer_index] = Some(CachedImport { image, memory }); - Ok((image, true)) - } - - /// Perform the raw Vulkan import of a DMA-BUF with the specified format. - /// - /// Returns the `(VkImage, VkDeviceMemory)` pair. The image is in - /// `UNDEFINED` layout; the caller must transition it. - fn import_internal( - &self, - width: u32, - height: u32, - format: vk::Format, - planes: &[DmaBufPlane], - ) -> Result<(vk::Image, vk::DeviceMemory)> { - if planes.is_empty() { - return Err(anyhow::anyhow!("At least one DMA-BUF plane is required")); - } - - let device = self.context.device(); - - // Build DRM format modifier plane layouts for all planes. - // AMD modifiers (e.g. tiled/DCC) may require multiple planes; - // the layout count must match the modifier's expected plane count. - let plane_layouts: Vec = planes - .iter() - .map(|p| { - vk::SubresourceLayout::default() - .offset(p.offset as u64) - .row_pitch(p.stride as u64) - }) - .collect(); - - let modifier = planes[0].modifier; - let mut drm_format_modifier_info = - vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default() - .drm_format_modifier(modifier) - .plane_layouts(&plane_layouts); - - let mut external_memory_info = vk::ExternalMemoryImageCreateInfo::default() - .handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT); - external_memory_info.p_next = &mut drm_format_modifier_info - as *mut vk::ImageDrmFormatModifierExplicitCreateInfoEXT - as *mut _; - - let mut image_create_info = vk::ImageCreateInfo::default() - .image_type(vk::ImageType::TYPE_2D) - .format(format) - .extent(vk::Extent3D { - width, - height, - depth: 1, - }) - .mip_levels(1) - .array_layers(1) - .samples(vk::SampleCountFlags::TYPE_1) - .tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT) - .usage(vk::ImageUsageFlags::TRANSFER_SRC | vk::ImageUsageFlags::SAMPLED) - .sharing_mode(vk::SharingMode::EXCLUSIVE) - .initial_layout(vk::ImageLayout::UNDEFINED); - image_create_info.p_next = - &mut external_memory_info as *mut vk::ExternalMemoryImageCreateInfo as *mut _; - - let image = unsafe { device.create_image(&image_create_info, None) } - .map_err(|e| anyhow::anyhow!("DMA-BUF image creation: {e}"))?; - - // Memory requirements. - let mem_requirements = unsafe { device.get_image_memory_requirements(image) }; - - // FD memory properties. - let mut memory_fd_properties = vk::MemoryFdPropertiesKHR::default(); - unsafe { - self.external_memory_fd.get_memory_fd_properties( - vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT, - planes[0].fd, - &mut memory_fd_properties, - ) - } - .map_err(|e| anyhow::anyhow!("Failed to get memory FD properties: {e}"))?; - - // Duplicate the FD — vkAllocateMemory consumes it. - let fd = unsafe { BorrowedFd::borrow_raw(planes[0].fd) } - .try_clone_to_owned() - .map_err(|e| anyhow::anyhow!("Failed to duplicate DMA-BUF FD: {e}"))? - .into_raw_fd(); - - let mut import_memory_fd_info = vk::ImportMemoryFdInfoKHR::default() - .handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT) - .fd(fd); - - let memory_type_bits = - mem_requirements.memory_type_bits & memory_fd_properties.memory_type_bits; - - debug!( - "Memory allocation: size={}, image_type_bits={:#x}, fd_type_bits={:#x}, combined={:#x}", - mem_requirements.size, - mem_requirements.memory_type_bits, - memory_fd_properties.memory_type_bits, - memory_type_bits - ); - - let memory_type_index = self - .context - .find_memory_type(memory_type_bits, vk::MemoryPropertyFlags::empty()) - .ok_or_else(|| { - // The numbers, in the error rather than in the debug! above - // it: fd_type_bits=0 means the driver could not resolve the - // descriptor at all, which is a different fault from a - // mismatch, and the difference is the whole diagnosis. - anyhow::anyhow!( - "No suitable memory type for DMA-BUF import: \ - image_type_bits={:#x} & fd_type_bits={:#x} = {:#x}, \ - size={}, format={:?}, modifier={:#x}, stride={}", - mem_requirements.memory_type_bits, - memory_fd_properties.memory_type_bits, - memory_type_bits, - mem_requirements.size, - format, - planes[0].modifier, - planes[0].stride - ) - })?; - - // Dedicated allocation (required by many drivers for external memory). - let mut dedicated_alloc_info = vk::MemoryDedicatedAllocateInfo::default().image(image); - import_memory_fd_info.p_next = - &mut dedicated_alloc_info as *mut vk::MemoryDedicatedAllocateInfo as *mut _; - - let mut alloc_info = vk::MemoryAllocateInfo::default() - .allocation_size(mem_requirements.size) - .memory_type_index(memory_type_index); - alloc_info.p_next = &mut import_memory_fd_info as *mut vk::ImportMemoryFdInfoKHR as *mut _; - - let memory = unsafe { device.allocate_memory(&alloc_info, None) }.map_err(|e| { - unsafe { device.destroy_image(image, None) }; - anyhow::anyhow!("DMA-BUF memory import: {e}") - })?; - - if let Err(e) = unsafe { device.bind_image_memory(image, memory, 0) } { - unsafe { - device.free_memory(memory, None); - device.destroy_image(image, None); - } - return Err(anyhow::anyhow!("DMA-BUF memory bind: {e}")); - } - - Ok((image, memory)) - } -} - -impl Drop for DmaBufImporter { - fn drop(&mut self) { - let device = self.context.device(); - unsafe { - // Clean up cached imports. - for cached in self.cached_imports.drain(..).flatten() { - device.destroy_image(cached.image, None); - device.free_memory(cached.memory, None); - } - } - } -} diff --git a/apps/nescapture/src/encode.rs b/apps/nescapture/src/encode.rs index 3f895d78..ed9da0f0 100644 --- a/apps/nescapture/src/encode.rs +++ b/apps/nescapture/src/encode.rs @@ -1,54 +1,62 @@ // ───────────────────────────────────────────────────────────────────────────── // encode.rs — Vulkan Video hardware encoding + IPC transmission to neshub // -// ┌─────────────────── Zero-copy GPU pipeline ──────────────────────────────┐ -// │ │ -// │ Game VkDevice (intercepted by nescapture layer) │ -// │ vkCmdCopyImage(swapchain → final_image) ← GPU, no CPU │ -// │ get_dmabuf_fd(final_memory) ← export fd │ -// │ │ -// │ pixelforge VkDevice (separate, video-encode queue) │ -// │ DmaBufImporter::import_or_reuse(fd, ...) ← import as vk::Image │ -// │ ColorConverter::convert(bgra_img, ← GPU compute shader │ -// │ encoder.input_image()) BGRA/RGB10/FP16 │ -// │ → NV12/P010/YUV444 │ -// │ Encoder::encode(encoder.input_image()) ← Vulkan Video encode │ -// │ IPC send to neshub │ -// └─────────────────────────────────────────────────────────────────────────┘ +// A frame's path, on the game's own device: +// +// vkCmdCopyImage(swapchain -> ring slot) the game's queue, signals the +// ring's timeline +// ColorConverter::convert_async(slot, ...) waits on that, compute shader +// BGRA/RGB10/FP16 -> NV12/P010/YUV444 +// Encoder::encode_after(input, ...) waits on the conversion, +// Vulkan Video encode +// IPC send to neshub +// +// Where the game's device cannot host the encoder, it gets a device of its +// own, and the slot is read back on the CPU and uploaded there instead. // // Environment variables // ────────────────────── // NESCAPTURE_CODEC "h264" | "h265" | "av1" (default: best available) // NESCAPTURE_FORMAT "yuv420" | "yuv444" (default: yuv420) -// NESCAPTURE_DEPTH "8" | "10" (default: auto from VkFormat) -// NESCAPTURE_BITRATE CBR target kbps (default: 10000) -// NESCAPTURE_QP Constant QP (overrides BITRATE) (default: unset) +// NESCAPTURE_DEPTH "8" | "10" (default: 8) +// NESCAPTURE_RC "cqp" | "cbr" | "vbr" (default: inferred) +// NESCAPTURE_BITRATE Target kbps, under cbr and vbr (default: 10000) +// NESCAPTURE_BITRATE_MAX Ceiling kbps, vbr only (default: 1.5x target) +// NESCAPTURE_QP Constant QP, under cqp (default: unset) // NESCAPTURE_FPS Frame rate (default: 60) // NESCAPTURE_IDR_INTERVAL Force IDR every N seconds (default: 4) +// NESCAPTURE_INTRA_REFRESH_SHAPE +// auto | rows | columns | partitions (default: auto) +// NESCAPTURE_INTRA_REFRESH Replace periodic key frames with an intra refresh +// cycle. The cycle length follows from the codec, +// the picture and the device (default: off) +// NESCAPTURE_INTRA_REFRESH_QP_DELTA +// QP shift inside the refresh band, negative to +// spend bits on it (default: -4) // NESCAPTURE_TUNE "highquality" | "lowlatency" | "ultralowlatency" | "lossless" (default: unset) // NESCAPTURE_IPC_PATH Unix socket path for hub IPC (default: /tmp/nestri-video.sock) // ───────────────────────────────────────────────────────────────────────────── use anyhow::Result; -use std::os::unix::io::{AsRawFd, RawFd}; +use std::os::unix::io::AsRawFd; use std::os::unix::net::UnixDatagram; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, mpsc}; use std::thread; use std::time::Instant; use nesprotocol::{ - CODEC_AV1, CODEC_H264, CODEC_H265, CODEC_KEEP, FLAG_KEYFRAME, FLAG_RECONFIG, - MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, STREAM_VIDEO, decode_encode_settings, encode_ipc_frame, + CODEC_AV1, CODEC_H264, CODEC_H265, CODEC_KEEP, ClientCaps, FLAG_KEYFRAME, FLAG_RECONFIG, + MSG_CLIENT_CAPS, MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, STREAM_VIDEO, decode_client_caps, + decode_encode_settings, encode_ipc_frame, }; use pixelforge::{ - Codec, ColorConverter, ColorConverterConfig, ColorDescription, ColorSpace, EncodeBitDepth, + Codec, ColorConverter, ColorConverterConfig, ColorRange, ColorSpec, EncodeBitDepth, EncodeConfig, EncodeContentHint, EncodeFuture, EncodeUsageHint, Encoder, EncoderTuningMode, - InputFormat, OutputFormat, PixelFormat, RateControlMode, VideoContextBuilder, + InputFormat, IntraRefresh, IntraRefreshShape, OutputFormat, PixelFormat, RateControlMode, + VideoContextBuilder, }; -use crate::dmabuf_import::{DmaBufImporter, DmaBufPlane}; - // ── VkColorSpaceKHR constants ──────────────────────────────────────────────── // // Taken from `ash` rather than written out. They were transcribed by hand once @@ -66,6 +74,7 @@ const VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::EXTENDED_SRGB_LINEAR_EXT); const VK_COLOR_SPACE_BT2020_LINEAR_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::BT2020_LINEAR_EXT); const VK_COLOR_SPACE_HDR10_HLG_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::HDR10_HLG_EXT); +const VK_COLOR_SPACE_PASS_THROUGH_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::PASS_THROUGH_EXT); /// The converter input format for a swapchain's `VkFormat`, or `None` when /// there is no correct one. @@ -91,72 +100,132 @@ pub fn vk_format_to_input_format(vk_format: u32) -> Option { } } -pub fn vk_colorspace_to_color_space(vk_colorspace: u32) -> ColorSpace { +/// The colour space to believe, given what the swapchain says and what the +/// compositor was told. +/// +/// The swapchain is the right source whenever it names a colour space. It does +/// not always: `PASS_THROUGH` means "do not convert my values" and carries no +/// colour information at all -- the surface's real colour space is declared +/// separately, to the compositor, over `wp_color_manager_v1`. A Windows title +/// turning on HDR through wine arrives exactly that way: BT.2020 PQ pixels in a +/// swapchain that says nothing. +/// +/// So `PASS_THROUGH` defers and everything else does not. That distinction is +/// the whole rule: it is the one case where the swapchain is explicitly +/// declining to say, which makes it the one case where asking elsewhere is +/// reading rather than guessing. +pub fn effective_colorspace(vk_colorspace: u32, declared: Option) -> u32 { + if vk_colorspace == VK_COLOR_SPACE_PASS_THROUGH_EXT + && let Some(declared) = declared + { + return declared; + } + vk_colorspace +} + +/// A surface colour from the compositor, as a Vulkan colour space. +/// +/// Mapped into the swapchain's own vocabulary so there is one thing to reason +/// about downstream rather than two spellings of the same fact. +pub fn surface_color_to_vk(space: u8) -> u32 { + match space { + nesprotocol::SURFACE_COLOR_BT2020_PQ => VK_COLOR_SPACE_HDR10_ST2084_EXT, + _ => VK_COLOR_SPACE_SRGB_NONLINEAR_KHR, + } +} + +/// What a swapchain's pixels already are, from its `VkColorSpaceKHR`. +/// +/// Only the source. What the stream should be is a separate decision -- +/// [`stream_spec`] -- and keeping them apart is what fixed `BT2020_LINEAR_EXT`: +/// with one fused source-to-target enum there was no arm for linear light on +/// BT.2020 primaries, so it borrowed scRGB's and took a gamut error to avoid a +/// gamma one. Now it says what it is. +/// +/// Says nothing about the transfer a float buffer actually holds; see +/// [`source_spec`], which is what capture uses. +pub fn vk_colorspace_to_source_spec(vk_colorspace: u32) -> ColorSpec { match vk_colorspace { - VK_COLOR_SPACE_HDR10_ST2084_EXT | VK_COLOR_SPACE_HDR10_HLG_EXT => ColorSpace::Bt2020, - - // Both are linear, so the inverse sRGB EOTF that `SrgbToBt2020Pq` applies - // would decode data that was never encoded. `Bt709LinearToBt2020Pq` is - // documented for `EXTENDED_SRGB_LINEAR_EXT` exactly. `BT2020_LINEAR_EXT` - // is linear on BT.2020 primaries and there is no arm for that yet, so it - // borrows this one and takes a gamut error rather than a gamma one. - VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT | VK_COLOR_SPACE_BT2020_LINEAR_EXT => { - ColorSpace::Bt709LinearToBt2020Pq - } - - _ => ColorSpace::Bt709, + VK_COLOR_SPACE_HDR10_ST2084_EXT | VK_COLOR_SPACE_HDR10_HLG_EXT => ColorSpec::Bt2020Pq, + // Linear, so no inverse sRGB EOTF on the way out -- applying one would + // decode data that was never encoded. + VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT => ColorSpec::Bt709Linear, + VK_COLOR_SPACE_BT2020_LINEAR_EXT => ColorSpec::Bt2020Linear, + _ => ColorSpec::Srgb, } } -/// SDR reference white for the PQ conversions, in nits. +/// What the pixels in a capture actually are, from the colour space that was +/// declared and the format they are stored in. /// -/// Only the two arms that write PQ consume this. They disagree on what 1.0 means: -/// sRGB content is gamma-encoded and its white sits at the BT.2408 reference of -/// 203 nits, while scRGB is linear and IEC 61966-2-2 puts 1.0 at 80 nits. -/// pixelforge defaults to 203 for both, which maps scRGB white about 2.5x too -/// bright. -pub fn sdr_reference_white_nits(color_space: ColorSpace) -> f32 { - match color_space { - ColorSpace::Bt709LinearToBt2020Pq => 80.0, - _ => 203.0, - } -} - -/// Bit depth implied by a converter input format. +/// The format decides the transfer function, because only an integer format +/// can carry an encoded one. A float colour buffer holds linear light: that is +/// the whole reason to spend sixteen bits a channel on it, and there is no +/// float convention that stores PQ. So a float surface declared BT.2020 PQ is +/// not PQ -- it is scRGB, which is what every Windows title turning on HDR +/// hands DXVK, and what a probe of Control's own frames measured directly: +/// components up to 2.54, which PQ has no way to represent at all. /// -/// Taken from the input format rather than matched against the `VkFormat` a -/// second time. The two matches had drifted: `A2R10G10B10` counted as ten-bit -/// here while the input-format mapping above had no entry for it and fell back -/// to eight-bit BGRA, so the encoder was configured for ten-bit while the -/// converter read the buffer as eight. Deriving one from the other makes that -/// particular disagreement unrepresentable. -pub fn input_format_bit_depth(input_fmt: InputFormat) -> EncodeBitDepth { - match input_fmt { - InputFormat::ABGR2101010 | InputFormat::RGBA16F => EncodeBitDepth::Ten, - _ => EncodeBitDepth::Eight, +/// Believing the declaration there was the bug. It made the converter do +/// matrix work only, so linear light was read as if it were already PQ and +/// every highlight clipped -- a picture that blows out to yellow rather than +/// one that looks merely wrong. +/// +/// The declaration still decides the primaries, since that part it can say. +pub fn source_spec(vk_colorspace: u32, input_fmt: InputFormat) -> ColorSpec { + let declared = vk_colorspace_to_source_spec(vk_colorspace); + if !matches!(input_fmt, InputFormat::RGBA16F) { + return declared; + } + match declared { + // Already linear, and already says which primaries. + ColorSpec::Bt709Linear | ColorSpec::Bt2020Linear => declared, + // PQ or sRGB over float samples. scRGB is the only thing this is in + // practice, and scRGB is BT.709 primaries with a linear transfer. + ColorSpec::Bt2020Pq | ColorSpec::Srgb => ColorSpec::Bt709Linear, } } -pub fn vk_colorspace_to_color_description(vk_colorspace: u32) -> Option { - // Derived from the conversion rather than matched separately: the VUI has to - // describe what the shader actually wrote, and two independent matches on the - // same input drift the moment one gains an arm the other doesn't. - // - // Full range on every arm, because the converter is configured full-range - // unconditionally. pixelforge's constructors are limited-range per - // ITU-R BT.709-6, so leaving the flag off tags full-range luma as limited and - // every compliant decoder expands it again — darkening midtones and clipping - // both ends. - let desc = match vk_colorspace_to_color_space(vk_colorspace) { - ColorSpace::Bt709 => ColorDescription::bt709(), - // BT.2020 passthrough carries PQ-encoded input; the other two write PQ. - // HLG swapchains are tagged PQ here because pixelforge has no HLG transfer - // constant — a pre-existing approximation, not a consequence of this. - ColorSpace::Bt2020 | ColorSpace::SrgbToBt2020Pq | ColorSpace::Bt709LinearToBt2020Pq => { - ColorDescription::bt2020_pq() +/// What to encode a given source as. +/// +/// Video has no way to record that it holds linear light, so neither linear +/// space can be a target -- [`ColorSpec::is_encodable`] says as much. Anything +/// wide or linear goes out as HDR10; everything else stays SDR. +pub fn stream_spec(source: ColorSpec) -> ColorSpec { + match source { + ColorSpec::Srgb => ColorSpec::Srgb, + ColorSpec::Bt709Linear | ColorSpec::Bt2020Linear | ColorSpec::Bt2020Pq => { + ColorSpec::Bt2020Pq } - }; - Some(desc.with_full_range(true)) + } +} + +/// The converter configuration for one capture. +/// +/// One place decides source, target and range, and the colour description the +/// encoder declares is then derived from this same value rather than matched +/// separately -- so the matrix the shader applies and the one the VUI announces +/// cannot disagree. +pub fn converter_config( + width: u32, + height: u32, + input_fmt: InputFormat, + out_fmt: OutputFormat, + vk_colorspace: u32, +) -> ColorConverterConfig { + let source = source_spec(vk_colorspace, input_fmt); + ColorConverterConfig::new( + width, + height, + input_fmt, + out_fmt, + source, + stream_spec(source), + // Capture is always full-range, and the description derived from this + // config says so. A limited-range tag over full-range samples is + // expanded again by the decoder. + ColorRange::Full, + ) } pub fn output_format(pixel_fmt: PixelFormat, bit_depth: EncodeBitDepth) -> OutputFormat { @@ -185,36 +254,162 @@ pub struct CapturedFrame { /// When the game presented this frame. Carried all the way to the wire so /// the timestamp describes the frame rather than the encoder's backlog. pub present_time: Instant, - /// Reserves the capture ring slot this frame's DMA-BUF lives in. Dropping + /// Reserves the capture ring slot this frame lives in. Dropping /// the frame — encoded, skipped, or abandoned — returns the slot, so the /// present hook can never blit over a buffer the encoder is still reading. pub slot: Option, + /// On a device the encoder shares, the timeline point the slot's blit + /// signals. `None` when the frame has to be waited for on the CPU. + pub blit: Option, } /// An encode in flight, with the time of the present it came from. +/// Where each media thread last got to, and when. +/// +/// A freeze with nothing in the log is the worst shape a fault can take: the +/// capture layer stops, the session stays up, and no counter moves. Both media +/// threads are serial and both have a step that can wait indefinitely -- the +/// encoder thread blocks handing a frame to a full channel, and the IPC thread +/// blocks awaiting an encode. Neither can report being stuck, because being +/// stuck is precisely not reaching the next line. +/// +/// So each says where it is and when it got there, and something else does the +/// noticing. +#[derive(Default)] +struct Progress { + /// Milliseconds since the process epoch at the last step. + at_ms: AtomicU64, + /// Which step, as an index into `STEPS`. + step: AtomicU32, +} + +/// The steps a media thread can be waiting in, named for the log. +const STEPS: [&str; 13] = [ + "starting", + "waiting for a captured frame", + "encoding", + "handing the encoded frame on", + "awaiting the encoder", + "writing to the socket", + "waiting for an encoded frame", + "offering a captured frame", + "in the present hook", + "presenting, in the driver", + "holding to the target rate", + "back in the game, not presenting", + "acquiring an image, in the driver", +]; + +/// What the device looks like, asked for when a stall is noticed. +/// +/// Where a thread is says what it is waiting for; this says whether the GPU is +/// still getting through the work in front of it, which the thread cannot. +type StallProbe = Arc String + Send>>>>; + +/// Where the game's present thread is, as far as this layer can see it. +/// +/// The capture watchdog says frames stopped arriving; this says whether the +/// game is stuck inside a call of ours, inside the driver below us, or simply +/// stopped calling. Those three need looking for in three different places. +#[derive(Debug, Clone, Copy)] +pub enum PresentStep { + InHook = 8, + Presenting = 9, + Holding = 10, + InGame = 11, + Acquiring = 12, +} + +impl Progress { + fn note(&self, epoch: Instant, step: u32) { + self.at_ms + .store(epoch.elapsed().as_millis() as u64, Ordering::Relaxed); + self.step.store(step, Ordering::Relaxed); + } + + /// How long this thread has been where it is, and what it is doing. + fn stalled_for(&self, epoch: Instant) -> (u64, &'static str) { + let at = self.at_ms.load(Ordering::Relaxed); + let now = epoch.elapsed().as_millis() as u64; + let step = self.step.load(Ordering::Relaxed) as usize; + ( + now.saturating_sub(at), + STEPS.get(step).copied().unwrap_or("unknown"), + ) + } +} + +/// Say where the media threads are if either stops moving. +/// +/// Warn rather than error: a stall is not necessarily fatal and may clear. +/// Once per thread per stall, not once a second, because a frozen pipeline +/// would otherwise fill the log with the same line and bury whatever else is +/// still being said. +fn spawn_stall_watchdog( + epoch: Instant, + present: Arc, + capture: Arc, + encoder: Arc, + ipc: Arc, + probe: StallProbe, + shutdown: Arc, +) { + const STALL_MS: u64 = 2_000; + let _ = thread::Builder::new() + .name("nescapture-watchdog".into()) + .spawn(move || { + let mut said = [false; 4]; + while !shutdown.load(Ordering::Relaxed) { + thread::sleep(std::time::Duration::from_millis(500)); + // Upstream first: the game's present thread, then capture, then + // the two after it. Each stopping makes everything after it look + // idle rather than stuck. + for (i, (name, p)) in [ + ("present", &present), + ("capture", &capture), + ("encoder", &encoder), + ("ipc", &ipc), + ] + .iter() + .enumerate() + { + let (stalled, step) = p.stalled_for(epoch); + if stalled >= STALL_MS { + if !said[i] { + log::warn!("{name} thread has not moved for {stalled} ms, {step}"); + // Once per stall, from the first thread to report + // it: the state is the device's, not the thread's. + if !said.iter().any(|&s| s) + && let Ok(probe) = probe.lock() + && let Some(f) = probe.as_ref() + { + log::warn!("at the stall: {}", f()); + } + said[i] = true; + } + } else if said[i] { + log::warn!("{name} thread moving again after a stall"); + said[i] = false; + } + } + } + }); +} + struct EncodedFrame { future: EncodeFuture, present_time: Instant, } pub enum FrameSource { - DmaBuf { - fd: RawFd, - stride: u32, - modifier: u64, + /// A slot image on the game's own device, read in place once `blit` is + /// reached. + Shared { + image: ash::vk::Image, + blit: pixelforge::TimelinePoint, }, Pixels(Vec), } -impl Drop for FrameSource { - fn drop(&mut self) { - if let FrameSource::DmaBuf { fd, .. } = self { - if *fd >= 0 { - unsafe { libc::close(*fd) }; - } - } - } -} - // ── Codec probing ───────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -249,12 +444,14 @@ fn probe_any() -> Option<(HwCodec, pixelforge::VideoContext)> { } fn probe_specific(codec: HwCodec) -> Option<(HwCodec, pixelforge::VideoContext)> { - let ctx = VideoContextBuilder::new() - .app_name("nescapture") - .enable_validation(false) - .require_encode(codec.to_pixelforge()) - .build() - .ok()?; + let ctx = crate::shared::creating_own_device(|| { + VideoContextBuilder::new() + .app_name("nescapture") + .enable_validation(false) + .require_encode(codec.to_pixelforge()) + .build() + }) + .ok()?; if ctx.supports_encode(codec.to_pixelforge()) { log::info!("hardware {:?} encode available", codec); Some((codec, ctx)) @@ -282,14 +479,274 @@ fn resolve_codec(requested: Option<&str>) -> Option<(HwCodec, pixelforge::VideoC } } +/// The codec to use on a context that already exists: the one asked for if +/// it can, falling back the way [`resolve_codec`] does. +/// What this device can encode, in the same shape the client sends. +/// +/// Ten bits is offered for everything but H.264, which matches +/// [`depth_for_codec`]: the encoder refuses ten-bit H.264, so advertising it +/// would let a negotiation settle on something that is then quietly downgraded. +fn host_caps(ctx: &pixelforge::VideoContext) -> ClientCaps { + let mut caps = ClientCaps::empty(); + for (codec, id) in [ + (HwCodec::AV1, nesprotocol::CODEC_AV1), + (HwCodec::H265, nesprotocol::CODEC_H265), + (HwCodec::H264, nesprotocol::CODEC_H264), + ] { + if !ctx.supports_encode(codec.to_pixelforge()) { + continue; + } + caps = caps.with(id, nesprotocol::DEPTH_8); + if codec != HwCodec::H264 { + caps = caps.with(id, nesprotocol::DEPTH_10); + } + } + caps +} + +/// The codec this host should switch to for `client`, if any. +/// +/// `None` means "keep what you are doing", which covers three cases that want +/// the same answer: a client that said nothing, a client sharing nothing with +/// this encoder, and a negotiation that landed where the encoder already is. +fn negotiated( + client: ClientCaps, + host: ClientCaps, + forced: Option<&str>, +) -> Option<(HwCodec, EncodeBitDepth)> { + let (codec_id, depth_id) = client.best(host)?; + let codec = match codec_id { + nesprotocol::CODEC_AV1 => HwCodec::AV1, + nesprotocol::CODEC_H265 => HwCodec::H265, + _ => HwCodec::H264, + }; + let depth = if depth_id == nesprotocol::DEPTH_10 { + EncodeBitDepth::Ten + } else { + EncodeBitDepth::Eight + }; + + // An operator who names a codec gets that codec. Second-guessing an + // explicit setting is worse than sending something undecodable, because + // the undecodable case is visible and this would not be -- but it is worth + // saying out loud, since the result is a black screen at the far end. + if let Some(name) = forced { + let wanted = match name { + "av1" => Some(nesprotocol::CODEC_AV1), + "h265" | "hevc" => Some(nesprotocol::CODEC_H265), + "h264" | "avc" => Some(nesprotocol::CODEC_H264), + _ => None, + }; + if let Some(wanted) = wanted { + if !client.supports_codec(wanted) { + log::warn!( + "NESCAPTURE_CODEC={name} is set and this client cannot decode it; \ + sending it anyway, which the client will not be able to show" + ); + } + // The depth may still follow the negotiation: it is not what was + // pinned. The codec stays whatever the operator named, which + // `resolve_codec_on` has already applied. + return None; + } + } + + Some((codec, depth)) +} + +fn resolve_codec_on(ctx: &pixelforge::VideoContext, requested: Option<&str>) -> Option { + let has = |c: HwCodec| ctx.supports_encode(c.to_pixelforge()); + let best = || { + [HwCodec::AV1, HwCodec::H265, HwCodec::H264] + .into_iter() + .find(|&c| has(c)) + }; + match requested { + Some("av1") if has(HwCodec::AV1) => Some(HwCodec::AV1), + Some("h265" | "hevc") if has(HwCodec::H265) => Some(HwCodec::H265), + Some("h264" | "avc") => has(HwCodec::H264).then_some(HwCodec::H264), + Some("av1" | "h265" | "hevc") => { + log::warn!("{} unavailable — falling back to H.264", requested.unwrap()); + has(HwCodec::H264).then_some(HwCodec::H264) + } + Some(other) => { + log::warn!("unknown NESCAPTURE_CODEC={other} — probing best available"); + best() + } + None => best(), + } +} + +// ── Rate control ────────────────────────────────────────────────────────────── + +/// The bitrate used when nothing asks for one. +const DEFAULT_BITRATE_KBPS: u32 = 10_000; + +/// How the encoder decides what a frame may spend. +/// +/// One value rather than the pair of `Option`s this used to be. The pair could +/// say "both" and "neither", and an encoder can be built for neither of those; +/// the mode was carried by which of the two happened to be set, which left +/// nowhere to put a second number when a mode needed one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateControl { + /// Constant quality. Frame size follows the content, unbounded. + Cqp { qp: u32 }, + /// Constant bitrate: every frame is pushed toward the same size. + Cbr { kbps: u32 }, + /// Variable bitrate: `target_kbps` on average, with frames that need it + /// allowed up to `max_kbps`. + Vbr { target_kbps: u32, max_kbps: u32 }, +} + +impl RateControl { + /// The target in kbps, or `None` under constant QP, which has no bitrate. + fn target_kbps(self) -> Option { + match self { + Self::Cqp { .. } => None, + Self::Cbr { kbps } => Some(kbps), + Self::Vbr { target_kbps, .. } => Some(target_kbps), + } + } + + /// The same mode, aimed at `kbps`. + /// + /// VBR keeps its ceiling. The ceiling describes what the path can carry, + /// which is not a function of what the encode is currently aiming at -- + /// scaling it with the target would shrink the headroom at exactly the + /// moment the target dropped because frames were being lost. + /// + /// Constant QP becomes constant bitrate, because there is no target inside + /// it to move: a caller asking for one is asking for a mode that has one. + fn retargeted(self, kbps: u32) -> Self { + match self { + Self::Cqp { .. } | Self::Cbr { .. } => Self::Cbr { kbps }, + Self::Vbr { max_kbps, .. } => Self::Vbr { + target_kbps: kbps, + max_kbps, + }, + } + } +} + +impl std::fmt::Display for RateControl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Cqp { qp } => write!(f, "QP {qp}"), + Self::Cbr { kbps } => write!(f, "CBR {kbps} kbps"), + Self::Vbr { + target_kbps, + max_kbps, + } => write!(f, "VBR {target_kbps} kbps, up to {max_kbps}"), + } + } +} + +/// Bits per second from kilobits, saturating. The encoder's unit is bits; the +/// environment's is kilobits, because that is the unit a bitrate is quoted in. +fn bps(kbps: u32) -> u32 { + kbps.saturating_mul(1_000) +} + +/// The ceiling a VBR encode gets when it is not given one: half again the +/// target. Enough headroom that a scene change is coded rather than smeared, +/// and still a bound the path can be planned around -- which is the whole +/// reason to name a ceiling instead of leaving the encode unbounded. +fn default_ceiling_kbps(target_kbps: u32) -> u32 { + // Divided before multiplied so a target near the top of the range + // saturates instead of wrapping. A wrapped ceiling is a *small* one, and a + // small ceiling silently throttles the encode -- the one failure here that + // would not look like a failure. + (target_kbps / 2).saturating_mul(3).max(target_kbps) +} + +/// Resolve the rate control from what the environment said. +/// +/// Pure, and takes the parsed values rather than reading them, so the decision +/// can be tested without a process-wide environment. +/// +/// `rc` is authoritative when it names a mode this understands. When it does +/// not -- unset, or a word this does not know -- the mode is inferred the way +/// it was before the variable existed: a QP means constant QP, anything else +/// means a bitrate. That fallback is what keeps every configuration written +/// before this working unchanged. +fn resolve_rate_control( + rc: Option<&str>, + qp: Option, + bitrate_kbps: Option, + max_kbps: Option, +) -> RateControl { + #[derive(Clone, Copy, PartialEq)] + enum Mode { + Cqp, + Cbr, + Vbr, + } + + let named = match rc.map(str::trim) { + None => None, + Some(s) if s.eq_ignore_ascii_case("cqp") => Some(Mode::Cqp), + Some(s) if s.eq_ignore_ascii_case("cbr") => Some(Mode::Cbr), + Some(s) if s.eq_ignore_ascii_case("vbr") => Some(Mode::Vbr), + Some(other) => { + log::warn!( + "NESCAPTURE_RC={other:?} is not a rate control mode \ + (cqp, cbr or vbr) — choosing one from the other settings" + ); + None + } + }; + + let mode = named.unwrap_or(if qp.is_some() { Mode::Cqp } else { Mode::Cbr }); + + if mode != Mode::Vbr && max_kbps.is_some() { + log::warn!("NESCAPTURE_BITRATE_MAX is a VBR ceiling and this encode is not VBR — ignored"); + } + + let target = bitrate_kbps.unwrap_or(DEFAULT_BITRATE_KBPS); + + match mode { + Mode::Cqp => match qp { + Some(qp) => RateControl::Cqp { qp }, + // Inventing a QP would encode at a quality nobody chose, and + // constant quality is the one mode where that number *is* the + // setting. Say what happened and encode at a bitrate instead. + None => { + log::warn!( + "NESCAPTURE_RC=cqp needs a NESCAPTURE_QP to hold constant — \ + encoding at {target} kbps instead" + ); + RateControl::Cbr { kbps: target } + } + }, + Mode::Cbr => RateControl::Cbr { kbps: target }, + Mode::Vbr => { + let max = match max_kbps { + Some(m) if m < target => { + log::warn!( + "NESCAPTURE_BITRATE_MAX={m} is below the {target} kbps target, \ + which is not a ceiling — raising it to the target" + ); + target + } + Some(m) => m, + None => default_ceiling_kbps(target), + }; + RateControl::Vbr { + target_kbps: target, + max_kbps: max, + } + } + } +} + // ── Pipeline config ─────────────────────────────────────────────────────────── pub struct PipelineConfig { pub width: u32, pub height: u32, pub fps: u32, - pub bitrate_kbps: Option, - pub qp: Option, + pub rate_control: RateControl, pub idr_interval: u32, pub encoder_tuning_mode: EncoderTuningMode, pub pixel_format: PixelFormat, @@ -308,11 +765,6 @@ impl PipelineConfig { .unwrap_or_else(|_| "/tmp/nestri-video.sock".to_string()) .into(); - let mut bitrate: Option = None; - if std::env::var("NESCAPTURE_QP").is_err() { - bitrate = Some(env_u64("NESCAPTURE_BITRATE", 10_000) as u32); - } - let encoder_tuning_mode = match std::env::var("NESCAPTURE_TUNE").as_deref() { Ok("highquality") => EncoderTuningMode::HighQuality, Ok("lowlatency") => EncoderTuningMode::LowLatency, @@ -325,10 +777,12 @@ impl PipelineConfig { width, height, fps: env_u64("NESCAPTURE_FPS", 60) as u32, - bitrate_kbps: bitrate, - qp: std::env::var("NESCAPTURE_QP") - .ok() - .and_then(|s| s.parse().ok()), + rate_control: resolve_rate_control( + std::env::var("NESCAPTURE_RC").ok().as_deref(), + env_opt_u32("NESCAPTURE_QP"), + env_opt_u32("NESCAPTURE_BITRATE"), + env_opt_u32("NESCAPTURE_BITRATE_MAX"), + ), idr_interval: (env_u64("NESCAPTURE_FPS", 60) * env_u64("NESCAPTURE_IDR_INTERVAL", 4)) as u32, encoder_tuning_mode, @@ -347,10 +801,31 @@ fn env_u64(key: &str, default: u64) -> u64 { .unwrap_or(default) } +/// A setting that has no default, so that "unset" and "set to something +/// unreadable" can be told apart from a value. +fn env_opt_u32(key: &str) -> Option { + let raw = std::env::var(key).ok()?; + match raw.trim().parse() { + Ok(v) => Some(v), + Err(_) => { + log::warn!("{key}={raw:?} is not a number — ignored"); + None + } + } +} + // ── Pipeline handle ─────────────────────────────────────────────────────────── pub struct PipelineHandle { frame_tx: mpsc::SyncSender, + /// The encoder thread, which owns every pixelforge object. On a shared + /// device those are objects on the game's device, so the game's + /// vkDestroyDevice has to wait for this thread first. See [`Self::finish`]. + encoder_thread: Option>, + capture_progress: Arc, + present_progress: Arc, + stall_probe: StallProbe, + progress_epoch: Instant, idr_requested: Arc, shutdown: Arc, pub codec: HwCodec, @@ -365,9 +840,40 @@ pub struct PipelineHandle { } impl PipelineHandle { - pub fn new(config: PipelineConfig) -> Result { - let (codec, ctx) = resolve_codec(config.codec_request.as_deref()) - .ok_or_else(|| "no hardware video encoder found on this GPU".to_string())?; + /// Build the pipeline, on `shared` when given: a context on the game's own + /// device. Without one the encoder gets a device of its own. + pub fn new( + config: PipelineConfig, + shared: Option, + ) -> Result { + let (codec, ctx) = match shared { + Some(ctx) => { + resolve_codec_on(&ctx, config.codec_request.as_deref()).map(|codec| (codec, ctx)) + } + None => resolve_codec(config.codec_request.as_deref()), + } + .ok_or_else(|| "no hardware video encoder found on this GPU".to_string())?; + let device_caps = host_caps(&ctx); + // The surface colour as the compositor last stated it, for the clients. + // They need it for two things the host cannot do for them: picking a + // swapchain that reads the values the right way, and telling their own + // compositor how bright the picture goes. + let declared_surface: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + // The colour space capture resolved for the frames it is encoding. + // + // Not the same thing as the compositor's declaration, and this is the + // one to send: the swapchain has the last word wherever it names a + // space, and only a pass-through one defers. A game leaving HDR gets a + // plain sRGB swapchain, which says so outright -- while the + // compositor's view of the surface may not have caught up, or may + // never, if the client dropped the object rather than unsetting it. + // `u32::MAX` until the first frame has been encoded. + let stream_colorspace = Arc::new(AtomicU32::new(u32::MAX)); + // What the compositor says the surface is, for the frames whose + // swapchain declines to say. `u32::MAX` until it has said anything; + // see `effective_colorspace`. + let declared_colorspace = Arc::new(AtomicU32::new(u32::MAX)); // One deep. The frame in it is now an unwaited blit rather than an // exported buffer, and the ring's four slots are already the @@ -376,6 +882,22 @@ impl PipelineHandle { let (encoded_tx, encoded_rx) = mpsc::sync_channel::(2); let (reconfig_tx, reconfig_rx) = mpsc::channel::(); let shutdown = Arc::new(AtomicBool::new(false)); + let progress_epoch = Instant::now(); + let encoder_progress = Arc::new(Progress::default()); + let ipc_progress = Arc::new(Progress::default()); + let capture_progress = Arc::new(Progress::default()); + let present_progress = Arc::new(Progress::default()); + present_progress.note(progress_epoch, PresentStep::InGame as u32); + let stall_probe: StallProbe = Arc::default(); + spawn_stall_watchdog( + progress_epoch, + present_progress.clone(), + capture_progress.clone(), + encoder_progress.clone(), + ipc_progress.clone(), + stall_probe.clone(), + shutdown.clone(), + ); let idr_requested = Arc::new(AtomicBool::new(false)); let capture_fps = Arc::new(AtomicU32::new(0)); let encode_avg_ms = Arc::new(AtomicU32::new(0)); @@ -394,8 +916,7 @@ impl PipelineHandle { width: config.width, height: config.height, fps: config.fps, - bitrate_kbps: config.bitrate_kbps, - qp: config.qp, + rate_control: config.rate_control, idr_interval: config.idr_interval, encoder_tuning_mode: config.encoder_tuning_mode, pixel_format: config.pixel_format, @@ -405,13 +926,27 @@ impl PipelineHandle { reconfig_rx, current_codec: current_codec.clone(), wanted_depth_override: None, + declared_colorspace: declared_colorspace.clone(), + stream_colorspace: stream_colorspace.clone(), needs_reconfig_flag: needs_reconfig_flag.clone(), capture_ms: capture_ms.clone(), }; - thread::Builder::new() + let enc_progress = encoder_progress.clone(); + let progress_base = progress_epoch; + let encoder_thread = thread::Builder::new() .name("nescapture-encoder".into()) - .spawn(move || encoder_thread(enc_cfg, frame_rx, encoded_tx, enc_shutdown)) + .spawn(move || { + encoder_thread( + enc_cfg, + frame_rx, + encoded_tx, + enc_shutdown, + progress_base, + enc_progress, + ) + }) .map_err(|e| format!("spawn encoder: {e}"))?; + let encoder_thread = Some(encoder_thread); let ipc_path = config.ipc_path.clone(); let ipc_cfg = IpcConfig { @@ -423,10 +958,14 @@ impl PipelineHandle { encode_ms: encode_avg_ms.clone(), idr_requested: idr_requested.clone(), epoch: Instant::now(), + reconfig_tx: reconfig_tx.clone(), }; + let ipc_prog = ipc_progress.clone(); thread::Builder::new() .name("nescapture-ipc".into()) - .spawn(move || ipc_send_thread(ipc_cfg, encoded_rx, ipc_shutdown)) + .spawn(move || { + ipc_send_thread(ipc_cfg, encoded_rx, ipc_shutdown, progress_base, ipc_prog) + }) .map_err(|e| format!("spawn ipc: {e}"))?; // Spawn periodic stats sender @@ -439,6 +978,8 @@ impl PipelineHandle { let pa = present_attempts.clone(); let ca = capture_attempts.clone(); let stats_timing = timing.clone(); + let stats_surface = declared_surface.clone(); + let stats_stream_colour = stream_colorspace.clone(); thread::Builder::new() .name("nescapture-stats".into()) .spawn(move || { @@ -450,6 +991,8 @@ impl PipelineHandle { pa, ca, stats_timing, + stats_surface, + stats_stream_colour, stats_ipc, stats_shutdown, ) @@ -458,6 +1001,12 @@ impl PipelineHandle { // Spawn IDR command listener (separate thread, blocks on recv) let idr_thread = idr_requested.clone(); + let declared_listener = declared_colorspace.clone(); + let surface_listener = declared_surface.clone(); + // What this device can encode, worked out once here where the context + // is, so the listener answers a capability message without needing one. + let listener_host_caps = device_caps; + let listener_forced = config.codec_request.clone(); thread::Builder::new() .name("nescapture-idr".into()) .spawn(move || { @@ -484,6 +1033,71 @@ impl PipelineHandle { log::info!("IDR requested by client"); idr_thread.store(true, Ordering::Relaxed); } + Ok(n) if n >= 2 && buf[0] == nesprotocol::MSG_SURFACE_COLOR => { + match nesprotocol::decode_surface_color(&buf[1..n]) { + Some(colour) => { + let vk = surface_color_to_vk(colour.space); + if let Ok(mut slot) = surface_listener.lock() { + *slot = Some(colour); + } + let previous = + declared_listener.swap(vk, Ordering::Relaxed); + if previous != vk { + // The mastering numbers go out with it: + // they are not applied yet, and whether + // wine even supplies any decides + // whether applying them is worth + // anything. All zero means it said + // nothing. + log::info!( + "the compositor says this surface is {:?}, mastered at max_cll={} max_fall={} luminance={}..{}", + ash::vk::ColorSpaceKHR::from_raw(vk as i32), + colour.max_cll, + colour.max_fall, + colour.min_luminance, + colour.max_luminance + ); + } + } + None => log::warn!( + "unreadable surface colour from the compositor ({} bytes)", + n - 1 + ), + } + } + Ok(n) if n >= 2 && buf[0] == MSG_CLIENT_CAPS => { + let Some(client) = decode_client_caps(&buf[1..n]) else { + log::warn!("unreadable client capabilities ({} bytes)", n - 1); + continue; + }; + match negotiated(client, listener_host_caps, listener_forced.as_deref()) + { + Some((codec, depth)) => { + log::info!( + "client decodes {:#08b}; {codec:?} {depth:?} is the best \ + both ends can do", + client.bits() + ); + let change = EncodeSettingsChange { + codec: Some(codec), + rate_control_mode: None, + value: 0, + bit_depth: Some(depth), + }; + if reconfig_tx.send(change).is_err() { + log::warn!( + "reconfig channel closed, stopping cmd listener" + ); + break; + } + } + None => log::info!( + "client decodes {:#08b}, which shares nothing with this \ + encoder; leaving the stream alone", + client.bits() + ), + } + } Ok(n) if n >= 2 && buf[0] == MSG_ENCODE_SETTINGS => { if let Some((codec_id, rc, value, depth)) = decode_encode_settings(&buf[1..n]) @@ -500,9 +1114,21 @@ impl PipelineHandle { continue; } }; + // `RC_KEEP` is not a mode, it is the + // absence of one: a depth-only message must + // not take the bitrate away from whoever is + // managing it. Read as a mode it would have + // meant CQP at quality zero. let rate_control = match rc { - 0 => RateControlMode::Cbr, - _ => RateControlMode::Cqp, + nesprotocol::RC_KEEP => None, + nesprotocol::RC_CBR => Some(RateControlMode::Cbr), + nesprotocol::RC_CQP => Some(RateControlMode::Cqp), + other => { + log::warn!( + "unknown rate control mode {other} in encode settings; keeping current" + ); + None + } }; let bit_depth = depth.and_then(|d| match d { 0 => Some(EncodeBitDepth::Eight), @@ -532,22 +1158,21 @@ impl PipelineHandle { .map_err(|e| format!("spawn idr: {e}"))?; log::info!( - "pipeline ready — {:?} {}x{} @ {}FPS {} -> {}", + "pipeline ready — {:?} {}x{} @ {}FPS, {} -> {}", codec, config.width, config.height, config.fps, - (if config.bitrate_kbps.is_some() { - std::format!("- CBR: {}kbps", config.bitrate_kbps.unwrap()) - } else if config.qp.is_some() { - std::format!("- QP: {}", config.qp.unwrap()) - } else { - "".to_string() - }), + config.rate_control, ipc_path.display(), ); Ok(Self { frame_tx, + encoder_thread, + capture_progress: capture_progress.clone(), + present_progress, + stall_probe, + progress_epoch, idr_requested, shutdown, codec, @@ -561,12 +1186,31 @@ impl PipelineHandle { }) } + /// Say where the game's present thread has got to. + pub fn note_present(&self, step: PresentStep) { + self.present_progress.note(self.progress_epoch, step as u32); + } + + /// What to describe the device with when a stall is noticed. + pub fn set_stall_probe(&self, probe: Box String + Send>) { + if let Ok(mut p) = self.stall_probe.lock() { + *p = Some(probe); + } + } + pub fn push_frame(&self, frame: CapturedFrame) -> bool { // Counted on success only. It used to be incremented before the send, // so a frame the channel refused was reported both as captured and as // dropped, and the capture rate read as the rate the ring offered // rather than the rate the encoder accepted — which is the number // anyone reading it wants. + // The game's own thread, reached from the present hook. It is the + // start of the chain and the only part nothing else can speak for: if + // the game stops presenting, or the layer stops capturing what it + // presents, every thread downstream sits idle waiting and none of them + // is stuck. An idle thread looks healthy, which is how a stopped + // capture reads as a working encoder. + self.capture_progress.note(self.progress_epoch, 7); match self.frame_tx.try_send(frame) { Ok(()) => { self.capture_fps.fetch_add(1, Ordering::Relaxed); @@ -583,6 +1227,28 @@ impl PipelineHandle { self.shutdown.store(true, Ordering::SeqCst); } + /// Stop the pipeline and wait up to `timeout` for the encoder thread to + /// have dropped everything it built. Whether it did. + /// + /// The thread notices within one receive timeout of the flag, or at once + /// when the frame channel closes, which dropping the handle does. + pub fn finish(mut self, timeout: std::time::Duration) -> bool { + self.shutdown(); + let Some(thread) = self.encoder_thread.take() else { + return true; + }; + drop(self); + let deadline = Instant::now() + timeout; + while !thread.is_finished() { + if Instant::now() >= deadline { + return false; + } + thread::sleep(std::time::Duration::from_millis(5)); + } + let _ = thread.join(); + true + } + pub fn request_idr(&self) { self.idr_requested.store(true, Ordering::Relaxed); } @@ -601,8 +1267,7 @@ struct EncoderConfig { width: u32, height: u32, fps: u32, - bitrate_kbps: Option, - qp: Option, + rate_control: RateControl, idr_interval: u32, encoder_tuning_mode: EncoderTuningMode, pixel_format: PixelFormat, @@ -612,6 +1277,11 @@ struct EncoderConfig { reconfig_rx: mpsc::Receiver, current_codec: Arc, wanted_depth_override: Option, + /// What the compositor says the surface's colour is, when the swapchain + /// does not. See [`effective_colorspace`]. + declared_colorspace: Arc, + /// What that resolved to for the frames being encoded, for the clients. + stream_colorspace: Arc, needs_reconfig_flag: Arc, /// Present-to-encoder latency in milliseconds, as `f32` bits. Written here /// now that this thread is the one doing the waiting. @@ -627,7 +1297,9 @@ struct EncodedPacket { #[derive(Debug, Clone)] pub struct EncodeSettingsChange { pub codec: Option, - pub rate_control_mode: RateControlMode, + /// `None` leaves rate control exactly as it is, which is what a message + /// that only means to change something else says. + pub rate_control_mode: Option, pub value: u32, pub bit_depth: Option, } @@ -637,6 +1309,8 @@ fn encoder_thread( frame_rx: mpsc::Receiver, encoded_tx: mpsc::SyncSender, shutdown: Arc, + epoch: Instant, + progress: Arc, ) { let ctx = cfg.ctx; @@ -645,25 +1319,71 @@ fn encoder_thread( // once per frame. let mut unsupported_format: Option = None; - let mut dmabuf_importer = match DmaBufImporter::new(ctx.clone()) { - Ok(i) => Some(i), - Err(e) => { - log::warn!("DmaBufImporter init failed: {e} — GPU path unavailable"); - None - } - }; - let mut frame_number = 0u32; + // The slot of the last frame converted in place, until that conversion + // is known to be done. See `HeldSlot`. + let mut held: Option = None; + let wanted_depth = std::env::var("NESCAPTURE_DEPTH"); + // So the ten-bit refusal is said once per codec rather than per frame. + let mut warned_depth = false; + // When the last encoder build failed, and what it said, so a configuration + // the device refuses is not rebuilt on every frame. + let mut init_failed_at: Option = None; + let mut last_init_error: Option = None; loop { if shutdown.load(Ordering::Relaxed) { break; } + // Also on idle ticks, so the last frame before a pause gives its slot + // back: a ring rebuild waits for every slot. + if held.as_ref().is_some_and(HeldSlot::done) + && let Some(done) = held.take() + { + done.release(); + } + // Check for dynamic encode settings changes if let Ok(change) = cfg.reconfig_rx.try_recv() { + // A bitrate is the one setting that can move without rebuilding + // anything, and it is the one that moves most often -- a congestion + // controller adjusts it continuously, and every rebuild costs an IDR. + // An IDR is the largest frame there is, so paying one per adjustment + // would spend the most on the path least able to afford it, at the + // exact moment it is struggling. Everything else here -- a codec, a + // bit depth, a rate-control *mode* -- changes the video session + // itself and cannot avoid the rebuild. + // A change that asks for what is already happening still costs + // a rebuild and an IDR, and the negotiation sends one every time a + // client connects -- including the common case where the client + // wants exactly what this encoder is already producing. + if changes_nothing(&change, cfg.codec, cfg.wanted_depth_override) { + log::debug!("reconfig asks for the current settings; nothing to do"); + continue; + } + if let Some(kbps) = bitrate_only_change( + &change, + cfg.rate_control, + cfg.codec, + cfg.wanted_depth_override, + ) && let Some(state) = encoder_state.as_mut() + { + match state.encoder.set_target_bitrate(bps(kbps)) { + Ok(()) => { + cfg.rate_control = cfg.rate_control.retargeted(kbps); + // Same reasoning as the hub's own line: a controller + // tracking a moving path retunes every second. + log::trace!("bitrate → {kbps} kbps (no rebuild, no IDR)"); + continue; + } + // Refused means this encode has no bitrate to retarget, so + // fall through and rebuild it as one that does. + Err(e) => log::info!("live retune refused ({e}), rebuilding"), + } + } log::info!( "reconfig: codec={:?}, rc={:?}, value={}", change.codec, @@ -671,13 +1391,20 @@ fn encoder_thread( change.value, ); match change.rate_control_mode { - RateControlMode::Cbr => { - cfg.bitrate_kbps = Some(change.value); - cfg.qp = None; + // Said nothing about rate control, so nothing changes. The + // rebuild below still happens: a depth change needs one. + None => {} + // `retargeted` rather than an outright CBR, so a VBR encode + // keeps its ceiling across a rebuild it is having for some + // other reason -- a codec change, say. The settings message has + // no way to name VBR, so every bitrate arrives labelled CBR; + // reading that label as a mode would mean a ceiling asked for + // at launch survived only until the first codec switch. + Some(RateControlMode::Cbr) => { + cfg.rate_control = cfg.rate_control.retargeted(change.value); } - RateControlMode::Cqp => { - cfg.bitrate_kbps = None; - cfg.qp = Some(change.value); + Some(RateControlMode::Cqp) => { + cfg.rate_control = RateControl::Cqp { qp: change.value }; } _ => { log::warn!( @@ -693,7 +1420,7 @@ fn encoder_thread( cfg.wanted_depth_override = Some(depth); } // Drop old encoder state to force re-creation with new settings - encoder_state = None; + drop_encoder(&mut encoder_state, &mut held); // Signal IPC thread to set FLAG_RECONFIG on next frame cfg.needs_reconfig_flag.store(true, Ordering::Relaxed); // Update IPC thread with new codec @@ -703,7 +1430,8 @@ fn encoder_thread( cfg.idr_requested.store(true, Ordering::Relaxed); } - let raw = match frame_rx.recv_timeout(std::time::Duration::from_millis(100)) { + progress.note(epoch, 1); + let mut raw = match frame_rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(frame) => frame, Err(mpsc::RecvTimeoutError::Timeout) => continue, Err(mpsc::RecvTimeoutError::Disconnected) => break, @@ -713,7 +1441,10 @@ fn encoder_thread( // its own between the present hook and here; it is cheaper on this one, // because the blit it waits for was submitted a frame earlier and has // already completed, and every frame saves a channel and a wakeup. - let Some(ds) = crate::state::DEVICE_STATE.get(&raw.ds_key).map(|s| s.clone()) else { + let Some(ds) = crate::state::DEVICE_STATE + .get(&raw.ds_key) + .map(|s| s.clone()) + else { log::error!("encoder: device state gone"); break; }; @@ -741,45 +1472,132 @@ fn encoder_thread( continue; }; - let bit_depth = if let Some(ov) = cfg.wanted_depth_override { + let requested_depth = if let Some(ov) = cfg.wanted_depth_override { ov } else { match wanted_depth.as_deref() { Ok("10") => EncodeBitDepth::Ten, - Ok("8") => EncodeBitDepth::Eight, - _ => input_format_bit_depth(input_fmt), + // Eight rather than the source's own depth. What the game chose + // to render into says what *it* wanted, not what this stream + // should carry or what the encoder can produce -- and deriving + // one from the other means a game opening a 10-bit swapchain + // silently selects an encode profile the hardware may not have. + // Control does exactly that, and picked a profile that does not + // exist. + _ => EncodeBitDepth::Eight, } }; - let color_space = vk_colorspace_to_color_space(raw.vk_colorspace); + let bit_depth = depth_for_codec(cfg.codec, requested_depth, &mut warned_depth); let out_fmt = output_format(cfg.pixel_format, bit_depth); + // The geometry joins the guard. It used to be absent, and `cfg.width` / + // `cfg.height` were whatever the *first* frame happened to be, so a game + // that changed resolution kept being encoded at the old one: shrinking + // left a band of the previous picture down the right edge and along the + // bottom, and growing had no surface large enough to hold the frame. + // Resolved per frame, because the compositor can say what a surface + // is after the swapchain that carries it was created -- and on the + // path that needs this, it always does. + let declared = match cfg.declared_colorspace.load(Ordering::Relaxed) { + u32::MAX => None, + value => Some(value), + }; + let frame_colorspace = effective_colorspace(raw.vk_colorspace, declared); + cfg.stream_colorspace + .store(frame_colorspace, Ordering::Relaxed); + let state = match encoder_state.as_mut() { - Some(s) if s.bit_depth == bit_depth && s.pixel_format == cfg.pixel_format => s, + Some(s) + if encoder_still_serves( + ( + s.width, + s.height, + s.bit_depth, + s.pixel_format, + s.colorspace, + s.input_fmt, + ), + ( + raw.width, + raw.height, + bit_depth, + cfg.pixel_format, + frame_colorspace, + input_fmt, + ), + ) => + { + s + } _ => { - let color_desc = vk_colorspace_to_color_description(raw.vk_colorspace); + // Building an encoder is expensive and a configuration the + // device cannot do will not start working on the next frame. + // Without this, a refused profile is retried sixty times a + // second forever -- which is how an unsupported ten-bit H.264 + // request turned into a log with nothing else in it and a + // session that never recovered. + if let Some(failed_at) = init_failed_at + && failed_at.elapsed() < INIT_RETRY_INTERVAL + { + frame_number += 1; + continue; + } + if let Some(old) = encoder_state.as_ref() { + if old.width != raw.width || old.height != raw.height { + log::info!( + "resolution changed {}x{} -> {}x{}, rebuilding the encoder", + old.width, + old.height, + raw.width, + raw.height, + ); + } + // Said out loud because a game can change this without + // changing anything else about the surface -- Cyberpunk's + // HDR10 and scRGB modes differ in the format and in + // nothing the old check looked at. + if old.input_fmt != input_fmt { + log::info!( + "surface format changed {:?} -> {:?}, rebuilding the encoder", + old.input_fmt, + input_fmt, + ); + } + } + // The old converter may still be reading a held slot, and the + // held slot's point is on the old converter's timeline. + drop_encoder(&mut encoder_state, &mut held); match PerFrameEncoder::new( &ctx, cfg.codec.to_pixelforge(), - cfg.width, - cfg.height, + raw.width, + raw.height, cfg.fps, - cfg.bitrate_kbps, - cfg.qp, + cfg.rate_control, cfg.idr_interval, cfg.encoder_tuning_mode, cfg.pixel_format, bit_depth, - color_desc, input_fmt, out_fmt, - color_space, + frame_colorspace, + matches!(source, FrameSource::Shared { .. }), ) { Ok(s) => { + init_failed_at = None; + last_init_error = None; encoder_state = Some(s); encoder_state.as_mut().unwrap() } Err(e) => { - log::error!("encoder (re)init: {e}"); + // Said once per distinct failure. The same refusal every + // second says nothing the first one did not, and buries + // the one that is different. + if last_init_error.as_deref() != Some(e.as_str()) { + log::error!("encoder (re)init: {e}"); + last_init_error = Some(e); + } + init_failed_at = Some(std::time::Instant::now()); frame_number += 1; continue; } @@ -787,51 +1605,43 @@ fn encoder_thread( } }; - let mut force_idr = cfg.idr_requested.swap(false, Ordering::Relaxed); - if cfg.idr_interval > 0 { - force_idr = force_idr || frame_number % cfg.idr_interval == 0; - } - if force_idr { + // Only when something asked. Periodic key frames are the encoder's own + // schedule, set by `with_gop_size` -- counting frames here as well + // meant two mechanisms driving one thing, and the encoder's schedule + // being the one that could be turned off. Turning it off changed + // nothing, because this kept asking every four seconds regardless. + if cfg.idr_requested.swap(false, Ordering::Relaxed) { state.encoder.request_idr(); } - // Each ring slot is a distinct DMA-BUF, so the importer caches an - // imported image per slot. Importing every frame under index 0 would - // have handed the encoder whichever buffer happened to be imported - // first, for every frame after it. + // Which ring slot the frame is in, for the slot hold below. let buffer_index = raw.slot.as_ref().map(|s| s.index()).unwrap_or(0); + progress.note(epoch, 2); let result = match &mut source { - FrameSource::DmaBuf { - fd, - stride, - modifier, - } => { - let owned_fd = *fd; - *fd = -1; - match dmabuf_importer.as_mut() { - Some(importer) => gpu_encode_frame( - importer, - &mut state.converter, - &mut state.encoder, - owned_fd, - *stride, - *modifier, - raw.width, - raw.height, - raw.vk_format, - frame_number, - buffer_index, - ), - None => { - unsafe { libc::close(owned_fd) }; - log::warn!( - "DmaBuf fd available but importer is gone — skipping frame {frame_number}" - ); - frame_number += 1; - continue; - } + // The encoder converts RGB itself: it copies the slot and waits for + // the copy before returning, so the slot needs no holding. + FrameSource::Shared { image, blit } if state.converter.is_none() => state + .encoder + .encode_after(*image, &[*blit]) + .map_err(|e| anyhow::anyhow!("Encoder::encode_after: {e}")), + FrameSource::Shared { image, blit } => { + let converter = state.converter.as_mut().expect("guarded above"); + let (result, converted) = + shared_encode_frame(converter, &mut state.encoder, *image, *blit); + // Replacing the held slot is what releases it: a successful + // conversion started only once the previous one had finished. + if let (Some(converted), Some(guard)) = (converted, raw.slot.take()) + && let Some(prev) = held.replace(HeldSlot { + guard, + slot: buffer_index, + converted, + ds: ds.clone(), + }) + { + prev.release(); } + result } FrameSource::Pixels(pixels) => cpu_encode_frame( &ctx, @@ -858,6 +1668,9 @@ fn encoder_thread( future, present_time: raw.present_time, }; + // The blocking send. If the thread draining this stops, every + // frame after the second one waits here forever. + progress.note(epoch, 3); if encoded_tx.send(pending).is_err() { break; } @@ -871,15 +1684,39 @@ fn encoder_thread( if let Some(state) = encoder_state.as_mut() { let _ = state.encoder.flush(); } + drop_encoder(&mut encoder_state, &mut held); log::info!("encoder thread exited"); } struct PerFrameEncoder { encoder: Encoder, - converter: ColorConverter, + /// The colour space this encoder and its converter were built for. + /// + /// Part of what decides whether it still serves: the stream declares this + /// in its own metadata and the converter is built around it, so a surface + /// that changes colour needs a new one rather than a relabelled old one. + colorspace: u32, + /// The swapchain format this encoder and its converter were built to + /// read. + /// + /// Part of what decides whether it still serves, and the part whose + /// absence corrupted the picture: a converter reads its input at a fixed + /// bytes-per-pixel, so one built for a packed 10-bit surface fed a float16 + /// one reads every row at half its length. That is not a colour error, it + /// is the picture sheared into stripes. + input_fmt: InputFormat, + /// `None` when the encoder takes the RGB frame and converts it itself. + converter: Option, bit_depth: EncodeBitDepth, pixel_format: PixelFormat, + /// The geometry this encoder and its converter were built for. + /// + /// A Vulkan video session pins its coded extent at creation and the + /// converter is sized to match, so a frame of a different size cannot be + /// encoded by either -- it has to be rebuilt. + width: u32, + height: u32, } impl PerFrameEncoder { @@ -890,24 +1727,21 @@ impl PerFrameEncoder { width: u32, height: u32, fps: u32, - bitrate_kbps: Option, - qp: Option, + rate_control: RateControl, idr_interval: u32, encoder_tuning_mode: EncoderTuningMode, pixel_format: PixelFormat, bit_depth: EncodeBitDepth, - color_desc: Option, input_fmt: InputFormat, out_fmt: OutputFormat, - color_space: ColorSpace, + vk_colorspace: u32, + in_place: bool, ) -> Result { + let source = source_spec(vk_colorspace, input_fmt); log::info!( - "(re)init encoder: {:?} {:?} {:?} {:?} → {:?}", - codec, - pixel_format, - bit_depth, - color_space, - out_fmt + "(re)init encoder: {codec:?} {width}x{height} {pixel_format:?} {bit_depth:?} \ + {rate_control} {source:?} → {:?} {out_fmt:?}", + stream_spec(source), ); let mut enc_cfg = match codec { @@ -924,99 +1758,397 @@ impl PerFrameEncoder { .with_encode_usage_hint(EncodeUsageHint::Streaming) .with_encode_content_hint(EncodeContentHint::Rendered) .with_encoder_tuning_mode(encoder_tuning_mode); - if let Some(desc) = color_desc { - enc_cfg = enc_cfg.with_color_description(desc); - } else { - // GPU framebuffer captures are always full-range — use BT.709 full-range - // so the decoder doesn't apply limited‑range expansion. - enc_cfg = - enc_cfg.with_color_description(ColorDescription::bt709().with_full_range(true)); + // Derived from the conversion rather than matched separately: the VUI + // has to describe what the shader actually wrote, and two independent + // matches on the same input drift the moment one gains an arm the other + // does not. `color_description` answers from the same source, target and + // range the converter is about to be built with. + let conv_cfg = converter_config(width, height, input_fmt, out_fmt, vk_colorspace); + enc_cfg = + enc_cfg.with_color_description(conv_cfg.color_description().ok_or_else(|| { + format!("colour space {vk_colorspace} has no encodable stream description") + })?); + // `Smooth` rather than `Recovering`, because this stream already has a + // way to recover: the client asks for an IDR on the command socket + // when it needs one. `Recovering` would restrict prediction on every + // picture forever to buy a guarantee that is wanted seconds at a time, + // and the restriction is expensive -- it is what turns the refreshed + // band into a visible quality discontinuity. + // + // The cycle length is not set here on purpose. It follows the key + // frame interval, bounded by the device and by how many refresh + // regions the picture has, and that last bound is not visible from + // this side: at 1080p an H.265 picture has 17 CTB rows, so a cycle + // named in seconds was routinely asking for regions that do not exist. + let refresh = intra_refresh_enabled().then_some(IntraRefresh::Smooth); + let shape = intra_refresh_shape(); + if refresh.is_some() { + log::info!( + "intra refresh on, replacing periodic key frames{}", + match shape { + Some(shape) => std::format!(" — shape {shape:?}"), + None => String::new(), + } + ); } - enc_cfg = if let Some(q) = qp { - enc_cfg + enc_cfg = enc_cfg + .with_intra_refresh(refresh) + .with_intra_refresh_mode(shape) + .with_intra_refresh_qp_delta(intra_refresh_qp_delta()); + + // The rate-control buffer is left unset: the encoder derives it from + // the streaming usage hint above and the frame rate, which is the same + // answer this used to compute and one fewer place to disagree. + enc_cfg = match rate_control { + RateControl::Cqp { qp } => enc_cfg .with_rate_control(RateControlMode::Cqp) - .with_quality_level(q) - } else { - if let Some(bitrate) = bitrate_kbps { - enc_cfg - .with_rate_control(RateControlMode::Cbr) - .with_target_bitrate(bitrate * 1_000) - } else { - enc_cfg - .with_rate_control(RateControlMode::Cbr) - .with_target_bitrate(1000 * 1_000) - } + .with_quality_level(qp), + RateControl::Cbr { kbps } => enc_cfg + .with_rate_control(RateControlMode::Cbr) + .with_target_bitrate(bps(kbps)), + RateControl::Vbr { + target_kbps, + max_kbps, + } => enc_cfg + .with_rate_control(RateControlMode::Vbr) + .with_target_bitrate(bps(target_kbps)) + .with_max_bitrate(bps(max_kbps)), }; + if in_place + && std::env::var("NESCAPTURE_RGB_ENCODE").as_deref() != Ok("0") + && let Some(rgb) = conv_cfg.rgb_encode_input(ctx) + { + // Full range only. The hardware matrix is a whole shader pass + // saved on every frame, but not at the cost of the samples: VCN 5 + // converts BT.709 to limited range whatever it is asked for, which + // RADV now reports honestly instead of pretending otherwise, and + // taking that offer would mean every session on that hardware + // throws away range because of a firmware bug. The shader costs GPU + // time; limited range costs picture, everywhere, until AMD fix it. + // + // Nothing here needs changing when they do - the driver will start + // accepting full range and this will start succeeding. + if let Some(description) = conv_cfg.color_description() { + let rgb_cfg = enc_cfg + .clone() + .with_color_description(description) + .with_rgb_input(rgb); + match Encoder::new(ctx.clone(), rgb_cfg) { + Ok(encoder) => { + log::info!( + "the encoder converts {rgb:?} to full-range YUV itself; no conversion shader" + ); + return Ok(Self { + encoder, + colorspace: vk_colorspace, + input_fmt, + converter: None, + bit_depth, + pixel_format, + width, + height, + }); + } + Err(e) => log::info!( + "the encoder will not convert {rgb:?} at full range ({e}); using the shader" + ), + } + } + } + let encoder = Encoder::new(ctx.clone(), enc_cfg).map_err(|e| format!("Encoder::new: {e}"))?; - let mut conv_cfg = ColorConverterConfig::new(width, height, input_fmt, out_fmt); - // The matrix the shader applies has to be the one the VUI declares. The - // colour space was previously computed, logged and then dropped on the - // floor, so BT.2020 captures were converted with the BT.709 matrix and - // the scRGB→PQ arm never ran at all. - conv_cfg.color_space = color_space; - conv_cfg.sdr_reference_white_nits = sdr_reference_white_nits(color_space); - // Capture is always full-range; `vk_colorspace_to_color_description` tags - // the stream to match. - conv_cfg.full_range = true; - - let converter = ColorConverter::new(ctx.clone(), conv_cfg) - .map_err(|e| format!("ColorConverter::new: {e}"))?; + let converter = Some( + ColorConverter::new(ctx.clone(), conv_cfg) + .map_err(|e| format!("ColorConverter::new: {e}"))?, + ); Ok(Self { encoder, + colorspace: vk_colorspace, + input_fmt, converter, bit_depth, pixel_format, + width, + height, }) } } -fn gpu_encode_frame( - importer: &mut DmaBufImporter, +/// The new target in kbps when a settings change is nothing but a bitrate. +/// +/// `None` when anything else moved, in which case the session has to be rebuilt. +/// The comparisons against the running configuration matter: the debug overlay +/// sends every field on every apply, so a change that only moved the slider +/// still arrives carrying a codec and a bit depth. Treating those as changes +/// would rebuild the encoder -- and emit an IDR -- every time somebody nudged +/// the bitrate. +/// Pictures in one intra refresh cycle, or `None` for periodic key frames. +/// +/// Intra refresh spreads a key frame's work across a cycle: each picture codes +/// one slice of the image as intra, so after a full cycle every part has been +/// refreshed and a decoder joining anywhere is correct within one cycle. The +/// same recovery, with no picture much larger than any other. +/// +/// The cycle is expressed in *seconds* and converted, for the same reason the +/// rate-control buffer is expressed in frames: it is a recovery interval, and +/// how many pictures that is depends on the frame rate. It defaults to the IDR +/// interval it replaces, so the recovery guarantee does not quietly change +/// when this is turned on -- what changes is that the cost is paid evenly +/// rather than all at once. +/// +/// Measured on RADV, H.264 1080p: the largest picture went from twice the +/// median to 1.2 times it, and the only one above the median was the opening +/// IDR. AV1 is not yet worth turning this on for; see the pixelforge test. +/// How much to shift QP inside the refresh band, or `None` for the encoder's +/// own default. +/// +/// The band is freshly intra-coded every cycle and carries none of the +/// refinement its neighbours have built up, so it reads as a strip of lower +/// quality sweeping across the picture. A negative delta spends bits back into +/// it, out of the rest of the frame — which is a perceptual trade with no +/// closed form, so the number worth using is whichever looks best on the +/// content being streamed. +/// +/// Ignored where the device cannot express it; the encoder says so and carries +/// on without one. +fn intra_refresh_qp_delta() -> Option { + let raw = std::env::var("NESCAPTURE_INTRA_REFRESH_QP_DELTA").ok()?; + match raw.trim().parse() { + Ok(v) => Some(v), + Err(_) => { + log::warn!("NESCAPTURE_INTRA_REFRESH_QP_DELTA={raw:?} is not a number — ignored"); + None + } + } +} + +/// Which shape the refresh regions take, from the environment. +/// +/// `auto` (the default) lets the driver divide the picture and choose the +/// direction of the sweep, which is what the spec recommends when there is no +/// preference. `rows` sweeps a horizontal band down the picture and `columns` +/// a vertical band across it -- which of those looks better depends on how the +/// content moves, so it is a question for whoever is watching rather than one +/// answerable here. An unknown value is refused rather than guessed at. +fn intra_refresh_shape() -> Option { + let v = std::env::var("NESCAPTURE_INTRA_REFRESH_SHAPE").ok()?; + match v.trim().to_ascii_lowercase().as_str() { + "auto" | "" => None, + "blocks" => Some(IntraRefreshShape::Blocks), + "rows" | "row" => Some(IntraRefreshShape::Rows), + "columns" | "column" | "cols" => Some(IntraRefreshShape::Columns), + "partitions" | "partition" => Some(IntraRefreshShape::Partitions), + other => { + log::warn!("unknown intra refresh shape {other:?}; letting the driver choose"); + None + } + } +} + +/// Whether to replace periodic key frames with an intra refresh cycle. +/// +/// A plain switch. It used to name a cycle length in seconds, which turned out +/// to be a number this side cannot get right: the cycle is bounded by how many +/// refresh regions the picture has, and that depends on the codec's block size +/// and the device's capabilities. At 1080p an H.265 picture has 17 CTB rows +/// against H.264's 68 macroblock rows, so the same duration was valid for one +/// codec and impossible for the other. The encoder knows both and derives it. +fn intra_refresh_enabled() -> bool { + match std::env::var("NESCAPTURE_INTRA_REFRESH").as_deref() { + Ok("1" | "true" | "yes" | "on") => true, + Ok("0" | "false" | "no" | "off") | Err(_) => false, + Ok(other) => { + log::warn!("NESCAPTURE_INTRA_REFRESH={other:?} is not a yes or a no — off"); + false + } + } +} + +/// Whether `change` asks for exactly what the encoder is already doing. +/// +/// Only true for a change that names no rate control: one that does is either +/// a retarget, which is handled without a rebuild anyway, or a mode change, +/// which is never a no-op. +fn changes_nothing( + change: &EncodeSettingsChange, + current_codec: HwCodec, + current_depth: Option, +) -> bool { + if change.rate_control_mode.is_some() { + return false; + } + let codec_same = change.codec.is_none_or(|c| c == current_codec); + // `None` for the current depth means nothing has overridden the default, + // which is eight bits -- so a request for eight bits is a no-op and a + // request for ten is not. + let depth_same = change.bit_depth.is_none_or(|d| { + Some(d) == current_depth || (current_depth.is_none() && d == EncodeBitDepth::Eight) + }); + codec_same && depth_same +} + +fn bitrate_only_change( + change: &EncodeSettingsChange, + current: RateControl, + current_codec: HwCodec, + current_depth_override: Option, +) -> Option { + if change.rate_control_mode != Some(RateControlMode::Cbr) { + return None; + } + // Already under a bitrate. Coming *from* constant QP is a mode change, and + // the session was built for the other one. + // + // A VBR encode qualifies, and that is the point. The settings message can + // only say CBR or constant QP, so every bitrate a controller sends arrives + // labelled CBR -- and reading the label rather than the number would tear + // down a VBR session and rebuild it as CBR on the first adjustment, so a + // ceiling asked for at launch would last exactly until the path moved. + // What the message carries is a target; the mode is what was asked for. + current.target_kbps()?; + if change.codec.is_some_and(|c| c != current_codec) { + return None; + } + if change + .bit_depth + .is_some_and(|d| Some(d) != current_depth_override) + { + return None; + } + Some(change.value) +} + +/// Whether an existing encoder can take this frame, or has to be rebuilt. +/// +/// **The geometry is part of the answer**, and it used to be missing. A Vulkan +/// video session pins its coded extent when it is created and the colour +/// converter is sized to match, so neither can take a frame of another size -- +/// but the guard only compared bit depth and pixel format, and the dimensions +/// it built with came from whatever the *first* frame happened to be. A game +/// that changed resolution went on being encoded at the old one. +fn encoder_still_serves( + existing: (u32, u32, EncodeBitDepth, PixelFormat, u32, InputFormat), + wanted: (u32, u32, EncodeBitDepth, PixelFormat, u32, InputFormat), +) -> bool { + existing == wanted +} + +/// The bit depth a codec can actually encode, given what was asked for. +/// +/// **H.264 has no ten-bit encode.** Main 10 exists on paper for H.264 only as a +/// vendor extension that no Vulkan Video implementation here offers, so asking +/// for it does not produce a worse stream, it produces +/// `ERROR_VIDEO_PROFILE_CODEC_NOT_SUPPORTED_KHR` and no stream at all. H.265 and +/// AV1 have it properly. +/// +/// Clamped here rather than refused, because the caller asking is often not a +/// person: a client may send a depth alongside a codec change, and a session +/// that stops encoding is worse than one that encodes eight-bit. +/// How long to wait before rebuilding an encoder whose last build failed. +const INIT_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); + +/// How long a write to the IPC socket may wait before the frame is dropped. +/// +/// Long enough to ride out a consumer that is briefly busy -- rebuilding a +/// decoder after a resolution change, say -- and short enough that it cannot +/// stop capture. There is no value here worth freezing the pipeline for: a +/// frame nobody could take in a quarter of a second is one nobody wanted. +const IPC_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250); + +fn depth_for_codec(codec: HwCodec, requested: EncodeBitDepth, warned: &mut bool) -> EncodeBitDepth { + if codec == HwCodec::H264 && requested == EncodeBitDepth::Ten { + if !*warned { + *warned = true; + log::warn!("H.264 has no ten-bit encode; using eight-bit"); + } + return EncodeBitDepth::Eight; + } + *warned = false; + requested +} + +/// A slot whose image the converter may still be reading. +/// +/// On a shared device nothing waits for a conversion on the CPU, so a frame's +/// slot cannot go back to the ring the moment its conversion is submitted: the +/// next blit into it would race the read. It is held until the conversion is +/// known to be done instead, which costs nothing in steady state -- the next +/// frame's conversion waits for this one before it starts, so the slot is +/// released right after -- and a timeline poll on idle ticks otherwise. +/// +/// The point is on the converter's own timeline, so it may only be looked at +/// while that converter exists. [`drop_encoder`] is what keeps it so. +struct HeldSlot { + guard: crate::slots::SlotGuard, + slot: usize, + converted: pixelforge::TimelinePoint, + ds: Arc, +} + +impl HeldSlot { + fn done(&self) -> bool { + self.ds + .shared_encoder() + .is_none_or(|s| s.reached(self.converted)) + } + + /// Give the slot back. The blit it held finished before the conversion + /// that waited on it did, so its timing can be read without waiting. + fn release(self) { + if let Some(ns) = unsafe { crate::capture::blit_gpu_time_ns(&self.ds, self.slot, false) } + && let Ok(enc) = self.ds.encoder.lock() + && let Some(ref h) = *enc + { + h.timing.blit.record(std::time::Duration::from_nanos(ns)); + } + drop(self.guard); + } +} + +/// Drop the encoder and its converter, then the held slot. +/// +/// In that order: dropping the converter waits for its last conversion, after +/// which the held slot is free and its point, on the converter's timeline, +/// would be a dangling handle anyway. +fn drop_encoder(state: &mut Option, held: &mut Option) { + *state = None; + if let Some(slot) = held.take() { + slot.release(); + } +} + +/// Convert a slot image in place on the game's device and encode the result, +/// every step ordered on the GPU: the conversion waits for the blit, the +/// encode for the conversion. +/// +/// Also returns the point the conversion signals, whenever it was submitted, +/// even if the encode then failed: the slot must be held until it is reached +/// either way. +fn shared_encode_frame( converter: &mut ColorConverter, encoder: &mut Encoder, - fd: RawFd, - stride: u32, - modifier: u64, - width: u32, - height: u32, - vk_format: u32, - frame_number: u32, - buffer_index: usize, -) -> Result { - use ash::vk; - - let bgra_vk_fmt = map_vk_format_raw(vk_format); - - let plane = DmaBufPlane { - fd, - offset: 0, - stride, - modifier, - }; - - let (imported_image, needs_layout_transition) = importer - .import_or_reuse(buffer_index, width, height, bgra_vk_fmt, &[plane]) - .map_err(|e| anyhow::anyhow!("DmaBufImporter: {e}"))?; - - unsafe { libc::close(fd) }; - - let src_layout = if needs_layout_transition { - vk::ImageLayout::UNDEFINED - } else { - vk::ImageLayout::GENERAL - }; - - converter - .convert(imported_image, src_layout, encoder.input_image()) - .map_err(|e| anyhow::anyhow!("ColorConverter::convert frame {frame_number}: {e}"))?; - - encoder - .encode(encoder.input_image()) - .map_err(|e| anyhow::anyhow!("Encoder::encode frame {frame_number}: {e}")) + image: ash::vk::Image, + blit: pixelforge::TimelinePoint, +) -> (Result, Option) { + let target = encoder.input_image(); + let converted = + match converter.convert_async(image, ash::vk::ImageLayout::GENERAL, target, &[blit]) { + Ok(p) => p, + Err(e) => { + return ( + Err(anyhow::anyhow!("ColorConverter::convert_async: {e}")), + None, + ); + } + }; + let result = encoder + .encode_after(target, &[converted]) + .map_err(|e| anyhow::anyhow!("Encoder::encode_after: {e}")); + (result, Some(converted)) } fn map_vk_format_raw(vk_format: u32) -> ash::vk::Format { @@ -1125,12 +2257,23 @@ struct IpcConfig { idr_requested: Arc, /// Zero point for wire timestamps. epoch: Instant, + /// For the rate probe to command its own steps. + /// + /// The probe has to live where the encoded sizes are, which is here, and + /// has to drive the bitrate, which happens in the encoder thread -- so it + /// sends down the same channel every other bitrate change uses. Measuring + /// through the real path rather than beside it is the point: a probe that + /// called the encoder directly would not measure the path the controller + /// will actually use. + reconfig_tx: mpsc::Sender, } fn ipc_send_thread( cfg: IpcConfig, encoded_rx: mpsc::Receiver, shutdown: Arc, + epoch: Instant, + progress: Arc, ) { let socket = match UnixDatagram::unbound() { Ok(s) => { @@ -1144,6 +2287,24 @@ fn ipc_send_thread( std::mem::size_of::() as libc::socklen_t, ); } + // A datagram socket whose peer has stopped reading blocks the + // sender once its buffer fills, and blocks it forever. This thread + // is serial and the channel feeding it is two deep, so that stops + // the encoder thread as well -- the whole capture layer frozen + // behind one write, which is what a game changing resolution was + // doing: the consumer pauses to rebuild its decoder, the buffer + // fills, and nothing here ever returns. + // + // Waiting a bounded time and giving up is the right answer for + // live media anyway. A frame nobody could take for a quarter of a + // second is a frame not worth having, and intra refresh means the + // picture recovers continuously rather than waiting for a key + // frame. + if let Err(e) = s.set_write_timeout(Some(IPC_WRITE_TIMEOUT)) { + log::warn!( + "IPC socket write timeout could not be set ({e}); a stalled consumer will block capture" + ); + } s } Err(e) => { @@ -1185,6 +2346,9 @@ fn ipc_send_thread( // Send loop let mut last_warn = Instant::now(); let mut error_count: u64 = 0; + // Set while the consumer is refusing frames, so the recovery is said + // once as well rather than being left to be inferred from silence. + let mut blocked_consumer = false; // Where this loop's time goes, per second. // @@ -1204,6 +2368,17 @@ fn ipc_send_thread( let mut worst_key_wait = std::time::Duration::ZERO; let mut keyframes: u32 = 0; let mut last_pace = Instant::now(); + // Off unless asked for. A sweep takes the bitrate away from whatever + // else is steering it, so it must never start by accident. + let mut rate_probe = match std::env::var("NESCAPTURE_RATE_PROBE") { + Ok(v) if v != "0" && !v.is_empty() => { + log::info!( + "rate probe armed: the encoder's bitrate is under this sweep, not the hub's control" + ); + Some(crate::rate_probe::RateProbe::new(Instant::now(), 0)) + } + _ => None, + }; loop { if shutdown.load(Ordering::Relaxed) { @@ -1214,6 +2389,13 @@ fn ipc_send_thread( // loop waiting for the capture side to submit anything at all; // `awaited` is the encoder finishing work already submitted. A // single timer around both reported 40 ms and named neither. + // Noted every time round, including the timeout path below. + // Without this the last step recorded was the socket write, so a + // thread sitting idle here -- because whatever feeds it stopped -- + // reported itself as stuck writing to the socket. A watchdog that + // names the wrong thread is worse than none: it sends the next + // hour after the wrong bug. + progress.note(epoch, 6); let recv_start = Instant::now(); let pending = match encoded_rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(p) => p, @@ -1223,6 +2405,11 @@ fn ipc_send_thread( let queued = recv_start.elapsed(); let present_time = pending.present_time; let encode_start = Instant::now(); + // The other indefinite wait. An encode that never completes stops + // this thread, which fills the channel, which stops the encoder + // thread -- a whole pipeline stalled behind one frame, with + // nothing said anywhere. + progress.note(epoch, 4); let result = pollster::block_on(pending.future); let awaited = encode_start.elapsed(); let waited = queued + awaited; @@ -1265,6 +2452,59 @@ fn ipc_send_thread( &pkt.data, ); + if let Some(probe) = rate_probe.as_mut() { + let now = Instant::now(); + probe.observe(now, pkt.data.len() as u32, pkt.is_key_frame); + if let Some(kbps) = probe.due_step(now) { + log::info!("rate probe: stepping to {kbps} kbps"); + let change = EncodeSettingsChange { + codec: None, + rate_control_mode: Some(RateControlMode::Cbr), + value: kbps, + bit_depth: None, + }; + let _ = cfg.reconfig_tx.send(change); + } + if probe.finished() { + // One line per rung, said once, at the end. This is the + // whole point of the run, not per-tick reporting. + log::info!("rate probe: finished"); + for r in probe.reports() { + match r.settle_ms { + Some(ms) => log::info!( + "rate probe: {} -> {} kbps settled in {} ms ({} frames), steady {:.2}x target, {} keyframe(s) worst {} bytes = {} ms of link", + r.from_kbps, + r.to_kbps, + ms, + r.settle_frames.unwrap_or(0), + r.steady_ratio, + r.keyframes, + r.keyframe_bytes, + r.keyframe_ms + ), + None => log::warn!( + "rate probe: {} -> {} kbps NEVER settled, steady {:.2}x target, {} keyframe(s) worst {} bytes = {} ms of link", + r.from_kbps, + r.to_kbps, + r.steady_ratio, + r.keyframes, + r.keyframe_bytes, + r.keyframe_ms + ), + } + } + match probe.worst_settle_ms() { + Some(ms) => log::info!( + "rate probe: worst settle {ms} ms -- a control loop cannot usefully run faster than this" + ), + None => log::warn!( + "rate probe: nothing settled; the encoder does not follow its target" + ), + } + rate_probe = None; + } + } + if pkt.is_key_frame { keyframes += 1; worst_key_wait = worst_key_wait.max(waited); @@ -1273,7 +2513,21 @@ fn ipc_send_thread( worst_awaited = worst_awaited.max(awaited); let send_start = Instant::now(); + progress.note(epoch, 5); if let Err(e) = socket.send(&ipc_frame) { + // Said on the edges only. A stalled consumer fails every frame, + // and sixty identical lines a second bury whatever else is + // being said about why it stalled. + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut + { + if !blocked_consumer { + log::warn!("IPC consumer is not reading; dropping frames until it does"); + blocked_consumer = true; + } + frame_count += 1; + continue; + } error_count += 1; if last_warn.elapsed() > std::time::Duration::from_secs(5) { log::warn!("IPC send failed ({} frames dropped): {e}", error_count); @@ -1288,6 +2542,11 @@ fn ipc_send_thread( break; } + if blocked_consumer { + log::warn!("IPC consumer is reading again"); + blocked_consumer = false; + } + worst_send = worst_send.max(send_start.elapsed()); let out = Instant::now(); worst_out_gap = worst_out_gap.max(out.duration_since(last_out)); @@ -1295,8 +2554,8 @@ fn ipc_send_thread( if last_pace.elapsed() >= std::time::Duration::from_secs(1) { last_pace = Instant::now(); - log::info!( - " ipc: worst gap between frames out {:.1}ms = worst wait for a \ + log::trace!( + "ipc: worst gap between frames out {:.1}ms = worst wait for a \ submission {:.1}ms + worst wait for the encoder {:.1}ms, worst \ socket send {:.1}ms, {keyframes} keyframe(s) (worst wait on one \ {:.1}ms)", @@ -1352,6 +2611,43 @@ fn pressure_total_us(kind: &str) -> Option<(u64, u64)> { Some((some?, full.unwrap_or(0))) } +/// What to tell the clients this stream's colour is. +/// +/// The space comes from what capture resolved for the frames it encoded, not +/// from the compositor's declaration. Those differ, and when they do the +/// resolved one is right: the swapchain has the last word wherever it names a +/// space, and only a pass-through swapchain defers. A game leaving HDR builds +/// a plain sRGB swapchain that says so outright, which is the case that broke +/// -- the compositor's view of the surface stayed HDR, so a client told only +/// that kept presenting sRGB frames through a PQ swapchain. +/// +/// The mastering numbers still come from the compositor, which is the only +/// side that has them, and only where the resolved space can carry them. An +/// SDR stream reports none: its brightness is the display's business, and +/// leaving HDR's numbers attached would describe a picture that is no longer +/// being sent. +fn stream_colour( + declared: &std::sync::Mutex>, + resolved: &AtomicU32, +) -> Option { + let space = match resolved.load(Ordering::Relaxed) { + u32::MAX => return None, + VK_COLOR_SPACE_HDR10_ST2084_EXT => nesprotocol::SURFACE_COLOR_BT2020_PQ, + _ => nesprotocol::SURFACE_COLOR_SRGB, + }; + if space == nesprotocol::SURFACE_COLOR_SRGB { + return Some(nesprotocol::SurfaceColor { + space, + ..Default::default() + }); + } + let mastered = declared.lock().ok().and_then(|d| *d); + Some(nesprotocol::SurfaceColor { + space, + ..mastered.unwrap_or_default() + }) +} + fn stats_sender_thread( capture_fps: Arc, encode_avg_ms: Arc, @@ -1360,6 +2656,8 @@ fn stats_sender_thread( present_attempts: Arc, capture_attempts: Arc, timing: Arc, + declared_surface: Arc>>, + stream_colorspace: Arc, ipc_path: std::path::PathBuf, shutdown: Arc, ) { @@ -1373,9 +2671,7 @@ fn stats_sender_thread( Some(s) } Ok(_) => { - log::warn!( - "stats socket connect failed; rates are logged but not sent to the hub" - ); + log::warn!("stats socket connect failed; rates are logged but not sent to the hub"); None } Err(e) => { @@ -1395,12 +2691,42 @@ fn stats_sender_thread( // scheduling, the guest's storage, or its memory sizing. let mut prev_pressure: Option<[(u64, u64); 3]> = None; let mut pressure_said_missing = false; + let mut last_colour: Option = None; + + // The loop runs ten times a second and the statistics every tenth pass, so + // they keep the per-second window their counters are reset on. The colour + // is checked every pass, because it has to reach the clients before the + // frames it describes do: a client still reading a stream as HDR while SDR + // frames arrive measures sRGB white as if it were PQ, which is ten + // thousand nits. + const PASSES_PER_SECOND: u64 = 10; + let mut pass: u64 = 0; loop { if shutdown.load(Ordering::Relaxed) { break; } - std::thread::sleep(std::time::Duration::from_secs(1)); + std::thread::sleep(std::time::Duration::from_millis(100)); + pass += 1; + let second = pass.is_multiple_of(PASSES_PER_SECOND); + + // On a change immediately, and once a second regardless. The heartbeat + // is for the client that joined after the compositor last spoke, which + // would otherwise never hear it; sending only on change would mean + // tracking who has been told. + if let Some(socket) = socket.as_ref() + && let Some(colour) = stream_colour(&declared_surface, &stream_colorspace) + && (second || last_colour != Some(colour)) + { + last_colour = Some(colour); + let mut payload = vec![nesprotocol::MSG_SURFACE_COLOR]; + nesprotocol::encode_surface_color(&mut payload, &colour); + let _ = socket.send(&payload); + } + + if !second { + continue; + } let raw_fps = capture_fps.load(Ordering::Relaxed); let fps = raw_fps.min(255) as u8; @@ -1435,13 +2761,13 @@ fn stats_sender_thread( let (hold_avg, hold_max) = timing.hold.take(); let long_gaps = timing.take_long_gaps(); - log::info!( + log::trace!( "present {pa}/s, admitted {ca}/s, encoded {raw_fps}/s, \ starved {starved}, dropped {dropped}, capture {cap_ms:.1}ms, \ encode {enc_ms:.1}ms" ); - log::info!( - " gap {gap_avg:.1}/{gap_max:.1}ms, layer {layer_avg:.2}/{layer_max:.2}ms, \ + log::trace!( + "gap {gap_avg:.1}/{gap_max:.1}ms, layer {layer_avg:.2}/{layer_max:.2}ms, \ down {down_avg:.2}/{down_max:.2}ms, acquire {acq_avg:.1}/{acq_max:.1}ms, \ hold {hold_avg:.2}/{hold_max:.2}ms, blit-gpu {blit_avg:.3}/{blit_max:.3}ms \ (avg/max), hitches {long_gaps}" @@ -1468,8 +2794,8 @@ fn stats_sender_thread( }; a.saturating_sub(b) as f64 / 1000.0 }; - log::info!( - " pressure: cpu {:.1}ms, io {:.1}/{:.1}ms, memory {:.1}/{:.1}ms \ + log::trace!( + "pressure: cpu {:.1}ms, io {:.1}/{:.1}ms, memory {:.1}/{:.1}ms \ (some/full, stalled in the last second)", ms(0, false), ms(1, false), @@ -1481,8 +2807,8 @@ fn stats_sender_thread( prev_pressure = Some(now); } None if !pressure_said_missing => { - log::info!( - " pressure: /proc/pressure is unreadable, so this guest cannot say \ + log::trace!( + "pressure: /proc/pressure is unreadable, so this guest cannot say \ whether a stall was cpu, io or memory (CONFIG_PSI off, or psi=0)" ); pressure_said_missing = true; @@ -1492,7 +2818,9 @@ fn stats_sender_thread( if let Some(ref socket) = socket { let mut buf = Vec::with_capacity(22); - nesprotocol::stats::encode_hudless_stats(&mut buf, fps, enc_ms, dropped, pa, ca, cap_ms); + nesprotocol::stats::encode_nescapture_stats( + &mut buf, fps, enc_ms, dropped, pa, ca, cap_ms, + ); let _ = socket.send(&buf); } } @@ -1504,6 +2832,7 @@ fn stats_sender_thread( mod tests { use super::*; use ash::vk::ColorSpaceKHR as Cs; + use pixelforge::ColorDescription; /// The colour space values were once written out by hand and two were wrong, /// which routed every HDR swapchain into the SDR arm silently. Deriving them @@ -1530,31 +2859,6 @@ mod tests { } } - #[test] - fn bit_depth_agrees_with_the_input_format() { - // The two used to be separate matches on VkFormat and had drifted. - // Ten-bit in means ten-bit out, eight means eight, for every format - // the converter accepts. - let ten = [64u32, 97]; - let eight = [37u32, 43, 44, 50]; - for f in ten { - let fmt = vk_format_to_input_format(f).expect("mapped"); - assert_eq!( - input_format_bit_depth(fmt), - EncodeBitDepth::Ten, - "VkFormat {f} is a ten-bit format" - ); - } - for f in eight { - let fmt = vk_format_to_input_format(f).expect("mapped"); - assert_eq!( - input_format_bit_depth(fmt), - EncodeBitDepth::Eight, - "VkFormat {f} is an eight-bit format" - ); - } - } - #[test] fn hdr_formats_map_to_their_converter_inputs() { // The two pairs a WSI layer injects that we can actually consume. @@ -1570,18 +2874,31 @@ mod tests { ); } + /// A description for one colour space, through the same path the encoder + /// uses. The geometry and formats are irrelevant to the answer. + fn description(vk_colorspace: u32) -> ColorDescription { + converter_config( + 1920, + 1080, + InputFormat::BGRA, + OutputFormat::NV12, + vk_colorspace, + ) + .color_description() + .expect("every source we accept has an encodable stream") + } + #[test] - fn hdr_colour_spaces_select_the_hdr_arm() { + fn hdr_colour_spaces_are_already_pq() { for cs in [Cs::HDR10_ST2084_EXT, Cs::HDR10_HLG_EXT] { let raw = cs.as_raw() as u32; assert_eq!( - vk_colorspace_to_color_space(raw), - ColorSpace::Bt2020, - "{cs:?} must convert as BT.2020, not BT.709" + vk_colorspace_to_source_spec(raw), + ColorSpec::Bt2020Pq, + "{cs:?} must convert as BT.2020 PQ, not BT.709" ); - let desc = vk_colorspace_to_color_description(raw).expect("a description"); assert_eq!( - desc, + description(raw), ColorDescription::bt2020_pq().with_full_range(true), "{cs:?}" ); @@ -1589,35 +2906,61 @@ mod tests { } /// Linear swapchains must not be run through an inverse sRGB EOTF on the way - /// to PQ; `Bt709LinearToBt2020Pq` is the arm that skips it. + /// to PQ, and they are not the same linear space as each other. #[test] - fn scrgb_and_bt2020_linear_select_the_pq_conversion() { + fn the_two_linear_spaces_are_told_apart() { + // They used to share an arm: there was no source for linear light on + // BT.2020 primaries, so BT2020_LINEAR borrowed scRGB's and took a gamut + // error to avoid a gamma one. Splitting source from target gives it one. + assert_eq!( + vk_colorspace_to_source_spec(Cs::EXTENDED_SRGB_LINEAR_EXT.as_raw() as u32), + ColorSpec::Bt709Linear, + ); + assert_eq!( + vk_colorspace_to_source_spec(Cs::BT2020_LINEAR_EXT.as_raw() as u32), + ColorSpec::Bt2020Linear, + ); + // Both still leave as HDR10: video cannot carry linear light. for cs in [Cs::EXTENDED_SRGB_LINEAR_EXT, Cs::BT2020_LINEAR_EXT] { + let raw = cs.as_raw() as u32; assert_eq!( - vk_colorspace_to_color_space(cs.as_raw() as u32), - ColorSpace::Bt709LinearToBt2020Pq, + stream_spec(vk_colorspace_to_source_spec(raw)), + ColorSpec::Bt2020Pq, + "{cs:?}" + ); + assert!( + stream_spec(vk_colorspace_to_source_spec(raw)).is_encodable(), "{cs:?}" ); } } - /// scRGB is linear with 1.0 at 80 nits; everything else that reaches PQ is - /// gamma-encoded sRGB with white at the BT.2408 reference of 203. + /// scRGB is linear with 1.0 at 80 nits; gamma-encoded sRGB puts white at the + /// BT.2408 reference of 203. #[test] fn scrgb_white_is_not_the_srgb_reference() { + assert_eq!(ColorSpec::Bt709Linear.reference_white_nits(), Some(80.0)); + assert_eq!(ColorSpec::Srgb.reference_white_nits(), Some(203.0)); assert_eq!( - sdr_reference_white_nits(ColorSpace::Bt709LinearToBt2020Pq), - 80.0 + vk_colorspace_to_source_spec(Cs::EXTENDED_SRGB_LINEAR_EXT.as_raw() as u32) + .reference_white_nits(), + Some(80.0), ); - assert_eq!(sdr_reference_white_nits(ColorSpace::SrgbToBt2020Pq), 203.0); - for cs in [Cs::EXTENDED_SRGB_LINEAR_EXT, Cs::BT2020_LINEAR_EXT] { - let nits = sdr_reference_white_nits(vk_colorspace_to_color_space(cs.as_raw() as u32)); - assert_eq!(nits, 80.0, "{cs:?}"); - } + // BT.2020 linear now answers 203 rather than scRGB's 80, because it is + // no longer pretending to be scRGB. That is a deliberate change of + // behaviour: 80 was a side effect of the borrowed arm, not a decision + // about this space. + assert_eq!( + vk_colorspace_to_source_spec(Cs::BT2020_LINEAR_EXT.as_raw() as u32) + .reference_white_nits(), + Some(203.0), + ); + // PQ already carries absolute brightness, so there is nothing to map. + assert_eq!(ColorSpec::Bt2020Pq.reference_white_nits(), None); } /// The matrix and transfer the shader applies and the ones the VUI declares - /// come from the same match, so they cannot disagree. + /// come from one config, so they cannot disagree. #[test] fn conversion_and_declaration_agree() { for cs in [ @@ -1629,8 +2972,8 @@ mod tests { Cs::BT2020_LINEAR_EXT, ] { let raw = cs.as_raw() as u32; - let desc = vk_colorspace_to_color_description(raw).expect("a description"); - let writes_bt2020 = vk_colorspace_to_color_space(raw) != ColorSpace::Bt709; + let desc = description(raw); + let writes_bt2020 = stream_spec(vk_colorspace_to_source_spec(raw)) != ColorSpec::Srgb; assert_eq!(desc.is_hdr(), writes_bt2020, "{cs:?}"); assert_eq!( desc, @@ -1647,11 +2990,9 @@ mod tests { #[test] fn sdr_colour_spaces_stay_on_bt709() { for cs in [Cs::SRGB_NONLINEAR, Cs::PASS_THROUGH_EXT] { - assert_eq!( - vk_colorspace_to_color_space(cs.as_raw() as u32), - ColorSpace::Bt709, - "{cs:?}" - ); + let raw = cs.as_raw() as u32; + assert_eq!(vk_colorspace_to_source_spec(raw), ColorSpec::Srgb, "{cs:?}"); + assert_eq!(stream_spec(ColorSpec::Srgb), ColorSpec::Srgb); } } @@ -1700,12 +3041,919 @@ mod tests { Cs::EXTENDED_SRGB_LINEAR_EXT, Cs::PASS_THROUGH_EXT, ] { - let desc = - vk_colorspace_to_color_description(cs.as_raw() as u32).expect("a description"); assert!( - desc.full_range, + description(cs.as_raw() as u32).full_range, "{cs:?} produced a limited-range description" ); } } } + +#[cfg(test)] +mod source_spec_tests { + use super::{source_spec, stream_spec}; + use ash::vk::ColorSpaceKHR as Cs; + use pixelforge::{ColorSpec, InputFormat}; + + fn raw(cs: Cs) -> u32 { + cs.as_raw() as u32 + } + + /// The bug this exists for, measured rather than argued: Control through + /// wine hands a float16 swapchain declared BT.2020 PQ, and a probe of its + /// frames found components up to 2.54. PQ cannot represent that, so the + /// buffer is scRGB and the declaration is about the volume, not the + /// encoding. Believing it made the converter skip the transfer entirely. + #[test] + fn a_float_surface_declared_pq_is_scrgb() { + assert_eq!( + source_spec(raw(Cs::HDR10_ST2084_EXT), InputFormat::RGBA16F), + ColorSpec::Bt709Linear + ); + // And the stream is still HDR10, because linear light is not encodable. + assert_eq!(stream_spec(ColorSpec::Bt709Linear), ColorSpec::Bt2020Pq); + } + + /// An integer surface can carry an encoded transfer, so there the + /// declaration is the answer and nothing overrides it. + #[test] + fn an_integer_surface_declared_pq_really_is_pq() { + for fmt in [InputFormat::ABGR2101010, InputFormat::BGRA] { + assert_eq!( + source_spec(raw(Cs::HDR10_ST2084_EXT), fmt), + ColorSpec::Bt2020Pq, + "{fmt:?}" + ); + } + } + + /// A float surface that declares linear already agrees, and must keep the + /// primaries it named -- BT.2020 linear is not scRGB. + #[test] + fn a_float_surface_that_declares_linear_keeps_its_primaries() { + assert_eq!( + source_spec(raw(Cs::EXTENDED_SRGB_LINEAR_EXT), InputFormat::RGBA16F), + ColorSpec::Bt709Linear + ); + assert_eq!( + source_spec(raw(Cs::BT2020_LINEAR_EXT), InputFormat::RGBA16F), + ColorSpec::Bt2020Linear + ); + } + + /// Float samples with nothing said about them are still linear: no float + /// convention stores an encoded curve, so reading them as sRGB applies an + /// inverse EOTF to data that never had one. + #[test] + fn float_samples_are_linear_even_when_nothing_says_so() { + assert_eq!( + source_spec(raw(Cs::SRGB_NONLINEAR), InputFormat::RGBA16F), + ColorSpec::Bt709Linear + ); + } + + /// The ordinary SDR path is untouched. + #[test] + fn eight_bit_srgb_is_left_alone() { + assert_eq!( + source_spec(raw(Cs::SRGB_NONLINEAR), InputFormat::BGRA), + ColorSpec::Srgb + ); + assert_eq!(stream_spec(ColorSpec::Srgb), ColorSpec::Srgb); + } +} + +#[cfg(test)] +mod surface_colour_tests { + use super::{ + VK_COLOR_SPACE_HDR10_ST2084_EXT, VK_COLOR_SPACE_PASS_THROUGH_EXT, + VK_COLOR_SPACE_SRGB_NONLINEAR_KHR, effective_colorspace, surface_color_to_vk, + vk_colorspace_to_source_spec, + }; + use pixelforge::ColorSpec; + + /// The case this exists for: a Windows title turning HDR on through wine + /// gets a swapchain that says nothing and a surface declared BT.2020 PQ. + /// Read from the swapchain alone it encodes as SDR, which is what it did. + #[test] + fn pass_through_takes_what_the_compositor_was_told() { + let declared = surface_color_to_vk(nesprotocol::SURFACE_COLOR_BT2020_PQ); + let effective = effective_colorspace(VK_COLOR_SPACE_PASS_THROUGH_EXT, Some(declared)); + assert_eq!(effective, VK_COLOR_SPACE_HDR10_ST2084_EXT); + assert_eq!(vk_colorspace_to_source_spec(effective), ColorSpec::Bt2020Pq); + } + + /// A swapchain that names a colour space is the authority. The compositor + /// is told about a surface, which may carry something else entirely, so + /// letting it override a swapchain that has spoken would be guessing. + #[test] + fn a_swapchain_that_names_one_is_not_overridden() { + let declared = surface_color_to_vk(nesprotocol::SURFACE_COLOR_BT2020_PQ); + assert_eq!( + effective_colorspace(VK_COLOR_SPACE_SRGB_NONLINEAR_KHR, Some(declared)), + VK_COLOR_SPACE_SRGB_NONLINEAR_KHR + ); + assert_eq!( + effective_colorspace(VK_COLOR_SPACE_HDR10_ST2084_EXT, Some(declared)), + VK_COLOR_SPACE_HDR10_ST2084_EXT + ); + } + + /// Nothing said yet is the first few frames of every session, and it must + /// read as the swapchain's own answer rather than as HDR. + #[test] + fn pass_through_with_nothing_declared_stays_as_it_is() { + assert_eq!( + effective_colorspace(VK_COLOR_SPACE_PASS_THROUGH_EXT, None), + VK_COLOR_SPACE_PASS_THROUGH_EXT + ); + assert_eq!( + vk_colorspace_to_source_spec(VK_COLOR_SPACE_PASS_THROUGH_EXT), + ColorSpec::Srgb + ); + } + + /// An SDR surface says so, and must not be read as "said nothing" -- that + /// is the difference between a deliberate answer and a missing one. + #[test] + fn an_sdr_surface_is_an_answer() { + let declared = surface_color_to_vk(nesprotocol::SURFACE_COLOR_SRGB); + assert_eq!(declared, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR); + assert_eq!( + effective_colorspace(VK_COLOR_SPACE_PASS_THROUGH_EXT, Some(declared)), + VK_COLOR_SPACE_SRGB_NONLINEAR_KHR + ); + } + + /// An unknown space from a newer compositor reads as SDR rather than as + /// something unencodable. + #[test] + fn an_unknown_surface_colour_falls_back_to_sdr() { + assert_eq!(surface_color_to_vk(200), VK_COLOR_SPACE_SRGB_NONLINEAR_KHR); + } +} + +#[cfg(test)] +mod encoder_identity_tests { + use super::encoder_still_serves; + use pixelforge::{EncodeBitDepth, InputFormat, PixelFormat}; + + const SDR: u32 = 0; + type Identity = (u32, u32, EncodeBitDepth, PixelFormat, u32, InputFormat); + const HD: Identity = ( + 1920, + 1080, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + SDR, + InputFormat::BGRA, + ); + + #[test] + fn an_unchanged_frame_reuses_the_encoder() { + assert!(encoder_still_serves(HD, HD)); + } + + #[test] + fn a_resolution_change_rebuilds_in_either_direction() { + // The regression. Shrinking left the encoder sending the old geometry + // with stale margins; growing had no surface big enough for the frame. + let smaller = ( + 1280, + 720, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + SDR, + InputFormat::BGRA, + ); + assert!(!encoder_still_serves(HD, smaller)); + assert!(!encoder_still_serves(smaller, HD)); + } + + #[test] + fn one_axis_moving_is_still_a_change() { + assert!(!encoder_still_serves( + HD, + ( + 1920, + 720, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + SDR, + InputFormat::BGRA + ) + )); + assert!(!encoder_still_serves( + HD, + ( + 1280, + 1080, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + SDR, + InputFormat::BGRA + ) + )); + } + + /// The regression this guards, seen in Cyberpunk switching from its HDR10 + /// mode to its scRGB one. Both are HDR, both declare BT.2020 PQ to the + /// compositor, both are ten bit at the same size -- so every other part of + /// the identity matched and the encoder was reused. Its converter reads a + /// fixed bytes-per-pixel, and a packed 10-bit surface is four where a + /// float16 one is eight, so every row was read at half its length: the + /// picture came out sheared into stripes rather than merely the wrong + /// colour. + #[test] + fn a_surface_format_change_rebuilds_even_when_nothing_else_moves() { + let float16 = ( + 1920, + 1080, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + SDR, + InputFormat::RGBA16F, + ); + assert!(!encoder_still_serves(HD, float16)); + assert!(!encoder_still_serves(float16, HD)); + } + + /// Specifically the pair that did it: the same colour space either way, + /// because pass-through defers to what the compositor was told and the + /// compositor was told PQ both times. + #[test] + fn packed_ten_bit_and_float16_are_not_the_same_surface() { + const PQ: u32 = 1000104; + let packed = ( + 1920, + 1080, + EncodeBitDepth::Ten, + PixelFormat::Yuv420, + PQ, + InputFormat::ABGR2101010, + ); + let float16 = ( + 1920, + 1080, + EncodeBitDepth::Ten, + PixelFormat::Yuv420, + PQ, + InputFormat::RGBA16F, + ); + assert!(!encoder_still_serves(packed, float16)); + } + + #[test] + fn depth_and_pixel_format_still_rebuild() { + assert!(!encoder_still_serves( + HD, + ( + 1920, + 1080, + EncodeBitDepth::Ten, + PixelFormat::Yuv420, + SDR, + InputFormat::BGRA + ) + )); + assert!(!encoder_still_serves( + HD, + ( + 1920, + 1080, + EncodeBitDepth::Eight, + PixelFormat::Yuv444, + SDR, + InputFormat::BGRA + ) + )); + } +} + +#[cfg(test)] +mod depth_tests { + use super::{HwCodec, depth_for_codec}; + use pixelforge::EncodeBitDepth; + + #[test] + fn h264_is_never_asked_for_ten_bit() { + // There is no H.264 ten-bit encode to ask for. Asking does not produce a + // worse stream, it produces ERROR_VIDEO_PROFILE_CODEC_NOT_SUPPORTED_KHR + // and no stream at all -- which is what the game Control caused by + // opening a ten-bit swapchain. + let mut warned = false; + assert_eq!( + depth_for_codec(HwCodec::H264, EncodeBitDepth::Ten, &mut warned), + EncodeBitDepth::Eight, + ); + assert!(warned, "it was clamped silently"); + } + + #[test] + fn the_refusal_is_said_once_and_not_per_frame() { + let mut warned = false; + for _ in 0..600 { + depth_for_codec(HwCodec::H264, EncodeBitDepth::Ten, &mut warned); + } + assert!(warned); + } + + #[test] + fn the_codecs_that_have_ten_bit_keep_it() { + let mut warned = false; + for codec in [HwCodec::H265, HwCodec::AV1] { + assert_eq!( + depth_for_codec(codec, EncodeBitDepth::Ten, &mut warned), + EncodeBitDepth::Ten, + "{codec:?} lost its ten-bit", + ); + assert!(!warned); + } + } + + #[test] + fn eight_bit_passes_through_every_codec() { + let mut warned = false; + for codec in [HwCodec::H264, HwCodec::H265, HwCodec::AV1] { + assert_eq!( + depth_for_codec(codec, EncodeBitDepth::Eight, &mut warned), + EncodeBitDepth::Eight, + ); + assert!(!warned, "{codec:?} warned about a depth it supports"); + } + } + + #[test] + fn moving_off_h264_lets_the_warning_be_said_again() { + // The flag is about not repeating one refusal, not about never + // mentioning it twice in a session: a codec switch is a new situation. + let mut warned = false; + depth_for_codec(HwCodec::H264, EncodeBitDepth::Ten, &mut warned); + assert!(warned); + depth_for_codec(HwCodec::AV1, EncodeBitDepth::Ten, &mut warned); + assert!(!warned); + depth_for_codec(HwCodec::H264, EncodeBitDepth::Ten, &mut warned); + assert!(warned); + } +} + +#[cfg(test)] +mod bitrate_only_tests { + use super::{HwCodec, RateControl, bitrate_only_change, changes_nothing, negotiated}; + + use crate::encode::EncodeSettingsChange; + use pixelforge::{EncodeBitDepth, RateControlMode}; + + fn change( + mode: RateControlMode, + value: u32, + codec: Option, + bit_depth: Option, + ) -> EncodeSettingsChange { + EncodeSettingsChange { + codec, + rate_control_mode: Some(mode), + value, + bit_depth, + } + } + + /// The running encode for these: CBR at 8 Mbps, H.264, no depth override. + fn running(c: &EncodeSettingsChange) -> Option { + bitrate_only_change(c, CBR_8M, HwCodec::H264, None) + } + + fn caps(pairs: &[(u8, u8)]) -> nesprotocol::ClientCaps { + pairs + .iter() + .fold(nesprotocol::ClientCaps::empty(), |acc, &(c, d)| { + acc.with(c, d) + }) + } + + /// A device that encodes all three, ten bits on everything but H.264. + fn full_host() -> nesprotocol::ClientCaps { + caps(&[ + (nesprotocol::CODEC_AV1, nesprotocol::DEPTH_8), + (nesprotocol::CODEC_AV1, nesprotocol::DEPTH_10), + (nesprotocol::CODEC_H265, nesprotocol::DEPTH_8), + (nesprotocol::CODEC_H265, nesprotocol::DEPTH_10), + (nesprotocol::CODEC_H264, nesprotocol::DEPTH_8), + ]) + } + + /// The whole point: a client with no AV1 decoder gets H.265 rather than a + /// black screen. + #[test] + fn a_client_without_av1_is_moved_off_it() { + let client = caps(&[ + (nesprotocol::CODEC_H265, nesprotocol::DEPTH_8), + (nesprotocol::CODEC_H265, nesprotocol::DEPTH_10), + (nesprotocol::CODEC_H264, nesprotocol::DEPTH_8), + ]); + assert_eq!( + negotiated(client, full_host(), None), + Some((HwCodec::H265, EncodeBitDepth::Ten)) + ); + } + + #[test] + fn a_client_with_only_h264_is_moved_all_the_way_down() { + let client = caps(&[(nesprotocol::CODEC_H264, nesprotocol::DEPTH_8)]); + assert_eq!( + negotiated(client, full_host(), None), + Some((HwCodec::H264, EncodeBitDepth::Eight)) + ); + } + + /// An operator who names a codec keeps it, even against a client that + /// cannot decode it. Second-guessing an explicit setting is worse than the + /// black screen, which is at least visible. + #[test] + fn a_forced_codec_is_not_negotiated_away() { + let client = caps(&[(nesprotocol::CODEC_H264, nesprotocol::DEPTH_8)]); + assert_eq!(negotiated(client, full_host(), Some("av1")), None); + } + + /// A client that said nothing, or shares nothing, leaves the stream alone. + #[test] + fn nothing_in_common_changes_nothing() { + assert_eq!( + negotiated(nesprotocol::ClientCaps::empty(), full_host(), None), + None + ); + let av1_only = caps(&[(nesprotocol::CODEC_AV1, nesprotocol::DEPTH_8)]); + let h264_client = caps(&[(nesprotocol::CODEC_H264, nesprotocol::DEPTH_8)]); + assert_eq!(negotiated(h264_client, av1_only, None), None); + } + + /// A negotiation landing where the encoder already is must not rebuild: + /// every rebuild costs an IDR, and this message arrives on every connect. + #[test] + fn asking_for_the_current_settings_is_a_no_op() { + let same = EncodeSettingsChange { + codec: Some(HwCodec::AV1), + rate_control_mode: None, + value: 0, + bit_depth: Some(EncodeBitDepth::Ten), + }; + assert!(changes_nothing( + &same, + HwCodec::AV1, + Some(EncodeBitDepth::Ten) + )); + assert!(!changes_nothing( + &same, + HwCodec::H265, + Some(EncodeBitDepth::Ten) + )); + assert!(!changes_nothing( + &same, + HwCodec::AV1, + Some(EncodeBitDepth::Eight) + )); + } + + /// No override recorded means the default, which is eight bits -- so a + /// request for eight is a no-op and one for ten is not. + #[test] + fn an_unset_depth_reads_as_eight_bits() { + let eight = EncodeSettingsChange { + codec: None, + rate_control_mode: None, + value: 0, + bit_depth: Some(EncodeBitDepth::Eight), + }; + assert!(changes_nothing(&eight, HwCodec::AV1, None)); + let ten = EncodeSettingsChange { + bit_depth: Some(EncodeBitDepth::Ten), + ..eight.clone() + }; + assert!(!changes_nothing(&ten, HwCodec::AV1, None)); + } + + /// A rate-control change is never a no-op, whatever else it carries. + #[test] + fn a_rate_control_change_always_counts() { + let rc = EncodeSettingsChange { + codec: Some(HwCodec::AV1), + rate_control_mode: Some(RateControlMode::Cbr), + value: 5_000, + bit_depth: Some(EncodeBitDepth::Ten), + }; + assert!(!changes_nothing( + &rc, + HwCodec::AV1, + Some(EncodeBitDepth::Ten) + )); + } + + /// What a client sends on connect to say what it can decode: a depth, and + /// nothing else. It must not read as a bitrate change, or the rebuild it + /// needs would be skipped in favour of a live retune to zero. + #[test] + fn a_depth_only_change_is_not_a_bitrate_change() { + let depth_only = EncodeSettingsChange { + codec: None, + rate_control_mode: None, + value: 0, + bit_depth: Some(EncodeBitDepth::Ten), + }; + assert_eq!( + running(&depth_only), + None, + "a change that names no rate control cannot be retuned live" + ); + } + + /// And the wire form of it decodes to exactly that. + #[test] + fn the_depth_only_payload_names_no_rate_control() { + let mut buf = Vec::new(); + nesprotocol::encode_depth_only(&mut buf, nesprotocol::DEPTH_10); + let (codec, rc, value, depth) = + nesprotocol::decode_encode_settings(&buf).expect("readable"); + assert_eq!(codec, nesprotocol::CODEC_KEEP); + assert_eq!(rc, nesprotocol::RC_KEEP); + assert_eq!(value, 0); + assert_eq!(depth, Some(nesprotocol::DEPTH_10)); + } + + const CBR_8M: RateControl = RateControl::Cbr { kbps: 8_000 }; + const VBR_8M: RateControl = RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 12_000, + }; + + #[test] + fn a_bare_bitrate_change_is_taken() { + let c = change(RateControlMode::Cbr, 2_000, None, None); + assert_eq!(running(&c), Some(2_000)); + } + + #[test] + fn restating_the_current_codec_and_depth_is_not_a_change() { + // The debug overlay sends every field on every apply, so a change that + // only moved the slider still arrives carrying a codec. Treating that as + // a codec change would rebuild the encoder, and an IDR with it, every + // time somebody nudged the bitrate. + let c = change(RateControlMode::Cbr, 2_000, Some(HwCodec::H264), None); + assert_eq!(running(&c), Some(2_000)); + + // Same, with the depth restated as what is already in force. + let c = change( + RateControlMode::Cbr, + 2_000, + Some(HwCodec::H264), + Some(EncodeBitDepth::Eight), + ); + assert_eq!( + bitrate_only_change(&c, CBR_8M, HwCodec::H264, Some(EncodeBitDepth::Eight)), + Some(2_000), + ); + } + + #[test] + fn a_stated_depth_against_no_override_rebuilds() { + // Deliberately conservative. With no override in force the running depth + // came from the input format or the environment, and this cannot tell + // whether the stated value matches it -- so it rebuilds rather than + // assume. The cost is one rebuild on the first manual apply; the cost of + // assuming wrongly is a stream whose depth silently disagrees with the + // encoder's. + let c = change( + RateControlMode::Cbr, + 2_000, + None, + Some(EncodeBitDepth::Eight), + ); + assert_eq!(bitrate_only_change(&c, CBR_8M, HwCodec::H264, None), None); + } + + #[test] + fn a_different_codec_rebuilds() { + // A codec is the video session's profile; there is no retuning it. + for codec in [HwCodec::H265, HwCodec::AV1] { + let c = change(RateControlMode::Cbr, 2_000, Some(codec), None); + assert_eq!(running(&c), None, "{codec:?}"); + } + } + + #[test] + fn a_different_bit_depth_rebuilds() { + let c = change(RateControlMode::Cbr, 2_000, None, Some(EncodeBitDepth::Ten)); + assert_eq!(running(&c), None); + } + + #[test] + fn switching_to_constant_qp_rebuilds() { + let c = change(RateControlMode::Cqp, 28, None, None); + assert_eq!(running(&c), None); + } + + #[test] + fn coming_back_from_constant_qp_rebuilds() { + // The session was built without a bitrate, so there is nothing to + // retarget -- it has to become an encode that has one. + let c = change(RateControlMode::Cbr, 2_000, None, None); + assert_eq!( + bitrate_only_change(&c, RateControl::Cqp { qp: 26 }, HwCodec::H264, None), + None, + ); + } + + /// The one that makes launch-time VBR survive a session. A controller has + /// no way to say VBR, so its bitrates arrive labelled CBR; taken as a mode + /// this would rebuild the encode as CBR and drop the ceiling on the first + /// adjustment. + #[test] + fn a_bitrate_under_vbr_is_a_retune_not_a_mode_change() { + let c = change(RateControlMode::Cbr, 2_000, None, None); + assert_eq!( + bitrate_only_change(&c, VBR_8M, HwCodec::H264, None), + Some(2_000), + ); + } + + /// Everything else that rebuilds still rebuilds under VBR -- the reprieve + /// is for the rate control label alone, not for a codec or a depth. + #[test] + fn a_vbr_encode_rebuilds_for_the_same_reasons_any_other_does() { + let c = change(RateControlMode::Cbr, 2_000, Some(HwCodec::AV1), None); + assert_eq!(bitrate_only_change(&c, VBR_8M, HwCodec::H264, None), None); + + let c = change(RateControlMode::Cqp, 28, None, None); + assert_eq!(bitrate_only_change(&c, VBR_8M, HwCodec::H264, None), None); + } +} + +#[cfg(test)] +mod rate_control_tests { + use super::{DEFAULT_BITRATE_KBPS, RateControl, resolve_rate_control}; + + /// Nothing in the environment: the historical behaviour, which is a + /// bitrate at the default. + #[test] + fn an_empty_environment_is_the_default_bitrate() { + assert_eq!( + resolve_rate_control(None, None, None, None), + RateControl::Cbr { + kbps: DEFAULT_BITRATE_KBPS + }, + ); + } + + /// The inference this replaced, preserved exactly: a QP and no mode is + /// constant QP. Anyone who set only `NESCAPTURE_QP` before this existed + /// gets what they got before. + #[test] + fn a_bare_qp_still_means_constant_qp() { + assert_eq!( + resolve_rate_control(None, Some(26), None, None), + RateControl::Cqp { qp: 26 }, + ); + } + + /// And the mode wins over the inference, in the direction that makes the + /// QP the ignored one -- otherwise asking for a bitrate while a stale QP + /// sits in the environment would silently not give you one. + #[test] + fn an_explicit_bitrate_mode_outranks_a_qp_in_the_environment() { + assert_eq!( + resolve_rate_control(Some("cbr"), Some(26), Some(8_000), None), + RateControl::Cbr { kbps: 8_000 }, + ); + } + + #[test] + fn a_mode_is_read_without_regard_to_case_or_surrounding_space() { + assert_eq!( + resolve_rate_control(Some(" VbR "), None, Some(8_000), Some(12_000)), + RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 12_000, + }, + ); + } + + #[test] + fn vbr_takes_both_numbers() { + assert_eq!( + resolve_rate_control(Some("vbr"), None, Some(8_000), Some(12_000)), + RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 12_000, + }, + ); + } + + /// A ceiling is optional, and the one it gets is headroom over the target + /// rather than the target itself -- a VBR encode whose ceiling is its + /// target is a CBR encode with extra steps. + #[test] + fn vbr_without_a_ceiling_gets_headroom_over_the_target() { + assert_eq!( + resolve_rate_control(Some("vbr"), None, Some(8_000), None), + RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 12_000, + }, + ); + } + + /// A ceiling under the target is not a ceiling. Raised to the target + /// rather than refused: the target is the number the operator was more + /// specific about, and an encode that runs is worth more than one that + /// does not. + #[test] + fn a_ceiling_below_the_target_is_raised_to_it() { + assert_eq!( + resolve_rate_control(Some("vbr"), None, Some(8_000), Some(3_000)), + RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 8_000, + }, + ); + } + + /// Constant QP asked for without a QP has no quality to hold constant. + /// Falling back to a bitrate says so; inventing a QP would encode at a + /// quality nobody chose. + #[test] + fn constant_qp_without_a_qp_falls_back_to_a_bitrate() { + assert_eq!( + resolve_rate_control(Some("cqp"), None, Some(8_000), None), + RateControl::Cbr { kbps: 8_000 }, + ); + } + + #[test] + fn an_unreadable_mode_falls_back_to_the_inference() { + assert_eq!( + resolve_rate_control(Some("adaptive"), Some(26), None, None), + RateControl::Cqp { qp: 26 }, + ); + assert_eq!( + resolve_rate_control(Some(""), None, Some(8_000), None), + RateControl::Cbr { kbps: 8_000 }, + ); + } + + /// A ceiling has nowhere to go under a mode with no ceiling. It is + /// dropped, not quietly turned into VBR -- the mode is the operator's to + /// pick, and one var implying another is how a config becomes unreadable. + #[test] + fn a_ceiling_outside_vbr_changes_nothing() { + assert_eq!( + resolve_rate_control(Some("cbr"), None, Some(8_000), Some(12_000)), + RateControl::Cbr { kbps: 8_000 }, + ); + assert_eq!( + resolve_rate_control(None, Some(26), None, Some(12_000)), + RateControl::Cqp { qp: 26 }, + ); + } + + /// The ceiling scales off whatever target is in force, including the + /// default one. + #[test] + fn vbr_with_nothing_at_all_still_has_a_target_and_a_ceiling() { + assert_eq!( + resolve_rate_control(Some("vbr"), None, None, None), + RateControl::Vbr { + target_kbps: DEFAULT_BITRATE_KBPS, + max_kbps: DEFAULT_BITRATE_KBPS / 2 * 3, + }, + ); + } + + /// A target large enough to overflow the headroom arithmetic is nonsense, + /// but nonsense that wraps is worse than nonsense that saturates: a + /// wrapped ceiling is a *small* one, which silently throttles the encode. + #[test] + fn an_absurd_target_saturates_rather_than_wraps() { + let RateControl::Vbr { max_kbps, .. } = + resolve_rate_control(Some("vbr"), None, Some(u32::MAX), None) + else { + panic!("asked for vbr, got something else"); + }; + assert!(max_kbps >= u32::MAX / 2); + } + + /// Retargeting is what a congestion controller does, and it must not + /// disturb the ceiling: the ceiling describes the path, not the target. + #[test] + fn retargeting_vbr_keeps_the_ceiling() { + let rc = RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 12_000, + }; + assert_eq!( + rc.retargeted(2_000), + RateControl::Vbr { + target_kbps: 2_000, + max_kbps: 12_000, + }, + ); + } + + /// There is no target inside constant QP to move, so a caller asking for + /// one is asking for the mode that has one. + #[test] + fn retargeting_constant_qp_gives_a_bitrate() { + assert_eq!( + RateControl::Cqp { qp: 26 }.retargeted(2_000), + RateControl::Cbr { kbps: 2_000 }, + ); + } + + #[test] + fn only_a_bitrate_mode_reports_a_target() { + assert_eq!(RateControl::Cbr { kbps: 8_000 }.target_kbps(), Some(8_000)); + assert_eq!( + RateControl::Vbr { + target_kbps: 8_000, + max_kbps: 12_000, + } + .target_kbps(), + Some(8_000), + ); + assert_eq!(RateControl::Cqp { qp: 26 }.target_kbps(), None); + } +} + +#[cfg(test)] +mod stream_colour_tests { + use super::{ + VK_COLOR_SPACE_HDR10_ST2084_EXT, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR, stream_colour, + }; + use std::sync::Mutex; + use std::sync::atomic::AtomicU32; + + fn mastered() -> nesprotocol::SurfaceColor { + nesprotocol::SurfaceColor { + space: nesprotocol::SURFACE_COLOR_BT2020_PQ, + max_cll: 500, + max_fall: 100, + min_luminance: 1000, + max_luminance: 5_000_000, + } + } + + /// The regression, seen switching Cyberpunk out of HDR. The swapchain came + /// back plain sRGB and capture encoded sRGB, but the compositor still had + /// the surface down as HDR -- so a client told only the compositor's view + /// kept presenting sRGB frames through a PQ swapchain. + #[test] + fn leaving_hdr_is_reported_even_while_the_compositor_still_says_hdr() { + let declared = Mutex::new(Some(mastered())); + let resolved = AtomicU32::new(VK_COLOR_SPACE_SRGB_NONLINEAR_KHR); + let colour = stream_colour(&declared, &resolved).unwrap(); + assert_eq!(colour.space, nesprotocol::SURFACE_COLOR_SRGB); + } + + /// An SDR stream carries no mastering numbers. Its brightness is the + /// display's business, and leaving HDR's attached would describe a picture + /// that is no longer being sent. + #[test] + fn an_sdr_stream_carries_no_mastering_numbers() { + let declared = Mutex::new(Some(mastered())); + let resolved = AtomicU32::new(VK_COLOR_SPACE_SRGB_NONLINEAR_KHR); + let colour = stream_colour(&declared, &resolved).unwrap(); + assert_eq!(colour.max_cll, 0); + assert_eq!(colour.max_fall, 0); + assert_eq!(colour.max_luminance, 0); + } + + /// An HDR stream keeps them, because the compositor is the only side that + /// has them. + #[test] + fn an_hdr_stream_keeps_what_the_compositor_said() { + let declared = Mutex::new(Some(mastered())); + let resolved = AtomicU32::new(VK_COLOR_SPACE_HDR10_ST2084_EXT); + let colour = stream_colour(&declared, &resolved).unwrap(); + assert_eq!(colour.space, nesprotocol::SURFACE_COLOR_BT2020_PQ); + assert_eq!(colour.max_cll, 500); + assert_eq!(colour.max_luminance, 5_000_000); + } + + /// An HDR stream whose compositor said nothing is still HDR. Saying so + /// with no numbers beats not saying it. + #[test] + fn an_hdr_stream_with_nothing_declared_is_still_hdr() { + let declared = Mutex::new(None); + let resolved = AtomicU32::new(VK_COLOR_SPACE_HDR10_ST2084_EXT); + let colour = stream_colour(&declared, &resolved).unwrap(); + assert_eq!(colour.space, nesprotocol::SURFACE_COLOR_BT2020_PQ); + } + + /// Before the first frame there is nothing to report, and reporting SDR + /// would make every session start by telling its clients something that + /// may be wrong. + #[test] + fn nothing_is_said_before_the_first_frame() { + let declared = Mutex::new(None); + let resolved = AtomicU32::new(u32::MAX); + assert!(stream_colour(&declared, &resolved).is_none()); + } +} diff --git a/apps/nescapture/src/instance.rs b/apps/nescapture/src/instance.rs index 8fd88355..b04b1b31 100644 --- a/apps/nescapture/src/instance.rs +++ b/apps/nescapture/src/instance.rs @@ -41,7 +41,42 @@ pub unsafe extern "system" fn vkCreateInstance( let next_create: PFN_vkCreateInstance = unsafe { load_instance_fn(next_gipa, vk::Instance::null(), b"vkCreateInstance\0") }; - let result = unsafe { next_create(p_create_info, p_allocator, p_instance) }; + // The encoder needs Vulkan 1.1 on the game's instance to run on the game's + // device. An application asking for 1.0 is raised to 1.1 where the loader + // has it: 1.1 only adds to 1.0, so nothing the application can do behaves + // any differently, and without it the encoder needs a device of its own. + let asked = unsafe { + let app = (*p_create_info).p_application_info; + if app.is_null() { + vk::API_VERSION_1_0 + } else { + (*app).api_version + } + }; + let mut raised_app; + let mut raised_ci; + let mut create_info = p_create_info; + let mut api_version = asked; + if asked < vk::API_VERSION_1_1 && unsafe { loader_version(next_gipa) } >= vk::API_VERSION_1_1 { + raised_app = unsafe { + let app = (*p_create_info).p_application_info; + if app.is_null() { + vk::ApplicationInfo::default() + } else { + *app + } + }; + raised_app.api_version = vk::API_VERSION_1_1; + raised_ci = unsafe { *p_create_info }; + raised_ci.p_application_info = &raised_app; + create_info = &raised_ci; + api_version = vk::API_VERSION_1_1; + log::info!( + "instance asked for Vulkan 1.0; created as 1.1 so the encoder can share its devices" + ); + } + + let result = unsafe { next_create(create_info, p_allocator, p_instance) }; if result != vk::Result::SUCCESS { return result; } @@ -51,6 +86,8 @@ pub unsafe extern "system" fn vkCreateInstance( // ── Build and store per-instance dispatch table ─────────────────────────── let istate = Arc::new(NextInstanceFn { + instance, + api_version, get_instance_proc_addr: next_gipa, destroy_instance: unsafe { load_instance_fn(next_gipa, instance, b"vkDestroyInstance\0") }, get_physical_device_memory_properties: unsafe { @@ -60,13 +97,6 @@ pub unsafe extern "system" fn vkCreateInstance( b"vkGetPhysicalDeviceMemoryProperties\0", ) }, - get_physical_device_format_properties2: unsafe { - crate::try_load_instance_fn( - next_gipa, - instance, - b"vkGetPhysicalDeviceFormatProperties2\0", - ) - }, get_physical_device_properties: unsafe { crate::try_load_instance_fn(next_gipa, instance, b"vkGetPhysicalDeviceProperties\0") }, @@ -85,6 +115,26 @@ pub unsafe extern "system" fn vkCreateInstance( vk::Result::SUCCESS } +/// The highest instance version the loader below supports; 1.0 when it cannot +/// say, since vkEnumerateInstanceVersion is itself a 1.1 addition. +unsafe fn loader_version(next_gipa: crate::dispatch::PFN_vkGetInstanceProcAddr) -> u32 { + type Enumerate = unsafe extern "system" fn(*mut u32) -> vk::Result; + let Some(f) = (unsafe { + crate::try_load_instance_fn::( + next_gipa, + vk::Instance::null(), + b"vkEnumerateInstanceVersion\0", + ) + }) else { + return vk::API_VERSION_1_0; + }; + let mut version = vk::API_VERSION_1_0; + if unsafe { f(&mut version) } != vk::Result::SUCCESS { + return vk::API_VERSION_1_0; + } + version +} + #[unsafe(no_mangle)] pub unsafe extern "system" fn vkDestroyInstance( instance: vk::Instance, diff --git a/apps/nescapture/src/lib.rs b/apps/nescapture/src/lib.rs index b365d556..9cd337e8 100644 --- a/apps/nescapture/src/lib.rs +++ b/apps/nescapture/src/lib.rs @@ -27,20 +27,20 @@ mod config; mod device; mod discovery; mod dispatch; -mod dmabuf_import; mod encode; mod framebuffer; mod instance; mod memory; -mod modifiers; mod pacing; mod pipeline; mod present; +mod rate_probe; mod shader; +mod shared; mod slots; mod state; -mod timing; mod swapchain; +mod timing; use commands::{ vkCmdBeginRenderPass, vkCmdBeginRenderingKHR, vkCmdBindPipeline, vkCmdDraw, vkCmdDrawIndexed, @@ -48,7 +48,7 @@ use commands::{ vkCmdDrawIndirect, vkCmdDrawIndirectCount, vkCmdDrawIndirectCountKHR, vkCmdEndRenderPass, vkCmdEndRenderingKHR, }; -use device::{vkCreateDevice, vkDestroyDevice, vkGetDeviceQueue}; +use device::{vkCreateDevice, vkDestroyDevice, vkGetDeviceQueue, vkGetDeviceQueue2}; use framebuffer::{ vkAllocateCommandBuffers, vkCreateFramebuffer, vkCreateImageView, vkDestroyFramebuffer, vkDestroyImageView, vkFreeCommandBuffers, @@ -100,10 +100,16 @@ pub(crate) struct VkLayerDeviceLink { pfnNextGetDeviceProcAddr: Option, } +/// Stamps the loader's dispatch data into a dispatchable object a layer +/// created itself. See [`crate::device::stamp`]. +pub(crate) type PFN_vkSetDeviceLoaderData = + unsafe extern "system" fn(vk::Device, *mut c_void) -> vk::Result; + #[repr(C)] pub(crate) union VkLayerCreateInfoU { pub pLayerInfo: *mut VkLayerInstanceLink, pub pDeviceLayerInfo: *mut VkLayerDeviceLink, + pub pfnSetDeviceLoaderData: Option, } const VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO: i32 = 47; @@ -271,6 +277,7 @@ unsafe fn match_device_fn(name: &[u8]) -> Option { b"vkCreateDevice" => Some(to_raw(vkCreateDevice as *const () as usize)), b"vkDestroyDevice" => Some(to_raw(vkDestroyDevice as *const () as usize)), b"vkGetDeviceQueue" => Some(to_raw(vkGetDeviceQueue as *const () as usize)), + b"vkGetDeviceQueue2" => Some(to_raw(vkGetDeviceQueue2 as *const () as usize)), b"vkQueuePresentKHR" => Some(to_raw(vkQueuePresentKHR as *const () as usize)), b"vkCreateShaderModule" => Some(to_raw(vkCreateShaderModule as *const () as usize)), diff --git a/apps/nescapture/src/memory.rs b/apps/nescapture/src/memory.rs index 61f63d7f..e9a69561 100644 --- a/apps/nescapture/src/memory.rs +++ b/apps/nescapture/src/memory.rs @@ -43,7 +43,12 @@ pub fn pick_memory_type(types: &[MemoryType], bits: u32, want: Want) -> Option (0..types.len()) - .find(|&i| allowed(i) && types[i].flags.contains(vk::MemoryPropertyFlags::DEVICE_LOCAL)) + .find(|&i| { + allowed(i) + && types[i] + .flags + .contains(vk::MemoryPropertyFlags::DEVICE_LOCAL) + }) .or_else(|| (0..types.len()).find(|&i| allowed(i))) .map(|i| i as u32), Want::HostCoherent => (0..types.len()) diff --git a/apps/nescapture/src/modifiers.rs b/apps/nescapture/src/modifiers.rs deleted file mode 100644 index 4644fc7b..00000000 --- a/apps/nescapture/src/modifiers.rs +++ /dev/null @@ -1,112 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// modifiers.rs — choosing a DRM format modifier for the capture ring -// -// The importer has always been able to take a tiled buffer: `dmabuf_import.rs` -// builds `VkImageDrmFormatModifierExplicitCreateInfoEXT` with per-plane -// layouts and creates the image with `DRM_FORMAT_MODIFIER_EXT` tiling. Only -// the producer was linear — hard-coded `ImageTiling::LINEAR` and a `modifier` -// of zero passed down with every frame — so every capture detiled a full frame -// on the write and the encoder sampled a linear image on the read. -// -// Picking the modifier is the whole of the decision and it is pure, so it is -// here and tested rather than buried in an unsafe block. -// ───────────────────────────────────────────────────────────────────────────── - -/// One entry of `VkDrmFormatModifierPropertiesListEXT`, already filtered to -/// modifiers whose tiling features cover both our write and the encoder's read. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ModifierProps { - pub modifier: u64, - pub plane_count: u32, -} - -/// `DRM_FORMAT_MOD_LINEAR`. -pub const LINEAR: u64 = 0; - -/// `DRM_FORMAT_MOD_INVALID`, which a driver may list and which means "let the -/// driver choose" — not something to ask for explicitly. -pub const INVALID: u64 = 0x00ff_ffff_ffff_ffff; - -/// Pick the modifier to allocate the capture ring with. -/// -/// Single-plane only, and deliberately so. A multi-plane modifier needs an -/// offset and a stride per plane on the import side, and the export path hands -/// out one fd with one stride — so accepting one would produce an image the far -/// side reads at the wrong offsets, which arrives at the right size and frame -/// rate carrying nonsense. Prefer any real tiled modifier; fall back to linear, -/// which is what the ring used before this existed and always works. -pub fn pick_modifier(candidates: &[ModifierProps]) -> Option { - let usable = |m: &&ModifierProps| m.plane_count == 1 && m.modifier != INVALID; - candidates - .iter() - .filter(usable) - .find(|m| m.modifier != LINEAR) - .or_else(|| candidates.iter().filter(usable).find(|m| m.modifier == LINEAR)) - .copied() -} - -#[cfg(test)] -mod tests { - use super::*; - - const TILED: u64 = 0x0200_0000_0000_0001; - - #[test] - fn a_tiled_modifier_beats_linear() { - let c = [ - ModifierProps { modifier: LINEAR, plane_count: 1 }, - ModifierProps { modifier: TILED, plane_count: 1 }, - ]; - assert_eq!(pick_modifier(&c).unwrap().modifier, TILED); - } - - /// Order must not decide it — the driver lists them in its own order. - #[test] - fn a_tiled_modifier_wins_from_either_position() { - let c = [ - ModifierProps { modifier: TILED, plane_count: 1 }, - ModifierProps { modifier: LINEAR, plane_count: 1 }, - ]; - assert_eq!(pick_modifier(&c).unwrap().modifier, TILED); - } - - /// A multi-plane modifier with one exported fd would be imported at the - /// wrong plane offsets and produce a corrupt frame rather than an error. - #[test] - fn multi_plane_modifiers_are_refused() { - let c = [ - ModifierProps { modifier: 0x0200_0000_0000_0002, plane_count: 2 }, - ModifierProps { modifier: LINEAR, plane_count: 1 }, - ]; - assert_eq!(pick_modifier(&c).unwrap().modifier, LINEAR); - } - - /// A multi-plane tiled modifier must not beat a single-plane linear one - /// just for being tiled. - #[test] - fn tiling_does_not_excuse_a_plane_count_we_cannot_export() { - let c = [ - ModifierProps { modifier: TILED, plane_count: 4 }, - ModifierProps { modifier: LINEAR, plane_count: 1 }, - ]; - assert_eq!(pick_modifier(&c).unwrap().modifier, LINEAR); - } - - /// `DRM_FORMAT_MOD_INVALID` is not a modifier to ask for. - #[test] - fn the_invalid_modifier_is_never_chosen() { - let c = [ModifierProps { modifier: INVALID, plane_count: 1 }]; - assert_eq!(pick_modifier(&c), None); - } - - #[test] - fn nothing_usable_is_none() { - let c = [ModifierProps { modifier: TILED, plane_count: 4 }]; - assert_eq!(pick_modifier(&c), None); - } - - #[test] - fn an_empty_list_is_none() { - assert_eq!(pick_modifier(&[]), None); - } -} diff --git a/apps/nescapture/src/present.rs b/apps/nescapture/src/present.rs index 92dfd96b..9366d8d7 100644 --- a/apps/nescapture/src/present.rs +++ b/apps/nescapture/src/present.rs @@ -1,5 +1,5 @@ use crate::capture; -use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle}; +use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle, PresentStep}; use crate::slots::SlotGuard; use crate::state::{DEVICE_STATE, QUEUE_TO_DEVICE_KEY}; use ash::vk::{self, Handle}; @@ -32,6 +32,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR( // it is the game's own frame time with this layer's cost excluded — the // three spans then partition the wall clock between presents exactly. let entered = std::time::Instant::now(); + note_present(&ds, PresentStep::InHook); ds.frame_counter.fetch_add(1, Ordering::Relaxed); ds.hud_detected_frame.store(false, Ordering::Relaxed); @@ -55,9 +56,8 @@ pub unsafe extern "system" fn vkQueuePresentKHR( // Rewriting the wait semaphores is only well defined for a single // swapchain. A multi-swapchain present is rare enough that passing it // through untouched beats getting the interposition subtly wrong. - let single_swapchain = pi.swapchain_count == 1 - && !pi.p_swapchains.is_null() - && !pi.p_image_indices.is_null(); + let single_swapchain = + pi.swapchain_count == 1 && !pi.p_swapchains.is_null() && !pi.p_image_indices.is_null(); let submission = if single_swapchain { unsafe { try_capture(&ds, queue, pi) } @@ -68,9 +68,11 @@ pub unsafe extern "system" fn vkQueuePresentKHR( let down_us = std::cell::Cell::new(std::time::Duration::ZERO); let call_down = |info: *const vk::PresentInfoKHR| match ds.fp.queue_present_khr { Some(f) => { + note_present(&ds, PresentStep::Presenting); let t = std::time::Instant::now(); let r = unsafe { f(queue, info) }; down_us.set(t.elapsed()); + note_present(&ds, PresentStep::InHook); r } None => vk::Result::ERROR_EXTENSION_NOT_PRESENT, @@ -82,7 +84,6 @@ pub unsafe extern "system" fn vkQueuePresentKHR( return r; }; - // The blit consumed the application's wait semaphores, so the present waits // on ours instead. Presenting on the originals as well would be a second // wait on an already-consumed signal. @@ -121,11 +122,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR( /// `layer` is everything in this hook that is not the down-call, both sides of /// it added together, so `gap + layer + down` accounts for the wall clock /// between one present and the next with nothing unattributed. -fn finish( - ds: &crate::state::DeviceState, - entered: std::time::Instant, - down: std::time::Duration, -) { +fn finish(ds: &crate::state::DeviceState, entered: std::time::Instant, down: std::time::Duration) { // Everything this hook cost, before any deliberate waiting. let worked = std::time::Instant::now(); @@ -138,6 +135,7 @@ fn finish( Err(_) => std::time::Duration::ZERO, }; if !held.is_zero() { + note_present(ds, PresentStep::Holding); std::thread::sleep(held); } @@ -167,12 +165,24 @@ fn finish( if let Ok(mut last) = ds.last_present_return.lock() { *last = Some(now); } + note_present(ds, PresentStep::InGame); +} + +/// Tell the stall watchdog where the game's present thread is. A no-op until +/// the pipeline exists. +pub fn note_present(ds: &crate::state::DeviceState, step: PresentStep) { + if let Ok(enc) = ds.encoder.lock() + && let Some(ref h) = *enc + { + h.note_present(step); + } } /// Everything the present hook needs to carry from the blit to the worker. struct Submission { slot: SlotGuard, present_wait: vk::Semaphore, + blit: Option, width: u32, height: u32, sc_fmt: vk::Format, @@ -185,6 +195,13 @@ unsafe fn try_capture( pi: &vk::PresentInfoKHR, ) -> Option { let image_index = unsafe { *pi.p_image_indices } as usize; + // An image acquired from a retired swapchain may still be presented, and + // the tracked images belong to its replacement: the same index there is a + // different image, possibly of a different size. + let presented = unsafe { *pi.p_swapchains }; + if !crate::swapchain::is_current(*ds.swapchain.lock().ok()?, presented) { + return None; + } let (sc_image, sc_fmt, sc_ext, image_count) = { let images = ds.swapchain_images.lock().ok()?; let fmt = *ds.swapchain_format.lock().ok()?; @@ -196,7 +213,7 @@ unsafe fn try_capture( }; // Gate before any GPU work is queued. A game presenting faster than the - // target would otherwise pay a full blit and DMA-BUF export for frames the + // target would otherwise pay a full blit for frames the // encoder throws away moments later. let present_time = std::time::Instant::now(); let admitted = match ds.frame_gate.lock() { @@ -221,14 +238,14 @@ unsafe fn try_capture( } } - let app_waits: &[vk::Semaphore] = if pi.wait_semaphore_count == 0 || pi.p_wait_semaphores.is_null() - { - &[] - } else { - unsafe { - std::slice::from_raw_parts(pi.p_wait_semaphores, pi.wait_semaphore_count as usize) - } - }; + let app_waits: &[vk::Semaphore] = + if pi.wait_semaphore_count == 0 || pi.p_wait_semaphores.is_null() { + &[] + } else { + unsafe { + std::slice::from_raw_parts(pi.p_wait_semaphores, pi.wait_semaphore_count as usize) + } + }; let submission = unsafe { capture::capture_present_frame( @@ -246,6 +263,7 @@ unsafe fn try_capture( Some(Submission { slot: submission.slot, present_wait: submission.present_wait, + blit: submission.blit, width: sc_ext.width, height: sc_ext.height, sc_fmt, @@ -273,6 +291,7 @@ fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) { vk_colorspace: ds.swapchain_colorspace.load(Ordering::Relaxed), present_time: submission.present_time, slot: Some(submission.slot), + blit: submission.blit, }); } @@ -303,11 +322,31 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig log::error!("no encode pipeline configuration; capture disabled"); return; }; - match PipelineHandle::new(cfg) { + // On the game's own device where it was created for that, and + // on a device of the encoder's own where it was not, or where + // adopting it fails. + let shared = ds.shared.as_ref().and_then(|s| match s.video_context() { + Ok(ctx) => Some(ctx), + Err(e) => { + log::warn!( + "could not encode on the game's device ({e}); using a device of its own" + ); + None + } + }); + let on_shared = shared.is_some(); + match PipelineHandle::new(cfg, shared) { Ok(h) => { + h.set_stall_probe(Box::new(move || describe_gpu(ds_key))); + // Before the handle is published: the first capture after + // it builds the ring, and must build it for this device. + ds.shared_active.store(on_shared, Ordering::Release); if let Ok(mut enc) = ds.encoder.lock() { *enc = Some(h); } + if on_shared { + log::info!("encoding on the game's own device"); + } } Err(e) => log::error!("encode pipeline: {e}"), } @@ -316,9 +355,13 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig false } -/// Wait for a frame's blit and turn its slot into something the encoder reads. +/// Turn a frame's slot into something the encoder reads. /// -/// This is the CPU handover the two devices need: pixelforge's `VkDevice` +/// On a device the encoder shares this is immediate: the slot's image is read +/// in place, and the encoder's GPU work waits on the blit's timeline point, so +/// nothing here waits at all. +/// +/// Otherwise this is the CPU handover two devices need: pixelforge's `VkDevice` /// shares no timeline with the game's, so no semaphore can bridge them and /// somebody has to block. It used to be a thread of its own between the present /// hook and the encoder; it is now the first thing the encoder thread does with @@ -331,13 +374,19 @@ pub fn resolve_source( ) -> Option { let slot_index = frame.slot.as_ref()?.index(); + if let Some(blit) = frame.blit { + let ring = ds.capture_ring.lock().ok()?; + let image = ring.as_ref()?.slots.get(slot_index)?.image; + return Some(FrameSource::Shared { image, blit }); + } + // Copy the handles out and drop the ring lock before waiting: the present // hook needs that lock every frame and must not queue behind a GPU wait. - let (fence, dmabuf_fd, stride, modifier, image, memory) = { + let (fence, image, memory) = { let ring = ds.capture_ring.lock().ok()?; ring.as_ref() .and_then(|r| r.slots.get(slot_index)) - .map(|s| (s.fence, s.dmabuf_fd, s.stride, s.modifier, s.image, s.memory))? + .map(|s| (s.fence, s.image, s.memory))? }; let waited = unsafe { (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000_000) }; @@ -347,30 +396,59 @@ pub fn resolve_source( } // After the fence, so the queries have landed and `WAIT` returns at once. - if let Some(ns) = unsafe { capture::blit_gpu_time_ns(ds, slot_index) } + if let Some(ns) = unsafe { capture::blit_gpu_time_ns(ds, slot_index, true) } && let Ok(enc) = ds.encoder.lock() && let Some(ref h) = *enc { h.timing.blit.record(std::time::Duration::from_nanos(ns)); } - if dmabuf_fd >= 0 { - let duped = unsafe { libc::dup(dmabuf_fd) }; - if duped < 0 { - log::warn!("dup of capture DMA-BUF failed — frame dropped"); - return None; - } - return Some(FrameSource::DmaBuf { - fd: duped, - stride, - // The slot's own modifier. This was hard-coded to zero, which was - // true only because the producer could not ask for anything else. - modifier, - }); - } - match unsafe { capture::read_frame_pixels(ds, image, memory, frame.width, frame.height) } { Some(p) if !p.is_empty() => Some(FrameSource::Pixels(p)), _ => None, } } + +/// Whether the GPU has got through the capture work this layer gave it, for +/// the stall watchdog. +/// +/// A blit is queued behind the game's frame on the game's own queue, so a blit +/// that never completes means that queue stopped: the game is waiting on the +/// GPU. One that did complete means the GPU finished everything in front of +/// it, and whatever the game is waiting for is not GPU work on that queue. +/// +/// Never blocks. It runs exactly when something may be holding a lock forever. +fn describe_gpu(ds_key: usize) -> String { + let Some(ds) = DEVICE_STATE.get(&ds_key).map(|s| s.clone()) else { + return "device gone".into(); + }; + let slots = format!( + "{} of {} capture slots free", + ds.capture_slots.available(), + crate::state::CAPTURE_SLOTS + ); + let Ok(ring) = ds.capture_ring.try_lock() else { + return format!("{slots}; capture ring locked"); + }; + let Some(ring) = ring.as_ref() else { + return format!("{slots}; no capture ring"); + }; + if let Some(shared) = ds.shared.as_ref() + && !ring.blit_timeline.is_null() + { + let reached = shared + .counter(ring.blit_timeline) + .map_or("unknown".to_string(), |v| v.to_string()); + return format!( + "{slots}; last blit submitted signals {}, the GPU has reached {reached}", + ring.blit_value + ); + } + let pending = ring + .slots + .iter() + .filter(|s| unsafe { (ds.fp.wait_for_fences)(ds.raw, 1, &s.fence, vk::TRUE, 0) } + == vk::Result::TIMEOUT) + .count(); + format!("{slots}; {pending} blits submitted and not yet complete") +} diff --git a/apps/nescapture/src/rate_probe.rs b/apps/nescapture/src/rate_probe.rs new file mode 100644 index 00000000..66e742b0 --- /dev/null +++ b/apps/nescapture/src/rate_probe.rs @@ -0,0 +1,415 @@ +//! How long the encoder takes to actually reach a bitrate it was told to use. +//! +//! # Why this exists +//! +//! The bitrate controller decides how often it is worth deciding. Everything +//! else about its design follows from one number nobody has measured: the time +//! between `set_target_bitrate` and the encoder actually producing that rate. +//! +//! Simulated against a measured 1000-mile path, the difference is the whole +//! design. With the encoder settling in a quarter second, a controller sampling +//! the send queue five times a second holds the queue at 50 ms. With the +//! encoder taking a second, the same controller at any rate holds it at about +//! 1400 ms, which is unplayable, and sampling faster buys nothing at all -- +//! there is no point reacting quicker than the thing being steered can move. +//! +//! So this measures it, rather than picking a control rate and hoping. +//! +//! # Shape +//! +//! Self-driving on purpose. The ladder and the dwell are fixed here so that two +//! people on two networks produce numbers that can be laid beside each other; +//! if the steps came from a person moving a slider, they would not be. +//! +//! While a sweep is running the encoder ignores bitrates from anywhere else. +//! A controller adjusting in the background would be a second hand on the same +//! dial, and the measurement would describe the argument rather than the +//! encoder. + +use std::time::{Duration, Instant}; + +/// Targets to step through, in kbps. +/// +/// Large steps and small ones, downward and upward. Downward matters most -- +/// that is the direction taken under congestion, and the direction where being +/// slow costs latency rather than picture -- but an encoder can easily be quick +/// one way and slow the other, so both are here. +const LADDER: [u32; 6] = [6_000, 1_500, 6_000, 3_000, 1_000, 4_000]; + +/// How long to sit at each rung. +/// +/// Long enough to settle and then be seen to be steady. If settling turns out +/// to take longer than this, that is itself the answer and it is reported as a +/// failure to settle rather than as a number. +const DWELL: Duration = Duration::from_secs(5); + +/// Window over which the produced rate is measured. +/// +/// Short enough to see a change quickly, long enough that one large frame does +/// not look like a rate. At sixty frames a second this averages about thirty. +const WINDOW: Duration = Duration::from_millis(500); + +/// How close counts as arrived. +const TOLERANCE: f32 = 0.10; + +/// How long it must stay inside the tolerance to count as settled, rather than +/// having passed through on the way somewhere else. +const HOLD: Duration = Duration::from_millis(500); + +/// What one rung of the ladder turned out to cost. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StepReport { + pub from_kbps: u32, + pub to_kbps: u32, + /// Milliseconds from the command to the produced rate first being inside + /// the tolerance and staying there. `None` if it never settled. + pub settle_ms: Option, + pub settle_frames: Option, + /// Produced rate over the last second of the dwell, as a fraction of the + /// target. This is the overshoot the controller has to divide out: a + /// hardware encoder asked for 1000 does not produce 1000. + pub steady_ratio: f32, + /// Keyframes during the rung. + pub keyframes: u32, + /// The largest keyframe seen, in bytes. + /// + /// Reported rather than folded in, because it is a different quantity with + /// a different consumer. Settling is about the rate control finding its + /// operating point; a keyframe is a single burst handed to the transport + /// whole. The controller needs both and must not confuse them: measured at + /// 4 s GOP, a run where keyframes were counted in the rate said settling + /// took 2266 ms where the same encoder with no keyframes said 450 ms, and + /// in one case the number went *down* when keyframes were added. That is + /// not an encoder being erratic, it is a window catching an IDR. + pub keyframe_bytes: u32, + /// How long that keyframe alone occupies the link at this rung's target, + /// in milliseconds. This is the burst a queue has to absorb, and the + /// reason a queue setpoint cannot simply be set below it. + pub keyframe_ms: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + /// Letting the encoder reach a steady state before the first step. + WarmUp, + Stepping, + Done, +} + +pub struct RateProbe { + phase: Phase, + rung: usize, + /// When the current rung was commanded. + began: Instant, + current_kbps: u32, + /// `(when, bytes)` inside the measurement window, delta frames only. + /// + /// Keyframes are deliberately absent. One IDR is worth many delta frames, + /// so a half-second window containing one reports a rate several times the + /// truth, leaves the tolerance band, and restarts the settle clock -- which + /// measures the GOP rather than the encoder. + samples: Vec<(Instant, u32)>, + keyframe_bytes: u32, + /// Frames since the current rung was commanded. + frames: u32, + keyframes: u32, + /// When the produced rate first entered the tolerance, if it still is. + inside_since: Option, + settled: Option<(u32, u32)>, + /// The last second of the dwell, for the steady-state ratio. + steady: Vec<(Instant, u32)>, + reports: Vec, +} + +impl RateProbe { + /// A probe that has not started stepping yet. + pub fn new(now: Instant, starting_kbps: u32) -> Self { + Self { + phase: Phase::WarmUp, + rung: 0, + began: now, + current_kbps: starting_kbps, + samples: Vec::new(), + keyframe_bytes: 0, + frames: 0, + keyframes: 0, + inside_since: None, + settled: None, + steady: Vec::new(), + reports: Vec::new(), + } + } + + pub fn finished(&self) -> bool { + self.phase == Phase::Done + } + + /// Whether a bitrate from elsewhere should be ignored. + pub fn owns_the_bitrate(&self) -> bool { + self.phase != Phase::Done + } + + /// One encoded frame. + pub fn observe(&mut self, now: Instant, bytes: u32, keyframe: bool) { + if keyframe { + self.keyframe_bytes = self.keyframe_bytes.max(bytes); + } else { + self.samples.push((now, bytes)); + } + self.samples + .retain(|(t, _)| now.duration_since(*t) <= WINDOW); + // The steady-state ratio *does* include keyframes: it answers what the + // link actually carries for a given target, which is the whole output. + self.steady.push((now, bytes)); + self.steady + .retain(|(t, _)| now.duration_since(*t) <= Duration::from_secs(1)); + if self.phase != Phase::Stepping { + return; + } + self.frames += 1; + if keyframe { + self.keyframes += 1; + } + if self.settled.is_some() { + return; + } + let Some(rate) = self.measured_kbps(now) else { + return; + }; + let drift = + (rate as f32 - self.current_kbps as f32).abs() / self.current_kbps.max(1) as f32; + if drift > TOLERANCE { + // Left the band, so whatever it was doing was not settling. + self.inside_since = None; + return; + } + let entered = *self.inside_since.get_or_insert(now); + if now.duration_since(entered) >= HOLD { + // Credit the moment it arrived, not the moment it had stayed long + // enough to prove it: the hold is evidence about the arrival, not + // part of the time the encoder took. + let ms = entered.duration_since(self.began).as_millis() as u32; + self.settled = Some((ms, self.frames)); + } + } + + /// Produced rate over the window, or `None` before there is a window's worth. + fn measured_kbps(&self, now: Instant) -> Option { + let oldest = self.samples.first()?.0; + let span = now.duration_since(oldest); + if span < WINDOW / 2 { + return None; + } + let bits: u64 = self.samples.iter().map(|(_, b)| u64::from(*b) * 8).sum(); + Some((bits as f64 / span.as_secs_f64() / 1000.0) as u32) + } + + /// The next target to apply, when the current rung is done. + pub fn due_step(&mut self, now: Instant) -> Option { + if now.duration_since(self.began) < DWELL { + return None; + } + if self.phase == Phase::Stepping { + self.close_rung(now); + } + if self.rung >= LADDER.len() { + self.phase = Phase::Done; + return None; + } + let next = LADDER[self.rung]; + self.rung += 1; + self.phase = Phase::Stepping; + self.began = now; + self.frames = 0; + self.keyframes = 0; + self.keyframe_bytes = 0; + self.inside_since = None; + self.settled = None; + let from = self.current_kbps; + self.current_kbps = next; + let _ = from; + Some(next) + } + + fn close_rung(&mut self, now: Instant) { + let steady_bits: u64 = self.steady.iter().map(|(_, b)| u64::from(*b) * 8).sum(); + let span = self + .steady + .first() + .map(|(t, _)| now.duration_since(*t).as_secs_f64()) + .unwrap_or(0.0); + let steady_kbps = if span > 0.0 { + steady_bits as f64 / span / 1000.0 + } else { + 0.0 + }; + let from = self.reports.last().map_or(0, |r| r.to_kbps); + self.reports.push(StepReport { + from_kbps: from, + to_kbps: self.current_kbps, + settle_ms: self.settled.map(|(ms, _)| ms), + settle_frames: self.settled.map(|(_, f)| f), + steady_ratio: (steady_kbps / f64::from(self.current_kbps.max(1))) as f32, + keyframes: self.keyframes, + keyframe_bytes: self.keyframe_bytes, + keyframe_ms: (u64::from(self.keyframe_bytes) * 8 / u64::from(self.current_kbps.max(1))) + as u32, + }); + } + + pub fn reports(&self) -> &[StepReport] { + &self.reports + } + + /// The number the controller design turns on: the slowest settle observed. + /// + /// The slowest rather than the average, for the same reason the bitrate + /// controller reads the worst client's report: a loop that keeps up with + /// the typical step and not the worst one is a loop that falls behind + /// exactly when the path is changing, which is the only time it matters. + pub fn worst_settle_ms(&self) -> Option { + self.reports.iter().map(|r| r.settle_ms).max().flatten() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Feed a probe frames at `kbps` for `secs`, sixty a second. + fn feed(probe: &mut RateProbe, start: Instant, secs: f32, kbps: u32) -> Instant { + let frames = (secs * 60.0) as u32; + let bytes = (u64::from(kbps) * 1000 / 8 / 60) as u32; + let mut now = start; + for i in 1..=frames { + // From the start each time: stepping by a rounded frame interval + // drifts, and a helper that loses 4% of every second makes a probe + // measuring seconds look broken when it is not. + now = start + Duration::from_secs_f64(f64::from(i) / 60.0); + probe.observe(now, bytes, false); + } + now + } + + #[test] + fn an_encoder_that_changes_at_once_settles_at_once() { + let t0 = Instant::now(); + let mut p = RateProbe::new(t0, 6_000); + let mut now = feed(&mut p, t0, 5.1, 6_000); + let step = p.due_step(now).expect("first rung"); + assert_eq!(step, 6_000); + now = feed(&mut p, now, 5.1, 6_000); + assert_eq!(p.due_step(now), Some(1_500)); + // Produces the new rate immediately. + feed(&mut p, now, 2.0, 1_500); + let r = p.reports(); + assert!(!r.is_empty()); + assert!( + r[0].settle_ms.is_some_and(|ms| ms < 1_200), + "an instant encoder reported {:?}", + r[0].settle_ms + ); + } + + /// The case that would kill the fast-loop design, and must be visible. + #[test] + fn an_encoder_that_never_gets_there_reports_no_settle() { + let t0 = Instant::now(); + let mut p = RateProbe::new(t0, 6_000); + let mut now = feed(&mut p, t0, 5.1, 6_000); + p.due_step(now); + now = feed(&mut p, now, 5.1, 6_000); + assert_eq!(p.due_step(now), Some(1_500)); + // Ignores the command completely. + now = feed(&mut p, now, 5.1, 6_000); + p.due_step(now); + let r = p.reports(); + let step = r + .iter() + .find(|r| r.to_kbps == 1_500) + .expect("the 1500 rung"); + assert_eq!( + step.settle_ms, None, + "an encoder that ignored us looked settled" + ); + assert!( + step.steady_ratio > 3.0, + "steady ratio {} did not show it producing four times the target", + step.steady_ratio + ); + } + + /// Overshoot is the other number the controller needs, so it must be real. + #[test] + fn steady_ratio_reports_the_overshoot() { + let t0 = Instant::now(); + let mut p = RateProbe::new(t0, 6_000); + let mut now = feed(&mut p, t0, 5.1, 6_000); + p.due_step(now); + now = feed(&mut p, now, 5.1, 6_000); + p.due_step(now); + // Asked for 1500, produces 1875: the 25% overshoot measured on hardware. + now = feed(&mut p, now, 5.1, 1_875); + p.due_step(now); + let step = p.reports().iter().find(|r| r.to_kbps == 1_500).unwrap(); + assert!( + (step.steady_ratio - 1.25).abs() < 0.1, + "steady ratio {} should be about 1.25", + step.steady_ratio + ); + } + + /// The confound that made a 4 s GOP look like a three-times slower + /// encoder, including making one rung appear *faster* when keyframes were + /// added -- which no encoder does, and which gave the measurement away. + #[test] + fn a_keyframe_landing_mid_window_does_not_delay_the_reported_settle() { + let t0 = Instant::now(); + let mut p = RateProbe::new(t0, 6_000); + let mut now = feed(&mut p, t0, 5.1, 6_000); + p.due_step(now); + now = feed(&mut p, now, 5.1, 6_000); + assert_eq!(p.due_step(now), Some(1_500)); + + // Settles immediately, but an IDR worth a second of bitrate lands in + // the middle of the window that is meant to prove it. + let bytes = (1_500u64 * 1000 / 8 / 60) as u32; + let base = now; + for i in 1..=320u32 { + now = base + Duration::from_secs_f64(f64::from(i) / 60.0); + let key = i == 40; + p.observe(now, if key { 1_500 * 1000 / 8 } else { bytes }, key); + } + p.due_step(now); + let step = p.reports().iter().find(|r| r.to_kbps == 1_500).unwrap(); + assert!( + step.settle_ms.is_some_and(|ms| ms < 1_000), + "an encoder that settled at once reported {:?} because of one keyframe", + step.settle_ms + ); + // And the burst is still reported, because the queue has to absorb it. + assert_eq!(step.keyframes, 1); + assert!( + step.keyframe_ms >= 900, + "a keyframe worth a second of bitrate reported {} ms", + step.keyframe_ms + ); + } + + #[test] + fn a_sweep_ends_and_does_not_step_forever() { + let t0 = Instant::now(); + let mut p = RateProbe::new(t0, 6_000); + let mut now = t0; + for _ in 0..(LADDER.len() + 2) { + now = feed(&mut p, now, 5.1, 3_000); + p.due_step(now); + } + assert!(p.finished()); + assert!( + !p.owns_the_bitrate(), + "a finished sweep still holds the dial" + ); + assert_eq!(p.reports().len(), LADDER.len()); + } +} diff --git a/apps/nescapture/src/shared.rs b/apps/nescapture/src/shared.rs new file mode 100644 index 00000000..bd80719f --- /dev/null +++ b/apps/nescapture/src/shared.rs @@ -0,0 +1,1165 @@ +// ───────────────────────────────────────────────────────────────────────────── +// shared.rs — encoding on the game's own VkDevice +// +// The encoder used to run on a device of its own, which meant every frame +// crossed between two devices as exported memory, with a CPU wait in between +// because two devices share no timeline. Here the game's device is created +// with what the encoder needs, and the encoder is handed that device instead. +// +// Three things are added to the game's vkCreateDevice, and none of them +// changes what the game gets: +// +// - Extensions the encoder needs and the game did not ask for. +// - Feature bits, set in whichever feature structs the game already chains, +// and in structs of our own only where it chains none that hold them. +// Vulkan forbids chaining VkPhysicalDeviceVulkan13Features alongside the +// per-feature structs it contains, so appending blindly would make a valid +// game's device creation invalid. +// - Queues. A VkQueue may not be submitted to from two threads at once, and +// the encoder submits from its own thread while the game submits from its. +// So the encoder gets queues the game did not ask for where a family has +// one to spare. Where none does, the game's queue is created internally +// synchronized instead, which makes sharing it safe. +// ───────────────────────────────────────────────────────────────────────────── + +use ash::vk; +use ash::vk::TaggedStructure; +use pixelforge::vulkan::{DeviceFeatures, DeviceQueue, DeviceRequirements}; +use std::collections::BTreeMap; + +// ── Queues ──────────────────────────────────────────────────────────────────── + +/// Which queue the encoder uses for each of its roles, and what that means for +/// the device's queue create infos. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueuePlan { + pub encode: DeviceQueue, + pub compute: DeviceQueue, + pub transfer: DeviceQueue, + /// How many queues to create in each family, where that is more than the + /// game asked for. + pub counts: BTreeMap, + /// Families whose queues are created internally synchronized, because the + /// encoder shares the game's queue there. + pub internally_synchronized: Vec, +} + +/// What a role needs from a family. +fn can(flags: vk::QueueFlags, needs: vk::QueueFlags) -> bool { + let mut effective = flags; + // Graphics and compute families support transfer whether or not they say so. + if flags.intersects(vk::QueueFlags::GRAPHICS | vk::QueueFlags::COMPUTE) { + effective |= vk::QueueFlags::TRANSFER; + } + effective.contains(needs) +} + +/// Lower is better. The game renders on the graphics family, so it is the +/// last choice for anything, and a family that also does video is a poor one +/// for compute or copies because it contends with the encode itself. +fn rank(flags: vk::QueueFlags) -> u32 { + let mut r = 0; + if flags.contains(vk::QueueFlags::GRAPHICS) { + r += 4; + } + if flags.intersects(vk::QueueFlags::VIDEO_ENCODE_KHR | vk::QueueFlags::VIDEO_DECODE_KHR) { + r += 2; + } + if flags.contains(vk::QueueFlags::COMPUTE) { + r += 1; + } + r +} + +/// Pick a queue for every role. +/// +/// `families` is the device's queue families, `game` how many queues the game +/// asked for in each (and with which flags). The encoder takes at most one +/// queue per family: all its submissions come from one thread, so its roles +/// can share a queue with each other freely, just not with the game. +/// +/// `None` when some role has neither a spare queue nor a way to share the +/// game's safely, in which case the device is created as the game asked. +pub fn plan_queues( + families: &[vk::QueueFamilyProperties], + game: &BTreeMap, + preferred_encode: Option, + internally_synchronized_queues: bool, +) -> Option { + let mut counts: BTreeMap = BTreeMap::new(); + let mut ours: BTreeMap = BTreeMap::new(); + let mut shared: Vec = Vec::new(); + + let requested = |f: u32| game.get(&f).map_or(0, |&(n, _)| n); + + let pick = |needs: vk::QueueFlags, + prefer: Option, + counts: &mut BTreeMap, + ours: &mut BTreeMap, + shared: &mut Vec| + -> Option { + let mut candidates: Vec = (0..families.len() as u32) + .filter(|&f| can(families[f as usize].queue_flags, needs)) + .collect(); + candidates.sort_by_key(|&f| { + ( + Some(f) != prefer, + // A family already holding one of ours comes first: roles + // sharing a queue costs nothing, a second queue costs one. + !ours.contains_key(&f) && !shared.contains(&f), + rank(families[f as usize].queue_flags), + f, + ) + }); + + for &f in &candidates { + if let Some(&index) = ours.get(&f) { + return Some(DeviceQueue::new(f, index)); + } + if shared.contains(&f) { + return Some(DeviceQueue::new(f, 0)); + } + let have = requested(f); + if have < families[f as usize].queue_count { + counts.insert(f, have + 1); + ours.insert(f, have); + return Some(DeviceQueue::new(f, have)); + } + } + + // No spare anywhere. Share the game's queue, if it can be made safe to. + if !internally_synchronized_queues { + return None; + } + for &f in &candidates { + // A family the game did not ask for would have had a spare above. + // A non-zero flags value is a protected queue, which is not ours to + // touch. + if let Some(&(n, flags)) = game.get(&f) + && n > 0 + && flags.is_empty() + { + shared.push(f); + return Some(DeviceQueue::new(f, 0)); + } + } + None + }; + + let encode = pick( + vk::QueueFlags::VIDEO_ENCODE_KHR, + preferred_encode, + &mut counts, + &mut ours, + &mut shared, + )?; + let compute = pick( + vk::QueueFlags::COMPUTE, + None, + &mut counts, + &mut ours, + &mut shared, + )?; + let transfer = pick( + vk::QueueFlags::TRANSFER, + Some(compute.family), + &mut counts, + &mut ours, + &mut shared, + )?; + + Some(QueuePlan { + encode, + compute, + transfer, + counts, + internally_synchronized: shared, + }) +} + +// ── Features ────────────────────────────────────────────────────────────────── + +/// One feature bit the encoder needs, and where it may live in a pNext chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Feature { + Synchronization2, + TimelineSemaphore, + SamplerYcbcrConversion, + Ycbcr2Plane444Formats, + VideoEncodeAv1, + VideoEncodeRgbConversion, + VideoEncodeIntraRefresh, + VideoEncodeQuantizationMap, + InternallySynchronizedQueues, +} + +/// The features `f` asks for, plus the one queue sharing needs. +pub fn wanted_features(f: &DeviceFeatures, share_queues: bool) -> Vec { + let mut out = Vec::new(); + let mut want = |on: bool, feature| { + if on { + out.push(feature); + } + }; + want(f.synchronization2, Feature::Synchronization2); + want(f.timeline_semaphore, Feature::TimelineSemaphore); + want(f.sampler_ycbcr_conversion, Feature::SamplerYcbcrConversion); + want(f.ycbcr_2plane_444_formats, Feature::Ycbcr2Plane444Formats); + want(f.video_encode_av1, Feature::VideoEncodeAv1); + want( + f.video_encode_rgb_conversion, + Feature::VideoEncodeRgbConversion, + ); + want( + f.video_encode_intra_refresh, + Feature::VideoEncodeIntraRefresh, + ); + want( + f.video_encode_quantization_map, + Feature::VideoEncodeQuantizationMap, + ); + want(share_queues, Feature::InternallySynchronizedQueues); + out +} + +/// Where in a struct of type `s_type` the bit for `feature` is, if that struct +/// holds it. Covers the per-feature structs and the core aggregates. +/// +/// # Safety +/// +/// `p` must point to a live struct whose type is `s_type`. +unsafe fn field( + s_type: vk::StructureType, + p: *mut vk::BaseOutStructure<'_>, + feature: Feature, +) -> Option<*mut vk::Bool32> { + use Feature as F; + use vk::StructureType as S; + macro_rules! at { + ($ty:ty, $field:ident) => { + Some(unsafe { &raw mut (*(p as *mut $ty)).$field }) + }; + } + match (s_type, feature) { + (S::PHYSICAL_DEVICE_VULKAN_1_3_FEATURES, F::Synchronization2) => { + at!(vk::PhysicalDeviceVulkan13Features, synchronization2) + } + (S::PHYSICAL_DEVICE_SYNCHRONIZATION_2_FEATURES, F::Synchronization2) => { + at!(vk::PhysicalDeviceSynchronization2Features, synchronization2) + } + (S::PHYSICAL_DEVICE_VULKAN_1_2_FEATURES, F::TimelineSemaphore) => { + at!(vk::PhysicalDeviceVulkan12Features, timeline_semaphore) + } + (S::PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES, F::TimelineSemaphore) => { + at!( + vk::PhysicalDeviceTimelineSemaphoreFeatures, + timeline_semaphore + ) + } + (S::PHYSICAL_DEVICE_VULKAN_1_1_FEATURES, F::SamplerYcbcrConversion) => { + at!(vk::PhysicalDeviceVulkan11Features, sampler_ycbcr_conversion) + } + (S::PHYSICAL_DEVICE_SAMPLER_YCBCR_CONVERSION_FEATURES, F::SamplerYcbcrConversion) => { + at!( + vk::PhysicalDeviceSamplerYcbcrConversionFeatures, + sampler_ycbcr_conversion + ) + } + (S::PHYSICAL_DEVICE_YCBCR_2_PLANE_444_FORMATS_FEATURES_EXT, F::Ycbcr2Plane444Formats) => { + at!( + vk::PhysicalDeviceYcbcr2Plane444FormatsFeaturesEXT, + ycbcr2plane444_formats + ) + } + (S::PHYSICAL_DEVICE_VIDEO_ENCODE_AV1_FEATURES_KHR, F::VideoEncodeAv1) => { + at!( + vk::PhysicalDeviceVideoEncodeAV1FeaturesKHR, + video_encode_av1 + ) + } + ( + S::PHYSICAL_DEVICE_VIDEO_ENCODE_RGB_CONVERSION_FEATURES_VALVE, + F::VideoEncodeRgbConversion, + ) => at!( + vk::PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE, + video_encode_rgb_conversion + ), + ( + S::PHYSICAL_DEVICE_VIDEO_ENCODE_INTRA_REFRESH_FEATURES_KHR, + F::VideoEncodeIntraRefresh, + ) => { + at!( + vk::PhysicalDeviceVideoEncodeIntraRefreshFeaturesKHR, + video_encode_intra_refresh + ) + } + ( + S::PHYSICAL_DEVICE_VIDEO_ENCODE_QUANTIZATION_MAP_FEATURES_KHR, + F::VideoEncodeQuantizationMap, + ) => at!( + vk::PhysicalDeviceVideoEncodeQuantizationMapFeaturesKHR, + video_encode_quantization_map + ), + ( + S::PHYSICAL_DEVICE_INTERNALLY_SYNCHRONIZED_QUEUES_FEATURES_KHR, + F::InternallySynchronizedQueues, + ) => at!( + vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR, + internally_synchronized_queues + ), + _ => None, + } +} + +/// The encoder's feature bits merged into a device create info's pNext chain. +/// +/// A bit the chain already has a place for is set there, in place, and put +/// back by [`Self::restore`] once vkCreateDevice has returned: the chain is +/// the application's memory and only borrowed. A bit it has no place for goes +/// in a struct of ours, placed in front of the application's chain so that +/// nothing of theirs has to be relinked. +pub struct FeaturePatch { + restores: Vec<(*mut vk::Bool32, vk::Bool32)>, + /// Our own structs, boxed so their addresses hold while the chain points + /// at them: the Vec moves its elements when it grows, a box does not. + #[allow(clippy::vec_box)] + owned: Vec>, +} + +/// A per-feature struct of ours. One variant per [`Feature`], since each is a +/// distinct Vulkan type. +enum OwnedFeature { + Sync2(vk::PhysicalDeviceSynchronization2Features<'static>), + Timeline(vk::PhysicalDeviceTimelineSemaphoreFeatures<'static>), + Ycbcr(vk::PhysicalDeviceSamplerYcbcrConversionFeatures<'static>), + Ycbcr444(vk::PhysicalDeviceYcbcr2Plane444FormatsFeaturesEXT<'static>), + Av1(vk::PhysicalDeviceVideoEncodeAV1FeaturesKHR<'static>), + Rgb(vk::PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE<'static>), + IntraRefresh(vk::PhysicalDeviceVideoEncodeIntraRefreshFeaturesKHR<'static>), + QpMap(vk::PhysicalDeviceVideoEncodeQuantizationMapFeaturesKHR<'static>), + SharedQueues(vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR<'static>), +} + +impl OwnedFeature { + fn new(feature: Feature) -> Self { + use Feature as F; + match feature { + F::Synchronization2 => Self::Sync2( + vk::PhysicalDeviceSynchronization2Features::default().synchronization2(true), + ), + F::TimelineSemaphore => Self::Timeline( + vk::PhysicalDeviceTimelineSemaphoreFeatures::default().timeline_semaphore(true), + ), + F::SamplerYcbcrConversion => Self::Ycbcr( + vk::PhysicalDeviceSamplerYcbcrConversionFeatures::default() + .sampler_ycbcr_conversion(true), + ), + F::Ycbcr2Plane444Formats => Self::Ycbcr444( + vk::PhysicalDeviceYcbcr2Plane444FormatsFeaturesEXT::default() + .ycbcr2plane444_formats(true), + ), + F::VideoEncodeAv1 => Self::Av1( + vk::PhysicalDeviceVideoEncodeAV1FeaturesKHR::default().video_encode_av1(true), + ), + F::VideoEncodeRgbConversion => Self::Rgb( + vk::PhysicalDeviceVideoEncodeRgbConversionFeaturesVALVE::default() + .video_encode_rgb_conversion(true), + ), + F::VideoEncodeIntraRefresh => Self::IntraRefresh( + vk::PhysicalDeviceVideoEncodeIntraRefreshFeaturesKHR::default() + .video_encode_intra_refresh(true), + ), + F::VideoEncodeQuantizationMap => Self::QpMap( + vk::PhysicalDeviceVideoEncodeQuantizationMapFeaturesKHR::default() + .video_encode_quantization_map(true), + ), + F::InternallySynchronizedQueues => Self::SharedQueues( + vk::PhysicalDeviceInternallySynchronizedQueuesFeaturesKHR::default() + .internally_synchronized_queues(true), + ), + } + } + + fn base(&mut self) -> *mut vk::BaseOutStructure<'static> { + let p: *mut std::ffi::c_void = match self { + Self::Sync2(s) => std::ptr::from_mut(s).cast(), + Self::Timeline(s) => std::ptr::from_mut(s).cast(), + Self::Ycbcr(s) => std::ptr::from_mut(s).cast(), + Self::Ycbcr444(s) => std::ptr::from_mut(s).cast(), + Self::Av1(s) => std::ptr::from_mut(s).cast(), + Self::Rgb(s) => std::ptr::from_mut(s).cast(), + Self::IntraRefresh(s) => std::ptr::from_mut(s).cast(), + Self::QpMap(s) => std::ptr::from_mut(s).cast(), + Self::SharedQueues(s) => std::ptr::from_mut(s).cast(), + }; + p.cast() + } +} + +impl FeaturePatch { + /// Turn on every feature in `wanted` for a device whose create info has + /// `chain` as its pNext. Returns the patch and the pNext the create info + /// should carry instead. + /// + /// # Safety + /// + /// `chain` must be a valid pNext chain, and must stay alive and untouched + /// by anyone else until [`Self::restore`] is called. + pub unsafe fn apply( + chain: *const std::ffi::c_void, + wanted: &[Feature], + ) -> (Self, *const std::ffi::c_void) { + let mut patch = Self { + restores: Vec::new(), + owned: Vec::new(), + }; + let mut missing: Vec = Vec::new(); + + for &feature in wanted { + let mut found = false; + let mut p = chain as *mut vk::BaseOutStructure<'_>; + while !p.is_null() { + let s_type = unsafe { (*p).s_type }; + if let Some(bit) = unsafe { field(s_type, p, feature) } { + patch.restores.push((bit, unsafe { *bit })); + unsafe { *bit = vk::TRUE }; + found = true; + } + p = unsafe { (*p).p_next }; + } + if !found { + missing.push(feature); + } + } + + let mut head = chain; + for feature in missing.into_iter().rev() { + let mut owned = Box::new(OwnedFeature::new(feature)); + let base = owned.base(); + unsafe { (*base).p_next = head as *mut _ }; + head = base as *const _; + patch.owned.push(owned); + } + (patch, head) + } + + /// Put the application's chain back as it was. + /// + /// # Safety + /// + /// The chain given to [`Self::apply`] must still be alive. + pub unsafe fn restore(self) { + for (bit, value) in self.restores.into_iter().rev() { + unsafe { *bit = value }; + } + } +} + +// ── Extensions ──────────────────────────────────────────────────────────────── + +/// `game` with every name in `add` it does not already have, as pointers +/// valid for as long as `add`'s strings are (they are `'static`). +pub fn merged_extensions( + game: &[*const std::ffi::c_char], + add: &[&'static std::ffi::CStr], +) -> Vec<*const std::ffi::c_char> { + let mut out = game.to_vec(); + for name in add { + let present = out + .iter() + .any(|&p| unsafe { std::ffi::CStr::from_ptr(p) } == *name); + if !present { + out.push(name.as_ptr()); + } + } + out +} + +/// Everything the device needs beyond what the game asked for: the pixelforge +/// requirements, and the queue plan that satisfies them. +pub struct Additions { + pub requirements: DeviceRequirements, + pub queues: QueuePlan, +} + +impl Additions { + /// The extensions to add, including the one queue sharing needs. + pub fn extensions(&self) -> Vec<&'static std::ffi::CStr> { + let mut names = self.requirements.extensions.clone(); + if !self.queues.internally_synchronized.is_empty() { + names.push(ash::khr::internally_synchronized_queues::NAME); + } + names + } + + pub fn features(&self) -> Vec { + wanted_features( + &self.requirements.features, + !self.queues.internally_synchronized.is_empty(), + ) + } +} + +// ── Device creation ─────────────────────────────────────────────────────────── + +thread_local! { + /// Set while this layer creates a device of its own, through the loader + /// and so through this very layer. Such a device is the encoder's own and + /// gets nothing added: there is no game on it to share with. + static OWN_DEVICE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Run `f`, which creates a device for the encoder's own use. +pub fn creating_own_device(f: impl FnOnce() -> T) -> T { + OWN_DEVICE.with(|own| own.set(true)); + let out = f(); + OWN_DEVICE.with(|own| own.set(false)); + out +} + +/// Whether the device being created is one this layer asked for itself. +pub fn is_own_device() -> bool { + OWN_DEVICE.with(|own| own.get()) +} + +/// The chain [`instance_view`]'s `ash::Entry` resolves its commands through. +/// +/// `ash::Entry` loads the instance-global commands — `vkCreateInstance`, +/// `vkEnumerateInstanceExtensionProperties` and the rest — by calling +/// `vkGetInstanceProcAddr` with a **null instance**. That is how the loader's +/// own entry point is meant to be called, and the loader answers such calls +/// itself. What we hold inside a layer is not the loader's pointer but the +/// next layer's, and in a well-formed chain a layer's `vkGetInstanceProcAddr` +/// is never called with a null instance — so a layer is under no obligation to +/// survive one, and they do not all survive one. Mesa's +/// `VK_LAYER_MESA_device_select` dereferences the handle to find its own +/// per-instance state and takes the process down with it, which is a segfault +/// in `vkCreateDevice` on any machine that has it installed — every Mesa +/// desktop, so nearly every AMD and Intel one. +/// +/// So the instance we already have is substituted for the null. +/// `vkGetInstanceProcAddr(instance, name)` is valid for a global command and +/// returns the same pointer, and the layer below sees a handle it knows. +static ENTRY_CHAIN: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[derive(Clone, Copy)] +struct EntryChain { + gipa: crate::dispatch::PFN_vkGetInstanceProcAddr, + instance: vk::Instance, +} + +// The handle is an opaque `u64` and the pointer is to code, so the pair is +// shareable; `vk::Instance` is simply not marked so. +unsafe impl Send for EntryChain {} + +fn set_entry_chain(gipa: crate::dispatch::PFN_vkGetInstanceProcAddr, instance: vk::Instance) { + // Last writer wins. A process with two instances resolves the global + // commands through whichever chain prepared a device most recently, which + // is harmless: the four of them are global, so every chain gives the same + // answer. It matters only that the handle passed down belongs to a live + // instance, and the one recorded here is live for as long as the device + // being created on it. + if let Ok(mut chain) = ENTRY_CHAIN.lock() { + *chain = Some(EntryChain { gipa, instance }); + } +} + +/// `vkGetInstanceProcAddr` with the null instance replaced. See [`ENTRY_CHAIN`]. +unsafe extern "system" fn entry_gipa( + instance: vk::Instance, + name: *const std::ffi::c_char, +) -> vk::PFN_vkVoidFunction { + let Some(chain) = ENTRY_CHAIN.lock().ok().and_then(|c| *c) else { + // Nothing has prepared a device, so there is no chain to ask. Reporting + // the command as absent is the honest answer and ash treats it as one. + return None; + }; + let handle = if instance == vk::Instance::null() { + chain.instance + } else { + instance + }; + unsafe { (chain.gipa)(handle, name) } +} + +/// The layer's view of the instance, as the encoder needs it: `ash` objects +/// whose calls go to the next layer down, never back into this one. +/// +/// `vkGetDeviceProcAddr` is the one entry point taken from the device chain +/// rather than asked of the instance one. `ash` resolves every extension's +/// device functions through the instance's copy of it, and inside a layer the +/// instance chain has no usable answer for that name: the result was a null +/// pointer, called the first time anything loaded an extension. +fn instance_view( + istate: &crate::dispatch::NextInstanceFn, + next_gdpa: crate::dispatch::PFN_vkGetDeviceProcAddr, +) -> (ash::Entry, ash::Instance) { + let gipa = istate.get_instance_proc_addr; + // Not `istate.get_instance_proc_addr` directly: see `entry_gipa`. + set_entry_chain(gipa, istate.instance); + let static_fn = ash::StaticFn { + get_instance_proc_addr: entry_gipa, + }; + let entry = unsafe { ash::Entry::from_static_fn(static_fn) }; + let handle = istate.instance; + let instance = unsafe { + ash::Instance::load_with( + |name| { + if name == c"vkGetDeviceProcAddr" { + next_gdpa as *const std::ffi::c_void + } else { + gipa(handle, name.as_ptr()) + .map_or(std::ptr::null(), |f| f as *const std::ffi::c_void) + } + }, + handle, + ) + }; + (entry, instance) +} + +/// The game's device create info with the encoder's additions, and the +/// storage the modified create info points into. +pub struct PreparedDevice { + additions: Additions, + entry: ash::Entry, + instance: ash::Instance, + queue_infos: Vec>, + /// Owns the priority arrays `queue_infos` points into. + _priorities: Vec>, + extensions: Vec<*const std::ffi::c_char>, + patch: FeaturePatch, + p_next: *const std::ffi::c_void, +} + +/// Work out what to add to the game's device so the encoder can run on it. +/// +/// `None`, with the reason logged, when the encoder cannot share this device: +/// the instance is older than Vulkan 1.1, the device cannot encode, or no queue +/// arrangement keeps the encoder's submissions from racing the game's. +/// +/// # Safety +/// +/// `ci` must be the create info the game passed to vkCreateDevice, and its +/// pNext chain must stay alive until [`PreparedDevice::finish`]. +pub unsafe fn prepare( + istate: &crate::dispatch::NextInstanceFn, + next_gdpa: crate::dispatch::PFN_vkGetDeviceProcAddr, + physical_device: vk::PhysicalDevice, + ci: &vk::DeviceCreateInfo<'_>, + extensions: &[*const std::ffi::c_char], +) -> Option { + if is_own_device() { + return None; + } + // The off switch. Changing how a game's device is created is the one + // thing here a title could object to, so it can be turned off per title + // without turning capture off. + if std::env::var("NESCAPTURE_SHARED_DEVICE").as_deref() == Ok("0") { + log::info!("NESCAPTURE_SHARED_DEVICE=0; encoding on a device of its own"); + return None; + } + if istate.api_version < vk::API_VERSION_1_1 { + log::info!("instance asked for Vulkan 1.0; encoding on a device of its own"); + return None; + } + let (entry, instance) = instance_view(istate, next_gdpa); + let requirements = match pixelforge::VideoContextBuilder::new().encode_device_requirements( + &entry, + &instance, + physical_device, + ) { + Ok(r) => r, + Err(e) => { + log::info!("the game's device cannot host the encoder ({e})"); + return None; + } + }; + + let families = unsafe { instance.get_physical_device_queue_family_properties(physical_device) }; + let game_infos: &[vk::DeviceQueueCreateInfo<'_>] = if ci.queue_create_info_count == 0 + || ci.p_queue_create_infos.is_null() + { + &[] + } else { + unsafe { + std::slice::from_raw_parts(ci.p_queue_create_infos, ci.queue_create_info_count as usize) + } + }; + let game: BTreeMap = game_infos + .iter() + .map(|q| (q.queue_family_index, (q.queue_count, q.flags))) + .collect(); + + let Some(queues) = plan_queues( + &families, + &game, + requirements.queues.encode, + requirements.internally_synchronized_queues, + ) else { + log::info!( + "no queue for the encoder that the game does not submit to; \ + encoding on a device of its own" + ); + return None; + }; + log::info!( + "encoder on the game's device: encode queue {:?}, compute queue {:?}, transfer queue {:?}{}", + queues.encode, + queues.compute, + queues.transfer, + if queues.internally_synchronized.is_empty() { + String::new() + } else { + format!( + ", sharing the game's queues in families {:?}", + queues.internally_synchronized + ) + } + ); + + // The game's queue create infos, copied, with counts raised and flags added + // where the plan says. Families it did not ask for get an entry of ours. + let mut priorities: Vec> = Vec::new(); + let mut queue_infos: Vec> = Vec::new(); + for q in game_infos { + let mut info: vk::DeviceQueueCreateInfo<'static> = unsafe { std::mem::transmute(*q) }; + if let Some(&count) = queues.counts.get(&q.queue_family_index) { + let theirs = + unsafe { std::slice::from_raw_parts(q.p_queue_priorities, q.queue_count as usize) }; + // The encoder's queue at the game's own priority, so neither side + // gets to starve the other. + let extra = theirs.first().copied().unwrap_or(1.0); + let mut all = theirs.to_vec(); + all.resize(count as usize, extra); + info.queue_count = count; + info.p_queue_priorities = all.as_ptr(); + priorities.push(all); + } + if queues + .internally_synchronized + .contains(&q.queue_family_index) + { + info.flags |= vk::DeviceQueueCreateFlags::INTERNALLY_SYNCHRONIZED_KHR; + } + queue_infos.push(info); + } + for (&family, &count) in &queues.counts { + if game.contains_key(&family) { + continue; + } + let all = vec![1.0f32; count as usize]; + queue_infos.push(vk::DeviceQueueCreateInfo { + queue_family_index: family, + queue_count: count, + p_queue_priorities: all.as_ptr(), + ..Default::default() + }); + priorities.push(all); + } + + let additions = Additions { + requirements, + queues, + }; + let extensions = merged_extensions(extensions, &additions.extensions()); + let (patch, p_next) = unsafe { FeaturePatch::apply(ci.p_next, &additions.features()) }; + + Some(PreparedDevice { + additions, + entry, + instance, + queue_infos, + _priorities: priorities, + extensions, + patch, + p_next, + }) +} + +impl PreparedDevice { + /// `ci` with the additions in. Valid while `self` is. + pub fn create_info<'a>(&'a self, ci: &vk::DeviceCreateInfo<'a>) -> vk::DeviceCreateInfo<'a> { + let mut out = *ci; + out.p_next = self.p_next; + out.queue_create_info_count = self.queue_infos.len() as u32; + out.p_queue_create_infos = self.queue_infos.as_ptr().cast(); + out.enabled_extension_count = self.extensions.len() as u32; + out.pp_enabled_extension_names = self.extensions.as_ptr(); + out + } + + /// Put the game's pNext chain back as it was, and return what is needed + /// to hand the device to the encoder once it exists. + /// + /// # Safety + /// + /// The game's pNext chain must still be alive. + pub unsafe fn finish(self) -> (Additions, ash::Entry, ash::Instance) { + unsafe { self.patch.restore() }; + (self.additions, self.entry, self.instance) + } +} + +/// The game's device, as the encoder sees it. +pub struct SharedDevice { + entry: ash::Entry, + instance: ash::Instance, + physical_device: vk::PhysicalDevice, + device: ash::Device, + /// Timeline semaphore queries through their KHR names, which a device + /// created below Vulkan 1.2 still has: the encoder enabled the extension. + timeline: ash::khr::timeline_semaphore::Device, + pub queues: QueuePlan, +} + +impl SharedDevice { + /// Wrap the game's freshly created device for the encoder. + /// + /// The device's calls go to the next layer down, with two exceptions that + /// come back to this layer. `vkGetDeviceQueue`, because a queue created + /// internally synchronized can only be fetched with vkGetDeviceQueue2, and + /// the hook is what translates, for the encoder exactly as for the game. + /// And `vkAllocateCommandBuffers`, because the encoder's command buffers + /// never pass through the loader and have to be given its dispatch data by + /// hand; see [`crate::device::stamp`]. The hooked `vkGetDeviceQueue` does + /// the same for the encoder's queues. + /// + /// # Safety + /// + /// `device` must have been created from a [`PreparedDevice`] for the same + /// instance, and `next_gdpa` must be the next layer's vkGetDeviceProcAddr. + pub unsafe fn adopt( + additions: Additions, + entry: ash::Entry, + instance: ash::Instance, + physical_device: vk::PhysicalDevice, + device: vk::Device, + next_gdpa: crate::dispatch::PFN_vkGetDeviceProcAddr, + ) -> Self { + let device = unsafe { + ash::Device::load_with( + |name| { + if name == c"vkGetDeviceQueue" { + crate::device::vkGetDeviceQueue as *const std::ffi::c_void + } else if name == c"vkAllocateCommandBuffers" { + crate::device::encoder_allocate_command_buffers as *const std::ffi::c_void + } else { + next_gdpa(device, name.as_ptr()) + .map_or(std::ptr::null(), |f| f as *const std::ffi::c_void) + } + }, + device, + ) + }; + let timeline = ash::khr::timeline_semaphore::Device::load(&instance, &device); + Self { + entry, + instance, + physical_device, + device, + timeline, + queues: additions.queues, + } + } + + /// A new timeline semaphore at zero. + pub fn create_timeline(&self) -> Option { + let mut kind = vk::SemaphoreTypeCreateInfo::default() + .semaphore_type(vk::SemaphoreType::TIMELINE) + .initial_value(0); + let info = vk::SemaphoreCreateInfo::default().push(&mut kind); + unsafe { self.device.create_semaphore(&info, None) }.ok() + } + + pub fn destroy_timeline(&self, semaphore: vk::Semaphore) { + unsafe { self.device.destroy_semaphore(semaphore, None) }; + } + + /// The value `semaphore` has reached, without waiting. + pub fn counter(&self, semaphore: vk::Semaphore) -> Option { + unsafe { self.timeline.get_semaphore_counter_value(semaphore) }.ok() + } + + /// Whether `point` has been reached, without waiting. + pub fn reached(&self, point: pixelforge::TimelinePoint) -> bool { + unsafe { self.timeline.get_semaphore_counter_value(point.semaphore) } + .is_ok_and(|v| v >= point.value) + } + + /// Wait up to `timeout` for `point`. Whether it was reached. + pub fn wait(&self, point: pixelforge::TimelinePoint, timeout: std::time::Duration) -> bool { + let semaphores = [point.semaphore]; + let values = [point.value]; + let info = vk::SemaphoreWaitInfo::default() + .semaphores(&semaphores) + .values(&values); + unsafe { + self.timeline + .wait_semaphores(&info, timeout.as_nanos() as u64) + } + .is_ok() + } + + /// A pixelforge context on the game's device, submitting to the queues + /// the plan set aside. + pub fn video_context(&self) -> Result { + pixelforge::VideoContextBuilder::new() + .app_name("nescapture") + .with_encode_queue(self.queues.encode) + .with_compute_queue(self.queues.compute) + .with_transfer_queue(self.queues.transfer) + .build_from_existing_encode( + self.entry.clone(), + self.instance.clone(), + self.physical_device, + self.device.clone(), + ) + } + + /// The queue families that touch a capture image: the one the game + /// presents on, where the blit runs, and the encoder's. + pub fn image_families(&self, present_family: u32) -> Vec { + let mut out = vec![present_family]; + for f in [self.queues.compute.family, self.queues.transfer.family] { + if !out.contains(&f) { + out.push(f); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn family(flags: vk::QueueFlags, count: u32) -> vk::QueueFamilyProperties { + vk::QueueFamilyProperties { + queue_flags: flags, + queue_count: count, + ..Default::default() + } + } + + const GFX: vk::QueueFlags = vk::QueueFlags::from_raw( + vk::QueueFlags::GRAPHICS.as_raw() + | vk::QueueFlags::COMPUTE.as_raw() + | vk::QueueFlags::TRANSFER.as_raw(), + ); + const COMPUTE: vk::QueueFlags = vk::QueueFlags::from_raw( + vk::QueueFlags::COMPUTE.as_raw() | vk::QueueFlags::TRANSFER.as_raw(), + ); + const ENCODE: vk::QueueFlags = vk::QueueFlags::VIDEO_ENCODE_KHR; + + fn game(entries: &[(u32, u32)]) -> BTreeMap { + entries + .iter() + .map(|&(f, n)| (f, (n, vk::DeviceQueueCreateFlags::empty()))) + .collect() + } + + /// An AMD card: one graphics queue, four compute, one encode. + fn amd() -> Vec { + vec![family(GFX, 1), family(COMPUTE, 4), family(ENCODE, 1)] + } + + #[test] + fn spare_queues_keep_the_encoder_off_the_games_queue() { + let plan = plan_queues(&amd(), &game(&[(0, 1)]), Some(2), false).unwrap(); + assert_eq!(plan.encode, DeviceQueue::new(2, 0)); + // Compute on the compute family, not on the game's graphics queue. + assert_eq!(plan.compute, DeviceQueue::new(1, 0)); + // And copies share it: one thread submits both. + assert_eq!(plan.transfer, plan.compute); + assert!(plan.internally_synchronized.is_empty()); + } + + #[test] + fn a_family_the_game_already_uses_gets_one_more_queue() { + // The game took two of the four compute queues for itself. + let plan = plan_queues(&amd(), &game(&[(0, 1), (1, 2)]), Some(2), false).unwrap(); + assert_eq!(plan.compute, DeviceQueue::new(1, 2)); + assert_eq!(plan.counts.get(&1), Some(&3)); + } + + #[test] + fn with_every_capable_family_full_nothing_is_planned_unless_sharing_is_safe() { + // The game took all four compute queues and its one graphics queue. + let full = game(&[(0, 1), (1, 4)]); + assert_eq!(plan_queues(&amd(), &full, Some(2), false), None); + + // Sharing picks the compute family over the graphics one, since the + // game renders on the latter. + let plan = plan_queues(&amd(), &full, Some(2), true).unwrap(); + assert_eq!(plan.compute, DeviceQueue::new(1, 0)); + assert_eq!(plan.internally_synchronized, vec![1]); + } + + /// An Intel card: one queue that does everything, and a video family. + fn intel() -> Vec { + vec![ + family(GFX, 1), + family( + vk::QueueFlags::VIDEO_ENCODE_KHR | vk::QueueFlags::VIDEO_DECODE_KHR, + 2, + ), + ] + } + + #[test] + fn without_a_spare_the_games_queue_is_shared_where_that_is_safe() { + let plan = plan_queues(&intel(), &game(&[(0, 1)]), Some(1), true).unwrap(); + assert_eq!(plan.encode, DeviceQueue::new(1, 0)); + assert_eq!(plan.compute, DeviceQueue::new(0, 0)); + assert_eq!(plan.transfer, DeviceQueue::new(0, 0)); + assert_eq!(plan.internally_synchronized, vec![0]); + } + + #[test] + fn without_a_spare_or_a_safe_share_there_is_no_plan() { + assert_eq!( + plan_queues(&intel(), &game(&[(0, 1)]), Some(1), false), + None + ); + } + + #[test] + fn a_protected_queue_is_never_shared() { + let mut g = game(&[(0, 1)]); + g.insert(0, (1, vk::DeviceQueueCreateFlags::PROTECTED)); + assert_eq!(plan_queues(&intel(), &g, Some(1), true), None); + } + + #[test] + fn a_game_that_already_encodes_keeps_its_encode_queue() { + let mut families = amd(); + families[2].queue_count = 2; + let plan = plan_queues(&families, &game(&[(0, 1), (2, 1)]), Some(2), false).unwrap(); + assert_eq!(plan.encode, DeviceQueue::new(2, 1)); + } + + #[test] + fn features_are_set_where_the_game_already_chains_them() { + let mut v13 = vk::PhysicalDeviceVulkan13Features::default(); + let mut v12 = vk::PhysicalDeviceVulkan12Features::default(); + v13.p_next = (&raw mut v12).cast(); + let chain = (&raw const v13).cast(); + + let (patch, head) = unsafe { + FeaturePatch::apply( + chain, + &[Feature::Synchronization2, Feature::TimelineSemaphore], + ) + }; + // Nothing appended: both have a home in the game's structs, and a + // per-feature struct next to its aggregate is invalid. + assert_eq!(head, chain); + assert_eq!(v13.synchronization2, vk::TRUE); + assert_eq!(v12.timeline_semaphore, vk::TRUE); + + unsafe { patch.restore() }; + assert_eq!(v13.synchronization2, vk::FALSE); + assert_eq!(v12.timeline_semaphore, vk::FALSE); + } + + #[test] + fn a_feature_with_no_home_gets_a_struct_in_front_of_the_chain() { + let v12 = vk::PhysicalDeviceVulkan12Features::default(); + let chain = (&raw const v12).cast(); + let (patch, head) = unsafe { FeaturePatch::apply(chain, &[Feature::Synchronization2]) }; + assert_ne!(head, chain); + let first = head as *const vk::BaseInStructure<'_>; + unsafe { + assert_eq!( + (*first).s_type, + vk::StructureType::PHYSICAL_DEVICE_SYNCHRONIZATION_2_FEATURES + ); + assert_eq!((*first).p_next.cast(), chain); + } + unsafe { patch.restore() }; + } + + #[test] + fn an_empty_chain_gets_every_struct() { + let wanted = [ + Feature::Synchronization2, + Feature::TimelineSemaphore, + Feature::VideoEncodeAv1, + ]; + let (patch, head) = unsafe { FeaturePatch::apply(std::ptr::null(), &wanted) }; + let mut seen = Vec::new(); + let mut p = head as *const vk::BaseInStructure<'_>; + while !p.is_null() { + seen.push(unsafe { (*p).s_type }); + p = unsafe { (*p).p_next }; + } + assert_eq!( + seen, + vec![ + vk::StructureType::PHYSICAL_DEVICE_SYNCHRONIZATION_2_FEATURES, + vk::StructureType::PHYSICAL_DEVICE_TIMELINE_SEMAPHORE_FEATURES, + vk::StructureType::PHYSICAL_DEVICE_VIDEO_ENCODE_AV1_FEATURES_KHR, + ] + ); + unsafe { patch.restore() }; + } + + #[test] + fn an_extension_the_game_already_enables_is_not_repeated() { + let game = [ash::khr::synchronization2::NAME.as_ptr()]; + let merged = merged_extensions( + &game, + &[ + ash::khr::synchronization2::NAME, + ash::khr::video_queue::NAME, + ], + ); + assert_eq!(merged.len(), 2); + } + + // ── The null instance a layer below need not survive ───────────────────── + + /// On construction `ash::Entry` asks for the instance-global commands with + /// no instance. The layer below must never see that null: Mesa's + /// device_select layer dereferences it. A real instance goes down instead, + /// and an instance the caller did name is left alone. + /// + /// One test rather than two because the chain is process-wide, and two + /// would race each other for it. + #[test] + fn the_null_instance_never_reaches_the_layer_below() { + use ash::vk::Handle; + use std::sync::atomic::{AtomicU64, Ordering}; + static SEEN: AtomicU64 = AtomicU64::new(u64::MAX); + + unsafe extern "system" fn record( + instance: vk::Instance, + _name: *const std::ffi::c_char, + ) -> vk::PFN_vkVoidFunction { + SEEN.store(instance.as_raw(), Ordering::SeqCst); + None + } + + set_entry_chain(record, vk::Instance::from_raw(0xfeed_beef)); + + unsafe { + entry_gipa( + vk::Instance::null(), + c"vkEnumerateInstanceExtensionProperties".as_ptr(), + ) + }; + assert_eq!( + SEEN.load(Ordering::SeqCst), + 0xfeed_beef, + "the null was passed down instead of the instance we hold" + ); + + unsafe { entry_gipa(vk::Instance::from_raw(0x2222), c"vkCreateDevice".as_ptr()) }; + assert_eq!( + SEEN.load(Ordering::SeqCst), + 0x2222, + "an instance the caller named was substituted" + ); + } +} diff --git a/apps/nescapture/src/slots.rs b/apps/nescapture/src/slots.rs index 8be080e7..cf2aa96f 100644 --- a/apps/nescapture/src/slots.rs +++ b/apps/nescapture/src/slots.rs @@ -2,7 +2,7 @@ // slots.rs — ownership of the capture ring's destination buffers // // A captured frame travels from the present hook, through the capture worker, -// into the encoder thread, and its DMA-BUF must not be written again until the +// into the encoder thread, and its image must not be written again until the // encoder has finished reading it. Tracking that by hand across three threads // is how the single-buffer version got it wrong. Instead the slot index is // carried by a guard that returns it to the pool when it drops, wherever that diff --git a/apps/nescapture/src/state.rs b/apps/nescapture/src/state.rs index 7bc607d7..b720cdab 100644 --- a/apps/nescapture/src/state.rs +++ b/apps/nescapture/src/state.rs @@ -25,21 +25,10 @@ pub const CAPTURE_SLOTS: usize = 4; pub struct CaptureSlot { pub image: vk::Image, pub memory: vk::DeviceMemory, - /// Exported once at allocation and duplicated per frame. -1 if the export - /// failed, which sends that frame down the CPU readback path instead. - pub dmabuf_fd: std::os::raw::c_int, - pub stride: u32, - /// The DRM format modifier the driver gave this slot's image. - /// - /// Carried per slot rather than assumed, and passed to the importer, which - /// creates its side with this exact value. It used to be hard-coded to - /// `DRM_FORMAT_MOD_LINEAR` on both sides — true at the time, because the - /// producer only ever asked for linear. - pub modifier: u64, /// Signalled when this slot's blit has finished reading the swapchain and - /// writing the slot. The capture worker waits on it before handing the - /// DMA-BUF to the encoder, which reads it from a different VkDevice and so - /// cannot be synchronised with a semaphore. + /// writing the slot. Where the encoder has a device of its own, the encoder + /// thread waits on it before reading the slot back on the CPU; a device of + /// its own shares no timeline with the game's. pub fence: vk::Fence, } @@ -75,12 +64,14 @@ pub struct CaptureRing { pub timestamp_period: f32, /// The extent `blits` were recorded for. /// - /// The ring is kept when the swapchain shrinks — `ensure_capture_ring` - /// accepts a ring at least as large as the request — so the extent can - /// change under a ring that is not rebuilt, and the recordings have to - /// follow it even though the images do not. + /// Kept separate from `size` because a swapchain can be recreated at the + /// same extent — on a format or present-mode change — which invalidates the + /// recordings without invalidating the images. pub blit_extent: vk::Extent2D, pub size: (u32, u32, vk::Format), + /// Which ring this is, counting from the device's first. For the log, so a + /// resize shows up as the ring it caused. + pub generation: u64, /// Queue family the command pool was created for. Command buffers may only /// be submitted to a queue of the family their pool belongs to, so a /// present arriving on a different family rebuilds the ring rather than @@ -102,6 +93,15 @@ pub struct CaptureRing { /// cannot reacquire it until the present that waited on this semaphore is /// done. pub present_wait: Vec, + /// Timeline semaphore every blit signals, one value higher each time, or + /// null when the encoder does not share this device. + /// + /// On a shared device this is the whole handover: the frame carries the + /// value its blit signals, and the encoder's GPU work waits on it. Nothing + /// waits on the CPU. + pub blit_timeline: vk::Semaphore, + /// The value the last blit signalled. + pub blit_value: u64, } /// Index into [`CaptureRing::blits`] for one (swapchain image, slot) pair. @@ -177,6 +177,17 @@ pub struct DeviceState { pub raw: vk::Device, pub physical_device: vk::PhysicalDevice, pub fp: NextDeviceFn, + /// The encoder's view of this device, when it runs on it. `None` when the + /// device could not be created with what the encoder needs, in which case + /// the encoder uses a device of its own. + pub shared: Option, + /// Whether the encode pipeline really runs on `shared`. Set once the + /// pipeline has been built on it; until then, and for good if that fails, + /// capture works as it does for a device of the encoder's own. + pub shared_active: std::sync::atomic::AtomicBool, + /// The loader's callback for stamping dispatchable objects this layer + /// creates itself. See [`crate::device::stamp`]. + pub set_loader_data: Option, // Phase 1: shader / pipeline pub shader_registry: DashMap, @@ -197,7 +208,7 @@ pub struct DeviceState { pub hudless_memory: std::sync::Mutex>, pub hudless_size: std::sync::Mutex<(u32, u32, vk::Format)>, - // Phase 4: final-frame capture (DMA-BUF exportable) + // Phase 4: final-frame capture pub capture_ring: std::sync::Mutex>, /// Which ring slots are free. Held separately from the ring itself so a /// slot can be returned from the encoder thread without taking the lock @@ -219,6 +230,9 @@ pub struct DeviceState { pub swapchain_transfer_src: std::sync::atomic::AtomicBool, pub frame_counter: std::sync::atomic::AtomicU64, + /// Rings built for this device so far. Names the current one; see + /// [`CaptureRing::generation`]. + pub ring_generation: std::sync::atomic::AtomicU64, // Phase 3/4: per-frame HUD detection flags pub hud_detected_frame: std::sync::atomic::AtomicBool, @@ -229,7 +243,6 @@ pub struct DeviceState { // Phase 7: encode + IPC pipeline (lazy-init on first frame) pub encoder: std::sync::Mutex>, - // ── Frame-rate throttle ─────────────────────────────────────────── /// Decides which presented frames are worth capturing. Consulted in the /// present hook, before any GPU work is queued, so a dropped frame costs @@ -252,6 +265,16 @@ pub struct DeviceState { pub encoder_starting: std::sync::atomic::AtomicBool, } +impl DeviceState { + /// The shared device, when the encoder is actually running on it. + pub fn shared_encoder(&self) -> Option<&crate::shared::SharedDevice> { + self.shared.as_ref().filter(|_| { + self.shared_active + .load(std::sync::atomic::Ordering::Acquire) + }) + } +} + // ── Per-command-buffer state ────────────────────────────────────────────────── #[derive(Default, Clone)] diff --git a/apps/nescapture/src/swapchain.rs b/apps/nescapture/src/swapchain.rs index 5c83c3e6..42986a52 100644 --- a/apps/nescapture/src/swapchain.rs +++ b/apps/nescapture/src/swapchain.rs @@ -125,6 +125,13 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR( vk::Result::SUCCESS } +/// Whether `swapchain` is the one this layer tracks, which is the one most +/// recently created. A swapchain retired through `oldSwapchain` stays valid +/// until destroyed, and calls on it must not touch the tracked state. +pub fn is_current(tracked: Option, swapchain: vk::SwapchainKHR) -> bool { + swapchain != vk::SwapchainKHR::null() && tracked == Some(swapchain) +} + #[unsafe(no_mangle)] pub unsafe extern "system" fn vkDestroySwapchainKHR( device: vk::Device, @@ -133,8 +140,17 @@ pub unsafe extern "system" fn vkDestroySwapchainKHR( ) { let key = unsafe { dispatch_key(device.as_raw() as *const c_void) }; if let Some(ds) = DEVICE_STATE.get(&key) { - *ds.swapchain.lock().unwrap() = None; - *ds.swapchain_images.lock().unwrap() = Vec::new(); + // Only the swapchain being tracked. A game recreating through + // `oldSwapchain` destroys the retired one after its replacement is + // created and its images fetched, and clearing then would leave the + // live swapchain with no images: every present after would skip + // capture, silently, until the next recreation. + let mut current = ds.swapchain.lock().unwrap(); + if is_current(*current, swapchain) { + *current = None; + *ds.swapchain_images.lock().unwrap() = Vec::new(); + } + drop(current); if let Some(destroy_fn) = ds.fp.destroy_swapchain_khr { unsafe { destroy_fn(device, swapchain, p_allocator) }; } @@ -171,7 +187,8 @@ pub unsafe extern "system" fn vkGetSwapchainImagesKHR( return result; } - if !p_swapchain_images.is_null() { + // Images of a retired swapchain are not the ones capture reads. + if !p_swapchain_images.is_null() && is_current(*ds.swapchain.lock().unwrap(), swapchain) { let count = unsafe { *p_swapchain_image_count as usize }; let images = unsafe { std::slice::from_raw_parts(p_swapchain_images, count) }; *ds.swapchain_images.lock().unwrap() = images.to_vec(); @@ -231,9 +248,11 @@ pub unsafe extern "system" fn vkAcquireNextImageKHR( return vk::Result::ERROR_EXTENSION_NOT_PRESENT; }; + crate::present::note_present(&ds, crate::encode::PresentStep::Acquiring); let started = std::time::Instant::now(); let result = unsafe { acquire(device, swapchain, timeout, semaphore, fence, p_image_index) }; record_acquire(&ds, started.elapsed()); + crate::present::note_present(&ds, crate::encode::PresentStep::InGame); result } @@ -250,8 +269,37 @@ pub unsafe extern "system" fn vkAcquireNextImage2KHR( return vk::Result::ERROR_EXTENSION_NOT_PRESENT; }; + crate::present::note_present(&ds, crate::encode::PresentStep::Acquiring); let started = std::time::Instant::now(); let result = unsafe { acquire(device, p_acquire_info, p_image_index) }; record_acquire(&ds, started.elapsed()); + crate::present::note_present(&ds, crate::encode::PresentStep::InGame); result } + +#[cfg(test)] +mod tests { + use super::*; + + fn sc(raw: u64) -> vk::SwapchainKHR { + vk::SwapchainKHR::from_raw(raw) + } + + #[test] + fn the_tracked_swapchain_is_current() { + assert!(is_current(Some(sc(2)), sc(2))); + } + + #[test] + fn a_retired_swapchain_is_not() { + // Created 2 with oldSwapchain = 1; destroying 1 afterwards must leave + // 2's state alone. + assert!(!is_current(Some(sc(2)), sc(1))); + } + + #[test] + fn nothing_is_current_once_the_tracked_one_is_gone() { + assert!(!is_current(None, sc(1))); + assert!(!is_current(None, vk::SwapchainKHR::null())); + } +} diff --git a/apps/nescope/Cargo.toml b/apps/nescope/Cargo.toml index 9afca051..ae0f2542 100644 --- a/apps/nescope/Cargo.toml +++ b/apps/nescope/Cargo.toml @@ -28,8 +28,6 @@ smithay = { version = "0.7", default-features = false, features = [ # 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 diff --git a/apps/nescope/build.rs b/apps/nescope/build.rs deleted file mode 100644 index 719d740f..00000000 --- a/apps/nescope/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("cargo:rerun-if-changed=src/protocols/gamescope-swapchain.xml"); -} diff --git a/apps/nescope/src/handlers.rs b/apps/nescope/src/handlers.rs index 9d08547a..135e8776 100644 --- a/apps/nescope/src/handlers.rs +++ b/apps/nescope/src/handlers.rs @@ -11,6 +11,7 @@ use smithay::desktop::Window; use smithay::input::pointer::{CursorImageStatus, PointerHandle}; use smithay::input::{Seat, SeatHandler, SeatState}; use smithay::output::Output; +use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; 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; @@ -119,7 +120,6 @@ impl CompositorHandler for NescopeState { fn destroyed(&mut self, surface: &WlSurface) { self.hdr.surface_destroyed(surface); - self.vulkan_surfaces.remove(surface); } } @@ -138,7 +138,7 @@ impl DmabufHandler for NescopeState { _dmabuf: Dmabuf, notifier: ImportNotifier, ) { - // Accept unconditionally — libhudless reads buffers + // Accept unconditionally — the nescapture layer reads buffers // directly from the game's Vulkan queue; nescope doesn't need to. let _ = notifier.successful::(); } @@ -187,6 +187,29 @@ impl XdgShellHandler for NescopeState { self.determine_and_apply_focus(); } + // Granted, not merely acknowledged. The default answers with a configure + // that lacks the fullscreen state, which a client reads as a refusal: Wine + // then asks again on every window update and never treats its window as + // fullscreen, so a game switching to exclusive fullscreen -- Control does + // this on leaving its title screen -- stalls in the transition and stays + // where it was. Wine also scales an emulated display mode up to the output + // only for a fullscreen window. + fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option) { + surface.with_pending_state(|state| { + state.states.set(xdg_toplevel::State::Fullscreen); + state.size = Some((self.width as i32, self.height as i32).into()); + }); + surface.send_configure(); + } + + // Still the size of the output: there is nowhere else for a window to be. + fn unfullscreen_request(&mut self, surface: ToplevelSurface) { + surface.with_pending_state(|state| { + state.states.unset(xdg_toplevel::State::Fullscreen); + }); + surface.send_configure(); + } + fn new_popup(&mut self, _: PopupSurface, _: PositionerState) {} fn grab(&mut self, _: PopupSurface, _: WlSeat, _: Serial) {} fn reposition_request(&mut self, _: PopupSurface, _: PositionerState, _: u32) {} diff --git a/apps/nescope/src/hdr.rs b/apps/nescope/src/hdr.rs index 87b9c3b3..146c8594 100644 --- a/apps/nescope/src/hdr.rs +++ b/apps/nescope/src/hdr.rs @@ -30,33 +30,6 @@ //! advertised the opaque FourCC spellings alongside the alpha ones. See the //! list in `state.rs`, which is where that constraint lives. //! -//! # `gamescope_swapchain_factory_v2` is the legacy route, and stays off -//! -//! Also implemented here, because it costs little and a host may deliberately -//! want it. It predates Wayland colour management and works the other way -//! round: a WSI layer inside the game's process appends HDR colour spaces Mesa -//! never offered, rewrites `imageColorSpace` to `SRGB_NONLINEAR` so the driver -//! is never told HDR is happening, and reports the real colour space to the -//! compositor over this protocol instead. -//! -//! It is not how we do HDR, for three reasons that all point the same way: -//! -//! - It needs a Vulkan layer this tree does not ship. Verified working with -//! gamescope's own, unmodified -- the XML here is byte-identical to theirs, -//! and the atoms written in `state.rs` are what it reads. -//! - It only helps the XWayland path, which is the path without HDR anyway. -//! - **Capture reads the colour space it hides.** A game asking for HDR10 -//! through it has its ten-bit PQ samples encoded and tagged BT.709 SDR, at -//! full frame rate, decoding cleanly. Recorded where that value is read, in -//! the capture layer's swapchain hook. -//! -//! That last one makes enabling it worse than leaving it off: it trades no HDR -//! for wrong HDR. So `GAMESCOPE_WAYLAND_DISPLAY` is set for the child but -//! `ENABLE_GAMESCOPE_WSI` deliberately is not, which leaves the layer inert -//! unless someone opts in. If anyone ever does want this path, the colour space -//! it reports arrives here and the capture layer cannot see it, so it would -//! need a channel from this process to that one. -//! //! # What HDR does not cover //! //! A game that cannot be a Wayland client gets SDR, and XWayland is off by @@ -66,18 +39,23 @@ //! without HDR: Mesa offers no HDR colour space on the XWayland surface, and //! nothing in this module can change that. //! -//! Still unexercised: no game has run, and the scRGB/FP16 arm has had no pixels -//! through it -- only HDR10 PQ. +//! Exercised end to end since: Control and Cyberpunk 2077, through both the +//! HDR10 PQ and the scRGB arms, with the colour read back out of the stream on +//! the far side to check it arrived as what was sent. //! -//! # Signalling paths, for reference +//! # One signalling path //! -//! Both feed [`HdrState`], which tracks the colour space the active surface has -//! declared. This module never converts anything itself. +//! `wp_color_manager_v1` feeds [`HdrState`], which tracks what the active +//! surface has declared. This module never converts anything itself. //! -//! 1. **`wp_color_manager_v1`** -- the standard protocol, and the live one. -//! 2. **`gamescope_swapchain_factory_v2`** -- the legacy route described above, -//! reachable only if a WSI layer is present and opted into. -#![allow(unused)] +//! There used to be a second: gamescope's `swapchain_factory_v2`, where a WSI +//! layer inside the game rewrote `imageColorSpace` to `SRGB_NONLINEAR` so the +//! driver was never told HDR was happening, and reported the real colour space +//! here instead. It needed a Vulkan layer this tree does not ship, only helped +//! the XWayland path, and hid the colour space from capture -- which reads the +//! swapchain, so a game asking for HDR10 through it had ten-bit PQ samples +//! encoded and tagged BT.709. It was never enabled, and now that colour +//! management carries this properly it is gone rather than left inert. use std::collections::HashMap; use std::sync::Mutex; @@ -94,10 +72,6 @@ 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; // --------------------------------------------------------------------------- @@ -105,6 +79,10 @@ use crate::state::NescopeState; // --------------------------------------------------------------------------- /// Simplified color space used by the external capture library. +/// Where capture listens. Fixed rather than configurable: both ends are ours, +/// and a mismatch would be silent. +const CAPTURE_SOCKET: &str = "/tmp/nescapture-cmd.sock"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ColorSpace { /// BT.709 primaries, sRGB EOTF. @@ -117,6 +95,14 @@ pub enum ColorSpace { pub enum TransferFunction { Gamma22, St2084Pq, + /// Extended-range linear light, which is what scRGB is. + /// + /// Needed because it is the transfer a Windows title asks for when it + /// turns HDR on: DXGI's HDR path is scRGB in FP16, and DXVK maps that to + /// `VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT`. Mesa only offers that colour + /// space when the compositor names this transfer, so a compositor that + /// does not is one where HDR silently does not happen. + ExtLinear, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -125,6 +111,97 @@ pub enum Primaries { Bt2020, } +/// How bright the session says it can go. +/// +/// A game asks the display what it can do before deciding how to render. Under +/// wine that question reaches DXGI, DXGI asks the Wayland driver, and the +/// driver asks the compositor -- so it ends here. An HDR output that answers +/// with its primaries and transfer function and nothing about luminance tells +/// a title only that HDR exists, and a title that cannot find out how bright +/// the display goes renders as though it does not go far: exactly the flat, +/// SDR-bright picture this was measured producing. +/// +/// These are the session's numbers, not a panel's. nescope drives a video +/// stream whose real display is on the other end of a network and is not +/// knowable here, so the defaults describe an ordinary HDR display and +/// `NESCOPE_HDR_MAX_NITS` / `NESCOPE_HDR_REFERENCE_NITS` exist for a person +/// who knows better than the default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HdrTarget { + /// Peak luminance, cd/m². + pub max_nits: u32, + /// Peak luminance sustained over a whole frame, cd/m². Real displays + /// cannot hold their peak across the panel, and a title that plans its + /// tone mapping around the small-area peak alone gets it wrong. + pub max_fall_nits: u32, + /// Reference white -- what diffuse white renders at. BT.2408 says 203, + /// and it is the number a compositor maps its own SDR white onto. + pub reference_nits: u32, + /// Black level, in units of 0.0001 cd/m², as the protocol carries it. + pub min_lum: u32, +} + +impl Default for HdrTarget { + fn default() -> Self { + Self { + // The protocol's own default for PQ, and comfortably above the + // 250 nits below which DXVK reads a reported peak as "the driver + // did not fill this in". + max_nits: 1000, + max_fall_nits: 600, + reference_nits: 203, + // 0.005 cd/m², which is what the protocol documents as the PQ + // primary colour volume's minimum. + min_lum: 50, + } + } +} + +impl HdrTarget { + /// Read the overrides, falling back to the default for anything absent or + /// unreadable rather than refusing to start over a stray environment + /// variable. + pub fn from_env() -> Self { + let read = |key: &str, fallback: u32| { + std::env::var(key) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(fallback) + }; + let default = Self::default(); + Self { + max_nits: read("NESCOPE_HDR_MAX_NITS", default.max_nits), + max_fall_nits: read("NESCOPE_HDR_MAX_FALL_NITS", default.max_fall_nits), + reference_nits: read("NESCOPE_HDR_REFERENCE_NITS", default.reference_nits), + min_lum: default.min_lum, + } + .clamped() + } + + /// Put an override back inside what a display can be. + /// + /// No panel sustains its small-area peak across the whole frame, so a + /// full-frame luminance above the peak describes nothing. Clamped rather + /// than refused: the person asked for a bright display and got the + /// brightest coherent one. + pub fn clamped(self) -> Self { + Self { + max_fall_nits: self.max_fall_nits.min(self.max_nits), + ..self + } + } + + /// Whether this describes a volume at all. + /// + /// The protocol raises `invalid_luminance` on a range that does not go + /// upwards, and the compositor is the one sending it here, so a bad + /// override must be dropped rather than passed on. + pub fn is_usable(&self) -> bool { + self.max_nits as f64 > self.min_lum as f64 / 10_000.0 && self.max_nits > 0 + } +} + /// A resolved per-surface color / HDR description. #[derive(Debug, Clone, Copy)] pub struct ImageDescription { @@ -133,8 +210,28 @@ pub struct ImageDescription { pub max_cll: Option, pub max_fall: Option, pub mastering_luminance: Option<(u32, u32)>, + /// The gamut and white point of the display the content was graded on. + /// + /// Recorded faithfully and not passed on, because there is nowhere to put + /// it: the surface colour message carries a luminance range and two light + /// levels, and the client declares BT.2020 primaries because that is what + /// the stream is by the time it presents. A game graded on a P3 display + /// therefore has its gamut slightly overstated downstream. Carrying it + /// properly means widening the message and re-pinning it on both sides, + /// which is worth doing if anything is ever found to care. + #[allow(dead_code)] pub mastering_primaries: Option<[(u32, u32); 3]>, + #[allow(dead_code)] pub white_point: Option<(u32, u32)>, + /// The primary colour volume's luminance range, as `(min * 10000, max, + /// reference)` in cd/m². `None` says nothing, which is what an SDR + /// description does and what this said about HDR until it was measured + /// costing a title its highlights. + pub luminances: Option<(u32, u32, u32)>, + /// The target colour volume: what the display this is headed for can + /// actually show. A title reads this to size its tone mapping, and wine + /// maps it onto the luminance fields of `DXGI_OUTPUT_DESC1`. + pub target: Option, } impl ImageDescription { @@ -147,10 +244,17 @@ impl ImageDescription { mastering_luminance: None, mastering_primaries: None, white_point: None, + // SDR's luminance is the display's business and always has been. + luminances: None, + target: None, } } - pub fn bt2020_pq() -> Self { + /// HDR10, saying how bright it goes. + /// + /// The luminances are the point: see [`HdrTarget`]. A description without + /// them is what a title reads as "HDR, brightness unknown". + pub fn bt2020_pq(target: HdrTarget) -> Self { Self { transfer_function: TransferFunction::St2084Pq, primaries: Primaries::Bt2020, @@ -159,9 +263,22 @@ impl ImageDescription { mastering_luminance: None, mastering_primaries: None, white_point: None, + luminances: target.is_usable().then_some(( + target.min_lum, + target.max_nits, + target.reference_nits, + )), + target: target.is_usable().then_some(target), } } + /// Which of the two colour spaces this description is. + /// + /// scRGB reads as here, which is not what it is: extended-range + /// linear light is HDR, and this type has no way to say so. Left alone + /// because nothing outside this module reads it -- capture takes the + /// colour space from the game swapchain, not from here -- and inventing a + /// third variant for a question nobody asks would be worse than the note. pub fn color_space(self) -> ColorSpace { if self.primaries == Primaries::Bt2020 && self.transfer_function == TransferFunction::St2084Pq @@ -198,22 +315,17 @@ pub struct CreatorParams { mastering_luminance: Option<(u32, u32)>, mastering_primaries: Option<[(u32, u32); 3]>, white_point: Option<(u32, u32)>, + luminances: Option<(u32, u32, u32)>, } pub struct ColorOutputData; -pub struct ColorSurfaceFeedbackData { - pub surface: WlSurface, -} +/// The feedback object itself carries no state: every surface is told the +/// same preferred description, because there is one output and one session. +pub struct ColorSurfaceFeedbackData; 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 // --------------------------------------------------------------------------- @@ -222,10 +334,30 @@ pub struct SwapchainData { pub struct HdrState { /// Whether HDR protocols are advertised to clients. pub enabled: bool, + /// How bright this session says it goes. See [`HdrTarget`]. + pub target: HdrTarget, /// Pending (not-yet-committed) image descriptions keyed by surface. pending: HashMap>, /// Committed image descriptions keyed by surface. current: HashMap, + /// Information requests waiting to be answered after the request that + /// created them has returned. See [`HdrState::queue_information`]. + pending_information: Vec<( + wp_image_description_info_v1::WpImageDescriptionInfoV1, + ImageDescription, + )>, + /// Where capture is told what the compositor was told. + /// + /// Capture reads the colour space from the game's Vulkan swapchain, and a + /// swapchain set to `PASS_THROUGH` carries none -- the surface's colour is + /// declared here instead, and only here. So it goes across. + /// + /// `None` when a socket cannot be opened at all, which is not worth + /// failing a compositor over. + capture_socket: Option, + /// The last thing sent, so an unchanged surface does not resend on every + /// commit -- which is every frame. + last_sent: Option, } impl HdrState { @@ -234,21 +366,99 @@ impl HdrState { if enabled { display.create_global::(1, ()); display.create_global::(1, ()); - register_gamescope_swapchain(display); + tracing::info!("HDR protocols registered (wp_color_management_v1)"); + } + + let target = HdrTarget::from_env(); + if enabled { tracing::info!( - "HDR protocols registered (wp_color_management_v1 + gamescope_swapchain)" + "HDR output advertised at {} nits peak, {} nits full-frame, {} nits reference white", + target.max_nits, + target.max_fall_nits, + target.reference_nits ); + // The clients cannot be told this one. A client declares its + // stream through `VK_EXT_hdr_metadata`, which carries a luminance + // range and two light levels and has no field for a reference + // white -- so a compositor at the far end assumes PQ's default of + // 203 nits whatever was used here. Moving it makes the game render + // its diffuse white somewhere the far end will not look for it, + // and the picture arrives uniformly too bright or too dim. + if target.reference_nits != HdrTarget::default().reference_nits { + tracing::warn!( + "reference white moved to {} nits; the clients will still read the stream as {} and show it that much brighter or darker", + target.reference_nits, + HdrTarget::default().reference_nits + ); + } } Self { enabled, + target, pending: HashMap::new(), current: HashMap::new(), + pending_information: Vec::new(), + capture_socket: std::os::unix::net::UnixDatagram::unbound().ok(), + last_sent: None, } } // ── Pending state ───────────────────────────────────────────────────── + /// Remember an information object to answer once the request that made + /// it has returned. See the call site for why it cannot be answered there. + pub fn queue_information( + &mut self, + info: wp_image_description_info_v1::WpImageDescriptionInfoV1, + desc: ImageDescription, + ) { + self.pending_information.push((info, desc)); + } + + /// Answer every queued information request. + /// + /// Called once per loop iteration. The protocol does not say how promptly + /// `done` must follow, only that it ends the sequence, so a client waiting + /// on it waits one dispatch longer and nothing else changes. + pub fn flush_information(&mut self) { + for (info, desc) in self.pending_information.drain(..) { + match desc.transfer_function { + TransferFunction::St2084Pq => { + info.tf_named(wp_color_manager_v1::TransferFunction::St2084Pq) + } + TransferFunction::ExtLinear => { + info.tf_named(wp_color_manager_v1::TransferFunction::ExtLinear) + } + TransferFunction::Gamma22 => { + info.tf_named(wp_color_manager_v1::TransferFunction::Gamma22) + } + } + match desc.primaries { + Primaries::Bt2020 => info.primaries_named(wp_color_manager_v1::Primaries::Bt2020), + Primaries::Srgb => info.primaries_named(wp_color_manager_v1::Primaries::Srgb), + } + // Both the volume and the target: the first says what the + // description covers, the second what a renderer should aim at. + // A title reads one or the other depending on its driver, and + // sending only one leaves half of them none the wiser. + if let Some((min_lum, max_lum, reference_lum)) = desc.luminances { + info.luminances(min_lum, max_lum, reference_lum); + } + // The target volume is the display's, and it is the half a title + // reads to decide how bright to render. Sent whole: wine maps the + // range onto `MinLuminance`/`MaxLuminance` and the two light + // levels onto the peak and full-frame fields beside them, and a + // field it cannot fill is one the title plans around not having. + if let Some(target) = desc.target { + info.target_luminance(target.min_lum, target.max_nits); + info.target_max_cll(target.max_nits); + info.target_max_fall(target.max_fall_nits); + } + info.done(); + } + } + pub fn set_pending(&mut self, surface: &WlSurface, desc: ImageDescription) { tracing::debug!( surface_id = ?surface.id(), @@ -278,6 +488,70 @@ impl HdrState { self.current.remove(surface); } } + self.tell_capture(); + } + } + + /// Tell capture what the active surface's colour is, when it changes. + /// + /// Sent rather than asked for, because capture lives inside the game's + /// process and has no way to reach a compositor object. Unreliable by + /// construction -- a datagram to a socket that may not be bound yet -- and + /// that is the right trade here: the next commit sends it again, and + /// commits are frequent. Blocking a compositor commit on a process that + /// may not exist would not be. + fn tell_capture(&mut self) { + let Some(socket) = self.capture_socket.as_ref() else { + return; + }; + let colour = self.surface_color_message(); + if self.last_sent == Some(colour) { + return; + } + + let mut payload = vec![nesprotocol::MSG_SURFACE_COLOR]; + nesprotocol::encode_surface_color(&mut payload, &colour); + match socket.send_to(&payload, CAPTURE_SOCKET) { + Ok(_) => { + tracing::info!( + space = colour.space, + max_cll = colour.max_cll, + max_fall = colour.max_fall, + max_luminance = colour.max_luminance, + "told capture what this surface is" + ); + self.last_sent = Some(colour); + } + // Not a warning. No capture attached is the ordinary state for a + // compositor running on its own, and this fires per commit. + Err(e) => tracing::trace!("capture is not listening: {e}"), + } + } + + /// What to tell capture, from the surfaces currently mapped. + /// + /// Any HDR surface makes the answer HDR. A session is one game on one + /// screen, so "any" and "the one that matters" are the same set, and + /// picking between several would need a notion of active this does not + /// have. + fn surface_color_message(&self) -> nesprotocol::SurfaceColor { + surface_colour_from( + self.current + .values() + .find(|desc| desc.color_space() == ColorSpace::Bt2020Pq), + self.target, + ) + } + + /// Drop what was remembered about a surface, and say so. + /// + /// Separate from [`Self::surface_destroyed`] because the `wl_surface` may + /// well outlive the colour-management object attached to it: a game + /// leaving HDR keeps its window. + pub fn forget(&mut self, surface: &WlSurface) { + self.pending.remove(surface); + if self.current.remove(surface).is_some() { + self.tell_capture(); } } @@ -285,218 +559,6 @@ impl HdrState { 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 => {} - } - } } // =========================================================================== @@ -524,6 +586,12 @@ impl GlobalDispatch for NescopeState 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); + // scRGB. Mesa pairs this with sRGB primaries to offer + // `EXTENDED_SRGB_LINEAR`, which is the colour space DXVK asks for when + // a Windows title enables HDR -- see `TransferFunction::ExtLinear`. + // Without it a game gets its float16 swapchain tagged SRGB_NONLINEAR + // and every value in it read as if it were ordinary sRGB. + res.supported_tf_named(wp_color_manager_v1::TransferFunction::ExtLinear); res.supported_primaries_named(wp_color_manager_v1::Primaries::Srgb); res.supported_primaries_named(wp_color_manager_v1::Primaries::Bt2020); res.done(); @@ -532,7 +600,7 @@ impl GlobalDispatch for NescopeState impl Dispatch for NescopeState { fn request( - _: &mut Self, + state: &mut Self, _: &Client, _: &wp_color_manager_v1::WpColorManagerV1, request: wp_color_manager_v1::Request, @@ -548,8 +616,8 @@ impl Dispatch for NescopeState { 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::GetSurfaceFeedback { id, .. } => { + data_init.init(id, ColorSurfaceFeedbackData); } wp_color_manager_v1::Request::CreateParametricCreator { obj } => { data_init.init( @@ -563,12 +631,12 @@ impl Dispatch for NescopeState { 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. + // Windows scRGB, which wine converts before presenting, so + // it arrives declared as BT.2020 PQ. let res = data_init.init( image_description, ImageDescriptionUserData { - desc: ImageDescription::bt2020_pq(), + desc: ImageDescription::bt2020_pq(state.hdr.target), }, ); res.ready(0); @@ -606,9 +674,27 @@ impl Dispatch { state.hdr.unset_pending(&data.surface); } + // Destroying the object removes the image description from the + // surface as surely as unsetting it does, and a client leaving HDR + // may well do it this way. Landing in the catch-all left the + // surface remembered as HDR for the rest of the session. + wp_color_management_surface_v1::Request::Destroy => { + state.hdr.unset_pending(&data.surface); + state.hdr.forget(&data.surface); + } _ => {} } } + + fn destroyed( + state: &mut Self, + _: smithay::reexports::wayland_server::backend::ClientId, + _: &wp_color_management_surface_v1::WpColorManagementSurfaceV1, + data: &ColorSurfaceData, + ) { + // Also reached when the client goes away without tidying up. + state.hdr.forget(&data.surface); + } } // =========================================================================== @@ -641,15 +727,30 @@ impl mastering_luminance: p.mastering_luminance, mastering_primaries: p.mastering_primaries, white_point: p.white_point, + luminances: p.luminances, + target: None, }; let r = data_init.init(image_description, ImageDescriptionUserData { desc }); r.ready(0); } + wp_image_description_creator_params_v1::Request::SetLuminances { + min_lum, + max_lum, + reference_lum, + } => { + // Advertised as a supported feature, so a client that uses it + // is entitled to have it mean something. It used to land in + // the catch-all and vanish. + data.params.lock().unwrap().luminances = Some((min_lum, max_lum, reference_lum)); + } wp_image_description_creator_params_v1::Request::SetTfNamed { tf } => { let tf = match tf.into_result() { Ok(wp_color_manager_v1::TransferFunction::St2084Pq) => { TransferFunction::St2084Pq } + Ok(wp_color_manager_v1::TransferFunction::ExtLinear) => { + TransferFunction::ExtLinear + } _ => TransferFunction::Gamma22, }; data.params.lock().unwrap().transfer_function = Some(tf); @@ -708,7 +809,7 @@ impl Dispatch, ) { if let wp_image_description_v1::Request::GetInformation { information } = request { + // Answered after this returns, not here. `done` destroys the + // object, and destroying it inside the request that created it + // takes the id out of the client's map before the backend has + // attached the data this handler just returned -- which it then + // unwraps, and panics on. The whole compositor goes down the first + // time a client asks what colour space it has. 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(); + state.hdr.queue_information(info, data.desc); } } } @@ -770,7 +865,7 @@ impl Dispatch { let desc = if state.hdr.enabled { - ImageDescription::bt2020_pq() + ImageDescription::bt2020_pq(state.hdr.target) } else { ImageDescription::srgb() }; @@ -902,3 +997,186 @@ impl ) { } } + +/// What to tell capture about a surface's colour. +/// +/// Two different kinds of number, and only one of them can be answered from +/// here. +/// +/// The mastering luminance is a decision, not a guess: it is what the session +/// told the game its display could do, so it is what the frames were graded +/// for -- which is exactly what the measure means. Saying nothing instead +/// leaves the far end to assume PQ's default of 10000 nits and tone map a +/// range the picture never uses. +/// +/// The light levels are measurements of the *content*, and nothing here has +/// looked at the content. Filling them in from the mastering display answers a +/// different question: it says the picture contains what the display can show, +/// which for a dark game is wrong by more than an order of magnitude, and +/// wrong in the direction that costs brightness -- a display told the frame +/// average is high dims to protect itself. Zero is what CTA-861 reserves for +/// "not known", and not known is the truth. The client measures the peak for +/// itself and fills that one in. +fn surface_colour_from( + hdr: Option<&ImageDescription>, + target: HdrTarget, +) -> nesprotocol::SurfaceColor { + match hdr { + Some(desc) => nesprotocol::SurfaceColor { + space: nesprotocol::SURFACE_COLOR_BT2020_PQ, + // A game that states its own has looked, and is believed. + max_cll: desc.max_cll.unwrap_or(0), + max_fall: desc.max_fall.unwrap_or(0), + min_luminance: desc + .mastering_luminance + .map(|(min, _)| min) + .unwrap_or(target.min_lum), + max_luminance: desc + .mastering_luminance + .map(|(_, max)| max) + .unwrap_or(target.max_nits.saturating_mul(10_000)), + }, + None => nesprotocol::SurfaceColor { + space: nesprotocol::SURFACE_COLOR_SRGB, + ..Default::default() + }, + } +} + +#[cfg(test)] +mod hdr_target_tests { + use super::{HdrTarget, ImageDescription, Primaries, TransferFunction}; + + /// The default is the protocol's own for PQ, and the peak is deliberately + /// well above 250: DXVK reads a reported peak below that as the driver + /// having failed to fill the field in, and a title told nothing renders as + /// though the display does not go far. + #[test] + fn the_default_describes_an_ordinary_hdr_display() { + let t = HdrTarget::default(); + assert_eq!(t.reference_nits, 203); + assert_eq!(t.min_lum, 50); + assert!(t.max_nits > 250); + assert!(t.is_usable()); + } + + /// The bug this exists for: an HDR output that names its primaries and + /// transfer function and nothing about luminance tells a title only that + /// HDR exists. Measured cost was a game rendering a 203-nit peak. + #[test] + fn an_hdr_description_says_how_bright_it_goes() { + let t = HdrTarget::default(); + let desc = ImageDescription::bt2020_pq(t); + assert_eq!(desc.transfer_function, TransferFunction::St2084Pq); + assert_eq!(desc.primaries, Primaries::Bt2020); + assert_eq!( + desc.luminances, + Some((t.min_lum, t.max_nits, t.reference_nits)) + ); + assert_eq!(desc.target, Some(t)); + } + + /// SDR's brightness is the display's business, and saying otherwise would + /// make every ordinary surface claim a volume it does not have. + #[test] + fn an_sdr_description_says_nothing_about_luminance() { + let desc = ImageDescription::srgb(); + assert!(desc.luminances.is_none()); + assert!(desc.target.is_none()); + } + + /// A display cannot sustain its peak across the whole panel, so an + /// override that claims it does is clamped rather than passed on. + #[test] + fn full_frame_luminance_never_exceeds_the_peak() { + let clamped = HdrTarget { + max_nits: 400, + max_fall_nits: 4000, + ..HdrTarget::default() + } + .clamped(); + assert_eq!(clamped.max_fall_nits, 400); + assert_eq!(clamped.max_nits, 400); + } + + /// The protocol raises `invalid_luminance` on a range that does not go + /// upwards. nescope is the one sending it, so a bad override has to be + /// dropped here rather than become a protocol error at the client. + #[test] + fn a_range_that_does_not_go_upwards_is_not_sent() { + let bad = HdrTarget { + max_nits: 0, + ..HdrTarget::default() + }; + assert!(!bad.is_usable()); + assert!(ImageDescription::bt2020_pq(bad).luminances.is_none()); + assert!(ImageDescription::bt2020_pq(bad).target.is_none()); + } +} + +#[cfg(test)] +mod surface_colour_tests { + use super::{HdrTarget, ImageDescription, surface_colour_from}; + + /// The mastering range is a decision this side actually made: it is what + /// the game was told its display could do, so it is what the frames were + /// graded for. + #[test] + fn the_mastering_range_comes_from_the_session() { + let target = HdrTarget::default(); + let colour = surface_colour_from(Some(&ImageDescription::bt2020_pq(target)), target); + assert_eq!(colour.space, nesprotocol::SURFACE_COLOR_BT2020_PQ); + assert_eq!(colour.min_luminance, target.min_lum); + assert_eq!(colour.max_luminance, target.max_nits * 10_000); + } + + /// The light levels are measurements of the content, and nothing here has + /// looked at the content. Taking them from the mastering display says the + /// picture contains what the display can show, which for a dark game is + /// wrong by more than an order of magnitude -- and wrong in the direction + /// that costs brightness. + #[test] + fn the_light_levels_are_not_invented() { + let target = HdrTarget::default(); + let colour = surface_colour_from(Some(&ImageDescription::bt2020_pq(target)), target); + assert_eq!(colour.max_cll, 0, "zero is CTA-861 for 'not known'"); + assert_eq!(colour.max_fall, 0); + } + + /// A game that states its own is believed, because it has looked. + #[test] + fn a_games_own_light_levels_are_kept() { + let target = HdrTarget::default(); + let desc = ImageDescription { + max_cll: Some(500), + max_fall: Some(100), + ..ImageDescription::bt2020_pq(target) + }; + let colour = surface_colour_from(Some(&desc), target); + assert_eq!(colour.max_cll, 500); + assert_eq!(colour.max_fall, 100); + } + + /// And so is its mastering range, which is a claim about how it was + /// graded rather than about this session. + #[test] + fn a_games_own_mastering_range_is_kept() { + let target = HdrTarget::default(); + let desc = ImageDescription { + mastering_luminance: Some((1000, 5_000_000)), + ..ImageDescription::bt2020_pq(target) + }; + let colour = surface_colour_from(Some(&desc), target); + assert_eq!(colour.min_luminance, 1000); + assert_eq!(colour.max_luminance, 5_000_000); + } + + /// An SDR session says nothing about luminance at all; that is the + /// display's business. + #[test] + fn an_sdr_session_reports_no_luminance() { + let colour = surface_colour_from(None, HdrTarget::default()); + assert_eq!(colour.space, nesprotocol::SURFACE_COLOR_SRGB); + assert_eq!(colour.max_luminance, 0); + } +} diff --git a/apps/nescope/src/input.rs b/apps/nescope/src/input.rs index 6ed2a80d..825be0b6 100644 --- a/apps/nescope/src/input.rs +++ b/apps/nescope/src/input.rs @@ -106,11 +106,6 @@ pub fn process_input(event: InputEvent, state: &mut NescopeState) { _ => 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() { @@ -363,21 +358,6 @@ fn clamp_cursor(state: &mut NescopeState) { /// 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())); diff --git a/apps/nescope/src/main.rs b/apps/nescope/src/main.rs index 2bdad410..072a2500 100644 --- a/apps/nescope/src/main.rs +++ b/apps/nescope/src/main.rs @@ -4,7 +4,7 @@ //! //! 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 +//! Vulkan interception library (`nescapture`); nescope itself //! never allocates a GBM pool or forwards DMA-BUFs. //! //! # Usage @@ -17,7 +17,7 @@ //! --height Output height [default: 1080] //! --fps Virtual refresh rate, advertised only [default: 60] //! --frame-callback-hz wl_surface.frame cadence [default: 1000] -//! --hdr Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain) +//! --hdr Enable HDR colour management (wp_color_manager_v1) //! --socket Wayland socket name [default: nescope-0] //! ``` //! @@ -67,7 +67,6 @@ mod hdr; mod input; mod input_ipc; mod libinput_backend; -mod protocols; //mod screenshot_ipc; //mod screenshot_wire; mod state; @@ -80,6 +79,22 @@ use state::{CalloopData, ClientState, NescopeState}; // CLI // --------------------------------------------------------------------------- +/// A flag that can also arrive as an environment variable. +/// +/// `--hdr` on its own still means true. The difference is what a value may be: +/// clap's own bool parser takes `true` and `false` and nothing else, so +/// `NESCOPE_HDR=1` -- which is how every other environment variable in this +/// stack is written, and the first thing anyone tries -- was rejected outright. +fn flag_value(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" | "" => Ok(false), + other => Err(std::format!( + "expected 1 or 0 (true/false, yes/no and on/off are also taken), got {other:?}" + )), + } +} + #[derive(Parser, Debug)] #[command( name = "nescope", @@ -125,8 +140,15 @@ struct Args { #[arg(long, default_value = "1000", env = "NESCOPE_FRAME_CALLBACK_HZ")] frame_callback_hz: u32, - /// Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain_factory_v2). - #[arg(long, env = "NESCOPE_HDR")] + /// Enable HDR colour management (`wp_color_manager_v1`). + #[arg( + long, + env = "NESCOPE_HDR", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "true", + value_parser = flag_value, + )] hdr: bool, /// Run XWayland, for Linux-native software with no Wayland support. @@ -137,7 +159,14 @@ struct Args { /// which is what the launch environment does -- and HDR is only offered on /// the Wayland surface, so a game routed through XWayland loses it too. /// Turn this on for the shrinking set of X11-only native software. - #[arg(long, env = "NESCOPE_XWAYLAND")] + #[arg( + long, + env = "NESCOPE_XWAYLAND", + num_args = 0..=1, + default_value_t = false, + default_missing_value = "true", + value_parser = flag_value, + )] xwayland: bool, /// Wayland socket name (created in $XDG_RUNTIME_DIR). @@ -422,8 +451,7 @@ fn main() { // wrong way round, and it capped them at 60 while sessions asked for 120. // The capture layer holds the game instead, and this runs fast enough to // stay out of the way. - let frame_interval = - Duration::from_micros(1_000_000 / args.frame_callback_hz.max(1) as u64); + let frame_interval = Duration::from_micros(1_000_000 / args.frame_callback_hz.max(1) as u64); loop_handle .insert_source(Timer::from_duration(frame_interval), move |_, _, data| { if let Some(ref mut li) = data.libinput { @@ -437,7 +465,7 @@ fn main() { // ── CalloopData ─────────────────────────────────────────────────────── let socket_name_for_cleanup = args.socket.clone(); let command = args.command.clone(); - let gamescope_wayland_socket = args.socket.clone(); + let wayland_socket = args.socket.clone(); // ── libinput backend ───────────────────────────────────────────────── let libinput_ctx = @@ -491,7 +519,7 @@ fn main() { // Put the game in its own process group so we can // kill the whole tree at once with kill(-pgid, …). .process_group(0) - .env("WAYLAND_DISPLAY", &gamescope_wayland_socket); + .env("WAYLAND_DISPLAY", &wayland_socket); // DISPLAY only if XWayland is actually running. Setting it // otherwise points clients at a server that is not there, @@ -516,24 +544,6 @@ fn main() { // this. Without it neither DX11 nor DX12 (vkd3d-proton // through DXVK's dxgi) sees HDR as available. cmd.env("DXVK_HDR", "1"); - - // Left set, but deliberately without ENABLE_GAMESCOPE_WSI - // alongside it, so it is inert unless somebody opts in. - // - // That pair activates gamescope's WSI layer, which - // predates Wayland colour management and works by - // hiding HDR from the driver and reporting it to the - // compositor out of band. We do not want it: it needs a - // layer this image does not ship, it only helps the - // XWayland path, and capture reads the colour space it - // hides -- measured, a game asking for HDR10 through it - // has its PQ samples encoded and tagged BT.709 SDR. - // Enabling it would trade no HDR for wrong HDR. - tracing::debug!( - gamescope_wayland_socket, - "HDR: Wayland colour management; gamescope WSI not enabled" - ); - cmd.env("GAMESCOPE_WAYLAND_DISPLAY", &gamescope_wayland_socket); } // Detect GPU vendor from render device and set VK_DRIVER_FILES @@ -625,6 +635,12 @@ fn main() { } } + // Answer any colour-management information requests that came in + // this iteration. Deferred to here because the event that ends + // them destroys the object, and doing that inside the request that + // created it panics the backend -- see . + data.state.hdr.flush_information(); + // ── Flush Wayland clients ───────────────────────────────── if let Err(e) = data.display.flush_clients() { tracing::warn!("Error flushing Wayland clients: {e}"); diff --git a/apps/nescope/src/protocols/gamescope-swapchain.xml b/apps/nescope/src/protocols/gamescope-swapchain.xml deleted file mode 100644 index 58ac8463..00000000 --- a/apps/nescope/src/protocols/gamescope-swapchain.xml +++ /dev/null @@ -1,194 +0,0 @@ - - - - - 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 deleted file mode 100644 index 22f7d1e2..00000000 --- a/apps/nescope/src/protocols/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! 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/state.rs b/apps/nescope/src/state.rs index 9b7a4a6a..3363b43a 100644 --- a/apps/nescope/src/state.rs +++ b/apps/nescope/src/state.rs @@ -24,6 +24,7 @@ use calloop::channel::Sender; use smithay::desktop::utils::{ OutputPresentationFeedback, send_frames_surface_tree, surface_presentation_feedback_flags_from_states, surface_primary_scanout_output, + take_presentation_feedback_surface_tree, }; use smithay::desktop::{Space, Window}; use smithay::input::pointer::CursorImageStatus; @@ -96,13 +97,18 @@ impl ClientData for ClientState { // X11 atoms + connection // --------------------------------------------------------------------------- -/// Gamescope-compatible X11 atoms used for focus and HDR signalling. +/// Gamescope-compatible X11 atoms, for focus. +/// +/// Focus only. HDR used to be signalled through one of these as well, back +/// when a WSI layer inside the game reported the colour space out of band; +/// that route is gone and `wp_color_manager_v1` carries it. These remain +/// because Steam reads them to work out which window it is looking at, and +/// that is unrelated to colour. 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, @@ -157,10 +163,6 @@ pub struct NescopeState { 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. @@ -267,7 +269,7 @@ impl NescopeState { let (dmabuf_state, dmabuf_global) = build_dmabuf_global::(&display_handle, render_device.as_deref()); - // HDR + gamescope swapchain globals (optional). + // Colour management, when asked for. let hdr_state = HdrState::new(&display_handle, hdr); // Input channel — the Sender is returned to the caller. @@ -309,8 +311,6 @@ impl NescopeState { 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(), @@ -398,10 +398,6 @@ impl NescopeState { 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", @@ -422,8 +418,10 @@ impl NescopeState { } } - /// Write gamescope-specific X11 root window properties so the WSI layer - /// can discover this compositor as a gamescope-compatible server. + /// Write the gamescope-compatible X11 root window properties. + /// + /// These say which application has focus and what could take it, which is + /// what Steam looks for. Nothing here concerns colour. pub fn set_gamescope_atoms( &self, conn: &smithay::reexports::x11rb::rust_connection::RustConnection, @@ -438,14 +436,6 @@ impl NescopeState { 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, @@ -465,16 +455,6 @@ impl NescopeState { 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 // ----------------------------------------------------------------------- @@ -769,12 +749,24 @@ impl NescopeState { // 1. Release the held buffer → frees a swapchain image for the game. self.held_buffer.take(); - // 2. Presentation feedback — tell clients about vsync timing. + // 2. Presentation feedback — every frame committed since the last tick + // is reported presented, on the one output there is. + // + // Not filtered by primary scan-out output: that is recorded by a + // renderer, and nothing here renders, so the filter matched no + // surface ever. Feedback then resolved only as `discarded`, when the + // next commit superseded it -- which never happens for a client that + // waits for its last present before drawing the next. A Vulkan + // client with present-wait under FIFO does exactly that: Control on + // VKD3D-Proton froze on leaving its title screen, GPU idle, the game + // still running behind a stream that no longer moved. let mut output_presentation_feedback = OutputPresentationFeedback::new(&output); + let on_output = + |_: &WlSurface, _: &smithay::wayland::compositor::SurfaceData| Some(output.clone()); for window in self.space.elements().cloned().collect::>() { window.take_presentation_feedback( &mut output_presentation_feedback, - surface_primary_scanout_output, + on_output, |_, _| wp_presentation_feedback::Kind::Vsync, ); } @@ -797,12 +789,6 @@ impl NescopeState { } } - 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) { diff --git a/apps/neshub/Cargo.toml b/apps/neshub/Cargo.toml index 48537226..e15082f8 100644 --- a/apps/neshub/Cargo.toml +++ b/apps/neshub/Cargo.toml @@ -11,7 +11,7 @@ name = "neshub" path = "src/main.rs" [dependencies] -iroh = "1.1.0" +iroh = "1.2" anyhow.workspace = true clap.workspace = true diff --git a/apps/neshub/src/control.rs b/apps/neshub/src/control.rs new file mode 100644 index 00000000..ad9bd58b --- /dev/null +++ b/apps/neshub/src/control.rs @@ -0,0 +1,914 @@ +//! Deciding what the encoder may spend. +//! +//! # Why this exists at all +//! +//! Nothing used to decide. The encoder took a bitrate from an environment +//! variable nobody set, so every session offered the same 10 Mbps whatever the +//! path between the two ends could carry. On a path sustaining under 3 Mbps that +//! does not degrade the picture, it removes it: most fragments are dropped, +//! every frame made of several fragments arrives incomplete, and the keyframe -- +//! the largest frame there is -- never completes either, so nothing ever +//! recovers. +//! +//! # Why the receiver decides, and not this end +//! +//! The obvious inputs are here: the hub holds the QUIC connection and can read +//! its round-trip time, its congestion window and its remaining datagram buffer. +//! They were measured, over an entire multi-session run, saying the path was +//! healthy while the client was receiving almost nothing -- 182 ms round trip, +//! *zero* QUIC packet loss, and arrivals falling from 1776 to 239 datagrams a +//! second. +//! +//! Part of that is structural rather than bad luck. `send_datagram` evicts the +//! oldest queued datagrams and returns `Ok`, so a sender that is overrunning the +//! path is told nothing at all; the "frames dropped" counter it feeds cannot be +//! non-zero however badly things are going. So the primary evidence is the +//! receiver's own report of what arrived, and this end's view is the fallback +//! for when no report has come in. +//! +//! # Shape +//! +//! Slow and dull on purpose. This is not a congestion controller in the QUIC +//! sense and does not try to be: it finds the right order of magnitude for a +//! video bitrate and stays there, at one decision a second. The transport +//! underneath is still doing real congestion control on its own packets. + +use nesprotocol::{ControlMode, ReceiverReport}; + +/// Loss above this means the path is being overrun and the target comes down. +const LOSS_DECREASE: f32 = 0.10; +/// Loss below this means there is room to climb. +const LOSS_INCREASE: f32 = 0.02; + +/// What to keep of the measured goodput when backing off. +/// +/// Backing off to *below* what actually got through, rather than to a fraction +/// of what we were asking for, is what makes recovery quick. At 10 Mbps into a +/// 3 Mbps path the next target is about 2.5 Mbps rather than 8.5, so one step +/// does what twenty multiplicative decreases would. +const BACKOFF: f32 = 0.85; + +/// How much of the ceiling to add per calm second. Twenty steps from floor to +/// ceiling: slow enough that a brief quiet spell does not undo a back-off. +const CLIMB_FRACTION: u32 = 20; + +/// Don't actuate for less than this fraction of the current target. +/// +/// Retuning is cheap now -- it no longer rebuilds the encoder or forces a +/// keyframe -- but a line in the log every second saying the bitrate moved by +/// 1% is noise that hides the changes that matter. +const DEADBAND: f32 = 0.10; + +/// Never go below this, whatever the ceiling is. +/// +/// Under this a 1080p stream is not worth sending and the right answer is fewer +/// pixels rather than fewer bits, which is a decision about the tier and not one +/// this can make. It also has to leave room for audio, which shares the +/// connection and is not counted against the video ceiling. +const ABSOLUTE_FLOOR_KBPS: u32 = 800; + +/// Seconds without a receiver report before this end's own view is used instead. +const SILENT_TICKS_BEFORE_FALLBACK: u32 = 3; + +/// Backlog, in milliseconds of video, that means the path is being overrun. +/// +/// Not a tuned number: it is a latency budget. At sixty frames a second a frame +/// is under 17 ms, so a quarter second of backlog is fifteen frames already +/// handed over and not yet gone -- a player is looking at a picture from before +/// their last four keypresses. There is no bitrate worth that, so past this the +/// target comes down whatever the loss says. +const QUEUE_DECREASE_MS: u32 = 250; + +/// Backlog, in milliseconds, under which the path is considered clear. +/// +/// Climbing needs a stronger warrant than holding does, because climbing is +/// what digs the queue. Five frames or so of backlog is the most that can be +/// outstanding and still be called live. +const QUEUE_CLIMB_MS: u32 = 80; + +/// The band a target must stay inside. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + pub ceiling_kbps: u32, +} + +impl Limits { + pub fn new(ceiling_kbps: u32) -> Self { + Self { ceiling_kbps } + } + + /// The lowest this may go. + pub fn floor_kbps(&self) -> u32 { + ABSOLUTE_FLOOR_KBPS + .max(self.ceiling_kbps / 10) + // A ceiling below the floor is a tier nobody should have configured, + // but clamping the wrong way round would put the target *above* the + // ceiling, which is the one thing a ceiling must never allow. + .min(self.ceiling_kbps) + } + + fn clamp(&self, kbps: u32) -> u32 { + kbps.clamp(self.floor_kbps(), self.ceiling_kbps) + } +} + +/// What this end can see about the path, for when the far end is not talking. +/// +/// Every field is optional because every one of them can be unavailable: a +/// connection with no established path has no round-trip time, and a congestion +/// window is only meaningful once something has been sent. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PathView { + /// Congestion window in bytes, as the transport currently believes it. + pub cwnd_bytes: Option, + /// Round-trip time in milliseconds. + pub rtt_ms: Option, + /// Datagram bytes handed to the transport that have not yet left. + /// + /// The backlog is the one thing this end can see that says the path is + /// being overrun *while it is still only late*. Loss says so too, but only + /// afterwards, and on a path that queues rather than drops, "afterwards" + /// can be several seconds. + pub backlog_bytes: Option, +} + +impl PathView { + /// How long the backlog takes to drain at `drain_kbps`, in milliseconds. + /// + /// Bits divided by kilobits-per-second is milliseconds. The rate to divide + /// by is the one the queue actually drains at -- what is getting through -- + /// and not what we are asking for, which is the number that is too high + /// whenever this matters. + pub fn backlog_ms(&self, drain_kbps: u32) -> Option { + let backlog = self.backlog_bytes?; + let drain = u64::from(drain_kbps.max(1)); + Some((backlog.saturating_mul(8) / drain).min(u64::from(u32::MAX)) as u32) + } + + /// Delivery rate the window and round trip imply, in kbps. + /// + /// A window is a quantity of bytes in flight for one round trip, so the two + /// together are a rate. Treated as an estimate of last resort: it describes + /// the transport's own opinion of the path, and that opinion is exactly what + /// was observed to be wrong when this mattered most. + pub fn estimate_kbps(&self) -> Option { + let (cwnd, rtt) = (self.cwnd_bytes?, self.rtt_ms?.max(1)); + let bits_per_second = cwnd.saturating_mul(8) * 1000 / u64::from(rtt); + Some((bits_per_second / 1000).min(u64::from(u32::MAX)) as u32) + } +} + +/// Why the target is what it is, for the overlay and the log. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Reason { + /// Loss was high; backed off to under what was actually getting through. + Congested, + /// Loss was in the middle band; left alone. + Holding, + /// Loss was low; climbing towards the ceiling. + Climbing, + /// No receiver report recently; using this end's view of the path. + Fallback, + /// No report and no usable view either; decaying towards the floor. + Blind, + /// Somebody set the bitrate by hand. + Manual, + /// The encoder is not under a bitrate at all, so there is nothing to decide. + ConstantQuality, + /// Nobody is connected, so there is no path to have an opinion about. + NoClients, + /// This second's numbers describe frames the hub chose not to send, so they + /// say nothing about the path. + SelfInflicted, + /// Nothing was lost, but the send queue is standing deep enough that the + /// picture is arriving late. Backed off on the queue rather than on loss. + Backlogged, +} + +/// The controller's whole state. +#[derive(Debug, Clone, Copy)] +pub struct Controller { + /// The ceiling the box was given, which a client may lower but never raise. + /// + /// Kept apart from `limits`, which holds whatever is in force *now*. Folding + /// the two together means a client that lowers the ceiling can never raise + /// it again, because the only number left to compare against is the one it + /// just lowered. + box_ceiling_kbps: u32, + /// What the controller currently believes the bitrate should be. + /// + /// Kept apart from `sent_kbps` deliberately. Folding the two together looks + /// simpler and stalls the climb: the step up is a fraction of the *ceiling*, + /// so once the target passes half of it each step is under the deadband, and + /// a target that only moved when it actuated could never accumulate past + /// that point. Belief moves every second; only saying so is rationed. + target_kbps: u32, + /// What the encoder was last told, or `None` before anything was sent. + sent_kbps: Option, + limits: Limits, + mode: ControlMode, + /// Set when the encoder is in constant-QP; there is no bitrate to control. + constant_quality: bool, + silent_ticks: u32, + reason: Reason, + /// Last measured send-queue depth, kept for reporting rather than control. + backlog_ms: u32, +} + +impl Controller { + /// Start at the ceiling. + /// + /// Optimistic on purpose: the common case is a path that can carry the tier + /// that was sold, and a session that opened at its floor and climbed would + /// take twenty seconds to look like what was paid for. The first bad report + /// undoes it in one step. + pub fn new(limits: Limits) -> Self { + Self { + box_ceiling_kbps: limits.ceiling_kbps, + target_kbps: limits.ceiling_kbps, + sent_kbps: None, + limits, + mode: ControlMode::Auto, + constant_quality: false, + silent_ticks: 0, + reason: Reason::Holding, + backlog_ms: 0, + } + } + + pub fn target_kbps(&self) -> u32 { + self.target_kbps + } + pub fn limits(&self) -> Limits { + self.limits + } + pub fn mode(&self) -> ControlMode { + self.mode + } + pub fn reason(&self) -> Reason { + self.reason + } + + /// How far behind the send queue was at the last decision, in milliseconds. + pub fn backlog_ms(&self) -> u32 { + self.backlog_ms + } + + /// The ceiling the box was given, whatever a client has since asked for. + pub fn box_ceiling_kbps(&self) -> u32 { + self.box_ceiling_kbps + } + + /// Adopt a ceiling a client asked for, never above the one the box was given. + /// + /// A client may lower its own ceiling -- to test a path, or because it knows + /// something about its link that this end does not -- but it may not raise + /// the one the tier bought. + pub fn set_ceiling(&mut self, ceiling_kbps: u32) { + self.limits = Limits::new(ceiling_kbps.min(self.box_ceiling_kbps).max(1)); + self.target_kbps = self.limits.clamp(self.target_kbps); + // A ceiling that moved is worth restating even when the target did not, + // because the encoder is the thing that has to hear about it. + self.sent_kbps = None; + } + + pub fn set_mode(&mut self, mode: ControlMode) { + self.mode = mode; + if mode == ControlMode::Manual { + self.reason = Reason::Manual; + } + } + + /// Note that somebody set the bitrate by hand. + pub fn note_manual_target(&mut self, kbps: u32) { + self.target_kbps = kbps; + self.sent_kbps = Some(kbps); + self.mode = ControlMode::Manual; + self.reason = Reason::Manual; + } + + /// Note that the encoder is, or is no longer, under constant quality. + pub fn set_constant_quality(&mut self, constant_quality: bool) { + self.constant_quality = constant_quality; + } + + /// One second's decision. + /// + /// `report` is the worst report across the clients attached, or `None` when + /// none of them said anything. Returns the new target when it is worth + /// sending, and `None` when nothing should be sent -- which is most seconds. + /// One second's decision. + /// + /// `self_inflicted` says this second contained frames the hub deliberately + /// withheld -- a client resynchronising, whose deltas were undecodable and + /// were dropped rather than sent. Those seconds cannot be read as evidence + /// about the path: fewer frames were sent, so fewer arrived, so the measured + /// goodput is low and the loss is high, and a controller anchoring a backoff + /// on that would cut the bitrate in response to its own decision. Every + /// stutter would then also cost bandwidth, which is the opposite of what a + /// resynchronisation needs. + pub fn tick( + &mut self, + clients: usize, + report: Option, + path: PathView, + self_inflicted: bool, + ) -> Option { + if clients == 0 { + // Nothing is connected, so nothing is being carried and there is no + // path to form an opinion about. Deciding here means deciding on the + // absence of evidence: the controller used to read it as silence and + // decay to the floor, so a box waiting for its first client spent + // that time winding itself down and then jumped back up the moment + // somebody arrived. + self.reason = Reason::NoClients; + self.silent_ticks = 0; + return None; + } + if self.constant_quality { + self.reason = Reason::ConstantQuality; + return None; + } + if self.mode == ControlMode::Manual { + self.reason = Reason::Manual; + return None; + } + if self_inflicted { + // Held, not decayed. The path may be fine; this second simply + // cannot say, and silence about the path is not evidence against it. + self.reason = Reason::SelfInflicted; + self.silent_ticks = 0; + return None; + } + + // What the queue drains at is what is getting through. With no report + // to say, the target is the best guess available -- and a target that + // is too high only makes the backlog look shorter than it is, so this + // errs towards patience rather than towards cutting the rate. + let drain_kbps = match report.as_ref().map(|r| (r.goodput_bps / 1000) as u32) { + Some(measured) if measured > 0 => measured, + _ => self.target_kbps, + }; + self.backlog_ms = path.backlog_ms(drain_kbps).unwrap_or(0); + + let next = match report.as_ref().and_then(|r| r.loss().map(|l| (r, l))) { + Some((report, loss)) => { + self.silent_ticks = 0; + self.decide_from_report(report, loss, path) + } + None => { + self.silent_ticks = self.silent_ticks.saturating_add(1); + if self.silent_ticks < SILENT_TICKS_BEFORE_FALLBACK { + self.reason = Reason::Holding; + return None; + } + self.decide_from_path(path) + } + }; + + self.target_kbps = self.limits.clamp(next); + // Every second moves the belief; only a change worth acting on is said. + // The first decision is always said -- the encoder started at whatever + // its environment gave it, and this end has no way to know it matches. + match self.sent_kbps { + Some(sent) if !worth_sending(sent, self.target_kbps, self.limits) => None, + _ => { + self.sent_kbps = Some(self.target_kbps); + Some(self.target_kbps) + } + } + } + + fn decide_from_report(&mut self, report: &ReceiverReport, loss: f32, path: PathView) -> u32 { + let measured = (report.goodput_bps / 1000) as u32; + // `backlog_ms` is zero both when the queue is empty and when the + // transport cannot say, which are the same thing for this purpose: a + // queue nobody can see is not evidence of one. + let known = path.backlog_bytes.is_some(); + let queued = known && self.backlog_ms > QUEUE_DECREASE_MS; + + if loss > LOSS_DECREASE || queued { + self.reason = if queued { + Reason::Backlogged + } else { + Reason::Congested + }; + // Anchored on what actually arrived, not on what we were asking for. + // `goodput` of zero means nothing completed at all, in which case + // there is no measurement to anchor to and the target is simply + // halved -- the alternative, backing off to zero, would take the + // stream below the floor on a single bad second. + let anchor = if measured == 0 { + self.target_kbps / 2 + } else { + measured.min(self.target_kbps) + }; + return (anchor as f32 * BACKOFF) as u32; + } + // Climbing is what digs the queue, so it needs the queue to be empty as + // well as the loss to be low. Without this the controller climbs all + // the way to the ceiling against a path it is already overrunning, + // because a path that queues instead of dropping reports no loss at all + // until the buffer finally overflows -- and by then the picture is + // seconds behind. + if loss < LOSS_INCREASE && (!known || self.backlog_ms < QUEUE_CLIMB_MS) { + self.reason = Reason::Climbing; + return self + .target_kbps + .saturating_add(self.limits.ceiling_kbps / CLIMB_FRACTION); + } + self.reason = Reason::Holding; + self.target_kbps + } + + fn decide_from_path(&mut self, path: PathView) -> u32 { + match path.estimate_kbps() { + Some(estimate) => { + self.reason = Reason::Fallback; + estimate + } + None => { + // Nothing said and nothing visible. Holding a high target on no + // evidence is how the original failure sustained itself, so this + // decays rather than holds. + self.reason = Reason::Blind; + (self.target_kbps as f32 * BACKOFF) as u32 + } + } + } +} + +/// Whether a change is big enough to be worth actuating. +/// +/// Reaching either end of the band always is: a target pinned to the floor or +/// the ceiling is a fact worth stating even when the step to it was small. +pub fn worth_sending(current: u32, next: u32, limits: Limits) -> bool { + if current == next { + return false; + } + if next == limits.floor_kbps() || next == limits.ceiling_kbps { + return true; + } + let change = current.abs_diff(next) as f32; + change >= current as f32 * DEADBAND +} + +#[cfg(test)] +mod tests { + use super::*; + + const CEILING: u32 = 10_000; + + fn limits() -> Limits { + Limits::new(CEILING) + } + + fn controller() -> Controller { + Controller::new(limits()) + } + + /// A second in which `released` of `total` frames made it, carrying + /// `goodput_kbps` of completed video. + fn report(released: u32, incomplete: u32, goodput_kbps: u64) -> ReceiverReport { + ReceiverReport { + goodput_bps: goodput_kbps * 1000, + released, + incomplete, + never_arrived: 0, + rtt_ms: 180, + } + } + + fn healthy() -> ReceiverReport { + report(60, 0, 9_800) + } + + /// A path with `backlog` bytes handed over and not yet gone. + fn backlogged(backlog: u64) -> PathView { + PathView { + cwnd_bytes: Some(13_000), + rtt_ms: Some(180), + backlog_bytes: Some(backlog), + } + } + + /// The second reported failure, as measured over a 1000-mile link. + /// + /// Every frame arrived and every frame completed -- zero loss, zero QUIC + /// congestion events, a flat 180 ms round trip for the whole session -- and + /// the picture was still several seconds behind, because the offer was + /// several times what the path carried and the difference was sitting in + /// the hub's own send buffer. A controller reading loss alone sees a + /// perfect path here and climbs to the ceiling against it. + #[test] + fn a_path_that_queues_instead_of_dropping_must_not_read_as_healthy() { + let mut c = controller(); + // Two megabits get through; a megabyte is already queued behind them. + let arriving = report(60, 0, 2_000); + let path = backlogged(1024 * 1024); + + for _ in 0..30 { + c.tick(1, Some(arriving), path, false); + } + + assert_ne!( + c.reason(), + Reason::Climbing, + "climbing against a path with four seconds of backlog" + ); + assert!( + c.target_kbps() < 2_000, + "target {} kbps is at or above what is getting through, so the \ + queue can only grow", + c.target_kbps() + ); + } + + /// The sawtooth: floor, climb to ceiling, collapse, repeat every 20 s. + /// + /// Loss alone cannot break this cycle, because on a queueing path loss only + /// appears once the buffer finally overflows -- long after the latency has + /// made the session unplayable, and by then the queue is deep enough that + /// backing off to the floor is the only way out. + #[test] + fn the_target_settles_instead_of_sawtoothing() { + let mut c = controller(); + // A steady 2 Mbps path. The backlog is what the last second of + // over-sending left behind, drained at what actually gets through. + let mut backlog: i64 = 0; + let mut seen = Vec::new(); + + for _ in 0..60 { + let target = c.target_kbps(); + // Whatever was asked for above 2 Mbps piles up; the rest drains. + backlog = (backlog + (i64::from(target) - 2_000) * 1000 / 8).clamp(0, 4 * 1024 * 1024); + c.tick( + 1, + Some(report(60, 0, 2_000)), + backlogged(backlog as u64), + false, + ); + seen.push(c.target_kbps()); + } + + let settled = &seen[30..]; + let (lo, hi) = ( + *settled.iter().min().unwrap(), + *settled.iter().max().unwrap(), + ); + assert!( + hi - lo <= 1_000, + "target still swinging between {lo} and {hi} kbps after 30 seconds" + ); + assert!( + hi <= 2_400, + "settled at {hi} kbps against a path carrying 2000" + ); + } + + /// The reported depth is what the next session will be judged on, so it + /// has to be right: bytes over the rate they leave at, in milliseconds. + #[test] + fn the_queue_depth_is_reported_as_measured() { + let mut c = controller(); + // 250 kB queued behind a 2 Mbps drain is exactly one second. + c.tick(1, Some(report(60, 0, 2_000)), backlogged(250_000), false); + assert_eq!(c.backlog_ms(), 1_000); + + // A transport that cannot say must report no queue, not a wrong one. + c.tick(1, Some(report(60, 0, 2_000)), PathView::default(), false); + assert_eq!(c.backlog_ms(), 0); + } + + /// The backlog must not become a reason never to climb again. + #[test] + fn a_clear_queue_still_climbs() { + let mut c = controller(); + c.set_ceiling(4_000); + let clear = backlogged(0); + for _ in 0..5 { + c.tick(1, Some(report(60, 0, 3_900)), clear, false); + } + assert_eq!(c.reason(), Reason::Climbing); + } + + /// The reported failure, as measured: a 10 Mbps offer into a path carrying + /// under three, where *no* frame completed for minutes. + #[test] + fn the_reported_failure_is_corrected_in_seconds() { + let mut c = controller(); + assert_eq!(c.target_kbps(), CEILING); + + let mut ticks = 0; + while c.target_kbps() > 2_800 && ticks < 10 { + // 46 frames a second starting to arrive, none of them completing. + c.tick(1, Some(report(0, 46, 0)), PathView::default(), false); + ticks += 1; + } + assert!( + c.target_kbps() <= 2_800, + "still at {} kbps after {ticks} seconds", + c.target_kbps(), + ); + assert!( + ticks <= 5, + "took {ticks} seconds to stop overrunning the path" + ); + } + + /// With a goodput measurement to anchor on it should take one step, not + /// several -- that is the whole reason for backing off to what arrived + /// rather than to a fraction of what was asked for. + #[test] + fn a_measured_goodput_is_corrected_in_one_step() { + let mut c = controller(); + let sent = c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false); + assert_eq!(sent, Some(c.target_kbps())); + assert!( + c.target_kbps() < 2_850, + "backed off to {} kbps, which is above what actually arrived", + c.target_kbps(), + ); + } + + /// The negative that matters most: a path that is fine must be left alone. + #[test] + fn a_healthy_path_is_not_wandered_away_from() { + let mut c = controller(); + // The first decision is always stated. The encoder started at whatever + // its environment gave it and this end cannot know that matches, so the + // target is asserted once rather than assumed. + assert_eq!( + c.tick(1, Some(healthy()), PathView::default(), false), + Some(CEILING) + ); + for _ in 0..60 { + assert_eq!( + c.tick(1, Some(healthy()), PathView::default(), false), + None, + "a healthy path produced a bitrate change", + ); + } + assert_eq!(c.target_kbps(), CEILING); + } + + #[test] + fn the_first_decision_is_always_stated() { + // Otherwise a session whose path happens to match its ceiling never + // tells the encoder anything, and the encoder keeps whatever its + // environment gave it -- which is the failure this replaces. + let mut c = controller(); + assert_eq!( + c.tick(1, Some(healthy()), PathView::default(), false), + Some(CEILING), + "the opening target was never stated", + ); + // And not repeated, now that the encoder has been told. + assert_eq!(c.tick(1, Some(healthy()), PathView::default(), false), None); + } + + #[test] + fn a_small_change_is_not_worth_saying() { + // Below the deadband nothing is sent, so the log and the encoder are not + // touched sixty times a minute for changes nobody could see. + let l = limits(); + assert!(!worth_sending(5_000, 5_200, l)); + assert!(worth_sending(5_000, 5_600, l)); + } + + #[test] + fn reaching_either_end_of_the_band_is_always_worth_saying() { + let l = limits(); + assert!(worth_sending(l.floor_kbps() + 1, l.floor_kbps(), l)); + assert!(worth_sending(CEILING - 1, CEILING, l)); + } + + #[test] + fn the_ceiling_is_never_exceeded() { + let mut c = controller(); + for _ in 0..200 { + c.tick(1, Some(healthy()), PathView::default(), false); + assert!(c.target_kbps() <= CEILING, "{} kbps", c.target_kbps()); + } + } + + #[test] + fn the_floor_is_never_gone_below() { + let mut c = controller(); + for _ in 0..200 { + c.tick(1, Some(report(0, 60, 0)), PathView::default(), false); + assert!( + c.target_kbps() >= c.limits().floor_kbps(), + "{} kbps is below the floor", + c.target_kbps(), + ); + } + } + + #[test] + fn it_climbs_back_after_a_bad_patch() { + let mut c = controller(); + for _ in 0..5 { + c.tick(1, Some(report(0, 46, 0)), PathView::default(), false); + } + let bottom = c.target_kbps(); + for _ in 0..40 { + c.tick(1, Some(healthy()), PathView::default(), false); + } + assert!( + c.target_kbps() > bottom, + "never recovered from {bottom} kbps" + ); + assert_eq!(c.target_kbps(), CEILING, "did not climb all the way back"); + } + + #[test] + fn a_second_the_hub_starved_is_not_read_as_a_bad_path() { + // While a client resynchronises its deltas are withheld, so fewer frames + // are sent, fewer arrive, goodput reads low and loss reads high. Backing + // off on that would cut the bitrate in response to the hub's own + // decision -- and make every stutter cost bandwidth as well. + let mut c = controller(); + assert_eq!( + c.tick(1, Some(healthy()), PathView::default(), false), + Some(CEILING) + ); + + let starved = report(0, 46, 0); + for _ in 0..10 { + assert_eq!(c.tick(1, Some(starved), PathView::default(), true), None); + } + assert_eq!(c.target_kbps(), CEILING, "the hub cut its own bitrate"); + assert_eq!(c.reason(), Reason::SelfInflicted); + } + + #[test] + fn a_genuinely_bad_second_still_acts_once_the_hub_stops_starving_it() { + // The flag holds, it does not blind. The moment a second is the path's + // own, the same evidence is acted on. + let mut c = controller(); + c.tick(1, Some(healthy()), PathView::default(), false); + c.tick(1, Some(report(0, 46, 0)), PathView::default(), true); + assert_eq!(c.target_kbps(), CEILING); + + c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false); + assert!(c.target_kbps() < 2_850, "a real bad second was ignored too"); + } + + #[test] + fn a_starved_second_does_not_count_towards_silence() { + // It is not a missing report -- one arrived, it just cannot be read. + // Counting it as silence would slide towards the path-based fallback and + // then to decaying blind, which is a different wrong answer. + let mut c = controller(); + c.tick(1, Some(healthy()), PathView::default(), false); + for _ in 0..10 { + c.tick(1, Some(report(0, 60, 0)), PathView::default(), true); + } + assert_eq!(c.reason(), Reason::SelfInflicted); + assert_eq!(c.target_kbps(), CEILING); + } + + #[test] + fn a_hand_set_bitrate_stands_the_controller_down() { + // How this class of bug gets diagnosed. A controller that overrode a + // person's setting a second later would take the tool away. + let mut c = controller(); + c.note_manual_target(1_000); + for _ in 0..30 { + assert_eq!( + c.tick(1, Some(report(0, 60, 0)), PathView::default(), false), + None + ); + } + assert_eq!(c.target_kbps(), 1_000); + assert_eq!(c.reason(), Reason::Manual); + } + + #[test] + fn constant_quality_has_no_bitrate_to_decide() { + let mut c = controller(); + c.set_constant_quality(true); + assert_eq!( + c.tick(1, Some(report(0, 60, 0)), PathView::default(), false), + None + ); + assert_eq!(c.reason(), Reason::ConstantQuality); + } + + #[test] + fn silence_is_tolerated_briefly_and_then_acted_on() { + let mut c = controller(); + // A missed report or two says nothing; the path was fine a second ago. + for _ in 0..(SILENT_TICKS_BEFORE_FALLBACK - 1) { + assert_eq!(c.tick(1, None, PathView::default(), false), None); + assert_eq!(c.target_kbps(), CEILING); + } + // Past that, with nothing visible from this end either, it decays rather + // than holding a high target on no evidence at all -- holding is how the + // original failure sustained itself. + c.tick(1, None, PathView::default(), false); + assert_eq!(c.reason(), Reason::Blind); + assert!(c.target_kbps() < CEILING); + } + + #[test] + fn this_ends_view_is_used_only_when_the_far_end_is_silent() { + let mut c = controller(); + let path = PathView { + cwnd_bytes: Some(120_000), + rtt_ms: Some(200), + backlog_bytes: None, + }; + // A report present means the path view is ignored, however tempting. + c.tick(1, Some(healthy()), path, false); + assert_eq!(c.reason(), Reason::Climbing); + + for _ in 0..SILENT_TICKS_BEFORE_FALLBACK { + c.tick(1, None, path, false); + } + assert_eq!(c.reason(), Reason::Fallback); + // 120 KB in flight per 200 ms is 4.8 Mbps. + assert_eq!(c.target_kbps(), 4_800); + } + + #[test] + fn nothing_is_decided_while_nobody_is_connected() { + // A box waiting for its first client used to read the silence as a dead + // path and wind itself down to the floor, then jump back up the moment + // somebody arrived. There is no path to have an opinion about yet. + let mut c = controller(); + for _ in 0..30 { + assert_eq!(c.tick(0, None, PathView::default(), false), None); + } + assert_eq!(c.target_kbps(), CEILING); + assert_eq!(c.reason(), Reason::NoClients); + } + + #[test] + fn a_lowered_ceiling_can_be_raised_again() { + // The trap in folding the box's ceiling together with the one in force: + // after lowering, the only number left to compare against is the lowered + // one, so the client can never get back up. + let mut c = controller(); + c.set_ceiling(2_000); + assert_eq!(c.limits().ceiling_kbps, 2_000); + c.set_ceiling(5_000); + assert_eq!(c.limits().ceiling_kbps, 5_000); + assert_eq!(c.box_ceiling_kbps(), CEILING); + } + + #[test] + fn a_ceiling_change_is_restated_to_the_encoder() { + // The encoder is the thing that has to act on it, and a target that + // happens to land on the same number is still a different instruction + // when the band around it moved. + let mut c = controller(); + assert!( + c.tick(1, Some(healthy()), PathView::default(), false) + .is_some() + ); + c.set_ceiling(3_000); + assert_eq!( + c.tick(1, Some(healthy()), PathView::default(), false), + Some(3_000) + ); + } + + #[test] + fn a_client_may_lower_its_ceiling_but_not_raise_it() { + let mut c = controller(); + c.set_ceiling(4_000); + assert_eq!(c.limits().ceiling_kbps, 4_000); + assert!(c.target_kbps() <= 4_000, "the target outlived its ceiling"); + + c.set_ceiling(50_000); + assert_eq!( + c.limits().ceiling_kbps, + CEILING, + "a client raised the ceiling the tier bought", + ); + } + + #[test] + fn a_ceiling_under_the_floor_does_not_invert_the_band() { + // A misconfigured tier must not produce a floor above its own ceiling, + // which would put every target above the limit it exists to enforce. + let l = Limits::new(500); + assert!(l.floor_kbps() <= l.ceiling_kbps); + assert_eq!(l.clamp(10_000), 500); + } + + #[test] + fn a_silent_second_is_not_read_as_a_healthy_one() { + // No frames at all accounted for: nothing sent, or nothing arrived. + // Reading it as no loss would climb straight into a path that may be + // carrying nothing. + let mut c = controller(); + let empty = ReceiverReport::default(); + assert_eq!(empty.loss(), None); + c.tick(1, Some(empty), PathView::default(), false); + assert_eq!(c.target_kbps(), CEILING, "climbed on an empty report"); + } +} diff --git a/apps/neshub/src/dgram.rs b/apps/neshub/src/dgram.rs index 77ef7bd3..3781858f 100644 --- a/apps/neshub/src/dgram.rs +++ b/apps/neshub/src/dgram.rs @@ -143,6 +143,73 @@ impl DatagramSender { } } +/// How long deltas may be withheld from a client waiting to resynchronise. +/// +/// A bound rather than a belief. Withholding is correct only while the keyframe +/// is actually coming, and if the encoder never produces one -- it refused, it +/// died, the request never reached it -- then withholding forever turns a +/// recoverable freeze into a permanent black screen. Past this the deltas go out +/// again: useless to a desynchronised decoder, but "useless" beats "nothing at +/// all, forever" when the assumption behind the suppression has been proven +/// wrong. +const MAX_RESYNC_WITHHOLD: std::time::Duration = std::time::Duration::from_secs(2); + +/// Whether to send delta frames to a client that cannot decode them yet. +/// +/// A client that has lost synchronisation asks for a keyframe, and until one +/// arrives every delta frame sent to it is undecodable -- it predicts from +/// pictures that client does not have. Those frames are not merely wasted: quinn +/// writes DATAGRAM frames into a packet before STREAM frames, so a steady stream +/// of deltas takes the space the keyframe needs and starves the one frame that +/// would end the freeze. That is the loop behind "26 keyframe fallbacks, 19 IDR +/// requests": the recovery frame could not get out past the frames that needed +/// it to arrive first. +/// +/// So while a client is waiting, its deltas are dropped rather than sent. +#[derive(Debug, Default)] +pub struct ResyncGate { + /// When this client started waiting, or `None` if it is not. + waiting_since: Option, + /// Deltas dropped during the current wait. + withheld: u64, + /// Set once the withhold bound is passed, so it is said once per wait. + gave_up: bool, +} + +impl ResyncGate { + /// Whether this delta frame should go out. + /// + /// `awaiting` is whether the client has asked for a keyframe and not yet + /// been sent one. + pub fn admit_delta(&mut self, awaiting: bool, now: std::time::Instant) -> bool { + if !awaiting { + self.end_wait(); + return true; + } + let since = *self.waiting_since.get_or_insert(now); + if now.duration_since(since) >= MAX_RESYNC_WITHHOLD { + self.gave_up = true; + return true; + } + self.withheld += 1; + false + } + + /// A keyframe has gone out, so the wait is over. + pub fn note_keyframe(&mut self) -> Option<(u64, bool)> { + let withheld = self.withheld; + let gave_up = self.gave_up; + self.end_wait(); + (withheld > 0).then_some((withheld, gave_up)) + } + + fn end_wait(&mut self) { + self.waiting_since = None; + self.withheld = 0; + self.gave_up = false; + } +} + /// Frames arriving on `rx` are numbered and sent — deltas as datagrams, /// keyframes on a reliable stream each when `keyframes_reliable` is set. /// @@ -161,6 +228,12 @@ pub async fn run_datagram_writer( mut rx: tokio::sync::mpsc::UnboundedReceiver>, relay_ms: Option>, keyframes_reliable: bool, + // Set while this client has asked for a keyframe and not yet been sent + // one. Video only; audio has no such notion. + awaiting_keyframe: Option>, + // Counts frames withheld during a resynchronisation, so the bitrate + // controller can tell a second it starved from a second the path did. + withheld: Option>, ) { let sender = DatagramSender::new(conn.clone(), kind); let keyframes = keyframes_reliable.then(|| KeyframeSender::new(conn, sender.clone())); @@ -170,33 +243,54 @@ pub async fn run_datagram_writer( // debug. Losing datagram support entirely is not, and is worth a warning — // but only the first time, since it will then be true for every frame. let mut warned_unsupported = false; - - // Where this writer's second went. - // - // The pair that matters is the first two: if frames arrive here already - // 43 ms apart then the hole was made upstream, in the encoder or on the IPC - // hop, and nothing in this file can be the cause. If they arrive evenly and - // leave unevenly, it is made here. A client measured exactly that hole in - // video datagram arrivals while audio — same connection, same congestion - // window, its own writer — stayed at 8 ms. - let mut last_in = std::time::Instant::now(); - let mut worst_in_gap = std::time::Duration::ZERO; - let mut worst_send = std::time::Duration::ZERO; - let mut frames: u32 = 0; - let mut last_pace = std::time::Instant::now(); + let mut resync = ResyncGate::default(); while let Some(payload) = rx.recv().await { let t0 = std::time::Instant::now(); - worst_in_gap = worst_in_gap.max(t0.duration_since(last_in)); - last_in = t0; - frames += 1; - body.clear(); nesprotocol::encode_frame_body(&mut body, MSG_DATA, seq, &payload); // A keyframe goes on a stream of its own when one will take it. The // relay timing below is not recorded for it: the write is asynchronous // by design, so the time this loop spent on it says nothing. + // A client that cannot decode has asked for a keyframe; until it gets + // one, everything else sent to it is undecodable and takes the space the + // keyframe needs. See `ResyncGate`. + if let Some(ref awaiting) = awaiting_keyframe { + if nesprotocol::reliable::video_is_keyframe(&payload) { + awaiting.store(false, Ordering::Relaxed); + if let Some((withheld, gave_up)) = resync.note_keyframe() { + debug!( + "{label}: resynchronised after withholding {withheld} frame(s){}", + if gave_up { + ", having given up waiting" + } else { + "" + } + ); + } + } else if !resync + .admit_delta(awaiting.load(Ordering::Relaxed), std::time::Instant::now()) + { + // The sequence number deliberately does *not* advance. A + // withheld frame was never sent, so leaving a hole would make + // the receiver count it as lost -- and that count is what the + // bitrate controller reads. The hub would then lower the + // bitrate because of frames it chose not to send, which is a + // controller reacting to its own decision rather than to the + // path. Reusing the number is safe precisely because nothing + // went out under it. + // + // Saying so is the other half: the receiver still reports fewer + // frames this second, and without this count the controller has + // no way to tell that second from one the path ruined. + if let Some(ref withheld) = withheld { + withheld.fetch_add(1, Ordering::Relaxed); + } + continue; + } + } + if let Some(ref keyframes) = keyframes && video_wants_reliable(&payload) { @@ -229,19 +323,6 @@ pub async fn run_datagram_writer( } Err(e) => debug!("{label}: dropping frame {seq}: {e}"), } - worst_send = worst_send.max(t0.elapsed()); - - if last_pace.elapsed() >= std::time::Duration::from_secs(1) { - last_pace = std::time::Instant::now(); - debug!( - "{label}: {frames} frames, worst gap between frames in {:.1}ms, worst send {:.1}ms", - worst_in_gap.as_secs_f64() * 1000.0, - worst_send.as_secs_f64() * 1000.0, - ); - worst_in_gap = std::time::Duration::ZERO; - worst_send = std::time::Duration::ZERO; - frames = 0; - } seq = seq.wrapping_add(1); } @@ -252,3 +333,95 @@ pub async fn run_datagram_writer( } debug!("{label} datagram writer exiting (channel closed)"); } + +#[cfg(test)] +mod resync_gate_tests { + use super::{MAX_RESYNC_WITHHOLD, ResyncGate}; + use std::time::{Duration, Instant}; + + #[test] + fn a_client_that_can_decode_gets_everything() { + // The negative that matters: nothing is withheld from a healthy client. + let mut gate = ResyncGate::default(); + let now = Instant::now(); + for i in 0..1000 { + assert!(gate.admit_delta(false, now + Duration::from_millis(i))); + } + } + + #[test] + fn a_waiting_client_is_not_sent_frames_it_cannot_decode() { + let mut gate = ResyncGate::default(); + let now = Instant::now(); + for i in 0..60 { + assert!( + !gate.admit_delta(true, now + Duration::from_millis(i * 16)), + "frame {i} went to a client with no reference to decode it against", + ); + } + } + + #[test] + fn the_keyframe_ends_the_wait_and_reports_the_cost() { + let mut gate = ResyncGate::default(); + let now = Instant::now(); + for i in 0..5 { + gate.admit_delta(true, now + Duration::from_millis(i * 16)); + } + assert_eq!(gate.note_keyframe(), Some((5, false))); + // And the next delta goes out immediately. + assert!(gate.admit_delta(false, now + Duration::from_millis(100))); + } + + #[test] + fn a_keyframe_with_nothing_withheld_says_nothing() { + // So an ordinary periodic keyframe does not log a recovery that did not + // happen. + let mut gate = ResyncGate::default(); + assert_eq!(gate.note_keyframe(), None); + } + + #[test] + fn withholding_gives_up_rather_than_going_dark_forever() { + // The bound. Withholding is only correct while the keyframe is actually + // coming; if the encoder never produces one, suppressing forever turns a + // recoverable freeze into a permanent black screen. Undecodable frames + // beat no frames once the assumption is disproven. + let mut gate = ResyncGate::default(); + let now = Instant::now(); + assert!(!gate.admit_delta(true, now)); + assert!(!gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD - Duration::from_millis(1))); + assert!( + gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD), + "still withholding after the keyframe plainly is not coming", + ); + } + + #[test] + fn giving_up_is_reported_when_the_keyframe_finally_lands() { + let mut gate = ResyncGate::default(); + let now = Instant::now(); + gate.admit_delta(true, now); + gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD); + let (withheld, gave_up) = gate.note_keyframe().expect("something was withheld"); + assert_eq!(withheld, 1); + assert!(gave_up, "the wait timed out and nothing said so"); + } + + #[test] + fn a_second_wait_starts_fresh() { + // Otherwise the first wait's elapsed time carries over and the second is + // abandoned immediately, or its count reports the wrong recovery. + let mut gate = ResyncGate::default(); + let now = Instant::now(); + gate.admit_delta(true, now); + gate.note_keyframe(); + + let later = now + Duration::from_secs(60); + assert!( + !gate.admit_delta(true, later), + "the new wait was not honoured" + ); + assert_eq!(gate.note_keyframe(), Some((1, false))); + } +} diff --git a/apps/neshub/src/main.rs b/apps/neshub/src/main.rs index 28b8afde..b5fd2e36 100644 --- a/apps/neshub/src/main.rs +++ b/apps/neshub/src/main.rs @@ -1,3 +1,4 @@ +mod control; mod dgram; mod ipc_listener; mod keyframe; @@ -14,7 +15,7 @@ use iroh::endpoint::presets; use crate::session::SessionManager; use crate::ticket::NestriTicket; -use nesprotocol::ALPN; +use nesprotocol::{ALPNS, Carrier}; #[derive(Parser, Debug)] #[command(name = "neshub")] @@ -73,6 +74,16 @@ struct Args { #[arg(long, env = "NESTRI_AUDIO_BITRATE", default_value_t = 64)] audio_bitrate_per_channel: u32, + /// Ceiling on the video bitrate, in kbps. + /// + /// Set by `nesinit` from the boot descriptor's video limits, which come + /// from the tier the box was sized for. Absent means nobody said -- which is + /// not a licence to send whatever the encoder defaults to, since that is + /// precisely how every session came to offer 10 Mbps regardless of what the + /// path could carry. Unset is reported, and a conservative ceiling is used. + #[arg(long, env = "NESTRI_MAX_BITRATE")] + max_bitrate_kbps: Option, + /// Socket nescope sends screenshots on. neshub listens; nescope dials out. #[arg( long, @@ -82,6 +93,14 @@ struct Args { screenshot_ipc: PathBuf, } +/// What to assume when nobody said. +/// +/// Deliberately modest. A ceiling that was never set should not behave like an +/// unlimited one: the whole failure this exists to fix was a session offering +/// 10 Mbps into a path carrying under three, because no number had ever been +/// chosen and the encoder's own default stood in for one. +const DEFAULT_MAX_BITRATE_KBPS: u32 = 4_000; + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -93,7 +112,7 @@ async fn main() -> Result<()> { let args = Args::parse(); let mut builder = iroh::Endpoint::builder(presets::N0) - .alpns(vec![ALPN.to_vec()]) + .alpns(ALPNS.iter().map(|a| a.to_vec()).collect::>()) .transport_config(crate::dgram::media_transport_config()); match args.relay.as_str() { @@ -117,6 +136,15 @@ async fn main() -> Result<()> { } } + match args.max_bitrate_kbps { + Some(kbps) => tracing::info!("video ceiling: {kbps} kbps, from the boot descriptor"), + None => tracing::warn!( + "no video ceiling on the boot descriptor; using {DEFAULT_MAX_BITRATE_KBPS} kbps. \ + A box sized by a tier is told its ceiling -- if this is one, the descriptor did \ + not carry it." + ), + } + let endpoint = builder.bind().await?; let endpoint_addr = endpoint.addr(); let ep_id = endpoint_addr.id; @@ -134,6 +162,14 @@ async fn main() -> Result<()> { let session_manager = Arc::new(SessionManager::new()); + // One controller for the box, not one per client: there is one encoder, so + // there is one bitrate, and the client having the worst time is the one it + // has to answer. + let box_ceiling_kbps = args.max_bitrate_kbps.unwrap_or(DEFAULT_MAX_BITRATE_KBPS); + let controller = Arc::new(tokio::sync::Mutex::new(control::Controller::new( + control::Limits::new(box_ceiling_kbps), + ))); + // IDR / encode settings command channel: input reader → nescapture let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::>(); { @@ -181,23 +217,105 @@ async fn main() -> Result<()> { args.audio_channels, args.audio_bitrate_per_channel ); + let controller = controller.clone(); + let cmd_tx = cmd_tx.clone(); tokio::spawn(async move { + // Ten times a second, and only when asked for. Pairs with + // nescapture's rate probe: that measures how fast the encoder + // follows a new bitrate, this shows how fast the queue downstream + // of it responds, and the slower of the two is the fastest a + // control loop can usefully run. At one sample a second neither is + // visible -- a queue that fills and drains inside a second looks + // like a queue that was never there. + if std::env::var("NESHUB_BACKLOG_TRACE").is_ok_and(|v| v != "0" && !v.is_empty()) { + let mgr = mgr.clone(); + let controller = controller.clone(); + tokio::spawn(async move { + let mut fast = tokio::time::interval(std::time::Duration::from_millis(100)); + tracing::info!("backlog trace on, 10 Hz"); + loop { + fast.tick().await; + let backlogs = mgr.backlog_bytes().await; + if backlogs.is_empty() { + continue; + } + let target = controller.lock().await.target_kbps(); + for bytes in backlogs { + // Against the target rather than a measured drain: + // this is a trace to read afterwards, and a number + // divided by a second measurement is two things + // moving at once. + let ms = bytes.saturating_mul(8) / u64::from(target.max(1)); + tracing::info!("backlog {ms} ms ({bytes} bytes) at {target} kbps"); + } + } + }); + } + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); loop { interval.tick().await; + + // One decision a second, on the same tick as the stats, because + // a report describes the second that just passed and there is + // nothing to gain from deciding more often than they arrive. + { + let clients = mgr.client_count().await; + let (report, path, self_inflicted) = mgr.worst_report().await; + let mut controller = controller.lock().await; + if let Some(kbps) = controller.tick(clients, report, path, self_inflicted) { + let mut cmd = vec![nesprotocol::MSG_ENCODE_SETTINGS]; + nesprotocol::encode_bitrate_only(&mut cmd, kbps); + if cmd_tx.send(cmd).is_err() { + tracing::warn!("encoder command channel closed"); + } else { + // Trace, not info: under a path that keeps moving + // this fires every second, and a per-second line at + // info buries everything worth reading. + tracing::trace!( + "video ceiling {}/{} kbps: {:?}", + kbps, + controller.limits().ceiling_kbps, + controller.reason(), + ); + } + } + } + let clients = mgr.client_count().await as u8; - let bitrate = mgr.video_bitrate_bps(); + let (key_bps, delta_bps, keyframes) = mgr.video_breakdown(); + let (pipeline_p50_ms, pipeline_p95_ms, pipeline_max_ms) = mgr.pipeline_delays(); let audio_kbps = mgr.audio_bitrate_kbps(); let relay_ms = mgr.relay_ms(); - let mut buf = Vec::with_capacity(15); + let mut buf = Vec::with_capacity(34); nesprotocol::stats::encode_hub_stats( &mut buf, clients, - bitrate, + key_bps.saturating_add(delta_bps), relay_ms, audio_kbps, audio_channels, ); + { + let controller = controller.lock().await; + nesprotocol::stats::encode_video_breakdown( + &mut buf, + &nesprotocol::stats::VideoBreakdown { + key_bps, + delta_bps, + keyframes, + target_kbps: controller.target_kbps(), + ceiling_kbps: controller.limits().ceiling_kbps, + reason: controller.reason() as u8, + manual: u8::from(controller.mode() == nesprotocol::ControlMode::Manual), + box_ceiling_kbps: controller.box_ceiling_kbps(), + pipeline_p50_ms, + pipeline_p95_ms, + pipeline_max_ms, + backlog_ms: controller.backlog_ms().min(u32::from(u16::MAX)) as u16, + }, + ); + } mgr.broadcast_stats(buf).await; } }); @@ -262,18 +380,43 @@ async fn main() -> Result<()> { match incoming.await { Ok(conn) => { let remote_id = conn.remote_id(); - tracing::info!(remote = %remote_id.fmt_short(), "client connected"); - let session = session::ClientSession::new( + // A client opens one connection per kind of traffic, so the + // ALPN says which this is and the endpoint id says whose. + let Some(carrier) = Carrier::from_alpn(conn.alpn()) else { + tracing::warn!( + remote = %remote_id.fmt_short(), + "connection with an unknown ALPN; closing" + ); + conn.close(0u32.into(), b"unknown alpn"); + continue; + }; + tracing::info!( + remote = %remote_id.fmt_short(), + carrier = carrier.label(), + "client connected" + ); + mgr.attach( + remote_id, + carrier, conn.clone(), input_broadcast_tx.clone(), - session_manager.relay_ms_atomic(), cmd_tx.clone(), - ); - mgr.add_session(remote_id, session).await; + controller.clone(), + ) + .await; let mgr_clone = mgr.clone(); - let conn_clone = conn.clone(); tokio::spawn(async move { - conn_clone.closed().await; + conn.closed().await; + // Any one of them going means the session goes. A client + // left holding audio and input but no video is not a + // degraded session, it is a stuck one, and a clean + // reconnect is both simpler to reason about and quicker + // than whatever partial recovery would be built here. + tracing::debug!( + remote = %remote_id.fmt_short(), + carrier = carrier.label(), + "carrier closed, ending session" + ); mgr_clone.remove_session(&remote_id).await; }); } diff --git a/apps/neshub/src/session.rs b/apps/neshub/src/session.rs index 904de104..90d8d5ff 100644 --- a/apps/neshub/src/session.rs +++ b/apps/neshub/src/session.rs @@ -3,130 +3,321 @@ use std::sync::Arc; use iroh::endpoint::Connection; use tokio::sync::Mutex; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; -use nesprotocol::datagram::{DGRAM_AUDIO, DGRAM_VIDEO}; +use nesprotocol::datagram::{DGRAM_AUDIO, DGRAM_BUFFER_BYTES, DGRAM_VIDEO}; use nesprotocol::input::{INPUT_KEY, INPUT_MOUSE_BUTTON, INPUT_MOUSE_MOVE, INPUT_MOUSE_WHEEL}; -use nesprotocol::{BIDI_INPUT, STREAM_CURSOR, STREAM_STATS}; +use nesprotocol::{BIDI_CONTROL, BIDI_INPUT, Carrier, STREAM_CURSOR, STREAM_STATS}; +use nesprotocol::{ControlMode, ReceiverReport, decode_control_mode, decode_receiver_report}; use nesprotocol::{FRAME_HDR_LEN, STREAM_VERSION, encode_frame}; -use nesprotocol::{MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, MSG_INPUT_BATCH}; +use nesprotocol::{ + MSG_CLIENT_CAPS, MSG_CONTROL_MODE, MSG_ENCODE_SETTINGS, MSG_IDR_REQUEST, MSG_INPUT_BATCH, + MSG_RECEIVER_REPORT, +}; + +use crate::control::{Controller, PathView}; use crate::dgram::run_datagram_writer; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +/// One client, across the several connections it opens. +/// +/// A connection is the unit congestion control, pacing and the datagram send +/// buffer all work on, so a client that put everything on one connection had +/// one queue for everything -- and video is the only flow big enough to build +/// a queue, so video delayed audio and input with it. Each kind of traffic +/// therefore gets its own connection, and this is what reassembles them into +/// one client. They are correlated by endpoint id, which is the same for every +/// connection a client opens. +/// +/// Connections arrive in whatever order they are dialled, so every one is +/// optional until it turns up. Frames handed to a carrier that has not +/// connected yet are dropped, which is right: there is nothing to send them on. pub struct ClientSession { + /// The connection carrying the picture. + /// + /// The only one whose path is worth reading for control: it carries + /// essentially all the bytes, so it is the only one that can build a + /// backlog, and its congestion window is the one that describes where the + /// video is going. + video_conn: Option, + /// Kept only so it can be closed with the rest of the session. + other_conns: Vec, + /// Set while this client has asked for a keyframe and not yet been sent + /// one, so the writer knows its deltas are undecodable. + awaiting_keyframe: Arc, + /// Frames this client was not sent because it could not have decoded them. + /// + /// Read and cleared by the controller's tick: a second containing these is + /// a second the hub starved on purpose, and reading it as path evidence + /// would cut the bitrate in response to the hub's own decision. + withheld: Arc, + /// The most recent report from this client, if it has sent one. + /// + /// Overwritten rather than queued. A report describes the second that just + /// passed, and an older one is not evidence about now. + latest_report: Arc>>, + relay_ms: Arc, + input_broadcast: tokio::sync::broadcast::Sender>, + idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, + controller: Arc>, send_video: tokio::sync::mpsc::UnboundedSender>, send_audio: tokio::sync::mpsc::UnboundedSender>, send_cursor: tokio::sync::mpsc::UnboundedSender>, send_stats: tokio::sync::mpsc::UnboundedSender>, - _video_task: tokio::task::JoinHandle<()>, - _audio_task: tokio::task::JoinHandle<()>, - _cursor_task: tokio::task::JoinHandle<()>, - _stats_task: tokio::task::JoinHandle<()>, - _input_task: tokio::task::JoinHandle<()>, + /// Receiving ends, held until the carrier that drains them connects. + pending_video: Option>>, + pending_audio: Option>>, + pending_cursor: Option>>, + pending_stats: Option>>, + tasks: Vec>, +} + +impl Drop for ClientSession { + fn drop(&mut self) { + // Dropping a `JoinHandle` detaches its task rather than stopping it, + // so without this a removed session's readers and writers keep running + // until their connections time out on their own -- seconds later, and + // visible in the log as a session that had already gone still + // reporting. They have nothing left to serve; the session holding + // their channels is what is being dropped. + for task in &self.tasks { + task.abort(); + } + } } impl ClientSession { + /// A client with no connections yet. They attach as they are accepted. pub fn new( - conn: Connection, input_broadcast: tokio::sync::broadcast::Sender>, relay_ms: Arc, idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, + controller: Arc>, ) -> Self { let (video_tx, video_rx) = tokio::sync::mpsc::unbounded_channel::>(); let (audio_tx, audio_rx) = tokio::sync::mpsc::unbounded_channel::>(); let (cursor_tx, cursor_rx) = tokio::sync::mpsc::unbounded_channel::>(); let (stats_tx, stats_rx) = tokio::sync::mpsc::unbounded_channel::>(); - - // Delta frames and audio go out as datagrams; cursor, stats and input - // stay on reliable streams. See `nestri_protocol::datagram` for why. - // - // Video keyframes are the exception: each goes on a reliable stream of - // its own, because a lost keyframe freezes the picture until the next - // one instead of costing a single frame. See - // `nestri_protocol::reliable`. Audio is not offered the same path — it - // has no keyframes to promote. - let conn_v = conn.clone(); - let _video_task = tokio::spawn(async move { - run_datagram_writer(conn_v, DGRAM_VIDEO, "video", video_rx, Some(relay_ms), true).await - }); - - let conn_a = conn.clone(); - let _audio_task = tokio::spawn(async move { - run_datagram_writer(conn_a, DGRAM_AUDIO, "audio", audio_rx, None, false).await - }); - - let conn_c = conn.clone(); - let _cursor_task = tokio::spawn(async move { run_cursor_sender(conn_c, cursor_rx).await }); - - let conn_s = conn.clone(); - let _stats_task = tokio::spawn(async move { run_stats_sender(conn_s, stats_rx).await }); - - let conn_i = conn.clone(); - let _input_task = - tokio::spawn( - async move { run_input_reader(conn_i, input_broadcast, idr_cmd_tx).await }, - ); - Self { + video_conn: None, + other_conns: Vec::new(), + awaiting_keyframe: Arc::new(AtomicBool::new(false)), + withheld: Arc::new(AtomicU64::new(0)), + latest_report: Arc::new(std::sync::Mutex::new(None)), + relay_ms, + input_broadcast, + idr_cmd_tx, + controller, send_video: video_tx, send_audio: audio_tx, send_cursor: cursor_tx, send_stats: stats_tx, - _video_task, - _audio_task, - _cursor_task, - _stats_task, - _input_task, + pending_video: Some(video_rx), + pending_audio: Some(audio_rx), + pending_cursor: Some(cursor_rx), + pending_stats: Some(stats_rx), + tasks: Vec::new(), } } + /// Whether the picture has somewhere to go. + /// + /// A client with every connection but this one is connected and not yet + /// streaming, which for the controller is the same as not being there. + pub fn is_streaming(&self) -> bool { + self.video_conn.is_some() + } + + /// Take on one of this client's connections. + /// + /// A carrier arriving twice is the second one being dropped: the first is + /// already draining the channel, and two writers on one channel would split + /// the stream between them. + pub fn attach(&mut self, carrier: Carrier, conn: Connection) { + match carrier { + Carrier::Video => { + let Some(rx) = self.pending_video.take() else { + debug!("video carrier attached twice; ignoring the second"); + return; + }; + self.video_conn = Some(conn.clone()); + let relay_ms = self.relay_ms.clone(); + let awaiting = self.awaiting_keyframe.clone(); + let withheld = self.withheld.clone(); + // Keyframes go on reliable streams of their own, because a lost + // keyframe freezes the picture until the next one instead of + // costing a single frame. See `nesprotocol::reliable`. + self.tasks.push(tokio::spawn(async move { + run_datagram_writer( + conn, + DGRAM_VIDEO, + "video", + rx, + Some(relay_ms), + true, + Some(awaiting), + Some(withheld), + ) + .await + })); + } + Carrier::Audio => { + let Some(rx) = self.pending_audio.take() else { + debug!("audio carrier attached twice; ignoring the second"); + return; + }; + self.other_conns.push(conn.clone()); + // No reliable path offered: audio has no keyframes to promote, + // and on its own connection it is no longer queued behind any. + self.tasks.push(tokio::spawn(async move { + run_datagram_writer(conn, DGRAM_AUDIO, "audio", rx, None, false, None, None) + .await + })); + } + Carrier::Input => { + self.other_conns.push(conn.clone()); + let broadcast = self.input_broadcast.clone(); + self.tasks.push(tokio::spawn(async move { + run_input_reader(conn, broadcast).await + })); + } + Carrier::Control => { + let (Some(cursor_rx), Some(stats_rx)) = + (self.pending_cursor.take(), self.pending_stats.take()) + else { + debug!("control carrier attached twice; ignoring the second"); + return; + }; + self.other_conns.push(conn.clone()); + let conn_c = conn.clone(); + self.tasks.push(tokio::spawn(async move { + run_cursor_sender(conn_c, cursor_rx).await + })); + let conn_s = conn.clone(); + self.tasks.push(tokio::spawn(async move { + run_stats_sender(conn_s, stats_rx).await + })); + let reports = self.latest_report.clone(); + let awaiting = self.awaiting_keyframe.clone(); + let idr = self.idr_cmd_tx.clone(); + let controller = self.controller.clone(); + self.tasks.push(tokio::spawn(async move { + run_control_reader(conn, idr, reports, controller, awaiting).await + })); + } + } + } + + /// The latest report, cleared as it is taken. + /// + /// Taken rather than read so a client that stops reporting stops looking + /// healthy: a report left in place would be read again every second and the + /// controller would keep acting on a second that is long gone. + /// Frames withheld since the last call, cleared as it is read. + pub fn take_withheld(&self) -> u64 { + self.withheld.swap(0, Ordering::Relaxed) + } + + pub fn take_report(&self) -> Option { + self.latest_report.lock().ok()?.take() + } + + /// What this end can see of the path, from the route actually in use. + /// + /// A connection can hold several paths at once -- typically one through a + /// relay and one direct -- and only the selected one describes where the + /// media is going. + pub fn path_view(&self) -> PathView { + // The video connection or nothing. Every connection now has its own + // congestion window, and the one describing where the picture goes is + // the only one worth controlling against -- audio's window says + // nothing about whether the video is keeping up. + let Some(conn) = self.video_conn.as_ref() else { + return PathView::default(); + }; + let paths = conn.paths(); + let Some(path) = paths.iter().find(|p| p.is_selected()) else { + return PathView::default(); + }; + let stats = path.stats(); + PathView { + cwnd_bytes: Some(stats.cwnd), + rtt_ms: Some(path.rtt().as_millis().min(u128::from(u32::MAX)) as u32), + // What is left of the buffer says what is still in it. This is the + // only signal either end has that reports an overrun *before* it + // becomes loss, and it costs nothing to read. + backlog_bytes: Some( + (DGRAM_BUFFER_BYTES as u64) + .saturating_sub(conn.datagram_send_buffer_space() as u64), + ), + } + } + + /// Hand one frame to this client's writer. + /// + /// A failure here is debug rather than warn because it is per frame: the + /// only way it fails is a closed channel, which means the writer is already + /// gone, and sixty warnings a second on the way down bury whatever actually + /// ended the session. pub fn send_video_frame(&self, data: Vec) { if let Err(e) = self.send_video.send(data) { - warn!("failed to send video data: {e}"); + debug!("failed to send video data: {e}"); } } pub fn send_audio_packet(&self, data: Vec) { if let Err(e) = self.send_audio.send(data) { - warn!("failed to send audio data: {e}"); + debug!("failed to send audio data: {e}"); } } pub fn send_cursor_data(&self, data: Vec) { if let Err(e) = self.send_cursor.send(data) { - warn!("failed to send cursor data: {e}"); + debug!("failed to send cursor data: {e}"); } } pub fn send_stats_data(&self, data: Vec) { if let Err(e) = self.send_stats.send(data) { - warn!("failed to send stats data: {e}"); + debug!("failed to send stats data: {e}"); } } } -async fn run_input_reader( +#[allow(clippy::too_many_arguments)] +/// Open the client's bidi stream, announce what it is, and read framed +/// messages off it until it ends. +/// +/// Shared because input and control differ only in which messages they expect: +/// the framing, the announcement and the reconnect behaviour are the same, and +/// two copies of that would drift. +async fn run_framed_reader( conn: Connection, - input_broadcast: tokio::sync::broadcast::Sender>, - idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, -) { - debug!("input reader started"); + stream_type: u8, + label: &'static str, + mut handle: F, +) where + F: FnMut(u8, Vec) -> Fut, + Fut: std::future::Future, +{ + debug!("{label} reader started"); loop { - debug!("input reader opening bidi stream"); match conn.open_bi().await { Ok((mut send, mut recv)) => { - debug!("input bidi stream opened, writing type+version byte"); - if send.write_all(&[BIDI_INPUT, STREAM_VERSION]).await.is_err() { - debug!("input type byte write failed"); + if send + .write_all(&[stream_type, STREAM_VERSION]) + .await + .is_err() + { + debug!("{label} type byte write failed"); break; } let _ = send.finish(); - debug!("input bidi stream ready, reading framed events"); - + debug!("{label} bidi stream ready, reading framed messages"); loop { - // Read uniform frame: [4B len][1B type][2B seq][payload] + // [4B len][1B type][2B seq][payload] let mut len_buf = [0u8; 4]; if recv.read_exact(&mut len_buf).await.is_err() { break; @@ -139,97 +330,187 @@ async fn run_input_reader( if recv.read_exact(&mut frame).await.is_err() { break; } - let msg_type = frame[0]; - let _seq = u16::from_le_bytes([frame[1], frame[2]]); - let payload = &frame[3..]; - - match msg_type { - MSG_INPUT_BATCH => { - let mut offset = 0; - while offset < payload.len() { - if offset + 1 > payload.len() { - break; - } - match payload[offset] { - INPUT_KEY => { - if offset + 4 > payload.len() { - break; - } - let raw = vec![ - INPUT_KEY, - payload[offset + 1], - payload[offset + 2], - payload[offset + 3], - ]; - let _ = input_broadcast.send(raw); - offset += 4; - } - INPUT_MOUSE_MOVE => { - if offset + 5 > payload.len() { - break; - } - let mut raw = Vec::with_capacity(5); - raw.push(INPUT_MOUSE_MOVE); - raw.extend_from_slice(&payload[offset + 1..offset + 5]); - let _ = input_broadcast.send(raw); - offset += 5; - } - INPUT_MOUSE_BUTTON => { - if offset + 3 > payload.len() { - break; - } - let raw = vec![ - INPUT_MOUSE_BUTTON, - payload[offset + 1], - payload[offset + 2], - ]; - let _ = input_broadcast.send(raw); - offset += 3; - } - INPUT_MOUSE_WHEEL => { - if offset + 5 > payload.len() { - break; - } - let mut raw = Vec::with_capacity(5); - raw.push(INPUT_MOUSE_WHEEL); - raw.extend_from_slice(&payload[offset + 1..offset + 5]); - let _ = input_broadcast.send(raw); - offset += 5; - } - _ => { - debug!("unknown input event type: {}", payload[offset]); - break; - } - } - } - } - MSG_IDR_REQUEST => { - info!("received IDR request from client"); - let _ = idr_cmd_tx.send(vec![MSG_IDR_REQUEST]); - } - MSG_ENCODE_SETTINGS => { - info!( - "received encode settings from client ({} bytes)", - payload.len() - ); - let mut cmd = Vec::with_capacity(1 + payload.len()); - cmd.push(MSG_ENCODE_SETTINGS); - cmd.extend_from_slice(payload); - let _ = idr_cmd_tx.send(cmd); - } - _ => { - debug!("unknown bidi msg type: {}", msg_type); - } - } + handle(frame[0], frame[3..].to_vec()).await; } } Err(e) => { - debug!("input open_bi failed: {e}"); + debug!("{label} open_bi failed: {e}"); break; } } } - debug!("input reader exiting"); + debug!("{label} reader exiting"); +} + +/// Split a batch of input events into the individual events to broadcast. +/// +/// Its own function because it is the only part of the input path with +/// arithmetic in it, and arithmetic is the part that can be wrong while +/// everything still runs. A key event is four bytes and was read as three: +/// the keycode lost its high byte, the offset finished one short, and every +/// later event in the batch was read starting one byte inside the one before +/// it. Nothing failed -- events were forwarded, the stream stayed up, and the +/// only sign was a keycode appearing in the log as an event type. +/// +/// Stops at the first malformed event rather than trying to resynchronise. A +/// batch is built by one sender in one write; if it does not parse, the +/// disagreement is about the format and skipping ahead would only invent +/// events nobody sent. +fn split_input_events(payload: &[u8]) -> Vec> { + let mut out = Vec::new(); + let mut offset = 0usize; + while offset < payload.len() { + // Type, then the event's own fields. The widths are the protocol's, + // not this function's: see `nesprotocol::input`. + let len = match payload[offset] { + // [type][up/down][keycode u16 LE] + INPUT_KEY => 4, + // [type][dx i16 LE][dy i16 LE] + INPUT_MOUSE_MOVE | INPUT_MOUSE_WHEEL => 5, + // [type][button][up/down] + INPUT_MOUSE_BUTTON => 3, + other => { + debug!("unknown input event type: {other}"); + break; + } + }; + if offset + len > payload.len() { + break; + } + out.push(payload[offset..offset + len].to_vec()); + offset += len; + } + out +} + +/// Input events, and nothing else. +/// +/// On its own connection so a keypress never waits behind a video keyframe. +async fn run_input_reader( + conn: Connection, + input_broadcast: tokio::sync::broadcast::Sender>, +) { + run_framed_reader(conn, BIDI_INPUT, "input", |msg_type, payload| { + let input_broadcast = input_broadcast.clone(); + async move { + if msg_type != MSG_INPUT_BATCH { + debug!("unknown input msg type: {msg_type}"); + return; + } + for event in split_input_events(&payload) { + let _ = input_broadcast.send(event); + } + } + }) + .await; +} + +/// Everything the client says that is not an input event. +async fn run_control_reader( + conn: Connection, + idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, + latest_report: Arc>>, + controller: Arc>, + awaiting_keyframe: Arc, +) { + run_framed_reader(conn, BIDI_CONTROL, "control", |msg_type, payload| { + let idr_cmd_tx = idr_cmd_tx.clone(); + let latest_report = latest_report.clone(); + let controller = controller.clone(); + let awaiting_keyframe = awaiting_keyframe.clone(); + async move { + match msg_type { + MSG_IDR_REQUEST => { + // Client-side rate limited to one every two seconds, which + // is still far too often for info when a struggling + // receiver asks continuously. + debug!("received IDR request from client"); + // Until one arrives, everything else sent to this client is + // undecodable and starves the keyframe that would fix it. + // See `dgram::ResyncGate`. + awaiting_keyframe.store(true, Ordering::Relaxed); + let _ = idr_cmd_tx.send(vec![MSG_IDR_REQUEST]); + } + MSG_CLIENT_CAPS => { + // Passed through untouched, and in particular *without* + // taking the controller off automatic the way encode + // settings do. This is the client stating a fact about + // itself, not a person overriding a decision, and reading + // it as the latter would silently stop the bitrate + // controller the first time a client said what it can + // decode. + match nesprotocol::decode_client_caps(&payload) { + Some(caps) => { + // Manual means a person is choosing, and the panel + // they chose in sets the codec and the depth + // together. A client joining afterwards must not + // renegotiate either of them: that is the same + // override the controller itself stops doing in + // this mode, and for the same reason. + if controller.lock().await.mode() == ControlMode::Manual { + info!( + "client decodes {:#08b}, but the encoder is set by hand; \ + leaving it alone", + caps.bits() + ); + } else { + info!("client decodes {:#08b}", caps.bits()); + let mut cmd = Vec::with_capacity(1 + payload.len()); + cmd.push(MSG_CLIENT_CAPS); + cmd.extend_from_slice(&payload); + let _ = idr_cmd_tx.send(cmd); + } + } + None => debug!("unreadable client capabilities ({} bytes)", payload.len()), + } + } + MSG_ENCODE_SETTINGS => { + info!( + "received encode settings from client ({} bytes)", + payload.len() + ); + // A person set this by hand, so the controller stops + // deciding until it is told otherwise. Overriding a + // person's setting a second later would take away the only + // tool that finds this class of bug. + if let Some((_, rc, value, _)) = nesprotocol::decode_encode_settings(&payload) { + let mut controller = controller.lock().await; + if rc == nesprotocol::RC_CBR { + controller.note_manual_target(value); + } else { + controller.set_constant_quality(true); + } + } + let mut cmd = Vec::with_capacity(1 + payload.len()); + cmd.push(MSG_ENCODE_SETTINGS); + cmd.extend_from_slice(&payload); + let _ = idr_cmd_tx.send(cmd); + } + MSG_RECEIVER_REPORT => match decode_receiver_report(&payload) { + Some(report) => { + if let Ok(mut slot) = latest_report.lock() { + *slot = Some(report); + } + } + None => debug!("unreadable receiver report ({} bytes)", payload.len()), + }, + MSG_CONTROL_MODE => match decode_control_mode(&payload) { + Some((mode, ceiling)) => { + let mut controller = controller.lock().await; + controller.set_mode(mode); + controller.set_constant_quality(false); + if let Some(kbps) = ceiling { + controller.set_ceiling(kbps); + } + info!("control mode {mode:?}, ceiling {ceiling:?}"); + } + None => debug!("unreadable control mode ({} bytes)", payload.len()), + }, + other => debug!("unknown control msg type: {other}"), + } + } + }) + .await; } async fn run_cursor_sender( @@ -361,11 +642,42 @@ async fn run_stats_sender(conn: Connection, mut rx: tokio::sync::mpsc::Unbounded debug!("stats sender exiting"); } +/// Whether a broadcast video payload is a keyframe. +/// +/// The payload is `[1B codec][1B flags][4B ts][2B w][2B h][data]`, the same +/// layout `encode_ipc_frame` writes. Deliberately narrower than +/// `video_wants_reliable`, which also answers true for the reconfiguration +/// frame that follows a codec change: that one belongs on a reliable stream for +/// the same reason a keyframe does, but counting it as a keyframe would put a +/// once-per-session frame into a per-second rate. +fn is_keyframe(payload: &[u8]) -> bool { + payload + .get(nesprotocol::reliable::VIDEO_PAYLOAD_FLAGS_OFFSET) + .is_some_and(|flags| flags & nesprotocol::FLAG_KEYFRAME != 0) +} + pub struct SessionManager { sessions: Arc>>, - video_bytes: AtomicU64, - last_video_bytes: AtomicU64, - video_bitrate: AtomicU64, // bytes/sec + /// Video bytes, split by what they were. + /// + /// One counter could not tell an encoder ignoring its bitrate target from a + /// stream that is mostly keyframes, and those have opposite fixes. A session + /// overshooting its target by ten times looked the same either way. + video_key_bytes: AtomicU64, + video_delta_bytes: AtomicU64, + keyframes: AtomicU64, + last_video_key_bytes: AtomicU64, + last_video_delta_bytes: AtomicU64, + last_keyframes: AtomicU64, + /// How much this box's own pipeline delay varied, frame to frame. + /// + /// **Variation, not absolute delay**, and it cannot be otherwise: the + /// encoder's `timestamp_ms` counts from its own start rather than from any + /// epoch, so the difference to wall clock holds an unknown constant even + /// though both run on this machine. Variation is also the comparable + /// quantity -- the client measures the same thing about the total, using the + /// same code, so the difference between the two is what the network added. + pipeline: std::sync::Mutex, audio_bytes: AtomicU64, last_audio_bytes: AtomicU64, relay_ms: Arc, // latest relay latency (f32 bits) @@ -375,30 +687,114 @@ impl SessionManager { pub fn new() -> Self { Self { sessions: Arc::new(Mutex::new(HashMap::new())), - video_bytes: AtomicU64::new(0), - last_video_bytes: AtomicU64::new(0), - video_bitrate: AtomicU64::new(0), + video_key_bytes: AtomicU64::new(0), + video_delta_bytes: AtomicU64::new(0), + keyframes: AtomicU64::new(0), + last_video_key_bytes: AtomicU64::new(0), + last_video_delta_bytes: AtomicU64::new(0), + last_keyframes: AtomicU64::new(0), + pipeline: std::sync::Mutex::new(nesprotocol::delay::DelayTracker::new()), audio_bytes: AtomicU64::new(0), last_audio_bytes: AtomicU64::new(0), relay_ms: Arc::new(AtomicU32::new(0)), } } - pub async fn add_session(&self, id: iroh::EndpointId, session: ClientSession) { + /// Take on one of a client's connections, creating the session if this is + /// the first to arrive. + /// + /// A client dials several connections and they are accepted in whatever + /// order they complete, so no one of them can be "the" arrival. They are + /// matched by endpoint id, which every connection from one client shares. + pub async fn attach( + &self, + id: iroh::EndpointId, + carrier: Carrier, + conn: Connection, + input_broadcast: tokio::sync::broadcast::Sender>, + idr_cmd_tx: tokio::sync::mpsc::UnboundedSender>, + controller: Arc>, + ) { let mut sessions = self.sessions.lock().await; - sessions.insert(id, session); - info!(remote = %id.fmt_short(), "client session added ({} total)", sessions.len()); + let fresh = !sessions.contains_key(&id); + let session = sessions.entry(id).or_insert_with(|| { + ClientSession::new( + input_broadcast, + self.relay_ms.clone(), + idr_cmd_tx, + controller, + ) + }); + session.attach(carrier, conn); + if fresh { + info!(remote = %id.fmt_short(), "client session added ({} total)", sessions.len()); + } else { + debug!(remote = %id.fmt_short(), "{} carrier attached", carrier.label()); + } } pub async fn remove_session(&self, id: &iroh::EndpointId) { let mut sessions = self.sessions.lock().await; - sessions.remove(id); - info!(remote = %id.fmt_short(), "client session removed ({} remaining)", sessions.len()); + // Said only when something was actually removed. Every carrier of a + // client reports its own close, so a client leaving announced its + // session as removed four times over -- three of them describing a + // session that had already gone, which reads like four clients + // leaving. + if sessions.remove(id).is_some() { + info!(remote = %id.fmt_short(), "client session removed ({} remaining)", sessions.len()); + } + } + + /// Note how long this box took over a frame, relative to its own best. + /// + /// `ts_ms` is stamped at capture by an encoder on this same machine, so the + /// clock is shared even though its origin is arbitrary -- which is exactly + /// what [`DelayTracker`] is built for. + /// + /// [`DelayTracker`]: nesprotocol::delay::DelayTracker + fn note_pipeline_delay(&self, payload: &[u8]) { + let Some(ts_bytes) = payload.get(2..6) else { + return; + }; + let ts_ms = u32::from_le_bytes(ts_bytes.try_into().unwrap()); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + if let Ok(mut pipeline) = self.pipeline.lock() { + pipeline.observe(ts_ms, now_ms, std::time::Instant::now()); + } + } + + /// The median, 95th percentile and worst pipeline variation since the last + /// call, in milliseconds. + /// + /// Zeroes for a second in which no frame was broadcast. That is not the + /// same as a second with no variation, but the wire has no room to say so + /// and the client can tell from the frame counters beside it. + pub fn pipeline_delays(&self) -> (u16, u16, u16) { + let Ok(mut pipeline) = self.pipeline.lock() else { + return (0, 0, 0); + }; + pipeline + .take() + .map(|s| (s.p50_ms, s.p95_ms, s.max_ms)) + .unwrap_or((0, 0, 0)) } pub async fn broadcast_video(&self, data: Vec) { - self.video_bytes - .fetch_add(data.len() as u64, Ordering::Relaxed); + self.note_pipeline_delay(&data); + // Counted before the early return, like audio, so the figure measures + // what the encoder produced rather than what a client happened to be + // around for. + if is_keyframe(&data) { + self.video_key_bytes + .fetch_add(data.len() as u64, Ordering::Relaxed); + self.keyframes.fetch_add(1, Ordering::Relaxed); + } else { + self.video_delta_bytes + .fetch_add(data.len() as u64, Ordering::Relaxed); + } let sessions = self.sessions.lock().await; if sessions.is_empty() { return; @@ -443,16 +839,79 @@ impl SessionManager { } } + /// The report from whichever client is having the worst time, and the path + /// view belonging to that same client. + /// + /// **The worst, not the average.** One encoder serves every client, so it + /// can only answer one question, and the client that cannot decode is the + /// one that matters -- averaging its trouble away leaves it never + /// recovering while the numbers look acceptable. + /// The send-queue depth of every streaming client, in bytes. + /// + /// Separate from `worst_report` because it needs nothing from the far end + /// and so is not tied to the once-a-second cadence a receiver report + /// imposes. That independence is the whole question the rate probe exists + /// to settle: a control loop can only usefully run as fast as the thing it + /// steers responds, but it is not otherwise limited by how often the client + /// talks. + pub async fn backlog_bytes(&self) -> Vec { + self.sessions + .lock() + .await + .values() + .filter_map(|s| s.path_view().backlog_bytes) + .collect() + } + + pub async fn worst_report(&self) -> (Option, PathView, bool) { + let sessions = self.sessions.lock().await; + let mut worst: Option<(f32, ReceiverReport, PathView)> = None; + let mut any_path = PathView::default(); + let mut self_inflicted = false; + for session in sessions.values() { + // Read for every client, not only the worst, and always cleared -- + // a count left behind would contaminate a later second too. + self_inflicted |= session.take_withheld() > 0; + let path = session.path_view(); + if path != PathView::default() { + any_path = path; + } + // Taken every tick whether or not it is used, so a report never + // outlives the second it describes. + let Some(report) = session.take_report() else { + continue; + }; + // A report accounting for no frames says nothing about loss, so it + // cannot be ranked -- but it is still the freshest thing this client + // has said, and losing it would look like silence. + let loss = report.loss().unwrap_or(0.0); + if worst.as_ref().is_none_or(|(w, _, _)| loss > *w) { + worst = Some((loss, report, path)); + } + } + match worst { + Some((_, report, path)) => (Some(report), path, self_inflicted), + None => (None, any_path, self_inflicted), + } + } + pub fn relay_ms(&self) -> f32 { f32::from_bits(self.relay_ms.swap(0, Ordering::Relaxed)) } - pub fn relay_ms_atomic(&self) -> Arc { - self.relay_ms.clone() - } - + /// Clients with somewhere to send a picture. + /// + /// Not simply the number of sessions: a client whose other connections + /// have completed but whose video connection has not is connected and not + /// yet streaming, and counting it would have the controller deciding a + /// bitrate for a carrier that cannot yet take one. pub async fn client_count(&self) -> usize { - self.sessions.lock().await.len() + self.sessions + .lock() + .await + .values() + .filter(|s| s.is_streaming()) + .count() } /// Opus actually received from neswire since the last call, in kbps. @@ -474,11 +933,154 @@ impl SessionManager { (diff * 8 / 1000) as u32 } - pub fn video_bitrate_bps(&self) -> u32 { - let current = self.video_bytes.load(Ordering::Relaxed); - let last = self.last_video_bytes.swap(current, Ordering::Relaxed); - let diff = current.saturating_sub(last); - self.video_bitrate.store(diff, Ordering::Relaxed); - (diff * 8) as u32 // bits per second + /// Keyframe bits, delta bits and keyframe count for the last second. + /// + /// Like the audio figure, this assumes the caller ticks once a second: the + /// difference since the previous call *is* the per-second number. + pub fn video_breakdown(&self) -> (u32, u32, u8) { + let per_second = |current: &AtomicU64, last: &AtomicU64| -> u64 { + let now = current.load(Ordering::Relaxed); + now.saturating_sub(last.swap(now, Ordering::Relaxed)) + }; + let key = per_second(&self.video_key_bytes, &self.last_video_key_bytes); + let delta = per_second(&self.video_delta_bytes, &self.last_video_delta_bytes); + let keyframes = per_second(&self.keyframes, &self.last_keyframes); + ( + (key * 8) as u32, + (delta * 8) as u32, + keyframes.min(u64::from(u8::MAX)) as u8, + ) + } +} + +#[cfg(test)] +mod pipeline_delay_tests { + use super::SessionManager; + + /// A video payload with `ts_ms` where the wire format puts it. + fn payload(ts_ms: u32) -> Vec { + let mut p = vec![0u8, 0u8]; + p.extend_from_slice(&ts_ms.to_le_bytes()); + p.extend_from_slice(&1920u16.to_le_bytes()); + p.extend_from_slice(&1080u16.to_le_bytes()); + p.extend_from_slice(&[0u8; 32]); + p + } + + #[test] + fn a_second_with_no_frames_reports_nothing() { + let mgr = SessionManager::new(); + assert_eq!(mgr.pipeline_delays(), (0, 0, 0)); + } + + #[test] + fn a_steady_pipeline_reports_no_variation() { + // The encoder's timestamp counts from its own start, so the difference + // to wall clock holds a large unknown constant. A pipeline taking the + // same time on every frame must read as zero, not as that constant -- + // which is what a plain subtraction would have reported, pinned at the + // maximum the wire can carry. + let mgr = SessionManager::new(); + for i in 0..60u32 { + mgr.note_pipeline_delay(&payload(i * 16)); + } + let (p50, _, max) = mgr.pipeline_delays(); + assert_eq!(p50, 0); + assert!( + max < 100, + "a steady pipeline reported {max} ms of variation" + ); + } + + #[test] + fn draining_means_each_answer_describes_one_second() { + let mgr = SessionManager::new(); + mgr.note_pipeline_delay(&payload(0)); + assert!(mgr.pipeline_delays().0 == 0); + assert_eq!(mgr.pipeline_delays(), (0, 0, 0)); + } + + #[test] + fn a_truncated_payload_is_ignored_rather_than_misread() { + let mgr = SessionManager::new(); + mgr.note_pipeline_delay(&[0u8, 0u8, 1u8]); + assert_eq!(mgr.pipeline_delays(), (0, 0, 0)); + } +} + +#[cfg(test)] +mod input_batch_tests { + use super::split_input_events; + use nesprotocol::input::{ + encode_key_event, encode_mouse_button, encode_mouse_move, encode_mouse_wheel, + }; + + /// The bug this exists for: a four-byte key event read as three bytes + /// forwards a truncated keycode and leaves the offset one short, so the + /// next event is read from inside this one. Built with the encoder rather + /// than by hand, so the widths cannot drift apart again. + #[test] + fn a_key_event_survives_the_round_trip_whole() { + let mut batch = Vec::new(); + // A keycode above 255, so a lost high byte cannot go unnoticed. + encode_key_event(&mut batch, true, 0x1234); + let events = split_input_events(&batch); + assert_eq!(events.len(), 1); + assert_eq!(events[0], batch, "the event was not forwarded intact"); + } + + /// The consequence of getting a width wrong, and the symptom that was + /// actually seen: a keycode read as though it were an event type. + #[test] + fn every_event_in_a_batch_is_recovered() { + let mut batch = Vec::new(); + let mut expected = Vec::new(); + for (down, code) in [(true, 29u16), (true, 42), (true, 32), (false, 32)] { + let mut one = Vec::new(); + encode_key_event(&mut one, down, code); + batch.extend_from_slice(&one); + expected.push(one); + } + assert_eq!( + split_input_events(&batch), + expected, + "a batch of key events did not come back as the events that went in" + ); + } + + #[test] + fn mixed_events_keep_their_boundaries() { + let mut batch = Vec::new(); + let mut expected = Vec::new(); + let mut add = |f: &dyn Fn(&mut Vec), batch: &mut Vec| { + let mut one = Vec::new(); + f(&mut one); + batch.extend_from_slice(&one); + expected.push(one); + }; + add(&|b| encode_key_event(b, true, 0x0102), &mut batch); + add(&|b| encode_mouse_move(b, -300, 42), &mut batch); + add(&|b| encode_mouse_button(b, 1, true), &mut batch); + add(&|b| encode_mouse_wheel(b, 0, -120), &mut batch); + add(&|b| encode_key_event(b, false, 0x0102), &mut batch); + assert_eq!(split_input_events(&batch), expected); + } + + /// A truncated batch must stop, not read past the end or invent an event. + #[test] + fn a_cut_off_event_is_dropped_rather_than_guessed_at() { + let mut batch = Vec::new(); + encode_key_event(&mut batch, true, 0x1234); + let whole = batch.clone(); + encode_key_event(&mut batch, true, 0x5678); + batch.pop(); + let events = split_input_events(&batch); + assert_eq!(events, vec![whole], "the half event was not discarded"); + } + + #[test] + fn an_unknown_event_type_stops_the_batch_rather_than_the_process() { + let events = split_input_events(&[0xAA, 0xBB, 0xCC]); + assert!(events.is_empty()); } } diff --git a/apps/nesinit/src/lib.rs b/apps/nesinit/src/lib.rs index 85730fe0..a52936ef 100644 --- a/apps/nesinit/src/lib.rs +++ b/apps/nesinit/src/lib.rs @@ -14,6 +14,7 @@ pub mod filesystems; pub mod payload; +pub mod platform; pub mod reap; pub mod services; pub mod session; diff --git a/apps/nesinit/src/main.rs b/apps/nesinit/src/main.rs index dccaa17b..431823ee 100644 --- a/apps/nesinit/src/main.rs +++ b/apps/nesinit/src/main.rs @@ -45,6 +45,7 @@ fn main() -> anyhow::Result<()> { // nothing else in this guest is an init system, so until this runs there // is no `/proc` to score this process in and nowhere to put a socket. nesinit::filesystems::establish(); + nesinit::platform::describe(); // Everything a distribution's init scripts used to do, and nothing else is // going to: a hostname, the box's address, the directories a session's @@ -80,6 +81,7 @@ fn main() -> anyhow::Result<()> { Ok(outcome) => tracing::info!(?outcome, "the session ended"), Err(error) => tracing::error!(%error, "the session failed"), } + nesinit::platform::report_steal(); // Before anything below waits on a pid: the reaper runs on this runtime's // threads, and two things calling `wait` is what the registry exists to diff --git a/apps/nesinit/src/platform.rs b/apps/nesinit/src/platform.rs new file mode 100644 index 00000000..41d58dda --- /dev/null +++ b/apps/nesinit/src/platform.rs @@ -0,0 +1,148 @@ +// What the kernel under this box actually chose, said once, at debug. +// +// Several of the settings that decide a box's latency are not decided by the +// image. The clocksource is picked at boot and can be demoted by a watchdog, +// the idle driver loads only when the host or the command line asks for it, +// and the preemption model is a boot parameter. None of them is visible from +// outside, and a box has no shell to ask with, so this reads them from sysfs +// and procfs and logs them. Enable with `RUST_LOG=nesinit=debug` on the kernel +// command line. + +use std::path::Path; + +/// Clocksources a vDSO can read without entering the kernel. +/// +/// Anything else makes every `clock_gettime` a syscall, and in a guest an +/// emulated one: `hpet` is an MMIO read the host has to trap. A Windows game +/// polls its performance counter constantly, so that is a cost paid thousands +/// of times a frame, and nothing reports it. +const VDSO_CLOCKSOURCES: &[&str] = &["tsc", "kvm-clock"]; + +/// Log the kernel's timing and idle choices. Needs `/proc` and `/sys`. +pub fn describe() { + let clocksource = read("/sys/devices/system/clocksource/clocksource0/current_clocksource"); + let cmdline = read("/proc/cmdline").unwrap_or_default(); + + tracing::debug!( + clocksource = clocksource.as_deref().unwrap_or("unknown"), + available = read("/sys/devices/system/clocksource/clocksource0/available_clocksource") + .as_deref() + .unwrap_or("unknown"), + idle_driver = read("/sys/devices/system/cpu/cpuidle/current_driver") + .as_deref() + .unwrap_or("none"), + idle_governor = read("/sys/devices/system/cpu/cpuidle/current_governor_ro") + .as_deref() + .unwrap_or("none"), + // Present only while the haltpoll governor is built in, and only + // meaningful while it is the governor in use. + halt_poll_ns = read("/sys/module/haltpoll/parameters/guest_halt_poll_ns") + .as_deref() + .unwrap_or("n/a"), + // The build's default when absent. The mode actually in effect is only + // readable through debugfs, which this kernel does not have. + preempt = kernel_parameter(&cmdline, "preempt").unwrap_or("default"), + cpus = std::thread::available_parallelism().map_or(0, usize::from), + kernel = read("/proc/sys/kernel/version") + .as_deref() + .unwrap_or("unknown"), + "platform" + ); + + if let Some(source) = clocksource.as_deref() + && !VDSO_CLOCKSOURCES.contains(&source) + { + tracing::warn!( + clocksource = source, + "every clock read in this box is a syscall; the kernel did not trust a faster clock" + ); + } +} + +/// Log how much of this box's CPU time the host took back. Call once, at the +/// end of a session: the counters are cumulative since boot. +pub fn report_steal() { + let Some(stat) = read("/proc/stat") else { + return; + }; + let Some((steal, total)) = steal_of(&stat) else { + return; + }; + // Parts per thousand, so the log carries an integer and no float + // formatting decides how small a number reads as zero. + let permille = (steal * 1000).checked_div(total).unwrap_or(0); + tracing::debug!( + steal_ticks = steal, + total_ticks = total, + permille, + "cpu time stolen by the host" + ); +} + +/// Steal and total ticks from the aggregate `cpu` line of `/proc/stat`. +/// +/// Columns are user, nice, system, idle, iowait, irq, softirq, steal, and then +/// guest time, which the kernel already counts inside user and nice. Summing +/// past steal would count it twice. +fn steal_of(stat: &str) -> Option<(u64, u64)> { + let line = stat.lines().find(|l| l.starts_with("cpu "))?; + let fields: Vec = line + .split_whitespace() + .skip(1) + .take(8) + .map(str::parse) + .collect::>() + .ok()?; + let steal = *fields.get(7)?; + Some((steal, fields.iter().sum())) +} + +/// The value of one bare `name=value` kernel parameter. +fn kernel_parameter<'a>(cmdline: &'a str, name: &str) -> Option<&'a str> { + cmdline + .split_whitespace() + .find_map(|word| word.strip_prefix(name)?.strip_prefix('=')) + .filter(|value| !value.is_empty()) +} + +fn read(path: impl AsRef) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_parameter_is_found_by_its_whole_name() { + let cmdline = "cpuidle_haltpoll.force=1 console=hvc0 preempt=full ro"; + assert_eq!(kernel_parameter(cmdline, "preempt"), Some("full")); + assert_eq!( + kernel_parameter(cmdline, "cpuidle_haltpoll.force"), + Some("1") + ); + } + + /// `preempt` must not match `preempt_foo=`, which is a different parameter. + #[test] + fn a_longer_name_sharing_the_prefix_is_not_a_match() { + assert_eq!(kernel_parameter("preempt_foo=x", "preempt"), None); + assert_eq!(kernel_parameter("console=hvc0", "preempt"), None); + } + + #[test] + fn steal_is_the_eighth_column_and_guest_time_is_not_counted_twice() { + // user nice system idle iowait irq softirq steal guest guest_nice + let stat = "cpu 100 0 50 800 10 5 5 30 999 999\ncpu0 1 2 3 4 5 6 7 8 9 10\n"; + assert_eq!(steal_of(stat), Some((30, 1000))); + } + + #[test] + fn a_stat_without_a_cpu_line_reports_nothing() { + assert_eq!(steal_of("intr 1 2 3\n"), None); + assert_eq!(steal_of("cpu 1 2 3\n"), None); + } +} diff --git a/apps/nesinit/src/services.rs b/apps/nesinit/src/services.rs index 3d7ee018..9244a599 100644 --- a/apps/nesinit/src/services.rs +++ b/apps/nesinit/src/services.rs @@ -41,6 +41,7 @@ use tokio::sync::mpsc::{Receiver, Sender}; use crate::reap::{Waiters, Watched}; use crate::workload::Failure; +use nesprotocol::lifecycle::VideoLimits; /// A service that died, and how. #[derive(Debug, Clone, PartialEq, Eq)] @@ -60,7 +61,13 @@ pub trait Services { /// Called once, after the shares are mounted and before anything may be /// launched. An empty stack is legitimate: a box with no services still /// boots, and a caller can still launch something that needs none. - fn bring_up(&mut self) -> Result, Failure>; + /// + /// `video` comes from the descriptor and reaches the services that read it. + /// It has to arrive here rather than later because a service configured + /// after it is already running has a window in which it is not configured, + /// and for a bitrate ceiling that window is a session streaming at whatever + /// default it started with. + fn bring_up(&mut self, video: VideoLimits) -> Result, Failure>; /// Deaths, as they happen. /// @@ -160,6 +167,17 @@ pub const SERVICE_UID: u32 = 1000; /// here: the directory belongs to the service user and is not writable by the /// workload, which is the property [`crate::ticket::Untrusted`] depends on. pub const AUDIO_DIR: &str = "/run/pipewire"; + +/// Where the PulseAudio protocol is served, in [`AUDIO_DIR`] for the reason +/// audio's own socket is. +/// +/// pipewire-pulse is told this path by its configuration file in the image, +/// which is not something this program can pass it, so the two are compared by +/// a test rather than trusted to agree. +pub const PULSE_SOCKET: &str = "/run/pipewire/pulse-native"; + +/// [`PULSE_SOCKET`] as a PulseAudio client is told it. +pub const PULSE_SERVER: &str = "unix:/run/pipewire/pulse-native"; pub const SERVICE_GID: u32 = 1000; /// Where a service's runtime sockets live. @@ -273,6 +291,26 @@ pub const STACK: &[Service] = &[ umask: None, ready: None, }, + Service { + name: "pipewire-pulse", + argv: &["/usr/bin/pipewire-pulse"], + env: &[ + ("XDG_RUNTIME_DIR", RUNTIME_DIR), + ("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR), + ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"), + ], + user: Some((SERVICE_UID, SERVICE_GID)), + // Optional for the reason the sender is: everything that speaks + // PipeWire itself is unaffected, and a session with sound missing is + // degraded rather than unusable. + cost: "anything that only speaks PulseAudio plays silently, and Wine is one", + required: false, + // The workload is its client, and is not this user. + umask: Some(0), + // Nothing in this table connects to it, but the workload does, and the + // workload is started after the table. + ready: Some(PULSE_SOCKET), + }, Service { name: "neswire", argv: &["/usr/bin/neswire"], @@ -308,6 +346,13 @@ pub struct Stack { running: Vec<(&'static str, Watched)>, deaths: Receiver, reported: Sender, + /// What the host said this box may spend on video, from the descriptor. + /// + /// Held here because `spawn` is where it reaches a service, and `spawn` + /// takes a `&'static Service` whose `env` is a fixed table -- a value that + /// arrives at runtime has no route through it otherwise. The same problem + /// `RUST_LOG` has, solved the same way. + video: VideoLimits, } impl Stack { @@ -328,6 +373,7 @@ impl Stack { running: Vec::new(), deaths, reported, + video: VideoLimits::default(), } } @@ -390,6 +436,15 @@ impl Stack { if let Ok(filter) = std::env::var("RUST_LOG") { command.env("RUST_LOG", filter); } + // The descriptor's video limits, for the services that read them. Same + // shape of problem as `RUST_LOG` above -- `env_clear` drops everything + // and the service table is a fixed list of literals, so a value that + // only exists at runtime has no other route in. `neshub` reads this + // through the clap `env =` attribute it already uses for every other + // setting. + if let Some(kbps) = self.video.bitrate_kbps { + command.env("NESTRI_MAX_BITRATE", kbps.to_string()); + } // The service's own entry last, so a service that states one of these // for itself wins over the defaults above. command.envs(service.env.iter().copied()); @@ -451,7 +506,8 @@ impl Stack { } impl Services for Stack { - fn bring_up(&mut self) -> Result, Failure> { + fn bring_up(&mut self, video: VideoLimits) -> Result, Failure> { + self.video = video; let mut up = Vec::new(); // Lifted out so the loop does not hold a borrow of `self` across the // start it is asking for. @@ -604,6 +660,9 @@ pub mod double { /// only thing under test. pub struct Double { pub brought_up: usize, + /// What the last `bring_up` was told, so a test can assert the limits + /// reached the stack rather than assuming they did. + pub video: VideoLimits, pub failure: Option, pub names: Vec, deaths: Receiver, @@ -625,6 +684,7 @@ pub mod double { names: vec!["dbus-system".into(), "neshub".into()], deaths, report, + video: VideoLimits::default(), } } @@ -637,8 +697,9 @@ pub mod double { } impl Services for Double { - fn bring_up(&mut self) -> Result, Failure> { + fn bring_up(&mut self, video: VideoLimits) -> Result, Failure> { self.brought_up += 1; + self.video = video; match &self.failure { Some(failure) => Err(failure.clone()), None => Ok(self.names.clone()), @@ -853,14 +914,42 @@ mod tests { /// owner -- and the workload is not the owner. #[test] fn the_audio_socket_is_reachable_by_a_user_who_does_not_own_it() { - let pipewire = STACK + for name in ["pipewire", "pipewire-pulse"] { + let service = STACK + .iter() + .find(|s| s.name == name) + .unwrap_or_else(|| panic!("{name} is in the table")); + assert_eq!( + service.umask, + Some(0), + "with any other umask the game finds {name}'s socket and cannot open it" + ); + } + } + + /// The PulseAudio socket's path is written three times: here, in the + /// client address the workload is given, and in pipewire-pulse's own + /// configuration in the image. If any of them moves on its own, the game + /// finds no server and plays silently, and nothing fails. + #[test] + fn pulse_is_served_where_the_workload_is_told_to_look() { + assert!( + PULSE_SOCKET.starts_with(AUDIO_DIR), + "the workload can only reach sockets in {AUDIO_DIR}" + ); + assert_eq!(PULSE_SERVER, format!("unix:{PULSE_SOCKET}")); + + let pulse = STACK .iter() - .find(|s| s.name == "pipewire") - .expect("audio is in the table"); - assert_eq!( - pipewire.umask, - Some(0), - "with any other umask the game finds the socket and cannot open it" + .find(|s| s.name == "pipewire-pulse") + .expect("pulse is in the table"); + assert_eq!(pulse.ready, Some(PULSE_SOCKET)); + + let config = + include_str!("../../../build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf"); + assert!( + config.contains(&format!("\"{PULSE_SERVER}\"")), + "pipewire-pulse is configured to listen somewhere other than {PULSE_SOCKET}" ); } diff --git a/apps/nesinit/src/session.rs b/apps/nesinit/src/session.rs index 9cdda4f9..fadc9a43 100644 --- a/apps/nesinit/src/session.rs +++ b/apps/nesinit/src/session.rs @@ -266,19 +266,19 @@ where } booted = true; - // The drives, then the shares, then the services. + // The overlays, then the shares, then the services. // - // **Drives first, and one `Mounted` between them.** A drive is - // a filesystem this end mounts itself, so a share whose target - // lives under one has to find it already there. The host is + // **Overlays first, and one `Mounted` between them.** An + // overlay is a filesystem this end mounts itself, so a share + // whose target lives under one has to find it already there. The host is // told once, after both, because `Mounted` answers "is the // content where the descriptor said" and there is one answer to // that -- sending it twice made the host read the second as a // reply to something it had not asked. - if let Err(failure) = workload.mount_drives(&descriptor.drives) { + if let Err(failure) = workload.mount_overlays(&descriptor.overlays) { // Said before it is returned. `Refused` ends the session // either way; without the message the host sees a box that - // stopped and has to guess between a drive, a share and a + // stopped and has to guess between an overlay, a share and a // service -- which is the whole reason these are reported // separately. send( @@ -311,7 +311,7 @@ where // A box whose own services will not come up cannot be launched // into, so this is refused rather than reported and carried on // from — unlike a launch, which is the caller's to correct. - match services.bring_up() { + match services.bring_up(descriptor.video) { Ok(up) => { tracing::info!(services = up.len(), "the box is ready to be launched into"); send(&mut writer, &GuestToHost::Initialized { services: up }).await? @@ -516,7 +516,8 @@ mod tests { at: "/mnt/user".into(), ro: false, }], - drives: Vec::new(), + overlays: Vec::new(), + video: Default::default(), } } @@ -715,6 +716,53 @@ mod tests { ); } + #[tokio::test] + async fn the_descriptors_video_limits_reach_the_services() { + // The ceiling is useless if it stops at the descriptor. `neshub` is the + // only thing that can enforce it and it is a service, so the number has + // to survive the whole way from the boot document to the spawn. + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0)))); + + let mut given = descriptor(); + given.video.bitrate_kbps = Some(8_000); + + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(given), + }) + .await; + caller.expect_booted().await; + caller.say(&HostToGuest::Shutdown).await; + + let (_, _, services) = session.await.unwrap(); + assert_eq!(services.video.bitrate_kbps, Some(8_000)); + } + + #[tokio::test] + async fn a_box_told_nothing_about_video_says_so_rather_than_inventing_a_limit() { + // "Unsaid" must not arrive as a number. A stack that cannot tell the two + // apart cannot log that it was never told, and a ceiling nobody set is + // exactly how every session came to offer 10 Mbps. + let (guest, host) = tokio::io::duplex(4096); + let mut caller = Caller::new(host); + let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0)))); + + caller.expect_ready().await; + caller + .say(&HostToGuest::Boot { + descriptor: Box::new(descriptor()), + }) + .await; + caller.expect_booted().await; + caller.say(&HostToGuest::Shutdown).await; + + let (_, _, services) = session.await.unwrap(); + assert_eq!(services.video.bitrate_kbps, None); + } + #[tokio::test] async fn a_launch_runs_what_it_names_and_is_reported_by_its_id() { let (guest, host) = tokio::io::duplex(4096); diff --git a/apps/nesinit/src/workload.rs b/apps/nesinit/src/workload.rs index bde0b783..d260def9 100644 --- a/apps/nesinit/src/workload.rs +++ b/apps/nesinit/src/workload.rs @@ -11,7 +11,7 @@ use std::future::Future; use std::io; use std::pin::Pin; -use nesprotocol::lifecycle::{Drive, Exec, Exit, Mount}; +use nesprotocol::lifecycle::{Exec, Exit, Mount, Overlay}; use std::os::unix::process::CommandExt; @@ -41,8 +41,9 @@ pub trait Workload { /// Make the shares the descriptor names, where it says to put them. fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure>; - /// Mount drives - fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure>; + /// Stack each overlay the descriptor names: its build image, its writable + /// layer, and the two together where it says. + fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>; /// Start the command the descriptor names. /// @@ -132,9 +133,9 @@ impl Workload for Process { Ok(()) } - fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> { - for drive in drives { - mount_drive(drive)?; + fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> { + for overlay in overlays { + mount_overlay(overlay)?; } Ok(()) } @@ -302,6 +303,9 @@ const GRAPHICS: &[(&str, &str)] = &[ // reach and both are told where. Without this a game renders and plays // silently, having looked under its own uid and found nothing. ("PIPEWIRE_RUNTIME_DIR", crate::services::AUDIO_DIR), + // The same, for a client that speaks PulseAudio instead. It does not read + // the variable above, and its default is under its own runtime directory. + ("PULSE_SERVER", crate::services::PULSE_SERVER), ]; /// Mount one share where the descriptor says to put it. @@ -340,35 +344,104 @@ fn mount_share(share: &Mount) -> Result<(), Failure> { /// filesystem this mounts. A descriptor cannot name another. const FSTYPE: &std::ffi::CStr = c"virtiofs"; -/// Mounts block device instead of virtiofs share -fn mount_drive(drive: &Drive) -> Result<(), Failure> { - // Checked before anything is created: a descriptor this component cannot - // act on should leave no directory behind to confuse whoever reads the - // failure. - let (source, target, flags) = options_drive(drive)?; +/// Stack one overlay: the build image, the box's writable layer, and the two +/// together at `at`. +/// +/// Each step's failure names the step, because "the install did not mount" +/// has three different causes and each one is fixed somewhere else: a build +/// image the kernel cannot read, an upper layer that was never formatted, and +/// an overlay the kernel refused. +fn mount_overlay(overlay: &Overlay) -> Result<(), Failure> { + // Everything that can be refused without touching the filesystem is + // refused first, so a descriptor this cannot act on leaves nothing behind. + let plan = OverlayPlan::new(overlay)?; - // The mount point may not exist yet: a share can land anywhere the - // descriptor names, including a directory no image created. - std::fs::create_dir_all(&drive.at).map_err(|error| failed_drive(drive, error))?; + mount_one( + &plan.lower, + &plan.lower_at, + c"erofs", + plan.lower_flags, + None, + ) + .map_err(|error| plan.failed("the build image", &plan.lower_at, error))?; + mount_one(&plan.upper, &plan.rw_at, c"ext4", plan.upper_flags, None) + .map_err(|error| plan.failed("the writable layer", &plan.rw_at, error))?; - // SAFETY: mount takes two paths, a filesystem name and a flag word, all - // of which outlive the call, and no options string. + for dir in [&plan.upper_dir, &plan.work_dir] { + std::fs::create_dir_all(as_path(dir)) + .map_err(|error| plan.failed("the writable layer", dir, error))?; + } + // **The upper directory takes the build's root ownership.** overlayfs + // shows a merged directory with the attributes of its upper half when it + // has one, and this one always does -- so an upper directory this init + // created, `root:root 0755`, would make the install's top directory + // unwritable to the workload, whatever the build image says. Copied from + // the lower root rather than named here: which uid the workload runs as is + // the host's decision, and the host already made it when it packed the + // image. + let (uid, gid, mode) = ownership(&plan.lower_at) + .map_err(|error| plan.failed("the build image", &plan.lower_at, error))?; + set_ownership(&plan.upper_dir, uid, gid, mode) + .map_err(|error| plan.failed("the writable layer", &plan.upper_dir, error))?; + + mount_one( + c"overlay", + &plan.at, + c"overlay", + plan.overlay_flags, + Some(&plan.overlay_data), + ) + .map_err(|error| plan.failed("the overlay", &plan.at, error))?; + Ok(()) +} + +/// Mount one filesystem, creating its mount point first. +fn mount_one( + source: &std::ffi::CStr, + target: &std::ffi::CStr, + fstype: &std::ffi::CStr, + flags: libc::c_ulong, + data: Option<&std::ffi::CStr>, +) -> io::Result<()> { + // The mount point may not exist yet: the descriptor can name anywhere, + // including a directory no image created. + std::fs::create_dir_all(as_path(target))?; + // SAFETY: every pointer is to a nul-terminated string that outlives the + // call, and a null data pointer is what mount(2) takes for "no options". let mounted = unsafe { libc::mount( source.as_ptr(), target.as_ptr(), - FSTYPE_DRIVE.as_ptr(), + fstype.as_ptr(), flags, - std::ptr::null(), + data.map_or(std::ptr::null(), |d| d.as_ptr().cast()), ) }; if mounted != 0 { - return Err(failed_drive(drive, io::Error::last_os_error())); + return Err(io::Error::last_os_error()); } Ok(()) } -const FSTYPE_DRIVE: &std::ffi::CStr = c"ext4"; +/// The same bytes, as a path: lossless, where a round trip through `str` is +/// not. +fn as_path(path: &std::ffi::CStr) -> &std::path::Path { + use std::os::unix::ffi::OsStrExt; + std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())) +} + +fn ownership(path: &std::ffi::CStr) -> io::Result<(u32, u32, u32)> { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::metadata(as_path(path))?; + Ok((meta.uid(), meta.gid(), meta.mode() & 0o7777)) +} + +fn set_ownership(path: &std::ffi::CStr, uid: u32, gid: u32, mode: u32) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let path = as_path(path); + std::os::unix::fs::chown(path, Some(uid), Some(gid))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) +} /// What the mount call is given, split out because this is the part worth /// asserting: mounting itself needs privileges a test does not have. @@ -400,48 +473,114 @@ fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure> Ok((source, target, flags)) } -/// What the drive mount call is given. +/// Everything an overlay's three mounts are given, worked out before any of +/// them is attempted. /// -/// # No filesystem-specific options, and that is a decision +/// Split out because this is the part worth asserting: mounting needs +/// privileges a test does not have. /// -/// `commit=` and `barrier=` were here once and the mount failed outright: -/// *"can't mount with commit=, fs mounted w/o journal"*, `EINVAL`, and a box -/// that refused its own descriptor before the session started. Both options -/// only mean anything to a journal, and a build volume is made without one -- -/// what it holds is one game, re-downloadable, mounted by a clone that is -/// destroyed with the box. Anything added here has to be an option that is -/// still true of a journal-less ext4. +/// # Where the layers go /// -/// `noatime` stays: a game reading its own install has no use for access -/// times, and writing them turns every read of a clone into a write. It is not -/// paired with `nodiratime`, which it already implies. +/// Beside the overlay, in a hidden directory named after it: +/// `/nestri/install` stacks `/nestri/.install/lower` under +/// `/nestri/.install/rw/upper`. Beside rather than under, because anything +/// mounted under `at` is covered the moment the overlay is mounted over it. /// -/// # nosuid and nodev, for the same reason every share has them +/// # Flags /// -/// What this mounts is the least trusted thing in the box: files a CDN handed -/// us, checked for the bytes the manifest named and for nothing about what -/// those bytes are. A setuid binary or a device node inside a depot is not -/// something a workload should be able to use, and no descriptor has a way to -/// ask for one. +/// `nosuid` and `nodev` on every layer and on the result, for the reason every +/// share has them: what is stacked here is files a CDN handed us, checked for +/// the bytes the manifest named and for nothing about what those bytes are. +/// **Not `noexec`** anywhere: the game's executable is in the build. /// -/// **Not `noexec`.** The game's own executable is on this volume and the whole -/// point is to run it. -fn options_drive(drive: &Drive) -> Result<(CString, CString, libc::c_ulong), Failure> { - let flags = libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOATIME; +/// `noatime` on the writable layer and the overlay, so a game reading its own +/// install does not turn every read into a write. The build image is mounted +/// read-only and has no access times to write. +/// +/// No filesystem-specific options on the upper layer: `commit=` and +/// `barrier=` were once passed to a journal-less ext4 and the mount failed +/// outright with `EINVAL`. +#[derive(Debug)] +struct OverlayPlan { + lower: CString, + upper: CString, + at: CString, + lower_at: CString, + rw_at: CString, + upper_dir: CString, + work_dir: CString, + lower_flags: libc::c_ulong, + upper_flags: libc::c_ulong, + overlay_flags: libc::c_ulong, + overlay_data: CString, +} - let source = CString::new(drive.dev.as_str()).map_err(|_| { +impl OverlayPlan { + fn new(overlay: &Overlay) -> Result { + let at = std::path::Path::new(&overlay.at); + let (Some(parent), Some(name)) = (at.parent(), at.file_name()) else { + return Err(Failure::new(format!( + "the overlay mount point has no parent to put its layers beside: {:?}", + overlay.at + ))); + }; + let layers = parent.join(format!(".{}", name.to_string_lossy())); + let lower_at = layers.join("lower"); + let rw_at = layers.join("rw"); + let upper_dir = rw_at.join("upper"); + let work_dir = rw_at.join("work"); + + // overlayfs splits its options on commas and its layer lists on + // colons, and has no escape for either that this should rely on. A + // path carrying one would mount a different directory than the one + // named, so it is refused. + for path in [&lower_at, &upper_dir, &work_dir] { + let text = path.to_string_lossy(); + if text.contains([',', ':']) { + return Err(Failure::new(format!( + "the overlay mount point cannot carry a comma or a colon: {:?}", + overlay.at + ))); + } + } + let data = format!( + "lowerdir={},upperdir={},workdir={}", + lower_at.display(), + upper_dir.display(), + work_dir.display() + ); + + let common = libc::MS_NOSUID | libc::MS_NODEV; + Ok(Self { + lower: c_string(&overlay.lower, "the build image device")?, + upper: c_string(&overlay.upper, "the writable layer device")?, + at: c_string(&overlay.at, "the overlay mount point")?, + lower_at: c_string(&lower_at.to_string_lossy(), "the overlay mount point")?, + rw_at: c_string(&rw_at.to_string_lossy(), "the overlay mount point")?, + upper_dir: c_string(&upper_dir.to_string_lossy(), "the overlay mount point")?, + work_dir: c_string(&work_dir.to_string_lossy(), "the overlay mount point")?, + lower_flags: common | libc::MS_RDONLY, + upper_flags: common | libc::MS_NOATIME, + overlay_flags: common | libc::MS_NOATIME, + overlay_data: c_string(&data, "the overlay mount point")?, + }) + } + + /// Which step failed, on which path, in the operating system's words. + fn failed(&self, step: &str, path: &std::ffi::CStr, error: io::Error) -> Failure { Failure::new(format!( - "the drive device contains a nul byte: {:?}", - drive.dev + "{}: {step}: {}: {error}", + self.at.to_string_lossy(), + path.to_string_lossy() )) - })?; - let target = CString::new(drive.at.as_str()).map_err(|_| { - Failure::new(format!( - "the drive mount point contains a nul byte: {:?}", - drive.at - )) - })?; - Ok((source, target, flags)) + } +} + +/// A nul byte inside a path is a descriptor that cannot be carried out under +/// any flags. Refused by name rather than silently emptied: an empty path turns +/// up later as a mount failure about something else entirely. +fn c_string(text: &str, what: &str) -> Result { + CString::new(text).map_err(|_| Failure::new(format!("{what} contains a nul byte: {text:?}"))) } /// A failure names the path, which is what makes it actionable: a permission @@ -451,10 +590,6 @@ fn failed(share: &Mount, error: io::Error) -> Failure { Failure::new(format!("{}: {error}", share.at)) } -fn failed_drive(drive: &Drive, error: io::Error) -> Failure { - Failure::new(format!("{}: {error}", drive.at)) -} - #[cfg(test)] mod tests { use super::*; @@ -578,28 +713,83 @@ mod tests { assert_eq!(flags & libc::MS_RDONLY, 0); } - /// The drive carries the same guard every share carries. - /// - /// It is the mount that most needs it: a share is a directory this host - /// prepared, and a drive is a filesystem built out of whatever a CDN sent. + fn overlay(at: &str) -> Overlay { + Overlay { + lower: "/dev/vdb".into(), + upper: "/dev/vdc".into(), + at: at.into(), + } + } + + /// The layers sit beside the overlay, never under it: anything mounted + /// under `at` is hidden the moment the overlay covers it. #[test] - fn a_drive_is_mounted_without_devices_or_setuid_but_can_still_execute() { - let drive = Drive { - dev: "/dev/vdb".into(), - at: "/nestri/install".into(), - }; - let (source, target, flags) = options_drive(&drive).unwrap(); + fn an_overlays_layers_sit_beside_it_and_the_options_name_them() { + let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap(); + assert_eq!(plan.lower.to_str().unwrap(), "/dev/vdb"); + assert_eq!(plan.upper.to_str().unwrap(), "/dev/vdc"); + assert_eq!(plan.lower_at.to_str().unwrap(), "/nestri/.install/lower"); + assert_eq!(plan.rw_at.to_str().unwrap(), "/nestri/.install/rw"); assert_eq!( - source.to_str().unwrap(), - "/dev/vdb", - "the device is the source" + plan.overlay_data.to_str().unwrap(), + "lowerdir=/nestri/.install/lower,\ + upperdir=/nestri/.install/rw/upper,\ + workdir=/nestri/.install/rw/work" + ); + } + + /// Every layer carries the guard every share carries, and none of them + /// stops the game's own executable from running. + /// + /// These are the mounts that most need it: a share is a directory this + /// host prepared, and a build is a filesystem made out of whatever a CDN + /// sent. + #[test] + fn every_layer_is_mounted_without_devices_or_setuid_but_can_still_execute() { + let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap(); + for flags in [plan.lower_flags, plan.upper_flags, plan.overlay_flags] { + assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID); + assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV); + assert_eq!(flags & libc::MS_NOEXEC, 0); + } + assert_eq!(plan.lower_flags & libc::MS_RDONLY, libc::MS_RDONLY); + assert_eq!(plan.upper_flags & libc::MS_RDONLY, 0); + assert_eq!(plan.overlay_flags & libc::MS_RDONLY, 0); + assert_eq!(plan.overlay_flags & libc::MS_NOATIME, libc::MS_NOATIME); + } + + /// overlayfs splits its options on commas and colons, so a path carrying + /// one would stack a different directory than the one named. + #[test] + fn an_overlay_the_kernel_would_misread_is_refused_before_anything_mounts() { + for at in [ + "/nestri/in,stall", + "/nestri/in:stall", + "/", + "/nestri/ins\0tall", + ] { + assert!( + OverlayPlan::new(&overlay(at)).is_err(), + "{at:?} was accepted" + ); + } + } + + #[test] + fn a_failed_overlay_step_names_the_overlay_the_step_and_the_path() { + let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap(); + let failure = plan.failed( + "the build image", + &plan.lower_at, + io::Error::from_raw_os_error(libc::ENODEV), + ); + assert!( + failure + .reason + .starts_with("/nestri/install: the build image: /nestri/.install/lower: "), + "{}", + failure.reason ); - assert_eq!(target.to_str().unwrap(), "/nestri/install"); - assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID); - assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV); - assert_eq!(flags & libc::MS_NOATIME, libc::MS_NOATIME); - // The game's executable lives here. - assert_eq!(flags & libc::MS_NOEXEC, 0); } #[test] @@ -652,7 +842,7 @@ pub mod double { /// only thing under test. pub struct Double { pub mounted: Vec>, - pub drives: Vec>, + pub overlays: Vec>, pub started: Vec, pub stops: usize, pub mount_failure: Option, @@ -676,7 +866,7 @@ pub mod double { fn new(exit: Exit, holds_until_stopped: bool) -> Self { Self { mounted: Vec::new(), - drives: Vec::new(), + overlays: Vec::new(), started: Vec::new(), stops: 0, mount_failure: None, @@ -697,8 +887,8 @@ pub mod double { } } - fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> { - self.drives.push(drives.to_vec()); + fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> { + self.overlays.push(overlays.to_vec()); match &self.mount_failure { Some(failure) => Err(failure.clone()), None => Ok(()), diff --git a/apps/nesinit/tests/services_stop.rs b/apps/nesinit/tests/services_stop.rs index 9859a186..66cab48b 100644 --- a/apps/nesinit/tests/services_stop.rs +++ b/apps/nesinit/tests/services_stop.rs @@ -50,7 +50,9 @@ fn alive(pid: i32) -> bool { async fn a_stack_that_goes_away_takes_its_services_with_it() { let waiters = Waiters::new(); let mut stack = Stack::from_table(waiters, SLEEPERS); - let up = stack.bring_up().expect("two sleeps did not start"); + let up = stack + .bring_up(Default::default()) + .expect("two sleeps did not start"); assert_eq!(up.len(), 2); let pids = stack.pids(); diff --git a/build/Containerfile b/build/Containerfile index 7cdf2322..b77723b2 100644 --- a/build/Containerfile +++ b/build/Containerfile @@ -13,9 +13,9 @@ # this image is plain Arch rather than a distribution chosen for its init. # ref(d-0064) # -# Proton is here, and it is not a closed component: it is proton-cachyos built -# from source with --enable-wow64, which is what removes the need for a whole -# 32-bit host stack. Valve's steamclient.so is a different thing and is NOT +# Proton is here, and it is not a closed component: it is proton-ge built +# from source wow64-only, which is what removes the need for a whole 32-bit +# host stack. Valve's steamclient.so is a different thing and is NOT # here — that one is closed, and nestri/CLAUDE.md is explicit that nothing # closed enters this repo. Whatever layers it on top of runtime_prod is a # closed build outside this repo — see build/README.md. @@ -31,8 +31,7 @@ # scoped to one stage instead, `FROM ${PROTON_IMAGE}` expands to nothing, and # the build fails with "no FROM statement found" — which says nothing about # the actual mistake. See the Proton stage below for what this is. -ARG PROTON_IMAGE=ghcr.io/nestrilabs/proton-cachyos-native-wow64:11.0-20260703 - +ARG PROTON_IMAGE=ghcr.io/nestrilabs/proton-ge:11-7 # ─────────────────────────────────────────────────────────── # initial / builder @@ -72,18 +71,14 @@ ENV ARTIFACTS=/artifacts # ─────────────────────────────────────────────────────────── FROM builder AS mesa-build -ARG MESA_GIT=https://gitlab.freedesktop.org/mesa/mesa.git -ARG MESA_COMMIT=8ace865d958b0f17254afc427db21b0ad1747b4b +ARG MESA_GIT=https://gitlab.freedesktop.org/nowrep/mesa.git +ARG MESA_COMMIT=1af89bea491deabc4741700f8df4d41edfd22f7a ARG JOBS= # Our patches to the amdgpu native-context path, applied on top of the pinned # commit. They are not cosmetic: upstream's winsys re-queries device-static # facts on a path that costs a synchronous round trip to the host under # virtio, and it was most of a frame's time. See each patch's own message. -# -# `git apply` and not `git am`: no committer identity is needed, and a patch -# that no longer applies stops the build here rather than producing an image -# that is quietly unpatched and slow. COPY build/patches/mesa /build/patches/mesa RUN test -n "$JOBS" || JOBS=$(nproc) && \ @@ -173,14 +168,13 @@ RUN --mount=type=cache,target=/root/.cargo/registry \ # `FROM scratch` over the tree, so its root *is* the tree and there is nothing # in it to run — only something to copy from. # -# Built with `--enable-wow64`, which is the whole reason it is a build of ours -# rather than the distribution's package. wow64 runs 32-bit Windows code -# inside a 64-bit unix process, so a box needs no lib32 anything: no 32-bit -# glibc, no second Mesa for i686, and — the one that matters most here — no +# Built wow64-only, which is the whole reason it is a build of ours rather +# than a released one. wow64 runs 32-bit Windows code inside a 64-bit unix +# process, so a box needs no lib32 anything: no 32-bit glibc, no second Mesa +# for i686, and — the one that matters most here — no # second capture layer, because the game is a 64-bit process and loads the -# 64-bit Vulkan loader the existing layer already sits in. The distribution's -# package is built without the flag, which is exactly why it depends on -# lib32-*. +# 64-bit Vulkan loader the existing layer already sits in. The released builds +# carry a 32-bit unix side, which is exactly why they need lib32-*. # # Override to build it yourself; the tag is a version and moves deliberately. # ─────────────────────────────────────────────────────────── @@ -269,7 +263,7 @@ RUN pacman -Syu --noconfirm --needed \ libglvnd libdrm libepoxy libxxf86vm libinput wayland \ expat zlib llvm-libs lm_sensors elfutils libva shaderc vulkan-icd-loader \ pixman libxkbcommon xcb-util-keysyms xorg-xwayland \ - pipewire pipewire-audio wireplumber opus \ + pipewire pipewire-audio pipewire-pulse libpulse wireplumber opus \ python libunwind \ && rm -f /usr/share/libalpm/hooks/dbus-reload.hook \ && pacman -Rdd --noconfirm systemd systemd-sysvcompat \ @@ -281,6 +275,15 @@ RUN pacman -Syu --noconfirm --needed \ # after the prefix had already been created -- so the session got past every # check that Proton was present and usable. # +# `pipewire-pulse` and `libpulse` are Wine's audio. Its driver speaks only the +# PulseAudio protocol, so without the translation layer a game starts, renders +# and plays silently, with nothing failing to say why. `libpulse` is what that +# driver, `winepulse.so`, links, and it would arrive as a dependency of +# pipewire-pulse anyway. It is named because the dependency is Wine's and not +# pipewire-pulse's, and a library that only arrives as somebody else's +# dependency goes away when they do. The check below covers `winepulse.so`, so a +# missing library fails the build rather than the session. + # `python` is not a build dependency here -- the builder stage has its own for # Mesa -- it is a *runtime* one. The compatibility tool's entry point is a # Python script, so a box without an interpreter starts a game and the launch @@ -368,7 +371,7 @@ COPY --from=mesa-build /artifacts/mesa / COPY --from=nestri-build /artifacts/nestri / # The Proton tree, whose image root is the tree, so this lands it at -# /usr/share/steam/compatibilitytools.d/proton-cachyos. +# /usr/share/steam/compatibilitytools.d/proton-ge. # # Deliberately not in the strip manifest above: that list is our own build # output, and the two stripping decisions are not the same one. Proton ships @@ -470,11 +473,17 @@ RUN for intruder in /usr/lib/systemd/systemd /sbin/openrc-init /usr/bin/openrc-i # directory. Forty files, every one a false positive, hiding the one real # entry. # -# So: the programs in `bin/`, which are ordinary ELF and resolve normally, and +# So: the programs in `bin/`, which are ordinary ELF and resolve normally, # `ntdll.so`, which is the first thing Wine loads and the one that linked the -# missing library. That is exactly the failure that got through, with none of -# the noise. The Windows-side DLLs beside them are not ELF and `ldd` skips them -# anyway. +# missing library, and `winepulse.so`, which is audio. That is exactly the +# failure that got through, with none of the noise. The Windows-side DLLs beside +# them are not ELF and `ldd` skips them anyway. +# +# Each file is resolved with its own directory on the library path. That is +# where Wine's loader finds the siblings a unix object links by soname, so +# `winepulse.so` naming `ntdll.so` resolves the way it does at run time, and +# what is left is a library the image really lacks. For every other file in +# the list it changes nothing. # The output is one file per line with its own missing libraries under it, and # then every missing library once at the end. That last list is what somebody # acts on -- it is the set of packages to add -- and forty files each naming the @@ -486,13 +495,14 @@ RUN failed=0; \ : > /tmp/missing-libs; \ for f in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \ /usr/lib/libnescapture_layer.so \ - /usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/wireplumber /usr/bin/ip \ + /usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \ + /usr/bin/wireplumber /usr/bin/ip \ /usr/lib/libgallium-*.so /usr/lib/libEGL_mesa.so.0 \ /usr/lib/libvulkan_*.so /usr/lib/dri/*.so /usr/lib/gbm/*.so \ /usr/share/steam/compatibilitytools.d/proton-cachyos/files/bin/* \ /usr/share/steam/compatibilitytools.d/proton-cachyos/files/lib*/wine/*-unix/ntdll.so; do \ [ -e "$f" ] || continue; \ - libs="$(ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \ + libs="$(LD_LIBRARY_PATH="${f%/*}" ldd "$f" 2>/dev/null | awk '/not found/ { print $1 }')"; \ [ -n "$libs" ] || continue; \ failed=1; \ printf ' %s\n' "$f" >&2; \ @@ -507,8 +517,8 @@ RUN failed=0; \ fi RUN for required in /usr/bin/nesinit /usr/bin/nescope /usr/bin/neshub /usr/bin/neswire \ - /usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/wireplumber /usr/bin/ip \ - /usr/bin/python3 \ + /usr/bin/dbus-daemon /usr/bin/pipewire /usr/bin/pipewire-pulse \ + /usr/bin/wireplumber /usr/bin/ip /usr/bin/python3 \ /usr/share/steam/compatibilitytools.d/proton-cachyos/proton; do \ test -x "$required" || { echo "the image is missing $required" >&2; exit 1; }; \ done diff --git a/build/Containerfile.proton b/build/Containerfile.proton index b09befac..fb633159 100644 --- a/build/Containerfile.proton +++ b/build/Containerfile.proton @@ -1,102 +1,25 @@ # ═══════════════════════════════════════════════════════════ -# proton-cachyos, built wow64-only +# proton-ge, built wow64-only, as a copyable image +# +# This file does not build Proton. It packages a Proton tree that was already +# built, and it cannot build it: proton-ge's build runs its own steps in the +# Steam Runtime SDK image through a container engine, so it runs on the host, +# and running it inside this build would mean nested containers. +# `scripts/proton-build.sh` does the build, and this file wraps the result. # # Separate from the guest Containerfile because it is a separate cadence: # hours to build, and only when PROTON_TAG moves. The guest image pulls the -# result from a registry instead of rebuilding it, which is why this file is -# not part of that build and is not reached by `make build`. +# result from a registry instead of rebuilding it, which is why none of this +# is reached by `make build`. # -# The final stage is FROM scratch, so the image *is* the Proton tree — nothing -# to run, only something to COPY --from. +# It is FROM scratch, so the image *is* the Proton tree. There is nothing in +# it to run, only something to COPY --from. # # Build and publish with `make proton-image` / `make proton-push`. **Its -# context is this directory**, not the repository root the guest build uses: -# all it needs is the two scripts beside it, and a context of the whole tree -# would hand it a multi-gigabyte `output/` for no reason. -# -# `PROTON_TAG` is the one thing to change, and the Makefile derives the -# published image's version from it. They are the same number in two -# spellings, and an image whose name does not say which Proton is inside it -# is worse than no image. -# ═══════════════════════════════════════════════════════════ -FROM docker.io/archlinux:base-devel AS builder - -# proton-cachyos-native's makedepends, minus every lib32-* (that is the whole -# point of --enable-wow64), with two substitutions Arch requires: ocl-icd -# provides opencl-icd-loader, and mesa-libgl is folded into libglvnd. -# -# glib2-devel is the one that is easy to miss — it carries glib-mkenums, which -# libsoup's meson looks up through glib-2.0's pkg-config variables and fails on -# obscurely. unzip and zip are for the xalia dist step. afdko is not packaged at -# all, and the fonts submodule needs it, so it comes from PyPI. -RUN pacman -Syu --noconfirm --needed \ - alsa-lib clang cmake curl ffmpeg fontforge giflib git glib2-devel \ - glslang gnutls gtk3 libgphoto2 libglvnd libpulse libva libxcomposite \ - libxinerama libxxf86vm lld mesa meson ninja nasm \ - opencl-headers ocl-icd pcsclite perl perl-json python python-pip \ - python-pefile python-setuptools-scm rsync rust samba unixodbc \ - unzip zip v4l-utils vulkan-headers vulkan-icd-loader wayland \ - wayland-protocols wget xorg-util-macros \ - mingw-w64-gcc mingw-w64-binutils mingw-w64-crt mingw-w64-headers \ - mingw-w64-winpthreads \ - && pip install --break-system-packages --no-cache-dir afdko \ - && pacman -Scc --noconfirm - -# widl generates vkd3d's public headers. Without it autoconf sets HAVE_WIDL to -# false, the headers are silently not generated, and the build dies an hour -# later on a missing vkd3d_d3dx9shader.h. Arch ships widl only inside `wine`, -# which requires multilib; the AUR's mingw-w64-tools builds it standalone, so do -# the same. -# -# The digest is not a formality. What is extracted here has its `configure` and -# its makefiles run as root in this builder, and what they produce is copied -# into the image a box runs -- so whoever can change these bytes can change what -# runs on every host. SourceForge hands the request to whichever mirror it -# likes, over a connection this builder does not pin, and the project publishes -# no signature. The digest is the only thing that makes the mirror not matter. -# -# Taken 2026-09-14 from two different mirrors of v14.0.0, which agreed. A -# mismatch here is not a thing to paper over by taking the new digest: it means -# the bytes behind this exact version string changed, and that wants looking at -# before it wants fixing. -ARG MINGW_W64_VER=14.0.0 -ARG MINGW_W64_SHA256=6eaf921d9eb987d3820b364ea9775bc19b965ec81490b6fdd716526c28e1995c -RUN curl -fL --retry 3 -o /tmp/mingw-w64.tar.bz2 \ - "https://sourceforge.net/projects/mingw-w64/files/mingw-w64/mingw-w64-release/mingw-w64-v${MINGW_W64_VER}.tar.bz2/download" \ - && echo "${MINGW_W64_SHA256} /tmp/mingw-w64.tar.bz2" | sha256sum -c - \ - && tar xf /tmp/mingw-w64.tar.bz2 -C /tmp \ - && for arch in i686-w64-mingw32 x86_64-w64-mingw32; do \ - mkdir -p "/tmp/widl-${arch}" && cd "/tmp/widl-${arch}" \ - && "/tmp/mingw-w64-v${MINGW_W64_VER}/mingw-w64-tools/widl/configure" \ - --prefix=/usr --target="${arch}" --program-prefix="${arch}-" \ - && make -j"$(nproc)" && make install; \ - done \ - && rm -rf /tmp/mingw-w64* /tmp/widl-* - -WORKDIR /build - -ARG PROTON_GIT=https://github.com/CachyOS/proton-cachyos.git -ARG PROTON_TAG=cachyos-11.0-20260703-native -ARG GECKO_VER=2.47.4 -ARG MONO_VER=11.2.0 -ARG XALIA_VER=0.4.9 -ARG JOBS= - -# Fetch and build are separate layers on purpose: the submodule checkout runs -# well past ten minutes, and a build that fails on a flag or a missing tool must -# not pay for it again. -COPY scripts/proton-fetch.sh /build/proton-fetch.sh -RUN PROTON_GIT="${PROTON_GIT}" PROTON_TAG="${PROTON_TAG}" \ - GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" XALIA_VER="${XALIA_VER}" \ - bash /build/proton-fetch.sh - -COPY scripts/proton-build.sh /build/proton-build.sh -RUN GECKO_VER="${GECKO_VER}" MONO_VER="${MONO_VER}" JOBS="${JOBS}" \ - bash /build/proton-build.sh - - -# ═══════════════════════════════════════════════════════════ -# The publishable artifact: the Proton tree and nothing else +# context is the built tree** (`output/proton/obj/dist`), and not this +# directory or the repository root: the tree is the whole input, and it is +# the only thing the build needs to send. # ═══════════════════════════════════════════════════════════ FROM scratch -COPY --from=builder /artifacts/proton/ / +ARG BUILD_NAME=proton-ge +COPY . /usr/share/steam/compatibilitytools.d/${BUILD_NAME}/ diff --git a/build/Containerfile.proton.containerignore b/build/Containerfile.proton.containerignore deleted file mode 100644 index f7b2c9b1..00000000 --- a/build/Containerfile.proton.containerignore +++ /dev/null @@ -1,15 +0,0 @@ -# This build's context is `build/`, not the repository root, because all it -# needs is the two scripts beside the Containerfile. -# -# `output/` is the reason this file exists. It holds packed rootfs images — -# multiple gigabytes each — and a build context is copied before the first -# instruction runs, so without this line every Proton build starts by moving -# the last one it produced. -output - -# Nothing else here is an input to this build. -etc -README.md -Makefile -Containerfile -Containerfile.containerignore diff --git a/build/Makefile b/build/Makefile index e5c33dd2..b6a42ff0 100644 --- a/build/Makefile +++ b/build/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/bash -.PHONY: build build-debug image image-debug proton-image proton-push clean help +.PHONY: build build-debug image image-debug kernel kernel-clean proton-image proton-push proton-clean clean help CONTAINER_RT := $(shell command -v podman 2>/dev/null || command -v docker 2>/dev/null) ifeq ($(CONTAINER_RT),) @@ -37,14 +37,53 @@ IMAGE_NAME := ghcr.io/nestrilabs/nestri/base # number in two spellings and an image whose name does not say which Proton is # inside it is worse than no image at all. Changing the tag by hand and # forgetting the version is exactly the mistake this removes. -PROTON_TAG ?= cachyos-11.0-20260703-native -PROTON_VERSION := $(PROTON_TAG:cachyos-%-native=%) +PROTON_GIT ?= https://github.com/gloriouseggroll/proton-ge-custom.git +PROTON_TAG ?= GE-Proton11-7 +PROTON_VERSION := 11.0-20260703 PROTON_IMAGE ?= ghcr.io/nestrilabs/proton-cachyos-native-wow64 PROTON_REF := $(PROTON_IMAGE):$(PROTON_VERSION) +# The directory name under compatibilitytools.d, and the name Steam shows. +PROTON_NAME := proton-ge OUTPUT_DIR := output +# Source, build tree, ccache and cargo downloads. Tens of gigabytes, and it +# lives under output/ because that directory is already ignored by git and +# kept out of the guest build's context. +PROTON_WORK := $(OUTPUT_DIR)/proton ROOTFS_SIZE ?= 3G FORCE_REBUILD ?= +# The guest kernel: CachyOS's fork, for its scheduler patches, with our own +# minimal config on top (kernel/nestri.fragment). The tree is a clone rather +# than vendored, and lives under output/ for the same reason Proton's does. +# KERNEL_SRC may point at an existing tree elsewhere. +KERNEL_GIT ?= https://github.com/CachyOS/linux.git +KERNEL_REF ?= cachyos-7.2.6-1 +# The target hardware is at least AVX2-capable. Never `native`: the build host +# is not the target. Empty leaves the tree's own generic choice alone. +KERNEL_MARCH ?= x86-64-v3 + +# Experimental: KERNEL_INFINITY=1 applies the Infinity scheduler series (fair, +# rt and DRM scheduler rework) on top. It builds in its own tree and installs +# under its own name, so the stock kernel is never patched and switching the +# flag back and forth never needs a revert. +# +# The series is published per CachyOS release, so its directory is derived +# from KERNEL_REF rather than written twice: bumping the kernel to a release +# the series does not cover fails at the patch step, not in a booted box. +KERNEL_INFINITY ?= +INFINITY_GIT ?= https://github.com/galpt/infinity-sched-new.git +INFINITY_REV ?= e6c85d841f25e21393a7ea47fdef286318915d8c +INFINITY_SERIES := patches/cachyos/tuned-eevdf/$(KERNEL_REF:cachyos-%=%) +INFINITY_WORK := $(OUTPUT_DIR)/infinity-sched + +ifneq ($(KERNEL_INFINITY),) +KERNEL_SRC ?= $(OUTPUT_DIR)/kernel-infinity +KERNEL_OUTPUT := $(OUTPUT_DIR)/vmlinux-infinity +else +KERNEL_SRC ?= $(OUTPUT_DIR)/kernel +KERNEL_OUTPUT := $(OUTPUT_DIR)/vmlinux +endif + build: DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \ --build-arg PROTON_IMAGE=$(PROTON_REF) \ @@ -55,13 +94,20 @@ build-debug: --build-arg PROTON_IMAGE=$(PROTON_REF) \ -f Containerfile -t $(IMAGE_NAME):debug --target runtime_debug $(CONTEXT) -# Hours, and only when PROTON_TAG moves. Its context is this directory rather -# than the repository root: the two scripts beside the Containerfile are the -# whole input, and the root would hand it everything else for nothing. +# Hours, and only when PROTON_TAG moves. Two steps: the build runs on the host, +# because proton-ge's build drives the container engine itself, and the +# finished tree is then the whole context of a FROM scratch image. See the head +# of Containerfile.proton for why the build cannot run inside it. +# +# A second run of the same tag resumes where the last one stopped. A new tag, +# or FORCE_REBUILD, starts the tree over but keeps ccache. proton-image: - DOCKER_BUILDKIT=1 $(CONTAINER_RT) build $(if $(FORCE_REBUILD),--no-cache,) \ - --build-arg PROTON_TAG=$(PROTON_TAG) \ - -f Containerfile.proton -t $(PROTON_REF) . + PROTON_GIT=$(PROTON_GIT) PROTON_TAG=$(PROTON_TAG) PROTON_WORK=$(PROTON_WORK) \ + BUILD_NAME=$(PROTON_NAME) CONTAINER_ENGINE=$(notdir $(CONTAINER_RT)) \ + FORCE_REBUILD=$(FORCE_REBUILD) \ + bash scripts/proton-build.sh + $(CONTAINER_RT) build --build-arg BUILD_NAME=$(PROTON_NAME) \ + -f Containerfile.proton -t $(PROTON_REF) $(PROTON_WORK)/obj/dist @echo "Built $(PROTON_REF)" # Publishing is what makes `make build` cheap for everyone else, since that @@ -82,8 +128,29 @@ image-debug: build-debug @mkdir -p $(OUTPUT_DIR) bash scripts/mkimage.sh $(IMAGE_NAME):debug $(OUTPUT_DIR)/rootfs-debug.ext4 $(ROOTFS_SIZE) +# Not part of `build`: it changes far less often than the rootfs, and nothing +# in the image depends on it. A rerun rebuilds only what changed in the tree. +kernel: + KERNEL_GIT=$(KERNEL_GIT) KERNEL_REF=$(KERNEL_REF) KERNEL_SRC=$(KERNEL_SRC) \ + KERNEL_MARCH=$(KERNEL_MARCH) KERNEL_OUTPUT=$(KERNEL_OUTPUT) \ + KERNEL_INFINITY=$(KERNEL_INFINITY) INFINITY_GIT=$(INFINITY_GIT) \ + INFINITY_REV=$(INFINITY_REV) INFINITY_SERIES=$(INFINITY_SERIES) \ + INFINITY_WORK=$(INFINITY_WORK) \ + bash scripts/kernel-build.sh + +# Leaves $(PROTON_WORK) and the kernel alone: one is hours of build and ccache, +# the other a clone and a warm object tree, and a rootfs is neither. +# proton-clean and kernel-clean are the ones that drop them. clean: - rm -rf $(OUTPUT_DIR) + find $(OUTPUT_DIR) -mindepth 1 -maxdepth 1 ! -name proton ! -name 'kernel*' \ + ! -name 'vmlinux*' ! -name infinity-sched -exec rm -rf {} + 2>/dev/null || true + +kernel-clean: + rm -rf $(OUTPUT_DIR)/kernel $(OUTPUT_DIR)/kernel-infinity $(INFINITY_WORK) \ + $(OUTPUT_DIR)/vmlinux $(OUTPUT_DIR)/vmlinux-infinity + +proton-clean: + rm -rf $(PROTON_WORK) help: @echo "Usage:" @@ -91,9 +158,13 @@ help: @echo " make build-debug Build the runtime_debug container image" @echo " make image Build + pack runtime_prod into output/rootfs.ext4" @echo " make image-debug Build + pack runtime_debug into output/rootfs-debug.ext4" - @echo " make clean Remove output/" + @echo " make kernel Build the guest kernel into output/vmlinux" + @echo " make KERNEL_INFINITY=1 kernel Experimental Infinity scheduler build → output/vmlinux-infinity" + @echo " make kernel-clean Remove both kernel trees and images" + @echo " make clean Remove the rootfs images from output/" @echo " make proton-image Build Proton from source (hours)" + @echo " make proton-clean Remove the Proton source, build tree and ccache" @echo " make proton-push Build it and publish it" @echo " make FORCE_REBUILD=1 ... Rebuild from scratch, no layer cache" - @echo " make PROTON_TAG=... ... Use a different proton-cachyos tag" + @echo " make PROTON_TAG=... ... Use a different proton-ge tag" @echo " make ROOTFS_SIZE=8G image Override the packed image size (default 5G)" diff --git a/build/README.md b/build/README.md index 766b6e35..561259b4 100644 --- a/build/README.md +++ b/build/README.md @@ -66,17 +66,16 @@ wrong about Proton: it is compiled from source, which is not a thing you can do with closed software. Keeping it out cost a box the only way it has to run a Windows title, for a rule that did not apply to it. -What it is: **proton-cachyos built with `--enable-wow64`**, pulled by tag as a +What it is: **proton-ge built wow64-only**, pulled by tag as a published image rather than rebuilt here, because it takes hours and moves only when its own tag does. `PROTON_IMAGE` overrides the tag, and it has to be -declared before the first `FROM` — an `ARG` a `FROM` expands is global or it +declared before the first `FROM`: an `ARG` a `FROM` expands is global or it is nothing, and getting that wrong fails with `no FROM statement found`, which -says nothing about the actual mistake. wow64 is the whole reason it is a build of ours -and not the distribution's package — it runs 32-bit Windows code inside a -64-bit unix process, so a box needs no lib32 glibc, no second Mesa for i686, -and no second capture layer for 32-bit titles to be captured. The -distribution's package is built without the flag, which is exactly why it -depends on `lib32-*`. +says nothing about the actual mistake. wow64 is the whole reason it is a build +of ours and not a released one. It runs 32-bit Windows code inside a 64-bit +unix process, so a box needs no lib32 glibc, no second Mesa for i686, and no +second capture layer for 32-bit titles to be captured. The released builds +carry a 32-bit unix side, which is exactly why they need `lib32-*`. It costs about 1.4 GB of image, and it is the one thing in here that is payload-shaped: a compatibility layer for Windows games in an image that is @@ -92,6 +91,59 @@ the virtio-gpu native-context protocol never drift apart. Only Mesa — `virglrenderer` is the host half of that protocol and nesbox builds its own, patched, from `nesbox/patches/`; nothing in this image carries it. +## The guest kernel + +```sh +make kernel # clone if needed, configure, verify, build → output/vmlinux +make KERNEL_SRC=~/src/linux kernel # build an existing tree instead +make kernel-clean # drop the tree and the image +``` + +CachyOS's fork (`KERNEL_REF` in the `Makefile`), taken for its scheduler +patches, not its config: theirs is a desktop build with thousands of modules, +and this guest has `CONFIG_MODULES` off and no `/lib/modules` at all. + +- **`kernel/nestri.fragment` is the source of truth**, and says why each entry + is there. It is merged onto the tree's `.config`, resolved with + `olddefconfig`, and then **checked**: any entry that did not survive fails the + build. `merge_config.sh` and `olddefconfig` both drop options quietly, and + the worst of these fails as perfectly healthy, perfectly silent audio. +- **`kernel/base.config` is only a seed** for a tree with no `.config`, so a + fresh clone does not start from `defconfig`'s enormous driver set. Change the + fragment, not the seed and not a tree's `.config`. +- **`vmlinux`, not `bzImage`.** The guest is loaded as a raw ELF with no + bootloader in the path. It is ~16 MB unstripped, which costs nothing at run + time: only the loadable segments are mapped. +- **`-march=x86-64-v3`** goes in through `KCFLAGS`. It is safe in a kernel: + the kernel's own `-mno-sse -mno-avx …` masks every vector extension off + whatever the flag order, leaving v3's integer ISA. +- The tree is off the pinned ref (a bisect, a local patch)? The build warns + and builds what is there rather than checking the ref out over your work. + +### Experimental: the Infinity scheduler + +```sh +make KERNEL_INFINITY=1 kernel # → output/vmlinux-infinity +``` + +Applies [infinity-sched](https://github.com/galpt/infinity-sched-new)'s +series (GPL-2), which reworks the fair, RT and DRM schedulers for latency +under load. It is pinned by commit (`INFINITY_REV`), and the series directory +comes from `KERNEL_REF`, since upstream publishes one per CachyOS release. + +- **Its own tree and its own image.** It builds in `output/kernel-infinity`, + so the stock kernel is never patched and switching between the two needs no + revert. +- **All or nothing, zero fuzz.** The whole series is checked against the + stacked result before any of it is applied. The applied commit is recorded in + the tree; to move `INFINITY_REV`, start over with `make kernel-clean`. +- **Only the CPU half does anything here.** virtio-gpu does not use the DRM + scheduler and `CONFIG_DRM_SCHED` is not built, so the GPU patch is compiled + out. It is applied anyway because upstream says a partial series + misbehaves. +- Upstream's `/sys/kernel/debug/infinity_*` counters need `CONFIG_DEBUG_FS`, + which this kernel does not have. + ## Two packages that look droppable and are not `llvm-libs` is 164 MB, the largest single thing in the image after Proton, and @@ -109,13 +161,14 @@ a package list says that; the check below is what said it. `make build` **pulls** Proton by tag; it does not build it. Building it takes hours and it changes only when its tag moves, so it is one image published -once and copied into every guest image after that. `Containerfile.proton` is +once and copied into every guest image after that. `make proton-image` is that build, and it lives here so the published tag stays reproducible from this tree rather than from somebody's laptop. ```sh -make proton-image # the current tag -make PROTON_TAG=cachyos-11.1-20261115-native proton-image +make proton-image # the current tag +make PROTON_TAG=GE-Proton11-8 proton-image +make proton-clean # drop the source, build tree and ccache ``` **`PROTON_TAG` is the only thing to change.** The published version is derived @@ -125,23 +178,38 @@ which Proton is inside it is worse than no image. The `Containerfile`'s own `PROTON_IMAGE` default is a fallback for a bare container build; going through `make` is what keeps them in step. -Its **context is `build/`**, not the repository root the guest build uses. All -it needs is the two scripts beside it, and `Containerfile.proton.containerignore` -keeps `output/` out of that context — a build context is copied before the -first instruction runs, so without it every Proton build would begin by moving -the last rootfs image it produced. +**The build runs on the host, not in a `podman build`.** proton-ge's build is +container-driven itself: `make` runs outside, and every step runs in the Steam +Runtime SDK image through the container engine, so the host needs only git, +make and podman. Wrapping that in a container build would mean nested +containers. So there are two steps: -Two things in the recipe are worth knowing before changing it: +1. `scripts/proton-build.sh` clones the tag with its submodules, applies + proton-ge's patch set, and runs its build with one change: the arch list + drops the 32-bit unix side, which is what makes it wow64-only. Everything + happens under `output/proton/`, which is tens of gigabytes. +2. `Containerfile.proton` is `FROM scratch` with the built tree as its whole + context, so the image is the tree and nothing else. -- **Fetch and build are separate layers on purpose.** The submodule checkout - runs well past ten minutes, and a build that fails on a flag or a missing - tool must not pay for that again. Keep anything that can fail *fast* in - `proton-build.sh`. -- **`widl` is built by hand from the mingw-w64 release.** Without it autoconf - quietly sets `HAVE_WIDL` to false, vkd3d's public headers are never - generated, and the build dies an hour later on a missing header. Arch ships - `widl` only inside `wine`, which wants multilib — which is the thing - `--enable-wow64` exists to avoid. +Things worth knowing before changing it: + +- **A rerun of the same tag resumes.** The clone, the patching and the + configure step each run once per tag, and proton-ge's own make picks up + where it stopped. A new tag or `FORCE_REBUILD=1` starts the tree over; + ccache and the cargo downloads survive both. `make clean` leaves all of it + alone, and `make proton-clean` removes it. +- **The patch script does not fail on a patch that does not apply.** It + carries on and exits 0, so `proton-build.sh` greps its output + (`output/proton/patch.log`) and stops. Otherwise the result is an image + that looks fine and is missing a fix. +- **`patches/proton-ge/` is ours, applied after proton-ge's own set.** It holds + what wow64-only needs that proton-ge's makefile does not handle, and fixes + for things a tag pinned that have since moved. Each patch says why it exists + at its top, and each one fails the build outright once it stops applying, + which on a tag bump is usually upstream having fixed it. +- **The patch script is not idempotent**, so a tree is patched exactly once. + An interrupted run resets every submodule to the commits the tag pins before + patching again. ## There is no init system in here, and that is the design diff --git a/build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf b/build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf new file mode 100644 index 00000000..98917a0c --- /dev/null +++ b/build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf @@ -0,0 +1,66 @@ +# pipewire-pulse, the PulseAudio protocol on top of PipeWire, for clients that +# speak nothing else. Wine's audio driver is one of them. +# +# The socket moves out of the service user's runtime directory. That directory +# is 0700 and the workload runs as a different user, so a client there finds +# nothing and plays silently, which is the failure PipeWire's own socket was +# moved for. The workload is pointed here with PULSE_SERVER, and nesinit starts +# this under a umask that lets a user who does not own the socket open it. This +# path is written in nesinit too, and a test there compares the two. +# +# A drop-in replaces a key of pulse.properties rather than appending to it, so +# the default "unix:native" socket is not created as well. +pulse.properties = { + server.address = [ "unix:/run/pipewire/pulse-native" ] + + # module-always-sink creates a "Dummy Output" sink whenever no other sink + # exists, and while neswire's sink is not up yet that is always. A second + # sink is a second default-node candidate, and a game that picks it plays + # into nothing. neswire has to be the only sink; see the WirePlumber + # drop-in. + pulse.cmd.always-sink = false + + # The root is read-only and every box starts from the same image, so there + # is nothing to restore and nowhere to save, which is the reasoning behind + # turning off WirePlumber's state hooks. Left on, these are one more place + # that could pin a default. + pulse.cmd.device-manager = false + pulse.cmd.device-restore = false + pulse.cmd.stream-restore = false + + # Wine sizes its whole audio path from this number. At startup its driver + # opens a probe stream asking for a 1-frame request size, reads back what + # the server grants, which is this floor, and makes the device period ten + # times that. Streams then queue three periods. The default floor of 256 + # frames therefore becomes a 53 ms period and about 160 ms of audio in + # flight, which is desktop playback and not a game being streamed. + # + # 48 frames (1 ms) makes that period 10 ms, the default period Windows + # itself uses, so a game gets the buffering it was written for. A stream + # then asks for 480-frame requests, which is what is granted, so the floor + # affects the probe and nothing else Wine does. + pulse.min.req = 48/48000 + + # What a client gets when it asks for no particular buffering. The shipped + # default is two seconds, which is only harmless where nobody is listening + # live. 10 ms requests and 40 ms of target buffer are enough for a client + # that does not say. + pulse.default.req = 480/48000 + pulse.default.tlength = 1920/48000 +} + +# pipewire-pulse.conf raises the minimum node latency to 1024 frames (21.3 ms) +# inside a virtual machine, which a box is. That puts every Pulse client, and +# therefore Wine, at least 21 ms behind, whatever it asked for. Floor it where +# the graph itself is floored instead; see the pipewire.conf drop-in for why +# 256 frames. A drop-in appends to this array, and every matching rule applies +# in order, so this runs after the shipped rule and wins. +pulse.properties.rules = [ + { matches = [ { cpu.vm.name = !null } ] + actions = { + update-props = { + pulse.min.quantum = 256/48000 + } + } + } +] diff --git a/build/etc/pipewire/pipewire.conf.d/50-nestri.conf b/build/etc/pipewire/pipewire.conf.d/50-nestri.conf new file mode 100644 index 00000000..2dda99c3 --- /dev/null +++ b/build/etc/pipewire/pipewire.conf.d/50-nestri.conf @@ -0,0 +1,24 @@ +# The graph's clock, floored where the audio sender needs it. +# +# Inside a virtual machine, pipewire.conf raises the graph's minimum quantum to +# 1024 frames (21.3 ms), because a VM's timers jitter and small quanta then +# underrun on real audio hardware. A box is a VM, so that rule applies, and it +# silently overrides neswire's request for its 5 ms packet size. Every sample +# then waits for a 21 ms cycle before it can be encoded. +# +# The floor comes down to 256 frames (5.3 ms), the smallest power of two that +# holds one of neswire's packets. It is not lower than that on purpose: nothing +# here needs less, and the reason for the rule, a VM's timer driving the graph, +# still holds, because the Dummy-Driver is exactly that. +# +# A drop-in appends to a rules array, and every matching rule applies in order, +# so this one runs after the shipped one and wins. +context.properties.rules = [ + { matches = [ { cpu.vm.name = !null } ] + actions = { + update-props = { + default.clock.min-quantum = 256 + } + } + } +] diff --git a/build/etc/wireplumber/wireplumber.conf.d/50-nestri.conf b/build/etc/wireplumber/wireplumber.conf.d/50-nestri.conf index 1a679960..c5fcf4d5 100644 --- a/build/etc/wireplumber/wireplumber.conf.d/50-nestri.conf +++ b/build/etc/wireplumber/wireplumber.conf.d/50-nestri.conf @@ -3,14 +3,13 @@ # The goal is that `neswire` is the *only* Audio/Sink in the graph, so # default-node selection has exactly one candidate and cannot pick wrong. # -# The "Dummy Output" (`auto_null`) sink is not disabled here, and no longer -# needs to be: it came from pipewire-pulse's `module-always-sink`, and -# pipewire-pulse is no longer installed. WirePlumber ships -# scripts/fallback-sink.lua, which creates a node by the same name, but no -# component in wireplumber.conf references it, so it never loads. If auto_null -# ever comes back, it came back with pipewire-pulse -- the switch is -# `pulse.cmd.always-sink = false` in a pipewire-pulse.conf.d drop-in, not -# anything on this side. +# The "Dummy Output" (`auto_null`) sink is not disabled here, because it does +# not come from this side. pipewire-pulse creates it with `module-always-sink`, +# and the pipewire-pulse drop-in turns that off with +# `pulse.cmd.always-sink = false`. WirePlumber ships scripts/fallback-sink.lua, +# which creates a node by the same name, but no component in wireplumber.conf +# references it, so it never loads. If auto_null appears, look at the +# pipewire-pulse drop-in first. # # pipewire.conf's Dummy-Driver / Freewheel-Driver are a third thing again: # support.node.driver objects with no ports -- drivers, not sinks, so nothing diff --git a/build/kernel/base.config b/build/kernel/base.config new file mode 100644 index 00000000..ccbde962 --- /dev/null +++ b/build/kernel/base.config @@ -0,0 +1,3147 @@ +# +# Automatically generated file; DO NOT EDIT. +# Linux/x86 7.1.5 Kernel Configuration +# +CONFIG_CC_VERSION_TEXT="gcc (GCC) 16.2.1 20260810" +CONFIG_CC_IS_GCC=y +CONFIG_GCC_VERSION=160201 +CONFIG_CLANG_VERSION=0 +CONFIG_AS_IS_GNU=y +CONFIG_AS_VERSION=24700 +CONFIG_LD_IS_BFD=y +CONFIG_LD_VERSION=24700 +CONFIG_LLD_VERSION=0 +CONFIG_RUSTC_VERSION=109701 +CONFIG_RUST_IS_AVAILABLE=y +CONFIG_RUSTC_LLVM_VERSION=220106 +CONFIG_RUSTC_LLVM_MAJOR_VERSION=22 +CONFIG_CC_CAN_LINK=y +CONFIG_CC_HAS_ASM_GOTO_OUTPUT=y +CONFIG_CC_HAS_ASM_GOTO_TIED_OUTPUT=y +CONFIG_TOOLS_SUPPORT_RELR=y +CONFIG_CC_HAS_ASM_INLINE=y +CONFIG_CC_HAS_ASSUME=y +CONFIG_CC_HAS_NO_PROFILE_FN_ATTR=y +CONFIG_CC_HAS_COUNTED_BY=y +CONFIG_CC_HAS_COUNTED_BY_PTR=y +CONFIG_CC_HAS_MULTIDIMENSIONAL_NONSTRING=y +CONFIG_LD_CAN_USE_KEEP_IN_OVERLAY=y +CONFIG_RUSTC_HAS_SPAN_FILE=y +CONFIG_RUSTC_HAS_UNNECESSARY_TRANSMUTES=y +CONFIG_RUSTC_HAS_FILE_WITH_NUL=y +CONFIG_RUSTC_HAS_FILE_AS_C_STR=y +CONFIG_PAHOLE_VERSION=131 +CONFIG_IRQ_WORK=y +CONFIG_BUILDTIME_TABLE_SORT=y +CONFIG_THREAD_INFO_IN_TASK=y + +# +# General setup +# +CONFIG_INIT_ENV_ARG_LIMIT=32 +# CONFIG_COMPILE_TEST is not set +# CONFIG_WERROR is not set +CONFIG_LOCALVERSION="" +# CONFIG_LOCALVERSION_AUTO is not set +CONFIG_BUILD_SALT="" +CONFIG_HAVE_KERNEL_GZIP=y +CONFIG_HAVE_KERNEL_BZIP2=y +CONFIG_HAVE_KERNEL_LZMA=y +CONFIG_HAVE_KERNEL_XZ=y +CONFIG_HAVE_KERNEL_LZO=y +CONFIG_HAVE_KERNEL_LZ4=y +CONFIG_HAVE_KERNEL_ZSTD=y +# CONFIG_KERNEL_GZIP is not set +# CONFIG_KERNEL_BZIP2 is not set +# CONFIG_KERNEL_LZMA is not set +CONFIG_KERNEL_XZ=y +# CONFIG_KERNEL_LZO is not set +# CONFIG_KERNEL_LZ4 is not set +# CONFIG_KERNEL_ZSTD is not set +CONFIG_DEFAULT_INIT="" +CONFIG_DEFAULT_HOSTNAME="(none)" +CONFIG_SYSVIPC=y +CONFIG_SYSVIPC_SYSCTL=y +CONFIG_SYSVIPC_COMPAT=y +CONFIG_POSIX_MQUEUE=y +CONFIG_POSIX_MQUEUE_SYSCTL=y +# CONFIG_WATCH_QUEUE is not set +# CONFIG_CROSS_MEMORY_ATTACH is not set +# CONFIG_AUDIT is not set +CONFIG_HAVE_ARCH_AUDITSYSCALL=y + +# +# IRQ subsystem +# +CONFIG_GENERIC_IRQ_PROBE=y +CONFIG_GENERIC_IRQ_SHOW=y +CONFIG_GENERIC_IRQ_EFFECTIVE_AFF_MASK=y +CONFIG_GENERIC_PENDING_IRQ=y +CONFIG_GENERIC_IRQ_MIGRATION=y +CONFIG_HARDIRQS_SW_RESEND=y +CONFIG_IRQ_DOMAIN=y +CONFIG_IRQ_DOMAIN_HIERARCHY=y +CONFIG_GENERIC_MSI_IRQ=y +CONFIG_GENERIC_IRQ_MATRIX_ALLOCATOR=y +CONFIG_GENERIC_IRQ_RESERVATION_MODE=y +CONFIG_IRQ_FORCED_THREADING=y +CONFIG_SPARSE_IRQ=y +# end of IRQ subsystem + +CONFIG_CLOCKSOURCE_WATCHDOG=y +CONFIG_ARCH_CLOCKSOURCE_INIT=y +CONFIG_ARCH_WANTS_CLOCKSOURCE_READ_INLINE=y +CONFIG_GENERIC_TIME_VSYSCALL=y +CONFIG_GENERIC_CLOCKEVENTS=y +CONFIG_GENERIC_CLOCKEVENTS_BROADCAST=y +CONFIG_GENERIC_CLOCKEVENTS_BROADCAST_IDLE=y +CONFIG_GENERIC_CLOCKEVENTS_MIN_ADJUST=y +CONFIG_GENERIC_CLOCKEVENTS_COUPLED=y +CONFIG_GENERIC_CLOCKEVENTS_COUPLED_INLINE=y +CONFIG_GENERIC_CMOS_UPDATE=y +CONFIG_HRTIMER_REARM_DEFERRED=y +CONFIG_HAVE_POSIX_CPU_TIMERS_TASK_WORK=y +CONFIG_POSIX_CPU_TIMERS_TASK_WORK=y +CONFIG_CONTEXT_TRACKING=y +CONFIG_CONTEXT_TRACKING_IDLE=y + +# +# Timers subsystem +# +CONFIG_TICK_ONESHOT=y +CONFIG_NO_HZ_COMMON=y +# CONFIG_HZ_PERIODIC is not set +CONFIG_NO_HZ_IDLE=y +# CONFIG_NO_HZ_FULL is not set +# CONFIG_NO_HZ is not set +CONFIG_HIGH_RES_TIMERS=y +# CONFIG_POSIX_AUX_CLOCKS is not set +# end of Timers subsystem + +CONFIG_BPF=y +CONFIG_HAVE_EBPF_JIT=y +CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y + +# +# BPF subsystem +# +CONFIG_BPF_SYSCALL=y +# CONFIG_BPF_JIT is not set +CONFIG_BPF_UNPRIV_DEFAULT_OFF=y +# CONFIG_BPF_PRELOAD is not set +# end of BPF subsystem + +CONFIG_PREEMPT_BUILD=y +CONFIG_ARCH_HAS_PREEMPT_LAZY=y +# CONFIG_PREEMPT is not set +CONFIG_PREEMPT_LAZY=y +# CONFIG_PREEMPT_RT is not set +CONFIG_PREEMPT_COUNT=y +CONFIG_PREEMPTION=y +CONFIG_PREEMPT_DYNAMIC=y +# CONFIG_SCHED_CORE is not set + +# +# CPU/Task time and stats accounting +# +CONFIG_TICK_CPU_ACCOUNTING=y +# CONFIG_VIRT_CPU_ACCOUNTING_GEN is not set +# CONFIG_IRQ_TIME_ACCOUNTING is not set +# CONFIG_BSD_PROCESS_ACCT is not set +# CONFIG_TASKSTATS is not set +CONFIG_PSI=y +# CONFIG_PSI_DEFAULT_DISABLED is not set +# end of CPU/Task time and stats accounting + +CONFIG_CPU_ISOLATION=y + +# +# RCU Subsystem +# +CONFIG_TREE_RCU=y +CONFIG_PREEMPT_RCU=y +# CONFIG_RCU_EXPERT is not set +CONFIG_TREE_SRCU=y +CONFIG_TASKS_RCU_GENERIC=y +CONFIG_NEED_TASKS_RCU=y +CONFIG_TASKS_RCU=y +CONFIG_TASKS_TRACE_RCU=y +CONFIG_RCU_STALL_COMMON=y +CONFIG_RCU_NEED_SEGCBLIST=y +# end of RCU Subsystem + +# CONFIG_IKCONFIG is not set +# CONFIG_IKHEADERS is not set +CONFIG_LOG_BUF_SHIFT=17 +CONFIG_LOG_CPU_MAX_BUF_SHIFT=12 +CONFIG_HAVE_UNSTABLE_SCHED_CLOCK=y + +# +# Scheduler features +# +# CONFIG_UCLAMP_TASK is not set +# CONFIG_SCHED_PROXY_EXEC is not set +# end of Scheduler features + +CONFIG_ARCH_SUPPORTS_NUMA_BALANCING=y +CONFIG_ARCH_WANT_BATCHED_UNMAP_TLB_FLUSH=y +CONFIG_CC_HAS_INT128=y +CONFIG_CC_IMPLICIT_FALLTHROUGH="-Wimplicit-fallthrough=5" +CONFIG_CC_MS_EXTENSIONS="-fms-extensions" +CONFIG_GCC10_NO_ARRAY_BOUNDS=y +CONFIG_CC_NO_ARRAY_BOUNDS=y +CONFIG_GCC_NO_STRINGOP_OVERFLOW=y +CONFIG_CC_NO_STRINGOP_OVERFLOW=y +CONFIG_ARCH_SUPPORTS_INT128=y +CONFIG_SLAB_OBJ_EXT=y +CONFIG_CGROUPS=y +CONFIG_PAGE_COUNTER=y +# CONFIG_CGROUP_FAVOR_DYNMODS is not set +CONFIG_MEMCG=y +# CONFIG_MEMCG_V1 is not set +CONFIG_BLK_CGROUP=y +CONFIG_CGROUP_WRITEBACK=y +CONFIG_CGROUP_SCHED=y +CONFIG_GROUP_SCHED_WEIGHT=y +CONFIG_FAIR_GROUP_SCHED=y +# CONFIG_CFS_BANDWIDTH is not set +# CONFIG_RT_GROUP_SCHED is not set +CONFIG_CGROUP_PIDS=y +# CONFIG_CGROUP_RDMA is not set +# CONFIG_CGROUP_DMEM is not set +CONFIG_CGROUP_FREEZER=y +CONFIG_CPUSETS=y +# CONFIG_CPUSETS_V1 is not set +CONFIG_CGROUP_DEVICE=y +# CONFIG_CGROUP_CPUACCT is not set +CONFIG_CGROUP_PERF=y +CONFIG_CGROUP_BPF=y +# CONFIG_CGROUP_MISC is not set +# CONFIG_CGROUP_DEBUG is not set +CONFIG_SOCK_CGROUP_DATA=y +CONFIG_NAMESPACES=y +CONFIG_UTS_NS=y +CONFIG_TIME_NS=y +CONFIG_TIME_NS_VDSO=y +CONFIG_IPC_NS=y +CONFIG_USER_NS=y +CONFIG_PID_NS=y +CONFIG_NET_NS=y +# CONFIG_CHECKPOINT_RESTORE is not set +CONFIG_SCHED_AUTOGROUP=y +# CONFIG_RELAY is not set +# CONFIG_BLK_DEV_INITRD is not set +# CONFIG_BOOT_CONFIG is not set +CONFIG_CMDLINE_LOG_WRAP_IDEAL_LEN=1021 +# CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE is not set +CONFIG_CC_OPTIMIZE_FOR_SIZE=y +CONFIG_LD_ORPHAN_WARN=y +CONFIG_LD_ORPHAN_WARN_LEVEL="warn" +CONFIG_SYSCTL=y +CONFIG_HAVE_UID16=y +CONFIG_SYSCTL_EXCEPTION_TRACE=y +# CONFIG_SYSFS_SYSCALL is not set +CONFIG_HAVE_PCSPKR_PLATFORM=y +CONFIG_EXPERT=y +CONFIG_UID16=y +CONFIG_MULTIUSER=y +# CONFIG_SGETMASK_SYSCALL is not set +CONFIG_FHANDLE=y +CONFIG_POSIX_TIMERS=y +CONFIG_PRINTK=y +# CONFIG_BUG is not set +# CONFIG_PCSPKR_PLATFORM is not set +# CONFIG_BASE_SMALL is not set +CONFIG_FUTEX=y +CONFIG_FUTEX_PI=y +CONFIG_FUTEX_PRIVATE_HASH=y +CONFIG_EPOLL=y +CONFIG_SIGNALFD=y +CONFIG_TIMERFD=y +CONFIG_EVENTFD=y +CONFIG_SHMEM=y +CONFIG_AIO=y +CONFIG_IO_URING=y +# CONFIG_IO_URING_MOCK_FILE is not set +# CONFIG_ADVISE_SYSCALLS is not set +# CONFIG_MEMBARRIER is not set +CONFIG_KCMP=y +# CONFIG_RSEQ is not set +# CONFIG_CACHESTAT_SYSCALL is not set +# CONFIG_KALLSYMS is not set +CONFIG_ARCH_HAS_MEMBARRIER_SYNC_CORE=y +CONFIG_ARCH_SUPPORTS_MSEAL_SYSTEM_MAPPINGS=y +CONFIG_HAVE_PERF_EVENTS=y + +# +# Kernel Performance Events And Counters +# +CONFIG_PERF_EVENTS=y +# CONFIG_DEBUG_PERF_USE_VMALLOC is not set +# end of Kernel Performance Events And Counters + +# CONFIG_PROFILING is not set +# CONFIG_RUST is not set + +# +# Kexec and crash features +# +# CONFIG_KEXEC is not set +# CONFIG_KEXEC_FILE is not set +# end of Kexec and crash features + +# +# Live Update and Kexec HandOver +# +# CONFIG_KEXEC_HANDOVER is not set +# end of Live Update and Kexec HandOver +# end of General setup + +CONFIG_64BIT=y +CONFIG_X86_64=y +CONFIG_X86=y +CONFIG_INSTRUCTION_DECODER=y +CONFIG_OUTPUT_FORMAT="elf64-x86-64" +CONFIG_LOCKDEP_SUPPORT=y +CONFIG_STACKTRACE_SUPPORT=y +CONFIG_MMU=y +CONFIG_ARCH_MMAP_RND_BITS_MIN=28 +CONFIG_ARCH_MMAP_RND_BITS_MAX=32 +CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MIN=8 +CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MAX=16 +CONFIG_GENERIC_ISA_DMA=y +CONFIG_ARCH_MAY_HAVE_PC_FDC=y +CONFIG_GENERIC_CALIBRATE_DELAY=y +CONFIG_ARCH_HAS_CPU_RELAX=y +CONFIG_ARCH_HIBERNATION_POSSIBLE=y +CONFIG_ARCH_SUSPEND_POSSIBLE=y +CONFIG_AUDIT_ARCH=y +CONFIG_ARCH_SUPPORTS_UPROBES=y +CONFIG_FIX_EARLYCON_MEM=y +CONFIG_PGTABLE_LEVELS=5 + +# +# Processor type and features +# +CONFIG_SMP=y +CONFIG_X86_X2APIC=y +# CONFIG_X86_MPPARSE is not set +# CONFIG_X86_FRED is not set +# CONFIG_X86_EXTENDED_PLATFORM is not set +# CONFIG_X86_INTEL_LPSS is not set +# CONFIG_X86_AMD_PLATFORM_DEVICE is not set +# CONFIG_IOSF_MBI is not set +# CONFIG_SCHED_OMIT_FRAME_POINTER is not set +CONFIG_HYPERVISOR_GUEST=y +CONFIG_PARAVIRT=y +CONFIG_PARAVIRT_SPINLOCKS=y +CONFIG_X86_HV_CALLBACK_VECTOR=y +# CONFIG_XEN is not set +CONFIG_KVM_GUEST=y +CONFIG_ARCH_CPUIDLE_HALTPOLL=y +# CONFIG_PVH is not set +# CONFIG_PARAVIRT_TIME_ACCOUNTING is not set +CONFIG_PARAVIRT_CLOCK=y +# CONFIG_JAILHOUSE_GUEST is not set +# CONFIG_ACRN_GUEST is not set +# CONFIG_BHYVE_GUEST is not set +CONFIG_CC_HAS_MARCH_NATIVE=y +# CONFIG_X86_NATIVE_CPU is not set +CONFIG_X86_INTERNODE_CACHE_SHIFT=6 +CONFIG_X86_L1_CACHE_SHIFT=6 +CONFIG_X86_TSC=y +CONFIG_X86_HAVE_PAE=y +CONFIG_X86_CX8=y +CONFIG_X86_CMOV=y +CONFIG_X86_MINIMUM_CPU_FAMILY=64 +CONFIG_X86_DEBUGCTLMSR=y +CONFIG_IA32_FEAT_CTL=y +CONFIG_X86_VMX_FEATURE_NAMES=y +# CONFIG_PROCESSOR_SELECT is not set +CONFIG_CPU_SUP_INTEL=y +CONFIG_CPU_SUP_AMD=y +CONFIG_CPU_SUP_HYGON=y +CONFIG_CPU_SUP_CENTAUR=y +CONFIG_CPU_SUP_ZHAOXIN=y +CONFIG_BROADCAST_TLB_FLUSH=y +CONFIG_HPET_TIMER=y +# CONFIG_DMI is not set +# CONFIG_GART_IOMMU is not set +# CONFIG_MAXSMP is not set +CONFIG_NR_CPUS_RANGE_BEGIN=2 +CONFIG_NR_CPUS_RANGE_END=512 +CONFIG_NR_CPUS_DEFAULT=64 +CONFIG_NR_CPUS=32 +CONFIG_SCHED_MC_PRIO=y +CONFIG_X86_LOCAL_APIC=y +CONFIG_ACPI_MADT_WAKEUP=y +CONFIG_X86_IO_APIC=y +# CONFIG_X86_REROUTE_FOR_BROKEN_BOOT_IRQS is not set +# CONFIG_X86_MCE is not set + +# +# Performance monitoring +# +CONFIG_PERF_EVENTS_INTEL_UNCORE=y +CONFIG_PERF_EVENTS_INTEL_RAPL=y +CONFIG_PERF_EVENTS_INTEL_CSTATE=y +# CONFIG_PERF_EVENTS_AMD_POWER is not set +# CONFIG_PERF_EVENTS_AMD_UNCORE is not set +# CONFIG_PERF_EVENTS_AMD_BRS is not set +# end of Performance monitoring + +CONFIG_X86_VSYSCALL_EMULATION=y +# CONFIG_X86_IOPL_IOPERM is not set +CONFIG_MICROCODE=y +# CONFIG_MICROCODE_LATE_LOADING is not set +# CONFIG_MICROCODE_DBG is not set +# CONFIG_X86_MSR is not set +# CONFIG_X86_CPUID is not set +CONFIG_X86_DIRECT_GBPAGES=y +# CONFIG_NUMA is not set +CONFIG_ARCH_SPARSEMEM_ENABLE=y +CONFIG_ARCH_SPARSEMEM_DEFAULT=y +CONFIG_ILLEGAL_POINTER_VALUE=0xdead000000000000 +# CONFIG_X86_PMEM_LEGACY is not set +# CONFIG_X86_CHECK_BIOS_CORRUPTION is not set +# CONFIG_MTRR is not set +# CONFIG_X86_UMIP is not set +CONFIG_CC_HAS_IBT=y +CONFIG_X86_CET=y +CONFIG_X86_KERNEL_IBT=y +CONFIG_X86_INTEL_MEMORY_PROTECTION_KEYS=y +CONFIG_ARCH_PKEY_BITS=4 +# CONFIG_X86_INTEL_TSX_MODE_OFF is not set +# CONFIG_X86_INTEL_TSX_MODE_ON is not set +CONFIG_X86_INTEL_TSX_MODE_AUTO=y +# CONFIG_X86_SGX is not set +# CONFIG_X86_USER_SHADOW_STACK is not set +# CONFIG_EFI is not set +# CONFIG_HZ_100 is not set +# CONFIG_HZ_250 is not set +# CONFIG_HZ_300 is not set +CONFIG_HZ_1000=y +CONFIG_HZ=1000 +CONFIG_SCHED_HRTICK=y +CONFIG_ARCH_SUPPORTS_KEXEC=y +CONFIG_ARCH_SUPPORTS_KEXEC_FILE=y +CONFIG_ARCH_SUPPORTS_KEXEC_PURGATORY=y +CONFIG_ARCH_SUPPORTS_KEXEC_SIG=y +CONFIG_ARCH_SUPPORTS_KEXEC_SIG_FORCE=y +CONFIG_ARCH_SUPPORTS_KEXEC_BZIMAGE_VERIFY_SIG=y +CONFIG_ARCH_SUPPORTS_KEXEC_JUMP=y +CONFIG_ARCH_SUPPORTS_KEXEC_HANDOVER=y +CONFIG_ARCH_SUPPORTS_CRASH_DUMP=y +CONFIG_ARCH_DEFAULT_CRASH_DUMP=y +CONFIG_ARCH_SUPPORTS_CRASH_HOTPLUG=y +CONFIG_PHYSICAL_START=0x1000000 +# CONFIG_RELOCATABLE is not set +CONFIG_PHYSICAL_ALIGN=0x200000 +# CONFIG_ADDRESS_MASKING is not set +CONFIG_HOTPLUG_CPU=y +# CONFIG_COMPAT_VDSO is not set +CONFIG_LEGACY_VSYSCALL_XONLY=y +# CONFIG_LEGACY_VSYSCALL_NONE is not set +# CONFIG_CMDLINE_BOOL is not set +# CONFIG_MODIFY_LDT_SYSCALL is not set +# CONFIG_STRICT_SIGALTSTACK_SIZE is not set +CONFIG_HAVE_LIVEPATCH=y +CONFIG_HAVE_KLP_BUILD=y +# CONFIG_X86_BUS_LOCK_DETECT is not set +# end of Processor type and features + +CONFIG_CC_HAS_NAMED_AS=y +CONFIG_CC_HAS_NAMED_AS_FIXED_SANITIZERS=y +CONFIG_USE_X86_SEG_SUPPORT=y +CONFIG_CC_HAS_SLS=y +CONFIG_CC_HAS_RETURN_THUNK=y +CONFIG_CC_HAS_ENTRY_PADDING=y +CONFIG_FUNCTION_PADDING_CFI=11 +CONFIG_FUNCTION_PADDING_BYTES=16 +# CONFIG_CPU_MITIGATIONS is not set +CONFIG_ARCH_HAS_ADD_PAGES=y + +# +# Power management and ACPI options +# +# CONFIG_SUSPEND is not set +# CONFIG_HIBERNATION is not set +# CONFIG_PM_QOS_CPU_SYSTEM_WAKEUP is not set +# CONFIG_PM is not set +# CONFIG_ENERGY_MODEL is not set +CONFIG_ARCH_SUPPORTS_ACPI=y +CONFIG_ACPI=y +CONFIG_ACPI_LEGACY_TABLES_LOOKUP=y +CONFIG_ARCH_MIGHT_HAVE_ACPI_PDC=y +CONFIG_ACPI_SYSTEM_POWER_STATES_SUPPORT=y +CONFIG_ACPI_THERMAL_LIB=y +# CONFIG_ACPI_DEBUGGER is not set +CONFIG_ACPI_SPCR_TABLE=y +# CONFIG_ACPI_FPDT is not set +CONFIG_ACPI_LPIT=y +CONFIG_ACPI_REV_OVERRIDE_POSSIBLE=y +CONFIG_ACPI_EC=y +# CONFIG_ACPI_EC_DEBUGFS is not set +CONFIG_ACPI_AC=y +CONFIG_ACPI_BATTERY=y +CONFIG_ACPI_BUTTON=y +# CONFIG_ACPI_VIDEO is not set +CONFIG_ACPI_FAN=y +# CONFIG_ACPI_DOCK is not set +CONFIG_ACPI_CPU_FREQ_PSS=y +CONFIG_ACPI_PROCESSOR_CSTATE=y +CONFIG_ACPI_PROCESSOR_IDLE=y +CONFIG_ACPI_CPPC_LIB=y +CONFIG_ACPI_PROCESSOR=y +CONFIG_ACPI_HOTPLUG_CPU=y +# CONFIG_ACPI_PROCESSOR_AGGREGATOR is not set +CONFIG_ACPI_THERMAL=y +CONFIG_ACPI_PLATFORM_PROFILE=y +CONFIG_ACPI_CUSTOM_DSDT_FILE="" +CONFIG_ARCH_HAS_ACPI_TABLE_UPGRADE=y +CONFIG_ACPI_DEBUG=y +# CONFIG_ACPI_PCI_SLOT is not set +CONFIG_ACPI_CONTAINER=y +CONFIG_ACPI_HOTPLUG_IOAPIC=y +# CONFIG_ACPI_SBS is not set +# CONFIG_ACPI_HED is not set +# CONFIG_ACPI_REDUCED_HARDWARE_ONLY is not set +# CONFIG_ACPI_NFIT is not set +CONFIG_HAVE_ACPI_APEI=y +CONFIG_HAVE_ACPI_APEI_NMI=y +# CONFIG_ACPI_APEI is not set +# CONFIG_ACPI_DPTF is not set +# CONFIG_ACPI_CONFIGFS is not set +# CONFIG_ACPI_PFRUT is not set +CONFIG_ACPI_PCC=y +# CONFIG_ACPI_FFH is not set +CONFIG_ACPI_MRRM=y +# CONFIG_PMIC_OPREGION is not set +CONFIG_X86_PM_TIMER=y + +# +# CPU Frequency scaling +# +CONFIG_CPU_FREQ=y +CONFIG_CPU_FREQ_GOV_ATTR_SET=y +# CONFIG_CPU_FREQ_STAT is not set +# CONFIG_CPU_FREQ_DEFAULT_GOV_PERFORMANCE is not set +# CONFIG_CPU_FREQ_DEFAULT_GOV_POWERSAVE is not set +# CONFIG_CPU_FREQ_DEFAULT_GOV_USERSPACE is not set +CONFIG_CPU_FREQ_DEFAULT_GOV_SCHEDUTIL=y +CONFIG_CPU_FREQ_GOV_PERFORMANCE=y +# CONFIG_CPU_FREQ_GOV_POWERSAVE is not set +# CONFIG_CPU_FREQ_GOV_USERSPACE is not set +# CONFIG_CPU_FREQ_GOV_ONDEMAND is not set +# CONFIG_CPU_FREQ_GOV_CONSERVATIVE is not set +CONFIG_CPU_FREQ_GOV_SCHEDUTIL=y + +# +# CPU frequency scaling drivers +# +CONFIG_X86_INTEL_PSTATE=y +# CONFIG_X86_PCC_CPUFREQ is not set +CONFIG_X86_AMD_PSTATE=y +CONFIG_X86_AMD_PSTATE_DEFAULT_MODE=3 +# CONFIG_X86_AMD_PSTATE_UT is not set +# CONFIG_X86_ACPI_CPUFREQ is not set +# CONFIG_X86_SPEEDSTEP_CENTRINO is not set +# CONFIG_X86_P4_CLOCKMOD is not set + +# +# shared options +# +CONFIG_CPUFREQ_ARCH_CUR_FREQ=y +# end of CPU Frequency scaling + +# +# CPU Idle +# +CONFIG_CPU_IDLE=y +CONFIG_CPU_IDLE_GOV_LADDER=y +CONFIG_CPU_IDLE_GOV_MENU=y +# CONFIG_CPU_IDLE_GOV_TEO is not set +CONFIG_CPU_IDLE_GOV_HALTPOLL=y +CONFIG_HALTPOLL_CPUIDLE=y +# end of CPU Idle + +# CONFIG_INTEL_IDLE is not set +# end of Power management and ACPI options + +# +# Bus options (PCI etc.) +# +CONFIG_PCI_DIRECT=y +CONFIG_PCI_MMCONFIG=y +CONFIG_MMCONF_FAM10H=y +# CONFIG_ISA_BUS is not set +CONFIG_ISA_DMA_API=y +CONFIG_AMD_NB=y +CONFIG_AMD_NODE=y +# end of Bus options (PCI etc.) + +# +# Binary Emulations +# +CONFIG_IA32_EMULATION=y +# CONFIG_IA32_EMULATION_DEFAULT_DISABLED is not set +# CONFIG_X86_X32_ABI is not set +CONFIG_COMPAT_32=y +CONFIG_COMPAT=y +CONFIG_COMPAT_FOR_U64_ALIGNMENT=y +# end of Binary Emulations + +# CONFIG_VIRTUALIZATION is not set +CONFIG_X86_REQUIRED_FEATURE_ALWAYS=y +CONFIG_X86_REQUIRED_FEATURE_NOPL=y +CONFIG_X86_REQUIRED_FEATURE_CX8=y +CONFIG_X86_REQUIRED_FEATURE_CMOV=y +CONFIG_X86_REQUIRED_FEATURE_SYSFAST32=y +CONFIG_X86_REQUIRED_FEATURE_CPUID=y +CONFIG_X86_REQUIRED_FEATURE_FPU=y +CONFIG_X86_REQUIRED_FEATURE_PAE=y +CONFIG_X86_REQUIRED_FEATURE_PSE=y +CONFIG_X86_REQUIRED_FEATURE_PGE=y +CONFIG_X86_REQUIRED_FEATURE_MSR=y +CONFIG_X86_REQUIRED_FEATURE_FXSR=y +CONFIG_X86_REQUIRED_FEATURE_XMM=y +CONFIG_X86_REQUIRED_FEATURE_XMM2=y +CONFIG_X86_REQUIRED_FEATURE_LM=y +CONFIG_X86_DISABLED_FEATURE_UMIP=y +CONFIG_X86_DISABLED_FEATURE_VME=y +CONFIG_X86_DISABLED_FEATURE_K6_MTRR=y +CONFIG_X86_DISABLED_FEATURE_CYRIX_ARR=y +CONFIG_X86_DISABLED_FEATURE_CENTAUR_MCR=y +CONFIG_X86_DISABLED_FEATURE_PTI=y +CONFIG_X86_DISABLED_FEATURE_RETPOLINE=y +CONFIG_X86_DISABLED_FEATURE_RETPOLINE_LFENCE=y +CONFIG_X86_DISABLED_FEATURE_RETHUNK=y +CONFIG_X86_DISABLED_FEATURE_UNRET=y +CONFIG_X86_DISABLED_FEATURE_CALL_DEPTH=y +CONFIG_X86_DISABLED_FEATURE_LAM=y +CONFIG_X86_DISABLED_FEATURE_ENQCMD=y +CONFIG_X86_DISABLED_FEATURE_SGX=y +CONFIG_X86_DISABLED_FEATURE_XENPV=y +CONFIG_X86_DISABLED_FEATURE_TDX_GUEST=y +CONFIG_X86_DISABLED_FEATURE_USER_SHSTK=y +CONFIG_X86_DISABLED_FEATURE_FRED=y +CONFIG_X86_DISABLED_FEATURE_SEV_SNP=y +CONFIG_AS_WRUSS=y +CONFIG_ARCH_CONFIGURES_CPU_MITIGATIONS=y + +# +# General architecture-dependent options +# +CONFIG_HOTPLUG_SMT=y +CONFIG_ARCH_SUPPORTS_SCHED_SMT=y +CONFIG_ARCH_SUPPORTS_SCHED_CLUSTER=y +CONFIG_ARCH_SUPPORTS_SCHED_MC=y +CONFIG_SCHED_SMT=y +CONFIG_SCHED_CLUSTER=y +CONFIG_SCHED_MC=y +CONFIG_HOTPLUG_CORE_SYNC=y +CONFIG_HOTPLUG_CORE_SYNC_DEAD=y +CONFIG_HOTPLUG_CORE_SYNC_FULL=y +CONFIG_HOTPLUG_SPLIT_STARTUP=y +CONFIG_HOTPLUG_PARALLEL=y +CONFIG_GENERIC_IRQ_ENTRY=y +CONFIG_GENERIC_SYSCALL=y +CONFIG_GENERIC_ENTRY=y +# CONFIG_KPROBES is not set +# CONFIG_JUMP_LABEL is not set +# CONFIG_STATIC_CALL_SELFTEST is not set +CONFIG_HAVE_EFFICIENT_UNALIGNED_ACCESS=y +CONFIG_ARCH_USE_BUILTIN_BSWAP=y +CONFIG_HAVE_IOREMAP_PROT=y +CONFIG_HAVE_KPROBES=y +CONFIG_HAVE_KRETPROBES=y +CONFIG_HAVE_OPTPROBES=y +CONFIG_HAVE_KPROBES_ON_FTRACE=y +CONFIG_ARCH_CORRECT_STACKTRACE_ON_KRETPROBE=y +CONFIG_HAVE_FUNCTION_ERROR_INJECTION=y +CONFIG_HAVE_NMI=y +CONFIG_TRACE_IRQFLAGS_SUPPORT=y +CONFIG_TRACE_IRQFLAGS_NMI_SUPPORT=y +CONFIG_HAVE_ARCH_TRACEHOOK=y +CONFIG_HAVE_DMA_CONTIGUOUS=y +CONFIG_GENERIC_SMP_IDLE_THREAD=y +CONFIG_ARCH_HAS_FORTIFY_SOURCE=y +CONFIG_ARCH_HAS_SET_MEMORY=y +CONFIG_ARCH_HAS_SET_DIRECT_MAP=y +CONFIG_ARCH_HAS_CPU_FINALIZE_INIT=y +CONFIG_HAVE_ARCH_THREAD_STRUCT_WHITELIST=y +CONFIG_ARCH_WANTS_DYNAMIC_TASK_STRUCT=y +CONFIG_ARCH_WANTS_NO_INSTR=y +CONFIG_HAVE_ASM_MODVERSIONS=y +CONFIG_HAVE_REGS_AND_STACK_ACCESS_API=y +CONFIG_HAVE_RSEQ=y +CONFIG_HAVE_RUST=y +CONFIG_HAVE_FUNCTION_ARG_ACCESS_API=y +CONFIG_HAVE_HW_BREAKPOINT=y +CONFIG_HAVE_MIXED_BREAKPOINTS_REGS=y +CONFIG_HAVE_USER_RETURN_NOTIFIER=y +CONFIG_HAVE_PERF_EVENTS_NMI=y +CONFIG_HAVE_HARDLOCKUP_DETECTOR_PERF=y +CONFIG_UNWIND_USER=y +CONFIG_HAVE_UNWIND_USER_FP=y +CONFIG_HAVE_PERF_REGS=y +CONFIG_HAVE_PERF_USER_STACK_DUMP=y +CONFIG_HAVE_ARCH_JUMP_LABEL=y +CONFIG_HAVE_ARCH_JUMP_LABEL_RELATIVE=y +CONFIG_MMU_GATHER_TABLE_FREE=y +CONFIG_MMU_GATHER_RCU_TABLE_FREE=y +CONFIG_MMU_GATHER_MERGE_VMAS=y +CONFIG_ARCH_WANT_IRQS_OFF_ACTIVATE_MM=y +CONFIG_MMU_LAZY_TLB_REFCOUNT=y +CONFIG_ARCH_HAVE_NMI_SAFE_CMPXCHG=y +CONFIG_ARCH_HAVE_EXTRA_ELF_NOTES=y +CONFIG_ARCH_HAS_NMI_SAFE_THIS_CPU_OPS=y +CONFIG_HAVE_ALIGNED_STRUCT_PAGE=y +CONFIG_HAVE_CMPXCHG_LOCAL=y +CONFIG_HAVE_CMPXCHG_DOUBLE=y +CONFIG_ARCH_WANT_COMPAT_IPC_PARSE_VERSION=y +CONFIG_ARCH_WANT_OLD_COMPAT_IPC=y +CONFIG_HAVE_ARCH_SECCOMP=y +CONFIG_HAVE_ARCH_SECCOMP_FILTER=y +CONFIG_SECCOMP=y +CONFIG_SECCOMP_FILTER=y +# CONFIG_SECCOMP_CACHE_DEBUG is not set +CONFIG_HAVE_ARCH_KSTACK_ERASE=y +CONFIG_HAVE_STACKPROTECTOR=y +# CONFIG_STACKPROTECTOR is not set +CONFIG_ARCH_SUPPORTS_LTO_CLANG=y +CONFIG_ARCH_SUPPORTS_LTO_CLANG_THIN=y +CONFIG_LTO_NONE=y +CONFIG_ARCH_SUPPORTS_AUTOFDO_CLANG=y +CONFIG_ARCH_SUPPORTS_PROPELLER_CLANG=y +CONFIG_ARCH_SUPPORTS_CFI=y +CONFIG_HAVE_ARCH_WITHIN_STACK_FRAMES=y +CONFIG_HAVE_CONTEXT_TRACKING_USER=y +CONFIG_HAVE_CONTEXT_TRACKING_USER_OFFSTACK=y +CONFIG_HAVE_VIRT_CPU_ACCOUNTING_GEN=y +CONFIG_HAVE_IRQ_TIME_ACCOUNTING=y +CONFIG_HAVE_PV_STEAL_CLOCK_GEN=y +CONFIG_HAVE_MOVE_PUD=y +CONFIG_HAVE_MOVE_PMD=y +CONFIG_HAVE_ARCH_TRANSPARENT_HUGEPAGE=y +CONFIG_HAVE_ARCH_TRANSPARENT_HUGEPAGE_PUD=y +CONFIG_HAVE_ARCH_HUGE_VMAP=y +CONFIG_HAVE_ARCH_HUGE_VMALLOC=y +CONFIG_ARCH_WANT_HUGE_PMD_SHARE=y +CONFIG_ARCH_WANT_PMD_MKWRITE=y +CONFIG_HAVE_ARCH_SOFT_DIRTY=y +CONFIG_HAVE_MOD_ARCH_SPECIFIC=y +CONFIG_MODULES_USE_ELF_RELA=y +CONFIG_HAVE_IRQ_EXIT_ON_IRQ_STACK=y +CONFIG_HAVE_SOFTIRQ_ON_OWN_STACK=y +CONFIG_SOFTIRQ_ON_OWN_STACK=y +CONFIG_ARCH_HAS_ELF_RANDOMIZE=y +CONFIG_HAVE_ARCH_MMAP_RND_BITS=y +CONFIG_HAVE_EXIT_THREAD=y +CONFIG_ARCH_MMAP_RND_BITS=28 +CONFIG_HAVE_ARCH_MMAP_RND_COMPAT_BITS=y +CONFIG_ARCH_MMAP_RND_COMPAT_BITS=8 +CONFIG_HAVE_ARCH_COMPAT_MMAP_BASES=y +CONFIG_HAVE_PAGE_SIZE_4KB=y +CONFIG_PAGE_SIZE_4KB=y +CONFIG_PAGE_SIZE_LESS_THAN_64KB=y +CONFIG_PAGE_SIZE_LESS_THAN_256KB=y +CONFIG_PAGE_SHIFT=12 +CONFIG_HAVE_OBJTOOL=y +CONFIG_HAVE_JUMP_LABEL_HACK=y +CONFIG_HAVE_NOINSTR_HACK=y +CONFIG_HAVE_NOINSTR_VALIDATION=y +CONFIG_HAVE_UACCESS_VALIDATION=y +CONFIG_HAVE_STACK_VALIDATION=y +CONFIG_OLD_SIGSUSPEND3=y +CONFIG_COMPAT_OLD_SIGACTION=y +CONFIG_COMPAT_32BIT_TIME=y +CONFIG_ARCH_SUPPORTS_RT=y +CONFIG_HAVE_ARCH_VMAP_STACK=y +CONFIG_VMAP_STACK=y +CONFIG_HAVE_ARCH_RANDOMIZE_KSTACK_OFFSET=y +# CONFIG_RANDOMIZE_KSTACK_OFFSET is not set +CONFIG_ARCH_HAS_STRICT_KERNEL_RWX=y +CONFIG_STRICT_KERNEL_RWX=y +CONFIG_ARCH_HAS_STRICT_MODULE_RWX=y +CONFIG_HAVE_ARCH_PREL32_RELOCATIONS=y +CONFIG_ARCH_HAS_MEM_ENCRYPT=y +CONFIG_HAVE_STATIC_CALL=y +CONFIG_HAVE_STATIC_CALL_INLINE=y +CONFIG_HAVE_PREEMPT_DYNAMIC=y +CONFIG_HAVE_PREEMPT_DYNAMIC_CALL=y +CONFIG_ARCH_WANT_LD_ORPHAN_WARN=y +CONFIG_ARCH_SUPPORTS_DEBUG_PAGEALLOC=y +CONFIG_ARCH_SUPPORTS_PAGE_TABLE_CHECK=y +CONFIG_ARCH_HAS_ELFCORE_COMPAT=y +CONFIG_ARCH_HAS_PARANOID_L1D_FLUSH=y +CONFIG_DYNAMIC_SIGFRAME=y +CONFIG_ARCH_HAS_HW_PTE_YOUNG=y +CONFIG_ARCH_HAS_NONLEAF_PMD_YOUNG=y +CONFIG_ARCH_HAS_KERNEL_FPU_SUPPORT=y +CONFIG_HAVE_GENERIC_TIF_BITS=y + +# +# GCOV-based kernel profiling +# +CONFIG_ARCH_HAS_GCOV_PROFILE_ALL=y +# end of GCOV-based kernel profiling + +CONFIG_HAVE_GCC_PLUGINS=y +# CONFIG_GCC_PLUGINS is not set +CONFIG_FUNCTION_ALIGNMENT_4B=y +CONFIG_FUNCTION_ALIGNMENT_16B=y +CONFIG_FUNCTION_ALIGNMENT=16 +CONFIG_CC_HAS_MIN_FUNCTION_ALIGNMENT=y +CONFIG_CC_HAS_SANE_FUNCTION_ALIGNMENT=y +# end of General architecture-dependent options + +CONFIG_RT_MUTEXES=y +# CONFIG_MODULES is not set +CONFIG_BLOCK=y +CONFIG_BLOCK_LEGACY_AUTOLOAD=y +# CONFIG_BLK_DEV_BSGLIB is not set +# CONFIG_BLK_DEV_INTEGRITY is not set +CONFIG_BLK_DEV_WRITE_MOUNTED=y +# CONFIG_BLK_DEV_ZONED is not set +# CONFIG_BLK_DEV_THROTTLING is not set +# CONFIG_BLK_WBT is not set +# CONFIG_BLK_CGROUP_IOLATENCY is not set +# CONFIG_BLK_CGROUP_IOCOST is not set +# CONFIG_BLK_CGROUP_IOPRIO is not set +# CONFIG_BLK_INLINE_ENCRYPTION is not set + +# +# Partition Types +# +# CONFIG_PARTITION_ADVANCED is not set +CONFIG_MSDOS_PARTITION=y +CONFIG_EFI_PARTITION=y +# end of Partition Types + +# +# IO Schedulers +# +CONFIG_MQ_IOSCHED_DEADLINE=y +CONFIG_MQ_IOSCHED_KYBER=y +# CONFIG_IOSCHED_BFQ is not set +# end of IO Schedulers + +CONFIG_UNINLINE_SPIN_UNLOCK=y +CONFIG_ARCH_SUPPORTS_ATOMIC_RMW=y +CONFIG_MUTEX_SPIN_ON_OWNER=y +CONFIG_RWSEM_SPIN_ON_OWNER=y +CONFIG_LOCK_SPIN_ON_OWNER=y +CONFIG_ARCH_USE_QUEUED_SPINLOCKS=y +CONFIG_QUEUED_SPINLOCKS=y +CONFIG_ARCH_USE_QUEUED_RWLOCKS=y +CONFIG_QUEUED_RWLOCKS=y +CONFIG_ARCH_HAS_NON_OVERLAPPING_ADDRESS_SPACE=y +CONFIG_ARCH_HAS_SYNC_CORE_BEFORE_USERMODE=y +CONFIG_ARCH_HAS_SYSCALL_WRAPPER=y +CONFIG_FREEZER=y + +# +# Executable file formats +# +CONFIG_BINFMT_ELF=y +CONFIG_COMPAT_BINFMT_ELF=y +CONFIG_ELFCORE=y +CONFIG_BINFMT_SCRIPT=y +# CONFIG_BINFMT_MISC is not set +# CONFIG_COREDUMP is not set +# end of Executable file formats + +# +# Memory Management options +# +CONFIG_SWAP=y +# CONFIG_ZSWAP is not set + +# +# Slab allocator options +# +CONFIG_SLUB=y +CONFIG_SLUB_TINY=y +CONFIG_SLAB_MERGE_DEFAULT=y +# end of Slab allocator options + +# CONFIG_SHUFFLE_PAGE_ALLOCATOR is not set +# CONFIG_COMPAT_BRK is not set +CONFIG_SPARSEMEM=y +CONFIG_SPARSEMEM_EXTREME=y +CONFIG_SPARSEMEM_VMEMMAP_ENABLE=y +CONFIG_SPARSEMEM_VMEMMAP=y +CONFIG_ARCH_WANT_OPTIMIZE_DAX_VMEMMAP=y +CONFIG_ARCH_WANT_OPTIMIZE_HUGETLB_VMEMMAP=y +CONFIG_ARCH_WANT_HUGETLB_VMEMMAP_PREINIT=y +CONFIG_HAVE_GUP_FAST=y +CONFIG_EXCLUSIVE_SYSTEM_RAM=y +CONFIG_ARCH_ENABLE_MEMORY_HOTPLUG=y +# CONFIG_MEMORY_HOTPLUG is not set +CONFIG_ARCH_MHP_MEMMAP_ON_MEMORY_ENABLE=y +CONFIG_SPLIT_PTE_PTLOCKS=y +CONFIG_ARCH_ENABLE_SPLIT_PMD_PTLOCK=y +CONFIG_SPLIT_PMD_PTLOCKS=y +CONFIG_BALLOON=y +CONFIG_BALLOON_MIGRATION=y +CONFIG_COMPACTION=y +CONFIG_COMPACT_UNEVICTABLE_DEFAULT=1 +CONFIG_PAGE_REPORTING=y +CONFIG_MIGRATION=y +CONFIG_ARCH_ENABLE_THP_MIGRATION=y +CONFIG_PCP_BATCH_SCALE_MAX=5 +CONFIG_PHYS_ADDR_T_64BIT=y +# CONFIG_KSM is not set +CONFIG_DEFAULT_MMAP_MIN_ADDR=4096 +CONFIG_ARCH_WANT_GENERAL_HUGETLB=y +CONFIG_ARCH_WANTS_THP_SWAP=y +# CONFIG_PERSISTENT_HUGE_ZERO_FOLIO is not set +CONFIG_MM_ID=y +CONFIG_TRANSPARENT_HUGEPAGE=y +# CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS is not set +CONFIG_TRANSPARENT_HUGEPAGE_MADVISE=y +# CONFIG_TRANSPARENT_HUGEPAGE_NEVER is not set +CONFIG_TRANSPARENT_HUGEPAGE_SHMEM_HUGE_NEVER=y +# CONFIG_TRANSPARENT_HUGEPAGE_SHMEM_HUGE_ALWAYS is not set +# CONFIG_TRANSPARENT_HUGEPAGE_SHMEM_HUGE_WITHIN_SIZE is not set +# CONFIG_TRANSPARENT_HUGEPAGE_SHMEM_HUGE_ADVISE is not set +CONFIG_TRANSPARENT_HUGEPAGE_TMPFS_HUGE_NEVER=y +# CONFIG_TRANSPARENT_HUGEPAGE_TMPFS_HUGE_ALWAYS is not set +# CONFIG_TRANSPARENT_HUGEPAGE_TMPFS_HUGE_WITHIN_SIZE is not set +# CONFIG_TRANSPARENT_HUGEPAGE_TMPFS_HUGE_ADVISE is not set +CONFIG_THP_SWAP=y +# CONFIG_READ_ONLY_THP_FOR_FS is not set +# CONFIG_NO_PAGE_MAPCOUNT is not set +CONFIG_PAGE_MAPCOUNT=y +CONFIG_PGTABLE_HAS_HUGE_LEAVES=y +CONFIG_ARCH_SUPPORTS_HUGE_PFNMAP=y +CONFIG_ARCH_SUPPORTS_PMD_PFNMAP=y +CONFIG_ARCH_SUPPORTS_PUD_PFNMAP=y +CONFIG_NEED_PER_CPU_EMBED_FIRST_CHUNK=y +CONFIG_NEED_PER_CPU_PAGE_FIRST_CHUNK=y +CONFIG_HAVE_SETUP_PER_CPU_AREA=y +# CONFIG_CMA is not set +CONFIG_PAGE_BLOCK_MAX_ORDER=10 +CONFIG_GENERIC_EARLY_IOREMAP=y +# CONFIG_DEFERRED_STRUCT_PAGE_INIT is not set +# CONFIG_IDLE_PAGE_TRACKING is not set +CONFIG_ARCH_HAS_CACHE_LINE_SIZE=y +CONFIG_ARCH_HAS_CURRENT_STACK_POINTER=y +CONFIG_ARCH_HAS_ZONE_DMA_SET=y +# CONFIG_ZONE_DMA is not set +CONFIG_ZONE_DMA32=y +CONFIG_ARCH_USES_HIGH_VMA_FLAGS=y +CONFIG_ARCH_HAS_PKEYS=y +CONFIG_VM_EVENT_COUNTERS=y +# CONFIG_PERCPU_STATS is not set + +# +# GUP_TEST needs to have DEBUG_FS enabled +# +# CONFIG_DMAPOOL_TEST is not set +CONFIG_ARCH_HAS_PTE_SPECIAL=y +CONFIG_MEMFD_CREATE=y +# CONFIG_SECRETMEM is not set +# CONFIG_USERFAULTFD is not set +CONFIG_LRU_GEN=y +CONFIG_LRU_GEN_ENABLED=y +# CONFIG_LRU_GEN_STATS is not set +CONFIG_LRU_GEN_WALKS_MMU=y +CONFIG_ARCH_SUPPORTS_PER_VMA_LOCK=y +CONFIG_PER_VMA_LOCK=y +CONFIG_LOCK_MM_AND_FIND_VMA=y +CONFIG_PT_RECLAIM=y + +# +# Data Access Monitoring +# +# CONFIG_DAMON is not set +# end of Data Access Monitoring +# end of Memory Management options + +CONFIG_NET=y +CONFIG_NET_INGRESS=y +CONFIG_NET_EGRESS=y +CONFIG_NET_XGRESS=y +CONFIG_NET_DEVMEM=y + +# +# Networking options +# +CONFIG_PACKET=y +# CONFIG_PACKET_DIAG is not set +# CONFIG_INET_PSP is not set +CONFIG_UNIX=y +CONFIG_AF_UNIX_OOB=y +# CONFIG_UNIX_DIAG is not set +# CONFIG_TLS is not set +# CONFIG_XFRM_USER is not set +# CONFIG_NET_KEY is not set +# CONFIG_DIBS is not set +# CONFIG_XDP_SOCKETS is not set +CONFIG_INET=y +# CONFIG_IP_MULTICAST is not set +# CONFIG_IP_ADVANCED_ROUTER is not set +CONFIG_IP_PNP=y +# CONFIG_IP_PNP_DHCP is not set +# CONFIG_IP_PNP_BOOTP is not set +# CONFIG_IP_PNP_RARP is not set +# CONFIG_NET_IPIP is not set +# CONFIG_NET_IPGRE_DEMUX is not set +CONFIG_NET_IP_TUNNEL=y +# CONFIG_SYN_COOKIES is not set +# CONFIG_NET_IPVTI is not set +# CONFIG_NET_FOU is not set +# CONFIG_NET_FOU_IP_TUNNELS is not set +# CONFIG_INET_AH is not set +# CONFIG_INET_ESP is not set +# CONFIG_INET_IPCOMP is not set +CONFIG_INET_TABLE_PERTURB_ORDER=16 +CONFIG_INET_TUNNEL=y +CONFIG_INET_DIAG=y +CONFIG_INET_TCP_DIAG=y +# CONFIG_INET_UDP_DIAG is not set +# CONFIG_INET_RAW_DIAG is not set +# CONFIG_INET_DIAG_DESTROY is not set +# CONFIG_TCP_CONG_ADVANCED is not set +CONFIG_TCP_CONG_CUBIC=y +CONFIG_DEFAULT_TCP_CONG="cubic" +# CONFIG_TCP_AO is not set +# CONFIG_TCP_MD5SIG is not set +CONFIG_IPV6=y +# CONFIG_IPV6_ROUTER_PREF is not set +# CONFIG_IPV6_OPTIMISTIC_DAD is not set +# CONFIG_INET6_AH is not set +# CONFIG_INET6_ESP is not set +# CONFIG_INET6_IPCOMP is not set +# CONFIG_IPV6_MIP6 is not set +# CONFIG_IPV6_VTI is not set +CONFIG_IPV6_SIT=y +# CONFIG_IPV6_SIT_6RD is not set +CONFIG_IPV6_NDISC_NODETYPE=y +# CONFIG_IPV6_TUNNEL is not set +# CONFIG_IPV6_MULTIPLE_TABLES is not set +# CONFIG_IPV6_MROUTE is not set +# CONFIG_IPV6_SEG6_LWTUNNEL is not set +# CONFIG_IPV6_SEG6_HMAC is not set +# CONFIG_IPV6_RPL_LWTUNNEL is not set +# CONFIG_IPV6_IOAM6_LWTUNNEL is not set +# CONFIG_MPTCP is not set +# CONFIG_NETWORK_SECMARK is not set +CONFIG_NET_PTP_CLASSIFY=y +# CONFIG_NETWORK_PHY_TIMESTAMPING is not set +# CONFIG_NETFILTER is not set +# CONFIG_IP_SCTP is not set +# CONFIG_RDS is not set +# CONFIG_TIPC is not set +# CONFIG_ATM is not set +# CONFIG_L2TP is not set +# CONFIG_BRIDGE is not set +# CONFIG_NET_DSA is not set +# CONFIG_VLAN_8021Q is not set +# CONFIG_LLC2 is not set +# CONFIG_ATALK is not set +# CONFIG_X25 is not set +# CONFIG_LAPB is not set +# CONFIG_PHONET is not set +# CONFIG_6LOWPAN is not set +# CONFIG_IEEE802154 is not set +# CONFIG_NET_SCHED is not set +# CONFIG_DCB is not set +# CONFIG_BATMAN_ADV is not set +# CONFIG_OPENVSWITCH is not set +CONFIG_VSOCKETS=y +CONFIG_VSOCKETS_DIAG=y +CONFIG_VSOCKETS_LOOPBACK=y +CONFIG_VIRTIO_VSOCKETS=y +CONFIG_VIRTIO_VSOCKETS_COMMON=y +# CONFIG_NETLINK_DIAG is not set +# CONFIG_MPLS is not set +# CONFIG_NET_NSH is not set +# CONFIG_HSR is not set +# CONFIG_NET_SWITCHDEV is not set +# CONFIG_NET_L3_MASTER_DEV is not set +# CONFIG_QRTR is not set +# CONFIG_NET_NCSI is not set +CONFIG_PCPU_DEV_REFCNT=y +CONFIG_MAX_SKB_FRAGS=17 +CONFIG_RPS=y +CONFIG_RFS_ACCEL=y +CONFIG_SOCK_RX_QUEUE_MAPPING=y +CONFIG_XPS=y +# CONFIG_CGROUP_NET_PRIO is not set +# CONFIG_CGROUP_NET_CLASSID is not set +CONFIG_NET_RX_BUSY_POLL=y +CONFIG_BQL=y +# CONFIG_BPF_STREAM_PARSER is not set +CONFIG_NET_FLOW_LIMIT=y + +# +# Network testing +# +# CONFIG_NET_PKTGEN is not set +# end of Network testing +# end of Networking options + +# CONFIG_CAN is not set +# CONFIG_BT is not set +# CONFIG_AF_RXRPC is not set +# CONFIG_AF_KCM is not set +# CONFIG_MCTP is not set +CONFIG_WIRELESS=y +# CONFIG_CFG80211 is not set + +# +# CFG80211 needs to be enabled for MAC80211 +# +CONFIG_MAC80211_STA_HASH_MAX_SIZE=0 +# CONFIG_RFKILL is not set +# CONFIG_NET_9P is not set +# CONFIG_CEPH_LIB is not set +# CONFIG_NFC is not set +# CONFIG_PSAMPLE is not set +# CONFIG_NET_IFE is not set +# CONFIG_LWTUNNEL is not set +CONFIG_DST_CACHE=y +CONFIG_GRO_CELLS=y +CONFIG_NET_SOCK_MSG=y +CONFIG_PAGE_POOL=y +# CONFIG_PAGE_POOL_STATS is not set +CONFIG_FAILOVER=y +CONFIG_ETHTOOL_NETLINK=y + +# +# Device Drivers +# +CONFIG_HAVE_PCI=y +CONFIG_GENERIC_PCI_IOMAP=y +CONFIG_PCI=y +CONFIG_PCI_DOMAINS=y +CONFIG_PCIEPORTBUS=y +# CONFIG_PCIEAER is not set +CONFIG_PCIEASPM=y +CONFIG_PCIEASPM_DEFAULT=y +# CONFIG_PCIEASPM_POWERSAVE is not set +# CONFIG_PCIEASPM_POWER_SUPERSAVE is not set +# CONFIG_PCIEASPM_PERFORMANCE is not set +# CONFIG_PCIE_PTM is not set +CONFIG_PCI_MSI=y +CONFIG_PCI_QUIRKS=y +# CONFIG_PCI_DEBUG is not set +# CONFIG_PCI_STUB is not set +# CONFIG_PCI_TSM is not set +# CONFIG_PCI_DOE is not set +CONFIG_PCI_LOCKLESS_CONFIG=y +# CONFIG_PCI_IOV is not set +# CONFIG_PCI_PRI is not set +# CONFIG_PCI_PASID is not set +# CONFIG_PCIE_TPH is not set +CONFIG_PCI_LABEL=y +# CONFIG_PCIE_BUS_TUNE_OFF is not set +CONFIG_PCIE_BUS_DEFAULT=y +# CONFIG_PCIE_BUS_SAFE is not set +# CONFIG_PCIE_BUS_PERFORMANCE is not set +# CONFIG_PCIE_BUS_PEER2PEER is not set +CONFIG_VGA_ARB=y +CONFIG_VGA_ARB_MAX_GPUS=16 +# CONFIG_HOTPLUG_PCI is not set + +# +# PCI controller drivers +# +# CONFIG_VMD is not set + +# +# Cadence-based PCIe controllers +# +# end of Cadence-based PCIe controllers + +# +# DesignWare-based PCIe controllers +# +# CONFIG_PCI_MESON is not set +# CONFIG_PCIE_DW_PLAT_HOST is not set +# end of DesignWare-based PCIe controllers + +# +# Mobiveil-based PCIe controllers +# +# end of Mobiveil-based PCIe controllers + +# +# PLDA-based PCIe controllers +# +# end of PLDA-based PCIe controllers +# end of PCI controller drivers + +# +# PCI Endpoint +# +# CONFIG_PCI_ENDPOINT is not set +# end of PCI Endpoint + +# +# PCI switch controller drivers +# +# CONFIG_PCI_SW_SWITCHTEC is not set +# end of PCI switch controller drivers + +# CONFIG_PCI_PWRCTRL_GENERIC is not set +# CONFIG_PCI_PWRCTRL_TC9563 is not set +# CONFIG_CXL_BUS is not set +# CONFIG_PCCARD is not set +# CONFIG_RAPIDIO is not set +# CONFIG_PC104 is not set + +# +# Generic Driver Options +# +CONFIG_AUXILIARY_BUS=y +# CONFIG_UEVENT_HELPER is not set +CONFIG_DEVTMPFS=y +CONFIG_DEVTMPFS_MOUNT=y +# CONFIG_DEVTMPFS_SAFE is not set +CONFIG_DRIVER_DEFERRED_PROBE_TIMEOUT=0 +# CONFIG_STANDALONE is not set +# CONFIG_PREVENT_FIRMWARE_BUILD is not set + +# +# Firmware loader +# +CONFIG_FW_LOADER=y +CONFIG_EXTRA_FIRMWARE="" +# CONFIG_FW_LOADER_USER_HELPER is not set +# CONFIG_FW_LOADER_COMPRESS is not set +# CONFIG_FW_UPLOAD is not set +# end of Firmware loader + +# CONFIG_ALLOW_DEV_COREDUMP is not set +# CONFIG_DEBUG_DRIVER is not set +# CONFIG_DEBUG_DEVRES is not set +# CONFIG_DEBUG_TEST_DRIVER_REMOVE is not set +CONFIG_GENERIC_CPU_DEVICES=y +CONFIG_GENERIC_CPU_AUTOPROBE=y +CONFIG_GENERIC_CPU_VULNERABILITIES=y +CONFIG_DMA_SHARED_BUFFER=y +# CONFIG_DMA_FENCE_TRACE is not set +# CONFIG_FW_DEVLINK_SYNC_STATE_TIMEOUT is not set +# end of Generic Driver Options + +# +# Bus devices +# +# CONFIG_MHI_BUS is not set +# CONFIG_MHI_BUS_EP is not set +# end of Bus devices + +# CONFIG_CONNECTOR is not set + +# +# Firmware Drivers +# + +# +# ARM System Control and Management Interface Protocol +# +# end of ARM System Control and Management Interface Protocol + +# CONFIG_EDD is not set +# CONFIG_FIRMWARE_MEMMAP is not set +# CONFIG_FW_CFG_SYSFS is not set +# CONFIG_SYSFB_SIMPLEFB is not set +# CONFIG_GOOGLE_FIRMWARE is not set + +# +# Qualcomm firmware drivers +# +# end of Qualcomm firmware drivers + +# +# Tegra firmware driver +# +# end of Tegra firmware driver +# end of Firmware Drivers + +# CONFIG_FWCTL is not set +# CONFIG_GNSS is not set +# CONFIG_MTD is not set +# CONFIG_OF is not set +CONFIG_ARCH_MIGHT_HAVE_PC_PARPORT=y +# CONFIG_PARPORT is not set +CONFIG_PNP=y +CONFIG_PNP_DEBUG_MESSAGES=y + +# +# Protocols +# +CONFIG_PNPACPI=y +CONFIG_BLK_DEV=y +# CONFIG_BLK_DEV_NULL_BLK is not set +# CONFIG_BLK_DEV_FD is not set +# CONFIG_BLK_DEV_PCIESSD_MTIP32XX is not set +# CONFIG_ZRAM is not set +# CONFIG_BLK_DEV_LOOP is not set +# CONFIG_BLK_DEV_DRBD is not set +# CONFIG_BLK_DEV_NBD is not set +# CONFIG_BLK_DEV_RAM is not set +# CONFIG_ATA_OVER_ETH is not set +CONFIG_VIRTIO_BLK=y +# CONFIG_BLK_DEV_RBD is not set +# CONFIG_BLK_DEV_UBLK is not set + +# +# NVME Support +# +# CONFIG_BLK_DEV_NVME is not set +# CONFIG_NVME_FC is not set +# CONFIG_NVME_TCP is not set +# CONFIG_NVME_TARGET is not set +# end of NVME Support + +# +# Misc devices +# +# CONFIG_AD525X_DPOT is not set +# CONFIG_DUMMY_IRQ is not set +# CONFIG_IBM_ASM is not set +# CONFIG_PHANTOM is not set +# CONFIG_RPMB is not set +# CONFIG_TIFM_CORE is not set +# CONFIG_ICS932S401 is not set +# CONFIG_ENCLOSURE_SERVICES is not set +# CONFIG_HP_ILO is not set +# CONFIG_APDS9802ALS is not set +# CONFIG_ISL29003 is not set +# CONFIG_ISL29020 is not set +# CONFIG_SENSORS_TSL2550 is not set +# CONFIG_SENSORS_BH1770 is not set +# CONFIG_SENSORS_APDS990X is not set +# CONFIG_HMC6352 is not set +# CONFIG_DS1682 is not set +# CONFIG_SRAM is not set +# CONFIG_DW_XDATA_PCIE is not set +# CONFIG_PCI_ENDPOINT_TEST is not set +# CONFIG_XILINX_SDFEC is not set +# CONFIG_NTSYNC is not set +# CONFIG_NSM is not set +# CONFIG_C2PORT is not set + +# +# EEPROM support +# +# CONFIG_EEPROM_AT24 is not set +# CONFIG_EEPROM_MAX6875 is not set +CONFIG_EEPROM_93CX6=y +# CONFIG_EEPROM_IDT_89HPESX is not set +# CONFIG_EEPROM_EE1004 is not set +# CONFIG_EEPROM_M24LR is not set +# end of EEPROM support + +# CONFIG_CB710_CORE is not set +# CONFIG_SENSORS_LIS3_I2C is not set +# CONFIG_ALTERA_STAPL is not set +CONFIG_INTEL_MEI=y +CONFIG_INTEL_MEI_ME=y +# CONFIG_INTEL_MEI_TXE is not set +# CONFIG_INTEL_MEI_CSC is not set +# CONFIG_VMWARE_VMCI is not set +# CONFIG_GENWQE is not set +# CONFIG_BCM_VK is not set +# CONFIG_MISC_ALCOR_PCI is not set +# CONFIG_MISC_RTSX_PCI is not set +# CONFIG_PVPANIC is not set +# CONFIG_KEBA_CP500 is not set +# end of Misc devices + +# +# SCSI device support +# +CONFIG_SCSI_MOD=y +# CONFIG_RAID_ATTRS is not set +# CONFIG_SCSI is not set +# end of SCSI device support + +# CONFIG_ATA is not set +# CONFIG_MD is not set +# CONFIG_TARGET_CORE is not set +# CONFIG_FUSION is not set + +# +# IEEE 1394 (FireWire) support +# +# CONFIG_FIREWIRE is not set +# CONFIG_FIREWIRE_NOSY is not set +# end of IEEE 1394 (FireWire) support + +# CONFIG_MACINTOSH_DRIVERS is not set +CONFIG_NETDEVICES=y +CONFIG_NET_CORE=y +# CONFIG_BONDING is not set +# CONFIG_DUMMY is not set +# CONFIG_WIREGUARD is not set +# CONFIG_OVPN is not set +# CONFIG_EQUALIZER is not set +# CONFIG_NET_TEAM is not set +# CONFIG_MACVLAN is not set +# CONFIG_IPVLAN is not set +# CONFIG_VXLAN is not set +# CONFIG_GENEVE is not set +# CONFIG_BAREUDP is not set +# CONFIG_GTP is not set +# CONFIG_PFCP is not set +# CONFIG_MACSEC is not set +# CONFIG_NETCONSOLE is not set +CONFIG_TUN=y +# CONFIG_TUN_VNET_CROSS_LE is not set +# CONFIG_VETH is not set +CONFIG_VIRTIO_NET=y +# CONFIG_NLMON is not set +# CONFIG_NETKIT is not set +# CONFIG_ARCNET is not set +# CONFIG_ETHERNET is not set +# CONFIG_FDDI is not set +# CONFIG_PHYLIB is not set + +# +# PCS device drivers +# +# CONFIG_PCS_XPCS is not set +# end of PCS device drivers + +# CONFIG_PPP is not set +# CONFIG_SLIP is not set + +# +# Host-side USB support is needed for USB Network Adapter support +# +# CONFIG_WLAN is not set +# CONFIG_WAN is not set + +# +# Wireless WAN +# +# CONFIG_WWAN is not set +# end of Wireless WAN + +# CONFIG_VMXNET3 is not set +# CONFIG_FUJITSU_ES is not set +CONFIG_NET_FAILOVER=y + +# +# Input device support +# +CONFIG_INPUT=y +# CONFIG_INPUT_FF_MEMLESS is not set +# CONFIG_INPUT_SPARSEKMAP is not set +# CONFIG_INPUT_MATRIXKMAP is not set +CONFIG_INPUT_VIVALDIFMAP=y + +# +# Userland interfaces +# +# CONFIG_INPUT_MOUSEDEV is not set +# CONFIG_INPUT_JOYDEV is not set +CONFIG_INPUT_EVDEV=y + +# +# Input Device Drivers +# +CONFIG_INPUT_KEYBOARD=y +# CONFIG_KEYBOARD_ADP5588 is not set +CONFIG_KEYBOARD_ATKBD=y +# CONFIG_KEYBOARD_QT1050 is not set +# CONFIG_KEYBOARD_QT1070 is not set +# CONFIG_KEYBOARD_QT2160 is not set +# CONFIG_KEYBOARD_DLINK_DIR685 is not set +# CONFIG_KEYBOARD_LKKBD is not set +# CONFIG_KEYBOARD_TCA8418 is not set +# CONFIG_KEYBOARD_LM8333 is not set +# CONFIG_KEYBOARD_MAX7359 is not set +# CONFIG_KEYBOARD_MPR121 is not set +# CONFIG_KEYBOARD_NEWTON is not set +# CONFIG_KEYBOARD_OPENCORES is not set +# CONFIG_KEYBOARD_STOWAWAY is not set +# CONFIG_KEYBOARD_SUNKBD is not set +# CONFIG_KEYBOARD_XTKBD is not set +# CONFIG_KEYBOARD_CYPRESS_SF is not set +CONFIG_INPUT_MOUSE=y +CONFIG_MOUSE_PS2=y +CONFIG_MOUSE_PS2_ALPS=y +CONFIG_MOUSE_PS2_BYD=y +CONFIG_MOUSE_PS2_LOGIPS2PP=y +CONFIG_MOUSE_PS2_SYNAPTICS=y +CONFIG_MOUSE_PS2_SYNAPTICS_SMBUS=y +CONFIG_MOUSE_PS2_CYPRESS=y +CONFIG_MOUSE_PS2_TRACKPOINT=y +# CONFIG_MOUSE_PS2_ELANTECH is not set +# CONFIG_MOUSE_PS2_SENTELIC is not set +# CONFIG_MOUSE_PS2_TOUCHKIT is not set +CONFIG_MOUSE_PS2_FOCALTECH=y +# CONFIG_MOUSE_PS2_VMMOUSE is not set +CONFIG_MOUSE_PS2_SMBUS=y +# CONFIG_MOUSE_SERIAL is not set +# CONFIG_MOUSE_CYAPA is not set +# CONFIG_MOUSE_ELAN_I2C is not set +# CONFIG_MOUSE_VSXXXAA is not set +# CONFIG_MOUSE_SYNAPTICS_I2C is not set +# CONFIG_INPUT_JOYSTICK is not set +# CONFIG_INPUT_TABLET is not set +# CONFIG_INPUT_TOUCHSCREEN is not set +CONFIG_INPUT_MISC=y +# CONFIG_INPUT_AD714X is not set +# CONFIG_INPUT_AW86927 is not set +# CONFIG_INPUT_BMA150 is not set +# CONFIG_INPUT_E3X0_BUTTON is not set +# CONFIG_INPUT_MMA8450 is not set +# CONFIG_INPUT_ATLAS_BTNS is not set +# CONFIG_INPUT_KXTJ9 is not set +CONFIG_INPUT_UINPUT=y +# CONFIG_INPUT_PCF8574 is not set +# CONFIG_INPUT_DA7280_HAPTICS is not set +# CONFIG_INPUT_ADXL34X is not set +# CONFIG_INPUT_IQS269A is not set +# CONFIG_INPUT_IQS626A is not set +# CONFIG_INPUT_IQS7222 is not set +# CONFIG_INPUT_CMA3000 is not set +# CONFIG_INPUT_IDEAPAD_SLIDEBAR is not set +# CONFIG_INPUT_DRV2665_HAPTICS is not set +# CONFIG_INPUT_DRV2667_HAPTICS is not set +# CONFIG_RMI4_CORE is not set + +# +# Hardware I/O ports +# +CONFIG_SERIO=y +CONFIG_ARCH_MIGHT_HAVE_PC_SERIO=y +CONFIG_SERIO_I8042=y +CONFIG_SERIO_SERPORT=y +# CONFIG_SERIO_PCIPS2 is not set +CONFIG_SERIO_LIBPS2=y +# CONFIG_SERIO_RAW is not set +# CONFIG_SERIO_ALTERA_PS2 is not set +# CONFIG_SERIO_PS2MULT is not set +# CONFIG_SERIO_ARC_PS2 is not set +# CONFIG_USERIO is not set +# CONFIG_GAMEPORT is not set +# end of Hardware I/O ports +# end of Input device support + +# +# Character devices +# +CONFIG_TTY=y +CONFIG_VT=y +CONFIG_CONSOLE_TRANSLATIONS=y +CONFIG_VT_CONSOLE=y +# CONFIG_VT_HW_CONSOLE_BINDING is not set +CONFIG_UNIX98_PTYS=y +CONFIG_LEGACY_PTYS=y +CONFIG_LEGACY_PTY_COUNT=256 +CONFIG_LEGACY_TIOCSTI=y +CONFIG_LDISC_AUTOLOAD=y + +# +# Serial drivers +# +CONFIG_SERIAL_EARLYCON=y +CONFIG_SERIAL_8250=y +CONFIG_SERIAL_8250_PNP=y +# CONFIG_SERIAL_8250_16550A_VARIANTS is not set +# CONFIG_SERIAL_8250_FINTEK is not set +CONFIG_SERIAL_8250_CONSOLE=y +CONFIG_SERIAL_8250_PCILIB=y +CONFIG_SERIAL_8250_PCI=y +CONFIG_SERIAL_8250_EXAR=y +CONFIG_SERIAL_8250_NR_UARTS=4 +CONFIG_SERIAL_8250_RUNTIME_UARTS=4 +# CONFIG_SERIAL_8250_EXTENDED is not set +# CONFIG_SERIAL_8250_PCI1XXXX is not set +# CONFIG_SERIAL_8250_DW is not set +# CONFIG_SERIAL_8250_RT288X is not set +CONFIG_SERIAL_8250_LPSS=y +CONFIG_SERIAL_8250_MID=y +CONFIG_SERIAL_8250_PERICOM=y +# CONFIG_SERIAL_8250_NI is not set +CONFIG_SERIAL_8250_DWLIB=y + +# +# Non-8250 serial port support +# +# CONFIG_SERIAL_UARTLITE is not set +CONFIG_SERIAL_CORE=y +CONFIG_SERIAL_CORE_CONSOLE=y +# CONFIG_SERIAL_JSM is not set +# CONFIG_SERIAL_LANTIQ is not set +# CONFIG_SERIAL_SCCNXP is not set +# CONFIG_SERIAL_SC16IS7XX is not set +# CONFIG_SERIAL_ALTERA_JTAGUART is not set +# CONFIG_SERIAL_ALTERA_UART is not set +# CONFIG_SERIAL_ARC is not set +# CONFIG_SERIAL_RP2 is not set +# CONFIG_SERIAL_FSL_LPUART is not set +# CONFIG_SERIAL_FSL_LINFLEXUART is not set +# end of Serial drivers + +# CONFIG_SERIAL_NONSTANDARD is not set +# CONFIG_N_GSM is not set +# CONFIG_NOZOMI is not set +# CONFIG_NULL_TTY is not set +CONFIG_HVC_DRIVER=y +# CONFIG_SERIAL_DEV_BUS is not set +# CONFIG_TTY_PRINTK is not set +CONFIG_VIRTIO_CONSOLE=y +# CONFIG_IPMI_HANDLER is not set +CONFIG_HW_RANDOM=y +# CONFIG_HW_RANDOM_TIMERIOMEM is not set +CONFIG_HW_RANDOM_INTEL=y +CONFIG_HW_RANDOM_AMD=y +# CONFIG_HW_RANDOM_BA431 is not set +CONFIG_HW_RANDOM_VIA=y +CONFIG_HW_RANDOM_VIRTIO=y +# CONFIG_HW_RANDOM_XIPHERA is not set +# CONFIG_APPLICOM is not set +# CONFIG_DEVMEM is not set +# CONFIG_NVRAM is not set +# CONFIG_DEVPORT is not set +# CONFIG_HPET is not set +# CONFIG_HANGCHECK_TIMER is not set +# CONFIG_TCG_TPM is not set +# CONFIG_TELCLOCK is not set +# CONFIG_XILLYBUS is not set +# end of Character devices + +# +# I2C support +# +CONFIG_I2C=y +CONFIG_ACPI_I2C_OPREGION=y +CONFIG_I2C_BOARDINFO=y +# CONFIG_I2C_CHARDEV is not set +# CONFIG_I2C_MUX is not set +CONFIG_I2C_HELPER_AUTO=y + +# +# I2C Hardware Bus support +# + +# +# PC SMBus host controller drivers +# +# CONFIG_I2C_ALI1535 is not set +# CONFIG_I2C_ALI1563 is not set +# CONFIG_I2C_ALI15X3 is not set +# CONFIG_I2C_AMD756 is not set +# CONFIG_I2C_AMD8111 is not set +# CONFIG_I2C_AMD_MP2 is not set +# CONFIG_I2C_I801 is not set +# CONFIG_I2C_ISCH is not set +# CONFIG_I2C_ISMT is not set +# CONFIG_I2C_PIIX4 is not set +# CONFIG_I2C_NFORCE2 is not set +# CONFIG_I2C_NVIDIA_GPU is not set +# CONFIG_I2C_SIS5595 is not set +# CONFIG_I2C_SIS630 is not set +# CONFIG_I2C_SIS96X is not set +# CONFIG_I2C_VIA is not set +# CONFIG_I2C_VIAPRO is not set +# CONFIG_I2C_ZHAOXIN is not set + +# +# ACPI drivers +# +# CONFIG_I2C_SCMI is not set + +# +# I2C system bus drivers (mostly embedded / system-on-chip) +# +# CONFIG_I2C_DESIGNWARE_CORE is not set +# CONFIG_I2C_OCORES is not set +# CONFIG_I2C_PCA_PLATFORM is not set +# CONFIG_I2C_SIMTEC is not set +# CONFIG_I2C_XILINX is not set + +# +# External I2C/SMBus adapter drivers +# +# CONFIG_I2C_PCI1XXXX is not set +# CONFIG_I2C_TAOS_EVM is not set + +# +# Other I2C/SMBus bus drivers +# +# CONFIG_I2C_MLXCPLD is not set +# CONFIG_I2C_VIRTIO is not set +# end of I2C Hardware Bus support + +# CONFIG_I2C_SLAVE is not set +# CONFIG_I2C_DEBUG_CORE is not set +# CONFIG_I2C_DEBUG_ALGO is not set +# CONFIG_I2C_DEBUG_BUS is not set +# end of I2C support + +# CONFIG_I3C is not set +CONFIG_I3C_OR_I2C=y +# CONFIG_SPI is not set +# CONFIG_SPMI is not set +# CONFIG_HSI is not set +CONFIG_PPS=y +# CONFIG_PPS_DEBUG is not set + +# +# PPS clients support +# +# CONFIG_PPS_CLIENT_KTIMER is not set +# CONFIG_PPS_CLIENT_LDISC is not set +# CONFIG_PPS_CLIENT_GPIO is not set +# CONFIG_PPS_GENERATOR is not set + +# +# PTP clock support +# +CONFIG_PTP_1588_CLOCK=y +CONFIG_PTP_1588_CLOCK_OPTIONAL=y + +# +# Enable PHYLIB and NETWORK_PHY_TIMESTAMPING to see the additional clocks. +# +CONFIG_PTP_1588_CLOCK_KVM=y +CONFIG_PTP_1588_CLOCK_VMCLOCK=y +# CONFIG_PTP_1588_CLOCK_IDT82P33 is not set +# CONFIG_PTP_1588_CLOCK_IDTCM is not set +# CONFIG_PTP_1588_CLOCK_FC3W is not set +# CONFIG_PTP_1588_CLOCK_MOCK is not set +# CONFIG_PTP_1588_CLOCK_VMW is not set +# CONFIG_PTP_NETC_V4_TIMER is not set +# end of PTP clock support + +# +# DPLL device support +# +# CONFIG_ZL3073X_I2C is not set +# end of DPLL device support + +# CONFIG_PINCTRL is not set +CONFIG_GPIOLIB_LEGACY=y +# CONFIG_GPIOLIB is not set +# CONFIG_W1 is not set +# CONFIG_POWER_RESET is not set +# CONFIG_POWER_SEQUENCING is not set +CONFIG_POWER_SUPPLY=y +# CONFIG_POWER_SUPPLY_DEBUG is not set +CONFIG_POWER_SUPPLY_HWMON=y +# CONFIG_IP5XXX_POWER is not set +# CONFIG_TEST_POWER is not set +# CONFIG_CHARGER_ADP5061 is not set +# CONFIG_BATTERY_CW2015 is not set +# CONFIG_BATTERY_DS2780 is not set +# CONFIG_BATTERY_DS2781 is not set +# CONFIG_BATTERY_DS2782 is not set +# CONFIG_BATTERY_SAMSUNG_SDI is not set +# CONFIG_BATTERY_S2MU005 is not set +# CONFIG_BATTERY_SBS is not set +# CONFIG_CHARGER_SBS is not set +# CONFIG_BATTERY_BQ27XXX is not set +# CONFIG_BATTERY_MAX17042 is not set +# CONFIG_BATTERY_MAX1720X is not set +# CONFIG_CHARGER_MAX8903 is not set +# CONFIG_CHARGER_LP8727 is not set +# CONFIG_CHARGER_LTC4162L is not set +# CONFIG_CHARGER_MAX77976 is not set +# CONFIG_CHARGER_MAX8971 is not set +# CONFIG_CHARGER_BQ2415X is not set +# CONFIG_BATTERY_GAUGE_LTC2941 is not set +# CONFIG_BATTERY_GOLDFISH is not set +# CONFIG_BATTERY_RT5033 is not set +# CONFIG_CHARGER_RT9756 is not set +# CONFIG_CHARGER_BD99954 is not set +# CONFIG_BATTERY_UG3105 is not set +# CONFIG_FUEL_GAUGE_MM8013 is not set +CONFIG_HWMON=y +# CONFIG_HWMON_DEBUG_CHIP is not set + +# +# Native drivers +# +# CONFIG_SENSORS_AD7414 is not set +# CONFIG_SENSORS_AD7418 is not set +# CONFIG_SENSORS_ADM1025 is not set +# CONFIG_SENSORS_ADM1026 is not set +# CONFIG_SENSORS_ADM1029 is not set +# CONFIG_SENSORS_ADM1031 is not set +# CONFIG_SENSORS_ADM1177 is not set +# CONFIG_SENSORS_ADM9240 is not set +# CONFIG_SENSORS_ADT7410 is not set +# CONFIG_SENSORS_ADT7411 is not set +# CONFIG_SENSORS_ADT7462 is not set +# CONFIG_SENSORS_ADT7470 is not set +# CONFIG_SENSORS_ADT7475 is not set +# CONFIG_SENSORS_AHT10 is not set +# CONFIG_SENSORS_AS370 is not set +# CONFIG_SENSORS_ASC7621 is not set +# CONFIG_SENSORS_ASUS_ROG_RYUJIN is not set +# CONFIG_SENSORS_AXI_FAN_CONTROL is not set +# CONFIG_SENSORS_K8TEMP is not set +# CONFIG_SENSORS_K10TEMP is not set +# CONFIG_SENSORS_FAM15H_POWER is not set +# CONFIG_SENSORS_APPLESMC is not set +# CONFIG_SENSORS_ASB100 is not set +# CONFIG_SENSORS_ATXP1 is not set +# CONFIG_SENSORS_CHIPCAP2 is not set +# CONFIG_SENSORS_CORSAIR_CPRO is not set +# CONFIG_SENSORS_CORSAIR_PSU is not set +# CONFIG_SENSORS_DS620 is not set +# CONFIG_SENSORS_DS1621 is not set +# CONFIG_SENSORS_DELL_SMM is not set +# CONFIG_SENSORS_I5K_AMB is not set +# CONFIG_SENSORS_F71805F is not set +# CONFIG_SENSORS_F71882FG is not set +# CONFIG_SENSORS_F75375S is not set +# CONFIG_SENSORS_FSCHMD is not set +# CONFIG_SENSORS_GL518SM is not set +# CONFIG_SENSORS_GL520SM is not set +# CONFIG_SENSORS_G760A is not set +# CONFIG_SENSORS_G762 is not set +# CONFIG_SENSORS_HIH6130 is not set +# CONFIG_SENSORS_HS3001 is not set +# CONFIG_SENSORS_HTU31 is not set +# CONFIG_SENSORS_I5500 is not set +# CONFIG_SENSORS_CORETEMP is not set +# CONFIG_SENSORS_ISL28022 is not set +# CONFIG_SENSORS_IT87 is not set +# CONFIG_SENSORS_JC42 is not set +# CONFIG_SENSORS_POWR1220 is not set +# CONFIG_SENSORS_LENOVO_EC is not set +# CONFIG_SENSORS_LINEAGE is not set +# CONFIG_SENSORS_LTC2945 is not set +# CONFIG_SENSORS_LTC2947_I2C is not set +# CONFIG_SENSORS_LTC2990 is not set +# CONFIG_SENSORS_LTC2991 is not set +# CONFIG_SENSORS_LTC4151 is not set +# CONFIG_SENSORS_LTC4215 is not set +# CONFIG_SENSORS_LTC4222 is not set +# CONFIG_SENSORS_LTC4245 is not set +# CONFIG_SENSORS_LTC4260 is not set +# CONFIG_SENSORS_LTC4261 is not set +# CONFIG_SENSORS_LTC4282 is not set +# CONFIG_SENSORS_MAX127 is not set +# CONFIG_SENSORS_MAX16065 is not set +# CONFIG_SENSORS_MAX1619 is not set +# CONFIG_SENSORS_MAX1668 is not set +# CONFIG_SENSORS_MAX197 is not set +# CONFIG_SENSORS_MAX31730 is not set +# CONFIG_SENSORS_MAX31760 is not set +# CONFIG_MAX31827 is not set +# CONFIG_SENSORS_MAX6620 is not set +# CONFIG_SENSORS_MAX6621 is not set +# CONFIG_SENSORS_MAX6639 is not set +# CONFIG_SENSORS_MAX6650 is not set +# CONFIG_SENSORS_MAX6697 is not set +# CONFIG_SENSORS_MAX31790 is not set +# CONFIG_SENSORS_MC34VR500 is not set +# CONFIG_SENSORS_MCP3021 is not set +# CONFIG_SENSORS_MCP9982 is not set +# CONFIG_SENSORS_TC654 is not set +# CONFIG_SENSORS_TPS23861 is not set +# CONFIG_SENSORS_MR75203 is not set +# CONFIG_SENSORS_LM63 is not set +# CONFIG_SENSORS_LM73 is not set +# CONFIG_SENSORS_LM75 is not set +# CONFIG_SENSORS_LM77 is not set +# CONFIG_SENSORS_LM78 is not set +# CONFIG_SENSORS_LM80 is not set +# CONFIG_SENSORS_LM83 is not set +# CONFIG_SENSORS_LM85 is not set +# CONFIG_SENSORS_LM87 is not set +# CONFIG_SENSORS_LM90 is not set +# CONFIG_SENSORS_LM92 is not set +# CONFIG_SENSORS_LM93 is not set +# CONFIG_SENSORS_LM95234 is not set +# CONFIG_SENSORS_LM95241 is not set +# CONFIG_SENSORS_LM95245 is not set +# CONFIG_SENSORS_PC87360 is not set +# CONFIG_SENSORS_PC87427 is not set +# CONFIG_SENSORS_NCT6683 is not set +# CONFIG_SENSORS_NCT6775 is not set +# CONFIG_SENSORS_NCT6775_I2C is not set +# CONFIG_SENSORS_NCT7363 is not set +# CONFIG_SENSORS_NCT7802 is not set +# CONFIG_SENSORS_NPCM7XX is not set +# CONFIG_SENSORS_OCC_P8_I2C is not set +# CONFIG_SENSORS_PCF8591 is not set +# CONFIG_PMBUS is not set +# CONFIG_SENSORS_PT5161L is not set +# CONFIG_SENSORS_SBTSI is not set +# CONFIG_SENSORS_SHT21 is not set +# CONFIG_SENSORS_SHT3x is not set +# CONFIG_SENSORS_SHT4x is not set +# CONFIG_SENSORS_SHTC1 is not set +# CONFIG_SENSORS_SIS5595 is not set +# CONFIG_SENSORS_DME1737 is not set +# CONFIG_SENSORS_EMC1403 is not set +# CONFIG_SENSORS_EMC2103 is not set +# CONFIG_SENSORS_EMC2305 is not set +# CONFIG_SENSORS_EMC6W201 is not set +# CONFIG_SENSORS_SMSC47M1 is not set +# CONFIG_SENSORS_SMSC47M192 is not set +# CONFIG_SENSORS_SMSC47B397 is not set +# CONFIG_SENSORS_STTS751 is not set +# CONFIG_SENSORS_ADC128D818 is not set +# CONFIG_SENSORS_ADS7828 is not set +# CONFIG_SENSORS_AMC6821 is not set +# CONFIG_SENSORS_INA209 is not set +# CONFIG_SENSORS_INA2XX is not set +# CONFIG_SENSORS_INA238 is not set +# CONFIG_SENSORS_INA3221 is not set +# CONFIG_SENSORS_SPD5118 is not set +# CONFIG_SENSORS_TC74 is not set +# CONFIG_SENSORS_THMC50 is not set +# CONFIG_SENSORS_TMP102 is not set +# CONFIG_SENSORS_TMP103 is not set +# CONFIG_SENSORS_TMP108 is not set +# CONFIG_SENSORS_TMP401 is not set +# CONFIG_SENSORS_TMP421 is not set +# CONFIG_SENSORS_TMP464 is not set +# CONFIG_SENSORS_TMP513 is not set +# CONFIG_SENSORS_TSC1641 is not set +# CONFIG_SENSORS_VIA_CPUTEMP is not set +# CONFIG_SENSORS_VIA686A is not set +# CONFIG_SENSORS_VT1211 is not set +# CONFIG_SENSORS_VT8231 is not set +# CONFIG_SENSORS_W83773G is not set +# CONFIG_SENSORS_W83781D is not set +# CONFIG_SENSORS_W83791D is not set +# CONFIG_SENSORS_W83792D is not set +# CONFIG_SENSORS_W83793 is not set +# CONFIG_SENSORS_W83795 is not set +# CONFIG_SENSORS_W83L785TS is not set +# CONFIG_SENSORS_W83L786NG is not set +# CONFIG_SENSORS_W83627HF is not set +# CONFIG_SENSORS_W83627EHF is not set +# CONFIG_SENSORS_XGENE is not set + +# +# ACPI drivers +# +# CONFIG_SENSORS_ACPI_POWER is not set +# CONFIG_SENSORS_ATK0110 is not set +# CONFIG_SENSORS_ASUS_WMI is not set +# CONFIG_SENSORS_ASUS_EC is not set +# CONFIG_SENSORS_HP_WMI is not set +CONFIG_THERMAL=y +CONFIG_THERMAL_NETLINK=y +# CONFIG_THERMAL_STATISTICS is not set +CONFIG_THERMAL_EMERGENCY_POWEROFF_DELAY_MS=0 +CONFIG_THERMAL_HWMON=y +CONFIG_THERMAL_DEFAULT_GOV_STEP_WISE=y +# CONFIG_THERMAL_DEFAULT_GOV_FAIR_SHARE is not set +# CONFIG_THERMAL_DEFAULT_GOV_USER_SPACE is not set +# CONFIG_THERMAL_GOV_FAIR_SHARE is not set +CONFIG_THERMAL_GOV_STEP_WISE=y +# CONFIG_THERMAL_GOV_BANG_BANG is not set +# CONFIG_THERMAL_GOV_USER_SPACE is not set +# CONFIG_PCIE_THERMAL is not set +# CONFIG_THERMAL_EMULATION is not set + +# +# Intel thermal drivers +# +# CONFIG_INTEL_POWERCLAMP is not set +CONFIG_X86_THERMAL_VECTOR=y +CONFIG_INTEL_TCC=y +CONFIG_X86_PKG_TEMP_THERMAL=y +# CONFIG_INTEL_SOC_DTS_THERMAL is not set + +# +# ACPI INT340X thermal drivers +# +# CONFIG_INT340X_THERMAL is not set +# end of ACPI INT340X thermal drivers + +# CONFIG_INTEL_PCH_THERMAL is not set +# CONFIG_INTEL_TCC_COOLING is not set +# CONFIG_INTEL_HFI_THERMAL is not set +# end of Intel thermal drivers + +# CONFIG_WATCHDOG is not set +CONFIG_SSB_POSSIBLE=y +# CONFIG_SSB is not set +CONFIG_BCMA_POSSIBLE=y +# CONFIG_BCMA is not set + +# +# Multifunction device drivers +# +# CONFIG_MFD_AS3711 is not set +# CONFIG_MFD_SMPRO is not set +# CONFIG_PMIC_ADP5520 is not set +# CONFIG_MFD_BCM590XX is not set +# CONFIG_MFD_BD9571MWV is not set +# CONFIG_MFD_AXP20X_I2C is not set +# CONFIG_MFD_CGBC is not set +# CONFIG_MFD_CS40L50_I2C is not set +# CONFIG_MFD_CS42L43_I2C is not set +# CONFIG_MFD_MADERA is not set +# CONFIG_PMIC_DA903X is not set +# CONFIG_MFD_DA9052_I2C is not set +# CONFIG_MFD_DA9055 is not set +# CONFIG_MFD_DA9062 is not set +# CONFIG_MFD_DA9063 is not set +# CONFIG_MFD_DA9150 is not set +# CONFIG_MFD_MC13XXX_I2C is not set +# CONFIG_MFD_MP2629 is not set +# CONFIG_LPC_ICH is not set +# CONFIG_LPC_SCH is not set +# CONFIG_MFD_INTEL_LPSS_ACPI is not set +# CONFIG_MFD_INTEL_LPSS_PCI is not set +# CONFIG_MFD_INTEL_PMC_BXT is not set +# CONFIG_MFD_IQS62X is not set +# CONFIG_MFD_JANZ_CMODIO is not set +# CONFIG_MFD_KEMPLD is not set +# CONFIG_MFD_88PM800 is not set +# CONFIG_MFD_88PM805 is not set +# CONFIG_MFD_88PM860X is not set +# CONFIG_MFD_MAX5970 is not set +# CONFIG_MFD_MAX14577 is not set +# CONFIG_MFD_MAX77541 is not set +# CONFIG_MFD_MAX77693 is not set +# CONFIG_MFD_MAX77705 is not set +# CONFIG_MFD_MAX77843 is not set +# CONFIG_MFD_MAX8907 is not set +# CONFIG_MFD_MAX8925 is not set +# CONFIG_MFD_MAX8997 is not set +# CONFIG_MFD_MAX8998 is not set +# CONFIG_MFD_MT6360 is not set +# CONFIG_MFD_MT6370 is not set +# CONFIG_MFD_MT6397 is not set +# CONFIG_MFD_MENF21BMC is not set +# CONFIG_MFD_RETU is not set +# CONFIG_MFD_SY7636A is not set +# CONFIG_MFD_RDC321X is not set +# CONFIG_MFD_RT4831 is not set +# CONFIG_MFD_RT5033 is not set +# CONFIG_MFD_RT5120 is not set +# CONFIG_MFD_RC5T583 is not set +# CONFIG_MFD_SI476X_CORE is not set +# CONFIG_MFD_SM501 is not set +# CONFIG_MFD_SKY81452 is not set +# CONFIG_MFD_SYSCON is not set +# CONFIG_MFD_LP3943 is not set +# CONFIG_MFD_LP8788 is not set +# CONFIG_MFD_TI_LMU is not set +# CONFIG_MFD_BQ257XX is not set +# CONFIG_MFD_PALMAS is not set +# CONFIG_TPS6105X is not set +# CONFIG_TPS6507X is not set +# CONFIG_MFD_TPS65086 is not set +# CONFIG_MFD_TPS65090 is not set +# CONFIG_MFD_TI_LP873X is not set +# CONFIG_MFD_TPS6586X is not set +# CONFIG_MFD_TPS65912_I2C is not set +# CONFIG_MFD_TPS6594_I2C is not set +# CONFIG_TWL4030_CORE is not set +# CONFIG_TWL6040_CORE is not set +# CONFIG_MFD_LM3533 is not set +# CONFIG_MFD_TQMX86 is not set +# CONFIG_MFD_VX855 is not set +# CONFIG_MFD_ARIZONA_I2C is not set +# CONFIG_MFD_WM8400 is not set +# CONFIG_MFD_WM831X_I2C is not set +# CONFIG_MFD_WM8350_I2C is not set +# CONFIG_MFD_WM8994 is not set +# CONFIG_MFD_ATC260X_I2C is not set +# CONFIG_MFD_UPBOARD_FPGA is not set +# CONFIG_MFD_MAX7360 is not set +# end of Multifunction device drivers + +# CONFIG_REGULATOR is not set +# CONFIG_RC_CORE is not set + +# +# CEC support +# +# CONFIG_MEDIA_CEC_SUPPORT is not set +# end of CEC support + +# CONFIG_MEDIA_SUPPORT is not set + +# +# Graphics support +# +CONFIG_APERTURE_HELPERS=y +CONFIG_SCREEN_INFO=y +CONFIG_VIDEO=y +# CONFIG_AUXDISPLAY is not set +# CONFIG_AGP is not set +# CONFIG_VGA_SWITCHEROO is not set +CONFIG_DRM=y + +# +# DRM debugging options +# +# CONFIG_DRM_WERROR is not set +# CONFIG_DRM_DEBUG_MM is not set +# end of DRM debugging options + +CONFIG_DRM_KMS_HELPER=y +# CONFIG_DRM_PANIC is not set +# CONFIG_DRM_RAS is not set +# CONFIG_DRM_DEBUG_DP_MST_TOPOLOGY_REFS is not set +# CONFIG_DRM_DEBUG_MODESET_LOCK is not set +CONFIG_DRM_CLIENT_SELECTION=y + +# +# Supported DRM clients +# +# CONFIG_DRM_FBDEV_EMULATION is not set +# CONFIG_DRM_CLIENT_LOG is not set +# end of Supported DRM clients + +# CONFIG_DRM_LOAD_EDID_FIRMWARE is not set +CONFIG_DRM_GEM_SHMEM_HELPER=y +# CONFIG_DRM_AMDGPU is not set + +# +# ARM devices +# +# end of ARM devices + +# CONFIG_DRM_AST is not set +CONFIG_DRM_BRIDGE=y +CONFIG_DRM_PANEL_BRIDGE=y + +# +# Display Interface Bridges +# +# CONFIG_DRM_I2C_NXP_TDA998X is not set +# CONFIG_DRM_ANALOGIX_ANX78XX is not set +# end of Display Interface Bridges + +# CONFIG_DRM_ETNAVIV is not set +# CONFIG_DRM_GMA500 is not set +# CONFIG_DRM_HISI_HIBMC is not set +# CONFIG_DRM_I915 is not set +# CONFIG_DRM_MGAG200 is not set +# CONFIG_DRM_NOUVEAU is not set +CONFIG_DRM_PANEL=y + +# +# Display Panels +# +# end of Display Panels + +# CONFIG_DRM_QXL is not set +# CONFIG_DRM_RADEON is not set +# CONFIG_DRM_ST7571 is not set +# CONFIG_DRM_SSD130X is not set + +# +# Drivers for system framebuffers +# +# CONFIG_DRM_SIMPLEDRM is not set +# CONFIG_DRM_VESADRM is not set +# end of Drivers for system framebuffers + +# CONFIG_DRM_BOCHS is not set +# CONFIG_DRM_CIRRUS_QEMU is not set +# CONFIG_DRM_VBOXVIDEO is not set +# CONFIG_DRM_VGEM is not set +CONFIG_DRM_VIRTIO_GPU=y +CONFIG_DRM_VIRTIO_GPU_KMS=y +# CONFIG_DRM_VKMS is not set +# CONFIG_DRM_VMWGFX is not set +# CONFIG_DRM_XE is not set +CONFIG_DRM_PANEL_ORIENTATION_QUIRKS=y + +# +# Frame buffer Devices +# +# CONFIG_FB is not set +# end of Frame buffer Devices + +# +# Backlight & LCD device support +# +# CONFIG_LCD_CLASS_DEVICE is not set +CONFIG_BACKLIGHT_CLASS_DEVICE=y +# CONFIG_BACKLIGHT_AW99706 is not set +# CONFIG_BACKLIGHT_KTZ8866 is not set +# CONFIG_BACKLIGHT_APPLE is not set +# CONFIG_BACKLIGHT_QCOM_WLED is not set +# CONFIG_BACKLIGHT_SAHARA is not set +# CONFIG_BACKLIGHT_ADP8860 is not set +# CONFIG_BACKLIGHT_ADP8870 is not set +# CONFIG_BACKLIGHT_LM3509 is not set +# CONFIG_BACKLIGHT_LM3639 is not set +# CONFIG_BACKLIGHT_LV5207LP is not set +# CONFIG_BACKLIGHT_BD6107 is not set +# CONFIG_BACKLIGHT_ARCXCNN is not set +# end of Backlight & LCD device support + +CONFIG_HDMI=y +# CONFIG_FIRMWARE_EDID is not set + +# +# Console display driver support +# +CONFIG_VGA_CONSOLE=y +CONFIG_DUMMY_CONSOLE=y +CONFIG_DUMMY_CONSOLE_COLUMNS=80 +CONFIG_DUMMY_CONSOLE_ROWS=25 +# end of Console display driver support + +# CONFIG_TRACE_GPU_MEM is not set +# end of Graphics support + +# CONFIG_DRM_ACCEL is not set +# CONFIG_SOUND is not set +CONFIG_HID_SUPPORT=y +CONFIG_HID=y +# CONFIG_HID_BATTERY_STRENGTH is not set +# CONFIG_HIDRAW is not set +# CONFIG_UHID is not set +CONFIG_HID_GENERIC=y +# CONFIG_HID_HAPTIC is not set + +# +# Special HID drivers +# +# CONFIG_HID_A4TECH is not set +# CONFIG_HID_ACRUX is not set +# CONFIG_HID_APPLETB_BL is not set +# CONFIG_HID_AUREAL is not set +# CONFIG_HID_BELKIN is not set +# CONFIG_HID_CHERRY is not set +# CONFIG_HID_COUGAR is not set +# CONFIG_HID_MACALLY is not set +# CONFIG_HID_CMEDIA is not set +# CONFIG_HID_CYPRESS is not set +# CONFIG_HID_DRAGONRISE is not set +# CONFIG_HID_EMS_FF is not set +# CONFIG_HID_ELECOM is not set +# CONFIG_HID_EVISION is not set +# CONFIG_HID_EZKEY is not set +# CONFIG_HID_GEMBIRD is not set +# CONFIG_HID_GFRM is not set +# CONFIG_HID_GLORIOUS is not set +# CONFIG_HID_GOOGLE_STADIA_FF is not set +# CONFIG_HID_VIVALDI is not set +# CONFIG_HID_KEYTOUCH is not set +# CONFIG_HID_KYE is not set +# CONFIG_HID_WALTOP is not set +# CONFIG_HID_VIEWSONIC is not set +# CONFIG_HID_VRC2 is not set +# CONFIG_HID_XIAOMI is not set +# CONFIG_HID_GYRATION is not set +# CONFIG_HID_ICADE is not set +# CONFIG_HID_ITE is not set +# CONFIG_HID_JABRA is not set +# CONFIG_HID_TWINHAN is not set +# CONFIG_HID_KENSINGTON is not set +# CONFIG_HID_LCPOWER is not set +# CONFIG_HID_MAGICMOUSE is not set +# CONFIG_HID_MALTRON is not set +# CONFIG_HID_MAYFLASH is not set +# CONFIG_HID_REDRAGON is not set +# CONFIG_HID_MICROSOFT is not set +# CONFIG_HID_MONTEREY is not set +# CONFIG_HID_MULTITOUCH is not set +# CONFIG_HID_NTI is not set +# CONFIG_HID_ORTEK is not set +# CONFIG_HID_PANTHERLORD is not set +# CONFIG_HID_PETALYNX is not set +# CONFIG_HID_PICOLCD is not set +# CONFIG_HID_PLANTRONICS is not set +# CONFIG_HID_PXRC is not set +# CONFIG_HID_RAPOO is not set +# CONFIG_HID_RAZER is not set +# CONFIG_HID_PRIMAX is not set +# CONFIG_HID_SAITEK is not set +# CONFIG_HID_SEMITEK is not set +# CONFIG_HID_SPEEDLINK is not set +# CONFIG_HID_STEAM is not set +# CONFIG_HID_SUNPLUS is not set +# CONFIG_HID_RMI is not set +# CONFIG_HID_GREENASIA is not set +# CONFIG_HID_SMARTJOYPLUS is not set +# CONFIG_HID_TIVO is not set +# CONFIG_HID_TOPSEED is not set +# CONFIG_HID_TOPRE is not set +# CONFIG_HID_UDRAW_PS3 is not set +# CONFIG_HID_XINMO is not set +# CONFIG_HID_ZEROPLUS is not set +# CONFIG_HID_ZYDACRON is not set +# CONFIG_HID_SENSOR_HUB is not set +# CONFIG_HID_ALPS is not set +# end of Special HID drivers + +# +# HID-BPF support +# +# end of HID-BPF support + +CONFIG_I2C_HID=y +# CONFIG_I2C_HID_ACPI is not set +# CONFIG_I2C_HID_OF is not set + +# +# Intel ISH HID support +# +# CONFIG_INTEL_ISH_HID is not set +# end of Intel ISH HID support + +# +# AMD SFH HID Support +# +# CONFIG_AMD_SFH_HID is not set +# end of AMD SFH HID Support + +# +# Intel THC HID Support +# +# CONFIG_INTEL_THC_HID is not set +# end of Intel THC HID Support + +CONFIG_USB_OHCI_LITTLE_ENDIAN=y +# CONFIG_USB_SUPPORT is not set +# CONFIG_MMC is not set +# CONFIG_MEMSTICK is not set +# CONFIG_NEW_LEDS is not set +# CONFIG_ACCESSIBILITY is not set +# CONFIG_INFINIBAND is not set +CONFIG_EDAC_ATOMIC_SCRUB=y +CONFIG_EDAC_SUPPORT=y +CONFIG_RTC_LIB=y +CONFIG_RTC_MC146818_LIB=y +# CONFIG_RTC_CLASS is not set +# CONFIG_DMADEVICES is not set + +# +# DMABUF options +# +CONFIG_SYNC_FILE=y +CONFIG_UDMABUF=y +# CONFIG_DMABUF_DEBUG is not set +# CONFIG_DMABUF_SELFTESTS is not set +# CONFIG_DMABUF_HEAPS is not set +# end of DMABUF options + +# CONFIG_UIO is not set +# CONFIG_VFIO is not set +# CONFIG_VIRT_DRIVERS is not set +CONFIG_VIRTIO_ANCHOR=y +CONFIG_VIRTIO=y +CONFIG_VIRTIO_PCI_LIB=y +CONFIG_VIRTIO_PCI_LIB_LEGACY=y +CONFIG_VIRTIO_MENU=y +CONFIG_VIRTIO_PCI=y +CONFIG_VIRTIO_PCI_ADMIN_LEGACY=y +CONFIG_VIRTIO_PCI_LEGACY=y +CONFIG_VIRTIO_BALLOON=y +CONFIG_VIRTIO_INPUT=y +CONFIG_VIRTIO_MMIO=y +# CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES is not set +CONFIG_VIRTIO_DMA_SHARED_BUFFER=y +# CONFIG_VIRTIO_DEBUG is not set +# CONFIG_VIRTIO_RTC is not set +# CONFIG_VDPA is not set +# CONFIG_VHOST_MENU is not set + +# +# Microsoft Hyper-V guest support +# +# CONFIG_HYPERV is not set +# end of Microsoft Hyper-V guest support + +# CONFIG_GREYBUS is not set +# CONFIG_COMEDI is not set +# CONFIG_GPIB is not set +# CONFIG_STAGING is not set +# CONFIG_GOLDFISH is not set +# CONFIG_CHROME_PLATFORMS is not set +# CONFIG_MELLANOX_PLATFORM is not set +# CONFIG_SURFACE_PLATFORMS is not set +CONFIG_X86_PLATFORM_DEVICES=y +CONFIG_WMI_BMOF=y +# CONFIG_HUAWEI_WMI is not set +# CONFIG_X86_PLATFORM_DRIVERS_UNIWILL is not set +# CONFIG_MXM_WMI is not set +# CONFIG_XIAOMI_WMI is not set +# CONFIG_REDMI_WMI is not set +# CONFIG_GIGABYTE_WMI is not set +# CONFIG_ACERHDF is not set +# CONFIG_ACER_WIRELESS is not set +# CONFIG_ACER_WMI is not set + +# +# AMD HSMP Driver +# +# CONFIG_AMD_HSMP_ACPI is not set +# CONFIG_AMD_HSMP_PLAT is not set +# end of AMD HSMP Driver + +# CONFIG_AMD_HFI is not set +# CONFIG_AMD_3D_VCACHE is not set +# CONFIG_AMD_WBRF is not set +# CONFIG_AMD_ISP_PLATFORM is not set +# CONFIG_ADV_SWBUTTON is not set +# CONFIG_APPLE_GMUX is not set +# CONFIG_ASUS_LAPTOP is not set +# CONFIG_ASUS_WIRELESS is not set +# CONFIG_X86_PLATFORM_DRIVERS_DELL is not set +# CONFIG_FUJITSU_LAPTOP is not set +# CONFIG_FUJITSU_TABLET is not set +# CONFIG_GPD_POCKET_FAN is not set +# CONFIG_X86_PLATFORM_DRIVERS_HP is not set +# CONFIG_WIRELESS_HOTKEY is not set +# CONFIG_IBM_RTL is not set +# CONFIG_SENSORS_HDAPS is not set +# CONFIG_INTEL_IFS is not set +# CONFIG_INTEL_SAR_INT1092 is not set + +# +# Intel Speed Select Technology interface support +# +# CONFIG_INTEL_SPEED_SELECT_INTERFACE is not set +# end of Intel Speed Select Technology interface support + +# CONFIG_INTEL_WMI_SBL_FW_UPDATE is not set +# CONFIG_INTEL_WMI_THUNDERBOLT is not set + +# +# Intel Uncore Frequency Control +# +# CONFIG_INTEL_UNCORE_FREQ_CONTROL is not set +# end of Intel Uncore Frequency Control + +# CONFIG_INTEL_HID_EVENT is not set +# CONFIG_INTEL_VBTN is not set +# CONFIG_INTEL_EHL_PSE_IO is not set +# CONFIG_INTEL_PUNIT_IPC is not set +# CONFIG_INTEL_RST is not set +# CONFIG_INTEL_SMARTCONNECT is not set +# CONFIG_INTEL_TURBO_MAX_3 is not set +# CONFIG_INTEL_VSEC is not set +# CONFIG_LENOVO_WMI_HOTKEY_UTILITIES is not set +# CONFIG_LENOVO_WMI_CAMERA is not set +# CONFIG_THINKPAD_ACPI is not set +# CONFIG_YOGABOOK is not set +# CONFIG_LENOVO_WMI_TUNING is not set +# CONFIG_ACPI_QUICKSTART is not set +# CONFIG_MSI_EC is not set +# CONFIG_MSI_WMI is not set +# CONFIG_SAMSUNG_LAPTOP is not set +# CONFIG_SAMSUNG_Q10 is not set +# CONFIG_TOSHIBA_BT_RFKILL is not set +# CONFIG_TOSHIBA_HAPS is not set +# CONFIG_TOSHIBA_WMI is not set +# CONFIG_ACPI_CMPC is not set +# CONFIG_LG_LAPTOP is not set +# CONFIG_PANASONIC_LAPTOP is not set +# CONFIG_SYSTEM76_ACPI is not set +# CONFIG_TOPSTAR_LAPTOP is not set +# CONFIG_SERIAL_MULTI_INSTANTIATE is not set +# CONFIG_INSPUR_PLATFORM_PROFILE is not set +# CONFIG_DASHARO_ACPI is not set +# CONFIG_INTEL_IPS is not set +# CONFIG_INTEL_SCU_PCI is not set +# CONFIG_INTEL_SCU_PLATFORM is not set +# CONFIG_SIEMENS_SIMATIC_IPC is not set +# CONFIG_WINMATE_FM07_KEYS is not set +# CONFIG_OXP_EC is not set +# CONFIG_TUXEDO_NB04_WMI_AB is not set +CONFIG_ACPI_WMI=y +# CONFIG_ACPI_WMI_LEGACY_DEVICE_NAMES is not set +# CONFIG_COMMON_CLK is not set +# CONFIG_HWSPINLOCK is not set + +# +# Clock Source drivers +# +CONFIG_CLKEVT_I8253=y +CONFIG_CLKBLD_I8253=y +# end of Clock Source drivers + +CONFIG_MAILBOX=y +CONFIG_PCC=y +# CONFIG_ALTERA_MBOX is not set +# CONFIG_IOMMU_SUPPORT is not set + +# +# Remoteproc drivers +# +# CONFIG_REMOTEPROC is not set +# end of Remoteproc drivers + +# +# Rpmsg drivers +# +# CONFIG_RPMSG_QCOM_GLINK_RPM is not set +# CONFIG_RPMSG_VIRTIO is not set +# end of Rpmsg drivers + +# +# SOC (System On Chip) specific Drivers +# + +# +# Amlogic SoC drivers +# +# end of Amlogic SoC drivers + +# +# Broadcom SoC drivers +# +# end of Broadcom SoC drivers + +# +# NXP/Freescale QorIQ SoC drivers +# +# end of NXP/Freescale QorIQ SoC drivers + +# +# fujitsu SoC drivers +# +# end of fujitsu SoC drivers + +# +# i.MX SoC drivers +# +# end of i.MX SoC drivers + +# +# Enable LiteX SoC Builder specific drivers +# +# end of Enable LiteX SoC Builder specific drivers + +# CONFIG_WPCM450_SOC is not set + +# +# Qualcomm SoC drivers +# +# end of Qualcomm SoC drivers + +# CONFIG_SOC_TI is not set + +# +# Xilinx SoC drivers +# +# end of Xilinx SoC drivers +# end of SOC (System On Chip) specific Drivers + +# +# PM Domains +# + +# +# Amlogic PM Domains +# +# end of Amlogic PM Domains + +# +# Broadcom PM Domains +# +# end of Broadcom PM Domains + +# +# i.MX PM Domains +# +# end of i.MX PM Domains + +# +# Qualcomm PM Domains +# +# end of Qualcomm PM Domains +# end of PM Domains + +# CONFIG_PM_DEVFREQ is not set +# CONFIG_EXTCON is not set +# CONFIG_MEMORY is not set +# CONFIG_IIO is not set +# CONFIG_NTB is not set +# CONFIG_PWM is not set + +# +# IRQ chip support +# +# end of IRQ chip support + +# CONFIG_IPACK_BUS is not set +# CONFIG_RESET_CONTROLLER is not set + +# +# PHY Subsystem +# +# CONFIG_GENERIC_PHY is not set +# CONFIG_PHY_CAN_TRANSCEIVER is not set + +# +# PHY drivers for Broadcom platforms +# +# CONFIG_BCM_KONA_USB2_PHY is not set +# end of PHY drivers for Broadcom platforms + +# CONFIG_PHY_INTEL_LGM_EMMC is not set +# CONFIG_PHY_PXA_28NM_HSIC is not set +# CONFIG_PHY_PXA_28NM_USB2 is not set +# end of PHY Subsystem + +# CONFIG_POWERCAP is not set +# CONFIG_MCB is not set + +# +# Performance monitor support +# +# CONFIG_DWC_PCIE_PMU is not set +# end of Performance monitor support + +# CONFIG_RAS is not set +# CONFIG_USB4 is not set + +# +# Android +# +# CONFIG_ANDROID_BINDER_IPC is not set +# end of Android + +# CONFIG_LIBNVDIMM is not set +# CONFIG_DAX is not set +# CONFIG_NVMEM is not set + +# +# HW tracing support +# +# CONFIG_STM is not set +# CONFIG_INTEL_TH is not set +# end of HW tracing support + +# CONFIG_FPGA is not set +# CONFIG_TEE is not set +# CONFIG_MUX_CORE is not set +# CONFIG_SIOX is not set +# CONFIG_SLIMBUS is not set +# CONFIG_INTERCONNECT is not set +# CONFIG_COUNTER is not set +# CONFIG_PECI is not set +# CONFIG_HTE is not set +# end of Device Drivers + +# +# File systems +# +CONFIG_DCACHE_WORD_ACCESS=y +# CONFIG_VALIDATE_FS_PARSER is not set +CONFIG_FS_IOMAP=y +CONFIG_FS_STACK=y +CONFIG_BUFFER_HEAD=y +# CONFIG_EXT2_FS is not set +CONFIG_EXT4_FS=y +CONFIG_EXT4_USE_FOR_EXT2=y +# CONFIG_EXT4_FS_POSIX_ACL is not set +# CONFIG_EXT4_FS_SECURITY is not set +# CONFIG_EXT4_DEBUG is not set +CONFIG_JBD2=y +# CONFIG_JBD2_DEBUG is not set +CONFIG_FS_MBCACHE=y +# CONFIG_JFS_FS is not set +# CONFIG_XFS_FS is not set +# CONFIG_GFS2_FS is not set +# CONFIG_BTRFS_FS is not set +# CONFIG_NILFS2_FS is not set +# CONFIG_F2FS_FS is not set +CONFIG_FS_POSIX_ACL=y +CONFIG_EXPORTFS=y +# CONFIG_EXPORTFS_BLOCK_OPS is not set +CONFIG_FILE_LOCKING=y +# CONFIG_FS_ENCRYPTION is not set +# CONFIG_FS_VERITY is not set +CONFIG_FSNOTIFY=y +# CONFIG_DNOTIFY is not set +CONFIG_INOTIFY_USER=y +# CONFIG_FANOTIFY is not set +# CONFIG_QUOTA is not set +# CONFIG_AUTOFS_FS is not set +CONFIG_FUSE_FS=y +# CONFIG_CUSE is not set +CONFIG_VIRTIO_FS=y +CONFIG_FUSE_PASSTHROUGH=y +CONFIG_FUSE_IO_URING=y +CONFIG_OVERLAY_FS=y +# CONFIG_OVERLAY_FS_REDIRECT_DIR is not set +CONFIG_OVERLAY_FS_REDIRECT_ALWAYS_FOLLOW=y +# CONFIG_OVERLAY_FS_INDEX is not set +# CONFIG_OVERLAY_FS_XINO_AUTO is not set +# CONFIG_OVERLAY_FS_METACOPY is not set +# CONFIG_OVERLAY_FS_DEBUG is not set + +# +# Caches +# +# end of Caches + +# +# CD-ROM/DVD Filesystems +# +# CONFIG_ISO9660_FS is not set +# CONFIG_UDF_FS is not set +# end of CD-ROM/DVD Filesystems + +# +# DOS/FAT/EXFAT/NT Filesystems +# +# CONFIG_MSDOS_FS is not set +# CONFIG_VFAT_FS is not set +# CONFIG_EXFAT_FS is not set +# CONFIG_NTFS_FS is not set +# CONFIG_NTFS3_FS is not set +# end of DOS/FAT/EXFAT/NT Filesystems + +# +# Pseudo filesystems +# +CONFIG_PROC_FS=y +# CONFIG_PROC_KCORE is not set +CONFIG_PROC_SYSCTL=y +CONFIG_PROC_PAGE_MONITOR=y +# CONFIG_PROC_CHILDREN is not set +CONFIG_PROC_PID_ARCH_STATUS=y +CONFIG_KERNFS=y +CONFIG_SYSFS=y +CONFIG_TMPFS=y +# CONFIG_TMPFS_POSIX_ACL is not set +# CONFIG_TMPFS_XATTR is not set +# CONFIG_TMPFS_INODE64 is not set +# CONFIG_TMPFS_QUOTA is not set +CONFIG_ARCH_SUPPORTS_HUGETLBFS=y +# CONFIG_HUGETLBFS is not set +CONFIG_ARCH_HAS_GIGANTIC_PAGE=y +# CONFIG_CONFIGFS_FS is not set +# end of Pseudo filesystems + +# CONFIG_MISC_FILESYSTEMS is not set +CONFIG_NETWORK_FILESYSTEMS=y +# CONFIG_NFS_FS is not set +# CONFIG_NFSD is not set +# CONFIG_CEPH_FS is not set +# CONFIG_CIFS is not set +# CONFIG_SMB_SERVER is not set +# CONFIG_CODA_FS is not set +# CONFIG_AFS_FS is not set +CONFIG_NLS=y +CONFIG_NLS_DEFAULT="iso8859-1" +# CONFIG_NLS_CODEPAGE_437 is not set +# CONFIG_NLS_CODEPAGE_737 is not set +# CONFIG_NLS_CODEPAGE_775 is not set +# CONFIG_NLS_CODEPAGE_850 is not set +# CONFIG_NLS_CODEPAGE_852 is not set +# CONFIG_NLS_CODEPAGE_855 is not set +# CONFIG_NLS_CODEPAGE_857 is not set +# CONFIG_NLS_CODEPAGE_860 is not set +# CONFIG_NLS_CODEPAGE_861 is not set +# CONFIG_NLS_CODEPAGE_862 is not set +# CONFIG_NLS_CODEPAGE_863 is not set +# CONFIG_NLS_CODEPAGE_864 is not set +# CONFIG_NLS_CODEPAGE_865 is not set +# CONFIG_NLS_CODEPAGE_866 is not set +# CONFIG_NLS_CODEPAGE_869 is not set +# CONFIG_NLS_CODEPAGE_936 is not set +# CONFIG_NLS_CODEPAGE_950 is not set +# CONFIG_NLS_CODEPAGE_932 is not set +# CONFIG_NLS_CODEPAGE_949 is not set +# CONFIG_NLS_CODEPAGE_874 is not set +# CONFIG_NLS_ISO8859_8 is not set +# CONFIG_NLS_CODEPAGE_1250 is not set +# CONFIG_NLS_CODEPAGE_1251 is not set +# CONFIG_NLS_ASCII is not set +# CONFIG_NLS_ISO8859_1 is not set +# CONFIG_NLS_ISO8859_2 is not set +# CONFIG_NLS_ISO8859_3 is not set +# CONFIG_NLS_ISO8859_4 is not set +# CONFIG_NLS_ISO8859_5 is not set +# CONFIG_NLS_ISO8859_6 is not set +# CONFIG_NLS_ISO8859_7 is not set +# CONFIG_NLS_ISO8859_9 is not set +# CONFIG_NLS_ISO8859_13 is not set +# CONFIG_NLS_ISO8859_14 is not set +# CONFIG_NLS_ISO8859_15 is not set +# CONFIG_NLS_KOI8_R is not set +# CONFIG_NLS_KOI8_U is not set +# CONFIG_NLS_MAC_ROMAN is not set +# CONFIG_NLS_MAC_CELTIC is not set +# CONFIG_NLS_MAC_CENTEURO is not set +# CONFIG_NLS_MAC_CROATIAN is not set +# CONFIG_NLS_MAC_CYRILLIC is not set +# CONFIG_NLS_MAC_GAELIC is not set +# CONFIG_NLS_MAC_GREEK is not set +# CONFIG_NLS_MAC_ICELAND is not set +# CONFIG_NLS_MAC_INUIT is not set +# CONFIG_NLS_MAC_ROMANIAN is not set +# CONFIG_NLS_MAC_TURKISH is not set +# CONFIG_NLS_UTF8 is not set +# CONFIG_UNICODE is not set +CONFIG_IO_WQ=y +# end of File systems + +# +# Security options +# +# CONFIG_KEYS is not set +# CONFIG_SECURITY_DMESG_RESTRICT is not set +CONFIG_PROC_MEM_ALWAYS_FORCE=y +# CONFIG_PROC_MEM_FORCE_PTRACE is not set +# CONFIG_PROC_MEM_NO_FORCE is not set +# CONFIG_MSEAL_SYSTEM_MAPPINGS is not set +# CONFIG_SECURITY is not set +# CONFIG_SECURITYFS is not set +# CONFIG_STATIC_USERMODEHELPER is not set +CONFIG_DEFAULT_SECURITY_DAC=y + +# +# Kernel hardening options +# + +# +# Memory initialization +# +CONFIG_CC_HAS_AUTO_VAR_INIT_PATTERN=y +CONFIG_CC_HAS_AUTO_VAR_INIT_ZERO_BARE=y +CONFIG_CC_HAS_AUTO_VAR_INIT_ZERO=y +# CONFIG_INIT_STACK_NONE is not set +# CONFIG_INIT_STACK_ALL_PATTERN is not set +CONFIG_INIT_STACK_ALL_ZERO=y +# CONFIG_INIT_ON_ALLOC_DEFAULT_ON is not set +# CONFIG_INIT_ON_FREE_DEFAULT_ON is not set +CONFIG_CC_HAS_ZERO_CALL_USED_REGS=y +# CONFIG_ZERO_CALL_USED_REGS is not set +# end of Memory initialization + +# +# Bounds checking +# +# CONFIG_FORTIFY_SOURCE is not set +# CONFIG_HARDENED_USERCOPY is not set +# end of Bounds checking + +# +# Hardening of kernel data structures +# +# CONFIG_LIST_HARDENED is not set +# CONFIG_BUG_ON_DATA_CORRUPTION is not set +# end of Hardening of kernel data structures + +CONFIG_RANDSTRUCT_NONE=y +# end of Kernel hardening options +# end of Security options + +# CONFIG_CRYPTO is not set +CONFIG_BINARY_PRINTF=y + +# +# Library routines +# +# CONFIG_PACKING is not set +CONFIG_BITREVERSE=y +CONFIG_GENERIC_STRNCPY_FROM_USER=y +CONFIG_GENERIC_STRNLEN_USER=y +CONFIG_GENERIC_NET_UTILS=y +# CONFIG_CORDIC is not set +# CONFIG_PRIME_NUMBERS is not set +CONFIG_RATIONAL=y +CONFIG_GENERIC_IOMAP=y +CONFIG_ARCH_USE_CMPXCHG_LOCKREF=y +CONFIG_ARCH_HAS_FAST_MULTIPLIER=y +CONFIG_ARCH_USE_SYM_ANNOTATIONS=y +CONFIG_CRC16=y +CONFIG_CRC32=y +# CONFIG_CRC_OPTIMIZATIONS is not set +CONFIG_CRYPTO_LIB_BLAKE2S_ARCH=y +CONFIG_CRYPTO_LIB_POLY1305_RSIZE=11 +CONFIG_CRYPTO_LIB_SHA1=y +CONFIG_CRYPTO_LIB_SHA1_ARCH=y +CONFIG_CRYPTO_LIB_SHA256=y +CONFIG_CRYPTO_LIB_SHA256_ARCH=y +# CONFIG_RANDOM32_SELFTEST is not set +# CONFIG_XZ_DEC is not set +CONFIG_GENERIC_ALLOCATOR=y +CONFIG_XARRAY_MULTI=y +CONFIG_HAS_IOMEM=y +CONFIG_HAS_IOPORT=y +CONFIG_HAS_IOPORT_MAP=y +CONFIG_HAS_DMA=y +CONFIG_NEED_SG_DMA_LENGTH=y +CONFIG_NEED_DMA_MAP_STATE=y +CONFIG_ARCH_DMA_ADDR_T_64BIT=y +CONFIG_SWIOTLB=y +# CONFIG_SWIOTLB_DYNAMIC is not set +CONFIG_DMA_NEED_SYNC=y +# CONFIG_DMA_API_DEBUG is not set +CONFIG_CPU_RMAP=y +CONFIG_DQL=y +CONFIG_NLATTR=y +# CONFIG_IRQ_POLL is not set +CONFIG_DIMLIB=y +CONFIG_HAVE_GENERIC_VDSO=y +CONFIG_GENERIC_GETTIMEOFDAY=y +CONFIG_GENERIC_VDSO_OVERFLOW_PROTECT=y +CONFIG_VDSO_GETRANDOM=y +CONFIG_SG_POOL=y +CONFIG_ARCH_HAS_PMEM_API=y +CONFIG_ARCH_HAS_CPU_CACHE_INVALIDATE_MEMREGION=y +CONFIG_ARCH_HAS_UACCESS_FLUSHCACHE=y +CONFIG_ARCH_HAS_COPY_MC=y +CONFIG_ARCH_STACKWALK=y +CONFIG_SBITMAP=y +# CONFIG_LWQ_TEST is not set +# end of Library routines + +CONFIG_FIRMWARE_TABLE=y +CONFIG_UNION_FIND=y + +# +# Kernel hacking +# + +# +# printk and dmesg options +# +# CONFIG_PRINTK_TIME is not set +# CONFIG_PRINTK_CALLER is not set +# CONFIG_STACKTRACE_BUILD_ID is not set +CONFIG_CONSOLE_LOGLEVEL_DEFAULT=7 +CONFIG_CONSOLE_LOGLEVEL_QUIET=4 +CONFIG_MESSAGE_LOGLEVEL_DEFAULT=4 +# CONFIG_BOOT_PRINTK_DELAY is not set +# CONFIG_DYNAMIC_DEBUG is not set +# CONFIG_DYNAMIC_DEBUG_CORE is not set +# CONFIG_SYMBOLIC_ERRNAME is not set +# end of printk and dmesg options + +CONFIG_DEBUG_KERNEL=y +# CONFIG_DEBUG_MISC is not set + +# +# Compile-time checks and compiler options +# +CONFIG_AS_HAS_NON_CONST_ULEB128=y +CONFIG_DEBUG_INFO_NONE=y +# CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT is not set +# CONFIG_DEBUG_INFO_DWARF4 is not set +# CONFIG_DEBUG_INFO_DWARF5 is not set +CONFIG_FRAME_WARN=1280 +# CONFIG_STRIP_ASM_SYMS is not set +# CONFIG_READABLE_ASM is not set +# CONFIG_HEADERS_INSTALL is not set +# CONFIG_DEBUG_SECTION_MISMATCH is not set +# CONFIG_SECTION_MISMATCH_WARN_ONLY is not set +# CONFIG_DEBUG_FORCE_FUNCTION_ALIGN_64B is not set +CONFIG_OBJTOOL=y +# CONFIG_OBJTOOL_WERROR is not set +# CONFIG_VMLINUX_MAP is not set +# CONFIG_DEBUG_FORCE_WEAK_PER_CPU is not set +# end of Compile-time checks and compiler options + +# +# Generic Kernel Debugging Instruments +# +# CONFIG_MAGIC_SYSRQ is not set +# CONFIG_DEBUG_FS is not set +CONFIG_HAVE_ARCH_KGDB=y +# CONFIG_KGDB is not set +CONFIG_ARCH_HAS_UBSAN=y +# CONFIG_UBSAN is not set +CONFIG_HAVE_ARCH_KCSAN=y +CONFIG_HAVE_KCSAN_COMPILER=y +# CONFIG_KCSAN is not set +# end of Generic Kernel Debugging Instruments + +# +# Networking Debugging +# +# CONFIG_NET_DEV_REFCNT_TRACKER is not set +# CONFIG_NET_NS_REFCNT_TRACKER is not set +# CONFIG_DEBUG_NET is not set +# CONFIG_DEBUG_NET_SMALL_RTNL is not set +# end of Networking Debugging + +# +# Memory Debugging +# +# CONFIG_PAGE_EXTENSION is not set +# CONFIG_DEBUG_PAGEALLOC is not set +# CONFIG_PAGE_OWNER is not set +# CONFIG_PAGE_TABLE_CHECK is not set +# CONFIG_PAGE_POISONING is not set +# CONFIG_DEBUG_RODATA_TEST is not set +CONFIG_ARCH_HAS_DEBUG_WX=y +# CONFIG_DEBUG_WX is not set +CONFIG_ARCH_HAS_PTDUMP=y +CONFIG_HAVE_DEBUG_KMEMLEAK=y +# CONFIG_DEBUG_KMEMLEAK is not set +# CONFIG_PER_VMA_LOCK_STATS is not set +# CONFIG_DEBUG_OBJECTS is not set +# CONFIG_DEBUG_STACK_USAGE is not set +# CONFIG_SCHED_STACK_END_CHECK is not set +CONFIG_ARCH_HAS_DEBUG_VM_PGTABLE=y +# CONFIG_DEBUG_VFS is not set +# CONFIG_DEBUG_VM is not set +# CONFIG_DEBUG_VM_PGTABLE is not set +CONFIG_ARCH_HAS_DEBUG_VIRTUAL=y +# CONFIG_DEBUG_VIRTUAL is not set +# CONFIG_DEBUG_MEMORY_INIT is not set +# CONFIG_DEBUG_PER_CPU_MAPS is not set +CONFIG_ARCH_SUPPORTS_KMAP_LOCAL_FORCE_MAP=y +# CONFIG_DEBUG_KMAP_LOCAL_FORCE_MAP is not set +# CONFIG_MEM_ALLOC_PROFILING is not set +CONFIG_HAVE_ARCH_KASAN=y +CONFIG_HAVE_ARCH_KASAN_VMALLOC=y +CONFIG_CC_HAS_KASAN_GENERIC=y +CONFIG_CC_HAS_KASAN_SW_TAGS=y +CONFIG_CC_HAS_WORKING_NOSANITIZE_ADDRESS=y +CONFIG_HAVE_ARCH_KFENCE=y +# CONFIG_KFENCE is not set +CONFIG_HAVE_ARCH_KMSAN=y +# end of Memory Debugging + +# CONFIG_DEBUG_SHIRQ is not set + +# +# Debug Oops, Lockups and Hangs +# +# CONFIG_PANIC_ON_OOPS is not set +CONFIG_PANIC_TIMEOUT=0 +# CONFIG_SOFTLOCKUP_DETECTOR is not set +CONFIG_HAVE_HARDLOCKUP_DETECTOR_BUDDY=y +# CONFIG_HARDLOCKUP_DETECTOR is not set +CONFIG_HARDLOCKUP_CHECK_TIMESTAMP=y +# CONFIG_DETECT_HUNG_TASK is not set +# CONFIG_WQ_WATCHDOG is not set +# CONFIG_WQ_CPU_INTENSIVE_REPORT is not set +# end of Debug Oops, Lockups and Hangs + +# +# Scheduler Debugging +# +# CONFIG_SCHEDSTATS is not set +# end of Scheduler Debugging + +# CONFIG_DEBUG_PREEMPT is not set +# CONFIG_DEBUG_ATOMIC is not set + +# +# Lock Debugging (spinlocks, mutexes, etc...) +# +CONFIG_LOCK_DEBUGGING_SUPPORT=y +# CONFIG_PROVE_LOCKING is not set +# CONFIG_LOCK_STAT is not set +# CONFIG_DEBUG_RT_MUTEXES is not set +# CONFIG_DEBUG_SPINLOCK is not set +# CONFIG_DEBUG_MUTEXES is not set +# CONFIG_DEBUG_WW_MUTEX_SLOWPATH is not set +# CONFIG_DEBUG_RWSEMS is not set +# CONFIG_DEBUG_LOCK_ALLOC is not set +# CONFIG_DEBUG_ATOMIC_SLEEP is not set +# CONFIG_DEBUG_LOCKING_API_SELFTESTS is not set +# CONFIG_LOCK_TORTURE_TEST is not set +# CONFIG_WW_MUTEX_SELFTEST is not set +# CONFIG_SCF_TORTURE_TEST is not set +# CONFIG_CSD_LOCK_WAIT_DEBUG is not set +# end of Lock Debugging (spinlocks, mutexes, etc...) + +# CONFIG_NMI_CHECK_CPU is not set +# CONFIG_DEBUG_IRQFLAGS is not set +# CONFIG_STACKTRACE is not set +# CONFIG_DEBUG_KOBJECT is not set + +# +# Debug kernel data structures +# +# CONFIG_DEBUG_LIST is not set +# CONFIG_DEBUG_PLIST is not set +# CONFIG_DEBUG_SG is not set +# CONFIG_DEBUG_NOTIFIERS is not set +# CONFIG_DEBUG_MAPLE_TREE is not set +# end of Debug kernel data structures + +# +# RCU Debugging +# +# CONFIG_RCU_SCALE_TEST is not set +# CONFIG_RCU_TORTURE_TEST is not set +# CONFIG_RCU_REF_SCALE_TEST is not set +CONFIG_RCU_CPU_STALL_TIMEOUT=21 +CONFIG_RCU_EXP_CPU_STALL_TIMEOUT=0 +# CONFIG_RCU_CPU_STALL_CPUTIME is not set +# CONFIG_RCU_TRACE is not set +# CONFIG_RCU_EQS_DEBUG is not set +# end of RCU Debugging + +# CONFIG_DEBUG_WQ_FORCE_RR_CPU is not set +# CONFIG_CPU_HOTPLUG_STATE_CONTROL is not set +# CONFIG_LATENCYTOP is not set +CONFIG_USER_STACKTRACE_SUPPORT=y +CONFIG_HAVE_RETHOOK=y +CONFIG_HAVE_FUNCTION_TRACER=y +CONFIG_HAVE_DYNAMIC_FTRACE=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_REGS=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_ARGS=y +CONFIG_HAVE_FTRACE_REGS_HAVING_PT_REGS=y +CONFIG_HAVE_DYNAMIC_FTRACE_NO_PATCHABLE=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_JMP=y +CONFIG_HAVE_SYSCALL_TRACEPOINTS=y +CONFIG_HAVE_FENTRY=y +CONFIG_HAVE_OBJTOOL_MCOUNT=y +CONFIG_HAVE_OBJTOOL_NOP_MCOUNT=y +CONFIG_HAVE_C_RECORDMCOUNT=y +CONFIG_HAVE_BUILDTIME_MCOUNT_SORT=y +CONFIG_TRACING_SUPPORT=y +# CONFIG_FTRACE is not set +# CONFIG_PROVIDE_OHCI1394_DMA_INIT is not set +# CONFIG_SAMPLES is not set +CONFIG_HAVE_SAMPLE_FTRACE_DIRECT=y +CONFIG_HAVE_SAMPLE_FTRACE_DIRECT_MULTI=y +CONFIG_ARCH_HAS_DEVMEM_IS_ALLOWED=y + +# +# x86 Debugging +# +# CONFIG_X86_VERBOSE_BOOTUP is not set +# CONFIG_EARLY_PRINTK is not set +# CONFIG_DEBUG_TLBFLUSH is not set +CONFIG_HAVE_MMIOTRACE_SUPPORT=y +# CONFIG_X86_DECODER_SELFTEST is not set +CONFIG_IO_DELAY_0X80=y +# CONFIG_IO_DELAY_0XED is not set +# CONFIG_IO_DELAY_UDELAY is not set +# CONFIG_IO_DELAY_NONE is not set +# CONFIG_CPA_DEBUG is not set +# CONFIG_DEBUG_ENTRY is not set +# CONFIG_DEBUG_NMI_SELFTEST is not set +# CONFIG_X86_DEBUG_FPU is not set +# CONFIG_PUNIT_ATOM_DEBUG is not set +# CONFIG_UNWINDER_ORC is not set +# CONFIG_UNWINDER_FRAME_POINTER is not set +CONFIG_UNWINDER_GUESS=y +# end of x86 Debugging + +# +# Kernel Testing and Coverage +# +# CONFIG_KUNIT is not set +# CONFIG_NOTIFIER_ERROR_INJECTION is not set +# CONFIG_FAULT_INJECTION is not set +CONFIG_ARCH_HAS_KCOV=y +# CONFIG_KCOV is not set +# CONFIG_RUNTIME_TESTING_MENU is not set +CONFIG_ARCH_USE_MEMTEST=y +# CONFIG_MEMTEST is not set +# end of Kernel Testing and Coverage + +# +# Rust hacking +# +# end of Rust hacking +# end of Kernel hacking + +CONFIG_IO_URING_ZCRX=y +CONFIG_IO_URING_BPF=y diff --git a/build/kernel/nestri.fragment b/build/kernel/nestri.fragment new file mode 100644 index 00000000..2e488794 --- /dev/null +++ b/build/kernel/nestri.fragment @@ -0,0 +1,207 @@ +# Kconfig fragment for the guest kernel. +# +# Merged onto the tree's config by scripts/kernel-build.sh with the kernel's own +# merge_config.sh, then resolved with `make olddefconfig`, then checked: every +# entry below must hold in the final .config or the build stops. A fragment +# rather than a saved full config: a full .config pins thousands of symbols and +# rots the moment the tree is bumped, while this says only what the guest +# actually requires and lets the version's own defaults handle the rest. +# +# Every entry here is load-bearing or a deliberate performance choice. Nothing +# is speculative. + +# ── Gaming needs ───────────────────────────────────────── +# NTSYNC is a great improvement over fsync and esync approaches. +CONFIG_NTSYNC=y + +# ── Timers ─────────────────────────────────────────────── +# The one that cost a day of silent audio. The guest has no sound hardware, so +# PipeWire drives its whole graph off a timerfd at a 2.67ms cycle, which a +# HZ_PERIODIC/250 kernel cannot express. Symptom: everything reports healthy, +# neswire sends a steady ~3 kbps of Opus, and every sample is zero. +CONFIG_HIGH_RES_TIMERS=y +CONFIG_NO_HZ_IDLE=y +CONFIG_HZ_1000=y + +# ── Shared memory ──────────────────────────────────────── +# PipeWire moves audio through memfd, not through its socket. Wine uses POSIX +# shm and semaphores directly. /dev/shm itself is mounted by nesinit -- +# devtmpfs does not provide it. +CONFIG_SHMEM=y +CONFIG_MEMFD_CREATE=y +CONFIG_TMPFS=y +CONFIG_DEVTMPFS=y +CONFIG_DEVTMPFS_MOUNT=y + +# ── What PipeWire schedules on ─────────────────────────── +# FUTEX_PI is also what Proton's fsync uses. +CONFIG_TIMERFD=y +CONFIG_EVENTFD=y +CONFIG_EPOLL=y +CONFIG_SIGNALFD=y +CONFIG_FUTEX=y +CONFIG_FUTEX_PI=y + +# ── Clock sources ──────────────────────────────────────── +# Listed because they were correct throughout the silent-audio episode. A good +# clock *source* says nothing about whether the kernel can schedule a timer +# *event* at the resolution asked for; only the block above governs that. +CONFIG_KVM_GUEST=y +CONFIG_PARAVIRT_CLOCK=y +CONFIG_X86_TSC=y + +# ── Steal time ─────────────────────────────────────────── +# Time the host spends running something else on a vCPU is subtracted from +# whatever task was on it, rather than charged to it. vCPUs share a set of host +# cores instead of owning one each, so steal happens, and without this the +# scheduler reads it as the task having run -- a frame thread preempted by the +# host looks, to the fair class, like one that used its slice. +CONFIG_PARAVIRT_TIME_ACCOUNTING=y + +# ── Idle: poll before halting ──────────────────────────── +# An idle vCPU that halts exits to the host, and waking it costs an IPI and a +# VM entry. The frame pipeline sleeps and wakes every few milliseconds, so that +# is paid constantly. haltpoll spins for a bounded, adaptive window first +# (guest_halt_poll_ns, 200us), so a wakeup that comes quickly never leaves the +# guest. When the host offers it, the guest also switches host-side polling +# off, so the two never poll at once. +# +# Built in but inert: it loads only if the host sets KVM_HINTS_REALTIME or the +# command line forces it. The hint is the wrong tool -- it also promises the +# vCPUs are never preempted, and the guest drops paravirt spinlocks on the +# strength of that, which a shared set of host cores cannot promise. Forcing +# the idle driver gets the polling and nothing else. Built into the command +# line, which is prepended to what the host passes, so no caller has to know. +# The cost is host CPU spent spinning on an idle vCPU. +# +# Prepended, not replacing: with OVERRIDE off the host's arguments come after +# this and the last value wins, so `cpuidle_haltpoll.force=0` from the host +# still turns it off. +CONFIG_HALTPOLL_CPUIDLE=y +CONFIG_CPU_IDLE_GOV_HALTPOLL=y +CONFIG_CMDLINE_BOOL=y +CONFIG_CMDLINE="cpuidle_haltpoll.force=1 swiotlb=noforce" +# CONFIG_CMDLINE_OVERRIDE is not set + +# ── No bounce buffer ───────────────────────────────────── +# With more than about 3 GiB of RAM the guest has memory above 4 GiB, and x86 +# then sets aside a 64 MiB SWIOTLB bounce buffer and zeroes it at boot, which +# makes the host back all of it. Nothing here ever bounces: the virtio devices +# do not offer ACCESS_PLATFORM, so virtio skips the DMA API entirely, and there +# is no other DMA-capable device. `swiotlb=noforce` in the command line above +# skips the allocation. A device that genuinely needs a 32-bit DMA mask would +# fail to map rather than bounce; there is none. + +# ── microVM transport ──────────────────────────────────── +CONFIG_VIRTIO=y +CONFIG_VIRTIO_PCI=y +CONFIG_VIRTIO_MMIO=y +CONFIG_VIRTIO_BLK=y # root on /dev/vda +CONFIG_VIRTIO_CONSOLE=y # hvc0, the only way into a guest that will not boot +CONFIG_VIRTIO_NET=y +CONFIG_VIRTIO_FS=y # the shares the boot descriptor names +CONFIG_FUSE_FS=y # virtiofs needs it +CONFIG_VSOCKETS=y # neshub's link to the host +CONFIG_VIRTIO_VSOCKETS=y +CONFIG_VIRTIO_BALLOON=y +CONFIG_DRM=y +CONFIG_DRM_VIRTIO_GPU=y + +# ── Filesystems ────────────────────────────────────────── +CONFIG_EXT4_FS=y # output/rootfs.ext4, and a box's writable install layer +CONFIG_MISC_FILESYSTEMS=y # the menu EROFS lives under; off in base.config, which drops it silently +CONFIG_EROFS_FS=y # a game's build image, the install overlay's lower layer +CONFIG_OVERLAY_FS=y # the session overlay over a read-only install + +# ── Memory typing ──────────────────────────────────────── +# Page Attribute Table, and the MTRR support it depends on. +# +# Both are `def_bool y` upstream, promptable only under EXPERT -- so they were +# switched off deliberately at some point, and the guest booted logging +# "PAT support disabled because CONFIG_X86_PAT is disabled in the kernel". +# +# Without PAT there are no write-combining mappings. This guest exists to push +# frames through virtio-gpu, and WC on the GPU aperture is precisely what PAT +# provides; every frame would go through uncached or writeback mappings +# instead. Not a correctness problem, which is why it went unnoticed. +CONFIG_MTRR=y +CONFIG_X86_PAT=y + +# ── POSIX ACLs ─────────────────────────────────────────── +# Nothing depends on the ACLs themselves; without them, anything that sets one +# on a device node or a file logs "Operation not supported" at every boot, and +# a boot log full of failures that do not matter hides the ones that do. +CONFIG_EXT4_FS_POSIX_ACL=y +CONFIG_TMPFS_POSIX_ACL=y + +# ── Performance ────────────────────────────────────────── +# Chosen, not required. The mainline subset of what CachyOS enables, minus +# anything needing patches. PREEMPT_LAZY is the tree's own default and is left +# alone; PREEMPT_DYNAMIC on top allows `preempt=full` from the command line +# without a rebuild. +CONFIG_PREEMPT_DYNAMIC=y +CONFIG_LRU_GEN=y +CONFIG_LRU_GEN_ENABLED=y +CONFIG_TRANSPARENT_HUGEPAGE=y +CONFIG_TRANSPARENT_HUGEPAGE_MADVISE=y +CONFIG_PSI=y + +# ── Undoing the minimal seed ───────────────────────────── +# base.config descends from a size-minimised config with EXPERT on, and EXPERT +# is what makes each of these promptable. Every one is `default y` (or the +# default choice) upstream, and every one was off in the built kernel. They +# are pinned here because nothing else would stop a reseed from bringing them +# back. +# +# SLUB_TINY drops the per-CPU slab caches, so every kmalloc and kfree takes the +# locked slow path. Its own help text says it is not for performance. This +# guest allocates constantly: network buffers, GPU ioctls, io_uring, futexes. +# CONFIG_SLUB_TINY is not set +# +# Without ADVISE_SYSCALLS, madvise() and fadvise() return ENOSYS. THP above is +# madvise-only, so no user mapping could ever get a huge page, and allocators +# could never hand memory back with MADV_DONTNEED or MADV_FREE. +CONFIG_ADVISE_SYSCALLS=y +# +# Without MEMBARRIER, Wine and the .NET runtime fall back to an mprotect() trick +# to flush other threads' write buffers, which forces a TLB shootdown across +# every vCPU -- an IPI storm, and each IPI is a VM exit. RSEQ gives glibc and +# per-CPU allocators their fast paths. +CONFIG_MEMBARRIER=y +CONFIG_RSEQ=y +# +# -O2, not -Os: the whole kernel was being compiled for size, and a guest that +# runs Wine spends much of its time in syscalls. +CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE=y +# CONFIG_CC_OPTIMIZE_FOR_SIZE is not set +# +# Without jump labels, every static key is a memory load and a branch instead +# of a patched no-op, on paths such as steal-time accounting, the scheduler and +# cgroup checks. +CONFIG_JUMP_LABEL=y + +# Autogroup is deliberately OFF, which is a departure from CachyOS. +# +# It groups tasks by session id, which is a desktop tuning: it keeps a +# terminal's `make -j` from starving the browser. This guest is a +# single-application appliance with no interactive sessions to balance +# against each other, so there is nothing for it to do but add overhead. +# CONFIG_SCHED_AUTOGROUP is not set + +# ── Deliberately absent ────────────────────────────────── +# No CONFIG_SND of any kind. The guest has no sound hardware and wants none: +# neswire is a virtual PipeWire sink, and an ALSA stack would only add a second +# thing that could claim to be the default output. + +# Speculation mitigations are off, as a decision rather than an oversight. +# +# `lscpu` in the guest reports Spectre v1/v2, SSB, SRSO and TSA as Vulnerable, +# and that is intended. This is a single-tenant sandbox: one player's session, +# torn down at the end of it, with the VM boundary as the isolation. The +# attacks these defend against are cross-tenant side channels, and the cost is +# paid on every syscall and context switch in a workload that is latency +# bound. +# +# What would change this: running two players' sessions on one box at the same +# time, or anything of value living inside the guest. Neither is true today. +# CONFIG_CPU_MITIGATIONS is not set diff --git a/build/patches/mesa/0003-radv-amdgpu-cache-AMDGPU_INFO_MEMORY-briefly-on-the-.patch b/build/patches/mesa/0003-radv-amdgpu-cache-AMDGPU_INFO_MEMORY-briefly-on-the-.patch deleted file mode 100644 index bf3266c2..00000000 --- a/build/patches/mesa/0003-radv-amdgpu-cache-AMDGPU_INFO_MEMORY-briefly-on-the-.patch +++ /dev/null @@ -1,140 +0,0 @@ -From 4dcb0baf29136c90776133afb714bac3c4cb7686 Mon Sep 17 00:00:00 2001 -From: DatCaptainHorse -Date: Thu, 17 Sep 2026 21:50:25 +0300 -Subject: [PATCH] radv/amdgpu: cache AMDGPU_INFO_MEMORY briefly on the virtio - path - -Heap usage is asked for far more often than it changes. Measured on an -amdgpu native context with one game running, RADV asked for it ~47 times -per 8.8 ms frame -- the same answer, 47 times, each one a synchronous round -trip to the host rather than an ioctl. - -Serve a recent answer instead, for one millisecond. That is inside the -contract of what the value is for: heap usage feeds VK_EXT_memory_budget -and the winsys's own eviction decisions, and the spec calls those estimates -that may be out of date. It is also short enough that an application -allocating hard still sees its own pressure within a frame at any plausible -frame rate. - -Only on the virtio path, where a query costs a round trip; a local ioctl is -cheap enough that caching it would be complexity for nothing. Only this -query, and only at its natural size: every other AMDGPU_INFO_* either -carries a caller-supplied selector in the union, so one cached answer would -be the wrong answer to the next question, or is asked once at startup. - -Co-Authored-By: Claude Opus 5 ---- - src/amd/common/virtio/amdgpu_virtio.c | 53 +++++++++++++++++++ - src/amd/common/virtio/amdgpu_virtio_device.c | 1 + - src/amd/common/virtio/amdgpu_virtio_private.h | 6 +++ - 3 files changed, 60 insertions(+) - -diff --git a/src/amd/common/virtio/amdgpu_virtio.c b/src/amd/common/virtio/amdgpu_virtio.c -index 00494d6a750..dd38ef141f5 100644 ---- a/src/amd/common/virtio/amdgpu_virtio.c -+++ b/src/amd/common/virtio/amdgpu_virtio.c -@@ -19,8 +19,25 @@ - #include "drm-uapi/amdgpu_drm.h" - - #include "util/log.h" -+#include "util/os_time.h" - #include "util/u_math.h" - -+/* How long a cached AMDGPU_INFO_MEMORY answer is reused, in nanoseconds. -+ * -+ * Bounded by what the value is for. Heap usage feeds VK_EXT_memory_budget and -+ * the winsys's own eviction decisions, and the spec calls those values -+ * estimates that may be out of date -- so a millisecond of staleness is inside -+ * the contract, while a round trip per ask is not free here the way it is on a -+ * local ioctl. -+ * -+ * A millisecond rather than a frame: it is short enough that an application -+ * allocating hard still sees its own pressure within a frame at any plausible -+ * rate, and long enough to collapse the repeats. Measured on an amdgpu native -+ * context with one game running, RADV asked for this ~47 times per 8.8 ms -+ * frame -- the same answer, 47 synchronous round trips. -+ */ -+#define AMDVGPU_MEMORY_INFO_TTL_NS (1000 * 1000) -+ - int - amdvgpu_query_info(amdvgpu_device_handle dev, struct drm_amdgpu_info *info) - { -@@ -32,6 +49,30 @@ amdvgpu_query_info(amdvgpu_device_handle dev, struct drm_amdgpu_info *info) - struct amdgpu_ccmd_query_info_rsp *rsp; - assert(0 == (offsetof(struct amdgpu_ccmd_query_info_rsp, payload) % 8)); - -+ /* AMDGPU_INFO_MEMORY is asked far more often than it changes, and over -+ * virtio every ask is a synchronous round trip to the host rather than an -+ * ioctl. Serve a recent answer instead. -+ * -+ * Only this query, and only at its natural size: every other query either -+ * has a caller-supplied selector in the union -- so one cached answer would -+ * be the wrong answer to the next question -- or is asked once at startup -+ * and costs nothing. -+ */ -+ bool cacheable = info->query == AMDGPU_INFO_MEMORY && -+ info->return_size == sizeof(struct drm_amdgpu_memory_info); -+ if (cacheable) { -+ int64_t now = os_time_get_nano(); -+ simple_mtx_lock(&dev->memory_info_mutex); -+ if (dev->memory_info_stamp && -+ now - dev->memory_info_stamp < AMDVGPU_MEMORY_INFO_TTL_NS) { -+ memcpy((void *)(uintptr_t)info->return_pointer, &dev->memory_info, -+ sizeof(dev->memory_info)); -+ simple_mtx_unlock(&dev->memory_info_mutex); -+ return 0; -+ } -+ simple_mtx_unlock(&dev->memory_info_mutex); -+ } -+ - req->hdr = AMDGPU_CCMD(QUERY_INFO, req_len); - memcpy(&req->info, info, sizeof(struct drm_amdgpu_info)); - -@@ -43,6 +84,18 @@ amdvgpu_query_info(amdvgpu_device_handle dev, struct drm_amdgpu_info *info) - - memcpy((void*)(uintptr_t)info->return_pointer, rsp->payload, info->return_size); - -+ if (cacheable) { -+ simple_mtx_lock(&dev->memory_info_mutex); -+ memcpy(&dev->memory_info, rsp->payload, sizeof(dev->memory_info)); -+ /* Stamped after the answer is in hand, so the window covers the time the -+ * value is actually served rather than the round trip that fetched it. -+ * A zero stamp means "never fetched", so a clock that returns zero here -+ * costs a re-fetch rather than pinning a stale answer forever. -+ */ -+ dev->memory_info_stamp = os_time_get_nano(); -+ simple_mtx_unlock(&dev->memory_info_mutex); -+ } -+ - return 0; - } - -diff --git a/src/amd/common/virtio/amdgpu_virtio_device.c b/src/amd/common/virtio/amdgpu_virtio_device.c -index eecfd6aa11a..7e58913d520 100644 ---- a/src/amd/common/virtio/amdgpu_virtio_device.c -+++ b/src/amd/common/virtio/amdgpu_virtio_device.c -@@ -136,6 +136,7 @@ int amdvgpu_device_initialize(int fd, uint32_t *drm_major, uint32_t *drm_minor, - dev->vdev = vdev; - - simple_mtx_init(&dev->handle_to_vbo_mutex, mtx_plain); -+ simple_mtx_init(&dev->memory_info_mutex, mtx_plain); - simple_mtx_init(&dev->contexts_mutex, mtx_plain); - - dev->handle_to_vbo = _mesa_hash_table_u64_create(NULL); -diff --git a/src/amd/common/virtio/amdgpu_virtio_private.h b/src/amd/common/virtio/amdgpu_virtio_private.h -index 743f8f74b5c..fb877dc8d28 100644 ---- a/src/amd/common/virtio/amdgpu_virtio_private.h -+++ b/src/amd/common/virtio/amdgpu_virtio_private.h -@@ -60,6 +60,12 @@ struct amdvgpu_device { - - struct drm_amdgpu_info_device dev_info; - -+ /* AMDGPU_INFO_MEMORY, cached for a short while. See amdvgpu_query_info(). */ -+ simple_mtx_t memory_info_mutex; -+ struct drm_amdgpu_memory_info memory_info; -+ /* os_time_get_nano() when memory_info was fetched; 0 means never. */ -+ int64_t memory_info_stamp; -+ - /* Blob id are per drm_file identifiers of host blobs. - * Use a monotically increased integer to assign the blob id. - */ diff --git a/build/patches/mesa/0003-virtio-vdrm-ac-log-where-threads-wait-per-wait-and-p.patch b/build/patches/mesa/0003-virtio-vdrm-ac-log-where-threads-wait-per-wait-and-p.patch new file mode 100644 index 00000000..e4dee8c1 --- /dev/null +++ b/build/patches/mesa/0003-virtio-vdrm-ac-log-where-threads-wait-per-wait-and-p.patch @@ -0,0 +1,602 @@ +From 35db2e58006deca5c6383517f9228eb32a608548 Mon Sep 17 00:00:00 2001 +From: DatCaptainHorse +Date: Wed, 23 Sep 2026 20:13:47 +0300 +Subject: [PATCH] virtio/vdrm, ac: log where threads wait, per wait and per + second + +A guest thread under a native context can be blocked on the host in more +places than it looks: a synchronous request, a submission that holds +eb_lock across an ioctl which itself waits on in-fences, a BO wait, or an +ordinary syncobj wait. When a frame runs long there is no way to tell from +the outside which of those it was, or whether it was any of them. + +Two knobs, in milliseconds, both off when unset or 0: + +- MESA_SLOW_WAIT_MS logs each single wait longer than the value, with its + command, ring and how the time split between waiting for eb_lock, the + submit or flush ioctl, the fence, and the host catching up. +- MESA_WAIT_STATS logs, once a second per thread, the time and count spent + in each kind of wait, when the total reached the value. Many short waits + that add up to a long frame never trip a per-wait threshold; totals show + them, and a second with a long frame can be compared with one without. + +Syncobj waits are timed by wrapping the device's sync provider, since the +Vulkan runtime waits through it directly and never through the ac_drm_cs_* +helpers. The wrapper is installed only when a knob is set, and times only +waits that could block; a poll is not. + +Every line carries a UTC wall-clock stamp and the thread id, so it can be +lined up with other components' logs. The shared pieces live in a header-only +util/u_wait_log.h, so no build file changes. + +Co-Authored-By: Claude Opus 5.5 +--- + src/amd/common/ac_linux_drm.c | 197 +++++++++++++++++++++++++++++++++ + src/util/u_wait_log.h | 133 ++++++++++++++++++++++ + src/virtio/vdrm/vdrm.c | 80 +++++++++++++ + src/virtio/vdrm/vdrm.h | 14 +++ + src/virtio/vdrm/vdrm_virtgpu.c | 20 +++- + 5 files changed, 443 insertions(+), 1 deletion(-) + create mode 100644 src/util/u_wait_log.h + +diff --git a/src/amd/common/ac_linux_drm.c b/src/amd/common/ac_linux_drm.c +index 63b27058ec1..d2f16aefc83 100644 +--- a/src/amd/common/ac_linux_drm.c ++++ b/src/amd/common/ac_linux_drm.c +@@ -14,10 +14,205 @@ + #include + #include + ++#include "util/u_wait_log.h" ++ + #ifdef HAVE_AMDGPU_VIRTIO + #include "virtio/amdgpu_virtio.h" + #endif + ++/* Wait logging for blocking syncobj waits -- what vkWaitForFences and timeline ++ * semaphore waits come down to. See util/u_wait_log.h for the knobs; they are ++ * the virtio transport's too, so one setting shows both a thread waiting on ++ * the GPU and a thread waiting on the host. Polls (a zero timeout) are never ++ * timed. ++ * ++ * Done by wrapping the device's sync provider rather than the ac_drm_cs_* ++ * helpers, because the Vulkan runtime waits through the provider directly and ++ * never calls those helpers: timing them saw almost nothing. The wrapper is ++ * installed only when a knob is set, so it costs nothing otherwise. ++ */ ++enum { SYNCOBJ_WAIT, TIMELINE_WAIT, WAIT_KINDS }; ++static const char *const wait_kinds[WAIT_KINDS] = { "syncobj", "timeline" }; ++static __thread struct u_wait_log_window wait_window; ++ ++static void ++note_wait(unsigned kind, int64_t t0, unsigned num_handles, int ret) ++{ ++ int64_t took = os_time_get_nano() - t0; ++ u_wait_log_account(&wait_window, "sync", wait_kinds, WAIT_KINDS, kind, took); ++ ++ int64_t slow = u_wait_log_slow_ns(); ++ if (slow && took > slow) { ++ char stamp[16]; ++ u_wait_log_stamp(stamp); ++ mesa_logw("%s sync: %s wait on %u syncobj(s) took %.1f ms (ret %d) tid %d", stamp, ++ wait_kinds[kind], num_handles, took / 1e6, ret, gettid()); ++ } ++} ++ ++struct timed_sync_provider { ++ struct util_sync_provider base; ++ struct util_sync_provider *inner; ++}; ++ ++static struct util_sync_provider * ++inner_of(struct util_sync_provider *p) ++{ ++ return ((struct timed_sync_provider *)p)->inner; ++} ++ ++static int ++timed_create(struct util_sync_provider *p, uint32_t flags, uint32_t *handle) ++{ ++ return inner_of(p)->create(inner_of(p), flags, handle); ++} ++ ++static int ++timed_destroy(struct util_sync_provider *p, uint32_t handle) ++{ ++ return inner_of(p)->destroy(inner_of(p), handle); ++} ++ ++static int ++timed_handle_to_fd(struct util_sync_provider *p, uint32_t handle, int *out_obj_fd) ++{ ++ return inner_of(p)->handle_to_fd(inner_of(p), handle, out_obj_fd); ++} ++ ++static int ++timed_fd_to_handle(struct util_sync_provider *p, int obj_fd, uint32_t *handle) ++{ ++ return inner_of(p)->fd_to_handle(inner_of(p), obj_fd, handle); ++} ++ ++static int ++timed_import_sync_file(struct util_sync_provider *p, uint32_t handle, int sync_file_fd) ++{ ++ return inner_of(p)->import_sync_file(inner_of(p), handle, sync_file_fd); ++} ++ ++static int ++timed_export_sync_file(struct util_sync_provider *p, uint32_t handle, int *out_sync_file_fd) ++{ ++ return inner_of(p)->export_sync_file(inner_of(p), handle, out_sync_file_fd); ++} ++ ++static int ++timed_wait(struct util_sync_provider *p, uint32_t *handles, unsigned num_handles, ++ int64_t timeout_nsec, unsigned flags, uint32_t *first_signaled) ++{ ++ struct util_sync_provider *in = inner_of(p); ++ if (!timeout_nsec) ++ return in->wait(in, handles, num_handles, timeout_nsec, flags, first_signaled); ++ ++ int64_t t0 = os_time_get_nano(); ++ int ret = in->wait(in, handles, num_handles, timeout_nsec, flags, first_signaled); ++ note_wait(SYNCOBJ_WAIT, t0, num_handles, ret); ++ return ret; ++} ++ ++static int ++timed_reset(struct util_sync_provider *p, const uint32_t *handles, uint32_t handle_count) ++{ ++ return inner_of(p)->reset(inner_of(p), handles, handle_count); ++} ++ ++static int ++timed_signal(struct util_sync_provider *p, const uint32_t *handles, uint32_t handle_count) ++{ ++ return inner_of(p)->signal(inner_of(p), handles, handle_count); ++} ++ ++static int ++timed_timeline_signal(struct util_sync_provider *p, const uint32_t *handles, uint64_t *points, ++ uint32_t handle_count) ++{ ++ return inner_of(p)->timeline_signal(inner_of(p), handles, points, handle_count); ++} ++ ++static int ++timed_timeline_wait(struct util_sync_provider *p, uint32_t *handles, uint64_t *points, ++ unsigned num_handles, int64_t timeout_nsec, unsigned flags, ++ uint32_t *first_signaled) ++{ ++ struct util_sync_provider *in = inner_of(p); ++ if (!timeout_nsec) ++ return in->timeline_wait(in, handles, points, num_handles, timeout_nsec, flags, ++ first_signaled); ++ ++ int64_t t0 = os_time_get_nano(); ++ int ret = in->timeline_wait(in, handles, points, num_handles, timeout_nsec, flags, ++ first_signaled); ++ note_wait(TIMELINE_WAIT, t0, num_handles, ret); ++ return ret; ++} ++ ++static int ++timed_query(struct util_sync_provider *p, uint32_t *handles, uint64_t *points, ++ uint32_t handle_count, uint32_t flags) ++{ ++ return inner_of(p)->query(inner_of(p), handles, points, handle_count, flags); ++} ++ ++static int ++timed_transfer(struct util_sync_provider *p, uint32_t dst_handle, uint64_t dst_point, ++ uint32_t src_handle, uint64_t src_point, uint32_t flags) ++{ ++ return inner_of(p)->transfer(inner_of(p), dst_handle, dst_point, src_handle, src_point, ++ flags); ++} ++ ++static struct util_sync_provider *wrap_timed(struct util_sync_provider *inner); ++ ++static void ++timed_finalize(struct util_sync_provider *p) ++{ ++ inner_of(p)->finalize(inner_of(p)); ++ free(p); ++} ++ ++static struct util_sync_provider * ++timed_clone(struct util_sync_provider *p) ++{ ++ struct util_sync_provider *cloned = inner_of(p)->clone(inner_of(p)); ++ return cloned ? wrap_timed(cloned) : NULL; ++} ++ ++/* Wrap `inner` so its blocking waits are timed. On allocation failure the ++ * inner provider is returned as it is: logging is not worth failing a device. ++ */ ++static struct util_sync_provider * ++wrap_timed(struct util_sync_provider *inner) ++{ ++ struct timed_sync_provider *t = calloc(1, sizeof(*t)); ++ if (!t) ++ return inner; ++ t->inner = inner; ++ /* An operation the inner provider leaves NULL stays NULL here, since ++ * callers test for it rather than call through it. ++ */ ++#define FWD(op) .op = inner->op ? timed_##op : NULL ++ t->base = (struct util_sync_provider) { ++ FWD(create), ++ FWD(destroy), ++ FWD(handle_to_fd), ++ FWD(fd_to_handle), ++ FWD(import_sync_file), ++ FWD(export_sync_file), ++ FWD(wait), ++ FWD(reset), ++ FWD(signal), ++ FWD(timeline_signal), ++ FWD(timeline_wait), ++ FWD(query), ++ FWD(transfer), ++ FWD(finalize), ++ FWD(clone), ++ }; ++#undef FWD ++ return &t->base; ++} ++ + struct ac_drm_device { + union { + amdgpu_device_handle adev; +@@ -82,6 +277,8 @@ int ac_drm_device_initialize(int fd, bool is_virtio, + } + + if (r == 0) { ++ if (u_wait_log_enabled() && (*dev)->p) ++ (*dev)->p = wrap_timed((*dev)->p); + (*dev)->is_virtio = is_virtio; + /* Device-static, so it is asked once here rather than on every heap + * query. A failure is not fatal: the caller falls back to querying it. +diff --git a/src/util/u_wait_log.h b/src/util/u_wait_log.h +new file mode 100644 +index 00000000000..ce96256f371 +--- /dev/null ++++ b/src/util/u_wait_log.h +@@ -0,0 +1,133 @@ ++/* ++ * SPDX-License-Identifier: MIT ++ */ ++ ++/* Logging for time a thread spends waiting, for finding what a frame that ++ * runs long was blocked on. ++ * ++ * Two knobs, both in milliseconds and both off when unset or 0: ++ * ++ * - MESA_SLOW_WAIT_MS: log each single wait longer than this. ++ * - MESA_WAIT_STATS: once a second, per thread, log how much time went to ++ * each kind of wait, if the total reached this. Many ++ * short waits that add up to a long frame never trip a ++ * per-wait threshold; totals show them. ++ * ++ * Every line carries a UTC wall-clock stamp, so it can be lined up with other ++ * components' logs. Header-only, so each file that uses it keeps its own ++ * per-thread window and reports under its own name. ++ */ ++ ++#ifndef U_WAIT_LOG_H ++#define U_WAIT_LOG_H ++ ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "util/log.h" ++#include "util/os_time.h" ++#include "util/u_debug.h" ++ ++#define U_WAIT_LOG_MAX_KINDS 8 ++ ++struct u_wait_log_window { ++ int64_t start; ++ int64_t ns[U_WAIT_LOG_MAX_KINDS]; ++ uint32_t n[U_WAIT_LOG_MAX_KINDS]; ++}; ++ ++/* A millisecond option as nanoseconds, read once into *cache. */ ++static inline int64_t ++u_wait_log_option_ns(const char *name, int64_t *cache) ++{ ++ if (*cache < 0) ++ *cache = debug_get_num_option(name, 0) * 1000000ll; ++ return *cache; ++} ++ ++static inline int64_t ++u_wait_log_slow_ns(void) ++{ ++ static int64_t cache = -1; ++ return u_wait_log_option_ns("MESA_SLOW_WAIT_MS", &cache); ++} ++ ++static inline int64_t ++u_wait_log_stats_ns(void) ++{ ++ static int64_t cache = -1; ++ return u_wait_log_option_ns("MESA_WAIT_STATS", &cache); ++} ++ ++/* Whether any wait timing is wanted at all. */ ++static inline bool ++u_wait_log_enabled(void) ++{ ++ return u_wait_log_slow_ns() || u_wait_log_stats_ns(); ++} ++ ++/* "HH:MM:SS.mmm" in UTC. */ ++static inline void ++u_wait_log_stamp(char buf[16]) ++{ ++ struct timespec ts; ++ struct tm tm; ++ clock_gettime(CLOCK_REALTIME, &ts); ++ gmtime_r(&ts.tv_sec, &tm); ++ snprintf(buf, 16, "%02d:%02d:%02d.%03ld", tm.tm_hour, tm.tm_min, tm.tm_sec, ++ ts.tv_nsec / 1000000); ++} ++ ++/* Add one wait of `ns` nanoseconds of kind `kind` to this thread's window, ++ * and report and reset the window once it is a second old. ++ */ ++static inline void ++u_wait_log_account(struct u_wait_log_window *w, const char *who, ++ const char *const *kinds, unsigned num_kinds, ++ unsigned kind, int64_t ns) ++{ ++ int64_t floor = u_wait_log_stats_ns(); ++ if (!floor || kind >= num_kinds || num_kinds > U_WAIT_LOG_MAX_KINDS) ++ return; ++ ++ int64_t now = os_time_get_nano(); ++ if (!w->start) ++ w->start = now; ++ w->ns[kind] += ns; ++ w->n[kind]++; ++ ++ if (now - w->start < 1000000000ll) ++ return; ++ ++ int64_t total = 0; ++ for (unsigned i = 0; i < num_kinds; i++) ++ total += w->ns[i]; ++ ++ if (total >= floor) { ++ char line[256]; ++ size_t len = 0; ++ line[0] = '\0'; ++ for (unsigned i = 0; i < num_kinds && len < sizeof(line); i++) { ++ if (!w->n[i]) ++ continue; ++ int wrote = snprintf(line + len, sizeof(line) - len, " %s %.1f/%u", ++ kinds[i], w->ns[i] / 1e6, w->n[i]); ++ if (wrote < 0) ++ break; ++ len += wrote; ++ } ++ char stamp[16]; ++ u_wait_log_stamp(stamp); ++ mesa_logw("%s %s: tid %d waited %.1f ms of %.0f:%s", stamp, who, gettid(), ++ total / 1e6, (now - w->start) / 1e6, line); ++ } ++ ++ memset(w, 0, sizeof(*w)); ++ w->start = now; ++} ++ ++#endif /* U_WAIT_LOG_H */ +diff --git a/src/virtio/vdrm/vdrm.c b/src/virtio/vdrm/vdrm.c +index d09df9bb99d..4819219cbc5 100644 +--- a/src/virtio/vdrm/vdrm.c ++++ b/src/virtio/vdrm/vdrm.c +@@ -4,10 +4,40 @@ + */ + + #include "util/u_math.h" ++#include "util/u_wait_log.h" + #include "util/perf/cpu_trace.h" + + #include "vdrm.h" + ++/* Wait logging for the transport: see util/u_wait_log.h for the knobs. ++ * ++ * Every path here can stall the caller on the host, and execbuf holds eb_lock ++ * while it does, which stalls every other thread of this device too. The ++ * split into lock, flush, fence, host and submit time says which. ++ */ ++static const char *const wait_kinds[VDRM_WAIT_COUNT] = { ++ [VDRM_WAIT_LOCK] = "lock", ++ [VDRM_WAIT_FLUSH] = "flush", ++ [VDRM_WAIT_FENCE] = "fence", ++ [VDRM_WAIT_HOST] = "host", ++ [VDRM_WAIT_SUBMIT] = "submit", ++ [VDRM_WAIT_BO] = "bo_wait", ++}; ++ ++static __thread struct u_wait_log_window wait_window; ++ ++void ++vdrm_account_wait(enum vdrm_wait kind, int64_t ns) ++{ ++ u_wait_log_account(&wait_window, "vdrm", wait_kinds, VDRM_WAIT_COUNT, kind, ns); ++} ++ ++static double ++ms(int64_t ns) ++{ ++ return ns / 1e6; ++} ++ + struct vdrm_device * vdrm_virtgpu_connect(int fd, uint32_t context_type); + struct vdrm_device * vdrm_vpipe_connect(uint32_t context_type); + +@@ -114,7 +144,11 @@ vdrm_execbuf(struct vdrm_device *vdev, struct vdrm_execbuf_params *p) + + MESA_TRACE_FUNC(); + ++ bool timed = u_wait_log_enabled(); ++ int64_t t0 = timed ? os_time_get_nano() : 0; ++ + simple_mtx_lock(&vdev->eb_lock); ++ int64_t t_locked = timed ? os_time_get_nano() : 0; + + p->req->seqno = ++vdev->next_seqno; + +@@ -127,6 +161,23 @@ vdrm_execbuf(struct vdrm_device *vdev, struct vdrm_execbuf_params *p) + out_unlock: + simple_mtx_unlock(&vdev->eb_lock); + ++ if (timed) { ++ int64_t t_done = os_time_get_nano(); ++ vdrm_account_wait(VDRM_WAIT_LOCK, t_locked - t0); ++ vdrm_account_wait(VDRM_WAIT_SUBMIT, t_done - t_locked); ++ ++ int64_t slow = u_wait_log_slow_ns(); ++ if (slow && t_done - t0 > slow) { ++ char stamp[16]; ++ u_wait_log_stamp(stamp); ++ mesa_logw("%s vdrm: execbuf cmd %u ring %d took %.1f ms (lock %.1f, submit %.1f; " ++ "%u in-syncobjs, in-fence %s) tid %d", ++ stamp, p->req->cmd, p->ring_idx, ms(t_done - t0), ms(t_locked - t0), ++ ms(t_done - t_locked), p->num_in_syncobjs, ++ p->has_in_fence_fd ? "yes" : "no", gettid()); ++ } ++ } ++ + return ret; + } + +@@ -141,7 +192,11 @@ vdrm_send_req(struct vdrm_device *vdev, struct vdrm_ccmd_req *req, bool sync) + uintptr_t fence = 0; + int ret = 0; + ++ bool timed = u_wait_log_enabled(); ++ int64_t t0 = timed ? os_time_get_nano() : 0; ++ + simple_mtx_lock(&vdev->eb_lock); ++ int64_t t_locked = timed ? os_time_get_nano() : 0; + ret = enqueue_req(vdev, req); + + if (ret || !sync) +@@ -151,16 +206,41 @@ vdrm_send_req(struct vdrm_device *vdev, struct vdrm_ccmd_req *req, bool sync) + + out_unlock: + simple_mtx_unlock(&vdev->eb_lock); ++ int64_t t_flushed = timed ? os_time_get_nano() : 0; + + if (ret) + return ret; + ++ int64_t t_fenced = t_flushed; + if (sync) { + MESA_TRACE_SCOPE("vdrm_execbuf sync"); + vdev->funcs->wait_fence(vdev, fence); ++ if (timed) ++ t_fenced = os_time_get_nano(); + vdrm_host_sync(vdev, req); + } + ++ if (timed) { ++ int64_t t_done = os_time_get_nano(); ++ vdrm_account_wait(VDRM_WAIT_LOCK, t_locked - t0); ++ vdrm_account_wait(VDRM_WAIT_FLUSH, t_flushed - t_locked); ++ if (sync) { ++ vdrm_account_wait(VDRM_WAIT_FENCE, t_fenced - t_flushed); ++ vdrm_account_wait(VDRM_WAIT_HOST, t_done - t_fenced); ++ } ++ ++ int64_t slow = u_wait_log_slow_ns(); ++ if (slow && t_done - t0 > slow) { ++ char stamp[16]; ++ u_wait_log_stamp(stamp); ++ mesa_logw("%s vdrm: %s ccmd %u took %.1f ms (lock %.1f, flush %.1f, " ++ "fence %.1f, host %.1f) tid %d", ++ stamp, sync ? "sync" : "async", req->cmd, ms(t_done - t0), ++ ms(t_locked - t0), ms(t_flushed - t_locked), ++ ms(t_fenced - t_flushed), ms(t_done - t_fenced), gettid()); ++ } ++ } ++ + return 0; + } + +diff --git a/src/virtio/vdrm/vdrm.h b/src/virtio/vdrm/vdrm.h +index fa676896f23..dbb6bfd2441 100644 +--- a/src/virtio/vdrm/vdrm.h ++++ b/src/virtio/vdrm/vdrm.h +@@ -114,6 +114,20 @@ int vdrm_execbuf(struct vdrm_device *vdev, struct vdrm_execbuf_params *p); + + void vdrm_host_sync(struct vdrm_device *vdev, const struct vdrm_ccmd_req *req); + ++/* Kinds of wait the transport accounts for; see util/u_wait_log.h. */ ++enum vdrm_wait { ++ VDRM_WAIT_LOCK, /* waiting for eb_lock */ ++ VDRM_WAIT_FLUSH, /* the flush ioctl, lock held */ ++ VDRM_WAIT_FENCE, /* a sync request's fence */ ++ VDRM_WAIT_HOST, /* the host catching up after the fence */ ++ VDRM_WAIT_SUBMIT, /* the execbuf ioctl, lock held */ ++ VDRM_WAIT_BO, /* a BO wait */ ++ VDRM_WAIT_COUNT, ++}; ++ ++/* Add one wait to this thread's per-second totals (MESA_WAIT_STATS). */ ++void vdrm_account_wait(enum vdrm_wait kind, int64_t ns); ++ + /** + * Import dmabuf fd returning a GEM handle + */ +diff --git a/src/virtio/vdrm/vdrm_virtgpu.c b/src/virtio/vdrm/vdrm_virtgpu.c +index bccd4a5d279..9b9d5dc63c4 100644 +--- a/src/virtio/vdrm/vdrm_virtgpu.c ++++ b/src/virtio/vdrm/vdrm_virtgpu.c +@@ -13,6 +13,7 @@ + #include "drm-uapi/virtgpu_drm.h" + #include "util/libsync.h" + #include "util/log.h" ++#include "util/u_wait_log.h" + #include "util/perf/cpu_trace.h" + + +@@ -213,9 +214,26 @@ virtgpu_bo_wait(struct vdrm_device *vdev, uint32_t handle) + }; + int ret; + ++ bool timed = u_wait_log_enabled(); ++ int64_t t0 = timed ? os_time_get_nano() : 0; ++ + /* Side note, this ioctl is defined as IO_WR but should be IO_W: */ + ret = virtgpu_ioctl(vgdev->fd, VIRTGPU_WAIT, &args); +- if (ret && errno == EBUSY) ++ int err = errno; ++ ++ if (timed) { ++ int64_t took = os_time_get_nano() - t0; ++ vdrm_account_wait(VDRM_WAIT_BO, took); ++ int64_t slow = u_wait_log_slow_ns(); ++ if (slow && took > slow) { ++ char stamp[16]; ++ u_wait_log_stamp(stamp); ++ mesa_logw("%s vdrm: bo_wait handle %u took %.1f ms tid %d", stamp, handle, ++ took / 1e6, gettid()); ++ } ++ } ++ ++ if (ret && err == EBUSY) + return -EBUSY; + + return 0; +-- +2.55.0 + diff --git a/build/patches/proton-ge/0001-kaldi-order-i386-steps-only-when-they-exist.patch b/build/patches/proton-ge/0001-kaldi-order-i386-steps-only-when-they-exist.patch new file mode 100644 index 00000000..2a3fbb2b --- /dev/null +++ b/build/patches/proton-ge/0001-kaldi-order-i386-steps-only-when-they-exist.patch @@ -0,0 +1,26 @@ +kaldi's i386 and x86_64 builds share one source directory, and its cmake +writes generated files into it, so proton-ge serializes the two with plain +ordering rules. Those rules name the i386 steps unconditionally. + +A wow64-only build has no i386-unix in ARCHS, so the i386 steps are never +defined, and make stops with "no rule to make target .kaldi-i386-configure". +Only order against them when they exist. + +diff --git a/Makefile.in b/Makefile.in +index f8d959c..9b92fec 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -573,9 +573,13 @@ $(eval $(call rules-cmake,kaldi,aarch64,unix)) + # the same source directory. + # + # The below assures that only a single configure/build step can run at a time. ++# The i386 steps exist only when i386-unix is in ARCHS; a wow64-only build has ++# none, and ordering against them would ask for a target with no rule. ++ifneq ($(findstring i386-unix,$(ARCHS)),) + $(OBJ)/.kaldi-x86_64-configure: $(OBJ)/.kaldi-i386-configure + $(OBJ)/.kaldi-i386-build: $(OBJ)/.kaldi-x86_64-configure + $(OBJ)/.kaldi-i386-build: $(OBJ)/.kaldi-x86_64-build ++endif + + ## + ## vosk diff --git a/build/patches/proton-ge/0002-protonfixes-fetch-unzip-from-snapshot.debian.org.patch b/build/patches/proton-ge/0002-protonfixes-fetch-unzip-from-snapshot.debian.org.patch new file mode 100644 index 00000000..2d9cabe9 --- /dev/null +++ b/build/patches/proton-ge/0002-protonfixes-fetch-unzip-from-snapshot.debian.org.patch @@ -0,0 +1,22 @@ +protonfixes pins unzip to Debian revision 6.0-29 and fetches it from the +live pool. The pool keeps only current revisions, so once 6.0-31 landed the +download started failing with a 404 and the build with it. + +snapshot.debian.org keeps every revision Debian has published, at a stable +path per timestamp. This is the first snapshot that carries 6.0-29, and the +orig tarball has been in the pool since 2009, so both files resolve there +and are the same bytes the pin always meant. + +diff --git a/protonfixes/Makefile b/protonfixes/Makefile +index 4dcd851..8fab279 100644 +--- a/protonfixes/Makefile ++++ b/protonfixes/Makefile +@@ -146,7 +146,7 @@ DEFINES = -DACORN_FTYPE_NFS -DWILD_STOP_AT_DIR -DLARGE_FILE_SUPPORT \ + -DNO_WORKING_ISPRINT + UNZIP_VERSION := 6.0 + UNZIP_DEBIAN_REVISION := 29 +-UNZIP_BASE_URL := https://deb.debian.org/debian/pool/main/u/unzip ++UNZIP_BASE_URL := https://snapshot.debian.org/archive/debian/20250311T215724Z/pool/main/u/unzip + UNZIP_ORIG_TARBALL := unzip_$(UNZIP_VERSION).orig.tar.gz + UNZIP_DEBIAN_TARBALL := unzip_$(UNZIP_VERSION)-$(UNZIP_DEBIAN_REVISION).debian.tar.xz + UNZIP_DOWNLOAD_DIR := $(OBJDIR)/downloads/unzip diff --git a/build/patches/proton-ge/0003-wine-give-each-pe-arch-its-own-vkd3d-under-wow64.patch b/build/patches/proton-ge/0003-wine-give-each-pe-arch-its-own-vkd3d-under-wow64.patch new file mode 100644 index 00000000..846890b3 --- /dev/null +++ b/build/patches/proton-ge/0003-wine-give-each-pe-arch-its-own-vkd3d-under-wow64.patch @@ -0,0 +1,43 @@ +The x86_64 wine build gets VKD3D_PE_LIBS with a -L to the x86_64 vkd3d +DLLs. That is right while a separate i386 wine build exists and gets its +own value. + +A wow64-only build has no i386 wine build: the x86_64 one builds the i386 +PE side as well, and wine takes one VKD3D_PE_LIBS for every PE arch. The +i386 wined3d.dll then links against the x86_64 libvkd3d-1.dll and fails +with "file format not recognized". ld does not skip a mismatched DLL +named with -l:, so listing both directories does not work either. + +Under wow64, keep the library names in VKD3D_PE_LIBS and pass each arch +its own directory through i386_LDFLAGS and x86_64_LDFLAGS, which wine's +configure already takes per arch. The i386 vkd3d was only ever built +as a dependency of the i386 wine build, so the x86_64 one now waits for it. + +diff --git a/Makefile.in b/Makefile.in +index f8d959c..2ac0b34 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -635,9 +639,23 @@ WINE_aarch64_AUTOCONF_ARGS = \ + WINE_i386_AUTOCONF_ARGS += \ + VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_i386_DST)/include -I$(VKD3D_i386_DST)/include/vkd3d" \ + VKD3D_PE_LIBS="-L$(VKD3D_i386_LIBDIR)/vkd3d/i386-windows -l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll" ++ifneq ($(findstring i386-unix,$(ARCHS)),) + WINE_x86_64_AUTOCONF_ARGS += \ + VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_x86_64_DST)/include -I$(VKD3D_x86_64_DST)/include/vkd3d" \ + VKD3D_PE_LIBS="-L$(VKD3D_x86_64_LIBDIR)/vkd3d/x86_64-windows -l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll" ++else ++# Without i386-unix there is no i386 wine build, and the x86_64 one builds the ++# i386 PE side too (wow64). It links both, but VKD3D_PE_LIBS is one value for ++# every PE arch, so a -L in it would hand the i386 link the x86_64 DLLs. Name ++# the libraries there and give each arch its own directory through its own ++# LDFLAGS instead. ++WINE_x86_64_AUTOCONF_ARGS += \ ++ VKD3D_PE_CFLAGS="-I$(VULKAN_HEADERS_x86_64_DST)/include -I$(VKD3D_x86_64_DST)/include/vkd3d" \ ++ VKD3D_PE_LIBS="-l:libvkd3d-1.dll -l:libvkd3d-shader-1.dll -l:libvkd3d-utils-1.dll" ++WINE_i386-windows_LIBFLAGS = -L$(VKD3D_i386_LIBDIR)/vkd3d/i386-windows ++WINE_x86_64-windows_LIBFLAGS = -L$(VKD3D_x86_64_LIBDIR)/vkd3d/x86_64-windows ++$(OBJ)/.wine-x86_64-build: $(OBJ)/.vkd3d-i386-post-build ++endif + + WINE_DEPENDS = ffmpeg openfst kaldi vosk libxml2 libxkbcommon libpcap + WINE_i386_DEPENDS = vkd3d diff --git a/build/scripts/kernel-build.sh b/build/scripts/kernel-build.sh new file mode 100644 index 00000000..36e31ebe --- /dev/null +++ b/build/scripts/kernel-build.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# Builds the guest kernel and installs it at "${KERNEL_OUTPUT}". Runs on the +# host, as yourself: nothing here needs root. +# +# The tree is CachyOS's fork, taken for its patches rather than its config. +# Theirs is a desktop distro config with thousands of modules; this guest has +# no /lib/modules at all. What must hold is kernel/nestri.fragment, merged onto +# whatever .config the tree has and then verified, so a version bump that +# quietly drops an option fails here instead of in a booted box. +set -euo pipefail + +: "${KERNEL_GIT:?}" +: "${KERNEL_REF:?}" +: "${KERNEL_SRC:?}" +: "${KERNEL_OUTPUT:?}" + +JOBS="${JOBS:-$(nproc)}" +KERNEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../kernel" && pwd)" +FRAGMENT="${KERNEL_DIR}/nestri.fragment" +SEED="${KERNEL_DIR}/base.config" + +# Resolved now, because everything below runs from inside the tree. +mkdir -p "$(dirname "${KERNEL_OUTPUT}")" +KERNEL_OUTPUT="$(cd "$(dirname "${KERNEL_OUTPUT}")" && pwd)/$(basename "${KERNEL_OUTPUT}")" + +# ── Source ────────────────────────────────────────────── +if [[ ! -f "${KERNEL_SRC}/Makefile" ]]; then + echo "kernel: cloning ${KERNEL_REF} into ${KERNEL_SRC}" + mkdir -p "$(dirname "${KERNEL_SRC}")" + git clone --depth=1 --branch "${KERNEL_REF}" "${KERNEL_GIT}" "${KERNEL_SRC}" +else + have="$(git -C "${KERNEL_SRC}" describe --tags --exact-match 2>/dev/null || echo unknown)" + if [[ "${have}" != "${KERNEL_REF}" ]]; then + # Not fatal: a bisect or a local patch is a legitimate reason to be off + # the pinned ref, and silently checking it out would throw that away. + echo "kernel: tree is at '${have}', KERNEL_REF pins '${KERNEL_REF}'; building what is there" >&2 + fi +fi + +cd "${KERNEL_SRC}" + +# ── Infinity scheduler (experimental) ─────────────────── +# Applied once per tree, and recorded, because `patch -N` on an already patched +# tree does not skip cleanly: it rejects every hunk. The whole series goes in or +# none of it does -- upstream is explicit that a partial series misbehaves -- so +# every patch is dry-run against the stacked result before any is applied. +# +# In this guest only the fair and rt halves do anything. virtio-gpu does not +# use the DRM scheduler, so the gpu patch is compiled out with the rest of +# drivers/gpu/drm/scheduler; it is applied anyway to keep the series whole. +if [[ -n "${KERNEL_INFINITY:-}" ]]; then + : "${INFINITY_GIT:?}" "${INFINITY_REV:?}" "${INFINITY_SERIES:?}" "${INFINITY_WORK:?}" + stamp=".nestri-infinity-rev" + + if [[ "$(git -C "${INFINITY_WORK}" rev-parse HEAD 2>/dev/null)" != "${INFINITY_REV}" ]]; then + echo "kernel: fetching infinity-sched ${INFINITY_REV}" + rm -rf "${INFINITY_WORK}" + git init -q "${INFINITY_WORK}" + git -C "${INFINITY_WORK}" fetch -q --depth=1 "${INFINITY_GIT}" "${INFINITY_REV}" + git -C "${INFINITY_WORK}" checkout -q FETCH_HEAD + fi + series_dir="${INFINITY_WORK}/${INFINITY_SERIES}" + [[ -f "${series_dir}/series" ]] || { + echo "kernel: infinity-sched has no series at ${INFINITY_SERIES} for ${KERNEL_REF}" >&2 + exit 1 + } + + have="$(cat "${stamp}" 2>/dev/null || true)" + if [[ "${have}" == "${INFINITY_REV}" ]]; then + echo "kernel: infinity series already applied" + elif [[ -n "${have}" ]]; then + echo "kernel: tree carries infinity ${have}, INFINITY_REV pins ${INFINITY_REV}" >&2 + echo "kernel: start the tree over with \`make kernel-clean\`" >&2 + exit 1 + else + if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "kernel: ${KERNEL_SRC} has local changes; not applying the series over them" >&2 + exit 1 + fi + mapfile -t patches < <(grep -v '^[[:space:]]*\(#\|$\)' "${series_dir}/series") + # git apply --check takes the whole list and checks each patch against + # the result of the ones before it, which a per-file `patch --dry-run` + # cannot do. + git apply --check "${patches[@]/#/${series_dir}/}" + for p in "${patches[@]}"; do + echo "kernel: applying ${p}" + # -F 0: zero fuzz. Offsets are fine; a hunk that only fits + # approximately is a scheduler change landing somewhere it was not + # written for. + patch -p1 -N -F 0 --quiet < "${series_dir}/${p}" + done + echo "${INFINITY_REV}" > "${stamp}" + fi +fi + +# ── Config ────────────────────────────────────────────── +# A fresh tree has no .config. The seed is a known-good minimal config that +# olddefconfig migrates to whatever version the tree is at; it only saves a +# fresh tree from `make defconfig`, whose driver set is enormous next to what a +# microVM needs. An existing .config is always preferred. +if [[ ! -f .config ]]; then + echo "kernel: seeding .config from kernel/base.config" + cp "${SEED}" .config +fi + +echo "kernel: merging kernel/nestri.fragment" +# -m merges without running a config target, so olddefconfig resolves +# dependencies once, in one place. +./scripts/kconfig/merge_config.sh -m .config "${FRAGMENT}" >/dev/null +make olddefconfig >/dev/null + +# ── Verify the fragment actually took ─────────────────── +# merge_config.sh warns about overridden symbols but exits 0, and olddefconfig +# will happily drop an option whose dependencies are unmet. Neither is loud +# enough for a setting whose failure mode is silent audio, so check the result +# rather than the intent. Both halves count: an option that must be on, and one +# that must be off. +missing=() +total=0 +while read -r want; do + total=$((total + 1)) + case "${want}" in + CONFIG_*) grep -qx "${want}" .config || missing+=("${want%%=*}") ;; + "# "*) grep -qx "${want}" .config || missing+=("${want:2} (must be off)") ;; + esac +done < <(grep -E '^(CONFIG_[A-Z0-9_]+=|# CONFIG_[A-Z0-9_]+ is not set)' "${FRAGMENT}" \ + | sed -E 's/^(CONFIG_[A-Z0-9_]+=[^[:space:]#]+)[[:space:]]*#.*/\1/') + +if (( ${#missing[@]} )); then + echo "kernel: these fragment entries did not survive olddefconfig:" >&2 + printf '%s\n' "${missing[@]}" >&2 + exit 1 +fi +echo "kernel: all ${total} fragment entries hold" + +# ── Build ─────────────────────────────────────────────── +# -march goes in through KCFLAGS because mainline has no Kconfig for +# microarchitecture levels. It is safe for the kernel even at x86-64-v3: +# arch/x86/Makefile passes -mno-sse -mno-mmx -mno-sse2 -mno-avx and friends, +# and gcc applies those as a mask over -march regardless of flag order, so the +# kernel gets v3's integer ISA (BMI2, LZCNT, MOVBE) and its scheduling model +# and never touches a vector register. +make_args=() +if [[ -n "${KERNEL_MARCH:-}" ]]; then + make_args+=("KCFLAGS=-march=${KERNEL_MARCH}") + echo "kernel: building with -march=${KERNEL_MARCH}" +fi + +# vmlinux, not bzImage: the guest is booted by an ELF loader with no +# bootloader in the path, and a bzImage is a self-decompressing image behind a +# real-mode setup header, not an ELF. +make -j"${JOBS}" "${make_args[@]}" vmlinux + +cp vmlinux "${KERNEL_OUTPUT}" +echo "kernel: installed ${KERNEL_OUTPUT} ($(numfmt --to=iec "$(stat -c %s "${KERNEL_OUTPUT}")"))" diff --git a/build/scripts/proton-build.sh b/build/scripts/proton-build.sh index 2e576157..d24e3b47 100755 --- a/build/scripts/proton-build.sh +++ b/build/scripts/proton-build.sh @@ -1,96 +1,150 @@ #!/usr/bin/env bash -# Builds proton-cachyos from the tree proton-fetch.sh laid down. Container-only. +# Builds proton-ge wow64-only and leaves the finished tree in "${PROTON_WORK}/obj/dist". +# Runs on the host, not in a container. # -# The one thing that matters here is --enable-wow64: it builds wine so that -# 32-bit Windows code runs inside a 64-bit unix process, thunking down to the -# 64-bit host libraries. Without it, Proton needs a complete 32-bit host stack — -# lib32 glibc, a second Mesa built for i686, and a second nescapture layer, -# because a 32-bit game would load the 32-bit Vulkan loader and our 64-bit -# capture layer would be invisible to it. With it, none of that exists. +# It has to run on the host because proton-ge's build is itself container-driven: +# `make` runs outside, and every step runs in the Steam Runtime SDK image, where +# the toolchains live, through the engine it is configured with. There is no +# mode without a container, and a container engine inside `podman build` is +# nested containers, which is a lot of fragile setup for no gain. So the only +# thing this script needs from the host is git, make and the engine. The +# Makefile packages the result afterwards. # -# The cost is that the distro package cannot be used: proton-cachyos-native is -# packaged without the flag, which is exactly why it depends on lib32-*. +# The one thing we change is the arch list: it becomes wow64-only, and that +# change is the reason this is our own build and not a download. wow64 runs +# 32-bit Windows code inside a 64-bit unix process. Without it Proton needs a +# complete 32-bit host stack: lib32 glibc, a second Mesa built for i686, and a +# second nescapture layer, because a 32-bit game would load the 32-bit Vulkan +# loader and our 64-bit capture layer would be invisible to it. The released +# builds carry an i386 unix side, which is exactly why they need lib32-*. +# +# Everything else is proton-ge's own recipe: the same SDK image, the same flags +# and the same patch set. The one addition is patches/proton-ge/: fixes for the +# places its makefile assumes a 32-bit unix side that wow64 does not have, and +# for things a tag pinned that have since moved out from under it. set -euo pipefail -: "${GECKO_VER:?}" -: "${MONO_VER:?}" +: "${PROTON_GIT:?}" +: "${PROTON_TAG:?}" +: "${PROTON_WORK:?}" +: "${BUILD_NAME:?}" +ENGINE="${CONTAINER_ENGINE:-podman}" JOBS="${JOBS:-$(nproc)}" -BUILD_NAME="proton-cachyos" -SRC_DIR="/build/proton-cachyos" -BUILD_DIR="/build/build" -OUT_DIR="/artifacts/proton/usr/share/steam/compatibilitytools.d/${BUILD_NAME}" -[[ -d "${SRC_DIR}" ]] || { echo "no source tree — proton-fetch.sh did not run"; exit 1; } +mkdir -p "${PROTON_WORK}" +PROTON_WORK="$(cd "${PROTON_WORK}" && pwd)" +SRC="${PROTON_WORK}/src" +OBJ="${PROTON_WORK}/obj" +STAMP_TAG="${PROTON_WORK}/.tag" +STAMP_PATCHED="${PROTON_WORK}/.patched" +PATCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../patches/proton-ge" && pwd)" -# ── Toolchain wrappers ────────────────────────────────── -# Proton's build calls the compiler by GNU triplet. Arch's gcc does not install -# under those names, so stand in for them. The i686 set is generated too: with -# wow64 nothing should reach for it, and if something does, failing on a missing -# 32-bit header beats silently building a 32-bit unix library we then have to -# ship libraries for. -WRAP=/build/wrappers -rm -rf "$WRAP" && mkdir -p "$WRAP" -_wrappers() { - local arch="$1" gccflag="$2" ldflag="$3" asflag="$4" stripfmt="$5" - local l t - for l in ar ranlib nm; do - ln -sf "/usr/bin/gcc-${l}" "${WRAP}/${arch}-pc-linux-gnu-${l}" +# Two builds in one tree do not fail cleanly. They race on the same objects and +# leave half-written files that a later build trusts. A failed make also keeps +# running its in-flight jobs for a while after it reports the error, so the +# first build is often still running when the second one starts. +exec 9>"${PROTON_WORK}/.lock" +flock -n 9 || { echo "proton: another build is using ${PROTON_WORK}" >&2; exit 1; } + +# ccache and cargo's downloads are kept outside src/ and obj/, so a new tag or +# FORCE_REBUILD throws away the build and keeps the parts that are correct to +# reuse. proton-ge's makefile mounts both into the container from these +# variables. +export CCACHE_DIR="${PROTON_WORK}/ccache" +export CARGO_HOME="${PROTON_WORK}/cargo" +mkdir -p "${CCACHE_DIR}" "${CARGO_HOME}" + +if [[ -n "${FORCE_REBUILD:-}" || "$(cat "${STAMP_TAG}" 2>/dev/null)" != "${PROTON_TAG}" ]]; then + echo "proton: fresh tree for ${PROTON_TAG}" + rm -rf "${SRC}" "${OBJ}" "${STAMP_TAG}" "${STAMP_PATCHED}" +fi + +# ── Fetch ─────────────────────────────────────────────── +if [[ ! -e "${STAMP_TAG}" ]]; then + rm -rf "${SRC}" + git clone --branch "${PROTON_TAG}" --depth=1 "${PROTON_GIT}" "${SRC}" + # No --depth here: submodules are pinned to commits that are often not a + # branch tip. --filter=tree:0 keeps the download down instead. + git -C "${SRC}" submodule update --init --filter=tree:0 --recursive + echo "${PROTON_TAG}" > "${STAMP_TAG}" +fi + +# The SDK image is pinned by proton-ge's own makefile, per tag. Asking it keeps +# the patch step below and the build on the same image. +SDK_IMAGE="$(make --silent --no-print-directory -f "${SRC}/Makefile.in" \ + SRCDIR="${SRC}" get-steamrt-image)" + +# ── Patch ─────────────────────────────────────────────── +# The patch script edits the tree in place and is not idempotent: it resets +# some submodules first and not others. So a tree is patched once, and one that +# was interrupted halfway is reset to the commits the tag pins before trying +# again. +# +# It is run in the SDK image rather than on the host, so it does not depend on +# the host's python, patch or wget. +# +# The script carries on past a patch that does not apply and exits 0 anyway. +# The upstream instructions are to grep its output for failures, so that is +# what happens here. The alternative is an image that looks fine and is missing +# a fix. +# +# A build tree does not survive its source being re-patched. Changing +# Makefile.in re-syncs every component's source copy, but a component's +# configure step depends on that sync order-only, so it does not rerun, and its +# old build directory is left pointing at generated autotools files the sync +# just removed. So patching starts obj/ over too. ccache keeps that cheap. +if [[ ! -e "${STAMP_PATCHED}" ]]; then + rm -rf "${OBJ}" + git -C "${SRC}" reset -q --hard + git -C "${SRC}" submodule foreach -q --recursive 'git reset -q --hard && git clean -qfdx' + "${ENGINE}" run --rm -v "${SRC}:${SRC}" -w "${SRC}" "${SDK_IMAGE}" \ + ./patches/protonprep-valve-staging.sh 2>&1 | tee "${PROTON_WORK}/patch.log" + if grep -Ei 'hunk .* failed|saving rejects|can.t find file|malformed patch|skipping patch|^error' \ + "${PROTON_WORK}/patch.log"; then + echo "proton: patches did not apply cleanly, see ${PROTON_WORK}/patch.log" >&2 + exit 1 + fi + # Ours go on top. They are paths from the root of the tree, submodules + # included. `git apply` fails outright on a patch that no longer applies, + # which is what a tag bump should do: each one says why it exists, so the + # question is only whether upstream fixed it. + for p in "${PATCH_DIR}"/*.patch; do + [[ -e "$p" ]] || continue + echo "proton: applying $(basename "$p")" + git -C "${SRC}" apply "$p" done - for t in gcc g++; do - printf '#!/usr/bin/bash\n/usr/bin/%s %s "$@"\n' "$t" "$gccflag" \ - > "${WRAP}/${arch}-pc-linux-gnu-${t}" - chmod 755 "${WRAP}/${arch}-pc-linux-gnu-${t}" - done - printf '#!/usr/bin/bash\n/usr/bin/ld %s "$@"\n' "$ldflag" > "${WRAP}/${arch}-pc-linux-gnu-ld" - printf '#!/usr/bin/bash\n/usr/bin/as %s "$@"\n' "$asflag" > "${WRAP}/${arch}-pc-linux-gnu-as" - printf '#!/usr/bin/bash\n/usr/bin/strip -F %s "$@"\n' "$stripfmt" > "${WRAP}/${arch}-pc-linux-gnu-strip" - chmod 755 "${WRAP}/${arch}-pc-linux-gnu-"{ld,as,strip} -} -_wrappers x86_64 "-m64" "-melf_x86_64" "--64" "elf64-x86-64" -_wrappers i686 "-m32" "-melf_i386" "--32" "elf32-i386" -export PATH="${WRAP}:${PATH}" + touch "${STAMP_PATCHED}" +fi + +# ── Configure ─────────────────────────────────────────── +# configure.sh refuses an in-tree build, and it test-runs the SDK image to work +# out how the engine maps file ownership, so it is also where a broken engine +# setup shows up first. +mkdir -p "${OBJ}" +if [[ ! -e "${OBJ}/Makefile" ]]; then + (cd "${OBJ}" && "${SRC}/configure.sh" \ + --build-name="${BUILD_NAME}" \ + --container-engine="${ENGINE}") +fi # ── Build ─────────────────────────────────────────────── -# -march=nocona matches the distro packaging: Proton has to run on whatever CPU -# the guest is given, and the VMM does not promise a feature level. -export CFLAGS="-O3 -march=nocona -mtune=core-avx2" -export CXXFLAGS="${CFLAGS}" -export RUSTFLAGS="-C opt-level=3 -C target-cpu=nocona" -export LDFLAGS="-Wl,-O1,--sort-common,--as-needed" -export RUSTUP_TOOLCHAIN=stable +# ARCHS drops i386-unix, which leaves wine configured for x86_64 unix with an +# i386 PE side. That is wow64. Every component rule is gated on ARCHS, so the +# 32-bit unix builds of everything else go with it. ENABLE_WOW64 makes the +# proton script ask wine for a wow64 prefix. proton-ge ships it as a switch +# but never turns it on. +# +# A command-line variable reaches the container build too: the outer make +# hands its overrides to the inner one. +# +# SOURCE_DATE_EPOCH is the tag's commit time rather than now, so two builds of +# one tag stamp the same dates into their output. +make -C "${OBJ}" \ + J="${JOBS}" \ + ARCHS="i386-windows x86_64-windows x86_64-unix" \ + ENABLE_WOW64=1 \ + SOURCE_DATE_EPOCH="$(git -C "${SRC}" log -1 --format=%ct)" \ + dist -mkdir -p "${BUILD_DIR}" -cd "${BUILD_DIR}" - -ROOTLESS_CONTAINER="" \ -"${SRC_DIR}/configure.sh" \ - --container-engine="none" \ - --proton-sdk-image="" \ - --build-name="${BUILD_NAME}" \ - --without-extras=all \ - --without-vklayers=all \ - --without-steamrt-depends \ - --without-tts \ - --without-nvidia-libs \ - --enable-wow64 - -# The top-level make is serial by design; SUBJOBS is what it hands to each -# component's build. -SUBJOBS="${JOBS}" make -j1 dist - -# ── Install ───────────────────────────────────────────── -mkdir -p "${OUT_DIR}" -cp -a "${BUILD_DIR}/dist/." "${OUT_DIR}/" - -# Debug symbols in the bundled PE runtimes are dead weight in a guest image. -cd "${OUT_DIR}/files" -find "share/wine/gecko/wine-gecko-${GECKO_VER}-x86" -name '*.dll' -o -name '*.exe' 2>/dev/null \ - | xargs -r i686-w64-mingw32-strip --strip-debug 2>/dev/null || true -find "share/wine/gecko/wine-gecko-${GECKO_VER}-x86_64" -name '*.dll' -o -name '*.exe' 2>/dev/null \ - | xargs -r x86_64-w64-mingw32-strip --strip-debug 2>/dev/null || true -find "share/wine/mono/wine-mono-${MONO_VER}" -name '*.dll' -o -name '*.exe' 2>/dev/null \ - | xargs -r i686-w64-mingw32-strip --strip-debug 2>/dev/null || true - -rm -rf "${BUILD_DIR}" -echo "proton: installed to ${OUT_DIR}" +echo "proton: built ${OBJ}/dist" diff --git a/build/scripts/proton-fetch.sh b/build/scripts/proton-fetch.sh deleted file mode 100755 index 3b0cfad4..00000000 --- a/build/scripts/proton-fetch.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# Fetches proton-cachyos' source and its bundled runtimes. Container-only. -# -# Deliberately its own script, and its own layer: the submodule checkout runs -# well past ten minutes, and it must not be redone every time a build flag or a -# missing dependency changes. Keep everything that can fail *fast* in -# proton-build.sh instead. -set -euo pipefail - -: "${PROTON_GIT:?}" -: "${PROTON_TAG:?}" -: "${GECKO_VER:?}" -: "${MONO_VER:?}" -: "${XALIA_VER:?}" - -SRC_DIR="/build/proton-cachyos" - -git clone --branch "${PROTON_TAG}" --depth=1 "${PROTON_GIT}" "${SRC_DIR}" -cd "${SRC_DIR}" -# Relative submodule paths resolve against origin, so it has to be the real URL -# even though we cloned by tag. -git remote set-url origin "${PROTON_GIT}" -# No --depth here: submodules are pinned to commits that are often not a branch -# tip. --filter=tree:0 keeps the download down instead. -git submodule update --init --filter=tree:0 --recursive - -# Still needed with wow64: these are PE-side, and a 32-bit Windows program wants -# the 32-bit gecko and mono regardless of how wine is built. -mkdir -p contrib -for url in \ - "https://dl.winehq.org/wine/wine-gecko/${GECKO_VER}/wine-gecko-${GECKO_VER}-x86.tar.xz" \ - "https://dl.winehq.org/wine/wine-gecko/${GECKO_VER}/wine-gecko-${GECKO_VER}-x86_64.tar.xz" \ - "https://github.com/madewokherd/wine-mono/releases/download/wine-mono-${MONO_VER}/wine-mono-${MONO_VER}-x86.tar.xz" \ - "https://github.com/madewokherd/xalia/releases/download/xalia-${XALIA_VER}/xalia-${XALIA_VER}-net48-mono.zip" \ -; do - curl -fL --retry 3 -o "contrib/$(basename "$url")" "$url" -done - -# Proton's cargo rule runs `cargo build --locked --offline`, so every crate has -# to be in CARGO_HOME before the build starts — including the git dependencies, -# which is what the "you are in the offline mode" failure is really saying. The -# error names a URL that is perfectly reachable; the build simply refuses to go -# out and get it. -# -# gst-plugins-rs is the only cargo component in the tree. Both targets are -# fetched: wow64 should mean nothing builds the i386 unix side, but a fetch is -# metadata only and costs almost nothing next to being wrong about that. -# -# CARGO_HOME is left at its default so it lands in this layer and the build -# layer inherits it. -export CARGO_NET_GIT_FETCH_WITH_CLI=true -export RUSTUP_TOOLCHAIN=stable -cd "${SRC_DIR}/gst-plugins-rs" -cargo fetch --locked --target x86_64-unknown-linux-gnu -cargo fetch --locked --target i686-unknown-linux-gnu - -echo "proton: source at ${SRC_DIR}" diff --git a/crates/nesprotocol/src/delay.rs b/crates/nesprotocol/src/delay.rs new file mode 100644 index 00000000..e7176c91 --- /dev/null +++ b/crates/nesprotocol/src/delay.rs @@ -0,0 +1,327 @@ +//! How much a delay varies, measured the same way at both ends. +//! +//! # What this measures +//! +//! Every frame carries the timestamp the sender stamped on it at capture. The +//! two clocks are unrelated, so the difference between that and the moment the +//! frame arrives here is meaningless on its own -- it contains an unknown, +//! roughly constant offset. Its *variation* is not meaningless, and variation is +//! the only thing a playout buffer exists to absorb: a path that delivers every +//! frame exactly 300 ms late needs no buffer at all, while one that alternates +//! between 10 ms and 60 ms needs 50 ms whatever its average. +//! +//! So this tracks the smallest difference seen recently -- the best the path has +//! managed, which stands in for the unknown offset -- and reports how far above +//! it each frame lands. +//! +//! # What it deliberately does not do +//! +//! It does not distinguish the sender's contribution from the network's. The +//! sender reports its own pipeline delay separately, and the two are compared +//! rather than subtracted: a frame that was late because the encoder stalled +//! should not raise a buffer, because buffering is latency spent hiding a fault +//! that should be fixed instead. +//! +//! # Why this is shared +//! +//! Both ends measure a delay and the two are compared: the hub reports how much +//! its own pipeline varied before a frame left, the client reports how much the +//! total varied by the time it arrived. A comparison between two different +//! measures would be meaningless, so there is one measure, defined once. +//! +//! It also has to be *variation* on both sides rather than absolute delay, +//! because neither side can know the absolute. The hub's `timestamp_ms` counts +//! from the encoder's own start, not from any epoch, so even on one machine the +//! difference to wall clock contains an unknown constant -- and across two +//! machines there is no shared clock at all. +//! +//! Nothing here adapts anything yet. It measures, so that the decision about +//! what to adapt is made against a distribution rather than an intuition. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// How long a best-case observation stays authoritative. +/// +/// The minimum has to expire. Paths change -- a relay is dropped for a direct +/// connection, a phone moves between cells -- and a minimum from the old path +/// makes every frame on the new one look permanently late, which would pin a +/// buffer at a size nothing needs. Long enough that an ordinary quiet spell does +/// not reset the baseline, short enough that a genuine change is noticed. +const BASELINE_WINDOW: Duration = Duration::from_secs(30); + +/// Ceiling on retained observations, so a stalled consumer cannot grow this. +const MAX_SAMPLES: usize = 4096; + +/// One second's worth of lateness, summarised. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DelaySummary { + /// Frames measured. + pub frames: u32, + /// Median lateness above the best the path has managed, in milliseconds. + pub p50_ms: u16, + /// The 95th percentile of the same. + pub p95_ms: u16, + /// The worst single frame. + pub max_ms: u16, +} + +/// Tracks arrival lateness for one media stream. +#[derive(Debug)] +pub struct DelayTracker { + /// Best-case delay observations, each with when it was taken, oldest first. + /// + /// A deque rather than a single value because a minimum that can only fall + /// never recovers from a path that improved, and one that is simply reset on + /// a timer throws away a good baseline for no reason. Holding the recent + /// candidates lets the oldest expire while a better one is still standing. + baseline: VecDeque<(Instant, i32)>, + /// Lateness of each frame this second, in milliseconds. + samples: Vec, + /// The first delay seen, as an anchor for every later one. + /// + /// Delays are compared *relative to this*, in wrapping arithmetic. Both + /// clocks are unrelated and the sender's stamp is a `u32` of milliseconds + /// that wraps every 49 days, so an absolute subtraction is a number with no + /// meaning and a wrap in the middle of a session makes it jump by 2^32 -- + /// which, taken as lateness, reads as every frame being weeks late until the + /// baseline expires. Anchoring and then interpreting the difference as + /// signed makes the wrap a non-event, because the quantity that matters is + /// only ever a few hundred milliseconds wide. + anchor: Option, +} + +impl Default for DelayTracker { + fn default() -> Self { + Self::new() + } +} + +impl DelayTracker { + pub fn new() -> Self { + Self { + baseline: VecDeque::new(), + samples: Vec::new(), + anchor: None, + } + } + + /// Record one frame's arrival. + /// + /// `stamp_ms` is the timestamp the frame carries; `observed_ms` is the local + /// clock at the point being measured -- arrival, for a receiver; the moment + /// the frame is handed to the transport, for a sender. See [`anchor`](Self::anchor) for why the difference is taken + /// in wrapping arithmetic rather than widened. + pub fn observe(&mut self, stamp_ms: u32, observed_ms: u64, now: Instant) { + let raw = (observed_ms as u32).wrapping_sub(stamp_ms); + let anchor = *self.anchor.get_or_insert(raw); + let delay = raw.wrapping_sub(anchor) as i32; + self.expire(now); + + // Anything at or below the running minimum becomes the new baseline, and + // supersedes the candidates it beats -- they can only be worse, so + // keeping them would let a stale, higher value resurface on expiry. + while self.baseline.back().is_some_and(|(_, d)| *d >= delay) { + self.baseline.pop_back(); + } + self.baseline.push_back((now, delay)); + + let best = self.baseline.front().map(|(_, d)| *d).unwrap_or(delay); + let lateness = i64::from(delay) + .saturating_sub(i64::from(best)) + .clamp(0, i64::from(u16::MAX)) as u16; + if self.samples.len() < MAX_SAMPLES { + self.samples.push(lateness); + } + } + + /// Summarise and clear the frames seen since the last call. + /// + /// Returns `None` for a second in which nothing arrived. That is not the + /// same as a second with no lateness, and reporting zero would say the path + /// is behaving perfectly at the moment it has stopped delivering anything. + pub fn take(&mut self) -> Option { + if self.samples.is_empty() { + return None; + } + self.samples.sort_unstable(); + let at = |q: f64| self.samples[((self.samples.len() - 1) as f64 * q) as usize]; + let summary = DelaySummary { + frames: self.samples.len() as u32, + p50_ms: at(0.5), + p95_ms: at(0.95), + max_ms: self.samples[self.samples.len() - 1], + }; + self.samples.clear(); + Some(summary) + } + + /// Forget the baseline entirely. + /// + /// For a path change, where the old best case says nothing about the new + /// path and keeping it would make every frame look late until it expired. + pub fn reset_baseline(&mut self) { + self.baseline.clear(); + } + + fn expire(&mut self, now: Instant) { + while self + .baseline + .front() + .is_some_and(|(seen, _)| now.duration_since(*seen) > BASELINE_WINDOW) + { + self.baseline.pop_front(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A frame sent at `ts_ms` arriving `transit` milliseconds later, where the + /// receiver's clock is offset from the sender's by an arbitrary amount. + const OFFSET: u64 = 1_700_000_000_000; + + fn at(j: &mut DelayTracker, ts_ms: u32, transit: u64, now: Instant) { + j.observe(ts_ms, OFFSET + u64::from(ts_ms) + transit, now); + } + + #[test] + fn a_constant_delay_is_not_jitter() { + // The point of the whole measure. A path that delivers every frame + // exactly 300 ms late needs no buffer at all; only variation does. + let mut j = DelayTracker::new(); + let now = Instant::now(); + for i in 0..120u32 { + at( + &mut j, + i * 16, + 300, + now + Duration::from_millis(u64::from(i) * 16), + ); + } + let s = j.take().expect("frames arrived"); + assert_eq!(s.max_ms, 0, "a constant offset was read as lateness"); + assert_eq!(s.frames, 120); + } + + #[test] + fn lateness_is_measured_against_the_best_the_path_managed() { + let mut j = DelayTracker::new(); + let now = Instant::now(); + at(&mut j, 0, 20, now); + at(&mut j, 16, 70, now + Duration::from_millis(16)); + at(&mut j, 32, 20, now + Duration::from_millis(32)); + let s = j.take().expect("frames arrived"); + assert_eq!( + s.max_ms, 50, + "the 70 ms frame is 50 ms above the 20 ms best" + ); + } + + #[test] + fn a_silent_second_reports_nothing_rather_than_no_jitter() { + // Zero would say the path is behaving perfectly at the moment it has + // stopped delivering anything at all. + let mut j = DelayTracker::new(); + assert_eq!(j.take(), None); + } + + #[test] + fn the_baseline_expires_so_an_improved_path_is_noticed() { + // A minimum that can only fall never recovers: one lucky early frame + // would make every later frame look late for the rest of the session. + let mut j = DelayTracker::new(); + let now = Instant::now(); + at(&mut j, 0, 10, now); + j.take(); + + // Much later, the path settles at a steady 200 ms. + let later = now + BASELINE_WINDOW + Duration::from_secs(1); + for i in 0..10u32 { + at( + &mut j, + 1000 + i * 16, + 200, + later + Duration::from_millis(u64::from(i) * 16), + ); + } + let s = j.take().expect("frames arrived"); + assert_eq!( + s.max_ms, 0, + "a stale best case from a previous path made a steady path look late", + ); + } + + #[test] + fn a_better_observation_supersedes_worse_ones_still_in_the_window() { + // Otherwise a higher candidate resurfaces when the better one expires, + // and the baseline walks upwards for no reason the path can account for. + let mut j = DelayTracker::new(); + let now = Instant::now(); + at(&mut j, 0, 90, now); + at(&mut j, 16, 10, now + Duration::from_millis(16)); + // The 90 ms candidate is gone, so this sits 40 ms above the 10 ms best. + at(&mut j, 32, 50, now + Duration::from_millis(32)); + let s = j.take().expect("frames arrived"); + assert_eq!(s.max_ms, 40); + } + + #[test] + fn a_reset_forgets_the_old_path_entirely() { + let mut j = DelayTracker::new(); + let now = Instant::now(); + at(&mut j, 0, 10, now); + j.take(); + j.reset_baseline(); + at(&mut j, 16, 400, now + Duration::from_millis(16)); + let s = j.take().expect("frames arrived"); + assert_eq!(s.max_ms, 0, "the new path was judged against the old one"); + } + + #[test] + fn the_tail_is_kept_apart_from_the_middle() { + // The number that sizes a buffer is the tail. A run of prompt frames + // with one bad one must not average into "slightly late". + let mut j = DelayTracker::new(); + let now = Instant::now(); + for i in 0..99u32 { + at( + &mut j, + i * 16, + 10, + now + Duration::from_millis(u64::from(i) * 16), + ); + } + at(&mut j, 99 * 16, 260, now + Duration::from_millis(99 * 16)); + let s = j.take().expect("frames arrived"); + assert_eq!(s.p50_ms, 0); + assert_eq!(s.max_ms, 250); + } + + #[test] + fn taking_clears_so_each_answer_describes_one_second() { + let mut j = DelayTracker::new(); + let now = Instant::now(); + at(&mut j, 0, 10, now); + assert!(j.take().is_some()); + assert_eq!( + j.take(), + None, + "a second reported the previous second again" + ); + } + + #[test] + fn a_wrapped_sender_timestamp_does_not_poison_the_baseline() { + // ts_ms wraps every 49 days. A negative delay taken as the best case + // would make every subsequent frame appear weeks late. + let mut j = DelayTracker::new(); + let now = Instant::now(); + j.observe(u32::MAX - 10, OFFSET, now); + j.observe(5, OFFSET + 16, now + Duration::from_millis(16)); + let s = j.take().expect("frames arrived"); + assert!(s.max_ms < u16::MAX, "a wrap produced a nonsense lateness"); + } +} diff --git a/crates/nesprotocol/src/lib.rs b/crates/nesprotocol/src/lib.rs index f4bfd1ec..b1eaaa1c 100644 --- a/crates/nesprotocol/src/lib.rs +++ b/crates/nesprotocol/src/lib.rs @@ -3,13 +3,64 @@ // can drift from each other silently. pub mod datagram; +pub mod delay; pub mod input; #[cfg(feature = "lifecycle")] pub mod lifecycle; pub mod reliable; pub mod stats; -pub const ALPN: &[u8] = b"/nestri/stream/1"; +/// One connection per kind of traffic, not one connection for everything. +/// +/// A QUIC connection is the unit that congestion control, pacing and the +/// datagram send buffer all operate on, so everything sharing one shares a +/// queue. Measured over a 1000-mile link: a video backlog delayed audio and +/// input with it, because a backlog is a property of the connection and video +/// is the only flow large enough to build one. Audio behind a deep video queue +/// was twenty-five times worse for timing than audio behind a shallow one -- +/// the same audio, on the same path, ruined by what it was queued behind. +/// +/// Splitting them gives each its own congestion controller and its own send +/// buffer, so video can only ever delay video. They compete at a shared +/// bottleneck rather than cooperating, which is the point: audio and input are +/// small and need a share, not a place in line behind a keyframe. +pub const ALPN_VIDEO: &[u8] = b"/nestri/video/1"; +pub const ALPN_AUDIO: &[u8] = b"/nestri/audio/1"; +pub const ALPN_INPUT: &[u8] = b"/nestri/input/1"; +pub const ALPN_CONTROL: &[u8] = b"/nestri/control/1"; + +/// Every ALPN a hub accepts, for the endpoint builder. +pub const ALPNS: [&[u8]; 4] = [ALPN_VIDEO, ALPN_AUDIO, ALPN_INPUT, ALPN_CONTROL]; + +/// Which connection an accepted one is, by its ALPN. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Carrier { + Video, + Audio, + Input, + Control, +} + +impl Carrier { + pub fn from_alpn(alpn: &[u8]) -> Option { + match alpn { + a if a == ALPN_VIDEO => Some(Self::Video), + a if a == ALPN_AUDIO => Some(Self::Audio), + a if a == ALPN_INPUT => Some(Self::Input), + a if a == ALPN_CONTROL => Some(Self::Control), + _ => None, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Video => "video", + Self::Audio => "audio", + Self::Input => "input", + Self::Control => "control", + } + } +} pub const IPC_MAGIC: [u8; 4] = [b'N', b'S', b'T', b'R']; @@ -38,8 +89,21 @@ pub const FRAME_HDR_LEN: usize = 7; /// For single-purpose streams (video, audio) the type is redundant but included /// for uniformity. pub const MSG_DATA: u8 = 0; // generic data frame (video / audio) -pub const MSG_IDR_REQUEST: u8 = 0x10; // request a keyframe (desktop → hub → hudless) -pub const MSG_ENCODE_SETTINGS: u8 = 0x12; // change encoder settings (desktop → hub → hudless) +pub const MSG_IDR_REQUEST: u8 = 0x10; // request a keyframe (desktop → hub → nescapture) +pub const MSG_ENCODE_SETTINGS: u8 = 0x12; // change encoder settings (desktop → hub → nescapture) +pub const MSG_CLIENT_CAPS: u8 = 0x15; // what the client can decode (desktop → hub → nescapture) +pub const MSG_SURFACE_COLOR: u8 = 0x16; // what the compositor was told a surface is (nescope → nescapture) +/// What the receiver actually got, once a second (desktop → hub). +/// +/// The hub cannot see this. Its own view of the path -- RTT, congestion window, +/// whether a datagram send returned an error -- was measured saying the path was +/// healthy while the client was receiving almost nothing, and one reason is +/// structural: `send_datagram` evicts the oldest queued datagrams and returns +/// `Ok`, so the send side has no backpressure signal at all. Only the far end +/// knows what arrived. +pub const MSG_RECEIVER_REPORT: u8 = 0x13; +/// Who decides the bitrate, and the ceiling to decide within (desktop → hub). +pub const MSG_CONTROL_MODE: u8 = 0x14; pub const MSG_INPUT_BATCH: u8 = 0xFE; // batched input events (desktop → hub) /// Build a frame body: `[u8 type] [u16 LE seq] [payload]`. @@ -75,7 +139,7 @@ pub fn decode_frame(frame: &[u8]) -> Option<(u8, u16, &[u8])> { // ── Stream types ──────────────────────────────────────────────── // // These name a media kind, not a transport. `STREAM_VIDEO` and `STREAM_AUDIO` -// still tag IPC frames on the hudless→hub unix sockets, but over QUIC their +// still tag IPC frames on the nescapture→hub unix sockets, but over QUIC their // media now travels as datagrams; only cursor and stats still open a uni // stream and send this as its first byte. @@ -90,6 +154,13 @@ pub const STREAM_KEYFRAME: u8 = 5; // ── Bidi stream types (desktop ↔ hub over QUIC bidi) ─────────── pub const BIDI_INPUT: u8 = 2; // desktop → hub → nescope (input events) +/// Everything the client says that is not an input event: keyframe requests, +/// receiver reports, encode settings, control mode. +/// +/// Its own stream on its own connection. Input is small and latency-critical +/// and must not wait behind a receiver report, and neither must wait behind +/// video, which is why these live apart from the media connections entirely. +pub const BIDI_CONTROL: u8 = 6; // Codec IDs pub const CODEC_H264: u8 = 0; @@ -101,6 +172,13 @@ pub const CODEC_KEEP: u8 = 0xFF; // "keep current" sentinel for dynamic encoder // Rate control modes (encode settings) pub const RC_CBR: u8 = 0; pub const RC_CQP: u8 = 1; +/// "Keep current" sentinel, the rate-control counterpart of [`CODEC_KEEP`]. +/// +/// A settings message says four things at once, and until this existed there +/// was no way to say only one of them: a client wanting a different bit depth +/// had to name a rate control mode and a value too, which in Auto mode means +/// overruling the controller that owns the bitrate. +pub const RC_KEEP: u8 = 0xFF; // Bit-depth (encode settings) pub const DEPTH_8: u8 = 0; @@ -208,6 +286,207 @@ pub fn encode_encode_settings( buf.push(bit_depth); } +/// A settings payload that changes the bitrate and nothing else. +/// +/// Six bytes rather than seven: the bit-depth byte is *omitted*, which +/// [`decode_encode_settings`] reports as `None`. That matters at the far end, +/// where a change carrying a depth has to be treated as a possible depth change +/// and rebuild the video session -- and a rebuild costs a keyframe. A controller +/// adjusting the bitrate every second must not do that, so it says nothing it +/// does not mean: keep the codec, keep the depth, this bitrate. +pub fn encode_bitrate_only(buf: &mut Vec, kbps: u32) { + buf.reserve(6); + buf.push(CODEC_KEEP); + buf.push(RC_CBR); + buf.extend_from_slice(&kbps.to_le_bytes()); +} + +// ── What colour the compositor was told a surface is ──────────────────── + +/// SDR: BT.709 primaries, sRGB transfer. +pub const SURFACE_COLOR_SRGB: u8 = 0; +/// HDR10: BT.2020 primaries, PQ transfer. +pub const SURFACE_COLOR_BT2020_PQ: u8 = 1; + +/// What a Wayland client declared about its surface's colour. +/// +/// Capture normally reads the colour space from the game's Vulkan swapchain, +/// and that is the right source when the swapchain names one. It does not +/// always: `VK_COLOR_SPACE_PASS_THROUGH_EXT` means "do not convert my values" +/// and carries no colour information at all, while the surface's real colour +/// space is declared separately, over `wp_color_manager_v1`, to the +/// compositor. A Windows title turning on HDR through wine arrives exactly +/// that way -- the pixels are BT.2020 PQ and the swapchain says nothing. +/// +/// So the compositor, which is told, passes it to capture, which is not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SurfaceColor { + /// One of the `SURFACE_COLOR_*` constants. + pub space: u8, + /// Mastering metadata, as the client gave it. Zero where it said nothing. + /// + /// Carried now and not yet applied: it belongs in the stream's own + /// metadata, and sending it from the start means that can be wired up + /// without a second protocol change and a second pin. + pub max_cll: u32, + pub max_fall: u32, + pub min_luminance: u32, + pub max_luminance: u32, +} + +/// Encode a surface colour declaration: +/// `[1B space] [4B max_cll] [4B max_fall] [4B min_lum] [4B max_lum]`, LE. +pub fn encode_surface_color(buf: &mut Vec, colour: &SurfaceColor) { + buf.reserve(17); + buf.push(colour.space); + buf.extend_from_slice(&colour.max_cll.to_le_bytes()); + buf.extend_from_slice(&colour.max_fall.to_le_bytes()); + buf.extend_from_slice(&colour.min_luminance.to_le_bytes()); + buf.extend_from_slice(&colour.max_luminance.to_le_bytes()); +} + +/// Decode one. `None` when the payload is too short to be one. +pub fn decode_surface_color(payload: &[u8]) -> Option { + if payload.len() < 17 { + return None; + } + let u32_at = + |i: usize| u32::from_le_bytes([payload[i], payload[i + 1], payload[i + 2], payload[i + 3]]); + Some(SurfaceColor { + space: payload[0], + max_cll: u32_at(1), + max_fall: u32_at(5), + min_luminance: u32_at(9), + max_luminance: u32_at(13), + }) +} + +// ── What the client can decode ────────────────────────────────────────── + +/// The codec and depth combinations a client can decode, as one bitmask. +/// +/// A host that picks something the far end cannot decode produces a black +/// screen and no error, so it needs the client's whole set rather than its +/// favourite: knowing only "this one prefers AV1" leaves nowhere to fall back +/// to when the host cannot encode AV1 either. +/// +/// Sent once, on connect, before any picture. That is early enough that the +/// host never sends a codec the client cannot read, which reacting to the +/// first frame could not manage. +/// +/// One bit per pair, at `codec * 2 + depth`, so the layout follows from the +/// codec ids rather than from a table that can disagree with them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ClientCaps(u16); + +/// Best first. The same order the host uses to pick among its own encoders, +/// stated once so the two cannot drift apart. +pub const CODEC_PREFERENCE: [u8; 3] = [CODEC_AV1, CODEC_H265, CODEC_H264]; + +impl ClientCaps { + /// Nothing supported. What a client that never spoke is assumed to have, + /// which is why [`Self::best`] treats an empty set as "no opinion" rather + /// than as "decodes nothing". + pub fn empty() -> Self { + Self(0) + } + + pub fn from_bits(bits: u16) -> Self { + Self(bits) + } + + pub fn bits(self) -> u16 { + self.0 + } + + pub fn is_empty(self) -> bool { + self.0 == 0 + } + + fn bit(codec: u8, depth: u8) -> Option { + // `CODEC_KEEP` and the audio codec have no place in a video capability + // set, and shifting by them would be nonsense rather than a small + // error. + if !matches!(codec, CODEC_H264 | CODEC_H265 | CODEC_AV1) { + return None; + } + if !matches!(depth, DEPTH_8 | DEPTH_10) { + return None; + } + Some(1u16 << (codec * 2 + depth)) + } + + /// Add one pair. Unknown codecs and depths are ignored rather than + /// panicking: this is built from what a device probe reported. + #[must_use] + pub fn with(mut self, codec: u8, depth: u8) -> Self { + if let Some(bit) = Self::bit(codec, depth) { + self.0 |= bit; + } + self + } + + pub fn supports(self, codec: u8, depth: u8) -> bool { + Self::bit(codec, depth).is_some_and(|bit| self.0 & bit != 0) + } + + /// Whether this set can decode `codec` at any depth. + pub fn supports_codec(self, codec: u8) -> bool { + self.supports(codec, DEPTH_8) || self.supports(codec, DEPTH_10) + } + + /// The best codec and depth both ends can manage. + /// + /// Walks [`CODEC_PREFERENCE`], takes the first codec present in both sets, + /// and within it prefers ten bits -- deeper coefficients carry less + /// rounding error through the transform, so it is usually a small win on + /// efficiency rather than a trade against it. + /// + /// `None` when nothing overlaps, which is a real possibility rather than a + /// theoretical one: an old client that sends no capabilities at all reads + /// as empty. The caller keeps whatever it was already doing. + pub fn best(self, host: Self) -> Option<(u8, u8)> { + if self.is_empty() || host.is_empty() { + return None; + } + for codec in CODEC_PREFERENCE { + for depth in [DEPTH_10, DEPTH_8] { + if self.supports(codec, depth) && host.supports(codec, depth) { + return Some((codec, depth)); + } + } + } + None + } +} + +/// Encode a capability set: `[2B bits LE]`. +pub fn encode_client_caps(buf: &mut Vec, caps: ClientCaps) { + buf.extend_from_slice(&caps.bits().to_le_bytes()); +} + +/// Decode a capability set. `None` when the payload is too short to be one. +pub fn decode_client_caps(payload: &[u8]) -> Option { + if payload.len() < 2 { + return None; + } + Some(ClientCaps::from_bits(u16::from_le_bytes([ + payload[0], payload[1], + ]))) +} + +/// A settings payload that changes the bit depth and nothing else. +/// +/// What a client sends once, on connect, to say what it can actually decode. +/// The codec and the rate control are both left alone, so this is safe to send +/// in Auto mode, where the controller owns the bitrate. +/// +/// Seven bytes, because the depth byte is the seventh: see +/// [`encode_bitrate_only`] for why its absence means something. +pub fn encode_depth_only(buf: &mut Vec, bit_depth: u8) { + encode_encode_settings(buf, CODEC_KEEP, RC_KEEP, 0, bit_depth); +} + /// Decode an encode-settings payload. Returns `(codec_id, rate_control_mode, value, bit_depth)`. /// bit_depth is None for 6-byte (old client) payloads, Some(n) for 7+ byte payloads. pub fn decode_encode_settings(payload: &[u8]) -> Option<(u8, u8, u32, Option)> { @@ -224,3 +503,439 @@ pub fn decode_encode_settings(payload: &[u8]) -> Option<(u8, u8, u32, Option }; Some((codec_id, rc, value, depth)) } + +// ── Receiver report ───────────────────────────────────────────── + +/// What one second looked like from the receiving end. +/// +/// Counts are per-second deltas, not totals: a controller wants to know what is +/// happening now, and a total makes every reading depend on how long the session +/// has been running. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ReceiverReport { + /// Video bits per second actually reassembled and released to the decoder. + /// + /// Not what was sent, and not what arrived -- what *completed*. A frame + /// missing one fragment contributes nothing here, which is right: it + /// contributed nothing to the picture either. When the sender is saturating + /// the path this is the measured capacity of it. + pub goodput_bps: u64, + /// Frames released to the decoder. + pub released: u32, + /// Frames that started arriving and never completed. + pub incomplete: u32, + /// Frames no fragment of which ever arrived. + pub never_arrived: u32, + /// The receiver's own round-trip estimate, in milliseconds. + pub rtt_ms: u32, +} + +impl ReceiverReport { + /// The fraction of frames that did not make it, in `0.0..=1.0`. + /// + /// `None` when no frames were accounted for at all, which is not the same + /// as no loss -- a second in which nothing was sent and a second in which + /// nothing arrived look identical here, and only the caller knows which it + /// is expecting. + pub fn loss(&self) -> Option { + let total = self.released + self.incomplete + self.never_arrived; + if total == 0 { + return None; + } + Some((self.incomplete + self.never_arrived) as f32 / total as f32) + } +} + +/// `[8B goodput_bps][4B released][4B incomplete][4B never_arrived][4B rtt_ms]`, +/// all little-endian. +pub const RECEIVER_REPORT_LEN: usize = 24; + +pub fn encode_receiver_report(buf: &mut Vec, report: &ReceiverReport) { + buf.reserve(RECEIVER_REPORT_LEN); + buf.extend_from_slice(&report.goodput_bps.to_le_bytes()); + buf.extend_from_slice(&report.released.to_le_bytes()); + buf.extend_from_slice(&report.incomplete.to_le_bytes()); + buf.extend_from_slice(&report.never_arrived.to_le_bytes()); + buf.extend_from_slice(&report.rtt_ms.to_le_bytes()); +} + +/// Decode a receiver report. `None` when the payload is short. +/// +/// A payload *longer* than expected is accepted and its tail ignored, so a newer +/// client that appends a field still reports usefully to an older hub. +pub fn decode_receiver_report(payload: &[u8]) -> Option { + if payload.len() < RECEIVER_REPORT_LEN { + return None; + } + let u32_at = |o: usize| u32::from_le_bytes(payload[o..o + 4].try_into().unwrap()); + Some(ReceiverReport { + goodput_bps: u64::from_le_bytes(payload[0..8].try_into().unwrap()), + released: u32_at(8), + incomplete: u32_at(12), + never_arrived: u32_at(16), + rtt_ms: u32_at(20), + }) +} + +// ── Control mode ──────────────────────────────────────────────── + +/// Who is choosing the bitrate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ControlMode { + /// The hub's controller decides, within the ceiling. + #[default] + Auto, + /// A person decided, and the controller stands down until told otherwise. + /// + /// Kept because it is how this class of bug gets diagnosed at all: the + /// original "the bitrate is already lowered" report was wrong, and the only + /// way anyone established that was by setting one by hand and watching the + /// picture come back. + Manual, +} + +pub const CONTROL_MODE_AUTO: u8 = 0; +pub const CONTROL_MODE_MANUAL: u8 = 1; + +/// `[1B mode][4B ceiling_kbps LE]`. A ceiling of 0 means "no opinion, keep +/// whatever the hub was given". +pub const CONTROL_MODE_LEN: usize = 5; + +pub fn encode_control_mode(buf: &mut Vec, mode: ControlMode, ceiling_kbps: u32) { + buf.reserve(CONTROL_MODE_LEN); + buf.push(match mode { + ControlMode::Auto => CONTROL_MODE_AUTO, + ControlMode::Manual => CONTROL_MODE_MANUAL, + }); + buf.extend_from_slice(&ceiling_kbps.to_le_bytes()); +} + +/// Returns `(mode, ceiling_kbps)`; the ceiling is `None` when it was left at 0. +pub fn decode_control_mode(payload: &[u8]) -> Option<(ControlMode, Option)> { + if payload.len() < CONTROL_MODE_LEN { + return None; + } + let mode = match payload[0] { + CONTROL_MODE_AUTO => ControlMode::Auto, + CONTROL_MODE_MANUAL => ControlMode::Manual, + // An unknown mode is not a reason to stop controlling the bitrate, and + // guessing "manual" would silently disable the controller. + _ => return None, + }; + let ceiling = u32::from_le_bytes(payload[1..5].try_into().unwrap()); + Some((mode, (ceiling != 0).then_some(ceiling))) +} + +#[cfg(test)] +mod media_control_tests { + use super::*; + + fn report() -> ReceiverReport { + ReceiverReport { + goodput_bps: 2_850_000, + released: 47, + incomplete: 12, + never_arrived: 1, + rtt_ms: 182, + } + } + + #[test] + fn a_report_survives_the_wire() { + let mut buf = Vec::new(); + encode_receiver_report(&mut buf, &report()); + assert_eq!(buf.len(), RECEIVER_REPORT_LEN); + assert_eq!(decode_receiver_report(&buf), Some(report())); + } + + #[test] + fn a_short_report_is_refused_rather_than_guessed() { + let mut buf = Vec::new(); + encode_receiver_report(&mut buf, &report()); + for n in 0..RECEIVER_REPORT_LEN { + assert_eq!(decode_receiver_report(&buf[..n]), None, "{n} bytes"); + } + } + + #[test] + fn a_longer_report_is_read_and_its_tail_ignored() { + // So a newer client that appends a field still reports usefully to a + // hub that predates it. + let mut buf = Vec::new(); + encode_receiver_report(&mut buf, &report()); + buf.extend_from_slice(&[0xAA; 8]); + assert_eq!(decode_receiver_report(&buf), Some(report())); + } + + #[test] + fn loss_counts_every_frame_that_did_not_arrive_whole() { + // An incomplete frame is a lost frame. It cost bandwidth and produced no + // picture, which is worse than never having been sent. + let r = ReceiverReport { + released: 90, + incomplete: 8, + never_arrived: 2, + ..Default::default() + }; + assert_eq!(r.loss(), Some(0.1)); + } + + #[test] + fn a_silent_second_has_no_loss_figure() { + // Nothing sent and nothing arrived look identical from here. Reporting + // 0% would tell a controller the path is healthy; reporting 100% would + // tell it to collapse the bitrate. Neither is known, so neither is said. + assert_eq!(ReceiverReport::default().loss(), None); + } + + #[test] + fn total_loss_is_reported_as_total() { + let r = ReceiverReport { + released: 0, + incomplete: 46, + never_arrived: 14, + ..Default::default() + }; + assert_eq!(r.loss(), Some(1.0)); + } + + #[test] + fn a_control_mode_survives_the_wire() { + for (mode, ceiling) in [ + (ControlMode::Auto, 8_000u32), + (ControlMode::Manual, 1_000), + (ControlMode::Auto, 0), + ] { + let mut buf = Vec::new(); + encode_control_mode(&mut buf, mode, ceiling); + assert_eq!(buf.len(), CONTROL_MODE_LEN); + assert_eq!( + decode_control_mode(&buf), + Some((mode, (ceiling != 0).then_some(ceiling))), + ); + } + } + + #[test] + fn an_unknown_mode_is_refused_rather_than_defaulted() { + // Defaulting to manual would silently switch the controller off, which + // is the failure this whole change exists to remove. + let mut buf = vec![0x7F]; + buf.extend_from_slice(&8_000u32.to_le_bytes()); + assert_eq!(decode_control_mode(&buf), None); + } + + #[test] + fn a_surface_colour_survives_the_wire() { + let colour = SurfaceColor { + space: SURFACE_COLOR_BT2020_PQ, + max_cll: 1000, + max_fall: 400, + min_luminance: 0, + max_luminance: 1000, + }; + let mut buf = Vec::new(); + encode_surface_color(&mut buf, &colour); + assert_eq!(buf.len(), 17); + assert_eq!(decode_surface_color(&buf), Some(colour)); + } + + /// A truncated payload is not a surface that is suddenly SDR. Reading one + /// as though it were would turn a dropped byte into a wrong picture. + #[test] + fn a_short_surface_colour_is_not_read() { + let mut buf = Vec::new(); + encode_surface_color(&mut buf, &SurfaceColor::default()); + for len in 0..17 { + assert_eq!(decode_surface_color(&buf[..len]), None, "len {len}"); + } + } + + #[test] + fn every_message_type_is_its_own_number() { + // Every type byte that travels on a stream, as `(name, value)`. Listed + // by hand because the point is to catch a new one colliding with an + // existing one, and anything derived from the constants would agree + // with them by construction. + // + // `MSG_CLIENT_CAPS` was 0x13 when it was added, which is + // `MSG_RECEIVER_REPORT`. The hub matches on the type byte and the caps + // arm came first, so every receiver report would have been read as + // capabilities - taking away the only measurement the bitrate + // controller has, silently, on a message sent once per connection. + let types = [ + ("MSG_DATA", MSG_DATA), + ("MSG_IDR_REQUEST", MSG_IDR_REQUEST), + ("MSG_ENCODE_SETTINGS", MSG_ENCODE_SETTINGS), + ("MSG_CLIENT_CAPS", MSG_CLIENT_CAPS), + ("MSG_SURFACE_COLOR", MSG_SURFACE_COLOR), + ("MSG_RECEIVER_REPORT", MSG_RECEIVER_REPORT), + ("MSG_CONTROL_MODE", MSG_CONTROL_MODE), + ("MSG_INPUT_BATCH", MSG_INPUT_BATCH), + ]; + for (i, (name, value)) in types.iter().enumerate() { + for (other_name, other_value) in &types[i + 1..] { + assert_ne!( + value, other_value, + "{name} and {other_name} are both {value:#04x}" + ); + } + } + } + + #[test] + fn every_codec_and_depth_has_its_own_bit() { + let all = [CODEC_H264, CODEC_H265, CODEC_AV1] + .into_iter() + .flat_map(|c| [DEPTH_8, DEPTH_10].map(move |d| (c, d))); + let mut seen = Vec::new(); + for (codec, depth) in all { + let caps = ClientCaps::empty().with(codec, depth); + assert!(caps.supports(codec, depth)); + assert!(!seen.contains(&caps.bits()), "{codec}/{depth} collides"); + seen.push(caps.bits()); + } + } + + /// Nothing outside the video codecs belongs in a capability set, and a + /// shift by `CODEC_KEEP` would be nonsense rather than a small error. + #[test] + fn nonsense_pairs_are_ignored_rather_than_stored() { + let caps = ClientCaps::empty() + .with(CODEC_KEEP, DEPTH_8) + .with(CODEC_OPUS, DEPTH_8) + .with(CODEC_AV1, 7); + assert!(caps.is_empty()); + assert!(!caps.supports(CODEC_KEEP, DEPTH_8)); + } + + fn host_all() -> ClientCaps { + ClientCaps::empty() + .with(CODEC_AV1, DEPTH_8) + .with(CODEC_AV1, DEPTH_10) + .with(CODEC_H265, DEPTH_8) + .with(CODEC_H265, DEPTH_10) + .with(CODEC_H264, DEPTH_8) + } + + #[test] + fn the_best_shared_codec_wins_at_the_deeper_depth() { + assert_eq!(host_all().best(host_all()), Some((CODEC_AV1, DEPTH_10))); + } + + /// The case this exists for: a client with no AV1 decoder must not be sent + /// AV1 just because the host prefers it. + #[test] + fn a_client_without_av1_gets_h265() { + let client = ClientCaps::empty() + .with(CODEC_H265, DEPTH_8) + .with(CODEC_H265, DEPTH_10) + .with(CODEC_H264, DEPTH_8); + assert_eq!(client.best(host_all()), Some((CODEC_H265, DEPTH_10))); + } + + #[test] + fn a_client_with_only_h264_gets_h264() { + let client = ClientCaps::empty().with(CODEC_H264, DEPTH_8); + assert_eq!(client.best(host_all()), Some((CODEC_H264, DEPTH_8))); + } + + /// Ten bits is preferred, not required: a client that decodes H.265 at + /// eight bits only still gets H.265 rather than being pushed to H.264. + #[test] + fn eight_bit_is_taken_when_that_is_all_there_is() { + let client = ClientCaps::empty() + .with(CODEC_H265, DEPTH_8) + .with(CODEC_H264, DEPTH_8); + assert_eq!(client.best(host_all()), Some((CODEC_H265, DEPTH_8))); + } + + /// A host that can only encode AV1 and a client that cannot decode it + /// share nothing. The caller keeps what it was doing rather than picking + /// something neither end asked for. + #[test] + fn no_overlap_is_no_answer() { + let host = ClientCaps::empty().with(CODEC_AV1, DEPTH_8); + let client = ClientCaps::empty().with(CODEC_H264, DEPTH_8); + assert_eq!(client.best(host), None); + } + + /// A client that never sent capabilities reads as empty, which must mean + /// "said nothing" and not "decodes nothing". + #[test] + fn silence_is_not_an_answer_either() { + assert_eq!(ClientCaps::empty().best(host_all()), None); + assert_eq!(host_all().best(ClientCaps::empty()), None); + } + + #[test] + fn capabilities_survive_the_wire() { + let caps = ClientCaps::empty() + .with(CODEC_AV1, DEPTH_10) + .with(CODEC_H264, DEPTH_8); + let mut buf = Vec::new(); + encode_client_caps(&mut buf, caps); + assert_eq!(decode_client_caps(&buf), Some(caps)); + assert_eq!(decode_client_caps(&buf[..1]), None, "too short to read"); + } + + /// The host walks its own encoders in this order; stating it once is what + /// keeps the two ends agreeing about what "best" means. + #[test] + fn the_preference_order_is_av1_first() { + assert_eq!(CODEC_PREFERENCE, [CODEC_AV1, CODEC_H265, CODEC_H264]); + } + + #[test] + fn a_depth_only_change_touches_nothing_else() { + let mut buf = Vec::new(); + encode_depth_only(&mut buf, DEPTH_10); + let (codec, rc, value, depth) = decode_encode_settings(&buf).expect("readable"); + assert_eq!(codec, CODEC_KEEP, "the codec is the host's business"); + assert_eq!(rc, RC_KEEP, "the controller keeps the bitrate it chose"); + assert_eq!(value, 0, "and there is no value to read"); + assert_eq!(depth, Some(DEPTH_10), "the depth is the whole message"); + } + + /// The two sentinels have to be distinguishable from real values, or a + /// "keep this" reads as a request for something. + #[test] + fn the_keep_sentinels_are_not_real_settings() { + assert_ne!(RC_KEEP, RC_CBR); + assert_ne!(RC_KEEP, RC_CQP); + assert_ne!(CODEC_KEEP, CODEC_H264); + assert_ne!(CODEC_KEEP, CODEC_H265); + assert_ne!(CODEC_KEEP, CODEC_AV1); + } + + #[test] + fn a_bitrate_only_change_carries_no_depth_and_no_codec() { + // The far end rebuilds its video session -- and spends a keyframe -- for + // anything that might be a codec or depth change. A controller nudging + // the bitrate every second must say neither. + let mut buf = Vec::new(); + encode_bitrate_only(&mut buf, 2_500); + let (codec, rc, value, depth) = decode_encode_settings(&buf).expect("readable"); + assert_eq!(codec, CODEC_KEEP); + assert_eq!(rc, RC_CBR); + assert_eq!(value, 2_500); + assert_eq!(depth, None, "a depth byte would force a rebuild"); + } + + #[test] + fn the_new_message_types_do_not_collide() { + let all = [ + MSG_DATA, + MSG_IDR_REQUEST, + MSG_ENCODE_SETTINGS, + MSG_RECEIVER_REPORT, + MSG_CONTROL_MODE, + MSG_INPUT_BATCH, + ]; + for (i, a) in all.iter().enumerate() { + for b in &all[i + 1..] { + assert_ne!(a, b, "two message types share a value"); + } + } + } +} diff --git a/crates/nesprotocol/src/lifecycle.rs b/crates/nesprotocol/src/lifecycle.rs index 72a732a4..b69f707f 100644 --- a/crates/nesprotocol/src/lifecycle.rs +++ b/crates/nesprotocol/src/lifecycle.rs @@ -59,7 +59,10 @@ pub const CONTROL_PORT: u32 = 7000; /// messages, so a version-2 peer and a version-3 peer do not talk at all. /// There is deliberately no shim: nothing is deployed, and a shim would be the /// second definition of this wire that one shared crate exists to prevent. -pub const CONTROL_VERSION: u32 = 3; +/// +/// Version 4 replaced the descriptor's `drives` with `overlays`: a build is a +/// read-only image now, and a box writes into a layer of its own over it. +pub const CONTROL_VERSION: u32 = 4; /// The command to run, and who runs it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -101,15 +104,29 @@ pub struct Mount { pub ro: bool, } -/// A block device the guest mounts, rather than a share it is handed. +/// A read-only block device with a writable one layered over it. /// -/// There are no mount options on this and that is deliberate: what the guest -/// mounts a build volume with is a property of how the volume was built -- -/// journal-less ext4, `nosuid`, `nodev` -- and not something a descriptor is in -/// a position to know. A field for options was here and was never read. +/// This is how a game's install reaches a box. The build is an EROFS image +/// every box on the host shares, and the box's own writes land on an ext4 of +/// its own that is thrown away with it; the guest stacks the two with +/// overlayfs, so a game that writes into its install directory works and the +/// build under it cannot change. +/// +/// Stacked in the guest rather than on the host because the host has no +/// filesystem it can do it on: the host-side equivalent was a ZFS clone, and a +/// host that needs ZFS is what this replaced. +/// +/// There are no mount options on this and that is deliberate: how each layer +/// is mounted is a property of how it was built -- EROFS read-only, a +/// journal-less ext4 upper, `nosuid`, `nodev` -- and not something a +/// descriptor is in a position to know. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Drive { - pub dev: String, +pub struct Overlay { + /// The read-only build image, as the guest names the device. + pub lower: String, + /// The box's writable layer, as the guest names the device. + pub upper: String, + /// Where the stacked result lands. pub at: String, } @@ -178,7 +195,40 @@ pub struct BootDescriptor { #[serde(default)] pub mounts: Vec, #[serde(default)] - pub drives: Vec, + pub overlays: Vec, + /// What the box may spend on video. + /// + /// Here rather than on a launch because its consumer is `neshub`, which is + /// a service and comes up with the box. Geometry went the other way for the + /// same reason: its consumer is the compositor, which is started per launch. + /// A number travels to where the thing that reads it is started. + #[serde(default)] + pub video: VideoLimits, +} + +/// Ceilings on what a box's video may cost. +/// +/// A struct rather than a bare number so the next video-shaped limit joins it +/// instead of arriving loose alongside it. +/// +/// Note what `deny_unknown_fields` on [`BootDescriptor`] means for this: a host +/// that sends `video` to a guest too old to know the field is *refused*, not +/// quietly accepted. That is the intended direction of failure -- the +/// alternative is a box that comes up, streams, and ignores the ceiling it was +/// given, which is exactly the shape of failure this stack produces too easily. +/// The guest image is rebuilt before a host starts sending it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VideoLimits { + /// Ceiling on the video bitrate, in kbps. + /// + /// `None` means nobody said, which is not the same as zero and is not the + /// same as unlimited. A reader that was told nothing should say so and pick + /// a conservative default of its own; a reader that treats "unsaid" as + /// "unlimited" reproduces the bug this exists to fix, where every session + /// offered 10 Mbps because nothing had ever set a number. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub bitrate_kbps: Option, } /// How a workload ended. @@ -390,7 +440,8 @@ mod tests { at: "/mnt/install".into(), ro: true, }], - drives: Vec::new(), + overlays: Vec::new(), + video: VideoLimits::default(), } } @@ -633,3 +684,64 @@ mod tests { assert!(!on_exit.terminal); } } + +#[cfg(test)] +mod video_limits_tests { + use super::*; + + #[test] + fn a_descriptor_without_video_still_reads() { + // An older host says nothing about video. That has to keep working, and + // it has to be distinguishable from a host that said "no limit". + let d: BootDescriptor = serde_json::from_str(r#"{"mounts":[],"overlays":[]}"#).unwrap(); + assert_eq!(d.video.bitrate_kbps, None); + } + + #[test] + fn an_unsaid_ceiling_is_not_serialised() { + // So a host that has nothing to say produces the same bytes it always + // did, and an older guest keeps accepting it. + let d = BootDescriptor { + mounts: Vec::new(), + overlays: Vec::new(), + video: VideoLimits::default(), + }; + let json = serde_json::to_string(&d).unwrap(); + assert!(!json.contains("bitrate"), "{json}"); + } + + #[test] + fn a_ceiling_survives_the_round_trip() { + let d = BootDescriptor { + mounts: Vec::new(), + overlays: Vec::new(), + video: VideoLimits { + bitrate_kbps: Some(8_000), + }, + }; + let back: BootDescriptor = + serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back, d); + } + + #[test] + fn zero_is_not_the_same_as_unsaid() { + // A reader that conflates them cannot tell "the host wants no video" from + // "the host never mentioned it", and the second must not be read as a + // licence to send whatever it likes. + let said: BootDescriptor = serde_json::from_str(r#"{"video":{"bitrate_kbps":0}}"#).unwrap(); + let unsaid: BootDescriptor = serde_json::from_str("{}").unwrap(); + assert_eq!(said.video.bitrate_kbps, Some(0)); + assert_eq!(unsaid.video.bitrate_kbps, None); + } + + #[test] + fn an_unknown_video_field_is_refused() { + // Same reasoning as the descriptor's own `deny_unknown_fields`: a limit + // this build does not understand is one it would otherwise ignore while + // reporting success. + let r: Result = + serde_json::from_str(r#"{"video":{"bitrate_kbps":8000,"fps_cap":30}}"#); + assert!(r.is_err(), "an unknown video limit must not be ignored"); + } +} diff --git a/crates/nesprotocol/src/stats.rs b/crates/nesprotocol/src/stats.rs index c5a8847a..5879319e 100644 --- a/crates/nesprotocol/src/stats.rs +++ b/crates/nesprotocol/src/stats.rs @@ -13,9 +13,9 @@ pub fn encode_nescope_stats(buf: &mut Vec, game_fps: u8, frame_count: u32) { buf.extend_from_slice(&frame_count.to_le_bytes()); } -/// Hudless stats: capture FPS, encode time, dropped frames, diagnostics, capture latency. +/// nescapture stats: capture FPS, encode time, dropped frames, diagnostics, capture latency. /// [1][capture_fps: u8][encode_avg_ms: f32 LE][dropped: u32 LE][present_attempts: u32 LE][capture_attempts: u32 LE][capture_ms: f32 LE] -pub fn encode_hudless_stats( +pub fn encode_nescapture_stats( buf: &mut Vec, capture_fps: u8, encode_avg_ms: f32, @@ -60,15 +60,98 @@ pub fn encode_hub_stats( buf.push(audio_channels); } +/// What the video bitrate is made of, and who chose it. +/// +/// Appended to a hub stats packet rather than replacing anything, so an older +/// reader keeps working on the part it understands -- `decode_stats` already +/// guards each field on the length it needs. +/// +/// **The split is the point.** One combined byte counter cannot distinguish an +/// encoder ignoring its bitrate target from a stream that is mostly keyframes, +/// and those have opposite fixes. A session overshooting its target by ten times +/// looked identical either way, which is why the cause stayed ambiguous for +/// weeks. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VideoBreakdown { + /// Keyframe bits per second, measured over the last second. + pub key_bps: u32, + /// Everything else, measured the same way. + pub delta_bps: u32, + /// Keyframes in the last second. + pub keyframes: u8, + /// What the controller is asking the encoder for. + pub target_kbps: u32, + /// The ceiling it is choosing within, after any client lowered it. + pub ceiling_kbps: u32, + /// How much the box's own pipeline delay *varied* over the last second, in + /// milliseconds, at the median and the 95th percentile. + /// + /// Variation rather than absolute cost, and necessarily so: the encoder + /// stamps a frame with milliseconds since its own start, not since any + /// epoch, so the difference to wall clock holds an unknown constant even + /// though both run on the same machine. It is also the comparable + /// quantity -- the client measures the same thing about the total arrival + /// delay, with the same code, so the gap between the two is what the + /// network added. + /// + /// The point is attribution: a client seeing late frames cannot otherwise + /// tell a stalled encoder from a jittery path, and buffering against the + /// first is latency spent hiding a fault that should be fixed instead. + pub pipeline_p50_ms: u16, + pub pipeline_p95_ms: u16, + /// The worst single frame in that second. + pub pipeline_max_ms: u16, + /// The ceiling the box itself was given, which no client may exceed. + /// + /// Separate from `ceiling_kbps` because a client that lowers the ceiling + /// would otherwise have nothing left to raise it against: the only number + /// it can see is the one it just lowered. A control that can be turned down + /// and not back up is worse than no control. + pub box_ceiling_kbps: u32, + /// Why the target is what it is; see the hub's control module. + pub reason: u8, + /// 0 when the controller is deciding, 1 when a person set it by hand. + pub manual: u8, + /// How far behind the send queue is, in milliseconds of video. + /// + /// Bytes handed to the transport and not yet gone, over the rate they are + /// leaving at. This is the number that was invisible during the session + /// where every frame arrived, nothing was lost, and the picture was still + /// eight seconds old: loss cannot show a queue, only its overflow. Anything + /// but near-zero here means latency is being spent on backlog. + pub backlog_ms: u16, +} + +/// `[4B key_bps][4B delta_bps][1B keyframes][4B target][4B ceiling][1B reason] +/// [1B manual][4B box_ceiling][2B pipeline_p50][2B pipeline_p95][2B pipeline_max] +/// [2B backlog_ms]` +pub const VIDEO_BREAKDOWN_LEN: usize = 31; + +pub fn encode_video_breakdown(buf: &mut Vec, b: &VideoBreakdown) { + buf.reserve(VIDEO_BREAKDOWN_LEN); + buf.extend_from_slice(&b.key_bps.to_le_bytes()); + buf.extend_from_slice(&b.delta_bps.to_le_bytes()); + buf.push(b.keyframes); + buf.extend_from_slice(&b.target_kbps.to_le_bytes()); + buf.extend_from_slice(&b.ceiling_kbps.to_le_bytes()); + buf.push(b.reason); + buf.push(b.manual); + buf.extend_from_slice(&b.box_ceiling_kbps.to_le_bytes()); + buf.extend_from_slice(&b.pipeline_p50_ms.to_le_bytes()); + buf.extend_from_slice(&b.pipeline_p95_ms.to_le_bytes()); + buf.extend_from_slice(&b.pipeline_max_ms.to_le_bytes()); + buf.extend_from_slice(&b.backlog_ms.to_le_bytes()); +} + /// Decoded stats from any source. #[derive(Debug, Clone, Default)] pub struct PipelineStats { pub nescope_fps: u8, pub nescope_frames: u32, - pub hudless_fps: u8, - pub hudless_encode_ms: f32, - pub hudless_capture_ms: f32, - pub hudless_dropped: u32, + pub nescapture_fps: u8, + pub nescapture_encode_ms: f32, + pub nescapture_capture_ms: f32, + pub nescapture_dropped: u32, pub hub_clients: u8, pub hub_video_mb: u32, pub hub_relay_ms: f32, @@ -76,6 +159,8 @@ pub struct PipelineStats { pub capture_attempts: u32, pub audio_bitrate_kbps: u32, pub audio_channels: u8, + /// `None` from a hub that predates the breakdown. + pub video: Option, } /// Try to decode a single stats packet. The `msg_type` is the frame-level @@ -87,13 +172,13 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) { stats.nescope_frames = u32::from_le_bytes([data[1], data[2], data[3], data[4]]); } STATS_HUDLESS if data.len() >= 17 => { - stats.hudless_fps = data[0]; - stats.hudless_encode_ms = f32::from_le_bytes([data[1], data[2], data[3], data[4]]); - stats.hudless_dropped = u32::from_le_bytes([data[5], data[6], data[7], data[8]]); + stats.nescapture_fps = data[0]; + stats.nescapture_encode_ms = f32::from_le_bytes([data[1], data[2], data[3], data[4]]); + stats.nescapture_dropped = u32::from_le_bytes([data[5], data[6], data[7], data[8]]); stats.present_attempts = u32::from_le_bytes([data[9], data[10], data[11], data[12]]); stats.capture_attempts = u32::from_le_bytes([data[13], data[14], data[15], data[16]]); if data.len() >= 21 { - stats.hudless_capture_ms = + stats.nescapture_capture_ms = f32::from_le_bytes([data[17], data[18], data[19], data[20]]); } } @@ -110,7 +195,116 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) { if data.len() >= 14 { stats.audio_channels = data[13]; } + if data.len() >= 14 + VIDEO_BREAKDOWN_LEN { + let d = &data[14..]; + let u32_at = |o: usize| u32::from_le_bytes(d[o..o + 4].try_into().unwrap()); + stats.video = Some(VideoBreakdown { + key_bps: u32_at(0), + delta_bps: u32_at(4), + keyframes: d[8], + target_kbps: u32_at(9), + ceiling_kbps: u32_at(13), + reason: d[17], + manual: d[18], + box_ceiling_kbps: u32_at(19), + pipeline_p50_ms: u16::from_le_bytes([d[23], d[24]]), + pipeline_p95_ms: u16::from_le_bytes([d[25], d[26]]), + pipeline_max_ms: u16::from_le_bytes([d[27], d[28]]), + backlog_ms: u16::from_le_bytes([d[29], d[30]]), + }); + } } _ => {} } } + +#[cfg(test)] +mod breakdown_tests { + use super::*; + + fn breakdown() -> VideoBreakdown { + VideoBreakdown { + key_bps: 3_200_000, + delta_bps: 6_800_000, + keyframes: 2, + target_kbps: 6_000, + ceiling_kbps: 8_000, + reason: 1, + manual: 0, + box_ceiling_kbps: 8_000, + pipeline_p50_ms: 9, + pipeline_p95_ms: 24, + pipeline_max_ms: 61, + backlog_ms: 40, + } + } + + fn hub_packet(with_breakdown: bool) -> Vec { + let mut buf = Vec::new(); + encode_hub_stats(&mut buf, 1, 10_000_000, 0.0, 128, 2); + if with_breakdown { + encode_video_breakdown(&mut buf, &breakdown()); + } + buf + } + + #[test] + fn a_breakdown_survives_the_wire() { + let packet = hub_packet(true); + let mut stats = PipelineStats::default(); + decode_stats(STATS_HUB, &packet[1..], &mut stats); + assert_eq!(stats.video, Some(breakdown())); + // The fields that were always there still read correctly beside it. + assert_eq!(stats.hub_clients, 1); + assert_eq!(stats.audio_bitrate_kbps, 128); + assert_eq!(stats.audio_channels, 2); + } + + #[test] + fn a_hub_without_the_breakdown_still_reads() { + // The reason this is appended rather than folded into the layout: a hub + // that predates it keeps working, and says so by omission rather than by + // reporting zeroes that look like a stream carrying nothing. + let packet = hub_packet(false); + let mut stats = PipelineStats::default(); + decode_stats(STATS_HUB, &packet[1..], &mut stats); + assert_eq!(stats.video, None); + assert_eq!(stats.hub_clients, 1); + assert_eq!(stats.audio_channels, 2); + } + + #[test] + fn a_truncated_breakdown_is_left_out_rather_than_half_read() { + let full = hub_packet(true); + for n in 15..full.len() - 1 { + let mut stats = PipelineStats::default(); + decode_stats(STATS_HUB, &full[1..n], &mut stats); + assert_eq!(stats.video, None, "{n} bytes produced a partial breakdown"); + } + } + + #[test] + fn the_split_distinguishes_the_two_ways_a_stream_overshoots() { + // The whole reason for the split. Same total, opposite causes: an + // encoder ignoring its target, and a stream that is nearly all + // keyframes. One counter cannot tell them apart. + let ignoring_target = VideoBreakdown { + key_bps: 200_000, + delta_bps: 9_800_000, + keyframes: 1, + ..breakdown() + }; + let keyframe_storm = VideoBreakdown { + key_bps: 9_000_000, + delta_bps: 1_000_000, + keyframes: 30, + ..breakdown() + }; + assert_eq!( + ignoring_target.key_bps + ignoring_target.delta_bps, + keyframe_storm.key_bps + keyframe_storm.delta_bps, + ); + assert!(ignoring_target.delta_bps > ignoring_target.key_bps); + assert!(keyframe_storm.key_bps > keyframe_storm.delta_bps); + } +} diff --git a/docs/media-transport.md b/docs/media-transport.md new file mode 100644 index 00000000..2dcbfe7c --- /dev/null +++ b/docs/media-transport.md @@ -0,0 +1,208 @@ +# The media transport, as it actually behaves + +Media rides one QUIC connection: video delta frames and audio as datagrams, +keyframes as short-lived unidirectional streams, cursor and stats and input as +streams of their own. Several things about that arrangement are not what they +look like, and we designed against the wrong model twice before reading the +source. + +This file records what the transport *does*, with somewhere to check each +claim, and what follows from it. It is deliberately about behaviour rather than +about our code, because our code changes and this does not — except when the +dependency does, which is why the version is named. + +Read against **iroh 1.1, which uses `noq` and `noq-proto` 1.3 — a quinn fork, +not quinn itself.** Paths below are inside `noq-proto` unless stated. Reading +upstream quinn instead is a mistake worth naming, because we made it: the +behaviour turned out to agree on every point here, but "turned out to" is +precisely the standard this file exists to replace. If the iroh or noq version +moves, re-read before trusting any of this. + +## The rule + +**A path is the unit of congestion, not a stream and not a connection.** +Everything else here follows from that one sentence, and almost every wrong +assumption we made came from forgetting some part of it. + +## Congestion is per path, and shared by everything on that path + +Each path carries its own congestion controller and its own pacer +(`connection/paths.rs`, `PathData::congestion` and `PathData::pacing_delay`), +and `poll_transmit` consults both for the path it is about to send on +(`connection/mod.rs`, in the transmit blocking checks). Within a path, every +stream and every datagram shares them, whatever the packet ends up carrying. + +The "per path" part is not pedantry here: a connection commonly holds a relay +path and a direct path at once, they have different windows and different +round-trip times, and only the *selected* one describes where media is +currently going. Reading connection-level aggregates instead will average two +unrelated network conditions together. + +So moving keyframes onto their own streams — which we did, and which was right +— changed the *delivery guarantee* and nothing else. A lost fragment no longer +destroys a keyframe, because QUIC retransmits stream data. But that keyframe +still competes for the same congestion window as everything else on the path, +and still queues in the same scheduler. Stream-versus-datagram is a reliability +distinction, not an isolation one. + +The corollary is worth stating because it is tempting and wrong: **opening a +second connection does not create bandwidth.** At a 3 Mbps bottleneck a 150 KB +keyframe occupies about 400 ms of link time however it is carried. A second +connection turns strict queueing into competition between two flows over the +same bottleneck, which is sometimes what you want — but it is a change in +*fairness*, not in capacity, and it costs a second NAT traversal, a second +handshake and a second set of paths to manage. + +## Datagrams are written before stream data + +`populate_packet` fills each packet in a fixed order, and DATAGRAM frames come +before STREAM frames. Whatever datagrams are queued take their space first; +stream data gets the remainder. + +Two consequences, and they point in opposite directions: + +**Delta frames cannot be starved by keyframes.** A large keyframe simply +stretches out over many packets while deltas keep flowing beside it. This is +the behaviour we nearly built by hand before checking. + +**Keyframes can be starved by delta frames.** On a saturated link a steady +stream of datagrams fills every packet, and the keyframe on its stream gets +leftovers. If the client is waiting on that keyframe to resynchronise, the +frames starving it are the very frames it cannot decode without it. That is a +feedback loop with no bottom, and it is what produced "26 keyframe fallbacks, +19 IDR requests" in the field: the recovery frame could not get out past the +frames that needed it to arrive first. + +The fix is not to reorder the transport. It is to stop sending data that +depends on data the receiver does not have — see `dgram::ResyncGate`. + +## The send path has no backpressure signal + +`send_datagram` calls `datagrams().send(data, true)` (`noq/src/connection.rs`), +and that `true` is a `drop` flag: the queue makes room by **evicting the oldest +queued datagrams** via `make_space_for` and then returns `Ok` +(`connection/datagrams.rs`). It errors only for `TooLarge`, `UnsupportedByPeer`, +`Disabled`, and `ConnectionLost` — that is, for a datagram that was malformed +or a connection that is already gone, never for one the path could not carry. + +(The same function with `drop: false` returns `Blocked` instead and is what +`send_datagram_wait` uses. That would give backpressure, at the cost of +prioritising old frames over current ones, which for live media is the wrong +trade.) + +A sender overrunning the path is therefore told nothing at all. Any counter +fed by that return value cannot be non-zero however badly things are going, +which is exactly how a field report came to contain "encoder perfectly healthy, +zero frames dropped" beside a client receiving almost nothing. `send_datagram` +is still the right call for live media — waiting for buffer space prioritises +old frames over current ones — but its silence has to be designed around +rather than read as good news. + +What *can* be read: `datagram_send_buffer_space()` for remaining room, and the +selected path's `stats()` for `cwnd`, `rtt` and `lost_packets`. Note "selected": +a connection commonly holds a relay path and a direct path at once, and only +the selected one describes where media is going. + +## quinn already paces + +`poll_transmit` blocks on a token-bucket pacer (`connection/pacing.rs`, reached +through `PathData::pacing_delay`) sized from that path's congestion window and +round-trip time, before every packet. Handing 150 fragments over in a tight +loop does *not* put 150 packets on the wire at once. + +Application-level pacing on top of this is redundant, and worse than +redundant: it would spread transmission according to our guess at the available +rate, while the pacer underneath is using the measured window and RTT. If the +application is producing faster than the pacer drains, the answer is to produce +less — which is the bitrate controller's job — not to add a second pacer. + +## Loss is a lagging indicator; the queue is the leading one + +A path that is overrun does not necessarily drop anything. It can simply be +slower than the offer, in which case the pacer above meters packets out at the +rate it measures and the difference accumulates in the datagram send buffer. +Nothing is lost. Everything is late. + +This was measured over a 1000-mile link: every frame arrived, every frame +completed, zero QUIC loss, zero congestion events, and a **flat** 180 ms round +trip for an entire session — while the picture ran up to eight seconds behind. +A flat RTT is the tell that the queue is *ours* and not the network's; a +standing queue in the path would have shown up as rising round-trip time. + +The trap is that every health signal reads clean. Loss only appears once the +buffer finally overflows, long after the session became unplayable, and by then +the queue is deep enough that the only way out is the floor. A controller +driven by loss alone therefore climbs to its ceiling against a path it is +already overrunning, collapses when the buffer spills, drains, and climbs +again — a sawtooth with a period of tens of seconds, in which the stream is +never once at a rate the path can carry. + +`DGRAM_BUFFER_BYTES` is 4 MiB. That was chosen as headroom for a keyframe +burst, which is a real requirement and still correct. But a buffer sized in +bytes is a queue whose *duration* depends on the drain rate: at a couple of +megabits, 4 MiB is several seconds of video. Sizing for one dimension silently +set a bound in the other. + +The signal that does work was available the whole time and simply never read: + +``` +backlog_bytes = DGRAM_BUFFER_BYTES - conn.datagram_send_buffer_space() +backlog_ms = backlog_bytes * 8 / drain_kbps +``` + +Divide by the rate the queue actually drains at — what is getting through — and +not by what is being asked for, because the latter is the number that is too +high whenever this matters. + +## What follows + +These are the design rules we arrived at, and the reasoning is above rather +than in the code that implements them. + +**Do not send what the receiver cannot use.** Frames that depend on a keyframe +the receiver is still waiting for are not merely wasted bandwidth; on this +transport they actively delay the keyframe. + +**Do not leave a hole for something you chose not to send.** A receiver counts +a missing sequence number as loss, and that count feeds our own congestion +response. A sender that skips a frame *and* burns its sequence number makes the +controller lower the bitrate in response to the sender's own decision. Reusing +the number is safe precisely because nothing went out under it. + +**Bound every suppression.** Withholding is correct only while the assumption +behind it holds. If the keyframe never comes, withholding forever converts a +recoverable freeze into a permanent black screen, so the suppression expires +and says that it did. + +**Measure the queue, not just the casualties.** Loss says a path was overrun +some time ago. A standing send queue says it is being overrun right now, while +the frames are still only late. On a transport that drops the oldest datagram +rather than refusing the newest, the queue is the only signal that arrives in +time to act on — and climbing is what digs it, so climbing needs the queue to +be empty and not merely the loss to be low. + +**Trust the receiver over the sender.** The sender's view of this path was +measured reporting 182 ms RTT with zero packet loss while the client received +almost nothing. Some of that gap is structural, per the section above. The far +end's report of what actually arrived is the only measurement that cannot be +quietly wrong in our favour. + +## The method + +Every item here was found by reading the dependency's source, and every one of +them contradicted something we believed. The first draft of this very file +cited the wrong crate — upstream quinn rather than the fork iroh actually +builds against — which is a good illustration of how little intuition is worth +here even when the conclusions survive. The same thing happened one layer +down, in Vulkan Video: rate control turned out to be session state rather than +per-frame state, `gopFrameCount` of zero means "implementation chooses" rather +than "infinite" (`UINT32_MAX` means infinite), and a hardcoded QP ceiling made +low bitrate targets silently unreachable. None of that was in any comment we +had written, and two of those we had confidently asserted the opposite of. + +So: **when behaviour depends on a dependency's internals, read them, and write +down what you found with a path to check it against.** Not the documentation, +which is frequently about intent; not the interface, which is frequently about +what you may call rather than what will happen. A claim in this file that +nobody can verify in an afternoon is a claim that will be wrong within a year +and believed anyway.