Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e7c9bd059 | |||
| 7aa787a789 | |||
| 3514702d8c | |||
| 327a5fa828 | |||
| 9777ed7fb3 | |||
| ba68a98873 | |||
| 22359f5dc8 | |||
| 7e9023faad | |||
| 5acc12d9e9 | |||
| aed0bf0c2a | |||
| b65745284e | |||
| 8ca695eb4c | |||
| 61c02e695e | |||
| 203ad8069d | |||
| 5f8c6b6147 | |||
| cd3368fc71 | |||
| bd05bc8c30 | |||
| 658564353c | |||
| 6b3cbce120 | |||
| 739fa74e68 | |||
| c87ca577a3 | |||
| e68b7330ae | |||
| e5c2b4e7f5 | |||
| 7ad3a57e68 | |||
| 22bef1fd0a | |||
| bf577044f1 | |||
| 4c95ba72a3 | |||
| 011607ec10 | |||
| 803573b4ec | |||
| 00cf51d610 | |||
| 84a3b95f17 | |||
| 8cde8621ce | |||
| 0bf3984614 | |||
| 75ee53d1dd | |||
| 0255a8289c | |||
| 6bed5d9e8e | |||
| 48202a0f89 | |||
| bf57aa4000 | |||
| 0ccd0fe676 | |||
| e1ca2e4d3c | |||
| e119aa50e9 | |||
| 683c81be03 | |||
| fe61597d92 | |||
| d9b8b88a42 | |||
| 15202011c1 | |||
| 05e87e6ab0 | |||
| 38c68c33e5 | |||
| a0427cd2a3 | |||
| a4c85af155 | |||
| 9ba90d4b77 | |||
| 5358ef9fee | |||
| 0a63154293 | |||
| e5057f6cc1 | |||
| a3eefc2374 | |||
| cd591514ad | |||
| a2bd0cd77c | |||
| 48f980ebb1 | |||
| 1cd87066d7 | |||
| 789ad49bc4 | |||
| c87bfe0e7b | |||
| f98ab07dd6 | |||
| dbab1f98ba | |||
| 5d279f8886 | |||
| e60cda3939 | |||
| d638a93e04 | |||
| a755d6eab7 | |||
| b0d28380b5 | |||
| ed583650a6 | |||
| e5c9ee8327 | |||
| 0a7ae5ef09 | |||
| 95dcef3515 | |||
| 0badc17d87 |
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Stage 1 (this file): PROBE the runner's driver toolchain (WDK / EWDK / cargo-make / LLVM / the
|
||||
# inf2cat/stampinf/devgen/signtool tools) so we know what's provisioned BEFORE writing driver code,
|
||||
# and build+test the owned ABI crate (pf-vdisplay-proto) on MSVC to prove it compiles cross-OS and the
|
||||
# and build+test the owned ABI crate (pf-driver-proto) on MSVC to prove it compiles cross-OS and the
|
||||
# CI wiring works. The runner has no RTX GPU — that's fine: builds, the IddCx bindgen/link, the
|
||||
# /INTEGRITYCHECK self-sign-load, and (later) IDD-push frame flow on the basic display do not need one;
|
||||
# only live NVENC encode does, which defers to the RTX box.
|
||||
@@ -18,12 +18,12 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/windows-drivers.yml'
|
||||
- 'crates/pf-vdisplay-proto/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitea/workflows/windows-drivers.yml'
|
||||
- 'crates/pf-vdisplay-proto/**'
|
||||
- 'crates/pf-driver-proto/**'
|
||||
- 'packaging/windows/drivers/**'
|
||||
|
||||
# Driver builds need the WDK on the runner (provision once via windows-drivers-provision.yml).
|
||||
@@ -93,17 +93,17 @@ jobs:
|
||||
Write-Host ("CARGO_HOME = " + ($env:CARGO_HOME ?? '<unset>'))
|
||||
Write-Host ("CARGO_TARGET_DIR (daemon) = " + ($env:CARGO_TARGET_DIR ?? '<unset>'))
|
||||
|
||||
- name: Build + test pf-vdisplay-proto (MSVC)
|
||||
- name: Build + test pf-driver-proto (MSVC)
|
||||
run: |
|
||||
# Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml).
|
||||
$env:CARGO_TARGET_DIR = "C:\t\drv"
|
||||
cargo build -p pf-vdisplay-proto
|
||||
cargo test -p pf-vdisplay-proto
|
||||
cargo clippy -p pf-vdisplay-proto --all-targets -- -D warnings
|
||||
cargo fmt -p pf-vdisplay-proto -- --check
|
||||
cargo build -p pf-driver-proto
|
||||
cargo test -p pf-driver-proto
|
||||
cargo clippy -p pf-driver-proto --all-targets -- -D warnings
|
||||
cargo fmt -p pf-driver-proto -- --check
|
||||
|
||||
# Build the UMDF driver workspace (wdk-probe) on windows-drivers-rs: proves wdk-sys bindgen/link works
|
||||
# on the runner's WDK + LLVM, that pf-vdisplay-proto path-deps into a driver, and exposes the produced
|
||||
# on the runner's WDK + LLVM, that pf-driver-proto path-deps into a driver, and exposes the produced
|
||||
# DLL's FORCE_INTEGRITY (/INTEGRITYCHECK) bit — the M0 self-signed-load question.
|
||||
driver-build:
|
||||
runs-on: windows-amd64
|
||||
|
||||
Generated
+424
-3
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
@@ -735,6 +741,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
@@ -1010,6 +1025,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.14.1"
|
||||
@@ -1088,6 +1115,16 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1111,6 +1148,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1586,7 +1629,16 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1594,6 +1646,18 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1712,12 +1776,115 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.15.0"
|
||||
@@ -1966,12 +2133,29 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2066,6 +2250,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -2419,7 +2613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay-proto"
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
@@ -2498,6 +2692,15 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@@ -2655,6 +2858,7 @@ dependencies = [
|
||||
"audiopus_sys",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"cbc",
|
||||
"ffmpeg-next",
|
||||
@@ -2670,14 +2874,16 @@ dependencies = [
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"opus",
|
||||
"pf-vdisplay-proto",
|
||||
"pf-driver-proto",
|
||||
"pipewire",
|
||||
"punktfunk-core",
|
||||
"quinn",
|
||||
"rand 0.8.6",
|
||||
"rcgen",
|
||||
"reis",
|
||||
"roxmltree",
|
||||
"rsa",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rusty_enet",
|
||||
@@ -2689,6 +2895,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-scalar",
|
||||
@@ -2700,6 +2907,7 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"windows-service",
|
||||
"winreg",
|
||||
"x509-parser",
|
||||
"xkbcommon",
|
||||
]
|
||||
@@ -3002,6 +3210,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpkg-config"
|
||||
version = "0.1.2"
|
||||
@@ -3028,6 +3245,31 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -3478,6 +3720,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3548,6 +3796,24 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -3700,6 +3966,16 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytemplate"
|
||||
version = "1.2.1"
|
||||
@@ -4005,6 +4281,40 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
@@ -4332,6 +4642,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
@@ -4857,6 +5185,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
@@ -4951,6 +5289,12 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
@@ -4994,6 +5338,29 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.16.0"
|
||||
@@ -5070,12 +5437,66 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/punktfunk-core",
|
||||
"crates/punktfunk-host",
|
||||
"crates/pf-vdisplay-proto",
|
||||
"crates/pf-driver-proto",
|
||||
"clients/probe",
|
||||
"clients/linux",
|
||||
"clients/windows",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# own OS type. Defining every wire struct ONCE here — with `const` size/offset asserts + bytemuck
|
||||
# round-trips — makes host<->driver ABI drift a COMPILE error instead of a silent frame/IOCTL corruption.
|
||||
[package]
|
||||
name = "pf-vdisplay-proto"
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
@@ -16,4 +16,5 @@ description = "Shared host<->driver binary contract for the punktfunk pf-vdispla
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
# `min_const_generics`: Pod/Zeroable for `[u8; N]` of any N (the gamepad SHM reserved tails are >32).
|
||||
bytemuck = { version = "1.19", features = ["derive", "min_const_generics"] }
|
||||
@@ -119,13 +119,32 @@ pub mod control {
|
||||
}
|
||||
|
||||
// Layout is load-bearing across the process boundary — pin it. (bytemuck's Pod derive already
|
||||
// rejects any internal padding; these assert the externally-visible sizes too.)
|
||||
// rejects any internal padding; these assert the externally-visible sizes too.) The `offset_of!`
|
||||
// asserts additionally catch a SAME-SIZE field reorder, which the size+Pod checks alone miss.
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<AddRequest>() == 24);
|
||||
assert!(core::mem::size_of::<AddReply>() == 16);
|
||||
assert!(core::mem::size_of::<RemoveRequest>() == 8);
|
||||
assert!(core::mem::size_of::<SetRenderAdapterRequest>() == 8);
|
||||
assert!(core::mem::size_of::<InfoReply>() == 8);
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<AddRequest>() == 24);
|
||||
assert!(offset_of!(AddRequest, session_id) == 0);
|
||||
assert!(offset_of!(AddRequest, width) == 8);
|
||||
assert!(offset_of!(AddRequest, height) == 12);
|
||||
assert!(offset_of!(AddRequest, refresh_hz) == 16);
|
||||
|
||||
assert!(size_of::<AddReply>() == 16);
|
||||
assert!(offset_of!(AddReply, adapter_luid_low) == 0);
|
||||
assert!(offset_of!(AddReply, adapter_luid_high) == 4);
|
||||
assert!(offset_of!(AddReply, target_id) == 8);
|
||||
|
||||
assert!(size_of::<RemoveRequest>() == 8);
|
||||
assert!(offset_of!(RemoveRequest, session_id) == 0);
|
||||
|
||||
assert!(size_of::<SetRenderAdapterRequest>() == 8);
|
||||
assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0);
|
||||
assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4);
|
||||
|
||||
assert!(size_of::<InfoReply>() == 8);
|
||||
assert!(offset_of!(InfoReply, protocol_version) == 0);
|
||||
assert!(offset_of!(InfoReply, watchdog_timeout_s) == 4);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,8 +247,138 @@ pub mod frame {
|
||||
alloc::format!("Global\\pfvd-tex-{target_id}-{generation}-{slot}")
|
||||
}
|
||||
|
||||
// Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the
|
||||
// mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after
|
||||
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too.
|
||||
const _: () = {
|
||||
assert!(core::mem::size_of::<SharedHeader>() == 64);
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<SharedHeader>() == 64);
|
||||
assert!(offset_of!(SharedHeader, magic) == 0);
|
||||
assert!(offset_of!(SharedHeader, version) == 4);
|
||||
assert!(offset_of!(SharedHeader, generation) == 8);
|
||||
assert!(offset_of!(SharedHeader, ring_len) == 12);
|
||||
assert!(offset_of!(SharedHeader, width) == 16);
|
||||
assert!(offset_of!(SharedHeader, height) == 20);
|
||||
assert!(offset_of!(SharedHeader, dxgi_format) == 24);
|
||||
assert!(offset_of!(SharedHeader, _pad) == 28);
|
||||
assert!(offset_of!(SharedHeader, latest) == 32);
|
||||
assert!(offset_of!(SharedHeader, qpc_pts) == 40);
|
||||
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
|
||||
assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52);
|
||||
assert!(offset_of!(SharedHeader, driver_status) == 56);
|
||||
assert!(offset_of!(SharedHeader, driver_status_detail) == 60);
|
||||
};
|
||||
}
|
||||
|
||||
/// Gamepad shared-memory layouts (host ↔ the UMDF gamepad drivers `pf_xusb` / `pf_dualsense`).
|
||||
///
|
||||
/// These were hand-duplicated as `OFF_*`/`SHM_*` constants in `inject/{gamepad,dualsense}_windows.rs`
|
||||
/// and (as bare literals — `*view.add(140)`) in the standalone `xusb-driver`/`dualsense-driver`
|
||||
/// workspaces, guarded only by "must match" comments — the top ABI-drift hazard the audit flagged
|
||||
/// (`docs/windows-host-rewrite.md` §2.7). Owning them here with `Pod` derives + `offset_of!`
|
||||
/// asserts makes a one-sided edit a compile error.
|
||||
///
|
||||
/// The host creates the section (privileged, permissive DACL so the restricted WUDFHost token can
|
||||
/// open it) and the driver maps it. Layout only; the section itself is host-created shared memory.
|
||||
pub mod gamepad {
|
||||
use alloc::string::String;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
/// XUSB section magic — the exact u32 the shipped host + `pf_xusb` driver compare (loosely "PFXU").
|
||||
pub const XUSB_MAGIC: u32 = 0x5558_4650;
|
||||
/// Pad section magic — the exact u32 the shipped host + `pf_dualsense` driver compare (loosely
|
||||
/// "PFDS"). (Note: the two magics happen to use opposite byte-order mnemonics in the legacy code;
|
||||
/// only the u32 value is the contract.)
|
||||
pub const PAD_MAGIC: u32 = 0x5046_4453;
|
||||
|
||||
/// `device_type` selector the `pf_dualsense` driver reads to pick its HID identity. The section is
|
||||
/// zeroed, so `0` = DualSense is the default; one driver serves either identity.
|
||||
pub const DEVTYPE_DUALSENSE: u8 = 0;
|
||||
/// `device_type` = DualShock 4 (`VID_054C&PID_09CC` HID identity).
|
||||
pub const DEVTYPE_DUALSHOCK4: u8 = 1;
|
||||
|
||||
/// `Global\pfxusb-shm-<index>` — the virtual Xbox 360 (XInput) shared section.
|
||||
pub fn xusb_shm_name(index: u8) -> String {
|
||||
alloc::format!("Global\\pfxusb-shm-{index}")
|
||||
}
|
||||
/// `Global\pfds-shm-<index>` — the virtual DualSense / DualShock 4 shared section.
|
||||
pub fn pad_shm_name(index: u8) -> String {
|
||||
alloc::format!("Global\\pfds-shm-{index}")
|
||||
}
|
||||
|
||||
/// Virtual Xbox 360 (XInput) shared section (64 B). The host writes the XInput state (a bumped
|
||||
/// `packet` number + buttons/triggers/sticks in XInput conventions); the driver answers
|
||||
/// `XInputGetState`. The driver writes force-feedback (`XInputSetState`) into `rumble_*`, bumping
|
||||
/// `rumble_seq`, which the host relays to the client.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||
pub struct XusbShm {
|
||||
pub magic: u32,
|
||||
/// XInput `dwPacketNumber` — bumped by the host on every state change.
|
||||
pub packet: u32,
|
||||
pub buttons: u16,
|
||||
pub left_trigger: u8,
|
||||
pub right_trigger: u8,
|
||||
pub thumb_lx: i16,
|
||||
pub thumb_ly: i16,
|
||||
pub thumb_rx: i16,
|
||||
pub thumb_ry: i16,
|
||||
pub _reserved0: u32,
|
||||
/// Bumped by the driver on a new force-feedback packet.
|
||||
pub rumble_seq: u32,
|
||||
pub rumble_large: u8,
|
||||
pub rumble_small: u8,
|
||||
pub _reserved1: [u8; 34],
|
||||
}
|
||||
|
||||
/// Virtual DualSense / DualShock 4 shared section (256 B). The host writes the `0x01`-style HID
|
||||
/// input report into `input`; the driver feeds it to game `READ_REPORT`s and publishes a game's
|
||||
/// `0x02` output (rumble / lightbar / player-LEDs / adaptive triggers) into `output`, bumping
|
||||
/// `out_seq`. `device_type` selects the HID identity ([`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`]).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Pod, Zeroable, Debug)]
|
||||
pub struct PadShm {
|
||||
pub magic: u32,
|
||||
pub _reserved0: u32,
|
||||
/// Input report region (host-written; the codec's report is <= 64 B — see
|
||||
/// `inject::dualsense_proto::DS_INPUT_REPORT_LEN`). The region spans `magic`+pad .. `out_seq`.
|
||||
pub input: [u8; 64],
|
||||
/// Bumped by the driver when it publishes a new `output` report.
|
||||
pub out_seq: u32,
|
||||
/// Output report region (driver-written): rumble / lightbar / player-LEDs / adaptive triggers.
|
||||
pub output: [u8; 64],
|
||||
/// HID identity selector — see [`DEVTYPE_DUALSENSE`] / [`DEVTYPE_DUALSHOCK4`].
|
||||
pub device_type: u8,
|
||||
pub _reserved1: [u8; 115],
|
||||
}
|
||||
|
||||
// Offsets are the wire contract the shipped drivers already read by hand — pin every one. A failing
|
||||
// assert here means the struct no longer matches the historical `OFF_*` layout (host) / `view.add(N)`
|
||||
// literal (driver) and must be fixed before either side switches to the type.
|
||||
const _: () = {
|
||||
use core::mem::{offset_of, size_of};
|
||||
|
||||
assert!(size_of::<XusbShm>() == 64);
|
||||
assert!(offset_of!(XusbShm, magic) == 0);
|
||||
assert!(offset_of!(XusbShm, packet) == 4);
|
||||
assert!(offset_of!(XusbShm, buttons) == 8);
|
||||
assert!(offset_of!(XusbShm, left_trigger) == 10);
|
||||
assert!(offset_of!(XusbShm, right_trigger) == 11);
|
||||
assert!(offset_of!(XusbShm, thumb_lx) == 12);
|
||||
assert!(offset_of!(XusbShm, thumb_ly) == 14);
|
||||
assert!(offset_of!(XusbShm, thumb_rx) == 16);
|
||||
assert!(offset_of!(XusbShm, thumb_ry) == 18);
|
||||
assert!(offset_of!(XusbShm, rumble_seq) == 24);
|
||||
assert!(offset_of!(XusbShm, rumble_large) == 28);
|
||||
assert!(offset_of!(XusbShm, rumble_small) == 29);
|
||||
|
||||
assert!(size_of::<PadShm>() == 256);
|
||||
assert!(offset_of!(PadShm, magic) == 0);
|
||||
assert!(offset_of!(PadShm, input) == 8);
|
||||
assert!(offset_of!(PadShm, out_seq) == 72);
|
||||
assert!(offset_of!(PadShm, output) == 76);
|
||||
assert!(offset_of!(PadShm, device_type) == 140);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,6 +450,15 @@ mod tests {
|
||||
assert_eq!(frame::texture_name(10, 3, 5), "Global\\pfvd-tex-10-3-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_names_and_magics_are_stable() {
|
||||
assert_eq!(gamepad::xusb_shm_name(0), "Global\\pfxusb-shm-0");
|
||||
assert_eq!(gamepad::pad_shm_name(2), "Global\\pfds-shm-2");
|
||||
// Lock the exact u32 magics the shipped host/drivers use (inject/{gamepad,dualsense}_windows.rs).
|
||||
assert_eq!(gamepad::XUSB_MAGIC, 0x5558_4650);
|
||||
assert_eq!(gamepad::PAD_MAGIC, 0x5046_4453);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctl_codes_are_contiguous_and_distinct() {
|
||||
assert_eq!(control::IOCTL_ADD, ctl_code(0x900));
|
||||
@@ -25,6 +25,14 @@ aes-gcm = "0.10"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
# Cover-art delivery in the game library: encode Lutris's local JPEGs into `data:` URLs and decode
|
||||
# the Epic launcher's base64 `catcache.bin`. Cross-platform (Linux Lutris art + Windows Epic art).
|
||||
base64 = "0.22"
|
||||
# Blocking HTTP for the library cover-art warmer (no-auth GOG api.gog.com + Xbox displaycatalog),
|
||||
# run on a background thread off the hot path. `ureq` is small + sync (no tokio here) and bundles
|
||||
# webpki roots (no system cert dependency). Cross-platform so the fetch/parse code is compiled +
|
||||
# checked everywhere even though only the Windows GOG/Xbox providers need it today.
|
||||
ureq = "2"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
@@ -85,6 +93,10 @@ wayland-scanner = "0.31"
|
||||
wayland-backend = "0.3"
|
||||
# Parse `pw-dump` JSON to find gamescope's PipeWire node (gamescope backend).
|
||||
serde_json = "1"
|
||||
# Read the Lutris library DB (`pga.db`) for the Lutris store provider. `bundled` vendors + compiles
|
||||
# SQLite (cc, already needed for ffmpeg/opus) so there's no system libsqlite3 runtime dependency —
|
||||
# clean for the deb/rpm/flatpak packaging. Opened read-only/immutable (Lutris may hold it open).
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# Builds/validates the xkb keymap uploaded to the virtual keyboard + tracks modifier state.
|
||||
xkbcommon = "0.8"
|
||||
# The safe `opus` crate is stereo-only; surround (5.1/7.1) needs the libopus *multistream*
|
||||
@@ -155,7 +167,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
# VirtualProtect — for the inline patch of the win32u GPU-preference shim (Apollo's MinHook port:
|
||||
# the hybrid-GPU output-reparenting hook that keeps Desktop Duplication stable on a 4090+iGPU box).
|
||||
# See capture/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# See capture/windows/dxgi.rs `install_gpu_pref_hook`. No trampoline (we fully replace the fn) → no detour
|
||||
# crate / no C length-disassembler dep; a 12-byte absolute-jmp prologue patch suffices.
|
||||
"Win32_System_Memory",
|
||||
# Per-monitor-v2 DPI awareness — IDXGIOutput5::DuplicateOutput1 (the modern capture path Apollo
|
||||
@@ -169,13 +181,19 @@ windows = { version = "0.62", features = [
|
||||
# handler / ServiceManager install). Wraps the Win32 service API; the supervision loop itself uses
|
||||
# the `windows` crate above.
|
||||
windows-service = "0.7"
|
||||
# Read the GOG.com install registry (HKLM\SOFTWARE\WOW6432Node\GOG.com\Games) for the GOG store
|
||||
# provider — ergonomic + correct-by-construction vs. hand-rolled Reg* FFI for subkey enumeration.
|
||||
winreg = "0.56"
|
||||
# Parse each Xbox/Game-Pass game's MicrosoftGame.config (GDK manifest XML) for the Xbox store
|
||||
# provider — a small read-only DOM is all we need (Identity/Executable/ShellVisuals/StoreId).
|
||||
roxmltree = "0.21"
|
||||
# Software H.264 encoder (GPU-less path + NVENC fallback). The default `source` feature statically
|
||||
# compiles OpenH264 (BSD-2) — no system lib, builds on MSVC; nasm on PATH adds the SIMD fast path.
|
||||
openh264 = "0.9"
|
||||
# WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path).
|
||||
wasapi = "0.23"
|
||||
# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver),
|
||||
# driven over shared memory from inject/gamepad_windows.rs — no ViGEmBus dependency.
|
||||
# driven over shared memory from inject/windows/gamepad_windows.rs — no ViGEmBus dependency.
|
||||
# NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with
|
||||
# `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches
|
||||
# cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how
|
||||
@@ -192,7 +210,7 @@ ffmpeg-next = { version = "8", optional = true }
|
||||
# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs,
|
||||
# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs
|
||||
# to/from the DeviceIoControl byte buffers.
|
||||
pf-vdisplay-proto = { path = "../pf-vdisplay-proto" }
|
||||
pf-driver-proto = { path = "../pf-driver-proto" }
|
||||
bytemuck = { version = "1.19", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -88,6 +88,8 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_cap.rs"]
|
||||
mod wasapi_cap;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "audio/windows/wasapi_mic.rs"]
|
||||
mod wasapi_mic;
|
||||
|
||||
+10
@@ -13,6 +13,9 @@
|
||||
//! when the client isn't talking. WASAPI objects are `!Send`, so they live entirely on that thread
|
||||
//! (mirrors `WasapiLoopbackCapturer`).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{VirtualMic, SAMPLE_RATE};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::VecDeque;
|
||||
@@ -154,6 +157,13 @@ fn find_or_install_device() -> Result<wasapi::Device> {
|
||||
Ok(d) => Ok(d),
|
||||
Err(e) => {
|
||||
tracing::info!("no virtual mic device present — attempting auto-install");
|
||||
// SAFETY: `try_install_virtual_mic` is `unsafe` only because it `LoadLibraryExW`s
|
||||
// `newdev.dll` and calls `DiInstallDriverW` through a `transmute`d function pointer;
|
||||
// calling it imposes no extra precondition here (it takes no args and aliases nothing).
|
||||
// Its internal contract holds: the `DiInstall` type matches the documented
|
||||
// `BOOL DiInstallDriverW(HWND, PCWSTR, DWORD, PBOOL)` ABI, and it passes a
|
||||
// NUL-terminated UTF-16 INF path with null/zero optional args. Invoked once on the
|
||||
// dedicated mic thread.
|
||||
if unsafe { try_install_virtual_mic() } {
|
||||
find_device()
|
||||
} else {
|
||||
@@ -2,6 +2,10 @@
|
||||
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
||||
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
||||
|
||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). As a parent module this also covers the child modules (capture::windows/linux::*).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
||||
@@ -44,6 +48,49 @@ impl PixelFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Windows capturer should produce, resolved **once** per session and passed **into**
|
||||
/// [`capture_virtual_output`] (Goal-1 stage 5, plan §2.3/§5). Passing the format in is what lets a
|
||||
/// capturer stop re-deriving the encode backend itself — it kills the
|
||||
/// `capture/dxgi.rs → encode::windows_resolved_backend()` back-reference (the highest-severity coupling:
|
||||
/// capture and encode could otherwise disagree on whether frames are GPU-resident). Neutral type; the
|
||||
/// Linux portal capturer ignores it (it negotiates its own format with PipeWire).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OutputFormat {
|
||||
/// Produce GPU-resident D3D11 frames (zero-copy for a GPU encoder — NVENC/AMF/QSV) rather than CPU
|
||||
/// staging. `false` **only** for the GPU-less software encoder.
|
||||
pub gpu: bool,
|
||||
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `Rgb10a2`; the DDA secure-desktop HDR hint).
|
||||
/// `false` = 8-bit SDR.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
/// Resolve the output format for an entry point that doesn't build a full [`SessionPlan`]
|
||||
/// (`crate::session_plan`) — the GameStream + spike paths: `gpu` from the resolved encode backend,
|
||||
/// `hdr` as given. The native punktfunk/1 path uses `SessionPlan::output_format()` instead (it already
|
||||
/// resolved the encoder), so neither path makes a capturer re-derive it.
|
||||
pub fn resolve(hdr: bool) -> Self {
|
||||
OutputFormat {
|
||||
gpu: gpu_encode(),
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the resolved encode backend produces GPU frames (anything but the software encoder). The single
|
||||
/// source for [`OutputFormat::resolve`]'s `gpu`; on Linux always true (the portal/VAAPI/CUDA path is GPU).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
!matches!(
|
||||
crate::encode::windows_resolved_backend(),
|
||||
crate::encode::WindowsBackend::Software
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn gpu_encode() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A captured frame. [`format`](Self::format)/dimensions describe the pixels regardless of
|
||||
/// where they live — [`payload`](Self::payload) is either a CPU buffer (the spike/fallback path)
|
||||
/// or a GPU buffer already on the device (the zero-copy path, plan §9).
|
||||
@@ -314,9 +361,12 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
_want_hdr: bool,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream), so `want_hdr` is unused here.
|
||||
// The Linux host stays 8-bit (HDR is blocked upstream) and the portal negotiates its own format, so
|
||||
// the `OutputFormat` is unused here; the capture backend is always the portal (the `CaptureBackend`
|
||||
// arg is a Windows-only dispatch — ignored here).
|
||||
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -327,14 +377,16 @@ pub fn capture_virtual_output(
|
||||
/// compiled and comes back the moment the flag is unset.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn wgc_disabled() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_NO_WGC").is_some()
|
||||
crate::config::config().no_wgc
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn capture_virtual_output(
|
||||
vout: crate::vdisplay::VirtualOutput,
|
||||
want_hdr: bool,
|
||||
want: OutputFormat,
|
||||
capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
use crate::session_plan::CaptureBackend;
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||
@@ -343,27 +395,38 @@ pub fn capture_virtual_output(
|
||||
let pref = vout.preferred_mode;
|
||||
let keep = vout.keepalive;
|
||||
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
||||
// ring — no Desktop Duplication, no win32u reparenting hook. Opt-in while it's A/B'd against DDA;
|
||||
// `idd_push` takes the keepalive (owns the virtual display) so there's no fall-through.
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
||||
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
||||
// display) so there's no fall-through.
|
||||
if capture == CaptureBackend::IddPush {
|
||||
// Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a
|
||||
// working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and
|
||||
// the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by
|
||||
// stamping target_id onto the monitor context. The ring is always FP16 (the driver composes
|
||||
// the IDD in FP16); `want_hdr` selects the per-frame conversion (FP16 → Rgb10a2 vs Bgra).
|
||||
return idd_push::IddPushCapturer::open(target, pref, want_hdr, keep)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
// If IDD-push can't open OR the driver doesn't attach to the ring within a few seconds (e.g. a
|
||||
// hybrid-GPU render mismatch), fall back to DDA so the session is NEVER left black (audit §5.1).
|
||||
// `open()` hands the keepalive back on failure so DDA can take ownership of the virtual display.
|
||||
match idd_push::IddPushCapturer::open(target.clone(), pref, want.hdr, keep) {
|
||||
Ok(c) => return Ok(Box::new(c) as Box<dyn Capturer>),
|
||||
Err((e, keep)) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"IDD-push open/attach failed — falling back to DDA"
|
||||
);
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
}
|
||||
}
|
||||
// WGC (Windows.Graphics.Capture) is the default: it captures the COMPOSED desktop including the
|
||||
// overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug),
|
||||
// and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is
|
||||
// the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback.
|
||||
let backend = std::env::var("PUNKTFUNK_CAPTURE")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||
if capture == CaptureBackend::Dda {
|
||||
return dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||
}
|
||||
// WGC default, with a watchdog'd DDA fallback. WGC's Direct3D11CaptureFramePool::CreateFreeThreaded
|
||||
@@ -374,6 +437,11 @@ pub fn capture_virtual_output(
|
||||
// DDA is the safety net (+ the secure-desktop path). The encode thread is set MTA so the WGC
|
||||
// objects built on the watchdog thread (also MTA) are usable here; the keepalive is handed to WGC
|
||||
// only on success, else to DDA. A hung watchdog thread is abandoned (holds no keepalive).
|
||||
// SAFETY: `RoInitialize` is a combase FFI call that initializes the WinRT apartment for the calling
|
||||
// thread. It takes the `RO_INIT_MULTITHREADED` enum by value and borrows no memory, so there is no
|
||||
// pointer/lifetime/aliasing obligation; it is safe on any thread and idempotent — a second call on a
|
||||
// thread already in a compatible apartment returns S_FALSE / RPC_E_CHANGED_MODE, which we discard.
|
||||
// Runs on the encode thread that goes on to use the WGC (WinRT) objects built by the watchdog thread.
|
||||
unsafe {
|
||||
let _ = windows::Win32::System::WinRT::RoInitialize(
|
||||
windows::Win32::System::WinRT::RO_INIT_MULTITHREADED,
|
||||
@@ -393,12 +461,12 @@ pub fn capture_virtual_output(
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "WGC open failed — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("WGC open timed out (CreateFreeThreaded hang on the virtual display) — falling back to DDA");
|
||||
dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||
dxgi::DuplCapturer::open(target, pref, keep, want.gpu, false)
|
||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
}
|
||||
@@ -407,22 +475,31 @@ pub fn capture_virtual_output(
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn capture_virtual_output(
|
||||
_vout: crate::vdisplay::VirtualOutput,
|
||||
_want_hdr: bool,
|
||||
_want: OutputFormat,
|
||||
_capture: crate::session_plan::CaptureBackend,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: the Windows backends live under `capture/windows/`, the Linux one under `capture/linux/`
|
||||
// (`#[path]` keeps the module names flat, so every `crate::capture::*` path is unchanged).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/composed_flip.rs"]
|
||||
pub mod composed_flip;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/desktop_watch.rs"]
|
||||
pub mod desktop_watch;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/dxgi.rs"]
|
||||
pub mod dxgi;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/idd_push.rs"]
|
||||
pub mod idd_push;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc.rs"]
|
||||
pub mod wgc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "capture/windows/wgc_relay.rs"]
|
||||
pub mod wgc_relay;
|
||||
|
||||
+77
@@ -17,6 +17,9 @@
|
||||
//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus
|
||||
//! connection until process exit.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
@@ -498,6 +501,12 @@ mod pipewire {
|
||||
|
||||
impl DmabufMap {
|
||||
fn new(fd: i32, len: usize) -> Option<DmabufMap> {
|
||||
// SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned
|
||||
// dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length.
|
||||
// `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and
|
||||
// returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned
|
||||
// region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying
|
||||
// object mapped independently of `fd` (which may be closed after this returns).
|
||||
let ptr = unsafe {
|
||||
libc::mmap(
|
||||
std::ptr::null_mut(),
|
||||
@@ -514,6 +523,11 @@ mod pipewire {
|
||||
|
||||
impl Drop for DmabufMap {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in
|
||||
// `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns
|
||||
// that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no
|
||||
// double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s
|
||||
// lifetime, so no borrow outlives the unmap.
|
||||
unsafe {
|
||||
libc::munmap(self.ptr, self.len);
|
||||
}
|
||||
@@ -719,6 +733,14 @@ mod pipewire {
|
||||
if !ud.active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
|
||||
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
|
||||
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
|
||||
// before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and
|
||||
// `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast
|
||||
// `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe
|
||||
// exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so
|
||||
// this `&mut` slice is the only reference to it (no aliasing/data race).
|
||||
let datas: &mut [pw::spa::buffer::Data] = unsafe {
|
||||
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
|
||||
&mut []
|
||||
@@ -783,6 +805,10 @@ mod pipewire {
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. (Content stability across the brief map+CSC window relies on
|
||||
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
// The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0).
|
||||
let dup =
|
||||
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if dup >= 0 {
|
||||
@@ -796,6 +822,10 @@ mod pipewire {
|
||||
pts_ns,
|
||||
format: fmt,
|
||||
payload: FramePayload::Dmabuf(DmabufFrame {
|
||||
// SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned
|
||||
// (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole
|
||||
// ownership and closes it exactly once on drop — no alias, no
|
||||
// double-close.
|
||||
fd: unsafe { OwnedFd::from_raw_fd(dup) },
|
||||
fourcc,
|
||||
modifier: ud.modifier,
|
||||
@@ -930,6 +960,11 @@ mod pipewire {
|
||||
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
|
||||
// trust `d.data()`.
|
||||
let fd_len = if raw_fd > 0 {
|
||||
// SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so
|
||||
// `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and
|
||||
// valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned,
|
||||
// correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only
|
||||
// after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it.
|
||||
unsafe {
|
||||
let mut st: libc::stat = std::mem::zeroed();
|
||||
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
|
||||
@@ -946,6 +981,14 @@ mod pipewire {
|
||||
match DmabufMap::new(raw_fd as i32, map_len) {
|
||||
Some(m) => {
|
||||
_mapping = m;
|
||||
// SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a
|
||||
// successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned,
|
||||
// and the VMA is one allocated object of `len` bytes valid for reads. In the common
|
||||
// path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the
|
||||
// whole object; the de-pad copy below is further bounded by the `offset <= buf.len()`
|
||||
// and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end
|
||||
// of `consume_frame`, so the slice never outlives the mapping, and the memory is only
|
||||
// read here, so there is no aliasing/mutation.
|
||||
Some(unsafe {
|
||||
std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len)
|
||||
})
|
||||
@@ -1177,24 +1220,43 @@ mod pipewire {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and
|
||||
// recycles its pool; an older queued buffer carries a STALE frame. Drain all
|
||||
// queued buffers, requeue the older ones, keep only the newest.
|
||||
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on
|
||||
// the loop thread, where `pw_stream_dequeue_buffer` is the documented call. It returns
|
||||
// a `*mut pw_buffer` owned by the stream (or null when the queue is drained),
|
||||
// null-checked before any use. The loop is single-threaded, so no concurrent access.
|
||||
let mut newest = unsafe { stream.dequeue_raw_buffer() };
|
||||
if newest.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut drained = 1u32;
|
||||
loop {
|
||||
// SAFETY: same stream/loop-thread contract as the dequeue above; each call returns
|
||||
// the next stream-owned `*mut pw_buffer` or null (null-checked before use).
|
||||
let next = unsafe { stream.dequeue_raw_buffer() };
|
||||
if next.is_null() {
|
||||
break;
|
||||
}
|
||||
// SAFETY: `newest` is a non-null `*mut pw_buffer` previously dequeued from this same
|
||||
// stream and not yet requeued; `pw_stream_queue_buffer` hands ownership back to the
|
||||
// stream. We immediately overwrite `newest = next`, so the requeued pointer is never
|
||||
// touched again (no use-after-requeue). Loop thread, single-threaded.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
newest = next;
|
||||
drained += 1;
|
||||
}
|
||||
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
|
||||
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
|
||||
// load through a valid pointer — no mutation or aliasing.
|
||||
let spa_buf = unsafe { (*newest).buffer };
|
||||
|
||||
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
|
||||
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
|
||||
// `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header`
|
||||
// of at least `size_of::<spa_meta_header>()` bytes and returns a pointer into the held
|
||||
// buffer's metadata (or null). The size argument matches the struct the result is cast
|
||||
// to, and the pointer stays valid as long as the buffer is held (until requeue). Null is
|
||||
// handled below.
|
||||
let hdr = unsafe {
|
||||
spa::sys::spa_buffer_find_meta_data(
|
||||
spa_buf,
|
||||
@@ -1205,11 +1267,20 @@ mod pipewire {
|
||||
let hdr_flags = if hdr.is_null() {
|
||||
0u32
|
||||
} else {
|
||||
// SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header`
|
||||
// inside the live buffer's metadata (returned for a size >=
|
||||
// `size_of::<spa_meta_header>()`, so `.flags` is in bounds). A single field read
|
||||
// while the buffer is still held.
|
||||
unsafe { (*hdr).flags }
|
||||
};
|
||||
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
|
||||
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
|
||||
// stale skip only applies to mappable SHM buffers).
|
||||
// SAFETY: every dereference is guarded in order before any field read — `spa_buf`
|
||||
// non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first
|
||||
// element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c`
|
||||
// its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads
|
||||
// of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation.
|
||||
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
|
||||
if !spa_buf.is_null()
|
||||
&& (*spa_buf).n_datas > 0
|
||||
@@ -1246,11 +1317,17 @@ mod pipewire {
|
||||
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
|
||||
);
|
||||
}
|
||||
// SAFETY: `newest` is the non-null buffer we own (dequeued, never requeued on this
|
||||
// skip path); hand it back to the stream exactly once and return without touching it
|
||||
// again. Loop thread inside `.process`.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
return;
|
||||
}
|
||||
|
||||
consume_frame(ud, spa_buf);
|
||||
// SAFETY: `consume_frame` has finished reading `spa_buf` (and the `datas` borrows derived
|
||||
// from `newest`), so requeuing the owned `newest` exactly once here is sound — no
|
||||
// use-after-requeue. Loop thread inside `.process`.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
+10
@@ -15,6 +15,9 @@
|
||||
//! composed while a session is live). Effectiveness can be build/driver-dependent; gated by
|
||||
//! `PUNKTFUNK_FORCE_COMPOSED` (default ON; set =0 to disable).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use windows::core::w;
|
||||
@@ -48,6 +51,10 @@ impl ForceComposedFlip {
|
||||
let st = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("composed-flip".into())
|
||||
// SAFETY: `run` is this module's `unsafe fn` (it owns a desktop+window lifecycle via Win32
|
||||
// FFI); it takes ownership of `st` (the stop `Arc<AtomicBool>`) and has no caller-side memory
|
||||
// precondition. It is designed to own its thread for its whole duration — exactly the
|
||||
// dedicated `composed-flip` thread spawned here.
|
||||
.spawn(move || unsafe { run(st) })
|
||||
.ok()?;
|
||||
tracing::info!("force-composed-flip overlay started (Winlogon-aware)");
|
||||
@@ -62,6 +69,9 @@ impl Drop for ForceComposedFlip {
|
||||
}
|
||||
|
||||
extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
|
||||
// SAFETY: this is the window procedure the OS invokes with the window's own `hwnd` and a real
|
||||
// message `(msg, wp, lp)`. `DefWindowProcW` performs default processing for exactly those
|
||||
// parameters (all passed straight through by value); it borrows no Rust memory and is synchronous.
|
||||
unsafe { DefWindowProcW(hwnd, msg, wp, lp) }
|
||||
}
|
||||
|
||||
+10
@@ -7,6 +7,9 @@
|
||||
//! desktop's NAME (WTS session notifications miss UAC entirely, so the name is the reliable signal)
|
||||
//! and publishes it as an atomic the capture mux + input path read.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -33,6 +36,10 @@ impl DesktopWatcher {
|
||||
// mux) sees the real state immediately. Otherwise a session that begins already on the secure
|
||||
// desktop (e.g. a reconnect to a locked box) would read DESKTOP_NORMAL for the first poll
|
||||
// interval and relay one stale normal-desktop frame — the "flash of the login screen" bug.
|
||||
// SAFETY: `is_secure_desktop` is this module's `unsafe fn` — unsafe only because it calls Win32
|
||||
// desktop FFI (`OpenInputDesktop`/`GetUserObjectInformationW`/`CloseDesktop`), with no caller
|
||||
// precondition; it opens, names, and closes the input-desktop handle internally and is safe to
|
||||
// call from any thread (here, on the thread running `DesktopWatcher::start`).
|
||||
let initial = if unsafe { is_secure_desktop() } {
|
||||
DESKTOP_SECURE
|
||||
} else {
|
||||
@@ -53,6 +60,9 @@ impl DesktopWatcher {
|
||||
let mut candidate = initial;
|
||||
let mut stable = 0u32;
|
||||
while !st.load(Ordering::Relaxed) {
|
||||
// SAFETY: same as in `start` — `is_secure_desktop` is self-contained Win32 desktop
|
||||
// FFI with no caller precondition, called here on the dedicated `desktop-watch`
|
||||
// polling thread.
|
||||
let v = if unsafe { is_secure_desktop() } {
|
||||
DESKTOP_SECURE
|
||||
} else {
|
||||
+75
-14
@@ -7,6 +7,9 @@
|
||||
//! Validates only with a real GPU + an *activated* SudoVDA monitor (`DuplicateOutput` needs a live
|
||||
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::ffi::c_void;
|
||||
@@ -69,7 +72,12 @@ pub struct D3d11Frame {
|
||||
pub texture: ID3D11Texture2D,
|
||||
pub device: ID3D11Device,
|
||||
}
|
||||
// COM pointers, used only from the single owning thread.
|
||||
// SAFETY: `D3d11Frame` owns an `ID3D11Texture2D` + `ID3D11Device`, which are COM interface pointers.
|
||||
// D3D11 devices/resources use thread-safe (interlocked) COM reference counting, and the device is
|
||||
// created free-threaded (`make_device` passes no `D3D11_CREATE_DEVICE_SINGLETHREADED`), so handing
|
||||
// ownership of the frame to another thread — the capture→encode handoff — and releasing it there is
|
||||
// sound. The value is moved, never aliased (no `Sync`), so there is no concurrent use of the
|
||||
// single-threaded immediate context.
|
||||
unsafe impl Send for D3d11Frame {}
|
||||
|
||||
pub fn pack_luid(luid: LUID) -> i64 {
|
||||
@@ -295,6 +303,12 @@ unsafe fn d3dkmt_set_scheduling_priority_class(
|
||||
fn elevate_process_gpu_priority() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
// SAFETY: the closure calls two of this module's `unsafe fn`s — `enable_inc_base_priority`
|
||||
// (adjusts the current-process token; it has no caller precondition and builds all its FFI args
|
||||
// locally) and `d3dkmt_set_scheduling_priority_class` (loads gdi32 by name and calls the export).
|
||||
// The latter requires `process` to be a valid process handle; `GetCurrentProcess()` returns the
|
||||
// current-process pseudo-handle, which is always valid and needs no close. Runs once via
|
||||
// `Once::call_once`; no raw pointers are dereferenced here.
|
||||
ONCE.call_once(|| unsafe {
|
||||
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||
let Some(prio) = configured_gpu_priority_class() else {
|
||||
@@ -538,6 +552,17 @@ unsafe extern "system" fn hybrid_query_hook(gpu_preference: *mut u32) -> i32 {
|
||||
pub(crate) fn install_gpu_pref_hook() {
|
||||
use std::sync::Once;
|
||||
static HOOK: Once = Once::new();
|
||||
// SAFETY: this one-time hook install only touches a region it has just validated.
|
||||
// `LoadLibraryA("win32u.dll")` + `GetProcAddress("NtGdiDdDDIGetCachedHybridQueryValue")` yield the
|
||||
// live base of the real exported function, so `target` is a valid executable code pointer to at
|
||||
// least the 12 bytes the patch overwrites (an x64 prologue, per Apollo's verified hook). The two
|
||||
// `ptr::copy_nonoverlapping`s each move exactly 12 bytes between the 12-byte stack arrays
|
||||
// (`patch`/`readback`) and `target`, which `VirtualProtect(target, 12, PAGE_EXECUTE_READWRITE, …)`
|
||||
// has just made writable (and is restored to `old` after) — source and dest never overlap (stack
|
||||
// vs. loaded module image), so every access stays in mapped, in-bounds memory.
|
||||
// `FlushInstructionCache` gets the current-process pseudo-handle + that same range. The DPI calls
|
||||
// take by-value context handles / fill the live local `&mut old`/`&mut restore` for the duration of
|
||||
// each synchronous call. Runs once via `Once::call_once`, before any DXGI use.
|
||||
HOOK.call_once(|| unsafe {
|
||||
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
|
||||
use windows::Win32::System::Memory::{
|
||||
@@ -1389,6 +1414,14 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
@@ -2038,7 +2071,11 @@ pub struct DuplCapturer {
|
||||
dbg_cursor: u64,
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// COM objects used only from the one thread that owns the capturer (the encode thread).
|
||||
// SAFETY: `DuplCapturer` holds D3D11 device/context/duplication COM pointers plus plain data. The
|
||||
// device is created free-threaded (`make_device` sets no `D3D11_CREATE_DEVICE_SINGLETHREADED`) and
|
||||
// COM reference counting is interlocked, so moving ownership of the whole capturer to another thread
|
||||
// is sound. It is used by exactly one thread (the encode thread) at a time — moved to it once, never
|
||||
// shared (no `Sync`) — so the single-threaded immediate context is never touched concurrently.
|
||||
unsafe impl Send for DuplCapturer {}
|
||||
|
||||
impl DuplCapturer {
|
||||
@@ -2046,8 +2083,18 @@ impl DuplCapturer {
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
keepalive: Box<dyn Send>,
|
||||
// Whether the (already-resolved) encode backend wants GPU-resident frames — passed IN (Goal-1
|
||||
// stage 5) so the capturer never re-derives the encode backend itself.
|
||||
gpu: bool,
|
||||
want_hdr: bool,
|
||||
) -> Result<Self> {
|
||||
// SAFETY: runs on the capture thread that will own this `DuplCapturer`. `install_gpu_pref_hook()`
|
||||
// and the DPI-context calls take by-value handles / no args and touch only thread/process state;
|
||||
// `SetThreadExecutionState` takes a flags bitmask by value. `CreateDXGIFactory1` yields a live
|
||||
// `IDXGIFactory1`, and every subsequent COM method (`EnumAdapters1`/`EnumOutputs`/`GetDesc1`/
|
||||
// `GetDesc`/`cast`) is called on that factory or on an adapter/output it returned — each obtained
|
||||
// through a checked `while let Ok(..)`/`?` — all from this one thread. No raw pointers are
|
||||
// dereferenced; the borrowed strings/locals outlive each synchronous call.
|
||||
unsafe {
|
||||
// Stop DXGI hybrid-GPU output reparenting BEFORE we create the factory / enumerate outputs
|
||||
// (the cause of the 0x887A0026 ACCESS_LOST churn on this hybrid box: RTX 4090 + AMD iGPU).
|
||||
@@ -2183,9 +2230,9 @@ impl DuplCapturer {
|
||||
let context = context.context("null D3D11 context")?;
|
||||
// 3) duplicate the output. Attach to the current input desktop first (as SYSTEM this can
|
||||
// be the Winlogon secure desktop) so a session that starts at the lock/login screen works.
|
||||
// The SudoVDA is kept the sole desktop via the CCD isolation in sudovda::create_monitor
|
||||
// (registry-persisted), so the secure desktop has nowhere to render but the output we
|
||||
// capture — no per-open re-isolation needed.
|
||||
// The virtual display is kept the sole desktop via the CCD isolation the pf-vdisplay backend
|
||||
// applies at monitor creation (registry-persisted), so the secure desktop has nowhere to render
|
||||
// but the output we capture — no per-open re-isolation needed.
|
||||
attach_input_desktop();
|
||||
let dupl = duplicate_output(&output, &device, want_hdr)
|
||||
.context("DuplicateOutput (already duplicated by another app?)")?;
|
||||
@@ -2213,14 +2260,13 @@ impl DuplCapturer {
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or((2000 / refresh_hz.max(1)).max(100));
|
||||
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV
|
||||
// backends read back / import) whenever the resolved encode backend is a GPU one — so the
|
||||
// capturer's output format matches the encoder's input. Only the software (GPU-less) path
|
||||
// takes CPU staging. Mirrors `encode::open_video`'s dispatch exactly.
|
||||
let gpu_mode = !matches!(
|
||||
crate::encode::windows_resolved_backend(),
|
||||
crate::encode::WindowsBackend::Software
|
||||
);
|
||||
// Produce GPU-resident D3D11 frames (zero-copy NVENC, or the NV12/P010 the AMF/QSV backends
|
||||
// read back / import) whenever the encode backend is a GPU one — so the capturer's output
|
||||
// format matches the encoder's input. Only the software (GPU-less) path takes CPU staging.
|
||||
// The decision is resolved ONCE per session and passed in (Goal-1 stage 5), instead of this
|
||||
// capturer re-calling `encode::windows_resolved_backend()` — the back-reference that let
|
||||
// capture and encode disagree (plan §2.3/§5).
|
||||
let gpu_mode = gpu;
|
||||
// Read the source display's HDR mastering metadata while we still hold `output` (it is
|
||||
// moved into the struct below). Only meaningful for an HDR (FP16) duplication.
|
||||
let is_hdr_init = dd.ModeDesc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT;
|
||||
@@ -2712,7 +2758,7 @@ impl DuplCapturer {
|
||||
}
|
||||
// The SudoVDA output's GDI name can CHANGE across a secure-desktop topology rebuild —
|
||||
// re-resolve from the STABLE target id so we find it under its current name.
|
||||
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(self.target_id) {
|
||||
if let Some(n) = crate::win_display::resolve_gdi_name(self.target_id) {
|
||||
self.gdi_name = n;
|
||||
}
|
||||
// Re-sync the capture thread to the CURRENT input desktop on EVERY rebuild — symmetric for
|
||||
@@ -3205,6 +3251,11 @@ impl Capturer for DuplCapturer {
|
||||
// the duplication up to 12 s). Better a few seconds of frozen-last-frame than dropping the stream.
|
||||
let mut deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
// SAFETY: `acquire` is an `unsafe fn` because it drives the D3D11 immediate context + the
|
||||
// output duplication, which must be touched only from the capturer's owning thread.
|
||||
// `next_frame` runs on that one thread — `DuplCapturer` is `Send` but not `Sync`, so it is
|
||||
// owned by a single (encode) thread for its whole life — and `&mut self` gives exclusive
|
||||
// access for the call, satisfying that contract.
|
||||
if let Some(f) = unsafe { self.acquire() }? {
|
||||
self.ever_got_frame = true;
|
||||
return Ok(f);
|
||||
@@ -3251,6 +3302,8 @@ impl Capturer for DuplCapturer {
|
||||
}
|
||||
|
||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
// SAFETY: as in `next_frame` — `acquire` must run on the capturer's single owning thread, and
|
||||
// `try_latest` is called on it (`DuplCapturer` is `Send`, not `Sync`); `&mut self` is exclusive.
|
||||
unsafe { self.acquire() }
|
||||
}
|
||||
|
||||
@@ -3262,11 +3315,19 @@ impl Capturer for DuplCapturer {
|
||||
impl Drop for DuplCapturer {
|
||||
fn drop(&mut self) {
|
||||
if self.holding_frame {
|
||||
// SAFETY: `self.dupl` is the live `IDXGIOutputDuplication` this capturer created and owns;
|
||||
// `ReleaseFrame` is a valid COM method on it, called only when `holding_frame` records that a
|
||||
// frame was acquired and not yet released (so it is not an unbalanced release). Drop runs on
|
||||
// whichever thread owns the capturer — its sole owner, since it is `!Sync` — and the `&`
|
||||
// borrow of the duplication outlives this synchronous call.
|
||||
unsafe {
|
||||
let _ = self.dupl.as_ref().map(|d| d.ReleaseFrame());
|
||||
}
|
||||
}
|
||||
// Release the display/system-required execution state we took at open().
|
||||
// SAFETY: `SetThreadExecutionState` is a Win32 FFI call taking an execution-state flag bitmask
|
||||
// by value (`ES_CONTINUOUS` clears the display/system-required state taken at open); it borrows
|
||||
// no Rust memory and is safe to call from any thread.
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
}
|
||||
+424
-223
@@ -7,26 +7,28 @@
|
||||
//! `PUNKTFUNK_IDD_PUSH`. Driver counterpart: `packaging/windows/drivers/pf-vdisplay/src/
|
||||
//! frame_transport.rs`. The shared `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||
//! `DRV_STATUS_*` codes, the `Global\` name scheme and the publish token all come from
|
||||
//! [`pf_vdisplay_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides
|
||||
//! [`pf_driver_proto::frame`] (which OWNS the contract, with `const` size asserts) — both sides
|
||||
//! `use` it, so drift is a compile error rather than a "must match" comment.
|
||||
|
||||
use super::dxgi::{make_device, D3d11Frame, HdrConverter, WinCaptureTarget};
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{make_device, D3d11Frame, HdrP010Converter, VideoConverter, WinCaptureTarget};
|
||||
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use pf_vdisplay_proto::frame;
|
||||
use pf_driver_proto::frame;
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use windows::core::{w, Interface, HSTRING};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE, LUID};
|
||||
use windows::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE, LUID};
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11RenderTargetView, ID3D11ShaderResourceView,
|
||||
ID3D11Texture2D, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
|
||||
D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
||||
D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM,
|
||||
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010,
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_SAMPLE_DESC,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::{
|
||||
@@ -43,7 +45,7 @@ use windows::Win32::System::Memory::{
|
||||
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};
|
||||
|
||||
// The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the
|
||||
// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_vdisplay_proto::frame`; both sides
|
||||
// `DRV_STATUS_*` codes and the `Global\` name helpers — lives in `pf_driver_proto::frame`; both sides
|
||||
// `use frame::*`, so a layout/name/code drift is a compile error (the proto has `const` size asserts).
|
||||
use frame::{
|
||||
event_name, header_name, texture_name, SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
|
||||
@@ -60,7 +62,7 @@ const DXGI_SHARED_RESOURCE_RW: u32 = 0x8000_0000 | 0x1;
|
||||
const OUT_RING: usize = 3;
|
||||
|
||||
/// Bring-up debug block (fixed name) — the host creates it; the driver writes diagnostics into it
|
||||
/// independent of the per-target header. NOT part of `pf_vdisplay_proto` (a host-side bring-up channel,
|
||||
/// independent of the per-target header. NOT part of `pf_driver_proto` (a host-side bring-up channel,
|
||||
/// not the data path); the matching `DebugBlock` lives in the OLD oracle driver's `frame_transport.rs`.
|
||||
#[repr(C)]
|
||||
struct DebugBlock {
|
||||
@@ -90,20 +92,78 @@ fn now_ns() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// RAII wrapper for a file-mapping object + its mapped view: on drop the view is `UnmapViewOfFile`'d,
|
||||
/// THEN the [`OwnedHandle`] closes the underlying mapping object (order matters — unmap before close).
|
||||
/// A `header`/`dbg_block` raw pointer borrows into the view via [`ptr`](Self::ptr); the section must
|
||||
/// outlive it (it's declared before it in [`IddPushCapturer`], and moving the section doesn't move the
|
||||
/// OS mapping, so the borrowed pointer stays valid).
|
||||
struct MappedSection {
|
||||
handle: OwnedHandle,
|
||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||
}
|
||||
|
||||
impl MappedSection {
|
||||
/// The mapped view base as a `*mut T` (a borrow into the section; valid only while it lives).
|
||||
fn ptr<T>(&self) -> *mut T {
|
||||
self.view.Value as *mut T
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MappedSection {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `view` is the live view we created with `MapViewOfFile` and have not yet unmapped;
|
||||
// unmap it BEFORE `handle` (the OwnedHandle) closes the mapping object — order matters.
|
||||
unsafe {
|
||||
let _ = UnmapViewOfFile(self.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HostSlot {
|
||||
tex: ID3D11Texture2D,
|
||||
mutex: IDXGIKeyedMutex,
|
||||
shared: HANDLE,
|
||||
/// The named shared-resource handle, held only to keep the resource alive (the driver opens it by
|
||||
/// NAME). An [`OwnedHandle`] so it closes on drop (was a manual `CloseHandle` in a `Drop` impl);
|
||||
/// never read directly — its sole purpose is the RAII close.
|
||||
#[allow(dead_code)]
|
||||
shared: OwnedHandle,
|
||||
/// SRV on the slot texture so the HDR path samples the FP16 slot DIRECTLY (no slot→scratch copy);
|
||||
/// the convert pass writes the output ring while holding the slot's keyed mutex. Unused for SDR
|
||||
/// (which CopyResource's the BGRA slot straight to the output).
|
||||
srv: ID3D11ShaderResourceView,
|
||||
}
|
||||
|
||||
impl Drop for HostSlot {
|
||||
/// RAII guard over an [`IDXGIKeyedMutex`]: [`acquire`](Self::acquire) does `AcquireSync(key, timeout)`,
|
||||
/// `Drop` does `ReleaseSync(key)`. So the lock is released even if the work between acquire and the end
|
||||
/// of the guard's scope `?`-returns or panics — the "leak the keyed-mutex lock → stall the driver on
|
||||
/// that slot" footgun the consume loop guards against by hand. Keeps the hot loop free of a raw
|
||||
/// `ReleaseSync` that a future early-return could skip.
|
||||
struct KeyedMutexGuard<'a> {
|
||||
mutex: &'a IDXGIKeyedMutex,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl<'a> KeyedMutexGuard<'a> {
|
||||
/// Acquire `mutex` at `key`, waiting up to `timeout_ms`. `None` if the acquire times out / errors
|
||||
/// (the caller skips the frame), so the guard is only ever held when the lock is genuinely held.
|
||||
fn acquire(
|
||||
mutex: &'a IDXGIKeyedMutex,
|
||||
key: u64,
|
||||
timeout_ms: u32,
|
||||
) -> Option<KeyedMutexGuard<'a>> {
|
||||
// SAFETY: `mutex` is a live `IDXGIKeyedMutex` on this thread's immediate-context device.
|
||||
if unsafe { mutex.AcquireSync(key, timeout_ms) }.is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(KeyedMutexGuard { mutex, key })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for KeyedMutexGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: we hold `mutex` at `key` (acquired in `acquire`, never released elsewhere); release it.
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.shared);
|
||||
let _ = self.mutex.ReleaseSync(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,10 +173,17 @@ pub struct IddPushCapturer {
|
||||
device: ID3D11Device,
|
||||
context: ID3D11DeviceContext,
|
||||
target_id: u32,
|
||||
map: HANDLE,
|
||||
/// Owns the shared-header file mapping + its mapped view (RAII unmap-then-close). Declared BEFORE
|
||||
/// `header`, which is a raw pointer borrowed into this view via [`MappedSection::ptr`]. Never read
|
||||
/// directly (the `header` pointer is) — held purely so the mapping outlives the capturer.
|
||||
#[allow(dead_code)]
|
||||
section: MappedSection,
|
||||
header: *mut SharedHeader,
|
||||
event: HANDLE,
|
||||
dbg_map: HANDLE,
|
||||
event: OwnedHandle,
|
||||
/// Owns the bring-up debug section (mapping + view), or `None` when the debug block wasn't created.
|
||||
/// Never read directly (the `dbg_block` pointer is) — held purely for the RAII unmap/close.
|
||||
#[allow(dead_code)]
|
||||
dbg_section: Option<MappedSection>,
|
||||
dbg_block: *mut DebugBlock,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -136,118 +203,39 @@ pub struct IddPushCapturer {
|
||||
/// Throttle for the `advanced_color_enabled` poll (a CCD `QueryDisplayConfig`, ~ms — too costly per
|
||||
/// frame at 240 Hz).
|
||||
last_acm_poll: Instant,
|
||||
/// Host-owned ROTATING output ring NVENC encodes (texture + RTV per slot). Rotating it per frame is
|
||||
/// the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||
/// ASIC, frame N+1's convert/copy writes a DIFFERENT texture on the 3D engine — the two overlap. The
|
||||
/// HDR convert and the SDR copy both write into the current slot. Format = `out_format()` (Rgb10a2 in
|
||||
/// HDR, Bgra in SDR); rebuilt on a display-mode flip. Built lazily.
|
||||
out_ring: Vec<(ID3D11Texture2D, ID3D11RenderTargetView)>,
|
||||
/// Set when a display-descriptor change triggered a ring recreate (recovery, game-capture bug GB1);
|
||||
/// cleared when a fresh frame resumes. If it stays set past the recovery window, `try_consume` drops
|
||||
/// the session (recover-or-drop, no DDA).
|
||||
recovering_since: Option<Instant>,
|
||||
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
||||
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
||||
/// NV12 (SDR, BT.709 limited) or P010 (HDR, BT.2020 PQ limited), so NVENC takes native YUV and skips
|
||||
/// its internal RGB→YUV CSC on the SM/3D engine the game saturates (plan §5.A). Rebuilt on a
|
||||
/// display-mode flip. Built lazily.
|
||||
out_ring: Vec<ID3D11Texture2D>,
|
||||
out_idx: usize,
|
||||
/// FP16 scRGB → `Rgb10a2` BT.2020 PQ converter, used while the display is HDR. Built lazily.
|
||||
hdr_conv: Option<HdrConverter>,
|
||||
/// BGRA slot → NV12 (BT.709 limited) on the dedicated D3D11 VIDEO engine, used while the display is
|
||||
/// SDR — keeps the colour-convert OFF the contended 3D/compute engine. Built lazily; rebuilt on a
|
||||
/// size/HDR flip.
|
||||
video_conv: Option<VideoConverter>,
|
||||
/// FP16 scRGB slot → P010 (BT.2020 PQ limited) via two shader passes, used while the display is HDR
|
||||
/// (NVIDIA's VideoProcessor can't do RGB→P010). The passes run on the 3D engine, but it still skips
|
||||
/// NVENC's internal SM-side CSC. Built lazily.
|
||||
hdr_p010_conv: Option<HdrP010Converter>,
|
||||
last_seq: u64,
|
||||
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
|
||||
status_logged: bool,
|
||||
/// The monitor generation this capturer was opened for. When the active monitor gen changes (a
|
||||
/// reconnect preempted + recreated the monitor), `next_frame` bails immediately so this session
|
||||
/// releases its NVENC encoder instead of lingering on the dead ring's 20s deadline.
|
||||
my_gen: u64,
|
||||
_keepalive: Box<dyn Send>,
|
||||
}
|
||||
// COM objects used only from the owning (encode) thread.
|
||||
// SAFETY: `IddPushCapturer` is `!Send` only because of its `*mut SharedHeader`/`*mut DebugBlock` raw
|
||||
// pointers (and the COM interfaces). It is created, used, and dropped by a SINGLE thread — the owning
|
||||
// capture/encode thread — never shared: the `ID3D11DeviceContext` is the device's IMMEDIATE context
|
||||
// (single-threaded by D3D11 contract) and is only ever touched from that thread, and the header/
|
||||
// dbg_block pointers (into mappings this struct owns) are only dereferenced there. `Send` transfers
|
||||
// ownership to one thread at a time with NO concurrent access; we do not (and must not) claim `Sync`.
|
||||
unsafe impl Send for IddPushCapturer {}
|
||||
|
||||
/// The persistent IDD-push capturer, kept alive for the host lifetime and SHARED across client
|
||||
/// sessions. The driver's per-session monitor TEARDOWN→RECREATE path is unstable (on session 2 the
|
||||
/// target-id resolves to 0, `IddCxSwapChainSetDevice` fails `0x80070057`, then an access violation),
|
||||
/// while the FIRST-session path is solid. So we create the monitor + ring + swap-chain ONCE and hand
|
||||
/// every later session a thin handle delegating to this one. The persistent capturer holds a monitor
|
||||
/// lease for the host lifetime, so `VirtualDisplay::create` always JOINs the same live monitor (same
|
||||
/// target id) and the reuse match always hits — no recreate, no driver crash. Prototype scope:
|
||||
/// single-client, single-mode (a different mode would need a recreate, the unstable path).
|
||||
static IDD_PERSIST: Mutex<Option<IddPushCapturer>> = Mutex::new(None);
|
||||
|
||||
/// Open the IDD-push capturer, reusing the persistent one across sessions (see [`IDD_PERSIST`]).
|
||||
pub fn open_or_reuse(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let (w, h, _) =
|
||||
preferred.context("IDD push needs the negotiated mode (WxH) to size the ring")?;
|
||||
let mut slot = IDD_PERSIST.lock().unwrap();
|
||||
let reuse = matches!(slot.as_ref(), Some(c) if c.target_id == target.target_id && c.width == w && c.height == h);
|
||||
match slot.as_mut() {
|
||||
Some(c) if reuse => {
|
||||
// Reuse: the persistent capturer already owns the monitor + ring + driver attach. Drop the
|
||||
// new per-session monitor lease (the persistent capturer's lease keeps the monitor live).
|
||||
// The ring tracks the display, not the client; only the client's 10-bit cap can differ.
|
||||
drop(keepalive);
|
||||
c.set_client_10bit(client_10bit);
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
client_10bit,
|
||||
"IDD push: reusing the persistent capturer (no monitor/ring recreate)"
|
||||
);
|
||||
}
|
||||
Some(c) => bail!(
|
||||
"IDD-push persistent capturer is {}x{} target {}, this session wants {}x{} target {} — a \
|
||||
mode/target change needs a recreate (the driver's recreate path is unstable); not \
|
||||
supported in the persistent prototype",
|
||||
c.width,
|
||||
c.height,
|
||||
c.target_id,
|
||||
w,
|
||||
h,
|
||||
target.target_id
|
||||
),
|
||||
None => {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
client_10bit,
|
||||
"IDD push: creating the persistent capturer (first session)"
|
||||
);
|
||||
*slot = Some(IddPushCapturer::open(target, preferred, client_10bit, keepalive)?);
|
||||
}
|
||||
}
|
||||
Ok(Box::new(IddReuseHandle))
|
||||
}
|
||||
|
||||
/// Thin per-session handle: every method delegates to the single persistent [`IddPushCapturer`].
|
||||
/// Dropping it (session end) does NOT tear down the ring/monitor — that's the whole point.
|
||||
struct IddReuseHandle;
|
||||
impl Capturer for IddReuseHandle {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
IDD_PERSIST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.context("IDD-push persistent capturer missing")?
|
||||
.next_frame()
|
||||
}
|
||||
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
||||
IDD_PERSIST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.context("IDD-push persistent capturer missing")?
|
||||
.try_latest()
|
||||
}
|
||||
fn set_active(&self, active: bool) {
|
||||
if let Some(c) = IDD_PERSIST.lock().unwrap().as_ref() {
|
||||
c.set_active(active);
|
||||
}
|
||||
}
|
||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||
IDD_PERSIST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.and_then(|c| c.hdr_meta())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a permissive (Everyone:GenericAll) `SECURITY_ATTRIBUTES` so the restricted WUDFHost driver
|
||||
/// can OPEN the host-created objects — the same `D:(A;;GA;;;WD)` SDDL the gamepad shared section uses.
|
||||
/// The returned `psd` backing must outlive `sa`; both are dropped when the process exits.
|
||||
@@ -315,6 +303,8 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(texture_name(target_id, generation, k)),
|
||||
)
|
||||
.context("CreateSharedHandle(IDD-push ring slot)")?;
|
||||
// Own the shared handle so the slot's `Drop` closes it via RAII (was a manual `CloseHandle`).
|
||||
let shared = OwnedHandle::from_raw_handle(shared.0 as _);
|
||||
let mutex: IDXGIKeyedMutex = tex.cast()?;
|
||||
let mut srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
@@ -331,14 +321,49 @@ impl IddPushCapturer {
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
|
||||
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
|
||||
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
|
||||
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
|
||||
pub fn open(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
keepalive: Box<dyn Send>,
|
||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||
match Self::open_inner(target, preferred, client_10bit) {
|
||||
Ok(mut me) => {
|
||||
me._keepalive = keepalive;
|
||||
Ok(me)
|
||||
}
|
||||
Err(e) => Err((e, keepalive)),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_inner(
|
||||
target: WinCaptureTarget,
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
client_10bit: bool,
|
||||
) -> Result<Self> {
|
||||
let (w, h, _hz) = preferred
|
||||
let (pw, ph, _hz) = preferred
|
||||
.context("IDD push needs the negotiated mode (WxH) to size the shared ring")?;
|
||||
// Size the ring to the display's ACTUAL current resolution if it differs from the negotiated mode:
|
||||
// a fullscreen game can hold the virtual display at a different mode (esp. across a reconnect), so
|
||||
// matching the actual mode lets the first frame flow instead of being dropped (game-capture bug
|
||||
// GB1). Falls back to the negotiated mode when the CCD read is unavailable.
|
||||
// SAFETY: `active_resolution` is an `unsafe fn` (Win32 CCD `QueryDisplayConfig`) that takes only a
|
||||
// copy of the plain `u32` CCD target id and returns owned `(w, h)` values; it forms no borrows from
|
||||
// us and validates the id internally, returning `None` on any failure (handled by `unwrap_or`).
|
||||
let (w, h) =
|
||||
unsafe { crate::win_display::active_resolution(target.target_id) }.unwrap_or((pw, ph));
|
||||
if (w, h) != (pw, ph) {
|
||||
tracing::info!(
|
||||
target_id = target.target_id,
|
||||
negotiated = format!("{pw}x{ph}"),
|
||||
actual = format!("{w}x{h}"),
|
||||
"IDD push: sizing the ring to the display's actual mode (differs from negotiated)"
|
||||
);
|
||||
}
|
||||
// The driver composes the virtual display in FP16 (R16G16B16A16_FLOAT scRGB) when the display is
|
||||
// in advanced-color (HDR) mode, and 8-bit BGRA otherwise (per swap_chain_processor.rs + the
|
||||
// COMMIT_MODES2 colorspace/rgb_bpc log). The user can flip "Use HDR" in Windows at any time, so
|
||||
@@ -347,13 +372,40 @@ impl IddPushCapturer {
|
||||
// PROACTIVELY enable advanced color so HDR streams without the user toggling anything; an
|
||||
// SDR-only client leaves the display alone (and still gets a tone-mapped picture, never a freeze,
|
||||
// if the user does enable HDR).
|
||||
// SAFETY: one block over the whole ring setup; every operation in it is sound:
|
||||
// - `set_advanced_color`/`advanced_color_enabled` are `unsafe fn`s taking only a copy of the plain
|
||||
// `u32` target id; they read/flip CCD display config and return owned values, borrowing nothing.
|
||||
// - `CreateDXGIFactory1`, `EnumAdapterByLuid`, `make_device`, `permissive_sa`, `CreateFileMappingW`,
|
||||
// `MapViewOfFile`, `CreateEventW`, and `create_ring_slots` are all `?`-checked, so every returned
|
||||
// interface/handle/view is non-error before use; `&sa`/`&adapter`/`&device`/the `&HSTRING` names
|
||||
// are live borrows that outlive each synchronous call, and `sa.lpSecurityDescriptor` stays valid
|
||||
// because its backing `_psd` is held in scope for the whole block.
|
||||
// - The header mapping is created AND viewed at `bytes == size_of::<SharedHeader>().max(64)`; the
|
||||
// view's null is checked (`bail!` on failure, after which the owned `map` closes the mapping). The
|
||||
// OS view base is page-aligned, so `section.ptr::<SharedHeader>()` is suitably aligned for a
|
||||
// `SharedHeader`, and `write_bytes(.., 0, bytes)` plus the `(*header).field = ..` writes all stay
|
||||
// within those `bytes` and write THROUGH the raw pointer without forming any `&mut`. The debug
|
||||
// section is the same pattern at `dbg_bytes == size_of::<DebugBlock>()`, only entered when its
|
||||
// own view is non-null.
|
||||
// - The `magic` publish stores through `addr_of!((*header).magic) as *const AtomicU32`: `addr_of!`
|
||||
// takes the field address without a reference; the field is a 4-aligned `u32` (valid for
|
||||
// `AtomicU32`), and the `Release` store after the `Release` fence is the cross-process handshake
|
||||
// that orders all preceding writes before the driver may observe `MAGIC`.
|
||||
// - `header`/`dbg_block` point into the OS mappings, NOT into the `MappedSection` structs, so moving
|
||||
// `section`/`dbg_section` into `me` leaves them valid (see the `MappedSection` doc comment).
|
||||
unsafe {
|
||||
if client_10bit && crate::vdisplay::sudovda::set_advanced_color(target.target_id, true)
|
||||
{
|
||||
// If we ENABLE advanced color for a 10-bit client, trust it (the driver will compose FP16) and
|
||||
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
|
||||
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
|
||||
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
|
||||
let enabled_hdr =
|
||||
client_10bit && crate::win_display::set_advanced_color(target.target_id, true);
|
||||
if enabled_hdr {
|
||||
// Let the colorspace change settle before the driver composes + we size the ring.
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
let display_hdr = crate::vdisplay::sudovda::advanced_color_enabled(target.target_id);
|
||||
let display_hdr =
|
||||
enabled_hdr || crate::win_display::advanced_color_enabled(target.target_id);
|
||||
let ring_fmt = if display_hdr {
|
||||
DXGI_FORMAT_R16G16B16A16_FLOAT
|
||||
} else {
|
||||
@@ -382,13 +434,21 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(header_name(target.target_id)),
|
||||
)
|
||||
.context("CreateFileMapping(IDD-push header)")?;
|
||||
let view = MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, bytes);
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII even on bail.
|
||||
let map = OwnedHandle::from_raw_handle(map.0 as _);
|
||||
let view = MapViewOfFile(
|
||||
HANDLE(map.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
bytes,
|
||||
);
|
||||
if view.Value.is_null() {
|
||||
let _ = CloseHandle(map);
|
||||
bail!("MapViewOfFile failed for IDD-push header");
|
||||
bail!("MapViewOfFile failed for IDD-push header"); // `map` drops → mapping closed
|
||||
}
|
||||
let section = MappedSection { handle: map, view };
|
||||
let generation = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let header = view.Value.cast::<SharedHeader>();
|
||||
let header = section.ptr::<SharedHeader>();
|
||||
std::ptr::write_bytes(header.cast::<u8>(), 0, bytes);
|
||||
(*header).version = VERSION;
|
||||
(*header).generation = generation;
|
||||
@@ -407,6 +467,7 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(event_name(target.target_id)),
|
||||
)
|
||||
.context("CreateEvent(IDD-push)")?;
|
||||
let event = OwnedHandle::from_raw_handle(event.0 as _);
|
||||
|
||||
// Ring of shared keyed-mutex textures, format matched to the display's current mode.
|
||||
let slots =
|
||||
@@ -414,7 +475,7 @@ impl IddPushCapturer {
|
||||
|
||||
// Bring-up debug block (fixed name) — the driver writes diagnostics here. Best-effort.
|
||||
let dbg_bytes = std::mem::size_of::<DebugBlock>();
|
||||
let (dbg_map, dbg_block) = match CreateFileMappingW(
|
||||
let (dbg_section, dbg_block) = match CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
@@ -423,18 +484,29 @@ impl IddPushCapturer {
|
||||
&HSTRING::from(DBG_NAME),
|
||||
) {
|
||||
Ok(dm) => {
|
||||
let dv = MapViewOfFile(dm, FILE_MAP_ALL_ACCESS, 0, 0, dbg_bytes);
|
||||
// Own the mapping handle so it (and its view) free via `MappedSection` RAII.
|
||||
let dm = OwnedHandle::from_raw_handle(dm.0 as _);
|
||||
let dv = MapViewOfFile(
|
||||
HANDLE(dm.as_raw_handle()),
|
||||
FILE_MAP_ALL_ACCESS,
|
||||
0,
|
||||
0,
|
||||
dbg_bytes,
|
||||
);
|
||||
if dv.Value.is_null() {
|
||||
let _ = CloseHandle(dm);
|
||||
(HANDLE::default(), std::ptr::null_mut())
|
||||
(None, std::ptr::null_mut()) // `dm` drops → mapping closed
|
||||
} else {
|
||||
let p = dv.Value.cast::<DebugBlock>();
|
||||
let section = MappedSection {
|
||||
handle: dm,
|
||||
view: dv,
|
||||
};
|
||||
let p = section.ptr::<DebugBlock>();
|
||||
std::ptr::write_bytes(p.cast::<u8>(), 0, dbg_bytes);
|
||||
(*p).magic = DBG_MAGIC;
|
||||
(dm, p)
|
||||
(Some(section), p)
|
||||
}
|
||||
}
|
||||
Err(_) => (HANDLE::default(), std::ptr::null_mut()),
|
||||
Err(_) => (None, std::ptr::null_mut()),
|
||||
};
|
||||
|
||||
// Publish: magic LAST (Release) — signals the driver the ring is ready to open.
|
||||
@@ -451,14 +523,14 @@ impl IddPushCapturer {
|
||||
ring_fp16 = display_hdr,
|
||||
"IDD push(host): created shared ring; waiting for the driver to attach + publish"
|
||||
);
|
||||
Ok(Self {
|
||||
let me = Self {
|
||||
device,
|
||||
context,
|
||||
target_id: target.target_id,
|
||||
map,
|
||||
section,
|
||||
header,
|
||||
event,
|
||||
dbg_map,
|
||||
dbg_section,
|
||||
dbg_block,
|
||||
width: w,
|
||||
height: h,
|
||||
@@ -467,20 +539,76 @@ impl IddPushCapturer {
|
||||
client_10bit,
|
||||
display_hdr,
|
||||
last_acm_poll: Instant::now(),
|
||||
recovering_since: None,
|
||||
out_ring: Vec::new(),
|
||||
out_idx: 0,
|
||||
hdr_conv: None,
|
||||
video_conv: None,
|
||||
hdr_p010_conv: None,
|
||||
last_seq: 0,
|
||||
last_present: None,
|
||||
status_logged: false,
|
||||
my_gen: crate::vdisplay::sudovda::CURRENT_MON_GEN.load(Ordering::Relaxed),
|
||||
_keepalive: keepalive,
|
||||
})
|
||||
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
|
||||
// it back to the caller for the DDA fallback (audit §5.1).
|
||||
_keepalive: Box::new(()),
|
||||
};
|
||||
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
|
||||
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
|
||||
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
|
||||
// instead of next_frame's 20 s black-then-bail.
|
||||
me.wait_for_attach()?;
|
||||
Ok(me)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
|
||||
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
|
||||
/// `docs/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
|
||||
///
|
||||
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
|
||||
/// a fullscreen game can leave the virtual display in a format/size that the driver's `publish()` guard
|
||||
/// rejects, so the driver ATTACHES but silently drops every frame; without this the host sails past
|
||||
/// `open()` and only dies on `next_frame`'s 20 s deadline (the "reconnect = black + audio" symptom). At
|
||||
/// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s,
|
||||
/// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
|
||||
fn wait_for_attach(&self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
// SAFETY: `self.header` points into the live shared-header mapping this capturer owns (sized
|
||||
// `>= size_of::<SharedHeader>()`, page-aligned), so the field read is in-bounds + aligned, and
|
||||
// no reference into the shared region is formed. Plain read: the driver writes this `u32`
|
||||
// cross-process, but an aligned `u32` read can't tear and `driver_status` is best-effort
|
||||
// diagnostics — the real handshake is the atomic `magic`/`latest` (same access as
|
||||
// log_driver_status_once).
|
||||
let st = unsafe { (*self.header).driver_status };
|
||||
if matches!(st, DRV_STATUS_TEX_FAIL | DRV_STATUS_NO_DEVICE1) {
|
||||
// SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field
|
||||
// through the owned, live header mapping; no reference into the shared region is formed.
|
||||
let detail = unsafe { (*self.header).driver_status_detail };
|
||||
bail!(
|
||||
"IDD-push driver failed to attach (driver_status={st} detail=0x{detail:08x} — \
|
||||
render-adapter mismatch?)"
|
||||
);
|
||||
}
|
||||
// Attached AND a frame has been published — the publish token's seq advances past 0.
|
||||
if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
bail!(
|
||||
"IDD-push: driver_status={st} but no frame published within 4s — the virtual display \
|
||||
is likely in a format/size the ring can't match (fullscreen game?); falling back"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn latest(&self) -> u64 {
|
||||
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
||||
// `SharedHeader`). `addr_of!((*self.header).latest)` forms the address of the `latest` field
|
||||
// WITHOUT a reference; it is an 8-aligned `u64` (so valid for `AtomicU64`), and the `Acquire` load
|
||||
// is the consumer half of the cross-process publish handshake (pairs with the driver's `Release`).
|
||||
unsafe {
|
||||
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
|
||||
.load(Ordering::Acquire)
|
||||
@@ -492,6 +620,10 @@ impl IddPushCapturer {
|
||||
if self.status_logged {
|
||||
return;
|
||||
}
|
||||
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping. The driver writes
|
||||
// these `u32`/`i32` diagnostic fields cross-process, but aligned word reads can't tear and these are
|
||||
// best-effort status (the real handshake is the atomic `magic`/`latest`); no `&`/`&mut` reference
|
||||
// into the shared region is formed.
|
||||
let (status, detail, lo, hi) = unsafe {
|
||||
(
|
||||
(*self.header).driver_status,
|
||||
@@ -531,6 +663,11 @@ impl IddPushCapturer {
|
||||
tracing::warn!("IDD push DEBUG: no debug block");
|
||||
return;
|
||||
}
|
||||
// SAFETY: `self.dbg_block` was just checked non-null (the early return above); it points into the
|
||||
// owned `dbg_section` mapping sized exactly `size_of::<DebugBlock>()` and page-aligned, so it is
|
||||
// valid + aligned for `DebugBlock`. `d` is a short-lived SHARED reference used only to read the
|
||||
// fields below; we never form `&mut` into this region, and the driver's cross-process writes are
|
||||
// aligned `u32`s that don't tear (best-effort bring-up diagnostics).
|
||||
let d = unsafe { &*self.dbg_block };
|
||||
tracing::error!(
|
||||
run_core_entries = d.run_core_entries,
|
||||
@@ -546,16 +683,17 @@ impl IddPushCapturer {
|
||||
);
|
||||
}
|
||||
|
||||
/// The output texture format + the [`PixelFormat`] it presents as, driven SOLELY by the DISPLAY's
|
||||
/// HDR state (like the WGC path): HDR → `Rgb10a2` BT.2020 PQ → NVENC Main10, and the client
|
||||
/// auto-detects PQ from the HEVC VUI; SDR → 8-bit `Bgra`. We do NOT gate HDR on the client's
|
||||
/// advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the Mac advertises 10-bit only when
|
||||
/// its OWN display is HDR), yet all decode Main10 + auto-switch, exactly as on the WGC path.
|
||||
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
|
||||
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
|
||||
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
|
||||
/// NVENC skips its internal RGB→YUV CSC on the contended SM (plan §5.A). We do NOT gate HDR on the
|
||||
/// client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the Mac advertises 10-bit
|
||||
/// only when its OWN display is HDR), yet all decode Main10 + auto-switch, exactly as on the WGC path.
|
||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
||||
if self.display_hdr {
|
||||
(DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2)
|
||||
(DXGI_FORMAT_P010, PixelFormat::P010)
|
||||
} else {
|
||||
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
|
||||
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,20 +707,20 @@ impl IddPushCapturer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the client's 10-bit capability (the reuse path). Only affects whether a fresh `open`
|
||||
/// proactively enables advanced color; the per-frame conversion follows the display, not the client.
|
||||
fn set_client_10bit(&mut self, client_10bit: bool) {
|
||||
self.client_10bit = client_10bit;
|
||||
}
|
||||
|
||||
/// Recreate the ring at the format for `new_display_hdr` (the user flipped "Use HDR"). Bumps the
|
||||
/// generation so the driver re-attaches ([`is_stale`]) to the new-format textures; clears the
|
||||
/// header's `latest` so we don't consume a stale slot from the old ring; drops the conversion
|
||||
/// textures so they rebuild at the new format.
|
||||
fn recreate_ring(&mut self, new_display_hdr: bool) -> Result<()> {
|
||||
fn recreate_ring(&mut self, new_display_hdr: bool, new_w: u32, new_h: u32) -> Result<()> {
|
||||
self.display_hdr = new_display_hdr;
|
||||
self.width = new_w;
|
||||
self.height = new_h;
|
||||
let fmt = self.ring_format();
|
||||
let new_gen = IDD_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
// SAFETY: `create_ring_slots` is an `unsafe fn` (it makes D3D11/DXGI COM calls); we pass a live
|
||||
// borrow of `self.device` (the capturer's own device, on which the slots are created) plus plain
|
||||
// `u32`/`DXGI_FORMAT` values, and `?` propagates any failure before the slots are used. Every
|
||||
// returned slot's texture + keyed mutex belongs to that same `self.device`.
|
||||
let new_slots = unsafe {
|
||||
Self::create_ring_slots(
|
||||
&self.device,
|
||||
@@ -593,6 +731,12 @@ impl IddPushCapturer {
|
||||
fmt,
|
||||
)?
|
||||
};
|
||||
// SAFETY: `self.header` is the live, owned shared-header mapping (page-aligned, sized for a
|
||||
// `SharedHeader`). The `latest`/`generation` stores go through `addr_of!`-formed field pointers (no
|
||||
// references) of correctly-aligned `u64`/`u32` fields, valid for `AtomicU64`/`AtomicU32`; the
|
||||
// `dxgi_format`/`width`/`height` writes are in-bounds raw writes through the pointer (no `&mut`).
|
||||
// The `Release` fence + the `Release` `generation` store publish all preceding writes so the driver
|
||||
// only re-attaches (`Acquire`) once the new textures + format are in place.
|
||||
unsafe {
|
||||
// Clear `latest` to the 0 sentinel (generation 0, which try_consume rejects). The real guard
|
||||
// against consuming an unwritten new-ring slot is the generation tag in `latest`: a stale
|
||||
@@ -601,6 +745,8 @@ impl IddPushCapturer {
|
||||
(*(std::ptr::addr_of!((*self.header).latest) as *const AtomicU64))
|
||||
.store(0, Ordering::Relaxed);
|
||||
(*self.header).dxgi_format = fmt.0 as u32;
|
||||
(*self.header).width = new_w;
|
||||
(*self.header).height = new_h;
|
||||
// Publish the new generation LAST (Release): when the driver observes it (Acquire) the new
|
||||
// textures already exist and the format is already updated.
|
||||
std::sync::atomic::fence(Ordering::Release);
|
||||
@@ -611,6 +757,8 @@ impl IddPushCapturer {
|
||||
self.generation = new_gen;
|
||||
self.last_seq = 0;
|
||||
self.out_ring.clear(); // the output format changed → rebuild lazily at the new format
|
||||
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
|
||||
self.hdr_p010_conv = None;
|
||||
self.out_idx = 0;
|
||||
self.last_present = None;
|
||||
Ok(())
|
||||
@@ -624,17 +772,28 @@ impl IddPushCapturer {
|
||||
return;
|
||||
}
|
||||
self.last_acm_poll = Instant::now();
|
||||
let now_hdr = unsafe { crate::vdisplay::sudovda::advanced_color_enabled(self.target_id) };
|
||||
if now_hdr == self.display_hdr {
|
||||
// SAFETY: `advanced_color_enabled` is an `unsafe fn` taking only a copy of the plain `u32` target
|
||||
// id; it performs a read-only CCD query and returns an owned `bool`, borrowing nothing from us.
|
||||
let now_hdr = unsafe { crate::win_display::advanced_color_enabled(self.target_id) };
|
||||
// Follow the display's ACTUAL resolution too — a fullscreen game can mode-set the virtual display
|
||||
// out from under the negotiated size (game-capture bug GB1). Unknown read → keep our current size.
|
||||
// SAFETY: `active_resolution` is an `unsafe fn` taking only a copy of the plain `u32` target id; it
|
||||
// performs a read-only CCD query and returns owned `(w, h)` values, borrowing nothing from us.
|
||||
let (now_w, now_h) = unsafe { crate::win_display::active_resolution(self.target_id) }
|
||||
.unwrap_or((self.width, self.height));
|
||||
if now_hdr == self.display_hdr && now_w == self.width && now_h == self.height {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
target_id = self.target_id,
|
||||
display_hdr = now_hdr,
|
||||
client_10bit = self.client_10bit,
|
||||
"IDD push: display HDR mode flipped — recreating the ring at the new format"
|
||||
from = format!("{}x{} hdr={}", self.width, self.height, self.display_hdr),
|
||||
to = format!("{now_w}x{now_h} hdr={now_hdr}"),
|
||||
"IDD push: display descriptor changed — recreating the ring at the new mode"
|
||||
);
|
||||
if let Err(e) = self.recreate_ring(now_hdr) {
|
||||
// Start the recovery clock (if not already running): if a fresh frame doesn't resume within the
|
||||
// window, try_consume drops the session rather than freeze.
|
||||
self.recovering_since.get_or_insert_with(Instant::now);
|
||||
if let Err(e) = self.recreate_ring(now_hdr, now_w, now_h) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "IDD push: ring recreate failed");
|
||||
}
|
||||
}
|
||||
@@ -658,31 +817,46 @@ impl IddPushCapturer {
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
|
||||
// NVENC registers it as encode input — matching the WGC YUV ring.
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
for _ in 0..OUT_RING {
|
||||
let mut t: Option<ID3D11Texture2D> = None;
|
||||
let mut rtv: Option<ID3D11RenderTargetView> = None;
|
||||
// SAFETY: `CreateTexture2D` is called on `self.device` (the capturer's live D3D11 device);
|
||||
// `&desc` is a fully-initialized stack `D3D11_TEXTURE2D_DESC`, the data arg is `None` (no
|
||||
// initial data), and `Some(&mut t)` is a live out-parameter the call fills. `?` rejects a failed
|
||||
// HRESULT before `t` is unwrapped, and the created texture belongs to `self.device`.
|
||||
unsafe {
|
||||
self.device
|
||||
.CreateTexture2D(&desc, None, Some(&mut t))
|
||||
.context("CreateTexture2D(IDD out ring)")?;
|
||||
let t = t.context("null out-ring texture")?;
|
||||
self.device
|
||||
.CreateRenderTargetView(&t, None, Some(&mut rtv))
|
||||
.context("CreateRenderTargetView(IDD out ring)")?;
|
||||
self.out_ring.push((t, rtv.context("null out-ring rtv")?));
|
||||
self.out_ring.push(t.context("null out-ring texture")?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the HDR converter if not already built (HDR-display path only — an SDR display is a copy).
|
||||
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
|
||||
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
|
||||
fn ensure_converter(&mut self) -> Result<()> {
|
||||
if self.hdr_conv.is_none() {
|
||||
self.hdr_conv = Some(unsafe { HdrConverter::new(&self.device)? });
|
||||
if self.display_hdr {
|
||||
if self.hdr_p010_conv.is_none() {
|
||||
// SAFETY: `HdrP010Converter::new` is `unsafe` (it compiles D3D11 shaders + creates
|
||||
// resources); we pass a live borrow of `self.device`, the device the converter's resources
|
||||
// belong to, and `?` propagates any failure before the converter is stored.
|
||||
self.hdr_p010_conv = Some(unsafe { HdrP010Converter::new(&self.device)? });
|
||||
}
|
||||
} else if self.video_conv.is_none() {
|
||||
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
|
||||
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
|
||||
// plain `u32` dimensions, and `?` propagates any failure before it is stored. The converter's
|
||||
// resources belong to that same device/context.
|
||||
self.video_conv = Some(unsafe {
|
||||
VideoConverter::new(&self.device, &self.context, self.width, self.height, false)?
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -691,6 +865,17 @@ impl IddPushCapturer {
|
||||
self.log_driver_status_once();
|
||||
// Follow the display: a "Use HDR" flip recreates the ring at the matching format.
|
||||
self.poll_display_hdr();
|
||||
// Recover-or-drop (GB1): if a descriptor change triggered a recreate but no fresh frame has resumed
|
||||
// within the window, the IDD-push path can't follow the display (e.g. an exclusive-flip) — drop the
|
||||
// session cleanly (the loop's `?` ends it → the client reconnects) rather than freeze forever.
|
||||
if let Some(since) = self.recovering_since {
|
||||
if since.elapsed() > Duration::from_secs(3) {
|
||||
bail!(
|
||||
"IDD-push: display descriptor changed and the ring could not recover within 3s — \
|
||||
dropping the session so the client reconnects"
|
||||
);
|
||||
}
|
||||
}
|
||||
let latest = self.latest();
|
||||
// `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish
|
||||
// whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0
|
||||
@@ -706,40 +891,53 @@ impl IddPushCapturer {
|
||||
return Ok(None);
|
||||
}
|
||||
self.ensure_out_ring()?;
|
||||
// Build the HDR converter BEFORE acquiring the slot so nothing between Acquire and Release can
|
||||
// Build the converter BEFORE acquiring the slot so nothing between Acquire and Release can
|
||||
// `?`-return and leak the keyed-mutex lock (which would stall the driver on that slot).
|
||||
if self.display_hdr {
|
||||
self.ensure_converter()?;
|
||||
}
|
||||
self.ensure_converter()?;
|
||||
let i = self.out_idx;
|
||||
let (out, out_rtv) = {
|
||||
let (t, rtv) = &self.out_ring[i];
|
||||
(t.clone(), rtv.clone())
|
||||
};
|
||||
let out = self.out_ring[i].clone();
|
||||
let (_, pf) = self.out_format();
|
||||
|
||||
// Hold the slot's keyed mutex only across the convert/copy into the host out-ring (NOT across the
|
||||
// ~3 ms encode — NVENC reads the host out-ring slot, not the keyed-mutex slot), so the driver gets
|
||||
// the slot back immediately and the encode of the PREVIOUS frame overlaps this convert.
|
||||
let s = &self.slots[slot];
|
||||
if unsafe { s.mutex.AcquireSync(0, 8) }.is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
unsafe {
|
||||
if self.display_hdr {
|
||||
// Sample the FP16 slot's SRV directly (no scratch copy) → BT.2020 PQ Rgb10a2.
|
||||
if let Some(conv) = self.hdr_conv.as_ref() {
|
||||
conv.convert(&self.context, &s.srv, &out_rtv, self.width, self.height);
|
||||
// Acquire the slot's keyed mutex via a RAII guard, scoped to JUST the convert/copy below so it
|
||||
// releases at the same point as the old hand-written `ReleaseSync` (the driver gets the slot back
|
||||
// immediately, NOT held across the rest of `try_consume`) — but now leak-proof on any early return.
|
||||
{
|
||||
let Some(_lock) = KeyedMutexGuard::acquire(&s.mutex, 0, 8) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// SAFETY: convert on the owning (encode) thread's immediate context, holding the slot lock.
|
||||
// A `?` here is leak-safe: `_lock` (the KeyedMutexGuard) drops on the early return, releasing
|
||||
// the slot back to the driver.
|
||||
unsafe {
|
||||
if self.display_hdr {
|
||||
// HDR: FP16 slot SRV → P010 (BT.2020 PQ) via the shader; NVENC takes native P010.
|
||||
if let Some(conv) = self.hdr_p010_conv.as_ref() {
|
||||
conv.convert(
|
||||
&self.device,
|
||||
&self.context,
|
||||
&s.srv,
|
||||
&out,
|
||||
self.width,
|
||||
self.height,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
||||
if let Some(conv) = self.video_conv.as_ref() {
|
||||
conv.convert(&s.tex, &out)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// SDR: the slot is already 8-bit BGRA — one copy into the out-ring (hidden by pipelining).
|
||||
self.context.CopyResource(&out, &s.tex);
|
||||
}
|
||||
let _ = s.mutex.ReleaseSync(0);
|
||||
// `_lock` drops here → `ReleaseSync(0)`.
|
||||
}
|
||||
self.out_idx = (i + 1) % self.out_ring.len();
|
||||
self.last_seq = seq;
|
||||
self.last_present = Some((out.clone(), pf));
|
||||
self.recovering_since = None; // a fresh frame resumed → recovered
|
||||
Ok(Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
@@ -752,14 +950,28 @@ impl IddPushCapturer {
|
||||
}))
|
||||
}
|
||||
|
||||
fn repeat_last(&self) -> Option<CapturedFrame> {
|
||||
self.last_present.as_ref().map(|(tex, pf)| CapturedFrame {
|
||||
fn repeat_last(&mut self) -> Option<CapturedFrame> {
|
||||
// Copy the last presented frame into a FRESH rotated out-ring slot so a repeat (static desktop, no
|
||||
// new driver frame) never re-hands a slot that may still be encoding under pipeline_depth>1 — the
|
||||
// out-ring rotation IS the texture-ownership contract, and repeats must honor it too (audit §5.3).
|
||||
// OUT_RING(3) > the max pipeline_depth(2) guarantees the rotated slot is not in flight.
|
||||
let (src, pf) = self.last_present.clone()?;
|
||||
let i = self.out_idx;
|
||||
let dst = self.out_ring.get(i)?.clone();
|
||||
// SAFETY: GPU copy on the owning thread's immediate context; src/dst are our out-ring textures of
|
||||
// identical format/size (src is a previous out-ring slot; dst the next).
|
||||
unsafe {
|
||||
self.context.CopyResource(&dst, &src);
|
||||
}
|
||||
self.out_idx = (i + 1) % self.out_ring.len();
|
||||
self.last_present = Some((dst.clone(), pf));
|
||||
Some(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns: now_ns(),
|
||||
format: *pf,
|
||||
format: pf,
|
||||
payload: FramePayload::D3d11(D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
texture: dst,
|
||||
device: self.device.clone(),
|
||||
}),
|
||||
})
|
||||
@@ -796,7 +1008,7 @@ pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32
|
||||
);
|
||||
cap.log_debug_block();
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
Err((e, _keep)) => tracing::warn!(
|
||||
target_id = tid,
|
||||
"IDD push OBSERVER: ring open failed: {e:#}"
|
||||
),
|
||||
@@ -806,7 +1018,9 @@ pub fn spawn_observer(target: WinCaptureTarget, preferred: Option<(u32, u32, u32
|
||||
|
||||
/// The discrete render GPU LUID (where NVENC runs), falling back to the monitor's `OsAdapterLuid`.
|
||||
fn resolve_render_adapter_luid_or(fallback_packed: i64) -> LUID {
|
||||
if let Some(l) = unsafe { crate::vdisplay::sudovda::resolve_render_adapter_luid() } {
|
||||
// SAFETY: `resolve_render_adapter_luid` is an `unsafe fn` (it enumerates DXGI adapters) that takes no
|
||||
// arguments and returns an owned `Option<LUID>`, borrowing nothing.
|
||||
if let Some(l) = unsafe { crate::win_adapter::resolve_render_adapter_luid() } {
|
||||
return l;
|
||||
}
|
||||
LUID {
|
||||
@@ -819,7 +1033,10 @@ impl Capturer for IddPushCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
let _ = unsafe { WaitForSingleObject(self.event, 16) };
|
||||
// SAFETY: `self.event` is the live frame-ready `OwnedHandle` this capturer owns; its raw value
|
||||
// (borrowed for the call, so it outlives this synchronous wait) is a valid auto-reset event
|
||||
// handle. `WaitForSingleObject` only reads the handle; the 16 ms timeout bounds the wait.
|
||||
let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 16) };
|
||||
if let Some(f) = self.try_consume()? {
|
||||
return Ok(f);
|
||||
}
|
||||
@@ -828,6 +1045,9 @@ impl Capturer for IddPushCapturer {
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
self.log_debug_block();
|
||||
// SAFETY: four in-bounds, aligned reads of the live, owned shared-header mapping — the same
|
||||
// best-effort diagnostic fields as `log_driver_status_once` (aligned word reads can't tear;
|
||||
// no reference into the shared region is formed).
|
||||
let (st, detail, lo, hi) = unsafe {
|
||||
(
|
||||
(*self.header).driver_status,
|
||||
@@ -864,34 +1084,15 @@ impl Capturer for IddPushCapturer {
|
||||
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
|
||||
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
|
||||
// always has its own texture).
|
||||
std::env::var("PUNKTFUNK_IDD_DEPTH")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, OUT_RING)
|
||||
crate::config::config().idd_depth.clamp(1, OUT_RING)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IddPushCapturer {
|
||||
fn drop(&mut self) {
|
||||
self.slots.clear();
|
||||
unsafe {
|
||||
if !self.dbg_block.is_null() {
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.dbg_block.cast(),
|
||||
});
|
||||
}
|
||||
if !self.dbg_map.is_invalid() {
|
||||
let _ = CloseHandle(self.dbg_map);
|
||||
}
|
||||
if !self.header.is_null() {
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.header.cast(),
|
||||
});
|
||||
}
|
||||
let _ = CloseHandle(self.event);
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
// The shared header + debug sections (`MappedSection`) and the frame-ready `event`
|
||||
// (`OwnedHandle`) free themselves via RAII (each unmaps its view, then closes its handle).
|
||||
// _keepalive drops after, REMOVEing the virtual display.
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -16,6 +16,9 @@
|
||||
//! Limitation: WGC cannot capture the secure desktop (lock / UAC / login) — the caller falls back to
|
||||
//! the DDA backend ([`super::dxgi::DuplCapturer`]) for those (see capture.rs).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::dxgi::{
|
||||
find_output, hdr_shader_p010_enabled, make_device, nudge_cursor_onto, D3d11Frame, HdrConverter,
|
||||
HdrP010Converter, VideoConverter, WinCaptureTarget,
|
||||
@@ -92,6 +95,10 @@ struct Deimpersonate(Option<HANDLE>);
|
||||
impl Drop for Deimpersonate {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tok) = self.0.take() {
|
||||
// SAFETY: `RevertToSelf` takes no arguments and undoes the thread impersonation set during
|
||||
// WGC activation; `tok` is the impersonation token `HANDLE` from `impersonate_active_user`,
|
||||
// owned by this `Deimpersonate` and closed exactly once here (taken out of the `Option`, so
|
||||
// no double-close). Both are FFI calls borrowing no Rust memory.
|
||||
unsafe {
|
||||
let _ = RevertToSelf();
|
||||
let _ = CloseHandle(tok);
|
||||
@@ -174,7 +181,12 @@ pub struct WgcCapturer {
|
||||
_keepalive: Option<Box<dyn Send>>,
|
||||
}
|
||||
|
||||
// COM + WinRT pointers; confined to the single owning (encode) thread, like DuplCapturer.
|
||||
// SAFETY: like `DuplCapturer`. `WgcCapturer` holds D3D11 (free-threaded device/context) plus WGC WinRT
|
||||
// objects (`Direct3D11CaptureFramePool` etc., created free-threaded via `CreateFreeThreaded`). COM/WinRT
|
||||
// reference counting is interlocked, and the capturer is owned + used by exactly one encode thread,
|
||||
// moved to it once and never shared (no `Sync`), so transferring ownership across threads is sound. The
|
||||
// free-threaded `FrameArrived` callback touches only the `Arc<WgcSignal>` (itself `Send + Sync`), not
|
||||
// the capturer's COM fields.
|
||||
unsafe impl Send for WgcCapturer {}
|
||||
|
||||
impl WgcCapturer {
|
||||
@@ -182,6 +194,15 @@ impl WgcCapturer {
|
||||
/// [`attach_keepalive`](Self::attach_keepalive) only after open succeeds, so a failure leaves the
|
||||
/// keepalive with the caller to hand to the DDA fallback.
|
||||
pub fn open(target: WinCaptureTarget, preferred: Option<(u32, u32, u32)>) -> Result<Self> {
|
||||
// SAFETY: runs on the thread opening the WGC session. `RoInitialize` inits this thread's WinRT
|
||||
// apartment (idempotent; result ignored). `impersonate_active_user()` and `find_output()` are
|
||||
// this module's `unsafe fn`s whose contracts (call on the activating thread; pass a GDI name)
|
||||
// are met, and the impersonation is reverted by `_deimp`'s Drop on every return path. Every
|
||||
// COM/WinRT call thereafter operates on an object obtained + `?`-checked earlier in this same
|
||||
// block on this single thread — the `IDXGIOutput1` from `find_output`, the device/context from
|
||||
// `make_device`, the factory/interop/item/pool/session — and the `TypedEventHandler` closure
|
||||
// captures an `Arc<WgcSignal>` (Send+Sync) by move. No raw pointers are dereferenced; borrowed
|
||||
// locals outlive their synchronous calls.
|
||||
unsafe {
|
||||
// WGC is WinRT — the calling thread needs a COM/WinRT apartment for the GraphicsCaptureItem
|
||||
// activation factory (RoGetActivationFactory). Initialize MTA; ignore "already initialized"
|
||||
@@ -196,7 +217,7 @@ impl WgcCapturer {
|
||||
// The SudoVDA output appears a beat after the display is created — settle-retry like DDA.
|
||||
let deadline = Instant::now() + Duration::from_millis(2000);
|
||||
let (adapter, output) = loop {
|
||||
if let Some(n) = crate::vdisplay::sudovda::resolve_gdi_name(target.target_id) {
|
||||
if let Some(n) = crate::win_display::resolve_gdi_name(target.target_id) {
|
||||
if let Ok(found) = find_output(&n) {
|
||||
break found;
|
||||
}
|
||||
@@ -585,6 +606,15 @@ impl WgcCapturer {
|
||||
}
|
||||
|
||||
fn process_frame(&mut self, frame: Direct3D11CaptureFrame) -> Result<CapturedFrame> {
|
||||
// SAFETY: runs on the capturer's single owning thread. `frame` is a live
|
||||
// `Direct3D11CaptureFrame` from `self.pool`; `frame.Surface().cast::<IDirect3DDxgiInterfaceAccess
|
||||
// >().GetInterface()` yields the frame's backing `ID3D11Texture2D`, which belongs to
|
||||
// `self.device` (the pool was created on it via `CreateDirect3D11DeviceFromDXGIDevice`). Every
|
||||
// helper called here — `hdr_to_p010`, `convert_to_yuv`, `ensure_fp16_src`, `ensure_out_ring`,
|
||||
// `HdrConverter::convert`, `CopyResource`, `CreateRenderTargetView` — operates on
|
||||
// `self.device`/`self.context` and that same-device texture, so all resources share one device.
|
||||
// The frame is held in `self.held` until its async GPU read completes for the zero-copy paths.
|
||||
// Single-threaded immediate-context use; borrowed textures/SRVs/RTVs outlive each synchronous call.
|
||||
unsafe {
|
||||
let surface = frame.Surface().context("frame Surface")?;
|
||||
let access: IDirect3DDxgiInterfaceAccess = surface
|
||||
+40
-2
@@ -13,6 +13,9 @@
|
||||
//! Wire framing (must match `wgc_helper::write_au`): per AU
|
||||
//! `[u32 magic "PFAU" LE][u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::capture::dxgi::WinCaptureTarget;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
@@ -56,9 +59,15 @@ pub struct HelperRelay {
|
||||
rx: Receiver<RelayAu>,
|
||||
}
|
||||
|
||||
// HANDLEs are just kernel handle values; we own them for the relay's lifetime and close them on Drop.
|
||||
// SAFETY: every field is itself `Send`: the `proc`/`thread` `HANDLE`s are process-global kernel
|
||||
// handle values (plain integers valid from any thread, owned for the relay's lifetime and closed once
|
||||
// on Drop), `stdin_w` is a `Mutex<HANDLE>`, and `rx` is an mpsc `Receiver<RelayAu>` (which is `Send`).
|
||||
// The relay is moved to one thread and owned there, so transferring it across threads is sound.
|
||||
unsafe impl Send for HelperRelay {}
|
||||
unsafe impl Sync for HelperRelay {}
|
||||
// NOTE: `HelperRelay` is deliberately NOT `Sync`. Its `rx: Receiver<RelayAu>` is `!Sync` (std mpsc
|
||||
// is single-consumer), and the relay is only ever a single-owner local in the punktfunk1 two-process
|
||||
// mux loop — never shared by `&` across threads — so `Sync` is neither sound nor needed. (A prior
|
||||
// `unsafe impl Sync` here asserted more than the fields support; removed.)
|
||||
|
||||
/// Control byte on the helper's stdin: force the next encoded frame to be an IDR (client decode
|
||||
/// recovery). Mirrors `enc.request_keyframe()` in the single-process path.
|
||||
@@ -84,6 +93,10 @@ impl HelperRelay {
|
||||
);
|
||||
tracing::info!(cmd = %cmdline, "spawning WGC helper in user session");
|
||||
|
||||
// SAFETY: `spawn_inner` is an `unsafe fn` only because it drives raw Win32 token/pipe/process
|
||||
// FFI; it imposes no caller-side memory precondition beyond valid arguments. `cmdline` is a live
|
||||
// `&str` borrowed for the synchronous call and `(w, h, hz)` are plain `u32`s. It validates its
|
||||
// own runtime requirements (active console session, SYSTEM token) and returns `Err` otherwise.
|
||||
unsafe { spawn_inner(&cmdline, w, h, hz) }
|
||||
}
|
||||
|
||||
@@ -108,6 +121,11 @@ impl HelperRelay {
|
||||
pub fn request_keyframe(&self) {
|
||||
let h = self.stdin_w.lock().unwrap();
|
||||
let mut written = 0u32;
|
||||
// SAFETY: `*h` is the host's write end of the helper's stdin pipe — a live `HANDLE` owned by
|
||||
// this `HelperRelay` (held under the `stdin_w` Mutex, locked here), closed only in Drop.
|
||||
// `WriteFile` reads the 1-byte `&[CTL_KEYFRAME]` buffer and writes the byte count into
|
||||
// `written`; both are live locals that outlive the synchronous call. A failure (helper gone) is
|
||||
// discarded as documented.
|
||||
unsafe {
|
||||
let _ = windows::Win32::Storage::FileSystem::WriteFile(
|
||||
*h,
|
||||
@@ -121,6 +139,10 @@ impl HelperRelay {
|
||||
|
||||
impl Drop for HelperRelay {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.proc`/`self.thread` are the child process/thread `HANDLE`s from
|
||||
// `CreateProcessAsUserW`, and `stdin_w` is the host's pipe write end — all owned by this
|
||||
// `HelperRelay` and closed exactly once here in Drop (no double-close). `TerminateProcess` and
|
||||
// the three `CloseHandle`s are FFI calls taking those handles by value, borrowing no Rust memory.
|
||||
unsafe {
|
||||
// Terminate the child first so its WGC capture + NVENC session tear down, then close our
|
||||
// handles (the reader threads end on the resulting broken pipe).
|
||||
@@ -364,10 +386,17 @@ fn au_reader(mut r: HandleReader, tx: SyncSender<RelayAu>) {
|
||||
|
||||
/// Minimal `Read` over a Win32 pipe HANDLE (the windows crate doesn't impl `Read` on HANDLE).
|
||||
struct HandleReader(HANDLE);
|
||||
// SAFETY: `HandleReader` owns a single pipe `HANDLE` (a process-global kernel handle value, valid from
|
||||
// any thread). It is moved into the dedicated reader thread and used only there (and closed once on
|
||||
// Drop), never shared — so transferring ownership across threads is sound.
|
||||
unsafe impl Send for HandleReader {}
|
||||
impl Read for HandleReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut read = 0u32;
|
||||
// SAFETY: `self.0` is the live read end of an anonymous pipe owned by this `HandleReader`
|
||||
// (closed only in Drop). `ReadFile` fills the caller-provided `buf` (writing at most `buf.len()`
|
||||
// bytes) and stores the count in `read`; both outlive the synchronous call. A broken pipe
|
||||
// surfaces as `Err` and is mapped to EOF below.
|
||||
let ok = unsafe {
|
||||
windows::Win32::Storage::FileSystem::ReadFile(self.0, Some(buf), Some(&mut read), None)
|
||||
};
|
||||
@@ -380,6 +409,8 @@ impl Read for HandleReader {
|
||||
}
|
||||
impl Drop for HandleReader {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the pipe `HANDLE` this `HandleReader` owns; `CloseHandle` (an FFI call
|
||||
// taking the handle by value) is invoked exactly once here in Drop, so there is no double-close.
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
@@ -391,6 +422,13 @@ impl Drop for HandleReader {
|
||||
pub fn running_as_system() -> bool {
|
||||
use windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER};
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
// SAFETY: `OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)` opens the current-process
|
||||
// token (the pseudo-handle is always valid) into `token`, which is closed once before each return.
|
||||
// The first `GetTokenInformation` (null buffer) queries the required `len`; `buf` is then a
|
||||
// `Vec<u8>` of exactly `len` bytes and the second call fills it, so `&*(buf.as_ptr() as *const
|
||||
// TOKEN_USER)` reads a `TOKEN_USER` the kernel just wrote into a sufficiently-sized buffer (the
|
||||
// variable-length SID it points at also lies within `buf`, which outlives the borrow).
|
||||
// `is_local_system_sid` is this module's `unsafe fn`, given that in-buffer `PSID`. Safe on any thread.
|
||||
unsafe {
|
||||
let mut token = HANDLE::default();
|
||||
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() {
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `HostConfig` — the host's runtime knobs parsed ONCE from the environment, instead of the ~68 scattered
|
||||
//! `env::var` reads recomputed at every call site (some up to 8×, which lets capture + encode silently
|
||||
//! disagree on the resolved backend — plan §2.4). The service / launcher loads `host.env` into the process
|
||||
//! environment before the host starts, and **for the knobs captured here the environment is constant for the
|
||||
//! process lifetime**, so a lazily-parsed global is equivalent to "parsed once at startup".
|
||||
//!
|
||||
//! **Goal-1 stages 1–2** (`docs/windows-host-rewrite.md` §2.2): stage 1 stood this up; stage 2 migrated the
|
||||
//! genuinely-constant operator/dispatch knobs onto it (the dispatch-disagreement bug class: `idd_push`,
|
||||
//! `capture_backend`, `encoder_pref`, `render_adapter`, `no_wgc`, the vdisplay backend select — plus the
|
||||
//! plan-named `secure_dda`/`idd_depth`/`zerocopy`/`ten_bit` and the multi-site `perf`/`compositor`/
|
||||
//! `video_source`/`gamepad`). `SessionPlan` (stage 3) consumes it as the single owner of the
|
||||
//! capture/topology/encoder decision.
|
||||
//!
|
||||
//! **What is deliberately NOT here (and must stay a live `env::var` read):**
|
||||
//! - **Runtime-mutated session vars.** On Linux, [`crate::vdisplay::apply_session_env`] rewrites the process
|
||||
//! env on *every connect* so one host follows a Bazzite box across Gaming↔Desktop: `WAYLAND_DISPLAY`,
|
||||
//! `XDG_CURRENT_DESKTOP`, `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, and the *derived* `PUNKTFUNK_*`
|
||||
//! vars `INPUT_BACKEND`, `GAMESCOPE_SESSION`/`GAMESCOPE_NODE`, `KWIN_VIRTUAL_PRIMARY`,
|
||||
//! `MUTTER_VIRTUAL_PRIMARY`, `FORCE_SHM` (+ `GAMESCOPE_APP` on the launch path). Parsing these once would
|
||||
//! freeze them at startup and silently break session-following — they are NOT constant.
|
||||
//! - **Single-use local tuning** read exactly where it is used (no resolve-once benefit, and a parse with a
|
||||
//! call-site-local default/clamp): e.g. `FEC_PCT` (two *different* semantics — GameStream default-20 vs
|
||||
//! punktfunk/1 `Option`/clamp-90), `VIDEO_DROP`, `VBV_FRAMES`, `SPLIT_ENCODE`, `PACE_BURST_KB`, the
|
||||
//! `capture/dxgi.rs` timing knobs, the `*_LIVE` test gates.
|
||||
//! - **Path / genuinely-dynamic reads**: the config-dir resolution, `PATH` executable search, the
|
||||
//! env-forward-to-child loop, `PUNKTFUNK_MGMT_TOKEN`, `PUNKTFUNK_HOST_CMD`, `PUNKTFUNK_RENDER_NODE`.
|
||||
//!
|
||||
//! `PUNKTFUNK_ZEROCOPY` note: this field uses **presence** semantics (`var_os(..).is_some()`) to match the
|
||||
//! Windows `encode/ffmpeg_win.rs` reader. The Linux `zerocopy` module keeps its own *truthy* parser
|
||||
//! (`1|true|yes|on`) — the two are independent features that share a name; do NOT conflate them.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for
|
||||
/// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the
|
||||
/// derived `Debug` impl, so the parser can stay a single platform-neutral function.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostConfig {
|
||||
/// `PUNKTFUNK_IDD_PUSH` — capture from the pf-vdisplay driver's shared ring (in-process Session-0
|
||||
/// capture; no WGC helper). **Value-aware** (`0`/`false`/`no`/`off`/empty ⇒ off, else on); unset ⇒ off.
|
||||
/// The installer's default `host.env` sets it on, so a fresh install runs the validated IDD-push path
|
||||
/// (it falls back to DDA if the driver can't attach — see [`crate::capture`]). NOT a bare presence flag
|
||||
/// (so an operator can turn it OFF in `host.env` with `=0`, which a `var_os` presence check can't).
|
||||
pub idd_push: bool,
|
||||
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
|
||||
pub encoder_pref: String,
|
||||
/// `PUNKTFUNK_NO_HELPER` — never spawn the user-session WGC helper.
|
||||
pub no_helper: bool,
|
||||
/// `PUNKTFUNK_FORCE_HELPER` — force the WGC helper even when not running as SYSTEM.
|
||||
pub force_helper: bool,
|
||||
/// `PUNKTFUNK_NO_WGC` — force the pure single-process DDA path (skip WGC and the two-process relay).
|
||||
pub no_wgc: bool,
|
||||
/// `PUNKTFUNK_CAPTURE` — explicit Windows capture-backend override (lowercased; `dda`/`dxgi` vs the WGC default).
|
||||
pub capture_backend: String,
|
||||
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
|
||||
/// the empty string still counts as "set" for the presence checks, and the value reader filters it).
|
||||
pub render_adapter: Option<String>,
|
||||
/// `PUNKTFUNK_SECURE_DDA` — enable the experimental DDA-on-secure-desktop (Winlogon/UAC) mux leg.
|
||||
pub secure_dda: bool,
|
||||
/// `PUNKTFUNK_IDD_DEPTH` — IDD-push pipeline depth override (default 2; the call site clamps to its `OUT_RING`).
|
||||
pub idd_depth: usize,
|
||||
/// `PUNKTFUNK_ZEROCOPY` — opt into the Windows D3D11 zero-copy encode path (presence semantics; see module docs).
|
||||
pub zerocopy: bool,
|
||||
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
|
||||
pub ten_bit: bool,
|
||||
/// `PUNKTFUNK_PERF` — per-stage timing instrumentation.
|
||||
pub perf: bool,
|
||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||
pub video_source: Option<String>,
|
||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
||||
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||
pub compositor: Option<String>,
|
||||
/// `PUNKTFUNK_GAMEPAD` — client/operator virtual-pad backend preference (fed to `pick_gamepad`).
|
||||
pub gamepad: Option<String>,
|
||||
/// `PUNKTFUNK_VDISPLAY` — Windows virtual-display backend. The pf-vdisplay IddCx driver is now the only
|
||||
/// backend (the legacy SudoVDA backend was removed), so this is currently informational — kept for the
|
||||
/// shipped `host.env` and as a forward seam if a second backend is ever added.
|
||||
pub vdisplay: Option<String>,
|
||||
}
|
||||
|
||||
impl HostConfig {
|
||||
fn from_env() -> Self {
|
||||
// Presence flag: set ⇒ true. Matches the original `var_os(k).is_some()` reads (and the few
|
||||
// `var(k).is_ok()` flag reads, which coincide for every real-world value).
|
||||
let flag = |k: &str| std::env::var_os(k).is_some();
|
||||
// String value: `var(k).ok()` — `Some` (possibly empty) when set with valid UTF-8, else `None`.
|
||||
let val = |k: &str| std::env::var(k).ok();
|
||||
Self {
|
||||
// Value-aware (not a bare presence flag): the shipped default `host.env` turns it ON, and an
|
||||
// operator turns it OFF with `PUNKTFUNK_IDD_PUSH=0` (a `var_os` presence check would read `=0`
|
||||
// as "on"). Unset ⇒ off (the dev / non-pf-driver default).
|
||||
idd_push: match std::env::var("PUNKTFUNK_IDD_PUSH") {
|
||||
Ok(v) => !matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "no" | "off"
|
||||
),
|
||||
Err(_) => false,
|
||||
},
|
||||
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
no_helper: flag("PUNKTFUNK_NO_HELPER"),
|
||||
force_helper: flag("PUNKTFUNK_FORCE_HELPER"),
|
||||
no_wgc: flag("PUNKTFUNK_NO_WGC"),
|
||||
capture_backend: std::env::var("PUNKTFUNK_CAPTURE")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase(),
|
||||
render_adapter: val("PUNKTFUNK_RENDER_ADAPTER"),
|
||||
secure_dda: flag("PUNKTFUNK_SECURE_DDA"),
|
||||
idd_depth: val("PUNKTFUNK_IDD_DEPTH")
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.unwrap_or(2),
|
||||
zerocopy: flag("PUNKTFUNK_ZEROCOPY"),
|
||||
ten_bit: flag("PUNKTFUNK_10BIT"),
|
||||
perf: flag("PUNKTFUNK_PERF"),
|
||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
||||
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process-wide host configuration, parsed once on first access.
|
||||
pub fn config() -> &'static HostConfig {
|
||||
static CFG: OnceLock<HostConfig> = OnceLock::new();
|
||||
CFG.get_or_init(HostConfig::from_env)
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
//! RGB→YUV on the GPU, so no host-side CSC) and VAAPI on AMD/Intel (`*_vaapi`; the CPU-input
|
||||
//! fallback swscales RGB→NV12, the zero-copy path imports the capture dmabuf straight into a
|
||||
//! VA surface). One [`Encoder`] trait, selected in [`open_video`].
|
||||
// Every unsafe block in this module tree carries a `// SAFETY:` proof; enforce it (unsafe-proof
|
||||
// program). As a parent module this also covers the child modules (encode::windows/linux::*).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::capture::{CapturedFrame, PixelFormat};
|
||||
use anyhow::Result;
|
||||
@@ -71,9 +74,34 @@ impl Codec {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static capabilities an [`Encoder`] declares so the session glue routes loss-recovery and HDR
|
||||
/// plumbing by *query* rather than relying on a method's no-op/`false` default. Cheap `Copy`; fixed
|
||||
/// for the session (an HDR toggle re-initialises the encoder — re-query if that matters).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EncoderCaps {
|
||||
/// The encoder can perform real reference-frame invalidation — i.e.
|
||||
/// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) can return `true`. When `false`
|
||||
/// the caller skips that always-`false` call and forces a keyframe directly on loss recovery.
|
||||
/// Only the Windows direct-NVENC path implements RFI; libavcodec (Linux NVENC), VAAPI and
|
||||
/// AMF/QSV always keyframe.
|
||||
pub supports_rfi: bool,
|
||||
/// The encoder emits in-band HDR mastering/CLL SEI from [`set_hdr_meta`](Encoder::set_hdr_meta).
|
||||
/// When `false`, `set_hdr_meta` is a no-op and no in-band grade reaches the client. Only the
|
||||
/// Windows direct-NVENC path attaches it today.
|
||||
pub supports_hdr_metadata: bool,
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// This encoder's static [capabilities](EncoderCaps) (RFI, HDR SEI), so the session glue can
|
||||
/// route by query rather than rely on the no-op/`false` defaults of
|
||||
/// [`invalidate_ref_frames`](Self::invalidate_ref_frames) / [`set_hdr_meta`](Self::set_hdr_meta).
|
||||
/// Default: no optional capabilities (the SDR / libavcodec backends) — only the direct-NVENC
|
||||
/// path overrides it.
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps::default()
|
||||
}
|
||||
/// Force the next submitted frame to be an IDR keyframe (e.g. after a client
|
||||
/// reference-frame-invalidation request). Default: no-op.
|
||||
fn request_keyframe(&mut self) {}
|
||||
@@ -173,14 +201,12 @@ pub fn open_video(
|
||||
// AMD/Intel → VAAPI (one libavcodec backend for both). Auto-detect by default so a single
|
||||
// Linux binary serves any GPU; `PUNKTFUNK_ENCODER` forces a specific backend (and surfaces
|
||||
// its errors crisply instead of silently trying the other).
|
||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
let pref = crate::config::config().encoder_pref.as_str();
|
||||
let open_vaapi = || -> Result<Box<dyn Encoder>> {
|
||||
vaapi::VaapiEncoder::open(codec, format, width, height, fps, bitrate_bps, bit_depth)
|
||||
.map(|e| Box::new(e) as Box<dyn Encoder>)
|
||||
};
|
||||
match pref.as_str() {
|
||||
match pref {
|
||||
"nvenc" | "nvidia" | "cuda" => open_nvenc_probed(
|
||||
codec,
|
||||
format,
|
||||
@@ -379,11 +405,7 @@ fn nvidia_present() -> bool {
|
||||
/// passthrough for VAAPI vs the EGL→CUDA import for NVENC).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_zero_copy_is_vaapi() -> bool {
|
||||
match std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "nvidia" | "cuda" => false,
|
||||
"vaapi" | "amd" | "intel" => true,
|
||||
_ => !nvidia_present(),
|
||||
@@ -450,10 +472,8 @@ enum GpuVendor {
|
||||
/// vendor). Shared by [`open_video`] and the GameStream codec advertisement so both agree.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_resolved_backend() -> WindowsBackend {
|
||||
let pref = std::env::var("PUNKTFUNK_ENCODER")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
match pref.as_str() {
|
||||
// Resolved ONCE in HostConfig (Goal-1) — was re-read from PUNKTFUNK_ENCODER on every call.
|
||||
match crate::config::config().encoder_pref.as_str() {
|
||||
"nvenc" | "hw" | "nvidia" | "cuda" => WindowsBackend::Nvenc,
|
||||
"amf" | "amd" => WindowsBackend::Amf,
|
||||
"qsv" | "intel" => WindowsBackend::Qsv,
|
||||
@@ -488,6 +508,14 @@ fn windows_gpu_vendor() -> Option<GpuVendor> {
|
||||
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||
};
|
||||
static CACHE: OnceLock<Option<GpuVendor>> = OnceLock::new();
|
||||
// SAFETY: `CreateDXGIFactory1` returns a fresh owned `IDXGIFactory1` COM object (refcounted by the
|
||||
// windows-rs wrapper, Released when the local drops); `.ok()?` bails on failure so `factory` is a
|
||||
// valid interface before any use. `EnumAdapters1(i)` hands back the i-th adapter as an owned
|
||||
// `IDXGIAdapter1` (or an error past the last adapter, which ends the loop). `GetDesc1()` returns the
|
||||
// `DXGI_ADAPTER_DESC1` by value (no out-pointer), so reading `desc.Flags`/`desc.VendorId` is plain
|
||||
// field access. Every call only touches COM objects this closure owns; the `OnceLock` runs the
|
||||
// closure once (no data race) and all interfaces are Released as the locals drop. No raw pointer is
|
||||
// dereferenced and nothing is aliased.
|
||||
*CACHE.get_or_init(|| unsafe {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut i = 0u32;
|
||||
@@ -539,15 +567,21 @@ pub fn windows_codec_support() -> CodecSupport {
|
||||
})
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, AMF/QSV ffmpeg, software) and
|
||||
// `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the `crate::encode::*` module names flat.
|
||||
#[cfg(all(target_os = "windows", feature = "amf-qsv"))]
|
||||
#[path = "encode/windows/ffmpeg_win.rs"]
|
||||
mod ffmpeg_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(all(target_os = "windows", feature = "nvenc"))]
|
||||
#[path = "encode/windows/nvenc.rs"]
|
||||
mod nvenc;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "encode/windows/sw.rs"]
|
||||
mod sw;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "encode/linux/vaapi.rs"]
|
||||
mod vaapi;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+53
@@ -8,6 +8,8 @@
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -79,6 +81,12 @@ impl CudaHw {
|
||||
|
||||
impl Drop for CudaHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `CudaHw::new` created
|
||||
// (it bails before returning `Self` if either alloc fails, so a live `CudaHw` always holds
|
||||
// both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`. This
|
||||
// `Drop` runs exactly once and `CudaHw` owns these refs exclusively → no double-free /
|
||||
// use-after-free. Frames are unref'd before the device (the frames ctx internally refs the
|
||||
// device; refcounted, so the order is sound regardless).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
@@ -136,6 +144,13 @@ pub struct NvencEncoder {
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s; the encoder lives on a single thread. The CPU encoder is
|
||||
// already `Send` via ffmpeg-next; assert it for the CUDA fields too.
|
||||
// SAFETY: `NvencEncoder` owns an ffmpeg-next `Encoder`/`VideoFrame` (already `Send`) plus a `CudaHw`
|
||||
// holding raw `AVBufferRef`s, which are not `Send` by default. The encoder is owned and driven by
|
||||
// exactly ONE thread — the per-session encode thread it is moved to — and is only touched through
|
||||
// `&mut self` methods, so it is never aliased or accessed concurrently. The wrapped libav contexts
|
||||
// (and the shared `CUcontext` the `CudaHw` references) have no thread affinity, so transferring
|
||||
// ownership across threads is sound. This asserts `Send` (transfer) only, extending ffmpeg-next's
|
||||
// existing `Send` to the raw CUDA fields; `Sync` (shared `&`) is deliberately NOT implemented.
|
||||
unsafe impl Send for NvencEncoder {}
|
||||
|
||||
impl NvencEncoder {
|
||||
@@ -162,6 +177,9 @@ impl NvencEncoder {
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
// is a valid level with no pointer args, and libav was just initialized by `ffmpeg::init()`
|
||||
// above — always sound.
|
||||
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
|
||||
}
|
||||
let name = codec.nvenc_name();
|
||||
@@ -195,6 +213,11 @@ impl NvencEncoder {
|
||||
.unwrap_or(1.0);
|
||||
let vbv_bits = ((bitrate_bps as f64 / fps.max(1) as f64) * vbv_frames as f64)
|
||||
.clamp(1.0, i32::MAX as f64);
|
||||
// SAFETY: `video` is the ffmpeg-next encoder builder wrapping a freshly-allocated
|
||||
// `AVCodecContext` that we hold by value and have not opened yet; `video.as_mut_ptr()` returns
|
||||
// that non-null, properly-aligned, exclusively-owned context. Writing the plain `rc_buffer_size`
|
||||
// int field before `open_with` is the supported way to set a field ffmpeg-next exposes no
|
||||
// setter for. Sole owner → no aliasing; synchronous in-bounds scalar write.
|
||||
unsafe {
|
||||
(*video.as_mut_ptr()).rc_buffer_size = vbv_bits as i32;
|
||||
}
|
||||
@@ -204,6 +227,9 @@ impl NvencEncoder {
|
||||
// "freeze". NVENC emits one IDR at stream start, then P-frames only; `forced-idr` (below)
|
||||
// turns a client recovery request (RFI, via `request_keyframe`) into an IDR on demand.
|
||||
// This is the Moonlight/Sunshine low-latency model.
|
||||
// SAFETY: same `video` builder as above — a non-null, properly-aligned, sole-owned, not-yet-
|
||||
// opened `AVCodecContext`. We write the plain `gop_size` int field (= -1, infinite GOP) before
|
||||
// `open_with`, which ffmpeg-next has no setter for. No aliasing; synchronous scalar write.
|
||||
unsafe {
|
||||
(*video.as_mut_ptr()).gop_size = -1;
|
||||
}
|
||||
@@ -214,6 +240,10 @@ impl NvencEncoder {
|
||||
// RGB-input paths leave these unset (NVENC's internal CSC writes its own VUI). Matches the
|
||||
// Windows NV12 path's BT.709 limited-range signalling.
|
||||
if matches!(format, PixelFormat::Nv12) {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
// fields to valid `AVColorSpace`/`AVColorRange`/`AVColorPrimaries`/`AVColorTransfer-
|
||||
// Characteristic` variants before `open_with`. Sole owner → no aliasing; synchronous writes.
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
@@ -228,7 +258,17 @@ impl NvencEncoder {
|
||||
// *before* open (NVENC derives the device from `hw_frames_ctx`).
|
||||
let cuda_hw = if cuda {
|
||||
let cu_ctx = crate::zerocopy::cuda::context().context("shared CUDA context")?;
|
||||
// SAFETY: `CudaHw::new` (an `unsafe fn`) requires libav initialized (the `ffmpeg::init()`
|
||||
// above ran) and a valid `CUcontext`; `cu_ctx` is the shared importer context from
|
||||
// `zerocopy::cuda::context()?`, non-null on the `Ok` path. `nvenc_pixel` is a valid `Pixel`
|
||||
// and `width`/`height` are the validated positive dims. It returns a RAII `CudaHw` wrapping
|
||||
// (not owning) `cu_ctx` and owning two `AVBufferRef`s freed on drop.
|
||||
let hw = unsafe { CudaHw::new(cu_ctx, nvenc_pixel, width, height)? };
|
||||
// SAFETY: `raw = video.as_mut_ptr()` is the non-null, sole-owned, not-yet-opened
|
||||
// `AVCodecContext`. We set `pix_fmt = CUDA` and attach NEW refs (`av_buffer_ref`) of
|
||||
// `hw.device_ref`/`hw.frames_ref` — both non-null (`CudaHw::new` guarantees) and from the
|
||||
// live `hw`, which is moved into `NvencEncoder.cuda` next to `enc` and so outlives the
|
||||
// encoder. The context owns its own refs (freed when the context closes). No aliasing.
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).pix_fmt = ffi::AVPixelFormat::AV_PIX_FMT_CUDA;
|
||||
@@ -428,6 +468,19 @@ impl NvencEncoder {
|
||||
// The device→device copy below uses our shared context directly; make it current on the
|
||||
// encode thread (ffmpeg pushes its own around the pool alloc, so order is fine).
|
||||
crate::zerocopy::cuda::make_current().context("CUDA context current (encode thread)")?;
|
||||
// SAFETY: `frames_ref` is the non-null CUDA frames ctx from `self.cuda` (unwrapped via
|
||||
// `.context(..)?` above), and the shared CUDA context was just made current on THIS thread
|
||||
// (`make_current()?`), the precondition for the device-pointer copies below.
|
||||
// * `av_frame_alloc` → `f` (null-checked). `av_hwframe_get_buffer(frames_ref, f, 0)` fills `f`
|
||||
// with a pooled CUDA surface (sets `data[]`/`linesize[]`/`buf[0]`/`hw_frames_ctx`); on
|
||||
// failure we free `f` and bail.
|
||||
// * For NV12 we read `(*f).data[0..2]` / `linesize[0..2]` (Y + interleaved UV), else
|
||||
// `data[0]`/`linesize[0]` — in-struct fields of the non-null `f`, valid for the surface dims
|
||||
// ffmpeg allocated — and pass them to the cuda copy helpers, which device→device copy `buf`
|
||||
// (the imported `DeviceBuffer`, owned by the caller and live for this call) into the surface.
|
||||
// * On copy error we free `f` and return. Otherwise we write `pts`/`pict_type` through `f` and
|
||||
// `avcodec_send_frame` it into the live owned `self.enc` context (which takes its own ref of
|
||||
// the pooled surface), then free our `f` ref exactly once. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
let mut f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
+148
-3
@@ -19,6 +19,8 @@
|
||||
//! hwdevice/hwframes/buffersrc/buffersink calls go through `ffmpeg::ffi` (= `ffmpeg_sys_next`),
|
||||
//! as the CUDA encode path and the clients' decode paths already do. The encoder is opened
|
||||
//! *without* a global header, so VPS/SPS/PPS are in-band on every IDR.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
|
||||
@@ -133,6 +135,14 @@ pub fn probe_can_encode(codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `ffmpeg::init()` returned Ok above, so libav is initialized. `av_log_get_level`/
|
||||
// `av_log_set_level` only read/write libav's global integer log level (no pointer args) and are
|
||||
// always sound to call post-init. `VaapiHw::new` (an `unsafe fn`) builds a VAAPI device + NV12
|
||||
// frames pool from the literal NV12/640x480/pool=2 args and hands back a RAII handle that unrefs
|
||||
// both `AVBufferRef`s on drop. `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/
|
||||
// `hw.frames_ref` — the two non-null refs `VaapiHw::new` just created — and `av_buffer_ref`s them
|
||||
// into the encoder; `hw` is a live local for the whole match arm, so the borrows outlive the
|
||||
// synchronous call, and both `hw` and the probe encoder are dropped (RAII) when the arm ends.
|
||||
unsafe {
|
||||
// A missing VA device (non-VAAPI host, GPU-less CI) is an expected probe outcome — quiet
|
||||
// ffmpeg's "No VA display found" error for the probe, then restore the level.
|
||||
@@ -224,6 +234,12 @@ impl VaapiHw {
|
||||
|
||||
impl Drop for VaapiHw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `VaapiHw::new`
|
||||
// created (it bails before constructing `Self` if either alloc fails, so a live `VaapiHw`
|
||||
// always holds both). `av_buffer_unref` drops one reference and nulls the pointer through the
|
||||
// `&mut`. This `Drop` runs exactly once and `VaapiHw` owns these refs exclusively, so there
|
||||
// is no double-free / use-after-free. Frames are unref'd before the device because the frames
|
||||
// ctx internally holds a ref on the device (refcounted, so the order is sound either way).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
@@ -252,7 +268,16 @@ impl CpuInner {
|
||||
) -> Result<Self> {
|
||||
let src_pixel = vaapi_sws_src(format)?;
|
||||
const POOL: c_int = 16;
|
||||
// SAFETY: `VaapiHw::new` (an `unsafe fn`) requires libav initialized — guaranteed because the
|
||||
// only path here is `VaapiEncoder::open` → `ensure_inner` → `CpuInner::open`, and `open` ran
|
||||
// `ffmpeg::init()`. The args are valid: NV12 sw_format, the validated positive `width`/`height`,
|
||||
// pool=16. It returns a RAII `VaapiHw` that unrefs its two `AVBufferRef`s on drop.
|
||||
let hw = unsafe { VaapiHw::new(ffi::AVPixelFormat::AV_PIX_FMT_NV12, width, height, POOL)? };
|
||||
// SAFETY: `open_vaapi_encoder` (an `unsafe fn`) borrows `hw.device_ref`/`hw.frames_ref` — both
|
||||
// non-null (`VaapiHw::new` guarantees it) and from the `hw` just built above, which is a live
|
||||
// local that outlives this synchronous call. The fn `av_buffer_ref`s them into the encoder, so
|
||||
// the encoder holds its own references; `hw` is also moved into the returned `CpuInner` next to
|
||||
// `enc`, keeping the device/frames alive for the encoder's whole lifetime.
|
||||
let enc = unsafe {
|
||||
open_vaapi_encoder(
|
||||
codec,
|
||||
@@ -266,6 +291,12 @@ impl CpuInner {
|
||||
};
|
||||
// swscale RGB→NV12, BT.709 limited (matches the VUI), no rescale.
|
||||
let src_av = pixel_to_av(src_pixel);
|
||||
// SAFETY: `sws_getContext` allocates a swscale context for the given src/dst dimensions and
|
||||
// pixel formats. All four dims are the encoder's positive `width`/`height` cast to `c_int`;
|
||||
// `src_av` is a valid `AVPixelFormat` (from `pixel_to_av` of the `vaapi_sws_src`-validated
|
||||
// `src_pixel`), the dst is NV12. The three trailing pointers (srcFilter, dstFilter, param) are
|
||||
// explicitly null = "use defaults", which the API documents as accepted. No Rust memory is
|
||||
// borrowed — only by-value ints/enums — and the returned pointer is null-checked just below.
|
||||
let sws = unsafe {
|
||||
ffi::sws_getContext(
|
||||
width as c_int,
|
||||
@@ -283,10 +314,23 @@ impl CpuInner {
|
||||
if sws.is_null() {
|
||||
bail!("sws_getContext(RGB→NV12) failed");
|
||||
}
|
||||
// SAFETY: `sws` is the non-null `SwsContext` from `sws_getContext` above (the `is_null()`
|
||||
// check immediately preceding returned false). `sws_getCoefficients(SWS_CS_ITU709)` returns a
|
||||
// pointer into a libswscale static const coefficient table valid for the whole process, reused
|
||||
// here for both the inverse (src) and forward (dst) matrices. `sws_setColorspaceDetails` only
|
||||
// reads those tables and writes scalar CSC settings into `sws`; the table pointer outlives the
|
||||
// synchronous call and no Rust memory is passed.
|
||||
unsafe {
|
||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
// SAFETY: `av_frame_alloc` returns a fresh, uniquely-owned heap `AVFrame` (null-checked — on
|
||||
// null we free the already-built `sws` and bail). We then write the plain `format`/`width`/
|
||||
// `height` fields through the non-null, properly-aligned `f` (sole owner, not yet shared).
|
||||
// `av_frame_get_buffer(f, 0)` allocates backing storage for those dims/format; on failure we
|
||||
// free `f` and `sws` (unwinding the half-built state) and bail. On success `f` is a fully-owned
|
||||
// NV12 frame stored in `CpuInner.nv12` and freed once in `CpuInner::drop`. `f` is a unique
|
||||
// fresh pointer, so none of these writes alias anything.
|
||||
let nv12 = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
@@ -329,6 +373,18 @@ impl CpuInner {
|
||||
let h = self.height as usize;
|
||||
let src_row = w * self.src_format.bytes_per_pixel();
|
||||
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
||||
// SAFETY: The `ensure!`s above guarantee `format == self.src_format` and
|
||||
// `bytes.len() >= src_row * h`. `sws_scale` reads `h` rows of `src_row` bytes from
|
||||
// `src_data[0] = bytes.as_ptr()` (the other planes null/0 — packed RGB is single-plane), all
|
||||
// in bounds; `bytes`, `src_data`, `src_stride` are live locals for this synchronous call.
|
||||
// `self.sws` is the non-null context built in `open`; it writes into `self.nv12` (a non-null
|
||||
// owned frame whose `data`/`linesize` in-struct arrays were sized by `av_frame_get_buffer`).
|
||||
// `av_frame_alloc` (null-checked) yields a fresh `hwf`; `av_hwframe_get_buffer` pulls a pooled
|
||||
// VAAPI surface from the live non-null `self.hw.frames_ref`; `av_hwframe_transfer_data` uploads
|
||||
// the staged NV12 into it — both frames live, failures free `hwf` and bail. We then write
|
||||
// `pts`/`pict_type` through the non-null `hwf` and `avcodec_send_frame` it into the live
|
||||
// owned `self.enc` context (which takes its own ref), then free our `hwf` ref exactly once.
|
||||
// The encoder runs only on this thread (see `unsafe impl Send`), so no aliasing/data race.
|
||||
unsafe {
|
||||
let src_data: [*const u8; 4] = [bytes.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
|
||||
let src_stride: [c_int; 4] = [src_row as c_int, 0, 0, 0];
|
||||
@@ -374,6 +430,12 @@ impl CpuInner {
|
||||
|
||||
impl Drop for CpuInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.nv12` (an owned `AVFrame`) and `self.sws` (an owned `SwsContext`) are each
|
||||
// freed exactly once here, guarded by `is_null()` so a never-set pointer is skipped (no double
|
||||
// free). `CpuInner` owns both exclusively and `Drop` runs once. `av_frame_free` takes `&mut`
|
||||
// and nulls the pointer. `self.enc`/`self.hw` are freed afterward by their own `Drop` impls;
|
||||
// the encoder holds its own `av_buffer_ref`'d device/frames copies, so field-drop order is
|
||||
// irrelevant to soundness.
|
||||
unsafe {
|
||||
if !self.nv12.is_null() {
|
||||
ffi::av_frame_free(&mut self.nv12);
|
||||
@@ -417,6 +479,31 @@ impl DmabufInner {
|
||||
let drm_fourcc = crate::zerocopy::drm_fourcc(format)
|
||||
.ok_or_else(|| anyhow!("no DRM fourcc for {format:?} (VAAPI zero-copy)"))?;
|
||||
let node = render_node();
|
||||
// SAFETY: libav is initialized (`VaapiEncoder::open` ran `ffmpeg::init()` before
|
||||
// `ensure_inner` → `DmabufInner::open`). Every raw pointer dereferenced below is either freshly
|
||||
// allocated by the immediately-preceding ffmpeg call and null-checked, or an in-struct field of
|
||||
// such an object:
|
||||
// * `node` is a `CString` (from `render_node`) live for the whole block; its `.as_ptr()` is a
|
||||
// NUL-terminated path read only during `av_hwdevice_ctx_create`.
|
||||
// * `av_hwdevice_ctx_create(&mut drm_device, DRM, …)` / `…_create_derived(&mut vaapi_device,
|
||||
// VAAPI, drm_device, …)`: on `r < 0` the out-param stays null and we bail (the derive path
|
||||
// unrefs `drm_device` first); on success each is a non-null owned `AVBufferRef`.
|
||||
// * `av_hwframe_ctx_alloc(drm_device)` → `drm_frames` (null-checked); `(*drm_frames).data` is
|
||||
// its `AVHWFramesContext` payload, written before `av_hwframe_ctx_init`.
|
||||
// * `avfilter_graph_alloc` → `graph` (null-checked); `avfilter_get_by_name` returns a static
|
||||
// const `AVFilter` (process-lifetime) or null; `avfilter_graph_alloc_filter` allocates each
|
||||
// filter ctx inside `graph`; the four are null-checked together. `inst`/arg strings are
|
||||
// 'static C literals.
|
||||
// * `(*hwmap/scale).hw_device_ctx = av_buffer_ref(vaapi_device)` attaches a NEW ref owned by
|
||||
// the filter (freed by `avfilter_graph_free`); our `vaapi_device` ref is untouched.
|
||||
// * `av_buffersink_get_hw_frames_ctx(sink)` → `nv12_ctx` is a borrowed ref owned by the sink,
|
||||
// valid while `graph` lives (and `graph` is moved into the returned `DmabufInner`).
|
||||
// * `open_vaapi_encoder` borrows `vaapi_device` (our live owned ref) and `nv12_ctx` (sink's
|
||||
// live ref) and `av_buffer_ref`s both into the encoder.
|
||||
// Every early-error path unref's the allocated buffers and frees the graph in the right order
|
||||
// before bailing; on success the four `AVBufferRef`s + `graph` + `src`/`sink` are moved into
|
||||
// `DmabufInner` and freed in its `Drop`. (Two non-UB leaks noted below: `av_buffersrc_*` and
|
||||
// the final `?`.)
|
||||
unsafe {
|
||||
// DRM device (source dmabuf frames) + a VAAPI device derived from it (same GPU) for
|
||||
// hwmap/scale_vaapi/the encoder.
|
||||
@@ -509,7 +596,12 @@ impl DmabufInner {
|
||||
num: 1,
|
||||
den: fps as c_int,
|
||||
};
|
||||
(*par).hw_frames_ctx = ffi::av_buffer_ref(drm_frames);
|
||||
// Assign `drm_frames` BORROWED (no extra ref): `av_buffersrc_parameters_set` takes its
|
||||
// own ref of `par->hw_frames_ctx` (via av_buffer_replace), and `av_free(par)` frees only
|
||||
// the struct, not the ref. Our single owned `drm_frames` ref is retained, lives in
|
||||
// `DmabufInner`, and is unref'd in `Drop`. Wrapping it in `av_buffer_ref` here would leak
|
||||
// that extra ref every session (the persistent listener would accumulate them).
|
||||
(*par).hw_frames_ctx = drm_frames;
|
||||
let r = ffi::av_buffersrc_parameters_set(src, par);
|
||||
ffi::av_free(par as *mut _);
|
||||
if r < 0 {
|
||||
@@ -564,7 +656,12 @@ impl DmabufInner {
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
bail!("filter sink has no VAAPI frames context");
|
||||
}
|
||||
let enc = open_vaapi_encoder(
|
||||
// On encoder-open failure, free the graph + our owned buffer refs before bailing (matching
|
||||
// every error path above) so a failed session doesn't leak them. `nv12_ctx` is borrowed
|
||||
// from the sink (owned by `graph`), so `avfilter_graph_free` reclaims it — don't unref it
|
||||
// separately. On success the encoder takes its own ref of `vaapi_device`, and `drm_frames`/
|
||||
// `vaapi_device`/`drm_device`/`graph` move into `DmabufInner` (freed in `Drop`).
|
||||
let enc = match open_vaapi_encoder(
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
@@ -572,7 +669,16 @@ impl DmabufInner {
|
||||
bitrate_bps,
|
||||
vaapi_device,
|
||||
nv12_ctx,
|
||||
)?;
|
||||
) {
|
||||
Ok(enc) => enc,
|
||||
Err(e) => {
|
||||
ffi::avfilter_graph_free(&mut graph);
|
||||
ffi::av_buffer_unref(&mut drm_frames);
|
||||
ffi::av_buffer_unref(&mut vaapi_device);
|
||||
ffi::av_buffer_unref(&mut drm_device);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
encoder = codec.vaapi_name(),
|
||||
@@ -600,6 +706,23 @@ impl DmabufInner {
|
||||
dmabuf.fourcc,
|
||||
self.fourcc
|
||||
);
|
||||
// SAFETY: The `ensure!` above checked `dmabuf.fourcc == self.fourcc`.
|
||||
// * `std::mem::zeroed::<AVDRMFrameDescriptor>()` is sound: it is a `#[repr(C)]` POD of ints and
|
||||
// nested int-struct arrays (no `NonNull`/refs), for which all-zero is a valid bit pattern;
|
||||
// `Box` puts it on the heap with a unique owner.
|
||||
// * `dmabuf.fd.as_raw_fd()` is the fd of the caller's `&DmabufFrame`, which owns it for the
|
||||
// whole synchronous `submit`; we describe one object/layer/plane from its
|
||||
// fourcc/modifier/offset/stride and pass `object.size = 0` (ffmpeg queries the real size).
|
||||
// * `av_frame_alloc` → `drm` (null-checked); we set its scalar fields and
|
||||
// `hw_frames_ctx = av_buffer_ref(self.drm_frames)` (new ref of the live owned ctx).
|
||||
// * `data[0] = Box::into_raw(desc)` transfers the box into the frame; `buf[0] =
|
||||
// av_buffer_create(.., free_desc, ..)` registers a destructor that reclaims it exactly once
|
||||
// when the buffer's refcount hits zero — matched alloc/free, no leak/double-free.
|
||||
// * `av_buffersrc_add_frame_flags(self.src, drm, KEEP_REF)` pushes a ref into the live
|
||||
// buffersrc; KEEP_REF keeps our own `drm` ref, which we then `av_frame_free`. We pull the
|
||||
// converted surface with `av_buffersink_get_frame(self.sink, nv12)` BEFORE returning, so the
|
||||
// dmabuf (owned by the caller) is read while still valid. `nv12` is sent into the live owned
|
||||
// `self.enc` (takes its own ref) and our ref freed once. Single-threaded encoder → no race.
|
||||
unsafe {
|
||||
// Build a DRM-PRIME AVFrame describing the dmabuf (one object/fd, one layer/plane).
|
||||
let mut desc: Box<ffi::AVDRMFrameDescriptor> = Box::new(std::mem::zeroed());
|
||||
@@ -626,6 +749,11 @@ impl DmabufInner {
|
||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||
// which outlives this call — the graph reads the surface before submit returns).
|
||||
extern "C" fn free_desc(_opaque: *mut std::ffi::c_void, data: *mut u8) {
|
||||
// SAFETY: `data` is exactly the pointer produced by `Box::into_raw(desc)` and passed as
|
||||
// `av_buffer_create`'s first arg, which libav hands back verbatim to this callback. It
|
||||
// is a valid, uniquely-owned `Box<AVDRMFrameDescriptor>` raw pointer; libav invokes the
|
||||
// callback exactly once (when the last buffer ref drops), so `from_raw` + `drop`
|
||||
// reclaims it exactly once — no double-free. `_opaque` is unused (we passed null).
|
||||
unsafe { drop(Box::from_raw(data as *mut ffi::AVDRMFrameDescriptor)) };
|
||||
}
|
||||
(*drm).buf[0] = ffi::av_buffer_create(
|
||||
@@ -673,6 +801,13 @@ impl DmabufInner {
|
||||
|
||||
impl Drop for DmabufInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `graph`/`drm_frames`/`vaapi_device`/`drm_device` are the non-null objects
|
||||
// `DmabufInner::open` built and moved into `self` (open bails before constructing `Self` if any
|
||||
// alloc fails). `avfilter_graph_free` frees the graph (and the per-filter device refs it owns);
|
||||
// each `av_buffer_unref` drops one ref and nulls the pointer via `&mut`. `DmabufInner` owns all
|
||||
// four exclusively and `Drop` runs once → no double-free/use-after-free. The graph is freed
|
||||
// first (it holds refs on the devices), then frames, then the derived VAAPI device, then DRM.
|
||||
// (`self.enc` drops via ffmpeg-next afterward, holding its own refs.)
|
||||
unsafe {
|
||||
ffi::avfilter_graph_free(&mut self.graph);
|
||||
ffi::av_buffer_unref(&mut self.drm_frames);
|
||||
@@ -703,6 +838,13 @@ pub struct VaapiEncoder {
|
||||
}
|
||||
|
||||
// Raw FFI pointers; the encoder lives on a single thread (same contract as `NvencEncoder`).
|
||||
// SAFETY: `VaapiEncoder`'s `Inner` holds raw FFI pointers (`SwsContext`, `AVFrame`, `AVBufferRef`,
|
||||
// `AVFilterContext`, `AVCodecContext`) that are not `Send` by default. The encoder is owned and
|
||||
// driven by exactly ONE thread — the host's per-session encode thread it is moved (transferred) to —
|
||||
// and is only ever touched through `&mut self` methods, so it is never aliased or accessed
|
||||
// concurrently from two threads. None of the underlying libav/libswscale objects have thread
|
||||
// affinity (they are not thread-local), so transferring ownership across threads is sound. This
|
||||
// asserts `Send` (transfer) only; `Sync` (shared `&`) is deliberately NOT implemented.
|
||||
unsafe impl Send for VaapiEncoder {}
|
||||
|
||||
impl VaapiEncoder {
|
||||
@@ -720,6 +862,9 @@ impl VaapiEncoder {
|
||||
}
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `av_log_set_level` sets libav's global integer log level; `48` (= AV_LOG_DEBUG)
|
||||
// is a valid level and there are no pointer args. libav was just initialized by the
|
||||
// `ffmpeg::init()` above, so the call is always sound.
|
||||
unsafe { ffi::av_log_set_level(48) };
|
||||
}
|
||||
// Validate the codec/format up front so a bad request fails at open, not on the first frame.
|
||||
+100
-1
@@ -28,6 +28,8 @@
|
||||
//! through `ffmpeg::ffi` (= `ffmpeg_sys_next`), exactly as the Linux CUDA/VAAPI paths do. The
|
||||
//! `AVD3D11VADeviceContext`/`AVD3D11VAFramesContext` layouts are mirrored (the bindings don't
|
||||
//! allowlist `hwcontext_d3d11va.h`), as [`super::linux`] mirrors `AVCUDADeviceContext`.
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -109,7 +111,7 @@ impl WinVendor {
|
||||
/// Is the zero-copy D3D11 path enabled? Opt-in (`PUNKTFUNK_ZEROCOPY=1`) until on-glass validated;
|
||||
/// the default is the robust system-memory readback path.
|
||||
fn zerocopy_enabled() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some()
|
||||
crate::config::config().zerocopy
|
||||
}
|
||||
|
||||
/// The swscale *source* pixel format for a captured packed-RGB/BGR layout (8-bit BGRA fallback only).
|
||||
@@ -243,6 +245,12 @@ pub fn probe_can_encode(vendor: WinVendor, codec: Codec) -> bool {
|
||||
if ffmpeg::init().is_err() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `ffmpeg::init()` succeeded above, so libav's global state is initialised.
|
||||
// `av_log_get_level`/`av_log_set_level` are global scalar getters/setters with no pointer args.
|
||||
// `open_win_encoder` (the `unsafe fn`) is called with null `device_ref`/`frames_ref` (the system
|
||||
// path), so it touches no D3D11/hwcontext — it only allocates and opens a self-contained
|
||||
// libavcodec encoder that is dropped at the end of `.is_ok()`. We restore the prior log level and
|
||||
// no raw pointer escapes the block.
|
||||
unsafe {
|
||||
// A missing AMF/QSV runtime (wrong-vendor host, GPU-less CI) is an expected probe outcome —
|
||||
// quiet ffmpeg's open error for the probe, then restore the level.
|
||||
@@ -337,6 +345,10 @@ impl SystemInner {
|
||||
} else {
|
||||
ffi::AVPixelFormat::AV_PIX_FMT_NV12
|
||||
};
|
||||
// SAFETY: calls the `unsafe fn open_win_encoder` with null `device_ref`/`frames_ref`, so the
|
||||
// system path is taken (no hw device/frames context is touched); all other args are scalars.
|
||||
// The returned `encoder::video::Encoder` owns its `AVCodecContext` and frees it on drop; no raw
|
||||
// pointer is aliased.
|
||||
let enc = unsafe {
|
||||
open_win_encoder(
|
||||
vendor,
|
||||
@@ -352,6 +364,11 @@ impl SystemInner {
|
||||
ptr::null_mut(),
|
||||
)?
|
||||
};
|
||||
// SAFETY: `av_frame_alloc` returns a freshly-allocated, uniquely-owned `AVFrame` (null-checked
|
||||
// before any deref); writing `format`/`width`/`height` through `*f` stays inside that
|
||||
// allocation. `av_frame_get_buffer(f, 0)` allocates the backing planes — on failure we
|
||||
// `av_frame_free` the sole owner (no double-free) and bail; on success the raw `f` is moved into
|
||||
// `self.sw_frame` and freed exactly once in `Drop`.
|
||||
let sw_frame = unsafe {
|
||||
let f = ffi::av_frame_alloc();
|
||||
if f.is_null() {
|
||||
@@ -467,6 +484,18 @@ impl SystemInner {
|
||||
} else {
|
||||
DXGI_FORMAT_NV12
|
||||
};
|
||||
// SAFETY: `ensure_staging` builds a STAGING texture (CPU_ACCESS_READ) matching `dxgi_fmt` on
|
||||
// `frame.device` — the same `ID3D11Device` that owns `frame.texture` — and caches that device's
|
||||
// immediate context in `self.ctx`. `src`/`dst` are that device's textures of identical NV12/P010
|
||||
// format and dimensions, so `CopyResource` on the single-threaded immediate context is valid.
|
||||
// `Map(.., D3D11_MAP_READ)` succeeds on a staging texture and yields `map.pData` valid for the
|
||||
// whole resource; for NV12/P010 the luma plane is `H` rows at `RowPitch` and the chroma plane
|
||||
// follows at byte offset `RowPitch*H` (`H/2` rows), so `total = pitch*(H+⌈H/2⌉)` is exactly the
|
||||
// mapped extent and `from_raw_parts(base, total)` stays in-bounds. Each `copy_nonoverlapping`
|
||||
// reads a bounds-checked `mapped[..]` sub-slice (`row_bytes ≤ pitch`) and writes `row_bytes ≤
|
||||
// linesize` into the `av_frame_get_buffer`-allocated plane at row `y < H`, so every destination
|
||||
// offset is inside the frame's plane allocation; src and dst never alias. `Unmap` pairs `Map`,
|
||||
// then `send` (the `unsafe fn`) hands `sw_frame` to the encoder.
|
||||
unsafe {
|
||||
self.ensure_staging(&frame.device, dxgi_fmt)?;
|
||||
let staging = self.staging.clone().context("staging texture")?;
|
||||
@@ -510,6 +539,14 @@ impl SystemInner {
|
||||
if self.ten_bit {
|
||||
bail!("ffmpeg_win: BGRA readback is 8-bit only (HDR needs the P010 capture path)");
|
||||
}
|
||||
// SAFETY: `ensure_staging` builds a B8G8R8A8 STAGING texture on `frame.device` and caches that
|
||||
// device's immediate context; `src`/`dst` are that device's textures of matching BGRA format,
|
||||
// so `CopyResource` on the single-threaded context is valid. `Map(READ)` on the staging texture
|
||||
// yields `base` valid for `pitch` × `h` rows. `ensure_sws` lazily builds the BGRA→NV12 context;
|
||||
// `sws_scale` reads `h` rows of `pitch` bytes from `base` (in-bounds — the staging surface is
|
||||
// `≥ pitch*h`) into the `sw_frame` planes addressed by its `data`/`linesize` (allocated for
|
||||
// `width`×`height` NV12). `Unmap` pairs `Map`; the cached `sws` is freed once in `Drop`. The
|
||||
// mapped read region never aliases the owned encoder frame.
|
||||
unsafe {
|
||||
self.ensure_staging(&frame.device, DXGI_FORMAT_B8G8R8A8_UNORM)?;
|
||||
let staging = self.staging.clone().context("staging texture")?;
|
||||
@@ -552,6 +589,13 @@ impl SystemInner {
|
||||
/// R10 shader output instead of P010. DXGI `R10G10B10A2_UNORM` (R in the low 10 bits, X2 alpha in
|
||||
/// the top 2) == FFmpeg `AV_PIX_FMT_X2BGR10LE`. UNTESTED on glass (no AMD/Intel Windows box).
|
||||
fn readback_rgb10(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
||||
// SAFETY: same shape as `readback_yuv`/`readback_bgra` — `ensure_staging` builds an
|
||||
// R10G10B10A2 STAGING texture on `frame.device` and caches its immediate context; `src`/`dst`
|
||||
// are that device's matching-format textures, so `CopyResource` on the single-threaded context
|
||||
// is valid. `Map(READ)` yields `base` valid for `pitch` × `h` rows. `ensure_sws` builds the
|
||||
// X2BGR10LE→P010 (BT.2020) context; `sws_scale` reads `h` rows of `pitch` bytes from `base`
|
||||
// (in-bounds) into the `sw_frame` P010 planes (`data`/`linesize`, allocated `width`×`height`).
|
||||
// `Unmap` pairs `Map`; `sws` is freed once in `Drop`. No aliasing between read and write.
|
||||
unsafe {
|
||||
self.ensure_staging(&frame.device, DXGI_FORMAT_R10G10B10A2_UNORM)?;
|
||||
let staging = self.staging.clone().context("staging texture")?;
|
||||
@@ -605,6 +649,12 @@ impl SystemInner {
|
||||
let h = self.height as usize;
|
||||
let src_row = w * format.bytes_per_pixel();
|
||||
anyhow::ensure!(bytes.len() >= src_row * h, "captured buffer too small");
|
||||
// SAFETY: `ensure_sws` lazily builds the (packed RGB/BGR)→NV12 context for this fixed src/dst
|
||||
// format pair. `src_data[0] = bytes.as_ptr()` with `src_stride[0] = src_row`; the `ensure!`
|
||||
// above guarantees `bytes` holds at least `src_row*h` bytes, so `sws_scale` reads `h` rows of
|
||||
// `src_row` bytes in-bounds and writes the `sw_frame` NV12 planes (`data`/`linesize`, allocated
|
||||
// `width`×`height`). `bytes` is borrowed for the call only and never aliases the owned
|
||||
// `sw_frame`. `send` then hands `sw_frame` to the encoder.
|
||||
unsafe {
|
||||
self.ensure_sws(
|
||||
pixel_to_av(sws_src(format)?),
|
||||
@@ -667,6 +717,10 @@ impl SystemInner {
|
||||
|
||||
impl Drop for SystemInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `sw_frame` is the `AVFrame` allocated in `open` (or null) — `av_frame_free` drops it
|
||||
// once and nulls the pointer through the `&mut`; `sws` is the cached `SwsContext` (or null) —
|
||||
// `sws_freeContext` frees it once. This `Drop` runs exactly once and `SystemInner` owns both
|
||||
// exclusively, so there is no double-free or use-after-free.
|
||||
unsafe {
|
||||
if !self.sw_frame.is_null() {
|
||||
ffi::av_frame_free(&mut self.sw_frame);
|
||||
@@ -745,6 +799,12 @@ impl D3d11Hw {
|
||||
|
||||
impl Drop for D3d11Hw {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `frames_ref`/`device_ref` are the two non-null `AVBufferRef`s `D3d11Hw::new` created
|
||||
// (it bails before constructing `Self` if either alloc/init fails, so a live `D3d11Hw` always
|
||||
// holds both). `av_buffer_unref` drops one reference and nulls the pointer through the `&mut`.
|
||||
// This `Drop` runs exactly once and `D3d11Hw` owns these refs exclusively → no double-free /
|
||||
// use-after-free. Frames are unref'd before the device because the frames ctx internally holds
|
||||
// a ref on the device (refcounted, so the order is sound either way).
|
||||
unsafe {
|
||||
ffi::av_buffer_unref(&mut self.frames_ref);
|
||||
ffi::av_buffer_unref(&mut self.device_ref);
|
||||
@@ -800,6 +860,18 @@ impl ZeroCopyInner {
|
||||
WinVendor::Qsv => (D3D11_BIND_DECODER.0 | D3D11_BIND_VIDEO_ENCODER.0) as u32,
|
||||
};
|
||||
const POOL: c_int = 8;
|
||||
// SAFETY: `D3d11Hw::new` wraps the capturer's `device` as a D3D11VA hwdevice (handing FFmpeg an
|
||||
// owned AddRef of it, balanced by FFmpeg's teardown Release) and builds an owned
|
||||
// device_ref/frames_ref pair freed by `D3d11Hw::Drop`; `hw` is a local, so it is dropped (and
|
||||
// both refs freed) on every early `return Err`. For QSV, `av_hwdevice_ctx_create_derived` and
|
||||
// `av_hwframe_ctx_create_derived` fill the null-initialised `qsv_device`/`qsv_frames` out-params
|
||||
// only on success (`r >= 0` checked); on the frames-derive failure we unref the already-created
|
||||
// `qsv_device` before bailing. `open_win_encoder` internally `av_buffer_ref`s the dev/frames
|
||||
// refs it is given (so ownership of `hw`'s and the derived refs stays here), and on its failure
|
||||
// we unref the still-owned derived `qsv_frames`/`qsv_device` (null for AMF → skipped) and return
|
||||
// — `hw` then drops its D3D11 refs. On success the derived refs are moved into `ZeroCopyInner`
|
||||
// (freed in its `Drop`) and the encoder holds its own AddRef'd copies. Every `AVBufferRef` is
|
||||
// unref'd exactly once across all paths — no leak, no double-free.
|
||||
unsafe {
|
||||
let hw = D3d11Hw::new(device, sw_av, bind_flags, width, height, POOL)?;
|
||||
let (pix_fmt, dev_ref, frames_ref, mut qsv_device, mut qsv_frames) = match vendor {
|
||||
@@ -887,6 +959,19 @@ impl ZeroCopyInner {
|
||||
}
|
||||
|
||||
fn submit(&mut self, frame: &D3d11Frame, pts: i64, idr: bool) -> Result<()> {
|
||||
// SAFETY: `d3d = av_frame_alloc()` is a fresh owned frame (null-checked) and is `av_frame_free`d
|
||||
// exactly once on every path below. `av_hwframe_get_buffer` fills it from the pool — on failure
|
||||
// we free it and bail. `(*d3d).data[0]` is the pool's texture-array and `data[1]` the array
|
||||
// index; `from_raw_borrowed` borrows that `ID3D11Texture2D` WITHOUT taking ownership (no Release
|
||||
// — the frame owns it) and is null-checked. `src` (the captured texture) and `dst` (the pooled
|
||||
// slice) live on the SAME D3D11 device wrapped by `self.hw`, and the caller guarantees
|
||||
// `captured.format == pool_format` before calling, so `CopySubresourceRegion(dst, dst_index, ..,
|
||||
// src, 0, ..)` on the single-threaded immediate context `self.ctx` is a valid same-format GPU
|
||||
// copy. For QSV the mapped `qsv` frame is a fresh owned frame whose `hw_frames_ctx` takes an
|
||||
// `av_buffer_ref` of `self.qsv_frames`; it is `av_frame_free`d (releasing that ref) on both the
|
||||
// map-failure and success paths. `avcodec_send_frame` only internally refs the input frame, so
|
||||
// the `av_frame_free(d3d)`/`av_frame_free(qsv)` afterwards are the sole owning frees — no leak,
|
||||
// no double-free, no use-after-free.
|
||||
unsafe {
|
||||
// Pull a pooled D3D11 surface; its data[0] is the pool's texture-ARRAY, data[1] the slice.
|
||||
let mut d3d = ffi::av_frame_alloc();
|
||||
@@ -959,6 +1044,11 @@ impl ZeroCopyInner {
|
||||
|
||||
impl Drop for ZeroCopyInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `qsv_frames`/`qsv_device` are the derived QSV `AVBufferRef`s (or null for AMF); each
|
||||
// is `av_buffer_unref`'d once here (nulling the pointer through the `&mut`) — `ZeroCopyInner`
|
||||
// owns these handles exclusively and this `Drop` runs once, so no double-free. The `enc` and
|
||||
// `hw` fields free the encoder's AddRef'd copies and the D3D11 device/frames refs through their
|
||||
// own `Drop`, so all references stay balanced.
|
||||
unsafe {
|
||||
if !self.qsv_frames.is_null() {
|
||||
ffi::av_buffer_unref(&mut self.qsv_frames);
|
||||
@@ -996,6 +1086,13 @@ pub struct FfmpegWinEncoder {
|
||||
}
|
||||
|
||||
// Raw FFI pointers + COM objects; the encoder lives on a single thread (same contract as NVENC/VAAPI).
|
||||
// SAFETY: `FfmpegWinEncoder` owns raw libav pointers (`AVFrame`/`SwsContext`/`AVBufferRef`) and
|
||||
// windows-rs COM handles (`ID3D11Device`/`ID3D11DeviceContext`/textures) that are not auto-`Send`. The
|
||||
// session creates the encoder, drives `submit`/`poll`/`flush`, and drops it all on one dedicated encode
|
||||
// thread; it is never shared by reference across threads, and the D3D11 immediate context is only ever
|
||||
// touched from that thread. The only cross-thread action is the initial move to the encode thread,
|
||||
// after which every interior pointer/COM ref is used single-threaded — the same contract the
|
||||
// NVENC/VAAPI encoders rely on. No interior state is accessed concurrently.
|
||||
unsafe impl Send for FfmpegWinEncoder {}
|
||||
|
||||
impl FfmpegWinEncoder {
|
||||
@@ -1012,6 +1109,8 @@ impl FfmpegWinEncoder {
|
||||
) -> Result<Self> {
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
|
||||
// SAFETY: `ffmpeg::init()` ran on the line above, so libav is initialised; `av_log_set_level`
|
||||
// is a global scalar setter with no pointer arguments.
|
||||
unsafe { ffi::av_log_set_level(48) };
|
||||
}
|
||||
// Make sure the encoder name exists in this libavcodec build up front (clear error vs a
|
||||
+82
-3
@@ -13,7 +13,10 @@
|
||||
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less, but
|
||||
//! `open`/`submit` only succeed on a GPU box. The software encoder (`super::sw`) is the fallback.
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
// Every `unsafe` block / impl in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -88,7 +91,15 @@ pub struct NvencD3d11Encoder {
|
||||
init_device: *mut c_void,
|
||||
}
|
||||
|
||||
// Raw NVENC handle + COM ptrs; confined to the single encode thread (like the Linux encoder).
|
||||
// SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
|
||||
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
|
||||
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads. This
|
||||
// encoder is owned by exactly one thread: it is moved onto the host encode thread once at
|
||||
// construction, and every NVENC call and D3D11 access happens only from that thread thereafter
|
||||
// (`submit`/`poll`/`invalidate_ref_frames`/`Drop` all run there, like the Linux encoder). Moving the
|
||||
// handles across that single ownership-transfer boundary is sound because no NVENC/D3D11 call is in
|
||||
// flight during the move and the session and its D3D11 immediate context are never shared (`&`) or
|
||||
// used concurrently — so `Send` introduces no data race on the non-`Send` fields.
|
||||
unsafe impl Send for NvencD3d11Encoder {}
|
||||
|
||||
impl NvencD3d11Encoder {
|
||||
@@ -403,6 +414,17 @@ impl NvencD3d11Encoder {
|
||||
|
||||
/// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
|
||||
fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
|
||||
// SAFETY: every call below goes through a function pointer resolved once from the loaded
|
||||
// `nvidia_video_codec_sdk::ENCODE_API` (`nvEncodeAPI`) table, or through this type's own
|
||||
// `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
|
||||
// the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
|
||||
// open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
|
||||
// `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
|
||||
// a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
|
||||
// session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
|
||||
// to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
|
||||
// returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
|
||||
// escapes the encode thread.
|
||||
unsafe {
|
||||
// Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
|
||||
// gated on what this card supports and an out-of-range mode fails with a clear error
|
||||
@@ -589,6 +611,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
new = format!("{}x{}", captured.width, captured.height),
|
||||
"NVENC: capture device/size/HDR changed — re-initializing session"
|
||||
);
|
||||
// SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
|
||||
// flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
|
||||
// All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
|
||||
// live session every cached resource was created against, and the previous frame's encode
|
||||
// has already been polled (synchronous submit→poll), so nothing is mid-encode.
|
||||
unsafe { self.teardown() };
|
||||
}
|
||||
if !self.inited {
|
||||
@@ -609,7 +636,14 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.bit_depth = 10;
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||
}
|
||||
PixelFormat::Nv12 => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12,
|
||||
PixelFormat::Nv12 => {
|
||||
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
|
||||
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
|
||||
// Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
|
||||
// `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
|
||||
self.bit_depth = 8;
|
||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
|
||||
}
|
||||
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||
};
|
||||
let device = frame.device.clone();
|
||||
@@ -618,6 +652,21 @@ impl Encoder for NvencD3d11Encoder {
|
||||
}
|
||||
let slot = self.next % POOL;
|
||||
self.next += 1;
|
||||
// SAFETY: every NVENC call goes through a function pointer from the loaded `ENCODE_API` table
|
||||
// and takes `self.encoder`, the live session `init_session` just established (non-null on the
|
||||
// path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
|
||||
// NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
|
||||
// `frame.device`, which is the SAME device the session was opened against (any device change
|
||||
// tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
|
||||
// `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
|
||||
// `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map is recorded
|
||||
// in `pending` to be unmapped exactly once in `poll`/`teardown`. `pic` (`NV_ENC_PIC_PARAMS`,
|
||||
// version set) points `inputBuffer` at `mp.mappedResource` and `outputBitstream` at the live
|
||||
// pool bitstream `bitstreams[slot]`; the optional SEI scratch (`mastering_sei`/`cll_sei` and the
|
||||
// `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
|
||||
// the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
|
||||
// for the duration of its one synchronous call. (In-place encode without `CopyResource` is
|
||||
// sound because the encode loop is synchronous, as the module docs state.)
|
||||
unsafe {
|
||||
// Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
|
||||
// IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
|
||||
@@ -732,6 +781,15 @@ impl Encoder for NvencD3d11Encoder {
|
||||
self.force_kf = true;
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
// RFI is probed once at open (`rfi_supported`); HDR SEI rides keyframes whenever the
|
||||
// session is in HDR mode. Both are the real capabilities the session glue routes on.
|
||||
EncoderCaps {
|
||||
supports_rfi: self.rfi_supported,
|
||||
supports_hdr_metadata: self.hdr,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
|
||||
// Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
|
||||
// frame; only changes when the source is regraded or HDR toggles.
|
||||
@@ -765,6 +823,12 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// We tag each input with `inputTimeStamp = frame_idx` (0,1,2,…), which is also the client's
|
||||
// frame number (the packetizer numbers frames in submit order), so the client's lost-frame
|
||||
// range maps 1:1 onto the timestamps NVENC invalidates here.
|
||||
// SAFETY: `invalidate_ref_frames` is a function pointer from the loaded `ENCODE_API` table.
|
||||
// `self.encoder` was checked non-null at the top of this fn and is the live session; this runs
|
||||
// on the encode thread (like submit/poll), so there is no concurrent NVENC use. Each `ts` was
|
||||
// clamped to `[oldest_in_dpb, frame_idx - 1]` above, so it names a frame still in the session's
|
||||
// DPB; the call passes only that `u64` timestamp (no struct), so there is no struct-size or
|
||||
// lifetime concern.
|
||||
unsafe {
|
||||
for ts in first..=last {
|
||||
if (API.invalidate_ref_frames)(self.encoder, ts as u64)
|
||||
@@ -783,6 +847,16 @@ impl Encoder for NvencD3d11Encoder {
|
||||
let Some((bs, map, pts_ns)) = self.pending.pop_front() else {
|
||||
return Ok(None);
|
||||
};
|
||||
// SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
|
||||
// (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
|
||||
// pointers from the loaded `ENCODE_API` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
|
||||
// (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
|
||||
// targeted; `lock_bitstream` blocks until that encode finishes, so on success
|
||||
// `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
|
||||
// NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
|
||||
// only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
|
||||
// same buffer — so it never outlives the lock. `map` (the input resource paired with `bs` in
|
||||
// `pending`) is unmapped here, after the encode completed, exactly once.
|
||||
unsafe {
|
||||
let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
|
||||
version: nv::NV_ENC_LOCK_BITSTREAM_VER,
|
||||
@@ -822,6 +896,11 @@ impl Encoder for NvencD3d11Encoder {
|
||||
|
||||
impl Drop for NvencD3d11Encoder {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
|
||||
// a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
|
||||
// exclusively (no other reference can exist), runs on the encode thread it was confined to, and
|
||||
// `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
|
||||
// pending was created against that live session. It runs exactly once (here).
|
||||
unsafe { self.teardown() };
|
||||
}
|
||||
}
|
||||
+8
@@ -2,6 +2,8 @@
|
||||
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
|
||||
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range.
|
||||
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||
@@ -30,6 +32,12 @@ pub struct OpenH264Encoder {
|
||||
}
|
||||
|
||||
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
|
||||
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
|
||||
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (`YUVBuffer`, `Vec`, scalars,
|
||||
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
|
||||
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
|
||||
// reference across threads, so the C handle is only ever touched from a single thread. Moving the
|
||||
// whole value to that thread is therefore sound — there is no concurrent access to the handle.
|
||||
unsafe impl Send for OpenH264Encoder {}
|
||||
|
||||
impl OpenH264Encoder {
|
||||
@@ -17,6 +17,9 @@
|
||||
//! data packets are consumed immediately and missing parity only costs loss recovery — so
|
||||
//! the validated stereo path stays byte-identical (data packets only, exactly as before).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows", test))]
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
@@ -409,7 +412,10 @@ struct MsEncoder {
|
||||
st: std::ptr::NonNull<audiopus_sys::OpusMSEncoder>,
|
||||
}
|
||||
|
||||
// The raw encoder state has no thread affinity; the session owns it on one thread at a time.
|
||||
// SAFETY: `MsEncoder` owns a unique `OpusMSEncoder` via `NonNull` (it is neither `Clone` nor
|
||||
// `Sync`, so the pointer is never aliased). libopus's multistream encoder state is a self-contained
|
||||
// heap allocation with no thread-local or thread-affine state, so moving ownership to another thread
|
||||
// is sound; every method takes `&mut self`, keeping access single-threaded at any instant.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe impl Send for MsEncoder {}
|
||||
|
||||
@@ -418,6 +424,13 @@ impl MsEncoder {
|
||||
fn new(layout: &OpusLayout) -> Result<MsEncoder> {
|
||||
use std::os::raw::c_int;
|
||||
let mut err: c_int = 0;
|
||||
// SAFETY: every scalar arg is a valid libopus input (sample rate, channel/stream/coupled
|
||||
// counts, the RESTRICTED_LOWDELAY application constant). `layout.mapping.as_ptr()` addresses
|
||||
// a 'static slice of exactly `layout.channels` bytes (every `OpusLayout` constant upholds
|
||||
// that), which is the element count `opus_multistream_encoder_create` reads through it, and
|
||||
// `&mut err` is a live local the call writes its status into. libopus copies the mapping into
|
||||
// its own allocation, so the pointer need only be valid for the call; the returned pointer is
|
||||
// null/`OPUS_OK`-checked below before any use.
|
||||
let st = unsafe {
|
||||
audiopus_sys::opus_multistream_encoder_create(
|
||||
SAMPLE_RATE as i32,
|
||||
@@ -432,6 +445,11 @@ impl MsEncoder {
|
||||
let st = std::ptr::NonNull::new(st)
|
||||
.filter(|_| err == audiopus_sys::OPUS_OK)
|
||||
.ok_or_else(|| anyhow::anyhow!("opus_multistream_encoder_create failed ({err})"))?;
|
||||
// SAFETY: `st` is the non-null encoder `opus_multistream_encoder_create` just returned, owned
|
||||
// exclusively here. Each `opus_multistream_encoder_ctl` call passes a valid request constant
|
||||
// with the single by-value `c_int` argument that request's variadic ABI expects
|
||||
// (`OPUS_SET_BITRATE_REQUEST` → bitrate, `OPUS_SET_VBR_REQUEST` → 0). No pointer escapes the
|
||||
// call and the encoder outlives it.
|
||||
unsafe {
|
||||
audiopus_sys::opus_multistream_encoder_ctl(
|
||||
st.as_ptr(),
|
||||
@@ -453,6 +471,13 @@ impl MsEncoder {
|
||||
samples_per_channel: usize,
|
||||
out: &mut [u8],
|
||||
) -> Result<usize> {
|
||||
// SAFETY: `self.st` is the live encoder from `new`. libopus reads `samples_per_channel *
|
||||
// channels` f32s through `frame.as_ptr()`; every caller passes a `frame` of exactly that
|
||||
// length together with the matching `samples_per_channel` (`audio_body`'s `frame_len =
|
||||
// samples_per_channel * layout.channels`; the round-trip tests size identically), so the read
|
||||
// stays in bounds. `out.as_mut_ptr()` is written for at most `out.len()` bytes, which is
|
||||
// passed as the capacity bound. Both buffers are live locals outliving this synchronous call;
|
||||
// the return value is range-checked before being used as a length.
|
||||
let n = unsafe {
|
||||
audiopus_sys::opus_multistream_encode_float(
|
||||
self.st.as_ptr(),
|
||||
@@ -470,6 +495,9 @@ impl MsEncoder {
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Drop for MsEncoder {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.st` is the encoder `opus_multistream_encoder_create` returned; this
|
||||
// `MsEncoder` owns it uniquely and `drop` runs exactly once, so the destroy frees it once
|
||||
// with no subsequent use.
|
||||
unsafe { audiopus_sys::opus_multistream_encoder_destroy(self.st.as_ptr()) }
|
||||
}
|
||||
}
|
||||
@@ -761,6 +789,10 @@ mod tests {
|
||||
let client_mapping = client_swap(&digits[3..]);
|
||||
|
||||
let mut err = 0i32;
|
||||
// SAFETY: scalar args are valid libopus inputs. `client_mapping.as_ptr()` addresses a
|
||||
// `Vec<u8>` of exactly `ch` entries (derived from the advertised surround-params), which is
|
||||
// the element count the decoder reads through it, and `&mut err` is a live local the call
|
||||
// writes. The returned pointer is `OPUS_OK`/non-null-checked immediately below before use.
|
||||
let dec = unsafe {
|
||||
audiopus_sys::opus_multistream_decoder_create(
|
||||
SAMPLE_RATE as i32,
|
||||
@@ -789,6 +821,11 @@ mod tests {
|
||||
}
|
||||
let n = enc.encode_float(&frame, samples, &mut out).unwrap();
|
||||
assert!(n > 0);
|
||||
// SAFETY: `dec` is the non-null decoder asserted above. `out.as_ptr()` is read for
|
||||
// the `n` encoded bytes just produced by `encode_float`; `decoded.as_mut_ptr()` is
|
||||
// written for up to `samples * ch` f32s and `decoded` is exactly that long; `samples`
|
||||
// is the per-channel frame size. All buffers are live locals outliving the call; the
|
||||
// return is checked to equal `samples`.
|
||||
let got = unsafe {
|
||||
audiopus_sys::opus_multistream_decode_float(
|
||||
dec,
|
||||
@@ -817,6 +854,8 @@ mod tests {
|
||||
(energies: {energy:?})"
|
||||
);
|
||||
}
|
||||
// SAFETY: `dec` is the decoder `opus_multistream_decoder_create` returned; the test owns it
|
||||
// and destroys it exactly once here, after the final decode — no later use, no double free.
|
||||
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
||||
}
|
||||
|
||||
@@ -853,6 +892,9 @@ mod tests {
|
||||
let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
|
||||
let client_mapping = client_swap(&digits[3..]);
|
||||
let mut err = 0i32;
|
||||
// SAFETY: scalar args are valid; `client_mapping.as_ptr()` addresses a 6-entry `Vec<u8>`
|
||||
// (matches the 6-channel layout the decoder reads through it), alive past the call, and
|
||||
// `&mut err` is a live local. The pointer is `OPUS_OK`-checked before use.
|
||||
let dec = unsafe {
|
||||
audiopus_sys::opus_multistream_decoder_create(
|
||||
48000,
|
||||
@@ -865,6 +907,10 @@ mod tests {
|
||||
};
|
||||
assert_eq!(err, audiopus_sys::OPUS_OK);
|
||||
let mut pcm = vec![0f32; 240 * 6];
|
||||
// SAFETY: `dec` is the non-null decoder from create. `out.as_ptr()` is read for the CBR
|
||||
// packet length passed in (`*sizes.first()`, a real encoded packet size in `out`);
|
||||
// `pcm.as_mut_ptr()` is written for up to `240 * 6` f32s and `pcm` is exactly that long;
|
||||
// `240` is the per-channel frame size. All buffers are live locals outliving the call.
|
||||
let got = unsafe {
|
||||
audiopus_sys::opus_multistream_decode_float(
|
||||
dec,
|
||||
@@ -875,6 +921,7 @@ mod tests {
|
||||
0,
|
||||
)
|
||||
};
|
||||
// SAFETY: `dec` is owned by the test; destroyed exactly once here after the final decode.
|
||||
unsafe { audiopus_sys::opus_multistream_decoder_destroy(dec) };
|
||||
assert_eq!(got, 240);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! either real portal desktop capture (`PUNKTFUNK_VIDEO_SOURCE=portal`, the portal PipeWire path) or
|
||||
//! a synthetic test pattern (default). Runs on its own native thread.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::video::{FrameType, VideoPacketizer};
|
||||
use super::VIDEO_PORT;
|
||||
use crate::capture::{self, Capturer, FastSyntheticCapturer};
|
||||
@@ -102,7 +105,7 @@ fn run(
|
||||
// request and capture it (no scaling). Self-contained — deliberately NOT pooled in
|
||||
// `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the
|
||||
// output is released when this capturer drops at stream end (RAII via its keepalive).
|
||||
if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") {
|
||||
if crate::config::config().video_source.as_deref() == Some("virtual") {
|
||||
// The launched app picks the compositor (e.g. gamescope for game entries) and the
|
||||
// nested command.
|
||||
let compositor = app
|
||||
@@ -134,9 +137,32 @@ fn run(
|
||||
// IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push
|
||||
// is wired only for punktfunk/1 (want_hdr = negotiated bit_depth >= 10). TODO: derive want_hdr
|
||||
// from a GameStream HDR flag once StreamConfig carries one.
|
||||
let mut capturer =
|
||||
capture::capture_virtual_output(vout, false).context("capture virtual output")?;
|
||||
let mut capturer = capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
// Launch the app's command now that capture is live, for the backends that DON'T nest it via
|
||||
// set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream
|
||||
// the existing desktop, so the app must be spawned into the session to land on the streamed
|
||||
// output). Linux gamescope already nested it via set_launch_command, so skip it there.
|
||||
#[cfg(windows)]
|
||||
let launch_here = true;
|
||||
#[cfg(target_os = "linux")]
|
||||
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
if launch_here {
|
||||
if let Some(cmd) = app
|
||||
.and_then(|a| a.cmd.as_deref())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
{
|
||||
if let Err(e) = crate::library::launch_gamestream_command(cmd) {
|
||||
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
|
||||
}
|
||||
}
|
||||
}
|
||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
||||
}
|
||||
|
||||
@@ -147,7 +173,7 @@ fn run(
|
||||
tracing::info!("video source: reusing capturer");
|
||||
c
|
||||
}
|
||||
None if std::env::var("PUNKTFUNK_VIDEO_SOURCE").is_ok_and(|v| v == "portal") => {
|
||||
None if crate::config::config().video_source.as_deref() == Some("portal") => {
|
||||
tracing::info!("video source: portal desktop capture");
|
||||
capture::open_portal_monitor().context("open portal capturer")?
|
||||
}
|
||||
@@ -184,6 +210,10 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
||||
let mut hdrs: Vec<libc::mmsghdr> = iovs
|
||||
.iter_mut()
|
||||
.map(|iov| {
|
||||
// SAFETY: `libc::mmsghdr` is a plain `#[repr(C)]` struct of integers and raw
|
||||
// pointers, for which an all-zero bit pattern is valid (null pointers / zero
|
||||
// lengths); the fields we rely on (`msg_iov`, `msg_iovlen`) are overwritten on the
|
||||
// next two lines before the struct is handed to the kernel.
|
||||
let mut h: libc::mmsghdr = unsafe { std::mem::zeroed() };
|
||||
h.msg_hdr.msg_iov = iov;
|
||||
h.msg_hdr.msg_iovlen = 1;
|
||||
@@ -192,6 +222,13 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
||||
.collect();
|
||||
let mut off = 0usize;
|
||||
while off < hdrs.len() {
|
||||
// SAFETY: `fd` is `sock`'s live raw fd (`sock` outlives the call). `hdrs[off..]
|
||||
// .as_mut_ptr()` is a live slice of `(hdrs.len() - off)` `mmsghdr`s — exactly the count
|
||||
// passed — into which the kernel writes each `msg_len`. Each header's `msg_iov` points
|
||||
// into `iovs` (a local that outlives this call, with `msg_iovlen == 1` matching its one
|
||||
// entry) and each `iovec.iov_base` points into the `chunk` packet buffers (the caller's
|
||||
// `pkts`, alive for the call); the kernel only reads those payloads. Flags 0; the return
|
||||
// is error-/progress-checked before advancing `off`.
|
||||
let n = unsafe {
|
||||
libc::sendmmsg(fd, hdrs[off..].as_mut_ptr(), (hdrs.len() - off) as u32, 0)
|
||||
};
|
||||
@@ -358,11 +395,15 @@ fn stream_body(
|
||||
|
||||
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
|
||||
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
|
||||
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some();
|
||||
let perf = crate::config::config().perf;
|
||||
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
|
||||
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
|
||||
// Absolute next-frame deadline — the single pacing clock for the loop.
|
||||
let mut next_frame = Instant::now();
|
||||
// RFI capability is fixed for the session (probed at encoder open). Query it once so the
|
||||
// recovery path skips the always-`false` invalidate call on encoders without NVENC RFI and
|
||||
// forces a keyframe directly instead.
|
||||
let supports_rfi = enc.caps().supports_rfi;
|
||||
|
||||
while running.load(Ordering::SeqCst) {
|
||||
let tick = Instant::now();
|
||||
@@ -376,7 +417,9 @@ fn stream_body(
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
|
||||
if !enc.invalidate_ref_frames(first, last) {
|
||||
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
|
||||
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
|
||||
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
|
||||
enc.request_keyframe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,10 @@ pub fn default_backend() -> Backend {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
if std::env::var("PUNKTFUNK_COMPOSITOR")
|
||||
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
|
||||
if crate::config::config()
|
||||
.compositor
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("gamescope"))
|
||||
{
|
||||
return Backend::GamescopeEi;
|
||||
}
|
||||
@@ -260,8 +262,10 @@ fn coalesce(events: Vec<InputEvent>) -> Vec<InputEvent> {
|
||||
/// (`org.gnome.Mutter.RemoteDesktop`), the same direct API the Mutter video backend uses.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn libei_ei_source() -> libei::EiSource {
|
||||
let gnome = std::env::var("PUNKTFUNK_COMPOSITOR")
|
||||
.is_ok_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
||||
let gnome = crate::config::config()
|
||||
.compositor
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("mutter"))
|
||||
|| std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase()
|
||||
@@ -421,30 +425,45 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: Linux UHID/uinput/libei/wlr backends under `inject/linux/`, the Windows UMDF/SendInput
|
||||
// backends under `inject/windows/`, and the transport-independent HID codecs under `inject/proto/`;
|
||||
// `#[path]` keeps every `crate::inject::*` module name flat.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/dualsense.rs"]
|
||||
pub mod dualsense;
|
||||
/// Transport-independent DualSense HID contract, shared by the Linux UHID backend ([`dualsense`])
|
||||
/// and the Windows UMDF-driver backend ([`dualsense_windows`]).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/proto/dualsense_proto.rs"]
|
||||
pub mod dualsense_proto;
|
||||
/// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/dualsense_windows.rs"]
|
||||
pub mod dualsense_windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/dualshock4.rs"]
|
||||
pub mod dualshock4;
|
||||
/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend
|
||||
/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.)
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
#[path = "inject/proto/dualshock4_proto.rs"]
|
||||
pub mod dualshock4_proto;
|
||||
/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/dualshock4_windows.rs"]
|
||||
pub mod dualshock4_windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/gamepad.rs"]
|
||||
pub mod gamepad;
|
||||
/// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/gamepad_windows.rs"]
|
||||
#[path = "inject/windows/gamepad_windows.rs"]
|
||||
pub mod gamepad;
|
||||
/// Windows: small RAII wrappers (`Shm` section+view, `SwDevice` devnode) shared by the three gamepad
|
||||
/// backends (DualSense / DualShock 4 / XUSB), so each per-pad resource closes deterministically on drop.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/gamepad_raii.rs"]
|
||||
mod gamepad_raii;
|
||||
/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod gamepad {
|
||||
@@ -459,10 +478,13 @@ pub mod gamepad {
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/libei.rs"]
|
||||
mod libei;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "inject/windows/sendinput.rs"]
|
||||
mod sendinput;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/wlr.rs"]
|
||||
mod wlr;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+43
@@ -15,6 +15,9 @@
|
||||
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
||||
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::HashMap;
|
||||
@@ -215,6 +218,11 @@ const _: () = {
|
||||
};
|
||||
|
||||
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
|
||||
// SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as
|
||||
// `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is
|
||||
// dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd`
|
||||
// being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd
|
||||
// would merely return -1/EBADF (reported below), never UB.
|
||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
@@ -222,6 +230,12 @@ fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Resul
|
||||
}
|
||||
|
||||
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
|
||||
// SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live,
|
||||
// uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes
|
||||
// (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::<UinputSetup>(); UI_ABS_SETUP → 0x1c=28; the FF
|
||||
// upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies
|
||||
// exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased
|
||||
// for the whole synchronous call.
|
||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
@@ -251,6 +265,9 @@ pub struct VirtualPad {
|
||||
impl VirtualPad {
|
||||
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
|
||||
use std::os::fd::FromRawFd;
|
||||
// SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a
|
||||
// valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1)
|
||||
// and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path.
|
||||
let raw = unsafe {
|
||||
libc::open(
|
||||
c"/dev/uinput".as_ptr(),
|
||||
@@ -264,6 +281,9 @@ impl VirtualPad {
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
// SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd
|
||||
// from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes
|
||||
// this the unique owner, which will `close` it exactly once on drop (no double-close, no leak).
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
|
||||
@@ -356,6 +376,11 @@ impl VirtualPad {
|
||||
code,
|
||||
value,
|
||||
};
|
||||
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
|
||||
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
|
||||
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
|
||||
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
|
||||
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
&ev as *const _ as *const u8,
|
||||
@@ -363,6 +388,10 @@ impl VirtualPad {
|
||||
)
|
||||
};
|
||||
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
||||
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
|
||||
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
|
||||
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
|
||||
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
|
||||
let _ = unsafe {
|
||||
libc::write(
|
||||
self.fd.as_raw_fd(),
|
||||
@@ -404,6 +433,10 @@ impl VirtualPad {
|
||||
let raw = self.fd.as_raw_fd();
|
||||
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
|
||||
loop {
|
||||
// SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a
|
||||
// live local `[u8; size_of::<InputEventRaw>()]`; `buf.as_mut_ptr()` is a valid writable pointer
|
||||
// to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer
|
||||
// outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race).
|
||||
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
||||
if n != buf.len() as isize {
|
||||
break; // EAGAIN / short read — queue drained
|
||||
@@ -415,6 +448,10 @@ impl VirtualPad {
|
||||
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
||||
match (ev.type_, ev.code) {
|
||||
(EV_UINPUT, UI_FF_UPLOAD) => {
|
||||
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
|
||||
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
|
||||
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
|
||||
// value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD.
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
@@ -442,6 +479,9 @@ impl VirtualPad {
|
||||
}
|
||||
}
|
||||
(EV_UINPUT, UI_FF_ERASE) => {
|
||||
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
|
||||
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
|
||||
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
|
||||
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
||||
er.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
||||
@@ -492,6 +532,9 @@ impl VirtualPad {
|
||||
|
||||
impl Drop for VirtualPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only
|
||||
// AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument
|
||||
// (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device.
|
||||
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
|
||||
}
|
||||
}
|
||||
+10
@@ -5,6 +5,9 @@
|
||||
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
|
||||
//! so the compositor resolves shifted keysyms correctly.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
@@ -264,10 +267,17 @@ impl InputInjector for WlrootsInjector {
|
||||
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
|
||||
fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||
let name = b"punktfunk-keymap\0";
|
||||
// SAFETY: `name` is a byte-string literal with an explicit trailing NUL, so `name.as_ptr()` is a
|
||||
// valid NUL-terminated C string; `memfd_create` only reads that name (copying it) and creates an
|
||||
// anonymous file, returning a fresh fd (or -1). `MFD_CLOEXEC` is a valid flag. The 'static literal
|
||||
// outlives the synchronous call and nothing aliases it. The result is checked `< 0` below.
|
||||
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
bail!("memfd_create failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: `fd` is the fresh memfd `memfd_create` just returned and checked `>= 0`; it is a unique
|
||||
// open fd nothing else owns, so `File` takes sole ownership and closes it exactly once on drop —
|
||||
// no alias, no double-close.
|
||||
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
f.write_all(s.as_bytes()).context("write keymap")?;
|
||||
f.write_all(&[0]).context("write keymap NUL")?;
|
||||
+36
-97
@@ -29,38 +29,35 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
||||
|
||||
/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`. `pub(super)`
|
||||
/// so the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
|
||||
pub(super) const SHM_SIZE: usize = 256;
|
||||
pub(super) const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS"
|
||||
pub(super) const OFF_INPUT: usize = 8;
|
||||
pub(super) const OFF_OUT_SEQ: usize = 72;
|
||||
pub(super) const OFF_OUTPUT: usize = 76;
|
||||
/// Shared-section layout — the single source of truth is [`pf_driver_proto::gamepad::PadShm`] (offset
|
||||
/// asserts pin every field; the `pf_dualsense` driver maps the same struct). Derive the size/offsets/magic
|
||||
/// from it so a layout change is a compile error, not a hand-synced literal (audit §6.1). `pub(super)` so
|
||||
/// the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets.
|
||||
pub(super) const SHM_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::PadShm>();
|
||||
pub(super) const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||
pub(super) const OFF_INPUT: usize = core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, input);
|
||||
pub(super) const OFF_OUT_SEQ: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, out_seq);
|
||||
pub(super) const OFF_OUTPUT: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, output);
|
||||
/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 =
|
||||
/// DualSense (the default — the section is zeroed), 1 = DualShock 4.
|
||||
pub(super) const OFF_DEVTYPE: usize = 140;
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = 1;
|
||||
pub(super) const OFF_DEVTYPE: usize =
|
||||
core::mem::offset_of!(pf_driver_proto::gamepad::PadShm, device_type);
|
||||
pub(super) const DEVTYPE_DUALSHOCK4: u8 = pf_driver_proto::gamepad::DEVTYPE_DUALSHOCK4;
|
||||
|
||||
/// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_<index>` software devnode (the driver
|
||||
/// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps.
|
||||
/// Dropping it removes the devnode (`SwDeviceClose`) and unmaps + closes the section.
|
||||
struct DsWinPad {
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band
|
||||
/// `pf_dualsense` devnode (installer/devgen).
|
||||
hsw: Option<HSWDEVICE>,
|
||||
map: HANDLE,
|
||||
view: *mut u8,
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
/// `None` falls back to an out-of-band `pf_dualsense` devnode (installer/devgen).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
||||
shm: super::gamepad_raii::Shm,
|
||||
seq: u8,
|
||||
ts: u32,
|
||||
last_out_seq: u32,
|
||||
@@ -234,62 +231,16 @@ pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<HSWDEVICE> {
|
||||
Ok(hsw)
|
||||
}
|
||||
|
||||
/// Create + map the named section `Global\pfds-shm-<index>`, zeroed, with a permissive DACL so the
|
||||
/// WUDFHost (whatever account it runs as) can open it. Returns `(section handle, mapped base)`; the
|
||||
/// caller stamps the device-type + initial input report and finally the magic. Shared by both Windows
|
||||
/// pad backends (DualSense + DualShock 4).
|
||||
pub(super) fn create_shm_section(index: u8) -> Result<(HANDLE, *mut u8)> {
|
||||
let name = HSTRING::from(format!("Global\\pfds-shm-{index}"));
|
||||
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS when
|
||||
// the process exits — acceptable for a host-lifetime object).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
|
||||
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
SHM_SIZE as u32,
|
||||
PCWSTR(name.as_ptr()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: map is a valid section handle; map the whole thing.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
|
||||
if view.Value.is_null() {
|
||||
// SAFETY: map is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(map);
|
||||
}
|
||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||
}
|
||||
let base = view.Value as *mut u8;
|
||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
||||
unsafe { std::ptr::write_bytes(base, 0, SHM_SIZE) };
|
||||
Ok((map, base))
|
||||
}
|
||||
|
||||
impl DsWinPad {
|
||||
/// Create + map the section `Global\pfds-shm-<index>`, stamp the magic, then spawn the
|
||||
/// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives
|
||||
/// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`).
|
||||
fn open(index: u8) -> Result<DsWinPad> {
|
||||
let (map, base) = create_shm_section(index)?;
|
||||
let shm = super::gamepad_raii::Shm::create(
|
||||
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
|
||||
SHM_SIZE,
|
||||
)?;
|
||||
let base = shm.base();
|
||||
// Stamp the neutral input report, then the magic LAST (the driver only accepts the section
|
||||
// once magic is set). The device-type stays 0 (DualSense — the section is already zeroed).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
||||
@@ -318,10 +269,10 @@ impl DsWinPad {
|
||||
None
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
Ok(DsWinPad {
|
||||
hsw,
|
||||
map,
|
||||
view: base,
|
||||
_sw,
|
||||
shm,
|
||||
seq: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
@@ -334,22 +285,25 @@ impl DsWinPad {
|
||||
self.ts = self.ts.wrapping_add(1);
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) };
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x02` report (rumble / LEDs / triggers) into a
|
||||
/// [`DsFeedback`] for pad `pad`. Returns empty feedback if the driver hasn't published anything new.
|
||||
fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
let mut fb = DsFeedback::default();
|
||||
// SAFETY: view points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) };
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq =
|
||||
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
};
|
||||
parse_ds_output(pad, &out, &mut fb);
|
||||
}
|
||||
@@ -357,21 +311,6 @@ impl DsWinPad {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DsWinPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
||||
unsafe {
|
||||
if let Some(h) = self.hsw {
|
||||
SwDeviceClose(h);
|
||||
}
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut c_void,
|
||||
});
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the Windows analogue of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager). Same method surface so the session input
|
||||
/// thread drives either backend identically.
|
||||
+23
-33
@@ -9,8 +9,8 @@
|
||||
|
||||
use super::dualsense_proto::DsState;
|
||||
use super::dualsense_windows::{
|
||||
create_shm_section, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE,
|
||||
OFF_INPUT, OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC,
|
||||
create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, OFF_INPUT, OFF_OUTPUT,
|
||||
OFF_OUT_SEQ, SHM_MAGIC, SHM_SIZE,
|
||||
};
|
||||
use super::dualshock4_proto::{
|
||||
parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W,
|
||||
@@ -18,18 +18,16 @@ use super::dualshock4_proto::{
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::ffi::c_void;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Memory::{UnmapViewOfFile, MEMORY_MAPPED_VIEW_ADDRESS};
|
||||
use windows::core::HSTRING;
|
||||
|
||||
/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_<index>` devnode plus the mapped
|
||||
/// shared section. Dropping it removes the devnode and unmaps + closes the section.
|
||||
struct Ds4WinPad {
|
||||
hsw: Option<HSWDEVICE>,
|
||||
map: HANDLE,
|
||||
view: *mut u8,
|
||||
/// Per-session devnode from SwDeviceCreate, when it succeeds (RAII — `SwDeviceClose` on drop).
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// The named shared section the driver maps (RAII — unmapped + closed on drop).
|
||||
shm: super::gamepad_raii::Shm,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
last_out_seq: u32,
|
||||
@@ -39,7 +37,11 @@ impl Ds4WinPad {
|
||||
/// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic,
|
||||
/// then spawn the `pf_ds4_<index>` devnode (the driver loads on it and maps the section).
|
||||
fn open(index: u8) -> Result<Ds4WinPad> {
|
||||
let (map, base) = create_shm_section(index)?;
|
||||
let shm = super::gamepad_raii::Shm::create(
|
||||
&HSTRING::from(pf_driver_proto::gamepad::pad_shm_name(index)),
|
||||
SHM_SIZE,
|
||||
)?;
|
||||
let base = shm.base();
|
||||
// device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST.
|
||||
// SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range.
|
||||
unsafe {
|
||||
@@ -65,10 +67,10 @@ impl Ds4WinPad {
|
||||
None
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
Ok(Ds4WinPad {
|
||||
hsw,
|
||||
map,
|
||||
view: base,
|
||||
_sw,
|
||||
shm,
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
last_out_seq: 0,
|
||||
@@ -81,22 +83,25 @@ impl Ds4WinPad {
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
// SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) };
|
||||
// SAFETY: base points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(r.as_ptr(), self.shm.base().add(OFF_INPUT), r.len())
|
||||
};
|
||||
}
|
||||
|
||||
/// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new.
|
||||
fn service(&mut self) -> Ds4Feedback {
|
||||
let mut fb = Ds4Feedback::default();
|
||||
// SAFETY: view points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) };
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq =
|
||||
unsafe { std::ptr::read_unaligned(self.shm.base().add(OFF_OUT_SEQ) as *const u32) };
|
||||
if seq != self.last_out_seq {
|
||||
self.last_out_seq = seq;
|
||||
let mut out = [0u8; 64];
|
||||
// SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
std::ptr::copy_nonoverlapping(self.shm.base().add(OFF_OUTPUT), out.as_mut_ptr(), 64)
|
||||
};
|
||||
parse_ds4_output(&out, &mut fb);
|
||||
}
|
||||
@@ -104,21 +109,6 @@ impl Ds4WinPad {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Ds4WinPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
||||
unsafe {
|
||||
if let Some(h) = self.hsw {
|
||||
SwDeviceClose(h);
|
||||
}
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut c_void,
|
||||
});
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the Windows analogue of
|
||||
/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the
|
||||
/// Windows DualSense manager so the session input thread drives either backend identically.
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Per-pad Windows resource RAII for the gamepad backends (DualSense / DualShock 4 / XUSB).
|
||||
//!
|
||||
//! Each virtual pad owns two OS resources: the named shared-memory section (+ its mapped view) the
|
||||
//! `pf_dualsense`/`pf_xusb` driver reads, and the `SwDeviceCreate`'d software devnode the driver loads
|
||||
//! on. Before this module, all three backends hand-rolled the same `CreateFileMappingW` +
|
||||
//! `MapViewOfFile` and an identical `Drop` doing `SwDeviceClose` + `UnmapViewOfFile` + `CloseHandle` —
|
||||
//! easy to drift or leak on an error path. [`Shm`] and [`SwDevice`] own those resources with RAII, so a
|
||||
//! backend just holds them and the cleanup (and ordering) happens by construction.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
||||
use windows::core::{w, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE};
|
||||
use windows::Win32::Foundation::INVALID_HANDLE_VALUE;
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
|
||||
/// A named, anonymous (pagefile-backed) shared section + its mapped read/write view, created with the
|
||||
/// permissive `D:(A;;GA;;;WD)` SDDL the restricted-token driver needs to open it. RAII: drop unmaps the
|
||||
/// view, then the [`OwnedHandle`] closes the section handle (in that order). Replaces the three backends'
|
||||
/// hand-duplicated `CreateFileMappingW` + `MapViewOfFile` + manual `Drop`.
|
||||
pub(super) struct Shm {
|
||||
/// Owns the section handle (closed on drop). Held only for ownership — never read after construction.
|
||||
_handle: OwnedHandle,
|
||||
view: MEMORY_MAPPED_VIEW_ADDRESS,
|
||||
}
|
||||
|
||||
impl Shm {
|
||||
/// Create + zero a `size`-byte section named `name`, mapped read/write. The section handle is owned
|
||||
/// immediately, so any failure below (or the returned `Shm`'s drop) closes it.
|
||||
pub(super) fn create(name: &HSTRING, size: usize) -> Result<Shm> {
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: the SDDL literal is valid; `psd` receives an OS-allocated descriptor (freed at process
|
||||
// exit — acceptable for a host-lifetime object).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: core::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// SAFETY: an anonymous (pagefile-backed) section of `size` bytes with the SDDL above.
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
size as u32,
|
||||
PCWSTR(name.as_ptr()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: `map` is a fresh section handle we own; take ownership immediately so that the early
|
||||
// return below (and the eventual drop) closes it. `map` (a `Copy` `HANDLE`) stays usable for the
|
||||
// `MapViewOfFile` borrow that follows — `from_raw_handle` only copies the inner pointer.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(map.0) };
|
||||
// SAFETY: `map` is a valid section handle; map the whole thing read/write.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, size) };
|
||||
if view.Value.is_null() {
|
||||
// `handle` drops here → closes the section. No view to unmap.
|
||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||
}
|
||||
// SAFETY: `view` points at `size` writable bytes (just mapped).
|
||||
unsafe { core::ptr::write_bytes(view.Value as *mut u8, 0, size) };
|
||||
Ok(Shm {
|
||||
_handle: handle,
|
||||
view,
|
||||
})
|
||||
}
|
||||
|
||||
/// The mapped section's base pointer. Stable for the `Shm`'s lifetime (moving the `Shm` does not
|
||||
/// relocate the OS mapping — the view address is fixed by `MapViewOfFile`).
|
||||
pub(super) fn base(&self) -> *mut u8 {
|
||||
self.view.Value as *mut u8
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `view` came from `MapViewOfFile`; unmap it BEFORE the `_handle` field closes the
|
||||
// section (struct fields drop only after this `Drop::drop` returns).
|
||||
unsafe {
|
||||
let _ = UnmapViewOfFile(self.view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `SwDeviceCreate`'d software devnode; drop removes it via `SwDeviceClose`. Replaces the manual
|
||||
/// `SwDeviceClose` each backend used to call in its `Drop`.
|
||||
pub(super) struct SwDevice(HSWDEVICE);
|
||||
|
||||
impl SwDevice {
|
||||
pub(super) fn new(hsw: HSWDEVICE) -> Self {
|
||||
SwDevice(hsw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SwDevice {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is the handle `SwDeviceCreate` returned; `SwDeviceClose` removes the devnode.
|
||||
unsafe { SwDeviceClose(self.0) };
|
||||
}
|
||||
}
|
||||
+47
-95
@@ -21,30 +21,25 @@ use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR};
|
||||
use windows::Win32::Devices::Enumeration::Pnp::{
|
||||
SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
|
||||
};
|
||||
use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
|
||||
use windows::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject};
|
||||
|
||||
// Shared-section layout — must match `packaging/windows/xusb-driver/src/lib.rs`.
|
||||
const SHM_SIZE: usize = 64;
|
||||
const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU"
|
||||
const OFF_PACKET: usize = 4;
|
||||
const OFF_BUTTONS: usize = 8;
|
||||
const OFF_LT: usize = 10;
|
||||
const OFF_RT: usize = 11;
|
||||
const OFF_LX: usize = 12;
|
||||
const OFF_LY: usize = 14;
|
||||
const OFF_RX: usize = 16;
|
||||
const OFF_RY: usize = 18;
|
||||
const OFF_RUMBLE_SEQ: usize = 24;
|
||||
const OFF_RUMBLE: usize = 28; // large @28, small @29
|
||||
// Shared-section layout — the single source of truth is `pf_driver_proto::gamepad::XusbShm` (offset
|
||||
// asserts pin every field; the `pf_xusb` driver maps the same struct). Derive the size/offsets/magic from
|
||||
// it so a layout change is a compile error, not a hand-synced literal (audit §6.1).
|
||||
use pf_driver_proto::gamepad::XusbShm;
|
||||
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
||||
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
||||
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
||||
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
||||
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
||||
const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
|
||||
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
|
||||
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
|
||||
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
|
||||
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
|
||||
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
|
||||
const OFF_RUMBLE: usize = core::mem::offset_of!(XusbShm, rumble_large); // large @28, small @29
|
||||
|
||||
/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports.
|
||||
#[repr(C)]
|
||||
@@ -147,9 +142,10 @@ fn create_swdevice(index: u8) -> Result<HSWDEVICE> {
|
||||
|
||||
/// A single virtual Xbox 360 pad: the `pf_xusb_<index>` devnode plus the mapped shared section.
|
||||
struct XusbWinPad {
|
||||
hsw: Option<HSWDEVICE>,
|
||||
map: HANDLE,
|
||||
view: *mut u8,
|
||||
/// Owns the `pf_xusb_<index>` devnode (dropped → `SwDeviceClose`). `None` if `SwDeviceCreate` failed.
|
||||
_sw: Option<super::gamepad_raii::SwDevice>,
|
||||
/// Owns `Global\pfxusb-shm-<index>` (the section + its mapped view; drop unmaps + closes).
|
||||
shm: super::gamepad_raii::Shm,
|
||||
packet: u32,
|
||||
last_rumble_seq: u32,
|
||||
}
|
||||
@@ -157,45 +153,13 @@ struct XusbWinPad {
|
||||
impl XusbWinPad {
|
||||
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
|
||||
fn open(index: u8) -> Result<XusbWinPad> {
|
||||
let name = HSTRING::from(format!("Global\\pfxusb-shm-{index}"));
|
||||
|
||||
// Permissive DACL so the WUDFHost (whatever account) can open the section.
|
||||
let mut psd = PSECURITY_DESCRIPTOR::default();
|
||||
// SAFETY: SDDL literal valid; psd receives an OS-freed descriptor (host-lifetime — fine).
|
||||
unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
w!("D:(A;;GA;;;WD)"),
|
||||
SDDL_REVISION_1,
|
||||
&mut psd,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
let sa = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: psd.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above.
|
||||
let map = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
Some(&sa),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
SHM_SIZE as u32,
|
||||
PCWSTR(name.as_ptr()),
|
||||
)?
|
||||
};
|
||||
// SAFETY: map is a valid section handle; map the whole thing.
|
||||
let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) };
|
||||
if view.Value.is_null() {
|
||||
// SAFETY: map is valid.
|
||||
unsafe {
|
||||
let _ = CloseHandle(map);
|
||||
}
|
||||
return Err(anyhow!("MapViewOfFile failed for {name}"));
|
||||
}
|
||||
let base = view.Value as *mut u8;
|
||||
// Permissive-DACL named section the WUDFHost (whatever account) can open; `Shm` owns the
|
||||
// section handle + its mapped view (zero-filled) and unmaps/closes on drop.
|
||||
let shm = super::gamepad_raii::Shm::create(
|
||||
&HSTRING::from(pf_driver_proto::gamepad::xusb_shm_name(index)),
|
||||
SHM_SIZE,
|
||||
)?;
|
||||
let base = shm.base();
|
||||
// Zero the section then stamp the magic LAST (the driver only accepts it once magic is set).
|
||||
// SAFETY: base points at SHM_SIZE writable bytes.
|
||||
unsafe {
|
||||
@@ -209,10 +173,10 @@ impl XusbWinPad {
|
||||
None
|
||||
}
|
||||
};
|
||||
let _sw = hsw.map(super::gamepad_raii::SwDevice::new);
|
||||
Ok(XusbWinPad {
|
||||
hsw,
|
||||
map,
|
||||
view: base,
|
||||
_sw,
|
||||
shm,
|
||||
packet: 0,
|
||||
last_rumble_seq: 0,
|
||||
})
|
||||
@@ -223,50 +187,38 @@ impl XusbWinPad {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) {
|
||||
self.packet = self.packet.wrapping_add(1);
|
||||
// SAFETY: view points at SHM_SIZE bytes; all offsets are in range.
|
||||
let base = self.shm.base();
|
||||
// SAFETY: `base` is the start of the mapped section (`SHM_SIZE` bytes, owned by `Shm`); every
|
||||
// `OFF_*` is a fixed in-range offset into it and `write_unaligned` handles the unaligned field
|
||||
// writes. Single owner (`&mut self`), so no concurrent writer races these stores.
|
||||
unsafe {
|
||||
std::ptr::write_unaligned(self.view.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||
*self.view.add(OFF_LT) = lt;
|
||||
*self.view.add(OFF_RT) = rt;
|
||||
std::ptr::write_unaligned(self.view.add(OFF_LX) as *mut i16, lx);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_LY) as *mut i16, ly);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_RX) as *mut i16, rx);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_RY) as *mut i16, ry);
|
||||
std::ptr::write_unaligned(self.view.add(OFF_PACKET) as *mut u32, self.packet);
|
||||
std::ptr::write_unaligned(base.add(OFF_BUTTONS) as *mut u16, buttons);
|
||||
*base.add(OFF_LT) = lt;
|
||||
*base.add(OFF_RT) = rt;
|
||||
std::ptr::write_unaligned(base.add(OFF_LX) as *mut i16, lx);
|
||||
std::ptr::write_unaligned(base.add(OFF_LY) as *mut i16, ly);
|
||||
std::ptr::write_unaligned(base.add(OFF_RX) as *mut i16, rx);
|
||||
std::ptr::write_unaligned(base.add(OFF_RY) as *mut i16, ry);
|
||||
std::ptr::write_unaligned(base.add(OFF_PACKET) as *mut u32, self.packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns
|
||||
/// `(large, small)` motor levels (0..=255) when a new one arrived.
|
||||
fn service(&mut self) -> Option<(u8, u8)> {
|
||||
// SAFETY: view points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_RUMBLE_SEQ) as *const u32) };
|
||||
let base = self.shm.base();
|
||||
// SAFETY: base points at SHM_SIZE bytes.
|
||||
let seq = unsafe { std::ptr::read_unaligned(base.add(OFF_RUMBLE_SEQ) as *const u32) };
|
||||
if seq == self.last_rumble_seq {
|
||||
return None;
|
||||
}
|
||||
self.last_rumble_seq = seq;
|
||||
// SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1.
|
||||
let (large, small) =
|
||||
unsafe { (*self.view.add(OFF_RUMBLE), *self.view.add(OFF_RUMBLE + 1)) };
|
||||
let (large, small) = unsafe { (*base.add(OFF_RUMBLE), *base.add(OFF_RUMBLE + 1)) };
|
||||
Some((large, small))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XusbWinPad {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW.
|
||||
unsafe {
|
||||
if let Some(h) = self.hsw {
|
||||
SwDeviceClose(h);
|
||||
}
|
||||
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut c_void,
|
||||
});
|
||||
let _ = CloseHandle(self.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager,
|
||||
/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the
|
||||
/// session input thread already drives.
|
||||
+30
-2
@@ -5,6 +5,9 @@
|
||||
//! thread stays bound to its desktop and only reattaches (`OpenInputDesktop`/`SetThreadDesktop`) when
|
||||
//! `SendInput` reports a short write (the input desktop switched) — no per-event reattach overhead.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::mem::size_of;
|
||||
@@ -35,7 +38,12 @@ pub struct SendInputInjector {
|
||||
desktop: Option<HDESK>,
|
||||
}
|
||||
|
||||
// Only ever used from the host's single injector thread (like SudoVdaDisplay).
|
||||
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
|
||||
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
|
||||
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
|
||||
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
|
||||
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
|
||||
// sound.
|
||||
unsafe impl Send for SendInputInjector {}
|
||||
|
||||
impl SendInputInjector {
|
||||
@@ -49,6 +57,12 @@ impl SendInputInjector {
|
||||
/// Bind this thread to the desktop currently receiving input. UAC / lock screen / Ctrl-Alt-Del
|
||||
/// swap the input desktop; `SendInput` silently no-ops unless our thread is on it.
|
||||
fn reattach_input_desktop(&mut self) {
|
||||
// SAFETY: `OpenInputDesktop`/`SetThreadDesktop`/`CloseDesktop` are FFI calls passed only
|
||||
// by-value args (constant desktop flags, a `bool`, an access mask). `OpenInputDesktop`
|
||||
// yields an owned `HDESK` only on `Ok`; we then either install it with `SetThreadDesktop`
|
||||
// (closing the previously-owned handle exactly once) or close the fresh handle on failure —
|
||||
// so every handle is closed exactly once and none is used after close. `SetThreadDesktop`
|
||||
// only rebinds this calling thread, which is where the injector runs.
|
||||
unsafe {
|
||||
match OpenInputDesktop(
|
||||
DESKTOP_CONTROL_FLAGS(0),
|
||||
@@ -75,12 +89,17 @@ impl SendInputInjector {
|
||||
/// switched out from under us, e.g. into UAC/lock) do we reattach to the now-current input desktop
|
||||
/// and retry once. This serves both the normal and secure desktops with no steady-state overhead.
|
||||
fn send(&mut self, inputs: &[INPUT]) -> Result<()> {
|
||||
// SAFETY: `inputs` is a live `&[INPUT]` slice that outlives this synchronous `SendInput`
|
||||
// call; `size_of::<INPUT>()` is the exact per-element stride Win32 requires as `cbSize`. The
|
||||
// call only reads the array (one event per element) and returns the count injected.
|
||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if n as usize == inputs.len() {
|
||||
return Ok(());
|
||||
}
|
||||
// Short write → the input desktop likely changed. Reattach + retry once.
|
||||
self.reattach_input_desktop();
|
||||
// SAFETY: same as the first `SendInput` — `inputs` is the identical live slice outliving the
|
||||
// call and `cbSize == size_of::<INPUT>()`; only re-issued after reattaching the input desktop.
|
||||
let n = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if n as usize != inputs.len() {
|
||||
anyhow::bail!(
|
||||
@@ -95,6 +114,9 @@ impl SendInputInjector {
|
||||
impl Drop for SendInputInjector {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.desktop.take() {
|
||||
// SAFETY: `h` is the `HDESK` this injector owned (moved out of `self.desktop`);
|
||||
// `CloseDesktop` runs once here in `Drop` on that still-valid handle, with no later use —
|
||||
// no double close.
|
||||
unsafe {
|
||||
let _ = CloseDesktop(h);
|
||||
}
|
||||
@@ -216,7 +238,11 @@ impl InputInjector for SendInputInjector {
|
||||
}
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
let down = event.kind == InputKind::KeyDown;
|
||||
let vk = (event.code & 0xff) as u16; // client sends Windows VK
|
||||
// client sends Windows VK
|
||||
let vk = (event.code & 0xff) as u16;
|
||||
// SAFETY: `MapVirtualKeyExW` is a pure value translation (VK → scancode); all three
|
||||
// args are by-value (`u32`, the `MAPVK_VK_TO_VSC_EX` map-type constant, a `None`
|
||||
// HKL). It dereferences no pointer and returns a `u32` — FFI-`unsafe` only.
|
||||
let sc_ex = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC_EX, None) };
|
||||
if sc_ex == 0 {
|
||||
return Ok(()); // unmappable -> drop
|
||||
@@ -264,6 +290,8 @@ fn key(ki: KEYBDINPUT) -> INPUT {
|
||||
}
|
||||
|
||||
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
|
||||
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
|
||||
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
|
||||
unsafe {
|
||||
(
|
||||
GetSystemMetrics(SM_XVIRTUALSCREEN),
|
||||
File diff suppressed because it is too large
Load Diff
+18
@@ -13,6 +13,9 @@
|
||||
//! attaches none, the export yields an already-signaled sync_file (poll returns immediately) — no
|
||||
//! wait, no harm, and `waited=false` tells us the driver doesn't fence (so zero-copy would still race).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::os::fd::RawFd;
|
||||
|
||||
// linux/dma-buf.h ioctls on the DMA_BUF_BASE ('b' = 0x62) magic. _IOWR = dir(3)<<30 | size<<16 | base<<8 | nr.
|
||||
@@ -40,6 +43,11 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
|
||||
flags: DMA_BUF_SYNC_READ,
|
||||
fd: -1,
|
||||
};
|
||||
// SAFETY: `dmabuf_fd` is a live dmabuf fd supplied by the caller (borrowed for this call; we
|
||||
// never close it). `DMA_BUF_IOCTL_EXPORT_SYNC_FILE` encodes `size_of::<DmaBufExportSyncFile>()`
|
||||
// — the exact byte count the kernel copies — and `&mut req` is a live, correctly-sized
|
||||
// `#[repr(C)]` struct the EXPORT_SYNC_FILE ioctl reads (`flags`) and writes (`fd`). `req`
|
||||
// outlives this synchronous call and is not aliased elsewhere.
|
||||
let r = unsafe { libc::ioctl(dmabuf_fd, DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &mut req) };
|
||||
if r < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
@@ -54,11 +62,21 @@ pub fn wait_read_ready(dmabuf_fd: RawFd, timeout_ms: i32) -> std::io::Result<boo
|
||||
revents: 0,
|
||||
};
|
||||
// Non-blocking probe: not-yet-signaled (poll==0) means the producer is still rendering.
|
||||
// SAFETY: `&mut pfd` points at a single live `libc::pollfd` and `nfds == 1` matches that one
|
||||
// element; `pfd.fd` is `sync_fd`, the sync_file fd just exported (already checked `>= 0`).
|
||||
// `poll` reads `fd`/`events` and writes `revents` for this non-blocking (timeout 0) probe, then
|
||||
// returns — `pfd` outlives the call and aliases nothing.
|
||||
let pending = unsafe { libc::poll(&mut pfd, 1, 0) } == 0;
|
||||
if pending {
|
||||
pfd.revents = 0;
|
||||
// SAFETY: same live single-element `pfd` (its `revents` reset to 0 just above), `nfds == 1`,
|
||||
// and `sync_fd` still open. This blocking `poll` (up to `timeout_ms`) waits for the render
|
||||
// fence to signal; it reads `fd`/`events`, writes `revents`, and returns before `pfd` ends.
|
||||
unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; // block until the render fence signals
|
||||
}
|
||||
// SAFETY: `sync_fd` is the sync_file fd the EXPORT_SYNC_FILE ioctl created and handed us to own;
|
||||
// this point is reached only when `sync_fd >= 0`, this `close` runs exactly once on it, and it is
|
||||
// never used afterward — no double-close or use-after-close.
|
||||
unsafe { libc::close(sync_fd) };
|
||||
Ok(pending)
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
//! verified (ioctl numbers + a live signal→wait round trip), ready to wire in the moment a producer
|
||||
//! gains working `SPA_META_SyncTimeline`.
|
||||
#![allow(dead_code)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
//!
|
||||
//! Compositors that render directly into the PipeWire buffer pool (Mutter's virtual
|
||||
//! monitors) hand buffers over at GPU-submit time; on drivers without implicit dmabuf
|
||||
@@ -81,6 +83,8 @@ pub struct DrmSync {
|
||||
impl DrmSync {
|
||||
pub fn open() -> Result<DrmSync> {
|
||||
let path = c"/dev/dri/renderD128";
|
||||
// SAFETY: `path` is a 'static NUL-terminated C string literal; `open` only reads it as a
|
||||
// filesystem path and returns an fd (or -1). No Rust memory is aliased or handed to the kernel.
|
||||
let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
bail!("open /dev/dri/renderD128 for syncobj ops: {}", errno());
|
||||
@@ -94,6 +98,9 @@ impl DrmSync {
|
||||
fd: syncobj_fd,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `self.fd` is the live render-node fd from `open`; the request number encodes
|
||||
// `size_of::<DrmSyncobjHandle>()` (the bytes the kernel copies), and `&mut req` is a live,
|
||||
// correctly-sized `#[repr(C)]` struct the FD_TO_HANDLE ioctl reads (`fd`) and writes (`handle`).
|
||||
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_FD_TO_HANDLE, &mut req) };
|
||||
if r < 0 {
|
||||
bail!("SYNCOBJ_FD_TO_HANDLE: {}", errno());
|
||||
@@ -106,6 +113,8 @@ impl DrmSync {
|
||||
handle,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `self.fd` is the live render-node fd; `DRM_IOCTL_SYNCOBJ_DESTROY` encodes
|
||||
// `size_of::<DrmSyncobjDestroy>()`, and `&mut req` is a live correctly-sized struct the kernel reads.
|
||||
unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_DESTROY, &mut req) };
|
||||
}
|
||||
|
||||
@@ -117,6 +126,8 @@ impl DrmSync {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
// SAFETY: `CLOCK_MONOTONIC` is a valid clock id and `&mut now` is a live `libc::timespec` the
|
||||
// kernel fills in; the call returns before `now` is read, so there is no aliasing/lifetime issue.
|
||||
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) };
|
||||
let deadline = now.tv_sec * 1_000_000_000 + now.tv_nsec + timeout_ms as i64 * 1_000_000;
|
||||
let handles = [handle];
|
||||
@@ -129,6 +140,11 @@ impl DrmSync {
|
||||
flags: DRM_SYNCOBJ_WAIT_FLAGS_WAIT_FOR_SUBMIT,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
|
||||
// `size_of::<DrmSyncobjTimelineWait>()`; `&mut req` is a live correctly-sized struct. Its
|
||||
// `handles`/`points` u64 fields hold the addresses of the local `handles`/`points` arrays, which
|
||||
// outlive this synchronous call, and `count_handles == 1` matches their length — so every kernel
|
||||
// read through those addresses stays in bounds.
|
||||
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_WAIT, &mut req) };
|
||||
let saved = errno();
|
||||
self.destroy(handle);
|
||||
@@ -151,6 +167,10 @@ impl DrmSync {
|
||||
count_handles: 1,
|
||||
flags: 0,
|
||||
};
|
||||
// SAFETY: `self.fd` is the live render-node fd; the request number encodes
|
||||
// `size_of::<DrmSyncobjTimelineArray>()`; `&mut req` is a live correctly-sized struct whose
|
||||
// `handles`/`points` u64 fields address the local `handles`/`points` arrays (alive for this
|
||||
// synchronous call, `count_handles == 1` matching their length).
|
||||
let r = unsafe { libc::ioctl(self.fd, DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL, &mut req) };
|
||||
let saved = errno();
|
||||
self.destroy(handle);
|
||||
@@ -163,6 +183,8 @@ impl DrmSync {
|
||||
|
||||
impl Drop for DrmSync {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.fd` is the fd `open` returned; this `DrmSync` owns it exclusively and `close`
|
||||
// runs exactly once (here, in `Drop`), so there is no double-close or use-after-close.
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
@@ -203,14 +225,19 @@ mod tests {
|
||||
const CREATE: u64 = iowr(0xBF, std::mem::size_of::<Create>());
|
||||
const HANDLE_TO_FD: u64 = iowr(0xC1, std::mem::size_of::<DrmSyncobjHandle>());
|
||||
let mut c = Create::default();
|
||||
// SAFETY: `sync.fd` is the live render-node fd; `CREATE` encodes `size_of::<Create>()`, and
|
||||
// `&mut c` is a live correctly-sized struct the kernel fills (`handle`).
|
||||
assert!(unsafe { libc::ioctl(sync.fd, CREATE, &mut c) } >= 0);
|
||||
let mut h = DrmSyncobjHandle {
|
||||
handle: c.handle,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `sync.fd` is live; `HANDLE_TO_FD` encodes `size_of::<DrmSyncobjHandle>()`; `&mut h`
|
||||
// is a live correctly-sized struct (the kernel reads `handle`, writes `fd`).
|
||||
assert!(unsafe { libc::ioctl(sync.fd, HANDLE_TO_FD, &mut h) } >= 0);
|
||||
sync.signal_point(h.fd, 1).expect("signal");
|
||||
sync.wait_point(h.fd, 1, 100).expect("wait after signal");
|
||||
// SAFETY: `h.fd` is the fd HANDLE_TO_FD just exported; we own it and close it exactly once here.
|
||||
unsafe { libc::close(h.fd) };
|
||||
sync.destroy(c.handle);
|
||||
}
|
||||
+138
-2
@@ -11,6 +11,8 @@
|
||||
//! thread) and ffmpeg's `hevc_nvenc` (encode thread); each thread makes it current before use.
|
||||
|
||||
#![allow(non_camel_case_types, non_snake_case)]
|
||||
// Every `unsafe` block/impl below carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use std::os::raw::{c_int, c_uint, c_void};
|
||||
@@ -128,8 +130,14 @@ struct CudaApi {
|
||||
) -> CUresult,
|
||||
cuDestroyExternalMemory: unsafe extern "C" fn(CUexternalMemory) -> CUresult,
|
||||
}
|
||||
// The resolved fn pointers are plain addresses into a process-lifetime mapping; safe to share.
|
||||
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
|
||||
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
|
||||
// value with no interior mutability and no thread affinity. Moving the table to another thread
|
||||
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
|
||||
unsafe impl Send for CudaApi {}
|
||||
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
|
||||
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
|
||||
// points they address are themselves thread-safe.
|
||||
unsafe impl Sync for CudaApi {}
|
||||
|
||||
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
|
||||
@@ -143,6 +151,14 @@ static CUDA_API: OnceLock<Option<CudaApi>> = OnceLock::new();
|
||||
/// (the expected case on AMD/Intel hosts) — logged at debug, not an error.
|
||||
fn cuda_api() -> Option<&'static CudaApi> {
|
||||
CUDA_API
|
||||
// SAFETY: `Library::new` runs `libcuda.so.1`'s initializers — it is the trusted NVIDIA
|
||||
// driver library, so loading has no unexpected effects; `?`/`None` handle its absence.
|
||||
// Each `lib.get::<T>(name)` asserts the symbol's real ABI equals `T`: every NUL-terminated
|
||||
// name is a documented CUDA Driver API entry point and `T` is the exact
|
||||
// `unsafe extern "C" fn(..)` signature from cuda.h/cudaGL.h (`_v2` for ctx/mem ops). Each
|
||||
// `Symbol` only borrows `lib` until the end of the struct-literal statement; we deref-copy
|
||||
// the raw fn-pointer out first, then `forget(lib)` leaks the mapping so those addresses
|
||||
// stay valid for the whole process. Runs once under the `OnceLock` init — no aliasing.
|
||||
.get_or_init(|| unsafe {
|
||||
let lib = libloading::Library::new("libcuda.so.1")
|
||||
.or_else(|_| libloading::Library::new("libcuda.so"))
|
||||
@@ -361,6 +377,12 @@ pub fn read_plane_to_host(
|
||||
Height: height,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `copy_blocking` is unsafe because it issues a CUDA copy; its contract is a valid
|
||||
// descriptor with the shared context current (the caller's responsibility — self-test path).
|
||||
// `©` is a live local `#[repr(C)] CUDA_MEMCPY2D` that outlives the synchronous call:
|
||||
// `srcDevice`/`srcPitch` are the caller's live pitched device plane, `dstHost` addresses the
|
||||
// freshly-allocated `host` `Vec` of exactly `width_bytes*height` bytes, and `WidthInBytes`×
|
||||
// `Height` fit both. The copy is synchronous, so `host` is fully written before we return it.
|
||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->host)")? };
|
||||
Ok(host)
|
||||
}
|
||||
@@ -369,7 +391,13 @@ pub fn read_plane_to_host(
|
||||
/// in a `OnceLock`; the raw `CUcontext` is thread-safe to make current from any thread.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Context(pub CUcontext);
|
||||
// SAFETY: `CUcontext` is an opaque CUDA driver handle, not a dereferenceable Rust pointer. It is
|
||||
// created once and never destroyed (process lifetime), and the only thing done with it is
|
||||
// `cuCtxSetCurrent`, which the Driver API explicitly allows from any thread — so transferring the
|
||||
// handle to another thread cannot dangle or race (the driver owns the synchronization).
|
||||
unsafe impl Send for Context {}
|
||||
// SAFETY: as above — the wrapped handle is an immutable opaque address and the driver does all the
|
||||
// synchronization, so sharing `&Context` across threads is sound.
|
||||
unsafe impl Sync for Context {}
|
||||
|
||||
static CONTEXT: OnceLock<Context> = OnceLock::new();
|
||||
@@ -382,6 +410,12 @@ pub fn context() -> Result<CUcontext> {
|
||||
if cuda_api().is_none() {
|
||||
bail!("libcuda.so.1 not available — no NVIDIA driver (CUDA zero-copy disabled)");
|
||||
}
|
||||
// SAFETY: we returned above unless `cuda_api()` is `Some`, so every wrapper here forwards into
|
||||
// the live, leaked `libcuda` table rather than the not-loaded stub. `cuInit(0)` passes the
|
||||
// API-required flags value 0. `&mut dev`/`&mut ctx` are live, zero/null-initialized stack
|
||||
// out-params the driver writes the device handle / new context into; each outlives its
|
||||
// synchronous call and they are distinct locals (no aliasing). `cuCtxCreate_v2` yields a valid
|
||||
// `CUcontext` on success (`ck` bails otherwise), which becomes the block's value.
|
||||
let ctx = unsafe {
|
||||
ck(cuInit(0), "cuInit")?;
|
||||
let mut dev: CUdevice = 0;
|
||||
@@ -401,6 +435,10 @@ pub fn context() -> Result<CUcontext> {
|
||||
/// Make the shared context current on the calling thread (required before any CUDA op here).
|
||||
pub fn make_current() -> Result<()> {
|
||||
let ctx = context()?;
|
||||
// SAFETY: `ctx` came from `context()?`, so it is the live shared `CUcontext` and the driver
|
||||
// table is present. `cuCtxSetCurrent` binds that opaque handle to the calling thread; it takes
|
||||
// no Rust-memory pointer and is thread-safe (affects only this thread's current context), so
|
||||
// there is no aliasing or lifetime hazard.
|
||||
unsafe { ck(cuCtxSetCurrent(ctx), "cuCtxSetCurrent") }
|
||||
}
|
||||
|
||||
@@ -423,6 +461,12 @@ fn copy_stream() -> CUstream {
|
||||
if let Some(s) = cell.get() {
|
||||
return s;
|
||||
}
|
||||
// SAFETY: `copy_stream` runs with the shared context current (its doc contract), so the
|
||||
// wrappers forward into the live `libcuda` table. `&mut least`/`&mut greatest` are live
|
||||
// stack `i32`s the driver fills with the priority range; `&mut s` is a live null-init
|
||||
// `CUstream` the driver writes the new stream into. All out-params outlive their
|
||||
// synchronous calls and are distinct locals. On any non-zero result we fall back to a null
|
||||
// (NULL-stream) value and never read an uninitialized handle.
|
||||
let stream = unsafe {
|
||||
let (mut least, mut greatest) = (0i32, 0i32);
|
||||
if cuCtxGetStreamPriorityRange(&mut least, &mut greatest) != 0 {
|
||||
@@ -459,6 +503,11 @@ unsafe fn copy_blocking(copy: &CUDA_MEMCPY2D, what: &str) -> Result<()> {
|
||||
fn alloc_pitched(width: u32, height: u32) -> Result<(CUdeviceptr, usize)> {
|
||||
let mut ptr: CUdeviceptr = 0;
|
||||
let mut pitch: usize = 0;
|
||||
// SAFETY: `cuMemAllocPitch_v2` allocates a pitched device buffer (the wrapper forwards to the
|
||||
// live table on any path that reached allocation). `&mut ptr` (`CUdeviceptr`) and `&mut pitch`
|
||||
// (`usize`) are live, distinct stack out-params the driver writes the allocation pointer and
|
||||
// its pitch into; both outlive the synchronous call. Width/height/element-size are by-value
|
||||
// ints. No aliasing — two separate locals.
|
||||
unsafe {
|
||||
ck(
|
||||
cuMemAllocPitch_v2(
|
||||
@@ -486,6 +535,10 @@ fn alloc_pitched_nv12(
|
||||
let mut y_pitch: usize = 0;
|
||||
let mut uv_ptr: CUdeviceptr = 0;
|
||||
let mut uv_pitch: usize = 0;
|
||||
// SAFETY: two independent `cuMemAllocPitch_v2` calls (wrapper → live table). `&mut y_ptr`/
|
||||
// `&mut y_pitch` and `&mut uv_ptr`/`&mut uv_pitch` are live, distinct stack out-params the
|
||||
// driver writes each plane's pointer and pitch into; all outlive their synchronous calls. The
|
||||
// dimension/element-size args are by-value ints. No aliasing — four separate locals.
|
||||
unsafe {
|
||||
ck(
|
||||
cuMemAllocPitch_v2(
|
||||
@@ -524,6 +577,13 @@ struct PoolInner {
|
||||
|
||||
impl Drop for PoolInner {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the pool only exists because allocation succeeded, so the driver table is live.
|
||||
// `PoolInner` drops only once every `DeviceBuffer` that referenced it (each holds an `Arc`
|
||||
// clone) has been recycled, so `free`/`free_uv` hold every outstanding allocation exactly
|
||||
// once and nothing else still uses them — no double-free or use-after-free. We make the
|
||||
// shared context current first (drop may run off the allocating thread) so `cuMemFree_v2`
|
||||
// targets the right context. Each `p` is a `CUdeviceptr` previously returned by
|
||||
// `cuMemAllocPitch_v2`; results are ignored (best-effort teardown).
|
||||
unsafe {
|
||||
if let Some(c) = CONTEXT.get() {
|
||||
let _ = cuCtxSetCurrent(c.0);
|
||||
@@ -697,6 +757,12 @@ impl Drop for DeviceBuffer {
|
||||
}
|
||||
} else {
|
||||
// The buffer may be freed on the encode thread; cuMemFree needs a current context.
|
||||
// SAFETY: this is the un-pooled branch (`pool` is `None`), so this `DeviceBuffer`
|
||||
// exclusively owns `self.ptr` (and `self.uv`'s `uv_ptr`), each returned by
|
||||
// `cuMemAllocPitch_v2` and freed exactly once here — `drop` runs once and the
|
||||
// `self.ptr == 0` guard above skips the sentinel/empty case, so no double-free. We set
|
||||
// the shared context current first because drop may run on a thread where it isn't, and
|
||||
// `cuMemFree_v2` needs it. Wrapper → live table; results ignored (teardown).
|
||||
unsafe {
|
||||
if let Some(c) = CONTEXT.get() {
|
||||
let _ = cuCtxSetCurrent(c.0);
|
||||
@@ -745,6 +811,16 @@ impl RegisteredTexture {
|
||||
/// unmap. The copy is synchronized (on our priority stream) before unmap so `dst` is ready
|
||||
/// before the source dmabuf is recycled. Always unmaps, even if the copy errors.
|
||||
pub fn copy_mapped_to(&mut self, dst: &DeviceBuffer) -> Result<()> {
|
||||
// SAFETY: `self.resource` is the valid `CUgraphicsResource` from a successful `register_gl`
|
||||
// (its only constructor), so the wrappers forward to the live table; the caller holds the
|
||||
// GL+CUDA contexts current (the registration's contract). `cuGraphicsMapResources` maps
|
||||
// `count == 1` resource via `&mut self.resource` (a live field) on the default stream;
|
||||
// `cuGraphicsSubResourceGetMappedArray` writes the mapped `CUarray` into the live local
|
||||
// `array` (index 0, mip 0). On failure we unmap and bail (balanced). `©` is a live
|
||||
// local `CUDA_MEMCPY2D` outliving the synchronous `copy_blocking`: `srcArray` is valid
|
||||
// while mapped, `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height` fit
|
||||
// both. `copy_blocking` syncs before we unmap, so the array stays valid through the copy;
|
||||
// we always unmap afterward (even on error), keeping the map/unmap pair balanced.
|
||||
unsafe {
|
||||
ck(
|
||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||
@@ -783,6 +859,14 @@ impl RegisteredTexture {
|
||||
width_bytes: usize,
|
||||
height: usize,
|
||||
) -> Result<()> {
|
||||
// SAFETY: identical contract to `copy_mapped_to` — `self.resource` is the valid
|
||||
// `CUgraphicsResource` from `register_gl` (wrappers → live table; caller holds GL+CUDA
|
||||
// contexts current). Map `count == 1` resource via the live `&mut self.resource`; the
|
||||
// mapped `CUarray` is written into the live local `array` (index 0, mip 0); on failure we
|
||||
// unmap and bail (balanced). `©` is a live local outliving the synchronous
|
||||
// `copy_blocking`: `srcArray` valid while mapped, `dstDevice`/`dstPitch` are the caller's
|
||||
// live plane, `width_bytes`×`height` fit it. We always unmap afterward, even on copy error,
|
||||
// so the map/unmap pair stays balanced and the array outlives the copy.
|
||||
unsafe {
|
||||
ck(
|
||||
cuGraphicsMapResources(1, &mut self.resource, std::ptr::null_mut()),
|
||||
@@ -847,6 +931,10 @@ pub fn copy_device_to_device(
|
||||
Height: src.height as usize,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
|
||||
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||
// the synchronous call: `srcDevice`/`srcPitch` are `src`'s live allocation, `dstDevice`/
|
||||
// `dstPitch` the caller's live region, `width*4`×`height` within both. Wrapper → live table.
|
||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(dev->dev)") }
|
||||
}
|
||||
|
||||
@@ -888,6 +976,12 @@ pub fn copy_nv12_to_device(
|
||||
Height: h / 2,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: two unsafe `copy_blocking` device→device copies; the caller must have the shared
|
||||
// context current (documented). `&y`/`&uv` are live local `CUDA_MEMCPY2D`s outliving each
|
||||
// synchronous call. All four device pointers are valid: `src.ptr`/`src_uv_ptr` come from a live
|
||||
// NV12 `DeviceBuffer` (its `.uv` presence was checked via `ok_or_else`), `y_dst`/`uv_dst` are
|
||||
// the caller's live NVENC surface planes; the luma copy is `w`×`h`, the chroma copy
|
||||
// `(w/2)*2`×`h/2`, each within its planes. Wrappers → live table.
|
||||
unsafe {
|
||||
copy_blocking(&y, "cuMemcpy2DAsync_v2(nv12 Y dev->dev)")?;
|
||||
copy_blocking(&uv, "cuMemcpy2DAsync_v2(nv12 UV dev->dev)")
|
||||
@@ -897,6 +991,12 @@ pub fn copy_nv12_to_device(
|
||||
impl Drop for RegisteredTexture {
|
||||
fn drop(&mut self) {
|
||||
if !self.resource.is_null() {
|
||||
// SAFETY: `self.resource` is non-null (just checked) and is the valid
|
||||
// `CUgraphicsResource` from `register_gl`, owned exclusively by this `RegisteredTexture`
|
||||
// and unregistered exactly once here (drop runs once) — no use-after-free or
|
||||
// double-unregister. `cuGraphicsUnregisterResource` releases the GL↔CUDA registration;
|
||||
// wrapper → live table (the resource exists ⇒ the driver was present). Result ignored
|
||||
// (best-effort teardown).
|
||||
unsafe {
|
||||
let _ = cuGraphicsUnregisterResource(self.resource);
|
||||
}
|
||||
@@ -913,7 +1013,11 @@ pub struct ExternalDmabuf {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
// Raw driver handles; used from the single capture thread but moved with the importer.
|
||||
// SAFETY: the fields are opaque CUDA driver handles — an external-memory handle and a device
|
||||
// pointer — not dereferenceable Rust memory, and the value is uniquely owned (no `Clone`). It is
|
||||
// used from a single capture thread but constructed on / moved between threads with the importer;
|
||||
// transferring these handles is sound because uniqueness rules out aliasing and they are destroyed
|
||||
// exactly once in `Drop`. Only `Send` (not `Sync`) is asserted, matching the single-thread use.
|
||||
unsafe impl Send for ExternalDmabuf {}
|
||||
|
||||
impl ExternalDmabuf {
|
||||
@@ -921,6 +1025,9 @@ impl ExternalDmabuf {
|
||||
/// from then on) and map its full `size` bytes to a device pointer. The shared context
|
||||
/// must be current.
|
||||
pub fn import(fd: i32, size: u64) -> Result<ExternalDmabuf> {
|
||||
// SAFETY: `libc::dup` only reads the integer `fd` and returns a new descriptor (or -1); it
|
||||
// touches no Rust memory and `fd` is the caller's still-owned dmabuf fd (not consumed
|
||||
// here). No aliasing or lifetime concern — a pure syscall on an integer.
|
||||
let dup = unsafe { libc::dup(fd) };
|
||||
if dup < 0 {
|
||||
bail!("dup(dmabuf fd) failed");
|
||||
@@ -938,8 +1045,17 @@ impl ExternalDmabuf {
|
||||
};
|
||||
desc.handle[0] = dup as u32 as u64; // union member `int fd` (little-endian low bytes)
|
||||
let mut ext: CUexternalMemory = std::ptr::null_mut();
|
||||
// SAFETY: `cuImportExternalMemory` imports the memory described by `&desc`, a live local
|
||||
// `#[repr(C)] CUDA_EXTERNAL_MEMORY_HANDLE_DESC` (cuda.h 64-bit layout) that outlives this
|
||||
// synchronous call: `type_` is OPAQUE_FD, `handle[0]` holds the dup'd fd in the union's
|
||||
// `int fd` low bytes, `size` is set. `&mut ext` is a live null-init out-param the driver
|
||||
// writes the imported handle into. The driver takes ownership of the fd only on success.
|
||||
// Distinct locals → no aliasing. Wrapper → live table (caller holds the context current).
|
||||
let r = unsafe { cuImportExternalMemory(&mut ext, &desc) };
|
||||
if r != 0 {
|
||||
// SAFETY: import failed (`r != 0`), so the driver did NOT take ownership of `dup`; we
|
||||
// still own it and close it exactly once here on the error path (the success path never
|
||||
// closes it — the driver does). `libc::close` acts on the integer fd alone.
|
||||
unsafe { libc::close(dup) }; // import failed → the driver did not take the fd
|
||||
bail!("cuImportExternalMemory failed ({r}) — LINEAR dmabuf import unsupported?");
|
||||
}
|
||||
@@ -949,8 +1065,17 @@ impl ExternalDmabuf {
|
||||
..Default::default()
|
||||
};
|
||||
let mut ptr: CUdeviceptr = 0;
|
||||
// SAFETY: maps a device pointer from `ext` (the valid `CUexternalMemory` just imported) per
|
||||
// `&buf`, a live local `CUDA_EXTERNAL_MEMORY_BUFFER_DESC` (offset 0, full `size`) that
|
||||
// outlives this synchronous call. `&mut ptr` is a live zero-init out-param the driver writes
|
||||
// the mapped device address into; distinct locals → no aliasing. Wrapper → live table
|
||||
// (context current).
|
||||
let r = unsafe { cuExternalMemoryGetMappedBuffer(&mut ptr, ext, &buf) };
|
||||
if r != 0 {
|
||||
// SAFETY: mapping failed; `ext` is the valid `CUexternalMemory` we imported and
|
||||
// exclusively own. We destroy it exactly once here on the error path (the success path
|
||||
// instead moves it into the returned `ExternalDmabuf`, whose `Drop` destroys it),
|
||||
// releasing the fd the driver took — no double-destroy or use-after-free.
|
||||
unsafe {
|
||||
let _ = cuDestroyExternalMemory(ext);
|
||||
}
|
||||
@@ -962,6 +1087,12 @@ impl ExternalDmabuf {
|
||||
|
||||
impl Drop for ExternalDmabuf {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: this `ExternalDmabuf` only exists after a successful import, so the driver table
|
||||
// is live. It exclusively owns `self.ptr` (the mapped buffer) and `self.ext` (the external
|
||||
// memory), each torn down exactly once here (drop runs once; guarded by `!= 0` / `!null`) —
|
||||
// no double-free or use-after-free. We make the shared context current first because drop
|
||||
// may run off the import thread, and we free the mapped buffer before destroying its
|
||||
// backing external memory. Results ignored (best-effort teardown).
|
||||
unsafe {
|
||||
if let Some(c) = CONTEXT.get() {
|
||||
let _ = cuCtxSetCurrent(c.0);
|
||||
@@ -996,5 +1127,10 @@ pub fn copy_pitched_to_buffer(
|
||||
};
|
||||
// copy_blocking syncs our priority stream before returning, so the copy is complete before the
|
||||
// dmabuf is requeued to the producer.
|
||||
// SAFETY: `copy_blocking` is unsafe (issues a CUDA copy); the caller must have the shared
|
||||
// context current (documented). `©` is a live local device→device `CUDA_MEMCPY2D` outliving
|
||||
// the synchronous call: `srcDevice`/`srcPitch` are the caller's live mapped span (e.g. an
|
||||
// `ExternalDmabuf`), `dstDevice`/`dstPitch` are `dst`'s live allocation, `width*4`×`height`
|
||||
// within both. Wrapper → live table.
|
||||
unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(ext->dev)") }
|
||||
}
|
||||
+110
-3
@@ -12,6 +12,8 @@
|
||||
//! owned [`DeviceBuffer`] so the dmabuf can be returned to the compositor immediately.
|
||||
|
||||
#![allow(non_upper_case_globals)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, DeviceBuffer};
|
||||
use anyhow::{bail, ensure, Context as _, Result};
|
||||
@@ -415,6 +417,14 @@ impl Nv12Blit {
|
||||
|
||||
impl Drop for Nv12Blit {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: these GL names (textures/FBOs/VAO/programs) were all created by THIS `Nv12Blit`
|
||||
// in `Nv12Blit::new` on the current GL context, which is still current because the owning
|
||||
// `EglImporter` is dropped on its single capture thread (fields drop before
|
||||
// `EglImporter::drop`, which never releases the context). `glDelete*` takes a count + a
|
||||
// pointer to that many names: `&self.y_tex`/`&self.vao` are `&u32` to one live field (n=1);
|
||||
// `[self.y_fbo, self.uv_fbo].as_ptr()` points at a 2-element temporary that lives for the
|
||||
// whole `glDeleteFramebuffers` call (n=2 matches). The symbols dispatch through libGL
|
||||
// (libglvnd) to the driver for the current context. Each name is deleted exactly once.
|
||||
unsafe {
|
||||
glDeleteTextures(1, &self.y_tex);
|
||||
glDeleteTextures(1, &self.uv_tex);
|
||||
@@ -459,7 +469,14 @@ pub struct EglImporter {
|
||||
render_fd: c_int,
|
||||
}
|
||||
|
||||
// The EGL handles are confined to the capture thread; the struct is moved there once.
|
||||
// SAFETY: `EglImporter` owns thread-affine handles — an EGLDisplay/contexts made current on one
|
||||
// thread, a loaded GL proc pointer, a `gbm_device*`, a raw fd, and CUDA-registered GL textures —
|
||||
// none safe to touch concurrently. It is constructed inside `pipewire_thread` on the dedicated
|
||||
// `punktfunk-pipewire` thread, and every method (`import*`, `supported_modifiers`, `Drop`) runs on
|
||||
// that same thread; it is never accessed through a shared `&` from another thread. `Send` asserts
|
||||
// only that transferring *ownership* is sound (needed so the importer can live in the PipeWire
|
||||
// stream's user-data, whose API imposes a `Send` bound) — the live handles are never used
|
||||
// off-thread. `Sync` is deliberately NOT implied.
|
||||
unsafe impl Send for EglImporter {}
|
||||
|
||||
impl EglImporter {
|
||||
@@ -470,16 +487,38 @@ impl EglImporter {
|
||||
// to the same DRM device CUDA-GL interop associates with, which the EGL device platform
|
||||
// did not (cuGraphicsGLRegisterImage rejected device-platform GL textures).
|
||||
let path = std::ffi::CString::new("/dev/dri/renderD128").unwrap();
|
||||
// SAFETY: `path` is a live local `CString` (built from a string with no interior NUL, so it
|
||||
// is NUL-terminated); `path.as_ptr()` is a valid pointer to that buffer which outlives this
|
||||
// synchronous `open`. `open` only reads the path and returns a new fd (or -1); it neither
|
||||
// retains the pointer nor writes through it, so there is no aliasing or lifetime hazard.
|
||||
let render_fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
|
||||
ensure!(render_fd >= 0, "open /dev/dri/renderD128 for GBM");
|
||||
// SAFETY: `render_fd` is the live DRM render-node fd just returned by `open` and checked
|
||||
// `>= 0`. `gbm_create_device` (libgbm, linked above) builds a `gbm_device` over that fd and
|
||||
// returns a `*mut gbm_device` (or null); it borrows but does not take ownership of the fd,
|
||||
// which `EglImporter` keeps open and closes only in `Drop` after `gbm_device_destroy`. No
|
||||
// Rust-owned memory is passed, so there is nothing to alias.
|
||||
let gbm = unsafe { gbm_create_device(render_fd) };
|
||||
if gbm.is_null() {
|
||||
// SAFETY: reached only when `gbm_create_device` failed (null) — the fd was not consumed
|
||||
// and no `EglImporter` exists yet to close it again, so this `close` runs exactly once on
|
||||
// the live `render_fd`, releasing it before the error return. No double-close.
|
||||
unsafe { libc::close(render_fd) };
|
||||
anyhow::bail!("gbm_create_device failed");
|
||||
}
|
||||
|
||||
// SAFETY: `Egl::load_required` dlopens the system libEGL and binds its entry points,
|
||||
// trusting that libEGL (libglvnd) is a genuine EGL 1.5 implementation whose core symbols
|
||||
// match the ABI the `khronos_egl` `EGL1_5` bindings declare. No Rust memory is passed; the
|
||||
// returned instance is afterwards used only through the safe `khronos_egl` wrappers.
|
||||
let egl: Egl =
|
||||
unsafe { Egl::load_required() }.context("load libEGL (EGL 1.5 dynamic instance)")?;
|
||||
// SAFETY: `gbm` is the non-null `gbm_device*` created just above (checked), and
|
||||
// `EGL_PLATFORM_GBM_KHR` is exactly the platform enum that pairs with a GBM device as the
|
||||
// native-display handle, so the `gbm as NativeDisplayType` cast hands EGL a valid native
|
||||
// display for the requested platform. `&[egl::ATTRIB_NONE]` is a properly terminated, empty
|
||||
// attribute array borrowed for this synchronous call; EGL only reads it and returns an
|
||||
// `EGLDisplay`, retaining no pointer into Rust memory.
|
||||
let display = unsafe {
|
||||
egl.get_platform_display(
|
||||
EGL_PLATFORM_GBM_KHR,
|
||||
@@ -533,6 +572,13 @@ impl EglImporter {
|
||||
.context("eglCreateContext(OpenGL)")?;
|
||||
egl.make_current(display, None, None, Some(gl_ctx))
|
||||
.context("eglMakeCurrent surfaceless (needs EGL_KHR_surfaceless_context)")?;
|
||||
// SAFETY: the GL context was made current on this thread just above, which `eglGetProcAddress`
|
||||
// requires to return a usable pointer. The non-null (`?`-checked) pointer it returns for
|
||||
// "glEGLImageTargetTexture2DOES" is the driver's implementation of that GL-OES entry point,
|
||||
// whose real ABI is `void(GLenum, GLeglImageOES)` = `(u32, *mut c_void)` `extern "system"`.
|
||||
// `EglImageTargetFn` is declared with exactly that signature, so the transmute only retypes a
|
||||
// same-size, same-ABI thin function pointer (no value/representation change). The function is
|
||||
// present because `EGL_EXT_image_dma_buf_import` was asserted on this display above.
|
||||
let egl_image_target: EglImageTargetFn = unsafe {
|
||||
std::mem::transmute(
|
||||
egl.get_proc_address("glEGLImageTargetTexture2DOES")
|
||||
@@ -543,6 +589,10 @@ impl EglImporter {
|
||||
// Create the shared CUDA context up front so import() is pure hot path.
|
||||
cuda::context().context("create CUDA context")?;
|
||||
|
||||
// SAFETY: `egl::NO_CONTEXT` is EGL's defined sentinel (a null handle) for "no context";
|
||||
// `Context::from_ptr` only stores the handle (it never dereferences it), so wrapping the
|
||||
// null sentinel is sound and yields exactly the `EGL_NO_CONTEXT` value that
|
||||
// `eglCreateImage(EGL_LINUX_DMA_BUF_EXT)` requires as its context argument later.
|
||||
let no_ctx = unsafe { egl::Context::from_ptr(egl::NO_CONTEXT) };
|
||||
tracing::info!(
|
||||
"zero-copy EGL importer ready (GBM platform + GL texture interop, dma_buf_import + modifiers)"
|
||||
@@ -602,8 +652,21 @@ impl EglImporter {
|
||||
let Some(sym) = self.egl.get_proc_address("eglQueryDmaBufModifiersEXT") else {
|
||||
return Vec::new();
|
||||
};
|
||||
// SAFETY: `sym` is the non-null pointer `eglGetProcAddress("eglQueryDmaBufModifiersEXT")`
|
||||
// returned (the `let-else` already bailed on `None`) — the driver's implementation of that
|
||||
// EGL extension entry point. `QueryFn` is declared with that function's exact documented ABI
|
||||
// (`EGLDisplay, EGLint, EGLint, EGLuint64* , EGLBoolean*, EGLint* -> EGLBoolean`), all
|
||||
// `extern "system"`, so the transmute only retypes a same-size, same-ABI thin fn pointer.
|
||||
let query: QueryFn = unsafe { std::mem::transmute(sym) };
|
||||
let dpy = self.display.as_ptr();
|
||||
// SAFETY: `dpy` is this importer's live, initialized `EGLDisplay`; `query` is the proc loaded
|
||||
// just above. The first call passes null out-arrays with `max_modifiers == 0`, which the
|
||||
// extension defines as "write only the count" — it writes solely through `&mut count` (a live
|
||||
// local `i32`). For the second call, `mods`/`ext` are freshly allocated `Vec`s of exactly
|
||||
// `count` elements and `max_modifiers == count`, so the driver writes at most `count`
|
||||
// `u64`/`u32` entries (in bounds) plus the actual count through `&mut n` (a live local). All
|
||||
// four Rust addresses outlive these synchronous calls and alias nothing else. `truncate` only
|
||||
// shrinks, so even a misbehaving `n > count` cannot read out of bounds.
|
||||
unsafe {
|
||||
let mut count: i32 = 0;
|
||||
if query(
|
||||
@@ -699,6 +762,10 @@ impl EglImporter {
|
||||
]);
|
||||
}
|
||||
attrs.push(egl::ATTRIB_NONE);
|
||||
// SAFETY: `eglCreateImage(EGL_LINUX_DMA_BUF_EXT, ...)` mandates a NULL `EGLClientBuffer`
|
||||
// (the source is described entirely by the attribute list built above), so wrapping
|
||||
// `null_mut()` is the required value. `from_ptr` only stores the pointer without
|
||||
// dereferencing it, so constructing it from null is sound.
|
||||
let client = unsafe { egl::ClientBuffer::from_ptr(std::ptr::null_mut()) };
|
||||
let image = self
|
||||
.egl
|
||||
@@ -733,11 +800,21 @@ impl EglImporter {
|
||||
) -> Result<DeviceBuffer> {
|
||||
cuda::make_current()?;
|
||||
if self.blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
||||
// SAFETY: `GlBlit::new` requires the GL context current on the calling thread and a
|
||||
// current CUDA context. Both hold: this runs on the capture thread where
|
||||
// `EglImporter::new` made the GL context current and never released it, and
|
||||
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
|
||||
// `Copy` frame dimensions.
|
||||
self.blit = Some(unsafe { GlBlit::new(width, height)? });
|
||||
}
|
||||
let egl_image_target = self.egl_image_target;
|
||||
let blit = self.blit.as_mut().unwrap();
|
||||
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage.
|
||||
// SAFETY: `GlBlit::run` requires a current GL context and a valid `EGLImage`. The GL context
|
||||
// is current on this capture thread (made current in `EglImporter::new`, never released) and
|
||||
// `cuda::make_current()` ran above; `egl_image_target` is the `glEGLImageTargetTexture2DOES`
|
||||
// pointer loaded in `new`; `image` is the raw handle of the live `EGLImage` that
|
||||
// `import_inner` created with `eglCreateImage` and destroys only AFTER this call returns, so
|
||||
// it stays valid for the whole synchronous `run`.
|
||||
unsafe { blit.run(egl_image_target, image)? };
|
||||
// Persistent registration (mapped per frame) + a pooled buffer — no per-frame
|
||||
// cuGraphicsGLRegisterImage / cuMemAllocPitch.
|
||||
@@ -757,11 +834,21 @@ impl EglImporter {
|
||||
) -> Result<DeviceBuffer> {
|
||||
cuda::make_current()?;
|
||||
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
||||
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
|
||||
// current CUDA context. Both hold: this runs on the capture thread where
|
||||
// `EglImporter::new` made the GL context current and never released it, and
|
||||
// `cuda::make_current()?` ran at the top of this function. `width`/`height` are plain
|
||||
// `Copy` frame dimensions.
|
||||
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
|
||||
}
|
||||
let egl_image_target = self.egl_image_target;
|
||||
let blit = self.nv12_blit.as_mut().unwrap();
|
||||
// SAFETY: GL + CUDA contexts current on this thread; `image` is a valid EGLImage.
|
||||
// SAFETY: `Nv12Blit::run` requires a current GL context and a valid `EGLImage`. The GL
|
||||
// context is current on this capture thread (made current in `EglImporter::new`, never
|
||||
// released) and `cuda::make_current()` ran above; `egl_image_target` is the
|
||||
// `glEGLImageTargetTexture2DOES` pointer loaded in `new`; `image` is the raw handle of the
|
||||
// live `EGLImage` that `import_inner` created with `eglCreateImage` and destroys only AFTER
|
||||
// this call returns, so it stays valid for the whole synchronous `run`.
|
||||
unsafe { blit.run(egl_image_target, image)? };
|
||||
let dst = blit.pool.get()?;
|
||||
cuda::copy_mapped_nv12(&mut blit.y_registered, &mut blit.uv_registered, &dst)?;
|
||||
@@ -787,9 +874,22 @@ impl EglImporter {
|
||||
);
|
||||
cuda::make_current()?;
|
||||
if self.nv12_blit.as_ref().map(|b| (b.width, b.height)) != Some((width, height)) {
|
||||
// SAFETY: `Nv12Blit::new` requires the GL context current on the calling thread and a
|
||||
// current CUDA context. Both hold: this self-test path runs on the thread that owns this
|
||||
// `EglImporter` with its GL context current, and `cuda::make_current()?` ran just above.
|
||||
// `width`/`height` are plain `Copy` scalars.
|
||||
self.nv12_blit = Some(unsafe { Nv12Blit::new(width, height)? });
|
||||
}
|
||||
let blit = self.nv12_blit.as_mut().unwrap();
|
||||
// SAFETY: runs on the thread that owns this `EglImporter` with its GL context current.
|
||||
// `blit.src_tex` is a texture this `Nv12Blit` owns; `glTexStorage2D` allocates immutable
|
||||
// RGBA8 storage exactly once (guarded by `test_src_storage`) sized `width×height`.
|
||||
// `glTexSubImage2D` then uploads exactly `width×height` RGBA8 texels, reading `width*height*4`
|
||||
// bytes from `rgba.as_ptr()`; the caller already asserted `rgba.len() == width*height*4`, rows
|
||||
// are `width*4` bytes (a multiple of the default 4-byte unpack alignment, so no row-padding
|
||||
// over-read), and `rgba` is a live borrow that outlives this synchronous upload. `run_passes`
|
||||
// then needs only the current GL context (no further Rust pointers). All GL names are this
|
||||
// blit's own, alias no other live object, and nothing is retained past the calls.
|
||||
unsafe {
|
||||
// Upload the host RGBA into `src_tex` (an immutable GL_RGBA8 backing must exist first;
|
||||
// the live path never allocates it — it retargets `src_tex` via EGLImage instead).
|
||||
@@ -824,9 +924,16 @@ impl EglImporter {
|
||||
impl Drop for EglImporter {
|
||||
fn drop(&mut self) {
|
||||
if !self.gbm.is_null() {
|
||||
// SAFETY: `self.gbm` is the non-null `gbm_device*` from `gbm_create_device` in `new`
|
||||
// (checked non-null here), owned exclusively by this `EglImporter` and destroyed exactly
|
||||
// once (in `Drop`). It is freed BEFORE `render_fd` is closed below — the correct order,
|
||||
// since the device borrowed that fd for its lifetime.
|
||||
unsafe { gbm_device_destroy(self.gbm) };
|
||||
}
|
||||
if self.render_fd >= 0 {
|
||||
// SAFETY: `self.render_fd` is the fd `open` returned in `new` (checked `>= 0`), owned
|
||||
// exclusively by this `EglImporter`; this `close` runs exactly once, after the gbm device
|
||||
// that borrowed it has been destroyed. No double-close or use-after-close.
|
||||
unsafe { libc::close(self.render_fd) };
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -16,6 +16,9 @@
|
||||
//! a stream's life). Falls back cleanly: any init/import error disables the importer and the
|
||||
//! CPU mmap path takes over.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::cuda::{self, DeviceBuffer};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use ash::vk;
|
||||
@@ -51,12 +54,27 @@ pub struct VkBridge {
|
||||
dst: Option<DstBuf>,
|
||||
}
|
||||
|
||||
// Confined to the capture thread; moved there once.
|
||||
// SAFETY: `VkBridge` owns ash Vulkan handles (instance/device/queue/command pool+buffer/fence), a
|
||||
// CUDA external-memory mapping, and an fd→buffer cache — none `Sync`, and a single queue +
|
||||
// command buffer must be externally synchronized. It is created inside `EglImporter::import_linear`
|
||||
// on the dedicated `punktfunk-pipewire` capture thread and every method (`import_linear`, `Drop`)
|
||||
// runs on that thread; it is never shared via `&` across threads. `Send` asserts only that
|
||||
// transferring ownership is sound (so the bridge can live inside the `Send` `EglImporter`); the live
|
||||
// handles are never touched off-thread, and `Sync` is deliberately NOT implied.
|
||||
unsafe impl Send for VkBridge {}
|
||||
|
||||
impl VkBridge {
|
||||
/// Bring up Vulkan on the NVIDIA GPU with the external-memory extensions.
|
||||
pub fn new() -> Result<VkBridge> {
|
||||
// SAFETY: standard ash bring-up — every call is `unsafe` only because ash cannot statically
|
||||
// verify Vulkan handle/CreateInfo validity. `ash::Entry::load` dlopens a real system
|
||||
// libvulkan. Each `*CreateInfo`/`AllocateInfo` is built by ash's builders from locals (`app`,
|
||||
// `exts`, `prio`, `qci`, and the inline infos) that all live for the duration of the
|
||||
// synchronous `create_*`/`enumerate_*` call that reads them — in particular the
|
||||
// `enabled_extension_names(&exts)` and `queue_priorities(&prio)` borrows outlive their calls.
|
||||
// Every handle passed (`instance`, `phys`, `device`, `qf`, `cmd_pool`) was just created and
|
||||
// checked via `?`/`ok_or_else` in this same function, so no invalid handle is ever used. This
|
||||
// constructor shares nothing across threads.
|
||||
unsafe {
|
||||
let entry = ash::Entry::load().context("load libvulkan")?;
|
||||
let app = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_1);
|
||||
@@ -294,6 +312,19 @@ impl VkBridge {
|
||||
height: u32,
|
||||
pool: &cuda::BufferPool,
|
||||
) -> Result<DeviceBuffer> {
|
||||
// SAFETY: `fd` is the live dmabuf fd handed in by the caller (borrowed; `import_src` dup's it
|
||||
// internally and Vulkan owns the dup). `libc::lseek` only queries the fd's size. The unsafe
|
||||
// `import_src`/`ensure_dst` are called with a valid fd and a checked size. The bounds are
|
||||
// proven: `import_src` asserts `size >= span` (so the cached `src_size >= span`),
|
||||
// `copy_size = src_size.min(span)`, and `ensure_dst(copy_size)` makes `dst` at least
|
||||
// `copy_size` — so the GPU `cmd_copy_buffer` of `copy_size` bytes reads/writes within both
|
||||
// buffers, and the later CUDA pitched copy reading `[offset, span)` from `dst.cuda.ptr` (=
|
||||
// `offset + stride*height = span <= copy_size`) stays inside the freshly-copied region. The
|
||||
// `*Info`/`region`/`cmds`/`submit` are locals that outlive the synchronous calls reading them.
|
||||
// `cmd`/`queue`/`fence` are this bridge's own handles, used on this single thread only. The
|
||||
// host-side `wait_for_fences` fully retires the Vulkan copy BEFORE CUDA reads the shared
|
||||
// memory, so there is no GPU write/read data race. `dst` is an `&self.dst` shared borrow that
|
||||
// does not alias the `&self.device` calls.
|
||||
unsafe {
|
||||
let span = offset as u64 + stride as u64 * height as u64;
|
||||
if !self.src_cache.contains_key(&fd) {
|
||||
@@ -347,6 +378,15 @@ impl VkBridge {
|
||||
|
||||
impl Drop for VkBridge {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: runs once when the bridge is dropped on its owning capture thread.
|
||||
// `device_wait_idle` first drains all in-flight GPU work, so no queued command still
|
||||
// references these objects. Every handle freed (the `src_cache` buffers+memories, the `dst`
|
||||
// buffer+memory, `fence`, `cmd_pool`, `device`, `instance`) was created by this `VkBridge`
|
||||
// and owned exclusively by it, so each `destroy_*`/`free_*` runs exactly once with no
|
||||
// double-free, in dependency order (child objects before `device`, `device` before
|
||||
// `instance`). `dst.cuda` is dropped after `free_memory`, which is safe because CUDA holds
|
||||
// its own dup'd OPAQUE_FD reference to the underlying allocation. No other thread touches
|
||||
// these handles.
|
||||
unsafe {
|
||||
let _ = self.device.device_wait_idle();
|
||||
for (_, s) in self.src_cache.drain() {
|
||||
@@ -13,18 +13,30 @@
|
||||
|
||||
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
||||
#![allow(dead_code)]
|
||||
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` in the crate must carry a `// SAFETY:`
|
||||
// proof of why it is sound. This crate-root deny is the permanent, catch-all gate (it also covers
|
||||
// any future module); individual files keep their own `#![deny(...)]` as belt-and-suspenders.
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
mod audio;
|
||||
mod capture;
|
||||
mod config;
|
||||
mod discovery;
|
||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||
// keeps the `crate::*` module names flat (every existing path is unchanged).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/dmabuf_fence.rs"]
|
||||
mod dmabuf_fence;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/drm_sync.rs"]
|
||||
mod drm_sync;
|
||||
mod encode;
|
||||
mod gamestream;
|
||||
mod hdr;
|
||||
mod inject;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/interactive.rs"]
|
||||
mod interactive;
|
||||
mod library;
|
||||
mod mgmt;
|
||||
mod mgmt_token;
|
||||
@@ -33,13 +45,23 @@ mod pipeline;
|
||||
mod punktfunk1;
|
||||
mod pwinit;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/service.rs"]
|
||||
mod service;
|
||||
mod session_plan;
|
||||
mod session_tuning;
|
||||
mod spike;
|
||||
mod vdisplay;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/wgc_helper.rs"]
|
||||
mod wgc_helper;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/win_adapter.rs"]
|
||||
mod win_adapter;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/win_display.rs"]
|
||||
mod win_display;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "linux/zerocopy/mod.rs"]
|
||||
mod zerocopy;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
//! Trust: the host serves with its persistent identity (`~/.config/punktfunk/cert.pem`, shared
|
||||
//! with GameStream pairing) and logs the SHA-256 fingerprint clients pin.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Role};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
@@ -209,6 +212,10 @@ pub(crate) async fn serve(opts: Punktfunk1Options, np: Arc<NativePairing>) -> Re
|
||||
// restores the box's autologin gaming session on idle, not per-disconnect — see
|
||||
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
|
||||
let _restore_worker = crate::vdisplay::start_restore_worker();
|
||||
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
|
||||
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
|
||||
// blocks on the network. A no-op on a host whose stores all carry their own art.
|
||||
let _art_warmer = crate::library::start_art_warmer();
|
||||
// Pairing state (arming PIN + trust store) is shared with the management API. If it was armed
|
||||
// at startup (the CLI flags), surface the PIN the headless operator reads from the log; the
|
||||
// web console arms it on demand instead (a fresh, time-limited PIN).
|
||||
@@ -571,6 +578,11 @@ async fn serve_session(
|
||||
// (`what's left` §3), resolve the command into the per-session VirtualDisplay via
|
||||
// `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other.
|
||||
if let Some(id) = hello.launch.as_deref() {
|
||||
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
|
||||
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
|
||||
// the title into the interactive user session via `library::launch_title` once capture is
|
||||
// live (threaded as `SessionContext.launch` below), so there is nothing to do here.
|
||||
#[cfg(not(windows))]
|
||||
match crate::library::launch_command(id) {
|
||||
Some(cmd) => {
|
||||
tracing::info!(launch_id = id, command = %cmd, "launching library title");
|
||||
@@ -581,6 +593,8 @@ async fn serve_session(
|
||||
"client requested a launch id not in this host's library — ignoring"
|
||||
),
|
||||
}
|
||||
#[cfg(windows)]
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||
@@ -599,7 +613,7 @@ async fn serve_session(
|
||||
// opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older
|
||||
// client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a
|
||||
// mgmt/console toggle replaces it.
|
||||
let host_wants_10bit = std::env::var_os("PUNKTFUNK_10BIT").is_some();
|
||||
let host_wants_10bit = crate::config::config().ten_bit;
|
||||
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
|
||||
let bit_depth: u8 = if host_wants_10bit && client_supports_10bit {
|
||||
10
|
||||
@@ -912,6 +926,10 @@ async fn serve_session(
|
||||
let source = opts.source;
|
||||
let (seconds, frames) = (opts.seconds, opts.frames);
|
||||
let mode = hello.mode;
|
||||
// Windows: the store-qualified launch id, threaded into the data plane so the title can be
|
||||
// launched into the interactive session once capture is live (no gamescope nesting on Windows).
|
||||
#[cfg(target_os = "windows")]
|
||||
let launch_for_dp = hello.launch.clone();
|
||||
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
||||
let stop_stream = stop.clone();
|
||||
@@ -957,21 +975,23 @@ async fn serve_session(
|
||||
Punktfunk1Source::Virtual => {
|
||||
let compositor = compositor
|
||||
.expect("the Virtual source resolves a compositor during the handshake");
|
||||
virtual_stream(
|
||||
virtual_stream(SessionContext {
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop_stream,
|
||||
&reconfig_rx,
|
||||
&keyframe_rx,
|
||||
stop: stop_stream,
|
||||
reconfig: reconfig_rx,
|
||||
keyframe: keyframe_rx,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target_dp,
|
||||
conn_stream,
|
||||
)
|
||||
fec_target: fec_target_dp,
|
||||
conn: conn_stream,
|
||||
#[cfg(target_os = "windows")]
|
||||
launch: launch_for_dp,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1616,7 +1636,7 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
/// Resolve the client's gamepad-backend preference (the env/logging shell around
|
||||
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
|
||||
fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
let env = std::env::var("PUNKTFUNK_GAMEPAD").ok();
|
||||
let env = crate::config::config().gamepad.clone();
|
||||
let chosen = pick_gamepad(
|
||||
pref,
|
||||
env.as_deref(),
|
||||
@@ -1683,7 +1703,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
||||
{
|
||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||
// to come with a hand-set env — don't retarget the process env in that case.
|
||||
let overridden = std::env::var_os("PUNKTFUNK_COMPOSITOR").is_some();
|
||||
let overridden = crate::config::config().compositor.is_some();
|
||||
let detected = if overridden {
|
||||
crate::vdisplay::detect().ok()
|
||||
} else {
|
||||
@@ -1948,6 +1968,11 @@ pub(crate) fn boost_thread_priority(critical: bool) {
|
||||
// capture/encode (critical) and send (non-critical).
|
||||
crate::session_tuning::on_hot_thread();
|
||||
#[cfg(target_os = "windows")]
|
||||
// SAFETY: `GetCurrentThread()` returns the constant pseudo-handle for the calling thread — always
|
||||
// valid, thread-local in meaning, and never closed (no leak/double-close). `SetThreadPriority`
|
||||
// takes that handle plus a `THREAD_PRIORITY_*` value the windows crate defines (HIGHEST or
|
||||
// ABOVE_NORMAL here); it only reprioritizes this OS thread, borrows no Rust memory, and its
|
||||
// `Result` is matched (a failure is logged, never UB). No pointers, lifetimes, or aliasing.
|
||||
unsafe {
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL,
|
||||
@@ -1975,6 +2000,10 @@ pub(crate) fn boost_thread_priority(critical: bool) {
|
||||
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
|
||||
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
let nice = if critical { -10 } else { -5 };
|
||||
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
|
||||
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
|
||||
// `nice` is in range; the call only adjusts this thread's scheduling nice value and returns an
|
||||
// `int` we inspect. No memory is touched.
|
||||
let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) };
|
||||
if rc == 0 {
|
||||
tracing::debug!(critical, nice, "thread nice raised");
|
||||
@@ -2141,71 +2170,81 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
|
||||
}
|
||||
}
|
||||
|
||||
/// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs
|
||||
/// stamped with the capture wall clock (the client derives per-frame pipeline latency).
|
||||
///
|
||||
/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is
|
||||
/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its
|
||||
/// keepalive, the virtual output) while the data-plane `session` continues untouched —
|
||||
/// the rebuilt encoder opens with an IDR + in-band parameter sets. `probe_rx`/`probe_result_tx`
|
||||
/// carry speed-test bursts (see [`service_probes`]).
|
||||
/// The stop flag of the current in-process IDD-push session, so a NEW connection can PREEMPT it.
|
||||
/// A fresh connection means the prior client is gone (a reconnect) and a reused IddCx monitor's
|
||||
/// swap-chain is dead — so we stop the prior session (it releases its monitor cleanly while frames
|
||||
/// still flow), then build a fresh one, instead of joining a dying session or tearing its monitor out
|
||||
/// from under it (which churns the driver's ADD/REMOVE path and wedges it under rapid reconnects).
|
||||
#[cfg(target_os = "windows")]
|
||||
static IDD_SESSION_STOP: std::sync::Mutex<Option<Arc<AtomicBool>>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Serializes IDD-push session SETUP (preempt + monitor create + first frame). Held across setup,
|
||||
/// released before the encode loop — so a reconnect FLOOD can never run concurrent monitor
|
||||
/// create/teardown (the churn that fails the ADD IOCTL and wedges the driver). Each session finishes
|
||||
/// setup before the next acquires this and preempts it, by which point the preempted session is in its
|
||||
/// encode loop and releases its monitor promptly.
|
||||
#[cfg(target_os = "windows")]
|
||||
static IDD_SETUP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn virtual_stream(
|
||||
/// All per-session inputs for [`virtual_stream`] / [`virtual_stream_relay`], bundled so the session entry
|
||||
/// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature
|
||||
/// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their
|
||||
/// only consumer) — so the whole context moves into the stream thread and the borrow plumbing disappears.
|
||||
struct SessionContext {
|
||||
/// The hardened data-plane `Session` (Leopard FEC + AES-GCM over UDP); moved into the send thread.
|
||||
session: Session,
|
||||
/// The client's requested mode — the virtual output is created at exactly this WxH@Hz (no scaling).
|
||||
mode: punktfunk_core::Mode,
|
||||
/// Stream duration cap (the persistent listener bounds back-to-back sessions).
|
||||
seconds: u32,
|
||||
/// Session stop flag (set on disconnect / reconnect-preempt).
|
||||
stop: Arc<AtomicBool>,
|
||||
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
keyframe: &std::sync::mpsc::Receiver<()>,
|
||||
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
|
||||
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
/// Client decode-recovery keyframe requests.
|
||||
keyframe: std::sync::mpsc::Receiver<()>,
|
||||
/// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there).
|
||||
compositor: crate::vdisplay::Compositor,
|
||||
/// Negotiated encoder bitrate (kbps).
|
||||
bitrate_kbps: u32,
|
||||
/// Negotiated encode bit depth (8, or 10 = HEVC Main10).
|
||||
bit_depth: u8,
|
||||
/// Speed-test burst requests (see [`service_probes`]).
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
/// Speed-test results back to the control task.
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
/// Adaptive-FEC target the control task updates from the client's loss reports.
|
||||
fec_target: Arc<AtomicU8>,
|
||||
/// The QUIC control connection (carries host→client 0xCE source-HDR metadata mid-stream).
|
||||
conn: quinn::Connection,
|
||||
) -> Result<()> {
|
||||
/// Windows: the store-qualified library id to launch into the interactive user session once
|
||||
/// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the
|
||||
/// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only.
|
||||
#[cfg(target_os = "windows")]
|
||||
launch: Option<String>,
|
||||
}
|
||||
|
||||
fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||
// This thread runs the capture+encode loop (single-process: Linux / synthetic / NO_WGC DDA) — or
|
||||
// tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission.
|
||||
boost_thread_priority(true);
|
||||
// Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed
|
||||
// path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site
|
||||
// (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the
|
||||
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
|
||||
let plan = crate::session_plan::SessionPlan::resolve(ctx.bit_depth);
|
||||
tracing::info!(?plan, "resolved session plan");
|
||||
// Windows two-process secure-desktop path: when the host runs as SYSTEM (required for the secure
|
||||
// desktop + SendInput), WGC can't activate in-process, so we capture the normal desktop via a
|
||||
// helper spawned in the user session and relay its AUs. (Single-process WGC/DDA is used as the
|
||||
// user, and stays the path on Linux.) See docs/windows-secure-desktop.md.
|
||||
#[cfg(target_os = "windows")]
|
||||
if should_use_helper() {
|
||||
return virtual_stream_relay(
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
);
|
||||
if plan.topology == crate::session_plan::SessionTopology::TwoProcessRelay {
|
||||
return virtual_stream_relay(ctx);
|
||||
}
|
||||
// Single-process path: unpack the context into the locals the loop below uses (names unchanged, so the
|
||||
// body is byte-for-byte the same; the receivers are now owned but `try_recv()` is identical).
|
||||
let SessionContext {
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
conn,
|
||||
#[cfg(target_os = "windows")]
|
||||
launch,
|
||||
} = ctx;
|
||||
tracing::info!(
|
||||
compositor = compositor.id(),
|
||||
?mode,
|
||||
@@ -2213,30 +2252,24 @@ fn virtual_stream(
|
||||
bit_depth,
|
||||
"punktfunk/1 virtual display"
|
||||
);
|
||||
// IDD-push reconnect preempt: a fresh connection means the prior client is gone. Hold IDD_SETUP_LOCK
|
||||
// across the preempt + pipeline build so a reconnect FLOOD can't run concurrent monitor
|
||||
// create/teardown. Then STOP the prior session (it ends cleanly while its monitor still composites
|
||||
// frames) and WAIT for it to release its monitor, before building a FRESH one — instead of the
|
||||
// driver-churning teardown of a monitor under a still-live session. Register THIS session's stop so
|
||||
// the next reconnect preempts it.
|
||||
#[cfg(target_os = "windows")]
|
||||
let idd_setup_guard = std::env::var_os("PUNKTFUNK_IDD_PUSH")
|
||||
.is_some()
|
||||
.then(|| IDD_SETUP_LOCK.lock().unwrap());
|
||||
#[cfg(target_os = "windows")]
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
let prev = IDD_SESSION_STOP.lock().unwrap().replace(stop.clone());
|
||||
if let Some(prev_stop) = prev {
|
||||
prev_stop.store(true, Ordering::SeqCst);
|
||||
crate::vdisplay::sudovda::wait_for_monitor_released(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
// Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the
|
||||
// host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push
|
||||
// preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics.
|
||||
let mut vd = crate::vdisplay::open(compositor)?;
|
||||
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
|
||||
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
|
||||
// to release its monitor (instead of tearing a monitor out from under a still-live session), and
|
||||
// register THIS session's stop. The returned guard holds the setup lock across the pipeline build;
|
||||
// dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created
|
||||
// (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation.
|
||||
#[cfg(target_os = "windows")]
|
||||
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
|
||||
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
|
||||
let (mut capturer, mut enc, mut frame, mut interval) =
|
||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth)?;
|
||||
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan)?;
|
||||
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||
#[cfg(target_os = "windows")]
|
||||
drop(idd_setup_guard);
|
||||
drop(_idd_setup_guard);
|
||||
|
||||
// Windows single-process DDA path (PUNKTFUNK_NO_WGC=1): the SudoVDA virtual display, isolated as the
|
||||
// SOLE active output, goes into fullscreen independent-flip (one plane on one display) which Desktop
|
||||
@@ -2251,7 +2284,18 @@ fn virtual_stream(
|
||||
#[cfg(target_os = "windows")]
|
||||
let _composed_flip = crate::capture::composed_flip::ForceComposedFlip::start();
|
||||
|
||||
let perf = std::env::var("PUNKTFUNK_PERF").is_ok();
|
||||
// Windows: capture is live (and composition forced) — launch the requested library title into the
|
||||
// interactive user session so it renders onto the captured desktop and grabs foreground. Linux
|
||||
// nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort:
|
||||
// a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(id) = launch.as_deref() {
|
||||
if let Err(e) = crate::library::launch_title(id) {
|
||||
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
|
||||
}
|
||||
}
|
||||
|
||||
let perf = crate::config::config().perf;
|
||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
|
||||
// only a bigger frame's overflow is spread. PUNKTFUNK_PACE_BURST_KB overrides the 128 KB default.
|
||||
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||
@@ -2291,7 +2335,7 @@ fn virtual_stream(
|
||||
let mut compositor = compositor;
|
||||
let (session_tx, session_rx) = std::sync::mpsc::channel::<SessionSwitch>();
|
||||
let watch = std::env::var_os("PUNKTFUNK_SESSION_WATCH").is_some()
|
||||
&& std::env::var_os("PUNKTFUNK_COMPOSITOR").is_none();
|
||||
&& crate::config::config().compositor.is_none();
|
||||
let _watcher = if watch {
|
||||
let stop = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
@@ -2324,6 +2368,12 @@ fn virtual_stream(
|
||||
// compositing), NOT an encoder problem. Logged every 2 s when `PUNKTFUNK_PERF`.
|
||||
let (mut diag_new, mut diag_repeat) = (0u64, 0u64);
|
||||
let mut diag_at = std::time::Instant::now();
|
||||
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
|
||||
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
|
||||
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
|
||||
// that dominates under a GPU-saturating game).
|
||||
let (mut st_cap, mut st_submit, mut st_wait): (Vec<u32>, Vec<u32>, Vec<u32>) =
|
||||
(Vec::new(), Vec::new(), Vec::new());
|
||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
||||
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
||||
@@ -2362,6 +2412,7 @@ fn virtual_stream(
|
||||
cur_mode,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
)?;
|
||||
Ok((new_vd, pipe))
|
||||
})();
|
||||
@@ -2405,7 +2456,7 @@ fn virtual_stream(
|
||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
||||
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||
// healthy session — keep streaming the current mode and log instead.
|
||||
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth) {
|
||||
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan) {
|
||||
Ok(next_pipe) => {
|
||||
(capturer, enc, frame, interval) = next_pipe;
|
||||
cur_mode = new_mode;
|
||||
@@ -2429,7 +2480,12 @@ fn virtual_stream(
|
||||
tracing::debug!("forcing keyframe (client decode recovery)");
|
||||
enc.request_keyframe();
|
||||
}
|
||||
match capturer.try_latest() {
|
||||
let t_cap = std::time::Instant::now();
|
||||
let cap_result = capturer.try_latest();
|
||||
if perf {
|
||||
st_cap.push(t_cap.elapsed().as_micros() as u32);
|
||||
}
|
||||
match cap_result {
|
||||
Ok(Some(f)) => {
|
||||
frame = f;
|
||||
diag_new += 1;
|
||||
@@ -2450,7 +2506,7 @@ fn virtual_stream(
|
||||
tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds,
|
||||
"capture lost — rebuilding pipeline in place");
|
||||
let (new_cap, new_enc, new_frame, new_interval) =
|
||||
build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth)
|
||||
build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth, plan)
|
||||
.context("rebuild after capture loss")?;
|
||||
capturer = new_cap;
|
||||
enc = new_enc;
|
||||
@@ -2468,6 +2524,20 @@ fn virtual_stream(
|
||||
"capture diag: NEW frames from the source vs REPEATS (low new_fps at high send rate ⇒ \
|
||||
the source isn't producing frames, not an encode stall)"
|
||||
);
|
||||
let wait_max = st_wait.iter().copied().max().unwrap_or(0);
|
||||
tracing::info!(
|
||||
cap_us_p50 = percentile(&mut st_cap, 0.50),
|
||||
cap_us_p99 = percentile(&mut st_cap, 0.99),
|
||||
submit_us_p50 = percentile(&mut st_submit, 0.50),
|
||||
submit_us_p99 = percentile(&mut st_submit, 0.99),
|
||||
wait_us_p50 = percentile(&mut st_wait, 0.50),
|
||||
wait_us_p99 = percentile(&mut st_wait, 0.99),
|
||||
wait_us_max = wait_max,
|
||||
"stage perf (µs/call): cap=try_latest(ring+convert) submit=encode_picture wait=lock_bitstream(sched+ASIC)"
|
||||
);
|
||||
st_cap.clear();
|
||||
st_submit.clear();
|
||||
st_wait.clear();
|
||||
diag_new = 0;
|
||||
diag_repeat = 0;
|
||||
diag_at = std::time::Instant::now();
|
||||
@@ -2486,7 +2556,11 @@ fn virtual_stream(
|
||||
// capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1.
|
||||
let depth = capturer.pipeline_depth().max(1);
|
||||
let capture_ns = now_ns();
|
||||
let t_submit = std::time::Instant::now();
|
||||
enc.submit(&frame).context("encoder submit")?;
|
||||
if perf {
|
||||
st_submit.push(t_submit.elapsed().as_micros() as u32);
|
||||
}
|
||||
// This frame's pacing deadline (the next frame's due time); the send thread spreads a big frame
|
||||
// up to here. Each in-flight frame carries its own (capture_ns, deadline) for when it's polled.
|
||||
next += interval;
|
||||
@@ -2497,7 +2571,12 @@ fn virtual_stream(
|
||||
// the oldest submitted frame's AU — matching `inflight.pop_front()`.
|
||||
let mut send_gone = false;
|
||||
while inflight.len() >= depth {
|
||||
let au = match enc.poll().context("encoder poll")? {
|
||||
let t_wait = std::time::Instant::now();
|
||||
let polled = enc.poll().context("encoder poll")?;
|
||||
if perf {
|
||||
st_wait.push(t_wait.elapsed().as_micros() as u32);
|
||||
}
|
||||
let au = match polled {
|
||||
Some(au) => au,
|
||||
None => break, // no AU ready for a submitted frame (shouldn't happen — poll blocks)
|
||||
};
|
||||
@@ -2569,29 +2648,6 @@ fn virtual_stream(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Should this host take the two-process (SYSTEM host + user-session WGC helper) path? Yes when it's
|
||||
/// running as SYSTEM — the only account that can capture the secure desktop + drive SendInput on it,
|
||||
/// and the account under which in-process WGC won't activate. `PUNKTFUNK_FORCE_HELPER` forces it on
|
||||
/// (for testing the relay as a normal user); `PUNKTFUNK_NO_HELPER` forces it off. `PUNKTFUNK_NO_WGC`
|
||||
/// also forces it off — that mode runs pure single-process DDA (one capturer for the normal AND secure
|
||||
/// desktop, Apollo-style), which has no WGC helper to relay.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn should_use_helper() -> bool {
|
||||
if std::env::var_os("PUNKTFUNK_NO_HELPER").is_some() || crate::capture::wgc_disabled() {
|
||||
return false;
|
||||
}
|
||||
// IDD direct-push captures IN-PROCESS in Session 0: the pf-vdisplay driver delivers frames to the
|
||||
// SYSTEM host's session via shared memory and NVENC is headless, so no user-session WGC helper is
|
||||
// needed for VIDEO (and a Session-1 helper couldn't open the Session-0 shared textures anyway).
|
||||
// NOTE: input injection (SendInput) from Session 0 can't reach the user's Session-1 desktop yet —
|
||||
// a known follow-up; this path validates the video transport. See docs/windows-virtual-display-rust-port.md.
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
return false;
|
||||
}
|
||||
std::env::var_os("PUNKTFUNK_FORCE_HELPER").is_some()
|
||||
|| crate::capture::wgc_relay::running_as_system()
|
||||
}
|
||||
|
||||
/// Windows two-process video stream: the SYSTEM host creates the SudoVDA virtual output (and holds
|
||||
/// its keepalive = the sole topology/isolation owner), spawns the WGC helper in the user session to
|
||||
/// capture+encode the NORMAL desktop, and relays the helper's AUs onto the QUIC data plane via the
|
||||
@@ -2603,27 +2659,30 @@ fn should_use_helper() -> bool {
|
||||
/// helper at the new mode (and drops the stale-target DDA); keyframe requests forward to the active
|
||||
/// source.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn virtual_stream_relay(
|
||||
session: Session,
|
||||
mode: punktfunk_core::Mode,
|
||||
seconds: u32,
|
||||
stop: Arc<AtomicBool>,
|
||||
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||
keyframe: &std::sync::mpsc::Receiver<()>,
|
||||
compositor: crate::vdisplay::Compositor,
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
probe_rx: std::sync::mpsc::Receiver<ProbeRequest>,
|
||||
probe_result_tx: tokio::sync::mpsc::UnboundedSender<ProbeResult>,
|
||||
fec_target: Arc<AtomicU8>,
|
||||
// The SYSTEM-host relay path doesn't yet send the source mastering metadata as 0xCE — the
|
||||
// helper's in-band SEI carries it (Windows follow-up). Held for that future wiring.
|
||||
_conn: quinn::Connection,
|
||||
) -> Result<()> {
|
||||
fn virtual_stream_relay(ctx: SessionContext) -> Result<()> {
|
||||
use crate::capture::dxgi::WinCaptureTarget;
|
||||
use crate::capture::wgc_relay::HelperRelay;
|
||||
use crate::capture::Capturer; // trait methods (set_active/next_frame) on the concrete DuplCapturer
|
||||
|
||||
// Unpack the context (names unchanged so the body is identical). The relay doesn't yet send the
|
||||
// source's 0xCE HDR metadata — the helper's in-band SEI carries it (a Windows follow-up) — so `conn`
|
||||
// is held unused.
|
||||
let SessionContext {
|
||||
session,
|
||||
mode,
|
||||
seconds,
|
||||
stop,
|
||||
reconfig,
|
||||
keyframe,
|
||||
compositor,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
probe_rx,
|
||||
probe_result_tx,
|
||||
fec_target,
|
||||
conn: _conn,
|
||||
launch,
|
||||
} = ctx;
|
||||
tracing::info!(
|
||||
?mode,
|
||||
bitrate_kbps,
|
||||
@@ -2660,8 +2719,13 @@ fn virtual_stream_relay(
|
||||
// The secure-desktop HDR drop (for the DDA leg) keys off the monitor's real state in the mux loop.
|
||||
#[cfg(target_os = "windows")]
|
||||
if bit_depth >= 10 {
|
||||
// SAFETY: `set_advanced_color` is marked `unsafe` only because it drives the Win32 CCD API
|
||||
// internally; it takes `target_id` by value (Copy `u32` — this session's live SudoVDA
|
||||
// monitor's CCD target id) and sizes + owns every buffer it hands the OS on its own stack.
|
||||
// We pass no pointers, so nothing must outlive the call and there is no aliasing; an
|
||||
// unknown/absent target id simply returns false.
|
||||
unsafe {
|
||||
if crate::vdisplay::sudovda::set_advanced_color(target.target_id, true) {
|
||||
if crate::win_display::set_advanced_color(target.target_id, true) {
|
||||
// Let the colorspace change settle before WGC creates its capture item / detects HDR.
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
@@ -2680,6 +2744,15 @@ fn virtual_stream_relay(
|
||||
let (mut _keepalive, mut relay, mut target, mut effective_hz) = build(&mut vd, mode)?;
|
||||
let mut cur_mode = mode;
|
||||
|
||||
// Capture is live (the WGC helper is relaying) — launch the requested library title into the
|
||||
// interactive user session so it renders onto the captured desktop and grabs foreground.
|
||||
// Best-effort: a failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
|
||||
if let Some(id) = launch.as_deref() {
|
||||
if let Err(e) = crate::library::launch_title(id) {
|
||||
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
|
||||
}
|
||||
}
|
||||
|
||||
// O3.1: optionally observe the IDD-push ring alongside WGC (WGC = the presentation trigger) to
|
||||
// confirm the 0257 driver pushes frames into a HOST-created ring. Diagnostic only; gated.
|
||||
if std::env::var_os("PUNKTFUNK_IDD_PUSH_OBSERVE").is_some() {
|
||||
@@ -2708,6 +2781,9 @@ fn virtual_stream_relay(
|
||||
target.clone(),
|
||||
Some((w, h, hz)),
|
||||
Box::new(()),
|
||||
// The relay's host encoder is GPU (NVENC/AMF/QSV unless software) — pass `gpu` in (Goal-1
|
||||
// stage 5) so the DDA capturer doesn't re-derive it.
|
||||
crate::capture::gpu_encode(),
|
||||
hdr,
|
||||
)
|
||||
.context("open DDA for secure desktop")?;
|
||||
@@ -2731,7 +2807,7 @@ fn virtual_stream_relay(
|
||||
})
|
||||
};
|
||||
|
||||
let perf = std::env::var("PUNKTFUNK_PERF").is_ok();
|
||||
let perf = crate::config::config().perf;
|
||||
let burst_cap = std::env::var("PUNKTFUNK_PACE_BURST_KB")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
@@ -2770,7 +2846,7 @@ fn virtual_stream_relay(
|
||||
// the secure desktop's HDR independent-flip (it storms ACCESS_LOST → black), whereas the WGC helper
|
||||
// STAYS LIVE through a lock/UAC. So by default the mux keeps WGC the whole time (no DesktopWatcher
|
||||
// switch, no overlay). Enable the experimental DDA-on-secure path with PUNKTFUNK_SECURE_DDA=1.
|
||||
let dda_secure = std::env::var("PUNKTFUNK_SECURE_DDA").is_ok() || secure_test_ms.is_some();
|
||||
let dda_secure = crate::config::config().secure_dda || secure_test_ms.is_some();
|
||||
// The authoritative Default↔Winlogon signal (requires SYSTEM to read the Winlogon desktop name);
|
||||
// only needed when the DDA-on-secure path is enabled.
|
||||
let watcher = dda_secure.then(crate::capture::desktop_watch::DesktopWatcher::start);
|
||||
@@ -2883,8 +2959,12 @@ fn virtual_stream_relay(
|
||||
// desktop (the drop just churned + still went black). Instead, if the monitor is in HDR,
|
||||
// open DDA in HDR (FP16 DuplicateOutput1 → BT.2020 PQ Main10); the normal-desktop DDA
|
||||
// overlay/flip issues that drove us to WGC don't apply to the composed Winlogon UI.
|
||||
let hdr =
|
||||
unsafe { crate::vdisplay::sudovda::advanced_color_enabled(target.target_id) };
|
||||
// SAFETY: `advanced_color_enabled` is `unsafe` only because it queries the Win32 CCD
|
||||
// API; it takes `target_id` by value (the live SudoVDA monitor's CCD target id) and
|
||||
// allocates + owns every buffer it passes the OS internally. No caller pointer is
|
||||
// involved, so nothing must outlive the call and there is no aliasing; a missing
|
||||
// target id just yields false.
|
||||
let hdr = unsafe { crate::win_display::advanced_color_enabled(target.target_id) };
|
||||
dda = None; // reopen to capture the secure desktop
|
||||
match open_dda(&target, cur_mode.width, cur_mode.height, effective_hz, hdr) {
|
||||
Ok(mut p) => {
|
||||
@@ -3041,6 +3121,7 @@ fn build_pipeline_with_retry(
|
||||
mode: punktfunk_core::Mode,
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
) -> Result<Pipeline> {
|
||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||
@@ -3050,7 +3131,7 @@ fn build_pipeline_with_retry(
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth) {
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan) {
|
||||
Ok(pipe) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(attempt, "pipeline up after retry");
|
||||
@@ -3109,6 +3190,7 @@ fn build_pipeline(
|
||||
mode: punktfunk_core::Mode,
|
||||
bitrate_kbps: u32,
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
) -> Result<Pipeline> {
|
||||
let vout = vd.create(mode).context("create virtual output")?;
|
||||
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
|
||||
@@ -3131,8 +3213,9 @@ fn build_pipeline(
|
||||
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
|
||||
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
|
||||
// which auto-detect HDR from the monitor state.)
|
||||
let mut capturer = crate::capture::capture_virtual_output(vout, bit_depth >= 10)
|
||||
.context("capture virtual output")?;
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
capturer.set_active(true);
|
||||
let frame = capturer.next_frame().context("first frame")?;
|
||||
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
|
||||
@@ -3306,12 +3389,27 @@ mod tests {
|
||||
unsafe fn pull_verified(conn: *mut punktfunk_core::abi::PunktfunkConnection, count: u32) {
|
||||
use punktfunk_core::error::PunktfunkStatus;
|
||||
let mut got = 0u32;
|
||||
// SAFETY: the inferred type is the `#[repr(C)]` POD `PunktfunkFrame` (a raw `*const u8`, a
|
||||
// `usize`, and integer fields); all-zero is a valid bit pattern for every field (a null
|
||||
// `data`, `len == 0`). It is only ever read after `next_au` below fully overwrites it on `Ok`,
|
||||
// so the zeroed value is never observed.
|
||||
let mut frame = unsafe { std::mem::zeroed() };
|
||||
while got < count {
|
||||
// SAFETY: `conn` is the live, non-null `*mut PunktfunkConnection` from `punktfunk_connect`
|
||||
// (the caller asserts non-null and does not close it until after this returns), meeting the
|
||||
// ABI's "valid handle". `&mut frame` is an exclusive, writable borrow of the local
|
||||
// `PunktfunkFrame` that outlives this synchronous call. This single test thread is the only
|
||||
// video puller, satisfying the one-video-thread rule.
|
||||
match unsafe {
|
||||
punktfunk_core::abi::punktfunk_connection_next_au(conn, &mut frame, 2000)
|
||||
} {
|
||||
PunktfunkStatus::Ok => {
|
||||
// SAFETY: on `Ok`, `next_au` set `frame.data`/`frame.len` to the reassembled AU
|
||||
// buffer the connection owns; per the ABI contract that borrow stays valid until
|
||||
// the NEXT `next_au` call on this handle. We read the whole slice here (the assert
|
||||
// + length-checked indexing) before the loop's next `next_au`, and `conn` outlives
|
||||
// it — so the pointer is live, exactly `len` bytes, read-only, single-threaded (no
|
||||
// aliasing/use-after-free).
|
||||
let data = unsafe { std::slice::from_raw_parts(frame.data, frame.len) };
|
||||
let idx = u32::from_le_bytes(data[0..4].try_into().unwrap());
|
||||
assert_eq!(
|
||||
@@ -3359,6 +3457,11 @@ mod tests {
|
||||
// Session 1: TOFU (no pin) — observe the host fingerprint.
|
||||
let addr = std::ffi::CString::new("127.0.0.1").unwrap();
|
||||
let mut observed = [0u8; 32];
|
||||
// SAFETY: `addr` is a live `CString` ("127.0.0.1") whose `as_ptr()` is the NUL-terminated
|
||||
// UTF-8 host string the contract requires; `pin_sha256`/cert/key are NULL (all permitted), and
|
||||
// `observed.as_mut_ptr()` is the local `[u8; 32]` — exactly the 32 writable bytes the contract
|
||||
// demands, not aliased during the call. Every pointer references a live local that outlives the
|
||||
// blocking connect.
|
||||
let conn = unsafe {
|
||||
punktfunk_connect(
|
||||
addr.as_ptr(),
|
||||
@@ -3377,26 +3480,28 @@ mod tests {
|
||||
assert_ne!(observed, [0u8; 32], "fingerprint not reported");
|
||||
|
||||
let (mut w, mut h, mut hz) = (0u32, 0u32, 0u32);
|
||||
assert_eq!(
|
||||
unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) },
|
||||
PunktfunkStatus::Ok
|
||||
);
|
||||
// SAFETY: `conn` is the live, non-null connection handle just asserted above; `&mut w/h/hz` are
|
||||
// exclusive, writable borrows of local `u32`s that outlive this synchronous call — the three
|
||||
// writable out-params the contract names.
|
||||
let st = unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) };
|
||||
assert_eq!(st, PunktfunkStatus::Ok);
|
||||
assert_eq!((w, h, hz), (1280, 720, 60));
|
||||
|
||||
// Mid-stream renegotiation: request a new mode, the host acks on the control
|
||||
// stream, and punktfunk_connection_mode reflects the switch.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144)
|
||||
},
|
||||
PunktfunkStatus::Ok
|
||||
);
|
||||
// SAFETY: `conn` is the live, non-null connection handle (the only pointer arg); the remaining
|
||||
// arguments are by-value integers. The handle outlives this non-blocking enqueue.
|
||||
let st = unsafe {
|
||||
punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144)
|
||||
};
|
||||
assert_eq!(st, PunktfunkStatus::Ok);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
assert_eq!(
|
||||
unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) },
|
||||
PunktfunkStatus::Ok
|
||||
);
|
||||
// SAFETY: same as the earlier `punktfunk_connection_mode` call — `conn` is the live handle
|
||||
// and `&mut w/h/hz` are exclusive writable borrows of locals that outlive this synchronous
|
||||
// call.
|
||||
let st = unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) };
|
||||
assert_eq!(st, PunktfunkStatus::Ok);
|
||||
if (w, h, hz) == (1920, 1080, 144) {
|
||||
break;
|
||||
}
|
||||
@@ -3407,6 +3512,8 @@ mod tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
|
||||
// SAFETY: `pull_verified` requires a live connection handle it alone pulls video from; `conn` is
|
||||
// the open, non-null handle from `punktfunk_connect` and this is the only thread touching it.
|
||||
unsafe { pull_verified(conn, 25) };
|
||||
|
||||
let ev = punktfunk_core::input::InputEvent {
|
||||
@@ -3417,13 +3524,19 @@ mod tests {
|
||||
y: 2,
|
||||
flags: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { punktfunk_connection_send_input(conn, &ev) },
|
||||
PunktfunkStatus::Ok
|
||||
);
|
||||
// SAFETY: `conn` is the live handle; `&ev` borrows the local `InputEvent`, valid and immutable
|
||||
// for this synchronous enqueue — the contract's "valid InputEvent" pointer.
|
||||
let st = unsafe { punktfunk_connection_send_input(conn, &ev) };
|
||||
assert_eq!(st, PunktfunkStatus::Ok);
|
||||
// SAFETY: `conn` was returned by `punktfunk_connect` and is never used after this call (session
|
||||
// 2 below uses a fresh `conn2`); `close` takes ownership and frees the handle exactly once.
|
||||
unsafe { punktfunk_connection_close(conn) };
|
||||
|
||||
// Session 2 (same host process — the listener survived): pin the fingerprint.
|
||||
// SAFETY: as for session 1 — `addr` is the live NUL-terminated host string; here
|
||||
// `observed.as_ptr()` is the 32-byte pin (the fingerprint captured above, a valid `[u8; 32]`),
|
||||
// `observed_sha256_out` is NULL and cert/key are NULL. All pointers reference live locals for
|
||||
// the duration of the blocking connect.
|
||||
let conn2 = unsafe {
|
||||
punktfunk_connect(
|
||||
addr.as_ptr(),
|
||||
@@ -3439,11 +3552,17 @@ mod tests {
|
||||
)
|
||||
};
|
||||
assert!(!conn2.is_null(), "pinned reconnect failed");
|
||||
// SAFETY: `conn2` is the live, non-null pinned handle, pulled only from this thread —
|
||||
// `pull_verified`'s requirement.
|
||||
unsafe { pull_verified(conn2, 25) };
|
||||
// SAFETY: `conn2` came from `punktfunk_connect` and is not used after this; `close` frees it once.
|
||||
unsafe { punktfunk_connection_close(conn2) };
|
||||
|
||||
// Session 3: a wrong pin must be rejected by the handshake.
|
||||
let bad = [0xAAu8; 32];
|
||||
// SAFETY: same shape as the prior connects — `addr` is the live host string, `bad.as_ptr()` is
|
||||
// the 32-byte `[0xAA; 32]` pin, and out/cert/key are NULL; all reference live locals across the
|
||||
// blocking call. (The handshake is expected to fail and return NULL here, which is sound.)
|
||||
let conn3 = unsafe {
|
||||
punktfunk_connect(
|
||||
addr.as_ptr(),
|
||||
@@ -3463,6 +3582,8 @@ mod tests {
|
||||
// The host saw the rejected handshake attempt as session 3? No — a TLS-failed
|
||||
// handshake never yields a connection, so accept() is still waiting. Connect once
|
||||
// more (TOFU) to complete the host's third session and let it exit.
|
||||
// SAFETY: same as session 1's connect — `addr` is the live host string, pin/out/cert/key all
|
||||
// NULL; the pointers reference live locals for the duration of the blocking connect.
|
||||
let conn4 = unsafe {
|
||||
punktfunk_connect(
|
||||
addr.as_ptr(),
|
||||
@@ -3478,7 +3599,9 @@ mod tests {
|
||||
)
|
||||
};
|
||||
assert!(!conn4.is_null());
|
||||
// SAFETY: `conn4` is the live, non-null handle, pulled only from this thread.
|
||||
unsafe { pull_verified(conn4, 25) };
|
||||
// SAFETY: `conn4` came from `punktfunk_connect` and is unused after this; `close` frees it once.
|
||||
unsafe { punktfunk_connection_close(conn4) };
|
||||
|
||||
host.join().unwrap().unwrap();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `SessionPlan` — the per-session capture / topology / encoder decision, resolved **once** from
|
||||
//! [`HostConfig`](crate::config) (+ the handshake-negotiated bit depth) into a typed, logged value.
|
||||
//!
|
||||
//! **Goal-1 stage 3** (`docs/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
|
||||
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
|
||||
//! process topology in `punktfunk1::should_use_helper`, and the encode backend in
|
||||
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
|
||||
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
|
||||
//! resolves them together, once, so the deployed path reads one typed artifact.
|
||||
//!
|
||||
//! Stage 3 routes the **capture** and **topology** decisions through the plan (see
|
||||
//! `capture::capture_virtual_output` taking [`CaptureBackend`] in, and `virtual_stream` reading
|
||||
//! [`SessionTopology`]). The **encoder** is resolved by `encode::windows_resolved_backend` (config-backed
|
||||
//! and GPU-vendor cached since stage 2, so already a single source) and *recorded* here as
|
||||
//! [`EncoderBackend`]. Threading `encoder`/`input_format` into the encoder + capturer opens — which
|
||||
//! removes the `capture → encode::windows_resolved_backend()` back-reference recomputed in `dxgi.rs` —
|
||||
//! is **stage 5**.
|
||||
//!
|
||||
//! The type is platform-neutral so it threads through the shared `virtual_stream`/`build_pipeline`
|
||||
//! signatures; on Linux it resolves to the single portal/single-process path (the 3-way dispatch is a
|
||||
//! Windows-only concern).
|
||||
|
||||
/// Where a session's frames come from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CaptureBackend {
|
||||
/// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path).
|
||||
Portal,
|
||||
/// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring
|
||||
/// (in-process, Session 0; no Desktop Duplication, no WGC helper).
|
||||
IddPush,
|
||||
/// Windows: DXGI Desktop Duplication (`PUNKTFUNK_CAPTURE=dda|dxgi` or `PUNKTFUNK_NO_WGC`).
|
||||
Dda,
|
||||
/// Windows: Windows.Graphics.Capture (the composed-desktop default), with a DDA watchdog fallback.
|
||||
Wgc,
|
||||
}
|
||||
|
||||
impl CaptureBackend {
|
||||
/// Resolve the capture backend from [`config`](crate::config). This is the single resolver shared by
|
||||
/// [`SessionPlan::resolve`] and the standalone callers (GameStream / spike), so they can't drift.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn resolve() -> Self {
|
||||
CaptureBackend::Portal
|
||||
}
|
||||
|
||||
/// Windows precedence (identical to the pre-stage-3 `capture_virtual_output` branch order):
|
||||
/// IDD-push wins; else an explicit `dda`/`dxgi` request or `PUNKTFUNK_NO_WGC` selects DDA; else WGC.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn resolve() -> Self {
|
||||
let cfg = crate::config::config();
|
||||
if cfg.idd_push {
|
||||
CaptureBackend::IddPush
|
||||
} else if matches!(cfg.capture_backend.as_str(), "dda" | "dxgi")
|
||||
|| crate::capture::wgc_disabled()
|
||||
{
|
||||
CaptureBackend::Dda
|
||||
} else {
|
||||
CaptureBackend::Wgc
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub fn resolve() -> Self {
|
||||
CaptureBackend::Portal
|
||||
}
|
||||
}
|
||||
|
||||
/// How a session is structured across processes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SessionTopology {
|
||||
/// One process captures + encodes (Linux; Windows non-SYSTEM / IDD-push / `NO_WGC`).
|
||||
SingleProcess,
|
||||
/// SYSTEM host + a user-session WGC helper relay (the Windows normal-desktop path under SYSTEM,
|
||||
/// where in-process WGC can't activate). See `virtual_stream_relay`.
|
||||
TwoProcessRelay,
|
||||
}
|
||||
|
||||
/// The resolved encode backend (recorded for logging / stages 4–5; the per-session encoder open still
|
||||
/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EncoderBackend {
|
||||
/// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here).
|
||||
PlatformAuto,
|
||||
Nvenc,
|
||||
Amf,
|
||||
Qsv,
|
||||
Software,
|
||||
}
|
||||
|
||||
impl EncoderBackend {
|
||||
/// True if this backend encodes on the GPU (so the capturer should produce GPU-resident frames). Only
|
||||
/// the software encoder takes CPU staging; `PlatformAuto` (Linux NVENC/VAAPI) is always GPU.
|
||||
pub fn is_gpu(self) -> bool {
|
||||
!matches!(self, EncoderBackend::Software)
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain
|
||||
/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SessionPlan {
|
||||
pub capture: CaptureBackend,
|
||||
pub topology: SessionTopology,
|
||||
pub encoder: EncoderBackend,
|
||||
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
|
||||
pub bit_depth: u8,
|
||||
/// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before.
|
||||
/// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
impl SessionPlan {
|
||||
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`.
|
||||
pub fn resolve(bit_depth: u8) -> Self {
|
||||
SessionPlan {
|
||||
capture: CaptureBackend::resolve(),
|
||||
topology: resolve_topology(),
|
||||
encoder: resolve_encoder(),
|
||||
bit_depth,
|
||||
hdr: bit_depth >= 10,
|
||||
}
|
||||
}
|
||||
|
||||
/// The capturer's target output format (Goal-1 stage 5): `gpu` from the already-resolved `encoder`
|
||||
/// (no second backend probe), `hdr` from the plan. Handed into `capture::capture_virtual_output` so the
|
||||
/// capturer never re-derives the encode backend.
|
||||
pub fn output_format(&self) -> crate::capture::OutputFormat {
|
||||
crate::capture::OutputFormat {
|
||||
gpu: self.encoder.is_gpu(),
|
||||
hdr: self.hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on
|
||||
/// every other platform the session is always single-process.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_topology() -> SessionTopology {
|
||||
let cfg = crate::config::config();
|
||||
// `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper);
|
||||
// otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there).
|
||||
let helper = if cfg.no_helper || crate::capture::wgc_disabled() || cfg.idd_push {
|
||||
false
|
||||
} else {
|
||||
cfg.force_helper || crate::capture::wgc_relay::running_as_system()
|
||||
};
|
||||
if helper {
|
||||
SessionTopology::TwoProcessRelay
|
||||
} else {
|
||||
SessionTopology::SingleProcess
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_topology() -> SessionTopology {
|
||||
SessionTopology::SingleProcess
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_encoder() -> EncoderBackend {
|
||||
match crate::encode::windows_resolved_backend() {
|
||||
crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc,
|
||||
crate::encode::WindowsBackend::Amf => EncoderBackend::Amf,
|
||||
crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv,
|
||||
crate::encode::WindowsBackend::Software => EncoderBackend::Software,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn resolve_encoder() -> EncoderBackend {
|
||||
EncoderBackend::PlatformAuto
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
//! state) auto-revert at thread exit (= session end); the process-wide bits revert at process exit.
|
||||
//! See `docs/host-latency-plan.md` Tier 3A.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod imp {
|
||||
#![allow(non_snake_case)]
|
||||
@@ -49,6 +52,10 @@ mod imp {
|
||||
/// Process-wide tuning, applied exactly once. Reverts at process exit. Best-effort: each call is
|
||||
/// independent and a failure is ignored (e.g. a non-elevated host may not get HIGH class).
|
||||
fn tune_process_once() {
|
||||
// SAFETY: each call is a C-ABI FFI into winmm/kernel32/dwmapi declared with a matching
|
||||
// `extern "system"` signature; every argument is a plain integer (no pointers/buffers escape),
|
||||
// and `GetCurrentProcess()` returns the current-process pseudo-handle (a constant, always valid,
|
||||
// never closed). The body runs inside `get_or_init`, so it executes exactly once per process.
|
||||
PROCESS_TUNED.get_or_init(|| unsafe {
|
||||
// 1 ms timer granularity (default ~15.6 ms) — the floor for precise frame pacing and the
|
||||
// encode|send split's sub-ms sleeps.
|
||||
@@ -70,6 +77,11 @@ mod imp {
|
||||
/// thread exits, so a session that ends tears them down without explicit bookkeeping.
|
||||
pub fn on_hot_thread() {
|
||||
tune_process_once();
|
||||
// SAFETY: C-ABI FFI declared with matching `extern "system"` signatures. SetThreadExecutionState
|
||||
// takes only flag bits. `task` is a local NUL-terminated UTF-16 buffer ("Games\0") alive for the
|
||||
// whole block, so `task.as_ptr()` is a valid LPCWSTR for the call, and `&mut idx` is a live local
|
||||
// u32 the call writes the task index into. The returned MMCSS handle is intentionally leaked (the
|
||||
// OS reverts the characteristics at thread exit), so there is nothing to free or double-free.
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
|
||||
let task: Vec<u16> = "Games\0".encode_utf16().collect();
|
||||
|
||||
@@ -76,7 +76,12 @@ pub fn run(opts: Options) -> Result<()> {
|
||||
refresh_hz: opts.fps,
|
||||
})
|
||||
.context("create virtual output")?;
|
||||
capture::capture_virtual_output(vout, false).context("capture virtual output")?
|
||||
capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
|
||||
//! consumes the node via [`crate::capture::capture_virtual_output`].
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::Result;
|
||||
pub use punktfunk_core::Mode;
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -225,6 +228,8 @@ pub fn compositor_for_kind(kind: ActiveKind) -> Option<Compositor> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn default_runtime_dir() -> String {
|
||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no
|
||||
// memory — it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
format!("/run/user/{uid}")
|
||||
})
|
||||
@@ -245,6 +250,8 @@ fn default_bus(runtime: &str) -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn detect_active_session() -> ActiveSession {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory —
|
||||
// it just returns the calling process's real uid. Nothing is aliased or freed.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
let xdg_runtime_dir = default_runtime_dir();
|
||||
let dbus = default_bus(&xdg_runtime_dir);
|
||||
@@ -479,7 +486,7 @@ pub fn apply_input_env(_chosen: Compositor) {}
|
||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||
pub fn detect() -> Result<Compositor> {
|
||||
if let Ok(v) = std::env::var("PUNKTFUNK_COMPOSITOR") {
|
||||
if let Some(v) = crate::config::config().compositor.as_deref() {
|
||||
return match v.trim().to_ascii_lowercase().as_str() {
|
||||
"kwin" | "kde" | "plasma" => Ok(Compositor::Kwin),
|
||||
"wlroots" | "sway" | "hyprland" | "wlr" => Ok(Compositor::Wlroots),
|
||||
@@ -529,15 +536,15 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Two virtual-display backends: the new pf-vdisplay IddCx driver (pf_vdisplay_proto) and the
|
||||
// shipping SudoVDA fallback. The compositor arg is moot on Windows. PUNKTFUNK_VDISPLAY overrides;
|
||||
// default auto-detects (prefer pf-vdisplay if its driver interface is present).
|
||||
// The pf-vdisplay all-Rust IddCx driver is the sole virtual-display backend (the legacy SudoVDA
|
||||
// fallback was removed — its driver is no longer shipped). The compositor arg is moot on Windows.
|
||||
let _ = compositor;
|
||||
if windows_use_pf_vdisplay() {
|
||||
Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
|
||||
} else {
|
||||
Ok(Box::new(sudovda::SudoVdaDisplay::new()?))
|
||||
}
|
||||
anyhow::ensure!(
|
||||
pf_vdisplay::is_available(),
|
||||
"pf-vdisplay driver interface not found — the pf-vdisplay IddCx driver is not installed or \
|
||||
not loaded (the host installer bundles it; reinstall or check the driver state)"
|
||||
);
|
||||
Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
@@ -546,22 +553,6 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the Windows virtual-display backend. `PUNKTFUNK_VDISPLAY=pf|pf-vdisplay|pfvd` forces the new
|
||||
/// pf-vdisplay IddCx driver; `=sudovda|sudo` forces the shipping SudoVDA driver; anything else (the
|
||||
/// default) auto-detects, preferring pf-vdisplay if its device interface is enumerable.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_use_pf_vdisplay() -> bool {
|
||||
match std::env::var("PUNKTFUNK_VDISPLAY")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
{
|
||||
Some("pf") | Some("pf-vdisplay") | Some("pfvd") => true,
|
||||
Some("sudovda") | Some("sudo") => false,
|
||||
_ => pf_vdisplay::is_available(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe for `compositor`: is it up and able to create a virtual output *right
|
||||
/// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate
|
||||
/// on actual readiness instead of racing the compositor with a blind sleep.
|
||||
@@ -582,11 +573,7 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = compositor;
|
||||
if windows_use_pf_vdisplay() {
|
||||
pf_vdisplay::probe()
|
||||
} else {
|
||||
sudovda::probe()
|
||||
}
|
||||
pf_vdisplay::probe()
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
@@ -627,17 +614,25 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
std::sync::Arc::new(())
|
||||
}
|
||||
|
||||
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
|
||||
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/gamescope.rs"]
|
||||
mod gamescope;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin.rs"]
|
||||
mod kwin;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
pub(crate) mod manager;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/mutter.rs"]
|
||||
mod mutter;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/pf_vdisplay.rs"]
|
||||
pub(crate) mod pf_vdisplay;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) mod sudovda;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/wlroots.rs"]
|
||||
mod wlroots;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+7
@@ -15,6 +15,8 @@
|
||||
//! the KWin session's environment.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -495,6 +497,11 @@ fn run(
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
// SAFETY: `&mut pfd` points at a single live, fully-initialized `libc::pollfd` on the stack, and
|
||||
// the count `1` matches that one-element array, so `poll` reads `fd`/`events` and writes `revents`
|
||||
// strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because `conn` (and the
|
||||
// `prepare_read` guard) are alive across the call. `poll` blocks up to 200 ms and writes only
|
||||
// `revents`; `pfd` outlives the synchronous call and aliases nothing (a fresh local).
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, 200) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
@@ -1,724 +0,0 @@
|
||||
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
|
||||
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
|
||||
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
|
||||
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
|
||||
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
|
||||
//!
|
||||
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
|
||||
//! the wire contract OWNED by [`pf_vdisplay_proto::control`] (versioned + `#[repr(C)] Pod` structs,
|
||||
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`.
|
||||
//!
|
||||
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
|
||||
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
|
||||
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
|
||||
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
|
||||
//! request/reply structs, the version handshake) differ, per `pf_vdisplay_proto`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
|
||||
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
|
||||
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
use windows::Win32::System::IO::DeviceIoControl;
|
||||
|
||||
use pf_vdisplay_proto::control;
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
// Backend-NEUTRAL CCD/DXGI helpers reused from the SudoVDA backend (a pf-vdisplay monitor's target_id
|
||||
// is a real OS target id, so these operate identically). The shared MON_GEN/CURRENT_MON_GEN generation
|
||||
// counter is reused too, so the IDD-push stale-ring bail works regardless of which backend is active.
|
||||
use super::sudovda::{
|
||||
isolate_displays_ccd, resolve_gdi_name, resolve_render_adapter_luid, restore_displays_ccd,
|
||||
set_active_mode, SavedConfig, CURRENT_MON_GEN, MON_GEN,
|
||||
};
|
||||
|
||||
// pf-vdisplay device-interface GUID (pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
|
||||
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
|
||||
// any accidental coexistence with a real SudoVDA install.
|
||||
const PF_VDISPLAY_INTERFACE: GUID =
|
||||
GUID::from_u128(pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
|
||||
|
||||
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
|
||||
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
|
||||
fn idd_push_mode() -> bool {
|
||||
std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some()
|
||||
}
|
||||
|
||||
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
|
||||
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
|
||||
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
|
||||
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
fn next_session_id() -> u64 {
|
||||
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
|
||||
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
let mut returned = 0u32;
|
||||
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
|
||||
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
|
||||
DeviceIoControl(
|
||||
h,
|
||||
code,
|
||||
inp,
|
||||
input.len() as u32,
|
||||
outp,
|
||||
output.len() as u32,
|
||||
Some(&mut returned),
|
||||
None,
|
||||
)
|
||||
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
|
||||
Ok(returned)
|
||||
}
|
||||
|
||||
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
|
||||
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
|
||||
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
|
||||
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
|
||||
///
|
||||
/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4
|
||||
/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the
|
||||
/// SudoVDA backend tolerated the driver IGNORING the pin.
|
||||
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
||||
let req = control::SetRenderAdapterRequest {
|
||||
luid_low: luid.LowPart,
|
||||
luid_high: luid.HighPart,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
ioctl(
|
||||
h,
|
||||
control::IOCTL_SET_RENDER_ADAPTER,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
.map(|_| ())
|
||||
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
||||
}
|
||||
|
||||
unsafe fn open_device() -> Result<HANDLE> {
|
||||
let hdev = SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?;
|
||||
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata)
|
||||
.context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?;
|
||||
|
||||
let mut required = 0u32;
|
||||
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
|
||||
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?;
|
||||
|
||||
let handle = CreateFileW(
|
||||
PCWSTR((*detail).DevicePath.as_ptr()),
|
||||
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(pf-vdisplay device)")?;
|
||||
let _ = SetupDiDestroyDeviceInfoList(hdev);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
// ── Host-level reference-counted pf-vdisplay monitor lifecycle ───────────────────────────────────
|
||||
//
|
||||
// The virtual monitor is created on the first session and REUSED across sessions. When the last
|
||||
// session disconnects the monitor LINGERS for a grace window (PUNKTFUNK_MONITOR_LINGER_MS, default
|
||||
// 10 s): a reconnect within the window reuses it instantly (no new screen, no PnP connect/disconnect
|
||||
// chime, no teardown/recreate kernel churn); after the window a background timer REMOVEs it so a
|
||||
// physical-screen user gets their screen back. Overlapping sessions share one monitor via the
|
||||
// refcount (teardown only at refs==0 + expired grace), so a stale session can never REMOVE a live
|
||||
// session's monitor. The control-device HANDLE is opened once and kept for the host lifetime — it's a
|
||||
// handle, not a screen, so it creates no phantom display.
|
||||
|
||||
/// The resources backing one live pf-vdisplay monitor (owned by [`MGR`], not by any session).
|
||||
struct Monitor {
|
||||
/// Per-session key for `IOCTL_ADD`/`IOCTL_REMOVE` (the proto keys monitors by a plain `u64`).
|
||||
session_id: u64,
|
||||
target_id: u32,
|
||||
luid: LUID,
|
||||
gdi_name: Option<String>,
|
||||
mode: Mode,
|
||||
stop: Arc<AtomicBool>,
|
||||
pinger: Option<JoinHandle<()>>,
|
||||
ccd_saved: Option<SavedConfig>,
|
||||
/// Generation stamp (shared [`MON_GEN`]); a [`MonitorLease`] only releases if its gen still matches.
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
enum MgrState {
|
||||
Idle,
|
||||
Active { mon: Monitor, refs: u32 },
|
||||
Lingering { mon: Monitor, until: Instant },
|
||||
}
|
||||
|
||||
struct Mgr {
|
||||
/// Control-device handle (raw isize; `HANDLE` isn't `Send`). Opened once, kept for the host life.
|
||||
device: Option<isize>,
|
||||
watchdog_s: u32,
|
||||
state: MgrState,
|
||||
}
|
||||
|
||||
static MGR: Mutex<Mgr> = Mutex::new(Mgr {
|
||||
device: None,
|
||||
watchdog_s: 10,
|
||||
state: MgrState::Idle,
|
||||
});
|
||||
|
||||
/// The Windows pf-vdisplay backend. A marker — the monitor lifecycle lives in the global [`MGR`].
|
||||
pub struct PfVdisplayDisplay;
|
||||
|
||||
impl PfVdisplayDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
// Open the control device once (validates the driver is present + version-matches) + log the
|
||||
// watchdog timeout.
|
||||
let mut g = MGR.lock().unwrap();
|
||||
mgr_ensure_device(&mut g)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PfVdisplayDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Nothing: the control device + monitor lifecycle are host-level (owned by MGR) and
|
||||
// deliberately outlive any single session so a reconnect can reuse the monitor.
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for PfVdisplayDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Delegate to the host-level manager: create the monitor, reuse a lingering one on reconnect,
|
||||
// or join the live one — and hand back a lease whose Drop releases the refcount.
|
||||
mgr_acquire(mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh pf-vdisplay monitor at `mode` on the (host-level) control `device`. ADD the target,
|
||||
/// start the watchdog ping, resolve the GDI name, force the client mode + (default) isolate to a sole
|
||||
/// composited display. Returns the [`Monitor`] resources; the manager tracks its lifecycle
|
||||
/// (refcount + linger).
|
||||
unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result<Monitor> {
|
||||
let dev = HANDLE(device as *mut c_void);
|
||||
{
|
||||
// Fresh session id per created monitor (the manager refcount, not the id, prevents the
|
||||
// cross-session REMOVE collision).
|
||||
let session_id = next_session_id();
|
||||
let add = control::AddRequest {
|
||||
session_id,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_hz: mode.refresh_hz,
|
||||
_reserved: 0,
|
||||
};
|
||||
// SET_RENDER_ADAPTER is OPT-IN. By default we do NOT pin the render adapter — let the IDD use
|
||||
// its natural adapter (Apollo-parity; avoids the cross-GPU mismatch ACCESS_LOST storm). Opt in
|
||||
// with PUNKTFUNK_RENDER_ADAPTER=<name substring> or the IDD-push path (which MUST run NVENC on
|
||||
// the discrete render GPU it pins here). NOTE: the pf-vdisplay driver currently returns
|
||||
// STATUS_NOT_IMPLEMENTED for this IOCTL (a STEP-4 stub), so the call below is tolerated to fail.
|
||||
let pinned = if std::env::var("PUNKTFUNK_RENDER_ADAPTER").is_ok() {
|
||||
unsafe { resolve_render_adapter_luid() }
|
||||
} else if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() {
|
||||
// P2 direct frame push: the host opens the driver's shared textures AND runs NVENC on the
|
||||
// RENDER adapter, so on a hybrid box (dGPU + iGPU) it MUST be the discrete encoder GPU — an
|
||||
// iGPU-rendered surface is untouchable by NVENC. pf-vdisplay HONORS SET_RENDER_ADAPTER (once
|
||||
// implemented), so pin the discrete GPU; the driver also reports the resulting render LUID in
|
||||
// the shared header, so the host binds correctly even if this is overridden.
|
||||
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
|
||||
unsafe { resolve_render_adapter_luid() }
|
||||
} else {
|
||||
tracing::info!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER skipped (no render pin — avoids cross-GPU mismatch; \
|
||||
set PUNKTFUNK_RENDER_ADAPTER=<name> to force a specific render GPU)"
|
||||
);
|
||||
None
|
||||
};
|
||||
if let Some(luid) = pinned {
|
||||
match unsafe { set_render_adapter(dev, luid) } {
|
||||
Ok(()) => tracing::info!(
|
||||
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
|
||||
),
|
||||
// The driver currently stubs this IOCTL (STATUS_NOT_IMPLEMENTED) — warn + continue, do
|
||||
// NOT propagate. The natural-adapter path still works (Apollo-parity).
|
||||
Err(e) => tracing::warn!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER failed (driver stub / not implemented — \
|
||||
continuing): {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = [0u8; size_of::<control::AddReply>()];
|
||||
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"pf-vdisplay ADD {}x{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
)
|
||||
})?;
|
||||
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed
|
||||
// 4-byte alignment, and `from_bytes` PANICS on an alignment mismatch. This copies the bytes
|
||||
// into a properly-aligned `AddReply` value.
|
||||
let reply: control::AddReply =
|
||||
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
|
||||
let luid = LUID {
|
||||
LowPart: reply.adapter_luid_low,
|
||||
HighPart: reply.adapter_luid_high,
|
||||
};
|
||||
tracing::info!(
|
||||
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
reply.target_id,
|
||||
luid.LowPart
|
||||
);
|
||||
if let Some(pin) = pinned {
|
||||
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
|
||||
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let device_raw = device;
|
||||
let interval = Duration::from_millis(watchdog_s as u64 * 1000 / 3);
|
||||
let stop_t = stop.clone();
|
||||
let pinger = thread::spawn(move || {
|
||||
let h = HANDLE(device_raw as *mut c_void);
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
let mut none: [u8; 0] = [];
|
||||
match unsafe { ioctl(h, control::IOCTL_PING, &[], &mut none) } {
|
||||
Ok(_) => warned = false,
|
||||
// A persistently failing PING means the cached control handle went invalid — the
|
||||
// driver watchdog will then tear the monitor down mid-session. Surface it once.
|
||||
Err(e) => {
|
||||
if !warned {
|
||||
tracing::warn!(
|
||||
"pf-vdisplay keepalive PING failed (control handle lost?): {e:#}"
|
||||
);
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not activated
|
||||
// into a WDDM path); the Windows capture backend will re-resolve once a GPU is present.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
if let Some(n) = unsafe { resolve_gdi_name(reply.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!("pf-vdisplay target {} -> {n}", reply.target_id);
|
||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||
set_active_mode(n, mode);
|
||||
// Make the pf-vdisplay the SOLE active display (default). An EXTENDED (non-primary) IDD
|
||||
// is NOT DWM-composited → Desktop Duplication gets a born-lost ACCESS_LOST; deactivating
|
||||
// the other display(s) FIRST (CCD, atomic) leaves the virtual output as the sole →
|
||||
// primary → composited desktop, so all content (incl. Winlogon) renders to it without a
|
||||
// MODE_CHANGE_IN_PROGRESS storm. Opt out with PUNKTFUNK_NO_ISOLATE=1 (a box with a real
|
||||
// second monitor to keep live).
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
ccd_saved = unsafe { isolate_displays_ccd(reply.target_id) };
|
||||
} else {
|
||||
tracing::info!(
|
||||
"display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"pf-vdisplay target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
reply.target_id
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Monitor {
|
||||
session_id,
|
||||
target_id: reply.target_id,
|
||||
luid,
|
||||
gdi_name,
|
||||
mode,
|
||||
stop,
|
||||
pinger: Some(pinger),
|
||||
ccd_saved,
|
||||
gen: MON_GEN.fetch_add(1, Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// The capture target handed to a session (`None` until the GDI name resolves).
|
||||
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
|
||||
self.gdi_name
|
||||
.clone()
|
||||
.map(|n| crate::capture::dxgi::WinCaptureTarget {
|
||||
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
||||
gdi_name: n,
|
||||
// target_id is stable across secure-desktop topology rebuilds; the GDI name is NOT,
|
||||
// so capture re-resolves the name from this on every recovery.
|
||||
target_id: self.target_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor (by session
|
||||
/// id). `device` is the host-level control handle. Consumes the monitor.
|
||||
unsafe fn teardown(mut self, device: isize) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = self.pinger.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||
if let Some(saved) = &self.ccd_saved {
|
||||
restore_displays_ccd(saved);
|
||||
}
|
||||
let req = control::RemoveRequest {
|
||||
session_id: self.session_id,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
let h = HANDLE(device as *mut c_void);
|
||||
if let Err(e) = ioctl(
|
||||
h,
|
||||
control::IOCTL_REMOVE,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
) {
|
||||
tracing::warn!("pf-vdisplay REMOVE failed: {e:#}");
|
||||
} else {
|
||||
tracing::info!("pf-vdisplay monitor removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the control device once + version/watchdog handshake; cache the handle (raw isize) in `g`.
|
||||
fn mgr_ensure_device(g: &mut Mgr) -> Result<isize> {
|
||||
if let Some(d) = g.device {
|
||||
return Ok(d);
|
||||
}
|
||||
let device = unsafe { open_device()? };
|
||||
// Single version+watchdog handshake. The proto intends a HARD protocol-version check (unlike
|
||||
// SudoVDA's best-effort log) — a mismatched host/driver pair fails loudly here rather than
|
||||
// corrupting the IOCTL stream.
|
||||
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
|
||||
unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) }
|
||||
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
||||
// `pod_read_unaligned` (see the AddReply note): copies out of the unaligned stack buffer.
|
||||
let info: control::InfoReply =
|
||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||
if info.protocol_version != pf_vdisplay_proto::PROTOCOL_VERSION {
|
||||
// Close the handle before bailing so a retry re-opens cleanly.
|
||||
unsafe {
|
||||
let _ = CloseHandle(device);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
||||
host + driver",
|
||||
pf_vdisplay_proto::PROTOCOL_VERSION,
|
||||
info.protocol_version
|
||||
);
|
||||
}
|
||||
g.watchdog_s = info.watchdog_timeout_s.max(1);
|
||||
tracing::info!(
|
||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||
info.protocol_version,
|
||||
g.watchdog_s
|
||||
);
|
||||
// Reap monitors orphaned by a crashed/killed previous host instance before we create ours. This is
|
||||
// a FIRST-CLASS op on pf-vdisplay (the driver returns SUCCESS), NOT a "send-and-hope" hack: without
|
||||
// it an orphan lingers until the driver watchdog fires — but a still-pinging new session keeps
|
||||
// resetting that watchdog, so orphans could accumulate.
|
||||
{
|
||||
let mut none: [u8; 0] = [];
|
||||
if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
|
||||
tracing::info!("cleared orphaned virtual monitors on host startup");
|
||||
} else {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
}
|
||||
let raw = device.0 as isize;
|
||||
g.device = Some(raw);
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down. A reconnect within it reuses the
|
||||
/// monitor (no new screen / PnP chime); after it the monitor is REMOVEd so a physical screen returns.
|
||||
fn linger_ms() -> u64 {
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
|
||||
/// Acquire the shared monitor for a new session: join the live one (refcount++), reuse a lingering
|
||||
/// one (reconfiguring if the client mode changed), or create one. The returned [`MonitorLease`]
|
||||
/// releases the refcount on drop.
|
||||
fn mgr_acquire(mode: Mode) -> Result<VirtualOutput> {
|
||||
ensure_linger_timer();
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let device = mgr_ensure_device(&mut g)?;
|
||||
let watchdog_s = g.watchdog_s;
|
||||
|
||||
// IDD-push: a new connection while a monitor is live = a single-client RECONNECT (the prior client
|
||||
// is gone — IDD-push is one display, no concurrency). A REUSED IddCx monitor's swap-chain is DEAD,
|
||||
// so joining it would hand the new client a black screen until the old session times out. PREEMPT:
|
||||
// tear the old monitor down (its teardown restores topology + IOCTL_REMOVEs) and fall through to
|
||||
// create a FRESH one. The old session's lease is gen-stamped, so its later drop is ignored
|
||||
// (mgr_release no-op) and can't tear down the new monitor.
|
||||
if idd_push_mode()
|
||||
&& matches!(
|
||||
g.state,
|
||||
MgrState::Active { .. } | MgrState::Lingering { .. }
|
||||
)
|
||||
{
|
||||
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut g.state, MgrState::Idle)
|
||||
{
|
||||
tracing::info!(
|
||||
old_target = mon.target_id,
|
||||
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
|
||||
);
|
||||
// teardown() — NOT drop() — sends IOCTL_REMOVE (and restores topology). `Monitor` has NO
|
||||
// `Drop` impl, so a bare `drop(mon)` would orphan the IddCx monitor in the driver (never
|
||||
// departed → leaks a live D3D device + a stuck swap-chain processor thread per reconnect).
|
||||
unsafe { mon.teardown(device) };
|
||||
// Let the OS finish the ASYNC IddCx monitor departure before the next ADD. A back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
|
||||
// A live monitor already exists — join it (refcount++). This covers a concurrent session AND the
|
||||
// build-then-drop overlap of a mid-stream Reconfigure / secure-return (the new lease is taken while
|
||||
// the old is still held). If the requested mode differs, reconfigure the shared monitor to it so a
|
||||
// Reconfigure actually applies (one shared monitor → sessions necessarily share a mode).
|
||||
if let MgrState::Active { mon, refs } = &mut g.state {
|
||||
*refs += 1;
|
||||
let changed = mon.mode.width != mode.width
|
||||
|| mon.mode.height != mode.height
|
||||
|| mon.mode.refresh_hz != mode.refresh_hz;
|
||||
if changed {
|
||||
unsafe { mgr_reconfigure(mon, mode) };
|
||||
}
|
||||
tracing::info!(
|
||||
refs = *refs,
|
||||
"pf-vdisplay monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
|
||||
let target = mon.target();
|
||||
let gen = mon.gen;
|
||||
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
|
||||
return Ok(VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: pm,
|
||||
win_capture: target,
|
||||
keepalive: Box::new(MonitorLease { gen }),
|
||||
});
|
||||
}
|
||||
|
||||
// Idle or Lingering: repurpose/create a monitor → Active{refs:1}.
|
||||
let mon = match std::mem::replace(&mut g.state, MgrState::Idle) {
|
||||
MgrState::Lingering { mut mon, .. } => {
|
||||
tracing::info!("pf-vdisplay monitor reused (reconnect within the linger window)");
|
||||
let changed = mon.mode.width != mode.width
|
||||
|| mon.mode.height != mode.height
|
||||
|| mon.mode.refresh_hz != mode.refresh_hz;
|
||||
if changed {
|
||||
unsafe { mgr_reconfigure(&mut mon, mode) };
|
||||
}
|
||||
mon
|
||||
}
|
||||
MgrState::Idle => unsafe { create_monitor(device, mode, watchdog_s)? },
|
||||
MgrState::Active { .. } => unreachable!("handled above"),
|
||||
};
|
||||
let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz));
|
||||
let target = mon.target();
|
||||
let gen = mon.gen;
|
||||
CURRENT_MON_GEN.store(gen, Ordering::Relaxed);
|
||||
g.state = MgrState::Active { mon, refs: 1 };
|
||||
Ok(VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: pm,
|
||||
win_capture: target,
|
||||
keepalive: Box::new(MonitorLease { gen }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
|
||||
unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) {
|
||||
tracing::info!(
|
||||
old = format!(
|
||||
"{}x{}@{}",
|
||||
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||
),
|
||||
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
"pf-vdisplay: reconfiguring reused monitor to the new client mode"
|
||||
);
|
||||
if let Some(n) = resolve_gdi_name(mon.target_id) {
|
||||
mon.gdi_name = Some(n);
|
||||
}
|
||||
if let Some(n) = &mon.gdi_name {
|
||||
set_active_mode(n, mode);
|
||||
}
|
||||
mon.mode = mode;
|
||||
}
|
||||
|
||||
/// Release a session's hold: refcount-- ; when the last session leaves, LINGER before teardown.
|
||||
/// `gen` is the lease's monitor generation: a STALE lease (its monitor was already torn down +
|
||||
/// recreated under it — the IDD-push reconnect-preempt path) does nothing, so it can't decrement the
|
||||
/// CURRENT (fresh) monitor's refcount and tear it down.
|
||||
fn mgr_release(gen: u64) {
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let stale = match &g.state {
|
||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
||||
MgrState::Idle => true,
|
||||
};
|
||||
if stale {
|
||||
return;
|
||||
}
|
||||
g.state = match std::mem::replace(&mut g.state, MgrState::Idle) {
|
||||
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active {
|
||||
mon,
|
||||
refs: refs - 1,
|
||||
},
|
||||
MgrState::Active { mon, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(
|
||||
linger_ms = ms,
|
||||
"pf-vdisplay: last session left — lingering before teardown"
|
||||
);
|
||||
MgrState::Lingering {
|
||||
mon,
|
||||
until: Instant::now() + Duration::from_millis(ms),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: `wait_for_monitor_released` is NOT redefined here. Its only caller (`punktfunk1.rs`, the
|
||||
// IDD-push reconnect preempt) reaches it as `crate::vdisplay::sudovda::wait_for_monitor_released`, and
|
||||
// pf_vdisplay.rs never calls it internally (the preempt is done inline in `mgr_acquire` above), so a
|
||||
// second copy here would be dead code waiting on the (separate) pf-vdisplay MGR. The two backends keep
|
||||
// independent MGRs but only one is ever active — see the cross-MGR caveat in the implementation report.
|
||||
|
||||
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
|
||||
/// so a physical-screen user gets their screen back after they stop streaming.
|
||||
fn ensure_linger_timer() {
|
||||
static TIMER: Once = Once::new();
|
||||
TIMER.call_once(|| {
|
||||
let _ = thread::Builder::new()
|
||||
.name("pf-vdisplay-linger".into())
|
||||
.spawn(|| loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let mut g = MGR.lock().unwrap();
|
||||
let due = matches!(&g.state, MgrState::Lingering { until, .. } if Instant::now() >= *until);
|
||||
if due {
|
||||
let device = g.device.unwrap_or(0);
|
||||
if let MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut g.state, MgrState::Idle)
|
||||
{
|
||||
drop(g); // release the lock before the REMOVE IOCTL + display restore
|
||||
unsafe { mon.teardown(device) };
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A session's lease on the shared monitor. Drop releases the refcount (→ linger when it hits 0),
|
||||
/// UNLESS the monitor was already torn down + recreated under it (gen mismatch — the IDD-push
|
||||
/// reconnect-preempt path), in which case the drop is a no-op so it can't tear down the new monitor.
|
||||
struct MonitorLease {
|
||||
gen: u64,
|
||||
}
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
mgr_release(self.gen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: can we open the pf-vdisplay control device?
|
||||
pub fn probe() -> Result<()> {
|
||||
let h = unsafe { open_device()? };
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the pf-vdisplay driver present (device interface enumerable)?
|
||||
pub fn is_available() -> bool {
|
||||
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
|
||||
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
fn live_create_drop() {
|
||||
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
|
||||
return;
|
||||
}
|
||||
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
|
||||
let vout = vd
|
||||
.create(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})
|
||||
.expect("create virtual display");
|
||||
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
drop(vout); // triggers REMOVE + stops the pinger
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
//! Host-lifetime virtual-display **ownership model** (Goal-1 §2.5). One reference-counted monitor
|
||||
//! lifecycle, shared by both Windows backends (SudoVDA + pf-vdisplay) instead of the two verbatim-
|
||||
//! duplicated `MGR: Mutex<Mgr>` globals each backend used to carry.
|
||||
//!
|
||||
//! [`VirtualDisplayManager`] owns the earned Idle/Active/Lingering refcount machine + the linger timer +
|
||||
//! a **typed** [`OwnedHandle`] control device (no more raw `isize` smuggled across the pinger/linger
|
||||
//! threads). The backend differences — the IOCTL protocol and the per-monitor REMOVE key — are the only
|
||||
//! thing behind the [`VdisplayDriver`] seam; the state machine, the render-adapter pin decision, the
|
||||
//! GDI/CCD glue (`crate::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral.
|
||||
//!
|
||||
//! It's a process-wide singleton ([`vdm`]) initialised once with the chosen backend's driver — the
|
||||
//! host runs exactly one virtual-display backend per process. The session holds a [`MonitorLease`];
|
||||
//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it —
|
||||
//! is a no-op, so it can never tear down the live monitor).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::os::windows::io::{AsRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Once, OnceLock};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use windows::Win32::Foundation::{HANDLE, LUID};
|
||||
|
||||
use super::{Mode, VirtualOutput};
|
||||
use crate::win_display::{
|
||||
isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig,
|
||||
};
|
||||
|
||||
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
|
||||
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum MonitorKey {
|
||||
Guid(windows::core::GUID),
|
||||
Session(u64),
|
||||
}
|
||||
|
||||
/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID.
|
||||
pub(crate) struct AddedMonitor {
|
||||
pub key: MonitorKey,
|
||||
pub target_id: u32,
|
||||
pub luid: LUID,
|
||||
}
|
||||
|
||||
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
|
||||
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
|
||||
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
|
||||
/// `&'static` singleton reached from the pinger + linger threads.
|
||||
pub(crate) trait VdisplayDriver: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
/// Find + open the control device, validate it (version handshake), read the watchdog timeout, and
|
||||
/// reap monitors orphaned by a crashed previous host (`CLEAR_ALL`). Returns the owned handle +
|
||||
/// watchdog seconds.
|
||||
///
|
||||
/// # Safety
|
||||
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
||||
unsafe fn open(&self) -> Result<(OwnedHandle, u32)>;
|
||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`.
|
||||
/// Returns the REMOVE key + target id + the adapter LUID the driver actually used.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle from [`open`](Self::open).
|
||||
unsafe fn add_monitor(
|
||||
&self,
|
||||
dev: HANDLE,
|
||||
mode: Mode,
|
||||
render_luid: Option<LUID>,
|
||||
) -> Result<AddedMonitor>;
|
||||
/// REMOVE the monitor identified by `key`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>;
|
||||
/// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread).
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by
|
||||
/// any session). No `Drop` impl — [`teardown`](VirtualDisplayManager::teardown) must be called so the
|
||||
/// REMOVE IOCTL fires (a bare drop would orphan the driver-side monitor).
|
||||
struct Monitor {
|
||||
key: MonitorKey,
|
||||
target_id: u32,
|
||||
luid: LUID,
|
||||
gdi_name: Option<String>,
|
||||
mode: Mode,
|
||||
stop: Arc<AtomicBool>,
|
||||
pinger: Option<JoinHandle<()>>,
|
||||
ccd_saved: Option<SavedConfig>,
|
||||
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
/// The capture target handed to a session (`None` until the GDI name resolves on a WDDM GPU).
|
||||
fn target(&self) -> Option<crate::capture::dxgi::WinCaptureTarget> {
|
||||
self.gdi_name
|
||||
.clone()
|
||||
.map(|n| crate::capture::dxgi::WinCaptureTarget {
|
||||
adapter_luid: crate::capture::dxgi::pack_luid(self.luid),
|
||||
gdi_name: n,
|
||||
target_id: self.target_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enum MgrState {
|
||||
Idle,
|
||||
Active { mon: Monitor, refs: u32 },
|
||||
Lingering { mon: Monitor, until: Instant },
|
||||
}
|
||||
|
||||
/// The host-lifetime virtual-display manager: the single owner of the monitor lifecycle.
|
||||
pub(crate) struct VirtualDisplayManager {
|
||||
driver: Box<dyn VdisplayDriver>,
|
||||
/// Control device, opened once on first acquire. Typed + `Send+Sync`, so the pinger/linger threads
|
||||
/// share it via the `&'static` singleton with no raw-handle smuggling.
|
||||
device: OnceLock<Arc<OwnedHandle>>,
|
||||
watchdog_s: AtomicU32,
|
||||
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
||||
gen: AtomicU64,
|
||||
state: Mutex<MgrState>,
|
||||
/// Serializes IDD-push session SETUP (preempt + monitor create) so a reconnect flood can't run
|
||||
/// concurrent monitor create/teardown — held by the session across the pipeline build (was the
|
||||
/// `IDD_SETUP_LOCK` global in `punktfunk1`).
|
||||
setup_lock: Mutex<()>,
|
||||
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
|
||||
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
|
||||
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
|
||||
}
|
||||
|
||||
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
|
||||
|
||||
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
|
||||
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
|
||||
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
|
||||
VDM.get_or_init(|| VirtualDisplayManager {
|
||||
driver,
|
||||
device: OnceLock::new(),
|
||||
watchdog_s: AtomicU32::new(3),
|
||||
gen: AtomicU64::new(1),
|
||||
state: Mutex::new(MgrState::Idle),
|
||||
setup_lock: Mutex::new(()),
|
||||
idd_session_stop: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// The process-wide manager. Panics if reached before a backend called [`init`] — by construction a
|
||||
/// session is only ever created after `vdisplay::open` constructed the backend (which calls `init`).
|
||||
pub(crate) fn vdm() -> &'static VirtualDisplayManager {
|
||||
VDM.get()
|
||||
.expect("VirtualDisplayManager used before a backend initialised it")
|
||||
}
|
||||
|
||||
impl VirtualDisplayManager {
|
||||
pub(crate) fn backend_name(&self) -> &'static str {
|
||||
self.driver.name()
|
||||
}
|
||||
|
||||
/// Open + cache the control device (once). Called under the `state` lock so two racing acquires can't
|
||||
/// double-open.
|
||||
fn ensure_device(&self) -> Result<HANDLE> {
|
||||
if let Some(d) = self.device.get() {
|
||||
return Ok(HANDLE(d.as_raw_handle()));
|
||||
}
|
||||
// SAFETY: `VdisplayDriver::open` is `unsafe` only because it issues SetupAPI + `DeviceIoControl`
|
||||
// FFI in the caller's apartment; `ensure_device` runs that on the acquiring thread under the
|
||||
// `state` lock (callers hold it), so there is no concurrent open. `open` has no handle
|
||||
// precondition to uphold, and the `OwnedHandle` it returns is the sole owner of the device.
|
||||
let (handle, watchdog_s) = unsafe { self.driver.open()? };
|
||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||
let raw = HANDLE(handle.as_raw_handle());
|
||||
let _ = self.device.set(Arc::new(handle));
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// The live control handle for the pinger/linger threads (lock-free: the device never changes once
|
||||
/// opened). `None` only before the first acquire opened it.
|
||||
fn device_handle(&self) -> Option<HANDLE> {
|
||||
self.device.get().map(|d| HANDLE(d.as_raw_handle()))
|
||||
}
|
||||
|
||||
/// Open + initialise the backend (validates the driver is present). Mirrors the old
|
||||
/// `PfVdisplayDisplay::new`.
|
||||
pub(crate) fn open_backend(&self) -> Result<()> {
|
||||
// Hold the state lock across the open so two racing backends can't double-open the device.
|
||||
let _guard = self.state.lock().unwrap();
|
||||
self.ensure_device().map(|_| ())
|
||||
}
|
||||
|
||||
/// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one
|
||||
/// (refcount++), reuse a lingering one, or create one. The returned [`MonitorLease`] releases the
|
||||
/// refcount on drop.
|
||||
pub(crate) fn acquire(&'static self, mode: Mode) -> Result<VirtualOutput> {
|
||||
self.ensure_linger_timer();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let dev = self.ensure_device()?;
|
||||
|
||||
// IDD-push: a new connection while a monitor is live is a single-client RECONNECT (the prior
|
||||
// client is gone). A REUSED IddCx swap-chain is DEAD, so joining it hands a black screen —
|
||||
// PREEMPT: tear the old monitor down (its key/topology are restored) and create a fresh one. The
|
||||
// old session's lease is gen-stamped, so its later drop is a no-op and can't tear down the new one.
|
||||
if idd_push_mode() && matches!(*state, MgrState::Active { .. } | MgrState::Lingering { .. })
|
||||
{
|
||||
if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut *state, MgrState::Idle)
|
||||
{
|
||||
tracing::info!(
|
||||
old_target = mon.target_id,
|
||||
"IDD-push reconnect — preempting the prior session, recreating a fresh monitor"
|
||||
);
|
||||
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the value
|
||||
// `ensure_device()` returned above (the device is cached in the `OnceLock` and never
|
||||
// closed for the manager's lifetime). `mon` was moved out of the prior `Active`/
|
||||
// `Lingering` state by `mem::replace`, so it is exclusively owned here — no aliasing.
|
||||
unsafe { self.teardown(dev, mon) };
|
||||
// Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back
|
||||
// REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn.
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
|
||||
// A live monitor already exists — join it (refcount++). Covers concurrent sessions AND the
|
||||
// build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken while the old is
|
||||
// still held). Reconfigure the shared monitor if the requested mode differs.
|
||||
if let MgrState::Active { mon, refs } = &mut *state {
|
||||
*refs += 1;
|
||||
if mon.mode != mode {
|
||||
// SAFETY: `reconfigure` only manipulates the live display topology via the CCD/GDI
|
||||
// helpers and needs an exclusive `&mut Monitor`. `mon` is the `&mut` into the current
|
||||
// `Active` state, held under the `state` lock, so nothing else reconfigures it concurrently.
|
||||
unsafe { self.reconfigure(mon, mode) };
|
||||
}
|
||||
tracing::info!(
|
||||
refs = *refs,
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (concurrent / reconfigure session)"
|
||||
);
|
||||
return Ok(self.output_for(mon));
|
||||
}
|
||||
|
||||
// Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}.
|
||||
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||
MgrState::Lingering { mut mon, .. } => {
|
||||
tracing::info!(
|
||||
backend = self.driver.name(),
|
||||
"virtual monitor reused (reconnect within the linger window)"
|
||||
);
|
||||
if mon.mode != mode {
|
||||
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
|
||||
// display topology. `mon` is the local monitor just moved out of the `Lingering`
|
||||
// state (sole owner), and we hold the `state` lock — no concurrent reconfigure.
|
||||
unsafe { self.reconfigure(&mut mon, mode) };
|
||||
}
|
||||
mon
|
||||
}
|
||||
// SAFETY: `create_monitor` requires `dev` to be the live control handle; `dev` is the
|
||||
// handle `ensure_device()` returned above (cached in the `OnceLock`, never closed for the
|
||||
// manager's lifetime), and we hold the `state` lock.
|
||||
MgrState::Idle => unsafe { self.create_monitor(dev, mode)? },
|
||||
MgrState::Active { .. } => unreachable!("handled above"),
|
||||
};
|
||||
let out = self.output_for(&mon);
|
||||
*state = MgrState::Active { mon, refs: 1 };
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`.
|
||||
fn output_for(&'static self, mon: &Monitor) -> VirtualOutput {
|
||||
VirtualOutput {
|
||||
node_id: 0,
|
||||
preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)),
|
||||
win_capture: mon.target(),
|
||||
keepalive: Box::new(MonitorLease {
|
||||
mgr: self,
|
||||
gen: mon.gen,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh monitor at `mode`: ADD via the driver (pinning the discrete render GPU under the
|
||||
/// usual conditions), start the watchdog pinger, resolve the GDI name, force the mode + isolate to a
|
||||
/// sole composited display.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn create_monitor(&'static self, dev: HANDLE, mode: Mode) -> Result<Monitor> {
|
||||
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
|
||||
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
|
||||
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
|
||||
// memory crosses the call.
|
||||
let added = unsafe { self.driver.add_monitor(dev, mode, resolve_render_pin())? };
|
||||
|
||||
// Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down.
|
||||
// The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let interval =
|
||||
Duration::from_millis(self.watchdog_s.load(Ordering::Relaxed) as u64 * 1000 / 3);
|
||||
let stop_t = stop.clone();
|
||||
let pinger = thread::spawn(move || {
|
||||
let mut warned = false;
|
||||
while !stop_t.load(Ordering::Relaxed) {
|
||||
if let Some(h) = vdm().device_handle() {
|
||||
// SAFETY: `ping` requires `dev` to be the live control handle. `h` is from
|
||||
// `device_handle()` (the `Some` branch) — the `OnceLock<Arc<OwnedHandle>>` that,
|
||||
// once set, is never cleared or closed for the process lifetime, so the handle is
|
||||
// live for this call. The pinger thread only spins while the `&'static` manager
|
||||
// singleton (and thus the device) lives.
|
||||
match unsafe { vdm().driver.ping(h) } {
|
||||
Ok(()) => warned = false,
|
||||
Err(e) => {
|
||||
if !warned {
|
||||
tracing::warn!("virtual-display keepalive PING failed (control handle lost?): {e:#}");
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(interval);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated);
|
||||
// the capture backend re-resolves once a GPU is present.
|
||||
let mut gdi_name = None;
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD (QueryDisplayConfig) FFI; it takes a
|
||||
// plain `Copy` `u32` target id by value and returns an owned `String`, so no caller memory
|
||||
// is borrowed across the call.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } {
|
||||
gdi_name = Some(n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut ccd_saved: Option<SavedConfig> = None;
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||
set_active_mode(n, mode);
|
||||
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD
|
||||
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
|
||||
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
|
||||
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
|
||||
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
|
||||
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
|
||||
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
|
||||
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
|
||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||
} else {
|
||||
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"virtual-display target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
added.target_id
|
||||
),
|
||||
}
|
||||
|
||||
Ok(Monitor {
|
||||
key: added.key,
|
||||
target_id: added.target_id,
|
||||
luid: added.luid,
|
||||
gdi_name,
|
||||
mode,
|
||||
stop,
|
||||
pinger: Some(pinger),
|
||||
ccd_saved,
|
||||
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name.
|
||||
///
|
||||
/// # Safety
|
||||
/// Touches the live display topology via the CCD/GDI helpers.
|
||||
unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) {
|
||||
tracing::info!(
|
||||
old = format!(
|
||||
"{}x{}@{}",
|
||||
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||
),
|
||||
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||
"virtual-display: reconfiguring reused monitor to the new client mode"
|
||||
);
|
||||
// SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes the `Copy` `u32`
|
||||
// `mon.target_id` by value and returns an owned `String`, so nothing borrowed crosses the call.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(mon.target_id) } {
|
||||
mon.gdi_name = Some(n);
|
||||
}
|
||||
if let Some(n) = &mon.gdi_name {
|
||||
set_active_mode(n, mode);
|
||||
}
|
||||
mon.mode = mode;
|
||||
}
|
||||
|
||||
/// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor. Consumes it.
|
||||
///
|
||||
/// # Safety
|
||||
/// `dev` must be the live control handle.
|
||||
unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) {
|
||||
mon.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(j) = mon.pinger.take() {
|
||||
let _ = j.join();
|
||||
}
|
||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||
if let Some(saved) = &mon.ccd_saved {
|
||||
restore_displays_ccd(saved);
|
||||
}
|
||||
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
|
||||
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the
|
||||
// still-owned `mon`, alive for this synchronous IOCTL, so the pointer the driver reads stays valid.
|
||||
if let Err(e) = unsafe { self.driver.remove_monitor(dev, &mon.key) } {
|
||||
tracing::warn!("virtual-display REMOVE failed: {e:#}");
|
||||
} else {
|
||||
tracing::info!(
|
||||
backend = self.driver.name(),
|
||||
"virtual-display monitor removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving
|
||||
/// LINGERs before teardown. A STALE lease (its monitor was preempted + recreated under it) is a
|
||||
/// no-op, so it can't tear down the CURRENT monitor.
|
||||
fn release(&self, gen: u64) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let stale = match &*state {
|
||||
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
|
||||
MgrState::Idle => true,
|
||||
};
|
||||
if stale {
|
||||
return;
|
||||
}
|
||||
*state = match std::mem::replace(&mut *state, MgrState::Idle) {
|
||||
MgrState::Active { mon, refs } if refs > 1 => MgrState::Active {
|
||||
mon,
|
||||
refs: refs - 1,
|
||||
},
|
||||
MgrState::Active { mon, .. } => {
|
||||
let ms = linger_ms();
|
||||
tracing::info!(
|
||||
linger_ms = ms,
|
||||
"virtual-display: last session left — lingering before teardown"
|
||||
);
|
||||
MgrState::Lingering {
|
||||
mon,
|
||||
until: Instant::now() + Duration::from_millis(ms),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
|
||||
/// Begin an IDD-push session setup (Goal-1 §2.5 — was the `IDD_SETUP_LOCK` / `IDD_SESSION_STOP` /
|
||||
/// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the setup lock,
|
||||
/// registers THIS session's stop flag while signalling the PRIOR IDD-push session to stop, and waits
|
||||
/// for it to release its monitor — so a reconnect (whose reused IddCx swap-chain is dead) preempts the
|
||||
/// stale session cleanly before a fresh monitor is created. Returns the setup guard; the caller holds
|
||||
/// it across the pipeline build, then drops it so the next reconnect can begin (and preempt this one).
|
||||
pub(crate) fn begin_idd_setup(
|
||||
&'static self,
|
||||
stop: Arc<AtomicBool>,
|
||||
) -> std::sync::MutexGuard<'static, ()> {
|
||||
let guard = self.setup_lock.lock().unwrap();
|
||||
let prev = self.idd_session_stop.lock().unwrap().replace(stop);
|
||||
if let Some(prev_stop) = prev {
|
||||
prev_stop.store(true, Ordering::SeqCst);
|
||||
self.wait_for_monitor_released(Duration::from_secs(3));
|
||||
}
|
||||
guard
|
||||
}
|
||||
|
||||
/// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`).
|
||||
/// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it
|
||||
/// tears its monitor down cleanly before we acquire a fresh one.
|
||||
pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
tracing::warn!(
|
||||
"IDD-push preempt: prior session didn't release the monitor within {timeout:?} — proceeding"
|
||||
);
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle),
|
||||
/// so a physical-screen user gets their screen back after they stop streaming.
|
||||
fn ensure_linger_timer(&'static self) {
|
||||
static TIMER: Once = Once::new();
|
||||
TIMER.call_once(|| {
|
||||
thread::Builder::new()
|
||||
.name("vdisplay-linger".into())
|
||||
.spawn(move || loop {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
let due = {
|
||||
let g = self.state.lock().unwrap();
|
||||
matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until)
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
let Some(dev) = self.device_handle() else {
|
||||
continue;
|
||||
};
|
||||
let taken = {
|
||||
let mut g = self.state.lock().unwrap();
|
||||
if matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until) {
|
||||
if let MgrState::Lingering { mon, .. } =
|
||||
std::mem::replace(&mut *g, MgrState::Idle)
|
||||
{
|
||||
Some(mon)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(mon) = taken {
|
||||
// SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is from
|
||||
// `self.device_handle()` (the `Some` checked just above), i.e. the cached
|
||||
// `OwnedHandle` live for the process lifetime. `mon` was moved out of the
|
||||
// `Lingering` state under the `state` lock, so it is exclusively owned here.
|
||||
unsafe { self.teardown(dev, mon) };
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The session's refcount handle. `Drop` releases the manager's refcount; a stale lease (its monitor was
|
||||
/// preempted + recreated under it) is a no-op.
|
||||
struct MonitorLease {
|
||||
mgr: &'static VirtualDisplayManager,
|
||||
gen: u64,
|
||||
}
|
||||
|
||||
impl Drop for MonitorLease {
|
||||
fn drop(&mut self) {
|
||||
self.mgr.release(self.gen);
|
||||
}
|
||||
}
|
||||
|
||||
/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect),
|
||||
/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions.
|
||||
fn idd_push_mode() -> bool {
|
||||
crate::config::config().idd_push
|
||||
}
|
||||
|
||||
/// The render-GPU pin decision (backend-neutral): pin the discrete render GPU when explicitly requested,
|
||||
/// or under IDD-push (the host runs NVENC on the render adapter, so it MUST be the discrete encoder GPU
|
||||
/// on a hybrid box). `None` = let the IDD use its natural adapter (Apollo parity — avoids the cross-GPU
|
||||
/// ACCESS_LOST storm SudoVDA hit when pinned).
|
||||
fn resolve_render_pin() -> Option<LUID> {
|
||||
if crate::config::config().render_adapter.is_some() {
|
||||
// SAFETY: `resolve_render_adapter_luid` is `unsafe` only for its DXGI factory FFI; it takes no
|
||||
// arguments and returns an `Option<LUID>` by value, so there is no input/borrow to keep valid.
|
||||
unsafe { crate::win_adapter::resolve_render_adapter_luid() }
|
||||
} else if crate::config::config().idd_push {
|
||||
tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)");
|
||||
// SAFETY: as above — `resolve_render_adapter_luid` takes no arguments and returns an
|
||||
// `Option<LUID>` by value; the `unsafe` covers only its DXGI factory enumeration FFI.
|
||||
unsafe { crate::win_adapter::resolve_render_adapter_luid() }
|
||||
} else {
|
||||
tracing::info!(
|
||||
"SET_RENDER_ADAPTER skipped (Apollo-parity: no render pin; set PUNKTFUNK_RENDER_ADAPTER=<name> to force one)"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
|
||||
fn linger_ms() -> u64 {
|
||||
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(10_000)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display
|
||||
//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor
|
||||
//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz`
|
||||
//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and
|
||||
//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII).
|
||||
//!
|
||||
//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with
|
||||
//! the wire contract OWNED by [`pf_driver_proto::control`] (versioned + `#[repr(C)] Pod` structs,
|
||||
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`.
|
||||
//!
|
||||
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
|
||||
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
|
||||
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
|
||||
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
|
||||
//! request/reply structs, the version handshake) differ, per `pf_driver_proto`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
use std::os::windows::io::{FromRawHandle, OwnedHandle};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use windows::core::{GUID, PCWSTR};
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW,
|
||||
SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT,
|
||||
SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W,
|
||||
};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
use windows::Win32::System::IO::DeviceIoControl;
|
||||
|
||||
use pf_driver_proto::control;
|
||||
|
||||
use super::manager::{AddedMonitor, MonitorKey, VdisplayDriver};
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
|
||||
// pf-vdisplay device-interface GUID (pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately
|
||||
// NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids
|
||||
// any accidental coexistence with a real SudoVDA install.
|
||||
const PF_VDISPLAY_INTERFACE: GUID =
|
||||
GUID::from_u128(pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128);
|
||||
|
||||
/// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike
|
||||
/// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level
|
||||
/// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a
|
||||
/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime.
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
fn next_session_id() -> u64 {
|
||||
NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the
|
||||
/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`.
|
||||
unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result<u32> {
|
||||
let mut returned = 0u32;
|
||||
let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void);
|
||||
let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void);
|
||||
DeviceIoControl(
|
||||
h,
|
||||
code,
|
||||
inp,
|
||||
input.len() as u32,
|
||||
outp,
|
||||
output.len() as u32,
|
||||
Some(&mut returned),
|
||||
None,
|
||||
)
|
||||
.with_context(|| format!("DeviceIoControl(code={code:#x})"))?;
|
||||
Ok(returned)
|
||||
}
|
||||
|
||||
/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No
|
||||
/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target
|
||||
/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a
|
||||
/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm).
|
||||
///
|
||||
/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4
|
||||
/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the
|
||||
/// SudoVDA backend tolerated the driver IGNORING the pin.
|
||||
unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> {
|
||||
let req = control::SetRenderAdapterRequest {
|
||||
luid_low: luid.LowPart,
|
||||
luid_high: luid.HighPart,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
ioctl(
|
||||
h,
|
||||
control::IOCTL_SET_RENDER_ADAPTER,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
.map(|_| ())
|
||||
.context("pf-vdisplay SET_RENDER_ADAPTER")
|
||||
}
|
||||
|
||||
unsafe fn open_device() -> Result<HANDLE> {
|
||||
let hdev = SetupDiGetClassDevsW(
|
||||
Some(&PF_VDISPLAY_INTERFACE),
|
||||
PCWSTR::null(),
|
||||
None,
|
||||
DIGCF_DEVICEINTERFACE | DIGCF_PRESENT,
|
||||
)
|
||||
.context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?;
|
||||
|
||||
let mut idata = SP_DEVICE_INTERFACE_DATA {
|
||||
cbSize: size_of::<SP_DEVICE_INTERFACE_DATA>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata)
|
||||
.context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?;
|
||||
|
||||
let mut required = 0u32;
|
||||
let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None);
|
||||
let mut buf = vec![0u8; required as usize];
|
||||
let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W;
|
||||
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
|
||||
SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None)
|
||||
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?;
|
||||
|
||||
let handle = CreateFileW(
|
||||
PCWSTR((*detail).DevicePath.as_ptr()),
|
||||
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
None,
|
||||
)
|
||||
.context("CreateFileW(pf-vdisplay device)")?;
|
||||
let _ = SetupDiDestroyDeviceInfoList(hdev);
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager)
|
||||
/// (Goal-1 §2.5) — the wire contract is owned by `pf_driver_proto::control` (versioned, hard-checked).
|
||||
pub(crate) struct PfVdisplayDriver;
|
||||
|
||||
impl VdisplayDriver for PfVdisplayDriver {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
unsafe fn open(&self) -> Result<(OwnedHandle, u32)> {
|
||||
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
|
||||
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
|
||||
// backend-init thread, with no precondition beyond a valid thread context.
|
||||
let device = unsafe { open_device()? };
|
||||
// HARD protocol-version check (unlike SudoVDA's best-effort log): a mismatched host/driver pair
|
||||
// fails loudly here rather than corrupting the IOCTL stream.
|
||||
let mut info_buf = [0u8; size_of::<control::InfoReply>()];
|
||||
// SAFETY: `ioctl` requires `h` to be a valid device handle and its slices to be valid for the
|
||||
// call. `device` is the live handle just returned by `open_device`. `IOCTL_GET_INFO` takes no
|
||||
// input (`&[]`) and writes into `info_buf`, a stack `[u8; size_of::<InfoReply>()]` whose length
|
||||
// is passed as the output size — so `DeviceIoControl` can't write OOB — and which outlives this
|
||||
// synchronous call.
|
||||
unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) }
|
||||
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
||||
let info: control::InfoReply =
|
||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||
if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION {
|
||||
// SAFETY: `device` is the valid raw handle from `open_device` and has NOT yet been wrapped
|
||||
// in an `OwnedHandle` (that happens only on the success path below), so this error path is
|
||||
// the sole owner closing it exactly once — no double-close.
|
||||
unsafe {
|
||||
let _ = CloseHandle(device);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
||||
host + driver",
|
||||
pf_driver_proto::PROTOCOL_VERSION,
|
||||
info.protocol_version
|
||||
);
|
||||
}
|
||||
let watchdog_s = info.watchdog_timeout_s.max(1);
|
||||
tracing::info!(
|
||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||
info.protocol_version,
|
||||
watchdog_s
|
||||
);
|
||||
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns SUCCESS).
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: `device` is the live handle from `open_device` (still owned here, before it is wrapped
|
||||
// below). `IOCTL_CLEAR_ALL` has no input and no output: `&[]` and the empty `none` slice pass
|
||||
// zero-length buffers, so nothing is read or written through them.
|
||||
if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() {
|
||||
tracing::info!("cleared orphaned virtual monitors on host startup");
|
||||
} else {
|
||||
tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)");
|
||||
}
|
||||
Ok((
|
||||
// SAFETY: `device` is the valid handle from `open_device`, still owned here and NOT closed
|
||||
// on this success path (the error paths above close it and return). `from_raw_handle`'s
|
||||
// contract — caller owns a valid handle — holds, so ownership transfers cleanly into the
|
||||
// `OwnedHandle`: exactly one owner, which `CloseHandle`s it on drop.
|
||||
unsafe { OwnedHandle::from_raw_handle(device.0 as _) },
|
||||
watchdog_s,
|
||||
))
|
||||
}
|
||||
|
||||
unsafe fn add_monitor(
|
||||
&self,
|
||||
dev: HANDLE,
|
||||
mode: Mode,
|
||||
render_luid: Option<LUID>,
|
||||
) -> Result<AddedMonitor> {
|
||||
let session_id = next_session_id();
|
||||
let add = control::AddRequest {
|
||||
session_id,
|
||||
width: mode.width,
|
||||
height: mode.height,
|
||||
refresh_hz: mode.refresh_hz,
|
||||
_reserved: 0,
|
||||
};
|
||||
// SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports
|
||||
// its real render LUID in the shared header, so the host binds correctly even if this is ignored.
|
||||
if let Some(luid) = render_luid {
|
||||
// SAFETY: `add_monitor`'s `# Safety` contract guarantees `dev` is the live control handle,
|
||||
// which is `set_render_adapter`'s precondition; we forward it unchanged. `luid` is a plain
|
||||
// `Copy` `LUID` passed by value — no borrow crosses the call.
|
||||
match unsafe { set_render_adapter(dev, luid) } {
|
||||
Ok(()) => tracing::info!(
|
||||
luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
"pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"pf-vdisplay SET_RENDER_ADAPTER failed (continuing on the natural adapter): {e:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
let mut out = [0u8; size_of::<control::AddReply>()];
|
||||
// SAFETY: per `add_monitor`'s contract `dev` is the live control handle. `bytemuck::bytes_of(&add)`
|
||||
// borrows the local `AddRequest` (alive across this synchronous call) as the input bytes, and
|
||||
// `out` is a stack `[u8; size_of::<AddReply>()]` whose length bounds the kernel's write — both
|
||||
// buffers outlive the call.
|
||||
unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"pf-vdisplay ADD {}x{}@{}",
|
||||
mode.width, mode.height, mode.refresh_hz
|
||||
)
|
||||
})?;
|
||||
// `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed 4-byte
|
||||
// alignment, and `from_bytes` PANICS on a mismatch. This copies into an aligned `AddReply`.
|
||||
let reply: control::AddReply =
|
||||
bytemuck::pod_read_unaligned(&out[..size_of::<control::AddReply>()]);
|
||||
let luid = LUID {
|
||||
LowPart: reply.adapter_luid_low,
|
||||
HighPart: reply.adapter_luid_high,
|
||||
};
|
||||
tracing::info!(
|
||||
"pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
reply.target_id,
|
||||
luid.LowPart
|
||||
);
|
||||
if let Some(pin) = render_luid {
|
||||
if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart {
|
||||
tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
|
||||
pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart),
|
||||
"pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(AddedMonitor {
|
||||
key: MonitorKey::Session(session_id),
|
||||
target_id: reply.target_id,
|
||||
luid,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
||||
let MonitorKey::Session(session_id) = key else {
|
||||
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||
};
|
||||
let req = control::RemoveRequest {
|
||||
session_id: *session_id,
|
||||
};
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per `remove_monitor`'s contract `dev` is the live control handle. `bytes_of(&req)`
|
||||
// borrows the local `RemoveRequest` for the duration of this synchronous call as the input
|
||||
// bytes; `none` is empty, so there is no output buffer.
|
||||
unsafe {
|
||||
ioctl(
|
||||
dev,
|
||||
control::IOCTL_REMOVE,
|
||||
bytemuck::bytes_of(&req),
|
||||
&mut none,
|
||||
)
|
||||
}
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
unsafe fn ping(&self, dev: HANDLE) -> Result<()> {
|
||||
let mut none: [u8; 0] = [];
|
||||
// SAFETY: per `ping`'s contract `dev` is the live control handle. `IOCTL_PING` has no input
|
||||
// (`&[]`) and no output (`none` is empty), so no memory is read or written through the buffers.
|
||||
unsafe { ioctl(dev, control::IOCTL_PING, &[], &mut none) }.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// The Windows pf-vdisplay virtual-display backend. A marker — the lifecycle lives in the shared
|
||||
/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager).
|
||||
pub struct PfVdisplayDisplay;
|
||||
|
||||
impl PfVdisplayDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for PfVdisplayDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"pf-vdisplay"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
super::manager::vdm().acquire(mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: can we open the pf-vdisplay control device?
|
||||
pub fn probe() -> Result<()> {
|
||||
// SAFETY: `open_device` is `unsafe` only for its SetupAPI + `CreateFileW` FFI; no arguments, returns
|
||||
// an owned raw `HANDLE` (or `Err`).
|
||||
let h = unsafe { open_device()? };
|
||||
// SAFETY: `h` is the handle just opened by `open_device` in this function, owned here and not yet
|
||||
// handed anywhere else, so this closes it exactly once — no double-close, no use-after-close.
|
||||
unsafe {
|
||||
let _ = CloseHandle(h);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is the pf-vdisplay driver present (device interface enumerable)?
|
||||
pub fn is_available() -> bool {
|
||||
// SAFETY: `open_device` returns an owned raw `HANDLE`; on `Ok(h)` the handle is moved into the
|
||||
// closure (sole owner) and closed exactly once via `CloseHandle`, on `Err` there is nothing to
|
||||
// close — so no double-close and no leak of an opened handle. The `unsafe` covers both FFI calls.
|
||||
unsafe { open_device().map(|h| CloseHandle(h)).is_ok() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay
|
||||
/// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).
|
||||
#[test]
|
||||
fn live_create_drop() {
|
||||
if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() {
|
||||
return;
|
||||
}
|
||||
let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay");
|
||||
let vout = vd
|
||||
.create(Mode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
})
|
||||
.expect("create virtual display");
|
||||
assert_eq!(vout.preferred_mode, Some((1920, 1080, 60)));
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
drop(vout); // triggers REMOVE + stops the pinger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Launch a process into the interactive user session from the SYSTEM host.
|
||||
//!
|
||||
//! The Windows host runs as a LocalSystem SCM service. To *launch* a game/launcher so it renders onto
|
||||
//! the captured desktop — and so the user's protocol handlers (`HKCU\Software\Classes`), UWP/appx
|
||||
//! activation, and each store's auth/entitlement context resolve — the process must run in the
|
||||
//! interactive session under the **logged-in user's** token, not SYSTEM and not session 0.
|
||||
//!
|
||||
//! This is the same `WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx →
|
||||
//! CreateProcessAsUserW(winsta0\\default)` primitive the WGC helper relay uses
|
||||
//! ([`crate::capture::wgc_relay`]), factored out for the library launch path
|
||||
//! ([`crate::library::launch_title`]).
|
||||
//!
|
||||
//! IMPORTANT — use the **user** token (`WTSQueryUserToken`), NOT a session-retargeted SYSTEM token
|
||||
//! (the host-spawn in [`crate::service`] duplicates the SYSTEM token and only changes its session id;
|
||||
//! that is correct for launching *our own* streamer, but a store launcher needs the real user's token
|
||||
//! for activation + auth). The host process itself stays SYSTEM.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Security::{
|
||||
DuplicateTokenEx, SecurityImpersonation, TokenPrimary, TOKEN_ALL_ACCESS,
|
||||
};
|
||||
use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock};
|
||||
use windows::Win32::System::RemoteDesktop::{WTSGetActiveConsoleSessionId, WTSQueryUserToken};
|
||||
use windows::Win32::System::Threading::{
|
||||
CreateProcessAsUserW, CREATE_UNICODE_ENVIRONMENT, PROCESS_INFORMATION, STARTUPINFOW,
|
||||
};
|
||||
|
||||
/// Spawn `cmdline` in the active console session, under the logged-in user's token, on the
|
||||
/// interactive desktop (`winsta0\default`). Returns the new process id.
|
||||
///
|
||||
/// Fire-and-forget: the launched game/launcher outlives this call, so the host does not track the
|
||||
/// child — its handles are closed before returning (the process keeps running). The environment is
|
||||
/// the user's block merged with the host's `PUNKTFUNK_*`/`RUST_LOG` (same merge the WGC helper uses),
|
||||
/// so `host.env` settings propagate.
|
||||
///
|
||||
/// Requires the host to run as SYSTEM (`WTSQueryUserToken` needs `SE_TCB`). Fails when no interactive
|
||||
/// user is logged on (a pre-login / freshly-booted box can stream the login desktop but cannot
|
||||
/// auto-launch a store title until someone signs in).
|
||||
pub fn spawn_in_active_session(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
|
||||
// SAFETY: `spawn_inner` is unsafe only for its Win32 FFI; it has no caller-side preconditions — it
|
||||
// validates the session/token itself and owns every handle it opens — so calling it is always sound.
|
||||
unsafe { spawn_inner(cmdline, workdir) }
|
||||
}
|
||||
|
||||
unsafe fn spawn_inner(cmdline: &str, workdir: Option<&Path>) -> Result<u32> {
|
||||
// The user token of the active console session (requires the host to be SYSTEM).
|
||||
let session = WTSGetActiveConsoleSessionId();
|
||||
if session == 0xFFFF_FFFF {
|
||||
bail!("no active console session (no interactive user is logged on)");
|
||||
}
|
||||
let mut user_token = HANDLE::default();
|
||||
WTSQueryUserToken(session, &mut user_token)
|
||||
.context("WTSQueryUserToken (host must be SYSTEM; needs a logged-on interactive user)")?;
|
||||
|
||||
// A primary token for CreateProcessAsUserW.
|
||||
let mut primary = HANDLE::default();
|
||||
let dup = DuplicateTokenEx(
|
||||
user_token,
|
||||
TOKEN_ALL_ACCESS,
|
||||
None,
|
||||
SecurityImpersonation,
|
||||
TokenPrimary,
|
||||
&mut primary,
|
||||
);
|
||||
let _ = CloseHandle(user_token);
|
||||
dup.context("DuplicateTokenEx(TokenPrimary)")?;
|
||||
|
||||
// The user's environment block (PATH/USERPROFILE/SystemRoot for handler + DLL resolution), MERGED
|
||||
// with the host's PUNKTFUNK_*/RUST_LOG vars — same shared helper the WGC helper + service spawns use.
|
||||
let mut env_block: *mut core::ffi::c_void = std::ptr::null_mut();
|
||||
let _ = CreateEnvironmentBlock(&mut env_block, Some(primary), false);
|
||||
let merged_env = crate::capture::wgc_relay::merged_env_block(env_block as *const u16);
|
||||
if !env_block.is_null() {
|
||||
let _ = DestroyEnvironmentBlock(env_block);
|
||||
}
|
||||
|
||||
// The game/launcher must appear on the interactive desktop the host is capturing.
|
||||
let mut desktop: Vec<u16> = "winsta0\\default\0".encode_utf16().collect();
|
||||
let si = STARTUPINFOW {
|
||||
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
|
||||
lpDesktop: PWSTR(desktop.as_mut_ptr()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut cmd: Vec<u16> = cmdline.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let workdir_w: Option<Vec<u16>> = workdir.map(|d| {
|
||||
d.as_os_str()
|
||||
.to_string_lossy()
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
});
|
||||
let cwd = match &workdir_w {
|
||||
Some(w) => PCWSTR(w.as_ptr()),
|
||||
None => PCWSTR::null(),
|
||||
};
|
||||
|
||||
let mut pi = PROCESS_INFORMATION::default();
|
||||
let created = CreateProcessAsUserW(
|
||||
Some(primary),
|
||||
None,
|
||||
Some(PWSTR(cmd.as_mut_ptr())),
|
||||
None,
|
||||
None,
|
||||
false, // no handle inheritance — fire-and-forget GUI launch, no stdio relay
|
||||
CREATE_UNICODE_ENVIRONMENT,
|
||||
Some(merged_env.as_ptr() as *const core::ffi::c_void),
|
||||
cwd,
|
||||
&si,
|
||||
&mut pi,
|
||||
);
|
||||
let _ = CloseHandle(primary);
|
||||
created.context("CreateProcessAsUserW (interactive-session launch)")?;
|
||||
|
||||
let pid = pi.dwProcessId;
|
||||
// We don't supervise the child (it owns its own window/lifetime) — close the handles the API gave us.
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
Ok(pid)
|
||||
}
|
||||
+127
-50
@@ -21,10 +21,14 @@
|
||||
//! loaded into the service's environment and carried to the host child. Logs land in
|
||||
//! `%ProgramData%\punktfunk\logs\`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::ffi::{c_void, OsString};
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
@@ -64,14 +68,19 @@ const SERVICE_DESCRIPTION: &str =
|
||||
/// legacy GCM nonce reuse — security-review #5/#9; native clients only).
|
||||
const DEFAULT_HOST_CMD: &str = "serve --gamestream";
|
||||
|
||||
/// Event handles shared between the SCM control handler (which signals them) and the supervision loop
|
||||
/// (which waits on them). Stored as raw `isize` so the `'static + Send` handler can reach them without
|
||||
/// a non-`Send` `HANDLE` capture. Set once in `run_service`.
|
||||
static STOP_EVENT: AtomicIsize = AtomicIsize::new(0);
|
||||
static SESSION_EVENT: AtomicIsize = AtomicIsize::new(0);
|
||||
/// The STOP and SESSION manual-reset events, shared between the SCM control handler (a capture-free
|
||||
/// `'static` closure that SIGNALS them) and the supervision loop (which WAITS on them). They live in
|
||||
/// `OnceLock`s — a static the handler can reach without capturing a non-`Send` `HANDLE` — and each owns
|
||||
/// its handle (`OwnedHandle`) for the process lifetime: the service process exits right after
|
||||
/// `run_service` returns, so the OS reaps them at exit, and owning them past the handler's last possible
|
||||
/// call avoids the close-then-signal window the old raw-`isize` statics had. Set once, in `run_service`.
|
||||
static STOP_EVENT: OnceLock<OwnedHandle> = OnceLock::new();
|
||||
static SESSION_EVENT: OnceLock<OwnedHandle> = OnceLock::new();
|
||||
|
||||
fn load_event(a: &AtomicIsize) -> HANDLE {
|
||||
HANDLE(a.load(Ordering::Relaxed) as *mut c_void)
|
||||
/// Borrow an event's handle for the control handler's `SetEvent`. `None` until `run_service` creates the
|
||||
/// events — but the handler is registered only AFTER they're set, so in practice this is always `Some`.
|
||||
fn event_handle(ev: &OnceLock<OwnedHandle>) -> Option<HANDLE> {
|
||||
ev.get().map(|h| HANDLE(h.as_raw_handle()))
|
||||
}
|
||||
|
||||
/// Dispatch `service <sub>`.
|
||||
@@ -199,12 +208,24 @@ fn run_service() -> Result<()> {
|
||||
|
||||
// Two manual-reset events: STOP (set once, never reset) and SESSION (set on a console
|
||||
// connect/disconnect, reset by the supervisor after it reacts).
|
||||
let stop =
|
||||
// SAFETY: CreateEventW with null attributes (None), manual-reset=true, initial-state=false and a null
|
||||
// name passes no pointers into Rust memory; it returns a fresh, owned event HANDLE (or Err, via `?`).
|
||||
// Nothing aliases or outlives the call.
|
||||
let stop_raw =
|
||||
unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.context("CreateEvent stop")?;
|
||||
let session = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }
|
||||
// SAFETY: as above — a second fresh manual-reset event; no pointers into Rust memory, no aliasing.
|
||||
let session_raw = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }
|
||||
.context("CreateEvent session")?;
|
||||
STOP_EVENT.store(stop.0 as isize, Ordering::Relaxed);
|
||||
SESSION_EVENT.store(session.0 as isize, Ordering::Relaxed);
|
||||
// Own each event handle (the OS reaps them at process exit); the handler reaches them through the
|
||||
// OnceLocks, while `supervise` waits on the borrowed `HANDLE`s. SAFETY: each is a fresh CreateEventW
|
||||
// handle we own — take ownership exactly once.
|
||||
let stop_owned = unsafe { OwnedHandle::from_raw_handle(stop_raw.0) };
|
||||
// SAFETY: `session_raw` is the other fresh CreateEventW handle nothing else owns — take ownership once.
|
||||
let session_owned = unsafe { OwnedHandle::from_raw_handle(session_raw.0) };
|
||||
let stop = HANDLE(stop_owned.as_raw_handle());
|
||||
let session = HANDLE(session_owned.as_raw_handle());
|
||||
let _ = STOP_EVENT.set(stop_owned); // set once per process
|
||||
let _ = SESSION_EVENT.set(session_owned);
|
||||
|
||||
// The control handler captures nothing — it reaches the events through the statics, so it stays
|
||||
// `Fn + Send + 'static`. Session lock/unlock are handled inside the host (DesktopWatcher), so we
|
||||
@@ -212,7 +233,12 @@ fn run_service() -> Result<()> {
|
||||
let handler = move |control| -> ServiceControlHandlerResult {
|
||||
match control {
|
||||
ServiceControl::Stop | ServiceControl::Preshutdown | ServiceControl::Shutdown => {
|
||||
unsafe { SetEvent(load_event(&STOP_EVENT)) }.ok();
|
||||
if let Some(h) = event_handle(&STOP_EVENT) {
|
||||
// SAFETY: `h` borrows the STOP event HANDLE from the STOP_EVENT OwnedHandle, set for
|
||||
// the whole process lifetime and never closed before exit, so it is open here; SetEvent
|
||||
// only signals the event and passes no Rust memory.
|
||||
unsafe { SetEvent(h) }.ok();
|
||||
}
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::SessionChange(param) => {
|
||||
@@ -221,7 +247,12 @@ fn run_service() -> Result<()> {
|
||||
param.reason,
|
||||
ConsoleConnect | ConsoleDisconnect | SessionLogon
|
||||
) {
|
||||
unsafe { SetEvent(load_event(&SESSION_EVENT)) }.ok();
|
||||
if let Some(h) = event_handle(&SESSION_EVENT) {
|
||||
// SAFETY: `h` borrows the SESSION event HANDLE from the SESSION_EVENT OwnedHandle,
|
||||
// alive for the whole process lifetime and never closed before exit; SetEvent only
|
||||
// signals the event and passes no Rust memory.
|
||||
unsafe { SetEvent(h) }.ok();
|
||||
}
|
||||
}
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
@@ -258,10 +289,8 @@ fn run_service() -> Result<()> {
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
..running
|
||||
});
|
||||
unsafe {
|
||||
let _ = CloseHandle(stop);
|
||||
let _ = CloseHandle(session);
|
||||
}
|
||||
// The STOP/SESSION events stay owned by the OnceLocks for the process lifetime (the OS reaps them at
|
||||
// exit); NOT closing them while the SCM handler could still fire avoids a use-after-close.
|
||||
result
|
||||
}
|
||||
|
||||
@@ -280,7 +309,10 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
.collect();
|
||||
|
||||
// Kill-on-close job so a service crash never orphans the SYSTEM host; BREAKAWAY_OK lets the host
|
||||
// still spawn the WGC helper.
|
||||
// still spawn the WGC helper. Owned: dropping it at function exit (KILL_ON_JOB_CLOSE) reaps any
|
||||
// straggler still inside it — no manual CloseHandle(job).
|
||||
// SAFETY: `make_job` is unsafe only for its Win32 FFI; it has no caller preconditions and creates +
|
||||
// immediately takes RAII ownership of the job object, so calling it here is sound.
|
||||
let job = unsafe { make_job() }.context("create job object")?;
|
||||
|
||||
let mut restarts: u32 = 0;
|
||||
@@ -288,6 +320,8 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
if wait_one(stop, 0) {
|
||||
break;
|
||||
}
|
||||
// SAFETY: WTSGetActiveConsoleSessionId takes no arguments and returns the active console session
|
||||
// id (or 0xFFFFFFFF); it passes no pointers, so the call is always sound.
|
||||
let session = unsafe { WTSGetActiveConsoleSessionId() };
|
||||
if session == 0xFFFF_FFFF {
|
||||
// No interactive session yet (boot / fully logged out). Wait, but wake on stop/session.
|
||||
@@ -295,12 +329,19 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
if wait_any(&[stop, session_ev], 3000) == Some(0) {
|
||||
break;
|
||||
}
|
||||
// SAFETY: `session_ev` is the SESSION event HANDLE borrowed from the SESSION_EVENT OwnedHandle,
|
||||
// alive for the process lifetime; ResetEvent only clears its signalled state, no Rust memory.
|
||||
unsafe { ResetEvent(session_ev) }.ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
let pi = match unsafe { spawn_host(session, &cmdline, &workdir, job) } {
|
||||
Ok(pi) => pi,
|
||||
// BORROW the owned job handle for AssignProcessToJobObject inside spawn_host.
|
||||
let job_h = HANDLE(job.as_raw_handle());
|
||||
// SAFETY: `spawn_host` is unsafe only for its Win32 FFI. `session` is a valid console session id
|
||||
// (checked != 0xFFFFFFFF above), `cmdline`/`workdir` are live borrows for the call, and `job_h`
|
||||
// borrows the still-live `job` OwnedHandle — every argument is valid for the call's duration.
|
||||
let child = match unsafe { spawn_host(session, &cmdline, &workdir, job_h) } {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to launch host into session {session}: {e:#}");
|
||||
if wait_one(stop, 3000) {
|
||||
@@ -309,23 +350,33 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
tracing::info!(pid = pi.dwProcessId, session, cmd = %host_cmd, "host launched");
|
||||
tracing::info!(pid = child.pid, session, cmd = %host_cmd, "host launched");
|
||||
|
||||
// A BORROW of the owned process handle for the waits + TerminateProcess (HANDLE is Copy, so
|
||||
// `proc_h` is a plain copy that does NOT close it). `child` owns the process + thread handles
|
||||
// and auto-closes BOTH when it drops — at the end of this iteration, on `continue`, or on
|
||||
// `break` — so every match arm below only stops/terminates and lets the drop do the closing.
|
||||
let proc_h = HANDLE(child.process.as_raw_handle());
|
||||
|
||||
// Wait on stop / session-change / child-exit.
|
||||
let reason = wait_any(&[stop, session_ev, pi.hProcess], INFINITE);
|
||||
let reason = wait_any(&[stop, session_ev, proc_h], INFINITE);
|
||||
match reason {
|
||||
Some(0) => {
|
||||
// Stop: terminate the child and exit.
|
||||
// Stop: terminate the child and exit (the `child` drop closes its handles).
|
||||
// SAFETY: `proc_h` is a HANDLE copy of the still-live `child.process` OwnedHandle (not
|
||||
// dropped until end of iteration), so the process handle is open; TerminateProcess only
|
||||
// signals termination by handle and passes no Rust memory.
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = TerminateProcess(proc_h, 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(1) => {
|
||||
// Session change: relaunch only if the active console session actually moved.
|
||||
// SAFETY: `session_ev` borrows the process-lifetime SESSION_EVENT OwnedHandle; ResetEvent
|
||||
// only clears its signalled state and passes no Rust memory.
|
||||
unsafe { ResetEvent(session_ev) }.ok();
|
||||
// SAFETY: WTSGetActiveConsoleSessionId takes no arguments and passes no pointers.
|
||||
let now = unsafe { WTSGetActiveConsoleSessionId() };
|
||||
if now != session {
|
||||
tracing::info!(
|
||||
@@ -333,20 +384,20 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
new = now,
|
||||
"console session changed — relaunching host"
|
||||
);
|
||||
// SAFETY: `proc_h` copies the still-live `child.process` OwnedHandle (dropped only at
|
||||
// end of iteration), so the handle is open; TerminateProcess only signals by handle.
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = TerminateProcess(proc_h, 0);
|
||||
}
|
||||
restarts = 0;
|
||||
continue;
|
||||
}
|
||||
// Same session (e.g. a stray notification) — keep waiting on the same child.
|
||||
let r = wait_any(&[stop, pi.hProcess], INFINITE);
|
||||
let r = wait_any(&[stop, proc_h], INFINITE);
|
||||
// SAFETY: `proc_h` copies the still-live `child.process` OwnedHandle (dropped only at end
|
||||
// of iteration), so the handle is open; TerminateProcess only signals by handle.
|
||||
unsafe {
|
||||
let _ = TerminateProcess(pi.hProcess, 0);
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
let _ = TerminateProcess(proc_h, 0);
|
||||
}
|
||||
if r == Some(0) {
|
||||
break;
|
||||
@@ -354,12 +405,9 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
// child exited → fall through to relaunch
|
||||
}
|
||||
_ => {
|
||||
// Child exited on its own — relaunch (with a small crash-loop backoff).
|
||||
// Child exited on its own — relaunch (with a small crash-loop backoff). The `child`
|
||||
// drop closes its (already-exited) handles.
|
||||
tracing::warn!("host process exited — relaunching");
|
||||
unsafe {
|
||||
let _ = CloseHandle(pi.hProcess);
|
||||
let _ = CloseHandle(pi.hThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,36 +416,43 @@ fn supervise(stop: HANDLE, session_ev: HANDLE) -> Result<()> {
|
||||
if wait_one(stop, backoff) {
|
||||
break;
|
||||
}
|
||||
// `child` drops here (end of iteration) → its process + thread handles close before relaunch.
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// Dropping the job (KILL_ON_JOB_CLOSE) reaps any straggler in it.
|
||||
let _ = CloseHandle(job);
|
||||
}
|
||||
// `job` (OwnedHandle) drops at function exit, closing the job object → KILL_ON_JOB_CLOSE reaps
|
||||
// any straggler still inside it.
|
||||
tracing::info!("supervision loop ended");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `true` if `h` is signalled within `ms`.
|
||||
fn wait_one(h: HANDLE, ms: u32) -> bool {
|
||||
// SAFETY: `&[h]` is a live one-element HANDLE slice the caller keeps open across the wait; the kernel
|
||||
// reads exactly one handle (the binding derives the count from the slice length), bWaitAll=false,
|
||||
// `ms` is a timeout — no pointers escape and the array is only read for this synchronous call.
|
||||
unsafe { WaitForMultipleObjects(&[h], false, ms) == WAIT_OBJECT_0 }
|
||||
}
|
||||
|
||||
/// Wait on several handles; returns the index of the first signalled, or `None` on timeout.
|
||||
fn wait_any(handles: &[HANDLE], ms: u32) -> Option<usize> {
|
||||
// SAFETY: `handles` is a live slice the caller keeps open across the wait; WaitForMultipleObjects
|
||||
// reads exactly `handles.len()` handles (the binding derives the count from the slice), bWaitAll=false,
|
||||
// `ms` is a timeout — the array is only read for this synchronous call and no pointers escape it.
|
||||
let r = unsafe { WaitForMultipleObjects(handles, false, ms) };
|
||||
let idx = r.0.wrapping_sub(WAIT_OBJECT_0.0);
|
||||
(idx < handles.len() as u32).then_some(idx as usize)
|
||||
}
|
||||
|
||||
/// A kill-on-close + breakaway-ok job object.
|
||||
unsafe fn make_job() -> Result<HANDLE> {
|
||||
let job = CreateJobObjectW(None, PCWSTR::null()).context("CreateJobObjectW")?;
|
||||
/// A kill-on-close + breakaway-ok job object, returned as an `OwnedHandle` (auto-`CloseHandle` on drop).
|
||||
unsafe fn make_job() -> Result<OwnedHandle> {
|
||||
let job_raw = CreateJobObjectW(None, PCWSTR::null()).context("CreateJobObjectW")?;
|
||||
// Own it immediately so any early return (e.g. a failed SetInformationJobObject) still closes it.
|
||||
let job = OwnedHandle::from_raw_handle(job_raw.0);
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags =
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
HANDLE(job.as_raw_handle()),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
@@ -406,13 +461,24 @@ unsafe fn make_job() -> Result<HANDLE> {
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Launch the host as SYSTEM into `session_id`'s interactive desktop. Returns the child handles.
|
||||
/// The owned handles to a spawned host child. The `process`/`thread` `OwnedHandle`s auto-`CloseHandle`
|
||||
/// when the `Child` drops (or is replaced each loop iteration) — replacing the manual
|
||||
/// `CloseHandle(pi.hProcess/hThread)` the supervise loop used to scatter across its match arms.
|
||||
struct Child {
|
||||
process: OwnedHandle,
|
||||
/// Held only for its RAII `CloseHandle` (the thread handle is never used after spawn) — `_`-prefixed
|
||||
/// so the `dead_code` lint (CI's `-D warnings`) doesn't flag the never-read field.
|
||||
_thread: OwnedHandle,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
/// Launch the host as SYSTEM into `session_id`'s interactive desktop. Returns the owned child handles.
|
||||
unsafe fn spawn_host(
|
||||
session_id: u32,
|
||||
cmdline: &str,
|
||||
workdir: &[u16],
|
||||
job: HANDLE,
|
||||
) -> Result<PROCESS_INFORMATION> {
|
||||
) -> Result<Child> {
|
||||
// 1) A primary SYSTEM token retargeted to the active console session: duplicate THIS process's
|
||||
// (LocalSystem) token, then set its session id. SYSTEM holds SE_TCB so SetTokenInformation
|
||||
// (TokenSessionId) is permitted.
|
||||
@@ -494,7 +560,14 @@ unsafe fn spawn_host(
|
||||
|
||||
// Best-effort: keep the host inside the kill-on-close job.
|
||||
let _ = AssignProcessToJobObject(job, pi.hProcess);
|
||||
Ok(pi)
|
||||
|
||||
// Take ownership of the process + thread handles the API filled into `pi`; the returned `Child`
|
||||
// closes BOTH on drop, so the supervise loop no longer hand-closes them in its match arms.
|
||||
Ok(Child {
|
||||
process: OwnedHandle::from_raw_handle(pi.hProcess.0),
|
||||
_thread: OwnedHandle::from_raw_handle(pi.hThread.0),
|
||||
pid: pi.dwProcessId,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open `path` for appending, as an INHERITABLE handle (so the child can use it as stdout/stderr).
|
||||
@@ -621,6 +694,10 @@ fn ensure_default_host_env() -> Result<()> {
|
||||
# Force one with nvenc | amf | qsv | sw (software H.264). amf/qsv need an FFmpeg-built host.\n\
|
||||
PUNKTFUNK_ENCODER=auto\n\
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual\n\
|
||||
# Virtual display = the bundled pf-vdisplay driver; capture from its shared ring (the validated\n\
|
||||
# zero-copy IDD-push path; falls back to DDA if it can't attach). Set PUNKTFUNK_IDD_PUSH=0 to force WGC/DDA.\n\
|
||||
PUNKTFUNK_VDISPLAY=pf\n\
|
||||
PUNKTFUNK_IDD_PUSH=1\n\
|
||||
PUNKTFUNK_SECURE_DDA=1\n\
|
||||
RUST_LOG=info\n\
|
||||
\n\
|
||||
+7
-1
@@ -12,6 +12,9 @@
|
||||
//!
|
||||
//! Wire framing on stdout, per AU: `[u32 len LE][u64 pts_ns LE][u8 keyframe][len bytes data]`.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use crate::capture::{dxgi::WinCaptureTarget, wgc::WgcCapturer, Capturer};
|
||||
use crate::encode::{self, Codec};
|
||||
use anyhow::{Context, Result};
|
||||
@@ -72,6 +75,9 @@ pub fn run(opts: HelperOptions) -> Result<()> {
|
||||
.name("pf-present-trigger".into())
|
||||
.spawn(move || {
|
||||
tracing::info!("present-trigger: starting D3D present loop on the virtual display");
|
||||
// SAFETY: `present_trigger` is unsafe only for its Win32/D3D11 FFI; it has no caller
|
||||
// preconditions (it creates and exclusively owns its own window, device, and swapchain on
|
||||
// this dedicated thread), so the call is sound.
|
||||
if let Err(e) = unsafe { present_trigger(w, h) } {
|
||||
tracing::warn!("present-trigger error: {e:#}");
|
||||
}
|
||||
@@ -135,7 +141,7 @@ pub fn run(opts: HelperOptions) -> Result<()> {
|
||||
// the GPU scheduling priority the SYSTEM host stamps on us, not pipeline depth.
|
||||
let interval = std::time::Duration::from_secs_f64(1.0 / opts.fps.max(1) as f64);
|
||||
|
||||
let perf = std::env::var_os("PUNKTFUNK_PERF").is_some();
|
||||
let perf = crate::config::config().perf;
|
||||
let mut frames = 0u64;
|
||||
let mut repeats = 0u64; // frames where no newer capture had arrived (duplicate re-encode)
|
||||
let mut cap_ns = 0u64; // time in try_latest (capture + video-processor convert)
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Backend-neutral DXGI adapter selection.
|
||||
//!
|
||||
//! The discrete render-GPU LUID picker used to live in the SudoVDA backend (`vdisplay::sudovda`) — a
|
||||
//! historical accident, since it is display-utility, not SudoVDA-specific. It lives here so the capturers
|
||||
//! (IDD-push) and the pf-vdisplay backend depend on it as a *peer* instead of reaching into the SudoVDA
|
||||
//! module — breaking that circular reach-in, which let the SudoVDA backend be dropped without losing this
|
||||
//! helper (audit §9 / Goal 2 — done). This is the plan's `windows/adapter.rs`.
|
||||
|
||||
use windows::Win32::Foundation::LUID;
|
||||
|
||||
/// Pick the discrete render GPU LUID: the adapter with the most `DedicatedVideoMemory`, skipping
|
||||
/// WARP / Basic-Render and the SudoVDA software adapter (≈0 VRAM). `PUNKTFUNK_RENDER_ADAPTER=<substring>`
|
||||
/// forces a match by Description (Apollo's `adapter_name`). Used by the IDD direct-push capturer (to
|
||||
/// create its shared textures on the same discrete GPU it pins, where NVENC runs) and SET_RENDER_ADAPTER.
|
||||
///
|
||||
/// # Safety
|
||||
/// Creates + enumerates a DXGI factory; the COM calls run in the caller's apartment (the existing callers
|
||||
/// already satisfy this).
|
||||
pub(crate) unsafe fn resolve_render_adapter_luid() -> Option<LUID> {
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1};
|
||||
let want = crate::config::config()
|
||||
.render_adapter
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty());
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?;
|
||||
let mut best: Option<(LUID, u64, String)> = None;
|
||||
let mut i = 0u32;
|
||||
while let Ok(a) = factory.EnumAdapters1(i) {
|
||||
i += 1;
|
||||
let Ok(d) = a.GetDesc1() else { continue };
|
||||
let name = String::from_utf16_lossy(&d.Description);
|
||||
let name = name.trim_end_matches('\u{0}').to_string();
|
||||
let lname = name.to_ascii_lowercase();
|
||||
if lname.contains("basic render") || lname.contains("warp") {
|
||||
continue; // never pin to the software rasterizer
|
||||
}
|
||||
if let Some(w) = &want {
|
||||
if lname.contains(&w.to_ascii_lowercase()) {
|
||||
tracing::info!(
|
||||
adapter = name,
|
||||
"render adapter chosen by PUNKTFUNK_RENDER_ADAPTER"
|
||||
);
|
||||
return Some(d.AdapterLuid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let vram = d.DedicatedVideoMemory as u64; // SudoVDA software adapter ≈ 0 → loses to the dGPU
|
||||
if best.as_ref().is_none_or(|(_, v, _)| vram > *v) {
|
||||
best = Some((d.AdapterLuid, vram, name));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((luid, vram, name)) => {
|
||||
tracing::info!(
|
||||
adapter = name,
|
||||
vram_mb = vram / (1024 * 1024),
|
||||
"render adapter chosen (max VRAM)"
|
||||
);
|
||||
Some(luid)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("no suitable render adapter found for SET_RENDER_ADAPTER");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Backend-neutral Windows display utilities — the CCD (QueryDisplayConfig) + GDI helpers shared by the
|
||||
//! virtual-display backends (pf-vdisplay, SudoVDA) and the capturers (IDD-push, WGC, DDA): GDI-name
|
||||
//! resolution, advanced-color (HDR) get/set, active-mode set, and CCD topology isolate/restore.
|
||||
//!
|
||||
//! These are display-utility, NOT SudoVDA-specific (a pf-vdisplay monitor's target_id is a real OS target
|
||||
//! id, so they operate identically), so they live here rather than in the SudoVDA backend — breaking the
|
||||
//! circular reach-in where the capturers + the pf-vdisplay backend reached into `vdisplay::sudovda` for
|
||||
//! them, which let the SudoVDA backend be dropped without losing them (audit §9 / Goal 2 — done). The
|
||||
//! plan's `windows/display_ccd.rs`. Extracted verbatim from the former SudoVDA backend before its removal.
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use std::mem::size_of;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Devices::Display::{
|
||||
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
|
||||
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
|
||||
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO,
|
||||
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
|
||||
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
|
||||
SDC_SAVE_TO_DATABASE, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
|
||||
};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
|
||||
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
|
||||
ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE,
|
||||
};
|
||||
|
||||
use crate::vdisplay::Mode;
|
||||
|
||||
/// Resolve the `\\.\DisplayN` GDI name for a SudoVDA target id via the CCD API. Returns `None`
|
||||
/// until the OS activates the target into the desktop topology (needs a real WDDM GPU; on a
|
||||
/// GPU-less box this stays `None` even though ADD succeeded).
|
||||
pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option<String> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for p in paths.iter().take(np as usize) {
|
||||
if p.targetInfo.id == target_id {
|
||||
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
|
||||
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
|
||||
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
|
||||
src.header.adapterId = p.sourceInfo.adapterId;
|
||||
src.header.id = p.sourceInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut src.header) == 0 {
|
||||
let name = String::from_utf16_lossy(&src.viewGdiDeviceName);
|
||||
return Some(name.trim_end_matches('\u{0}').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The virtual display's CURRENT active resolution `(width, height)` via the GDI/CCD API, or `None` if the
|
||||
/// target isn't an active display yet / the query fails. The IDD-push capturer sizes its ring to this
|
||||
/// ACTUAL mode and polls it to recreate the ring when it changes — a fullscreen game can change the
|
||||
/// virtual display's mode out from under the session-negotiated one (game-capture bug GB1).
|
||||
///
|
||||
/// # Safety
|
||||
/// Calls the GDI/CCD APIs; safe to call from any thread.
|
||||
pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
|
||||
let gdi = resolve_gdi_name(target_id)?;
|
||||
let wname: Vec<u16> = gdi.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
let ok = EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm).as_bool();
|
||||
if !ok || dm.dmPelsWidth == 0 || dm.dmPelsHeight == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
||||
}
|
||||
|
||||
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
||||
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
||||
/// WGC keeps HDR on the normal desktop. Returns true on a successful `DisplayConfigSetDeviceInfo`.
|
||||
pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for p in paths.iter().take(np as usize) {
|
||||
if p.targetInfo.id == target_id {
|
||||
let mut s = DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE::default();
|
||||
s.header.r#type = DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE;
|
||||
s.header.size = size_of::<DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE>() as u32;
|
||||
s.header.adapterId = p.targetInfo.adapterId;
|
||||
s.header.id = p.targetInfo.id;
|
||||
s.Anonymous.value = enable as u32; // bit 0 = enableAdvancedColor
|
||||
let rc = DisplayConfigSetDeviceInfo(&s.header);
|
||||
tracing::info!(
|
||||
target_id,
|
||||
enable,
|
||||
rc,
|
||||
"SudoVDA set advanced-color (HDR) state"
|
||||
);
|
||||
return rc == 0;
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target_id,
|
||||
"set_advanced_color: target not found in active paths"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// Read the SudoVDA target's CURRENT advanced-color (HDR) state via the CCD API — i.e. whether HDR is
|
||||
/// actually ON for the virtual display right now (e.g. because the user toggled it in Windows display
|
||||
/// settings). The capture/encode pipeline follows the monitor's real colorspace (WGC → FP16 → NVENC
|
||||
/// Main10 BT.2020 PQ), so this is the authoritative "is this an HDR session" signal — NOT the
|
||||
/// handshake-negotiated bit depth. Returns false if the target isn't found / the query fails.
|
||||
pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> bool {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for p in paths.iter().take(np as usize) {
|
||||
if p.targetInfo.id == target_id {
|
||||
let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO::default();
|
||||
info.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO;
|
||||
info.header.size = size_of::<DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO>() as u32;
|
||||
info.header.adapterId = p.targetInfo.adapterId;
|
||||
info.header.id = p.targetInfo.id;
|
||||
if DisplayConfigGetDeviceInfo(&mut info.header) == 0 {
|
||||
// value bit 1 = advancedColorEnabled (bit 0 = advancedColorSupported).
|
||||
return (info.Anonymous.value & 0x2) != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Force the freshly-added SudoVDA monitor to the client's exact `WxH@Hz`. The ADD IOCTL only
|
||||
/// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the
|
||||
/// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a
|
||||
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper
|
||||
// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged).
|
||||
pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
// Enumerate the modes the driver actually advertises for this output and pick the best match for
|
||||
// the requested RESOLUTION: the exact refresh if present, else the highest advertised refresh
|
||||
// <= requested, else the highest available at that resolution. The SudoVDA ADD IOCTL advertises
|
||||
// the client mode, but a very high pixel rate (e.g. 5120x1440@240 = 1.77 Gpix/s) can be clamped
|
||||
// or absent — falling back to a lower refresh AT THE SAME RESOLUTION keeps the client's
|
||||
// resolution (what the user sees) instead of collapsing to the 1280x720/1920x1080 OS default.
|
||||
let mut at_res: Vec<u32> = Vec::new();
|
||||
let mut res_set: std::collections::BTreeSet<(u32, u32)> = std::collections::BTreeSet::new();
|
||||
let mut i = 0u32;
|
||||
loop {
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name (built above) whose pointer stays
|
||||
// valid for the call; `&mut dm` is a live DEVMODEW with `dmSize` set that EnumDisplaySettingsW
|
||||
// fills in for mode index `i`. Both outlive this synchronous call; the API only reads the name
|
||||
// and writes `dm`, so nothing aliases.
|
||||
let ok = unsafe {
|
||||
EnumDisplaySettingsW(
|
||||
PCWSTR(wname.as_ptr()),
|
||||
ENUM_DISPLAY_SETTINGS_MODE(i),
|
||||
&mut dm,
|
||||
)
|
||||
}
|
||||
.as_bool();
|
||||
if !ok {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
res_set.insert((dm.dmPelsWidth, dm.dmPelsHeight));
|
||||
if dm.dmPelsWidth == mode.width && dm.dmPelsHeight == mode.height {
|
||||
at_res.push(dm.dmDisplayFrequency);
|
||||
}
|
||||
}
|
||||
let chosen_hz = if at_res.contains(&mode.refresh_hz) {
|
||||
mode.refresh_hz
|
||||
} else if let Some(hz) = at_res
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&hz| hz <= mode.refresh_hz)
|
||||
.max()
|
||||
{
|
||||
hz
|
||||
} else if let Some(hz) = at_res.iter().copied().max() {
|
||||
hz
|
||||
} else {
|
||||
mode.refresh_hz // resolution not advertised at all; attempt anyway (likely -> OS default)
|
||||
};
|
||||
if at_res.is_empty() {
|
||||
tracing::warn!(
|
||||
"{gdi_name}: driver advertises no {}x{} mode (top advertised: {:?}); attempting @{} anyway",
|
||||
mode.width,
|
||||
mode.height,
|
||||
res_set.iter().rev().take(8).collect::<Vec<_>>(),
|
||||
mode.refresh_hz
|
||||
);
|
||||
} else if chosen_hz != mode.refresh_hz {
|
||||
tracing::info!(
|
||||
"{gdi_name}: {}x{}@{} not advertised; using {}x{}@{} (advertised refreshes here: {:?})",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz,
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz,
|
||||
at_res
|
||||
);
|
||||
}
|
||||
|
||||
// Set ONLY this output's mode in place (size/refresh/bpp; NO DM_POSITION). Do NOT promote it to
|
||||
// PRIMARY here and do NOT write a GLOBAL topology: promoting the IDD to primary at (0,0) while the
|
||||
// box's leftover basic display is still active contests the topology and storms
|
||||
// DXGI_ERROR_MODE_CHANGE_IN_PROGRESS (measured live). The IDD is made the sole → primary →
|
||||
// DWM-composited display by the CCD isolation in create() (which deactivates the other display
|
||||
// first), so a sole display is already primary and needs no CDS_SET_PRIMARY here.
|
||||
let dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() as u16,
|
||||
dmFields: DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY | DM_BITSPERPEL,
|
||||
dmBitsPerPel: 32,
|
||||
dmPelsWidth: mode.width,
|
||||
dmPelsHeight: mode.height,
|
||||
dmDisplayFrequency: chosen_hz,
|
||||
..Default::default()
|
||||
};
|
||||
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&dm` is a live DEVMODEW describing
|
||||
// the requested mode; both outlive the call. CDS_TEST only validates the mode (no apply), the two
|
||||
// trailing args are null, and the API only reads its inputs.
|
||||
let test = unsafe {
|
||||
ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_TEST, None)
|
||||
};
|
||||
if test != DISP_CHANGE_SUCCESSFUL {
|
||||
tracing::warn!(
|
||||
result = test.0,
|
||||
"{gdi_name}: driver rejected {}x{}@{} (mode not advertised?) — leaving OS default",
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz
|
||||
);
|
||||
return;
|
||||
}
|
||||
// SAFETY: same inputs as the CDS_TEST call above — `wname` (live NUL-terminated device name) and
|
||||
// `&dm` (live DEVMODEW) both outlive the call; CDS_UPDATEREGISTRY applies the already-validated mode,
|
||||
// and the API only reads its inputs.
|
||||
let apply = unsafe {
|
||||
ChangeDisplaySettingsExW(
|
||||
PCWSTR(wname.as_ptr()),
|
||||
Some(&dm),
|
||||
None,
|
||||
CDS_UPDATEREGISTRY,
|
||||
None,
|
||||
)
|
||||
};
|
||||
if apply == DISP_CHANGE_SUCCESSFUL {
|
||||
tracing::info!(
|
||||
"{gdi_name}: active mode set to {}x{}@{}",
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
result = apply.0,
|
||||
"{gdi_name}: failed to apply {}x{}@{}",
|
||||
mode.width,
|
||||
mode.height,
|
||||
chosen_hz
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved active display topology, for restoring on teardown.
|
||||
// pub(crate) so vdisplay::pf_vdisplay's Monitor can hold the same saved-topology type.
|
||||
pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_MODE_INFO>);
|
||||
|
||||
/// `DISPLAYCONFIG_PATH_ACTIVE` (wingdi.h) — the `flags` bit marking a path active. The `windows` crate
|
||||
/// doesn't export it, so define it here.
|
||||
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
||||
|
||||
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
|
||||
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
|
||||
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
|
||||
/// lock screen lands on IT while our virtual output freezes. `QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS)`
|
||||
/// sees every active path; we deactivate all of them EXCEPT the SudoVDA target's, leaving the virtual
|
||||
/// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same
|
||||
/// way (CCD). Returns the original active config to restore on teardown.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
|
||||
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
|
||||
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ONLY_ACTIVE_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
paths.truncate(np as usize);
|
||||
modes.truncate(nm as usize);
|
||||
let saved = (paths.clone(), modes.clone());
|
||||
let mut others = 0u32;
|
||||
for p in paths.iter_mut() {
|
||||
if p.targetInfo.id == keep_target_id {
|
||||
continue;
|
||||
}
|
||||
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
|
||||
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
|
||||
others += 1;
|
||||
}
|
||||
}
|
||||
if others == 0 {
|
||||
// The virtual path shows active in the CCD database (from set_active_mode's legacy
|
||||
// ChangeDisplaySettingsExW), but a legacy mode-set does NOT drive the IddCx adapter's
|
||||
// EVT_IDD_CX_ADAPTER_COMMIT_MODES — and without COMMIT_MODES the OS never calls
|
||||
// ASSIGN_SWAPCHAIN, so the driver never receives composed frames. Force an explicit CCD
|
||||
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
|
||||
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
|
||||
// already lists the path active.
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_SAVE_TO_DATABASE
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
|
||||
return Some(saved);
|
||||
}
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY
|
||||
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
|
||||
| SDC_ALLOW_CHANGES
|
||||
| SDC_FORCE_MODE_ENUMERATION,
|
||||
);
|
||||
if rc == 0 {
|
||||
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop");
|
||||
} else {
|
||||
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))");
|
||||
}
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
/// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is
|
||||
/// removed), re-activating the displays we deactivated.
|
||||
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper.
|
||||
pub(crate) unsafe fn restore_displays_ccd(saved: &SavedConfig) {
|
||||
let (paths, modes) = saved;
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rc = SetDisplayConfig(
|
||||
Some(paths.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
|
||||
);
|
||||
tracing::info!("display isolate (CCD): restored original topology rc={rc:#x}");
|
||||
}
|
||||
+14
-84
@@ -11,8 +11,8 @@
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"fumadocs-core": "^16.10.1",
|
||||
"fumadocs-ui": "^16.10.1",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
},
|
||||
@@ -481,7 +481,7 @@
|
||||
|
||||
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.10", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ=="],
|
||||
|
||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.13", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA=="],
|
||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.17", "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg=="],
|
||||
|
||||
@@ -493,7 +493,7 @@
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA=="],
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.10", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g=="],
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
|
||||
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw=="],
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
|
||||
|
||||
@@ -527,13 +527,13 @@
|
||||
|
||||
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.18", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg=="],
|
||||
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g=="],
|
||||
|
||||
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.10", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg=="],
|
||||
|
||||
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw=="],
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.1", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw=="],
|
||||
|
||||
@@ -549,7 +549,7 @@
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.11", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ=="],
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA=="],
|
||||
|
||||
@@ -557,11 +557,11 @@
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.1", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="],
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA=="],
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="],
|
||||
|
||||
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw=="],
|
||||
|
||||
@@ -1297,11 +1297,11 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"fumadocs-core": ["fumadocs-core@16.10.1", "", { "dependencies": { "@fuma-translate/react": "^1.0.1", "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-iGnB03/VyMSTWIaZ8zaDG/b/4q1e4gSzWDSvP3AR5Yxg9UJMsA0acaN/IFcURBSgRgJq6PELyYA6WfHBvHAgSg=="],
|
||||
"fumadocs-core": ["fumadocs-core@16.10.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x || 8.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-e/xrZnKvQo8bF/WYMwPuym8PR3OtjZzHy0S/EIOvGwjKRgVq9z6J58zaBpi4LvYtPVZxNGsxdZVlmZXCVWq4FQ=="],
|
||||
|
||||
"fumadocs-mdx": ["fumadocs-mdx@15.0.12", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.2.0", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", "vite": "7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "rolldown", "vite"], "bin": { "fumadocs-mdx": "./bin.js" } }, "sha512-R4WenrNQxSKi+QU46Q1cscVWi+S90dj3As4jdN+vgChO2o0TVOj+FFIe3onWM7mglhPj53NxZp/upP+t/ryekQ=="],
|
||||
|
||||
"fumadocs-ui": ["fumadocs-ui@16.10.1", "", { "dependencies": { "@fuma-translate/react": "^1.0.1", "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.13", "@radix-ui/react-collapsible": "^1.1.13", "@radix-ui/react-dialog": "^1.1.16", "@radix-ui/react-direction": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.15", "@radix-ui/react-popover": "^1.1.16", "@radix-ui/react-presence": "^1.1.6", "@radix-ui/react-scroll-area": "^1.2.11", "@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-tabs": "^1.1.14", "class-variance-authority": "^0.7.1", "lucide-react": "^1.17.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tailwind-merge": "^3.6.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.10.1", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-ytEwbMFFadfuul9x4Pz4pg9FMRI1MkqW5P7bHrWsLF+d1C4whzNtcUKPn0QP6KCQqIKoVhIa3C7qlI9v06Ik1A=="],
|
||||
"fumadocs-ui": ["fumadocs-ui@16.10.5", "", { "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-collapsible": "^1.1.14", "@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-direction": "^1.1.2", "@radix-ui/react-navigation-menu": "^1.2.16", "@radix-ui/react-popover": "^1.1.17", "@radix-ui/react-presence": "^1.1.6", "@radix-ui/react-scroll-area": "^1.2.12", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tabs": "^1.1.15", "class-variance-authority": "^0.7.1", "lucide-react": "^1.20.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.2.0", "tailwind-merge": "^3.6.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.10.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-vd69ckYx/4a1aoJTCUJ5LBkqNeOFxm3r+8SK9bVYaeHJrY/n8+4W6b0soqxVqgj1UwNmgovoAg0vlsYmSxZBgQ=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -2355,56 +2355,6 @@
|
||||
|
||||
"@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"@radix-ui/react-accordion/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="],
|
||||
|
||||
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.5", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.0", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@rollup/plugin-commonjs/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@rollup/plugin-inject/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
@@ -2465,6 +2415,8 @@
|
||||
|
||||
"ast-kit/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="],
|
||||
|
||||
"fumadocs-ui/lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="],
|
||||
|
||||
"h3/cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="],
|
||||
|
||||
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -2487,22 +2439,6 @@
|
||||
|
||||
"payload/@next/env": ["@next/env@15.5.19", "", {}, "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.14", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.1", "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.12", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"radix-ui/@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg=="],
|
||||
|
||||
"radix-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
|
||||
|
||||
"radix-vue/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
|
||||
@@ -2565,12 +2501,6 @@
|
||||
|
||||
"@payloadcms/richtext-lexical/mdast-util-mdx-jsx/mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="],
|
||||
|
||||
"@scalar/icons/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"archiver-utils/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
"@tanstack/react-start": "^1.121.0",
|
||||
"@unom/style": "^0.4.4",
|
||||
"@unom/ui": "^0.8.16",
|
||||
"fumadocs-core": "^16.10.1",
|
||||
"fumadocs-ui": "^16.10.1",
|
||||
"fumadocs-core": "^16.10.5",
|
||||
"fumadocs-ui": "^16.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { ApiReferenceReact } from '@scalar/api-reference-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
// @scalar/api-reference-react@0.9.47's entry does NOT import its own stylesheet
|
||||
// (and doesn't inject it at runtime), so we must ship it ourselves or the
|
||||
// reference renders unstyled. Load it as a route-scoped <link> (same pattern as
|
||||
@@ -148,15 +147,24 @@ body.light-mode {
|
||||
`
|
||||
|
||||
function ApiReference() {
|
||||
// Follow the docs' own light/dark switch (Fumadocs drives next-themes). Scalar
|
||||
// has no way to auto-detect the host theme, so we feed it the resolved theme
|
||||
// and hide its own toggle — the Fumadocs toggle stays the single source of
|
||||
// truth. `mounted` avoids a hydration flash (resolvedTheme is undefined on the
|
||||
// server); default to dark to match the docs' default.
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
const isDark = !mounted || resolvedTheme !== 'light'
|
||||
// Follow the docs' own light/dark switch and hide Scalar's own toggle, so the
|
||||
// Fumadocs toggle stays the single source of truth. Fumadocs drives next-themes
|
||||
// with `attribute: "class"`, which writes the resolved theme as a class on
|
||||
// <html> — we read THAT class directly rather than next-themes' useTheme().
|
||||
// The class is the authoritative, already-resolved signal (system → light/dark
|
||||
// included) and, unlike the React context, can't be desynced when bridging into
|
||||
// Scalar's separate Vue app. Default to dark (the docs default) so SSR and the
|
||||
// first client render agree — no hydration flash; the observer then syncs to the
|
||||
// live class, tracking the docs toggle AND OS changes while in system mode.
|
||||
const [isDark, setIsDark] = useState(true)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const sync = () => setIsDark(root.classList.contains('dark'))
|
||||
sync()
|
||||
const observer = new MutationObserver(sync)
|
||||
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Scalar pollutes global scope and never cleans up: it appends a persistent
|
||||
// <style id="scalar-style"> to <head> that includes a *global*
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
# Game library: more game stores
|
||||
|
||||
Status: **design / not started** · Author research: web-backed, adversarially verified (2026-06-26).
|
||||
|
||||
Goal: extend the unified game library so it enumerates and launches titles from more stores —
|
||||
on **Windows** Xbox / Game Pass, Epic, EA app (and GOG / Ubisoft / Battle.net / Amazon);
|
||||
on **Linux** Heroic (Epic+GOG+Amazon), Lutris, and a `.desktop`/Flatpak catch-all.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the extension point already is
|
||||
|
||||
The library lives in [`crates/punktfunk-host/src/library.rs`](../crates/punktfunk-host/src/library.rs)
|
||||
and is already a plug-in system — its own doc comment names these exact targets. Adding a store is
|
||||
a new `LibraryProvider`, not a rewrite.
|
||||
|
||||
```rust
|
||||
pub trait LibraryProvider {
|
||||
fn store(&self) -> &'static str; // "steam", ...
|
||||
fn list(&self) -> Vec<GameEntry>; // best-effort: empty (not Err) if the store is absent
|
||||
}
|
||||
pub struct GameEntry { id: String /* "<store>:<localid>" */, store, title, art: Artwork, launch: Option<LaunchSpec> }
|
||||
pub struct Artwork { portrait, hero, logo, header: Option<String> } // URLs the CLIENT fetches
|
||||
pub struct LaunchSpec{ kind: String, value: String } // today: "steam_appid" | "command"
|
||||
```
|
||||
|
||||
Today: `SteamProvider` (reads local `.acf` / `.vdf` files — **no API key, no network**) plus a
|
||||
user-curated `custom` store. `all_games()` merges them; `launch_command(id)` resolves a
|
||||
store-qualified id **against the host's own library** and maps the `LaunchSpec` to a shell command,
|
||||
with injection guards (`steam_appid` is validated digits-only; the client never sends a raw command).
|
||||
|
||||
**The "read the launcher's own on-disk files, no auth" approach is the gold standard we replicate per store.**
|
||||
|
||||
Surfaces touched by adding stores:
|
||||
- `library.rs` — new providers (the bulk of the work is small per store).
|
||||
- [`mgmt.rs`](../crates/punktfunk-host/src/mgmt.rs) `:1138` — serves `/library`; OpenAPI-generated TS client picks up new stores as data.
|
||||
- [`web/src/sections/Library/view.tsx`](../web/src/sections/Library/view.tsx) — the grid; **store badge is hard-coded** steam-vs-custom, needs generalizing per `game.store`.
|
||||
- Launch wiring: [`punktfunk1.rs`](../crates/punktfunk-host/src/punktfunk1.rs) `:573` (native) and [`gamestream/stream.rs`](../crates/punktfunk-host/src/gamestream/stream.rs) `:122` (Moonlight).
|
||||
|
||||
> The legacy GameStream `apps.json` ([`gamestream/apps.rs`](../crates/punktfunk-host/src/gamestream/apps.rs))
|
||||
> is a **separate** Moonlight surface (session recipes: compositor + nested command) and stays as-is.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two cross-cutting pieces (this is the real work)
|
||||
|
||||
Per-store enumeration is mostly easy. Two shared problems gate everything — especially Windows.
|
||||
|
||||
### 2a. Launch abstraction + the Windows launch gap
|
||||
|
||||
- **Linux** runs the chosen title as a shell command **nested in the per-session gamescope**
|
||||
(`set_launch_command` / `PUNKTFUNK_GAMESCOPE_APP`). Works today.
|
||||
- **Windows** captures the whole desktop (DXGI/WGC); there is no nesting, and
|
||||
`VirtualDisplay::set_launch_command` is a **no-op** ([`vdisplay.rs:57`](../crates/punktfunk-host/src/vdisplay.rs)).
|
||||
So on Windows **nothing is auto-started** — the user just sees the desktop.
|
||||
|
||||
**Plan.** Stop returning a single Linux shell string from `command_for`; introduce an internal enum and
|
||||
an OS-aware resolver:
|
||||
|
||||
```rust
|
||||
enum LaunchAction { Shell(String), Spawn { exe: PathBuf, args: Vec<String>, workdir: Option<PathBuf> } }
|
||||
fn resolve_launch(&LaunchSpec) -> Option<LaunchAction> // cfg-aware
|
||||
fn launch_command(id) -> Option<String> // Linux: thin Shell wrapper (back-compat)
|
||||
#[cfg(windows)] fn launch_title(id) -> Result<()> // resolve Spawn + run in interactive session
|
||||
```
|
||||
|
||||
**The Windows launcher already exists in the codebase — reuse it.**
|
||||
[`capture/windows/wgc_relay.rs:196-204`](../crates/punktfunk-host/src/capture/windows/wgc_relay.rs)
|
||||
does exactly the needed sequence:
|
||||
`WTSGetActiveConsoleSessionId → WTSQueryUserToken → DuplicateTokenEx(TokenPrimary) →
|
||||
CreateEnvironmentBlock → CreateProcessAsUserW(lpDesktop="winsta0\\default")`.
|
||||
|
||||
- Factor that into `windows/interactive.rs::spawn_in_active_session(exe, args, workdir) -> u32`.
|
||||
- **Critical:** use the **logged-in user token** (`WTSQueryUserToken`, as `wgc_relay` does) — **not**
|
||||
`windows/service.rs:449-510`'s variant, which duplicates the **SYSTEM** token and only retargets its
|
||||
session id. UWP/appx activation, the user-hive protocol handlers (`HKCU\Software\Classes`), and each
|
||||
launcher's auth/entitlement context all require the *real user's* token. The host process stays SYSTEM.
|
||||
- For URI-handoff kinds (Epic/Steam/EA/Amazon/GOG-Galaxy) build a **concrete EXE + the URI as a separate
|
||||
argv element**. `CreateProcessAsUserW` does **no** shell/protocol resolution — never `cmd /c`, never a
|
||||
bare URI. For schemes with no exe-argv form (`amazon-games://`, `origin2://`), add an impersonate-token
|
||||
`ShellExecuteEx` fallback (`ImpersonateLoggedOnUser` on a worker thread + `CoInitialize`).
|
||||
- **Order:** launch the title **after** the interactive capture pipeline is live, so the game renders onto
|
||||
the already-captured desktop and grabs foreground.
|
||||
- **Caveats:** `WTSQueryUserToken` fails when no interactive user is logged on (a pre-login box can stream
|
||||
the login/secure desktop but can't auto-launch a title); on the lock/secure desktop a launch may queue
|
||||
until unlock. **Needs on-glass validation** (RTX box) that each launcher EXE accepts its URI on argv and
|
||||
that post-capture launch grabs foreground.
|
||||
|
||||
### 2b. Artwork: a layered, no-auth-first `ArtResolver`
|
||||
|
||||
Steam gets free CDN art keyed by appid. Most stores don't. Layered ladder, degrade to a title-only card:
|
||||
|
||||
1. **Steam** → public Steam CDN by appid (unchanged, client fetches directly).
|
||||
2. **Stores that already hold public CDN URLs** → emit verbatim, **no host endpoint**: Heroic
|
||||
`store_cache` `art_*` (Epic/GOG/Amazon CDN), itch `cover_url`, GOG via public `api.gog.com/products/<id>?expand=images`
|
||||
(one cached lookup), Epic via local `catcache.bin` keyImages.
|
||||
3. **Xbox** → one **unofficial** no-auth `displaycatalog.mp.microsoft.com` lookup by StoreId, cached,
|
||||
degrade to no-art offline. (Not a stable contract — tolerate drift.)
|
||||
4. **Genuinely-local art** (Lutris `coverart`/`banners` JPEGs, Flatpak/.desktop icons, Bottles) → a
|
||||
**new host-served endpoint is required**, because `Artwork` carries URLs the client fetches and a file
|
||||
on the host has no public URL.
|
||||
5. **Opt-in SteamGridDB** enrichment (v2 API `https://www.steamgriddb.com/api/v2`, `Authorization: Bearer
|
||||
<operator key>`, **off by default**) to fill gaps. Not no-auth; never blocks listing.
|
||||
6. **None** → existing title-only card.
|
||||
|
||||
**New endpoint:** `GET /library/art/<entryId>/<slot>` (slot ∈ `portrait|hero|logo|header`) on `mgmt.rs`.
|
||||
It resolves `entryId` in the host library to a **known on-disk absolute path** (never interpolates raw
|
||||
client input into a filesystem path), sanitizes the slot, rejects `..`, streams the bytes with the right
|
||||
content-type. Reserve `data:` URLs for tiny logos only (don't bloat the catalog JSON that crosses the
|
||||
control plane). See open question on whether this GET bypasses the mgmt bearer (images are non-sensitive
|
||||
and the streaming client connects over punktfunk/1, not the bearer-gated REST).
|
||||
|
||||
---
|
||||
|
||||
## 3. Security model (preserved and extended)
|
||||
|
||||
The invariant is unchanged: **the client sends only a store-qualified `GameEntry.id`** (e.g. `lutris:42`,
|
||||
`xbox:9NBLGGH4R315`, `epic:fn:4fe…:Fortnite`) in `Hello.launch`. The host looks it up in its **own**
|
||||
enumerated library, reads the **host-derived** `LaunchSpec`, and resolves it. The client never sends a
|
||||
`LaunchSpec`, command, URI, or path.
|
||||
|
||||
Per-kind charset validators are belt-and-suspenders before any interpolation (values are already
|
||||
host-derived from local files the host owns):
|
||||
|
||||
| kind | guard |
|
||||
|---|---|
|
||||
| `steam_appid`, `lutris_id`, `uplay` | digits only |
|
||||
| `battlenet` | `^[A-Za-z0-9]+$` (case-sensitive) |
|
||||
| `amazon` | `^[A-Za-z0-9-]+$` |
|
||||
| `aumid` | `^[A-Za-z0-9._-]+![A-Za-z0-9._-]+$` (the `!` separator) |
|
||||
| `epic` | ≤3 `:`-split parts, each `^[A-Za-z0-9._-]+$`, then URL-encode colons |
|
||||
| `heroic` | runner ∈ {legendary,gog,nile} + appName `^[A-Za-z0-9._-]+$` |
|
||||
| `ea_offer_ids` | `^[A-Za-z0-9._,-]+$` (allow comma) |
|
||||
|
||||
On **Windows never route a client-influenced string through `cmd /c start`.** `resolve_launch` yields
|
||||
`Spawn{exe,args,workdir}`; `CreateProcessAsUserW` launches a concrete EXE with the URI/flags as separate
|
||||
argv elements. The operator-only `command` kind (custom store + provider-generated Linux shell lines for
|
||||
`desktop`/`itch`) is host-derived/operator-typed, never client-set.
|
||||
|
||||
The one net-new surface is `GET /library/art` — covered in §2b (id-resolved path, no traversal).
|
||||
|
||||
---
|
||||
|
||||
## 4. New `LaunchSpec` kinds
|
||||
|
||||
| kind | value holds | maps to |
|
||||
|---|---|---|
|
||||
| `lutris_id` | `pga.db` `games.id` (digits) | Linux Shell `lutris lutris:rungameid/<id>` (nests in gamescope) |
|
||||
| `heroic` | `<runner>:<appName>` | Linux argv `heroic --no-gui "heroic://launch?appName=<app>&runner=<runner>"` |
|
||||
| `aumid` | `<PFN>!<AppId>` | Windows Spawn `explorer.exe "shell:AppsFolder\<aumid>"` (interactive session) |
|
||||
| `epic` | `<namespace>:<catalogItemId>:<appName>` | Windows Spawn `EpicGamesLauncher.exe` + `com.epicgames.launcher://apps/<ns>%3A<cat>%3A<app>?action=launch&silent=true` |
|
||||
| `gog` | host-resolved `exe \t args \t workdir` | Windows Spawn `CreateProcessAsUserW(exe,args,workdir)` (direct exe, no Galaxy) |
|
||||
| `uplay` | Ubisoft gameId (digits) | Windows `uplay://launch/<gameId>/0` |
|
||||
| `battlenet` | product code (e.g. `WTCG`, `Fen`, `OSI`) | Windows Spawn `Battle.net.exe --exec="launch <code>"` |
|
||||
| `amazon` | Amazon Games `DbSet.Id` | Windows `amazon-games://play/<Id>` (impersonate ShellExecute) |
|
||||
| `ea_offer_ids` | comma-joined contentID list | Windows `origin2://game/launch/?offerIds=<list>&autoDownload=1` |
|
||||
| `command` (existing) | host-derived shell line | Linux gamescope-nested (desktop/flatpak/itch reuse this) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Per-store provider catalog
|
||||
|
||||
Confidence is **after** adversarial web-verification (research → verify). All enumeration is no-auth,
|
||||
local, launcher-need-not-be-running unless noted.
|
||||
|
||||
### Linux
|
||||
|
||||
#### Lutris — P0, effort M, confidence **high**
|
||||
- **Enumerate:** read-only `rusqlite` open of `pga.db`
|
||||
(`$XDG_DATA_HOME/lutris` | `~/.local/share/lutris` | `~/.var/app/net.lutris.Lutris/data/lutris`).
|
||||
`SELECT id, slug, name, runner FROM games WHERE installed=1`. Optionally LEFT JOIN
|
||||
`games_categories`/`categories` to drop the `.hidden` category. Open `mode=ro`/`immutable=1` (Lutris
|
||||
holds it open). `installed=1` matters — the DB also lists owned-but-not-installed rows.
|
||||
- **Launch:** `lutris_id` → `lutris lutris:rungameid/<id>` (execs the game; most nesting-friendly).
|
||||
One-time on-box check that `games.id` == the `rungameid` int.
|
||||
- **Artwork:** **local** JPEGs keyed by slug — `coverart/<slug>.jpg` (→ portrait), `banners/<slug>.jpg`
|
||||
(→ header) under `~/.local/share/lutris` (0.5.18+), with `~/.cache/lutris` (≤0.5.17) and the Flatpak
|
||||
cache as fallbacks. Needs the `/library/art` endpoint. hero/logo stay None.
|
||||
- **Notes:** highest-confidence new store. A `runner=='steam'` row can duplicate `SteamProvider` — dedup
|
||||
is a nicety. Verify bundled-SQLite is fine for deb/rpm/flatpak.
|
||||
|
||||
#### Heroic — P0, effort M, confidence **high** (one provider = Epic + GOG + Amazon, art free)
|
||||
- **Enumerate:** parse `~/.config/heroic/store_cache/{legendary,gog,nile}_library.json` (Flatpak:
|
||||
`~/.var/app/com.heroicgameslauncher.hgl/config/heroic/...`). Data key is `"library"` (legendary/nile)
|
||||
or `"games"` (gog); ignore `__timestamp.*` siblings. Filter `is_installed==true` **and** cross-check
|
||||
`install.install_path` exists (works around the gog `is_installed` bug, Heroic #2691). Fall back to
|
||||
`legendaryConfig/legendary/installed.json` etc. when a cache file is absent.
|
||||
*(Heroic uses `legendaryConfig/legendary`, **not** the standalone `~/.config/legendary`.)*
|
||||
- **Launch:** `heroic` → `heroic --no-gui "heroic://launch?appName=<app>&runner=<runner>"` (argv, no shell).
|
||||
`--no-gui` does the suppression; the `gui=false` query param is **inert/fabricated** — drop it.
|
||||
**Ship enumeration+art first, gate launch:** Heroic is single-instance Electron — if already running it
|
||||
forwards the URI and **exits**, which (as gamescope's foreground child) would tear the session down while
|
||||
the game runs **outside** gamescope, uncaptured. Also Electron needs a display — fine nested in gamescope,
|
||||
not in a bare headless context.
|
||||
- **Artwork:** **free** — `art_square` → portrait, `art_cover` → header, `art_background`||`art_cover` →
|
||||
hero, `art_logo` → logo are already public Epic/GOG/Amazon CDN URLs. Skip non-`http(s)` values
|
||||
(sideloaded `file://` art). No host endpoint.
|
||||
- **Notes:** do **not** also build separate Linux GOG/Amazon providers — native Linux GOG Galaxy doesn't
|
||||
exist; Heroic is the canonical Linux path for those.
|
||||
|
||||
#### Desktop (`.desktop` + Flatpak) — P1, effort M, confidence medium (universal catch-all)
|
||||
- **Enumerate:** scan `{/var/lib/flatpak/exports/share/applications,
|
||||
~/.local/share/flatpak/.../applications, /usr/share/applications, /usr/local/share/applications,
|
||||
~/.local/share/applications}/*.desktop`. Require `Type=Application` + `Categories` contains `Game`; skip
|
||||
`NoDisplay`/`Hidden`/`Terminal=true` and known launcher app-ids (Steam/Heroic/Lutris/Bottles/RetroArch)
|
||||
to avoid recursion/dupes.
|
||||
- **Launch:** reuse `command` (host-derived shell line, nested in gamescope): cleaned `Exec` (strip
|
||||
`%U/%F/%f/%u/%i/%c/%k`) else `flatpak run <app-id>`.
|
||||
- **Artwork:** local — resolve `Icon=` via the hicolor theme / flatpak exported icons → `/library/art`.
|
||||
App icons are low-res, not box art (acceptable header fallback).
|
||||
- **Notes:** run **last** and dedup by install path / drop ids already surfaced by Steam/Heroic/Lutris.
|
||||
|
||||
#### itch.io — P3, effort S, confidence medium (Linux + Windows)
|
||||
- **Enumerate:** read-only `rusqlite` of `butler.db` (`~/.config/itch/db/butler.db`; Flatpak
|
||||
`io.itch.itch`; Windows `%AppData%\itch\db`, per-user). JOIN `caves`→`games`. **Key on `cave.ID`** (a
|
||||
game can have multiple caves; install location + verdict are per-cave). Read game title / `cover_url`;
|
||||
resolve install dir from `InstallLocationID`+`InstallFolderName`||`CustomInstallFolder` + the Verdict
|
||||
candidate. Confirm exact column names on-box.
|
||||
- **Launch:** `command` → direct binary `basePath`+`candidate.path`, **only** for Verdict candidates with
|
||||
`flavor==native` (html/jar/love need itch's runtime — fall back to custom).
|
||||
- **Artwork:** **free** — `games.cover_url` is a public itch CDN URL.
|
||||
|
||||
### Windows
|
||||
|
||||
#### Epic Games Store — P1, effort M, confidence medium (cleanest Windows store to validate the launch wiring)
|
||||
- **Enumerate:** read `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\*.item` (JSON; machine-wide,
|
||||
SYSTEM-readable, launcher need not run). Read `DisplayName`, `AppName`, `CatalogNamespace`,
|
||||
`CatalogItemId`, `InstallLocation`, `LaunchExecutable`, `MainGameAppName`, `AppCategories`. Iterate the
|
||||
dir (filename is a random GUID).
|
||||
**Use Playnite's EXCLUSION filter, not a positive `games` filter:** skip `AppName` starting `UE_`; skip
|
||||
DLC only when `AppCategories` has `addons` && **not** `addons/launchable`; require `InstallLocation`
|
||||
exists. (The first-pass positive filter `games + MainGameAppName==AppName` can drop legit games.)
|
||||
- **Launch:** `epic` → Spawn `EpicGamesLauncher.exe` + `com.epicgames.launcher://apps/<ns>%3A<cat>%3A<app>?action=launch&silent=true`.
|
||||
Build the **triple** only when both namespace and CatalogItemId are present; otherwise **fall back to the
|
||||
bare `appName` URI (don't set launch=None)** — bare still works in Playnite today, it's just less robust.
|
||||
CatalogItemId is **not** present in every `.item` — verify on a real box.
|
||||
- **Artwork:** **free** — base64-decode + parse `Data\Catalog\catcache.bin`, index by catalogItemId, map
|
||||
keyImages `DieselGameBoxTall`→portrait, `DieselGameBox`→hero, `DieselGameBoxLogo`→logo. None on miss.
|
||||
- **Notes:** `.item` + `catcache.bin` are community-RE'd; `silent=true` may not suppress a cold-start
|
||||
launcher window.
|
||||
|
||||
#### GOG — P1, effort M, confidence medium
|
||||
- **Enumerate:** registry `HKLM\SOFTWARE\WOW6432Node\GOG.com\Games\<id>` (PATH/GAMENAME/gameID/EXE) or
|
||||
Uninstall `<id>_is1` keys with `Publisher=='GOG.com'` (exclude `GOGPACK*`). Parse
|
||||
`<PATH>\goggame-<id>.info` for `playTasks[isPrimary && type=='FileTask']` → exe/args/workingDir.
|
||||
- **Launch:** `gog` → **direct-exe** Spawn (no Galaxy dependency, dodges cold-start/anti-cheat). Optional
|
||||
fallback: `GalaxyClient.exe /launchViaAutostart /gameId=<id> /command=runGame /path="<dir>"` (note the
|
||||
`/launchViaAutostart` token; `goggalaxy://openGameView/<id>` only **opens the page**, doesn't launch).
|
||||
- **Artwork:** **free** — public no-auth `GET https://api.gog.com/products/<id>?expand=images` →
|
||||
`images.logo2x`/`verticalCover`/`background`; cache resolved URLs. (`goggame-.info` carries no art; the
|
||||
Galaxy `galaxy-2.0.db` is undocumented/locked — avoid.)
|
||||
|
||||
#### Xbox / Microsoft Store / Game Pass — P1, effort **L**, confidence medium (big Game Pass value, most plumbing)
|
||||
- **Enumerate:** probe each fixed drive for an `XboxGames` dir (default `C:\XboxGames`; the `.GamingRoot`
|
||||
binary layout is **undocumented** — just scan, don't depend on parsing it). For each
|
||||
`<Title>\Content\MicrosoftGame.config` (**presence = it's a GDK game**, the game-vs-app signal) read
|
||||
`ShellVisuals.DefaultDisplayName` (title), `<StoreId>` (12-char BigId, the art key), `Identity Name`,
|
||||
`<Executable Id="Game">` (the AppId). **Read the PackageFamilyName from the
|
||||
`C:\ProgramData\Microsoft\Windows\AppRepository\Packages\<PackageFullName>` directory name** (strip
|
||||
`_Version_Arch_~_PublisherHash`) — **never compute the PFN by hashing the publisher**. AUMID = `PFN!AppId`.
|
||||
- **Launch:** `aumid` → `explorer.exe shell:AppsFolder\<AUMID>` into the interactive session. **UWP
|
||||
activation fails from SYSTEM/session-0 — the interactive user token is load-bearing.**
|
||||
- **Artwork:** one **unofficial** no-auth lookup
|
||||
`displaycatalog.mp.microsoft.com/v7.0/products/<StoreId>?market=US&languages=en-us&fieldsTemplate=Details`,
|
||||
map `Images[]` ImagePurpose Poster→portrait / SuperHeroArt→hero / Logo→logo / BoxArt→header; cache to
|
||||
the config dir, degrade to no-art offline. Not a stable contract.
|
||||
- **Notes:** misses pure-UWP (non-GDK) Store games under the ACL-locked `WindowsApps` — accept for v1.
|
||||
|
||||
#### Ubisoft Connect — P2, effort S, confidence medium
|
||||
- **Enumerate:** registry `HKLM\SOFTWARE\WOW6432Node\Ubisoft\Launcher\Installs\<gameId>` (both reg views),
|
||||
read `InstallDir`; title = install-dir leaf folder (primary) else the `Uplay Install <gameId>` Uninstall
|
||||
`DisplayName`.
|
||||
- **Launch:** `uplay` → `uplay://launch/<gameId>/0`. **Artwork:** none → title-only.
|
||||
- **Notes:** smallest effort once the Windows URI-launch wiring exists; hive+scheme unchanged across the
|
||||
Origin→EA migration.
|
||||
|
||||
#### Amazon Games — P2, effort S, confidence medium
|
||||
- **Enumerate:** read-only `rusqlite` of
|
||||
`%LocalAppData%\Amazon Games\Data\Games\Sql\GameInstallInfo.sqlite`:
|
||||
`SELECT Id,ProductTitle,InstallDirectory FROM DbSet WHERE Installed=1`. **Per-user path** — the SYSTEM
|
||||
service must resolve the **active session user's** profile (not the SYSTEM profile).
|
||||
- **Launch:** `amazon` → `amazon-games://play/<Id>` (impersonate-token ShellExecute; no clean exe-argv form).
|
||||
- **Artwork:** `ProductIconUrl`/`ProductLogoUrl` columns when present, else none.
|
||||
|
||||
#### Battle.net — P2, effort **L**, confidence medium (high catalog value: WoW/Diablo IV/Overwatch 2/CoD)
|
||||
- **Enumerate:** hand-roll a ~4-field protobuf decode of `C:\ProgramData\Battle.net\Agent\product.db`
|
||||
(`product_install{ uid, product_code, settings.install_path, cached_product_state.base_product_state.installed }`).
|
||||
Registry fallback: Uninstall keys whose `UninstallString` matches `Battle.net.exe --uid=<uid>`.
|
||||
`product.db` has **no titles** → maintain a ~30-entry `product_code`→name map (source from
|
||||
bnetlauncher/Lutris/Heroic; codes are **case-sensitive**).
|
||||
- **Launch:** `battlenet` → `Battle.net.exe --exec="launch <code>"` (more reliable than the
|
||||
`battlenet://<code>` URI, which only hands off). **Artwork:** none → title-only.
|
||||
- **Notes:** the protobuf + name map + no-art make it L; pin the `.proto` and decode defensively.
|
||||
|
||||
#### EA app — P2, effort M, confidence medium (most closed/fragile — ship last)
|
||||
- **Enumerate:** registry `HKLM\SOFTWARE\WOW6432Node\{EA Games,Origin Games}\<id>` (Install Dir /
|
||||
DisplayName), parse `<dir>\__Installer\installerdata.xml` for the **full** `<contentIDs>` list +
|
||||
`<gameTitle locale='en_US'>`. Registry under-reports for EA-app (vs legacy Origin) installs — known
|
||||
completeness gap. Keep the AES-256 encrypted `IS`-file decrypt **out** of the default path (optional
|
||||
feature flag for completeness).
|
||||
- **Launch:** `ea_offer_ids` → `origin2://game/launch/?offerIds=<full,comma,list>&autoDownload=1`. **Emit
|
||||
the full contentID list** — a single offerId generally no longer launches under the EA app.
|
||||
- **Artwork:** none no-auth → title-only.
|
||||
|
||||
#### Rockstar — P3, fold into custom
|
||||
- Registry `HKLM\SOFTWARE\WOW6432Node\Rockstar Games\<Title>\InstallFolder`; direct-exe Spawn; no art.
|
||||
Tiny catalog, most titles now bought on Steam/Epic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Suggested structure & phasing
|
||||
|
||||
**Structure.** Split `library.rs` → a `library/` dir before it balloons:
|
||||
`mod.rs` (trait, wire types, `LaunchAction`, custom CRUD, `all_games`, `resolve_launch`,
|
||||
`launch_command`/`launch_title`), `steam.rs`, one file per provider, `art.rs` (ArtResolver +
|
||||
displaycatalog/gog-api/steamgriddb helpers), `win_util.rs` (HKLM subkey enumerator, read-only SQLite
|
||||
opener, tiny read-only XML reader). New deps: `rusqlite` (bundled, read-only) for lutris/itch/amazon DBs;
|
||||
`roxmltree`/`quick-xml` for the Windows manifests; registry via the `windows` crate's
|
||||
`Win32_System_Registry` feature (no new crate). Avoid `prost` — hand-roll the ~4 Battle.net fields.
|
||||
|
||||
| Phase | Deliverable | Files |
|
||||
|---|---|---|
|
||||
| **1 — Foundation** (no new stores) | Split `library.rs` → `library/`; add `LaunchAction` + `resolve_launch`; factor `windows/interactive.rs::spawn_in_active_session` out of `wgc_relay.rs`; make `set_launch_command` real on Windows; wire `launch_title` at session-start post-capture; add `win_util.rs` + deps | `library/{mod,steam,launch,art,win_util}.rs`; `windows/interactive.rs` (new); `capture/windows/wgc_relay.rs`; `punktfunk1.rs:573`; `gamestream/stream.rs:122`; `vdisplay.rs:57`; `main.rs`; `Cargo.toml` |
|
||||
| **2 — Linux Lutris + Heroic + art endpoint** (P0) | `LutrisProvider`, `HeroicProvider` (art free); `GET /library/art/<id>/<slot>` for Lutris local JPEGs; wire into `all_games()`; unit tests for new `resolve_launch` arms + guards | `library/{lutris,heroic,art}.rs`; `library/mod.rs`; `mgmt.rs:1138` + new route |
|
||||
| **3 — Windows Epic + GOG** (P1) | `EpicProvider` (.item + catcache art), `GogProvider` (registry + .info + api.gog.com art); validate `windows/interactive.rs` end-to-end on the RTX box | `library/{epic,gog,win_util,art,launch}.rs` |
|
||||
| **4 — Xbox / Game Pass** (P1) | `XboxProvider` (XboxGames scan + MicrosoftGame.config + AppRepository PFN + aumid launch) + displaycatalog art with caching/offline degrade | `library/{xbox,art,launch}.rs` |
|
||||
| **5 — Linux Desktop catch-all + easy Windows URI stores** (P1/P2) | `DesktopProvider` (last + dedup, icons via `/library/art`), `UplayProvider`, `AmazonProvider` (+ per-user-profile-under-SYSTEM helper) | `library/{desktop,uplay,amazon,win_util,art}.rs` |
|
||||
| **6 — Remaining + opt-in enrichment** (P2/P3) | `BattleNetProvider` (hand-rolled protobuf + code→name map), `EaAppProvider`, `ItchProvider`; Rockstar/Bottles → custom; optional SteamGridDB v2 behind an operator key | `library/{battlenet,eaapp,itch,art,mod}.rs` |
|
||||
|
||||
Also generalize the web console store badge (`web/src/sections/Library/view.tsx`) to render per `game.store`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
- **Art delivery auth:** the streaming client connects over punktfunk/1 (QUIC), not the bearer-gated mgmt
|
||||
REST, yet already fetches Steam CDN URLs over plain HTTP. Should `GET /library/art/*` be an
|
||||
unauthenticated read-only image GET on the mgmt listener (bearer bypass for that path only), a separate
|
||||
tiny image server, or should local-art bytes ride the punktfunk/1 control plane?
|
||||
- **Windows launch ordering** needs on-glass RTX-box validation: confirm launching *after* capture is live
|
||||
grabs foreground+capture, and that `CreateProcessAsUserW(EpicGamesLauncher.exe/steam.exe, URI-as-argv)`
|
||||
actually starts the game per launcher (vs needing the impersonate-ShellExecute fallback).
|
||||
- **Per-user-profile resolution under SYSTEM** for Amazon (`%LocalAppData%`) and itch (`%AppData%`): add
|
||||
`WTSQueryUserToken` + `GetUserProfileDirectoryW` (or read `USERPROFILE` from `CreateEnvironmentBlock`)?
|
||||
- **`rusqlite` bundled SQLite** — acceptable for deb/rpm/flatpak and no link conflict? Otherwise fall back
|
||||
to `lutris -l -j` (fragile: single-instance D-Bus forwarding).
|
||||
- **Battle.net** product-code→name map source/maintenance, and `product.db` `.proto` drift across Agent versions.
|
||||
- **Unofficial art sources** (Xbox displaycatalog): best-effort with aggressive caching + no-art degrade,
|
||||
or Xbox-art local-tile-only for v1?
|
||||
- **Heroic launch:** ship enumeration+art only at first, or invest in direct legendary/gogdl/nile CLI
|
||||
launch (needs the user's on-disk auth tokens) to dodge the single-instance-Electron / gamescope-escape problem?
|
||||
- **`config_dir()` consistency:** `library.rs` uses an XDG/HOME-based dir; confirm the Windows SYSTEM host
|
||||
lands its art cache + custom store under `%ProgramData%\punktfunk` (there's a separate
|
||||
`gamestream::config_dir()` that already does this).
|
||||
- Should provider-generated Linux shell lines (`desktop`/`itch`) reuse the `command` kind (documented
|
||||
"operator-only") or get a distinct internal kind to keep the mgmt-UI `command` semantics clean?
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification notes (what the adversarial pass corrected)
|
||||
|
||||
First-pass research was web-re-checked; corrections folded into §5 above:
|
||||
- **Epic:** bare-`AppName` URI is **not** universally removed (Playnite still uses it) — build the triple
|
||||
when ids exist, fall back to bare; use Playnite's **exclusion** filter, not a positive `games` filter.
|
||||
- **EA:** a single offerId no longer launches — emit the **full** comma-joined contentID list; registry
|
||||
under-reports for EA-app installs.
|
||||
- **Battle.net:** `battlenet://<code>` only hands off — use `Battle.net.exe --exec="launch <code>"`.
|
||||
- **Xbox:** **read** the PFN from the AppRepository dir name, don't hash the publisher; `.GamingRoot`
|
||||
layout is undocumented — just scan `XboxGames`.
|
||||
- **Heroic:** `gui=false` is inert (`--no-gui` does it); single-instance Electron forwards-and-exits →
|
||||
gate launch.
|
||||
- **Lutris:** open the DB read-only; `lutris -l -j` fallback is fragile (single-instance D-Bus forwarding).
|
||||
- **SteamGridDB:** v1 is deprecated — use v2 (`/api/v2`, Bearer key).
|
||||
|
||||
**Not web-confirmable / needs on-box validation:** every Windows launch path (each launcher's argv
|
||||
handling, foreground grab, secure-desktop behavior), all registry keys / DB schemas against a live box,
|
||||
and `rusqlite` packaging.
|
||||
@@ -0,0 +1,430 @@
|
||||
# GPU-contention performance investigation — why a saturating game starves the stream (2026-06-25)
|
||||
|
||||
> The headache, stated precisely:
|
||||
> a game renders ~140 fps on the host GPU; the client requests 120/240; in a GPU-light scene the
|
||||
> stream tracks; the moment the game pins the GPU the **stream collapses to 40–50 fps** while the
|
||||
> game keeps rendering 140. Capping the game's fps raises the stream back up (clearest in light
|
||||
> titles like CS2). **Capping is not an acceptable fix** — demanding titles exhaust the GPU even
|
||||
> when capped.
|
||||
|
||||
This is the second, deeper pass on the problem. The first pass is
|
||||
[`host-latency-plan.md`](host-latency-plan.md) (a 25-agent investigation, 2026-06-18). **This doc
|
||||
supersedes several of that doc's conclusions** — the codebase moved a lot in the week since
|
||||
(the Windows-host rewrite landed IDD-push as the default capture path, split-encode shipped, the
|
||||
GPU-priority knob got configurable), and a fresh, adversarially-verified research pass overturned
|
||||
two of the old plan's premises. Read §1 (corrections) before acting on the old doc.
|
||||
|
||||
Method: five parallel investigations — three deep reads of the *current* code (encode, capture,
|
||||
mitigations) and two web-research passes (encoder-side and GPU-scheduling-side), the latter run with
|
||||
their own adversarial verifiers. Every external claim below carries a source URL; every code claim
|
||||
carries a current `file:line`.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR — the corrected mental model and the action list
|
||||
|
||||
**The governing fact:** NVENC is a **dedicated ASIC on its own GPU runlist**, physically separate
|
||||
from the SM/CUDA/graphics cores a 3D game saturates. The game does **not** steal the encode block.
|
||||
It steals everything that *feeds* the block — capture-acquire, the **RGB→YUV colour-convert**, the
|
||||
copy into the encoder's input surface, the readback — **and the GPU-scheduler time** to run that
|
||||
feed work, which is queued behind the game's graphics context.
|
||||
([NVENC app-note](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-application-note/index.html),
|
||||
[engine-table proof, UNC RTAS'24](https://www.cs.unc.edu/~jbakita/rtas24.pdf))
|
||||
|
||||
**Therefore there are two different bottlenecks with opposite fixes, and you must tell them apart
|
||||
before writing code:**
|
||||
|
||||
| Bottleneck | Symptom | Fix family |
|
||||
|---|---|---|
|
||||
| **(a) feed-scheduling contention** | `uniq`≈`fps`, both ~50; `encode_ms` 13–17 | shrink the host's contended-engine footprint; raise GPU scheduling priority; pipeline correctly; in the limit, a second GPU |
|
||||
| **(b) frame-source ceiling** | `fps`≈240 (held re-encodes) but `uniq`→40–50 | capture the game's real frames (swapchain hook); compose-flip for the DLSS-FG case |
|
||||
|
||||
**The single hardest truth:** on one saturated GPU there is **no free lunch**. Any host GPU work
|
||||
either *preempts* the game (and steals its frames) or *waits* behind it. Capping the game works
|
||||
only because it cuts the game's **total** GPU demand and opens idle gaps. The non-capping
|
||||
equivalents are exactly three: **need less GPU** (footprint shrink), **take more** (priority — which
|
||||
costs the game fps), or **use a different GPU** (real isolation). Anything pitched as "make the game
|
||||
politely yield without losing anything" — Reflex, render-queue tricks — is a **placebo** here (§7).
|
||||
|
||||
**Action list, highest leverage first** (detail in §5–§6):
|
||||
|
||||
1. **Diagnose first** (§3). Read `uniq`-vs-`fps` under the real workload + PresentMon presentation
|
||||
mode. Half a day; decides whether you're fighting (a) or (b). The repo already prints the counter.
|
||||
2. **Stop feeding NVENC RGB on the default path.** IDD-push (the install default) hands NVENC
|
||||
BGRA → NVENC runs its RGB→YUV CSC on the SM, the exact contended engine. Convert to NV12/P010 on
|
||||
the **video engine** like the WGC/DDA paths already do. Biggest in-our-control win. (§5.A)
|
||||
3. **Build a *correct* async encode pipeline** — submit on one thread, blocking-retrieve on another,
|
||||
deep surface pool, Windows completion events. Our past "pipelining didn't help" was a *same-thread*
|
||||
implementation that can't overlap; the two-thread pattern the NVENC guide mandates was never
|
||||
tried. Recovers the depth-1 serialization that produces ~50 fps, up to the priority ceiling. (§5.B)
|
||||
4. **Auto-gated REALTIME GPU priority.** Our `LocalSystem` service *can* grant it (most apps can't).
|
||||
Gate on HAGS-state + VRAM headroom to dodge the documented NVENC freeze. (§5.C)
|
||||
5. **Lock clocks / pin P-state** for jitter (cheap; fixes the light-scene "200-not-240", not the
|
||||
collapse). (§5.E)
|
||||
6. **If source-bound: swapchain-hook capture** (OBS-style) — the real escape from the compose
|
||||
ceiling. Big lift, anti-cheat tradeoffs. (§5.F)
|
||||
7. **The honest endgame for demanding titles: encode on a second GPU / the iGPU.** The only approach
|
||||
that *removes* contention instead of re-prioritizing it. We already have AMF/QSV paths. (§5.G)
|
||||
|
||||
---
|
||||
|
||||
## 1. Corrections to `host-latency-plan.md` (read before reusing it)
|
||||
|
||||
The old doc was right about the shape but several specifics are now wrong or stale:
|
||||
|
||||
- **"Windows already feeds NVENC YUV on the video engine, so it does the right thing."** True for the
|
||||
DDA and WGC paths — **false for IDD-push, which is now the install default** and feeds NVENC
|
||||
**RGB**, paying the SM-side CSC the old doc said Windows had eliminated. The default path
|
||||
*regressed* on the exact axis the doc celebrated. (§5.A, `capture/windows/idd_push.rs:545-551,743`)
|
||||
- **"`PUNKTFUNK_ENCODE_DEPTH` (default 4, ≤6) deep-pipelines."** **There is no such knob.** It exists
|
||||
only in two stale comments (`encode/windows/nvenc.rs:30`, `capture/windows/wgc.rs:57`) and is never
|
||||
parsed. The real depth knob is `PUNKTFUNK_IDD_DEPTH` (default 2), used only by IDD-push on the
|
||||
native path; GameStream and the WGC helper are hardcoded depth-1.
|
||||
- **"Async NVENC is measure-gated and probably stacks latency (Tier 3D)."** The measurement that
|
||||
produced that verdict (`capture/windows/wgc_helper.rs:131-135`) pipelined **on a single thread** —
|
||||
it queued more frames but still blocked `lock_bitstream` inline, so it added queue latency with
|
||||
**zero overlap**. That is not the pattern the NVENC guide prescribes (submit/retrieve on
|
||||
*separate* threads). The correct async pipeline is **untried**, not disproven. (§5.B)
|
||||
- **"More GPU priority is maxed and hits a hard preemption wall with no recourse."** Half right.
|
||||
Priority *is* near-maxed (HIGH), but the "no recourse" intuition is wrong: a **higher-priority GPU
|
||||
context does preempt a saturating graphics context at pixel granularity** — that is precisely how
|
||||
NVIDIA VR Async-TimeWarp injects a frame into a busy game
|
||||
([VRWorks Context Priority](https://developer.nvidia.com/vrworks/headset/contextpriority)). And we
|
||||
default to HIGH, leaving **REALTIME unused** even though our SYSTEM service can grant it. (§5.C)
|
||||
- **"Force Composed Flip / double-refresh recovers the 'capture sees half the frames' loss."** The
|
||||
"half the frames" effect is **specifically a DLSS-Frame-Generation flip-metering artifact**
|
||||
(FG v310.x+ / RTX 50-series), *not* a general property of independent-flip games — normal
|
||||
fullscreen flip games are captured at full rate by DDA. So composed-flip is a **narrow** fix, not a
|
||||
general lever. ([Apollo #676 — DDA captured a flip game at full 120 fps](https://github.com/ClassicOldSong/Apollo/issues/676),
|
||||
[Sunshine #3621 — version-pinned to FG 310.x](https://github.com/LizardByte/Sunshine/issues/3621))
|
||||
- **"NvFBC is a possible low-overhead capture path."** **Dead on Windows** — deprecated, frozen at
|
||||
Capture SDK 7.1 / Win10-1803
|
||||
([NVIDIA deprecation bulletin](https://developer.download.nvidia.com/designworks/capture-sdk/docs/NVFBC_Win10_Deprecation_Tech_Bulletin.pdf)).
|
||||
Linux-only, and there only via the consumer `keylase` patch.
|
||||
|
||||
What the old doc got right and still holds: feeding NVENC RGB is backwards; the source/compose ceiling
|
||||
is real and upstream of encode; split-encode is a pixel-rate lever not a contention lever; the
|
||||
honest residual ceiling at 100% GPU. Those carry forward.
|
||||
|
||||
---
|
||||
|
||||
## 2. How the pipeline actually serializes today (verified against current code)
|
||||
|
||||
The capture→encode loop is a **fixed-cadence pacer** (`gamestream/stream.rs:375-480`,
|
||||
`punktfunk1.rs:2430-2540`): every `1/target_fps` tick it grabs the freshest frame with a
|
||||
**non-blocking** `try_latest()`, and **if nothing new arrived it re-encodes the held frame** (a
|
||||
near-empty P-frame). So the **outbound fps is pinned at `target_fps` no matter what the source did** —
|
||||
which is *why the raw fps counter lies* under contention. The only honest signal is the `uniq` /
|
||||
`diag_new` counter (`stream.rs:380`, `punktfunk1.rs:2433-2436`), and the code itself states the
|
||||
diagnostic: *"low new_fps at high send rate ⇒ the source isn't producing frames, not an encode
|
||||
stall"* (`punktfunk1.rs:2466-2468`).
|
||||
|
||||
The encode round-trip (NVENC, the dominant path):
|
||||
|
||||
- `submit` → `encode_picture` (`encode/windows/nvenc.rs:722`) is a **non-blocking** ASIC launch; it
|
||||
pushes onto a `pending` FIFO.
|
||||
- `poll` → `lock_bitstream` (`nvenc.rs:801`) **blocks the same thread** until that frame's encode
|
||||
completes. The session is **synchronous** — no `enableEncodeAsync`, no completion event.
|
||||
- The only thread split is **encode-vs-network-send**, never submit-vs-retrieve.
|
||||
|
||||
So at depth-1 the loop is strictly serial: `capture (+convert) → submit → block in lock_bitstream →
|
||||
hand AU to the send thread`. The arithmetic matches the symptom — `1000/17 ≈ 59` and `1000/13 ≈ 77`
|
||||
fps bracket the observed ~50, the signature of **one frame in flight per round-trip**, not an ASIC
|
||||
throughput wall.
|
||||
([independent NVENC latency study: ~7 frames across all presets](https://arxiv.org/html/2511.18688v2))
|
||||
|
||||
Where the per-frame GPU work lands, by path (this is the crux of contention):
|
||||
|
||||
| Path | Colour-convert | Extra copy | NVENC input | Contended-engine load/frame |
|
||||
|---|---|---|---|---|
|
||||
| **IDD-push** (install default) | **none → NVENC internal RGB→YUV on the SM** | `CopyResource` BGRA→out-ring (3D), `idd_push.rs:743` | **BGRA/Rgb10a2** | **highest** (SM CSC + 3D copy) |
|
||||
| **WGC** (fallback default) | `VideoProcessorBlt` → NV12 on the **video engine**, `wgc.rs:631` | none (encodes pool texture in place) | NV12/P010 | low |
|
||||
| **DDA** | `VideoProcessorBlt` → NV12 on the **video engine**, `dxgi.rs:1657-1762` | one `CopyResource` (3D) to release the dup fast, `dxgi.rs:3099` | NV12/P010 | medium |
|
||||
| **Linux NVENC** | **none → NVENC internal RGB→YUV on the SM** (default) | CUDA dev→dev copy + `cuStreamSynchronize` | RGBZ/BGRZ (NV12 only if `PUNKTFUNK_NV12` *and* `PUNKTFUNK_ZEROCOPY`) | high |
|
||||
|
||||
Measured magnitude of "RGB vs NV12 to the encoder":
|
||||
[**RGB input ≈ video-engine 40% + 3D/CUDA 15%; NV12 input ≈ video 26% + 3D 2%**](https://hardforum.com/threads/can-someone-explain-to-me-how-nvenc-obs-work-with-nvidia-gpus-and-the-gpu-load-they-cause.2025896/).
|
||||
NVENC's guide confirms the mechanism: *"Encoding of RGB contents"* is on the explicit list of
|
||||
features that **internally use CUDA**
|
||||
([NVENC prog-guide §Encoder Features using CUDA](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html)).
|
||||
|
||||
---
|
||||
|
||||
## 3. Diagnose first — cheap, decisive, do before any code
|
||||
|
||||
Everything in §5 is gated on knowing whether you're fighting bottleneck (a) or (b). The dev VM
|
||||
cannot reproduce this — run on the **RTX 4090 Windows box** (and a real NVIDIA Linux box) with an
|
||||
actual saturating game.
|
||||
|
||||
1. **Run with `PUNKTFUNK_PERF=1` and read `uniq` vs `fps`** under CS2 at GPU-100%:
|
||||
- `fps`≈target but `uniq`→40–50 ⇒ **(b) source ceiling** — the compositor/IDD only produced
|
||||
40–50 unique frames. No encode/priority fix exceeds that number. Go to §5.F.
|
||||
- both `fps` and `uniq`→40–50, with `encode_ms` 13–17 ⇒ **(a) feed contention** — the round-trip
|
||||
is starving. Go to §5.A/B/C.
|
||||
2. **Classify the game's presentation with [PresentMon](https://github.com/GameTechDev/PresentMon)** —
|
||||
"Presented FPS" vs "Displayed FPS" and **Presentation Mode** (Hardware: Independent Flip vs
|
||||
Composed: Flip). Independent-Flip + `uniq` ≪ Presented ⇒ source/flip problem; **Presented FPS
|
||||
itself** collapsed ⇒ the game is genuinely GPU-bound and no capture trick invents the missing
|
||||
frames.
|
||||
3. Log `cap_us` / `enc_us` / `pace_us` p50/p99 alongside to localise the stall.
|
||||
|
||||
> **Necessary-but-not-sufficient caveat:** if the game only *rendered* 50 frames because it's
|
||||
> GPU-bound, **nothing downstream creates the other 90**. Source fixes address (b) only; the
|
||||
> throughput of a saturated single GPU is split between game and host no matter what.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current-state audit (what's shipped / regressed / missing)
|
||||
|
||||
| Area | State | Where |
|
||||
|---|---|---|
|
||||
| Thread priority (Win) | HIGH class + MMCSS "Games" + 1 ms timer | `session_tuning.rs` ✅ |
|
||||
| Thread priority (Linux) | `setpriority` −10/−5 — **native path only; GameStream Linux threads get none** | `punktfunk1.rs:1977` ⚠ |
|
||||
| GPU sched priority | `D3DKMTSetProcessSchedulingPriorityClass` **HIGH(4)** default; `realtime` opt-in, no auto-gate; cross-process onto WGC helper | `capture/windows/dxgi.rs:208-330` ⚠ |
|
||||
| GPU thread/latency | `SetGPUThreadPriority(0x4000001E)`, `SetMaximumFrameLatency(1)` | `dxgi.rs:193-200` ✅ |
|
||||
| CSC off-SM (Win SDR) | WGC/DDA video-engine NV12 ✅ — **IDD-push (default) RGB→SM ✗** | `wgc.rs:631` / `idd_push.rs:545` |
|
||||
| CSC off-SM (Win HDR) | on-SM unless `PUNKTFUNK_HDR_SHADER_P010` (default **off**) | `wgc.rs:603` ⚠ |
|
||||
| CSC off-SM (Linux) | RGB→SM by default; NV12 is **double-opt-in** (`PUNKTFUNK_NV12`+`PUNKTFUNK_ZEROCOPY`) | `encode/linux/mod.rs:104` ⚠ |
|
||||
| Encode pipeline | depth-1 synchronous, inline `lock_bitstream`; IDD-push native = depth-2 same-thread | `nvenc.rs:801` ⚠ |
|
||||
| Split-encode | 2-way >1 Gpix/s (HEVC/AV1); disabled 10-bit (correct); proper enum | `nvenc.rs:424-447` ✅ |
|
||||
| Zero-copy register-in-place | yes (no encoder-owned pool copy) — IDD-push adds its own out-ring copy | `nvenc.rs:623` ✅/⚠ |
|
||||
| AMF tuning | `usage=ultralowlatency`, `preanalysis=false` | `ffmpeg_win.rs:215-219` ✅ |
|
||||
| QSV tuning | `async_depth=1`, `low_power=1` (VDEnc) | `ffmpeg_win.rs:226-227` ✅ |
|
||||
| Intra-refresh / infinite GOP | yes (killed the periodic-IDR freeze) | ✅ |
|
||||
| encode\|send split + paced send + sendmmsg + 32 MB sockbuf | yes | `stream.rs`, `transport/qos.rs` ✅ |
|
||||
| **Clock / P-state pin** | **none** (zero hits repo-wide) | ✗ |
|
||||
| **Async NVENC (2-thread)** | **none** | ✗ |
|
||||
| **Frame-source escape (hook/NvFBC-Linux)** | **none** | ✗ |
|
||||
| **Second-GPU / iGPU encode offload** | **none** | ✗ |
|
||||
| DSCP/QoS | implemented, `PUNKTFUNK_DSCP` opt-in (default off) | `transport/qos.rs` ⚠ |
|
||||
|
||||
---
|
||||
|
||||
## 5. The levers, ranked, with honest verdicts
|
||||
|
||||
### A. Stop feeding NVENC RGB on the default path — **highest in-our-control win**
|
||||
|
||||
The default Windows capture path (IDD-push) and the default Linux path both hand NVENC packed RGB,
|
||||
forcing NVENC's internal RGB→YUV CSC onto the SM the game saturates. The WGC and DDA paths already
|
||||
solved this by doing the CSC with `ID3D11VideoProcessor::VideoProcessorBlt` (video engine) and
|
||||
feeding NV12/P010. **Make IDD-push and Linux do the same.**
|
||||
|
||||
- **Windows IDD-push:** add a `VideoProcessorBlt` BGRA→NV12 (SDR) / FP16→P010 (HDR) step into the
|
||||
out-ring, exactly like `wgc.rs:631` / `dxgi.rs:1657-1762`, and feed `NV_ENC_BUFFER_FORMAT_NV12` /
|
||||
`..._YUV420_10BIT`. This *also* lets you drop the separate `CopyResource` (the convert writes the
|
||||
out-ring), removing **both** contended-engine ops per frame. Plug it into `SessionPlan`
|
||||
(`session_plan.rs`, the single owner of the capture/encode decision) so capture and encode can't
|
||||
disagree on the format.
|
||||
- **Linux:** make NV12 the **default** for the tiled zero-copy path (it's gated behind
|
||||
`PUNKTFUNK_NV12` *and* `PUNKTFUNK_ZEROCOPY` today — `encode/linux/mod.rs:104`,
|
||||
`linux/zerocopy/egl.rs:272`), and feed NVENC `NV_ENC_BUFFER_FORMAT_NV12`. The GL detile already
|
||||
runs; emitting NV12 from it replaces the swizzle at ~equal cost and deletes NVENC's CSC.
|
||||
- **Windows HDR:** flip `PUNKTFUNK_HDR_SHADER_P010` on by default (or, better, use a video-engine
|
||||
P010 convert where the VP supports it).
|
||||
|
||||
**Verdict: REAL, but honestly *conditional*.** Feeding NV12 provably removes NVENC's internal CUDA
|
||||
CSC — but the convert has to land **off** the SM to fully pay off. `VideoProcessorBlt` is *designed*
|
||||
to use fixed-function video hardware and the hardforum numbers back the 15%→2% drop, **but no NVIDIA
|
||||
doc explicitly confirms `VideoProcessorBlt` runs off-SM on GeForce** — treat the "video engine" claim
|
||||
as well-founded-but-unverified and confirm on-box with `nvidia-smi dmon` (watch the `enc`/`sm`
|
||||
columns) before and after. Do **not** convert with a CUDA/3D shader and call it done — that just
|
||||
relocates the CSC to the same SM (Sunshine's RGB→NV12 CUDA kernel still contends).
|
||||
|
||||
### B. A *correct* async encode pipeline (the untried encoder lever)
|
||||
|
||||
The NVENC Programming Guide is explicit: *"The main encoder thread should be used only to submit
|
||||
work… (non-blocking `NvEncEncodePicture`). Output buffer processing — waiting on the completion
|
||||
event in asynchronous mode, or calling `NvEncLockBitstream` in synchronous mode — should be done in
|
||||
the **secondary thread**."*
|
||||
([NVENC prog-guide, threading model](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html))
|
||||
We do the opposite — submit and blocking-retrieve on **one** thread. Queuing more `pending` entries
|
||||
(IDD-push depth-2, or the abandoned wgc_helper experiment) adds queue latency with **no overlap**,
|
||||
which is exactly the "deeper pipeline only stacks latency" result we recorded. It was the wrong
|
||||
implementation, not a disproof.
|
||||
|
||||
The fix: **submit on the capture/encode thread; do `lock_bitstream` on a dedicated retrieve thread;
|
||||
hold a deep input+output surface pool (≈4–8); on Windows register a `completionEvent` per output
|
||||
buffer (`enableEncodeAsync=1`) — on Linux async events are unsupported, so use the same two-thread
|
||||
split with a blocking retrieve.**
|
||||
([async is Windows/WDDM-only](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html);
|
||||
FFmpeg models the same knob as `delay`/`async_depth`,
|
||||
[libavcodec/nvenc.c](https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/nvenc.c)).
|
||||
|
||||
This lets the WDDM scheduler find a **backlog** when it finally grants the encoder context a slice,
|
||||
and drain several frames back-to-back, while the ASIC encodes frame N as the contended engines do
|
||||
frame N+1's convert.
|
||||
|
||||
**Verdict: REAL throughput recovery for the depth-1 collapse, latency cost +1–2 frames, ceiling-bounded.**
|
||||
The honest bound (and why this is *second* to §A/§C): pipelining cannot manufacture GPU time — if the
|
||||
scheduler grants the encode context only X% under load, depth only guarantees work is *ready* for
|
||||
each grant; it can't raise X. That is why Sunshine's documented lever for "GPU heavily loaded" is
|
||||
**priority**, not depth. So §B recovers the serialization loss; §A/§C raise the share it's bounded by.
|
||||
Watch out: this **forecloses sub-frame slice output** (mutually exclusive with `enableEncodeAsync`),
|
||||
and HAGS can spike the *submit* call itself
|
||||
([100–200 ms `nvEncEncodePicture` stalls under HAGS](https://forums.developer.nvidia.com/t/windows-11-hardware-accelerated-gpu-scheduling-issue/286128)).
|
||||
|
||||
### C. Auto-gated REALTIME GPU scheduling priority
|
||||
|
||||
Raising the host process's WDDM GPU priority is **the** proven single-PC production lever — OBS and
|
||||
Sunshine both set `D3DKMT_SCHEDULINGPRIORITYCLASS_REALTIME` to stop being descheduled behind
|
||||
fullscreen games
|
||||
([OBS commit](https://github.com/obsproject/obs-studio/commit/ec769ef008b748f7dfba211daec9eb203ea4bea0),
|
||||
[Sunshine `display_base.cpp`](https://raw.githubusercontent.com/LizardByte/Sunshine/master/src/platform/windows/display_base.cpp)).
|
||||
It works **independently of HAGS** (HAGS does *not* reassign cross-process priority — Microsoft:
|
||||
*"Windows continues to control prioritization"*
|
||||
[DirectX devblog](https://devblogs.microsoft.com/directx/hardware-accelerated-gpu-scheduling/)).
|
||||
|
||||
We ship only **HIGH(4)** by default with a static `realtime` opt-in and **no auto-gate**. Two things
|
||||
to change:
|
||||
|
||||
- **We can actually grant REALTIME.** It needs `SeIncreaseBasePriorityPrivilege`, which an unelevated
|
||||
app lacks (OBS logs the failure) — **but our host runs as a `LocalSystem` service, which holds it.**
|
||||
The lever is available to us specifically.
|
||||
- **Gate it to dodge the freeze.** REALTIME + NVIDIA + HAGS-on + near-full-VRAM is a **documented
|
||||
NVENC hang** (Sunshine ships `nvenc_realtime_hags` to downgrade to HIGH for exactly this;
|
||||
[Sunshine config](https://docs.lizardbyte.dev/projects/sunshine/latest/md_docs_2configuration.html),
|
||||
[NVIDIA repro](https://forums.developer.nvidia.com/t/bug-report-nvenc-encoder-hangs-on-windows-when-using-d3d11-in-real-time-mode/357466)).
|
||||
Implement the old plan's "Tier 3B": probe HAGS via `D3DKMTQueryAdapterInfo` and VRAM headroom via
|
||||
`IDXGIAdapter3::QueryVideoMemoryInfo` (continuously); use REALTIME only when HAGS-off, or HAGS-on
|
||||
with comfortable VRAM headroom; downgrade to HIGH the instant VRAM tightens.
|
||||
|
||||
**Verdict: REAL — the genuine ceiling-raiser — but it is the no-free-lunch lever.** Priority is how
|
||||
the host *takes* GPU time from the game; it measurably **costs the game fps**
|
||||
([Doom Eternal 121→60 with Sunshine running](https://github.com/LizardByte/Sunshine/issues/3703)).
|
||||
That's acceptable for a streaming host (the remote view is the product), but say so plainly and make
|
||||
the class operator-configurable (we already expose `PUNKTFUNK_GPU_PRIORITY_CLASS`).
|
||||
|
||||
### D. Multi-vendor encoder hygiene (AMF/QSV) — mostly done, one caveat
|
||||
|
||||
Our `*_amf`/`*_qsv` libavcodec config already follows the research's advice: AMF
|
||||
`usage=ultralowlatency` + `preanalysis=false` (`ffmpeg_win.rs:215`), QSV `async_depth=1` +
|
||||
`low_power=1` VDEnc path (`:226`). Keep them. Two notes:
|
||||
|
||||
- **AMF/QSV suffer contention *worse* than NVENC.** OBS: *"For Intel and AMD GPUs, the hardware
|
||||
encoder requires significant resources of the same type a 3D app/game requires… different from
|
||||
NVIDIA's NVENC, which has dedicated encoding circuits"*
|
||||
([OBS KB](https://obsproject.com/forum/threads/how-to-debug-encoding-overloaded.168625/)). So on an
|
||||
AMD/Intel host the collapse is *expected to be harder* — and §G (iGPU offload) is even more
|
||||
attractive there.
|
||||
- **The AMF busy-poll floor** (a fixed-sleep `QueryOutput` poll imposes ~15 ms via timer
|
||||
granularity) is fixed in FFmpeg's amf wrapper (Cameron Gutman's `QUERY_TIMEOUT` patch); since we
|
||||
go through libavcodec we inherit it — just **confirm the pinned FFmpeg build includes it**.
|
||||
([ffmpeg-devel](https://www.mail-archive.com/ffmpeg-devel@ffmpeg.org/msg170489.html))
|
||||
|
||||
**Verdict: REAL but largely already captured.** No big win left here except via §G.
|
||||
|
||||
### E. Lock clocks / pin P-state — cheap jitter fix, not a collapse fix
|
||||
|
||||
NVIDIA's adaptive clocking downclocks between our small bursty frames and pays a ramp tax every
|
||||
frame — most visible in the *light* scene (the "200-not-240"). Pin it:
|
||||
|
||||
- **Windows:** NvAPI per-application DRS `PREFERRED_PSTATE = PREFER_MAX` scoped to our exe (this is
|
||||
exactly Sunshine's `nvenc_latency_over_power`,
|
||||
[Sunshine nvprefs](https://github.com/LizardByte/Sunshine/blob/master/src/platform/windows/nvprefs/driver_settings.cpp)).
|
||||
**Crash-safe undo is mandatory** — persist an undo record to `%ProgramData%\punktfunk\` *before*
|
||||
applying, revert a stale profile on next start, so a crash never leaves the user's control panel
|
||||
modified.
|
||||
- **Linux:** `nvidia-smi -lgc`/NVML `nvmlDeviceSetGpuLockedClocks` (needs root/`CAP_SYS_ADMIN`; query
|
||||
`nvmlDeviceGetMaxClockInfo`, lock to that, restore on teardown *and* SIGTERM). Plus the newly-added
|
||||
`CudaNoStablePerfLimit` driver profile — *new in R580/595, so usable on the 595 box* — to defeat
|
||||
the CUDA "Force P2" memory-clock clamp.
|
||||
- Gate behind `PUNKTFUNK_PIN_CLOCKS`; **default off on battery / Steam Deck** (pinning is harmful
|
||||
there).
|
||||
|
||||
**Verdict: REAL for latency *stability*, marginal for the saturated collapse** (at 100% util the game
|
||||
already pins P0). Cheap, low risk, do it for the light-scene win.
|
||||
|
||||
### F. Escape the frame-source ceiling — only if §3 says (b)
|
||||
|
||||
If `uniq` is the wall, no encoder/priority work helps — you need a better frame source.
|
||||
|
||||
- **Swapchain-hook capture (the real fix).** Inject a hook on `IDXGISwapChain::Present`/`Present1`,
|
||||
`vkQueuePresentKHR`, `wglSwapBuffers` and copy the backbuffer to a shared texture *before* the
|
||||
compositor — OBS Game Capture's mechanism. Sees **every presented frame**, no compose/refresh
|
||||
gating.
|
||||
([OBS dxgi-capture](https://github.com/obsproject/obs-studio/blob/master/plugins/win-capture/graphics-hook/dxgi-capture.cpp))
|
||||
**Tradeoffs are serious:** anti-cheat (EAC/BattlEye/Vanguard) flags injection — needs
|
||||
whitelisting/compat handling; per-graphics-API hooks; fragility across game updates. Scope it as an
|
||||
opt-in "game capture" mode, not the default.
|
||||
- **NvFBC:** **not an option on Windows** (dead, §1). On **Linux** it's viable via the consumer
|
||||
keylase patch and captures below composition — worth a flag for the Linux NVIDIA host.
|
||||
- **Compose-flip (narrow):** the topmost 1×1 layered-window trick (we already have
|
||||
`composed_flip.rs`) forces DWM composition and fixes specifically the **DLSS-Frame-Gen** half-rate
|
||||
case. Adds host-display latency; don't enable globally.
|
||||
- **WGC "deliver 2× rate":** Apollo sets `MinUpdateInterval = 1e7/(fps*2)` so the pacer always has a
|
||||
fresh frame to pick ([Apollo](https://github.com/ClassicOldSong/Apollo/pull/785)); we set it to 1×
|
||||
refresh (`wgc.rs:310`). Cheap tweak to try on the WGC path.
|
||||
|
||||
**Verdict: swapchain-hook is REAL and the only general escape; the rest are narrow.** None invents
|
||||
frames the game didn't render.
|
||||
|
||||
### G. The honest endgame — encode on a second GPU / the iGPU
|
||||
|
||||
For *demanding* titles that saturate the GPU even when capped, the only thing that **removes**
|
||||
contention rather than re-prioritizing it is to run the capture→convert→encode pipeline on a
|
||||
**different** GPU — a second dGPU or, more realistically, the **iGPU** (Intel QuickSync / AMD VCN),
|
||||
which most desktops already have. Render on the gaming GPU, copy the frame across the adapter once,
|
||||
encode on the iGPU's independent media engine. This is the textbook "stream on a separate encoder"
|
||||
play, and the OBS "second GPU is harmful" verdict does **not** apply — that verdict is about moving
|
||||
*only the NVENC block*; moving capture + CSC + copies off the gaming GPU genuinely frees it.
|
||||
([OBS forum](https://obsproject.com/forum/threads/can-you-use-a-2nd-gpu-to-eliminate-encoder-overload.149644/))
|
||||
|
||||
We're unusually well-placed for this: we already have working AMF and QSV backends
|
||||
(`encode/windows/ffmpeg_win.rs`) and the Linux VAAPI backend. The missing piece is a capture/topology
|
||||
mode that pins capture to the gaming adapter and the encoder to the iGPU adapter, with one
|
||||
cross-adapter shared-texture copy. Cost: that copy still shares VRAM bandwidth, so it's not free, but
|
||||
it's the only path that lets a demanding game and a clean stream coexist on one machine.
|
||||
|
||||
**Verdict: REAL — the cleanest isolation, and the right answer to "even capped it collapses."**
|
||||
Datacenter stacks (GeForce NOW, Stadia) "solve" this by one dedicated GPU + encoder per session;
|
||||
the consumer analogue is the iGPU.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended order of attack
|
||||
|
||||
1. **§3 Diagnose** on the RTX box + a real game. Settles (a) vs (b). *(half a day, decisive)*
|
||||
2. **§5.A NV12/P010 on the default paths** (IDD-push video-engine convert; Linux NV12 default-on;
|
||||
Windows HDR P010 default). Biggest in-our-control floor-raise; confirm off-SM with `nvidia-smi dmon`.
|
||||
3. **§5.C Auto-gated REALTIME** priority (HAGS + VRAM gate). Cheap, big, we can uniquely grant it.
|
||||
4. **§5.E Clock pin** both OSes (crash-safe undo). Cheap light-scene win.
|
||||
5. **§5.B Correct two-thread async pipeline.** Structural; recovers the depth-1 serialization.
|
||||
6. **§3-gated §5.F** source escape (swapchain hook) — only if `uniq` is the wall.
|
||||
7. **§5.G iGPU encode offload** — the strategic answer for demanding titles; larger build.
|
||||
|
||||
After 2–5 the light-scene gap closes and the saturated floor rises materially. But report the
|
||||
honest ceiling: **on one saturated GPU the game and the host split a fixed pie** — coarse WDDM
|
||||
graphics preemption caps how much priority can claw back, and a genuinely GPU-bound game that only
|
||||
*rendered* 50 frames cannot also yield 140 unique frames to capture. The only escapes from that pie
|
||||
are reducing the game's demand (cap — rejected), taking a bigger slice (priority — costs game fps),
|
||||
or a second slice of silicon (§G). Don't chase the rest with encoder micro-optimisation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Placebos & dead ends (so we don't re-propose them)
|
||||
|
||||
| Candidate | Verdict | Why |
|
||||
|---|---|---|
|
||||
| **NVIDIA Reflex / Ultra-Low-Latency / max-pre-rendered-frames** as a "non-capping yield" | ✗ placebo | Shrinks the *game's* render queue but the game still demands ~99% GPU → frees ≈0 SM headroom. Reflex needs in-game SDK (host can't force it); ULLM is host-forceable only on DX11/DX9 (DX12 since driver 551.23) and is NVIDIA's weaker mechanism. Only honest effect: µs of tail-jitter smoothing. ([Battle(non)sense LDAT data](https://forums.guru3d.com/threads/battle-non-sense-youtuber-claims-low-latency-mode-only-helps-when-gpu-load-is-99.429074/)) |
|
||||
| **HAGS on, as a contention fix** | ✗ neutral→harmful | Doesn't reassign cross-process priority (Microsoft); OBS reports it *causes* NVENC latency spikes; it's the freeze-hazard variable. Needed only to enable the VK/D3D12 realtime *queue*. ([OBS KB](https://obsproject.com/kb/hags)) |
|
||||
| **Split-frame encode (2/3/4-way) to fix contention** | ✗ (pixel-rate only) | Parallelizes the ASIC, not the contended copy/CSC; measured **zero** latency change at 4K. Correct use = raise the single-session pixel ceiling (5K@240). `splitEncodeMode=15` is the legit *disable* sentinel, not a bug. ([SDK header](https://raw.githubusercontent.com/FFmpeg/nv-codec-headers/master/include/ffnvcodec/nvEncodeAPI.h)) |
|
||||
| **Move the encoded-bitstream readback to a copy engine** | ✗ placebo | Output is KB-scale; the cost of `lock_bitstream` is the completion *wait*, not copy bandwidth. (The *input* full-frame copy is the real one — but D3D11 can't target the copy engine; zero-copy already avoids it.) |
|
||||
| **CUDA stream priority / `CUDA_DEVICE_MAX_CONNECTIONS` / `CU_CTX_SCHED_*`** | ✗ placebo cross-process | Intra-context only; the game is a *separate* context. Stream priority "will not preempt already executing work". ([CUDA docs](https://docs.nvidia.com/cuda/cuda-programming-guide/02-basics/asynchronous-execution.html)) |
|
||||
| **VK/EGL global-priority REALTIME on Linux NVIDIA** | ✗ | Not reliably granted on the proprietary driver, and moot anyway — our Linux NVENC is driven via CUDA/NVENC-SDK, not a Vulkan queue. |
|
||||
| **Windows "High performance" GPU preference** | ✗ single-GPU placebo | Only selects an adapter; real only to split work across adapters (→ that's §G). |
|
||||
| **MIG / MPS / vGPU** | ✗ N/A | MIG/vGPU are datacenter/pro + hypervisor/license; MPS is Linux-CUDA-only with no graphics notion. None apply to a consumer GPU. |
|
||||
| **NvFBC on Windows** | ✗ dead | Deprecated, frozen at Capture SDK 7.1 / Win10-1803. |
|
||||
| **Frame Generation / Smooth Motion** to "make more frames" | ✗ red herring | We stream *rendered* frames; FG adds optical-flow/tensor + present load to the same GPU → amplifies contention. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open evidence gaps (flagged honestly)
|
||||
|
||||
- Whether `ID3D11VideoProcessor::VideoProcessorBlt` (BGRA→NV12) runs **off the SM on GeForce** is not
|
||||
confirmed by any NVIDIA document — it's the linchpin of §5.A's full payoff. **Verify on-box** with
|
||||
`nvidia-smi dmon` (sm% vs enc%) on the WGC path before assuming IDD-push will match it.
|
||||
- The exact share of the 13–17 ms `encode_ms` that is *convert-on-SM* vs *scheduling-wait* is
|
||||
unmeasured. §3 + an A/B of IDD-push-RGB vs IDD-push-NV12 on the same scene settles it and tells you
|
||||
whether §5.A alone is enough or whether §5.C is doing the heavy lifting.
|
||||
- AMD VCN "degrades worse under contention" is practitioner-consensus + architecture, not an AMD
|
||||
whitepaper; treat the *direction* as solid, the magnitude as TBD.
|
||||
@@ -1,5 +1,14 @@
|
||||
# Host latency & the GPU-contention collapse — analysis + prioritized plan
|
||||
|
||||
> **⚠ Partially superseded (2026-06-25) by [`gpu-contention-investigation.md`](gpu-contention-investigation.md).**
|
||||
> That follow-up re-verified this plan against the current code and overturned several specifics:
|
||||
> the default Windows path (IDD-push) now feeds NVENC **RGB** (regressing the §0A "Windows does it
|
||||
> right" claim); `PUNKTFUNK_ENCODE_DEPTH` never existed (phantom knob); the "async NVENC stacks
|
||||
> latency" result was a *same-thread* implementation, not a disproof of a correct two-thread pipeline;
|
||||
> "capture sees half the frames" is DLSS-Frame-Gen-specific, not general; and NvFBC is dead on
|
||||
> Windows. Use the new doc's ranked action list. The tiers/dropped-placebo analysis below remain a
|
||||
> useful record.
|
||||
|
||||
Scope: Windows + Linux GameStream/punktfunk1 hosts. Priority: **latency**, and specifically the
|
||||
"saturating game starves the stream" headache:
|
||||
|
||||
|
||||
+389
-731
File diff suppressed because it is too large
Load Diff
@@ -71,21 +71,47 @@ read it from `%ProgramData%\punktfunk\web-password`.
|
||||
| `install-pf-vdisplay.ps1` | Runs at install time (elevated): trust cert → gated device-node create (nefconc) → `pnputil` install. |
|
||||
| `../../scripts/windows/web-run.cmd` | The `PunktfunkWeb` task action: loads the mgmt token + login password env, runs the bundled `bun` on the Nitro server (`:3000`). |
|
||||
| `../../scripts/windows/web-setup.ps1` | Install-time (elevated): write the ACL'd console password, register the `PunktfunkWeb` task + firewall rule, start it. |
|
||||
| `pf-vdisplay/` | **Vendored** signed pf-vdisplay driver: `pf_vdisplay.inf` / `pf_vdisplay.cat` / `pf_vdisplay.dll` / `punktfunk-driver.cer`. Built from `vdisplay-driver/`. |
|
||||
| `vdisplay-driver/` | The all-Rust IddCx **driver source** (`pf-vdisplay` crate + vendored `wdf-umdf*` bindings) + `deploy-dev.ps1` (build/sign/install for dev). |
|
||||
| `pf-vdisplay/` | **Vendored** signed pf-vdisplay driver: `pf_vdisplay.inf` / `pf_vdisplay.cat` / `pf_vdisplay.dll` / `punktfunk-driver.cer`. Built from `drivers/`. |
|
||||
| `drivers/` | The all-Rust IddCx **driver source** workspace: the `pf-vdisplay` crate on `wdk-sys` / windows-drivers-rs + the owned `pf-driver-proto` ABI + `wdk-iddcx` / `wdk-probe`, plus `deploy-dev.ps1` (build/sign/install for dev). |
|
||||
| `reset-pf-vdisplay.ps1` | **Dev:** recover a wedged driver — stop host → reap ghost monitor nodes → reload the adapter → start host (no reboot). See *Dev iteration* below. |
|
||||
| `redeploy-pf-vdisplay.ps1` | **Dev:** one-shot redeploy — (optional) build → stop host → `deploy-dev.ps1 -Install` → reload adapter → start host. |
|
||||
| `nvenc/nvenc.def`, `nvenc/gen-nvenc-importlib.ps1` | Synthesise `nvencodeapi.lib` for the `--features nvenc` link (llvm-dlltool / lib.exe). |
|
||||
|
||||
> **Vendored driver:** pf-vdisplay is our **all-Rust IddCx** virtual display (UMDF2), built from
|
||||
> `packaging/windows/vdisplay-driver/`. It replaced the vendored SudoVDA C++ driver — full story in
|
||||
> `packaging/windows/drivers/`. It replaced the vendored SudoVDA C++ driver — full story in
|
||||
> [`docs/windows-virtual-display-rust-port.md`](../../docs/windows-virtual-display-rust-port.md). The
|
||||
> **signed** output (`pf_vdisplay.dll`/`.inf`/`.cat` + `punktfunk-driver.cer`; signer
|
||||
> `punktfunk-ds-test` — the same cert the gamepad drivers ship, Class=Display, HWID `root\pf_vdisplay`)
|
||||
> is checked in under `pf-vdisplay/`. To refresh it after a driver-source change, rebuild + re-sign with
|
||||
> `vdisplay-driver/deploy-dev.ps1` and copy the staged `pf_vdisplay.{dll,inf,cat}` over the vendored
|
||||
> `drivers/deploy-dev.ps1` and copy the staged `pf_vdisplay.{dll,inf,cat}` over the vendored
|
||||
> copies. nefcon (the device-node tool — the install creates the node with it, **never** `devgen`, which
|
||||
> leaves persistent phantom devices) **is** fetched + SHA-256-verified from its pinned release in
|
||||
> `stage-pf-vdisplay.ps1`.
|
||||
|
||||
## Dev iteration on the test box (driver)
|
||||
|
||||
Two helpers wrap the painful manual steps of iterating on the pf-vdisplay driver against a live host
|
||||
service. Run **elevated**; both default to the `PunktfunkHost` service.
|
||||
|
||||
```powershell
|
||||
# Recover a WEDGED driver. Symptom: every session fails with
|
||||
# create virtual output: pf-vdisplay ADD ...: DeviceIoControl(0x222400): Element nicht gefunden (0x80070490)
|
||||
# i.e. ERROR_NOT_FOUND — sustained ADD/REMOVE churn exhausted the IddCx monitor slots (ghost
|
||||
# "Generic Monitor (punktfunk)" nodes pile up, target_ids climb). A host restart's CLEAR_ALL does NOT
|
||||
# fix it; the driver instance must be reloaded. This clears the ghosts + cycles the adapter (no reboot —
|
||||
# this box boots to Proxmox).
|
||||
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1 -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
|
||||
# Redeploy a driver build cleanly (stop host → install with a strictly-increasing DriverVer → reload
|
||||
# adapter → start host). -Build runs `cargo build` first, but ONLY from an MSVC dev shell
|
||||
# (LIBCLANG_PATH + Version_Number=10.0.26100.0); otherwise build separately and omit -Build.
|
||||
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1 -Build -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
```
|
||||
|
||||
The driver should reclaim monitor slots on REMOVE so churn can't wedge it; until it does, `reset` is
|
||||
the recovery. From a Linux box drive either over SSH, e.g.
|
||||
`ssh user@box 'powershell -ExecutionPolicy Bypass -File C:\...\reset-pf-vdisplay.ps1'`.
|
||||
|
||||
## Build locally (Windows, MSVC + Windows SDK + Inno Setup)
|
||||
|
||||
```powershell
|
||||
|
||||
Generated
+3
-3
@@ -398,7 +398,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
name = "pf-vdisplay"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-vdisplay-proto",
|
||||
"pf-driver-proto",
|
||||
"thiserror",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
@@ -408,7 +408,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay-proto"
|
||||
name = "pf-driver-proto"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
@@ -776,7 +776,7 @@ dependencies = [
|
||||
name = "wdk-probe"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"pf-vdisplay-proto",
|
||||
"pf-driver-proto",
|
||||
"wdk",
|
||||
"wdk-build",
|
||||
"wdk-sys",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Separate from the main cargo workspace (own [workspace] root) because driver crates are cdylibs built
|
||||
# with the WDK toolchain (cargo-wdk / wdk-build) on Windows only. Path-deps the shared ABI crate
|
||||
# crates/pf-vdisplay-proto from the main tree.
|
||||
# crates/pf-driver-proto from the main tree.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wdk-probe", "wdk-iddcx", "pf-vdisplay"]
|
||||
@@ -20,7 +20,7 @@ wdk = "0.4.1"
|
||||
wdk-sys = "0.5.1"
|
||||
wdk-build = "0.5.1"
|
||||
wdk-iddcx = { path = "wdk-iddcx" }
|
||||
pf-vdisplay-proto = { path = "../../../crates/pf-vdisplay-proto" }
|
||||
pf-driver-proto = { path = "../../../crates/pf-driver-proto" }
|
||||
|
||||
# Vendored windows-drivers-rs 0.5.1 (the published, self-contained crates) + an added `iddcx`
|
||||
# ApiSubset (M1 — bindgens iddcx/1.10/IddCx.h reusing wdk_default for WDF type-identity). Redirect ALL
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build-stage-sign-install the NEW-tree pf-vdisplay UMDF IddCx driver (packaging/windows/drivers/) for
|
||||
local dev/test on the RTX box. The wdk-sys / windows-drivers-rs analogue of the superseded
|
||||
vdisplay-driver/deploy-dev.ps1.
|
||||
|
||||
.DESCRIPTION
|
||||
Stages the freshly built pf_vdisplay.dll, CLEARS its FORCE_INTEGRITY PE bit (this tree's wdk-build links
|
||||
/INTEGRITYCHECK, which a self-signed cert can't satisfy — the old wdf-umdf tree didn't), signs it with
|
||||
the self-signed test cert, stamps a STRICTLY-INCREASING DriverVer into the INF, generates + signs the
|
||||
catalog, and (with -Install) pnputil-installs it.
|
||||
|
||||
Build first: from packaging/windows/drivers/, in an MSVC dev shell with LIBCLANG_PATH +
|
||||
Version_Number=10.0.26100.0, run `cargo build`.
|
||||
|
||||
Re-deploying needs a HIGHER DriverVer than the installed one or pnputil silently keeps the old binary —
|
||||
hence the 9.9.MMdd.HHmm scheme (the vendored build is 9.5.*). If the host service is running it holds the
|
||||
driver: `punktfunk-host service stop`, deploy, then start it, for a clean test.
|
||||
.PARAMETER Install
|
||||
Also add the driver package to the store + (if absent) create the Root\pf_vdisplay devnode via nefconc.
|
||||
Needs an ELEVATED shell.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Stage = 'C:\Users\Public\pfvd-stage-deploy',
|
||||
[string]$Thumbprint = '6A52984E54376C45A1C236B1A2C8A746C5AB6131',
|
||||
[string]$Nefconc = 'C:\Users\Public\virtual-display-rs\installer\files\nefconc.exe',
|
||||
[switch]$Install
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$dll = Join-Path $root 'target\x86_64-pc-windows-msvc\debug\pf_vdisplay.dll'
|
||||
$inx = Join-Path $root 'pf-vdisplay\pf_vdisplay.inx'
|
||||
$clear = Join-Path $root '..\clear-force-integrity.ps1'
|
||||
if (-not (Test-Path $dll)) { throw "driver not built: $dll (cargo build in packaging/windows/drivers first)" }
|
||||
|
||||
$kits = 'C:\Program Files (x86)\Windows Kits\10\bin'
|
||||
function Find-Tool([string]$name, [string]$arch) {
|
||||
(Get-ChildItem "$kits\*\$arch\$name" -EA SilentlyContinue | Sort-Object FullName | Select-Object -Last 1).FullName
|
||||
}
|
||||
$signtool = Find-Tool 'signtool.exe' 'x64'
|
||||
$stampinf = Find-Tool 'stampinf.exe' 'x64'
|
||||
$inf2cat = Find-Tool 'Inf2Cat.exe' 'x86'
|
||||
foreach ($t in @($signtool, $stampinf, $inf2cat)) { if (-not $t) { throw 'a WDK tool (signtool/stampinf/Inf2Cat) was not found' } }
|
||||
|
||||
if (Test-Path $Stage) { Remove-Item $Stage -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
$stagedDll = Join-Path $Stage 'pf_vdisplay.dll'
|
||||
$stagedInf = Join-Path $Stage 'pf_vdisplay.inf'
|
||||
$stagedCat = Join-Path $Stage 'pf_vdisplay.cat'
|
||||
Copy-Item $dll $stagedDll -Force
|
||||
Copy-Item $inx $stagedInf -Force # stampinf rewrites this copy in place
|
||||
|
||||
# Clear FORCE_INTEGRITY BEFORE signing (the clear edits the PE, which invalidates any signature).
|
||||
& $clear -Path $stagedDll | Out-Null
|
||||
|
||||
# DriverVer must strictly increase. Installed is 9.5.* — 9.9.MMdd.HHmm always wins on the same day.
|
||||
$now = Get-Date
|
||||
$ver = '9.9.{0}.{1}' -f $now.ToString('MMdd'), $now.ToString('HHmm')
|
||||
|
||||
& $signtool sign /fd SHA256 /sha1 $Thumbprint $stagedDll | Out-Null
|
||||
& $stampinf -f $stagedInf -d '*' -a 'amd64' -u '2.15.0' -v $ver | Out-Null
|
||||
& $inf2cat /driver:$Stage /os:10_X64 /uselocaltime | Out-Null
|
||||
& $signtool sign /fd SHA256 /sha1 $Thumbprint $stagedCat | Out-Null
|
||||
Write-Host "staged + signed pf-vdisplay (new tree) DriverVer=$ver -> $Stage"
|
||||
|
||||
if ($Install) {
|
||||
& pnputil /add-driver $stagedInf /install
|
||||
$present = Get-PnpDevice -EA SilentlyContinue |
|
||||
Where-Object { $_.InstanceId -match 'PF_VDISPLAY' -or $_.FriendlyName -match 'punktfunk Virtual Display' }
|
||||
if (-not $present) {
|
||||
if (-not (Test-Path $Nefconc)) { throw "nefconc not found: $Nefconc" }
|
||||
& $Nefconc --create-device-node --hardware-id 'root\pf_vdisplay' --class-name Display --class-guid '{4d36e968-e325-11ce-bfc1-08002be10318}' | Out-Null
|
||||
Start-Sleep 2
|
||||
& pnputil /add-driver $stagedInf /install
|
||||
}
|
||||
Write-Host "installed pf-vdisplay DriverVer=$ver"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
# pf-vdisplay — the all-Rust UMDF IddCx virtual-display driver (M1 step-2 rewrite onto wdk-sys + the
|
||||
# owned pf-vdisplay-proto ABI). Replaces the vendored-binding oracle at packaging/windows/vdisplay-driver/
|
||||
# owned pf-driver-proto ABI). Replaces the vendored-binding oracle at packaging/windows/vdisplay-driver/
|
||||
# (deleted once on-glass parity is reached, per docs/windows-host-rewrite.md §14 STEP 8).
|
||||
[package]
|
||||
name = "pf-vdisplay"
|
||||
@@ -23,7 +23,7 @@ wdk-build.workspace = true
|
||||
wdk.workspace = true
|
||||
wdk-sys = { workspace = true, features = ["iddcx"] }
|
||||
wdk-iddcx.workspace = true
|
||||
pf-vdisplay-proto.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
# STEP 5: the swap-chain processor's render-side D3D11 device + worker. 0.58.0 matches the wdk-build
|
||||
# transitive `windows` already in the workspace lock (one resolved version) AND the proven oracle's
|
||||
# version, so the ported D3D/DXGI/threading calls compile verbatim.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
; pf-vdisplay - punktfunk virtual display, UMDF2 IddCx driver INF (template; stampinf -> .inf).
|
||||
;
|
||||
; For the all-Rust wdk-sys / windows-drivers-rs driver in THIS tree
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_vdisplay_proto
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_driver_proto
|
||||
; control-interface GUID in CODE (WdfDeviceCreateDeviceInterface), so this INF is GUID-agnostic and
|
||||
; is byte-identical to the superseded oracle's (packaging/windows/vdisplay-driver/.../pf_vdisplay.inx,
|
||||
; itself adapted from MolotovCherry/virtual-display-rs (MIT) + SudoVDA's control-device security DACL).
|
||||
|
||||
@@ -66,9 +66,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
// Firmware/hardware version (telemetry). The oracle points BOTH at one IDDCX_ENDPOINT_VERSION.
|
||||
// `version` is a stack local read synchronously by IddCxAdapterInitAsync (same as the oracle). `.Size`
|
||||
// is `size_of` throughout — these are the IddCx 1.10 structs and the framework here is 1.10 (= upstream).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_ENDPOINT_VERSION;
|
||||
// the required `.Size` (+ version fields) are set immediately below before the struct is used.
|
||||
let mut version: iddcx::IDDCX_ENDPOINT_VERSION = unsafe { core::mem::zeroed() };
|
||||
let mut version = pod_init!(iddcx::IDDCX_ENDPOINT_VERSION);
|
||||
version.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_VERSION>() as u32;
|
||||
version.MajorVer = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(0);
|
||||
version.MinorVer = env!("CARGO_PKG_VERSION_MINOR").parse().unwrap_or(0);
|
||||
@@ -78,9 +76,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
// zeroed value is IDDCX_FEATURE_IMPLEMENTATION_UNINITIALIZED (0), which the framework's adapter Validate
|
||||
// rejects with INVALID_PARAMETER (ddivalidation.cpp:797) — set it to NONE (1) like upstream. THIS was
|
||||
// the on-glass adapter-init blocker.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDDCX_ENDPOINT_DIAGNOSTIC_INFO; the required `.Size` (+ the fields read by Validate) are set below.
|
||||
let mut diag: iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut diag = pod_init!(iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO);
|
||||
diag.Size = core::mem::size_of::<iddcx::IDDCX_ENDPOINT_DIAGNOSTIC_INFO>() as u32;
|
||||
diag.GammaSupport = iddcx::IDDCX_FEATURE_IMPLEMENTATION::IDDCX_FEATURE_IMPLEMENTATION_NONE;
|
||||
diag.TransmissionType = iddcx::IDDCX_TRANSMISSION_TYPE::IDDCX_TRANSMISSION_TYPE_WIRED_OTHER;
|
||||
@@ -92,9 +88,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
diag.pFirmwareVersion = (&raw mut version).cast();
|
||||
diag.pHardwareVersion = (&raw mut version).cast();
|
||||
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_ADAPTER_CAPS;
|
||||
// the required `.Size` (+ flags/limits/diag) are set immediately below.
|
||||
let mut caps: iddcx::IDDCX_ADAPTER_CAPS = unsafe { core::mem::zeroed() };
|
||||
let mut caps = pod_init!(iddcx::IDDCX_ADAPTER_CAPS);
|
||||
caps.Size = core::mem::size_of::<iddcx::IDDCX_ADAPTER_CAPS>() as u32;
|
||||
// STEP 7 (HDR): declare we can process FP16 (scRGB) desktop surfaces — this is what marks the virtual
|
||||
// monitor advanced-color-capable (→ the host sees display_hdr=true → the "Use HDR" toggle appears). The
|
||||
@@ -109,9 +103,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
|
||||
// The adapter WDF object's attributes: Size + Synchronization/Execution = InheritFromParent (NOT zeroed,
|
||||
// since zero = *Invalid*) + the adapter context type (STEP 4 stores adapter state here).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES;
|
||||
// the required `.Size` (+ execution/sync scope + context type) are set immediately below.
|
||||
let mut attr: wdk_sys::WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
let mut attr = pod_init!(wdk_sys::WDF_OBJECT_ATTRIBUTES);
|
||||
attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
|
||||
attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
|
||||
attr.SynchronizationScope =
|
||||
@@ -122,9 +114,7 @@ pub fn init_adapter(device: WDFDEVICE) -> NTSTATUS {
|
||||
pCaps: &raw mut caps,
|
||||
ObjectAttributes: &raw mut attr,
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_ADAPTER_INIT
|
||||
// (an out-param the framework fills).
|
||||
let mut out: iddcx::IDARG_OUT_ADAPTER_INIT = unsafe { core::mem::zeroed() };
|
||||
let mut out = pod_init!(iddcx::IDARG_OUT_ADAPTER_INIT);
|
||||
// SAFETY: `init`/`out` are valid local storage; IddCxAdapterInitAsync reads the caps synchronously
|
||||
// (the adapter object itself is delivered later via adapter_init_finished). Called once per device.
|
||||
let st = unsafe { wdk_iddcx::IddCxAdapterInitAsync(&init, &mut out) };
|
||||
@@ -142,3 +132,24 @@ pub fn set_adapter(adapter: iddcx::IDDCX_ADAPTER) {
|
||||
pub(crate) fn adapter() -> Option<iddcx::IDDCX_ADAPTER> {
|
||||
ADAPTER.get().map(|a| a.0)
|
||||
}
|
||||
|
||||
/// Honor the host's `IOCTL_SET_RENDER_ADAPTER`: pin the GPU the IddCx swap-chain renders on. On a hybrid
|
||||
/// iGPU+dGPU box the OS may otherwise pick the iGPU to render the virtual monitor, so the host's shared
|
||||
/// ring textures (created on the NVENC dGPU) can't be opened → `DRV_STATUS_TEX_FAIL` → the host's 20 s
|
||||
/// black bail. Pinning the render adapter to the encode GPU fixes that. Unconditional — NOT the
|
||||
/// SudoVDA-parity default-off branch (`docs/windows-host-rewrite.md` §2.8). Returns
|
||||
/// `STATUS_NOT_FOUND` if called before the adapter exists.
|
||||
pub fn set_render_adapter(luid_low: u32, luid_high: i32) -> NTSTATUS {
|
||||
let Some(adapter) = adapter() else {
|
||||
return crate::STATUS_NOT_FOUND;
|
||||
};
|
||||
let mut in_args = pod_init!(iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER);
|
||||
in_args.PreferredRenderAdapter = wdk_sys::LUID {
|
||||
LowPart: luid_low,
|
||||
HighPart: luid_high,
|
||||
};
|
||||
dbglog!("[pf-vd] set_render_adapter -> {luid_high:08x}:{luid_low:08x}");
|
||||
// SAFETY: `adapter` is the stashed IddCx adapter; `in_args` is valid local storage read synchronously.
|
||||
unsafe { wdk_iddcx::IddCxAdapterSetRenderAdapter(adapter, &in_args) };
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
@@ -33,9 +33,19 @@ pub unsafe extern "C" fn adapter_init_finished(
|
||||
) -> NTSTATUS {
|
||||
dbglog!("[pf-vd] adapter_init_finished");
|
||||
crate::adapter::set_adapter(adapter);
|
||||
crate::control::start_watchdog();
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// `EvtCleanupCallback` on the WDFDEVICE (E1): the device is being removed (PnP / driver unload) — drop
|
||||
/// every monitor's swap-chain worker so the worker threads don't linger into teardown. IddCx-free (the
|
||||
/// framework tears the monitors down with the departing device); see
|
||||
/// [`crate::monitor::cleanup_for_device_removal`].
|
||||
pub unsafe extern "C" fn device_cleanup(_object: WDFOBJECT) {
|
||||
dbglog!("[pf-vd] device cleanup — releasing monitors");
|
||||
crate::monitor::cleanup_for_device_removal();
|
||||
}
|
||||
|
||||
/// SDR mode list for an EDID monitor: EDID-serial lookup → count-then-fill `IDDCX_MONITOR_MODE`.
|
||||
pub unsafe extern "C" fn parse_monitor_description(
|
||||
p_in: *const iddcx::IDARG_IN_PARSEMONITORDESCRIPTION,
|
||||
@@ -70,9 +80,7 @@ pub unsafe extern "C" fn parse_monitor_description(
|
||||
// SAFETY: `pMonitorModes` points to >= `count` IDDCX_MONITOR_MODE entries (validated above).
|
||||
let out = unsafe { core::slice::from_raw_parts_mut(in_args.pMonitorModes, count as usize) };
|
||||
for (item, slot) in crate::monitor::flatten(&modes).zip(out.iter_mut()) {
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_MODE;
|
||||
// the required `.Size` (+ origin / signal info) are set immediately below.
|
||||
let mut mode: iddcx::IDDCX_MONITOR_MODE = unsafe { core::mem::zeroed() };
|
||||
let mut mode = pod_init!(iddcx::IDDCX_MONITOR_MODE);
|
||||
mode.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_MODE>() as u32;
|
||||
mode.Origin = iddcx::IDDCX_MONITOR_MODE_ORIGIN::IDDCX_MONITOR_MODE_ORIGIN_MONITORDESCRIPTOR;
|
||||
mode.MonitorVideoSignalInfo =
|
||||
@@ -121,9 +129,7 @@ pub unsafe extern "C" fn parse_monitor_description2(
|
||||
// SAFETY: `pMonitorModes` points to >= `count` IDDCX_MONITOR_MODE2 entries (validated above).
|
||||
let out = unsafe { core::slice::from_raw_parts_mut(in_args.pMonitorModes, count as usize) };
|
||||
for (item, slot) in crate::monitor::flatten(&modes).zip(out.iter_mut()) {
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_MODE2;
|
||||
// the required `.Size` (+ origin / signal info / bit depth) are set immediately below.
|
||||
let mut mode: iddcx::IDDCX_MONITOR_MODE2 = unsafe { core::mem::zeroed() };
|
||||
let mut mode = pod_init!(iddcx::IDDCX_MONITOR_MODE2);
|
||||
mode.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_MODE2>() as u32;
|
||||
mode.Origin = iddcx::IDDCX_MONITOR_MODE_ORIGIN::IDDCX_MONITOR_MODE_ORIGIN_MONITORDESCRIPTOR;
|
||||
mode.MonitorVideoSignalInfo =
|
||||
@@ -219,7 +225,7 @@ pub unsafe extern "C" fn query_target_info(
|
||||
) -> NTSTATUS {
|
||||
// SAFETY: p_out is the framework's (uninitialised) out buffer; zero then set the one field we report.
|
||||
unsafe {
|
||||
core::ptr::write(p_out, core::mem::zeroed());
|
||||
core::ptr::write(p_out, pod_init!(iddcx::IDARG_OUT_QUERYTARGET_INFO));
|
||||
(*p_out).TargetCaps = iddcx::IDDCX_TARGET_CAPS::IDDCX_TARGET_CAPS_HIGH_COLOR_SPACE;
|
||||
}
|
||||
STATUS_SUCCESS
|
||||
@@ -317,7 +323,7 @@ pub unsafe extern "C" fn unassign_swap_chain(monitor: iddcx::IDDCX_MONITOR) -> N
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// The pf-vdisplay-proto control plane. Returns `()` and completes the request itself (matches the C
|
||||
/// The pf-driver-proto control plane. Returns `()` and completes the request itself (matches the C
|
||||
/// `EVT_IDD_CX_DEVICE_IO_CONTROL` shape). STEP 4: dispatch the proto IOCTLs; for now just complete.
|
||||
pub unsafe extern "C" fn device_io_control(
|
||||
_device: WDFDEVICE,
|
||||
|
||||
@@ -1,40 +1,88 @@
|
||||
//! The `pf-vdisplay-proto` control plane (`EvtIddCxDeviceIoControl`). The host opens the device interface
|
||||
//! The `pf-driver-proto` control plane (`EvtIddCxDeviceIoControl`). The host opens the device interface
|
||||
//! (`PF_VDISPLAY_INTERFACE_GUID`) and drives the low-frequency IOCTLs: GET_INFO (version handshake), PING
|
||||
//! (watchdog keepalive), ADD/REMOVE/CLEAR_ALL (virtual monitors), and SET_RENDER_ADAPTER (next). Every
|
||||
//! path completes the `WDFREQUEST` exactly once (the `EVT_IDD_CX_DEVICE_IO_CONTROL` shape returns `()`).
|
||||
|
||||
use core::sync::atomic::{AtomicU64, Ordering};
|
||||
use core::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pf_vdisplay_proto::control;
|
||||
use pf_driver_proto::control;
|
||||
use wdk_iddcx::nt_success;
|
||||
use wdk_sys::{NTSTATUS, WDFREQUEST, call_unsafe_wdf_function_binding};
|
||||
|
||||
use crate::{STATUS_INVALID_PARAMETER, STATUS_NOT_FOUND, STATUS_NOT_IMPLEMENTED, STATUS_SUCCESS};
|
||||
use crate::{STATUS_INVALID_PARAMETER, STATUS_NOT_FOUND, STATUS_SUCCESS};
|
||||
|
||||
/// The host must PING within this window or the watchdog reaps all monitors (STEP 4: the watchdog thread).
|
||||
/// The host must send an IOCTL within this window (it PINGs on a `timeout/3` timer) or the watchdog
|
||||
/// treats it as gone and reaps every monitor. Reported to the host via [`control::IOCTL_GET_INFO`].
|
||||
const WATCHDOG_TIMEOUT_S: u32 = 10;
|
||||
|
||||
/// Keepalive counter — PING bumps it; STEP 4's watchdog thread samples it to detect a gone host.
|
||||
/// Host-liveness counter — EVERY inbound IOCTL bumps it; [`start_watchdog`]'s thread samples it.
|
||||
static WATCHDOG_PINGS: AtomicU64 = AtomicU64::new(0);
|
||||
/// Spawns the watchdog thread exactly once (idempotent across re-entrant adapter inits).
|
||||
static WATCHDOG_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Start the host-liveness watchdog (once, from `adapter_init_finished`).
|
||||
///
|
||||
/// Previously [`WATCHDOG_PINGS`] was bumped but NEVER sampled (no thread existed) — so a host that died
|
||||
/// without a cooperative REMOVE (crash / `TerminateProcess`) left its virtual monitor + swap-chain
|
||||
/// worker + pooled D3D device wedged in WUDFHost until the next host start's CLEAR_ALL, and a
|
||||
/// not-restarted host left the orphan monitor in the desktop topology indefinitely
|
||||
/// (`docs/windows-host-rewrite.md` §2.8). This thread closes that: if no IOCTL arrives for
|
||||
/// `WATCHDOG_TIMEOUT_S` while monitors exist, it departs them all.
|
||||
///
|
||||
/// (A WDF `EvtFileClose` on the control handle would be more immediate — the plan's preferred §3.4
|
||||
/// option — but the polling watchdog matches the proven oracle and needs no IddCx file-object plumbing.)
|
||||
pub fn start_watchdog() {
|
||||
if WATCHDOG_STARTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let tick = Duration::from_secs(u64::from((WATCHDOG_TIMEOUT_S / 3).max(1)));
|
||||
let timeout = Duration::from_secs(u64::from(WATCHDOG_TIMEOUT_S));
|
||||
std::thread::spawn(move || {
|
||||
let mut last = WATCHDOG_PINGS.load(Ordering::Relaxed);
|
||||
let mut last_change = Instant::now();
|
||||
loop {
|
||||
std::thread::sleep(tick);
|
||||
let cur = WATCHDOG_PINGS.load(Ordering::Relaxed);
|
||||
if cur != last {
|
||||
last = cur;
|
||||
last_change = Instant::now();
|
||||
continue;
|
||||
}
|
||||
// No IOCTL since `last_change`. A live host PINGs every `timeout/3`, so this only trips once
|
||||
// the host is truly gone; only reap when there's something to reap.
|
||||
if last_change.elapsed() >= timeout && crate::monitor::has_monitors() {
|
||||
let n = crate::monitor::reap_orphaned(Duration::from_secs(3));
|
||||
if n > 0 {
|
||||
dbglog!(
|
||||
"[pf-vd] watchdog: no host IOCTL in {WATCHDOG_TIMEOUT_S}s — host gone, departed {n} monitor(s)"
|
||||
);
|
||||
}
|
||||
last_change = Instant::now(); // don't re-reap every tick
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispatch one control IOCTL and complete the request.
|
||||
///
|
||||
/// # Safety
|
||||
/// `request` is the framework-provided `WDFREQUEST` for an `EvtIddCxDeviceIoControl` call.
|
||||
pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
|
||||
// Every inbound IOCTL is host liveness (the host PINGs on a timer, plus ADD/REMOVE/GET_INFO/…) —
|
||||
// bump the watchdog at the top so it only fires once the host has gone truly silent. See
|
||||
// [`start_watchdog`].
|
||||
WATCHDOG_PINGS.fetch_add(1, Ordering::Relaxed);
|
||||
match ioctl_code {
|
||||
control::IOCTL_GET_INFO => {
|
||||
let reply = control::InfoReply {
|
||||
protocol_version: pf_vdisplay_proto::PROTOCOL_VERSION,
|
||||
protocol_version: pf_driver_proto::PROTOCOL_VERSION,
|
||||
watchdog_timeout_s: WATCHDOG_TIMEOUT_S,
|
||||
};
|
||||
// SAFETY: `request` is the framework WDFREQUEST.
|
||||
unsafe { write_output_complete(request, &reply) };
|
||||
}
|
||||
control::IOCTL_PING => {
|
||||
WATCHDOG_PINGS.fetch_add(1, Ordering::Relaxed);
|
||||
complete(request, STATUS_SUCCESS);
|
||||
}
|
||||
control::IOCTL_PING => complete(request, STATUS_SUCCESS),
|
||||
// SAFETY: `request` is the framework WDFREQUEST.
|
||||
control::IOCTL_ADD => unsafe { add(request) },
|
||||
// SAFETY: `request` is the framework WDFREQUEST.
|
||||
@@ -43,12 +91,34 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) {
|
||||
crate::monitor::clear_all();
|
||||
complete(request, STATUS_SUCCESS);
|
||||
}
|
||||
// SET_RENDER_ADAPTER (hybrid-GPU render pin): STEP 4 (next).
|
||||
control::IOCTL_SET_RENDER_ADAPTER => complete(request, STATUS_NOT_IMPLEMENTED),
|
||||
// SAFETY: `request` is the framework WDFREQUEST.
|
||||
control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) },
|
||||
_ => complete(request, STATUS_NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanity bounds for a requested mode — generous (covers any real client) but rejects zero/absurd
|
||||
/// values that would otherwise feed the EDID/mode math unchecked.
|
||||
fn valid_mode(width: u32, height: u32, refresh_hz: u32) -> bool {
|
||||
(1..=16384).contains(&width)
|
||||
&& (1..=16384).contains(&height)
|
||||
&& (1..=1000).contains(&refresh_hz)
|
||||
}
|
||||
|
||||
/// `IOCTL_SET_RENDER_ADAPTER`: pin the IddCx render adapter (hybrid-GPU IDD-push).
|
||||
///
|
||||
/// # Safety
|
||||
/// `request` is the framework `WDFREQUEST`.
|
||||
unsafe fn set_render_adapter(request: WDFREQUEST) {
|
||||
// SAFETY: `request` is the framework WDFREQUEST.
|
||||
let Some(req) = (unsafe { read_input::<control::SetRenderAdapterRequest>(request) }) else {
|
||||
complete(request, STATUS_INVALID_PARAMETER);
|
||||
return;
|
||||
};
|
||||
let st = crate::adapter::set_render_adapter(req.luid_low, req.luid_high);
|
||||
complete(request, st);
|
||||
}
|
||||
|
||||
/// `IOCTL_ADD`: create a virtual monitor at the requested mode → reply with the OS target id + LUID.
|
||||
///
|
||||
/// # Safety
|
||||
@@ -59,6 +129,10 @@ unsafe fn add(request: WDFREQUEST) {
|
||||
complete(request, STATUS_INVALID_PARAMETER);
|
||||
return;
|
||||
};
|
||||
if !valid_mode(req.width, req.height, req.refresh_hz) {
|
||||
complete(request, STATUS_INVALID_PARAMETER);
|
||||
return;
|
||||
}
|
||||
let Some((target_id, luid_low, luid_high)) =
|
||||
crate::monitor::create_monitor(req.session_id, req.width, req.height, req.refresh_hz)
|
||||
else {
|
||||
|
||||
@@ -38,8 +38,7 @@ pub unsafe extern "system" fn driver_entry(
|
||||
registry_path: PCUNICODE_STRING,
|
||||
) -> NTSTATUS {
|
||||
dbglog!("[pf-vd] DriverEntry");
|
||||
// SAFETY: zeroed then Size + the device-add callback set, per the WDF_DRIVER_CONFIG contract.
|
||||
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
|
||||
let mut config = pod_init!(WDF_DRIVER_CONFIG);
|
||||
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
|
||||
config.EvtDriverDeviceAdd = Some(driver_add);
|
||||
// SAFETY: driver + registry_path are loader-provided; config is valid for the call.
|
||||
@@ -60,9 +59,7 @@ pub unsafe extern "system" fn driver_entry(
|
||||
extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||
dbglog!("[pf-vd] driver_add");
|
||||
// Defer adapter creation to the first D0 entry.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// WDF_PNPPOWER_EVENT_CALLBACKS; the required `.Size` (+ the D0-entry callback) are set immediately below.
|
||||
let mut pnp: WDF_PNPPOWER_EVENT_CALLBACKS = unsafe { core::mem::zeroed() };
|
||||
let mut pnp = pod_init!(WDF_PNPPOWER_EVENT_CALLBACKS);
|
||||
pnp.Size = core::mem::size_of::<WDF_PNPPOWER_EVENT_CALLBACKS>() as ULONG;
|
||||
pnp.EvtDeviceD0Entry = Some(callbacks::device_d0_entry);
|
||||
// SAFETY: init is the framework-provided device-init; pnp is valid for the call.
|
||||
@@ -71,9 +68,7 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
}
|
||||
|
||||
// Build the IddCx client config and wire the SDR callbacks. `.Size` = size_of (1.10 structs, 1.10 fw).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDD_CX_CLIENT_CONFIG;
|
||||
// the required `.Size` (+ the IddCx client callbacks) are set immediately below.
|
||||
let mut cfg: iddcx::IDD_CX_CLIENT_CONFIG = unsafe { core::mem::zeroed() };
|
||||
let mut cfg = pod_init!(iddcx::IDD_CX_CLIENT_CONFIG);
|
||||
cfg.Size = core::mem::size_of::<iddcx::IDD_CX_CLIENT_CONFIG>() as u32;
|
||||
cfg.EvtIddCxAdapterInitFinished = Some(callbacks::adapter_init_finished);
|
||||
cfg.EvtIddCxParseMonitorDescription = Some(callbacks::parse_monitor_description);
|
||||
@@ -105,14 +100,15 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
|
||||
let mut device: WDFDEVICE = core::ptr::null_mut();
|
||||
// Attach a device context type (like the working virtual-display-rs/oracle), not WDF_NO_OBJECT_ATTRIBUTES.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES;
|
||||
// the required `.Size` (+ execution/sync scope + context type) are set immediately below.
|
||||
let mut dev_attr: wdk_sys::WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
let mut dev_attr = pod_init!(wdk_sys::WDF_OBJECT_ATTRIBUTES);
|
||||
dev_attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
|
||||
dev_attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
|
||||
dev_attr.SynchronizationScope =
|
||||
wdk_sys::_WDF_SYNCHRONIZATION_SCOPE::WdfSynchronizationScopeInheritFromParent;
|
||||
dev_attr.ContextTypeInfo = &DEVICE_CTX.0;
|
||||
// Drop every monitor's swap-chain worker when the device is removed (PnP / unload), so the worker
|
||||
// threads don't linger into teardown (E1 device cleanup). IddCx-free; see callbacks::device_cleanup.
|
||||
dev_attr.EvtCleanupCallback = Some(callbacks::device_cleanup);
|
||||
// SAFETY: init configured above; dev_attr is a valid context-typed attributes block.
|
||||
let status = unsafe {
|
||||
call_unsafe_wdf_function_binding!(WdfDeviceCreate, &mut init, &mut dev_attr, &mut device)
|
||||
@@ -132,7 +128,7 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
|
||||
// Expose the owned pf-vdisplay control interface: the host opens this GUID and drives the proto control
|
||||
// plane (IOCTL_ADD/REMOVE/PING/…) which arrives at EvtIddCxDeviceIoControl. NOT SudoVDA's GUID. (The
|
||||
// upstream uses a socket instead, so it has no interface; ours is IOCTL-based.)
|
||||
let (d1, d2, d3, d4) = pf_vdisplay_proto::interface_guid_fields();
|
||||
let (d1, d2, d3, d4) = pf_driver_proto::interface_guid_fields();
|
||||
let guid = GUID {
|
||||
Data1: d1,
|
||||
Data2: d2,
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
//!
|
||||
//! Host counterpart: `crates/punktfunk-host/src/capture/idd_push.rs`. The shared `SharedHeader` layout,
|
||||
//! the [`FrameToken`] packing, the `Global\` object-name scheme, the `MAGIC`/`RING_LEN` and the
|
||||
//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_vdisplay_proto::frame::*`, which
|
||||
//! `DRV_STATUS_*` codes are NOT hand-duplicated here: both sides `use pf_driver_proto::frame::*`, which
|
||||
//! OWNS the contract (with `const` size asserts so any drift is a compile error).
|
||||
//!
|
||||
//! Ported from the proven oracle (`packaging/windows/vdisplay-driver/pf-vdisplay/src/frame_transport.rs`).
|
||||
//! Differences from the oracle:
|
||||
//! * the layout/consts/names/token come from `pf_vdisplay_proto::frame` instead of being re-declared;
|
||||
//! * the layout/consts/names/token come from `pf_driver_proto::frame` instead of being re-declared;
|
||||
//! * `dbglog!` replaces `log::info!`;
|
||||
//! * the optional fixed-name `Global\pfvd-dbg` `DebugBlock` bring-up channel is SKIPPED (not on the data
|
||||
//! path). FOLLOW-UP: if the host bring-up diagnostics are needed again, port the oracle's `DebugBlock`
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
use pf_vdisplay_proto::frame::{
|
||||
use pf_driver_proto::frame::{
|
||||
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN,
|
||||
SharedHeader, event_name, header_name, texture_name,
|
||||
};
|
||||
@@ -72,6 +72,9 @@ pub struct FramePublisher {
|
||||
/// recreates the ring at a new format mid-session (the display's HDR mode flipped) — [`Self::is_stale`]
|
||||
/// detects that so `run_core` re-attaches to the new-format textures instead of dropping every frame.
|
||||
generation: u32,
|
||||
/// Set when a surface is dropped for a descriptor mismatch (a game mode-set the display), cleared on a
|
||||
/// matched publish — throttles the drop log to once per mismatch episode (game-capture bug GB1).
|
||||
mismatch_logged: bool,
|
||||
}
|
||||
|
||||
// SAFETY: created and used only on the swap-chain processor thread.
|
||||
@@ -246,6 +249,7 @@ impl FramePublisher {
|
||||
// SAFETY: `header` is the mapped host header; `dxgi_format` lives within it.
|
||||
ring_format: unsafe { (*header).dxgi_format },
|
||||
generation,
|
||||
mismatch_logged: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,9 +285,28 @@ impl FramePublisher {
|
||||
let mut desc = D3D11_TEXTURE2D_DESC::default();
|
||||
// SAFETY: `surface` is a live ID3D11Texture2D (borrowed from IddCx); `desc` is a valid local out-param.
|
||||
unsafe { surface.GetDesc(&mut desc) };
|
||||
if desc.Format.0 as u32 != self.ring_format {
|
||||
// Descriptor guard: CopyResource needs the surface + ring textures to share format AND dimensions.
|
||||
// A fullscreen game can mode-set the display, changing the surface's format/size before the host
|
||||
// recreates the ring to match (game-capture bug GB1) — drop a mismatched frame (else garbage) and
|
||||
// report the ACTUAL descriptor once per episode so a repro shows exactly what changed.
|
||||
// SAFETY: `self.header` stays mapped for the publisher's lifetime; width/height are plain u32 fields.
|
||||
let (rw, rh) = unsafe { ((*self.header).width, (*self.header).height) };
|
||||
if desc.Format.0 as u32 != self.ring_format || desc.Width != rw || desc.Height != rh {
|
||||
if !self.mismatch_logged {
|
||||
self.mismatch_logged = true;
|
||||
dbglog!(
|
||||
"[pf-vd] frame-push DROP: surface {}x{} fmt={} != ring {}x{} fmt={} — display mode-set? (host should recreate the ring)",
|
||||
desc.Width,
|
||||
desc.Height,
|
||||
desc.Format.0 as u32,
|
||||
rw,
|
||||
rh,
|
||||
self.ring_format
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.mismatch_logged = false;
|
||||
let start = self.next;
|
||||
for attempt in 0..ring_len {
|
||||
let slot = (start + attempt) % ring_len;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! pf-vdisplay — the all-Rust UMDF IddCx virtual-display driver (M1 step-2 rewrite, on wdk-sys + the
|
||||
//! owned pf-vdisplay-proto ABI). See docs/windows-host-rewrite.md §14 for the full port plan.
|
||||
//! owned pf-driver-proto ABI). See docs/windows-host-rewrite.md §14 for the full port plan.
|
||||
//!
|
||||
//! STEP 2: the IddCx driver SKELETON — DriverEntry → driver_add builds the full `IDD_CX_CLIENT_CONFIG`
|
||||
//! (14 IddCx callbacks + the PnP `EvtDeviceD0Entry`, all stubs) sized via the versioned
|
||||
@@ -9,6 +9,10 @@
|
||||
//! control plane + monitor/modes (STEP 4), and swap-chain/IDD-push (STEP 5-6) fill the stubs in.
|
||||
|
||||
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
||||
// P0 lint (audit §8): an unsafe op inside an `unsafe fn` must be in an explicit `unsafe {}` block, so the
|
||||
// fn-level `unsafe` never silently blesses the whole body. (The per-site `// SAFETY:` discipline already
|
||||
// landed in STEP 8.)
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
#[macro_use]
|
||||
mod log;
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
//! Minimal driver logger (matches the DualSense driver). DebugView can't capture the UMDF host across
|
||||
//! session 0, so besides `OutputDebugStringA` we append to a world-writable file readable over SSH. Used
|
||||
//! only for bring-up/diagnostics; cheap and best-effort (ignores all errors).
|
||||
//! Minimal driver logger. `OutputDebugStringA` always (ETW/DebugView); the optional world-writable file
|
||||
//! (`C:\Users\Public\pfvd-driver.log`, readable over SSH) is now OPT-IN — debug builds, or the
|
||||
//! `PFVD_DEBUG_LOG` env var, only — so a RELEASE build never writes it (audit §4.4: it was an
|
||||
//! info-leak/DoS surface). Best-effort; ignores all errors. Production driver-state visibility is the
|
||||
//! SharedHeader `driver_status` channel, not this file.
|
||||
|
||||
unsafe extern "system" {
|
||||
fn OutputDebugStringA(s: *const u8);
|
||||
}
|
||||
|
||||
/// Whether the world-writable bring-up file log is enabled (resolved once). Off in release builds unless
|
||||
/// `PFVD_DEBUG_LOG` is set.
|
||||
fn file_log_enabled() -> bool {
|
||||
use std::sync::OnceLock;
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFVD_DEBUG_LOG").is_some())
|
||||
}
|
||||
|
||||
/// Process-lifetime append handle to the bring-up log, opened ONCE (by whichever thread logs first) and
|
||||
/// shared via a `Mutex` — so the swap-chain WORKER thread's writes land too. Per-call open/append raced
|
||||
/// the control thread and/or could fail under the worker's restricted token, hiding exactly the
|
||||
/// swap-chain-processor lines a game-break repro needs (game-capture bug S3). `flush` after each line so a
|
||||
/// crash/stall doesn't lose the tail.
|
||||
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
use std::sync::OnceLock;
|
||||
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
|
||||
APPENDER
|
||||
.get_or_init(|| {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfvd-driver.log")
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
pub fn log(s: &str) {
|
||||
if let Ok(c) = std::ffi::CString::new(s) {
|
||||
// SAFETY: `c` is a valid NUL-terminated string for the duration of the call.
|
||||
unsafe { OutputDebugStringA(c.as_ptr().cast()) };
|
||||
}
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("C:\\Users\\Public\\pfvd-driver.log")
|
||||
{
|
||||
let _ = writeln!(f, "{s}");
|
||||
if let Some(m) = file_appender() {
|
||||
if let Ok(mut f) = m.lock() {
|
||||
let _ = writeln!(f, "{s}");
|
||||
let _ = f.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! dbglog {
|
||||
($($a:tt)*) => { $crate::log::log(&::std::format!($($a)*)) };
|
||||
}
|
||||
|
||||
/// Zero-initialise a C POD struct (windows-rs / WDK / IddCx). These are `#[repr(C)]` framework structs
|
||||
/// whose all-zero bit pattern is a valid zero-initialised value; the caller stamps the required
|
||||
/// `.Size`/etc fields immediately after. Centralises the `unsafe { core::mem::zeroed() }` the IddCx/WDF
|
||||
/// bring-up needs — pass the type EXPLICITLY (`pod_init!(T)`) so it works without a binding annotation.
|
||||
/// Made crate-visible by the same `#[macro_use] mod log;` in `lib.rs` that exports `dbglog!`.
|
||||
macro_rules! pod_init {
|
||||
($t:ty) => {{
|
||||
// SAFETY: $t is a C POD (windows-rs/WDK/IddCx struct); its all-zero bit pattern is a valid
|
||||
// zero-initialised value and the caller sets the required .Size/etc fields immediately after.
|
||||
// `unused_unsafe`: pod_init! is also expanded at call sites already inside an `unsafe` block
|
||||
// (where this `unsafe` is redundant), but it IS required at the non-unsafe sites — so allow it.
|
||||
#[allow(unused_unsafe)]
|
||||
let zeroed = unsafe { ::core::mem::zeroed::<$t>() };
|
||||
zeroed
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
//! ([`crate::control`], `IOCTL_ADD`): each carries the requested mode (advertised as preferred) plus the
|
||||
//! `session_id` the host keys it by and the OS target id + render-adapter LUID captured at arrival. Ported
|
||||
//! from the working upstream virtual-display-rs (`monitor.rs` + `context.rs::create_monitor`), with
|
||||
//! `guid: u128` → `session_id: u64` for the owned `pf_vdisplay_proto` control plane.
|
||||
//! `guid: u128` → `session_id: u64` for the owned `pf_driver_proto` control plane.
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wdk_sys::iddcx;
|
||||
|
||||
@@ -60,9 +59,59 @@ pub struct MonitorObject {
|
||||
// SAFETY: the raw IddCx monitor handle is framework-managed; access is serialized by MONITOR_MODES.
|
||||
unsafe impl Send for MonitorObject {}
|
||||
|
||||
/// All live monitors. A process-`static` (not a WDFDEVICE-context-owned allocation) BY NECESSITY: the IddCx
|
||||
/// monitor/mode DDIs receive only an IddCx handle — never the WDFDEVICE or its context — so this state must
|
||||
/// be reachable without one (the upstream virtual-display-rs is a process-`static` for the same reason).
|
||||
/// With a single `pf_vdisplay` devnode + `UmdfHostProcessSharing=ProcessSharingDisabled` the host process
|
||||
/// (and this state) die WITH the device, so it is effectively device-scoped already; a `Box` + `AtomicPtr`
|
||||
/// "device-owned" variant (audit §2.5) would only add a use-after-free window — the host-gone watchdog
|
||||
/// thread ([`crate::control::start_watchdog`]) races device cleanup — for no real gain. Cleanup of the
|
||||
/// heavy per-monitor resources on device removal is instead done explicitly ([`cleanup_for_device_removal`]).
|
||||
pub static MONITOR_MODES: Mutex<Vec<MonitorObject>> = Mutex::new(Vec::new());
|
||||
/// Monitor id / EDID-serial counter (unique per created monitor).
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
|
||||
/// True if any virtual monitor currently exists — the host-gone watchdog only reaps when there's
|
||||
/// something to reap (see [`crate::control::start_watchdog`]).
|
||||
pub fn has_monitors() -> bool {
|
||||
MONITOR_MODES.lock().map(|l| !l.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Depart every monitor that has existed at least `grace` — the host-gone watchdog reap
|
||||
/// ([`crate::control::start_watchdog`]). The grace skips a just-created monitor (the host adds it, then
|
||||
/// starts pinging) so a momentarily-stale ping timer can't nuke a brand-new monitor. Returns the count
|
||||
/// departed. Same lock discipline as [`remove_monitor`]: drop each worker (which RAII-joins its thread)
|
||||
/// OUTSIDE the `MONITOR_MODES` lock, then depart.
|
||||
pub fn reap_orphaned(grace: Duration) -> usize {
|
||||
let mut drained: Vec<(
|
||||
Option<iddcx::IDDCX_MONITOR>,
|
||||
Option<crate::swap_chain_processor::SwapChainProcessor>,
|
||||
)> = {
|
||||
let Ok(mut lock) = MONITOR_MODES.lock() else {
|
||||
return 0;
|
||||
};
|
||||
let mut taken = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < lock.len() {
|
||||
if lock[i].created_at.elapsed() >= grace {
|
||||
let mut m = lock.remove(i);
|
||||
taken.push((m.object, m.swap_chain_processor.take()));
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
taken
|
||||
};
|
||||
let n = drained.len();
|
||||
for (_, processor) in &mut drained {
|
||||
drop(processor.take());
|
||||
}
|
||||
for (object, _) in drained {
|
||||
if let Some(m) = object {
|
||||
// SAFETY: `m` is a live IddCx monitor handle; departure tears it down.
|
||||
unsafe { wdk_iddcx::IddCxMonitorDeparture(m) };
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Fallback modes appended after the requested mode, so a topology change still has options.
|
||||
fn default_modes() -> Vec<Mode> {
|
||||
@@ -86,17 +135,19 @@ pub fn display_info(
|
||||
height: u32,
|
||||
refresh_rate: u32,
|
||||
) -> wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO {
|
||||
let clock_rate = refresh_rate * (height + 4) * (height + 4) + 1000;
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// DISPLAYCONFIG_VIDEO_SIGNAL_INFO; every meaningful field is assigned below.
|
||||
let mut si: wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO = unsafe { core::mem::zeroed() };
|
||||
si.pixelRate = u64::from(clock_rate);
|
||||
// Compute in u64 then saturate the u32 rational numerators: the old u32 `refresh*(h+4)^2` overflows
|
||||
// for a large mode (e.g. 8K@240), which panics→aborts the extern-"C" mode DDI in a debug build.
|
||||
// Identical for every real mode; only an absurd (also now bounds-rejected) mode saturates.
|
||||
let clock_rate: u64 = u64::from(refresh_rate) * u64::from(height + 4) * u64::from(height + 4) + 1000;
|
||||
let clock_rate_u32 = u32::try_from(clock_rate).unwrap_or(u32::MAX);
|
||||
let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
|
||||
si.pixelRate = clock_rate;
|
||||
si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
|
||||
Numerator: clock_rate,
|
||||
Numerator: clock_rate_u32,
|
||||
Denominator: height + 4,
|
||||
};
|
||||
si.vSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
|
||||
Numerator: clock_rate,
|
||||
Numerator: clock_rate_u32,
|
||||
Denominator: (height + 4) * (height + 4),
|
||||
};
|
||||
si.activeSize = wdk_sys::DISPLAYCONFIG_2DREGION {
|
||||
@@ -120,9 +171,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
|
||||
cx: width,
|
||||
cy: height,
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// DISPLAYCONFIG_VIDEO_SIGNAL_INFO; every meaningful field is assigned below.
|
||||
let mut si: wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut si = pod_init!(wdk_sys::DISPLAYCONFIG_VIDEO_SIGNAL_INFO);
|
||||
si.pixelRate = u64::from(refresh_rate) * u64::from(width) * u64::from(height);
|
||||
si.hSyncFreq = wdk_sys::DISPLAYCONFIG_RATIONAL {
|
||||
Numerator: refresh_rate * height,
|
||||
@@ -138,9 +187,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
|
||||
wdk_sys::DISPLAYCONFIG_SCANLINE_ORDERING::DISPLAYCONFIG_SCANLINE_ORDERING_PROGRESSIVE;
|
||||
// videoStandard=255, vSyncFreqDivider=1 (bits 16..21) => 255 | (1<<16).
|
||||
si.__bindgen_anon_1.videoStandard = 255 | (1 << 16);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_TARGET_MODE;
|
||||
// the required `.Size` (+ signal info) are set immediately below.
|
||||
let mut tm: iddcx::IDDCX_TARGET_MODE = unsafe { core::mem::zeroed() };
|
||||
let mut tm = pod_init!(iddcx::IDDCX_TARGET_MODE);
|
||||
tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE>() as u32;
|
||||
tm.TargetVideoSignalInfo = wdk_sys::DISPLAYCONFIG_TARGET_MODE {
|
||||
targetVideoSignalInfo: si,
|
||||
@@ -157,9 +204,7 @@ pub fn target_mode(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_T
|
||||
pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT {
|
||||
let rgb = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_8
|
||||
| iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_10;
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDDCX_WIRE_BITS_PER_COMPONENT; every field is assigned below.
|
||||
let mut w: iddcx::IDDCX_WIRE_BITS_PER_COMPONENT = unsafe { core::mem::zeroed() };
|
||||
let mut w = pod_init!(iddcx::IDDCX_WIRE_BITS_PER_COMPONENT);
|
||||
w.Rgb = rgb;
|
||||
w.YCbCr444 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE;
|
||||
w.YCbCr422 = iddcx::IDDCX_BITS_PER_COMPONENT::IDDCX_BITS_PER_COMPONENT_NONE;
|
||||
@@ -172,9 +217,7 @@ pub fn wire_bits() -> iddcx::IDDCX_WIRE_BITS_PER_COMPONENT {
|
||||
/// zeroed.
|
||||
pub fn target_mode2(width: u32, height: u32, refresh_rate: u32) -> iddcx::IDDCX_TARGET_MODE2 {
|
||||
let m1 = target_mode(width, height, refresh_rate);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_TARGET_MODE2;
|
||||
// the required `.Size` (+ signal info + bit depth) are set immediately below.
|
||||
let mut tm: iddcx::IDDCX_TARGET_MODE2 = unsafe { core::mem::zeroed() };
|
||||
let mut tm = pod_init!(iddcx::IDDCX_TARGET_MODE2);
|
||||
tm.Size = core::mem::size_of::<iddcx::IDDCX_TARGET_MODE2>() as u32;
|
||||
tm.TargetVideoSignalInfo = m1.TargetVideoSignalInfo;
|
||||
tm.BitsPerComponent = wire_bits();
|
||||
@@ -248,7 +291,7 @@ pub fn take_swap_chain_processor(
|
||||
}
|
||||
|
||||
/// `IOCTL_ADD`: create + arrive a virtual monitor at `width`x`height`@`refresh`. Returns the OS
|
||||
/// `(target_id, adapter_luid_low, adapter_luid_high)` for the [`AddReply`](pf_vdisplay_proto::control::AddReply),
|
||||
/// `(target_id, adapter_luid_low, adapter_luid_high)` for the [`AddReply`](pf_driver_proto::control::AddReply),
|
||||
/// or `None` on failure (no adapter yet / IddCx error).
|
||||
pub fn create_monitor(
|
||||
session_id: u64,
|
||||
@@ -257,8 +300,16 @@ pub fn create_monitor(
|
||||
refresh: u32,
|
||||
) -> Option<(u32, u32, i32)> {
|
||||
let adapter = crate::adapter::adapter()?;
|
||||
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Single identity per session (E1): if the host re-ADDs a still-live `session_id` (it shouldn't), depart
|
||||
// the stale monitor first, so one session maps to exactly one monitor (no duplicate EDID/target lingers).
|
||||
if MONITOR_MODES
|
||||
.lock()
|
||||
.map(|l| l.iter().any(|m| m.session_id == session_id))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
dbglog!("[pf-vd] create_monitor: session {session_id} already live — departing the stale monitor");
|
||||
remove_monitor(session_id);
|
||||
}
|
||||
let mut modes = vec![Mode {
|
||||
width,
|
||||
height,
|
||||
@@ -266,8 +317,17 @@ pub fn create_monitor(
|
||||
}];
|
||||
modes.extend(default_modes());
|
||||
|
||||
// Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival.
|
||||
if let Ok(mut lock) = MONITOR_MODES.lock() {
|
||||
// Register the (pending) monitor so the mode DDIs can find it by EDID-serial id before arrival, under a
|
||||
// REUSED id (the lowest not currently live). Reclaiming the id on REMOVE — instead of a monotonic
|
||||
// counter — keeps the connector index / EDID serial / container GUID bounded, so IddCx reuses the same
|
||||
// OS target slot on a fresh ADD rather than leaving a ghost monitor node behind (the slot-exhaustion
|
||||
// wedge: sustained ADD/REMOVE churn eventually makes ADD fail 0x80070490 ERROR_NOT_FOUND). Allocated
|
||||
// under the lock with the push so two concurrent ADDs can't pick the same id.
|
||||
let id = {
|
||||
let Ok(mut lock) = MONITOR_MODES.lock() else {
|
||||
return None;
|
||||
};
|
||||
let id = alloc_monitor_id(&lock);
|
||||
lock.push(MonitorObject {
|
||||
object: None,
|
||||
id,
|
||||
@@ -279,15 +339,12 @@ pub fn create_monitor(
|
||||
swap_chain_processor: None,
|
||||
created_at: Instant::now(),
|
||||
});
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
id
|
||||
};
|
||||
|
||||
// EDID (serial = id) describes the monitor; the OS calls back into parse_monitor_description.
|
||||
let mut edid = crate::edid::Edid::generate_with(id);
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDDCX_MONITOR_DESCRIPTION; the required `.Size`/Type/DataSize/pData are set immediately below.
|
||||
let mut desc: iddcx::IDDCX_MONITOR_DESCRIPTION = unsafe { core::mem::zeroed() };
|
||||
let mut desc = pod_init!(iddcx::IDDCX_MONITOR_DESCRIPTION);
|
||||
desc.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_DESCRIPTION>() as u32;
|
||||
desc.Type = iddcx::IDDCX_MONITOR_DESCRIPTION_TYPE::IDDCX_MONITOR_DESCRIPTION_TYPE_EDID;
|
||||
desc.DataSize = edid.len() as u32;
|
||||
@@ -295,9 +352,7 @@ pub fn create_monitor(
|
||||
// reads through `pData` SYNCHRONOUSLY, before `edid` drops — the pointer never escapes the call.
|
||||
desc.pData = edid.as_mut_ptr().cast();
|
||||
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDDCX_MONITOR_INFO;
|
||||
// the required `.Size` (+ container id / type / connector / description) are set immediately below.
|
||||
let mut info: iddcx::IDDCX_MONITOR_INFO = unsafe { core::mem::zeroed() };
|
||||
let mut info = pod_init!(iddcx::IDDCX_MONITOR_INFO);
|
||||
info.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_INFO>() as u32;
|
||||
info.MonitorContainerId = container_guid(id);
|
||||
info.MonitorType =
|
||||
@@ -305,9 +360,7 @@ pub fn create_monitor(
|
||||
info.ConnectorIndex = id;
|
||||
info.MonitorDescription = desc;
|
||||
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized WDF_OBJECT_ATTRIBUTES;
|
||||
// the required `.Size` (+ execution/sync scope) are set immediately below.
|
||||
let mut attr: wdk_sys::WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
let mut attr = pod_init!(wdk_sys::WDF_OBJECT_ATTRIBUTES);
|
||||
attr.Size = core::mem::size_of::<wdk_sys::WDF_OBJECT_ATTRIBUTES>() as u32;
|
||||
attr.ExecutionLevel = wdk_sys::_WDF_EXECUTION_LEVEL::WdfExecutionLevelInheritFromParent;
|
||||
attr.SynchronizationScope =
|
||||
@@ -317,9 +370,7 @@ pub fn create_monitor(
|
||||
ObjectAttributes: &raw mut attr,
|
||||
pMonitorInfo: &raw mut info,
|
||||
};
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_MONITORCREATE
|
||||
// (an out-param the framework fills).
|
||||
let mut create_out: iddcx::IDARG_OUT_MONITORCREATE = unsafe { core::mem::zeroed() };
|
||||
let mut create_out = pod_init!(iddcx::IDARG_OUT_MONITORCREATE);
|
||||
// SAFETY: adapter is a valid IddCx adapter; create_in points to valid local storage read synchronously.
|
||||
let st = unsafe { wdk_iddcx::IddCxMonitorCreate(adapter, &create_in, &mut create_out) };
|
||||
dbglog!("[pf-vd] IddCxMonitorCreate(id={id}) -> {st:#x}");
|
||||
@@ -335,9 +386,7 @@ pub fn create_monitor(
|
||||
}
|
||||
|
||||
// Tell the OS the monitor is plugged in.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized IDARG_OUT_MONITORARRIVAL
|
||||
// (an out-param the framework fills).
|
||||
let mut arrival_out: iddcx::IDARG_OUT_MONITORARRIVAL = unsafe { core::mem::zeroed() };
|
||||
let mut arrival_out = pod_init!(iddcx::IDARG_OUT_MONITORARRIVAL);
|
||||
// SAFETY: `monitor` is the just-created IddCx monitor handle.
|
||||
let st = unsafe { wdk_iddcx::IddCxMonitorArrival(monitor, &mut arrival_out) };
|
||||
dbglog!("[pf-vd] IddCxMonitorArrival(id={id}) -> {st:#x}");
|
||||
@@ -411,6 +460,27 @@ pub fn clear_all() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EvtCleanupCallback` (device removal, [`crate::callbacks::device_cleanup`]): drop every monitor's heavy
|
||||
/// resources — the swap-chain processor workers (each RAII-joins its thread + deletes its swap-chain) — and
|
||||
/// clear the list, WITHOUT `IddCxMonitorDeparture` (the framework tears the IddCx monitors down together
|
||||
/// with the departing device; departing here would double-tear). Frees our worker threads promptly even
|
||||
/// though the per-devnode WUDFHost (`ProcessSharingDisabled`) would also reap them when it exits.
|
||||
pub fn cleanup_for_device_removal() {
|
||||
let mut drained: Vec<Option<crate::swap_chain_processor::SwapChainProcessor>> = {
|
||||
let Ok(mut lock) = MONITOR_MODES.lock() else {
|
||||
return;
|
||||
};
|
||||
lock.drain(..)
|
||||
.map(|mut m| m.swap_chain_processor.take())
|
||||
.collect()
|
||||
};
|
||||
// Drop the workers (join their threads) AFTER releasing the lock — joining under MONITOR_MODES would
|
||||
// head-block the control plane (same discipline as remove_monitor / clear_all).
|
||||
for processor in &mut drained {
|
||||
drop(processor.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a pending entry by id (create failed before arrival).
|
||||
fn remove_by_id(id: u32) {
|
||||
if let Ok(mut lock) = MONITOR_MODES.lock() {
|
||||
@@ -418,6 +488,17 @@ fn remove_by_id(id: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowest monitor id (≥1) not currently live. Reusing freed ids (instead of a monotonic counter) keeps
|
||||
/// the connector index / EDID serial / container GUID bounded to the number of concurrent monitors, so a
|
||||
/// fresh ADD reuses a departed monitor's OS target slot rather than allocating a new one and orphaning the
|
||||
/// old (the ghost-monitor accumulation that wedges ADD at 0x80070490 ERROR_NOT_FOUND). Caller holds
|
||||
/// `MONITOR_MODES`. With ≤ N live ids, a free one always exists in `1..=N+1` (pigeonhole).
|
||||
fn alloc_monitor_id(modes: &[MonitorObject]) -> u32 {
|
||||
(1u32..=modes.len() as u32 + 1)
|
||||
.find(|id| !modes.iter().any(|m| m.id == *id))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
/// A deterministic, monitor-unique container GUID (groups targets into a physical device). Derived from
|
||||
/// `id` so it is stable + collision-free without a random source.
|
||||
fn container_guid(id: u32) -> wdk_sys::GUID {
|
||||
|
||||
@@ -183,9 +183,7 @@ impl SwapChainProcessor {
|
||||
}
|
||||
};
|
||||
// Built zeroed + field-assigned (driver style) — robust against a bindgen field-set difference.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDARG_IN_SWAPCHAINSETDEVICE; the `pDevice` field is set immediately below.
|
||||
let mut set_device: IDARG_IN_SWAPCHAINSETDEVICE = unsafe { core::mem::zeroed() };
|
||||
let mut set_device = pod_init!(IDARG_IN_SWAPCHAINSETDEVICE);
|
||||
set_device.pDevice = dxgi_device.as_raw().cast();
|
||||
let mut set_ok = false;
|
||||
let mut terminated = false;
|
||||
@@ -280,20 +278,16 @@ impl SwapChainProcessor {
|
||||
// the GPU surface (out.MetaData.pSurface) — STEP 6 publishes it into the shared ring in the
|
||||
// success branch below. Built zeroed + field-assigned (driver style) so a bindgen field-set
|
||||
// difference can't break a positional struct literal.
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDARG_IN_RELEASEANDACQUIREBUFFER2; the required `.Size`/AcquireSystemMemoryBuffer are set below.
|
||||
let mut in_args: IDARG_IN_RELEASEANDACQUIREBUFFER2 = unsafe { core::mem::zeroed() };
|
||||
let mut in_args = pod_init!(IDARG_IN_RELEASEANDACQUIREBUFFER2);
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
{
|
||||
in_args.Size = size_of::<IDARG_IN_RELEASEANDACQUIREBUFFER2>() as u32;
|
||||
}
|
||||
in_args.AcquireSystemMemoryBuffer = 0;
|
||||
// `core::mem::zeroed()` (not `::default()`) — consistent with every other IddCx out-struct
|
||||
// `pod_init!` (zeroed, not `::default()`) — consistent with every other IddCx out-struct
|
||||
// in this driver, and robust whether or not bindgen derives `Default` for this type (its
|
||||
// `MetaData` field carries a raw `pSurface` pointer + union which can suppress the derive).
|
||||
// SAFETY: building a C POD — the all-zero bit pattern is a valid uninitialized
|
||||
// IDARG_OUT_RELEASEANDACQUIREBUFFER2 (an out-param the framework fills).
|
||||
let mut buffer: IDARG_OUT_RELEASEANDACQUIREBUFFER2 = unsafe { core::mem::zeroed() };
|
||||
let mut buffer = pod_init!(IDARG_OUT_RELEASEANDACQUIREBUFFER2);
|
||||
// SAFETY: driver is loaded; `swap_chain` is valid; in/out point to valid local storage.
|
||||
let hr: NTSTATUS = unsafe {
|
||||
wdk_iddcx::IddCxSwapChainReleaseAndAcquireBuffer2(
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! code — handled at the call site in STEP 5).
|
||||
#![no_std]
|
||||
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
||||
// P0 lint (audit §8): require explicit `unsafe {}` blocks inside `unsafe fn`s.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
pub use wdk_sys::iddcx;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# M0/M1 toolchain probe: the smallest possible UMDF2 driver on windows-drivers-rs (crates.io wdk 0.5).
|
||||
# Purpose: prove on the windows-amd64 runner that (1) wdk-sys bindgen + WDF stub link works against the
|
||||
# runner's WDK + LLVM, (2) the shared no_std pf-vdisplay-proto ABI crate path-deps cleanly into a driver
|
||||
# runner's WDK + LLVM, (2) the shared no_std pf-driver-proto ABI crate path-deps cleanly into a driver
|
||||
# build graph, and (3) what the produced DLL's PE FORCE_INTEGRITY (/INTEGRITYCHECK) bit is. NOT shipped.
|
||||
[package]
|
||||
name = "wdk-probe"
|
||||
@@ -26,4 +26,4 @@ wdk.workspace = true
|
||||
# This is the M1 make-or-break: does IddCx.h bindgen in wdk-sys's config without a header conflict, and
|
||||
# do its WDF/DXGI types resolve to wdk-sys's (so the generated module compiles)?
|
||||
wdk-sys = { workspace = true, features = ["iddcx"] }
|
||||
pf-vdisplay-proto.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! crate's Cargo.toml). DriverEntry → WdfDriverCreate → (EvtDeviceAdd) IddCxDeviceInitConfig →
|
||||
//! WdfDeviceCreate → IddCxDeviceInitialize → IddCxAdapterInitAsync: enough to exercise the wdk-sys WDF
|
||||
//! stub link AND prove the `iddcx` subset is callable + links against `IddCxStub`. Also force-links the
|
||||
//! shared `pf-vdisplay-proto` ABI crate (no_std + bytemuck) across the workspace boundary.
|
||||
//! shared `pf-driver-proto` ABI crate (no_std + bytemuck) across the workspace boundary.
|
||||
|
||||
#![allow(non_snake_case, clippy::missing_safety_doc)]
|
||||
|
||||
@@ -18,10 +18,10 @@ use wdk_sys::{
|
||||
|
||||
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||
|
||||
/// Force `pf-vdisplay-proto` to actually link into the driver build graph (validates the cross-workspace
|
||||
/// Force `pf-driver-proto` to actually link into the driver build graph (validates the cross-workspace
|
||||
/// path-dep + that the no_std bytemuck ABI crate compiles for a UMDF cdylib). `#[used]` keeps it.
|
||||
#[used]
|
||||
static PROTO_GUID_LO: u64 = pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128 as u64;
|
||||
static PROTO_GUID_LO: u64 = pf_driver_proto::PF_VDISPLAY_INTERFACE_GUID_U128 as u64;
|
||||
|
||||
/// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the
|
||||
/// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` (which normally emits it) is compiled out under
|
||||
|
||||
@@ -141,7 +141,7 @@ $defines = @(
|
||||
)
|
||||
|
||||
# --- stage the pf-vdisplay virtual-display driver bundle --------------------------------------
|
||||
# pf-vdisplay is our all-Rust IddCx driver (packaging/windows/vdisplay-driver/), vendored signed under
|
||||
# pf-vdisplay is our all-Rust IddCx driver (packaging/windows/drivers/), vendored signed under
|
||||
# packaging/windows/pf-vdisplay/. It replaced the vendored SudoVDA C++ driver.
|
||||
if (-not $NoDriver) {
|
||||
$stage = Join-Path $OutDir 'stage'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
; pf-vdisplay - punktfunk virtual display, UMDF2 IddCx driver INF (template; stampinf -> .inf).
|
||||
;
|
||||
; For the all-Rust wdk-sys / windows-drivers-rs driver in THIS tree
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_vdisplay_proto
|
||||
; (packaging/windows/drivers/pf-vdisplay/). The driver registers the OWNED pf_driver_proto
|
||||
; control-interface GUID in CODE (WdfDeviceCreateDeviceInterface), so this INF is GUID-agnostic and
|
||||
; is byte-identical to the superseded oracle's (packaging/windows/vdisplay-driver/.../pf_vdisplay.inx,
|
||||
; itself adapted from MolotovCherry/virtual-display-rs (MIT) + SudoVDA's control-device security DACL).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-shot DEV redeploy of the pf-vdisplay (punktfunk) virtual-display driver on the test box:
|
||||
(optional) build -> stop host -> stage+sign+install -> reload the adapter -> start host.
|
||||
|
||||
.DESCRIPTION
|
||||
Wraps drivers/deploy-dev.ps1 (which stages the freshly built pf_vdisplay.dll, clears its
|
||||
FORCE_INTEGRITY PE bit, signs it, stamps a STRICTLY-INCREASING DriverVer, builds+signs the catalog,
|
||||
and pnputil-installs it) with the two things the dev loop always needs around it:
|
||||
|
||||
* The running host service HOLDS the driver's control device, and pnputil can't replace a busy
|
||||
DLL - so the host must be stopped across the install. This stops it first and starts it after.
|
||||
* pnputil /add-driver /install updates the driver STORE, but the OS keeps the LIVE adapter on the
|
||||
old binary until the device is reloaded - so this cycles the adapter (reset-pf-vdisplay.ps1)
|
||||
after install, which also clears the ghost monitor nodes for a clean slate.
|
||||
|
||||
Run ELEVATED. Use -Build only from an MSVC dev shell (the driver's cargo build needs LIBCLANG_PATH
|
||||
+ Version_Number=10.0.26100.0, per drivers/deploy-dev.ps1); otherwise build separately and omit it.
|
||||
|
||||
.PARAMETER Build Run `cargo build` in packaging/windows/drivers first (needs the MSVC env).
|
||||
.PARAMETER Service Host service name. Default PunktfunkHost.
|
||||
.PARAMETER Thumbprint Passthrough to deploy-dev.ps1 (test-cert SHA-1). Omit to use its default.
|
||||
.PARAMETER Nefconc Passthrough to deploy-dev.ps1 (nefconc.exe path). Omit to use its default.
|
||||
.PARAMETER Verify After redeploy, probe to confirm ADD works (passes through to the reset's
|
||||
-Verify; needs -Probe or punktfunk-probe.exe on PATH).
|
||||
.PARAMETER Probe Path to punktfunk-probe.exe for -Verify.
|
||||
|
||||
.EXAMPLE
|
||||
# already built the driver in an MSVC shell -> deploy it cleanly:
|
||||
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1
|
||||
.EXAMPLE
|
||||
# build + deploy + verify, from an MSVC dev shell:
|
||||
powershell -ExecutionPolicy Bypass -File redeploy-pf-vdisplay.ps1 -Build -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Build,
|
||||
[string]$Service = 'PunktfunkHost',
|
||||
[string]$Thumbprint,
|
||||
[string]$Nefconc,
|
||||
[switch]$Verify,
|
||||
[string]$Probe
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$driversDir = Join-Path $here 'drivers'
|
||||
$deploy = Join-Path $driversDir 'deploy-dev.ps1'
|
||||
$reset = Join-Path $here 'reset-pf-vdisplay.ps1'
|
||||
foreach ($f in @($deploy, $reset)) { if (-not (Test-Path $f)) { throw "missing helper: $f" } }
|
||||
|
||||
# 1) Optional rebuild (MSVC dev shell only).
|
||||
if ($Build) {
|
||||
Write-Host "==> cargo build (pf-vdisplay driver, $driversDir)"
|
||||
Push-Location $driversDir
|
||||
try {
|
||||
cargo build
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed ($LASTEXITCODE) - is this an MSVC dev shell with LIBCLANG_PATH + Version_Number set?" }
|
||||
}
|
||||
finally { Pop-Location }
|
||||
}
|
||||
|
||||
# 2) Stop the host (it holds the driver DLL; pnputil can't replace a busy binary).
|
||||
$svc = Get-Service $Service -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -eq 'Running') {
|
||||
Write-Host "==> stopping $Service"
|
||||
Stop-Service $Service -Force
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 3) Stage + sign + install (strictly-increasing DriverVer so pnputil takes the new binary).
|
||||
$deployArgs = @{ Install = $true }
|
||||
if ($Thumbprint) { $deployArgs.Thumbprint = $Thumbprint }
|
||||
if ($Nefconc) { $deployArgs.Nefconc = $Nefconc }
|
||||
Write-Host "==> deploy-dev.ps1 -Install"
|
||||
& $deploy @deployArgs
|
||||
|
||||
# 4) Reload the adapter so the OS loads the freshly-installed binary (+ clear ghost nodes). The reset
|
||||
# leaves the host alone (-NoHost) - we own the service lifecycle here.
|
||||
Write-Host "==> reloading the pf-vdisplay adapter (clean slate)"
|
||||
& $reset -NoHost
|
||||
|
||||
# 5) Start the host.
|
||||
if ($svc) {
|
||||
Write-Host "==> starting $Service"
|
||||
Start-Service $Service
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " $Service status: $((Get-Service $Service -ErrorAction SilentlyContinue).Status)"
|
||||
}
|
||||
|
||||
# 6) Optional verification probe.
|
||||
if ($Verify) {
|
||||
$vArgs = @{ NoHost = $true; KeepGhosts = $true; Verify = $true }
|
||||
if ($Probe) { $vArgs.Probe = $Probe }
|
||||
& $reset @vArgs
|
||||
}
|
||||
|
||||
Write-Host "pf-vdisplay redeploy done."
|
||||
@@ -0,0 +1,130 @@
|
||||
#requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recover the pf-vdisplay (punktfunk) virtual-display driver after it WEDGES under rapid ADD/REMOVE
|
||||
churn - no reboot. The dev-iteration counterpart to redeploy-pf-vdisplay.ps1.
|
||||
|
||||
.DESCRIPTION
|
||||
Sustained connect/disconnect churn (e.g. a client reconnect loop x the host's 8 pipeline-build
|
||||
retries - ~100 ADD/REMOVE cycles) exhausts the driver's IddCx monitor slots: the per-monitor
|
||||
target_ids climb, ghost "Generic Monitor (punktfunk)" device nodes pile up, and eventually
|
||||
IOCTL_ADD returns 0x80070490 ERROR_NOT_FOUND ("Element nicht gefunden"). Every session then fails
|
||||
to create a virtual output -> the client gets a hard blackscreen. A host-service restart's
|
||||
IOCTL_CLEAR_ALL does NOT recover it; the driver instance itself must be reloaded.
|
||||
|
||||
Steps (run ELEVATED):
|
||||
1. Stop the host service (it holds the driver's control device).
|
||||
2. pnputil /remove-device the GHOST (Status != OK = not-present) punktfunk virtual-monitor nodes
|
||||
that accumulated - the root of the slot exhaustion.
|
||||
3. Disable + Enable the pf-vdisplay adapter (ROOT\DISPLAY\*, "punktfunk Virtual Display") to
|
||||
reload the IddCx driver instance and reset its monitor list. (Restart-PnpDevice does NOT exist
|
||||
on this box's PowerShell, so we disable+enable explicitly.)
|
||||
4. Restart the host service.
|
||||
Avoids a reboot on purpose (this box boots to Proxmox).
|
||||
|
||||
.PARAMETER Service Host service name. Default PunktfunkHost.
|
||||
.PARAMETER AdapterName FriendlyName substring of the IddCx adapter to cycle. Default "punktfunk
|
||||
Virtual Display" (NOT SudoVDA's "SudoMaker Virtual Display Adapter").
|
||||
.PARAMETER GhostMatch FriendlyName substring of the virtual monitors to reap. Default "punktfunk".
|
||||
.PARAMETER KeepGhosts Skip the ghost-node cleanup; only cycle the adapter.
|
||||
.PARAMETER NoHost Don't stop/start the host service (just reset the driver) - used by
|
||||
redeploy-pf-vdisplay.ps1, which manages the service itself.
|
||||
.PARAMETER Verify After recovery, run a punktfunk-probe loopback and report whether ADD works
|
||||
again (best-effort; needs punktfunk-probe.exe on PATH or via -Probe).
|
||||
.PARAMETER Probe Path to punktfunk-probe.exe for -Verify.
|
||||
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1
|
||||
.EXAMPLE
|
||||
powershell -ExecutionPolicy Bypass -File reset-pf-vdisplay.ps1 -Verify -Probe C:\t-goal1\debug\punktfunk-probe.exe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Service = 'PunktfunkHost',
|
||||
[string]$AdapterName = 'punktfunk Virtual Display',
|
||||
[string]$GhostMatch = 'punktfunk',
|
||||
[switch]$KeepGhosts,
|
||||
[switch]$NoHost,
|
||||
[switch]$Verify,
|
||||
[string]$Probe
|
||||
)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
function Get-PfAdapter {
|
||||
Get-PnpDevice -Class Display -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FriendlyName -match $AdapterName } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# 1) Stop the host so it isn't mid-IOCTL during the reset (it holds the control device).
|
||||
$svc = Get-Service $Service -ErrorAction SilentlyContinue
|
||||
$hostWasRunning = $svc -and $svc.Status -eq 'Running'
|
||||
if (-not $NoHost -and $hostWasRunning) {
|
||||
Write-Host "==> stopping $Service"
|
||||
Stop-Service $Service -Force
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# 2) Reap the ghost (not-present) punktfunk virtual-monitor device nodes.
|
||||
if (-not $KeepGhosts) {
|
||||
$ghosts = Get-PnpDevice -Class Monitor -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match $GhostMatch }
|
||||
Write-Host "==> removing $($ghosts.Count) ghost virtual-monitor node(s)"
|
||||
$removed = 0
|
||||
foreach ($g in $ghosts) {
|
||||
pnputil /remove-device $g.InstanceId *> $null
|
||||
if ($LASTEXITCODE -eq 0) { $removed++ }
|
||||
}
|
||||
Write-Host " removed $removed"
|
||||
}
|
||||
|
||||
# 3) Reload the IddCx adapter instance (disable + enable) to clear its monitor list.
|
||||
$ad = Get-PfAdapter
|
||||
if (-not $ad) {
|
||||
Write-Warning "pf-vdisplay adapter '$AdapterName' not found (Class Display) - is the driver installed?"
|
||||
}
|
||||
else {
|
||||
Write-Host "==> cycling adapter $($ad.InstanceId)"
|
||||
Disable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false -ErrorAction Continue
|
||||
Start-Sleep -Seconds 3
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false -ErrorAction Continue
|
||||
Start-Sleep -Seconds 3
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId -ErrorAction SilentlyContinue).Status
|
||||
if ($st -ne 'OK') {
|
||||
# One retry - a disabled root device occasionally needs a second enable to come back OK.
|
||||
Enable-PnpDevice -InstanceId $ad.InstanceId -Confirm:$false -ErrorAction Continue
|
||||
Start-Sleep -Seconds 2
|
||||
$st = (Get-PnpDevice -InstanceId $ad.InstanceId -ErrorAction SilentlyContinue).Status
|
||||
}
|
||||
Write-Host " adapter status: $st"
|
||||
}
|
||||
|
||||
# 4) Restart the host.
|
||||
if (-not $NoHost -and $svc) {
|
||||
Write-Host "==> starting $Service"
|
||||
Start-Service $Service
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " $Service status: $((Get-Service $Service -ErrorAction SilentlyContinue).Status)"
|
||||
}
|
||||
|
||||
# 5) Optional: probe to confirm ADD recovers.
|
||||
if ($Verify) {
|
||||
if (-not $Probe) {
|
||||
$Probe = (Get-Command punktfunk-probe.exe -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
if (-not $Probe -or -not (Test-Path $Probe)) {
|
||||
Write-Warning "-Verify: punktfunk-probe.exe not found (pass -Probe <path>); skipping verification."
|
||||
}
|
||||
else {
|
||||
$log = Join-Path $env:ProgramData 'punktfunk\logs\host.log'
|
||||
Write-Host "==> verifying with $Probe"
|
||||
& $Probe *> $null
|
||||
Start-Sleep -Seconds 2
|
||||
$last = Get-Content $log -Tail 80 -ErrorAction SilentlyContinue |
|
||||
Select-String -Pattern 'pf-vdisplay created|Element nicht|0x80070490' | Select-Object -Last 1
|
||||
if ($last -match 'created') { Write-Host " OK: ADD succeeded after reset." }
|
||||
elseif ($last) { Write-Warning " ADD still failing after reset: $($last.Line.Trim())" }
|
||||
else { Write-Warning " no ADD outcome found in the log; check $log." }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "pf-vdisplay reset done."
|
||||
@@ -4,11 +4,11 @@
|
||||
driver + the fetched nefcon device tool.
|
||||
|
||||
.DESCRIPTION
|
||||
pf-vdisplay (our all-Rust IddCx virtual display) is built from packaging/windows/vdisplay-driver/, and
|
||||
pf-vdisplay (our all-Rust IddCx virtual display) is built from packaging/windows/drivers/, and
|
||||
the SIGNED output (pf_vdisplay.dll/.inf/.cat + punktfunk-driver.cer) is VENDORED under
|
||||
packaging/windows/pf-vdisplay/ (signer punktfunk-ds-test — shared with the gamepad drivers — Class=
|
||||
Display, HWID root\pf_vdisplay). Rebuild + re-vendor with
|
||||
packaging/windows/vdisplay-driver/deploy-dev.ps1 when the driver source changes, then copy the staged
|
||||
packaging/windows/drivers/deploy-dev.ps1 when the driver source changes, then copy the staged
|
||||
pf_vdisplay.{dll,inf,cat} over the vendored copies. nefcon publishes a pinned release, so we fetch +
|
||||
SHA-256-verify it (it provides nefconc.exe, used to create the root-enumerated device node — pnputil
|
||||
can't).
|
||||
@@ -36,7 +36,7 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
# --- vendored pf-vdisplay driver --------------------------------------------------------------
|
||||
$inf = Get-ChildItem -Path $VendorDir -Filter pf_vdisplay.inf -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $inf) { throw "no vendored pf_vdisplay.inf under $VendorDir — re-vendor via vdisplay-driver/deploy-dev.ps1" }
|
||||
if (-not $inf) { throw "no vendored pf_vdisplay.inf under $VendorDir — re-vendor via drivers/deploy-dev.ps1" }
|
||||
Copy-Item (Join-Path $VendorDir '*') $OutDir -Force
|
||||
Write-Host "==> vendored pf-vdisplay staged from $VendorDir"
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[build]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -1,3 +0,0 @@
|
||||
/target
|
||||
*.cer
|
||||
*.pfx
|
||||
-510
@@ -1,510 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
dependencies = [
|
||||
"bytemuck_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck_derive"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pf-vdisplay"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"log",
|
||||
"thiserror",
|
||||
"wdf-umdf",
|
||||
"wdf-umdf-sys",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wdf-umdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"thiserror",
|
||||
"wdf-umdf-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wdf-umdf-sys"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bytemuck",
|
||||
"paste",
|
||||
"thiserror",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys",
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# pf-vdisplay — punktfunk Windows virtual display (IddCx), in Rust.
|
||||
#
|
||||
# A self-contained driver workspace (NOT built on windows-drivers-rs like the gamepad drivers — IddCx
|
||||
# functions are direct IddCxStub exports the WDF function-table macro can't reach, so a unified bindgen
|
||||
# is the cleaner base). The wdf-umdf-sys / wdf-umdf binding crates are vendored from MolotovCherry's
|
||||
# MIT-licensed virtual-display-rs (see LICENSE.virtual-display-rs); pf-vdisplay is our driver, swapping
|
||||
# its named-pipe IPC for the SudoVDA-compatible IOCTL control plane our host already speaks.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wdf-umdf-sys", "wdf-umdf", "pf-vdisplay"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
multiple_unsafe_ops_per_block = "deny"
|
||||
ignored_unit_patterns = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
module_inception = "allow"
|
||||
module_name_repetitions = "allow"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user